// useSmoothSeries — the CANVAS streaming-chart engine shared by SmoothLine // (generic) and by host-bespoke charts (e.g. cmdop's ECG, which keeps its own // QRS geometry but reuses this scroll driver). // // THE CANVAS MODEL (Smoothie / uPlot / Grafana — one writer, one clock): // every rAF frame we `clearRect` the whole canvas and redraw the ENTIRE line at // its current wall-clock-scrolled X. The "new point arrived" and "everything // scrolled" are the SAME per-frame draw — there is no second thing to fall out // of sync, so the jump-then-catch-up is impossible BY CONSTRUCTION. // // WHY WE LEFT SVG-transform (the bug this rewrite kills): // The old engine animated TWO independent things on TWO clocks that had to agree // every frame: // (1) the SVG path `d` — rebuilt in a React effect ONCE per data tick (≈1 Hz, // jittery), in data-space; and // (2) the `` transform — animated EVERY rAF frame (60 Hz) off the wall clock. // The on-screen position of a point was `pathX(point) + transformX(frame)`. Those // two writes land in DIFFERENT frames on DIFFERENT clocks — at the data-tick seam // the path jumped one slot left while the transform's phase-reset landed a frame // early/late → a one-frame double-shift (JUMP) then the transform gliding back // (slow CATCH-UP). That was the user's "jump then slowly catch up to the right". // No amount of EMA / delay / clipPath tuning could close it because the root was // the two-writer/two-clock split, not a tuning bug. Canvas removes the split. // // WHAT WE KEEP (the parts that already worked — they were never the bug): // • MEASURED cadence (EMA of inter-sample arrival deltas) → estimates the real // feed rate so a jittery 1-Hz feed still glides at roughly the right speed (no // hardcoded tweenMs for the slide). // • DELAY/LAG — hold the freshest sample one interval off the right edge so the // next point is already known before we draw it (Smoothie `delay`). On canvas // the off-edge point simply sits past `width` and the scroll slides it in; no // clipPath needed — `clearRect` to the canvas box clips for free. // • CARRY across the commit seam — the fractional glide still in flight at a // commit is carried, never snapped to 0, so the scroll is continuous. // • prefers-reduced-motion → a single static draw (no loop); pause on // document.hidden. // // RESIDUAL-JITTER POLISH (the micro-wobble this revision kills): // The canvas rewrite removed the big jerk, but a slight wobble remained. Its root: // the per-frame scroll VELOCITY was `slotW / measuredInterval`, and `measuredInterval` // is an EMA recomputed on EVERY sample. A jittery feed nudged that EMA tick-to-tick, // so the visible scroll speed micro-adjusted at every commit seam — a perceptible // "поддёргивание". The professional fix (Smoothie / uPlot / Grafana): DECOUPLE the // visible velocity from per-sample timing. // • SLEWED velocity — the rAF loop scrolls off `effectiveIntervalRef`, a value // that creeps SLOWLY toward the EMA target (a small fraction per frame). The // EMA still tracks the cadence for adaptation, but the on-screen speed only // ever changes imperceptibly — no per-tick snap. The `delay` BUFFER (not the // velocity) absorbs the arrival jitter. // • LARGER delay buffer — phase may glide further past 1.0 (≈1.8 slots of slack // instead of ~1.35) so a late/early sample is swallowed by the buffer long // before it could perturb the visible scroll. // • CONSISTENT frame timestamp — the rAF callback uses its OWN `time` arg for the // phase calc, so there is no `performance.now()` drift between sub-steps of one // frame. The commit effect still stamps with `performance.now()` (it runs // outside rAF); the loop reconciles both on the same monotonic clock. // • C0-CONTINUOUS commit seam — the carry keeps the fractional leftover so the // scroll position never hops across a commit. // // THE PER-FRAME LOOP (one writer, one clock): // 1. now = rAF time arg (one clock, one timestamp per frame) // 2. phase = carry + (now − lastCommit) / effectiveInterval // slots, wall-clock // 3. scrollX = −slotW · (1 − clampedPhase) // px, ≤ 0 // 4. dpr-scale the ctx, clearRect the box, call host draw(ctx, { scrollX, … }) // The host draws point i at `i·slotW + scrollX` — newest point sits just past // the right edge and slides in; oldest fall off the left. Same geometry the SVG // builders drew, now emitted to ctx via moveTo/lineTo/bezierCurveTo. 'use client'; import * as React from 'react'; /** SSR-safe `prefers-reduced-motion: reduce` subscription. */ export function useReducedMotion(): boolean { const [reduced, setReduced] = React.useState(false); React.useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return; const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); const update = () => setReduced(mq.matches); update(); mq.addEventListener('change', update); return () => mq.removeEventListener('change', update); }, []); return reduced; } /** * Per-frame draw context handed to the host's {@link DrawFn}. All coordinates are * in CSS pixels over the chart's logical `width × height` box — the engine has * already applied the `devicePixelRatio` transform to the `ctx`, so the host * draws in plain logical units and gets crisp retina output for free. */ export interface DrawContext { /** * The current wall-clock scroll offset, in CSS px (≤ 0). Add it to each point's * data-space X so the trace slides left: the host draws point `i` at * `i·slotWidth + scrollX`. At a fresh commit `scrollX ≈ −slotWidth` (newest * point one slot off the right edge) and glides to `0` over one interval. */ scrollX: number; /** Per-slot scroll distance in CSS px (`width / (drawnCount − 1)`), or 0. */ slotWidth: number; /** Logical chart width in CSS px (the consumer's `width` prop / viewBox). */ width: number; /** Logical chart height in CSS px. */ height: number; /** Device pixel ratio the ctx is scaled by (host rarely needs it). */ dpr: number; } /** * Host-supplied per-frame draw. Called after the engine has sized + dpr-scaled * the ctx and `clearRect`-ed the box. The host strokes/fills its geometry at the * scrolled X. SmoothLine passes a monotone-cubic polyline drawer; the ECG passes * its bespoke QRS drawer (three colour passes in one callback). * * The draw receives the CURRENT committed values via closure (the host rebuilds * the callback when `values`/`epoch` change), so there is no per-frame Y-tween — * the shape is fixed between ticks, only the window scrolls. */ export type DrawFn = (ctx: CanvasRenderingContext2D, frame: DrawContext) => void; export interface SmoothSeriesConfig { /** Target series, oldest-first. A change advances the scroll + redraws. */ values: readonly number[]; /** * Seed cadence (ms) for the scroll velocity — used ONLY as the initial guess * and the floor/ceiling clamp bounds, NOT as the fixed slide speed. The engine * MEASURES the real inter-sample interval (an EMA of the deltas between datum * arrivals) as a cadence target, then SLEWS the visible scroll velocity slowly * toward it — so a jittery 1 Hz feed glides at a rock-steady speed (the velocity * is decoupled from per-sample timing; the `delay` buffer absorbs the jitter). * Pass the rough expected cadence (stats ≈ 1000, a 30 s quality series ≈ 30000). * @default 1000 */ tweenMs?: number; /** * Continuous left-scroll. When set, the engine drives a constant-velocity * wall-clock scroll so the window slides like a hospital monitor instead of * teleporting. `slotWidth` is derived from `width / (drawnCount − 1)`. When * false the engine still draws (static), it just never scrolls. @default false */ scroll?: boolean; /** Logical chart width (CSS px) used to derive the scroll slot-width. @default 240 */ width?: number; /** Logical chart height (CSS px). @default 40 */ height?: number; /** * Optional retarget trigger. The engine normally advances when the numeric * `values` change. A sliding window of IDENTICAL tokens (e.g. a steady ECG * ok-stream where the envelope stays all-1s) produces no numeric change, so the * scroll would never advance. Bump `epoch` once per tick (the host's sample * count works well) to advance the scroll even when `values` are unchanged. */ epoch?: number; /** * Hold the right edge back by one interval so the newest point is already known * before it scrolls into view (Smoothie / chartjs-streaming `delay`). When set, * the freshest value is dropped from the drawn set and lives off the right edge, * revealed only by the scroll — so the right edge reads as a continuous stream * instead of "growing then snapping". Requires `scroll`. @default true */ delay?: boolean; /** * Per-frame draw callback (host geometry). The drawn values are passed via * closure — see {@link DrawFn}. The host should rebuild this (`useMemo` / * `useCallback`) when its committed values or styling change. */ draw: DrawFn; } export interface SmoothSeriesHandles { /** Attach to the `` the engine sizes (dpr) and drives. */ canvasRef: React.RefObject; /** * Force one static redraw at scrollX = 0 (newest point flush at the right edge). * The engine calls this itself on reduced-motion / resize / `scroll=false`; the * host rarely needs it, but it is exposed for an imperative refresh. */ redraw: () => void; } /** * Drive a constant-velocity CANVAS streaming line from a changing `values` array. * * Returns a `canvasRef` the host attaches to one ``. The engine sizes the * canvas to its box × devicePixelRatio, runs a single persistent wall-clock rAF * loop, and every frame `clearRect`s and calls the host `draw` at the current * scroll offset — one writer, one clock, so the SVG-era jump-then-catch-up cannot * occur. On reduced-motion / `scroll=false` it never starts the loop and renders * a single static frame. */ export function useSmoothSeries(config: SmoothSeriesConfig): SmoothSeriesHandles { const { values, tweenMs = 1000, scroll = false, width = 240, height = 40, epoch, delay = true, draw, } = config; const reduced = useReducedMotion(); const canvasRef = React.useRef(null); // Latest draw callback without retriggering effects every render. const drawRef = React.useRef(draw); drawRef.current = draw; const targetKey = React.useMemo(() => values.join(','), [values]); // With `delay` on we draw the buffer MINUS its freshest point so the right edge // is always a point we already know — the newest value lives off the right edge, // revealed by the scroll. One slot of room is the lag. const useLag = scroll && delay && values.length > 2; const drawnCount = useLag ? Math.max(0, values.length - 1) : values.length; // Slot width: the per-tick scroll distance, in logical (CSS) px. const slotW = React.useMemo(() => { if (!scroll) return 0; return drawnCount > 1 ? width / (drawnCount - 1) : 0; }, [scroll, drawnCount, width]); const slotWRef = React.useRef(slotW); slotWRef.current = slotW; // Seed cadence — the floor/ceiling clamp and the initial guess for the EMA. const seedIntervalRef = React.useRef(Math.max(1, tweenMs)); seedIntervalRef.current = Math.max(1, tweenMs); // MEASURED cadence (EMA of inter-sample deltas). This is the cadence ESTIMATE // that tracks the feed rate; it is NOT what the visible scroll runs off directly // (that micro-wobbles on a jittery feed). It is the slew TARGET for the velocity. const measuredIntervalRef = React.useRef(Math.max(1, tweenMs)); // EFFECTIVE cadence — what the visible scroll velocity actually uses. It creeps // slowly toward `measuredInterval` (a few % per frame), so the on-screen speed // never snaps tick-to-tick even when the EMA wobbles. This is the residual-jitter // fix: the velocity is decoupled from per-sample timing; the `delay` buffer eats // the arrival jitter, not the velocity. const effectiveIntervalRef = React.useRef(Math.max(1, tweenMs)); // Wall-clock timestamp of the LAST commit (datum arrival). The scroll phase is a // pure function of (now − lastCommit) / measuredInterval — position is driven by // wall-clock, not by integrating per-frame dt (which drifts). Self-correcting: a // slow frame just lands further along the same line. const lastCommitRef = React.useRef(0); // Carry-over phase: the fractional glide still in flight at the moment of a // commit. We DON'T snap it to 0 — we keep gliding from the leftover so the // scroll never teleports across the commit seam (chartjs `preservation`). const carryRef = React.useRef(0); // The DPR the canvas backing store is currently sized for. const dprRef = React.useRef(1); // ── Size the canvas backing store to box × devicePixelRatio and scale the ctx // so all host drawing is in logical (CSS) px and stays crisp on retina. Returns // a ready, cleared, dpr-scaled 2D context (or null pre-mount / SSR). const prepareCtx = React.useCallback((): CanvasRenderingContext2D | null => { const canvas = canvasRef.current; if (!canvas || typeof window === 'undefined') return null; const dpr = Math.max(1, window.devicePixelRatio || 1); // Logical size: width/height props (the viewBox), but the canvas stretches to // its container width via CSS — so prefer the measured client box when laid // out, falling back to the prop box pre-layout. const cssW = canvas.clientWidth || width; const cssH = canvas.clientHeight || height; const bw = Math.round(cssW * dpr); const bh = Math.round(cssH * dpr); if (canvas.width !== bw) canvas.width = bw; if (canvas.height !== bh) canvas.height = bh; dprRef.current = dpr; const ctx = canvas.getContext('2d'); if (!ctx) return null; ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.clearRect(0, 0, bw, bh); // Scale logical→device, AND map the logical `width` (viewBox) onto the // measured CSS width so geometry built against `width` stretches to fill the // container — the canvas analogue of SVG `preserveAspectRatio="none"`. const sx = (cssW / width) * dpr; const sy = (cssH / height) * dpr; ctx.setTransform(sx, 0, 0, sy, 0, 0); return ctx; }, [width, height]); // ── One static draw at scrollX = 0 (newest point flush at the right edge). Used // for reduced-motion, scroll=false, and resize. Geometry is in logical units. const redraw = React.useCallback(() => { const ctx = prepareCtx(); if (!ctx) return; drawRef.current(ctx, { scrollX: 0, slotWidth: slotWRef.current, width, height, dpr: dprRef.current, }); }, [prepareCtx, width, height]); // ── Measure the inter-sample interval on every data advance (EMA) and commit. React.useEffect(() => { if (typeof window === 'undefined') return; const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); if (lastCommitRef.current > 0) { const deltaMs = now - lastCommitRef.current; // Ignore absurd deltas (backgrounded tab → huge gap; double-fire → ~0). // Clamp to a generous band (¼×…4×) around the seed so a single outlier // can't whip the velocity but genuine cadence changes still adapt. const lo = seedIntervalRef.current / 4; const hi = seedIntervalRef.current * 4; if (deltaMs >= lo && deltaMs <= hi) { const alpha = 0.25; // ~4-sample memory measuredIntervalRef.current = alpha * deltaMs + (1 - alpha) * measuredIntervalRef.current; } // Carry the in-flight glide across the seam. At commit the phase has glided // to ~1.0 (on time), past (late), or short (early). Keep the FRACTIONAL // leftover so the next slot resumes from exactly where this one was — no // snap. The drawn set simultaneously loses its oldest / gains a slot, so // phase-minus-1 keeps the on-screen position continuous across the seam. // // The carry MUST be measured against the SAME interval the visible loop // scrolls off (the slewed effectiveInterval), not the raw EMA — otherwise a // tick-to-tick mismatch between the carry's basis and the loop's basis would // re-introduce a sub-pixel seam hop. This keeps the position C0-continuous. const phase = deltaMs / effectiveIntervalRef.current; carryRef.current = Math.max(-0.25, Math.min(0.5, phase - 1)); } else { carryRef.current = 0; } lastCommitRef.current = now; // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetKey, epoch]); // ── Static path: when not scrolling (or reduced motion), redraw on any data / // geometry change. (When scrolling, the rAF loop owns the canvas.) React.useEffect(() => { if (scroll && !reduced) return; // the loop owns it redraw(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scroll, reduced, targetKey, epoch, width, height]); // ── The single persistent wall-clock rAF loop: clearRect → host draw at the // current scroll offset, every frame. One writer, one clock. React.useEffect(() => { if (!scroll || reduced || typeof window === 'undefined') return; let raf = 0; // How fast the visible velocity creeps toward the measured-cadence target, // per frame. Small → the on-screen speed only ever drifts imperceptibly, so a // wobbling EMA never shows up as a per-tick velocity snap. A genuine cadence // change (e.g. feed slows from 1 Hz → 0.5 Hz) is still tracked, just gently // (~60 frames ≈ 1 s to converge ~70%). This is the velocity decouple. const VELOCITY_SLEW = 0.02; const tick = (time: number) => { raf = requestAnimationFrame(tick); if (document.hidden) return; // pause while backgrounded (cheap poll) const ctx = prepareCtx(); if (!ctx) return; // ONE timestamp per frame, from rAF — used for BOTH the phase calc here and // (via lastCommit, stamped on the same monotonic clock) the seam math. No // performance.now() drift between sub-steps of a single frame. const now = time; // Slew the EFFECTIVE interval toward the EMA target a small fraction each // frame. The visible scroll runs off THIS, so its velocity changes are // gradual — the residual micro-jitter (velocity snapping every commit) is // gone. The buffer, not the velocity, absorbs the arrival jitter. const target = measuredIntervalRef.current; effectiveIntervalRef.current += (target - effectiveIntervalRef.current) * VELOCITY_SLEW; const effInterval = effectiveIntervalRef.current || 1; let scrollX = 0; if (lastCommitRef.current > 0) { // Wall-clock phase since the last commit, in slots (Smoothie model — pure // function of time, self-correcting, frame-rate independent). `carry` is // the leftover from the previous slot so the glide is continuous across // the commit seam. A LATE sample lets phase glide PAST 1 into the `delay` // buffer (off the right edge, clipped by clearRect) rather than freezing. // The buffer is generous (≈1.8 slots) so an early/late arrival is wholly // swallowed by slack and never perturbs the visible speed; floor at 0 so // an early phase holds the left edge. const elapsed = now - lastCommitRef.current; const phase = carryRef.current + elapsed / effInterval; const pos = Math.min(1.8, Math.max(0, phase)); scrollX = -slotWRef.current * (1 - pos); } drawRef.current(ctx, { scrollX, slotWidth: slotWRef.current, width, height, dpr: dprRef.current, }); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scroll, reduced, width, height, prepareCtx]); // ── Redraw on container resize (fluid width). The rAF loop already re-measures // every frame when scrolling; this covers the static / reduced-motion case. React.useEffect(() => { if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') { return; } const canvas = canvasRef.current; if (!canvas) return; const ro = new ResizeObserver(() => { if (!scroll || reduced) redraw(); }); ro.observe(canvas); return () => ro.disconnect(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [scroll, reduced, redraw]); return { canvasRef, redraw }; }