spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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