spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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