'use client'; import { ArrowRight, Maximize2 } from 'lucide-react'; import Link from 'next/link'; import { 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, ordinal } from '@/lib/format'; import { regionShort } from '@/lib/regions'; import { routes } from '@/lib/site'; import type { FormatSpec } from '@/lib/types'; import type { FramesResponse } from '@/lib/types-analytics'; import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view'; import { Sparkline } from '@/components/charts/sparkline'; import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; import { YearSlider } from '@/components/controls/year-slider'; import { BottomSheet } from '@/components/data/bottom-sheet'; import { EmptyState } from '@/components/data/empty-state'; import type { BaseFeature } from '@/components/indicators/indicator-map'; export interface HeroCountry { id: string; slug: string | null; name: string; flag: string | null; region: string | null; } /** * Homepage hero map: indicator chips (headline set) + full picker, time machine (frames endpoint, one request per * indicator, pooled quantile legend so colours stay comparable while scrubbing), hover label with value · year · * world rank · regional rank, tap/click → quick panel (bottom sheet / drawer) with sparkline and links. */ export function HeroMap({ geometry, sphere, initial, options, chips, countries }: { geometry: BaseFeature[]; sphere: string; initial: FramesResponse | null; options: IndicatorOption[]; chips: string[]; countries: HeroCountry[] }) { const [slug, setSlug] = useState(initial?.indicator.slug ?? chips[0] ?? 'gdp-per-capita-ppp'); const [cache, setCache] = useState>(() => (initial ? { [initial.indicator.slug]: initial } : {})); const [year, setYear] = useState(initial?.years[initial.years.length - 1] ?? null); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(false); const abortRef = useRef(null); const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]); useEffect(() => { if (cache[slug] !== undefined) return; abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; setLoading(true); clientAnalytics .indicatorFrames(slug, {}, ctrl.signal) .then((f) => { setCache((c) => ({ ...c, [slug]: f })); setYear((y) => (y != null && f.years.includes(y) ? y : f.years[f.years.length - 1] ?? null)); }) .catch((e) => { if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [slug]: null })); }) .finally(() => { if (!ctrl.signal.aborted) setLoading(false); }); return () => ctrl.abort(); }, [slug, cache]); const frames = cache[slug] ?? null; const yearIdx = frames && year != null ? frames.years.indexOf(year) : -1; const spec: FormatSpec | null = frames ? { format: frames.indicator.format, unit: frames.indicator.unit, unit_short: frames.indicator.unit_short, precision: frames.indicator.precision, name: frames.indicator.short_name ?? frames.indicator.name, higher_is_better: frames.indicator.higher_is_better } : null; const model = useMemo(() => { if (!frames || yearIdx < 0) return null; const breaks = frames.legend.breaks.slice(0, 6); const values = new Map(); for (const [iso, arr] of Object.entries(frames.values)) { const v = arr[yearIdx]; if (typeof v === 'number') values.set(iso, v); } const desc = frames.indicator.higher_is_better !== false; const sorted = Array.from(values.entries()).sort((a, b) => (desc ? b[1] - a[1] : a[1] - b[1])); const rank = new Map(); const regionRank = new Map(); const regionCount = new Map(); sorted.forEach(([iso], i) => rank.set(iso, i + 1)); for (const [iso] of sorted) { const rg = byId.get(iso)?.region ?? ''; const n = (regionCount.get(rg) ?? 0) + 1; regionCount.set(rg, n); regionRank.set(iso, [n, 0]); } for (const [iso, rr] of regionRank) rr[1] = regionCount.get(byId.get(iso)?.region ?? '') ?? 0; const features: ChoroplethFeature[] = geometry.map((g) => { const v = g.iso3 ? values.get(g.iso3) : undefined; return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null }; }); return { features, breaks, values, rank, regionRank, n: values.size, k: breaks.length + 1 }; }, [frames, yearIdx, geometry, byId]); const legend = useMemo(() => (frames && spec ? legendFromBreaks(frames.legend.breaks.slice(0, 6), frames.legend.min, frames.legend.max, spec) : []), [frames, spec]); const sel = selected && byId.get(selected) ? byId.get(selected)! : null; const selSeries = selected && frames ? frames.values[selected] ?? null : null; const selPoints = selSeries ? frames!.years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selSeries[i] ?? null })) : []; const selValue = selected ? model?.values.get(selected) ?? null : null; const summary = frames && spec && year != null && model ? t('chart.summary.map', { name: spec.name, year, n: model.n, min: formatValue(frames.legend.min, spec), max: formatValue(frames.legend.max, spec) }) : t('chart.noData'); return (
{/* Indicator switcher */}
    {chips.map((s) => { const o = options.find((x) => x.slug === s); if (!o) return null; return (
  • ); })}
{frames === null ? ( ) : model && spec ? ( f.iso3 && setSelected(f.iso3)} renderLabel={(f) => { const r = f.iso3 ? model.rank.get(f.iso3) : undefined; const rr = f.iso3 ? model.regionRank.get(f.iso3) : undefined; const c = f.iso3 ? byId.get(f.iso3) : undefined; return (
{formatValue(f.value, spec)} {year}
{r ? (
{t('home.map.world', { rank: r, n: model.n })} {rr && c?.region ? ` · ${t('home.map.region', { rank: rr[0], n: rr[1], region: regionShort(c.region) ?? c.region })}` : ''}
) : null}
); }} /> ) : (
{t('common.loading')}
)}
{/* Time machine */} {frames && year != null ? (
{t('indicator.map.n', { n: grouped(model?.n ?? 0) })} {t('home.map.openExplorer')}
) : null} {/* Quick panel */} setSelected(null)} side="drawer" title={sel ? `${sel.flag ?? ''} ${sel.name}`.trim() : ''}> {sel && spec && frames ? (
{spec.name}
{formatValue(selValue, spec)} {year}
{model?.rank.get(sel.id) ? (

{t('metric.rankWorld', { rank: ordinal(model.rank.get(sel.id)!), n: grouped(model.n) })} {model.regionRank.get(sel.id) && sel.region ? ` · ${t('metric.rankRegion', { rank: ordinal(model.regionRank.get(sel.id)![0]), region: regionShort(sel.region) ?? sel.region })}` : ''}

) : (

{t('home.map.noValue', { year: year ?? '' })}

)}
{selPoints.filter((p) => p.value != null).length >= 2 ? (
{t('home.map.history', { y0: frames.years[0] ?? '', y1: frames.years[frames.years.length - 1] ?? '' })}
) : null}
{sel.slug ? ( {t('home.map.openCountry')} ) : null} {sel.slug ? ( {t('common.compare')} ) : null} {t('home.map.openExplorer')}
) : null}
); }