TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import { useEffect, useId, useMemo, useRef, useState } from 'react';4import { SERIES_COLORS, compactNumber, fmtDay, fmtDayFull, fullNumber, niceTicks, parseDay } from './scale';56export interface SeriesPoint {7 x: string; // ISO date (YYYY-MM-DD) or ISO datetime8 y: number;9 meta?: string;10}1112export interface Series {13 id: string;14 label: string;15 points: SeriesPoint[];16 color?: string;17 kind?: 'line' | 'area' | 'dots' | 'step';18 /** dashed style for benchmarks/references */19 dashed?: boolean;20}2122export interface LineChartProps {23 series: Series[];24 height?: number;25 currency?: boolean;26 /** show values as % change from first point of each series (indexed comparison) */27 indexed?: boolean;28 yLabel?: string;29 className?: string;30 ariaLabel: string;31 /** optional horizontal reference line (e.g. RIV) */32 reference?: { value: number; label: string } | null;33 emptyLabel?: string;34}3536/**37 * Responsive SVG line/area/dot chart following the dataviz spec: 2px lines, hairline solid grid,38 * ≥8px end markers with a surface ring, crosshair + tooltip on hover, legend for ≥2 series,39 * selective end labels. Fixed categorical colour order; text uses text tokens only.40 */41export function LineChart({ series, height = 260, currency = false, indexed = false, yLabel, className, ariaLabel, reference = null, emptyLabel = 'Not enough data to chart' }: LineChartProps) {42 const ref = useRef<HTMLDivElement>(null);43 const [width, setWidth] = useState(640);44 const [hover, setHover] = useState<number | null>(null);45 const uid = useId();4647 useEffect(() => {48 const el = ref.current;49 if (!el) return;50 const ro = new ResizeObserver((entries) => {51 for (const e of entries) setWidth(Math.max(240, Math.floor(e.contentRect.width)));52 });53 ro.observe(el);54 return () => ro.disconnect();55 }, []);5657 const prepared = useMemo(() => {58 const s = series59 .map((ser, i) => {60 const pts = ser.points61 .map((p) => ({ x: parseDay(p.x), y: p.y, meta: p.meta }))62 .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y))63 .sort((a, b) => a.x - b.x);64 let out = pts;65 if (indexed && pts.length && pts[0]!.y !== 0) {66 const base = pts[0]!.y;67 out = pts.map((p) => ({ ...p, y: (p.y / base - 1) * 100 }));68 }69 return { ...ser, color: ser.color ?? SERIES_COLORS[i % SERIES_COLORS.length]!, pts: out };70 })71 .filter((s) => s.pts.length > 0);72 return s;73 }, [series, indexed]);7475 const allPts = prepared.flatMap((s) => s.pts);76 const hasLine = prepared.some((s) => (s.kind ?? 'line') !== 'dots' && s.pts.length >= 2);77 if (allPts.length === 0 || (!hasLine && allPts.length < 1)) {78 return (79 <div ref={ref} className={className} style={{ height }}>80 <div className="flex h-full items-center justify-center text-xs text-subtle">{emptyLabel}</div>81 </div>82 );83 }8485 const pad = { top: 12, right: 56, bottom: 26, left: 8 };86 const W = width;87 const H = height;88 const iw = W - pad.left - pad.right;89 const ih = H - pad.top - pad.bottom;90 const xs = allPts.map((p) => p.x);91 const ys = allPts.map((p) => p.y).concat(reference ? [indexed ? 0 : reference.value] : []);92 let xMin = Math.min(...xs);93 let xMax = Math.max(...xs);94 if (xMax - xMin < 7 * 86_400_000) {95 // pad a degenerate/short time range so single points and short runs stay readable96 xMin -= 15 * 86_400_000;97 xMax += 15 * 86_400_000;98 }99 let yMin = Math.min(...ys);100 let yMax = Math.max(...ys);101 if (yMin === yMax) {102 yMin -= Math.abs(yMin) * 0.05 || 1;103 yMax += Math.abs(yMax) * 0.05 || 1;104 }105 const yPad = (yMax - yMin) * 0.08;106 yMin -= yPad;107 yMax += yPad;108 const ticks = niceTicks(yMin, yMax, W < 480 ? 3 : 4);109 const sx = (x: number) => Math.round((pad.left + (xMax === xMin ? iw / 2 : ((x - xMin) / (xMax - xMin)) * iw)) * 100) / 100;110 const sy = (y: number) => Math.round((pad.top + ih - ((y - yMin) / (yMax - yMin)) * ih) * 100) / 100;111 const span = xMax - xMin;112 const xTicks = niceTimeTicks(xMin, xMax, Math.max(2, Math.min(5, Math.floor(iw / 100))));113 const fmtY = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(Math.abs(v) < 10 ? 1 : 0)}%` : compactNumber(v, { currency }));114 const fmtYFull = (v: number) => (indexed ? `${v > 0 ? '+' : ''}${v.toFixed(2)}%` : fullNumber(v, { currency }));115116 // hover: nearest x across all series117 const hoverX = hover === null ? null : xMin + ((hover - pad.left) / iw) * (xMax - xMin);118 const hoverRows = hoverX === null119 ? []120 : prepared.map((s) => {121 let best = s.pts[0]!;122 let bd = Infinity;123 for (const p of s.pts) {124 const d = Math.abs(p.x - hoverX);125 if (d < bd) {126 bd = d;127 best = p;128 }129 }130 return { s, p: best };131 });132 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;133134 const linePath = (pts: Array<{ x: number; y: number }>, step = false) =>135 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(' ');136137 const narrow = W < 560;138 const tooltipLeft = hover !== null ? (narrow ? pad.left : Math.min(Math.max(hover + 12, pad.left), W - 200)) : 0;139140 return (141 <div ref={ref} className={`relative min-w-0 max-w-full overflow-hidden ${className ?? ''}`}>142 {prepared.length > 1 ? (143 <ul className="mb-1 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-muted" aria-label="Legend">144 {prepared.map((s) => (145 <li key={s.id} className="flex items-center gap-1.5">146 <span className="inline-block h-0.5 w-4 rounded-full" style={{ background: s.color, ...(s.dashed ? { backgroundImage: `repeating-linear-gradient(90deg, ${s.color} 0 4px, transparent 4px 7px)` } : {}) }} />147 {s.label}148 </li>149 ))}150 </ul>151 ) : null}152 <svg153 width="100%"154 height={H}155 viewBox={`0 0 ${W} ${H}`}156 preserveAspectRatio="none"157 role="img"158 aria-label={ariaLabel}159 className="block max-w-full select-none"160 style={{ touchAction: 'pan-y' }}161 onPointerDown={(e) => {162 const rect = e.currentTarget.getBoundingClientRect();163 const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;164 setHover(x >= pad.left && x <= pad.left + iw ? x : null);165 }}166 onPointerMove={(e) => {167 if (e.pointerType !== 'mouse' && e.buttons === 0) return;168 const rect = e.currentTarget.getBoundingClientRect();169 const x = ((e.clientX - rect.left) / Math.max(1, rect.width)) * W;170 setHover(x >= pad.left && x <= pad.left + iw ? x : null);171 }}172 onPointerLeave={(e) => {173 if (e.pointerType === 'mouse') setHover(null);174 }}175 >176 <defs>177 {prepared.map((s) => (178 <linearGradient key={s.id} id={`${uid}-g-${s.id}`} x1="0" x2="0" y1="0" y2="1">179 <stop offset="0%" stopColor={s.color} stopOpacity={0.14} />180 <stop offset="100%" stopColor={s.color} stopOpacity={0.02} />181 </linearGradient>182 ))}183 </defs>184 {ticks.map((t) => (185 <g key={t}>186 <line x1={pad.left} x2={pad.left + iw} y1={sy(t)} y2={sy(t)} stroke="var(--ri-border)" strokeWidth={1} shapeRendering="crispEdges" />187 <text x={W - pad.right + 6} y={sy(t)} dy="0.32em" fontSize={10} fill="var(--ri-fg-subtle)" className="num">188 {fmtY(t)}189 </text>190 </g>191 ))}192 {xTicks.map((t) => (193 <text key={t} x={sx(t)} y={H - 8} fontSize={10} textAnchor="middle" fill="var(--ri-fg-subtle)">194 {fmtDay(t, span)}195 </text>196 ))}197 {reference ? (198 <g>199 <line x1={pad.left} x2={pad.left + iw} y1={sy(indexed ? 0 : reference.value)} y2={sy(indexed ? 0 : reference.value)} stroke="var(--ri-fg-subtle)" strokeWidth={1} strokeDasharray="3 3" />200 <text x={pad.left + 4} y={sy(indexed ? 0 : reference.value) - 4} fontSize={10} fill="var(--ri-fg-muted)">201 {reference.label}202 </text>203 </g>204 ) : null}205 {prepared.map((s) => {206 const kind = s.kind ?? 'line';207 if (kind === 'dots') {208 return (209 <g key={s.id}>210 {s.pts.map((p, i) => (211 <circle key={i} cx={sx(p.x)} cy={sy(p.y)} r={3.5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={1.5} opacity={0.85} />212 ))}213 </g>214 );215 }216 const d = linePath(s.pts, kind === 'step');217 const last = s.pts[s.pts.length - 1]!;218 return (219 <g key={s.id}>220 {kind === 'area' ? <path d={`${d} L${sx(last.x).toFixed(1)},${(pad.top + ih).toFixed(1)} L${sx(s.pts[0]!.x).toFixed(1)},${(pad.top + ih).toFixed(1)} Z`} fill={`url(#${uid}-g-${s.id})`} /> : null}221 <path d={d} fill="none" stroke={s.color} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={s.dashed ? '4 4' : undefined} />222 <circle cx={sx(last.x)} cy={sy(last.y)} r={4} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />223 </g>224 );225 })}226 {hover !== null && hoverDate !== null ? (227 <g>228 <line x1={sx(hoverDate)} x2={sx(hoverDate)} y1={pad.top} y2={pad.top + ih} stroke="var(--ri-border-strong)" strokeWidth={1} />229 {hoverRows.map(({ s, p }) => (230 <circle key={s.id} cx={sx(p.x)} cy={sy(p.y)} r={5} fill={s.color} stroke="var(--ri-bg-elevated)" strokeWidth={2} />231 ))}232 </g>233 ) : null}234 </svg>235 {hover !== null && hoverDate !== null ? (236 <div className="pointer-events-none absolute top-1 z-10 min-w-[160px] rounded-md border border-border bg-elevated px-2.5 py-2 text-[11px] shadow-pop" style={{ left: tooltipLeft }}>237 <div className="mb-1 font-medium text-fg">{fmtDayFull(hoverDate)}</div>238 {hoverRows.map(({ s, p }) => (239 <div key={s.id} className="flex items-center justify-between gap-3">240 <span className="flex items-center gap-1.5 text-muted">241 <span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />242 {s.label}243 </span>244 <span className="num font-medium text-fg">{fmtYFull(p.y)}</span>245 </div>246 ))}247 {hoverRows[0]?.p.meta ? <div className="mt-1 text-subtle">{hoverRows[0].p.meta}</div> : null}248 </div>249 ) : null}250 {yLabel ? <div className="mt-1 text-[10px] uppercase tracking-wider text-subtle">{yLabel}</div> : null}251 <table className="sr-only">252 <caption>{ariaLabel}</caption>253 <thead>254 <tr>255 <th>Series</th>256 <th>Date</th>257 <th>Value</th>258 </tr>259 </thead>260 <tbody>261 {prepared.flatMap((s) => s.pts.slice(-120).map((p, i) => (262 <tr key={`${s.id}-${i}`}>263 <td>{s.label}</td>264 <td>{fmtDayFull(p.x)}</td>265 <td>{fmtYFull(p.y)}</td>266 </tr>267 )))}268 </tbody>269 </table>270 </div>271 );272}273274function niceTimeTicks(min: number, max: number, count: number): number[] {275 if (max <= min) return [min];276 const span = max - min;277 const day = 86_400_000;278 const candidates = [day, 2 * day, 7 * day, 14 * day, 30 * day, 61 * day, 91 * day, 182 * day, 365 * day, 730 * day, 1826 * day];279 const step = candidates.find((c) => span / c <= count) ?? candidates[candidates.length - 1]!;280 const out: number[] = [];281 const start = Math.ceil(min / step) * step;282 for (let t = start; t <= max; t += step) out.push(t);283 if (!out.length) out.push(min, max);284 return out;285}286