import { fmtValue } from '@/lib/format'; export interface Series { name: string; points: Array<{ x: number; y: number; lo?: number | null; hi?: number | null }>; dashed?: boolean; // e.g. estimated vs observed } const SERIES_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-3)', 'var(--color-series-4)', 'var(--color-series-5)', 'var(--color-series-6)', 'var(--color-series-7)', 'var(--color-series-8)']; /** * Time-series line chart in pure SVG: years on x, values on y, optional confidence band. * Zero-based y axis; light grid; every series has a text legend (colour is never the only carrier). */ export function LineChart({ series, unit, ariaLabel, height = 220 }: { series: Series[]; unit?: string | null; ariaLabel: string; height?: number }) { const all = series.flatMap((s) => s.points); if (all.length === 0) return null; const xs = all.map((p) => p.x); const ys = all.flatMap((p) => [p.y, p.lo ?? p.y, p.hi ?? p.y]); const xMin = Math.min(...xs); const xMax = Math.max(...xs); const yMax = Math.max(...ys, Number.EPSILON) * 1.08; const width = 640; const pad = { l: 56, r: 12, t: 10, b: 28 }; const iw = width - pad.l - pad.r; const ih = height - pad.t - pad.b; const sx = (x: number) => pad.l + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw); const sy = (y: number) => pad.t + ih - (y / yMax) * ih; const yTicks = 4; const xTickCount = Math.min(8, xMax - xMin + 1); const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / Math.max(1, xTickCount - 1))); return (
{ariaLabel} {Array.from({ length: yTicks + 1 }, (_, i) => { const v = (yMax / yTicks) * i; const y = sy(v); return ( {fmtValue(v, unit === 'count' ? 'count' : unit)} ); })} {xTicks.map((x) => ( {x} ))} {series.map((s, si) => { const pts = [...s.points].sort((a, b) => a.x - b.x); const color = SERIES_COLORS[si % SERIES_COLORS.length]; const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); const band = pts.filter((p) => p.lo != null && p.hi != null); const bandPath = band.length > 1 ? `${band.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.hi!).toFixed(1)}`).join(' ')} ${[...band].reverse().map((p) => `L${sx(p.x).toFixed(1)},${sy(p.lo!).toFixed(1)}`).join(' ')} Z` : null; return ( {bandPath ? : null} {pts.map((p) => ( {`${s.name} — ${p.x}: ${fmtValue(p.y, unit)}`} ))} ); })}
{series.map((s, si) => ( {s.name} {s.dashed ? ' (estimated)' : ''} ))}
); }