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%
19.7 KB · 344 lines tsx
Raw Blame History
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