From d8249fb3514aecb359030298f5b3d2a8c943c221 Mon Sep 17 00:00:00 2001 From: Camillebzd <48495021+Camillebzd@users.noreply.github.com> Date: Mon, 31 Aug 2026 07:39:42 +0000 Subject: [PATCH 1/2] fix(tps-chart): restore per-frame smoothness without per-frame recharts renders fbcabf2 throttled the sliding clock to 10 Hz to stop recharts re-rendering every animation frame. That also throttled the head-vertex interpolation, so the newest segment now draws in ~3 steps per 300ms block instead of ~18, and the x-domain steps by tens of pixels while the window is still clamped to MIN_WINDOW_MS. Both read as stutter. Split the two concerns instead: - Draw the line and its fill in a layer that rewrites the two d attributes from a rAF loop, outside React. The head animation is back at full frame rate and costs no reconciliation. - Keep a transparent so recharts still owns the y-domain, the tooltip payload and the hover dot. - Make the sliding clock adaptive: publish when the domain has advanced ~1/1000 of the visible window, clamped to [16ms, 100ms]. A narrow window renders per frame but holds few points; the full 5-minute window holds ~1000 points but publishes at 10 Hz. Net recharts re-renders are lower than before fbcabf2 at every zoom level. Co-Authored-By: Claude Opus 5 --- .../network-activity-tracker/tps-chart.tsx | 245 +++++++++++++++--- 1 file changed, 207 insertions(+), 38 deletions(-) diff --git a/frontend/components/network-activity-tracker/tps-chart.tsx b/frontend/components/network-activity-tracker/tps-chart.tsx index ac2d6d4..ac5035b 100644 --- a/frontend/components/network-activity-tracker/tps-chart.tsx +++ b/frontend/components/network-activity-tracker/tps-chart.tsx @@ -1,8 +1,22 @@ 'use client' import Image from 'next/image' -import { useEffect, useState } from 'react' -import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts' +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from 'react' +import { + Area, + AreaChart, + CartesianGrid, + Customized, + XAxis, + YAxis, +} from 'recharts' import { type ChartConfig, ChartContainer, @@ -22,30 +36,67 @@ const chartConfig = { }, } satisfies ChartConfig +const SERIES_COLOR = '#6E54FF' +/** Matches recharts' default so the custom fill looks identical. */ +const SERIES_FILL_OPACITY = 0.6 + /** Maximum visible time window of the chart, matching the TPS history retained. */ const CHART_WINDOW_MS = 5 * 60 * 1000 /** Smallest window to show early on, so the chart starts zoomed in rather than mostly empty. */ const MIN_WINDOW_MS = 3 * 1000 -const SLIDING_CLOCK_INTERVAL_MS = 100 +/** + * Start zoomed in to the earliest data point and expand the window as data + * accumulates, capping at CHART_WINDOW_MS once we have 5 minutes of history. + */ +function windowStartAt(earliest: number, now: number): number { + return Math.max( + now - CHART_WINDOW_MS, + Math.min(earliest, now - MIN_WINDOW_MS), + ) +} + +/** + * Fraction of the visible window the x-domain is allowed to jump between React + * renders. Roughly a pixel on a ~1000px-wide plot, so axis ticks still read as + * continuously sliding without re-rendering recharts on every frame. The line + * itself is not bound by this — it is redrawn every frame outside of React (see + * TpsSeries). + */ +const CLOCK_STEP_FRACTION = 1 / 1000 +const CLOCK_MIN_INTERVAL_MS = 16 +const CLOCK_MAX_INTERVAL_MS = 100 /** - * Drives a smoothly advancing "now" so the chart's x-domain slides - * continuously instead of jumping by one slot as each point arrives. - * Publishes at 10 Hz for smooth motion without re-rendering Recharts every - * animation frame; rAF auto-pauses when the tab is hidden. + * Drives an advancing "now" so the chart's x-domain slides continuously instead + * of jumping by one slot as each point arrives. The publish rate adapts to the + * zoom level: a narrow window moves many pixels per millisecond and needs every + * frame (and holds few points, so it is cheap), while the full 5-minute window + * crawls and can be published at 10 Hz. rAF auto-pauses when the tab is hidden. */ -function useSlidingNow(): number { +function useSlidingNow(earliest: number | undefined): number { const [now, setNow] = useState(() => Date.now()) + const earliestRef = useRef(earliest) + + useEffect(() => { + earliestRef.current = earliest + }, [earliest]) useEffect(() => { let raf: number - let lastPublishedAt = 0 - const tick = (timestamp: number) => { - if (timestamp - lastPublishedAt >= SLIDING_CLOCK_INTERVAL_MS) { - lastPublishedAt = timestamp - setNow(Date.now()) + let lastPublishedAt = Date.now() + const tick = () => { + const current = Date.now() + const span = + current - windowStartAt(earliestRef.current ?? current, current) + const interval = Math.min( + CLOCK_MAX_INTERVAL_MS, + Math.max(CLOCK_MIN_INTERVAL_MS, span * CLOCK_STEP_FRACTION), + ) + if (current - lastPublishedAt >= interval) { + lastPublishedAt = current + setNow(current) } raf = requestAnimationFrame(tick) } @@ -57,27 +108,147 @@ function useSlidingNow(): number { } /** - * Returns the history with its newest segment progressively "drawn": instead of - * the last point appearing fully-formed, a head vertex travels from the previous - * point to the newest one over the inter-arrival interval. `now` advances with - * the sliding clock, so the head moves smoothly. Once it reaches the newest point - * the segment is complete and the next arrival starts drawing the following one. + * The newest segment is drawn progressively: instead of the last point appearing + * fully-formed, a head vertex travels from the previous point to the newest one + * over the inter-arrival interval. Returns null while there is nothing to + * interpolate, in which case the raw last point is used. */ -function drawHistory(history: TpsDataPoint[], now: number): TpsDataPoint[] { - if (history.length < 2) return history +function interpolatedHead( + history: TpsDataPoint[], + now: number, +): TpsDataPoint | null { + if (history.length < 2) return null const target = history[history.length - 1] const from = history[history.length - 2] const duration = target.timestamp - from.timestamp - if (duration <= 0) return history + if (duration <= 0) return null const progress = Math.min(1, Math.max(0, (now - target.timestamp) / duration)) - const head: TpsDataPoint = { + return { timestamp: from.timestamp + (target.timestamp - from.timestamp) * progress, tps: from.tps + (target.tps - from.tps) * progress, } +} + +const round = (value: number) => Math.round(value * 10) / 10 + +/** Builds the `d` attributes for the line and its filled area below it. */ +function buildSeriesPaths( + history: TpsDataPoint[], + now: number, + toX: (timestamp: number) => number, + toY: (tps: number) => number, +): { line: string; area: string } { + if (history.length === 0) return { line: '', area: '' } + + const head = interpolatedHead(history, now) + const lastIndex = history.length - 1 + + let line = '' + for (let i = 0; i < history.length; i++) { + const point = i === lastIndex && head ? head : history[i] + line += `${i === 0 ? 'M' : 'L'}${round(toX(point.timestamp))},${round(toY(point.tps))}` + } + + const baseY = round(toY(0)) + const firstX = round(toX(history[0].timestamp)) + const lastX = round(toX((head ?? history[lastIndex]).timestamp)) + + return { line, area: `${line}L${lastX},${baseY}L${firstX},${baseY}Z` } +} + +interface RechartsOffset { + top: number + left: number + width: number + height: number +} - return [...history.slice(0, -1), head] +interface TpsSeriesProps { + history: TpsDataPoint[] + /** Injected by recharts' : the plot rect, in svg coordinates. */ + offset?: RechartsOffset + /** Injected by recharts' : the configured y scales, keyed by axis id. */ + yAxisMap?: Record number }> +} + +/** + * Draws the TPS line and its gradient fill. + * + * This is deliberately not a recharts : the head vertex and the sliding + * x-domain both move every frame, and re-rendering recharts at 60 Hz means + * rebuilding every axis, tick and layer for a series that can hold ~1000 points. + * Instead recharts re-renders at the (throttled) sliding-clock rate and only + * these two elements are rewritten per frame, straight through the DOM. + */ +function TpsSeries({ history, offset, yAxisMap }: TpsSeriesProps) { + const clipId = `tps-clip-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}` + const lineRef = useRef(null) + const areaRef = useRef(null) + const latest = useRef<{ + history: TpsDataPoint[] + offset?: RechartsOffset + yScale?: (value: number) => number + }>({ history }) + + const draw = useCallback(() => { + const { history: points, offset: rect, yScale } = latest.current + if (points.length === 0 || !rect || !yScale) return + + const now = Date.now() + const start = windowStartAt(points[0].timestamp, now) + const span = Math.max(1, now - start) + const toX = (timestamp: number) => + rect.left + ((timestamp - start) / span) * rect.width + + const { line, area } = buildSeriesPaths(points, now, toX, yScale) + lineRef.current?.setAttribute('d', line) + areaRef.current?.setAttribute('d', area) + }, []) + + // Keep the frame loop's inputs current and repaint before the browser shows + // the commit, so a recharts re-render never flashes a stale line. + useLayoutEffect(() => { + latest.current = { history, offset, yScale: yAxisMap?.['0']?.scale } + draw() + }) + + useEffect(() => { + let raf: number + const tick = () => { + draw() + raf = requestAnimationFrame(tick) + } + raf = requestAnimationFrame(tick) + return () => cancelAnimationFrame(raf) + }, [draw]) + + if (!offset) return null + + return ( + + + + + + + + + + + + ) } /** Nice, human-friendly tick steps in ms for the relative-time x-axis. */ @@ -105,18 +276,9 @@ export function TpsChart() { const { currentTps, peakTps, history } = useTps() const totalTransactions = useTotalTransactions() const hasData = history.length > 0 - const now = useSlidingNow() - - // Start zoomed in to the earliest data point and expand the window as data - // accumulates, capping at CHART_WINDOW_MS once we have 5 minutes of history. - const earliest = history[0]?.timestamp ?? now - const windowStart = Math.max( - now - CHART_WINDOW_MS, - Math.min(earliest, now - MIN_WINDOW_MS), - ) + const now = useSlidingNow(history[0]?.timestamp) - // Progressively draw the newest segment rather than snapping it into place. - const chartData = drawHistory(history, now) + const windowStart = windowStartAt(history[0]?.timestamp ?? now, now) const ticks = buildTicks(windowStart, now) return ( @@ -152,7 +314,7 @@ export function TpsChart() { className="h-full min-w-2xl w-full p-0" > @@ -163,11 +325,12 @@ export function TpsChart() { x2="0%" y2="100%" > - + + } /> } /> + {/* + The visible series is drawn by above; this Area is + kept transparent so recharts still owns the y-domain, the + tooltip payload and the active dot on hover. + */} From 46ba805b5693e31543c898d73d7f1a6ecf98e908 Mon Sep 17 00:00:00 2001 From: Camillebzd <48495021+Camillebzd@users.noreply.github.com> Date: Mon, 31 Aug 2026 08:01:43 +0000 Subject: [PATCH 2/2] fix(tps-chart): keep the tooltip attached to the drawn line Feeding recharts the raw history left the tooltip and active dot on the newest raw sample while TpsSeries was still sweeping the head towards it, so the dot sat up to one inter-arrival interval (~300ms) ahead of the visible tip, and a full sample's worth of TPS above or below it. Feed recharts the drawn history again. TpsSeries still receives the raw array and still interpolates every frame, so smoothness is unaffected; the residual mismatch collapses to the sliding-clock gap, which CLOCK_STEP_FRACTION already bounds at roughly a pixel. Trades back the stable y-domain: the axis max can rescale mid-sweep again. Co-Authored-By: Claude Opus 5 --- .../network-activity-tracker/tps-chart.tsx | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/components/network-activity-tracker/tps-chart.tsx b/frontend/components/network-activity-tracker/tps-chart.tsx index ac5035b..5457e8b 100644 --- a/frontend/components/network-activity-tracker/tps-chart.tsx +++ b/frontend/components/network-activity-tracker/tps-chart.tsx @@ -131,6 +131,19 @@ function interpolatedHead( } } +/** + * The history as it is currently drawn, i.e. with the sweeping head in place of + * the newest raw point. Recharts is fed this rather than the raw history so the + * tooltip, cursor and active dot stay attached to the line the user can see; + * feeding it the raw point leaves the dot sitting up to one inter-arrival + * interval ahead of the visible head. Rebuilt at the sliding-clock rate only — + * TpsSeries interpolates from the raw history on its own, every frame. + */ +function drawHistory(history: TpsDataPoint[], now: number): TpsDataPoint[] { + const head = interpolatedHead(history, now) + return head ? [...history.slice(0, -1), head] : history +} + const round = (value: number) => Math.round(value * 10) / 10 /** Builds the `d` attributes for the line and its filled area below it. */ @@ -280,6 +293,7 @@ export function TpsChart() { const windowStart = windowStartAt(history[0]?.timestamp ?? now, now) const ticks = buildTicks(windowStart, now) + const chartData = drawHistory(history, now) return (
@@ -314,7 +328,7 @@ export function TpsChart() { className="h-full min-w-2xl w-full p-0" > @@ -380,7 +394,9 @@ export function TpsChart() { {/* The visible series is drawn by above; this Area is kept transparent so recharts still owns the y-domain, the - tooltip payload and the active dot on hover. + tooltip payload and the active dot on hover. It reads the same + drawn history the line does, so the dot lands on the visible + head instead of the raw sample it is still sweeping towards. */}