SPB Git

spb/countryatlas

Public

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 (Sep 12, 2026) parent ceb1a63 Browse files

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 import type { Metadata } from 'next'; 1 import type { Metadata } from 'next';
3−import Link from 'next/link';
4 import { t } from '@/i18n'; 2 import { t } from '@/i18n';
5 import { api, isNotBuilt, safe } from '@/lib/api'; 3 import { api, isNotBuilt, safe } from '@/lib/api';
4+import { apiAnalytics } from '@/lib/api-analytics';
6 import { apiExplore } from '@/lib/api-explore'; 5 import { apiExplore } from '@/lib/api-explore';
7−import { formatValue, grouped } from '@/lib/format';
8 import { routes } from '@/lib/site'; 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 import { NotBuiltState } from '@/components/data/empty-state'; 7 import { NotBuiltState } from '@/components/data/empty-state';
14−import { Section } from '@/components/data/section'; 8+import { WorldExplorer } from '@/components/explorer/world-explorer';
15−import { EntityPickerNav } from '@/components/explore/entity-picker-nav'; 9+import { baseFeatures } from '@/components/indicators/map-geometry';
16−import { PageHeader } from '@/components/explore/page-header'; 10+import { DEFAULT_EXPLORER_INDICATOR, EXPLORER_VIEWS, explorerIndicators, parseYearParam, type ExplorerView } from '@/components/explorer/options';
17−import { SimilarityPlayground } from '@/components/explore/similarity-playground';
18−import { RegionChips } from '@/components/home/region-chips';
19 11
20 export const revalidate = 900; 12 export const revalidate = 900;
21 13
22−export const metadata: Metadata = { 14+type SP = Record<string, string | string[] | undefined>;
23− title: t('explore.title'), 15+const SLUG = /^[a-z0-9][a-z0-9-]*$/;
24− description: t('explore.sub'),
25− alternates: { canonical: routes.explore() },
26−};
27 16
28−type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' }; 17+function first(sp: SP, k: string): string | null {
29−type GroupQ = { kind: 'compare'; key: string; group: string }; 18+ const v = sp[k];
30−const QUESTIONS: Array<RankQ | GroupQ> = [ 19+ return (Array.isArray(v) ? v[0] : v) ?? null;
31− { kind: 'ranking', key: 'age', indicator: 'median-age' }, 20+}
32− { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' }, 21+
33− { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' }, 22+function parseState(sp: SP) {
34− { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' }, 23+ const ind = (first(sp, 'indicator') ?? '').toLowerCase();
35− { kind: 'ranking', key: 'life', indicator: 'life-expectancy' }, 24+ const view = first(sp, 'view') as ExplorerView | null;
36− { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' }, 25+ const country = (first(sp, 'country') ?? '').toLowerCase();
37− { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' }, 26+ const group = (first(sp, 'group') ?? 'world').toLowerCase();
38− { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' }, 27+ return {
39− { kind: 'ranking', key: 'internet', indicator: 'internet-users' }, 28+ indicator: SLUG.test(ind) ? ind : DEFAULT_EXPLORER_INDICATOR,
40− { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' }, 29+ year: parseYearParam(first(sp, 'year')),
41− { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' }, 30+ view: view && EXPLORER_VIEWS.includes(view) ? view : ('map' as ExplorerView),
42− { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' }, 31+ country: /^[a-z]{3}$/.test(country) ? country : null,
43− { kind: 'compare', key: 'g7', group: 'g7' }, 32+ group: SLUG.test(group) ? group : 'world',
44− { kind: 'compare', key: 'brics', group: 'brics' }, 33+ };
45− { kind: 'compare', key: 'nordic', group: 'nordic-countries' }, 34+}
46−];
47 35
48−export default async function ExplorePage() { 36+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
49− let notBuilt = false; 37+ const sp = await searchParams;
50− let indicatorsCount = 260; 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 try { 52 try {
52− indicatorsCount = (await apiExplore.indicators()).n; 53+ countriesRes = await api.countries();
53 } catch (e) { 54 } catch (e) {
54− if (isNotBuilt(e)) notBuilt = true; 55+ if (isNotBuilt(e)) return <NotBuiltState />;
55− else throw e; 56+ throw e;
56− }
57− if (notBuilt) {
58− return (
59− <>
60− <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
61− <NotBuiltState />
62− </>
63− );
64 } 57 }
65− 58+ const [indicatorsRes, groupsRes, frames] = await Promise.all([
66− const [changes, ...answers] = await Promise.all([ 59+ safe(apiExplore.indicators({ with_data: true })),
67− safe(apiExplore.changes({ limit: 30 })), 60+ safe(apiExplore.regions()),
68− ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))), 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 return ( 66 return (
72− <> 67+ <WorldExplorer
73− <PageHeader title={t('explore.title')} lede={t('explore.sub')} /> 68+ features={features}
74− 69+ sphere={sphere}
75− <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0"> 70+ countries={countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name, flag: c.flag, region: c.region, income: c.income }))}
76− <div className="max-w-xl"> 71+ indicators={indicators}
77− <EntityPickerNav type="country" placeholder={t('explore.country.search')} /> 72+ groups={groupsRes?.items ?? []}
78− </div> 73+ initial={frames}
79− <div className="mt-4"> 74+ initialState={state}
80− <RegionChips /> 75+ />
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− </>
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 label?: string; 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 /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */ 48 /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */
36 export function regionColor(region: string | null | undefined): string { 49 export function regionColor(region: string | null | undefined): string {
37 const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase()); 50 const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase());
@@ -124,8 +137,8 @@ export function BubbleChart({
124 if (labelled.size >= labelCount + hl.size) break; 137 if (labelled.size >= labelCount + hl.size) break;
125 labelled.add(p.id); 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]!); 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]!);
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]!); 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 let fitPath: string | null = null; 142 let fitPath: string | null = null;
130 if (fit) { 143 if (fit) {
131 const [x0, x1] = x.domain() as [number, number]; 144 const [x0, x1] = x.domain() as [number, number];
@@ -181,7 +194,8 @@ export function BubbleChart({
181 [model.clean, xSpec, ySpec, sizeSpec], 194 [model.clean, xSpec, ySpec, sizeSpec],
182 ); 195 );
183 const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]); 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 return ( 200 return (
187 <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}> 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 {xSpec.name} 236 {xSpec.name}
223 {logX ? ` (${t('common.log')})` : ''} → 237 {logX ? ` (${t('common.log')})` : ''} →
224 </text> 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 ↑ {ySpec.name} 240 ↑ {ySpec.name}
227 {logY ? ` (${t('common.log')})` : ''} 241 {logY ? ` (${t('common.log')})` : ''}
228 </text> 242 </text>
@@ -242,8 +256,8 @@ export function BubbleChart({
242 const isHover = hover === p.id; 256 const isHover = hover === p.id;
243 const rr = model.radius(p); 257 const rr = model.radius(p);
244 return ( 258 return (
245− <g key={p.id} style={{ transform: `translate(${model.x(p.x)}px, ${model.y(p.y)}px)`, transition: trans }}> 259+ <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}>
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 }} /> 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 </g> 261 </g>
248 ); 262 );
249 })} 263 })}
@@ -252,7 +266,7 @@ export function BubbleChart({
252 .map((p) => { 266 .map((p) => {
253 const rr = model.radius(p); 267 const rr = model.radius(p);
254 return ( 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 {p.label} 270 {p.label}
257 </text> 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
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/explorer/scatter-view.tsx +264 −0
Large diff (264 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/explorer/trajectories-view.tsx +236 −0
Large diff (236 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/explorer/use-media.ts +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/explorer/world-explorer.tsx +343 −0
Large diff (343 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/i18n/en.flagship.ts +164 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api-analytics.ts +76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/client-api-analytics.ts +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/finder-query.ts +80 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme