'use client'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { SERIES_COLORS, compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale'; export interface SeriesPoint { x: string; // ISO date (YYYY-MM-DD) or ISO datetime y: number; meta?: string; } export interface Series { id: string; label: string; points: SeriesPoint[]; color?: string; kind?: 'line' | 'area' | 'dots' | 'step'; /** dashed style for benchmarks/references */ dashed?: boolean; } export interface LineChartProps { series: Series[]; height?: number; currency?: boolean; /** show values as % change from first point of each series (indexed comparison) */ indexed?: boolean; yLabel?: string; className?: string; ariaLabel: string; /** optional horizontal reference line (e.g. RIV) */ reference?: { value: number; label: string } | null; emptyLabel?: string; } /** * Responsive SVG line/area/dot chart following the dataviz spec: 2px lines, hairline solid grid, * ≥8px end markers with a surface ring, crosshair + tooltip on hover, legend for ≥2 series, * selective end labels. Fixed categorical colour order; text uses text tokens only. */ export function LineChart({ series, height = 260, currency = false, indexed = false, yLabel, className, ariaLabel, reference = null, emptyLabel = 'Not enough data to chart' }: LineChartProps) { const ref = useRef(null); const [width, setWidth] = useState(640); const [hover, setHover] = useState(null); const uid = useId(); useEffect(() => { const el = ref.current; if (!el) return; const ro = new ResizeObserver((entries) => { for (const e of entries) setWidth(Math.max(240, Math.floor(e.contentRect.width))); }); ro.observe(el); return () => ro.disconnect(); }, []); const prepared = useMemo(() => { const s = series .map((ser, i) => { const pts = ser.points .map((p) => ({ x: parseDay(p.x), y: p.y, meta: p.meta })) .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y)) .sort((a, b) => a.x - b.x); let out = pts; if (indexed && pts.length && pts[0]!.y !== 0) { const base = pts[0]!.y; out = pts.map((p) => ({ ...p, y: (p.y / base - 1) * 100 })); } return { ...ser, color: ser.color ?? SERIES_COLORS[i % SERIES_COLORS.length]!, pts: out }; }) .filter((s) => s.pts.length > 0); return s; }, [series, indexed]); const allPts = prepared.flatMap((s) => s.pts); const hasLine = prepared.some((s) => (s.kind ?? 'line') !== 'dots' && s.pts.length >= 2); if (allPts.length === 0 || (!hasLine && allPts.length < 1)) { return (
{emptyLabel}
); } const pad = { top: 12, right: 56, bottom: 26, left: 8 }; const W = width; const H = height; const iw = W - pad.left - pad.right; const ih = H - pad.top - pad.bottom; const xs = allPts.map((p) => p.x); const ys = allPts.map((p) => p.y).concat(reference ? [indexed ? 0 : reference.value] : []); let xMin = Math.min(...xs); let xMax = Math.max(...xs); if (xMax - xMin < 7 * 86_400_000) { // pad a degenerate/short time range so single points and short runs stay readable xMin -= 15 * 86_400_000; xMax += 15 * 86_400_000; } let yMin = Math.min(...ys); let yMax = Math.max(...ys); if (yMin === yMax) { yMin -= Math.abs(yMin) * 0.05 || 1; yMax += Math.abs(yMax) * 0.05 || 1; } const yPad = (yMax - yMin) * 0.08; yMin -= yPad; yMax += yPad; const ticks = niceTicks(yMin, yMax, W < 480 ? 3 : 4); const sx = (x: number) => Math.round((pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw)) * 100) / 100; const sy = (y: number) => Math.round((pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih) * 100) / 100; const span = xMax - xMin; const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 100)))); const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency })); const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency })); // hover: nearest x across all series const hoverX = hover === null ? null : xMin + ((hover - pad.left) / iw) * (xMax - xMin); const hoverRows = hoverX === null ? [] : prepared.map((s) => { let best = s.pts[0]!; let bd = Infinity; for (const p of s.pts) { const d = Math.abs(p.x - hoverX); if (d < bd) { bd = d; best = p; } } return { s, p: best }; }); const hoverDate = hoverRows.length ? hoverRows.reduce((a, r) => (Math.abs(r.p.x - hoverX!) < Math.abs(a - hoverX!) ? r.p.x : a), hoverRows[0]!.p.x) : null; const linePath = (pts: Array<{ x: number; y: number }>, step = false) => pts.map((p, i) => (i === 0 ? `M${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}` : step ? `H${sx(p.x).toFixed(1)}V${sy(p.y).toFixed(1)}` : `L${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`)).join(' '); const narrow = W < 560; const tooltipLeft = hover !== null ? (narrow ? pad.left : Math.min(Math.max(hover + 12, pad.left), W - 200)) : 0; return (
{prepared.length > 1 ? (
    {prepared.map((s) => (
  • {s.label}
  • ))}
) : null} { const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; setHover(x >= pad.left && x <= pad.left + iw ? x : null); }} onPointerMove={(e) => { if (e.pointerType !== 'mouse' && e.buttons === 0) return; const rect = e.currentTarget.getBoundingClientRect(); const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W; setHover(x >= pad.left && x <= pad.left + iw ? x : null); }} onPointerLeave={(e) => { if (e.pointerType === 'mouse') setHover(null); }} > {prepared.map((s) => ( ))} {ticks.map((t) => ( {fmtY(t)} ))} {xTicks.map((t) => ( {fmtDay(t, span)} ))} {reference ? ( {reference.label} ) : null} {prepared.map((s) => { const kind = s.kind ?? 'line'; if (kind === 'dots') { return ( {s.pts.map((p, i) => ( ))} ); } const d = linePath(s.pts, kind === 'step'); const last = s.pts[s.pts.length - 1]!; return ( {kind === 'area' ? : null} ); })} {hover !== null && hoverDate !== null ? ( {hoverRows.map(({ s, p }) => ( ))} ) : null} {hover !== null && hoverDate !== null ? (
{fmtDayFull(hoverDate)}
{hoverRows.map(({ s, p }) => (
{s.label} {fmtYFull(p.y)}
))} {hoverRows[0]?.p.meta ?
{hoverRows[0].p.meta}
: null}
) : null} {yLabel ?
{yLabel}
: null} {prepared.flatMap((s) => s.pts.slice(-120).map((p, i) => ( )))}
{ariaLabel}
Series Date Value
{s.label} {fmtDayFull(p.x)} {fmtYFull(p.y)}
); } function niceTimeTicks(min: number, max: number, count: number): number[] { if (max <= min) return [min]; const span = max - min; const day = 86_400_000; const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day]; const step = candidates.find((c) => span / c <= count) ?? candidates[candidates.length - 1]!; const out: number[] = []; const start = Math.ceil(min / step) * step; for (let t = start; t <= max; t += step) out.push(t); if (!out.length) out.push(min, max); return out; }