// SmoothLine — the streaming sibling of Sparkline, now drawn on a CANVAS. // // The trace SCROLLS left at constant velocity via the `useSmoothSeries` // wall-clock canvas engine: every rAF frame the whole monotone-cubic curve is // redrawn at its scrolled X (one writer, one clock), so there is no SVG-era // path/transform desync — the jump-then-catch-up is gone by construction. // // Geometry stays ours: the draw callback strokes a monotone-cubic (Fritsch– // Carlson / d3 `curveMonotoneX`) polyline through the data points, with an // optional area fill closed to the baseline. `tension={0}` degenerates to sharp // straight segments. // // Color via the token bridge, resolved ONCE per theme change (not per frame): // • a semantic token (`'primary' | 'success' | …`) → `useThemeColor()` → hex. // • `'currentColor'` → the canvas inherits an ancestor's CSS `color` (a `text-*` // className tint, e.g. cmdop's `text-chart-*`); we read the resolved color off // the canvas element via `getComputedStyle(canvas).color` and pass it to // `ctx.strokeStyle`. Re-resolved on theme change (the resolved-theme dep on // `useThemeColor`'s own re-render path drives a redraw). // // Requires mounted by the host app (no nested providers here). 'use client'; import * as React from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { useThemeColor } from '@djangocfg/ui-core/styles/palette'; import { useResolvedTheme } from '@djangocfg/ui-core/hooks'; import type { SmoothLineProps } from './types'; import { useSmoothSeries, type DrawFn } from './useSmoothSeries'; import { DEFAULT_TENSION, type Pt, traceSmoothSegments } from './smoothPath'; /** * Map oldest-first values to logical points (shared by the line + area draws). * * The Y-domain (min/max) is computed from the drawn values. Because the engine * draws from a FIXED committed set between ticks (no per-frame Y-tween), the * domain is stable across the whole scroll — the curve never rubber-bands. */ function valuePoints( values: readonly number[], width: number, height: number, ): Pt[] { const max = Math.max(...values, 1); const min = Math.min(...values, 0); const span = max - min || 1; const stepX = values.length > 1 ? width / (values.length - 1) : width; return values.map((v, i) => ({ x: i * stepX, y: height - ((v - min) / span) * (height - 2) - 1, })); } export function SmoothLine({ values, color = 'primary', width = 240, height = 40, strokeWidth = 1.5, area = false, tweenMs = 1000, scroll = false, delay = true, tension = DEFAULT_TENSION, ariaLabel, className, ...rest }: SmoothLineProps) { const isBridge = color === 'currentColor'; // Semantic token → hex; bridge mode reads the canvas's inherited CSS color. const themeColor = useThemeColor(isBridge ? 'primary' : color); // Re-resolve the bridge color when the theme flips (dark/light). const resolvedTheme = useResolvedTheme(); // With the right-edge lag, the drawn set drops the freshest point so the right // edge is always a known point; the engine's scroll reveals the off-edge value. const useLag = scroll && delay && values.length > 2; const drawn = React.useMemo( () => (useLag ? [...values].slice(0, -1) : [...values]), // values identity changes per tick; the joined key gates recompute. // eslint-disable-next-line react-hooks/exhaustive-deps [values.join(','), useLag], ); // The per-frame canvas draw: monotone-cubic stroke (+ optional area), at the // scrolled X. Rebuilt when the committed values / geometry / color change — so // there is no per-frame Y-tween, only the scroll moves. const draw = React.useCallback( (ctx, frame) => { // Resolve the stroke/fill color ONCE per draw (not a per-point cost). In // bridge mode read the inherited CSS color off the canvas; else the token. let stroke = themeColor; if (isBridge && ctx.canvas) { const c = getComputedStyle(ctx.canvas).color; if (c) stroke = c; } const w = frame.width; const h = frame.height; const sx = frame.scrollX; if (drawn.length === 0) { const mid = h / 2; ctx.beginPath(); ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; ctx.stroke(); return; } const pts = valuePoints(drawn, w, h).map((p) => ({ x: p.x + sx, y: p.y, })); // Area fill first (under the stroke), hugging the same curve down to the // baseline. Opacity 0.12 matches the old SVG fill. if (area) { ctx.beginPath(); ctx.moveTo(pts[0].x, h); ctx.lineTo(pts[0].x, pts[0].y); traceSmoothSegments(ctx, pts, tension); ctx.lineTo(pts[pts.length - 1].x, h); ctx.closePath(); ctx.globalAlpha = 0.12; ctx.fillStyle = stroke; ctx.fill(); ctx.globalAlpha = 1; } // Stroke the line over the fill. ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); traceSmoothSegments(ctx, pts, tension); ctx.strokeStyle = stroke; ctx.lineWidth = strokeWidth; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke(); }, // resolvedTheme re-runs the bridge color read on a theme flip. // eslint-disable-next-line react-hooks/exhaustive-deps [drawn, area, tension, strokeWidth, themeColor, isBridge, resolvedTheme], ); const { canvasRef } = useSmoothSeries({ values, tweenMs, scroll, delay, width, height, draw, }); return (
); }