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%
15.8 KB · 265 lines tsx
Raw Blame History
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