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%
13.0 KB · 237 lines tsx
Raw Blame History
1'use client';2import { Check, RotateCcw, Share2, SlidersHorizontal, X } 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 { grouped } from '@/lib/format';9import { routes } from '@/lib/site';10import { useUrlState } from '@/lib/url-state';11import type { FormatSpec } from '@/lib/types';12import type { TrajectoryResponse } from '@/lib/types-analytics';13import type { RegionItem } from '@/lib/types-explore';14import { BubbleChart, type BubblePoint } from '@/components/charts/bubble-chart';15import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select';16import { YearSlider } from '@/components/controls/year-slider';17import { BottomSheet } from '@/components/data/bottom-sheet';18import { EntityPicker } from '@/components/explore/entity-picker';19import { useProvenance } from '@/components/data/provenance-context';20import { DEFAULT_TRAJ } from './options';2122export interface TrajectoriesState {23  x: string;24  y: string;25  size: string;26  year: number | null;27  group: string;28  select: string[];29}3031function specOf(i: TrajectoryResponse['x'] | null | undefined, fallback: string): FormatSpec {32  return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallback, higher_is_better: i?.higher_is_better };33}3435/** Gapminder-style animated bubbles: one /trajectory payload per (x, y, size, group); the year only re-indexes arrays. */36export function TrajectoriesView({ indicators, groups, initial, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: TrajectoryResponse | null; initialState: TrajectoriesState }) {37  const { get, getNum, set } = useUrlState();38  const { open: openProv } = useProvenance();39  const x = get('x') ?? initialState.x;40  const y = get('y') ?? initialState.y;41  const size = get('size') ?? initialState.size;42  const group = get('group') ?? initialState.group;43  const select = useMemo(() => (get('select') ?? initialState.select.join(',')).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean).slice(0, 4), [get, initialState.select]);44  const key = `${x}|${y}|${size}|${group}`;45  const [cache, setCache] = useState<Record<string, TrajectoryResponse | null>>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}`]: initial } : {}));46  const [loading, setLoading] = useState(false);47  const [sheet, setSheet] = useState(false);48  const [copied, setCopied] = useState(false);49  const [playing, setPlaying] = useState(false);50  const abort = useRef<AbortController | null>(null);51  const [chartH, setChartH] = useState(480);52  useEffect(() => {53    const apply = () => setChartH(Math.max(360, Math.min(640, window.innerHeight - 330)));54    apply();55    window.addEventListener('resize', apply);56    return () => window.removeEventListener('resize', apply);57  }, []);5859  useEffect(() => {60    if (cache[key] !== undefined) return;61    abort.current?.abort();62    const ctrl = new AbortController();63    abort.current = ctrl;64    setLoading(true);65    clientAnalytics66      .trajectory({ x, y, size: size === 'none' ? 'none' : size, group: group !== 'world' ? group : null }, ctrl.signal)67      .then((r) => setCache((c) => ({ ...c, [key]: r })))68      .catch((e) => {69        if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null }));70      })71      .finally(() => {72        if (!ctrl.signal.aborted) setLoading(false);73      });74    return () => ctrl.abort();75  }, [key, x, y, size, group, cache]);7677  const data = cache[key] ?? null;78  const years = data?.years ?? [];79  const urlYear = getNum('year') ?? initialState.year;80  const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear());81  const yi = years.indexOf(year);82  const xSpec = specOf(data?.x, x);83  const ySpec = specOf(data?.y, y);84  const sizeSpec = data?.size ? specOf(data.size, size) : null;8586  const points: BubblePoint[] = useMemo(() => {87    if (!data || yi < 0) return [];88    return data.countries.map((c) => {89      const s = data.series[c.id];90      return { id: c.id, label: c.name ?? c.id, flag: c.flag, x: s?.x[yi] ?? null, y: s?.y[yi] ?? null, size: s?.size[yi] ?? null, region: c.region, yearX: year, yearY: year };91    });92  }, [data, yi, year]);93  const trails = useMemo(() => {94    const out: Record<string, Array<{ x: number; y: number }>> = {};95    if (!data || yi < 0) return out;96    for (const id of select) {97      const s = data.series[id];98      if (!s) continue;99      const pts: Array<{ x: number; y: number }> = [];100      for (let i = 0; i <= yi; i++) {101        const px = s.x[i];102        const py = s.y[i];103        if (px != null && py != null) pts.push({ x: px, y: py });104      }105      out[id] = pts;106    }107    return out;108  }, [data, select, yi]);109  const byId = useMemo(() => new Map((data?.countries ?? []).map((c) => [c.id, c])), [data]);110  const nYear = points.filter((p) => p.x != null && p.y != null).length;111112  const setYear = (v: number) => set({ year: years.length && v === years[years.length - 1] ? null : v }, playing ? 250 : 80);113  const setSelect = (ids: string[]) => set({ select: ids.length ? ids.join(',') : null }, 0);114  const reset = () => set({ x: null, y: null, size: null, group: null, select: null, year: null }, 0);115  const share = async () => {116    try {117      const url = window.location.href;118      if (navigator.share) await navigator.share({ title: document.title, url });119      else {120        await navigator.clipboard.writeText(url);121        setCopied(true);122        setTimeout(() => setCopied(false), 1600);123      }124    } catch {125      /* cancelled */126    }127  };128  const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world') }, ...groups.filter((g) => ['region', 'income', 'continent', 'org'].includes(g.kind ?? '')).map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id }))], [groups]);129  const sizeOptions: IndicatorOption[] = useMemo(() => [{ slug: 'none', name: t('traj.sizeNone') }, ...indicators.filter((i) => ['population', 'gdp', 'gdp-ppp', 'co2-emissions', 'area-km2', 'labor-force', 'electricity-generation', 'primary-energy-consumption'].includes(i.slug) || i.slug === size)], [indicators, size]);130131  const controls = (132    <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4">133      <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v }, 0)} label={t('traj.x')} size="sm" />134      <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v }, 0)} label={t('traj.y')} size="sm" />135      <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" />136      <label className="flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 md:h-9">137        <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.group')}</span>138        <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('traj.group')}>139          {groupOptions.map((g) => (140            <option key={g.slug} value={g.slug}>141              {g.name}142            </option>143          ))}144        </select>145      </label>146    </div>147  );148  const follow = (149    <div className="flex flex-wrap items-center gap-1.5">150      <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.select')}</span>151      {select.map((id) => {152        const c = byId.get(id);153        return (154          <span key={id} className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm">155            <span aria-hidden>{c?.flag}</span>156            <span className="max-w-[8rem] truncate">{c?.name ?? id}</span>157            <button type="button" onClick={() => setSelect(select.filter((s) => s !== id))} className="grid h-9 w-8 place-items-center text-ink-3 hover:text-down" aria-label={t('traj.remove', { name: c?.name ?? id })}>158              <X size={13} aria-hidden />159            </button>160          </span>161        );162      })}163      {select.length < 4 ? <EntityPicker type="country" placeholder={t('traj.selectHint')} onPick={(e) => setSelect(Array.from(new Set([...select, e.id])))} exclude={select} size="sm" className="w-56" /> : null}164    </div>165  );166167  return (168    <div className="flex min-h-[calc(100dvh-52px)] flex-col md:min-h-[calc(100dvh-56px)]">169      <div className="container-x mx-auto w-full max-w-[1400px]">170        <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-2 pt-4 md:pt-6">171          <div className="min-w-0">172            <h1 className="display text-2xl text-ink md:text-3xl">{t('traj.title')}</h1>173            <p className="mt-0.5 text-sm text-ink-2">{t('traj.lede')}</p>174          </div>175          <div className="flex items-center gap-1.5 text-sm">176            <button type="button" onClick={() => setSheet(true)} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-3 text-ink-2 md:hidden">177              <SlidersHorizontal size={15} aria-hidden /> {t('traj.controls')}178            </button>179            <button type="button" onClick={reset} className="inline-flex h-11 items-center gap-1.5 rounded-sm px-2.5 text-ink-2 hover:bg-surface-2 hover:text-ink md:h-9">180              <RotateCcw size={14} aria-hidden /> <span className="hidden sm:inline">{t('traj.reset')}</span>181            </button>182            <button type="button" onClick={share} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-ink-2 hover:text-ink md:h-9" aria-live="polite">183              {copied ? <Check size={14} aria-hidden className="text-up" /> : <Share2 size={14} aria-hidden />} {copied ? t('traj.copied') : t('traj.share')}184            </button>185          </div>186        </header>187        <div className="hidden space-y-2 pb-3 md:block">188          {controls}189          {follow}190        </div>191      </div>192193      <div className={cn('container-x mx-auto w-full max-w-[1400px] flex-1 transition-opacity', loading && 'opacity-60')} aria-busy={loading}>194        {data === null && !loading ? (195          <p className="py-16 text-center text-sm text-ink-3">{t('traj.noData')}</p>196        ) : data ? (197          <>198            <BubbleChart points={points} xSpec={xSpec} ySpec={ySpec} sizeSpec={sizeSpec} xDomain={data.domains.x} yDomain={data.domains.y} sizeDomain={data.domains.size} logX={data.log_x} logY={data.log_y} highlight={select} trails={trails} onSelect={(id) => id && setSelect(Array.from(new Set([...select, id])).slice(-4))} height={chartH} yearLabel={year} defaultWidth={1100} />199            <div className="mt-1 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-2xs text-ink-3">200              <span className="tnum">{t('traj.countries', { n: grouped(nYear), y0: years[0] ?? '', y1: years[years.length - 1] ?? '' })}</span>201              <span className="flex flex-wrap items-center gap-x-3">202                <button type="button" onClick={() => openProv({ indicator: { slug: x, name: xSpec.name ?? x, format: xSpec.format, unit: xSpec.unit, frequency: 'A' }, value: null, country: null, downloadHref: routes.indicatorDownload(x) })} className="inline-flex min-h-[32px] items-center hover:text-accent">203                  {t('common.source')}: {data.provenance.map((p) => p.source_name ?? p.source).filter((v, i, a) => v && a.indexOf(v) === i).join(', ')}204                </button>205                <Link href={routes.scatter({ x, y, size: size !== DEFAULT_TRAJ.size ? size : undefined, year, group: group !== 'world' ? group : null })} className="text-accent hover:underline">206                  {t('traj.openScatter')} →207                </Link>208              </span>209            </div>210            <p className="mt-1 text-2xs text-ink-3">{t('traj.note')}</p>211          </>212        ) : (213          <div className="grid min-h-[420px] place-items-center text-sm text-ink-3">{t('common.loading')}</div>214        )}215      </div>216217      <div className="sticky bottom-0 z-20 border-t border-rule bg-paper/95 backdrop-blur safe-bottom">218        <div className="container-x mx-auto max-w-[1400px] py-2">219          <YearSlider years={years} year={year} onChange={setYear} interval={450} onPlayingChange={setPlaying} label={t('control.year')} compact />220        </div>221      </div>222223      <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('traj.controls')}>224        <div className="space-y-4">225          {controls}226          {follow}227        </div>228        <div className="mt-6 flex justify-end">229          <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}>230            {t('common.apply')}231          </button>232        </div>233      </BottomSheet>234    </div>235  );236}237