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%
24.7 KB · 412 lines tsx
Raw Blame History
1'use client';2import { ArrowDownWideNarrow, ArrowUpNarrowWide, BarChart3, Map as MapIcon, Search, SlidersHorizontal, Table2, X } from 'lucide-react';3import Link from 'next/link';4import { usePathname, useRouter } from 'next/navigation';5import { useCallback, useEffect, useMemo, useRef, useState } from 'react';6import { t } from '@/i18n';7import { cn } from '@/lib/cn';8import { compact, displayValue, formatValue, grouped, isNum, ordinal } from '@/lib/format';9import { INCOME_GROUPS } from '@/lib/regions';10import { MIN_COV_OPTIONS, MIN_POP_OPTIONS, rankingQuery, type RankingState, type RankingView as ViewKind } from '@/lib/ranking-state';11import { routes } from '@/lib/site';12import type { RankingResponse, RankingRow } from '@/lib/types';13import type { CountryLite, RegionItem } from '@/lib/types-compare';14import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view';15import { MARK } from '@/components/charts/palette';16import { RankedBars, rankedRowFromCountry, staleYear } from '@/components/charts/ranked-bars';17import { Segmented } from '@/components/controls/indicator-select';18import type { BaseFeature } from '@/components/indicators/indicator-map';19import { pointsFromSpark } from '@/components/charts/scales';20import { Sparkline } from '@/components/charts/sparkline';21import { CountryTypeahead } from '@/components/compare/country-picker';22import { BottomSheet } from '@/components/data/bottom-sheet';23import { ChangeChip } from '@/components/data/change-chip';24import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context';2526const PAGE = 25;27const GROUP_KINDS = ['region', 'continent', 'income', 'org'] as const;2829/**30 * Interactive ranking: controls (year · group · sort · highlight · search; a compact sticky bar + bottom31 * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per32 * page with "Show more". Year/group/sort/highlight/q live in the URL (server re-renders the data).33 */34export function RankingView({ data, regions, countries, state, geometry, sphere }: { data: RankingResponse & { label?: string }; regions: RegionItem[]; countries: CountryLite[]; state: RankingState; geometry?: BaseFeature[]; sphere?: string }) {35  const router = useRouter();36  const pathname = usePathname();37  const { open } = useProvenance();38  const [q, setQ] = useState(state.q);39  const [shown, setShown] = useState(PAGE);40  const [sheet, setSheet] = useState(false);41  const [jump, setJump] = useState(false);42  const listRef = useRef<HTMLOListElement>(null);43  const ind = data.indicator;44  const hib = ind.higher_is_better;45  const sort = (data.sort as 'asc' | 'desc') ?? 'desc';4647  const setState = useCallback(48    (patch: Partial<RankingState>) => router.replace(`${pathname}${rankingQuery({ ...state, ...patch })}`, { scroll: false }),49    [router, pathname, state],50  );5152  // Search is client-side; keep the URL in sync (debounced) so a reload restores it.53  useEffect(() => {54    if (q === state.q) return;55    const id = setTimeout(() => setState({ q }), 400);56    return () => clearTimeout(id);57  }, [q, state.q, setState]);5859  // Client-side filters (income group · minimum population · minimum coverage) then re-rank within the result.60  const popById = useMemo(() => new Map(countries.map((c) => [c.id, c.population ?? null])), [countries]);61  const incomeCode = state.income ? INCOME_GROUPS.find((g) => g.slug === state.income || g.id.toLowerCase() === state.income)?.id ?? null : null;62  const rows = useMemo(() => {63    let r = data.rows;64    if (incomeCode) r = r.filter((x) => (x.country.income ?? '').toUpperCase() === incomeCode);65    if (state.minpop) r = r.filter((x) => (popById.get(x.country.id) ?? 0) >= state.minpop!);66    if (state.mincov && data.year_used != null) r = r.filter((x) => (x.year ?? 0) >= data.year_used! - state.mincov!);67    if (r.length !== data.rows.length) r = r.map((x, i) => ({ ...x, rank: i + 1 }));68    return r;69  }, [data.rows, data.year_used, incomeCode, state.minpop, state.mincov, popById]);70  // Freshness honesty: rows ≥ 2 years older than the ranking year show their year next to the value.71  const refYear = useMemo(() => Math.max(data.year_used ?? 0, ...rows.map((r) => r.year ?? 0)), [rows, data.year_used]);72  const max = useMemo(() => Math.max(0, ...rows.map((r) => (isNum(r.value) ? Math.abs(r.value) : 0))), [rows]);73  const ql = q.trim().toLowerCase();74  const filtered = useMemo(() => (ql ? rows.filter((r) => (r.country.name ?? '').toLowerCase().includes(ql) || r.country.id.toLowerCase() === ql || (r.country.region_name ?? '').toLowerCase().includes(ql)) : rows), [rows, ql]);75  const highlightRow = state.highlight ? rows.find((r) => (r.country.slug ?? r.country.id.toLowerCase()) === state.highlight) ?? null : null;76  const highlightCountry = state.highlight ? countries.find((c) => c.slug === state.highlight) ?? null : null;77  const visible = filtered.slice(0, shown);7879  // Scroll to the highlighted row after a user-initiated highlight.80  useEffect(() => {81    if (!jump || !highlightRow) return;82    const idx = filtered.findIndex((r) => r.country.id === highlightRow.country.id);83    if (idx >= shown) setShown(Math.ceil((idx + 1) / PAGE) * PAGE);84    const el = listRef.current?.querySelector<HTMLElement>(`[data-country="${highlightRow.country.id}"]`);85    if (el) {86      el.scrollIntoView({ block: 'center', behavior: 'smooth' });87      setJump(false);88    }89  }, [jump, highlightRow, filtered, shown]);9091  const payloadOf = (r: RankingRow): ProvenancePayload => ({92    indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, higher_is_better: hib },93    value: { value: r.value, formatted: r.formatted, period: r.year ? `${r.year}-01-01` : null, year: r.year, unit: ind.unit, provenance: r.provenance },94    country: { id: r.country.id, slug: r.country.slug, name: r.country.name ?? r.country.id, flag: r.country.flag },95    downloadHref: routes.indicatorDownload(ind.slug),96  });9798  const groupsByKind = GROUP_KINDS.map((k) => ({ kind: k, items: regions.filter((g) => g.kind === k).sort((a, b) => a.name.localeCompare(b.name)) })).filter((g) => g.items.length);99  const activeFilters = (state.group !== 'world' ? 1 : 0) + (state.sort ? 1 : 0) + (state.highlight ? 1 : 0) + (state.income ? 1 : 0) + (state.minpop ? 1 : 0) + (state.mincov ? 1 : 0);100  const years = [...data.years_available].sort((a, b) => b - a);101102  const yearSelect = (103    <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">104      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.year')}</span>105      <select value={data.year_used ?? ''} onChange={(e) => setState({ year: Number(e.target.value) })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.year')}>106        {years.map((y) => (107          <option key={y} value={y}>108            {y}109          </option>110        ))}111      </select>112    </label>113  );114  const groupSelect = (115    <label className="inline-flex h-11 max-w-full items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">116      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.group')}</span>117      <select value={state.group} onChange={(e) => setState({ group: e.target.value })} className="max-w-[12rem] truncate bg-transparent text-ink outline-none" aria-label={t('ranking.group')}>118        <option value="world">{t('ranking.group.world')}</option>119        {groupsByKind.map((g) => (120          <optgroup key={g.kind} label={t(`ranking.group.${g.kind}` as 'ranking.group.region')}>121            {g.items.map((it) => (122              <option key={it.slug} value={it.slug}>123                {it.name}124              </option>125            ))}126          </optgroup>127        ))}128      </select>129    </label>130  );131  const sortToggle = (132    <button type="button" onClick={() => setState({ sort: sort === 'desc' ? 'asc' : 'desc' })} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 hover:text-ink md:h-9" aria-label={t('ranking.sort')}>133      {sort === 'desc' ? <ArrowDownWideNarrow size={15} aria-hidden /> : <ArrowUpNarrowWide size={15} aria-hidden />}134      {sort === 'desc' ? t('ranking.sort.desc') : t('ranking.sort.asc')}135    </button>136  );137  const incomeSelect = (138    <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">139      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('common.income')}</span>140      <select value={state.income ?? ''} onChange={(e) => setState({ income: e.target.value || null })} className="max-w-[10rem] truncate bg-transparent text-ink outline-none" aria-label={t('common.income')}>141        <option value="">{t('common.all')}</option>142        {INCOME_GROUPS.map((g) => (143          <option key={g.id} value={g.slug}>144            {g.name}145          </option>146        ))}147      </select>148    </label>149  );150  const minPopSelect = (151    <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">152      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.minpop')}</span>153      <select value={state.minpop ?? 0} onChange={(e) => setState({ minpop: Number(e.target.value) || null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.minpop')}>154        {MIN_POP_OPTIONS.map((v) => (155          <option key={v} value={v}>156            {v ? `≥ ${compact(v)}` : t('common.all')}157          </option>158        ))}159      </select>160    </label>161  );162  const minCovSelect = (163    <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">164      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.mincov')}</span>165      <select value={state.mincov ?? 0} onChange={(e) => setState({ mincov: Number(e.target.value) || null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.mincov')}>166        {MIN_COV_OPTIONS.map((v) => (167          <option key={v} value={v}>168            {v ? t('ranking.mincov.within', { n: v }) : t('common.all')}169          </option>170        ))}171      </select>172    </label>173  );174  const viewSwitch = (175    <Segmented<ViewKind> value={state.view} onChange={(v) => setState({ view: v })} label={t('control.view')} size="sm" options={[{ value: 'table', label: t('ranking.view.table'), icon: <Table2 size={13} aria-hidden /> }, { value: 'bars', label: t('ranking.view.bars'), icon: <BarChart3 size={13} aria-hidden /> }, { value: 'map', label: t('ranking.view.map'), icon: <MapIcon size={13} aria-hidden /> }]} />176  );177  const highlightControl = highlightCountry ? (178    <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-accent bg-accent-soft pl-2.5 text-sm text-accent md:h-9">179      <span aria-hidden>{highlightCountry.flag}</span>180      <span className="max-w-[9rem] truncate">{highlightCountry.name}</span>181      <button type="button" onClick={() => setState({ highlight: null })} className="grid h-11 w-9 place-items-center md:h-9 md:w-7" aria-label={t('ranking.highlight.clear')}>182        <X size={14} aria-hidden />183      </button>184    </span>185  ) : (186    <CountryTypeahead187      countries={countries}188      exclude={new Set()}189      onPick={(c) => {190        setState({ highlight: c.slug });191        setJump(true);192      }}193      placeholder={t('ranking.highlight.placeholder')}194      className="w-full sm:w-56"195    />196  );197  const searchBox = (198    <div className="relative min-w-0 flex-1">199      <Search size={15} aria-hidden className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-ink-3" />200      <input type="search" value={q} onChange={(e) => { setQ(e.target.value); setShown(PAGE); }} placeholder={t('ranking.search')} aria-label={t('ranking.search')} className="h-11 w-full rounded-sm border border-rule bg-surface pl-9 pr-9 text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-9" />201      {q ? (202        <button type="button" onClick={() => setQ('')} className="absolute right-0 top-0 grid h-11 w-10 place-items-center text-ink-3 hover:text-ink md:h-9" aria-label={t('search.clear')}>203          <X size={14} aria-hidden />204        </button>205      ) : null}206    </div>207  );208209  const rankNote = hib == null ? (sort === 'desc' ? t('ranking.rankNote.higher') : t('ranking.rankNote.lowest')) : t('ranking.rankNote.better', { direction: hib ? t('ranking.direction.higher') : t('ranking.direction.lower') });210211  return (212    <div>213      {/* Sticky compact bar (phones) */}214      <div className="sticky top-[52px] z-20 -mx-4 flex items-center gap-2 border-b border-rule bg-paper/95 px-4 py-2 backdrop-blur supports-[backdrop-filter]:bg-paper/85 md:hidden">215        {yearSelect}216        {searchBox}217        <button type="button" onClick={() => setSheet(true)} className={cn('inline-flex h-11 shrink-0 items-center gap-1 rounded-sm border px-2.5 text-sm', activeFilters ? 'border-accent text-accent' : 'border-rule text-ink-2')} aria-label={t('ranking.filters')}>218          <SlidersHorizontal size={15} aria-hidden />219          {activeFilters ? <span className="tnum">{activeFilters}</span> : null}220        </button>221      </div>222      {/* Desktop controls */}223      <div className="hidden flex-wrap items-center gap-2 py-3 md:flex">224        {yearSelect}225        {groupSelect}226        {incomeSelect}227        {minPopSelect}228        {minCovSelect}229        {sortToggle}230        {highlightControl}231        <div className="ml-auto flex items-center gap-2">232          {viewSwitch}233          <div className="w-56">{searchBox}</div>234        </div>235      </div>236      <div className="flex items-center justify-between gap-2 py-2 md:hidden">{viewSwitch}</div>237238      <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2 text-xs text-ink-3">239        <span>{rankNote}</span>240        <span className="tnum">241          {t('ranking.showing', { shown: grouped(Math.min(shown, filtered.length)), n: grouped(filtered.length) })}242          {data.year != null && data.year_used != null && data.year !== data.year_used ? ` · ${t('ranking.yearUsed', { year: data.year_used, requested: data.year })}` : ''}243        </span>244      </div>245246      {/* Pinned highlighted row */}247      {highlightRow ? (248        <div className="mb-2 border-y border-accent/40 bg-accent-soft/40">249          <Row r={highlightRow} max={max} spec={ind} refYear={refYear} highlight onValue={() => open(payloadOf(highlightRow))} />250          <button type="button" onClick={() => setJump(true)} className="inline-flex min-h-[36px] items-center px-1 text-xs text-accent hover:underline">251            {t('ranking.jumpToHighlight', { name: highlightRow.country.name ?? highlightRow.country.id })} ↓252          </button>253        </div>254      ) : state.highlight && highlightCountry ? (255        <p className="mb-2 border-y border-dashed border-rule-strong py-2 text-xs text-ink-3">{t('ranking.highlight.notInGroup', { name: highlightCountry.name, year: data.year_used ?? '' })}</p>256      ) : null}257258      {state.view === 'bars' ? (259        <div className="py-2">260          <RankedBars rows={filtered.slice(0, 25).map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.change_10y?.formatted ?? null, staleYear(r.year, refYear)))} spec={ind} highlightId={highlightRow?.country.id ?? null} provenance={filtered[0]?.provenance ?? null} />261          {filtered.length > 25 ? <p className="mt-2 text-xs text-ink-3">{t('ranking.view.barsNote', { n: 25, total: filtered.length })}</p> : null}262        </div>263      ) : null}264      {state.view === 'map' ? (265        geometry && sphere ? (266          <RankingMap rows={filtered} geometry={geometry} sphere={sphere} spec={ind} year={data.year_used} highlight={highlightRow?.country.id ?? null} />267        ) : (268          <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p>269        )270      ) : null}271      {/* Header (sm+) */}272      <div className={cn('grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem] gap-x-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3', state.view === 'table' ? 'hidden sm:grid' : 'hidden')}>273        <span className="text-right">{t('ranking.col.rank')}</span>274        <span>{t('ranking.col.country')}</span>275        <span />276        <span className="text-right">{t('ranking.col.value')}</span>277        <span className="text-right">{t('ranking.col.change1y')}</span>278        <span className="text-right">{t('ranking.col.change10y')}</span>279        <span className="text-right">{t('ranking.col.trend')}</span>280      </div>281282      {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{ql ? t('ranking.noMatch', { q }) : t('ranking.empty')}</p> : null}283      <ol ref={listRef} className={cn('divide-y divide-rule', state.view !== 'table' && 'hidden')} role="list">284        {visible.map((r) => (285          <Row key={r.country.id} r={r} max={max} spec={ind} refYear={refYear} highlight={highlightRow?.country.id === r.country.id} onValue={() => open(payloadOf(r))} />286        ))}287      </ol>288      {state.view === 'table' && filtered.length > shown ? (289        <div className="flex justify-center py-4">290          <button type="button" onClick={() => setShown((s) => s + PAGE)} className="inline-flex h-11 items-center rounded-sm border border-rule px-5 text-sm text-ink hover:bg-surface-2 md:h-10">291            {t('ranking.showMore', { n: Math.min(PAGE, filtered.length - shown) })}292          </button>293        </div>294      ) : null}295296      <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('ranking.filters')}>297        <div className="space-y-4">298          <div>299            <div className="eyebrow mb-1.5">{t('ranking.group')}</div>300            {groupSelect}301          </div>302          <div>303            <div className="eyebrow mb-1.5">{t('common.income')}</div>304            {incomeSelect}305          </div>306          <div className="flex flex-wrap gap-2">307            {minPopSelect}308            {minCovSelect}309          </div>310          <div>311            <div className="eyebrow mb-1.5">{t('ranking.sort')}</div>312            {sortToggle}313          </div>314          <div>315            <div className="eyebrow mb-1.5">{t('ranking.highlight')}</div>316            {highlightControl}317          </div>318        </div>319        <div className="mt-6 flex justify-between">320          <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => setState({ group: 'world', income: null, minpop: null, mincov: null, sort: null, highlight: null })}>321            {t('common.reset')}322          </button>323          <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>324            {t('common.apply')}325          </button>326        </div>327      </BottomSheet>328    </div>329  );330}331332function Row({ r, max, spec, refYear, highlight, onValue }: { r: RankingRow; max: number; spec: RankingResponse['indicator']; refYear: number; highlight?: boolean; onValue: () => void }) {333  const stale = staleYear(r.year, refYear);334  const pct = isNum(r.value) && max > 0 ? Math.max(0, (Math.abs(r.value) / max) * 100) : 0;335  const points = pointsFromSpark(r.sparkline);336  const dir = r.change_1y?.abs == null ? null : r.change_1y.abs > 0 ? 'up' : r.change_1y.abs < 0 ? 'down' : 'flat';337  const value = displayValue(r.value, spec, r.formatted);338  const worldRank = isNum(r.rank_world) && isNum(r.n_world) ? t('ranking.worldRank', { rank: ordinal(r.rank_world), n: grouped(r.n_world) }) : null;339  return (340    <li data-country={r.country.id} className={cn('py-2 sm:py-1.5', highlight && 'bg-accent-soft/40')}>341      <div className="grid grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-x-3 sm:grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem]">342        <span className={cn('tnum text-right text-sm', highlight ? 'font-semibold text-accent' : 'text-ink-3')}>{r.rank}</span>343        <Link href={routes.country(r.country.slug ?? r.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-2 sm:min-h-[36px]" title={worldRank ?? undefined}>344          <span aria-hidden className="text-lg leading-none">345            {r.country.flag}346          </span>347          <span className="min-w-0">348            <span className={cn('block truncate text-sm', highlight ? 'font-semibold text-ink' : 'text-ink')}>{r.country.name}</span>349            <span className="block truncate text-2xs text-ink-3 sm:hidden">{worldRank ?? r.country.region_name ?? ''}</span>350          </span>351        </Link>352        <div className="hidden min-w-0 items-center sm:flex" aria-hidden>353          <div className="h-3.5 min-w-0 flex-1" style={{ maxHeight: MARK.barMax }}>354            <div className={cn('h-full', highlight ? 'bg-accent' : 'bg-series-1')} style={{ width: `${pct}%`, minWidth: isNum(r.value) ? 2 : 0, borderRadius: `0 ${MARK.barRadius}px ${MARK.barRadius}px 0`, opacity: highlight ? 1 : 0.85 }} />355          </div>356        </div>357        <button type="button" onClick={onValue} className="tnum -mx-1 inline-flex min-h-[44px] flex-col items-end justify-center rounded-sm px-1 text-right text-sm font-medium leading-tight text-ink hover:bg-surface-2 sm:min-h-[36px]" aria-label={`${r.country.name}: ${value} (${r.year ?? ''}). ${t('common.openProvenance')}`}>358          <span>{value}</span>359          {stale ? <span className="tnum rounded-xs bg-surface-2 px-1 text-2xs font-normal text-ink-2">{stale}</span> : null}360        </button>361        <span className="hidden justify-end sm:flex">362          <ChangeChip change={r.change_1y} spec={spec} showVs={false} />363        </span>364        <span className="hidden justify-end sm:flex">365          <ChangeChip change={r.change_10y} spec={spec} showVs={false} />366        </span>367        <span className="hidden justify-end sm:flex">{points.length >= 2 ? <Sparkline points={points} width={64} height={20} direction={dir} className="opacity-90" /> : <span className="inline-block h-5 w-16" />}</span>368      </div>369      {/* Phone: bar + changes + sparkline on a second line */}370      <div className="mt-1 grid grid-cols-[2.25rem_minmax(0,1fr)_auto] items-center gap-x-3 sm:hidden">371        <span />372        <div className="h-2 min-w-0 overflow-hidden rounded-xs bg-surface-2" aria-hidden>373          <div className={cn('h-full rounded-r-xs', highlight ? 'bg-accent' : 'bg-series-1')} style={{ width: `${pct}%`, opacity: highlight ? 1 : 0.85 }} />374        </div>375        <span className="flex items-center gap-2 text-2xs">376          <ChangeChip change={r.change_1y} spec={spec} showVs={false} />377          {points.length >= 2 ? <Sparkline points={points} width={44} height={16} direction={dir} className="opacity-90" /> : null}378        </span>379      </div>380    </li>381  );382}383384385/** Map view of the (filtered) ranking rows: quantile classes computed client-side, highlighted country outlined. */386function RankingMap({ rows, geometry, sphere, spec, year, highlight }: { rows: RankingRow[]; geometry: BaseFeature[]; sphere: string; spec: RankingResponse['indicator']; year: number | null; highlight: string | null }) {387  const model = useMemo(() => {388    const values = new Map(rows.filter((r) => isNum(r.value)).map((r) => [r.country.id, r.value as number]));389    const sorted = Array.from(values.values()).sort((a, b) => a - b);390    const k = sorted.length >= 40 ? 6 : Math.max(3, Math.min(5, sorted.length));391    const breaks: number[] = [];392    for (let i = 1; i < k; i++) {393      const pos = (i / k) * (sorted.length - 1);394      const lo = Math.floor(pos);395      const hi = Math.min(lo + 1, sorted.length - 1);396      const v = sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (pos - lo);397      if (!breaks.length || v > breaks[breaks.length - 1]!) breaks.push(v);398    }399    const features: ChoroplethFeature[] = geometry.map((g) => {400      const v = g.iso3 ? values.get(g.iso3) : undefined;401      return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null };402    });403    const fs = { format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, name: spec.short_name ?? spec.name };404    return { features, legend: legendFromBreaks(breaks, sorted[0] ?? null, sorted[sorted.length - 1] ?? null, fs), k: breaks.length + 1, fs, n: values.size, min: sorted[0], max: sorted[sorted.length - 1] };405  }, [rows, geometry, spec]);406  return (407    <div className="py-2">408      <ChoroplethView features={model.features} sphere={sphere} legend={model.legend} k={model.k} spec={model.fs} summary={t('chart.summary.map', { name: model.fs.name ?? '', year: year ?? '', n: model.n, min: formatValue(model.min ?? null, model.fs), max: formatValue(model.max ?? null, model.fs) })} title={t('chart.map.legend', { name: model.fs.name ?? '', year: year ?? '' })} compact selectedId={highlight} />409    </div>410  );411}412