spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { BarChart3, ChevronDown, ChevronUp, Globe2, Info, LineChart as LineIcon, Sigma, Table2 } from 'lucide-react';3import { useRouter } from 'next/navigation';4import { useCallback, useEffect, useMemo, useRef, useState } from 'react';5import { t } from '@/i18n';6import { clientAnalytics } from '@/lib/client-api-analytics';7import { cn } from '@/lib/cn';8import { formatValue, grouped } from '@/lib/format';9import { routes } from '@/lib/site';10import { useUrlState } from '@/lib/url-state';11import type { FormatSpec } from '@/lib/types';12import type { FramesResponse, PointCountry } from '@/lib/types-analytics';13import type { RegionItem } from '@/lib/types-explore';14import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';15import { YearSlider } from '@/components/controls/year-slider';16import { BottomSheet } from '@/components/data/bottom-sheet';17import { EntityPicker } from '@/components/explore/entity-picker';18import type { BaseFeature } from '@/components/indicators/indicator-map';19import { useProvenance } from '@/components/data/provenance-context';20import { classIndex } from './geo';21import { DEFAULT_EXPLORER_INDICATOR, type ExplorerView } from './options';22import { useIsDesktop } from './use-media';23import { MapCanvas, type MapHover } from './map-canvas';24import { ClassLegend, CountryPanel, MapTooltip, type CountryYearInfo } from './explorer-panels';25import { DistributionView, RankView, TrendView, type YearRow } from './explorer-views';2627const QUICK = ['population', 'gdp', 'gdp-per-capita-ppp', 'gdp-growth', 'inflation', 'life-expectancy', 'fertility-rate', 'internet-users', 'co2-per-capita', 'renewable-electricity-share', 'unemployment-rate', 'population-growth'];2829export interface ExplorerProps {30 features: BaseFeature[];31 sphere: string;32 countries: PointCountry[];33 indicators: IndicatorOption[];34 groups: RegionItem[];35 initial: FramesResponse | null;36 initialState: { indicator: string; year: number | null; view: ExplorerView; country: string | null; group: string };37}3839function specOf(fr: FramesResponse | null, fallbackName: string): FormatSpec {40 const i = fr?.indicator;41 return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallbackName, higher_is_better: i?.higher_is_better };42}4344/**45 * World Explorer: indicator × year × view, all in the URL. One /frames request per indicator (cached in state)46 * feeds the map, the rank list, the trend and the distribution — no further requests while scrubbing.47 */48export function WorldExplorer({ features, sphere, countries, indicators, groups, initial, initialState }: ExplorerProps) {49 const router = useRouter();50 const { get, getNum, set } = useUrlState();51 const { open: openProv } = useProvenance();52 const indicator = get('indicator') ?? initialState.indicator;53 const group = get('group') ?? initialState.group;54 const view = ((get('view') as ExplorerView | null) ?? initialState.view) as ExplorerView;55 const selectedId = (get('country') ?? initialState.country)?.toUpperCase() ?? null;56 const cacheKey = `${indicator}|${group}`;57 const [cache, setCache] = useState<Record<string, FramesResponse | null>>(() => (initial ? { [`${initialState.indicator}|${initialState.group}`]: initial } : {}));58 const [loading, setLoading] = useState(false);59 const [hover, setHover] = useState<MapHover | null>(null);60 const [focus, setFocus] = useState<string | null>(null);61 const [sheet, setSheet] = useState<'country' | 'table' | null>(null);62 const [legendOpen, setLegendOpen] = useState(false);63 const [playing, setPlaying] = useState(false);64 const abort = useRef<AbortController | null>(null);65 const desktop = useIsDesktop();6667 useEffect(() => {68 if (cache[cacheKey] !== undefined) return;69 abort.current?.abort();70 const ctrl = new AbortController();71 abort.current = ctrl;72 setLoading(true);73 clientAnalytics74 .indicatorFrames(indicator, { group: group !== 'world' ? group : null }, ctrl.signal)75 .then((r) => setCache((c) => ({ ...c, [cacheKey]: r })))76 .catch((e) => {77 if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [cacheKey]: null }));78 })79 .finally(() => {80 if (!ctrl.signal.aborted) setLoading(false);81 });82 return () => ctrl.abort();83 }, [cacheKey, indicator, group, cache]);8485 const frames = cache[cacheKey] ?? null;86 const years = frames?.years ?? [];87 const urlYear = getNum('year') ?? initialState.year;88 const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear());89 const yi = years.indexOf(year);90 const spec = useMemo(() => specOf(frames, indicators.find((i) => i.slug === indicator)?.name ?? indicator), [frames, indicators, indicator]);91 const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]);92 const breaks = frames?.legend.breaks ?? [];93 const k = breaks.length + 1;9495 /** Values of the current year, ranks (world + region) and the class per country. */96 const yearData = useMemo(() => {97 const rows: YearRow[] = [];98 const cls = new Map<string, number | null>();99 if (!frames || yi < 0) return { rows, cls, rankOf: new Map<string, { world: number; region: number | null; nRegion: number }>() };100 for (const [iso, arr] of Object.entries(frames.values)) {101 const v = arr[yi];102 const c = byId.get(iso);103 if (v != null && Number.isFinite(v) && c) rows.push({ country: c, value: v, rank: 0 });104 cls.set(iso, v != null && Number.isFinite(v) ? classIndex(v, breaks) : null);105 }106 const desc = spec.higher_is_better !== false;107 rows.sort((a, b) => (desc ? b.value - a.value : a.value - b.value));108 const rankOf = new Map<string, { world: number; region: number | null; nRegion: number }>();109 const regionCount = new Map<string, number>();110 rows.forEach((r, i) => {111 r.rank = i + 1;112 const reg = r.country.region ?? '';113 const rr = (regionCount.get(reg) ?? 0) + 1;114 regionCount.set(reg, rr);115 rankOf.set(r.country.id, { world: i + 1, region: reg ? rr : null, nRegion: 0 });116 });117 for (const [iso, info] of rankOf) info.nRegion = regionCount.get(byId.get(iso)?.region ?? '') ?? 0;118 return { rows, cls, rankOf };119 }, [frames, yi, byId, breaks, spec.higher_is_better]);120121 const infoFor = useCallback(122 (iso: string): CountryYearInfo | null => {123 const c = byId.get(iso);124 if (!c) return null;125 const arr = frames?.values[iso] ?? [];126 const series = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: arr[i] ?? null })).filter((p) => p.value != null);127 const rk = yearData.rankOf.get(iso);128 const last = series[series.length - 1];129 return {130 country: c,131 value: yi >= 0 ? arr[yi] ?? null : null,132 year,133 rankWorld: rk?.world ?? null,134 nWorld: yearData.rows.length,135 rankRegion: rk?.region ?? null,136 nRegion: rk?.nRegion ?? 0,137 series,138 firstYear: series[0]?.year ?? null,139 lastYear: last?.year ?? null,140 latestValue: last?.value ?? null,141 latestYear: last?.year ?? null,142 };143 },144 [byId, frames, years, yearData, yi, year],145 );146147 const selected = selectedId ? infoFor(selectedId) : null;148 const hoverInfo = hover ? infoFor(hover.iso3) : null;149 const indicatorName = frames?.indicator.name ?? spec.name ?? indicator;150151 const setIndicator = (slug: string) => set({ indicator: slug === DEFAULT_EXPLORER_INDICATOR ? null : slug }, 0);152 const setYear = (y: number) => set({ year: years.length && y === years[years.length - 1] ? null : y }, playing ? 250 : 80);153 const setView = (v: ExplorerView) => set({ view: v === 'map' ? null : v }, 0);154 const selectCountry = (iso: string | null) => {155 set({ country: iso ? iso.toLowerCase() : null }, 0);156 if (iso) setSheet('country');157 else setSheet(null);158 };159 const openCountry = (iso: string) => {160 const c = byId.get(iso);161 if (c?.slug) router.push(routes.country(c.slug));162 };163 const flyTo = (iso: string) => {164 setFocus(null);165 requestAnimationFrame(() => setFocus(iso));166 selectCountry(iso);167 };168169 const viewOptions = [170 { value: 'map' as const, label: t('explorer.view.map'), icon: <Globe2 size={14} aria-hidden /> },171 { value: 'rank' as const, label: t('explorer.view.rank'), icon: <BarChart3 size={14} aria-hidden /> },172 { value: 'trend' as const, label: t('explorer.view.trend'), icon: <LineIcon size={14} aria-hidden /> },173 { value: 'distribution' as const, label: t('explorer.view.distribution'), icon: <Sigma size={14} aria-hidden /> },174 ];175 const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world'), kind: 'world' }, ...groups.filter((g) => g.kind === 'region' || g.kind === 'income' || g.kind === 'continent' || g.kind === 'org').map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id, kind: g.kind ?? 'org' }))], [groups]);176 const nYear = yearData.rows.length;177 const compareHref = selected ? routes.compare(selected.country.slug ?? selected.country.id.toLowerCase()) : routes.compare();178 const trajHref = selected ? routes.trajectories({ year, group: group !== 'world' ? group : null }) + `&select=${selected.country.id}` : routes.trajectories();179180 const provPayload = frames181 ? {182 indicator: { slug: indicator, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' as const, higher_is_better: spec.higher_is_better },183 value: null,184 country: null,185 downloadHref: routes.indicatorDownload(indicator),186 }187 : null;188189 const countryPanel = selected ? <CountryPanel info={selected} spec={spec} indicatorSlug={indicator} indicatorName={indicatorName} provenance={frames?.provenance ?? null} onClose={() => selectCountry(null)} onCompareHref={compareHref} trajectoriesHref={trajHref} closeClassName="hidden md:grid" /> : null;190191 const legend = frames ? <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} /> : null;192193 return (194 <div className="flex h-[calc(100dvh-52px)] min-h-[520px] flex-col bg-paper md:h-[calc(100dvh-56px)] md:flex-row" data-testid="world-explorer">195 {/* Left rail (desktop) */}196 <aside className="hidden w-[19rem] shrink-0 flex-col border-r border-rule md:flex" aria-label={t('explorer.rail')}>197 <div className="space-y-3 p-4">198 <div>199 <h1 className="display text-xl text-ink">{t('explorer.title')}</h1>200 <p className="mt-0.5 text-xs text-ink-3">{t('explorer.lede')}</p>201 </div>202 <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} label={t('control.indicator')} size="sm" />203 <label className="flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">204 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('explorer.group')}</span>205 <select value={group} onChange={(e) => set({ group: e.target.value === 'world' ? null : e.target.value }, 0)} className="min-w-0 flex-1 truncate bg-transparent text-ink outline-none" aria-label={t('explorer.group')}>206 {groupOptions.map((g) => (207 <option key={g.slug} value={g.slug}>208 {g.name}209 </option>210 ))}211 </select>212 </label>213 <EntityPicker type="country" placeholder={t('explorer.searchCountry')} onPick={(e) => flyTo(e.id)} size="sm" />214 </div>215 <div className="min-h-0 flex-1 overflow-y-auto border-t border-rule px-4 py-3">216 <div className="eyebrow mb-1.5">{t('explorer.quick')}</div>217 <ul className="flex flex-wrap gap-1">218 {QUICK.filter((q) => indicators.some((i) => i.slug === q)).map((q) => {219 const o = indicators.find((i) => i.slug === q)!;220 return (221 <li key={q}>222 <button type="button" onClick={() => setIndicator(q)} className={cn('inline-flex h-8 items-center rounded-sm border px-2 text-xs', indicator === q ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>223 {o.short_name ?? o.name}224 </button>225 </li>226 );227 })}228 </ul>229 {frames ? (230 <div className="mt-5">231 <div className="eyebrow mb-1.5">{t('explorer.legendTitle', { name: spec.name ?? '' })}</div>232 <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} className="flex-col items-start gap-y-1.5 [&>li]:text-xs" />233 <p className="mt-2 text-2xs text-ink-3">{t('explorer.legendNote')}</p>234 </div>235 ) : null}236 {frames?.provenance ? (237 <button type="button" onClick={() => provPayload && openProv(provPayload)} className="mt-4 inline-flex min-h-[32px] items-center gap-1 text-left text-2xs text-ink-2 hover:text-accent" aria-label={t('common.openProvenance')}>238 <Info size={12} aria-hidden className="shrink-0" />239 <span className="truncate">240 {t('common.source')}: {[frames.provenance.source_name, frames.provenance.dataset].filter(Boolean).join(' — ')} · {frames.provenance.series_code}241 </span>242 </button>243 ) : null}244 </div>245 </aside>246247 {/* Main column */}248 <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">249 {/* Top bar */}250 <div className="flex items-center gap-2 border-b border-rule px-3 py-2 md:px-4">251 <div className="min-w-0 flex-1 md:hidden">252 <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} size="sm" />253 </div>254 <div className="hidden min-w-0 flex-1 items-baseline gap-2 md:flex">255 <h2 className="truncate text-base font-semibold text-ink">{indicatorName}</h2>256 <span className="tnum shrink-0 text-xs text-ink-3">{frames ? t('explorer.countriesYear', { n: grouped(nYear), year }) : loading ? t('common.loading') : ''}</span>257 </div>258 <Segmented value={view} onChange={setView} options={viewOptions.map((o) => ({ ...o, label: o.label }))} label={t('control.view')} size="sm" className="shrink-0 [&>button]:h-11 md:[&>button]:h-8 [&_span]:hidden sm:[&_span]:inline" />259 </div>260261 {/* Stage */}262 <div className={cn('relative min-h-0 flex-1', view !== 'map' && 'overflow-y-auto')} aria-busy={loading}>263 {frames === null && !loading ? (264 <div className="grid h-full place-items-center px-6 text-center text-sm text-ink-3">{t('explorer.noFrames', { indicator: indicatorName })}</div>265 ) : view === 'map' ? (266 <>267 <MapCanvas features={features} sphere={sphere} classOf={yearData.cls} k={k} selectedId={selectedId} focusId={focus} onHover={setHover} onSelect={(iso) => selectCountry(iso)} onOpen={openCountry} labelOf={(iso) => `${byId.get(iso)?.name ?? iso}: ${formatValue(frames?.values[iso]?.[yi] ?? null, spec)} (${year})`} className={cn(loading && 'opacity-60 transition-opacity')} />268 {hover && hoverInfo && !(sheet === 'country' && selectedId === hover.iso3) ? <MapTooltip info={hoverInfo} spec={spec} x={hover.x} y={hover.y} sticky={hover.sticky} onOpen={() => selectCountry(hover.iso3)} /> : null}269 {/* Year watermark + count (map only) */}270 <div className="pointer-events-none absolute left-3 top-2 md:left-4">271 <div className="display tnum text-4xl leading-none text-ink/80 md:text-6xl">{year}</div>272 <div className="tnum mt-1 text-2xs text-ink-3 md:text-xs">{t('explorer.countriesYear', { n: grouped(nYear), year })}</div>273 </div>274 {/* Mobile legend (collapsible) */}275 {frames ? (276 <div className="absolute bottom-2 left-2 right-14 md:hidden">277 <button type="button" onClick={() => setLegendOpen((o) => !o)} aria-expanded={legendOpen} className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-xs text-ink-2 shadow-pop">278 {legendOpen ? <ChevronDown size={14} aria-hidden /> : <ChevronUp size={14} aria-hidden />}279 {t('common.legend')}280 </button>281 {legendOpen ? (282 <div className="mt-1 rounded-sm border border-rule bg-surface/95 p-2 shadow-pop">283 <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} compact />284 </div>285 ) : null}286 </div>287 ) : null}288 <button type="button" onClick={() => setSheet('table')} className="absolute right-3 top-2 hidden h-8 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-2xs text-ink-2 shadow-pop hover:text-ink md:inline-flex" aria-label={t('common.viewTable')}>289 <Table2 size={12} aria-hidden /> {t('common.viewTable')}290 </button>291 </>292 ) : view === 'rank' ? (293 <RankView rows={yearData.rows} year={year} spec={spec} selectedId={selectedId} indicatorSlug={indicator} />294 ) : view === 'trend' ? (295 <TrendView years={years} values={frames?.values ?? {}} spec={spec} selected={selected ? { country: selected.country, series: frames?.values[selected.country.id] ?? [] } : null} subject={indicatorName} />296 ) : (297 <DistributionView rows={yearData.rows} year={year} spec={spec} selected={selected ? { country: selected.country, value: selected.value } : null} />298 )}299 </div>300301 {/* Time machine */}302 <div className="border-t border-rule bg-paper px-3 py-2 md:px-4 safe-bottom">303 <YearSlider years={years} year={year} onChange={setYear} compact interval={650} onPlayingChange={setPlaying} label={t('explorer.timeMachine')} />304 </div>305306 {/* Desktop drawer */}307 {selected ? (308 <div className="absolute right-0 top-[45px] bottom-[57px] z-20 hidden w-[22rem] border-l border-rule bg-surface/95 p-4 shadow-pop backdrop-blur md:block">{countryPanel}</div>309 ) : null}310 </div>311312 {/* Phone sheets */}313 <BottomSheet open={!desktop && sheet === 'country' && !!selected} onClose={() => setSheet(null)} side="drawer" title={<span className="sr-only">{selected?.country.name}</span>}>314 {countryPanel}315 </BottomSheet>316 <BottomSheet open={sheet === 'table'} onClose={() => setSheet(null)} side="drawer" title={t('explorer.table.title', { name: spec.name ?? '', year })}>317 <table className="w-full text-sm tnum">318 <caption className="sr-only">{t('explorer.table.title', { name: spec.name ?? '', year })}</caption>319 <thead>320 <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">321 <th scope="col" className="py-1 pr-2 font-medium">#</th>322 <th scope="col" className="py-1 pr-2 font-medium">{t('common.country')}</th>323 <th scope="col" className="py-1 text-right font-medium">{spec.name}</th>324 </tr>325 </thead>326 <tbody className="divide-y divide-rule">327 {yearData.rows.map((r) => (328 <tr key={r.country.id} className={cn(r.country.id === selectedId && 'bg-accent-soft/50')}>329 <td className="py-1 pr-2 text-ink-3">{r.rank}</td>330 <td className="py-1 pr-2 text-ink">331 <span aria-hidden>{r.country.flag} </span>332 {r.country.name}333 </td>334 <td className="py-1 text-right text-ink">{formatValue(r.value, spec)}</td>335 </tr>336 ))}337 </tbody>338 </table>339 {legend ? <div className="mt-3">{legend}</div> : null}340 </BottomSheet>341 </div>342 );343}344