SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
7.7 KB · 152 lines tsx
Raw Blame History
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