'use client'; import { bisector, extent } from 'd3-array'; import { scaleLinear, scaleTime } from 'd3-scale'; import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape'; import { useId, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/cn'; import { fmt1, fmtDate, fmtDateShort } from '@/lib/format'; export type LineSeries = { id: string; label: string; points: { day: string; value: number | null }[]; color?: string }; /** * Responsive multi-series time chart (SVG). Hover/touch crosshair with a value readout; y from data (optionally 0-based); * `baseline` dashed reference (index 100). Colours cycle through `--series-n`. Width follows the container (viewBox). */ export function LineChart({ series, height = 180, baseline, yZero = false, className, unit = '', showLegend = true, yFormat = fmt1, ariaLabel }: { series: LineSeries[]; height?: number; baseline?: number; yZero?: boolean; className?: string; unit?: string; showLegend?: boolean; yFormat?: (v: number) => string; ariaLabel?: string }) { const W = 800; const H = height; const m = { t: 10, r: 12, b: 22, l: 40 }; const uid = useId(); const ref = useRef(null); const [hover, setHover] = useState(null); const { x, y, dates, parsed } = useMemo(() => { const parsed = series.map((s) => ({ ...s, pts: s.points.map((p) => ({ t: new Date(p.day.length === 10 ? `${p.day}T00:00:00Z` : p.day).getTime(), v: p.value })).filter((p) => Number.isFinite(p.t)) })); const allT = parsed.flatMap((s) => s.pts.map((p) => p.t)); const allV = parsed.flatMap((s) => s.pts.map((p) => p.v)).filter((v): v is number => v !== null && Number.isFinite(v)); const [t0, t1] = allT.length ? (extent(allT) as [number, number]) : [Date.now() - 86400000, Date.now()]; let [v0, v1] = allV.length ? (extent([...allV, ...(baseline !== undefined ? [baseline] : [])]) as [number, number]) : [0, 1]; if (yZero) v0 = Math.min(0, v0); if (v0 === v1) { v0 -= 1; v1 += 1; } const pad = (v1 - v0) * 0.08; const x = scaleTime() .domain([new Date(t0), new Date(t1)]) .range([m.l, W - m.r]); const y = scaleLinear() .domain([v0 - (yZero && v0 === 0 ? 0 : pad), v1 + pad]) .range([H - m.b, m.t]) .nice(4); const dates = [...new Set(allT)].sort((a, b) => a - b); return { x, y, dates, parsed }; }, [series, baseline, yZero, H, m.b, m.l, m.r, m.t]); if (!parsed.some((s) => s.pts.some((p) => p.v !== null))) { return (
No series available yet.
); } const ticksY = y.ticks(4); const ticksX = x.ticks(Math.min(6, Math.max(2, dates.length))); const bis = bisector((d) => d).center; const onMove = (clientX: number) => { const svg = ref.current; if (!svg) return; const r = svg.getBoundingClientRect(); const px = ((clientX - r.left) / r.width) * W; const t = x.invert(px).getTime(); setHover(dates[bis(dates, t)] ?? null); }; const line = d3line<{ t: number; v: number | null }>() .defined((d) => d.v !== null) .x((d) => x(d.t)) .y((d) => y(d.v as number)) .curve(curveMonotoneX); const area = d3area<{ t: number; v: number | null }>() .defined((d) => d.v !== null) .x((d) => x(d.t)) .y0(y.range()[0] as number) .y1((d) => y(d.v as number)) .curve(curveMonotoneX); const colorOf = (i: number, c?: string) => c ?? `var(--series-${(i % 6) + 1})`; return (
s.label).join(', ')} over time`} onMouseMove={(e) => onMove(e.clientX)} onMouseLeave={() => setHover(null)} onTouchStart={(e) => e.touches[0] && onMove(e.touches[0].clientX)} onTouchMove={(e) => e.touches[0] && onMove(e.touches[0].clientX)} onTouchEnd={() => setHover(null)} > {ticksY.map((t) => ( {yFormat(t)} ))} {ticksX.map((t) => ( {fmtDateShort(t.toISOString())} ))} {baseline !== undefined && } {parsed.map((s, i) => ( {parsed.length === 1 && } ))} {hover !== null && ( {parsed.map((s, i) => { const p = s.pts.find((q) => q.t === hover); if (!p || p.v === null) return null; return ; })} )}
{hover !== null ? ( <> {fmtDate(new Date(hover).toISOString())} {parsed.map((s, i) => { const p = s.pts.find((q) => q.t === hover); return ( {s.label} {p && p.v !== null ? `${yFormat(p.v)}${unit}` : '—'} ); })} ) : ( showLegend && parsed.map((s, i) => ( {s.label} )) )}
); }