SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
11.6 KB · 222 lines tsx
Raw Blame History
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