'use client'; import { BarChart3, ChevronDown, ChevronUp, Globe2, Info, LineChart as LineIcon, Sigma, Table2 } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { clientAnalytics } from '@/lib/client-api-analytics'; import { cn } from '@/lib/cn'; import { formatValue, grouped } from '@/lib/format'; import { routes } from '@/lib/site'; import { useUrlState } from '@/lib/url-state'; import type { FormatSpec } from '@/lib/types'; import type { FramesResponse, PointCountry } from '@/lib/types-analytics'; import type { RegionItem } from '@/lib/types-explore'; import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select'; import { YearSlider } from '@/components/controls/year-slider'; import { BottomSheet } from '@/components/data/bottom-sheet'; import { EntityPicker } from '@/components/explore/entity-picker'; import type { BaseFeature } from '@/components/indicators/indicator-map'; import { useProvenance } from '@/components/data/provenance-context'; import { classIndex } from './geo'; import { DEFAULT_EXPLORER_INDICATOR, type ExplorerView } from './options'; import { useIsDesktop } from './use-media'; import { MapCanvas, type MapHover } from './map-canvas'; import { ClassLegend, CountryPanel, MapTooltip, type CountryYearInfo } from './explorer-panels'; import { DistributionView, RankView, TrendView, type YearRow } from './explorer-views'; const 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']; export interface ExplorerProps { features: BaseFeature[]; sphere: string; countries: PointCountry[]; indicators: IndicatorOption[]; groups: RegionItem[]; initial: FramesResponse | null; initialState: { indicator: string; year: number | null; view: ExplorerView; country: string | null; group: string }; } function specOf(fr: FramesResponse | null, fallbackName: string): FormatSpec { const i = fr?.indicator; 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 }; } /** * World Explorer: indicator × year × view, all in the URL. One /frames request per indicator (cached in state) * feeds the map, the rank list, the trend and the distribution — no further requests while scrubbing. */ export function WorldExplorer({ features, sphere, countries, indicators, groups, initial, initialState }: ExplorerProps) { const router = useRouter(); const { get, getNum, set } = useUrlState(); const { open: openProv } = useProvenance(); const indicator = get('indicator') ?? initialState.indicator; const group = get('group') ?? initialState.group; const view = ((get('view') as ExplorerView | null) ?? initialState.view) as ExplorerView; const selectedId = (get('country') ?? initialState.country)?.toUpperCase() ?? null; const cacheKey = `${indicator}|${group}`; const [cache, setCache] = useState>(() => (initial ? { [`${initialState.indicator}|${initialState.group}`]: initial } : {})); const [loading, setLoading] = useState(false); const [hover, setHover] = useState(null); const [focus, setFocus] = useState(null); const [sheet, setSheet] = useState<'country' | 'table' | null>(null); const [legendOpen, setLegendOpen] = useState(false); const [playing, setPlaying] = useState(false); const abort = useRef(null); const desktop = useIsDesktop(); useEffect(() => { if (cache[cacheKey] !== undefined) return; abort.current?.abort(); const ctrl = new AbortController(); abort.current = ctrl; setLoading(true); clientAnalytics .indicatorFrames(indicator, { group: group !== 'world' ? group : null }, ctrl.signal) .then((r) => setCache((c) => ({ ...c, [cacheKey]: r }))) .catch((e) => { if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [cacheKey]: null })); }) .finally(() => { if (!ctrl.signal.aborted) setLoading(false); }); return () => ctrl.abort(); }, [cacheKey, indicator, group, cache]); const frames = cache[cacheKey] ?? null; const years = frames?.years ?? []; const urlYear = getNum('year') ?? initialState.year; const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear()); const yi = years.indexOf(year); const spec = useMemo(() => specOf(frames, indicators.find((i) => i.slug === indicator)?.name ?? indicator), [frames, indicators, indicator]); const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]); const breaks = frames?.legend.breaks ?? []; const k = breaks.length + 1; /** Values of the current year, ranks (world + region) and the class per country. */ const yearData = useMemo(() => { const rows: YearRow[] = []; const cls = new Map(); if (!frames || yi < 0) return { rows, cls, rankOf: new Map() }; for (const [iso, arr] of Object.entries(frames.values)) { const v = arr[yi]; const c = byId.get(iso); if (v != null && Number.isFinite(v) && c) rows.push({ country: c, value: v, rank: 0 }); cls.set(iso, v != null && Number.isFinite(v) ? classIndex(v, breaks) : null); } const desc = spec.higher_is_better !== false; rows.sort((a, b) => (desc ? b.value - a.value : a.value - b.value)); const rankOf = new Map(); const regionCount = new Map(); rows.forEach((r, i) => { r.rank = i + 1; const reg = r.country.region ?? ''; const rr = (regionCount.get(reg) ?? 0) + 1; regionCount.set(reg, rr); rankOf.set(r.country.id, { world: i + 1, region: reg ? rr : null, nRegion: 0 }); }); for (const [iso, info] of rankOf) info.nRegion = regionCount.get(byId.get(iso)?.region ?? '') ?? 0; return { rows, cls, rankOf }; }, [frames, yi, byId, breaks, spec.higher_is_better]); const infoFor = useCallback( (iso: string): CountryYearInfo | null => { const c = byId.get(iso); if (!c) return null; const arr = frames?.values[iso] ?? []; const series = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: arr[i] ?? null })).filter((p) => p.value != null); const rk = yearData.rankOf.get(iso); const last = series[series.length - 1]; return { country: c, value: yi >= 0 ? arr[yi] ?? null : null, year, rankWorld: rk?.world ?? null, nWorld: yearData.rows.length, rankRegion: rk?.region ?? null, nRegion: rk?.nRegion ?? 0, series, firstYear: series[0]?.year ?? null, lastYear: last?.year ?? null, latestValue: last?.value ?? null, latestYear: last?.year ?? null, }; }, [byId, frames, years, yearData, yi, year], ); const selected = selectedId ? infoFor(selectedId) : null; const hoverInfo = hover ? infoFor(hover.iso3) : null; const indicatorName = frames?.indicator.name ?? spec.name ?? indicator; const setIndicator = (slug: string) => set({ indicator: slug === DEFAULT_EXPLORER_INDICATOR ? null : slug }, 0); const setYear = (y: number) => set({ year: years.length && y === years[years.length - 1] ? null : y }, playing ? 250 : 80); const setView = (v: ExplorerView) => set({ view: v === 'map' ? null : v }, 0); const selectCountry = (iso: string | null) => { set({ country: iso ? iso.toLowerCase() : null }, 0); if (iso) setSheet('country'); else setSheet(null); }; const openCountry = (iso: string) => { const c = byId.get(iso); if (c?.slug) router.push(routes.country(c.slug)); }; const flyTo = (iso: string) => { setFocus(null); requestAnimationFrame(() => setFocus(iso)); selectCountry(iso); }; const viewOptions = [ { value: 'map' as const, label: t('explorer.view.map'), icon: }, { value: 'rank' as const, label: t('explorer.view.rank'), icon: }, { value: 'trend' as const, label: t('explorer.view.trend'), icon: }, { value: 'distribution' as const, label: t('explorer.view.distribution'), icon: }, ]; 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]); const nYear = yearData.rows.length; const compareHref = selected ? routes.compare(selected.country.slug ?? selected.country.id.toLowerCase()) : routes.compare(); const trajHref = selected ? routes.trajectories({ year, group: group !== 'world' ? group : null }) + `&select=${selected.country.id}` : routes.trajectories(); const provPayload = frames ? { 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 }, value: null, country: null, downloadHref: routes.indicatorDownload(indicator), } : null; const countryPanel = selected ? selectCountry(null)} onCompareHref={compareHref} trajectoriesHref={trajHref} closeClassName="hidden md:grid" /> : null; const legend = frames ? : null; return (
{/* Left rail (desktop) */} {/* Main column */}
{/* Top bar */}

{indicatorName}

{frames ? t('explorer.countriesYear', { n: grouped(nYear), year }) : loading ? t('common.loading') : ''}
({ ...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" />
{/* Stage */}
{frames === null && !loading ? (
{t('explorer.noFrames', { indicator: indicatorName })}
) : view === 'map' ? ( <> 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')} /> {hover && hoverInfo && !(sheet === 'country' && selectedId === hover.iso3) ? selectCountry(hover.iso3)} /> : null} {/* Year watermark + count (map only) */}
{year}
{t('explorer.countriesYear', { n: grouped(nYear), year })}
{/* Mobile legend (collapsible) */} {frames ? (
{legendOpen ? (
) : null}
) : null} ) : view === 'rank' ? ( ) : view === 'trend' ? ( ) : ( )}
{/* Time machine */}
{/* Desktop drawer */} {selected ? (
{countryPanel}
) : null}
{/* Phone sheets */} setSheet(null)} side="drawer" title={{selected?.country.name}}> {countryPanel} setSheet(null)} side="drawer" title={t('explorer.table.title', { name: spec.name ?? '', year })}> {yearData.rows.map((r) => ( ))}
{t('explorer.table.title', { name: spec.name ?? '', year })}
# {t('common.country')} {spec.name}
{r.rank} {r.country.flag} {r.country.name} {formatValue(r.value, spec)}
{legend ?
{legend}
: null}
); }