'use client'; import { ArrowRight, 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 { fixed, formatValue, grouped } from '@/lib/format'; import { regionShort } from '@/lib/regions'; import { routes } from '@/lib/site'; import { useUrlState } from '@/lib/url-state'; import type { FormatSpec } from '@/lib/types'; import type { RelatedResponse, ScatterResponse } from '@/lib/types-analytics'; import type { RegionItem } from '@/lib/types-explore'; import { BubbleChart, type BubbleFit, type BubblePoint } from '@/components/charts/bubble-chart'; import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; import { useProvenance } from '@/components/data/provenance-context'; import { DEFAULT_TRAJ } from './options'; import { useIsDesktop } from './use-media'; import { BottomSheet } from '@/components/data/bottom-sheet'; export interface ScatterState { x: string; y: string; size: string; year: number | null; group: string; log: string | null; fit: boolean; country: string | null; } function specOf(i: ScatterResponse['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 }; } /** Cross-section scatter with descriptive statistics; every control is in the URL. */ export function ScatterView({ indicators, groups, initial, initialRelated, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: ScatterResponse | null; initialRelated: RelatedResponse | null; initialState: ScatterState }) { const { get, getNum, set } = useUrlState(); const { open: openProv } = useProvenance(); const desktop = useIsDesktop(); 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 year = getNum('year') ?? initialState.year; const logParam = get('log') ?? initialState.log; const fit = (get('fit') ?? (initialState.fit ? '1' : null)) === '1'; const selected = (get('country') ?? initialState.country)?.toUpperCase() ?? null; const logX = logParam == null ? null : logParam.includes('x'); const logY = logParam == null ? null : logParam.includes('y'); const key = `${x}|${y}|${size}|${group}|${year ?? ''}|${logParam ?? ''}`; const [cache, setCache] = useState>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}|${initialState.year ?? ''}|${initialState.log ?? ''}`]: initial } : {})); const [related, setRelated] = useState>(() => (initialRelated ? { [initialState.x]: initialRelated } : {})); const [loading, setLoading] = useState(false); const abort = useRef(null); useEffect(() => { if (cache[key] !== undefined) return; abort.current?.abort(); const ctrl = new AbortController(); abort.current = ctrl; setLoading(true); clientAnalytics .scatter({ x, y, size: size === 'none' ? 'none' : size, year, group: group !== 'world' ? group : null, log_x: logX == null ? null : String(logX), log_y: logY == null ? null : String(logY) }, 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, year, logX, logY, cache]); useEffect(() => { if (related[x] !== undefined) return; const ctrl = new AbortController(); clientAnalytics .indicatorRelated(x, 8, ctrl.signal) .then((r) => setRelated((c) => ({ ...c, [x]: r }))) .catch(() => setRelated((c) => ({ ...c, [x]: null }))); return () => ctrl.abort(); }, [x, related]); const data = cache[key] ?? null; 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(() => (data?.points ?? []).map((p) => ({ id: p.id, label: p.name ?? p.id, flag: p.flag, x: p.x, y: p.y, size: p.size, region: p.region, yearX: p.year_x, yearY: p.year_y })), [data]); const fitLine: BubbleFit | null = fit && data?.stats.ols ? { slope: data.stats.ols.slope, intercept: data.stats.ols.intercept, logX: data.stats.log_x, logY: data.stats.log_y } : null; const sel = selected ? data?.points.find((p) => p.id === selected) ?? null : null; const xOpt = indicators.find((i) => i.slug === x); const yOpt = indicators.find((i) => i.slug === y); const yearRange = useMemo(() => { const lo = Math.max(xOpt?.first_year ?? 1960, yOpt?.first_year ?? 1960); const hi = Math.min(xOpt?.last_year ?? 2025, yOpt?.last_year ?? 2025); const out: number[] = []; for (let yy = hi; yy >= lo; yy--) out.push(yy); return out; }, [xOpt, yOpt]); 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', 'labor-force', 'electricity-generation'].includes(i.slug) || i.slug === size)], [indicators, size]); const setLog = (axis: 'x' | 'y', on: boolean) => { const cur = new Set((logParam ?? `${data?.stats.log_x ? 'x' : ''}${data?.stats.log_y ? 'y' : ''}`).split('')); if (on) cur.add(axis); else cur.delete(axis); const v = ['x', 'y'].filter((a) => cur.has(a)).join(','); set({ log: v || 'none' }, 0); }; const effLogX = data?.stats.log_x ?? false; const effLogY = data?.stats.log_y ?? false; const rel = related[x] ?? null; const country = sel ? { id: sel.id, slug: sel.slug, name: sel.name ?? sel.id, flag: sel.flag } : null; const selPanel = sel ? (
{sel.flag}
{sel.name}
{regionShort(sel.region) ?? sel.region}
{[ { spec: xSpec, v: sel.x, yr: sel.year_x, slug: x }, { spec: ySpec, v: sel.y, yr: sel.year_y, slug: y }, ...(sizeSpec ? [{ spec: sizeSpec, v: sel.size, yr: null, slug: size }] : []), ].map((row) => (
{row.spec.name}
{row.yr ? · {row.yr} : null}
))}
{t('explorer.drawer.open')}
) : null; return (

{t('scatter.title')}

{t('scatter.lede')}

set({ x: v === DEFAULT_TRAJ.x ? null : v, log: null }, 0)} label={t('traj.x')} size="sm" /> set({ y: v === DEFAULT_TRAJ.y ? null : v, log: null }, 0)} label={t('traj.y')} size="sm" /> set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" />
{t('scatter.openTrajectories')} →
{data === null && !loading ? (

{t('scatter.noData', { year: year ?? '' })}

) : data && data.n === 0 ? (

{t('scatter.noData', { year: data.year_used ?? year ?? '' })}

) : data ? ( set({ country: id ? id.toLowerCase() : null }, 0)} height={desktop ? 480 : 380} defaultWidth={900} /> ) : (
{t('common.loading')}
)} {data ? (
{[ [t('scatter.stats.pearson'), data.stats.pearson != null ? fixed(data.stats.pearson, 2) : t('common.na')], [t('scatter.stats.spearman'), data.stats.spearman != null ? fixed(data.stats.spearman, 2) : t('common.na')], [t('scatter.stats.r2'), data.stats.ols?.r2 != null ? fixed(data.stats.ols.r2, 2) : t('common.na')], [t('scatter.stats.n'), grouped(data.n)], [t('scatter.stats.year'), String(data.year_used ?? '')], ].map(([k, v]) => (
{k}
{v}
))}

{t('scatter.caveat')}

{t('scatter.nearest', { year: data.year_used ?? '', n: data.nearest_years })} {effLogX || effLogY ? ` ${t('common.log')}: ${[effLogX ? 'x' : null, effLogY ? 'y' : null].filter(Boolean).join(', ')}.` : ''}

) : null}
set({ country: null }, 0)} side="drawer" title={{sel?.name}}> {selPanel}
); }