spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowRight, 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 { fixed, formatValue, grouped } from '@/lib/format';9import { regionShort } from '@/lib/regions';10import { routes } from '@/lib/site';11import { useUrlState } from '@/lib/url-state';12import type { FormatSpec } from '@/lib/types';13import type { RelatedResponse, ScatterResponse } from '@/lib/types-analytics';14import type { RegionItem } from '@/lib/types-explore';15import { BubbleChart, type BubbleFit, type BubblePoint } from '@/components/charts/bubble-chart';16import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select';17import { useProvenance } from '@/components/data/provenance-context';18import { DEFAULT_TRAJ } from './options';19import { useIsDesktop } from './use-media';20import { BottomSheet } from '@/components/data/bottom-sheet';2122export interface ScatterState {23 x: string;24 y: string;25 size: string;26 year: number | null;27 group: string;28 log: string | null;29 fit: boolean;30 country: string | null;31}3233function specOf(i: ScatterResponse['x'] | null | undefined, fallback: string): FormatSpec {34 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 };35}3637/** Cross-section scatter with descriptive statistics; every control is in the URL. */38export function ScatterView({ indicators, groups, initial, initialRelated, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: ScatterResponse | null; initialRelated: RelatedResponse | null; initialState: ScatterState }) {39 const { get, getNum, set } = useUrlState();40 const { open: openProv } = useProvenance();41 const desktop = useIsDesktop();42 const x = get('x') ?? initialState.x;43 const y = get('y') ?? initialState.y;44 const size = get('size') ?? initialState.size;45 const group = get('group') ?? initialState.group;46 const year = getNum('year') ?? initialState.year;47 const logParam = get('log') ?? initialState.log;48 const fit = (get('fit') ?? (initialState.fit ? '1' : null)) === '1';49 const selected = (get('country') ?? initialState.country)?.toUpperCase() ?? null;50 const logX = logParam == null ? null : logParam.includes('x');51 const logY = logParam == null ? null : logParam.includes('y');52 const key = `${x}|${y}|${size}|${group}|${year ?? ''}|${logParam ?? ''}`;53 const [cache, setCache] = useState<Record<string, ScatterResponse | null>>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}|${initialState.year ?? ''}|${initialState.log ?? ''}`]: initial } : {}));54 const [related, setRelated] = useState<Record<string, RelatedResponse | null>>(() => (initialRelated ? { [initialState.x]: initialRelated } : {}));55 const [loading, setLoading] = useState(false);56 const abort = useRef<AbortController | null>(null);5758 useEffect(() => {59 if (cache[key] !== undefined) return;60 abort.current?.abort();61 const ctrl = new AbortController();62 abort.current = ctrl;63 setLoading(true);64 clientAnalytics65 .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)66 .then((r) => setCache((c) => ({ ...c, [key]: r })))67 .catch((e) => {68 if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null }));69 })70 .finally(() => {71 if (!ctrl.signal.aborted) setLoading(false);72 });73 return () => ctrl.abort();74 }, [key, x, y, size, group, year, logX, logY, cache]);7576 useEffect(() => {77 if (related[x] !== undefined) return;78 const ctrl = new AbortController();79 clientAnalytics80 .indicatorRelated(x, 8, ctrl.signal)81 .then((r) => setRelated((c) => ({ ...c, [x]: r })))82 .catch(() => setRelated((c) => ({ ...c, [x]: null })));83 return () => ctrl.abort();84 }, [x, related]);8586 const data = cache[key] ?? null;87 const xSpec = specOf(data?.x, x);88 const ySpec = specOf(data?.y, y);89 const sizeSpec = data?.size ? specOf(data.size, size) : null;90 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]);91 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;92 const sel = selected ? data?.points.find((p) => p.id === selected) ?? null : null;93 const xOpt = indicators.find((i) => i.slug === x);94 const yOpt = indicators.find((i) => i.slug === y);95 const yearRange = useMemo(() => {96 const lo = Math.max(xOpt?.first_year ?? 1960, yOpt?.first_year ?? 1960);97 const hi = Math.min(xOpt?.last_year ?? 2025, yOpt?.last_year ?? 2025);98 const out: number[] = [];99 for (let yy = hi; yy >= lo; yy--) out.push(yy);100 return out;101 }, [xOpt, yOpt]);102 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]);103 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]);104 const setLog = (axis: 'x' | 'y', on: boolean) => {105 const cur = new Set((logParam ?? `${data?.stats.log_x ? 'x' : ''}${data?.stats.log_y ? 'y' : ''}`).split(''));106 if (on) cur.add(axis);107 else cur.delete(axis);108 const v = ['x', 'y'].filter((a) => cur.has(a)).join(',');109 set({ log: v || 'none' }, 0);110 };111 const effLogX = data?.stats.log_x ?? false;112 const effLogY = data?.stats.log_y ?? false;113 const rel = related[x] ?? null;114 const country = sel ? { id: sel.id, slug: sel.slug, name: sel.name ?? sel.id, flag: sel.flag } : null;115116 const selPanel = sel ? (117 <div className="text-sm">118 <div className="flex items-start gap-2">119 <span aria-hidden className="text-3xl leading-none">120 {sel.flag}121 </span>122 <div className="min-w-0 flex-1">123 <div className="display text-lg text-ink">{sel.name}</div>124 <div className="text-xs text-ink-3">{regionShort(sel.region) ?? sel.region}</div>125 </div>126 <button type="button" onClick={() => set({ country: null }, 0)} className="tap -mr-2 grid place-items-center text-ink-2 md:min-h-[32px] md:min-w-[32px]" aria-label={t('common.close')}>127 <X size={16} aria-hidden />128 </button>129 </div>130 <dl className="mt-3 divide-y divide-rule border-y border-rule">131 {[132 { spec: xSpec, v: sel.x, yr: sel.year_x, slug: x },133 { spec: ySpec, v: sel.y, yr: sel.year_y, slug: y },134 ...(sizeSpec ? [{ spec: sizeSpec, v: sel.size, yr: null, slug: size }] : []),135 ].map((row) => (136 <div key={row.slug} className="flex items-baseline justify-between gap-3 py-2">137 <dt className="text-ink-2">{row.spec.name}</dt>138 <dd className="tnum text-right">139 <button type="button" onClick={() => openProv({ indicator: { slug: row.slug, name: row.spec.name ?? row.slug, format: row.spec.format, unit: row.spec.unit, unit_short: row.spec.unit_short, precision: row.spec.precision, frequency: 'A' }, value: { value: row.v, period: row.yr ? `${row.yr}-01-01` : null, year: row.yr, unit: row.spec.unit, provenance: data?.provenance?.[0] ?? null }, country })} className="font-semibold text-ink hover:text-accent" aria-label={t('common.openProvenance')}>140 {formatValue(row.v, row.spec)}141 </button>142 {row.yr ? <span className="text-ink-3"> · {row.yr}</span> : null}143 </dd>144 </div>145 ))}146 </dl>147 <Link href={routes.country(sel.slug ?? sel.id.toLowerCase())} className="mt-3 inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline md:min-h-[32px]">148 {t('explorer.drawer.open')} <ArrowRight size={14} aria-hidden />149 </Link>150 </div>151 ) : null;152153 return (154 <div>155 <header className="pb-3 pt-6 md:pt-10">156 <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('scatter.title')}</h1>157 <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('scatter.lede')}</p>158 </header>159 <div className="grid grid-cols-1 gap-2 border-y border-rule py-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_9rem_10rem]">160 <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v, log: null }, 0)} label={t('traj.x')} size="sm" />161 <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v, log: null }, 0)} label={t('traj.y')} size="sm" />162 <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" />163 <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">164 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('scatter.year')}</span>165 <select value={data?.year_used ?? year ?? ''} onChange={(e) => set({ year: Number(e.target.value) }, 0)} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={t('scatter.year')}>166 {yearRange.map((yy) => (167 <option key={yy} value={yy}>168 {yy}169 </option>170 ))}171 </select>172 </label>173 <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">174 <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.group')}</span>175 <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')}>176 {groupOptions.map((g) => (177 <option key={g.slug} value={g.slug}>178 {g.name}179 </option>180 ))}181 </select>182 </label>183 </div>184 <div className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm">185 <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8">186 <input type="checkbox" checked={effLogX} onChange={(e) => setLog('x', e.target.checked)} className="accent-[var(--accent)]" /> {t('scatter.logX')}187 </label>188 <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8">189 <input type="checkbox" checked={effLogY} onChange={(e) => setLog('y', e.target.checked)} className="accent-[var(--accent)]" /> {t('scatter.logY')}190 </label>191 <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8">192 <input type="checkbox" checked={fit} onChange={(e) => set({ fit: e.target.checked ? '1' : null }, 0)} className="accent-[var(--accent)]" /> {t('scatter.fit')}193 </label>194 <Link href={routes.trajectories({ x, y, size: size !== DEFAULT_TRAJ.size ? size : undefined, year: data?.year_used ?? year, group: group !== 'world' ? group : null })} className="ml-auto inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">195 {t('scatter.openTrajectories')} →196 </Link>197 </div>198199 <div className="grid gap-x-8 lg:grid-cols-[minmax(0,1fr)_18rem]">200 <div className={cn('min-w-0 transition-opacity', loading && 'opacity-60')} aria-busy={loading}>201 {data === null && !loading ? (202 <p className="py-16 text-center text-sm text-ink-3">{t('scatter.noData', { year: year ?? '' })}</p>203 ) : data && data.n === 0 ? (204 <p className="py-16 text-center text-sm text-ink-3">{t('scatter.noData', { year: data.year_used ?? year ?? '' })}</p>205 ) : data ? (206 <BubbleChart points={points} xSpec={xSpec} ySpec={ySpec} sizeSpec={sizeSpec} logX={effLogX} logY={effLogY} fit={fitLine} highlight={selected ? [selected] : []} onSelect={(id) => set({ country: id ? id.toLowerCase() : null }, 0)} height={desktop ? 480 : 380} defaultWidth={900} />207 ) : (208 <div className="grid min-h-[420px] place-items-center text-sm text-ink-3">{t('common.loading')}</div>209 )}210 {data ? (211 <div className="mt-3 border-t border-rule pt-3">212 <dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-5">213 {[214 [t('scatter.stats.pearson'), data.stats.pearson != null ? fixed(data.stats.pearson, 2) : t('common.na')],215 [t('scatter.stats.spearman'), data.stats.spearman != null ? fixed(data.stats.spearman, 2) : t('common.na')],216 [t('scatter.stats.r2'), data.stats.ols?.r2 != null ? fixed(data.stats.ols.r2, 2) : t('common.na')],217 [t('scatter.stats.n'), grouped(data.n)],218 [t('scatter.stats.year'), String(data.year_used ?? '')],219 ].map(([k, v]) => (220 <div key={k}>221 <dt className="text-2xs uppercase tracking-wide text-ink-3">{k}</dt>222 <dd className="tnum text-xl font-semibold text-ink">{v}</dd>223 </div>224 ))}225 </dl>226 <p className="mt-2 text-xs font-medium text-ink-2">{t('scatter.caveat')}</p>227 <p className="text-2xs text-ink-3">228 {t('scatter.nearest', { year: data.year_used ?? '', n: data.nearest_years })}229 {effLogX || effLogY ? ` ${t('common.log')}: ${[effLogX ? 'x' : null, effLogY ? 'y' : null].filter(Boolean).join(', ')}.` : ''}230 </p>231 </div>232 ) : null}233 </div>234 <aside className="min-w-0">235 {desktop && selPanel ? <div className="border-t border-rule pt-3 lg:border-t-0 lg:pt-0">{selPanel}</div> : null}236 <div className={cn('border-t border-rule pt-3', desktop && selPanel && 'mt-6')}>237 <div className="text-sm font-semibold text-ink">{t('scatter.related', { name: xSpec.name ?? x })}</div>238 <p className="mb-2 text-2xs text-ink-3">{t('scatter.relatedHint')}</p>239 {rel && rel.items.length ? (240 <ul className="divide-y divide-rule">241 {rel.items242 .filter((it) => it.indicator.slug !== y)243 .slice(0, 6)244 .map((it) => (245 <li key={it.indicator.slug}>246 <button type="button" onClick={() => set({ y: it.indicator.slug, log: null }, 0)} className="flex min-h-[44px] w-full items-center justify-between gap-2 text-left text-sm hover:text-accent md:min-h-[36px]">247 <span className="truncate">{it.indicator.short_name ?? it.indicator.name}</span>248 <span className={cn('tnum shrink-0 text-xs', it.direction === 'negative' ? 'text-dec' : 'text-inc')}>ρ {it.spearman != null ? fixed(it.spearman, 2) : '—'}</span>249 </button>250 </li>251 ))}252 </ul>253 ) : (254 <p className="text-xs text-ink-3">{rel === null ? t('common.noDataLong') : t('common.loading')}</p>255 )}256 </div>257 </aside>258 </div>259 <BottomSheet open={!desktop && !!selPanel} onClose={() => set({ country: null }, 0)} side="drawer" title={<span className="sr-only">{sel?.name}</span>}>260 {selPanel}261 </BottomSheet>262 </div>263 );264}265