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%
5.0 KB · 80 lines tsx
Raw Blame History
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