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%

Flagship exploration: World Explorer (/explore, zoom/pan map + time machine + views + country drawer), /trajectories, /scatter, /finder, /extremes; analytics API clients; bubble chart log ticks

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 12 days ago (Sep 12, 2026) parent ceb1a63

23 changed files +2,765 −148

added apps/web/qa/flagship-qa.mjs +86 −0
@@ -0,0 +1,86 @@
1 +/**
2 + * Flagship views QA: /explore, /trajectories, /scatter, /finder, /extremes × 320/375/390/430/768/1440/1920.
3 + * Checks: HTTP 200, no horizontal overflow, no console errors, no failed requests, tap targets ≥ 44 px on phones;
4 + * exercises the year slider (arrow keys + play 2 s) and a country tap on /explore at 390 and 1440.
5 + * Screenshots → qa/screens/flagship/<route>-<width>.png; report → qa/screens/flagship/report.json.
6 + * node qa/flagship-qa.mjs [BASE_URL]
7 + */
8 +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
9 +import { mkdirSync, writeFileSync } from 'node:fs';
10 +
11 +const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';
12 +const OUT = new URL('./screens/flagship/', import.meta.url).pathname;
13 +mkdirSync(OUT, { recursive: true });
14 +const ROUTES = ['/explore', '/explore?indicator=life-expectancy&year=1990&view=rank', '/trajectories', '/scatter', '/finder?f=gdp-per-capita:gt:40000&f=population:gt:10000000', '/extremes?window=10'];
15 +const WIDTHS = [320, 375, 390, 430, 768, 1440, 1920];
16 +const slug = (p) => p.slice(1).replace(/[/?=&:,]+/g, '_');
17 +const report = [];
18 +const browser = await chromium.launch();
19 +for (const width of WIDTHS) {
20 + const mobile = width < 768;
21 + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 1000 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });
22 + const page = await ctx.newPage();
23 + const errors = [];
24 + const failed = [];
25 + page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 160)}`));
26 + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 160)); });
27 + page.on('requestfailed', (r) => { if (!r.url().includes('_rsc')) failed.push(r.url().slice(BASE.length)); });
28 + for (const path of ROUTES) {
29 + let status = 0;
30 + try {
31 + const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 });
32 + status = resp?.status() ?? 0;
33 + } catch (e) {
34 + report.push({ path, width, status: 'ERR', error: String(e).slice(0, 160) });
35 + continue;
36 + }
37 + await page.evaluate(() => document.fonts.ready);
38 + await page.waitForTimeout(600);
39 + if (path.startsWith('/explore') && (width === 390 || width === 1440)) {
40 + const range = page.locator('input[type=range]').first();
41 + if (await range.count()) {
42 + await range.focus();
43 + for (let i = 0; i < 8; i++) await page.keyboard.press('ArrowLeft');
44 + await page.waitForTimeout(300);
45 + const play = page.locator('button[aria-label="Play"]').first();
46 + if (await play.count()) { await play.click(); await page.waitForTimeout(2000); await page.locator('button[aria-label="Pause"]').first().click().catch(() => {}); }
47 + const br = page.locator('path[aria-label^="Brazil"]').first();
48 + if (await br.count()) { await br.click({ force: true }).catch(() => {}); await page.waitForTimeout(600); }
49 + }
50 + }
51 + const m = await page.evaluate(({ mobile }) => {
52 + const de = document.documentElement;
53 + const overflow = de.scrollWidth - de.clientWidth;
54 + const vis = (el) => { const r = el.getBoundingClientRect(); if (!r.width || !r.height) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; };
55 + const targets = [...document.querySelectorAll('a,button,[role=button],input,select,[role=radio],[role=tab]')].filter(vis).filter((el) => !el.closest('svg'));
56 + const small = mobile ? targets.filter((el) => { const r = el.getBoundingClientRect(); return r.height < 44 && r.width < 44; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`) : [];
57 + const text = document.body.innerText || '';
58 + const bad = [];
59 + for (const re of [/\bundefined\b/g, /\bNaN\b/g]) { let mm; while ((mm = re.exec(text)) && bad.length < 3) bad.push(text.slice(Math.max(0, mm.index - 30), mm.index + 20)); }
60 + return { overflow, small: small.slice(0, 8), nSmall: small.length, bad, title: document.title, url: location.href };
61 + }, { mobile });
62 + const file = `${slug(path)}-${width}.png`;
63 + await page.screenshot({ path: OUT + file, fullPage: !path.startsWith('/explore') && !path.startsWith('/trajectories') }).catch(() => {});
64 + report.push({ path, width, status, ...m, errors: errors.splice(0), failed: failed.splice(0), file });
65 + }
66 + await ctx.close();
67 +}
68 +await browser.close();
69 +writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
70 +let fails = 0;
71 +for (const r of report) {
72 + const flags = [];
73 + if (r.status !== 200) flags.push(`HTTP ${r.status}`);
74 + if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`);
75 + if (r.nSmall) flags.push(`${r.nSmall} small targets`);
76 + if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`);
77 + if (r.errors?.length) flags.push(`${r.errors.length} console errors`);
78 + if (r.failed?.length) flags.push(`${r.failed.length} failed requests`);
79 + if (flags.length) fails++;
80 + console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(60)} ${flags.join(' · ') || 'ok'}`);
81 + if (r.small?.length) console.log(' small:', r.small.slice(0, 4).join(' | '));
82 + if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 2).join(' | '));
83 + if (r.failed?.length) console.log(' failed:', r.failed.slice(0, 3).join(' | '));
84 + if (r.bad?.length) console.log(' bad:', r.bad.join(' | '));
85 +}
86 +console.log(`\n${report.length} renders, ${fails} with flags`);
modified apps/web/src/app/explore/page.tsx +59 −141
@@ -1,159 +1,77 @@
1 −import { ArrowRight } from 'lucide-react';
2 1 import type { Metadata } from 'next';
3 −import Link from 'next/link';
4 2 import { t } from '@/i18n';
5 3 import { api, isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
6 5 import { apiExplore } from '@/lib/api-explore';
7 −import { formatValue, grouped } from '@/lib/format';
8 6 import { routes } from '@/lib/site';
9 −import { TOPICS } from '@/lib/topics';
10 −import type { RankingResponse } from '@/lib/types';
11 −import type { RegionResponse } from '@/lib/types-explore';
12 −import { ChangesFeed } from '@/components/changes/changes-feed';
13 7 import { NotBuiltState } from '@/components/data/empty-state';
14 −import { Section } from '@/components/data/section';
15 −import { EntityPickerNav } from '@/components/explore/entity-picker-nav';
16 −import { PageHeader } from '@/components/explore/page-header';
17 −import { SimilarityPlayground } from '@/components/explore/similarity-playground';
18 −import { RegionChips } from '@/components/home/region-chips';
8 +import { WorldExplorer } from '@/components/explorer/world-explorer';
9 +import { baseFeatures } from '@/components/indicators/map-geometry';
10 +import { DEFAULT_EXPLORER_INDICATOR, EXPLORER_VIEWS, explorerIndicators, parseYearParam, type ExplorerView } from '@/components/explorer/options';
19 11
20 12 export const revalidate = 900;
21 13
22 −export const metadata: Metadata = {
23 − title: t('explore.title'),
24 − description: t('explore.sub'),
25 − alternates: { canonical: routes.explore() },
26 −};
14 +type SP = Record<string, string | string[] | undefined>;
15 +const SLUG = /^[a-z0-9][a-z0-9-]*$/;
27 16
28 −type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' };
29 −type GroupQ = { kind: 'compare'; key: string; group: string };
30 −const QUESTIONS: Array<RankQ | GroupQ> = [
31 − { kind: 'ranking', key: 'age', indicator: 'median-age' },
32 − { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' },
33 − { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' },
34 − { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' },
35 − { kind: 'ranking', key: 'life', indicator: 'life-expectancy' },
36 − { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' },
37 − { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' },
38 − { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' },
39 − { kind: 'ranking', key: 'internet', indicator: 'internet-users' },
40 − { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' },
41 − { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' },
42 − { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' },
43 − { kind: 'compare', key: 'g7', group: 'g7' },
44 − { kind: 'compare', key: 'brics', group: 'brics' },
45 − { kind: 'compare', key: 'nordic', group: 'nordic-countries' },
46 −];
17 +function first(sp: SP, k: string): string | null {
18 + const v = sp[k];
19 + return (Array.isArray(v) ? v[0] : v) ?? null;
20 +}
21 +
22 +function parseState(sp: SP) {
23 + const ind = (first(sp, 'indicator') ?? '').toLowerCase();
24 + const view = first(sp, 'view') as ExplorerView | null;
25 + const country = (first(sp, 'country') ?? '').toLowerCase();
26 + const group = (first(sp, 'group') ?? 'world').toLowerCase();
27 + return {
28 + indicator: SLUG.test(ind) ? ind : DEFAULT_EXPLORER_INDICATOR,
29 + year: parseYearParam(first(sp, 'year')),
30 + view: view && EXPLORER_VIEWS.includes(view) ? view : ('map' as ExplorerView),
31 + country: /^[a-z]{3}$/.test(country) ? country : null,
32 + group: SLUG.test(group) ? group : 'world',
33 + };
34 +}
47 35
48 −export default async function ExplorePage() {
49 − let notBuilt = false;
50 − let indicatorsCount = 260;
36 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
37 + const sp = await searchParams;
38 + const parameterized = Object.keys(sp).length > 0;
39 + return {
40 + title: t('explorer.metaTitle'),
41 + description: t('explorer.description'),
42 + alternates: { canonical: routes.explore() },
43 + robots: parameterized ? { index: false, follow: true } : undefined,
44 + openGraph: { title: `${t('explorer.metaTitle')} — ${t('site.name')}`, description: t('explorer.description'), url: routes.explore(), type: 'website' },
45 + };
46 +}
47 +
48 +export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {
49 + const sp = await searchParams;
50 + const state = parseState(sp);
51 + let countriesRes;
51 52 try {
52 − indicatorsCount = (await apiExplore.indicators()).n;
53 + countriesRes = await api.countries();
53 54 } catch (e) {
54 − if (isNotBuilt(e)) notBuilt = true;
55 − else throw e;
56 − }
57 − if (notBuilt) {
58 − return (
59 − <>
60 − <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
61 − <NotBuiltState />
62 − </>
63 − );
55 + if (isNotBuilt(e)) return <NotBuiltState />;
56 + throw e;
64 57 }
65 −
66 − const [changes, ...answers] = await Promise.all([
67 − safe(apiExplore.changes({ limit: 30 })),
68 − ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))),
58 + const [indicatorsRes, groupsRes, frames] = await Promise.all([
59 + safe(apiExplore.indicators({ with_data: true })),
60 + safe(apiExplore.regions()),
61 + safe(apiAnalytics.indicatorFrames(state.indicator, { group: state.group !== 'world' ? state.group : null })),
69 62 ]);
70 −
63 + const countries = countriesRes.items.filter((c) => (c.kind ?? 'country') === 'country');
64 + const { features, sphere } = baseFeatures(countriesRes.items);
65 + const indicators = explorerIndicators(indicatorsRes?.items ?? []);
71 66 return (
72 − <>
73 − <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
74 −
75 − <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0">
76 − <div className="max-w-xl">
77 − <EntityPickerNav type="country" placeholder={t('explore.country.search')} />
78 − </div>
79 − <div className="mt-4">
80 − <RegionChips />
81 − </div>
82 − </Section>
83 −
84 − <Section id="topic" title={t('explore.topic.title')} subtitle={t('explore.topic.sub', { n: grouped(indicatorsCount) })}>
85 − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
86 − {TOPICS.map((tp) => (
87 − <li key={tp.id} className="border-t border-rule">
88 − <Link href={routes.indicators(tp.id)} className="group flex min-h-[56px] flex-col justify-center py-2.5">
89 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{tp.name}</span>
90 − <span className="mt-0.5 text-xs text-ink-2">{tp.blurb}</span>
91 − </Link>
92 − </li>
93 − ))}
94 − </ul>
95 − </Section>
96 −
97 − <Section id="question" title={t('explore.question.title')} subtitle={t('explore.question.sub')}>
98 − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
99 − {QUESTIONS.map((q, i) => {
100 − const a = answers[i] ?? null;
101 − const title = t(`explore.q.${q.key}` as 'explore.q.age');
102 − const hint = t(`explore.q.${q.key}.hint` as 'explore.q.age.hint');
103 − if (q.kind === 'ranking') {
104 − const r = a as RankingResponse | null;
105 − const top = r?.rows[0];
106 − if (!top) return null; // no live figure → no static filler
107 − return (
108 − <li key={q.key} className="border-t border-rule">
109 − <Link href={routes.ranking(q.indicator)} className="group flex min-h-[84px] flex-col justify-center py-3">
110 − <span className="flex items-start justify-between gap-2">
111 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
112 − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
113 − </span>
114 − <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
115 − <span className="tnum mt-1.5 text-xs text-ink-2">
116 − <span aria-hidden>{top.country.flag} </span>
117 − {t('explore.question.leader', { country: top.country.name ?? top.country.id, value: formatValue(top.value, r.indicator), year: top.year ?? r.year_used ?? '' })}
118 − </span>
119 − </Link>
120 − </li>
121 − );
122 − }
123 − const g = a as RegionResponse | null;
124 − if (!g || g.members.length < 2) return null;
125 − const slugs = [...g.members]
126 − .sort((x, y) => (y.values.population?.value ?? 0) - (x.values.population?.value ?? 0))
127 − .slice(0, 8)
128 − .map((m) => m.slug ?? m.id);
129 − const gdp = g.aggregates.gdp;
130 − return (
131 − <li key={q.key} className="border-t border-rule">
132 − <Link href={routes.compare(...slugs)} className="group flex min-h-[84px] flex-col justify-center py-3">
133 − <span className="flex items-start justify-between gap-2">
134 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
135 − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
136 − </span>
137 − <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
138 − <span className="tnum mt-1.5 text-xs text-ink-2">
139 − {g.members.slice(0, 8).map((m) => m.flag).join(' ')} · {t('explore.q.members', { n: g.n_members })}
140 − {gdp?.value != null ? ` · ${t('regions.gdp')} ${formatValue(gdp.value, gdp.indicator)}` : ''}
141 − </span>
142 − </Link>
143 − </li>
144 − );
145 − })}
146 − </ul>
147 − </Section>
148 −
149 − <div className="grid gap-x-10 lg:grid-cols-2">
150 − <Section id="changes" title={t('explore.changes.title')} subtitle={t('explore.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('explore.changes.all')} →</Link>}>
151 − <ChangesFeed initial={changes?.items ?? []} kinds={changes?.kinds ?? []} limit={30} compact />
152 − </Section>
153 − <Section id="similar" title={t('explore.similar.title')} subtitle={t('explore.similar.sub')}>
154 − <SimilarityPlayground />
155 − </Section>
156 − </div>
157 − </>
67 + <WorldExplorer
68 + features={features}
69 + sphere={sphere}
70 + countries={countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name, flag: c.flag, region: c.region, income: c.income }))}
71 + indicators={indicators}
72 + groups={groupsRes?.items ?? []}
73 + initial={frames}
74 + initialState={state}
75 + />
158 76 );
159 77 }
added apps/web/src/app/extremes/page.tsx +77 −0
@@ -0,0 +1,77 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { grouped } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import { isTopicId } from '@/lib/topics';
8 +import type { ExtremesResponse } from '@/lib/types-analytics';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { ExtremeFacetBlock } from '@/components/explorer/extreme-facet';
11 +import { ExtremesControls } from '@/components/explorer/extremes-controls';
12 +import { firstParam, type SP } from '@/components/explorer/options';
13 +
14 +export const revalidate = 900;
15 +const WINDOWS = new Set(['1', '5', '10', '25', 'since1990']);
16 +
17 +function parseState(sp: SP) {
18 + const w = firstParam(sp, 'window') ?? '10';
19 + const topic = (firstParam(sp, 'topic') ?? '').toLowerCase();
20 + return { window: WINDOWS.has(w) ? w : '10', topic: isTopicId(topic) ? topic : null, all: firstParam(sp, 'all') === '1' };
21 +}
22 +
23 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
24 + const sp = await searchParams;
25 + return {
26 + title: t('extremes.metaTitle'),
27 + description: t('extremes.description'),
28 + alternates: { canonical: routes.extremes() },
29 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
30 + openGraph: { title: `${t('extremes.metaTitle')} — ${t('site.name')}`, description: t('extremes.description'), url: routes.extremes(), type: 'website' },
31 + };
32 +}
33 +
34 +export default async function ExtremesPage({ searchParams }: { searchParams: Promise<SP> }) {
35 + const state = parseState(await searchParams);
36 + let data: ExtremesResponse;
37 + try {
38 + data = await apiAnalytics.extremes({ window: state.window, topic: state.topic, min_population: state.all ? 0 : null });
39 + } catch (e) {
40 + if (isNotBuilt(e)) return <NotBuiltState />;
41 + throw e;
42 + }
43 + return (
44 + <>
45 + <header className="pb-3 pt-6 md:pt-10">
46 + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('extremes.title')}</h1>
47 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('extremes.lede')}</p>
48 + <p className="tnum mt-1 text-xs text-ink-3">
49 + {t('extremes.facets', { n: grouped(data.facets.length), y0: data.from_year ?? '', y1: data.to_year ?? '' })}
50 + {data.filter_note ? ` · ${data.filter_note}` : ` · ${t('extremes.allCountries')}`}
51 + </p>
52 + </header>
53 + <ExtremesControls window={state.window} topic={state.topic} all={state.all} />
54 + <p className="max-w-prose py-3 text-xs text-ink-3">{t('extremes.semantics')}</p>
55 + {data.facets.length ? (
56 + <>
57 + <nav aria-label={t('extremes.title')} className="-mx-4 px-4 sm:mx-0 sm:px-0">
58 + <ul className="scrollbar-none flex gap-1.5 overflow-x-auto py-1 sm:flex-wrap">
59 + {data.facets.map((f) => (
60 + <li key={f.id} className="shrink-0">
61 + <a href={`#${f.id}`} className="inline-flex h-11 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5">
62 + {f.title}
63 + </a>
64 + </li>
65 + ))}
66 + </ul>
67 + </nav>
68 + {data.facets.map((f) => (
69 + <ExtremeFacetBlock key={f.id} facet={f} />
70 + ))}
71 + </>
72 + ) : (
73 + <p className="py-10 text-center text-sm text-ink-3">{t('extremes.none')}</p>
74 + )}
75 + </>
76 + );
77 +}
added apps/web/src/app/finder/page.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { api, isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { parseFinderState } from '@/lib/finder-query';
7 +import { routes } from '@/lib/site';
8 +import type { FormatSpec } from '@/lib/types';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { FinderView } from '@/components/explorer/finder-view';
11 +import { explorerIndicators, type SP } from '@/components/explorer/options';
12 +import { baseFeatures } from '@/components/indicators/map-geometry';
13 +
14 +export const revalidate = 900;
15 +
16 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
17 + const sp = await searchParams;
18 + return {
19 + title: t('finder.metaTitle'),
20 + description: t('finder.description'),
21 + alternates: { canonical: routes.finder() },
22 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
23 + openGraph: { title: `${t('finder.metaTitle')} — ${t('site.name')}`, description: t('finder.description'), url: routes.finder(), type: 'website' },
24 + };
25 +}
26 +
27 +export default async function FinderPage({ searchParams }: { searchParams: Promise<SP> }) {
28 + const state = parseFinderState(await searchParams);
29 + let indicatorsRes;
30 + try {
31 + indicatorsRes = await apiExplore.indicators({ with_data: true });
32 + } catch (e) {
33 + if (isNotBuilt(e)) return <NotBuiltState />;
34 + throw e;
35 + }
36 + const [countriesRes, result] = await Promise.all([safe(api.countries()), state.filters.length ? safe(apiAnalytics.finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 })) : Promise.resolve(null)]);
37 + const { features, sphere } = baseFeatures(countriesRes?.items ?? []);
38 + const specs: Record<string, FormatSpec> = Object.fromEntries(indicatorsRes.items.map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, frequency: i.frequency, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }]));
39 + return <FinderView indicators={explorerIndicators(indicatorsRes.items, 10)} features={features} sphere={sphere} initial={result} initialState={state} specs={specs} />;
40 +}
added apps/web/src/app/scatter/page.tsx +56 −0
@@ -0,0 +1,56 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { routes } from '@/lib/site';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +import { DEFAULT_TRAJ, explorerIndicators, firstParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options';
9 +import { ScatterView } from '@/components/explorer/scatter-view';
10 +
11 +export const revalidate = 900;
12 +
13 +function parseState(sp: SP) {
14 + const log = (firstParam(sp, 'log') ?? '').toLowerCase();
15 + const country = (firstParam(sp, 'country') ?? '').toLowerCase();
16 + return {
17 + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!,
18 + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!,
19 + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!,
20 + year: parseYearParam(firstParam(sp, 'year')),
21 + group: slugParam(firstParam(sp, 'group'), 'world')!,
22 + log: /^(x|y|x,y|y,x|none)$/.test(log) ? log : null,
23 + fit: firstParam(sp, 'fit') === '1',
24 + country: /^[a-z]{3}$/.test(country) ? country : null,
25 + };
26 +}
27 +
28 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
29 + const sp = await searchParams;
30 + return {
31 + title: t('scatter.metaTitle'),
32 + description: t('scatter.description'),
33 + alternates: { canonical: routes.scatter() },
34 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
35 + openGraph: { title: `${t('scatter.metaTitle')} — ${t('site.name')}`, description: t('scatter.description'), url: routes.scatter(), type: 'website' },
36 + };
37 +}
38 +
39 +export default async function ScatterPage({ searchParams }: { searchParams: Promise<SP> }) {
40 + const state = parseState(await searchParams);
41 + let indicatorsRes;
42 + try {
43 + indicatorsRes = await apiExplore.indicators({ with_data: true });
44 + } catch (e) {
45 + if (isNotBuilt(e)) return <NotBuiltState />;
46 + throw e;
47 + }
48 + const logX = state.log == null ? null : state.log.includes('x');
49 + const logY = state.log == null ? null : state.log.includes('y');
50 + const [groupsRes, scatter, related] = await Promise.all([
51 + safe(apiExplore.regions()),
52 + safe(apiAnalytics.scatter({ x: state.x, y: state.y, size: state.size, year: state.year, group: state.group !== 'world' ? state.group : null, log_x: logX == null ? null : String(logX), log_y: logY == null ? null : String(logY) })),
53 + safe(apiAnalytics.indicatorRelated(state.x, { limit: 8 })),
54 + ]);
55 + return <ScatterView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={scatter} initialRelated={related} initialState={state} />;
56 +}
added apps/web/src/app/trajectories/page.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { routes } from '@/lib/site';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +import { DEFAULT_TRAJ, explorerIndicators, firstParam, listParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options';
9 +import { TrajectoriesView } from '@/components/explorer/trajectories-view';
10 +
11 +export const revalidate = 900;
12 +
13 +function parseState(sp: SP) {
14 + return {
15 + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!,
16 + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!,
17 + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!,
18 + year: parseYearParam(firstParam(sp, 'year')),
19 + group: slugParam(firstParam(sp, 'group'), 'world')!,
20 + select: listParam(firstParam(sp, 'select'), 4).map((s) => s.toUpperCase()),
21 + };
22 +}
23 +
24 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
25 + const sp = await searchParams;
26 + return {
27 + title: t('traj.metaTitle'),
28 + description: t('traj.description'),
29 + alternates: { canonical: routes.trajectories() },
30 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
31 + openGraph: { title: `${t('traj.metaTitle')} — ${t('site.name')}`, description: t('traj.description'), url: routes.trajectories(), type: 'website' },
32 + };
33 +}
34 +
35 +export default async function TrajectoriesPage({ searchParams }: { searchParams: Promise<SP> }) {
36 + const state = parseState(await searchParams);
37 + let indicatorsRes;
38 + try {
39 + indicatorsRes = await apiExplore.indicators({ with_data: true });
40 + } catch (e) {
41 + if (isNotBuilt(e)) return <NotBuiltState />;
42 + throw e;
43 + }
44 + const [groupsRes, traj] = await Promise.all([safe(apiExplore.regions()), safe(apiAnalytics.trajectory({ x: state.x, y: state.y, size: state.size, group: state.group !== 'world' ? state.group : null }))]);
45 + return <TrajectoriesView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={traj} initialState={state} />;
46 +}
modified apps/web/src/components/charts/bubble-chart.tsx +21 −7
@@ -32,6 +32,19 @@ export interface BubbleFit {
32 32 label?: string;
33 33 }
34 34
35 +/** Log-axis ticks: powers of ten; 2× and 5× steps are added only when fewer than three decades are visible. */
36 +export function logTicks(domain: [number, number], n: number): number[] {
37 + const [lo, hi] = domain;
38 + if (!(lo > 0) || !(hi > lo)) return [];
39 + const inRange = (v: number) => v >= lo && v <= hi;
40 + const decades: number[] = [];
41 + for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) decades.push(Math.pow(10, e));
42 + const shown = decades.filter(inRange);
43 + if (shown.length >= Math.min(3, n)) return shown;
44 + const extra = decades.flatMap((d) => [d, 2 * d, 5 * d]).filter(inRange);
45 + return Array.from(new Set(extra)).sort((a, b) => a - b);
46 +}
47 +
35 48 /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */
36 49 export function regionColor(region: string | null | undefined): string {
37 50 const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase());
@@ -124,8 +137,8 @@ export function BubbleChart({
124 137 if (labelled.size >= labelCount + hl.size) break;
125 138 labelled.add(p.id);
126 139 }
127 − const xTicks = (logX ? (x as ReturnType<typeof scaleLog>).ticks(5) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!);
128 − const yTicks = (logY ? (y as ReturnType<typeof scaleLog>).ticks(5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!);
140 + const xTicks = (logX ? logTicks(x.domain() as [number, number], Math.max(3, Math.floor(innerW / 110))) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!);
141 + const yTicks = (logY ? logTicks(y.domain() as [number, number], 5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!);
129 142 let fitPath: string | null = null;
130 143 if (fit) {
131 144 const [x0, x1] = x.domain() as [number, number];
@@ -181,7 +194,8 @@ export function BubbleChart({
181 194 [model.clean, xSpec, ySpec, sizeSpec],
182 195 );
183 196 const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]);
184 − const trans = animate ? 'transform 380ms cubic-bezier(0.2,0.8,0.2,1), r 380ms ease' : undefined;
197 + const trans = animate ? '[transition:transform_380ms_cubic-bezier(0.2,0.8,0.2,1),r_380ms_ease]' : '';
198 + const px = (v: number) => `${Math.round(v * 100) / 100}px`;
185 199
186 200 return (
187 201 <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}>
@@ -222,7 +236,7 @@ export function BubbleChart({
222 236 {xSpec.name}
223 237 {logX ? ` (${t('common.log')})` : ''} →
224 238 </text>
225 − <text x={-8} y={-4} textAnchor="end" className="label label-strong">
239 + <text x={6} y={-4} textAnchor="start" className="label label-strong">
226 240 ↑ {ySpec.name}
227 241 {logY ? ` (${t('common.log')})` : ''}
228 242 </text>
@@ -242,8 +256,8 @@ export function BubbleChart({
242 256 const isHover = hover === p.id;
243 257 const rr = model.radius(p);
244 258 return (
245 − <g key={p.id} style={{ transform: `translate(${model.x(p.x)}px, ${model.y(p.y)}px)`, transition: trans }}>
246 − <circle r={rr} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} style={{ transition: trans }} />
259 + <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}>
260 + <circle r={Math.round(rr * 100) / 100} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} className={trans} />
247 261 </g>
248 262 );
249 263 })}
@@ -252,7 +266,7 @@ export function BubbleChart({
252 266 .map((p) => {
253 267 const rr = model.radius(p);
254 268 return (
255 − <text key={`l-${p.id}`} x={model.x(p.x) + rr + 4} y={model.y(p.y)} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3, transition: trans }}>
269 + <text key={`l-${p.id}`} x={Math.round((model.x(p.x) + rr + 4) * 100) / 100} y={Math.round(model.y(p.y) * 100) / 100} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''} ${trans}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
256 270 {p.label}
257 271 </text>
258 272 );
added apps/web/src/components/explorer/explorer-panels.tsx +173 −0
@@ -0,0 +1,173 @@
1 +'use client';
2 +import { ArrowRight, ExternalLink, Scale, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { formatValue, grouped, ordinal } from '@/lib/format';
7 +import { regionShort } from '@/lib/regions';
8 +import { routes } from '@/lib/site';
9 +import type { FormatSpec, Provenance } from '@/lib/types';
10 +import type { PointCountry } from '@/lib/types-analytics';
11 +import { seqVar } from '@/components/charts/palette';
12 +import { Sparkline } from '@/components/charts/sparkline';
13 +import type { SeriesPoint } from '@/components/charts/scales';
14 +import { useProvenance } from '@/components/data/provenance-context';
15 +import { stepFor } from './geo';
16 +
17 +export interface CountryYearInfo {
18 + country: PointCountry;
19 + value: number | null;
20 + year: number;
21 + rankWorld: number | null;
22 + nWorld: number;
23 + rankRegion: number | null;
24 + nRegion: number;
25 + series: SeriesPoint[];
26 + firstYear: number | null;
27 + lastYear: number | null;
28 + latestValue: number | null;
29 + latestYear: number | null;
30 +}
31 +
32 +/** Legend for the pooled quantile classes (stable while scrubbing). */
33 +export function ClassLegend({ breaks, min, max, spec, k, compact, className }: { breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; k: number; compact?: boolean; className?: string }) {
34 + const items = Array.from({ length: k }, (_, i) => {
35 + const lo = i === 0 ? min : breaks[i - 1]!;
36 + const hi = i === k - 1 ? max : breaks[i]!;
37 + return { cls: i, lo, hi };
38 + });
39 + return (
40 + <ol className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-2', className)} aria-label={t('common.legend')}>
41 + {items.map((it) => (
42 + <li key={it.cls} className="inline-flex items-center gap-1 tnum">
43 + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(it.cls, k)) }} />
44 + {compact ? (it.cls === 0 ? formatValue(it.lo, spec) : it.cls === k - 1 ? formatValue(it.hi, spec) : '') : `${formatValue(it.lo, spec)} – ${formatValue(it.hi, spec)}`}
45 + </li>
46 + ))}
47 + <li className="inline-flex items-center gap-1">
48 + <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />
49 + {t('chart.legend.noData')}
50 + </li>
51 + </ol>
52 + );
53 +}
54 +
55 +/** Floating hover label on the map: country · value · year · world / regional rank. */
56 +export function MapTooltip({ info, spec, x, y, sticky, onOpen }: { info: CountryYearInfo; spec: FormatSpec; x: number; y: number; sticky: boolean; onOpen: () => void }) {
57 + return (
58 + <div className="pointer-events-none absolute z-10 max-w-[240px] rounded-sm border border-rule bg-surface/95 px-2.5 py-1.5 text-xs shadow-pop backdrop-blur" style={{ left: Math.min(x + 12, Math.max(0, x + 12)), top: Math.max(4, y - 64) }}>
59 + <div className="flex items-center gap-1.5 font-medium text-ink">
60 + {info.country.flag ? <span aria-hidden>{info.country.flag}</span> : null}
61 + <span className="truncate">{info.country.name}</span>
62 + </div>
63 + <div className="tnum text-ink">
64 + <span className="font-semibold">{formatValue(info.value, spec)}</span>
65 + <span className="text-ink-3"> · {info.year}</span>
66 + </div>
67 + {info.value != null && info.rankWorld != null ? (
68 + <div className="tnum text-2xs text-ink-2">
69 + {t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}
70 + {info.rankRegion != null && info.country.region ? ` · ${t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(info.country.region) ?? info.country.region })}` : ''}
71 + </div>
72 + ) : info.value == null ? (
73 + <div className="text-2xs text-ink-3">{t('explorer.noValueYear', { year: info.year })}</div>
74 + ) : null}
75 + {sticky ? (
76 + <button type="button" onClick={onOpen} className="pointer-events-auto mt-1 inline-flex min-h-[32px] items-center text-accent underline">
77 + {t('explorer.drawer.details')} →
78 + </button>
79 + ) : null}
80 + </div>
81 + );
82 +}
83 +
84 +/** Country drawer content (right panel on desktop, bottom sheet on phones). */
85 +export function CountryPanel({ info, spec, indicatorSlug, indicatorName, provenance, onClose, onCompareHref, trajectoriesHref, closeClassName }: { info: CountryYearInfo; spec: FormatSpec; indicatorSlug: string; indicatorName: string; provenance: Provenance | null; onClose: () => void; onCompareHref: string; trajectoriesHref: string; closeClassName?: string }) {
86 + const { open } = useProvenance();
87 + const c = info.country;
88 + const slug = c.slug ?? c.id.toLowerCase();
89 + const dir = info.series.length >= 2 ? Math.sign((info.series[info.series.length - 1]!.value ?? 0) - (info.series[info.series.length - 2]!.value ?? 0)) : 0;
90 + return (
91 + <div className="flex h-full min-h-0 flex-col">
92 + <div className="flex items-start gap-3 border-b border-rule pb-3">
93 + <span aria-hidden className="text-4xl leading-none">
94 + {c.flag}
95 + </span>
96 + <div className="min-w-0 flex-1">
97 + <h2 className="display truncate text-xl text-ink">{c.name}</h2>
98 + <p className="text-xs text-ink-3">
99 + {regionShort(c.region) ?? c.region ?? ''}
100 + {c.income ? ` · ${c.income}` : ''}
101 + </p>
102 + </div>
103 + <button type="button" onClick={onClose} className={cn('tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 md:min-h-[32px] md:min-w-[32px]', closeClassName)} aria-label={t('common.close')}>
104 + <X size={18} aria-hidden />
105 + </button>
106 + </div>
107 + <div className="min-h-0 flex-1 overflow-y-auto py-3">
108 + <div className="text-xs font-medium text-ink-2">{indicatorName}</div>
109 + <button
110 + type="button"
111 + onClick={() =>
112 + open({
113 + indicator: { slug: indicatorSlug, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A', higher_is_better: spec.higher_is_better },
114 + value: info.value != null ? { value: info.value, period: `${info.year}-01-01`, year: info.year, unit: spec.unit, provenance } : null,
115 + country: { id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag },
116 + })
117 + }
118 + className="-mx-1 mt-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2"
119 + aria-label={t('common.openProvenance')}
120 + >
121 + <span className="pnum text-3xl font-semibold leading-none text-ink">{info.value != null ? formatValue(info.value, spec) : <span className="text-ink-3">{t('common.noData')}</span>}</span>
122 + <span className="tnum mt-1 text-xs text-ink-3">
123 + {info.year}
124 + {info.value == null && info.latestValue != null ? ` · ${t('explorer.drawer.latest', { value: formatValue(info.latestValue, spec), year: info.latestYear ?? '' })}` : ''}
125 + </span>
126 + </button>
127 + {info.value != null && info.rankWorld != null ? (
128 + <p className="tnum mt-2 text-sm text-ink-2">
129 + <span className="font-medium text-ink">{t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}</span>
130 + {info.rankRegion != null && c.region ? <span className="text-ink-3"> · {t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(c.region) ?? c.region })}</span> : null}
131 + </p>
132 + ) : null}
133 + {info.series.length >= 2 ? (
134 + <div className="mt-4">
135 + <div className="flex items-baseline justify-between text-2xs text-ink-3">
136 + <span>{t('explorer.drawer.history', { y0: info.firstYear ?? '', y1: info.lastYear ?? '' })}</span>
137 + </div>
138 + <Sparkline points={info.series} width={320} height={64} className="mt-1 h-16 w-full" direction={dir > 0 ? 'up' : dir < 0 ? 'down' : 'flat'} ariaLabel={t('explorer.drawer.sparkAria', { name: c.name ?? c.id, indicator: indicatorName })} />
139 + <div className="tnum flex justify-between text-2xs text-ink-3">
140 + <span>{info.firstYear}</span>
141 + <span>{info.lastYear}</span>
142 + </div>
143 + </div>
144 + ) : null}
145 + <div className="mt-5 flex flex-col gap-2">
146 + <Link href={routes.country(slug)} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[38px]">
147 + {t('explorer.drawer.open')} <ArrowRight size={15} aria-hidden />
148 + </Link>
149 + <Link href={onCompareHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">
150 + <Scale size={15} aria-hidden /> {t('explorer.drawer.compare')}
151 + </Link>
152 + <Link href={trajectoriesHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">
153 + <ExternalLink size={14} aria-hidden /> {t('explorer.drawer.trajectories')}
154 + </Link>
155 + <Link href={routes.countryIndicator(slug, topicOf(indicatorSlug), indicatorSlug)} className="inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]">
156 + {t('explorer.drawer.series')} →
157 + </Link>
158 + </div>
159 + </div>
160 + </div>
161 + );
162 +}
163 +
164 +/** Best-effort topic for the country topic page link (falls back to the indicator page when unknown). */
165 +function topicOf(slug: string): string {
166 + return TOPIC_HINT[slug] ?? 'economy';
167 +}
168 +const TOPIC_HINT: Record<string, string> = {
169 + population: 'population', 'population-growth': 'population', 'median-age': 'population', 'fertility-rate': 'population', 'urban-population-share': 'population',
170 + gdp: 'economy', 'gdp-per-capita': 'economy', 'gdp-per-capita-ppp': 'economy', 'gdp-growth': 'economy', inflation: 'economy',
171 + 'unemployment-rate': 'labor', 'life-expectancy': 'health', 'infant-mortality-rate': 'health', 'general-government-gross-debt-pct-gdp': 'government',
172 + 'co2-per-capita': 'climate', 'co2-emissions': 'climate', 'renewable-electricity-share': 'energy', 'energy-use-per-capita': 'energy', 'internet-users': 'digital',
173 +};
added apps/web/src/components/explorer/explorer-views.tsx +73 −0
@@ -0,0 +1,73 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useMemo } from 'react';
4 +import { t } from '@/i18n';
5 +import { formatValue, grouped } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { FormatSpec } from '@/lib/types';
8 +import type { PointCountry } from '@/lib/types-analytics';
9 +import { Histogram } from '@/components/charts/histogram';
10 +import { LineChart, type LineSeries } from '@/components/charts/line-chart';
11 +import { RankedBars, type RankedBarRow } from '@/components/charts/ranked-bars';
12 +import { histogramOf, median, wantsLog } from './geo';
13 +
14 +export interface YearRow {
15 + country: PointCountry;
16 + value: number;
17 + rank: number;
18 +}
19 +
20 +/** Rank view: the top N of the current year, plus the selected country pinned when outside the top. */
21 +export function RankView({ rows, year, spec, selectedId, indicatorSlug, top = 25 }: { rows: YearRow[]; year: number; spec: FormatSpec; selectedId: string | null; indicatorSlug: string; top?: number }) {
22 + const shown = rows.slice(0, top);
23 + const sel = selectedId ? rows.find((r) => r.country.id === selectedId) : null;
24 + if (sel && !shown.some((r) => r.country.id === sel.country.id)) shown.push(sel);
25 + const bars: RankedBarRow[] = shown.map((r) => ({ id: r.country.id, label: r.country.name ?? r.country.id, flag: r.country.flag, href: r.country.slug ? routes.country(r.country.slug) : null, value: r.value, rank: r.rank }));
26 + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>;
27 + return (
28 + <div className="mx-auto max-w-3xl px-4 py-4">
29 + <div className="mb-2 flex flex-wrap items-baseline justify-between gap-2 text-xs text-ink-3">
30 + <span>{t('explorer.rank.sub', { n: grouped(rows.length), year })}</span>
31 + <Link href={routes.ranking(indicatorSlug, { year })} className="text-accent hover:underline">
32 + {t('explorer.rank.full')} →
33 + </Link>
34 + </div>
35 + <RankedBars rows={bars} spec={spec} highlightId={selectedId} />
36 + </div>
37 + );
38 +}
39 +
40 +/** Trend view: world median (and the country count), plus the selected country's own series. */
41 +export function TrendView({ years, values, spec, selected, subject }: { years: number[]; values: Record<string, Array<number | null>>; spec: FormatSpec; selected: { country: PointCountry; series: Array<number | null> } | null; subject: string }) {
42 + const series: LineSeries[] = useMemo(() => {
43 + const med = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: median(Object.values(values).map((arr) => arr[i])) }));
44 + const out: LineSeries[] = [{ id: 'median', name: t('explorer.trend.median'), points: med.filter((p) => p.value != null), colorIndex: 0 }];
45 + if (selected) out.push({ id: selected.country.id, name: selected.country.name ?? selected.country.id, points: years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selected.series[i] ?? null })).filter((p) => p.value != null), colorIndex: 1 });
46 + return out;
47 + }, [years, values, selected]);
48 + const counts = years.map((_, i) => Object.values(values).filter((arr) => arr[i] != null).length);
49 + return (
50 + <div className="mx-auto max-w-4xl px-4 py-4">
51 + <LineChart series={series} spec={spec} subject={subject} height={320} title={t('explorer.trend.title', { name: spec.name ?? '' })} subtitle={t('explorer.trend.sub', { min: Math.min(...counts), max: Math.max(...counts) })} defaultWidth={860} endLabels />
52 + <p className="mt-2 text-2xs text-ink-3">{t('explorer.trend.note')}</p>
53 + </div>
54 + );
55 +}
56 +
57 +/** Distribution view: histogram of the current year with world median and selected country markers. */
58 +export function DistributionView({ rows, year, spec, selected }: { rows: YearRow[]; year: number; spec: FormatSpec; selected: { country: PointCountry; value: number | null } | null }) {
59 + const vals = rows.map((r) => r.value);
60 + const log = wantsLog(spec, vals);
61 + const h = useMemo(() => histogramOf(vals, 20, log), [vals, log]);
62 + const med = median(vals);
63 + const markers = [
64 + ...(med != null ? [{ id: 'median', label: t('explorer.trend.median'), value: med, tone: 'ink' as const }] : []),
65 + ...(selected && selected.value != null ? [{ id: selected.country.id, label: selected.country.name ?? selected.country.id, value: selected.value, tone: 'accent' as const }] : []),
66 + ];
67 + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>;
68 + return (
69 + <div className="mx-auto max-w-4xl px-4 py-4">
70 + <Histogram edges={h.edges} counts={h.counts} log={h.log} markers={markers} spec={spec} height={300} title={t('explorer.dist.title', { name: spec.name ?? '', year })} subtitle={t('explorer.dist.sub', { n: grouped(rows.length), median: formatValue(med, spec) })} defaultWidth={860} unitLabel={spec.unit ?? undefined} />
71 + </div>
72 + );
73 +}
added apps/web/src/components/explorer/extreme-facet.tsx +92 −0
@@ -0,0 +1,92 @@
1 +'use client';
2 +import { ArrowDownRight, ArrowUpRight } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { fixed, formatValue } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { FormatSpec } from '@/lib/types';
9 +import type { ExtremeFacet } from '@/lib/types-analytics';
10 +import { SourceLine } from '@/components/charts/source-line';
11 +
12 +function changeText(row: ExtremeFacet['rows'][number], metric: ExtremeFacet['metric'], spec: FormatSpec): string {
13 + if (metric === 'pct' && row.delta_pct != null) return `${row.delta_pct >= 0 ? '+' : '−'}${fixed(Math.abs(row.delta_pct), 1)} %`;
14 + if (row.delta == null) return t('common.na');
15 + const sign = row.delta >= 0 ? '+' : '−';
16 + if (metric === 'points') return `${sign}${fixed(Math.abs(row.delta), spec.precision ?? 1)} pts`;
17 + return `${sign}${formatValue(Math.abs(row.delta), spec)}`;
18 +}
19 +
20 +/** One extremes facet: title, direction, rows start → end with the change bar. Neutral colours (increase/decrease). */
21 +export function ExtremeFacetBlock({ facet }: { facet: ExtremeFacet }) {
22 + const ind = facet.indicator;
23 + const spec: FormatSpec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A', name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };
24 + const up = facet.direction === 'up';
25 + const Icon = up ? ArrowUpRight : ArrowDownRight;
26 + const mags = facet.rows.map((r) => Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0));
27 + const max = Math.max(1e-9, ...mags);
28 + const y0 = facet.rows[0]?.year_start;
29 + const y1 = facet.rows[0]?.year_end;
30 + const payload = { indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A' as const, higher_is_better: ind.higher_is_better }, value: null, country: null, downloadHref: routes.indicatorDownload(ind.slug) };
31 + return (
32 + <section id={facet.id} className="hairline min-w-0 scroll-mt-28 pb-8 pt-6" aria-labelledby={`${facet.id}-h`}>
33 + <div className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-2">
34 + <div className="min-w-0">
35 + <h2 id={`${facet.id}-h`} className="display text-xl text-ink md:text-2xl">
36 + {facet.title}
37 + </h2>
38 + <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-ink-2">
39 + <span className={cn('inline-flex items-center gap-0.5 font-medium', up ? 'text-inc' : 'text-dec')}>
40 + <Icon size={14} aria-hidden /> {up ? t('extremes.direction.up') : t('extremes.direction.down')}
41 + </span>
42 + <span className="text-ink-3">·</span>
43 + <Link href={routes.indicator(ind.slug)} className="link-quiet text-ink">
44 + {ind.name}
45 + </Link>
46 + {y0 && y1 ? (
47 + <span className="tnum text-ink-3">
48 + · {y0}–{y1} · {t('extremes.n', { n: facet.n })}
49 + </span>
50 + ) : null}
51 + </p>
52 + </div>
53 + <div className="flex flex-wrap gap-x-4 text-sm">
54 + <Link href={routes.ranking(ind.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
55 + {t('extremes.openRanking')} →
56 + </Link>
57 + <Link href={routes.explore({ indicator: ind.slug, year: y1 ?? null })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
58 + {t('extremes.explore')} →
59 + </Link>
60 + </div>
61 + </div>
62 + <ol className="divide-y divide-rule">
63 + {facet.rows.map((r, i) => {
64 + const mag = Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0);
65 + const pct = (mag / max) * 100;
66 + return (
67 + <li key={r.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[1.5rem_minmax(0,12rem)_minmax(0,1fr)_minmax(9rem,auto)]">
68 + <span className="tnum text-right text-xs text-ink-3">{i + 1}</span>
69 + <Link href={routes.country(r.country.slug ?? r.country.id.toLowerCase())} className="link-quiet col-span-2 flex min-h-[40px] min-w-0 items-center gap-1.5 text-sm text-ink sm:col-span-1 sm:min-h-[32px]">
70 + <span aria-hidden>{r.country.flag}</span>
71 + <span className="truncate">{r.country.name}</span>
72 + </Link>
73 + <div className="col-start-2 row-start-2 h-2.5 min-w-0 self-center sm:col-start-3 sm:row-start-1 sm:h-3.5" aria-hidden>
74 + <div className={cn('h-full rounded-r-sm', up ? 'bg-inc' : 'bg-dec')} style={{ width: `${Math.max(1.5, pct)}%`, opacity: 0.85 }} />
75 + </div>
76 + <div className="tnum col-start-3 row-start-2 text-right text-sm sm:col-start-4 sm:row-start-1">
77 + <span className={cn('font-semibold', up ? 'text-inc' : 'text-dec')}>{changeText(r, facet.metric, spec)}</span>
78 + <span className="block text-2xs text-ink-3">
79 + {r.formatted_start ?? formatValue(r.value_start, spec)} <span aria-hidden>→</span> <span className="sr-only">{t('extremes.to')}</span> {r.formatted_end ?? formatValue(r.value_end, spec)}
80 + {r.year_start && r.year_end && (r.year_start !== y0 || r.year_end !== y1) ? ` (${r.year_start}–${r.year_end})` : ''}
81 + </span>
82 + </div>
83 + </li>
84 + );
85 + })}
86 + </ol>
87 + <div className="mt-2">
88 + <SourceLine provenance={facet.provenance} payload={payload} />
89 + </div>
90 + </section>
91 + );
92 +}
added apps/web/src/components/explorer/extremes-controls.tsx +44 −0
@@ -0,0 +1,44 @@
1 +'use client';
2 +import { useRouter } from 'next/navigation';
3 +import { t } from '@/i18n';
4 +import { routes } from '@/lib/site';
5 +import { TOPICS } from '@/lib/topics';
6 +import { Segmented } from '@/components/controls/indicator-select';
7 +
8 +const WINDOWS = ['1', '5', '10', '25', 'since1990'] as const;
9 +
10 +/** Window · topic · population toggle — every change navigates (server re-renders the facets). */
11 +export function ExtremesControls({ window, topic, all }: { window: string; topic: string | null; all: boolean }) {
12 + const router = useRouter();
13 + const go = (patch: { window?: string; topic?: string | null; all?: boolean }) => {
14 + const w = patch.window ?? window;
15 + const tp = patch.topic === undefined ? topic : patch.topic;
16 + const a = patch.all ?? all;
17 + const p = new URLSearchParams();
18 + if (w !== '10') p.set('window', w);
19 + if (tp) p.set('topic', tp);
20 + if (a) p.set('all', '1');
21 + const s = p.toString();
22 + router.replace(`${routes.extremes()}${s ? `?${s}` : ''}`, { scroll: false });
23 + };
24 + return (
25 + <div className="flex flex-wrap items-center gap-2 border-y border-rule py-3">
26 + <Segmented value={window} onChange={(w) => go({ window: w })} options={WINDOWS.map((w) => ({ value: w, label: t(`extremes.window.${w}` as 'extremes.window.1') }))} label={t('extremes.window')} size="sm" className="max-w-full overflow-x-auto" />
27 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
28 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('extremes.topic')}</span>
29 + <select value={topic ?? ''} onChange={(e) => go({ topic: e.target.value || null })} className="bg-transparent text-ink outline-none" aria-label={t('extremes.topic')}>
30 + <option value="">{t('extremes.allTopics')}</option>
31 + {TOPICS.map((tp) => (
32 + <option key={tp.id} value={tp.id}>
33 + {tp.short}
34 + </option>
35 + ))}
36 + </select>
37 + </label>
38 + <label className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 md:h-9">
39 + <input type="checkbox" checked={!all} onChange={(e) => go({ all: !e.target.checked })} className="accent-[var(--accent)]" />
40 + {t('extremes.minPop')}
41 + </label>
42 + </div>
43 + );
44 +}
added apps/web/src/components/explorer/finder-view.tsx +279 −0
@@ -0,0 +1,279 @@
1 +'use client';
2 +import { ArrowDown, ArrowUp, Download, Plus, Scale, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useEffect, useMemo, useRef, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { clientAnalytics } from '@/lib/client-api-analytics';
8 +import { cn } from '@/lib/cn';
9 +import { FINDER_OPS, finderStateQuery, type FinderFilterInput, type FinderOp, type FinderState } from '@/lib/finder-query';
10 +import { formatValue, grouped } from '@/lib/format';
11 +import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';
12 +import { routes } from '@/lib/site';
13 +import type { FormatSpec } from '@/lib/types';
14 +import type { FinderResponse } from '@/lib/types-analytics';
15 +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';
16 +import { useProvenance } from '@/components/data/provenance-context';
17 +import type { BaseFeature } from '@/components/indicators/indicator-map';
18 +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';
19 +
20 +interface Preset {
21 + id: string;
22 + filters: FinderFilterInput[];
23 +}
24 +const PRESETS: Preset[] = [
25 + { id: 'rich-large', filters: [{ slug: 'gdp-per-capita', op: 'gt', value: 40000 }, { slug: 'population', op: 'gt', value: 10_000_000 }] },
26 + { id: 'green-connected', filters: [{ slug: 'renewable-electricity-share', op: 'gt', value: 50 }, { slug: 'internet-users', op: 'gt', value: 80 }] },
27 + { id: 'ageing', filters: [{ slug: 'median-age', op: 'gt', value: 42 }] },
28 + { id: 'young-growing', filters: [{ slug: 'median-age', op: 'lt', value: 25 }, { slug: 'population-growth', op: 'gt', value: 2 }] },
29 + { id: 'long-lives-low-co2', filters: [{ slug: 'life-expectancy', op: 'gt', value: 80 }, { slug: 'co2-per-capita', op: 'lt', value: 5 }] },
30 +];
31 +
32 +function stepFor(spec: FormatSpec, value: number): number {
33 + if (spec.format === 'percent' || spec.format === 'years' || spec.format === 'ratio' || spec.format === 'per_1000') return 1;
34 + const mag = Math.pow(10, Math.max(0, Math.floor(Math.log10(Math.max(1, Math.abs(value)))) - 1));
35 + return mag;
36 +}
37 +
38 +/** Query builder over /finder: filters live in the URL exactly as the API reads them. */
39 +export function FinderView({ indicators, features, sphere, initial, initialState, specs }: { indicators: IndicatorOption[]; features: BaseFeature[]; sphere: string; initial: FinderResponse | null; initialState: FinderState; specs: Record<string, FormatSpec> }) {
40 + const router = useRouter();
41 + const { open: openProv } = useProvenance();
42 + const [state, setState] = useState<FinderState>(initialState);
43 + const [data, setData] = useState<FinderResponse | null>(initial);
44 + const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
45 + const abort = useRef<AbortController | null>(null);
46 + const first = useRef(true);
47 +
48 + // Debounced fetch + URL sync whenever the state changes.
49 + useEffect(() => {
50 + if (first.current) {
51 + first.current = false;
52 + return;
53 + }
54 + const q = finderStateQuery(state);
55 + const timer = setTimeout(() => {
56 + router.replace(`${routes.finder()}${q}`, { scroll: false });
57 + if (!state.filters.length) {
58 + setData(null);
59 + setStatus('idle');
60 + return;
61 + }
62 + abort.current?.abort();
63 + const ctrl = new AbortController();
64 + abort.current = ctrl;
65 + setStatus('loading');
66 + clientAnalytics
67 + .finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 }, ctrl.signal)
68 + .then((r) => {
69 + setData(r);
70 + setStatus('idle');
71 + })
72 + .catch((e) => {
73 + if ((e as Error).name !== 'AbortError') setStatus('error');
74 + });
75 + }, 350);
76 + return () => clearTimeout(timer);
77 + }, [state, router]);
78 +
79 + const update = (i: number, patch: Partial<FinderFilterInput>) => setState((s) => ({ ...s, filters: s.filters.map((f, k) => (k === i ? { ...f, ...patch } : f)) }));
80 + const remove = (i: number) => setState((s) => ({ ...s, filters: s.filters.filter((_, k) => k !== i) }));
81 + const add = () => {
82 + const used = new Set(state.filters.map((f) => f.slug));
83 + const next = ['gdp-per-capita', 'population', 'life-expectancy', 'median-age', 'internet-users', 'co2-per-capita'].find((s) => !used.has(s) && indicators.some((i) => i.slug === s)) ?? indicators[0]?.slug;
84 + if (!next) return;
85 + setState((s) => ({ ...s, filters: [...s.filters, { slug: next, op: 'gt' as FinderOp, value: 0 }].slice(0, 8) }));
86 + };
87 + const specFor = (slug: string): FormatSpec => specs[slug] ?? { format: 'number', name: indicators.find((i) => i.slug === slug)?.name ?? slug };
88 + const matches = data?.items ?? [];
89 + const matchSet = useMemo(() => new Set(matches.map((m) => m.country.id)), [matches]);
90 + const sortSlug = state.sort?.split(':')[0] ?? state.filters[0]?.slug ?? null;
91 + const sortDir = (state.sort?.split(':')[1] as 'asc' | 'desc' | undefined) ?? 'desc';
92 + const toggleSort = (slug: string) => setState((s) => ({ ...s, sort: sortSlug === slug && sortDir === 'desc' ? `${slug}:asc` : `${slug}:desc` }));
93 + const downloadHref = matches.length && state.filters.length ? routes.compareDownload(matches.slice(0, 20).map((m) => m.country.id), Array.from(new Set(state.filters.map((f) => f.slug))).slice(0, 20)) : null;
94 + const compareHref = matches.length >= 2 ? routes.compare(...matches.slice(0, 8).map((m) => m.country.slug ?? m.country.id.toLowerCase())) : null;
95 +
96 + return (
97 + <div>
98 + <header className="pb-3 pt-6 md:pt-10">
99 + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('finder.title')}</h1>
100 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('finder.lede')}</p>
101 + </header>
102 +
103 + {/* Presets */}
104 + <section aria-label={t('finder.presets')} className="border-y border-rule py-3">
105 + <div className="eyebrow mb-1.5">{t('finder.presets')}</div>
106 + <ul className="flex flex-wrap gap-1.5">
107 + {PRESETS.map((p) => (
108 + <li key={p.id}>
109 + <button type="button" onClick={() => setState((s) => ({ ...s, filters: p.filters, mode: 'and' }))} className="inline-flex h-11 flex-col items-start justify-center rounded-sm border border-rule px-3 text-left hover:border-accent md:h-auto md:py-1.5" title={t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}>
110 + <span className="text-sm text-ink">{t(`finder.preset.${p.id}` as 'finder.preset.rich-large')}</span>
111 + <span className="hidden text-2xs text-ink-3 md:block">{t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}</span>
112 + </button>
113 + </li>
114 + ))}
115 + </ul>
116 + </section>
117 +
118 + {/* Builder */}
119 + <section aria-label={t('finder.add')} className="py-4">
120 + <ol className="space-y-2">
121 + {state.filters.map((f, i) => {
122 + const spec = specFor(f.slug);
123 + return (
124 + <li key={`${i}-${f.slug}`} className="grid grid-cols-[minmax(0,1fr)_2.75rem] items-start gap-2 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_2.75rem]">
125 + <IndicatorSelect options={indicators} value={f.slug} onChange={(slug) => update(i, { slug })} size="sm" label={i === 0 ? t('finder.indicator') : undefined} />
126 + <button type="button" onClick={() => remove(i)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-down sm:order-last md:min-h-[36px]" aria-label={t('finder.remove')}>
127 + <X size={16} aria-hidden />
128 + </button>
129 + <label className="col-span-2 flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm sm:col-span-1 md:h-9">
130 + <span className="sr-only">{t('finder.op')}</span>
131 + <select value={f.op} onChange={(e) => update(i, { op: e.target.value as FinderOp, value2: e.target.value === 'between' ? (f.value2 ?? f.value) : null })} className="h-11 min-w-0 flex-1 bg-transparent text-ink outline-none md:h-8">
132 + {FINDER_OPS.map((op) => (
133 + <option key={op} value={op}>
134 + {t(`finder.op.${op}` as 'finder.op.gt')}
135 + </option>
136 + ))}
137 + </select>
138 + </label>
139 + <div className="col-span-2 flex items-center gap-1.5 sm:col-span-1">
140 + <label className="flex h-11 min-w-0 flex-1 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm md:h-9" title={t('finder.unit', { unit: spec.unit ?? '' })}>
141 + <span className="sr-only">{t('finder.value')}</span>
142 + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={Number.isFinite(f.value) ? f.value : ''} onChange={(e) => update(i, { value: e.target.value === '' ? 0 : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={`${t('finder.value')} (${spec.unit ?? ''})`} />
143 + <span className="shrink-0 truncate text-2xs text-ink-3">{spec.unit_short ?? spec.unit ?? ''}</span>
144 + </label>
145 + {f.op === 'between' ? (
146 + <>
147 + <span className="text-xs text-ink-3">{t('finder.value2')}</span>
148 + <label className="flex h-11 min-w-0 flex-1 items-center rounded-sm border border-rule bg-surface px-2 text-sm md:h-9">
149 + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={f.value2 ?? ''} onChange={(e) => update(i, { value2: e.target.value === '' ? null : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={t('finder.value2')} />
150 + </label>
151 + </>
152 + ) : null}
153 + </div>
154 + </li>
155 + );
156 + })}
157 + </ol>
158 + <div className="mt-3 flex flex-wrap items-center gap-2">
159 + <button type="button" onClick={add} disabled={state.filters.length >= 8} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-dashed border-rule-strong px-3 text-sm text-ink-2 hover:border-accent hover:text-accent disabled:opacity-40 md:h-9">
160 + <Plus size={14} aria-hidden /> {t('finder.add')}
161 + </button>
162 + <Segmented value={state.mode} onChange={(m) => setState((s) => ({ ...s, mode: m }))} options={[{ value: 'and' as const, label: t('finder.mode.and') }, { value: 'or' as const, label: t('finder.mode.or') }]} label={t('finder.mode')} size="sm" />
163 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
164 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.region')}</span>
165 + <select value={state.region ?? ''} onChange={(e) => setState((s) => ({ ...s, region: e.target.value || null }))} className="bg-transparent text-ink outline-none">
166 + <option value="">{t('finder.any')}</option>
167 + {WB_REGIONS.map((r) => (
168 + <option key={r.id} value={r.slug}>
169 + {r.short}
170 + </option>
171 + ))}
172 + </select>
173 + </label>
174 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
175 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.income')}</span>
176 + <select value={state.income ?? ''} onChange={(e) => setState((s) => ({ ...s, income: e.target.value || null }))} className="bg-transparent text-ink outline-none">
177 + <option value="">{t('finder.any')}</option>
178 + {INCOME_GROUPS.map((g) => (
179 + <option key={g.id} value={g.slug}>
180 + {g.name}
181 + </option>
182 + ))}
183 + </select>
184 + </label>
185 + </div>
186 + </section>
187 +
188 + {/* Results */}
189 + <section aria-live="polite" className="border-t border-rule pt-4">
190 + <div className="flex flex-wrap items-baseline justify-between gap-2">
191 + <h2 className="display text-xl text-ink md:text-2xl">
192 + {status === 'loading' ? t('finder.loading') : status === 'error' ? t('finder.error') : data ? t('finder.results', { n: grouped(data.n_matching), m: grouped(data.n_evaluated) }) : ''}
193 + </h2>
194 + <div className="flex flex-wrap items-center gap-2 text-sm">
195 + {downloadHref ? (
196 + <a href={downloadHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-rule px-3 text-ink hover:bg-surface-2 md:min-h-[36px]">
197 + <Download size={14} aria-hidden /> {t('finder.download')}
198 + </a>
199 + ) : null}
200 + {compareHref ? (
201 + <Link href={compareHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-ink px-3 font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[36px]">
202 + <Scale size={14} aria-hidden /> {t('finder.compare', { n: Math.min(8, matches.length) })}
203 + </Link>
204 + ) : null}
205 + </div>
206 + </div>
207 + {!state.filters.length ? <p className="mt-3 text-sm text-ink-3">{t('finder.empty')}</p> : null}
208 + {state.filters.length && data && data.n_matching === 0 && status === 'idle' ? <p className="mt-3 text-sm text-ink-3">{t('finder.none')}</p> : null}
209 + {data && matches.length ? (
210 + <div className={cn('mt-4 grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] transition-opacity', status === 'loading' && 'opacity-60')}>
211 + <div className="min-w-0 overflow-x-auto">
212 + <table className="w-full min-w-[32rem] text-sm">
213 + <caption className="sr-only">{t('finder.results', { n: data.n_matching, m: data.n_evaluated })}</caption>
214 + <thead>
215 + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">
216 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('finder.table.country')}</th>
217 + {data.filters.map((f) => (
218 + <th key={f.indicator.slug} scope="col" className="py-1.5 pl-3 text-right font-medium">
219 + <button type="button" onClick={() => toggleSort(f.indicator.slug)} className="inline-flex min-h-[32px] items-center gap-1 hover:text-ink" aria-label={t('finder.sort', { name: f.indicator.short_name ?? f.indicator.name })}>
220 + {f.indicator.short_name ?? f.indicator.name}
221 + {sortSlug === f.indicator.slug ? sortDir === 'desc' ? <ArrowDown size={11} aria-hidden /> : <ArrowUp size={11} aria-hidden /> : null}
222 + </button>
223 + </th>
224 + ))}
225 + </tr>
226 + </thead>
227 + <tbody className="divide-y divide-rule">
228 + {matches.map((m) => (
229 + <tr key={m.country.id}>
230 + <td className="py-1.5 pr-3">
231 + <Link href={routes.country(m.country.slug ?? m.country.id.toLowerCase())} className="link-quiet inline-flex min-h-[36px] items-center gap-1.5 text-ink">
232 + <span aria-hidden>{m.country.flag}</span>
233 + <span className="truncate">{m.country.name}</span>
234 + </Link>
235 + </td>
236 + {data.filters.map((f) => {
237 + const v = m.values[f.indicator.slug];
238 + const spec = specFor(f.indicator.slug);
239 + const matched = m.matched.includes(f.indicator.slug);
240 + return (
241 + <td key={f.indicator.slug} className="tnum py-1.5 pl-3 text-right">
242 + {v && v.value != null ? (
243 + <button type="button" onClick={() => openProv({ indicator: { slug: f.indicator.slug, name: f.indicator.name ?? f.indicator.slug, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' }, value: { value: v.value, formatted: v.formatted, period: v.year ? `${v.year}-01-01` : null, year: v.year, unit: spec.unit, provenance: v.provenance }, country: { id: m.country.id, slug: m.country.slug, name: m.country.name ?? m.country.id, flag: m.country.flag } })} className={cn('inline-flex min-h-[36px] items-baseline gap-1 hover:text-accent', matched ? 'font-medium text-ink' : 'text-ink-3')} aria-label={t('common.openProvenance')}>
244 + {formatValue(v.value, spec)}
245 + <span className="text-2xs text-ink-3">{v.year}</span>
246 + </button>
247 + ) : (
248 + <span className="text-ink-3">{t('common.noData')}</span>
249 + )}
250 + </td>
251 + );
252 + })}
253 + </tr>
254 + ))}
255 + </tbody>
256 + </table>
257 + <p className="mt-2 text-2xs text-ink-3">{t('finder.latestNote')}</p>
258 + </div>
259 + <figure className="min-w-0 lg:order-first">
260 + <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}` }}>
261 + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('finder.map')}>
262 + <title>{t('finder.map')}</title>
263 + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} />
264 + <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round">
265 + {features.map((f, i) => {
266 + const on = f.iso3 ? matchSet.has(f.iso3) : false;
267 + return <path key={f.iso3 ?? `${f.name}-${i}`} d={f.d} fill={on ? 'var(--accent)' : 'var(--nodata)'} fillOpacity={on ? 0.9 : 0.5} />;
268 + })}
269 + </g>
270 + </svg>
271 + </div>
272 + <figcaption className="mt-1 text-2xs text-ink-3">{t('finder.map')}</figcaption>
273 + </figure>
274 + </div>
275 + ) : null}
276 + </section>
277 + </div>
278 + );
279 +}
added apps/web/src/components/explorer/geo.ts +86 −0
@@ -0,0 +1,86 @@
1 +/** Small geometry helpers for the explorer map (client-safe, pure). */
2 +
3 +export interface BBox {
4 + x: number;
5 + y: number;
6 + w: number;
7 + h: number;
8 +}
9 +
10 +/** Bounding box of an SVG path made of absolute M/L/Z commands (what d3 geoPath emits). */
11 +export function pathBBox(d: string): BBox | null {
12 + const nums = d.match(/-?\d+(?:\.\d+)?/g);
13 + if (!nums || nums.length < 4) return null;
14 + let minX = Infinity;
15 + let minY = Infinity;
16 + let maxX = -Infinity;
17 + let maxY = -Infinity;
18 + for (let i = 0; i + 1 < nums.length; i += 2) {
19 + const x = Number(nums[i]);
20 + const y = Number(nums[i + 1]);
21 + if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
22 + if (x < minX) minX = x;
23 + if (x > maxX) maxX = x;
24 + if (y < minY) minY = y;
25 + if (y > maxY) maxY = y;
26 + }
27 + if (minX === Infinity) return null;
28 + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
29 +}
30 +
31 +/** Class index 0..breaks.length for a value against sorted quantile breaks. */
32 +export function classIndex(value: number, breaks: number[]): number {
33 + let i = 0;
34 + while (i < breaks.length && value >= breaks[i]!) i++;
35 + return i;
36 +}
37 +
38 +/** Map a class index (0..k-1) onto the 7-step sequential ramp (same rule as ChoroplethView). */
39 +export function stepFor(cls: number, k: number): number {
40 + if (k <= 1) return 4;
41 + const start = k >= 6 ? 1 : 2;
42 + return Math.round(start + (cls / (k - 1)) * (7 - start));
43 +}
44 +
45 +/** Median of finite numbers (null when empty). */
46 +export function median(values: Array<number | null | undefined>): number | null {
47 + const v = values.filter((x): x is number => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b);
48 + if (!v.length) return null;
49 + const mid = Math.floor(v.length / 2);
50 + return v.length % 2 ? v[mid]! : (v[mid - 1]! + v[mid]!) / 2;
51 +}
52 +
53 +/** Equal-width histogram (log10 when `log`), 20 bins by default. */
54 +export function histogramOf(values: number[], bins = 20, log = false): { edges: number[]; counts: number[]; log: boolean } {
55 + const v = log ? values.filter((x) => x > 0) : values;
56 + if (!v.length) return { edges: [], counts: [], log };
57 + const t = log ? v.map((x) => Math.log10(x)) : v;
58 + let lo = Math.min(...t);
59 + let hi = Math.max(...t);
60 + if (lo === hi) {
61 + const pad = Math.abs(lo) * 0.05 || 0.5;
62 + lo -= pad;
63 + hi += pad;
64 + }
65 + const counts = new Array<number>(bins).fill(0);
66 + const w = (hi - lo) / bins;
67 + for (const x of t) {
68 + let i = Math.floor((x - lo) / w);
69 + if (i >= bins) i = bins - 1;
70 + if (i < 0) i = 0;
71 + counts[i]!++;
72 + }
73 + const edges = Array.from({ length: bins + 1 }, (_, i) => {
74 + const e = lo + i * w;
75 + return log ? Math.pow(10, e) : e;
76 + });
77 + return { edges, counts, log };
78 +}
79 +
80 +/** Heuristic: a level series spanning > 50× on positive values reads better on a log axis. */
81 +export function wantsLog(spec: { format?: string | null }, values: number[]): boolean {
82 + if (!['currency', 'number', 'tonnes', 'kwh', 'per_million'].includes(spec.format ?? '')) return false;
83 + const pos = values.filter((v) => v > 0);
84 + if (pos.length < 10 || pos.length < values.length * 0.95) return false;
85 + return Math.max(...pos) / Math.min(...pos) > 50;
86 +}
added apps/web/src/components/explorer/map-canvas.tsx +345 −0
@@ -0,0 +1,345 @@
1 +'use client';
2 +import { Minus, Plus, RotateCcw } from 'lucide-react';
3 +import { useCallback, useEffect, useId, useMemo, useRef, useState, type PointerEvent as RPointerEvent } from 'react';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';
7 +import type { BaseFeature } from '@/components/indicators/indicator-map';
8 +import { seqVar } from '@/components/charts/palette';
9 +import { pathBBox, stepFor } from './geo';
10 +
11 +export interface MapHover {
12 + iso3: string;
13 + x: number;
14 + y: number;
15 + sticky: boolean;
16 +}
17 +
18 +const MIN_K = 1;
19 +const MAX_K = 8;
20 +
21 +/**
22 + * Zoomable, pannable Equal Earth world map (SVG). Paths are memoised once; only the fill class per country changes
23 + * with the year. Wheel zooms around the cursor, drag pans, two pointers pinch; +/− and reset buttons; `focusId`
24 + * flies to a country's bounding box. Mouse hover reports a floating position; touch taps select (`onSelect`).
25 + * The transform is applied straight to the <g> during gestures (rAF) and mirrored to React state at rest.
26 + */
27 +export function MapCanvas({
28 + features,
29 + sphere,
30 + classOf,
31 + k,
32 + selectedId,
33 + focusId,
34 + onHover,
35 + onSelect,
36 + onOpen,
37 + className,
38 + labelOf,
39 +}: {
40 + features: BaseFeature[];
41 + sphere: string;
42 + /** ISO3 → class index (0..k−1) or null for no data. */
43 + classOf: Map<string, number | null>;
44 + k: number;
45 + selectedId: string | null;
46 + focusId: string | null;
47 + onHover: (h: MapHover | null) => void;
48 + onSelect: (iso3: string | null) => void;
49 + /** Double click / double tap → open the country page. */
50 + onOpen?: (iso3: string) => void;
51 + className?: string;
52 + labelOf: (iso3: string) => string;
53 +}) {
54 + const id = useId();
55 + const wrap = useRef<HTMLDivElement>(null);
56 + const gRef = useRef<SVGGElement>(null);
57 + const view = useRef({ k: 1, tx: 0, ty: 0 });
58 + const [zoom, setZoom] = useState(1);
59 + const raf = useRef<number | null>(null);
60 + const pointers = useRef(new Map<number, { x: number; y: number }>());
61 + const gesture = useRef<{ startDist: number; startK: number; startTx: number; startTy: number; cx: number; cy: number; lastX: number; lastY: number; moved: boolean } | null>(null);
62 + const lastTap = useRef<{ iso: string; t: number } | null>(null);
63 + const bboxes = useMemo(() => new Map(features.filter((f) => f.iso3).map((f) => [f.iso3!, pathBBox(f.d)])), [features]);
64 +
65 + const apply = useCallback(() => {
66 + raf.current = null;
67 + const g = gRef.current;
68 + if (!g) return;
69 + const { k: kk, tx, ty } = view.current;
70 + g.setAttribute('transform', `translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${kk.toFixed(4)})`);
71 + g.style.setProperty('--k', String(kk));
72 + }, []);
73 + const schedule = useCallback(() => {
74 + if (raf.current == null) raf.current = requestAnimationFrame(apply);
75 + }, [apply]);
76 +
77 + /** Geometry of the `meet`-fitted viewBox inside the wrapper: CSS px per user unit and the centring offsets. */
78 + const fit = useCallback(() => {
79 + const el = wrap.current;
80 + if (!el) return { s: 1, ox: 0, oy: 0, left: 0, top: 0 };
81 + const r = el.getBoundingClientRect();
82 + const s = Math.min(r.width / MAP_WIDTH, r.height / MAP_HEIGHT) || 1;
83 + return { s, ox: (r.width - MAP_WIDTH * s) / 2, oy: (r.height - MAP_HEIGHT * s) / 2, left: r.left, top: r.top };
84 + }, []);
85 + /** Scale factor from CSS pixels to SVG user units. */
86 + const unitsPerPx = useCallback(() => 1 / fit().s, [fit]);
87 +
88 + const clampView = useCallback(() => {
89 + const v = view.current;
90 + v.k = Math.min(MAX_K, Math.max(MIN_K, v.k));
91 + // keep the map covering the viewport: translation bounds
92 + const maxTx = 0;
93 + const minTx = MAP_WIDTH - MAP_WIDTH * v.k;
94 + const maxTy = MAP_HEIGHT * 0.25 * (v.k - 1);
95 + const minTy = MAP_HEIGHT - MAP_HEIGHT * v.k - MAP_HEIGHT * 0.25 * (v.k - 1);
96 + if (v.k <= 1) {
97 + v.tx = 0;
98 + v.ty = 0;
99 + } else {
100 + v.tx = Math.min(maxTx, Math.max(minTx, v.tx));
101 + v.ty = Math.min(maxTy, Math.max(minTy, v.ty));
102 + }
103 + }, []);
104 +
105 + const zoomAt = useCallback(
106 + (factor: number, ux: number, uy: number) => {
107 + const v = view.current;
108 + const nk = Math.min(MAX_K, Math.max(MIN_K, v.k * factor));
109 + const f = nk / v.k;
110 + v.tx = ux - (ux - v.tx) * f;
111 + v.ty = uy - (uy - v.ty) * f;
112 + v.k = nk;
113 + clampView();
114 + schedule();
115 + setZoom(v.k);
116 + },
117 + [clampView, schedule],
118 + );
119 +
120 + const toUnits = useCallback(
121 + (clientX: number, clientY: number) => {
122 + const f = fit();
123 + return { ux: (clientX - f.left - f.ox) / f.s, uy: (clientY - f.top - f.oy) / f.s };
124 + },
125 + [fit],
126 + );
127 +
128 + // Wheel zoom (non-passive so we can prevent the page from scrolling while over the map).
129 + useEffect(() => {
130 + const el = wrap.current;
131 + if (!el) return;
132 + const onWheel = (e: WheelEvent) => {
133 + e.preventDefault();
134 + const { ux, uy } = toUnits(e.clientX, e.clientY);
135 + zoomAt(Math.exp(-e.deltaY * 0.0015), ux, uy);
136 + };
137 + el.addEventListener('wheel', onWheel, { passive: false });
138 + return () => el.removeEventListener('wheel', onWheel);
139 + }, [toUnits, zoomAt]);
140 +
141 + // Fly to a country.
142 + useEffect(() => {
143 + if (!focusId) return;
144 + const b = bboxes.get(focusId);
145 + if (!b) return;
146 + const v = view.current;
147 + const target = Math.min(MAX_K, Math.max(1.6, Math.min((MAP_WIDTH * 0.35) / Math.max(b.w, 1), (MAP_HEIGHT * 0.35) / Math.max(b.h, 1))));
148 + const cx = b.x + b.w / 2;
149 + const cy = b.y + b.h / 2;
150 + const start = { ...v };
151 + const end = { k: target, tx: MAP_WIDTH / 2 - cx * target, ty: MAP_HEIGHT / 2 - cy * target };
152 + const t0 = performance.now();
153 + const dur = 480;
154 + const g = gRef.current;
155 + if (g) g.style.transition = 'none';
156 + const step = (now: number) => {
157 + const p = Math.min(1, (now - t0) / dur);
158 + const e = 1 - Math.pow(1 - p, 3);
159 + v.k = start.k + (end.k - start.k) * e;
160 + v.tx = start.tx + (end.tx - start.tx) * e;
161 + v.ty = start.ty + (end.ty - start.ty) * e;
162 + clampView();
163 + apply();
164 + if (p < 1) requestAnimationFrame(step);
165 + else setZoom(v.k);
166 + };
167 + requestAnimationFrame(step);
168 + }, [focusId, bboxes, apply, clampView]);
169 +
170 + // Portrait viewports (phones): start at 1.5× so countries are legible; landscape keeps the whole world.
171 + useEffect(() => {
172 + const el = wrap.current;
173 + if (!el) return;
174 + const r = el.getBoundingClientRect();
175 + if (r.height / Math.max(1, r.width) > (MAP_HEIGHT / MAP_WIDTH) * 1.4 && view.current.k === 1) {
176 + const v = view.current;
177 + v.k = 1.5;
178 + v.tx = MAP_WIDTH / 2 - (MAP_WIDTH / 2) * v.k;
179 + v.ty = MAP_HEIGHT / 2 - (MAP_HEIGHT / 2) * v.k;
180 + clampView();
181 + apply();
182 + setZoom(v.k);
183 + }
184 + // eslint-disable-next-line react-hooks/exhaustive-deps
185 + }, []);
186 +
187 + const reset = () => {
188 + view.current = { k: 1, tx: 0, ty: 0 };
189 + schedule();
190 + setZoom(1);
191 + };
192 +
193 + const downIso = useRef<string | null>(null);
194 + const touch = useRef(false);
195 + const onPointerDown = (e: RPointerEvent<HTMLDivElement>) => {
196 + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
197 + touch.current = e.pointerType !== 'mouse';
198 + if (touch.current) onHover(null);
199 + downIso.current = pointers.current.size === 0 ? ((e.target as Element).closest?.('path[data-iso]') as SVGPathElement | null)?.dataset.iso ?? null : null;
200 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
201 + const pts = Array.from(pointers.current.values());
202 + const v = view.current;
203 + if (pts.length === 1) {
204 + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: e.clientX, cy: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
205 + } else if (pts.length === 2) {
206 + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];
207 + gesture.current = { startDist: Math.hypot(a.x - b.x, a.y - b.y), startK: v.k, startTx: v.tx, startTy: v.ty, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, lastX: (a.x + b.x) / 2, lastY: (a.y + b.y) / 2, moved: true };
208 + }
209 + };
210 + const onPointerMove = (e: RPointerEvent<HTMLDivElement>) => {
211 + if (!pointers.current.has(e.pointerId)) return;
212 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
213 + const g = gesture.current;
214 + if (!g) return;
215 + const pts = Array.from(pointers.current.values());
216 + const s = unitsPerPx();
217 + const v = view.current;
218 + if (pts.length >= 2) {
219 + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];
220 + const dist = Math.hypot(a.x - b.x, a.y - b.y);
221 + const mx = (a.x + b.x) / 2;
222 + const my = (a.y + b.y) / 2;
223 + const f = g.startDist > 0 ? dist / g.startDist : 1;
224 + const nk = Math.min(MAX_K, Math.max(MIN_K, g.startK * f));
225 + const { ux, uy } = toUnits(g.cx, g.cy);
226 + const ratio = nk / g.startK;
227 + v.tx = ux - (ux - g.startTx) * ratio + (mx - g.cx) * s;
228 + v.ty = uy - (uy - g.startTy) * ratio + (my - g.cy) * s;
229 + v.k = nk;
230 + g.moved = true;
231 + } else {
232 + const dx = e.clientX - g.lastX;
233 + const dy = e.clientY - g.lastY;
234 + if (Math.abs(e.clientX - g.cx) + Math.abs(e.clientY - g.cy) > 4) g.moved = true;
235 + if (v.k > 1 || g.moved) {
236 + v.tx += dx * s;
237 + v.ty += dy * s;
238 + }
239 + g.lastX = e.clientX;
240 + g.lastY = e.clientY;
241 + }
242 + clampView();
243 + schedule();
244 + };
245 + const endGesture = (e: RPointerEvent<HTMLDivElement>) => {
246 + pointers.current.delete(e.pointerId);
247 + if (pointers.current.size === 0) {
248 + const moved = gesture.current?.moved ?? false;
249 + gesture.current = null;
250 + setZoom(view.current.k);
251 + if (moved) lastTap.current = null;
252 + else if (downIso.current) countryTap(downIso.current);
253 + downIso.current = null;
254 + } else if (pointers.current.size === 1) {
255 + const [p] = Array.from(pointers.current.values()) as [{ x: number; y: number }];
256 + const v = view.current;
257 + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: p.x, cy: p.y, lastX: p.x, lastY: p.y, moved: true };
258 + }
259 + };
260 +
261 + const place = useCallback(
262 + (e: RPointerEvent<SVGPathElement>, iso: string, sticky: boolean) => {
263 + const box = wrap.current?.getBoundingClientRect();
264 + if (!box) return;
265 + onHover({ iso3: iso, x: e.clientX - box.left, y: e.clientY - box.top, sticky });
266 + },
267 + [onHover],
268 + );
269 +
270 + const countryTap = (iso: string) => {
271 + const now = performance.now();
272 + if (lastTap.current && lastTap.current.iso === iso && now - lastTap.current.t < 380) {
273 + lastTap.current = null;
274 + onOpen?.(iso);
275 + return;
276 + }
277 + lastTap.current = { iso, t: now };
278 + onSelect(iso);
279 + };
280 +
281 + return (
282 + <div ref={wrap} className={cn('relative h-full w-full touch-none select-none overflow-hidden', className)} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} onPointerLeave={() => onHover(null)}>
283 + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('explorer.map.aria')} preserveAspectRatio="xMidYMid meet">
284 + <defs>
285 + <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
286 + <rect width="6" height="6" fill="var(--nodata)" />
287 + <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />
288 + </pattern>
289 + </defs>
290 + <g ref={gRef} style={{ ['--k' as string]: 1 }}>
291 + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
292 + <g stroke="var(--map-stroke)" strokeWidth={0.7} strokeLinejoin="round">
293 + {features.map((f, i) => {
294 + const cls = f.iso3 ? classOf.get(f.iso3) ?? null : null;
295 + const sel = f.iso3 != null && f.iso3 === selectedId;
296 + return (
297 + <path
298 + key={f.iso3 ?? `${f.name}-${i}`}
299 + d={f.d}
300 + fill={cls != null ? seqVar(stepFor(cls, k)) : `url(#${id}-hatch)`}
301 + className={cn('outline-none transition-[fill] duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', f.iso3 && 'cursor-pointer', sel && 'stroke-ink [stroke-width:1.6]')}
302 + vectorEffect="non-scaling-stroke"
303 + tabIndex={f.iso3 ? 0 : -1}
304 + role={f.iso3 ? 'button' : undefined}
305 + aria-label={f.iso3 ? labelOf(f.iso3) : undefined}
306 + aria-pressed={sel || undefined}
307 + onPointerMove={(e) => {
308 + if (e.pointerType === 'mouse' && f.iso3 && !gesture.current?.moved) place(e, f.iso3, false);
309 + }}
310 + onPointerEnter={(e) => {
311 + if (e.pointerType === 'mouse' && f.iso3) place(e, f.iso3, false);
312 + }}
313 + data-iso={f.iso3 ?? undefined}
314 + onKeyDown={(e) => {
315 + if ((e.key === 'Enter' || e.key === ' ') && f.iso3) {
316 + e.preventDefault();
317 + onSelect(f.iso3);
318 + }
319 + }}
320 + onFocus={(e) => {
321 + if (!f.iso3 || touch.current) return;
322 + const b = e.currentTarget.getBoundingClientRect();
323 + const box = wrap.current?.getBoundingClientRect();
324 + if (box) onHover({ iso3: f.iso3, x: b.left - box.left + b.width / 2, y: b.top - box.top, sticky: false });
325 + }}
326 + />
327 + );
328 + })}
329 + </g>
330 + </g>
331 + </svg>
332 + <div className="absolute bottom-3 right-3 flex flex-col gap-1" role="group" aria-label={t('explorer.map.zoom')}>
333 + <button type="button" onClick={() => zoomAt(1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom >= MAX_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomIn')}>
334 + <Plus size={16} aria-hidden />
335 + </button>
336 + <button type="button" onClick={() => zoomAt(1 / 1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom <= MIN_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomOut')}>
337 + <Minus size={16} aria-hidden />
338 + </button>
339 + <button type="button" onClick={reset} disabled={zoom === 1} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.reset')}>
340 + <RotateCcw size={15} aria-hidden />
341 + </button>
342 + </div>
343 + </div>
344 + );
345 +}
added apps/web/src/components/explorer/options.ts +50 −0
@@ -0,0 +1,50 @@
1 +/**
2 + * Server-safe helpers for the explorer pages (no 'use client' — the shared `toIndicatorOption` in
3 + * components/controls/indicator-select.tsx and `parseYear` in lib/url-state.ts live in client modules and cannot
4 + * be invoked from server components).
5 + */
6 +import type { IndicatorOption } from '@/components/controls/indicator-select';
7 +import type { IndicatorSummary } from '@/lib/types';
8 +
9 +export function indicatorOption(i: IndicatorSummary): IndicatorOption {
10 + return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries };
11 +}
12 +
13 +export function parseYearParam(v: string | null | undefined): number | null {
14 + if (!v) return null;
15 + const n = Number(v);
16 + return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null;
17 +}
18 +
19 +const SLUG = /^[a-z0-9][a-z0-9-]*$/;
20 +export function slugParam(v: string | null | undefined, fallback: string | null = null): string | null {
21 + const s = (v ?? '').toLowerCase();
22 + return SLUG.test(s) ? s : fallback;
23 +}
24 +
25 +export function listParam(v: string | null | undefined, max = 8): string[] {
26 + if (!v) return [];
27 + const out: string[] = [];
28 + for (const part of v.split(',')) {
29 + const s = part.trim();
30 + if (s && /^[A-Za-z0-9-]+$/.test(s) && !out.includes(s)) out.push(s);
31 + if (out.length >= max) break;
32 + }
33 + return out;
34 +}
35 +
36 +export type SP = Record<string, string | string[] | undefined>;
37 +export function firstParam(sp: SP, k: string): string | null {
38 + const v = sp[k];
39 + return (Array.isArray(v) ? v[0] : v) ?? null;
40 +}
41 +
42 +/** Annual indicators with enough countries for cross-country views. */
43 +export function explorerIndicators(items: IndicatorSummary[], minCountries = 20): IndicatorOption[] {
44 + return items.filter((i) => (i.frequency ?? 'A') === 'A' && (i.n_countries ?? 0) >= minCountries).map(indicatorOption);
45 +}
46 +
47 +export type ExplorerView = 'map' | 'rank' | 'trend' | 'distribution';
48 +export const EXPLORER_VIEWS: ExplorerView[] = ['map', 'rank', 'trend', 'distribution'];
49 +export const DEFAULT_EXPLORER_INDICATOR = 'gdp-per-capita-ppp';
50 +export const DEFAULT_TRAJ = { x: 'gdp-per-capita-ppp', y: 'life-expectancy', size: 'population' } as const;
added apps/web/src/components/explorer/scatter-view.tsx +264 −0
@@ -0,0 +1,264 @@
1 +'use client';
2 +import { ArrowRight, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useMemo, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientAnalytics } from '@/lib/client-api-analytics';
7 +import { cn } from '@/lib/cn';
8 +import { fixed, formatValue, grouped } from '@/lib/format';
9 +import { regionShort } from '@/lib/regions';
10 +import { routes } from '@/lib/site';
11 +import { useUrlState } from '@/lib/url-state';
12 +import type { FormatSpec } from '@/lib/types';
13 +import type { RelatedResponse, ScatterResponse } from '@/lib/types-analytics';
14 +import type { RegionItem } from '@/lib/types-explore';
15 +import { BubbleChart, type BubbleFit, type BubblePoint } from '@/components/charts/bubble-chart';
16 +import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select';
17 +import { useProvenance } from '@/components/data/provenance-context';
18 +import { DEFAULT_TRAJ } from './options';
19 +import { useIsDesktop } from './use-media';
20 +import { BottomSheet } from '@/components/data/bottom-sheet';
21 +
22 +export 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 +}
32 +
33 +function 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 +}
36 +
37 +/** Cross-section scatter with descriptive statistics; every control is in the URL. */
38 +export 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);
57 +
58 + useEffect(() => {
59 + if (cache[key] !== undefined) return;
60 + abort.current?.abort();
61 + const ctrl = new AbortController();
62 + abort.current = ctrl;
63 + setLoading(true);
64 + clientAnalytics
65 + .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]);
75 +
76 + useEffect(() => {
77 + if (related[x] !== undefined) return;
78 + const ctrl = new AbortController();
79 + clientAnalytics
80 + .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]);
85 +
86 + 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;
115 +
116 + 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;
152 +
153 + 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 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>
198 +
199 + <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.items
242 + .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 +}
added apps/web/src/components/explorer/trajectories-view.tsx +236 −0
@@ -0,0 +1,236 @@
1 +'use client';
2 +import { Check, RotateCcw, Share2, SlidersHorizontal, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useEffect, useMemo, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientAnalytics } from '@/lib/client-api-analytics';
7 +import { cn } from '@/lib/cn';
8 +import { grouped } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import { useUrlState } from '@/lib/url-state';
11 +import type { FormatSpec } from '@/lib/types';
12 +import type { TrajectoryResponse } from '@/lib/types-analytics';
13 +import type { RegionItem } from '@/lib/types-explore';
14 +import { BubbleChart, type BubblePoint } from '@/components/charts/bubble-chart';
15 +import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select';
16 +import { YearSlider } from '@/components/controls/year-slider';
17 +import { BottomSheet } from '@/components/data/bottom-sheet';
18 +import { EntityPicker } from '@/components/explore/entity-picker';
19 +import { useProvenance } from '@/components/data/provenance-context';
20 +import { DEFAULT_TRAJ } from './options';
21 +
22 +export interface TrajectoriesState {
23 + x: string;
24 + y: string;
25 + size: string;
26 + year: number | null;
27 + group: string;
28 + select: string[];
29 +}
30 +
31 +function 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 +}
34 +
35 +/** Gapminder-style animated bubbles: one /trajectory payload per (x, y, size, group); the year only re-indexes arrays. */
36 +export 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 + }, []);
58 +
59 + useEffect(() => {
60 + if (cache[key] !== undefined) return;
61 + abort.current?.abort();
62 + const ctrl = new AbortController();
63 + abort.current = ctrl;
64 + setLoading(true);
65 + clientAnalytics
66 + .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]);
76 +
77 + 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;
85 +
86 + 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;
111 +
112 + 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]);
130 +
131 + 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 + );
166 +
167 + 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>
192 +
193 + <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>
216 +
217 + <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>
222 +
223 + <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 +}
added apps/web/src/components/explorer/use-media.ts +15 −0
@@ -0,0 +1,15 @@
1 +'use client';
2 +import { useEffect, useState } from 'react';
3 +
4 +/** True at ≥ 768 px (Tailwind `md`). False during SSR and the first client render (mobile-first). */
5 +export function useIsDesktop(): boolean {
6 + const [desktop, setDesktop] = useState(false);
7 + useEffect(() => {
8 + const mq = window.matchMedia('(min-width: 768px)');
9 + const apply = () => setDesktop(mq.matches);
10 + apply();
11 + mq.addEventListener('change', apply);
12 + return () => mq.removeEventListener('change', apply);
13 + }, []);
14 + return desktop;
15 +}
added apps/web/src/components/explorer/world-explorer.tsx +343 −0
@@ -0,0 +1,343 @@
1 +'use client';
2 +import { BarChart3, ChevronDown, ChevronUp, Globe2, Info, LineChart as LineIcon, Sigma, Table2 } from 'lucide-react';
3 +import { useRouter } from 'next/navigation';
4 +import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5 +import { t } from '@/i18n';
6 +import { clientAnalytics } from '@/lib/client-api-analytics';
7 +import { cn } from '@/lib/cn';
8 +import { formatValue, grouped } from '@/lib/format';
9 +import { routes } from '@/lib/site';
10 +import { useUrlState } from '@/lib/url-state';
11 +import type { FormatSpec } from '@/lib/types';
12 +import type { FramesResponse, PointCountry } from '@/lib/types-analytics';
13 +import type { RegionItem } from '@/lib/types-explore';
14 +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';
15 +import { YearSlider } from '@/components/controls/year-slider';
16 +import { BottomSheet } from '@/components/data/bottom-sheet';
17 +import { EntityPicker } from '@/components/explore/entity-picker';
18 +import type { BaseFeature } from '@/components/indicators/indicator-map';
19 +import { useProvenance } from '@/components/data/provenance-context';
20 +import { classIndex } from './geo';
21 +import { DEFAULT_EXPLORER_INDICATOR, type ExplorerView } from './options';
22 +import { useIsDesktop } from './use-media';
23 +import { MapCanvas, type MapHover } from './map-canvas';
24 +import { ClassLegend, CountryPanel, MapTooltip, type CountryYearInfo } from './explorer-panels';
25 +import { DistributionView, RankView, TrendView, type YearRow } from './explorer-views';
26 +
27 +const QUICK = ['population', 'gdp', 'gdp-per-capita-ppp', 'gdp-growth', 'inflation', 'life-expectancy', 'fertility-rate', 'internet-users', 'co2-per-capita', 'renewable-electricity-share', 'unemployment-rate', 'population-growth'];
28 +
29 +export interface ExplorerProps {
30 + features: BaseFeature[];
31 + sphere: string;
32 + countries: PointCountry[];
33 + indicators: IndicatorOption[];
34 + groups: RegionItem[];
35 + initial: FramesResponse | null;
36 + initialState: { indicator: string; year: number | null; view: ExplorerView; country: string | null; group: string };
37 +}
38 +
39 +function specOf(fr: FramesResponse | null, fallbackName: string): FormatSpec {
40 + const i = fr?.indicator;
41 + return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallbackName, higher_is_better: i?.higher_is_better };
42 +}
43 +
44 +/**
45 + * World Explorer: indicator × year × view, all in the URL. One /frames request per indicator (cached in state)
46 + * feeds the map, the rank list, the trend and the distribution — no further requests while scrubbing.
47 + */
48 +export function WorldExplorer({ features, sphere, countries, indicators, groups, initial, initialState }: ExplorerProps) {
49 + const router = useRouter();
50 + const { get, getNum, set } = useUrlState();
51 + const { open: openProv } = useProvenance();
52 + const indicator = get('indicator') ?? initialState.indicator;
53 + const group = get('group') ?? initialState.group;
54 + const view = ((get('view') as ExplorerView | null) ?? initialState.view) as ExplorerView;
55 + const selectedId = (get('country') ?? initialState.country)?.toUpperCase() ?? null;
56 + const cacheKey = `${indicator}|${group}`;
57 + const [cache, setCache] = useState<Record<string, FramesResponse | null>>(() => (initial ? { [`${initialState.indicator}|${initialState.group}`]: initial } : {}));
58 + const [loading, setLoading] = useState(false);
59 + const [hover, setHover] = useState<MapHover | null>(null);
60 + const [focus, setFocus] = useState<string | null>(null);
61 + const [sheet, setSheet] = useState<'country' | 'table' | null>(null);
62 + const [legendOpen, setLegendOpen] = useState(false);
63 + const [playing, setPlaying] = useState(false);
64 + const abort = useRef<AbortController | null>(null);
65 + const desktop = useIsDesktop();
66 +
67 + useEffect(() => {
68 + if (cache[cacheKey] !== undefined) return;
69 + abort.current?.abort();
70 + const ctrl = new AbortController();
71 + abort.current = ctrl;
72 + setLoading(true);
73 + clientAnalytics
74 + .indicatorFrames(indicator, { group: group !== 'world' ? group : null }, ctrl.signal)
75 + .then((r) => setCache((c) => ({ ...c, [cacheKey]: r })))
76 + .catch((e) => {
77 + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [cacheKey]: null }));
78 + })
79 + .finally(() => {
80 + if (!ctrl.signal.aborted) setLoading(false);
81 + });
82 + return () => ctrl.abort();
83 + }, [cacheKey, indicator, group, cache]);
84 +
85 + const frames = cache[cacheKey] ?? null;
86 + const years = frames?.years ?? [];
87 + const urlYear = getNum('year') ?? initialState.year;
88 + const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear());
89 + const yi = years.indexOf(year);
90 + const spec = useMemo(() => specOf(frames, indicators.find((i) => i.slug === indicator)?.name ?? indicator), [frames, indicators, indicator]);
91 + const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]);
92 + const breaks = frames?.legend.breaks ?? [];
93 + const k = breaks.length + 1;
94 +
95 + /** Values of the current year, ranks (world + region) and the class per country. */
96 + const yearData = useMemo(() => {
97 + const rows: YearRow[] = [];
98 + const cls = new Map<string, number | null>();
99 + if (!frames || yi < 0) return { rows, cls, rankOf: new Map<string, { world: number; region: number | null; nRegion: number }>() };
100 + for (const [iso, arr] of Object.entries(frames.values)) {
101 + const v = arr[yi];
102 + const c = byId.get(iso);
103 + if (v != null && Number.isFinite(v) && c) rows.push({ country: c, value: v, rank: 0 });
104 + cls.set(iso, v != null && Number.isFinite(v) ? classIndex(v, breaks) : null);
105 + }
106 + const desc = spec.higher_is_better !== false;
107 + rows.sort((a, b) => (desc ? b.value - a.value : a.value - b.value));
108 + const rankOf = new Map<string, { world: number; region: number | null; nRegion: number }>();
109 + const regionCount = new Map<string, number>();
110 + rows.forEach((r, i) => {
111 + r.rank = i + 1;
112 + const reg = r.country.region ?? '';
113 + const rr = (regionCount.get(reg) ?? 0) + 1;
114 + regionCount.set(reg, rr);
115 + rankOf.set(r.country.id, { world: i + 1, region: reg ? rr : null, nRegion: 0 });
116 + });
117 + for (const [iso, info] of rankOf) info.nRegion = regionCount.get(byId.get(iso)?.region ?? '') ?? 0;
118 + return { rows, cls, rankOf };
119 + }, [frames, yi, byId, breaks, spec.higher_is_better]);
120 +
121 + const infoFor = useCallback(
122 + (iso: string): CountryYearInfo | null => {
123 + const c = byId.get(iso);
124 + if (!c) return null;
125 + const arr = frames?.values[iso] ?? [];
126 + const series = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: arr[i] ?? null })).filter((p) => p.value != null);
127 + const rk = yearData.rankOf.get(iso);
128 + const last = series[series.length - 1];
129 + return {
130 + country: c,
131 + value: yi >= 0 ? arr[yi] ?? null : null,
132 + year,
133 + rankWorld: rk?.world ?? null,
134 + nWorld: yearData.rows.length,
135 + rankRegion: rk?.region ?? null,
136 + nRegion: rk?.nRegion ?? 0,
137 + series,
138 + firstYear: series[0]?.year ?? null,
139 + lastYear: last?.year ?? null,
140 + latestValue: last?.value ?? null,
141 + latestYear: last?.year ?? null,
142 + };
143 + },
144 + [byId, frames, years, yearData, yi, year],
145 + );
146 +
147 + const selected = selectedId ? infoFor(selectedId) : null;
148 + const hoverInfo = hover ? infoFor(hover.iso3) : null;
149 + const indicatorName = frames?.indicator.name ?? spec.name ?? indicator;
150 +
151 + const setIndicator = (slug: string) => set({ indicator: slug === DEFAULT_EXPLORER_INDICATOR ? null : slug }, 0);
152 + const setYear = (y: number) => set({ year: years.length && y === years[years.length - 1] ? null : y }, playing ? 250 : 80);
153 + const setView = (v: ExplorerView) => set({ view: v === 'map' ? null : v }, 0);
154 + const selectCountry = (iso: string | null) => {
155 + set({ country: iso ? iso.toLowerCase() : null }, 0);
156 + if (iso) setSheet('country');
157 + else setSheet(null);
158 + };
159 + const openCountry = (iso: string) => {
160 + const c = byId.get(iso);
161 + if (c?.slug) router.push(routes.country(c.slug));
162 + };
163 + const flyTo = (iso: string) => {
164 + setFocus(null);
165 + requestAnimationFrame(() => setFocus(iso));
166 + selectCountry(iso);
167 + };
168 +
169 + const viewOptions = [
170 + { value: 'map' as const, label: t('explorer.view.map'), icon: <Globe2 size={14} aria-hidden /> },
171 + { value: 'rank' as const, label: t('explorer.view.rank'), icon: <BarChart3 size={14} aria-hidden /> },
172 + { value: 'trend' as const, label: t('explorer.view.trend'), icon: <LineIcon size={14} aria-hidden /> },
173 + { value: 'distribution' as const, label: t('explorer.view.distribution'), icon: <Sigma size={14} aria-hidden /> },
174 + ];
175 + const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world'), kind: 'world' }, ...groups.filter((g) => g.kind === 'region' || g.kind === 'income' || g.kind === 'continent' || g.kind === 'org').map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id, kind: g.kind ?? 'org' }))], [groups]);
176 + const nYear = yearData.rows.length;
177 + const compareHref = selected ? routes.compare(selected.country.slug ?? selected.country.id.toLowerCase()) : routes.compare();
178 + const trajHref = selected ? routes.trajectories({ year, group: group !== 'world' ? group : null }) + `&select=${selected.country.id}` : routes.trajectories();
179 +
180 + const provPayload = frames
181 + ? {
182 + indicator: { slug: indicator, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' as const, higher_is_better: spec.higher_is_better },
183 + value: null,
184 + country: null,
185 + downloadHref: routes.indicatorDownload(indicator),
186 + }
187 + : null;
188 +
189 + const countryPanel = selected ? <CountryPanel info={selected} spec={spec} indicatorSlug={indicator} indicatorName={indicatorName} provenance={frames?.provenance ?? null} onClose={() => selectCountry(null)} onCompareHref={compareHref} trajectoriesHref={trajHref} closeClassName="hidden md:grid" /> : null;
190 +
191 + const legend = frames ? <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} /> : null;
192 +
193 + return (
194 + <div className="flex h-[calc(100dvh-52px)] min-h-[520px] flex-col bg-paper md:h-[calc(100dvh-56px)] md:flex-row" data-testid="world-explorer">
195 + {/* Left rail (desktop) */}
196 + <aside className="hidden w-[19rem] shrink-0 flex-col border-r border-rule md:flex" aria-label={t('explorer.rail')}>
197 + <div className="space-y-3 p-4">
198 + <div>
199 + <h1 className="display text-xl text-ink">{t('explorer.title')}</h1>
200 + <p className="mt-0.5 text-xs text-ink-3">{t('explorer.lede')}</p>
201 + </div>
202 + <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} label={t('control.indicator')} size="sm" />
203 + <label className="flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2">
204 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('explorer.group')}</span>
205 + <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('explorer.group')}>
206 + {groupOptions.map((g) => (
207 + <option key={g.slug} value={g.slug}>
208 + {g.name}
209 + </option>
210 + ))}
211 + </select>
212 + </label>
213 + <EntityPicker type="country" placeholder={t('explorer.searchCountry')} onPick={(e) => flyTo(e.id)} size="sm" />
214 + </div>
215 + <div className="min-h-0 flex-1 overflow-y-auto border-t border-rule px-4 py-3">
216 + <div className="eyebrow mb-1.5">{t('explorer.quick')}</div>
217 + <ul className="flex flex-wrap gap-1">
218 + {QUICK.filter((q) => indicators.some((i) => i.slug === q)).map((q) => {
219 + const o = indicators.find((i) => i.slug === q)!;
220 + return (
221 + <li key={q}>
222 + <button type="button" onClick={() => setIndicator(q)} className={cn('inline-flex h-8 items-center rounded-sm border px-2 text-xs', indicator === q ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>
223 + {o.short_name ?? o.name}
224 + </button>
225 + </li>
226 + );
227 + })}
228 + </ul>
229 + {frames ? (
230 + <div className="mt-5">
231 + <div className="eyebrow mb-1.5">{t('explorer.legendTitle', { name: spec.name ?? '' })}</div>
232 + <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} className="flex-col items-start gap-y-1.5 [&>li]:text-xs" />
233 + <p className="mt-2 text-2xs text-ink-3">{t('explorer.legendNote')}</p>
234 + </div>
235 + ) : null}
236 + {frames?.provenance ? (
237 + <button type="button" onClick={() => provPayload && openProv(provPayload)} className="mt-4 inline-flex min-h-[32px] items-center gap-1 text-left text-2xs text-ink-2 hover:text-accent" aria-label={t('common.openProvenance')}>
238 + <Info size={12} aria-hidden className="shrink-0" />
239 + <span className="truncate">
240 + {t('common.source')}: {[frames.provenance.source_name, frames.provenance.dataset].filter(Boolean).join(' — ')} · {frames.provenance.series_code}
241 + </span>
242 + </button>
243 + ) : null}
244 + </div>
245 + </aside>
246 +
247 + {/* Main column */}
248 + <div className="relative flex min-h-0 min-w-0 flex-1 flex-col">
249 + {/* Top bar */}
250 + <div className="flex items-center gap-2 border-b border-rule px-3 py-2 md:px-4">
251 + <div className="min-w-0 flex-1 md:hidden">
252 + <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} size="sm" />
253 + </div>
254 + <div className="hidden min-w-0 flex-1 items-baseline gap-2 md:flex">
255 + <h2 className="truncate text-base font-semibold text-ink">{indicatorName}</h2>
256 + <span className="tnum shrink-0 text-xs text-ink-3">{frames ? t('explorer.countriesYear', { n: grouped(nYear), year }) : loading ? t('common.loading') : ''}</span>
257 + </div>
258 + <Segmented value={view} onChange={setView} options={viewOptions.map((o) => ({ ...o, label: o.label }))} label={t('control.view')} size="sm" className="shrink-0 [&>button]:h-11 md:[&>button]:h-8 [&_span]:hidden sm:[&_span]:inline" />
259 + </div>
260 +
261 + {/* Stage */}
262 + <div className={cn('relative min-h-0 flex-1', view !== 'map' && 'overflow-y-auto')} aria-busy={loading}>
263 + {frames === null && !loading ? (
264 + <div className="grid h-full place-items-center px-6 text-center text-sm text-ink-3">{t('explorer.noFrames', { indicator: indicatorName })}</div>
265 + ) : view === 'map' ? (
266 + <>
267 + <MapCanvas features={features} sphere={sphere} classOf={yearData.cls} k={k} selectedId={selectedId} focusId={focus} onHover={setHover} onSelect={(iso) => selectCountry(iso)} onOpen={openCountry} labelOf={(iso) => `${byId.get(iso)?.name ?? iso}: ${formatValue(frames?.values[iso]?.[yi] ?? null, spec)} (${year})`} className={cn(loading && 'opacity-60 transition-opacity')} />
268 + {hover && hoverInfo && !(sheet === 'country' && selectedId === hover.iso3) ? <MapTooltip info={hoverInfo} spec={spec} x={hover.x} y={hover.y} sticky={hover.sticky} onOpen={() => selectCountry(hover.iso3)} /> : null}
269 + {/* Year watermark + count (map only) */}
270 + <div className="pointer-events-none absolute left-3 top-2 md:left-4">
271 + <div className="display tnum text-4xl leading-none text-ink/80 md:text-6xl">{year}</div>
272 + <div className="tnum mt-1 text-2xs text-ink-3 md:text-xs">{t('explorer.countriesYear', { n: grouped(nYear), year })}</div>
273 + </div>
274 + {/* Mobile legend (collapsible) */}
275 + {frames ? (
276 + <div className="absolute bottom-2 left-2 right-14 md:hidden">
277 + <button type="button" onClick={() => setLegendOpen((o) => !o)} aria-expanded={legendOpen} className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-xs text-ink-2 shadow-pop">
278 + {legendOpen ? <ChevronDown size={14} aria-hidden /> : <ChevronUp size={14} aria-hidden />}
279 + {t('common.legend')}
280 + </button>
281 + {legendOpen ? (
282 + <div className="mt-1 rounded-sm border border-rule bg-surface/95 p-2 shadow-pop">
283 + <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} compact />
284 + </div>
285 + ) : null}
286 + </div>
287 + ) : null}
288 + <button type="button" onClick={() => setSheet('table')} className="absolute right-3 top-2 hidden h-8 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-2xs text-ink-2 shadow-pop hover:text-ink md:inline-flex" aria-label={t('common.viewTable')}>
289 + <Table2 size={12} aria-hidden /> {t('common.viewTable')}
290 + </button>
291 + </>
292 + ) : view === 'rank' ? (
293 + <RankView rows={yearData.rows} year={year} spec={spec} selectedId={selectedId} indicatorSlug={indicator} />
294 + ) : view === 'trend' ? (
295 + <TrendView years={years} values={frames?.values ?? {}} spec={spec} selected={selected ? { country: selected.country, series: frames?.values[selected.country.id] ?? [] } : null} subject={indicatorName} />
296 + ) : (
297 + <DistributionView rows={yearData.rows} year={year} spec={spec} selected={selected ? { country: selected.country, value: selected.value } : null} />
298 + )}
299 + </div>
300 +
301 + {/* Time machine */}
302 + <div className="border-t border-rule bg-paper px-3 py-2 md:px-4 safe-bottom">
303 + <YearSlider years={years} year={year} onChange={setYear} compact interval={650} onPlayingChange={setPlaying} label={t('explorer.timeMachine')} />
304 + </div>
305 +
306 + {/* Desktop drawer */}
307 + {selected ? (
308 + <div className="absolute right-0 top-[45px] bottom-[57px] z-20 hidden w-[22rem] border-l border-rule bg-surface/95 p-4 shadow-pop backdrop-blur md:block">{countryPanel}</div>
309 + ) : null}
310 + </div>
311 +
312 + {/* Phone sheets */}
313 + <BottomSheet open={!desktop && sheet === 'country' && !!selected} onClose={() => setSheet(null)} side="drawer" title={<span className="sr-only">{selected?.country.name}</span>}>
314 + {countryPanel}
315 + </BottomSheet>
316 + <BottomSheet open={sheet === 'table'} onClose={() => setSheet(null)} side="drawer" title={t('explorer.table.title', { name: spec.name ?? '', year })}>
317 + <table className="w-full text-sm tnum">
318 + <caption className="sr-only">{t('explorer.table.title', { name: spec.name ?? '', year })}</caption>
319 + <thead>
320 + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">
321 + <th scope="col" className="py-1 pr-2 font-medium">#</th>
322 + <th scope="col" className="py-1 pr-2 font-medium">{t('common.country')}</th>
323 + <th scope="col" className="py-1 text-right font-medium">{spec.name}</th>
324 + </tr>
325 + </thead>
326 + <tbody className="divide-y divide-rule">
327 + {yearData.rows.map((r) => (
328 + <tr key={r.country.id} className={cn(r.country.id === selectedId && 'bg-accent-soft/50')}>
329 + <td className="py-1 pr-2 text-ink-3">{r.rank}</td>
330 + <td className="py-1 pr-2 text-ink">
331 + <span aria-hidden>{r.country.flag} </span>
332 + {r.country.name}
333 + </td>
334 + <td className="py-1 text-right text-ink">{formatValue(r.value, spec)}</td>
335 + </tr>
336 + ))}
337 + </tbody>
338 + </table>
339 + {legend ? <div className="mt-3">{legend}</div> : null}
340 + </BottomSheet>
341 + </div>
342 + );
343 +}
modified apps/web/src/i18n/en.flagship.ts +164 −0
@@ -3,6 +3,170 @@
3 3 * Merged into `en.ts`. Owned by the "flagship" stream.
4 4 */
5 5 export const enFlagship = {
6 + // --- world explorer
6 7 'explorer.title': 'World Explorer',
8 + 'explorer.metaTitle': 'World Explorer — Interactive Map of Every Indicator, 1960 to Today',
7 9 'explorer.description': 'Full-screen interactive world map of any indicator, with a year slider from 1960 to today. Google Earth for statistics.',
10 + 'explorer.lede': 'Pick an indicator, scrub through time, tap a country.',
11 + 'explorer.rail': 'Explorer controls',
12 + 'explorer.group': 'Countries',
13 + 'explorer.searchCountry': 'Find a country on the map…',
14 + 'explorer.quick': 'Quick picks',
15 + 'explorer.legendTitle': 'Legend · {name}',
16 + 'explorer.legendNote': 'Quantile classes over all years, so colours stay comparable while you scrub.',
17 + 'explorer.countriesYear': '{n} countries · {year}',
18 + 'explorer.noValueYear': 'No observation for {year}',
19 + 'explorer.noDataYear': 'No observations exist for {indicator} in {year}.',
20 + 'explorer.noFrames': 'No annual series is available for {indicator}.',
21 + 'explorer.timeMachine': 'Time machine',
22 + 'explorer.map.aria': 'World map coloured by the selected indicator; use the table view for the values.',
23 + 'explorer.map.zoom': 'Map zoom',
24 + 'explorer.map.zoomIn': 'Zoom in',
25 + 'explorer.map.zoomOut': 'Zoom out',
26 + 'explorer.map.reset': 'Reset the map view',
27 + 'explorer.view.map': 'Map',
28 + 'explorer.view.rank': 'Rank',
29 + 'explorer.view.trend': 'Trend',
30 + 'explorer.view.distribution': 'Distribution',
31 + 'explorer.rank.world': '{rank} of {n} worldwide',
32 + 'explorer.rank.region': '{rank} in {region}',
33 + 'explorer.rank.sub': '{n} countries with a value in {year}. Rank 1 = highest, or best when the indicator has a direction.',
34 + 'explorer.rank.full': 'Full ranking',
35 + 'explorer.trend.title': '{name} — world median',
36 + 'explorer.trend.sub': 'Countries per year: {min}–{max}',
37 + 'explorer.trend.median': 'World median',
38 + 'explorer.trend.note': 'Computed by CountryAtlas from country values in this snapshot, not a published aggregate.',
39 + 'explorer.dist.title': '{name}, {year}',
40 + 'explorer.dist.sub': '{n} countries · median {median}',
41 + 'explorer.drawer.details': 'Details',
42 + 'explorer.drawer.latest': 'latest {value} ({year})',
43 + 'explorer.drawer.history': 'History {y0}–{y1}',
44 + 'explorer.drawer.sparkAria': '{name}: {indicator} over time',
45 + 'explorer.drawer.open': 'Open country page',
46 + 'explorer.drawer.compare': 'Compare',
47 + 'explorer.drawer.trajectories': 'Add to trajectories',
48 + 'explorer.drawer.series': 'Full series with sources',
49 + 'explorer.table.title': '{name}, {year} — all countries',
50 +
51 + // --- trajectories
52 + 'traj.title': 'Trajectories',
53 + 'traj.metaTitle': 'Country Trajectories — Animated Bubble Chart, 1960 to Today',
54 + 'traj.description': 'Gapminder-style animation: pick two indicators and a bubble size, press play and watch every country move from 1960 to today.',
55 + 'traj.lede': 'Two indicators, one bubble per country, sixty years in motion.',
56 + 'traj.x': 'X axis',
57 + 'traj.y': 'Y axis',
58 + 'traj.size': 'Bubble size',
59 + 'traj.sizeNone': 'Same size',
60 + 'traj.group': 'Countries',
61 + 'traj.select': 'Follow countries',
62 + 'traj.selectHint': 'Up to 4 countries draw a trail.',
63 + 'traj.selected': 'Following',
64 + 'traj.reset': 'Reset',
65 + 'traj.share': 'Share',
66 + 'traj.copied': 'Link copied',
67 + 'traj.controls': 'Chart controls',
68 + 'traj.countries': '{n} countries · {y0}–{y1}',
69 + 'traj.noData': 'Not enough countries report both indicators over the same years.',
70 + 'traj.remove': 'Stop following {name}',
71 + 'traj.note': 'Positions are the values of each year (no interpolation); a bubble disappears when a year is missing. Colours are World Bank regions.',
72 + 'traj.openScatter': 'Open this year as a scatter with statistics',
73 +
74 + // --- scatter
75 + 'scatter.title': 'Scatter explorer',
76 + 'scatter.metaTitle': 'Scatter Explorer — Compare Any Two Indicators Across Countries',
77 + 'scatter.description': 'Plot any indicator against any other for every country, with Pearson and Spearman correlations. Descriptive, not causal.',
78 + 'scatter.lede': 'Any indicator against any other, one dot per country.',
79 + 'scatter.year': 'Year',
80 + 'scatter.fit': 'Regression line',
81 + 'scatter.logX': 'Log x',
82 + 'scatter.logY': 'Log y',
83 + 'scatter.stats.pearson': 'Pearson r',
84 + 'scatter.stats.spearman': 'Spearman ρ',
85 + 'scatter.stats.n': 'Countries',
86 + 'scatter.stats.r2': 'R²',
87 + 'scatter.stats.year': 'Year used',
88 + 'scatter.caveat': 'Correlation does not imply causation. Descriptive only.',
89 + 'scatter.nearest': 'Each axis uses the country’s value for {year} or the nearest year within {n}.',
90 + 'scatter.related': 'Related to {name}',
91 + 'scatter.relatedHint': 'Strongest cross-sectional associations — tap one to put it on the Y axis.',
92 + 'scatter.noData': 'No country reports both indicators for {year}.',
93 + 'scatter.openTrajectories': 'Animate over time',
94 + 'scatter.drawer.both': '{x}: {vx} ({yx}) · {y}: {vy} ({yy})',
95 + 'scatter.selected': 'Selected country',
96 +
97 + // --- finder
98 + 'finder.title': 'Country finder',
99 + 'finder.metaTitle': 'Country Finder — Filter the World Like a Database',
100 + 'finder.description': 'Structured questions without AI: countries with GDP per capita above 40,000, population above 10 million, life expectancy above 80… combine filters and get a map, a table and a download.',
101 + 'finder.lede': 'Countries with… combine conditions and see who matches.',
102 + 'finder.add': 'Add a condition',
103 + 'finder.indicator': 'Indicator',
104 + 'finder.op': 'Condition',
105 + 'finder.value': 'Value',
106 + 'finder.value2': 'and',
107 + 'finder.remove': 'Remove condition',
108 + 'finder.mode': 'Match',
109 + 'finder.mode.and': 'All conditions',
110 + 'finder.mode.or': 'Any condition',
111 + 'finder.region': 'Region',
112 + 'finder.income': 'Income group',
113 + 'finder.any': 'Any',
114 + 'finder.op.gt': 'greater than',
115 + 'finder.op.gte': 'at least',
116 + 'finder.op.lt': 'less than',
117 + 'finder.op.lte': 'at most',
118 + 'finder.op.eq': 'equal to',
119 + 'finder.op.between': 'between',
120 + 'finder.presets': 'Try a question',
121 + 'finder.preset.rich-large': 'Rich and large',
122 + 'finder.preset.rich-large.hint': 'GDP per capita above US$40,000 and more than 10 million people',
123 + 'finder.preset.green-connected': 'Green and connected',
124 + 'finder.preset.green-connected.hint': 'Renewables above 50 % of electricity and internet use above 80 %',
125 + 'finder.preset.ageing': 'Ageing societies',
126 + 'finder.preset.ageing.hint': 'Median age above 42 years',
127 + 'finder.preset.young-growing': 'Young and fast-growing',
128 + 'finder.preset.young-growing.hint': 'Median age below 25 and population growth above 2 % a year',
129 + 'finder.preset.long-lives-low-co2': 'Long lives, low emissions',
130 + 'finder.preset.long-lives-low-co2.hint': 'Life expectancy above 80 years and CO₂ below 5 tonnes per person',
131 + 'finder.results': '{n} of {m} countries match',
132 + 'finder.none': 'No country matches every condition. Loosen a threshold or switch to “any condition”.',
133 + 'finder.empty': 'Add a condition to start. Values compare against each country’s latest observation.',
134 + 'finder.download': 'Download CSV',
135 + 'finder.compare': 'Compare the top {n}',
136 + 'finder.map': 'Matching countries',
137 + 'finder.table.country': 'Country',
138 + 'finder.sort': 'Sort by {name}',
139 + 'finder.latestNote': 'Latest available year per country (shown next to each value).',
140 + 'finder.loading': 'Searching…',
141 + 'finder.error': 'The finder is unavailable right now.',
142 + 'finder.unit': 'Unit: {unit}',
143 +
144 + // --- extremes
145 + 'extremes.title': 'Extremes',
146 + 'extremes.metaTitle': 'Extremes — Fastest Changing Countries Over 1, 5, 10 and 25 Years',
147 + 'extremes.description': 'The largest increases and decreases in the world: fastest ageing, fastest urbanising, biggest life-expectancy gains, largest CO₂ reductions and more.',
148 + 'extremes.lede': 'The largest moves in the world, by window.',
149 + 'extremes.window': 'Window',
150 + 'extremes.window.1': '1 year',
151 + 'extremes.window.5': '5 years',
152 + 'extremes.window.10': '10 years',
153 + 'extremes.window.25': '25 years',
154 + 'extremes.window.since1990': 'Since 1990',
155 + 'extremes.topic': 'Topic',
156 + 'extremes.allTopics': 'All topics',
157 + 'extremes.minPop': 'Countries above 1M inhabitants',
158 + 'extremes.allCountries': 'All countries and territories',
159 + 'extremes.facets': '{n} facets · {y0}–{y1}',
160 + 'extremes.direction.up': 'Largest increases',
161 + 'extremes.direction.down': 'Largest decreases',
162 + 'extremes.n': '{n} countries evaluated',
163 + 'extremes.change.abs': 'change',
164 + 'extremes.change.pct': '% change',
165 + 'extremes.change.points': 'points',
166 + 'extremes.openRanking': 'Open ranking',
167 + 'extremes.explore': 'Explore on the map',
168 + 'extremes.none': 'No facet has enough countries for this window and topic.',
169 + 'extremes.semantics': 'Increase and decrease are directions of change, not judgements — a rising share of renewables and a rising debt ratio are both “increases”.',
170 + 'extremes.from': 'from',
171 + 'extremes.to': 'to',
8 172 } as const;
added apps/web/src/lib/api-analytics.ts +76 −0
@@ -0,0 +1,76 @@
1 +import 'server-only';
2 +import { request } from './api';
3 +import { finderQuery, type FinderFilterInput } from './finder-query';
4 +import type {
5 + CountryQualityResponse,
6 + DistributionResponse,
7 + ExtremesResponse,
8 + ExtremesWindow,
9 + FinderResponse,
10 + FramesResponse,
11 + IndicatorQualityResponse,
12 + MoverCategory,
13 + MoverKindFilter,
14 + MoverWindow,
15 + MoversResponse,
16 + PeersResponse,
17 + PulseResponse,
18 + RaceResponse,
19 + RegionCompareResponse,
20 + RelatedResponse,
21 + ScatterResponse,
22 + StoryResponse,
23 + TrajectoryResponse,
24 + UpdatesResponse,
25 +} from './types-analytics';
26 +
27 +/**
28 + * Server-side endpoints of the analytics API (API 1.1 — docs/API.md § Analytics). Same conventions as
29 + * `lib/api.ts` (`request<T>` → ApiError, ISR 900 s). Browser code uses `lib/client-api-analytics.ts`.
30 + */
31 +export type { FinderFilterInput } from './finder-query';
32 +
33 +export const apiAnalytics = {
34 + pulse: () => request<PulseResponse>('/pulse'),
35 +
36 + movers: (q: { window?: MoverWindow; category?: MoverCategory; kind?: MoverKindFilter; limit?: number; min_population?: number | null } = {}) =>
37 + request<MoversResponse>('/movers', { window: q.window, category: q.category, kind: q.kind, limit: q.limit, min_population: q.min_population ?? undefined }),
38 +
39 + extremes: (q: { window?: ExtremesWindow | string; topic?: string | null; min_population?: number | null } = {}) =>
40 + request<ExtremesResponse>('/extremes', { window: q.window, topic: q.topic ?? undefined, min_population: q.min_population ?? undefined }),
41 +
42 + scatter: (q: { x: string; y: string; size?: string | null; year?: number | null; group?: string | null; log_x?: string | boolean | null; log_y?: string | boolean | null }) =>
43 + request<ScatterResponse>('/scatter', { x: q.x, y: q.y, size: q.size ?? undefined, year: q.year ?? undefined, group: q.group ?? undefined, log_x: q.log_x ?? undefined, log_y: q.log_y ?? undefined }),
44 +
45 + trajectory: (q: { x?: string; y?: string; size?: string | null; from?: number | null; to?: number | null; group?: string | null } = {}) =>
46 + request<TrajectoryResponse>('/trajectory', { x: q.x, y: q.y, size: q.size ?? undefined, from: q.from ?? undefined, to: q.to ?? undefined, group: q.group ?? undefined }),
47 +
48 + finder: (filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}) =>
49 + request<FinderResponse>(`/finder${finderQuery(filters, q)}`),
50 +
51 + peers: (q: { y?: string; x?: string; year?: number | null; method?: 'theil-sen' | 'ols'; log_x?: string | boolean | null } = {}) =>
52 + request<PeersResponse>('/peers', { y: q.y, x: q.x, year: q.year ?? undefined, method: q.method, log_x: q.log_x ?? undefined }),
53 +
54 + indicatorRelated: (slug: string, q: { limit?: number; min_n?: number } = {}) => request<RelatedResponse>(`/indicators/${encodeURIComponent(slug)}/related`, q),
55 +
56 + indicatorDistribution: (slug: string, q: { year?: number | null; highlight?: string | null; bins?: number } = {}) =>
57 + request<DistributionResponse>(`/indicators/${encodeURIComponent(slug)}/distribution`, { year: q.year ?? undefined, highlight: q.highlight ?? undefined, bins: q.bins }),
58 +
59 + indicatorFrames: (slug: string, q: { from?: number | null; to?: number | null; step?: number; group?: string | null } = {}) =>
60 + request<FramesResponse>(`/indicators/${encodeURIComponent(slug)}/frames`, { from: q.from ?? undefined, to: q.to ?? undefined, step: q.step, group: q.group ?? undefined }),
61 +
62 + indicatorQuality: (slug: string) => request<IndicatorQualityResponse>(`/indicators/${encodeURIComponent(slug)}/quality`),
63 +
64 + race: (indicator: string, q: { from?: number | null; to?: number | null; top?: number; group?: string | null } = {}) =>
65 + request<RaceResponse>(`/rankings/${encodeURIComponent(indicator)}/race`, { from: q.from ?? undefined, to: q.to ?? undefined, top: q.top, group: q.group ?? undefined }),
66 +
67 + regionsCompare: (a: string, b: string, indicators?: string[]) => request<RegionCompareResponse>('/regions/compare', { a, b, indicators: indicators?.length ? indicators.join(',') : undefined }),
68 +
69 + countryStory: (id: string) => request<StoryResponse>(`/countries/${encodeURIComponent(id)}/story`),
70 +
71 + countryQuality: (id: string) => request<CountryQualityResponse>(`/countries/${encodeURIComponent(id)}/quality`),
72 +
73 + updates: () => request<UpdatesResponse>('/updates', undefined, { revalidate: 300 }),
74 +};
75 +
76 +export type ApiAnalytics = typeof apiAnalytics;
added apps/web/src/lib/client-api-analytics.ts +60 −0
@@ -0,0 +1,60 @@
1 +'use client';
2 +import { finderQuery, type FinderFilterInput } from './finder-query';
3 +import { ClientApiError } from './client-api';
4 +import type {
5 + CountryQualityResponse,
6 + DistributionResponse,
7 + ExtremesResponse,
8 + FinderResponse,
9 + FramesResponse,
10 + IndicatorQualityResponse,
11 + MoversResponse,
12 + PeersResponse,
13 + PulseResponse,
14 + RaceResponse,
15 + RegionCompareResponse,
16 + RelatedResponse,
17 + ScatterResponse,
18 + StoryResponse,
19 + TrajectoryResponse,
20 + UpdatesResponse,
21 +} from './types-analytics';
22 +
23 +/**
24 + * Browser-side fetches for the analytics API (same-origin `/api/v1/*`, rewritten to FastAPI). Every call takes an
25 + * optional AbortSignal so interactive views cancel superseded requests (slider scrubs, selector changes).
26 + */
27 +async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
28 + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal });
29 + if (!res.ok) throw new ClientApiError(res.status, `API ${res.status}`);
30 + return (await res.json()) as T;
31 +}
32 +
33 +function qs(query: Record<string, string | number | boolean | null | undefined>): string {
34 + const p = new URLSearchParams();
35 + for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v));
36 + const s = p.toString();
37 + return s ? `?${s}` : '';
38 +}
39 +
40 +export const clientAnalytics = {
41 + pulse: (signal?: AbortSignal) => get<PulseResponse>('/pulse', signal),
42 + movers: (q: { window?: number; category?: string; kind?: string; limit?: number; min_population?: number | null }, signal?: AbortSignal) => get<MoversResponse>(`/movers${qs(q)}`, signal),
43 + extremes: (q: { window?: string; topic?: string | null; min_population?: number | null }, signal?: AbortSignal) => get<ExtremesResponse>(`/extremes${qs(q)}`, signal),
44 + scatter: (q: { x: string; y: string; size?: string | null; year?: number | null; group?: string | null; log_x?: string | null; log_y?: string | null }, signal?: AbortSignal) => get<ScatterResponse>(`/scatter${qs(q)}`, signal),
45 + trajectory: (q: { x?: string; y?: string; size?: string | null; from?: number | null; to?: number | null; group?: string | null }, signal?: AbortSignal) => get<TrajectoryResponse>(`/trajectory${qs(q)}`, signal),
46 + finder: (filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}, signal?: AbortSignal) =>
47 + get<FinderResponse>(`/finder${finderQuery(filters, q)}`, signal),
48 + peers: (q: { y?: string; x?: string; year?: number | null; method?: string; log_x?: string | null }, signal?: AbortSignal) => get<PeersResponse>(`/peers${qs(q)}`, signal),
49 + indicatorRelated: (slug: string, limit = 12, signal?: AbortSignal) => get<RelatedResponse>(`/indicators/${encodeURIComponent(slug)}/related${qs({ limit })}`, signal),
50 + indicatorDistribution: (slug: string, q: { year?: number | null; highlight?: string | null; bins?: number }, signal?: AbortSignal) => get<DistributionResponse>(`/indicators/${encodeURIComponent(slug)}/distribution${qs(q)}`, signal),
51 + indicatorFrames: (slug: string, q: { from?: number | null; to?: number | null; group?: string | null } = {}, signal?: AbortSignal) => get<FramesResponse>(`/indicators/${encodeURIComponent(slug)}/frames${qs(q)}`, signal),
52 + indicatorQuality: (slug: string, signal?: AbortSignal) => get<IndicatorQualityResponse>(`/indicators/${encodeURIComponent(slug)}/quality`, signal),
53 + race: (indicator: string, q: { from?: number | null; to?: number | null; top?: number; group?: string | null } = {}, signal?: AbortSignal) => get<RaceResponse>(`/rankings/${encodeURIComponent(indicator)}/race${qs(q)}`, signal),
54 + regionsCompare: (a: string, b: string, indicators?: string[], signal?: AbortSignal) => get<RegionCompareResponse>(`/regions/compare${qs({ a, b, indicators: indicators?.join(',') })}`, signal),
55 + countryStory: (id: string, signal?: AbortSignal) => get<StoryResponse>(`/countries/${encodeURIComponent(id)}/story`, signal),
56 + countryQuality: (id: string, signal?: AbortSignal) => get<CountryQualityResponse>(`/countries/${encodeURIComponent(id)}/quality`, signal),
57 + updates: (signal?: AbortSignal) => get<UpdatesResponse>('/updates', signal),
58 +};
59 +
60 +export type { FinderFilterInput };
added apps/web/src/lib/finder-query.ts +80 −0
@@ -0,0 +1,80 @@
1 +/**
2 + * /finder URL contract, shared by the page (server + client) and the API helpers. The site URL and the API use
3 + * the SAME encoding: repeated `f=slug:op:value` (between → `slug:between:a..b`), `mode=and|or`, `region`, `income`,
4 + * `sort=slug:asc|desc`.
5 + */
6 +export type FinderOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'between';
7 +export const FINDER_OPS: readonly FinderOp[] = ['gt', 'gte', 'lt', 'lte', 'eq', 'between'];
8 +
9 +export interface FinderFilterInput {
10 + slug: string;
11 + op: FinderOp;
12 + value: number;
13 + value2?: number | null;
14 +}
15 +
16 +export interface FinderState {
17 + filters: FinderFilterInput[];
18 + mode: 'and' | 'or';
19 + region: string | null;
20 + income: string | null;
21 + sort: string | null;
22 +}
23 +
24 +const SLUG = /^[a-z0-9][a-z0-9-]*$/;
25 +
26 +export function finderFilterParam(f: FinderFilterInput): string {
27 + return f.op === 'between' ? `${f.slug}:between:${f.value}..${f.value2 ?? f.value}` : `${f.slug}:${f.op}:${f.value}`;
28 +}
29 +
30 +export function parseFinderFilter(raw: string): FinderFilterInput | null {
31 + const m = /^([a-z0-9][a-z0-9-]*):(gt|gte|lt|lte|eq|between):(.+)$/.exec(raw.trim().toLowerCase());
32 + if (!m) return null;
33 + const [, slug, op, rest] = m;
34 + if (op === 'between') {
35 + const [a, b] = rest!.split('..');
36 + const v1 = Number(a);
37 + const v2 = Number(b);
38 + if (!Number.isFinite(v1) || !Number.isFinite(v2)) return null;
39 + return { slug: slug!, op: 'between', value: Math.min(v1, v2), value2: Math.max(v1, v2) };
40 + }
41 + const v = Number(rest);
42 + if (!Number.isFinite(v)) return null;
43 + return { slug: slug!, op: op as FinderOp, value: v };
44 +}
45 +
46 +export function finderQuery(filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}): string {
47 + const p = new URLSearchParams();
48 + for (const f of filters) p.append('f', finderFilterParam(f));
49 + if (q.mode && q.mode !== 'and') p.set('mode', q.mode);
50 + if (q.region) p.set('region', q.region);
51 + if (q.income) p.set('income', q.income);
52 + if (q.sort) p.set('sort', q.sort);
53 + if (q.limit) p.set('limit', String(q.limit));
54 + const s = p.toString();
55 + return s ? `?${s}` : '';
56 +}
57 +
58 +type ParamsLike = { getAll(name: string): string[]; get(name: string): string | null } | Record<string, string | string[] | undefined>;
59 +
60 +function all(params: ParamsLike, key: string): string[] {
61 + if (typeof (params as { getAll?: unknown }).getAll === 'function') return (params as { getAll(name: string): string[] }).getAll(key);
62 + const v = (params as Record<string, string | string[] | undefined>)[key];
63 + return Array.isArray(v) ? v : v != null ? [v] : [];
64 +}
65 +function one(params: ParamsLike, key: string): string | null {
66 + return all(params, key)[0] ?? null;
67 +}
68 +
69 +export function parseFinderState(params: ParamsLike): FinderState {
70 + const filters = all(params, 'f').map(parseFinderFilter).filter((f): f is FinderFilterInput => !!f).slice(0, 8);
71 + const mode = one(params, 'mode') === 'or' ? 'or' : 'and';
72 + const clean = (v: string | null) => (v && SLUG.test(v.toLowerCase()) ? v.toLowerCase() : null);
73 + const sortRaw = one(params, 'sort');
74 + const sort = sortRaw && /^[a-z0-9][a-z0-9-]*:(asc|desc)$/.test(sortRaw.toLowerCase()) ? sortRaw.toLowerCase() : null;
75 + return { filters, mode, region: clean(one(params, 'region')), income: clean(one(params, 'income')), sort };
76 +}
77 +
78 +export function finderStateQuery(s: FinderState): string {
79 + return finderQuery(s.filters, { mode: s.mode, region: s.region, income: s.income, sort: s.sort });
80 +}
81