spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { area as d3Area, line as d3Line, stack as d3Stack } from 'd3-shape';3import { useCallback, useMemo, useState, type PointerEvent } from 'react';4import { t } from '@/i18n';5import { formatPeriod, formatTick, formatValue } from '@/lib/format';6import type { FormatSpec as Spec, Provenance } from '@/lib/types';7import type { ProvenancePayload } from '@/components/data/provenance-context';8import { ChartFrame, Legend, type TableData } from './chart-frame';9import { CHART, MARK, seriesVar } from './palette';10import { DEFAULT_MARGIN, extent, lineDomain, periodToX, splitForecast, xYearScale, yScale, yearTicks, type Margin, type SeriesPoint } from './scales';11import { summarizeMulti, summarizeSeries } from './summary';12import { ChartTooltip, TooltipRow } from './tooltip';13import { useMeasure } from './use-measure';1415export interface LineSeries {16 id: string;17 name: string;18 points: SeriesPoint[];19 /** Fixed colour slot (0-based). Defaults to the array index — colour follows the entity, never its rank. */20 colorIndex?: number;21 color?: string;22}2324export interface LineChartProps {25 series: LineSeries[];26 spec: Spec;27 /** Subject for the accessible summary ("Canada's GDP per capita"). */28 subject?: string;29 variant?: 'line' | 'area' | 'stacked';30 log?: boolean;31 height?: number;32 margin?: Partial<Margin>;33 title?: React.ReactNode;34 subtitle?: React.ReactNode;35 provenance?: Provenance | null;36 payload?: ProvenancePayload | null;37 actions?: React.ReactNode;38 /** Direct end-labels for ≤ 4 series. */39 endLabels?: boolean;40 /** Draw a highlighted marker on the latest actual point. */41 endDot?: boolean;42 className?: string;43 defaultWidth?: number;44}4546interface XY {47 x: number;48 y: number;49 p: SeriesPoint;50}5152/**53 * Multi-series line / area / stacked-area chart. Dashed segments for `is_forecast`, optional log scale,54 * crosshair + one tooltip listing every series at the nearest X (pointer events → touch friendly).55 * Index=100 transformations are data-side (pass already-indexed points).56 */57export function LineChart({58 series,59 spec,60 subject,61 variant = 'line',62 log = false,63 height = 240,64 margin: marginIn,65 title,66 subtitle,67 provenance,68 payload,69 actions,70 endLabels = true,71 endDot = true,72 className,73 defaultWidth = 640,74}: LineChartProps) {75 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);76 const margin: Margin = { ...DEFAULT_MARGIN, ...marginIn };77 if (endLabels && series.length > 1 && series.length <= 4) margin.right = Math.max(margin.right, 72);78 // Left margin fits the longest y tick label (≈ 6.4 px per character at 11 px) so labels are never clipped.79 // Uses the same domain rule as the chart itself (lineDomain) so the probe ticks are the real ticks.80 if (marginIn?.left == null) {81 const probe = extent(series.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1];82 const dom = variant === 'stacked' ? ([0, probe[1]] as [number, number]) : log && probe[0] > 0 ? probe : lineDomain(probe, spec);83 const longest = Math.max(...yScale(dom, [0, 1], { log, includeZero: variant === 'stacked' }).ticks(4).map((tk) => formatTick(tk, spec).length), 3);84 margin.left = Math.min(84, Math.max(36, Math.round(longest * 6.4) + 12));85 }86 const [hover, setHover] = useState<number | null>(null); // x-value (fractional year)8788 const model = useMemo(() => {89 const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => p.value != null && Number.isFinite(p.value)) }));90 const allX = clean.flatMap((s) => s.points.map(periodToX));91 const xDom = extent(allX) ?? [2000, 2024];92 const innerW = Math.max(10, width - margin.left - margin.right);93 const innerH = Math.max(10, height - margin.top - margin.bottom);94 const x = xYearScale(xDom, [0, innerW]);9596 let stacks: Array<Array<[number, number, number]>> | null = null; // per series: [x, y0, y1]97 let yDom: [number, number];98 if (variant === 'stacked') {99 // Build a wide table keyed by x.100 const keys = clean.map((s) => s.id);101 const xs = Array.from(new Set(allX)).sort((a, b) => a - b);102 const table = xs.map((xv) => {103 const row: Record<string, number> = { __x: xv };104 for (const s of clean) {105 const p = s.points.find((q) => periodToX(q) === xv);106 row[s.id] = p?.value ?? 0;107 }108 return row;109 });110 const st = d3Stack<Record<string, number>>().keys(keys)(table);111 stacks = st.map((layer) => layer.map((d) => [d.data.__x!, d[0], d[1]] as [number, number, number]));112 const top = Math.max(0, ...stacks.flat().map((d) => d[2]));113 yDom = [0, top];114 } else {115 // Line / area: padded nice extent; zero only when the rule says so (see lineDomain). Log keeps the raw extent.116 const raw = extent(clean.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1];117 yDom = log && raw[0] > 0 ? raw : lineDomain(raw, spec);118 }119 const y = yScale(yDom, [innerH, 0], { log, includeZero: variant === 'stacked' });120 const xTicks = yearTicks(xDom, Math.max(2, Math.floor(innerW / 90)));121 const yTicks = y.ticks(4);122 const pathFor = d3Line<XY>()123 .x((d) => d.x)124 .y((d) => d.y);125 const areaFor = d3Area<XY>()126 .x((d) => d.x)127 .y0(() => y(Math.max(0, y.domain()[0]!)))128 .y1((d) => d.y);129130 const layers = clean.map((s, i) => {131 const color = s.color ?? seriesVar(s.colorIndex ?? i);132 const runs = splitForecast(s.points).map((r) => ({133 forecast: r.forecast,134 xy: r.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })),135 }));136 const all = s.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p }));137 const actual = s.points.filter((p) => !p.is_forecast);138 const last = actual[actual.length - 1] ?? null;139 return { s, color, runs, all, last: last ? { x: x(periodToX(last)), y: y(last.value!), p: last } : null };140 });141142 const stackPaths =143 stacks &&144 stacks.map((layer, i) => {145 const a = d3Area<[number, number, number]>()146 .x((d) => x(d[0]))147 .y0((d) => y(d[1]))148 .y1((d) => y(d[2]));149 return { d: a(layer) ?? '', color: clean[i]!.color ?? seriesVar(clean[i]!.colorIndex ?? i) };150 });151152 return { clean, x, y, innerW, innerH, xTicks, yTicks, pathFor, areaFor, layers, stackPaths, xDom };153 // eslint-disable-next-line react-hooks/exhaustive-deps -- only spec.format drives the domain rule154 }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom, variant, log, spec.format]);155156 const onMove = useCallback(157 (e: PointerEvent<SVGRectElement>) => {158 const rect = e.currentTarget.getBoundingClientRect();159 const px = e.clientX - rect.left;160 const xv = model.x.invert(px);161 // snap to nearest existing x162 let best: number | null = null;163 let bestD = Infinity;164 for (const s of model.clean)165 for (const p of s.points) {166 const d = Math.abs(periodToX(p) - xv);167 if (d < bestD) {168 bestD = d;169 best = periodToX(p);170 }171 }172 setHover(best);173 },174 [model],175 );176177 const hoverRows = useMemo(() => {178 if (hover == null) return null;179 return model.layers180 .map((l) => {181 const p = l.s.points.find((q) => periodToX(q) === hover);182 return p ? { name: l.s.name, color: l.color, p } : null;183 })184 .filter((r): r is { name: string; color: string; p: SeriesPoint } => !!r);185 }, [hover, model]);186187 const summary =188 series.length === 1 ? summarizeSeries(subject ?? series[0]!.name, series[0]!.points, spec) : summarizeMulti(series.map((s) => s.name), series.map((s) => s.points));189190 const table: TableData = useMemo(() => {191 const xs = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.period)))).sort();192 return {193 columns: [{ key: 'period', label: t('common.period') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))],194 rows: xs.map((period) => {195 const row: Record<string, string> = { period: formatPeriod(period, spec.frequency ?? 'A') };196 for (const s of model.clean) {197 const p = s.points.find((q) => q.period === period);198 row[s.id] = p ? `${formatValue(p.value, spec)}${p.is_forecast ? ' *' : ''}` : t('common.na');199 }200 return row;201 }),202 };203 }, [model, spec]);204205 const hasForecast = series.some((s) => s.points.some((p) => p.is_forecast));206 const hoverX = hover != null ? model.x(hover) : null;207 const legendItems = model.layers.map((l) => ({ label: l.s.name, color: l.color, shape: variant === 'line' ? ('line' as const) : ('rect' as const) }));208 const empty = model.clean.every((s) => s.points.length === 0);209210 return (211 <ChartFrame212 title={title}213 subtitle={subtitle}214 summary={summary}215 provenance={provenance}216 payload={payload}217 table={empty ? undefined : table}218 actions={actions}219 legend={<Legend items={legendItems} />}220 note={hasForecast ? t('chart.forecastNote') : undefined}221 className={className}222 minHeight={height}223 >224 <div ref={ref} className="relative w-full" style={{ height }}>225 {empty ? (226 <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>227 ) : (228 <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>229 <title>{typeof title === 'string' ? title : (spec.name ?? subject ?? '')}</title>230 <desc>{summary}</desc>231 <g transform={`translate(${margin.left},${margin.top})`}>232 <g className="grid">233 {model.yTicks.map((tk) => (234 <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />235 ))}236 </g>237 <g className="axis">238 <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />239 {model.xTicks.map((yr) => (240 <text key={yr} x={model.x(yr)} y={model.innerH + 16} textAnchor="middle">241 {yr}242 </text>243 ))}244 {model.yTicks.map((tk) => (245 <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">246 {formatTick(tk, spec)}247 </text>248 ))}249 </g>250251 {variant === 'stacked' && model.stackPaths252 ? model.stackPaths.map((sp, i) => <path key={i} d={sp.d} fill={sp.color} fillOpacity={0.85} stroke={CHART.surface} strokeWidth={MARK.gap} />)253 : null}254255 {variant !== 'stacked'256 ? model.layers.map((l) => (257 <g key={l.s.id}>258 {variant === 'area' ? <path d={model.areaFor(l.all) ?? ''} fill={l.color} fillOpacity={MARK.areaOpacity} /> : null}259 {l.runs.map((r, i) => (260 <path key={i} className={`series-line${r.forecast ? ' forecast' : ''}`} d={model.pathFor(r.xy) ?? ''} stroke={l.color} />261 ))}262 {endDot && l.last ? (263 <g>264 <circle className="ring" cx={l.last.x} cy={l.last.y} r={MARK.dotR + MARK.ringW / 2} fill={l.color} />265 </g>266 ) : null}267 {endLabels && series.length > 1 && series.length <= 4 && l.last ? (268 <text className="label" x={l.last.x + 8} y={l.last.y} dy="0.32em">269 {l.s.name}270 </text>271 ) : null}272 </g>273 ))274 : null}275276 {series.length === 1 && model.layers[0]?.last ? (277 <text className="label label-strong" x={Math.min(model.layers[0].last.x, model.innerW)} y={model.layers[0].last.y - 10} textAnchor={model.layers[0].last.x > model.innerW * 0.8 ? 'end' : 'middle'} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>278 {formatValue(model.layers[0].last.p.value, spec)}279 </text>280 ) : null}281282 {hoverX != null ? <line x1={hoverX} x2={hoverX} y1={0} y2={model.innerH} stroke={CHART.ruleStrong} strokeWidth={1} /> : null}283 {hoverRows?.map((r) => {284 const l = model.layers.find((ly) => ly.s.name === r.name)!;285 return <circle key={r.name} className="ring" cx={model.x(periodToX(r.p))} cy={model.y(r.p.value!)} r={MARK.dotR} fill={l.color} />;286 })}287288 <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} />289 </g>290 </svg>291 )}292 {hover != null && hoverRows && hoverRows.length > 0 && hoverX != null ? (293 <ChartTooltip x={margin.left + hoverX} y={margin.top} width={width}>294 <div className="mb-0.5 text-2xs text-ink-3">295 {formatPeriod(hoverRows[0]!.p.period, spec.frequency ?? 'A')}296 {hoverRows.some((r) => r.p.is_forecast) ? ` · ${t('chart.tooltipForecast')}` : ''}297 </div>298 {hoverRows.map((r) => (299 <TooltipRow key={r.name} color={series.length > 1 ? r.color : undefined} label={series.length > 1 ? r.name : (spec.name ?? '')} value={formatValue(r.p.value, spec)} />300 ))}301 </ChartTooltip>302 ) : null}303 </div>304 </ChartFrame>305 );306}307308export function AreaChart(props: Omit<LineChartProps, 'variant'>) {309 return <LineChart {...props} variant="area" />;310}311export function StackedArea(props: Omit<LineChartProps, 'variant' | 'log'>) {312 return <LineChart {...props} variant="stacked" />;313}314