'use client'; import { line as d3Line } from 'd3-shape'; import { scaleLinear } from 'd3-scale'; import { useCallback, useMemo, useState, type PointerEvent } from 'react'; import { t } from '@/i18n'; import { ordinal } from '@/lib/format'; import { ChartFrame, type TableData } from '@/components/charts/chart-frame'; import { CHART, MARK, seriesVar } from '@/components/charts/palette'; import { yearTicks } from '@/components/charts/scales'; import { ChartTooltip, TooltipRow } from '@/components/charts/tooltip'; import { useMeasure } from '@/components/charts/use-measure'; export interface RankSeries { id: string; name: string; flag?: string | null; colorIndex: number; points: Array<{ year: number; rank: number; n: number | null }>; } /** * Rank-by-year line chart with an inverted y axis (rank 1 at the top). One colour per country by position, * end dots + direct labels (≤ 5 series), crosshair tooltip listing every country's rank at the hovered year, * accessible summary and data table via ChartFrame. */ export function RankHistoryChart({ series, height = 260, defaultWidth = 640 }: { series: RankSeries[]; height?: number; defaultWidth?: number }) { const { ref, width } = useMeasure(defaultWidth); const [hover, setHover] = useState(null); const margin = { top: 22, right: series.length <= 5 ? 96 : 16, bottom: 24, left: 40 }; const model = useMemo(() => { const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => Number.isFinite(p.rank) && p.rank >= 1) })); const years = clean.flatMap((s) => s.points.map((p) => p.year)); const xDom: [number, number] = years.length ? [Math.min(...years), Math.max(...years)] : [2000, 2024]; if (xDom[0] === xDom[1]) xDom[1] = xDom[0] + 1; const maxRank = Math.max(5, ...clean.flatMap((s) => s.points.map((p) => p.rank))); const innerW = Math.max(10, width - margin.left - margin.right); const innerH = Math.max(10, height - margin.top - margin.bottom); const x = scaleLinear().domain(xDom).range([0, innerW]); const y = scaleLinear().domain([1, maxRank * 1.05]).range([0, innerH]); // inverted: rank 1 at the top const yTicks = Array.from(new Set([1, ...y.ticks(4).filter((tk) => tk >= 1 && Number.isInteger(tk))])); const xTicks = yearTicks(xDom, Math.max(3, Math.floor(innerW / 60))); const gen = d3Line<{ year: number; rank: number }>() .x((p) => x(p.year)) .y((p) => y(p.rank)); const layers = clean.map((s) => { const last = s.points[s.points.length - 1] ?? null; return { s, color: seriesVar(s.colorIndex), d: gen(s.points) ?? '', last: last ? { x: x(last.year), y: y(last.rank), p: last } : null }; }); // Nudge overlapping end labels apart (12 px steps). const labels = layers .filter((l) => l.last) .sort((a, b) => a.last!.y - b.last!.y) .map((l) => ({ id: l.s.id, y: l.last!.y })); for (let i = 1; i < labels.length; i++) if (labels[i]!.y - labels[i - 1]!.y < 12) labels[i]!.y = labels[i - 1]!.y + 12; const labelY = new Map(labels.map((l) => [l.id, l.y])); return { clean, x, y, innerW, innerH, xTicks, yTicks, layers, labelY, xDom }; }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom]); const onMove = useCallback( (e: PointerEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const xv = model.x.invert(e.clientX - rect.left); setHover(Math.round(xv)); }, [model], ); const hoverRows = hover == null ? null : model.layers.map((l) => ({ l, p: l.s.points.find((p) => p.year === hover) ?? null })).filter((r) => r.p); const names = series.map((s) => s.name).join(', '); const summary = t('ranking.history.summary', { names, y0: model.xDom[0], y1: model.xDom[1] }); const table: TableData = useMemo(() => { const years = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.year)))).sort((a, b) => a - b); return { columns: [{ key: 'year', label: t('common.year') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))], rows: years.map((yr) => { const row: Record = { year: String(yr) }; for (const s of model.clean) { const p = s.points.find((q) => q.year === yr); row[s.id] = p ? `${p.rank}${p.n ? ` / ${p.n}` : ''}` : t('common.na'); } return row; }), }; }, [model]); const empty = model.clean.every((s) => s.points.length === 0); return (
{empty ? (
{t('chart.noData')}
) : ( {t('ranking.history.title')} {summary} {model.yTicks.map((tk) => ( ))} {model.xTicks.map((yr) => ( {yr} ))} {model.yTicks.map((tk) => ( {tk} ))} {t('ranking.history.axis')} {model.layers.map((l) => ( {l.last ? ( <> {series.length <= 5 ? ( {l.s.name} · {l.last.p.rank} ) : null} ) : null} ))} {hover != null ? : null} {hoverRows?.map((r) => )} setHover(null)} /> )} {hover != null && hoverRows && hoverRows.length ? (
{hover}
{hoverRows.map((r) => ( ))}
) : null}
); }