spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowRight, Maximize2 } from 'lucide-react';3import Link from 'next/link';4import { 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, ordinal } from '@/lib/format';9import { regionShort } from '@/lib/regions';10import { routes } from '@/lib/site';11import type { FormatSpec } from '@/lib/types';12import type { FramesResponse } from '@/lib/types-analytics';13import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view';14import { Sparkline } from '@/components/charts/sparkline';15import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select';16import { YearSlider } from '@/components/controls/year-slider';17import { BottomSheet } from '@/components/data/bottom-sheet';18import { EmptyState } from '@/components/data/empty-state';19import type { BaseFeature } from '@/components/indicators/indicator-map';2021export interface HeroCountry {22 id: string;23 slug: string | null;24 name: string;25 flag: string | null;26 region: string | null;27}2829/**30 * Homepage hero map: indicator chips (headline set) + full picker, time machine (frames endpoint, one request per31 * indicator, pooled quantile legend so colours stay comparable while scrubbing), hover label with value · year ·32 * world rank · regional rank, tap/click → quick panel (bottom sheet / drawer) with sparkline and links.33 */34export function HeroMap({ geometry, sphere, initial, options, chips, countries }: { geometry: BaseFeature[]; sphere: string; initial: FramesResponse | null; options: IndicatorOption[]; chips: string[]; countries: HeroCountry[] }) {35 const [slug, setSlug] = useState(initial?.indicator.slug ?? chips[0] ?? 'gdp-per-capita-ppp');36 const [cache, setCache] = useState<Record<string, FramesResponse | null>>(() => (initial ? { [initial.indicator.slug]: initial } : {}));37 const [year, setYear] = useState<number | null>(initial?.years[initial.years.length - 1] ?? null);38 const [selected, setSelected] = useState<string | null>(null);39 const [loading, setLoading] = useState(false);40 const abortRef = useRef<AbortController | null>(null);41 const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]);4243 useEffect(() => {44 if (cache[slug] !== undefined) return;45 abortRef.current?.abort();46 const ctrl = new AbortController();47 abortRef.current = ctrl;48 setLoading(true);49 clientAnalytics50 .indicatorFrames(slug, {}, ctrl.signal)51 .then((f) => {52 setCache((c) => ({ ...c, [slug]: f }));53 setYear((y) => (y != null && f.years.includes(y) ? y : f.years[f.years.length - 1] ?? null));54 })55 .catch((e) => {56 if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [slug]: null }));57 })58 .finally(() => {59 if (!ctrl.signal.aborted) setLoading(false);60 });61 return () => ctrl.abort();62 }, [slug, cache]);6364 const frames = cache[slug] ?? null;65 const yearIdx = frames && year != null ? frames.years.indexOf(year) : -1;66 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;6768 const model = useMemo(() => {69 if (!frames || yearIdx < 0) return null;70 const breaks = frames.legend.breaks.slice(0, 6);71 const values = new Map<string, number>();72 for (const [iso, arr] of Object.entries(frames.values)) {73 const v = arr[yearIdx];74 if (typeof v === 'number') values.set(iso, v);75 }76 const desc = frames.indicator.higher_is_better !== false;77 const sorted = Array.from(values.entries()).sort((a, b) => (desc ? b[1] - a[1] : a[1] - b[1]));78 const rank = new Map<string, number>();79 const regionRank = new Map<string, [number, number]>();80 const regionCount = new Map<string, number>();81 sorted.forEach(([iso], i) => rank.set(iso, i + 1));82 for (const [iso] of sorted) {83 const rg = byId.get(iso)?.region ?? '';84 const n = (regionCount.get(rg) ?? 0) + 1;85 regionCount.set(rg, n);86 regionRank.set(iso, [n, 0]);87 }88 for (const [iso, rr] of regionRank) rr[1] = regionCount.get(byId.get(iso)?.region ?? '') ?? 0;89 const features: ChoroplethFeature[] = geometry.map((g) => {90 const v = g.iso3 ? values.get(g.iso3) : undefined;91 return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null };92 });93 return { features, breaks, values, rank, regionRank, n: values.size, k: breaks.length + 1 };94 }, [frames, yearIdx, geometry, byId]);9596 const legend = useMemo(() => (frames && spec ? legendFromBreaks(frames.legend.breaks.slice(0, 6), frames.legend.min, frames.legend.max, spec) : []), [frames, spec]);97 const sel = selected && byId.get(selected) ? byId.get(selected)! : null;98 const selSeries = selected && frames ? frames.values[selected] ?? null : null;99 const selPoints = selSeries ? frames!.years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selSeries[i] ?? null })) : [];100 const selValue = selected ? model?.values.get(selected) ?? null : null;101 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');102103 return (104 <div className="min-w-0">105 {/* Indicator switcher */}106 <div className="flex flex-wrap items-center gap-2">107 <ul className="ticker -mx-4 max-w-[calc(100%+2rem)] gap-1.5 px-4 sm:mx-0 sm:max-w-none sm:flex-wrap sm:px-0" aria-label={t('control.indicator')}>108 {chips.map((s) => {109 const o = options.find((x) => x.slug === s);110 if (!o) return null;111 return (112 <li key={s}>113 <button type="button" onClick={() => setSlug(s)} aria-pressed={slug === s} className={cn('inline-flex h-10 items-center whitespace-nowrap rounded-sm border px-3 text-sm md:h-8 md:px-2.5 md:text-xs', slug === s ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>114 {o.short_name ?? o.name}115 </button>116 </li>117 );118 })}119 </ul>120 <IndicatorSelect options={options} value={slug} onChange={setSlug} size="sm" className="w-full sm:ml-auto sm:w-72" align="right" />121 </div>122123 <div className={cn('relative mt-3 transition-opacity', loading && 'opacity-60')} aria-busy={loading}>124 {frames === null ? (125 <EmptyState title={t('home.map.unavailable')} hint={t('indicator.nodata.why')} />126 ) : model && spec ? (127 <ChoroplethView128 features={model.features}129 sphere={sphere}130 legend={legend}131 k={model.k}132 spec={spec}133 summary={summary}134 title={t('chart.map.legend', { name: spec.name, year: year ?? '' })}135 compact136 selectedId={selected}137 onSelect={(f) => f.iso3 && setSelected(f.iso3)}138 renderLabel={(f) => {139 const r = f.iso3 ? model.rank.get(f.iso3) : undefined;140 const rr = f.iso3 ? model.regionRank.get(f.iso3) : undefined;141 const c = f.iso3 ? byId.get(f.iso3) : undefined;142 return (143 <div className="tnum text-ink-2">144 <div>145 <span className="font-semibold text-ink">{formatValue(f.value, spec)}</span> <span className="text-ink-3">{year}</span>146 </div>147 {r ? (148 <div className="text-2xs text-ink-3">149 {t('home.map.world', { rank: r, n: model.n })}150 {rr && c?.region ? ` · ${t('home.map.region', { rank: rr[0], n: rr[1], region: regionShort(c.region) ?? c.region })}` : ''}151 </div>152 ) : null}153 </div>154 );155 }}156 />157 ) : (158 <div className="grid aspect-[960/470] w-full place-items-center rounded-sm bg-map-water text-sm text-ink-3">{t('common.loading')}</div>159 )}160 </div>161162 {/* Time machine */}163 {frames && year != null ? (164 <div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:gap-6">165 <YearSlider years={frames.years} year={year} onChange={setYear} className="min-w-0 flex-1" interval={600} />166 <div className="flex shrink-0 items-center gap-3 text-xs text-ink-3">167 <span className="tnum">{t('indicator.map.n', { n: grouped(model?.n ?? 0) })}</span>168 <Link href={routes.explore({ indicator: slug, year })} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-accent hover:underline md:min-h-[32px]">169 <Maximize2 size={14} aria-hidden />170 {t('home.map.openExplorer')}171 </Link>172 </div>173 </div>174 ) : null}175176 {/* Quick panel */}177 <BottomSheet open={!!sel} onClose={() => setSelected(null)} side="drawer" title={sel ? `${sel.flag ?? ''} ${sel.name}`.trim() : ''}>178 {sel && spec && frames ? (179 <div className="space-y-4 text-sm">180 <div>181 <div className="eyebrow">{spec.name}</div>182 <div className="mt-1 flex flex-wrap items-baseline gap-x-3">183 <span className="pnum text-3xl font-semibold text-ink">{formatValue(selValue, spec)}</span>184 <span className="tnum text-ink-3">{year}</span>185 </div>186 {model?.rank.get(sel.id) ? (187 <p className="tnum mt-1 text-xs text-ink-2">188 {t('metric.rankWorld', { rank: ordinal(model.rank.get(sel.id)!), n: grouped(model.n) })}189 {model.regionRank.get(sel.id) && sel.region ? ` · ${t('metric.rankRegion', { rank: ordinal(model.regionRank.get(sel.id)![0]), region: regionShort(sel.region) ?? sel.region })}` : ''}190 </p>191 ) : (192 <p className="mt-1 text-xs text-ink-3">{t('home.map.noValue', { year: year ?? '' })}</p>193 )}194 </div>195 {selPoints.filter((p) => p.value != null).length >= 2 ? (196 <div>197 <div className="eyebrow mb-1">{t('home.map.history', { y0: frames.years[0] ?? '', y1: frames.years[frames.years.length - 1] ?? '' })}</div>198 <Sparkline points={selPoints} width={360} height={72} className="h-[72px] w-full" ariaLabel={t('metric.history')} />199 </div>200 ) : null}201 <div className="flex flex-wrap gap-2 pt-1">202 {sel.slug ? (203 <Link href={routes.country(sel.slug)} className="tap inline-flex items-center gap-1.5 rounded-sm bg-ink px-3 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink">204 {t('home.map.openCountry')} <ArrowRight size={14} aria-hidden />205 </Link>206 ) : null}207 {sel.slug ? (208 <Link href={routes.compare(sel.slug)} className="tap inline-flex items-center rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">209 {t('common.compare')}210 </Link>211 ) : null}212 <Link href={routes.explore({ indicator: slug, year, country: sel.id })} className="tap inline-flex items-center rounded-sm border border-rule px-3 text-sm hover:bg-surface-2">213 {t('home.map.openExplorer')}214 </Link>215 </div>216 </div>217 ) : null}218 </BottomSheet>219 </div>220 );221}222