import { fmtValue } from '@/lib/format'; export interface TrendSeries { key: string; name: string; href?: string; points: Array<{ x: number; y: number }>; dashed?: boolean; // estimated / projected values } /** * Eight-way colour set that stays distinguishable in print and for common colour-vision deficiencies; * shape (dash) and the legend label carry the meaning too — colour is never the only carrier. */ const TREND_COLORS = ['var(--color-series-1)', 'var(--color-series-2)', 'var(--color-series-5)', 'var(--color-series-4)', 'var(--color-series-3)', 'var(--color-series-7)', 'var(--color-series-6)', 'var(--color-series-8)']; /** * Multi-series yearly line chart in pure SVG for country trend panels (up to 8 series). Zero-based y axis, * light grid, year ticks, end-of-line labels when space allows, plus a text legend with the last value. */ export function TrendChart({ series, unit, ariaLabel, height = 300, yLabel }: { series: TrendSeries[]; unit?: string | null; ariaLabel: string; height?: number; yLabel?: string }) { const shown = series.filter((s) => s.points.length > 0).slice(0, TREND_COLORS.length); const all = shown.flatMap((s) => s.points); if (all.length === 0) return null; const xs = all.map((p) => p.x); const xMin = Math.min(...xs); const xMax = Math.max(...xs); const yMax = Math.max(...all.map((p) => p.y), Number.EPSILON) * 1.08; const width = 760; const pad = { l: 58, r: 16, t: 12, b: 30 }; 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 = 5; const span = xMax - xMin; const step = span <= 8 ? 1 : span <= 16 ? 2 : span <= 30 ? 5 : 10; const xTicks: number[] = []; for (let x = Math.ceil(xMin / step) * step; x <= xMax; x += step) xTicks.push(x); if (!xTicks.includes(xMin)) xTicks.unshift(xMin); if (!xTicks.includes(xMax)) xTicks.push(xMax); return (
{ariaLabel} {Array.from({ length: yTicks + 1 }, (_, i) => { const v = (yMax / yTicks) * i; const y = sy(v); return ( {fmtValue(v, unit)} ); })} {yLabel ? ( {yLabel} ) : null} {xTicks.map((x) => ( {x} ))} {shown.map((s, si) => { const pts = [...s.points].sort((a, b) => a.x - b.x); const color = TREND_COLORS[si % TREND_COLORS.length]; const d = pts.map((p, i) => `${i === 0 ? 'M' : 'L'}${sx(p.x).toFixed(1)},${sy(p.y).toFixed(1)}`).join(' '); return ( {pts.map((p) => ( {`${s.name} — ${p.x}: ${fmtValue(p.y, unit)}`} ))} ); })}
{shown.map((s, si) => { const last = [...s.points].sort((a, b) => a.x - b.x).at(-1); return ( {s.href ? ( {s.name} ) : ( {s.name} )} {last ? ( {fmtValue(last.y, unit)} ({last.x}) ) : null} {s.dashed ? estimated : null} ); })}
); }