spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import Link from 'next/link';3import { useMemo, useState } from 'react';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { formatValue } from '@/lib/format';7import { routes } from '@/lib/site';8import type { FormatSpec } from '@/lib/types';9import type { RaceResponse } from '@/lib/types-analytics';10import { YearSlider } from '@/components/controls/year-slider';11import { regionColor } from './bubble-chart';12import { ChartFrame, type TableData } from './chart-frame';1314const ROW_H = 36;1516/**17 * Bar chart race: the top N countries of a ranking, one horizontal bar per country keyed by id so bars slide18 * to their new rank (CSS transitions on transform + width) as the year slider plays. Colour = region, flags +19 * names + values, faded year behind. Data from `/rankings/{indicator}/race`. Table toggle lists the current frame.20 */21export function RankRace({ data, spec, top = 10, height, interval = 650, className, initialYear }: { data: RaceResponse; spec: FormatSpec; top?: number; height?: number; interval?: number; className?: string; initialYear?: number | null }) {22 const years = data.years;23 const [year, setYear] = useState<number>(initialYear && years.includes(initialYear) ? initialYear : years[years.length - 1] ?? 0);24 const frame = useMemo(() => data.frames.find((f) => f.year === year) ?? data.frames[data.frames.length - 1], [data.frames, year]);25 const rows = useMemo(() => (frame ? [...frame.rows].sort((a, b) => a.rank - b.rank).slice(0, top) : []), [frame, top]);26 const max = rows.length ? Math.max(...rows.map((r) => Math.abs(r.value))) || 1 : 1;27 const n = Math.min(top, data.top);28 const h = height ?? n * ROW_H + 8;29 // Every country that ever appears keeps a DOM node so its bar can slide in/out.30 const ids = useMemo(() => Object.keys(data.countries), [data.countries]);31 const byId = useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]);32 const summary = t('chart.race.summary', { name: spec.name ?? '', y0: years[0] ?? '', y1: years[years.length - 1] ?? '', top: n });33 const table: TableData = useMemo(34 () => ({ columns: [{ key: 'rank', label: t('common.rank'), numeric: true }, { key: 'country', label: t('common.country') }, { key: 'value', label: spec.name ?? t('common.value'), numeric: true }], rows: rows.map((r) => ({ rank: String(r.rank), country: data.countries[r.id]?.name ?? r.id, value: formatValue(r.value, spec) })) }),35 [rows, data.countries, spec],36 );3738 return (39 <ChartFrame summary={summary} table={rows.length ? table : undefined} className={className} minHeight={h + 64} provenance={data.provenance}>40 <div className="relative w-full overflow-hidden" style={{ height: h }} aria-hidden>41 <span className="display pointer-events-none absolute bottom-1 right-2 select-none text-[clamp(3rem,10vw,6rem)] font-semibold leading-none text-rule">{year}</span>42 {ids.map((id) => {43 const r = byId.get(id);44 const c = data.countries[id];45 const visible = !!r;46 const idx = r ? r.rank - 1 : n;47 return (48 <div key={id} className={cn('absolute left-0 right-0 grid grid-cols-[2rem_minmax(0,1fr)_6rem] items-center gap-x-2 sm:grid-cols-[2rem_minmax(0,1fr)_7rem]', !visible && 'pointer-events-none')} style={{ top: 4, transform: `translateY(${idx * ROW_H}px)`, opacity: visible ? 1 : 0, transition: 'transform 600ms cubic-bezier(0.2,0.8,0.2,1), opacity 400ms ease', height: ROW_H - 6 }}>49 <span className="tnum text-right text-xs text-ink-3">{r?.rank ?? ''}</span>50 <div className="relative h-full min-w-0">51 <div className="absolute inset-y-0 left-0 rounded-r-sm" style={{ width: `${r ? Math.max(1, (Math.abs(r.value) / max) * 100) : 0}%`, background: regionColor(c?.region), opacity: 0.85, transition: 'width 600ms cubic-bezier(0.2,0.8,0.2,1)' }} />52 <span className="relative flex h-full items-center gap-1.5 pl-2 text-sm text-ink" style={{ paintOrder: 'stroke' }}>53 <span aria-hidden>{c?.flag}</span>54 <span className="truncate font-medium" style={{ textShadow: '0 0 6px var(--surface), 0 0 2px var(--surface)' }}>55 {c?.name ?? id}56 </span>57 </span>58 </div>59 <span className="tnum text-right text-sm font-medium text-ink">{r ? formatValue(r.value, spec) : ''}</span>60 </div>61 );62 })}63 </div>64 <ul className="sr-only">65 {rows.map((r) => (66 <li key={r.id}>67 {r.rank}. {data.countries[r.id]?.name ?? r.id}: {formatValue(r.value, spec)} ({year})68 </li>69 ))}70 </ul>71 <div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-6">72 <YearSlider years={years} year={year} onChange={setYear} interval={interval} className="min-w-0 flex-1" ticks />73 <Link href={routes.ranking(data.indicator.slug, { year })} className="inline-flex min-h-[36px] shrink-0 items-center text-sm text-accent hover:underline">74 {t('chart.race.openYear', { year })} →75 </Link>76 </div>77 </ChartFrame>78 );79}80