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 1 import type { Metadata } from 'next';
3 −import Link from 'next/link';
4 2 import { t } from '@/i18n';
5 3 import { api, isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
6 5 import { apiExplore } from '@/lib/api-explore';
7 −import { formatValue, grouped } from '@/lib/format';
8 6 import { routes } from '@/lib/site';
9 −import { TOPICS } from '@/lib/topics';
10 −import type { RankingResponse } from '@/lib/types';
11 −import type { RegionResponse } from '@/lib/types-explore';
12 −import { ChangesFeed } from '@/components/changes/changes-feed';
13 7 import { NotBuiltState } from '@/components/data/empty-state';
14 −import { Section } from '@/components/data/section';
15 −import { EntityPickerNav } from '@/components/explore/entity-picker-nav';
16 −import { PageHeader } from '@/components/explore/page-header';
17 −import { SimilarityPlayground } from '@/components/explore/similarity-playground';
18 −import { RegionChips } from '@/components/home/region-chips';
8 +import { WorldExplorer } from '@/components/explorer/world-explorer';
9 +import { baseFeatures } from '@/components/indicators/map-geometry';
10 +import { DEFAULT_EXPLORER_INDICATOR, EXPLORER_VIEWS, explorerIndicators, parseYearParam, type ExplorerView } from '@/components/explorer/options';
19 11
20 12 export const revalidate = 900;
21 13
22 −export const metadata: Metadata = {
23 − title: t('explore.title'),
24 − description: t('explore.sub'),
25 − alternates: { canonical: routes.explore() },
26 −};
14 +type SP = Record<string, string | string[] | undefined>;
15 +const SLUG = /^[a-z0-9][a-z0-9-]*$/;
27 16
28 −type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' };
29 −type GroupQ = { kind: 'compare'; key: string; group: string };
30 −const QUESTIONS: Array<RankQ | GroupQ> = [
31 − { kind: 'ranking', key: 'age', indicator: 'median-age' },
32 − { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' },
33 − { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' },
34 − { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' },
35 − { kind: 'ranking', key: 'life', indicator: 'life-expectancy' },
36 − { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' },
37 − { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' },
38 − { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' },
39 − { kind: 'ranking', key: 'internet', indicator: 'internet-users' },
40 − { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' },
41 − { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' },
42 − { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' },
43 − { kind: 'compare', key: 'g7', group: 'g7' },
44 − { kind: 'compare', key: 'brics', group: 'brics' },
45 − { kind: 'compare', key: 'nordic', group: 'nordic-countries' },
46 −];
17 +function first(sp: SP, k: string): string | null {
18 + const v = sp[k];
19 + return (Array.isArray(v) ? v[0] : v) ?? null;
20 +}
21 +
22 +function parseState(sp: SP) {
23 + const ind = (first(sp, 'indicator') ?? '').toLowerCase();
24 + const view = first(sp, 'view') as ExplorerView | null;
25 + const country = (first(sp, 'country') ?? '').toLowerCase();
26 + const group = (first(sp, 'group') ?? 'world').toLowerCase();
27 + return {
28 + indicator: SLUG.test(ind) ? ind : DEFAULT_EXPLORER_INDICATOR,
29 + year: parseYearParam(first(sp, 'year')),
30 + view: view && EXPLORER_VIEWS.includes(view) ? view : ('map' as ExplorerView),
31 + country: /^[a-z]{3}$/.test(country) ? country : null,
32 + group: SLUG.test(group) ? group : 'world',
33 + };
34 +}
47 35
48 −export default async function ExplorePage() {
49 − let notBuilt = false;
50 − let indicatorsCount = 260;
36 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
37 + const sp = await searchParams;
38 + const parameterized = Object.keys(sp).length > 0;
39 + return {
40 + title: t('explorer.metaTitle'),
41 + description: t('explorer.description'),
42 + alternates: { canonical: routes.explore() },
43 + robots: parameterized ? { index: false, follow: true } : undefined,
44 + openGraph: { title: `${t('explorer.metaTitle')} — ${t('site.name')}`, description: t('explorer.description'), url: routes.explore(), type: 'website' },
45 + };
46 +}
47 +
48 +export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) {
49 + const sp = await searchParams;
50 + const state = parseState(sp);
51 + let countriesRes;
51 52 try {
52 − indicatorsCount = (await apiExplore.indicators()).n;
53 + countriesRes = await api.countries();
53 54 } catch (e) {
54 − if (isNotBuilt(e)) notBuilt = true;
55 − else throw e;
56 − }
57 − if (notBuilt) {
58 − return (
59 − <>
60 − <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
61 − <NotBuiltState />
62 − </>
63 − );
55 + if (isNotBuilt(e)) return <NotBuiltState />;
56 + throw e;
64 57 }
65 −
66 − const [changes, ...answers] = await Promise.all([
67 − safe(apiExplore.changes({ limit: 30 })),
68 − ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))),
58 + const [indicatorsRes, groupsRes, frames] = await Promise.all([
59 + safe(apiExplore.indicators({ with_data: true })),
60 + safe(apiExplore.regions()),
61 + safe(apiAnalytics.indicatorFrames(state.indicator, { group: state.group !== 'world' ? state.group : null })),
69 62 ]);
70 −
63 + const countries = countriesRes.items.filter((c) => (c.kind ?? 'country') === 'country');
64 + const { features, sphere } = baseFeatures(countriesRes.items);
65 + const indicators = explorerIndicators(indicatorsRes?.items ?? []);
71 66 return (
72 − <>
73 − <PageHeader title={t('explore.title')} lede={t('explore.sub')} />
74 −
75 − <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0">
76 − <div className="max-w-xl">
77 − <EntityPickerNav type="country" placeholder={t('explore.country.search')} />
78 − </div>
79 − <div className="mt-4">
80 − <RegionChips />
81 − </div>
82 − </Section>
83 −
84 − <Section id="topic" title={t('explore.topic.title')} subtitle={t('explore.topic.sub', { n: grouped(indicatorsCount) })}>
85 − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
86 − {TOPICS.map((tp) => (
87 − <li key={tp.id} className="border-t border-rule">
88 − <Link href={routes.indicators(tp.id)} className="group flex min-h-[56px] flex-col justify-center py-2.5">
89 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{tp.name}</span>
90 − <span className="mt-0.5 text-xs text-ink-2">{tp.blurb}</span>
91 − </Link>
92 − </li>
93 − ))}
94 − </ul>
95 − </Section>
96 −
97 − <Section id="question" title={t('explore.question.title')} subtitle={t('explore.question.sub')}>
98 − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
99 − {QUESTIONS.map((q, i) => {
100 − const a = answers[i] ?? null;
101 − const title = t(`explore.q.${q.key}` as 'explore.q.age');
102 − const hint = t(`explore.q.${q.key}.hint` as 'explore.q.age.hint');
103 − if (q.kind === 'ranking') {
104 − const r = a as RankingResponse | null;
105 − const top = r?.rows[0];
106 − if (!top) return null; // no live figure → no static filler
107 − return (
108 − <li key={q.key} className="border-t border-rule">
109 − <Link href={routes.ranking(q.indicator)} className="group flex min-h-[84px] flex-col justify-center py-3">
110 − <span className="flex items-start justify-between gap-2">
111 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
112 − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
113 − </span>
114 − <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
115 − <span className="tnum mt-1.5 text-xs text-ink-2">
116 − <span aria-hidden>{top.country.flag} </span>
117 − {t('explore.question.leader', { country: top.country.name ?? top.country.id, value: formatValue(top.value, r.indicator), year: top.year ?? r.year_used ?? '' })}
118 − </span>
119 − </Link>
120 − </li>
121 − );
122 − }
123 − const g = a as RegionResponse | null;
124 − if (!g || g.members.length < 2) return null;
125 − const slugs = [...g.members]
126 − .sort((x, y) => (y.values.population?.value ?? 0) - (x.values.population?.value ?? 0))
127 − .slice(0, 8)
128 − .map((m) => m.slug ?? m.id);
129 − const gdp = g.aggregates.gdp;
130 − return (
131 − <li key={q.key} className="border-t border-rule">
132 − <Link href={routes.compare(...slugs)} className="group flex min-h-[84px] flex-col justify-center py-3">
133 − <span className="flex items-start justify-between gap-2">
134 − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span>
135 − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" />
136 − </span>
137 − <span className="mt-0.5 text-xs text-ink-3">{hint}</span>
138 − <span className="tnum mt-1.5 text-xs text-ink-2">
139 − {g.members.slice(0, 8).map((m) => m.flag).join(' ')} · {t('explore.q.members', { n: g.n_members })}
140 − {gdp?.value != null ? ` · ${t('regions.gdp')} ${formatValue(gdp.value, gdp.indicator)}` : ''}
141 − </span>
142 − </Link>
143 − </li>
144 − );
145 − })}
146 − </ul>
147 − </Section>
148 −
149 − <div className="grid gap-x-10 lg:grid-cols-2">
150 − <Section id="changes" title={t('explore.changes.title')} subtitle={t('explore.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('explore.changes.all')} →</Link>}>
151 − <ChangesFeed initial={changes?.items ?? []} kinds={changes?.kinds ?? []} limit={30} compact />
152 − </Section>
153 − <Section id="similar" title={t('explore.similar.title')} subtitle={t('explore.similar.sub')}>
154 − <SimilarityPlayground />
155 − </Section>
156 − </div>
157 − </>
67 + <WorldExplorer
68 + features={features}
69 + sphere={sphere}
70 + countries={countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name, flag: c.flag, region: c.region, income: c.income }))}
71 + indicators={indicators}
72 + groups={groupsRes?.items ?? []}
73 + initial={frames}
74 + initialState={state}
75 + />
158 76 );
159 77 }
added apps/web/src/app/extremes/page.tsx +77 −0
@@ -0,0 +1,77 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { grouped } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import { isTopicId } from '@/lib/topics';
8 +import type { ExtremesResponse } from '@/lib/types-analytics';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { ExtremeFacetBlock } from '@/components/explorer/extreme-facet';
11 +import { ExtremesControls } from '@/components/explorer/extremes-controls';
12 +import { firstParam, type SP } from '@/components/explorer/options';
13 +
14 +export const revalidate = 900;
15 +const WINDOWS = new Set(['1', '5', '10', '25', 'since1990']);
16 +
17 +function parseState(sp: SP) {
18 + const w = firstParam(sp, 'window') ?? '10';
19 + const topic = (firstParam(sp, 'topic') ?? '').toLowerCase();
20 + return { window: WINDOWS.has(w) ? w : '10', topic: isTopicId(topic) ? topic : null, all: firstParam(sp, 'all') === '1' };
21 +}
22 +
23 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
24 + const sp = await searchParams;
25 + return {
26 + title: t('extremes.metaTitle'),
27 + description: t('extremes.description'),
28 + alternates: { canonical: routes.extremes() },
29 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
30 + openGraph: { title: `${t('extremes.metaTitle')} — ${t('site.name')}`, description: t('extremes.description'), url: routes.extremes(), type: 'website' },
31 + };
32 +}
33 +
34 +export default async function ExtremesPage({ searchParams }: { searchParams: Promise<SP> }) {
35 + const state = parseState(await searchParams);
36 + let data: ExtremesResponse;
37 + try {
38 + data = await apiAnalytics.extremes({ window: state.window, topic: state.topic, min_population: state.all ? 0 : null });
39 + } catch (e) {
40 + if (isNotBuilt(e)) return <NotBuiltState />;
41 + throw e;
42 + }
43 + return (
44 + <>
45 + <header className="pb-3 pt-6 md:pt-10">
46 + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('extremes.title')}</h1>
47 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('extremes.lede')}</p>
48 + <p className="tnum mt-1 text-xs text-ink-3">
49 + {t('extremes.facets', { n: grouped(data.facets.length), y0: data.from_year ?? '', y1: data.to_year ?? '' })}
50 + {data.filter_note ? ` · ${data.filter_note}` : ` · ${t('extremes.allCountries')}`}
51 + </p>
52 + </header>
53 + <ExtremesControls window={state.window} topic={state.topic} all={state.all} />
54 + <p className="max-w-prose py-3 text-xs text-ink-3">{t('extremes.semantics')}</p>
55 + {data.facets.length ? (
56 + <>
57 + <nav aria-label={t('extremes.title')} className="-mx-4 px-4 sm:mx-0 sm:px-0">
58 + <ul className="scrollbar-none flex gap-1.5 overflow-x-auto py-1 sm:flex-wrap">
59 + {data.facets.map((f) => (
60 + <li key={f.id} className="shrink-0">
61 + <a href={`#${f.id}`} className="inline-flex h-11 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5">
62 + {f.title}
63 + </a>
64 + </li>
65 + ))}
66 + </ul>
67 + </nav>
68 + {data.facets.map((f) => (
69 + <ExtremeFacetBlock key={f.id} facet={f} />
70 + ))}
71 + </>
72 + ) : (
73 + <p className="py-10 text-center text-sm text-ink-3">{t('extremes.none')}</p>
74 + )}
75 + </>
76 + );
77 +}
added apps/web/src/app/finder/page.tsx +40 −0
@@ -0,0 +1,40 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { api, isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { parseFinderState } from '@/lib/finder-query';
7 +import { routes } from '@/lib/site';
8 +import type { FormatSpec } from '@/lib/types';
9 +import { NotBuiltState } from '@/components/data/empty-state';
10 +import { FinderView } from '@/components/explorer/finder-view';
11 +import { explorerIndicators, type SP } from '@/components/explorer/options';
12 +import { baseFeatures } from '@/components/indicators/map-geometry';
13 +
14 +export const revalidate = 900;
15 +
16 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
17 + const sp = await searchParams;
18 + return {
19 + title: t('finder.metaTitle'),
20 + description: t('finder.description'),
21 + alternates: { canonical: routes.finder() },
22 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
23 + openGraph: { title: `${t('finder.metaTitle')} — ${t('site.name')}`, description: t('finder.description'), url: routes.finder(), type: 'website' },
24 + };
25 +}
26 +
27 +export default async function FinderPage({ searchParams }: { searchParams: Promise<SP> }) {
28 + const state = parseFinderState(await searchParams);
29 + let indicatorsRes;
30 + try {
31 + indicatorsRes = await apiExplore.indicators({ with_data: true });
32 + } catch (e) {
33 + if (isNotBuilt(e)) return <NotBuiltState />;
34 + throw e;
35 + }
36 + const [countriesRes, result] = await Promise.all([safe(api.countries()), state.filters.length ? safe(apiAnalytics.finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 })) : Promise.resolve(null)]);
37 + const { features, sphere } = baseFeatures(countriesRes?.items ?? []);
38 + const specs: Record<string, FormatSpec> = Object.fromEntries(indicatorsRes.items.map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, frequency: i.frequency, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }]));
39 + return <FinderView indicators={explorerIndicators(indicatorsRes.items, 10)} features={features} sphere={sphere} initial={result} initialState={state} specs={specs} />;
40 +}
added apps/web/src/app/scatter/page.tsx +56 −0
@@ -0,0 +1,56 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { routes } from '@/lib/site';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +import { DEFAULT_TRAJ, explorerIndicators, firstParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options';
9 +import { ScatterView } from '@/components/explorer/scatter-view';
10 +
11 +export const revalidate = 900;
12 +
13 +function parseState(sp: SP) {
14 + const log = (firstParam(sp, 'log') ?? '').toLowerCase();
15 + const country = (firstParam(sp, 'country') ?? '').toLowerCase();
16 + return {
17 + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!,
18 + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!,
19 + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!,
20 + year: parseYearParam(firstParam(sp, 'year')),
21 + group: slugParam(firstParam(sp, 'group'), 'world')!,
22 + log: /^(x|y|x,y|y,x|none)$/.test(log) ? log : null,
23 + fit: firstParam(sp, 'fit') === '1',
24 + country: /^[a-z]{3}$/.test(country) ? country : null,
25 + };
26 +}
27 +
28 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
29 + const sp = await searchParams;
30 + return {
31 + title: t('scatter.metaTitle'),
32 + description: t('scatter.description'),
33 + alternates: { canonical: routes.scatter() },
34 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
35 + openGraph: { title: `${t('scatter.metaTitle')} — ${t('site.name')}`, description: t('scatter.description'), url: routes.scatter(), type: 'website' },
36 + };
37 +}
38 +
39 +export default async function ScatterPage({ searchParams }: { searchParams: Promise<SP> }) {
40 + const state = parseState(await searchParams);
41 + let indicatorsRes;
42 + try {
43 + indicatorsRes = await apiExplore.indicators({ with_data: true });
44 + } catch (e) {
45 + if (isNotBuilt(e)) return <NotBuiltState />;
46 + throw e;
47 + }
48 + const logX = state.log == null ? null : state.log.includes('x');
49 + const logY = state.log == null ? null : state.log.includes('y');
50 + const [groupsRes, scatter, related] = await Promise.all([
51 + safe(apiExplore.regions()),
52 + safe(apiAnalytics.scatter({ x: state.x, y: state.y, size: state.size, year: state.year, group: state.group !== 'world' ? state.group : null, log_x: logX == null ? null : String(logX), log_y: logY == null ? null : String(logY) })),
53 + safe(apiAnalytics.indicatorRelated(state.x, { limit: 8 })),
54 + ]);
55 + return <ScatterView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={scatter} initialRelated={related} initialState={state} />;
56 +}
added apps/web/src/app/trajectories/page.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import type { Metadata } from 'next';
2 +import { t } from '@/i18n';
3 +import { isNotBuilt, safe } from '@/lib/api';
4 +import { apiAnalytics } from '@/lib/api-analytics';
5 +import { apiExplore } from '@/lib/api-explore';
6 +import { routes } from '@/lib/site';
7 +import { NotBuiltState } from '@/components/data/empty-state';
8 +import { DEFAULT_TRAJ, explorerIndicators, firstParam, listParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options';
9 +import { TrajectoriesView } from '@/components/explorer/trajectories-view';
10 +
11 +export const revalidate = 900;
12 +
13 +function parseState(sp: SP) {
14 + return {
15 + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!,
16 + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!,
17 + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!,
18 + year: parseYearParam(firstParam(sp, 'year')),
19 + group: slugParam(firstParam(sp, 'group'), 'world')!,
20 + select: listParam(firstParam(sp, 'select'), 4).map((s) => s.toUpperCase()),
21 + };
22 +}
23 +
24 +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
25 + const sp = await searchParams;
26 + return {
27 + title: t('traj.metaTitle'),
28 + description: t('traj.description'),
29 + alternates: { canonical: routes.trajectories() },
30 + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined,
31 + openGraph: { title: `${t('traj.metaTitle')} — ${t('site.name')}`, description: t('traj.description'), url: routes.trajectories(), type: 'website' },
32 + };
33 +}
34 +
35 +export default async function TrajectoriesPage({ searchParams }: { searchParams: Promise<SP> }) {
36 + const state = parseState(await searchParams);
37 + let indicatorsRes;
38 + try {
39 + indicatorsRes = await apiExplore.indicators({ with_data: true });
40 + } catch (e) {
41 + if (isNotBuilt(e)) return <NotBuiltState />;
42 + throw e;
43 + }
44 + const [groupsRes, traj] = await Promise.all([safe(apiExplore.regions()), safe(apiAnalytics.trajectory({ x: state.x, y: state.y, size: state.size, group: state.group !== 'world' ? state.group : null }))]);
45 + return <TrajectoriesView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={traj} initialState={state} />;
46 +}
modified apps/web/src/components/charts/bubble-chart.tsx +21 −7
@@ -32,6 +32,19 @@ export interface BubbleFit {
32 32 label?: string;
33 33 }
34 34
35 +/** Log-axis ticks: powers of ten; 2× and 5× steps are added only when fewer than three decades are visible. */
36 +export function logTicks(domain: [number, number], n: number): number[] {
37 + const [lo, hi] = domain;
38 + if (!(lo > 0) || !(hi > lo)) return [];
39 + const inRange = (v: number) => v >= lo && v <= hi;
40 + const decades: number[] = [];
41 + for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) decades.push(Math.pow(10, e));
42 + const shown = decades.filter(inRange);
43 + if (shown.length >= Math.min(3, n)) return shown;
44 + const extra = decades.flatMap((d) => [d, 2 * d, 5 * d]).filter(inRange);
45 + return Array.from(new Set(extra)).sort((a, b) => a - b);
46 +}
47 +
35 48 /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */
36 49 export function regionColor(region: string | null | undefined): string {
37 50 const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase());
@@ -124,8 +137,8 @@ export function BubbleChart({
124 137 if (labelled.size >= labelCount + hl.size) break;
125 138 labelled.add(p.id);
126 139 }
127 − const xTicks = (logX ? (x as ReturnType<typeof scaleLog>).ticks(5) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!);
128 − const yTicks = (logY ? (y as ReturnType<typeof scaleLog>).ticks(5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!);
140 + const xTicks = (logX ? logTicks(x.domain() as [number, number], Math.max(3, Math.floor(innerW / 110))) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!);
141 + const yTicks = (logY ? logTicks(y.domain() as [number, number], 5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!);
129 142 let fitPath: string | null = null;
130 143 if (fit) {
131 144 const [x0, x1] = x.domain() as [number, number];
@@ -181,7 +194,8 @@ export function BubbleChart({
181 194 [model.clean, xSpec, ySpec, sizeSpec],
182 195 );
183 196 const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]);
184 − const trans = animate ? 'transform 380ms cubic-bezier(0.2,0.8,0.2,1), r 380ms ease' : undefined;
197 + const trans = animate ? '[transition:transform_380ms_cubic-bezier(0.2,0.8,0.2,1),r_380ms_ease]' : '';
198 + const px = (v: number) => `${Math.round(v * 100) / 100}px`;
185 199
186 200 return (
187 201 <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}>
@@ -222,7 +236,7 @@ export function BubbleChart({
222 236 {xSpec.name}
223 237 {logX ? ` (${t('common.log')})` : ''} →
224 238 </text>
225 − <text x={-8} y={-4} textAnchor="end" className="label label-strong">
239 + <text x={6} y={-4} textAnchor="start" className="label label-strong">
226 240 ↑ {ySpec.name}
227 241 {logY ? ` (${t('common.log')})` : ''}
228 242 </text>
@@ -242,8 +256,8 @@ export function BubbleChart({
242 256 const isHover = hover === p.id;
243 257 const rr = model.radius(p);
244 258 return (
245 − <g key={p.id} style={{ transform: `translate(${model.x(p.x)}px, ${model.y(p.y)}px)`, transition: trans }}>
246 − <circle r={rr} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} style={{ transition: trans }} />
259 + <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}>
260 + <circle r={Math.round(rr * 100) / 100} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} className={trans} />
247 261 </g>
248 262 );
249 263 })}
@@ -252,7 +266,7 @@ export function BubbleChart({
252 266 .map((p) => {
253 267 const rr = model.radius(p);
254 268 return (
255 − <text key={`l-${p.id}`} x={model.x(p.x) + rr + 4} y={model.y(p.y)} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3, transition: trans }}>
269 + <text key={`l-${p.id}`} x={Math.round((model.x(p.x) + rr + 4) * 100) / 100} y={Math.round(model.y(p.y) * 100) / 100} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''} ${trans}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
256 270 {p.label}
257 271 </text>
258 272 );
added apps/web/src/components/explorer/explorer-panels.tsx +173 −0
@@ -0,0 +1,173 @@
1 +'use client';
2 +import { ArrowRight, ExternalLink, Scale, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { formatValue, grouped, ordinal } from '@/lib/format';
7 +import { regionShort } from '@/lib/regions';
8 +import { routes } from '@/lib/site';
9 +import type { FormatSpec, Provenance } from '@/lib/types';
10 +import type { PointCountry } from '@/lib/types-analytics';
11 +import { seqVar } from '@/components/charts/palette';
12 +import { Sparkline } from '@/components/charts/sparkline';
13 +import type { SeriesPoint } from '@/components/charts/scales';
14 +import { useProvenance } from '@/components/data/provenance-context';
15 +import { stepFor } from './geo';
16 +
17 +export interface CountryYearInfo {
18 + country: PointCountry;
19 + value: number | null;
20 + year: number;
21 + rankWorld: number | null;
22 + nWorld: number;
23 + rankRegion: number | null;
24 + nRegion: number;
25 + series: SeriesPoint[];
26 + firstYear: number | null;
27 + lastYear: number | null;
28 + latestValue: number | null;
29 + latestYear: number | null;
30 +}
31 +
32 +/** Legend for the pooled quantile classes (stable while scrubbing). */
33 +export function ClassLegend({ breaks, min, max, spec, k, compact, className }: { breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; k: number; compact?: boolean; className?: string }) {
34 + const items = Array.from({ length: k }, (_, i) => {
35 + const lo = i === 0 ? min : breaks[i - 1]!;
36 + const hi = i === k - 1 ? max : breaks[i]!;
37 + return { cls: i, lo, hi };
38 + });
39 + return (
40 + <ol className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-2', className)} aria-label={t('common.legend')}>
41 + {items.map((it) => (
42 + <li key={it.cls} className="inline-flex items-center gap-1 tnum">
43 + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(it.cls, k)) }} />
44 + {compact ? (it.cls === 0 ? formatValue(it.lo, spec) : it.cls === k - 1 ? formatValue(it.hi, spec) : '') : `${formatValue(it.lo, spec)} – ${formatValue(it.hi, spec)}`}
45 + </li>
46 + ))}
47 + <li className="inline-flex items-center gap-1">
48 + <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />
49 + {t('chart.legend.noData')}
50 + </li>
51 + </ol>
52 + );
53 +}
54 +
55 +/** Floating hover label on the map: country · value · year · world / regional rank. */
56 +export function MapTooltip({ info, spec, x, y, sticky, onOpen }: { info: CountryYearInfo; spec: FormatSpec; x: number; y: number; sticky: boolean; onOpen: () => void }) {
57 + return (
58 + <div className="pointer-events-none absolute z-10 max-w-[240px] rounded-sm border border-rule bg-surface/95 px-2.5 py-1.5 text-xs shadow-pop backdrop-blur" style={{ left: Math.min(x + 12, Math.max(0, x + 12)), top: Math.max(4, y - 64) }}>
59 + <div className="flex items-center gap-1.5 font-medium text-ink">
60 + {info.country.flag ? <span aria-hidden>{info.country.flag}</span> : null}
61 + <span className="truncate">{info.country.name}</span>
62 + </div>
63 + <div className="tnum text-ink">
64 + <span className="font-semibold">{formatValue(info.value, spec)}</span>
65 + <span className="text-ink-3"> · {info.year}</span>
66 + </div>
67 + {info.value != null && info.rankWorld != null ? (
68 + <div className="tnum text-2xs text-ink-2">
69 + {t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}
70 + {info.rankRegion != null && info.country.region ? ` · ${t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(info.country.region) ?? info.country.region })}` : ''}
71 + </div>
72 + ) : info.value == null ? (
73 + <div className="text-2xs text-ink-3">{t('explorer.noValueYear', { year: info.year })}</div>
74 + ) : null}
75 + {sticky ? (
76 + <button type="button" onClick={onOpen} className="pointer-events-auto mt-1 inline-flex min-h-[32px] items-center text-accent underline">
77 + {t('explorer.drawer.details')} →
78 + </button>
79 + ) : null}
80 + </div>
81 + );
82 +}
83 +
84 +/** Country drawer content (right panel on desktop, bottom sheet on phones). */
85 +export function CountryPanel({ info, spec, indicatorSlug, indicatorName, provenance, onClose, onCompareHref, trajectoriesHref, closeClassName }: { info: CountryYearInfo; spec: FormatSpec; indicatorSlug: string; indicatorName: string; provenance: Provenance | null; onClose: () => void; onCompareHref: string; trajectoriesHref: string; closeClassName?: string }) {
86 + const { open } = useProvenance();
87 + const c = info.country;
88 + const slug = c.slug ?? c.id.toLowerCase();
89 + const dir = info.series.length >= 2 ? Math.sign((info.series[info.series.length - 1]!.value ?? 0) - (info.series[info.series.length - 2]!.value ?? 0)) : 0;
90 + return (
91 + <div className="flex h-full min-h-0 flex-col">
92 + <div className="flex items-start gap-3 border-b border-rule pb-3">
93 + <span aria-hidden className="text-4xl leading-none">
94 + {c.flag}
95 + </span>
96 + <div className="min-w-0 flex-1">
97 + <h2 className="display truncate text-xl text-ink">{c.name}</h2>
98 + <p className="text-xs text-ink-3">
99 + {regionShort(c.region) ?? c.region ?? ''}
100 + {c.income ? ` · ${c.income}` : ''}
101 + </p>
102 + </div>
103 + <button type="button" onClick={onClose} className={cn('tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 md:min-h-[32px] md:min-w-[32px]', closeClassName)} aria-label={t('common.close')}>
104 + <X size={18} aria-hidden />
105 + </button>
106 + </div>
107 + <div className="min-h-0 flex-1 overflow-y-auto py-3">
108 + <div className="text-xs font-medium text-ink-2">{indicatorName}</div>
109 + <button
110 + type="button"
111 + onClick={() =>
112 + open({
113 + indicator: { slug: indicatorSlug, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A', higher_is_better: spec.higher_is_better },
114 + value: info.value != null ? { value: info.value, period: `${info.year}-01-01`, year: info.year, unit: spec.unit, provenance } : null,
115 + country: { id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag },
116 + })
117 + }
118 + className="-mx-1 mt-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2"
119 + aria-label={t('common.openProvenance')}
120 + >
121 + <span className="pnum text-3xl font-semibold leading-none text-ink">{info.value != null ? formatValue(info.value, spec) : <span className="text-ink-3">{t('common.noData')}</span>}</span>
122 + <span className="tnum mt-1 text-xs text-ink-3">
123 + {info.year}
124 + {info.value == null && info.latestValue != null ? ` · ${t('explorer.drawer.latest', { value: formatValue(info.latestValue, spec), year: info.latestYear ?? '' })}` : ''}
125 + </span>
126 + </button>
127 + {info.value != null && info.rankWorld != null ? (
128 + <p className="tnum mt-2 text-sm text-ink-2">
129 + <span className="font-medium text-ink">{t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}</span>
130 + {info.rankRegion != null && c.region ? <span className="text-ink-3"> · {t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(c.region) ?? c.region })}</span> : null}
131 + </p>
132 + ) : null}
133 + {info.series.length >= 2 ? (
134 + <div className="mt-4">
135 + <div className="flex items-baseline justify-between text-2xs text-ink-3">
136 + <span>{t('explorer.drawer.history', { y0: info.firstYear ?? '', y1: info.lastYear ?? '' })}</span>
137 + </div>
138 + <Sparkline points={info.series} width={320} height={64} className="mt-1 h-16 w-full" direction={dir > 0 ? 'up' : dir < 0 ? 'down' : 'flat'} ariaLabel={t('explorer.drawer.sparkAria', { name: c.name ?? c.id, indicator: indicatorName })} />
139 + <div className="tnum flex justify-between text-2xs text-ink-3">
140 + <span>{info.firstYear}</span>
141 + <span>{info.lastYear}</span>
142 + </div>
143 + </div>
144 + ) : null}
145 + <div className="mt-5 flex flex-col gap-2">
146 + <Link href={routes.country(slug)} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[38px]">
147 + {t('explorer.drawer.open')} <ArrowRight size={15} aria-hidden />
148 + </Link>
149 + <Link href={onCompareHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">
150 + <Scale size={15} aria-hidden /> {t('explorer.drawer.compare')}
151 + </Link>
152 + <Link href={trajectoriesHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">
153 + <ExternalLink size={14} aria-hidden /> {t('explorer.drawer.trajectories')}
154 + </Link>
155 + <Link href={routes.countryIndicator(slug, topicOf(indicatorSlug), indicatorSlug)} className="inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]">
156 + {t('explorer.drawer.series')} →
157 + </Link>
158 + </div>
159 + </div>
160 + </div>
161 + );
162 +}
163 +
164 +/** Best-effort topic for the country topic page link (falls back to the indicator page when unknown). */
165 +function topicOf(slug: string): string {
166 + return TOPIC_HINT[slug] ?? 'economy';
167 +}
168 +const TOPIC_HINT: Record<string, string> = {
169 + population: 'population', 'population-growth': 'population', 'median-age': 'population', 'fertility-rate': 'population', 'urban-population-share': 'population',
170 + gdp: 'economy', 'gdp-per-capita': 'economy', 'gdp-per-capita-ppp': 'economy', 'gdp-growth': 'economy', inflation: 'economy',
171 + 'unemployment-rate': 'labor', 'life-expectancy': 'health', 'infant-mortality-rate': 'health', 'general-government-gross-debt-pct-gdp': 'government',
172 + 'co2-per-capita': 'climate', 'co2-emissions': 'climate', 'renewable-electricity-share': 'energy', 'energy-use-per-capita': 'energy', 'internet-users': 'digital',
173 +};
added apps/web/src/components/explorer/explorer-views.tsx +73 −0
@@ -0,0 +1,73 @@
1 +'use client';
2 +import Link from 'next/link';
3 +import { useMemo } from 'react';
4 +import { t } from '@/i18n';
5 +import { formatValue, grouped } from '@/lib/format';
6 +import { routes } from '@/lib/site';
7 +import type { FormatSpec } from '@/lib/types';
8 +import type { PointCountry } from '@/lib/types-analytics';
9 +import { Histogram } from '@/components/charts/histogram';
10 +import { LineChart, type LineSeries } from '@/components/charts/line-chart';
11 +import { RankedBars, type RankedBarRow } from '@/components/charts/ranked-bars';
12 +import { histogramOf, median, wantsLog } from './geo';
13 +
14 +export interface YearRow {
15 + country: PointCountry;
16 + value: number;
17 + rank: number;
18 +}
19 +
20 +/** Rank view: the top N of the current year, plus the selected country pinned when outside the top. */
21 +export function RankView({ rows, year, spec, selectedId, indicatorSlug, top = 25 }: { rows: YearRow[]; year: number; spec: FormatSpec; selectedId: string | null; indicatorSlug: string; top?: number }) {
22 + const shown = rows.slice(0, top);
23 + const sel = selectedId ? rows.find((r) => r.country.id === selectedId) : null;
24 + if (sel && !shown.some((r) => r.country.id === sel.country.id)) shown.push(sel);
25 + const bars: RankedBarRow[] = shown.map((r) => ({ id: r.country.id, label: r.country.name ?? r.country.id, flag: r.country.flag, href: r.country.slug ? routes.country(r.country.slug) : null, value: r.value, rank: r.rank }));
26 + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>;
27 + return (
28 + <div className="mx-auto max-w-3xl px-4 py-4">
29 + <div className="mb-2 flex flex-wrap items-baseline justify-between gap-2 text-xs text-ink-3">
30 + <span>{t('explorer.rank.sub', { n: grouped(rows.length), year })}</span>
31 + <Link href={routes.ranking(indicatorSlug, { year })} className="text-accent hover:underline">
32 + {t('explorer.rank.full')} →
33 + </Link>
34 + </div>
35 + <RankedBars rows={bars} spec={spec} highlightId={selectedId} />
36 + </div>
37 + );
38 +}
39 +
40 +/** Trend view: world median (and the country count), plus the selected country's own series. */
41 +export function TrendView({ years, values, spec, selected, subject }: { years: number[]; values: Record<string, Array<number | null>>; spec: FormatSpec; selected: { country: PointCountry; series: Array<number | null> } | null; subject: string }) {
42 + const series: LineSeries[] = useMemo(() => {
43 + const med = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: median(Object.values(values).map((arr) => arr[i])) }));
44 + const out: LineSeries[] = [{ id: 'median', name: t('explorer.trend.median'), points: med.filter((p) => p.value != null), colorIndex: 0 }];
45 + if (selected) out.push({ id: selected.country.id, name: selected.country.name ?? selected.country.id, points: years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selected.series[i] ?? null })).filter((p) => p.value != null), colorIndex: 1 });
46 + return out;
47 + }, [years, values, selected]);
48 + const counts = years.map((_, i) => Object.values(values).filter((arr) => arr[i] != null).length);
49 + return (
50 + <div className="mx-auto max-w-4xl px-4 py-4">
51 + <LineChart series={series} spec={spec} subject={subject} height={320} title={t('explorer.trend.title', { name: spec.name ?? '' })} subtitle={t('explorer.trend.sub', { min: Math.min(...counts), max: Math.max(...counts) })} defaultWidth={860} endLabels />
52 + <p className="mt-2 text-2xs text-ink-3">{t('explorer.trend.note')}</p>
53 + </div>
54 + );
55 +}
56 +
57 +/** Distribution view: histogram of the current year with world median and selected country markers. */
58 +export function DistributionView({ rows, year, spec, selected }: { rows: YearRow[]; year: number; spec: FormatSpec; selected: { country: PointCountry; value: number | null } | null }) {
59 + const vals = rows.map((r) => r.value);
60 + const log = wantsLog(spec, vals);
61 + const h = useMemo(() => histogramOf(vals, 20, log), [vals, log]);
62 + const med = median(vals);
63 + const markers = [
64 + ...(med != null ? [{ id: 'median', label: t('explorer.trend.median'), value: med, tone: 'ink' as const }] : []),
65 + ...(selected && selected.value != null ? [{ id: selected.country.id, label: selected.country.name ?? selected.country.id, value: selected.value, tone: 'accent' as const }] : []),
66 + ];
67 + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>;
68 + return (
69 + <div className="mx-auto max-w-4xl px-4 py-4">
70 + <Histogram edges={h.edges} counts={h.counts} log={h.log} markers={markers} spec={spec} height={300} title={t('explorer.dist.title', { name: spec.name ?? '', year })} subtitle={t('explorer.dist.sub', { n: grouped(rows.length), median: formatValue(med, spec) })} defaultWidth={860} unitLabel={spec.unit ?? undefined} />
71 + </div>
72 + );
73 +}
added apps/web/src/components/explorer/extreme-facet.tsx +92 −0
@@ -0,0 +1,92 @@
1 +'use client';
2 +import { ArrowDownRight, ArrowUpRight } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { fixed, formatValue } from '@/lib/format';
7 +import { routes } from '@/lib/site';
8 +import type { FormatSpec } from '@/lib/types';
9 +import type { ExtremeFacet } from '@/lib/types-analytics';
10 +import { SourceLine } from '@/components/charts/source-line';
11 +
12 +function changeText(row: ExtremeFacet['rows'][number], metric: ExtremeFacet['metric'], spec: FormatSpec): string {
13 + if (metric === 'pct' && row.delta_pct != null) return `${row.delta_pct >= 0 ? '+' : '−'}${fixed(Math.abs(row.delta_pct), 1)} %`;
14 + if (row.delta == null) return t('common.na');
15 + const sign = row.delta >= 0 ? '+' : '−';
16 + if (metric === 'points') return `${sign}${fixed(Math.abs(row.delta), spec.precision ?? 1)} pts`;
17 + return `${sign}${formatValue(Math.abs(row.delta), spec)}`;
18 +}
19 +
20 +/** One extremes facet: title, direction, rows start → end with the change bar. Neutral colours (increase/decrease). */
21 +export function ExtremeFacetBlock({ facet }: { facet: ExtremeFacet }) {
22 + const ind = facet.indicator;
23 + const spec: FormatSpec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A', name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };
24 + const up = facet.direction === 'up';
25 + const Icon = up ? ArrowUpRight : ArrowDownRight;
26 + const mags = facet.rows.map((r) => Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0));
27 + const max = Math.max(1e-9, ...mags);
28 + const y0 = facet.rows[0]?.year_start;
29 + const y1 = facet.rows[0]?.year_end;
30 + const payload = { indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A' as const, higher_is_better: ind.higher_is_better }, value: null, country: null, downloadHref: routes.indicatorDownload(ind.slug) };
31 + return (
32 + <section id={facet.id} className="hairline min-w-0 scroll-mt-28 pb-8 pt-6" aria-labelledby={`${facet.id}-h`}>
33 + <div className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-2">
34 + <div className="min-w-0">
35 + <h2 id={`${facet.id}-h`} className="display text-xl text-ink md:text-2xl">
36 + {facet.title}
37 + </h2>
38 + <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-ink-2">
39 + <span className={cn('inline-flex items-center gap-0.5 font-medium', up ? 'text-inc' : 'text-dec')}>
40 + <Icon size={14} aria-hidden /> {up ? t('extremes.direction.up') : t('extremes.direction.down')}
41 + </span>
42 + <span className="text-ink-3">·</span>
43 + <Link href={routes.indicator(ind.slug)} className="link-quiet text-ink">
44 + {ind.name}
45 + </Link>
46 + {y0 && y1 ? (
47 + <span className="tnum text-ink-3">
48 + · {y0}–{y1} · {t('extremes.n', { n: facet.n })}
49 + </span>
50 + ) : null}
51 + </p>
52 + </div>
53 + <div className="flex flex-wrap gap-x-4 text-sm">
54 + <Link href={routes.ranking(ind.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
55 + {t('extremes.openRanking')} →
56 + </Link>
57 + <Link href={routes.explore({ indicator: ind.slug, year: y1 ?? null })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
58 + {t('extremes.explore')} →
59 + </Link>
60 + </div>
61 + </div>
62 + <ol className="divide-y divide-rule">
63 + {facet.rows.map((r, i) => {
64 + const mag = Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0);
65 + const pct = (mag / max) * 100;
66 + return (
67 + <li key={r.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[1.5rem_minmax(0,12rem)_minmax(0,1fr)_minmax(9rem,auto)]">
68 + <span className="tnum text-right text-xs text-ink-3">{i + 1}</span>
69 + <Link href={routes.country(r.country.slug ?? r.country.id.toLowerCase())} className="link-quiet col-span-2 flex min-h-[40px] min-w-0 items-center gap-1.5 text-sm text-ink sm:col-span-1 sm:min-h-[32px]">
70 + <span aria-hidden>{r.country.flag}</span>
71 + <span className="truncate">{r.country.name}</span>
72 + </Link>
73 + <div className="col-start-2 row-start-2 h-2.5 min-w-0 self-center sm:col-start-3 sm:row-start-1 sm:h-3.5" aria-hidden>
74 + <div className={cn('h-full rounded-r-sm', up ? 'bg-inc' : 'bg-dec')} style={{ width: `${Math.max(1.5, pct)}%`, opacity: 0.85 }} />
75 + </div>
76 + <div className="tnum col-start-3 row-start-2 text-right text-sm sm:col-start-4 sm:row-start-1">
77 + <span className={cn('font-semibold', up ? 'text-inc' : 'text-dec')}>{changeText(r, facet.metric, spec)}</span>
78 + <span className="block text-2xs text-ink-3">
79 + {r.formatted_start ?? formatValue(r.value_start, spec)} <span aria-hidden>→</span> <span className="sr-only">{t('extremes.to')}</span> {r.formatted_end ?? formatValue(r.value_end, spec)}
80 + {r.year_start && r.year_end && (r.year_start !== y0 || r.year_end !== y1) ? ` (${r.year_start}–${r.year_end})` : ''}
81 + </span>
82 + </div>
83 + </li>
84 + );
85 + })}
86 + </ol>
87 + <div className="mt-2">
88 + <SourceLine provenance={facet.provenance} payload={payload} />
89 + </div>
90 + </section>
91 + );
92 +}
added apps/web/src/components/explorer/extremes-controls.tsx +44 −0
@@ -0,0 +1,44 @@
1 +'use client';
2 +import { useRouter } from 'next/navigation';
3 +import { t } from '@/i18n';
4 +import { routes } from '@/lib/site';
5 +import { TOPICS } from '@/lib/topics';
6 +import { Segmented } from '@/components/controls/indicator-select';
7 +
8 +const WINDOWS = ['1', '5', '10', '25', 'since1990'] as const;
9 +
10 +/** Window · topic · population toggle — every change navigates (server re-renders the facets). */
11 +export function ExtremesControls({ window, topic, all }: { window: string; topic: string | null; all: boolean }) {
12 + const router = useRouter();
13 + const go = (patch: { window?: string; topic?: string | null; all?: boolean }) => {
14 + const w = patch.window ?? window;
15 + const tp = patch.topic === undefined ? topic : patch.topic;
16 + const a = patch.all ?? all;
17 + const p = new URLSearchParams();
18 + if (w !== '10') p.set('window', w);
19 + if (tp) p.set('topic', tp);
20 + if (a) p.set('all', '1');
21 + const s = p.toString();
22 + router.replace(`${routes.extremes()}${s ? `?${s}` : ''}`, { scroll: false });
23 + };
24 + return (
25 + <div className="flex flex-wrap items-center gap-2 border-y border-rule py-3">
26 + <Segmented value={window} onChange={(w) => go({ window: w })} options={WINDOWS.map((w) => ({ value: w, label: t(`extremes.window.${w}` as 'extremes.window.1') }))} label={t('extremes.window')} size="sm" className="max-w-full overflow-x-auto" />
27 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
28 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('extremes.topic')}</span>
29 + <select value={topic ?? ''} onChange={(e) => go({ topic: e.target.value || null })} className="bg-transparent text-ink outline-none" aria-label={t('extremes.topic')}>
30 + <option value="">{t('extremes.allTopics')}</option>
31 + {TOPICS.map((tp) => (
32 + <option key={tp.id} value={tp.id}>
33 + {tp.short}
34 + </option>
35 + ))}
36 + </select>
37 + </label>
38 + <label className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 md:h-9">
39 + <input type="checkbox" checked={!all} onChange={(e) => go({ all: !e.target.checked })} className="accent-[var(--accent)]" />
40 + {t('extremes.minPop')}
41 + </label>
42 + </div>
43 + );
44 +}
added apps/web/src/components/explorer/finder-view.tsx +279 −0
@@ -0,0 +1,279 @@
1 +'use client';
2 +import { ArrowDown, ArrowUp, Download, Plus, Scale, X } from 'lucide-react';
3 +import Link from 'next/link';
4 +import { useRouter } from 'next/navigation';
5 +import { useEffect, useMemo, useRef, useState } from 'react';
6 +import { t } from '@/i18n';
7 +import { clientAnalytics } from '@/lib/client-api-analytics';
8 +import { cn } from '@/lib/cn';
9 +import { FINDER_OPS, finderStateQuery, type FinderFilterInput, type FinderOp, type FinderState } from '@/lib/finder-query';
10 +import { formatValue, grouped } from '@/lib/format';
11 +import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';
12 +import { routes } from '@/lib/site';
13 +import type { FormatSpec } from '@/lib/types';
14 +import type { FinderResponse } from '@/lib/types-analytics';
15 +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';
16 +import { useProvenance } from '@/components/data/provenance-context';
17 +import type { BaseFeature } from '@/components/indicators/indicator-map';
18 +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';
19 +
20 +interface Preset {
21 + id: string;
22 + filters: FinderFilterInput[];
23 +}
24 +const PRESETS: Preset[] = [
25 + { id: 'rich-large', filters: [{ slug: 'gdp-per-capita', op: 'gt', value: 40000 }, { slug: 'population', op: 'gt', value: 10_000_000 }] },
26 + { id: 'green-connected', filters: [{ slug: 'renewable-electricity-share', op: 'gt', value: 50 }, { slug: 'internet-users', op: 'gt', value: 80 }] },
27 + { id: 'ageing', filters: [{ slug: 'median-age', op: 'gt', value: 42 }] },
28 + { id: 'young-growing', filters: [{ slug: 'median-age', op: 'lt', value: 25 }, { slug: 'population-growth', op: 'gt', value: 2 }] },
29 + { id: 'long-lives-low-co2', filters: [{ slug: 'life-expectancy', op: 'gt', value: 80 }, { slug: 'co2-per-capita', op: 'lt', value: 5 }] },
30 +];
31 +
32 +function stepFor(spec: FormatSpec, value: number): number {
33 + if (spec.format === 'percent' || spec.format === 'years' || spec.format === 'ratio' || spec.format === 'per_1000') return 1;
34 + const mag = Math.pow(10, Math.max(0, Math.floor(Math.log10(Math.max(1, Math.abs(value)))) - 1));
35 + return mag;
36 +}
37 +
38 +/** Query builder over /finder: filters live in the URL exactly as the API reads them. */
39 +export function FinderView({ indicators, features, sphere, initial, initialState, specs }: { indicators: IndicatorOption[]; features: BaseFeature[]; sphere: string; initial: FinderResponse | null; initialState: FinderState; specs: Record<string, FormatSpec> }) {
40 + const router = useRouter();
41 + const { open: openProv } = useProvenance();
42 + const [state, setState] = useState<FinderState>(initialState);
43 + const [data, setData] = useState<FinderResponse | null>(initial);
44 + const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle');
45 + const abort = useRef<AbortController | null>(null);
46 + const first = useRef(true);
47 +
48 + // Debounced fetch + URL sync whenever the state changes.
49 + useEffect(() => {
50 + if (first.current) {
51 + first.current = false;
52 + return;
53 + }
54 + const q = finderStateQuery(state);
55 + const timer = setTimeout(() => {
56 + router.replace(`${routes.finder()}${q}`, { scroll: false });
57 + if (!state.filters.length) {
58 + setData(null);
59 + setStatus('idle');
60 + return;
61 + }
62 + abort.current?.abort();
63 + const ctrl = new AbortController();
64 + abort.current = ctrl;
65 + setStatus('loading');
66 + clientAnalytics
67 + .finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 }, ctrl.signal)
68 + .then((r) => {
69 + setData(r);
70 + setStatus('idle');
71 + })
72 + .catch((e) => {
73 + if ((e as Error).name !== 'AbortError') setStatus('error');
74 + });
75 + }, 350);
76 + return () => clearTimeout(timer);
77 + }, [state, router]);
78 +
79 + const update = (i: number, patch: Partial<FinderFilterInput>) => setState((s) => ({ ...s, filters: s.filters.map((f, k) => (k === i ? { ...f, ...patch } : f)) }));
80 + const remove = (i: number) => setState((s) => ({ ...s, filters: s.filters.filter((_, k) => k !== i) }));
81 + const add = () => {
82 + const used = new Set(state.filters.map((f) => f.slug));
83 + const next = ['gdp-per-capita', 'population', 'life-expectancy', 'median-age', 'internet-users', 'co2-per-capita'].find((s) => !used.has(s) && indicators.some((i) => i.slug === s)) ?? indicators[0]?.slug;
84 + if (!next) return;
85 + setState((s) => ({ ...s, filters: [...s.filters, { slug: next, op: 'gt' as FinderOp, value: 0 }].slice(0, 8) }));
86 + };
87 + const specFor = (slug: string): FormatSpec => specs[slug] ?? { format: 'number', name: indicators.find((i) => i.slug === slug)?.name ?? slug };
88 + const matches = data?.items ?? [];
89 + const matchSet = useMemo(() => new Set(matches.map((m) => m.country.id)), [matches]);
90 + const sortSlug = state.sort?.split(':')[0] ?? state.filters[0]?.slug ?? null;
91 + const sortDir = (state.sort?.split(':')[1] as 'asc' | 'desc' | undefined) ?? 'desc';
92 + const toggleSort = (slug: string) => setState((s) => ({ ...s, sort: sortSlug === slug && sortDir === 'desc' ? `${slug}:asc` : `${slug}:desc` }));
93 + const downloadHref = matches.length && state.filters.length ? routes.compareDownload(matches.slice(0, 20).map((m) => m.country.id), Array.from(new Set(state.filters.map((f) => f.slug))).slice(0, 20)) : null;
94 + const compareHref = matches.length >= 2 ? routes.compare(...matches.slice(0, 8).map((m) => m.country.slug ?? m.country.id.toLowerCase())) : null;
95 +
96 + return (
97 + <div>
98 + <header className="pb-3 pt-6 md:pt-10">
99 + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('finder.title')}</h1>
100 + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('finder.lede')}</p>
101 + </header>
102 +
103 + {/* Presets */}
104 + <section aria-label={t('finder.presets')} className="border-y border-rule py-3">
105 + <div className="eyebrow mb-1.5">{t('finder.presets')}</div>
106 + <ul className="flex flex-wrap gap-1.5">
107 + {PRESETS.map((p) => (
108 + <li key={p.id}>
109 + <button type="button" onClick={() => setState((s) => ({ ...s, filters: p.filters, mode: 'and' }))} className="inline-flex h-11 flex-col items-start justify-center rounded-sm border border-rule px-3 text-left hover:border-accent md:h-auto md:py-1.5" title={t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}>
110 + <span className="text-sm text-ink">{t(`finder.preset.${p.id}` as 'finder.preset.rich-large')}</span>
111 + <span className="hidden text-2xs text-ink-3 md:block">{t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}</span>
112 + </button>
113 + </li>
114 + ))}
115 + </ul>
116 + </section>
117 +
118 + {/* Builder */}
119 + <section aria-label={t('finder.add')} className="py-4">
120 + <ol className="space-y-2">
121 + {state.filters.map((f, i) => {
122 + const spec = specFor(f.slug);
123 + return (
124 + <li key={`${i}-${f.slug}`} className="grid grid-cols-[minmax(0,1fr)_2.75rem] items-start gap-2 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_2.75rem]">
125 + <IndicatorSelect options={indicators} value={f.slug} onChange={(slug) => update(i, { slug })} size="sm" label={i === 0 ? t('finder.indicator') : undefined} />
126 + <button type="button" onClick={() => remove(i)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-down sm:order-last md:min-h-[36px]" aria-label={t('finder.remove')}>
127 + <X size={16} aria-hidden />
128 + </button>
129 + <label className="col-span-2 flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm sm:col-span-1 md:h-9">
130 + <span className="sr-only">{t('finder.op')}</span>
131 + <select value={f.op} onChange={(e) => update(i, { op: e.target.value as FinderOp, value2: e.target.value === 'between' ? (f.value2 ?? f.value) : null })} className="h-11 min-w-0 flex-1 bg-transparent text-ink outline-none md:h-8">
132 + {FINDER_OPS.map((op) => (
133 + <option key={op} value={op}>
134 + {t(`finder.op.${op}` as 'finder.op.gt')}
135 + </option>
136 + ))}
137 + </select>
138 + </label>
139 + <div className="col-span-2 flex items-center gap-1.5 sm:col-span-1">
140 + <label className="flex h-11 min-w-0 flex-1 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm md:h-9" title={t('finder.unit', { unit: spec.unit ?? '' })}>
141 + <span className="sr-only">{t('finder.value')}</span>
142 + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={Number.isFinite(f.value) ? f.value : ''} onChange={(e) => update(i, { value: e.target.value === '' ? 0 : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={`${t('finder.value')} (${spec.unit ?? ''})`} />
143 + <span className="shrink-0 truncate text-2xs text-ink-3">{spec.unit_short ?? spec.unit ?? ''}</span>
144 + </label>
145 + {f.op === 'between' ? (
146 + <>
147 + <span className="text-xs text-ink-3">{t('finder.value2')}</span>
148 + <label className="flex h-11 min-w-0 flex-1 items-center rounded-sm border border-rule bg-surface px-2 text-sm md:h-9">
149 + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={f.value2 ?? ''} onChange={(e) => update(i, { value2: e.target.value === '' ? null : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={t('finder.value2')} />
150 + </label>
151 + </>
152 + ) : null}
153 + </div>
154 + </li>
155 + );
156 + })}
157 + </ol>
158 + <div className="mt-3 flex flex-wrap items-center gap-2">
159 + <button type="button" onClick={add} disabled={state.filters.length >= 8} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-dashed border-rule-strong px-3 text-sm text-ink-2 hover:border-accent hover:text-accent disabled:opacity-40 md:h-9">
160 + <Plus size={14} aria-hidden /> {t('finder.add')}
161 + </button>
162 + <Segmented value={state.mode} onChange={(m) => setState((s) => ({ ...s, mode: m }))} options={[{ value: 'and' as const, label: t('finder.mode.and') }, { value: 'or' as const, label: t('finder.mode.or') }]} label={t('finder.mode')} size="sm" />
163 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
164 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.region')}</span>
165 + <select value={state.region ?? ''} onChange={(e) => setState((s) => ({ ...s, region: e.target.value || null }))} className="bg-transparent text-ink outline-none">
166 + <option value="">{t('finder.any')}</option>
167 + {WB_REGIONS.map((r) => (
168 + <option key={r.id} value={r.slug}>
169 + {r.short}
170 + </option>
171 + ))}
172 + </select>
173 + </label>
174 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
175 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.income')}</span>
176 + <select value={state.income ?? ''} onChange={(e) => setState((s) => ({ ...s, income: e.target.value || null }))} className="bg-transparent text-ink outline-none">
177 + <option value="">{t('finder.any')}</option>
178 + {INCOME_GROUPS.map((g) => (
179 + <option key={g.id} value={g.slug}>
180 + {g.name}
181 + </option>
182 + ))}
183 + </select>
184 + </label>
185 + </div>
186 + </section>
187 +
188 + {/* Results */}
189 + <section aria-live="polite" className="border-t border-rule pt-4">
190 + <div className="flex flex-wrap items-baseline justify-between gap-2">
191 + <h2 className="display text-xl text-ink md:text-2xl">
192 + {status === 'loading' ? t('finder.loading') : status === 'error' ? t('finder.error') : data ? t('finder.results', { n: grouped(data.n_matching), m: grouped(data.n_evaluated) }) : ''}
193 + </h2>
194 + <div className="flex flex-wrap items-center gap-2 text-sm">
195 + {downloadHref ? (
196 + <a href={downloadHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-rule px-3 text-ink hover:bg-surface-2 md:min-h-[36px]">
197 + <Download size={14} aria-hidden /> {t('finder.download')}
198 + </a>
199 + ) : null}
200 + {compareHref ? (
201 + <Link href={compareHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-ink px-3 font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[36px]">
202 + <Scale size={14} aria-hidden /> {t('finder.compare', { n: Math.min(8, matches.length) })}
203 + </Link>
204 + ) : null}
205 + </div>
206 + </div>
207 + {!state.filters.length ? <p className="mt-3 text-sm text-ink-3">{t('finder.empty')}</p> : null}
208 + {state.filters.length && data && data.n_matching === 0 && status === 'idle' ? <p className="mt-3 text-sm text-ink-3">{t('finder.none')}</p> : null}
209 + {data && matches.length ? (
210 + <div className={cn('mt-4 grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] transition-opacity', status === 'loading' && 'opacity-60')}>
211 + <div className="min-w-0 overflow-x-auto">
212 + <table className="w-full min-w-[32rem] text-sm">
213 + <caption className="sr-only">{t('finder.results', { n: data.n_matching, m: data.n_evaluated })}</caption>
214 + <thead>
215 + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">
216 + <th scope="col" className="py-1.5 pr-3 font-medium">{t('finder.table.country')}</th>
217 + {data.filters.map((f) => (
218 + <th key={f.indicator.slug} scope="col" className="py-1.5 pl-3 text-right font-medium">
219 + <button type="button" onClick={() => toggleSort(f.indicator.slug)} className="inline-flex min-h-[32px] items-center gap-1 hover:text-ink" aria-label={t('finder.sort', { name: f.indicator.short_name ?? f.indicator.name })}>
220 + {f.indicator.short_name ?? f.indicator.name}
221 + {sortSlug === f.indicator.slug ? sortDir === 'desc' ? <ArrowDown size={11} aria-hidden /> : <ArrowUp size={11} aria-hidden /> : null}
222 + </button>
223 + </th>
224 + ))}
225 + </tr>
226 + </thead>
227 + <tbody className="divide-y divide-rule">
228 + {matches.map((m) => (
229 + <tr key={m.country.id}>
230 + <td className="py-1.5 pr-3">
231 + <Link href={routes.country(m.country.slug ?? m.country.id.toLowerCase())} className="link-quiet inline-flex min-h-[36px] items-center gap-1.5 text-ink">
232 + <span aria-hidden>{m.country.flag}</span>
233 + <span className="truncate">{m.country.name}</span>
234 + </Link>
235 + </td>
236 + {data.filters.map((f) => {
237 + const v = m.values[f.indicator.slug];
238 + const spec = specFor(f.indicator.slug);
239 + const matched = m.matched.includes(f.indicator.slug);
240 + return (
241 + <td key={f.indicator.slug} className="tnum py-1.5 pl-3 text-right">
242 + {v && v.value != null ? (
243 + <button type="button" onClick={() => openProv({ indicator: { slug: f.indicator.slug, name: f.indicator.name ?? f.indicator.slug, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' }, value: { value: v.value, formatted: v.formatted, period: v.year ? `${v.year}-01-01` : null, year: v.year, unit: spec.unit, provenance: v.provenance }, country: { id: m.country.id, slug: m.country.slug, name: m.country.name ?? m.country.id, flag: m.country.flag } })} className={cn('inline-flex min-h-[36px] items-baseline gap-1 hover:text-accent', matched ? 'font-medium text-ink' : 'text-ink-3')} aria-label={t('common.openProvenance')}>
244 + {formatValue(v.value, spec)}
245 + <span className="text-2xs text-ink-3">{v.year}</span>
246 + </button>
247 + ) : (
248 + <span className="text-ink-3">{t('common.noData')}</span>
249 + )}
250 + </td>
251 + );
252 + })}
253 + </tr>
254 + ))}
255 + </tbody>
256 + </table>
257 + <p className="mt-2 text-2xs text-ink-3">{t('finder.latestNote')}</p>
258 + </div>
259 + <figure className="min-w-0 lg:order-first">
260 + <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}` }}>
261 + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('finder.map')}>
262 + <title>{t('finder.map')}</title>
263 + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} />
264 + <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round">
265 + {features.map((f, i) => {
266 + const on = f.iso3 ? matchSet.has(f.iso3) : false;
267 + return <path key={f.iso3 ?? `${f.name}-${i}`} d={f.d} fill={on ? 'var(--accent)' : 'var(--nodata)'} fillOpacity={on ? 0.9 : 0.5} />;
268 + })}
269 + </g>
270 + </svg>
271 + </div>
272 + <figcaption className="mt-1 text-2xs text-ink-3">{t('finder.map')}</figcaption>
273 + </figure>
274 + </div>
275 + ) : null}
276 + </section>
277 + </div>
278 + );
279 +}
added apps/web/src/components/explorer/geo.ts +86 −0
@@ -0,0 +1,86 @@
1 +/** Small geometry helpers for the explorer map (client-safe, pure). */
2 +
3 +export interface BBox {
4 + x: number;
5 + y: number;
6 + w: number;
7 + h: number;
8 +}
9 +
10 +/** Bounding box of an SVG path made of absolute M/L/Z commands (what d3 geoPath emits). */
11 +export function pathBBox(d: string): BBox | null {
12 + const nums = d.match(/-?\d+(?:\.\d+)?/g);
13 + if (!nums || nums.length < 4) return null;
14 + let minX = Infinity;
15 + let minY = Infinity;
16 + let maxX = -Infinity;
17 + let maxY = -Infinity;
18 + for (let i = 0; i + 1 < nums.length; i += 2) {
19 + const x = Number(nums[i]);
20 + const y = Number(nums[i + 1]);
21 + if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
22 + if (x < minX) minX = x;
23 + if (x > maxX) maxX = x;
24 + if (y < minY) minY = y;
25 + if (y > maxY) maxY = y;
26 + }
27 + if (minX === Infinity) return null;
28 + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
29 +}
30 +
31 +/** Class index 0..breaks.length for a value against sorted quantile breaks. */
32 +export function classIndex(value: number, breaks: number[]): number {
33 + let i = 0;
34 + while (i < breaks.length && value >= breaks[i]!) i++;
35 + return i;
36 +}
37 +
38 +/** Map a class index (0..k-1) onto the 7-step sequential ramp (same rule as ChoroplethView). */
39 +export function stepFor(cls: number, k: number): number {
40 + if (k <= 1) return 4;
41 + const start = k >= 6 ? 1 : 2;
42 + return Math.round(start + (cls / (k - 1)) * (7 - start));
43 +}
44 +
45 +/** Median of finite numbers (null when empty). */
46 +export function median(values: Array<number | null | undefined>): number | null {
47 + const v = values.filter((x): x is number => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b);
48 + if (!v.length) return null;
49 + const mid = Math.floor(v.length / 2);
50 + return v.length % 2 ? v[mid]! : (v[mid - 1]! + v[mid]!) / 2;
51 +}
52 +
53 +/** Equal-width histogram (log10 when `log`), 20 bins by default. */
54 +export function histogramOf(values: number[], bins = 20, log = false): { edges: number[]; counts: number[]; log: boolean } {
55 + const v = log ? values.filter((x) => x > 0) : values;
56 + if (!v.length) return { edges: [], counts: [], log };
57 + const t = log ? v.map((x) => Math.log10(x)) : v;
58 + let lo = Math.min(...t);
59 + let hi = Math.max(...t);
60 + if (lo === hi) {
61 + const pad = Math.abs(lo) * 0.05 || 0.5;
62 + lo -= pad;
63 + hi += pad;
64 + }
65 + const counts = new Array<number>(bins).fill(0);
66 + const w = (hi - lo) / bins;
67 + for (const x of t) {
68 + let i = Math.floor((x - lo) / w);
69 + if (i >= bins) i = bins - 1;
70 + if (i < 0) i = 0;
71 + counts[i]!++;
72 + }
73 + const edges = Array.from({ length: bins + 1 }, (_, i) => {
74 + const e = lo + i * w;
75 + return log ? Math.pow(10, e) : e;
76 + });
77 + return { edges, counts, log };
78 +}
79 +
80 +/** Heuristic: a level series spanning > 50× on positive values reads better on a log axis. */
81 +export function wantsLog(spec: { format?: string | null }, values: number[]): boolean {
82 + if (!['currency', 'number', 'tonnes', 'kwh', 'per_million'].includes(spec.format ?? '')) return false;
83 + const pos = values.filter((v) => v > 0);
84 + if (pos.length < 10 || pos.length < values.length * 0.95) return false;
85 + return Math.max(...pos) / Math.min(...pos) > 50;
86 +}
added apps/web/src/components/explorer/map-canvas.tsx +345 −0
@@ -0,0 +1,345 @@
1 +'use client';
2 +import { Minus, Plus, RotateCcw } from 'lucide-react';
3 +import { useCallback, useEffect, useId, useMemo, useRef, useState, type PointerEvent as RPointerEvent } from 'react';
4 +import { t } from '@/i18n';
5 +import { cn } from '@/lib/cn';
6 +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';
7 +import type { BaseFeature } from '@/components/indicators/indicator-map';
8 +import { seqVar } from '@/components/charts/palette';
9 +import { pathBBox, stepFor } from './geo';
10 +
11 +export interface MapHover {
12 + iso3: string;
13 + x: number;
14 + y: number;
15 + sticky: boolean;
16 +}
17 +
18 +const MIN_K = 1;
19 +const MAX_K = 8;
20 +
21 +/**
22 + * Zoomable, pannable Equal Earth world map (SVG). Paths are memoised once; only the fill class per country changes
23 + * with the year. Wheel zooms around the cursor, drag pans, two pointers pinch; +/− and reset buttons; `focusId`
24 + * flies to a country's bounding box. Mouse hover reports a floating position; touch taps select (`onSelect`).
25 + * The transform is applied straight to the <g> during gestures (rAF) and mirrored to React state at rest.
26 + */
27 +export function MapCanvas({
28 + features,
29 + sphere,
30 + classOf,
31 + k,
32 + selectedId,
33 + focusId,
34 + onHover,
35 + onSelect,
36 + onOpen,
37 + className,
38 + labelOf,
39 +}: {
40 + features: BaseFeature[];
41 + sphere: string;
42 + /** ISO3 → class index (0..k−1) or null for no data. */
43 + classOf: Map<string, number | null>;
44 + k: number;
45 + selectedId: string | null;
46 + focusId: string | null;
47 + onHover: (h: MapHover | null) => void;
48 + onSelect: (iso3: string | null) => void;
49 + /** Double click / double tap → open the country page. */
50 + onOpen?: (iso3: string) => void;
51 + className?: string;
52 + labelOf: (iso3: string) => string;
53 +}) {
54 + const id = useId();
55 + const wrap = useRef<HTMLDivElement>(null);
56 + const gRef = useRef<SVGGElement>(null);
57 + const view = useRef({ k: 1, tx: 0, ty: 0 });
58 + const [zoom, setZoom] = useState(1);
59 + const raf = useRef<number | null>(null);
60 + const pointers = useRef(new Map<number, { x: number; y: number }>());
61 + const gesture = useRef<{ startDist: number; startK: number; startTx: number; startTy: number; cx: number; cy: number; lastX: number; lastY: number; moved: boolean } | null>(null);
62 + const lastTap = useRef<{ iso: string; t: number } | null>(null);
63 + const bboxes = useMemo(() => new Map(features.filter((f) => f.iso3).map((f) => [f.iso3!, pathBBox(f.d)])), [features]);
64 +
65 + const apply = useCallback(() => {
66 + raf.current = null;
67 + const g = gRef.current;
68 + if (!g) return;
69 + const { k: kk, tx, ty } = view.current;
70 + g.setAttribute('transform', `translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${kk.toFixed(4)})`);
71 + g.style.setProperty('--k', String(kk));
72 + }, []);
73 + const schedule = useCallback(() => {
74 + if (raf.current == null) raf.current = requestAnimationFrame(apply);
75 + }, [apply]);
76 +
77 + /** Geometry of the `meet`-fitted viewBox inside the wrapper: CSS px per user unit and the centring offsets. */
78 + const fit = useCallback(() => {
79 + const el = wrap.current;
80 + if (!el) return { s: 1, ox: 0, oy: 0, left: 0, top: 0 };
81 + const r = el.getBoundingClientRect();
82 + const s = Math.min(r.width / MAP_WIDTH, r.height / MAP_HEIGHT) || 1;
83 + return { s, ox: (r.width - MAP_WIDTH * s) / 2, oy: (r.height - MAP_HEIGHT * s) / 2, left: r.left, top: r.top };
84 + }, []);
85 + /** Scale factor from CSS pixels to SVG user units. */
86 + const unitsPerPx = useCallback(() => 1 / fit().s, [fit]);
87 +
88 + const clampView = useCallback(() => {
89 + const v = view.current;
90 + v.k = Math.min(MAX_K, Math.max(MIN_K, v.k));
91 + // keep the map covering the viewport: translation bounds
92 + const maxTx = 0;
93 + const minTx = MAP_WIDTH - MAP_WIDTH * v.k;
94 + const maxTy = MAP_HEIGHT * 0.25 * (v.k - 1);
95 + const minTy = MAP_HEIGHT - MAP_HEIGHT * v.k - MAP_HEIGHT * 0.25 * (v.k - 1);
96 + if (v.k <= 1) {
97 + v.tx = 0;
98 + v.ty = 0;
99 + } else {
100 + v.tx = Math.min(maxTx, Math.max(minTx, v.tx));
101 + v.ty = Math.min(maxTy, Math.max(minTy, v.ty));
102 + }
103 + }, []);
104 +
105 + const zoomAt = useCallback(
106 + (factor: number, ux: number, uy: number) => {
107 + const v = view.current;
108 + const nk = Math.min(MAX_K, Math.max(MIN_K, v.k * factor));
109 + const f = nk / v.k;
110 + v.tx = ux - (ux - v.tx) * f;
111 + v.ty = uy - (uy - v.ty) * f;
112 + v.k = nk;
113 + clampView();
114 + schedule();
115 + setZoom(v.k);
116 + },
117 + [clampView, schedule],
118 + );
119 +
120 + const toUnits = useCallback(
121 + (clientX: number, clientY: number) => {
122 + const f = fit();
123 + return { ux: (clientX - f.left - f.ox) / f.s, uy: (clientY - f.top - f.oy) / f.s };
124 + },
125 + [fit],
126 + );
127 +
128 + // Wheel zoom (non-passive so we can prevent the page from scrolling while over the map).
129 + useEffect(() => {
130 + const el = wrap.current;
131 + if (!el) return;
132 + const onWheel = (e: WheelEvent) => {
133 + e.preventDefault();
134 + const { ux, uy } = toUnits(e.clientX, e.clientY);
135 + zoomAt(Math.exp(-e.deltaY * 0.0015), ux, uy);
136 + };
137 + el.addEventListener('wheel', onWheel, { passive: false });
138 + return () => el.removeEventListener('wheel', onWheel);
139 + }, [toUnits, zoomAt]);
140 +
141 + // Fly to a country.
142 + useEffect(() => {
143 + if (!focusId) return;
144 + const b = bboxes.get(focusId);
145 + if (!b) return;
146 + const v = view.current;
147 + const target = Math.min(MAX_K, Math.max(1.6, Math.min((MAP_WIDTH * 0.35) / Math.max(b.w, 1), (MAP_HEIGHT * 0.35) / Math.max(b.h, 1))));
148 + const cx = b.x + b.w / 2;
149 + const cy = b.y + b.h / 2;
150 + const start = { ...v };
151 + const end = { k: target, tx: MAP_WIDTH / 2 - cx * target, ty: MAP_HEIGHT / 2 - cy * target };
152 + const t0 = performance.now();
153 + const dur = 480;
154 + const g = gRef.current;
155 + if (g) g.style.transition = 'none';
156 + const step = (now: number) => {
157 + const p = Math.min(1, (now - t0) / dur);
158 + const e = 1 - Math.pow(1 - p, 3);
159 + v.k = start.k + (end.k - start.k) * e;
160 + v.tx = start.tx + (end.tx - start.tx) * e;
161 + v.ty = start.ty + (end.ty - start.ty) * e;
162 + clampView();
163 + apply();
164 + if (p < 1) requestAnimationFrame(step);
165 + else setZoom(v.k);
166 + };
167 + requestAnimationFrame(step);
168 + }, [focusId, bboxes, apply, clampView]);
169 +
170 + // Portrait viewports (phones): start at 1.5× so countries are legible; landscape keeps the whole world.
171 + useEffect(() => {
172 + const el = wrap.current;
173 + if (!el) return;
174 + const r = el.getBoundingClientRect();
175 + if (r.height / Math.max(1, r.width) > (MAP_HEIGHT / MAP_WIDTH) * 1.4 && view.current.k === 1) {
176 + const v = view.current;
177 + v.k = 1.5;
178 + v.tx = MAP_WIDTH / 2 - (MAP_WIDTH / 2) * v.k;
179 + v.ty = MAP_HEIGHT / 2 - (MAP_HEIGHT / 2) * v.k;
180 + clampView();
181 + apply();
182 + setZoom(v.k);
183 + }
184 + // eslint-disable-next-line react-hooks/exhaustive-deps
185 + }, []);
186 +
187 + const reset = () => {
188 + view.current = { k: 1, tx: 0, ty: 0 };
189 + schedule();
190 + setZoom(1);
191 + };
192 +
193 + const downIso = useRef<string | null>(null);
194 + const touch = useRef(false);
195 + const onPointerDown = (e: RPointerEvent<HTMLDivElement>) => {
196 + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
197 + touch.current = e.pointerType !== 'mouse';
198 + if (touch.current) onHover(null);
199 + downIso.current = pointers.current.size === 0 ? ((e.target as Element).closest?.('path[data-iso]') as SVGPathElement | null)?.dataset.iso ?? null : null;
200 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
201 + const pts = Array.from(pointers.current.values());
202 + const v = view.current;
203 + if (pts.length === 1) {
204 + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: e.clientX, cy: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false };
205 + } else if (pts.length === 2) {
206 + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];
207 + gesture.current = { startDist: Math.hypot(a.x - b.x, a.y - b.y), startK: v.k, startTx: v.tx, startTy: v.ty, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, lastX: (a.x + b.x) / 2, lastY: (a.y + b.y) / 2, moved: true };
208 + }
209 + };
210 + const onPointerMove = (e: RPointerEvent<HTMLDivElement>) => {
211 + if (!pointers.current.has(e.pointerId)) return;
212 + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
213 + const g = gesture.current;
214 + if (!g) return;
215 + const pts = Array.from(pointers.current.values());
216 + const s = unitsPerPx();
217 + const v = view.current;
218 + if (pts.length >= 2) {
219 + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }];
220 + const dist = Math.hypot(a.x - b.x, a.y - b.y);
221 + const mx = (a.x + b.x) / 2;
222 + const my = (a.y + b.y) / 2;
223 + const f = g.startDist > 0 ? dist / g.startDist : 1;
224 + const nk = Math.min(MAX_K, Math.max(MIN_K, g.startK * f));
225 + const { ux, uy } = toUnits(g.cx, g.cy);
226 + const ratio = nk / g.startK;
227 + v.tx = ux - (ux - g.startTx) * ratio + (mx - g.cx) * s;
228 + v.ty = uy - (uy - g.startTy) * ratio + (my - g.cy) * s;
229 + v.k = nk;
230 + g.moved = true;
231 + } else {
232 + const dx = e.clientX - g.lastX;
233 + const dy = e.clientY - g.lastY;
234 + if (Math.abs(e.clientX - g.cx) + Math.abs(e.clientY - g.cy) > 4) g.moved = true;
235 + if (v.k > 1 || g.moved) {
236 + v.tx += dx * s;
237 + v.ty += dy * s;
238 + }
239 + g.lastX = e.clientX;
240 + g.lastY = e.clientY;
241 + }
242 + clampView();
243 + schedule();
244 + };
245 + const endGesture = (e: RPointerEvent<HTMLDivElement>) => {
246 + pointers.current.delete(e.pointerId);
247 + if (pointers.current.size === 0) {
248 + const moved = gesture.current?.moved ?? false;
249 + gesture.current = null;
250 + setZoom(view.current.k);
251 + if (moved) lastTap.current = null;
252 + else if (downIso.current) countryTap(downIso.current);
253 + downIso.current = null;
254 + } else if (pointers.current.size === 1) {
255 + const [p] = Array.from(pointers.current.values()) as [{ x: number; y: number }];
256 + const v = view.current;
257 + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: p.x, cy: p.y, lastX: p.x, lastY: p.y, moved: true };
258 + }
259 + };
260 +
261 + const place = useCallback(
262 + (e: RPointerEvent<SVGPathElement>, iso: string, sticky: boolean) => {
263 + const box = wrap.current?.getBoundingClientRect();
264 + if (!box) return;
265 + onHover({ iso3: iso, x: e.clientX - box.left, y: e.clientY - box.top, sticky });
266 + },
267 + [onHover],
268 + );
269 +
270 + const countryTap = (iso: string) => {
271 + const now = performance.now();
272 + if (lastTap.current && lastTap.current.iso === iso && now - lastTap.current.t < 380) {
273 + lastTap.current = null;
274 + onOpen?.(iso);
275 + return;
276 + }
277 + lastTap.current = { iso, t: now };
278 + onSelect(iso);
279 + };
280 +
281 + return (
282 + <div ref={wrap} className={cn('relative h-full w-full touch-none select-none overflow-hidden', className)} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} onPointerLeave={() => onHover(null)}>
283 + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('explorer.map.aria')} preserveAspectRatio="xMidYMid meet">
284 + <defs>
285 + <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
286 + <rect width="6" height="6" fill="var(--nodata)" />
287 + <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />
288 + </pattern>
289 + </defs>
290 + <g ref={gRef} style={{ ['--k' as string]: 1 }}>
291 + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} vectorEffect="non-scaling-stroke" />
292 + <g stroke="var(--map-stroke)" strokeWidth={0.7} strokeLinejoin="round">
293 + {features.map((f, i) => {
294 + const cls = f.iso3 ? classOf.get(f.iso3) ?? null : null;
295 + const sel = f.iso3 != null && f.iso3 === selectedId;
296 + return (
297 + <path
298 + key={f.iso3 ?? `${f.name}-${i}`}
299 + d={f.d}
300 + fill={cls != null ? seqVar(stepFor(cls, k)) : `url(#${id}-hatch)`}
301 + className={cn('outline-none transition-[fill] duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', f.iso3 && 'cursor-pointer', sel && 'stroke-ink [stroke-width:1.6]')}
302 + vectorEffect="non-scaling-stroke"
303 + tabIndex={f.iso3 ? 0 : -1}
304 + role={f.iso3 ? 'button' : undefined}
305 + aria-label={f.iso3 ? labelOf(f.iso3) : undefined}
306 + aria-pressed={sel || undefined}
307 + onPointerMove={(e) => {
308 + if (e.pointerType === 'mouse' && f.iso3 && !gesture.current?.moved) place(e, f.iso3, false);
309 + }}
310 + onPointerEnter={(e) => {
311 + if (e.pointerType === 'mouse' && f.iso3) place(e, f.iso3, false);
312 + }}
313 + data-iso={f.iso3 ?? undefined}
314 + onKeyDown={(e) => {
315 + if ((e.key === 'Enter' || e.key === ' ') && f.iso3) {
316 + e.preventDefault();
317 + onSelect(f.iso3);
318 + }
319 + }}
320 + onFocus={(e) => {
321 + if (!f.iso3 || touch.current) return;
322 + const b = e.currentTarget.getBoundingClientRect();
323 + const box = wrap.current?.getBoundingClientRect();
324 + if (box) onHover({ iso3: f.iso3, x: b.left - box.left + b.width / 2, y: b.top - box.top, sticky: false });
325 + }}
326 + />
327 + );
328 + })}
329 + </g>
330 + </g>
331 + </svg>
332 + <div className="absolute bottom-3 right-3 flex flex-col gap-1" role="group" aria-label={t('explorer.map.zoom')}>
333 + <button type="button" onClick={() => zoomAt(1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom >= MAX_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomIn')}>
334 + <Plus size={16} aria-hidden />
335 + </button>
336 + <button type="button" onClick={() => zoomAt(1 / 1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom <= MIN_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomOut')}>
337 + <Minus size={16} aria-hidden />
338 + </button>
339 + <button type="button" onClick={reset} disabled={zoom === 1} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.reset')}>
340 + <RotateCcw size={15} aria-hidden />
341 + </button>
342 + </div>
343 + </div>
344 + );
345 +}
added apps/web/src/components/explorer/options.ts +50 −0
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