spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { line as d3Line } from 'd3-shape';3import { scaleLinear } from 'd3-scale';4import { useCallback, useMemo, useState, type PointerEvent } from 'react';5import { t } from '@/i18n';6import { ordinal } from '@/lib/format';7import { ChartFrame, type TableData } from '@/components/charts/chart-frame';8import { CHART, MARK, seriesVar } from '@/components/charts/palette';9import { yearTicks } from '@/components/charts/scales';10import { ChartTooltip, TooltipRow } from '@/components/charts/tooltip';11import { useMeasure } from '@/components/charts/use-measure';1213export interface RankSeries {14 id: string;15 name: string;16 flag?: string | null;17 colorIndex: number;18 points: Array<{ year: number; rank: number; n: number | null }>;19}2021/**22 * Rank-by-year line chart with an inverted y axis (rank 1 at the top). One colour per country by position,23 * end dots + direct labels (≤ 5 series), crosshair tooltip listing every country's rank at the hovered year,24 * accessible summary and data table via ChartFrame.25 */26export function RankHistoryChart({ series, height = 260, defaultWidth = 640 }: { series: RankSeries[]; height?: number; defaultWidth?: number }) {27 const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);28 const [hover, setHover] = useState<number | null>(null);29 const margin = { top: 22, right: series.length <= 5 ? 96 : 16, bottom: 24, left: 40 };3031 const model = useMemo(() => {32 const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => Number.isFinite(p.rank) && p.rank >= 1) }));33 const years = clean.flatMap((s) => s.points.map((p) => p.year));34 const xDom: [number, number] = years.length ? [Math.min(...years), Math.max(...years)] : [2000, 2024];35 if (xDom[0] === xDom[1]) xDom[1] = xDom[0] + 1;36 const maxRank = Math.max(5, ...clean.flatMap((s) => s.points.map((p) => p.rank)));37 const innerW = Math.max(10, width - margin.left - margin.right);38 const innerH = Math.max(10, height - margin.top - margin.bottom);39 const x = scaleLinear().domain(xDom).range([0, innerW]);40 const y = scaleLinear().domain([1, maxRank * 1.05]).range([0, innerH]); // inverted: rank 1 at the top41 const yTicks = Array.from(new Set([1, ...y.ticks(4).filter((tk) => tk >= 1 && Number.isInteger(tk))]));42 const xTicks = yearTicks(xDom, Math.max(3, Math.floor(innerW / 60)));43 const gen = d3Line<{ year: number; rank: number }>()44 .x((p) => x(p.year))45 .y((p) => y(p.rank));46 const layers = clean.map((s) => {47 const last = s.points[s.points.length - 1] ?? null;48 return { s, color: seriesVar(s.colorIndex), d: gen(s.points) ?? '', last: last ? { x: x(last.year), y: y(last.rank), p: last } : null };49 });50 // Nudge overlapping end labels apart (12 px steps).51 const labels = layers52 .filter((l) => l.last)53 .sort((a, b) => a.last!.y - b.last!.y)54 .map((l) => ({ id: l.s.id, y: l.last!.y }));55 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;56 const labelY = new Map(labels.map((l) => [l.id, l.y]));57 return { clean, x, y, innerW, innerH, xTicks, yTicks, layers, labelY, xDom };58 }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom]);5960 const onMove = useCallback(61 (e: PointerEvent<SVGRectElement>) => {62 const rect = e.currentTarget.getBoundingClientRect();63 const xv = model.x.invert(e.clientX - rect.left);64 setHover(Math.round(xv));65 },66 [model],67 );6869 const hoverRows = hover == null ? null : model.layers.map((l) => ({ l, p: l.s.points.find((p) => p.year === hover) ?? null })).filter((r) => r.p);70 const names = series.map((s) => s.name).join(', ');71 const summary = t('ranking.history.summary', { names, y0: model.xDom[0], y1: model.xDom[1] });72 const table: TableData = useMemo(() => {73 const years = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.year)))).sort((a, b) => a - b);74 return {75 columns: [{ key: 'year', label: t('common.year') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))],76 rows: years.map((yr) => {77 const row: Record<string, string> = { year: String(yr) };78 for (const s of model.clean) {79 const p = s.points.find((q) => q.year === yr);80 row[s.id] = p ? `${p.rank}${p.n ? ` / ${p.n}` : ''}` : t('common.na');81 }82 return row;83 }),84 };85 }, [model]);86 const empty = model.clean.every((s) => s.points.length === 0);8788 return (89 <ChartFrame summary={summary} table={empty ? undefined : table} minHeight={height}>90 <div ref={ref} className="relative w-full" style={{ height }}>91 {empty ? (92 <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>93 ) : (94 <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>95 <title>{t('ranking.history.title')}</title>96 <desc>{summary}</desc>97 <g transform={`translate(${margin.left},${margin.top})`}>98 <g className="grid">99 {model.yTicks.map((tk) => (100 <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />101 ))}102 </g>103 <g className="axis">104 <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />105 {model.xTicks.map((yr) => (106 <text key={yr} x={model.x(yr)} y={model.innerH + 16} textAnchor="middle">107 {yr}108 </text>109 ))}110 {model.yTicks.map((tk) => (111 <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">112 {tk}113 </text>114 ))}115 <text x={-6} y={-12} textAnchor="end" className="label">116 {t('ranking.history.axis')}117 </text>118 </g>119 {model.layers.map((l) => (120 <g key={l.s.id}>121 <path className="series-line" d={l.d} stroke={l.color} />122 {l.last ? (123 <>124 <circle className="ring" cx={l.last.x} cy={l.last.y} r={MARK.dotR + MARK.ringW / 2} fill={l.color} />125 {series.length <= 5 ? (126 <text className="label" x={l.last.x + 8} y={model.labelY.get(l.s.id) ?? l.last.y} dy="0.32em">127 {l.s.name} · {l.last.p.rank}128 </text>129 ) : null}130 </>131 ) : null}132 </g>133 ))}134 {hover != null ? <line x1={model.x(hover)} x2={model.x(hover)} y1={0} y2={model.innerH} stroke={CHART.ruleStrong} strokeWidth={1} /> : null}135 {hoverRows?.map((r) => <circle key={r.l.s.id} className="ring" cx={model.x(r.p!.year)} cy={model.y(r.p!.rank)} r={MARK.dotR} fill={r.l.color} />)}136 <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} />137 </g>138 </svg>139 )}140 {hover != null && hoverRows && hoverRows.length ? (141 <ChartTooltip x={margin.left + model.x(hover)} y={margin.top} width={width}>142 <div className="mb-0.5 text-2xs text-ink-3">{hover}</div>143 {hoverRows.map((r) => (144 <TooltipRow key={r.l.s.id} color={r.l.color} label={r.l.s.name} value={`${ordinal(r.p!.rank)}${r.p!.n ? ` ${t('ranking.history.of', { n: r.p!.n })}` : ''}`} />145 ))}146 </ChartTooltip>147 ) : null}148 </div>149 </ChartFrame>150 );151}152