'use client'; import { Check, RotateCcw, Share2, SlidersHorizontal, X } from 'lucide-react'; import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; import { t } from '@/i18n'; import { clientAnalytics } from '@/lib/client-api-analytics'; import { cn } from '@/lib/cn'; import { grouped } from '@/lib/format'; import { routes } from '@/lib/site'; import { useUrlState } from '@/lib/url-state'; import type { FormatSpec } from '@/lib/types'; import type { TrajectoryResponse } from '@/lib/types-analytics'; import type { RegionItem } from '@/lib/types-explore'; import { BubbleChart, type BubblePoint } from '@/components/charts/bubble-chart'; import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; import { YearSlider } from '@/components/controls/year-slider'; import { BottomSheet } from '@/components/data/bottom-sheet'; import { EntityPicker } from '@/components/explore/entity-picker'; import { useProvenance } from '@/components/data/provenance-context'; import { DEFAULT_TRAJ } from './options'; export interface TrajectoriesState { x: string; y: string; size: string; year: number | null; group: string; select: string[]; } function specOf(i: TrajectoryResponse['x'] | null | undefined, fallback: string): FormatSpec { 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 }; } /** Gapminder-style animated bubbles: one /trajectory payload per (x, y, size, group); the year only re-indexes arrays. */ export function TrajectoriesView({ indicators, groups, initial, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: TrajectoryResponse | null; initialState: TrajectoriesState }) { const { get, getNum, set } = useUrlState(); const { open: openProv } = useProvenance(); const x = get('x') ?? initialState.x; const y = get('y') ?? initialState.y; const size = get('size') ?? initialState.size; const group = get('group') ?? initialState.group; const select = useMemo(() => (get('select') ?? initialState.select.join(',')).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean).slice(0, 4), [get, initialState.select]); const key = `${x}|${y}|${size}|${group}`; const [cache, setCache] = useState>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}`]: initial } : {})); const [loading, setLoading] = useState(false); const [sheet, setSheet] = useState(false); const [copied, setCopied] = useState(false); const [playing, setPlaying] = useState(false); const abort = useRef(null); const [chartH, setChartH] = useState(480); useEffect(() => { const apply = () => setChartH(Math.max(360, Math.min(640, window.innerHeight - 330))); apply(); window.addEventListener('resize', apply); return () => window.removeEventListener('resize', apply); }, []); useEffect(() => { if (cache[key] !== undefined) return; abort.current?.abort(); const ctrl = new AbortController(); abort.current = ctrl; setLoading(true); clientAnalytics .trajectory({ x, y, size: size === 'none' ? 'none' : size, group: group !== 'world' ? group : null }, ctrl.signal) .then((r) => setCache((c) => ({ ...c, [key]: r }))) .catch((e) => { if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null })); }) .finally(() => { if (!ctrl.signal.aborted) setLoading(false); }); return () => ctrl.abort(); }, [key, x, y, size, group, cache]); const data = cache[key] ?? null; const years = data?.years ?? []; const urlYear = getNum('year') ?? initialState.year; const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear()); const yi = years.indexOf(year); const xSpec = specOf(data?.x, x); const ySpec = specOf(data?.y, y); const sizeSpec = data?.size ? specOf(data.size, size) : null; const points: BubblePoint[] = useMemo(() => { if (!data || yi < 0) return []; return data.countries.map((c) => { const s = data.series[c.id]; 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 }; }); }, [data, yi, year]); const trails = useMemo(() => { const out: Record> = {}; if (!data || yi < 0) return out; for (const id of select) { const s = data.series[id]; if (!s) continue; const pts: Array<{ x: number; y: number }> = []; for (let i = 0; i <= yi; i++) { const px = s.x[i]; const py = s.y[i]; if (px != null && py != null) pts.push({ x: px, y: py }); } out[id] = pts; } return out; }, [data, select, yi]); const byId = useMemo(() => new Map((data?.countries ?? []).map((c) => [c.id, c])), [data]); const nYear = points.filter((p) => p.x != null && p.y != null).length; const setYear = (v: number) => set({ year: years.length && v === years[years.length - 1] ? null : v }, playing ? 250 : 80); const setSelect = (ids: string[]) => set({ select: ids.length ? ids.join(',') : null }, 0); const reset = () => set({ x: null, y: null, size: null, group: null, select: null, year: null }, 0); const share = async () => { try { const url = window.location.href; if (navigator.share) await navigator.share({ title: document.title, url }); else { await navigator.clipboard.writeText(url); setCopied(true); setTimeout(() => setCopied(false), 1600); } } catch { /* cancelled */ } }; 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]); 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]); const controls = (
set({ x: v === DEFAULT_TRAJ.x ? null : v }, 0)} label={t('traj.x')} size="sm" /> set({ y: v === DEFAULT_TRAJ.y ? null : v }, 0)} label={t('traj.y')} size="sm" /> set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" />
); const follow = (
{t('traj.select')} {select.map((id) => { const c = byId.get(id); return ( {c?.flag} {c?.name ?? id} ); })} {select.length < 4 ? setSelect(Array.from(new Set([...select, e.id])))} exclude={select} size="sm" className="w-56" /> : null}
); return (

{t('traj.title')}

{t('traj.lede')}

{controls} {follow}
{data === null && !loading ? (

{t('traj.noData')}

) : data ? ( <> id && setSelect(Array.from(new Set([...select, id])).slice(-4))} height={chartH} yearLabel={year} defaultWidth={1100} />
{t('traj.countries', { n: grouped(nYear), y0: years[0] ?? '', y1: years[years.length - 1] ?? '' })} {t('traj.openScatter')} →

{t('traj.note')}

) : (
{t('common.loading')}
)}
setSheet(false)} side="center" title={t('traj.controls')}>
{controls} {follow}
); }