'use client'; import { area as d3Area, line as d3Line, stack as d3Stack } from 'd3-shape'; import { useCallback, useMemo, useState, type PointerEvent } from 'react'; import { t } from '@/i18n'; import { formatPeriod, formatTick, formatValue } from '@/lib/format'; import type { FormatSpec as Spec, Provenance } from '@/lib/types'; import type { ProvenancePayload } from '@/components/data/provenance-context'; import { ChartFrame, Legend, type TableData } from './chart-frame'; import { CHART, MARK, seriesVar } from './palette'; import { DEFAULT_MARGIN, extent, lineDomain, periodToX, splitForecast, xYearScale, yScale, yearTicks, type Margin, type SeriesPoint } from './scales'; import { summarizeMulti, summarizeSeries } from './summary'; import { ChartTooltip, TooltipRow } from './tooltip'; import { useMeasure } from './use-measure'; export interface LineSeries { id: string; name: string; points: SeriesPoint[]; /** Fixed colour slot (0-based). Defaults to the array index — colour follows the entity, never its rank. */ colorIndex?: number; color?: string; } export interface LineChartProps { series: LineSeries[]; spec: Spec; /** Subject for the accessible summary ("Canada's GDP per capita"). */ subject?: string; variant?: 'line' | 'area' | 'stacked'; log?: boolean; height?: number; margin?: Partial; title?: React.ReactNode; subtitle?: React.ReactNode; provenance?: Provenance | null; payload?: ProvenancePayload | null; actions?: React.ReactNode; /** Direct end-labels for ≤ 4 series. */ endLabels?: boolean; /** Draw a highlighted marker on the latest actual point. */ endDot?: boolean; className?: string; defaultWidth?: number; } interface XY { x: number; y: number; p: SeriesPoint; } /** * Multi-series line / area / stacked-area chart. Dashed segments for `is_forecast`, optional log scale, * crosshair + one tooltip listing every series at the nearest X (pointer events → touch friendly). * Index=100 transformations are data-side (pass already-indexed points). */ export function LineChart({ series, spec, subject, variant = 'line', log = false, height = 240, margin: marginIn, title, subtitle, provenance, payload, actions, endLabels = true, endDot = true, className, defaultWidth = 640, }: LineChartProps) { const { ref, width } = useMeasure(defaultWidth); const margin: Margin = { ...DEFAULT_MARGIN, ...marginIn }; if (endLabels && series.length > 1 && series.length <= 4) margin.right = Math.max(margin.right, 72); // Left margin fits the longest y tick label (≈ 6.4 px per character at 11 px) so labels are never clipped. // Uses the same domain rule as the chart itself (lineDomain) so the probe ticks are the real ticks. if (marginIn?.left == null) { const probe = extent(series.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1]; const dom = variant === 'stacked' ? ([0, probe[1]] as [number, number]) : log && probe[0] > 0 ? probe : lineDomain(probe, spec); const longest = Math.max(...yScale(dom, [0, 1], { log, includeZero: variant === 'stacked' }).ticks(4).map((tk) => formatTick(tk, spec).length), 3); margin.left = Math.min(84, Math.max(36, Math.round(longest * 6.4) + 12)); } const [hover, setHover] = useState(null); // x-value (fractional year) const model = useMemo(() => { const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => p.value != null && Number.isFinite(p.value)) })); const allX = clean.flatMap((s) => s.points.map(periodToX)); const xDom = extent(allX) ?? [2000, 2024]; const innerW = Math.max(10, width - margin.left - margin.right); const innerH = Math.max(10, height - margin.top - margin.bottom); const x = xYearScale(xDom, [0, innerW]); let stacks: Array> | null = null; // per series: [x, y0, y1] let yDom: [number, number]; if (variant === 'stacked') { // Build a wide table keyed by x. const keys = clean.map((s) => s.id); const xs = Array.from(new Set(allX)).sort((a, b) => a - b); const table = xs.map((xv) => { const row: Record = { __x: xv }; for (const s of clean) { const p = s.points.find((q) => periodToX(q) === xv); row[s.id] = p?.value ?? 0; } return row; }); const st = d3Stack>().keys(keys)(table); stacks = st.map((layer) => layer.map((d) => [d.data.__x!, d[0], d[1]] as [number, number, number])); const top = Math.max(0, ...stacks.flat().map((d) => d[2])); yDom = [0, top]; } else { // Line / area: padded nice extent; zero only when the rule says so (see lineDomain). Log keeps the raw extent. const raw = extent(clean.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1]; yDom = log && raw[0] > 0 ? raw : lineDomain(raw, spec); } const y = yScale(yDom, [innerH, 0], { log, includeZero: variant === 'stacked' }); const xTicks = yearTicks(xDom, Math.max(2, Math.floor(innerW / 90))); const yTicks = y.ticks(4); const pathFor = d3Line() .x((d) => d.x) .y((d) => d.y); const areaFor = d3Area() .x((d) => d.x) .y0(() => y(Math.max(0, y.domain()[0]!))) .y1((d) => d.y); const layers = clean.map((s, i) => { const color = s.color ?? seriesVar(s.colorIndex ?? i); const runs = splitForecast(s.points).map((r) => ({ forecast: r.forecast, xy: r.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })), })); const all = s.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })); const actual = s.points.filter((p) => !p.is_forecast); const last = actual[actual.length - 1] ?? null; return { s, color, runs, all, last: last ? { x: x(periodToX(last)), y: y(last.value!), p: last } : null }; }); const stackPaths = stacks && stacks.map((layer, i) => { const a = d3Area<[number, number, number]>() .x((d) => x(d[0])) .y0((d) => y(d[1])) .y1((d) => y(d[2])); return { d: a(layer) ?? '', color: clean[i]!.color ?? seriesVar(clean[i]!.colorIndex ?? i) }; }); return { clean, x, y, innerW, innerH, xTicks, yTicks, pathFor, areaFor, layers, stackPaths, xDom }; // eslint-disable-next-line react-hooks/exhaustive-deps -- only spec.format drives the domain rule }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom, variant, log, spec.format]); const onMove = useCallback( (e: PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const px = e.clientX - rect.left; const xv = model.x.invert(px); // snap to nearest existing x let best: number | null = null; let bestD = Infinity; for (const s of model.clean) for (const p of s.points) { const d = Math.abs(periodToX(p) - xv); if (d < bestD) { bestD = d; best = periodToX(p); } } setHover(best); }, [model], ); const hoverRows = useMemo(() => { if (hover == null) return null; return model.layers .map((l) => { const p = l.s.points.find((q) => periodToX(q) === hover); return p ? { name: l.s.name, color: l.color, p } : null; }) .filter((r): r is { name: string; color: string; p: SeriesPoint } => !!r); }, [hover, model]); const summary = series.length === 1 ? summarizeSeries(subject ?? series[0]!.name, series[0]!.points, spec) : summarizeMulti(series.map((s) => s.name), series.map((s) => s.points)); const table: TableData = useMemo(() => { const xs = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.period)))).sort(); return { columns: [{ key: 'period', label: t('common.period') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))], rows: xs.map((period) => { const row: Record = { period: formatPeriod(period, spec.frequency ?? 'A') }; for (const s of model.clean) { const p = s.points.find((q) => q.period === period); row[s.id] = p ? `${formatValue(p.value, spec)}${p.is_forecast ? ' *' : ''}` : t('common.na'); } return row; }), }; }, [model, spec]); const hasForecast = series.some((s) => s.points.some((p) => p.is_forecast)); const hoverX = hover != null ? model.x(hover) : null; const legendItems = model.layers.map((l) => ({ label: l.s.name, color: l.color, shape: variant === 'line' ? ('line' as const) : ('rect' as const) })); const empty = model.clean.every((s) => s.points.length === 0); return ( } note={hasForecast ? t('chart.forecastNote') : undefined} className={className} minHeight={height} >
{empty ? (
{t('chart.noData')}
) : ( {typeof title === 'string' ? title : (spec.name ?? subject ?? '')} {summary} {model.yTicks.map((tk) => ( ))} {model.xTicks.map((yr) => ( {yr} ))} {model.yTicks.map((tk) => ( {formatTick(tk, spec)} ))} {variant === 'stacked' && model.stackPaths ? model.stackPaths.map((sp, i) => ) : null} {variant !== 'stacked' ? model.layers.map((l) => ( {variant === 'area' ? : null} {l.runs.map((r, i) => ( ))} {endDot && l.last ? ( ) : null} {endLabels && series.length > 1 && series.length <= 4 && l.last ? ( {l.s.name} ) : null} )) : null} {series.length === 1 && model.layers[0]?.last ? ( model.innerW * 0.8 ? 'end' : 'middle'} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}> {formatValue(model.layers[0].last.p.value, spec)} ) : null} {hoverX != null ? : null} {hoverRows?.map((r) => { const l = model.layers.find((ly) => ly.s.name === r.name)!; return ; })} setHover(null)} /> )} {hover != null && hoverRows && hoverRows.length > 0 && hoverX != null ? (
{formatPeriod(hoverRows[0]!.p.period, spec.frequency ?? 'A')} {hoverRows.some((r) => r.p.is_forecast) ? ` · ${t('chart.tooltipForecast')}` : ''}
{hoverRows.map((r) => ( 1 ? r.color : undefined} label={series.length > 1 ? r.name : (spec.name ?? '')} value={formatValue(r.p.value, spec)} /> ))}
) : null}
); } export function AreaChart(props: Omit) { return ; } export function StackedArea(props: Omit) { return ; }