SPB Git

spb/countryatlas

Public

Platform 2.0: brand + share cards, data stories, download builder, API explorer, provenance panel 2.0, quality badges, /updates, SEO + sharded sitemap; shared histogram and bubble charts; header fits 768–1100

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent 96daba8 Browse files

31 changed files +2,884 −209

added apps/web/qa/platform-qa.mjs +99 −0
@@ -0,0 +1,99 @@
1+/**
2+ * Platform pages QA: /stories, one story, /download, /updates, /api at 320/390/768/1440.
3+ * Checks: HTTP 200, no horizontal overflow, no console errors, tap targets ≥ 44 px on phones, no "undefined/NaN".
4+ * Screenshots → qa/screens/platform/<route>-<width>.png, report → qa/screens/platform/report.json.
5+ * node qa/platform-qa.mjs [BASE_URL]
6+ */
7+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
8+import { mkdirSync, writeFileSync } from 'node:fs';
9+
10+const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';
11+const OUT = new URL('./screens/platform/', import.meta.url).pathname;
12+mkdirSync(OUT, { recursive: true });
13+const ROUTES = ['/stories', '/stories/the-world-is-getting-older', '/stories/shifting-centre-of-the-world-economy', '/download', '/download?countries=canada,france&indicators=gdp,life-expectancy&from=2000', '/updates', '/api'];
14+const WIDTHS = [320, 390, 768, 1440];
15+const slug = (p) => p.slice(1).replace(/[/?=&,]+/g, '_');
16+const report = [];
17+const browser = await chromium.launch();
18+for (const width of WIDTHS) {
19+ const mobile = width < 768;
20+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });
21+ const page = await ctx.newPage();
22+ const errors = [];
23+ page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`));
24+ page.on('console', (m) => {
25+ if (m.type() === 'error') errors.push(m.text().slice(0, 200));
26+ });
27+ for (const path of ROUTES) {
28+ let status = 0;
29+ try {
30+ const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 });
31+ status = resp?.status() ?? 0;
32+ } catch (e) {
33+ report.push({ path, width, status: 'ERR', error: String(e).slice(0, 200) });
34+ continue;
35+ }
36+ await page.evaluate(() => document.fonts.ready);
37+ await page.evaluate(async () => {
38+ const h = document.documentElement.scrollHeight;
39+ for (let y = 0; y < h; y += 700) {
40+ window.scrollTo(0, y);
41+ await new Promise((r) => setTimeout(r, 50));
42+ }
43+ window.scrollTo(0, 0);
44+ });
45+ await page.waitForLoadState('networkidle').catch(() => {});
46+ await page.waitForTimeout(500);
47+ const m = await page.evaluate(
48+ ({ mobile }) => {
49+ const de = document.documentElement;
50+ const overflow = de.scrollWidth - de.clientWidth;
51+ const vis = (el) => {
52+ const r = el.getBoundingClientRect();
53+ if (r.width === 0 || r.height === 0) return false;
54+ const cs = getComputedStyle(el);
55+ return cs.visibility !== 'hidden' && cs.display !== 'none';
56+ };
57+ const wide = [...document.querySelectorAll('body *')]
58+ .filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1 && el.getBoundingClientRect().width > 0)
59+ .slice(0, 5)
60+ .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
61+ const targets = [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')].filter(vis);
62+ const small = mobile ? targets.filter((el) => el.getBoundingClientRect().height < 44 && el.getBoundingClientRect().width < 44).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}"`) : [];
63+ const text = document.body.innerText || '';
64+ const bad = [];
65+ for (const re of [/\bundefined\b/g, /\bNaN\b/g]) {
66+ let mm;
67+ while ((mm = re.exec(text))) bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`);
68+ }
69+ const footer = document.querySelector('footer')?.innerText ?? '';
70+ const credits = /Simon-Pierre Boucher/.test(footer) && /contact@spboucher\.ai/.test(footer) && /MacLustr/.test(footer);
71+ return { overflow, wide, nSmall: small.length, small: small.slice(0, 6), bad, credits, title: document.title, h: de.scrollHeight };
72+ },
73+ { mobile },
74+ );
75+ const file = `${slug(path)}-${width}.png`;
76+ await page.screenshot({ path: OUT + file, fullPage: true }).catch(() => {});
77+ report.push({ path, width, status, ...m, errors: errors.splice(0), file });
78+ }
79+ await ctx.close();
80+}
81+await browser.close();
82+writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
83+let fails = 0;
84+for (const r of report) {
85+ const flags = [];
86+ if (r.status !== 200) flags.push(`HTTP ${r.status}`);
87+ if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`);
88+ if (r.nSmall) flags.push(`${r.nSmall} small targets`);
89+ if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`);
90+ if (r.errors?.length) flags.push(`${r.errors.length} console errors`);
91+ if (r.credits === false) flags.push('NO CREDITS');
92+ if (flags.length) fails++;
93+ console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(70)} ${flags.join(' · ') || 'ok'} (h=${r.h})`);
94+ if (r.wide?.length) console.log(' wide:', r.wide.join(' | '));
95+ if (r.small?.length) console.log(' small:', r.small.join(' | '));
96+ if (r.bad?.length) console.log(' bad:', r.bad.join(' | '));
97+ if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));
98+}
99+console.log(`\n${report.length} renders, ${fails} with flags`);
modified apps/web/src/app/api/page.tsx +157 −120
@@ -2,11 +2,12 @@ import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { t } from '@/i18n'; 3 import { t } from '@/i18n';
4 import { API_URL } from '@/lib/api'; 4 import { API_URL } from '@/lib/api';
5−import { SITE_URL, routes } from '@/lib/site'; 5+import { API_VERSION, SITE_URL, routes } from '@/lib/site';
6 import { Section } from '@/components/data/section'; 6 import { Section } from '@/components/data/section';
7 import { CodeBlock } from '@/components/explore/copy-button'; 7 import { CodeBlock } from '@/components/explore/copy-button';
8 import { PageHeader } from '@/components/explore/page-header'; 8 import { PageHeader } from '@/components/explore/page-header';
9 import { FooterCredits } from '@/components/layout/site-footer'; 9 import { FooterCredits } from '@/components/layout/site-footer';
10+import { EndpointExplorer, type ExplorerEndpoint } from '@/components/platform/endpoint-explorer';
10 11
11 export const revalidate = 3600; 12 export const revalidate = 3600;
12 13
@@ -18,38 +19,106 @@ export const metadata: Metadata = {
18 19
19 const BASE = `${SITE_URL}/api/v1`; 20 const BASE = `${SITE_URL}/api/v1`;
20 21
21−const ENDPOINTS: Array<[string, string]> = [ 22+type Row = [string, string, string]; // endpoint, description, group key
22− ['GET /health', t('apiPage.ep.health')], 23+const ENDPOINTS: Row[] = [
23− ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries')], 24+ ['GET /health', t('apiPage.ep.health'), 'reference'],
24− ['GET /countries/{id}', t('apiPage.ep.country')], 25+ ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries'), 'countries'],
25− ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic')], 26+ ['GET /countries/{id}', t('apiPage.ep.country'), 'countries'],
26− ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries')], 27+ ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic'), 'countries'],
27− ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges')], 28+ ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries'), 'countries'],
28− ['GET /countries/{id}/events', t('apiPage.ep.countryEvents')], 29+ ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges'), 'countries'],
29− ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar')], 30+ ['GET /countries/{id}/events', t('apiPage.ep.countryEvents'), 'countries'],
30− ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights')], 31+ ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar'), 'countries'],
31− ['GET /countries/{id}/dna', t('apiPage.ep.countryDna')], 32+ ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights'), 'countries'],
32− ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload')], 33+ ['GET /countries/{id}/dna?reference=', t('apiPage.ep.countryDna'), 'countries'],
33− ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators')], 34+ ['GET /countries/{id}/story', t('apiPage.ep.story'), 'analytics'],
34− ['GET /indicators/{slug}', t('apiPage.ep.indicator')], 35+ ['GET /countries/{id}/quality', t('apiPage.ep.countryQuality'), 'analytics'],
35− ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap')], 36+ ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload'), 'countries'],
36− ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend')], 37+ ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators'), 'indicators'],
37− ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload')], 38+ ['GET /indicators/{slug}', t('apiPage.ep.indicator'), 'indicators'],
38− ['GET /series?country=&indicator=', t('apiPage.ep.series')], 39+ ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap'), 'indicators'],
39− ['GET /rankings?topic=', t('apiPage.ep.rankings')], 40+ ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend'), 'indicators'],
40− ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking')], 41+ ['GET /indicators/{slug}/frames?from=&to=', t('apiPage.ep.frames'), 'analytics'],
41− ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory')], 42+ ['GET /indicators/{slug}/distribution?year=&highlight=', t('apiPage.ep.distribution'), 'analytics'],
42− ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare')], 43+ ['GET /indicators/{slug}/related?limit=', t('apiPage.ep.related'), 'analytics'],
43− ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot')], 44+ ['GET /indicators/{slug}/quality', t('apiPage.ep.indicatorQuality'), 'analytics'],
44− ['GET /regions?kind=', t('apiPage.ep.regions')], 45+ ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload'), 'indicators'],
45− ['GET /regions/{slug}?indicator=', t('apiPage.ep.region')], 46+ ['GET /series?country=&indicator=', t('apiPage.ep.series'), 'indicators'],
46− ['GET /search?q=&type=', t('apiPage.ep.search')], 47+ ['GET /rankings?topic=', t('apiPage.ep.rankings'), 'rankings'],
47− ['GET /home', t('apiPage.ep.home')], 48+ ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking'), 'rankings'],
48− ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes')], 49+ ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory'), 'rankings'],
49− ['GET /sources', t('apiPage.ep.sources')], 50+ ['GET /rankings/{indicator}/race?from=&to=&top=', t('apiPage.ep.race'), 'analytics'],
50− ['GET /sources/{id}', t('apiPage.ep.source')], 51+ ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare'), 'rankings'],
51− ['GET /methodology', t('apiPage.ep.methodology')], 52+ ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot'), 'rankings'],
53+ ['GET /compare/download.csv|json?countries=&indicators=', t('apiPage.ep.download'), 'rankings'],
54+ ['GET /regions?kind=', t('apiPage.ep.regions'), 'rankings'],
55+ ['GET /regions/{slug}?indicator=', t('apiPage.ep.region'), 'rankings'],
56+ ['GET /regions/compare?a=&b=', t('apiPage.ep.regionsCompare'), 'analytics'],
57+ ['GET /pulse', t('apiPage.ep.pulse'), 'analytics'],
58+ ['GET /movers?window=&category=&kind=', t('apiPage.ep.movers'), 'analytics'],
59+ ['GET /extremes?window=&topic=', t('apiPage.ep.extremes'), 'analytics'],
60+ ['GET /scatter?x=&y=&size=&year=', t('apiPage.ep.scatter'), 'analytics'],
61+ ['GET /trajectory?x=&y=&size=&from=&to=', t('apiPage.ep.trajectory'), 'analytics'],
62+ ['GET /finder?f=slug:op:value&mode=', t('apiPage.ep.finder'), 'analytics'],
63+ ['GET /peers?y=&x=&year=', t('apiPage.ep.peers'), 'analytics'],
64+ ['GET /search?q=&type=', t('apiPage.ep.search'), 'reference'],
65+ ['GET /home', t('apiPage.ep.home'), 'reference'],
66+ ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes'), 'reference'],
67+ ['GET /updates', t('apiPage.ep.updates'), 'analytics'],
68+ ['GET /sources', t('apiPage.ep.sources'), 'reference'],
69+ ['GET /sources/{id}', t('apiPage.ep.source'), 'reference'],
70+ ['GET /methodology', t('apiPage.ep.methodology'), 'reference'],
52 ]; 71 ];
72+const GROUP_ORDER = ['countries', 'indicators', 'rankings', 'analytics', 'reference'] as const;
73+
74+const EXPLORER: ExplorerEndpoint[] = [
75+ { id: 'country', group: 'Countries', template: '/countries/{id}', summary: t('apiPage.ep.country'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },
76+ { id: 'series', group: 'Countries', template: '/countries/{id}/series/{indicator}', summary: t('apiPage.ep.countrySeries'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'CAN' }, { name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'from', in: 'query', description: 'First year.', example: '2000' }, { name: 'to', in: 'query', description: 'Last year.' }, { name: 'include_alt', in: 'query', description: 'Also return values from lower-priority sources.', options: ['false', 'true'] }] },
77+ { id: 'similar', group: 'Countries', template: '/countries/{id}/similar', summary: t('apiPage.ep.countrySimilar'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }, { name: 'mode', in: 'query', description: 'Similarity mode.', options: ['overall', 'economic', 'demographic', 'energy', 'social'], example: 'overall' }, { name: 'limit', in: 'query', description: 'Peers returned (≤ 50).', example: '8' }] },
78+ { id: 'story', group: 'Countries', template: '/countries/{id}/story', summary: t('apiPage.ep.story'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },
79+ { id: 'quality', group: 'Countries', template: '/countries/{id}/quality', summary: t('apiPage.ep.countryQuality'), params: [{ name: 'id', in: 'path', description: 'ISO3 code or slug.', example: 'canada' }] },
80+ { id: 'indicator', group: 'Indicators', template: '/indicators/{slug}', summary: t('apiPage.ep.indicator'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }] },
81+ { id: 'map', group: 'Indicators', template: '/indicators/{slug}/map', summary: t('apiPage.ep.indicatorMap'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'year', in: 'query', description: 'Year (default: latest year with ≥ 50 countries).', example: '2023' }, { name: 'nearest', in: 'query', description: 'Use each country’s latest value within 3 years.', options: ['false', 'true'] }] },
82+ { id: 'trend', group: 'Indicators', template: '/indicators/{slug}/trend', summary: t('apiPage.ep.indicatorTrend'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'group', in: 'query', description: 'Group slug (world, oecd, g7, europe-central-asia…).', example: 'oecd' }] },
83+ { id: 'frames', group: 'Indicators', template: '/indicators/{slug}/frames', summary: t('apiPage.ep.frames'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita-ppp' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }, { name: 'to', in: 'query', description: 'Last year.' }] },
84+ { id: 'distribution', group: 'Indicators', template: '/indicators/{slug}/distribution', summary: t('apiPage.ep.distribution'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'highlight', in: 'query', description: 'Country to place on the distribution (ISO3).', example: 'CAN' }, { name: 'year', in: 'query', description: 'Year.' }] },
85+ { id: 'related', group: 'Indicators', template: '/indicators/{slug}/related', summary: t('apiPage.ep.related'), params: [{ name: 'slug', in: 'path', description: 'Indicator slug.', example: 'life-expectancy' }, { name: 'limit', in: 'query', description: 'Rows.', example: '12' }] },
86+ { id: 'ranking', group: 'Rankings & comparisons', template: '/rankings/{indicator}', summary: t('apiPage.ep.ranking'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp-per-capita' }, { name: 'year', in: 'query', description: 'Ranking year (nearest available).' }, { name: 'group', in: 'query', description: 'Group slug (default world).', example: 'world' }, { name: 'sort', in: 'query', description: 'Direction.', options: ['desc', 'asc'] }, { name: 'limit', in: 'query', description: 'Rows (≤ 300).', example: '10' }] },
87+ { id: 'race', group: 'Rankings & comparisons', template: '/rankings/{indicator}/race', summary: t('apiPage.ep.race'), params: [{ name: 'indicator', in: 'path', description: 'Indicator slug.', example: 'gdp' }, { name: 'from', in: 'query', description: 'First year.', example: '1960' }, { name: 'top', in: 'query', description: 'Top N per year.', example: '10' }] },
88+ { id: 'compare', group: 'Rankings & comparisons', template: '/compare', summary: t('apiPage.ep.compare'), params: [{ name: 'countries', in: 'query', description: 'Comma-separated ISO3 or slugs (2–8).', example: 'CAN,USA,FRA', required: true }, { name: 'indicators', in: 'query', description: 'Comma-separated slugs (1–8).', example: 'gdp-per-capita', required: true }, { name: 'mode', in: 'query', description: 'Transformation.', options: ['absolute', 'per-capita', 'index100', 'pct'] }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },
89+ { id: 'regions-compare', group: 'Rankings & comparisons', template: '/regions/compare', summary: t('apiPage.ep.regionsCompare'), params: [{ name: 'a', in: 'query', description: 'Group slug.', example: 'g7', required: true }, { name: 'b', in: 'query', description: 'Group slug.', example: 'brics', required: true }] },
90+ { id: 'pulse', group: 'Analytics (1.1)', template: '/pulse', summary: t('apiPage.ep.pulse'), params: [] },
91+ { id: 'movers', group: 'Analytics (1.1)', template: '/movers', summary: t('apiPage.ep.movers'), params: [{ name: 'window', in: 'query', description: 'Years.', options: ['1', '5', '10'], example: '1' }, { name: 'category', in: 'query', description: 'Category.', options: ['all', 'economic', 'demographic', 'health', 'energy', 'climate', 'digital', 'housing', 'labor'] }, { name: 'kind', in: 'query', description: 'Kind filter.', options: ['all', 'improvement', 'deterioration', 'increase', 'decrease', 'record', 'reversal', 'acceleration', 'structural'] }, { name: 'limit', in: 'query', description: 'Rows.', example: '20' }] },
92+ { id: 'extremes', group: 'Analytics (1.1)', template: '/extremes', summary: t('apiPage.ep.extremes'), params: [{ name: 'window', in: 'query', description: 'Window.', options: ['1', '5', '10', '25', 'since1990'], example: '10' }, { name: 'topic', in: 'query', description: 'Topic id filter.' }] },
93+ { id: 'scatter', group: 'Analytics (1.1)', template: '/scatter', summary: t('apiPage.ep.scatter'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator (or none).', example: 'population' }, { name: 'year', in: 'query', description: 'Year.' }, { name: 'group', in: 'query', description: 'Group slug.', example: 'world' }] },
94+ { id: 'trajectory', group: 'Analytics (1.1)', template: '/trajectory', summary: t('apiPage.ep.trajectory'), params: [{ name: 'x', in: 'query', description: 'X indicator slug.', example: 'gdp-per-capita-ppp', required: true }, { name: 'y', in: 'query', description: 'Y indicator slug.', example: 'life-expectancy', required: true }, { name: 'size', in: 'query', description: 'Bubble size indicator.', example: 'population' }, { name: 'from', in: 'query', description: 'First year.', example: '1990' }] },
95+ { id: 'finder', group: 'Analytics (1.1)', template: '/finder', summary: t('apiPage.ep.finder'), params: [{ name: 'f', in: 'query', description: 'Filter slug:op:value (ops gt gte lt lte eq between a..b). One filter here; the API accepts several f= parameters.', example: 'gdp-per-capita:gt:40000', required: true }, { name: 'mode', in: 'query', description: 'Combine filters.', options: ['and', 'or'] }, { name: 'region', in: 'query', description: 'Group slug.' }, { name: 'limit', in: 'query', description: 'Rows (≤ 218).', example: '50' }] },
96+ { id: 'peers', group: 'Analytics (1.1)', template: '/peers', summary: t('apiPage.ep.peers'), params: [{ name: 'y', in: 'query', description: 'Outcome indicator.', example: 'life-expectancy' }, { name: 'x', in: 'query', description: 'Explanatory indicator.', example: 'gdp-per-capita-ppp' }, { name: 'method', in: 'query', description: 'Fit.', options: ['theil-sen', 'ols'] }] },
97+ { id: 'search', group: 'Reference', template: '/search', summary: t('apiPage.ep.search'), params: [{ name: 'q', in: 'query', description: 'Query (try "compare canada usa" or "rank gdp").', example: 'canada gdp', required: true }, { name: 'limit', in: 'query', description: 'Hits.', example: '8' }] },
98+ { id: 'changes', group: 'Reference', template: '/changes', summary: t('apiPage.ep.changes'), params: [{ name: 'kind', in: 'query', description: 'Change kind.', options: ['yoy_jump', 'yoy_drop', 'record_high', 'record_low', 'n_year_high', 'n_year_low', 'sign_flip', 'accelerating', 'decelerating', 'structural_break', 'trend_reversal', 'volatility_spike'] }, { name: 'topic', in: 'query', description: 'Topic id.' }, { name: 'min_severity', in: 'query', description: '0–1.', example: '0.7' }, { name: 'limit', in: 'query', description: 'Rows.', example: '10' }] },
99+ { id: 'updates', group: 'Reference', template: '/updates', summary: t('apiPage.ep.updates'), params: [] },
100+ { id: 'health', group: 'Reference', template: '/health', summary: t('apiPage.ep.health'), params: [] },
101+];
102+
103+const NEW_IN_11 = ['pulse', 'movers', 'extremes', 'scatter', 'trajectory', 'finder', 'peers', 'related', 'distribution', 'frames', 'indicatorQuality', 'race', 'regionsCompare', 'story', 'countryQuality', 'updates'] as const;
104+const NEW_PATHS: Record<(typeof NEW_IN_11)[number], string> = {
105+ pulse: '/pulse',
106+ movers: '/movers',
107+ extremes: '/extremes',
108+ scatter: '/scatter',
109+ trajectory: '/trajectory',
110+ finder: '/finder',
111+ peers: '/peers',
112+ related: '/indicators/{slug}/related',
113+ distribution: '/indicators/{slug}/distribution',
114+ frames: '/indicators/{slug}/frames',
115+ indicatorQuality: '/indicators/{slug}/quality',
116+ race: '/rankings/{indicator}/race',
117+ regionsCompare: '/regions/compare',
118+ story: '/countries/{id}/story',
119+ countryQuality: '/countries/{id}/quality',
120+ updates: '/updates',
121+};
53 122
54 const PROVENANCE_FIELDS: Array<[string, string]> = [ 123 const PROVENANCE_FIELDS: Array<[string, string]> = [
55 ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'], 124 ['source / source_name', 'Connector id (worldbank, imf, oecd, eurostat, who, fred, owid, bis, ilo) and its display name.'],
@@ -61,74 +130,31 @@ const PROVENANCE_FIELDS: Array<[string, string]> = [
61 ['licence', 'Licence of the source for this series.'], 130 ['licence', 'Licence of the source for this series.'],
62 ]; 131 ];
63 132
64−type Example = { key: string; path: string; pick: (j: unknown) => unknown }; 133+async function liveVersion(): Promise<string | null> {
65−const EXAMPLES: Example[] = [
66− {
67− key: 'country',
68− path: '/countries/canada',
69− pick: (j) => {
70− const d = j as { country: Record<string, unknown>; headline: Array<Record<string, unknown>> };
71− const c = d.country;
72− return { country: { id: c.id, name: c.name, capital: c.capital, region_name: c.region_name, income_name: c.income_name }, headline: d.headline.slice(0, 2).map((m) => ({ indicator: m.indicator, formatted: m.formatted, year: m.year, rank_world: m.rank_world, n_world: m.n_world, provenance: m.provenance })), '…': `${d.headline.length} headline metrics` };
73− },
74− },
75− {
76− key: 'series',
77− path: '/countries/CAN/series/gdp-per-capita?from=2022',
78− pick: (j) => {
79− const d = j as { indicator: Record<string, unknown>; unit: unknown; values: Array<Record<string, unknown>>; stats: unknown; provenance: unknown };
80− return { indicator: { slug: d.indicator.slug, name: d.indicator.name }, unit: d.unit, values: d.values.slice(0, 3).map((v) => ({ period: v.period, year: v.year, value: v.value, is_forecast: v.is_forecast, status: v.status })), '…': `${d.values.length} values`, stats: d.stats, provenance: d.provenance };
81− },
82− },
83− {
84− key: 'ranking',
85− path: '/rankings/gdp-per-capita?limit=3',
86− pick: (j) => {
87− const d = j as { indicator: Record<string, unknown>; year_used: unknown; n: unknown; rows: Array<Record<string, unknown>> };
88− return { indicator: d.indicator.slug, year_used: d.year_used, n: d.n, rows: d.rows.map((r) => ({ rank: r.rank, country: (r.country as Record<string, unknown>).name, formatted: r.formatted, change_1y: (r.change_1y as Record<string, unknown> | null)?.formatted ?? null })) };
89− },
90− },
91− {
92− key: 'map',
93− path: '/indicators/life-expectancy/map?year=2023',
94− pick: (j) => {
95− const d = j as { year_used: unknown; n: unknown; legend: unknown; values: Record<string, number>; provenance: unknown };
96− return { year_used: d.year_used, n: d.n, legend: d.legend, values: { CAN: d.values.CAN, JPN: d.values.JPN, NGA: d.values.NGA, '…': `${Object.keys(d.values).length} countries` }, provenance: d.provenance };
97− },
98− },
99− {
100− key: 'search',
101− path: '/search?q=canada%20gdp&limit=3',
102− pick: (j) => {
103− const d = j as { q: unknown; n: unknown; hits: Array<Record<string, unknown>> };
104− return { q: d.q, n: d.n, hits: d.hits.slice(0, 3).map((h) => ({ type: h.type, name: h.name, hint: h.hint, url: h.url })) };
105− },
106− },
107− {
108− key: 'changes',
109− path: '/changes?limit=2',
110− pick: (j) => {
111− const d = j as { n: unknown; items: Array<Record<string, unknown>> };
112− return { n: d.n, items: d.items.slice(0, 2).map((c) => ({ country: (c.country as Record<string, unknown> | null)?.name, indicator: (c.indicator as Record<string, unknown>).slug, kind: c.kind, year: c.year, severity: c.severity, headline: c.headline })) };
113− },
114− },
115−];
116−
117−async function fetchExample(ex: Example): Promise<string | null> {
118 try { 134 try {
119− const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1${ex.path}`, { headers: { accept: 'application/json' }, next: { revalidate: 3600 } }); 135+ const res = await fetch(`${API_URL.replace(/\/$/, '')}/api/v1/health`, { headers: { accept: 'application/json' }, next: { revalidate: 600 } });
120 if (!res.ok) return null; 136 if (!res.ok) return null;
121− return JSON.stringify(ex.pick(await res.json()), null, 2); 137+ const j = (await res.json()) as { run_id?: string | null };
138+ return j.run_id ?? null;
122 } catch { 139 } catch {
123 return null; 140 return null;
124 } 141 }
125 } 142 }
126 143
127 export default async function ApiPage() { 144 export default async function ApiPage() {
128− const bodies = await Promise.all(EXAMPLES.map(fetchExample)); 145+ const runId = await liveVersion();
129 return ( 146 return (
130 <> 147 <>
131− <PageHeader title={t('apiPage.title')} lede={t('apiPage.sub')} /> 148+ <PageHeader
149+ title={t('apiPage.title')}
150+ lede={t('apiPage.sub')}
151+ eyebrow={
152+ <span className="inline-flex items-center gap-2">
153+ <span className="badge border-accent/40 bg-accent-soft text-accent">{t('apiPage.version', { v: API_VERSION })}</span>
154+ {runId ? <span className="tnum normal-case tracking-normal">{t('site.footer.build', { run: runId })}</span> : null}
155+ </span>
156+ }
157+ />
132 <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p> 158 <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>
133 <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3"> 159 <dl className="mt-5 grid gap-x-8 gap-y-3 border-y border-rule py-4 text-sm sm:grid-cols-3">
134 <div> 160 <div>
@@ -153,38 +179,45 @@ export default async function ApiPage() {
153 </div> 179 </div>
154 </dl> 180 </dl>
155 181
156− <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')} className="border-t-0"> 182+ <Section id="explorer" title={t('apiPage.explorer.title')} subtitle={t('apiPage.explorer.sub')} className="border-t-0">
157− <table className="w-full border-collapse text-sm"> 183+ <EndpointExplorer endpoints={EXPLORER} base={BASE} />
158− <thead>
159− <tr className="border-b border-rule text-left text-xs text-ink-3">
160− <th scope="col" className="py-1.5 pr-3 font-medium">{t('apiPage.endpoint')}</th>
161− <th scope="col" className="py-1.5 font-medium">{t('apiPage.describes')}</th>
162− </tr>
163− </thead>
164− <tbody className="divide-y divide-rule">
165− {ENDPOINTS.map(([ep, desc]) => (
166− <tr key={ep}>
167− <td className="py-2 pr-3 align-top">
168− <code className="break-all font-mono text-xs text-ink">{ep}</code>
169− </td>
170− <td className="py-2 align-top text-ink-2">{desc}</td>
171− </tr>
172− ))}
173− </tbody>
174− </table>
175 </Section> 184 </Section>
176 185
177− <Section id="examples" title={t('apiPage.examples.title')} subtitle={t('apiPage.examples.sub')}> 186+ <Section id="new" title={t('apiPage.new.title')} subtitle={t('apiPage.new.sub')}>
178− <div className="space-y-8"> 187+ <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
179− {EXAMPLES.map((ex, i) => ( 188+ {NEW_IN_11.map((k) => (
180− <div key={ex.key} className="min-w-0"> 189+ <li key={k} className="border-t border-rule py-2.5">
181− <h3 className="mb-2 text-sm font-semibold text-ink">{t(`apiPage.ex.${ex.key}` as 'apiPage.ex.country')}</h3> 190+ <code className="block break-all font-mono text-xs text-ink">GET {NEW_PATHS[k]}</code>
182− <CodeBlock code={`curl -s "${BASE}${ex.path}"`} lang="bash" /> 191+ <span className="mt-0.5 block text-sm text-ink-2">{t(`apiPage.ep.${k}` as 'apiPage.ep.pulse')}</span>
183− <div className="mt-2 text-2xs text-ink-3">{t('apiPage.examples.live')}</div> 192+ </li>
184− {bodies[i] ? <pre className="mt-1 max-h-80 overflow-auto rounded-sm border border-rule bg-surface-2/60 p-3 font-mono text-xs leading-relaxed text-ink-2">{bodies[i]}</pre> : <p className="mt-1 text-xs text-ink-3">{t('apiPage.examples.unavailable')}</p>}
185− </div>
186 ))} 193 ))}
187− </div> 194+ </ul>
195+ </Section>
196+
197+ <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')}>
198+ {GROUP_ORDER.map((g) => (
199+ <div key={g} className="mb-6">
200+ <h3 className="eyebrow mb-1">{t(`apiPage.group.${g}` as 'apiPage.group.countries')}</h3>
201+ <table className="w-full border-collapse text-sm">
202+ <thead className="sr-only">
203+ <tr>
204+ <th scope="col">{t('apiPage.endpoint')}</th>
205+ <th scope="col">{t('apiPage.describes')}</th>
206+ </tr>
207+ </thead>
208+ <tbody className="divide-y divide-rule border-y border-rule">
209+ {ENDPOINTS.filter((e) => e[2] === g).map(([ep, desc]) => (
210+ <tr key={ep}>
211+ <td className="py-2 pr-3 align-top md:w-[46%]">
212+ <code className="break-all font-mono text-xs text-ink">{ep}</code>
213+ </td>
214+ <td className="py-2 align-top text-ink-2">{desc}</td>
215+ </tr>
216+ ))}
217+ </tbody>
218+ </table>
219+ </div>
220+ ))}
188 </Section> 221 </Section>
189 222
190 <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}> 223 <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>
@@ -197,7 +230,7 @@ export default async function ApiPage() {
197 value: 55697.66, 230 value: 55697.66,
198 period: '2025-01-01', 231 period: '2025-01-01',
199 year: 2025, 232 year: 2025,
200− formatted: '55.7k', 233+ formatted: 'US$55.7k',
201 status: 'imported', 234 status: 'imported',
202 provenance: { source: 'worldbank', source_name: 'World Bank', dataset: 'WDI', series_code: 'NY.GDP.PCAP.CD', retrieved_at: '2026-09-11T06:49:30Z', source_updated_at: '2026-07-13T00:00:00Z', url: 'https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA', transform: null, licence: 'CC BY 4.0' }, 235 provenance: { source: 'worldbank', source_name: 'World Bank', dataset: 'WDI', series_code: 'NY.GDP.PCAP.CD', retrieved_at: '2026-09-11T06:49:30Z', source_updated_at: '2026-07-13T00:00:00Z', url: 'https://data.worldbank.org/indicator/NY.GDP.PCAP.CD?locations=CA', transform: null, licence: 'CC BY 4.0' },
203 }, 236 },
@@ -242,6 +275,10 @@ export default async function ApiPage() {
242 <Link href={routes.methodology()} className="text-accent hover:underline"> 275 <Link href={routes.methodology()} className="text-accent hover:underline">
243 {t('method.title')} → 276 {t('method.title')} →
244 </Link> 277 </Link>
278+ <span className="mx-2 text-ink-3">·</span>
279+ <Link href={routes.download()} className="text-accent hover:underline">
280+ {t('download.title')} →
281+ </Link>
245 </p> 282 </p>
246 <div className="mt-6 text-sm text-ink-2"> 283 <div className="mt-6 text-sm text-ink-2">
247 <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span> 284 <span className="mr-2 font-medium text-ink">{t('apiPage.contact')}</span>
modified apps/web/src/app/apple-icon.png +0 −0

Binary file not shown.

added apps/web/src/app/download/page.tsx +120 −0
@@ -0,0 +1,120 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { Suspense } from 'react';
4+import { t } from '@/i18n';
5+import { api, safe } from '@/lib/api';
6+import { apiExplore } from '@/lib/api-explore';
7+import { formatDate } from '@/lib/format';
8+import { SITE_URL, routes } from '@/lib/site';
9+import { toCountryLite } from '@/lib/types-compare';
10+import type { IndicatorOption } from '@/components/controls/indicator-select';
11+import { Section } from '@/components/data/section';
12+import { CodeBlock } from '@/components/explore/copy-button';
13+import { DownloadPicker } from '@/components/explore/download-picker';
14+import { PageHeader } from '@/components/explore/page-header';
15+import { FooterCredits } from '@/components/layout/site-footer';
16+import { DownloadBuilder } from '@/components/platform/download-builder';
17+
18+export const revalidate = 3600;
19+
20+export const metadata: Metadata = {
21+ title: t('download.title'),
22+ description: t('download.description'),
23+ alternates: { canonical: routes.download() },
24+};
25+
26+const COLUMNS = ['country_id', 'country_name', 'indicator_id', 'indicator_name', 'period', 'year', 'frequency', 'value', 'unit', 'is_estimate', 'is_forecast', 'status', 'source', 'source_name', 'dataset', 'series_code', 'retrieved_at', 'source_updated_at', 'url', 'licence'];
27+
28+export default async function DownloadPage() {
29+ const [sources, countriesRes, indicatorsRes] = await Promise.all([safe(apiExplore.sources()), safe(api.countries()), safe(apiExplore.indicators({ with_data: true }))]);
30+ const items = sources?.items ?? [];
31+ const names = items.map((s) => s.name ?? s.id);
32+ const built = sources?.meta.built_at ?? null;
33+ const countries = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));
34+ // Plain data for the client builder (the helper in indicator-select.tsx is a client module; map here on the server).
35+ const indicators: IndicatorOption[] = (indicatorsRes?.items ?? []).map((i) => ({ slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries }));
36+ const maxYear = Math.max(new Date().getUTCFullYear(), ...indicators.map((i) => i.last_year ?? 0));
37+ const bulk = `# list indicators, then fetch each dataset\ncurl -s "${SITE_URL}/api/v1/indicators" | jq -r '.items[].slug' | while read slug; do\n curl -s -o "$slug.csv" "${SITE_URL}/api/v1/indicators/$slug/download.csv"; sleep 0.6\ndone`;
38+
39+ return (
40+ <>
41+ <PageHeader title={t('download.title')} lede={t('download.sub')} meta={built && sources?.meta.run_id ? t('download.snapshot', { run: sources.meta.run_id, date: formatDate(built) }) : undefined} />
42+
43+ <Section id="builder" title={t('download.builder.title')} subtitle={t('download.builder.sub')} className="border-t-0">
44+ <Suspense fallback={null}>
45+ <DownloadBuilder countries={countries} indicators={indicators} maxYear={maxYear} />
46+ </Suspense>
47+ </Section>
48+
49+ <Section id="quick" title={t('download.quick.title')} subtitle={t('download.quick.sub')}>
50+ <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">
51+ <div>
52+ <h3 className="text-sm font-semibold text-ink">{t('download.quick.country')}</h3>
53+ <p className="mb-2 text-xs text-ink-3">{t('download.quick.countrySub')}</p>
54+ <DownloadPicker type="country" />
55+ </div>
56+ <div>
57+ <h3 className="text-sm font-semibold text-ink">{t('download.quick.indicator')}</h3>
58+ <p className="mb-2 text-xs text-ink-3">{t('download.quick.indicatorSub')}</p>
59+ <DownloadPicker type="indicator" />
60+ </div>
61+ </div>
62+ </Section>
63+
64+ <div className="grid gap-x-10 lg:grid-cols-2">
65+ <Section id="bulk" title={t('download.bulk.title')} subtitle={t('download.bulk.sub')}>
66+ <p className="max-w-prose text-sm leading-relaxed text-ink-2">{t('download.bulk.text')}</p>
67+ <CodeBlock code={bulk} lang="bash" className="mt-3" />
68+ </Section>
69+ <Section id="columns" title={t('download.columns.title')} subtitle={t('download.columns.sub')}>
70+ <ul className="flex flex-wrap gap-1.5">
71+ {COLUMNS.map((c) => (
72+ <li key={c} className="rounded-xs border border-rule bg-surface px-1.5 py-0.5 font-mono text-xs text-ink-2">
73+ {c}
74+ </li>
75+ ))}
76+ </ul>
77+ <h3 className="mt-6 text-sm font-semibold text-ink">{t('download.api.title')}</h3>
78+ <p className="mt-1 max-w-prose text-sm leading-relaxed text-ink-2">{t('download.api.text', { base: `${SITE_URL}/api/v1` })}</p>
79+ <Link href={routes.api()} className="mt-1 inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[36px]">
80+ {t('download.api.link')} →
81+ </Link>
82+ </Section>
83+ </div>
84+
85+ <Section id="licence" title={t('download.licence.title')} subtitle={t('download.licence.sub')}>
86+ <div className="max-w-prose space-y-3 text-sm leading-relaxed text-ink-2">
87+ <p>{t('download.licence.compilation')}</p>
88+ <p>{t('download.licence.sources')}</p>
89+ </div>
90+ {items.length ? (
91+ <table className="mt-4 w-full max-w-3xl border-collapse text-sm">
92+ <thead>
93+ <tr className="border-b border-rule text-left text-xs text-ink-3">
94+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('download.licence.source')}</th>
95+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('download.licence.licence')}</th>
96+ <th scope="col" className="hidden py-1.5 font-medium sm:table-cell">{t('download.licence.attribution')}</th>
97+ </tr>
98+ </thead>
99+ <tbody className="divide-y divide-rule">
100+ {items.map((s) => (
101+ <tr key={s.id}>
102+ <td className="py-2 pr-3">
103+ <Link href={routes.source(s.id)} className="link-quiet inline-flex min-h-[44px] items-center text-ink md:min-h-[32px]">
104+ {s.name ?? s.id}
105+ </Link>
106+ </td>
107+ <td className="py-2 pr-3 text-ink-2">{s.licence ?? t('common.na')}</td>
108+ <td className="hidden py-2 text-xs text-ink-3 sm:table-cell">{s.attribution ?? ''}</td>
109+ </tr>
110+ ))}
111+ </tbody>
112+ </table>
113+ ) : null}
114+ <h3 className="mt-5 text-sm font-semibold text-ink">{t('download.licence.example')}</h3>
115+ <blockquote className="mt-1 max-w-prose border-l-2 border-rule pl-3 text-sm text-ink-2">{t('download.licence.exampleText', { sources: names.slice(0, 4).join(', ') || 'World Bank, IMF, OECD', date: formatDate(built ?? new Date().toISOString()) })}</blockquote>
116+ <FooterCredits className="mt-6 text-xs text-ink-2" />
117+ </Section>
118+ </>
119+ );
120+}
modified apps/web/src/app/icon.png +0 −0

Binary file not shown.

modified apps/web/src/app/icon.svg +1 −1
@@ -1 +1 @@
1−<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" fill="none" stroke="#1c5cab" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect width="32" height="32" rx="7" fill="#fbfaf7" stroke="none"/><circle cx="16" cy="16" r="12"/><path d="M5 19.3h22"/><path d="M10.1 26.6 16 7.6l5.9 19"/><path d="M16 7.6c-3 2.9-4.3 7.1-4.3 11.7" opacity="0.55"/><path d="M16 7.6c3 2.9 4.3 7.1 4.3 11.7" opacity="0.55"/></svg> 1+<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" fill="none" stroke-linecap="round" stroke-linejoin="round"><style>.t{fill:#fbfaf7}.m{stroke:#1c5cab}@media (prefers-color-scheme:dark){.t{fill:#151513}.m{stroke:#5598e7}}</style><rect class="t" width="32" height="32" rx="7"/><g class="m" fill="none" stroke-width="2.3"><circle cx="16" cy="16" r="12"/><ellipse cx="16" cy="16" rx="5.2" ry="12" stroke-width="1.3" opacity="0.5"/><path d="M5.2 11.6h21.6" stroke-width="1.3" opacity="0.5"/><path d="M4.6 19.4h22.8"/><path d="M9.9 26.7 16 7.1l6.1 19.6"/></g></svg>
modified apps/web/src/app/manifest.ts +6 −5
@@ -3,17 +3,18 @@ import { SITE_NAME, TAGLINE } from '@/lib/site';
3 3
4 export default function manifest(): MetadataRoute.Manifest { 4 export default function manifest(): MetadataRoute.Manifest {
5 return { 5 return {
6− name: SITE_NAME, 6+ name: `${SITE_NAME} — ${TAGLINE}`,
7 short_name: SITE_NAME, 7 short_name: SITE_NAME,
8− description: TAGLINE, 8+ description: 'The interactive data atlas of the world: 218 countries, 260+ indicators, every number traceable to its source.',
9 start_url: '/', 9 start_url: '/',
10 display: 'standalone', 10 display: 'standalone',
11 background_color: '#fbfaf7', 11 background_color: '#fbfaf7',
12 theme_color: '#1c5cab', 12 theme_color: '#1c5cab',
13+ categories: ['education', 'news', 'reference'],
13 icons: [ 14 icons: [
14− { src: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }, 15+ { src: '/icon.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any' },
15− { src: '/icon.png', type: 'image/png', sizes: '512x512' }, 16+ { src: '/icon.png', type: 'image/png', sizes: '512x512', purpose: 'any' },
16− { src: '/apple-icon.png', type: 'image/png', sizes: '180x180' }, 17+ { src: '/apple-icon.png', type: 'image/png', sizes: '180x180', purpose: 'any' },
17 ], 18 ],
18 }; 19 };
19 } 20 }
modified apps/web/src/app/opengraph-image.tsx +23 −9
@@ -1,22 +1,36 @@
1 import { ImageResponse } from 'next/og'; 1 import { ImageResponse } from 'next/og';
2 import { t } from '@/i18n'; 2 import { t } from '@/i18n';
3−import { OG_INK2, OG_INK3, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from './og-shared'; 3+import { api, safe } from '@/lib/api';
4+import { compact, grouped } from '@/lib/format';
5+import { OG_INK2, OG_INK3, OG_SIZE_H, OG_SIZE_W, OgFigures, OgFrame, OgWordmark } from './og-shared';
4 6
5−export const alt = 'CountryAtlas — Understand the world, one country at a time.'; 7+export const alt = 'CountryAtlas — Explore the world through data.';
6 export const size = { width: OG_SIZE_W, height: OG_SIZE_H }; 8 export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
7 export const contentType = 'image/png'; 9 export const contentType = 'image/png';
10+export const revalidate = 3600;
8 11
9−/** Default social image: dark editorial background, mark + wordmark, tagline, meridian motif. Static (built once). */ 12+/** Default share card: dark editorial "wallpaper" with the world map, wordmark, tagline and the live headline counts. */
10−export default function OpenGraphImage() { 13+export default async function OpenGraphImage() {
14+ const home = await safe(api.home());
15+ const s = home?.snapshot;
16+ const figures = [
17+ { label: t('og.countries'), value: grouped(s?.n_countries ?? 218) },
18+ { label: t('og.indicators'), value: `${grouped(s?.n_indicators ?? 260)}+` },
19+ { label: t('og.observations'), value: s?.n_observations ? compact(s.n_observations, 2) : '2M+' },
20+ { label: t('og.sources'), value: grouped(s?.n_sources ?? 9) },
21+ ];
11 return new ImageResponse( 22 return new ImageResponse(
12 ( 23 (
13− <OgFrame> 24+ <OgFrame map>
14 <OgWordmark size={40} /> 25 <OgWordmark size={40} />
15− <div style={{ display: 'flex', flexDirection: 'column', marginTop: 'auto', maxWidth: 720 }}> 26+ <div style={{ display: 'flex', flexDirection: 'column', marginTop: 'auto', maxWidth: 760 }}>
16− <div style={{ fontSize: 62, lineHeight: 1.08, fontWeight: 600, letterSpacing: -1.5 }}>{t('home.hero.title')}</div> 27+ <div style={{ fontSize: 66, lineHeight: 1.05, fontWeight: 600, letterSpacing: -1.8, display: 'flex' }}>{t('site.tagline')}</div>
17− <div style={{ marginTop: 22, fontSize: 26, color: OG_INK2, lineHeight: 1.35 }}>{t('home.hero.sub')}</div> 28+ <div style={{ marginTop: 18, fontSize: 26, color: OG_INK2, lineHeight: 1.35, display: 'flex' }}>{t('og.sub')}</div>
29+ <div style={{ marginTop: 34, display: 'flex' }}>
30+ <OgFigures items={figures} />
31+ </div>
18 </div> 32 </div>
19− <div style={{ position: 'absolute', left: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div> 33+ <div style={{ position: 'absolute', left: 64, bottom: 24, fontSize: 20, color: OG_INK3, display: 'flex' }}>{t('og.site')}</div>
20 </OgFrame> 34 </OgFrame>
21 ), 35 ),
22 { ...size }, 36 { ...size },
modified apps/web/src/app/robots.ts +10 −2
@@ -1,10 +1,18 @@
1 import type { MetadataRoute } from 'next'; 1 import type { MetadataRoute } from 'next';
2 import { SITE_URL } from '@/lib/site'; 2 import { SITE_URL } from '@/lib/site';
3+import { SITEMAP_IDS } from './sitemap';
3 4
4 export default function robots(): MetadataRoute.Robots { 5 export default function robots(): MetadataRoute.Robots {
5 return { 6 return {
6− rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api/'] }], 7+ rules: [
7− sitemap: `${SITE_URL}/sitemap.xml`, 8+ {
9+ userAgent: '*',
10+ allow: '/',
11+ // Admin, raw API and the parameterised analytical views (their bare paths are indexed via the sitemap).
12+ disallow: ['/admin', '/api/v1/', '/explore?', '/scatter?', '/trajectories?', '/finder?', '/compare/og'],
13+ },
14+ ],
15+ sitemap: SITEMAP_IDS.map((id) => `${SITE_URL}/sitemap/${id}.xml`),
8 host: SITE_URL, 16 host: SITE_URL,
9 }; 17 };
10 } 18 }
modified apps/web/src/app/sitemap.ts +66 −40
@@ -3,48 +3,74 @@ import { api, safe } from '@/lib/api';
3 import { apiCompare } from '@/lib/api-compare'; 3 import { apiCompare } from '@/lib/api-compare';
4 import { apiExplore } from '@/lib/api-explore'; 4 import { apiExplore } from '@/lib/api-explore';
5 import { SITE_URL, routes } from '@/lib/site'; 5 import { SITE_URL, routes } from '@/lib/site';
6+import { STORIES } from '@/lib/stories';
6 import { TOPICS } from '@/lib/topics'; 7 import { TOPICS } from '@/lib/topics';
7 8
8 /** 9 /**
9− * Countries + country topic pages from the API. The next agent adds indicators / rankings / regions entries 10+ * Sharded sitemap (Next `generateSitemaps`): one file per URL family so no shard grows past a few thousand URLs.
10− * here (same pattern: fetch the list with `safe()`, map to URLs, tolerate an empty API). 11+ * Emitted at /sitemap/<id>.xml; robots.ts lists every shard. Parameterised analytical views (/explore?…,
12+ * /scatter?…, /finder?…) are deliberately absent: they are noindex and their canonical is the bare path.
11 */ 13 */
12−export default async function sitemap(): Promise<MetadataRoute.Sitemap> { 14+export const SITEMAP_IDS = ['core', 'countries', 'country-topics', 'indicators', 'rankings', 'regions', 'stories'] as const;
13− const res = await safe(api.countries()); 15+export type SitemapId = (typeof SITEMAP_IDS)[number];
14− const lastModified = res?.meta.built_at ? new Date(res.meta.built_at) : new Date(); 16+
15− const staticEntries: MetadataRoute.Sitemap = [ 17+export async function generateSitemaps(): Promise<Array<{ id: SitemapId }>> {
16− { url: SITE_URL, lastModified, changeFrequency: 'daily', priority: 1 }, 18+ return SITEMAP_IDS.map((id) => ({ id }));
17− { url: `${SITE_URL}${routes.countries()}`, lastModified, changeFrequency: 'daily', priority: 0.9 }, 19+}
18− ]; 20+
19− const countries = res?.items ?? []; 21+export default async function sitemap(props: { id: Promise<string> | string }): Promise<MetadataRoute.Sitemap> {
20− const countryEntries: MetadataRoute.Sitemap = countries.flatMap((c) => { 22+ const id = (await props.id) as SitemapId;
21− const slug = c.slug ?? c.id; 23+ const health = await safe(api.health());
22− return [ 24+ const lastModified = health?.built_at ? new Date(health.built_at) : new Date();
23− { url: `${SITE_URL}${routes.country(slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.8 }, 25+ const entry = (path: string, changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'], priority: number): MetadataRoute.Sitemap[number] => ({ url: `${SITE_URL}${path}`, lastModified, changeFrequency, priority });
24− ...TOPICS.map((tp) => ({ url: `${SITE_URL}${routes.countryTopic(slug, tp.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 })), 26+
25− ]; 27+ switch (id) {
26− }); 28+ case 'core':
27− // Indicators, regions, sources + static reference pages (explore agent). 29+ return [
28− const [indicatorsRes, regionsRes, sourcesRes] = await Promise.all([safe(apiExplore.indicators()), safe(apiExplore.regions()), safe(apiExplore.sources())]); 30+ entry('/', 'daily', 1),
29− const exploreStatic: MetadataRoute.Sitemap = [ 31+ entry(routes.explore(), 'daily', 0.9),
30− { url: `${SITE_URL}${routes.indicators()}`, lastModified, changeFrequency: 'daily', priority: 0.9 }, 32+ entry(routes.countries(), 'daily', 0.9),
31− { url: `${SITE_URL}${routes.regions()}`, lastModified, changeFrequency: 'weekly', priority: 0.7 }, 33+ entry(routes.compare(), 'monthly', 0.7),
32− { url: `${SITE_URL}${routes.explore()}`, lastModified, changeFrequency: 'daily', priority: 0.7 }, 34+ entry(routes.rankings(), 'daily', 0.9),
33− { url: `${SITE_URL}${routes.changes()}`, lastModified, changeFrequency: 'daily', priority: 0.6 }, 35+ entry(routes.indicators(), 'daily', 0.9),
34− { url: `${SITE_URL}${routes.data()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 }, 36+ entry(routes.changes(), 'daily', 0.6),
35− { url: `${SITE_URL}${routes.sources()}`, lastModified, changeFrequency: 'weekly', priority: 0.5 }, 37+ entry(routes.regions(), 'weekly', 0.7),
36− { url: `${SITE_URL}${routes.methodology()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 }, 38+ entry(routes.trajectories(), 'monthly', 0.6),
37− { url: `${SITE_URL}${routes.api()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 }, 39+ entry(routes.scatter(), 'monthly', 0.6),
38− ]; 40+ entry(routes.finder(), 'monthly', 0.6),
39− const indicatorEntries: MetadataRoute.Sitemap = (indicatorsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.indicator(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 })); 41+ entry(routes.extremes(), 'weekly', 0.6),
40− const regionEntries: MetadataRoute.Sitemap = (regionsRes?.items ?? []).map((g) => ({ url: `${SITE_URL}${routes.region(g.slug ?? g.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 })); 42+ entry(routes.peers(), 'monthly', 0.5),
41− const sourceEntries: MetadataRoute.Sitemap = (sourcesRes?.items ?? []).map((s) => ({ url: `${SITE_URL}${routes.source(s.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.4 })); 43+ entry(routes.stories(), 'weekly', 0.7),
42− // Compare landing + rankings (compare/rankings agent). 44+ entry(routes.download(), 'monthly', 0.5),
43− const rankingsRes = await safe(apiCompare.rankings()); 45+ entry(routes.updates(), 'daily', 0.4),
44− const rankingEntries: MetadataRoute.Sitemap = [ 46+ entry(routes.sources(), 'weekly', 0.5),
45− { url: `${SITE_URL}${routes.compare()}`, lastModified, changeFrequency: 'monthly', priority: 0.7 }, 47+ entry(routes.methodology(), 'monthly', 0.5),
46− { url: `${SITE_URL}${routes.rankings()}`, lastModified, changeFrequency: 'daily', priority: 0.9 }, 48+ entry(routes.api(), 'monthly', 0.5),
47− ...(rankingsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.ranking(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 })), 49+ ...((await safe(apiExplore.sources()))?.items ?? []).map((s) => entry(routes.source(s.id), 'weekly', 0.4)),
48− ]; 50+ ];
49− return [...staticEntries, ...countryEntries, ...exploreStatic, ...indicatorEntries, ...regionEntries, ...sourceEntries, ...rankingEntries]; 51+ case 'countries': {
52+ const res = await safe(api.countries());
53+ return (res?.items ?? []).map((c) => entry(routes.country(c.slug ?? c.id), 'weekly', 0.8));
54+ }
55+ case 'country-topics': {
56+ const res = await safe(api.countries());
57+ return (res?.items ?? []).flatMap((c) => TOPICS.map((tp) => entry(routes.countryTopic(c.slug ?? c.id, tp.id), 'weekly', 0.6)));
58+ }
59+ case 'indicators': {
60+ const res = await safe(apiExplore.indicators());
61+ return (res?.items ?? []).map((i) => entry(routes.indicator(i.slug), 'weekly', 0.7));
62+ }
63+ case 'rankings': {
64+ const res = await safe(apiCompare.rankings());
65+ return (res?.items ?? []).map((i) => entry(routes.ranking(i.slug), 'weekly', 0.7));
66+ }
67+ case 'regions': {
68+ const res = await safe(apiExplore.regions());
69+ return (res?.items ?? []).map((g) => entry(routes.region(g.slug ?? g.id), 'weekly', 0.6));
70+ }
71+ case 'stories':
72+ return STORIES.map((s) => entry(routes.story(s.slug), 'weekly', 0.7));
73+ default:
74+ return [];
75+ }
50 } 76 }
added apps/web/src/app/stories/[slug]/page.tsx +106 −0
@@ -0,0 +1,106 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { t } from '@/i18n';
5+import { api, isNotBuilt, safe } from '@/lib/api';
6+import { formatDate } from '@/lib/format';
7+import { jsonLd, seoTitle } from '@/lib/seo';
8+import { routes } from '@/lib/site';
9+import { STORIES, storyBySlug, storyChartCount, storyIndicators } from '@/lib/stories';
10+import { topicById } from '@/lib/topics';
11+import { NotBuiltState } from '@/components/data/empty-state';
12+import { PageHeader } from '@/components/explore/page-header';
13+import { JsonLd } from '@/components/platform/json-ld';
14+import { loadStoryData } from '@/components/stories/resolve';
15+import { StoryBlocks } from '@/components/stories/story-blocks';
16+
17+export const revalidate = 3600;
18+
19+type Params = { slug: string };
20+
21+export function generateStaticParams(): Params[] {
22+ return STORIES.map((s) => ({ slug: s.slug }));
23+}
24+
25+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
26+ const { slug } = await params;
27+ const s = storyBySlug(slug);
28+ if (!s) return { title: t('stories.notFound'), robots: { index: false } };
29+ const title = seoTitle.story(s.title);
30+ const canonical = routes.story(s.slug);
31+ return {
32+ title,
33+ description: s.standfirst,
34+ alternates: { canonical },
35+ openGraph: { title: `${title} — ${t('site.name')}`, description: s.standfirst, url: canonical, type: 'article', publishedTime: s.published },
36+ twitter: { card: 'summary_large_image', title, description: s.standfirst },
37+ };
38+}
39+
40+export default async function StoryPage({ params }: { params: Promise<Params> }) {
41+ const { slug } = await params;
42+ const story = storyBySlug(slug);
43+ if (!story) notFound();
44+ let health;
45+ try {
46+ health = await api.health();
47+ } catch (e) {
48+ if (isNotBuilt(e)) return <NotBuiltState />;
49+ throw e;
50+ }
51+ if (health.status === 'empty') return <NotBuiltState />;
52+ const data = await loadStoryData(story);
53+ const indicators = storyIndicators(story);
54+ const indicatorRes = await Promise.all(indicators.map((s) => safe(api.indicatorMap(s, {}))));
55+ const indicatorNames = indicators.map((s, i) => ({ slug: s, name: indicatorRes[i]?.indicator.name ?? s }));
56+ const sources = Array.from(new Set(indicatorRes.map((m) => m?.provenance?.source_name).filter((x): x is string => !!x)));
57+ const others = STORIES.filter((s) => s.slug !== story.slug);
58+ const topics = story.topics.map((tp) => topicById(tp)?.short ?? tp);
59+
60+ return (
61+ <>
62+ <JsonLd data={[jsonLd.article({ slug: story.slug, title: story.title, description: story.standfirst, published: story.published, modified: health.built_at }), jsonLd.breadcrumbs([{ name: t('site.name'), path: '/' }, { name: t('stories.title'), path: routes.stories() }, { name: story.title, path: routes.story(story.slug) }])]} />
63+ <PageHeader crumbs={[{ href: routes.stories(), label: t('stories.title') }]} eyebrow={`${t('stories.eyebrow')} · ${topics.join(' · ')}`} title={story.title} lede={story.standfirst} meta={`${t('stories.charts', { n: storyChartCount(story) })} · ${t('stories.minutes', { n: Math.max(2, Math.round(storyChartCount(story) * 0.8)) })} · ${t('site.footer.refreshed', { date: formatDate(health.built_at) })}`} />
64+
65+ <article className="mt-4 md:mt-6">
66+ <StoryBlocks story={story} data={data} />
67+ </article>
68+
69+ <section className="hairline mt-12 pt-6" aria-labelledby="story-sources-h">
70+ <h2 id="story-sources-h" className="display text-xl text-ink md:text-2xl">
71+ {t('stories.sources')}
72+ </h2>
73+ <p className="mt-2 max-w-prose text-sm leading-relaxed text-ink-2">{t('stories.sourcesNote', { run: health.run_id ?? '', date: formatDate(health.built_at) })}</p>
74+ {sources.length ? <p className="mt-1 text-sm text-ink-2">{sources.join(' · ')}</p> : null}
75+ <h3 className="mt-4 text-sm font-semibold text-ink">{t('stories.indicators')}</h3>
76+ <ul className="mt-1 flex flex-wrap gap-1.5">
77+ {indicatorNames.map((i) => (
78+ <li key={i.slug}>
79+ <Link href={routes.indicator(i.slug)} className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-accent hover:text-accent">
80+ {i.name}
81+ </Link>
82+ </li>
83+ ))}
84+ </ul>
85+ <h3 className="mt-5 text-sm font-semibold text-ink">{t('stories.method')}</h3>
86+ <p className="mt-1 max-w-prose text-sm leading-relaxed text-ink-3">{t('stories.methodText')}</p>
87+ </section>
88+
89+ <section className="hairline mt-10 pt-6 pb-10" aria-labelledby="story-more-h">
90+ <h2 id="story-more-h" className="display text-xl text-ink md:text-2xl">
91+ {t('stories.more')}
92+ </h2>
93+ <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
94+ {others.map((s) => (
95+ <li key={s.slug} className="border-t border-rule">
96+ <Link href={routes.story(s.slug)} className="group flex min-h-[64px] flex-col justify-center py-3">
97+ <span className="text-sm font-semibold text-ink group-hover:text-accent">{s.title}</span>
98+ <span className="mt-0.5 line-clamp-2 text-xs text-ink-2">{s.standfirst}</span>
99+ </Link>
100+ </li>
101+ ))}
102+ </ul>
103+ </section>
104+ </>
105+ );
106+}
added apps/web/src/app/stories/page.tsx +51 −0
@@ -0,0 +1,51 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { t } from '@/i18n';
4+import { routes } from '@/lib/site';
5+import { STORIES, storyChartCount, storyIndicators } from '@/lib/stories';
6+import { topicById } from '@/lib/topics';
7+import { PageHeader } from '@/components/explore/page-header';
8+import { JsonLd } from '@/components/platform/json-ld';
9+import { jsonLd } from '@/lib/seo';
10+
11+export const revalidate = 3600;
12+
13+export const metadata: Metadata = {
14+ title: t('stories.title'),
15+ description: t('stories.description'),
16+ alternates: { canonical: routes.stories() },
17+ openGraph: { title: `${t('stories.title')} — ${t('site.name')}`, description: t('stories.description'), url: routes.stories(), type: 'website' },
18+};
19+
20+/** /stories — editorial index: one rule per story, standfirst, topics, chart count. */
21+export default function StoriesPage() {
22+ return (
23+ <>
24+ <JsonLd data={jsonLd.breadcrumbs([{ name: t('site.name'), path: '/' }, { name: t('stories.title'), path: routes.stories() }])} />
25+ <PageHeader title={t('stories.title')} lede={t('stories.sub')} />
26+ <ol className="divide-y divide-rule border-y border-rule">
27+ {STORIES.map((s, i) => {
28+ const topics = s.topics.map((tp) => topicById(tp)?.short ?? tp);
29+ return (
30+ <li key={s.slug}>
31+ <Link href={routes.story(s.slug)} className="group grid gap-x-8 gap-y-2 py-6 md:grid-cols-[4rem_minmax(0,1fr)_14rem] md:py-8">
32+ <span className="tnum display text-2xl text-ink-3 md:text-3xl">{String(i + 1).padStart(2, '0')}</span>
33+ <span className="min-w-0">
34+ <span className="display block text-2xl leading-tight text-ink group-hover:text-accent md:text-3xl">{s.title}</span>
35+ <span className="mt-2 block max-w-prose text-base text-ink-2">{s.standfirst}</span>
36+ </span>
37+ <span className="tnum flex flex-wrap items-start gap-x-3 gap-y-1 text-xs text-ink-3 md:flex-col md:items-end md:text-right">
38+ <span>{topics.join(' · ')}</span>
39+ <span>{t('stories.charts', { n: storyChartCount(s) })}</span>
40+ <span>{t('stories.minutes', { n: Math.max(2, Math.round(storyChartCount(s) * 0.8)) })}</span>
41+ <span className="hidden md:block">{storyIndicators(s).length === 1 ? t('stories.indicatorsOne') : t('stories.indicatorsN', { n: storyIndicators(s).length })}</span>
42+ </span>
43+ </Link>
44+ </li>
45+ );
46+ })}
47+ </ol>
48+ <p className="mt-6 max-w-prose text-sm leading-relaxed text-ink-3">{t('stories.methodText')}</p>
49+ </>
50+ );
51+}
added apps/web/src/app/updates/page.tsx +209 −0
@@ -0,0 +1,209 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { t, tOpt } from '@/i18n';
4+import { ApiError, isNotBuilt } from '@/lib/api';
5+import { apiPlatform } from '@/lib/api-platform';
6+import { compact, formatDate, grouped } from '@/lib/format';
7+import { routes } from '@/lib/site';
8+import { topicById } from '@/lib/topics';
9+import type { UpdatesResponse } from '@/lib/types-analytics';
10+import { cn } from '@/lib/cn';
11+import { EmptyState, NotBuiltState } from '@/components/data/empty-state';
12+import { FreshnessBadge } from '@/components/data/freshness-badge';
13+import { Section } from '@/components/data/section';
14+import { PageHeader } from '@/components/explore/page-header';
15+
16+export const revalidate = 600;
17+
18+export const metadata: Metadata = {
19+ title: t('updates.title'),
20+ description: t('updates.description'),
21+ alternates: { canonical: routes.updates() },
22+};
23+
24+const STATUS_TONE: Record<string, string> = {
25+ ok: 'border-up/40 text-up',
26+ partial: 'border-warn/40 text-warn',
27+ failed: 'border-down/40 text-down',
28+ stale: 'border-warn/40 text-warn',
29+ unknown: 'border-rule text-ink-3',
30+};
31+
32+export default async function UpdatesPage() {
33+ let data: UpdatesResponse | null = null;
34+ let missing = false;
35+ try {
36+ data = await apiPlatform.updates();
37+ } catch (e) {
38+ if (isNotBuilt(e)) return <NotBuiltState />;
39+ if (e instanceof ApiError && e.status === 404) missing = true;
40+ else throw e;
41+ }
42+ const now = Date.parse(data?.meta.generated_at ?? '') || Date.now();
43+
44+ return (
45+ <>
46+ <PageHeader title={t('updates.title')} lede={t('updates.sub')} meta={data?.snapshot.built_at ? `${t('site.footer.refreshed', { date: formatDate(data.snapshot.built_at) })} · ${t('site.footer.build', { run: data.snapshot.run_id ?? '' })}` : undefined} />
47+ {missing || !data ? (
48+ <EmptyState title={t('updates.unavailable')} />
49+ ) : (
50+ <>
51+ {/* Snapshot ticker */}
52+ <section aria-label={t('updates.snapshot.title')} className="border-y border-rule">
53+ <dl className="ticker divide-x divide-rule">
54+ {[
55+ [t('updates.snapshot.built'), formatDate(data.snapshot.built_at), null],
56+ [t('updates.snapshot.observations'), compact(data.snapshot.observations), null],
57+ [t('updates.snapshot.indicators'), grouped(data.snapshot.indicators), null],
58+ [t('updates.snapshot.countries'), grouped(data.snapshot.countries), null],
59+ [t('updates.snapshot.changed'), grouped(data.snapshot.values_changed), t('updates.snapshot.changedHint')],
60+ ].map(([k, v, hint]) => (
61+ <div key={k as string} className="min-w-[10rem] px-4 py-4 first:pl-0" title={(hint as string | null) ?? undefined}>
62+ <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{k}</dt>
63+ <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{v}</dd>
64+ </div>
65+ ))}
66+ </dl>
67+ </section>
68+
69+ <Section id="sources" title={t('updates.sources.title')} subtitle={t('updates.sources.sub')} className="border-t-0">
70+ {/* Desktop table */}
71+ <table className="hidden w-full border-collapse text-sm md:table">
72+ <thead>
73+ <tr className="border-b border-rule text-left text-xs text-ink-3">
74+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.source')}</th>
75+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.status')}</th>
76+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.lastImport')}</th>
77+ <th scope="col" className="py-1.5 pr-3 font-medium">{t('updates.col.vintage')}</th>
78+ <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.datasets')}</th>
79+ <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.indicators')}</th>
80+ <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.observations')}</th>
81+ <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.latestYear')}</th>
82+ <th scope="col" className="py-1.5 pr-3 text-right font-medium">{t('updates.col.changed')}</th>
83+ <th scope="col" className="py-1.5 text-right font-medium">{t('updates.col.countries')}</th>
84+ </tr>
85+ </thead>
86+ <tbody className="divide-y divide-rule">
87+ {data.sources.map((s) => (
88+ <tr key={s.source.id}>
89+ <td className="py-2 pr-3">
90+ <Link href={routes.source(s.source.id)} className="link-quiet font-medium text-ink">
91+ {s.source.name ?? s.source.id}
92+ </Link>
93+ <span className="block text-2xs text-ink-3">{s.source.organization}</span>
94+ </td>
95+ <td className="py-2 pr-3">
96+ <span className={cn('badge', STATUS_TONE[s.status] ?? STATUS_TONE.unknown)}>{tOpt(`updates.status.${s.status}`, s.status)}</span>
97+ </td>
98+ <td className="tnum py-2 pr-3 text-ink-2">
99+ {formatDate(s.last_success_at ?? s.last_retrieved_at)}
100+ <span className="ml-1.5 inline-block align-middle">
101+ <FreshnessBadge retrievedAt={s.last_success_at ?? s.last_retrieved_at} now={now} />
102+ </span>
103+ </td>
104+ <td className="tnum py-2 pr-3 text-ink-2">{formatDate(s.source_updated_at)}</td>
105+ <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.n_datasets)}</td>
106+ <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.n_indicators)}</td>
107+ <td className="tnum py-2 pr-3 text-right text-ink">{compact(s.n_observations)}</td>
108+ <td className="tnum py-2 pr-3 text-right text-ink">{s.latest_year ?? t('common.na')}</td>
109+ <td className="tnum py-2 pr-3 text-right text-ink">{grouped(s.values_changed)}</td>
110+ <td className="tnum py-2 text-right text-ink">{grouped(s.countries_affected)}</td>
111+ </tr>
112+ ))}
113+ </tbody>
114+ </table>
115+ {/* Phone: one block per source */}
116+ <ul className="divide-y divide-rule md:hidden">
117+ {data.sources.map((s) => (
118+ <li key={s.source.id} className="py-3">
119+ <div className="flex items-center justify-between gap-2">
120+ <Link href={routes.source(s.source.id)} className="link-quiet inline-flex min-h-[44px] min-w-0 items-center truncate text-sm font-medium text-ink">
121+ {s.source.name ?? s.source.id}
122+ </Link>
123+ <span className={cn('badge shrink-0', STATUS_TONE[s.status] ?? STATUS_TONE.unknown)}>{tOpt(`updates.status.${s.status}`, s.status)}</span>
124+ </div>
125+ <dl className="tnum mt-1.5 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
126+ <div className="flex justify-between gap-2">
127+ <dt className="text-ink-3">{t('updates.col.lastImport')}</dt>
128+ <dd className="text-ink">{formatDate(s.last_success_at ?? s.last_retrieved_at)}</dd>
129+ </div>
130+ <div className="flex justify-between gap-2">
131+ <dt className="text-ink-3">{t('updates.col.vintage')}</dt>
132+ <dd className="text-ink">{formatDate(s.source_updated_at)}</dd>
133+ </div>
134+ <div className="flex justify-between gap-2">
135+ <dt className="text-ink-3">{t('updates.col.observations')}</dt>
136+ <dd className="text-ink">{compact(s.n_observations)}</dd>
137+ </div>
138+ <div className="flex justify-between gap-2">
139+ <dt className="text-ink-3">{t('updates.col.latestYear')}</dt>
140+ <dd className="text-ink">{s.latest_year ?? t('common.na')}</dd>
141+ </div>
142+ <div className="flex justify-between gap-2">
143+ <dt className="text-ink-3">{t('updates.col.changed')}</dt>
144+ <dd className="text-ink">{grouped(s.values_changed)}</dd>
145+ </div>
146+ <div className="flex justify-between gap-2">
147+ <dt className="text-ink-3">{t('updates.col.countries')}</dt>
148+ <dd className="text-ink">{grouped(s.countries_affected)}</dd>
149+ </div>
150+ </dl>
151+ </li>
152+ ))}
153+ </ul>
154+ <p className="mt-3 max-w-prose text-xs text-ink-3">{t('updates.schedule')}</p>
155+ </Section>
156+
157+ <div className="grid gap-x-10 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]">
158+ <Section id="runs" title={t('updates.runs.title')} subtitle={t('updates.runs.sub')}>
159+ {data.recent_runs.length === 0 ? (
160+ <p className="text-sm text-ink-3">{t('updates.none')}</p>
161+ ) : (
162+ <ol className="divide-y divide-rule border-y border-rule">
163+ {data.recent_runs.map((r, i) => (
164+ <li key={`${r.run_id}-${r.connector}-${r.dataset}-${i}`} className="grid gap-x-4 gap-y-0.5 py-2 text-sm sm:grid-cols-[5.5rem_minmax(0,1fr)_5rem_6rem]">
165+ <span className={cn('badge self-start', STATUS_TONE[r.status ?? 'unknown'] ?? STATUS_TONE.unknown)}>{tOpt(`source.run.${r.status ?? 'unknown'}`, r.status ?? '')}</span>
166+ <span className="min-w-0">
167+ <span className="block truncate font-mono text-xs text-ink">
168+ {r.connector} · {r.dataset}
169+ </span>
170+ {r.message ? <span className="block truncate text-xs text-ink-3">{r.message}</span> : null}
171+ </span>
172+ <span className="tnum text-xs text-ink-2">
173+ {r.rows_valid != null ? `${compact(r.rows_valid)} ${t('updates.col.rows').toLowerCase()}` : ''}
174+ {r.warnings ? ` · ${r.warnings} ⚠` : ''}
175+ {r.errors ? ` · ${r.errors} ✕` : ''}
176+ </span>
177+ <span className="tnum text-xs text-ink-3">{formatDate(r.finished_at ?? r.started_at)}</span>
178+ </li>
179+ ))}
180+ </ol>
181+ )}
182+ </Section>
183+ <Section id="indicators" title={t('updates.indicators.title')} subtitle={t('updates.indicators.sub')}>
184+ <ul className="divide-y divide-rule">
185+ {data.indicators_recently_updated.slice(0, 15).map((ind) => (
186+ <li key={ind.id}>
187+ <Link href={routes.indicator(ind.slug)} className="group grid min-h-[48px] grid-cols-[1fr_auto] items-center gap-x-4 py-2">
188+ <span className="min-w-0">
189+ <span className="block truncate text-sm text-ink group-hover:text-accent">{ind.name ?? ind.slug}</span>
190+ <span className="block truncate text-xs text-ink-3">
191+ {topicById(ind.topic ?? '')?.short ?? ind.topic}
192+ {ind.primary_source_id ? ` · ${ind.primary_source_id}` : ''}
193+ </span>
194+ </span>
195+ <span className="tnum text-right text-xs text-ink-2">
196+ <span className="block">{formatDate(ind.latest_source_updated_at)}</span>
197+ {ind.last_year ? <span className="block text-ink-3">→ {ind.last_year}</span> : null}
198+ </span>
199+ </Link>
200+ </li>
201+ ))}
202+ </ul>
203+ </Section>
204+ </div>
205+ </>
206+ )}
207+ </>
208+ );
209+}
modified apps/web/src/components/brand/Logo.tsx +28 −15
@@ -2,26 +2,32 @@ import { cn } from '@/lib/cn';
2 import { t } from '@/i18n'; 2 import { t } from '@/i18n';
3 3
4 /** 4 /**
5− * CountryAtlas brand. 5+ * CountryAtlas brand mark — "the graticule A".
6− * Mark: a globe ring whose equator doubles as the crossbar of an "A" — two meridian-like legs rise to a 6+ * A globe ring carrying a light graticule (one inner meridian ellipse and a tropic parallel), whose equator is
7− * single apex, so the glyph reads as globe + atlas + the letter A at once. Pure geometry, strokes only, 7+ * the crossbar of a capital "A" drawn by two meridian legs meeting at the pole. Globe + atlas + the letter A in one
8− * `currentColor` → works on light/dark and in monochrome. Wordmark: "Country" regular + "Atlas" semibold. 8+ * glyph; strokes only, `currentColor`, monochrome-capable, legible at 16 px (the graticule fades, the A and the
9+ * ring stay).
9 * 10 *
10 * Usage: <Logo variant="full" /> (header), <Logo variant="mark" size={24} /> (favicons, OG), <Logo variant="wordmark" />. 11 * Usage: <Logo variant="full" /> (header), <Logo variant="mark" size={24} /> (favicons, OG), <Logo variant="wordmark" />.
11 */ 12 */
13+export const MARK_PATHS = {
14+ ring: { cx: 16, cy: 16, r: 13 },
15+ meridian: { cx: 16, cy: 16, rx: 5.6, ry: 13 },
16+ equator: 'M3.6 19.6h24.8',
17+ tropic: 'M4.2 11.2h23.6',
18+ legs: 'M9.3 27.4 16 6.2l6.7 21.2',
19+} as const;
20+
12 export function LogoMark({ size = 28, className, title, strokeWidth = 2 }: { size?: number; className?: string; title?: string; strokeWidth?: number }) { 21 export function LogoMark({ size = 28, className, title, strokeWidth = 2 }: { size?: number; className?: string; title?: string; strokeWidth?: number }) {
22+ const m = MARK_PATHS;
13 return ( 23 return (
14 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={cn('shrink-0', className)} role={title ? 'img' : undefined} aria-hidden={title ? undefined : true} aria-label={title}> 24 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={cn('shrink-0', className)} role={title ? 'img' : undefined} aria-hidden={title ? undefined : true} aria-label={title}>
15 {title ? <title>{title}</title> : null} 25 {title ? <title>{title}</title> : null}
16− {/* globe ring */} 26+ <circle cx={m.ring.cx} cy={m.ring.cy} r={m.ring.r} />
17− <circle cx="16" cy="16" r="13" /> 27+ <ellipse cx={m.meridian.cx} cy={m.meridian.cy} rx={m.meridian.rx} ry={m.meridian.ry} strokeWidth={strokeWidth * 0.6} opacity="0.5" />
18− {/* equator = crossbar of the A, clipped to the ring */} 28+ <path d={m.tropic} strokeWidth={strokeWidth * 0.6} opacity="0.5" />
19− <path d="M4.1 19.5h23.8" /> 29+ <path d={m.equator} />
20− {/* the A: two legs to a single apex */} 30+ <path d={m.legs} />
21− <path d="M9.6 27.2 16 6.8l6.4 20.4" />
22− {/* inner meridian hint */}
23− <path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55" />
24− <path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55" />
25 </svg> 31 </svg>
26 ); 32 );
27 } 33 }
@@ -47,7 +53,14 @@ export function Logo({ variant = 'full', size = 26, className }: { variant?: 'fu
47 } 53 }
48 54
49 /** Raw SVG string of the mark (for favicons / OG rasterisation); `color` is a CSS colour. */ 55 /** Raw SVG string of the mark (for favicons / OG rasterisation); `color` is a CSS colour. */
50−export function logoMarkSvg({ size = 512, color = '#1c5cab', background, radius = 0 }: { size?: number; color?: string; background?: string; radius?: number } = {}): string { 56+export function logoMarkSvg({ size = 512, color = '#1c5cab', background, radius = 0, strokeWidth = 2 }: { size?: number; color?: string; background?: string; radius?: number; strokeWidth?: number } = {}): string {
57+ const m = MARK_PATHS;
51 const bg = background ? `<rect width="32" height="32" rx="${radius}" fill="${background}" stroke="none"/>` : ''; 58 const bg = background ? `<rect width="32" height="32" rx="${radius}" fill="${background}" stroke="none"/>` : '';
52− return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${bg}<circle cx="16" cy="16" r="13"/><path d="M4.1 19.5h23.8"/><path d="M9.6 27.2 16 6.8l6.4 20.4"/><path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55"/><path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55"/></svg>`; 59+ const thin = (strokeWidth * 0.6).toFixed(2);
60+ return (
61+ `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round">${bg}` +
62+ `<circle cx="${m.ring.cx}" cy="${m.ring.cy}" r="${m.ring.r}"/>` +
63+ `<ellipse cx="${m.meridian.cx}" cy="${m.meridian.cy}" rx="${m.meridian.rx}" ry="${m.meridian.ry}" stroke-width="${thin}" opacity="0.5"/>` +
64+ `<path d="${m.tropic}" stroke-width="${thin}" opacity="0.5"/><path d="${m.equator}"/><path d="${m.legs}"/></svg>`
65+ );
53 } 66 }
added apps/web/src/components/charts/bubble-chart.tsx +294 −0
@@ -0,0 +1,294 @@
1+'use client';
2+import { scaleLinear, scaleLog, scaleSqrt } from 'd3-scale';
3+import { useCallback, useMemo, useState, type PointerEvent } from 'react';
4+import { t } from '@/i18n';
5+import { formatTick, formatValue } from '@/lib/format';
6+import { WB_REGIONS } from '@/lib/regions';
7+import type { FormatSpec as Spec } from '@/lib/types';
8+import { ChartFrame, type TableData } from './chart-frame';
9+import { CHART, seriesVar } from './palette';
10+import { ChartTooltip, TooltipRow } from './tooltip';
11+import { useMeasure } from './use-measure';
12+
13+export interface BubblePoint {
14+ id: string;
15+ label: string;
16+ flag?: string | null;
17+ x: number | null;
18+ y: number | null;
19+ size?: number | null;
20+ /** Colour key (World Bank region id); fixed slot per region so colour follows the entity. */
21+ region?: string | null;
22+ yearX?: number | null;
23+ yearY?: number | null;
24+}
25+
26+export interface BubbleFit {
27+ /** y = intercept + slope · f(x) where f = log10 when `logX`. In display units. */
28+ slope: number;
29+ intercept: number;
30+ logX: boolean;
31+ logY: boolean;
32+ label?: string;
33+}
34+
35+/** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */
36+export function regionColor(region: string | null | undefined): string {
37+ const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase());
38+ return seriesVar(i < 0 ? 7 : i);
39+}
40+
41+/**
42+ * Bubble / scatter chart for the analytical views (scatter, trajectories, peers). Fixed domains keep the
43+ * axes stable while a year slider animates positions (CSS transitions on translate/r). Colour = region,
44+ * size = a third indicator (sqrt scale), optional fitted line, trails for the selected countries, labels for
45+ * highlighted + the largest bubbles only, 24 px nearest-hit hover, tap to select on touch, table toggle.
46+ */
47+export function BubbleChart({
48+ points,
49+ xSpec,
50+ ySpec,
51+ sizeSpec,
52+ xDomain,
53+ yDomain,
54+ sizeDomain,
55+ logX = false,
56+ logY = false,
57+ fit = null,
58+ highlight = [],
59+ trails = {},
60+ onSelect,
61+ height = 420,
62+ labelCount = 6,
63+ animate = true,
64+ title,
65+ subtitle,
66+ className,
67+ defaultWidth = 800,
68+ legend = true,
69+ yearLabel,
70+}: {
71+ points: BubblePoint[];
72+ xSpec: Spec;
73+ ySpec: Spec;
74+ sizeSpec?: Spec | null;
75+ xDomain?: [number, number] | null;
76+ yDomain?: [number, number] | null;
77+ sizeDomain?: [number, number] | null;
78+ logX?: boolean;
79+ logY?: boolean;
80+ fit?: BubbleFit | null;
81+ highlight?: string[];
82+ /** id → earlier positions (oldest first) drawn as a faint path behind the bubble. */
83+ trails?: Record<string, Array<{ x: number; y: number }>>;
84+ onSelect?: (id: string | null) => void;
85+ height?: number;
86+ labelCount?: number;
87+ animate?: boolean;
88+ title?: React.ReactNode;
89+ subtitle?: React.ReactNode;
90+ className?: string;
91+ defaultWidth?: number;
92+ legend?: boolean;
93+ /** Big faded year printed behind the plot (trajectories). */
94+ yearLabel?: string | number | null;
95+}) {
96+ const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);
97+ const [hover, setHover] = useState<string | null>(null);
98+ const m = { top: 16, right: 20, bottom: 40, left: 56 };
99+ const hl = useMemo(() => new Set(highlight), [highlight]);
100+
101+ const model = useMemo(() => {
102+ const clean = points.filter((p) => p.x != null && p.y != null && Number.isFinite(p.x) && Number.isFinite(p.y) && (!logX || p.x! > 0) && (!logY || p.y! > 0)) as Array<BubblePoint & { x: number; y: number }>;
103+ const innerW = Math.max(10, width - m.left - m.right);
104+ const innerH = Math.max(10, height - m.top - m.bottom);
105+ const xs = clean.map((p) => p.x);
106+ const ys = clean.map((p) => p.y);
107+ const xd = xDomain ?? [Math.min(...xs), Math.max(...xs)];
108+ const yd = yDomain ?? [Math.min(...ys), Math.max(...ys)];
109+ const pad = (d: [number, number], log: boolean): [number, number] => {
110+ if (!Number.isFinite(d[0]) || !Number.isFinite(d[1])) return [0, 1];
111+ if (log) return [d[0] / 1.15, d[1] * 1.15];
112+ const span = d[1] - d[0] || Math.abs(d[0]) || 1;
113+ return [d[0] - span * 0.05, d[1] + span * 0.05];
114+ };
115+ const x = logX ? scaleLog().domain(pad(xd, true)).range([0, innerW]) : scaleLinear().domain(pad(xd, false)).range([0, innerW]).nice();
116+ const y = logY ? scaleLog().domain(pad(yd, true)).range([innerH, 0]) : scaleLinear().domain(pad(yd, false)).range([innerH, 0]).nice();
117+ const sizes = clean.map((p) => p.size ?? null).filter((v): v is number => v != null && v > 0);
118+ const sd = sizeDomain ?? (sizes.length ? [0, Math.max(...sizes)] : null);
119+ const r = sd ? scaleSqrt().domain([0, sd[1]]).range([3, Math.max(14, Math.min(34, innerW / 22))]) : null;
120+ const radius = (p: BubblePoint) => (r && p.size != null && p.size > 0 ? r(p.size) : 5);
121+ const labelled = new Set<string>(clean.filter((p) => hl.has(p.id)).map((p) => p.id));
122+ const bySize = [...clean].sort((a, b) => (b.size ?? 0) - (a.size ?? 0));
123+ for (const p of bySize) {
124+ if (labelled.size >= labelCount + hl.size) break;
125+ labelled.add(p.id);
126+ }
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]!);
129+ let fitPath: string | null = null;
130+ if (fit) {
131+ const [x0, x1] = x.domain() as [number, number];
132+ const steps = 40;
133+ const pts: string[] = [];
134+ for (let i = 0; i <= steps; i++) {
135+ const xv = logX ? x0 * Math.pow(x1 / x0, i / steps) : x0 + ((x1 - x0) * i) / steps;
136+ const fx = fit.logX ? Math.log10(xv) : xv;
137+ let yv = fit.intercept + fit.slope * fx;
138+ if (fit.logY) yv = Math.pow(10, yv);
139+ if (!Number.isFinite(yv) || (logY && yv <= 0)) continue;
140+ const py = y(yv);
141+ if (py < -innerH || py > innerH * 2) continue;
142+ pts.push(`${pts.length ? 'L' : 'M'}${x(xv).toFixed(1)},${py.toFixed(1)}`);
143+ }
144+ fitPath = pts.length > 1 ? pts.join(' ') : null;
145+ }
146+ // Draw small bubbles on top of large ones so nothing is hidden.
147+ const ordered = [...clean].sort((a, b) => radius(b) - radius(a));
148+ return { clean, ordered, x, y, radius, innerW, innerH, labelled, xTicks, yTicks, fitPath, r, sd };
149+ // eslint-disable-next-line react-hooks/exhaustive-deps
150+ }, [points, width, height, logX, logY, xDomain?.[0], xDomain?.[1], yDomain?.[0], yDomain?.[1], sizeDomain?.[0], sizeDomain?.[1], hl, labelCount, fit?.slope, fit?.intercept, fit?.logX, fit?.logY]);
151+
152+ const onMove = useCallback(
153+ (e: PointerEvent<SVGRectElement>) => {
154+ const rect = e.currentTarget.getBoundingClientRect();
155+ const px = e.clientX - rect.left;
156+ const py = e.clientY - rect.top;
157+ let best: string | null = null;
158+ let bd = 26 * 26;
159+ for (const p of model.clean) {
160+ const dx = model.x(p.x) - px;
161+ const dy = model.y(p.y) - py;
162+ const rr = model.radius(p);
163+ const d = Math.max(0, Math.sqrt(dx * dx + dy * dy) - rr);
164+ if (d * d < bd) {
165+ bd = d * d;
166+ best = p.id;
167+ }
168+ }
169+ setHover(best);
170+ },
171+ [model],
172+ );
173+
174+ const hp = hover ? model.clean.find((p) => p.id === hover) ?? null : null;
175+ const summary = t('chart.summary.scatter', { x: xSpec.name ?? 'x', y: ySpec.name ?? 'y', n: model.clean.length });
176+ const table: TableData = useMemo(
177+ () => ({
178+ columns: [{ key: 'label', label: t('common.country') }, { key: 'x', label: xSpec.name ?? 'x', numeric: true }, { key: 'y', label: ySpec.name ?? 'y', numeric: true }, ...(sizeSpec ? [{ key: 's', label: sizeSpec.name ?? 'size', numeric: true }] : [])],
179+ rows: model.clean.map((p) => ({ label: p.label, x: formatValue(p.x, xSpec), y: formatValue(p.y, ySpec), s: sizeSpec ? formatValue(p.size ?? null, sizeSpec) : '' })),
180+ }),
181+ [model.clean, xSpec, ySpec, sizeSpec],
182+ );
183+ 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;
185+
186+ return (
187+ <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}>
188+ <div ref={ref} className="relative w-full select-none" style={{ height }}>
189+ {model.clean.length === 0 ? (
190+ <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>
191+ ) : (
192+ <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>
193+ <title>{typeof title === 'string' ? title : `${ySpec.name ?? ''} vs ${xSpec.name ?? ''}`}</title>
194+ <desc>{summary}</desc>
195+ <g transform={`translate(${m.left},${m.top})`}>
196+ {yearLabel != null ? (
197+ <text x={model.innerW - 8} y={model.innerH - 12} textAnchor="end" className="display" style={{ fontSize: Math.min(120, model.innerH / 2.6), fill: 'var(--rule)', fontWeight: 600 }} aria-hidden>
198+ {yearLabel}
199+ </text>
200+ ) : null}
201+ <g className="grid">
202+ {model.yTicks.map((tk) => (
203+ <line key={`y${tk}`} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />
204+ ))}
205+ {model.xTicks.map((tk) => (
206+ <line key={`x${tk}`} x1={model.x(tk)} x2={model.x(tk)} y1={0} y2={model.innerH} strokeDasharray="2 4" />
207+ ))}
208+ </g>
209+ <g className="axis">
210+ <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />
211+ {model.xTicks.map((tk) => (
212+ <text key={tk} x={model.x(tk)} y={model.innerH + 16} textAnchor="middle">
213+ {formatTick(tk, xSpec)}
214+ </text>
215+ ))}
216+ {model.yTicks.map((tk) => (
217+ <text key={tk} x={-8} y={model.y(tk)} dy="0.32em" textAnchor="end">
218+ {formatTick(tk, ySpec)}
219+ </text>
220+ ))}
221+ <text x={model.innerW} y={model.innerH + 32} textAnchor="end" className="label label-strong">
222+ {xSpec.name}
223+ {logX ? ` (${t('common.log')})` : ''} →
224+ </text>
225+ <text x={-8} y={-4} textAnchor="end" className="label label-strong">
226+ ↑ {ySpec.name}
227+ {logY ? ` (${t('common.log')})` : ''}
228+ </text>
229+ </g>
230+ {model.fitPath ? <path d={model.fitPath} fill="none" stroke={CHART.ink3} strokeWidth={1.5} strokeDasharray="6 4" opacity={0.8} /> : null}
231+ {Object.entries(trails).map(([id, pts]) => {
232+ if (pts.length < 2) return null;
233+ const d = pts
234+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.y) && (!logX || p.x > 0) && (!logY || p.y > 0))
235+ .map((p, i) => `${i ? 'L' : 'M'}${model.x(p.x).toFixed(1)},${model.y(p.y).toFixed(1)}`)
236+ .join(' ');
237+ const pt = model.clean.find((p) => p.id === id);
238+ return <path key={`trail-${id}`} d={d} fill="none" stroke={regionColor(pt?.region)} strokeWidth={1.5} opacity={0.55} strokeLinejoin="round" />;
239+ })}
240+ {model.ordered.map((p) => {
241+ const isH = hl.has(p.id);
242+ const isHover = hover === p.id;
243+ const rr = model.radius(p);
244+ 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 }} />
247+ </g>
248+ );
249+ })}
250+ {model.ordered
251+ .filter((p) => model.labelled.has(p.id) || hover === p.id)
252+ .map((p) => {
253+ const rr = model.radius(p);
254+ 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 }}>
256+ {p.label}
257+ </text>
258+ );
259+ })}
260+ <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} onClick={() => onSelect?.(hover)} />
261+ </g>
262+ </svg>
263+ )}
264+ {hp ? (
265+ <ChartTooltip x={m.left + model.x(hp.x)} y={m.top + model.y(hp.y) - model.radius(hp)} width={width}>
266+ <div className="mb-0.5 font-medium text-ink">
267+ {hp.flag ? <span aria-hidden>{hp.flag} </span> : null}
268+ {hp.label}
269+ </div>
270+ <TooltipRow label={xSpec.name ?? 'x'} value={`${formatValue(hp.x, xSpec)}${hp.yearX ? ` · ${hp.yearX}` : ''}`} />
271+ <TooltipRow label={ySpec.name ?? 'y'} value={`${formatValue(hp.y, ySpec)}${hp.yearY ? ` · ${hp.yearY}` : ''}`} />
272+ {sizeSpec && hp.size != null ? <TooltipRow label={sizeSpec.name ?? 'size'} value={formatValue(hp.size, sizeSpec)} muted /> : null}
273+ </ChartTooltip>
274+ ) : null}
275+ </div>
276+ {legend && regionsPresent.length > 1 ? (
277+ <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2" aria-label={t('common.legend')}>
278+ {regionsPresent.map((rg) => (
279+ <li key={rg.id} className="inline-flex items-center gap-1.5">
280+ <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: regionColor(rg.id) }} />
281+ {rg.short}
282+ </li>
283+ ))}
284+ {sizeSpec && model.sd ? (
285+ <li className="ml-auto inline-flex items-center gap-1.5 text-ink-3">
286+ <span aria-hidden className="inline-block h-3.5 w-3.5 rounded-full border border-rule-strong" />
287+ {t('chart.bubble.size', { name: sizeSpec.name ?? '' })}
288+ </li>
289+ ) : null}
290+ </ul>
291+ ) : null}
292+ </ChartFrame>
293+ );
294+}
added apps/web/src/components/charts/histogram.tsx +123 −0
@@ -0,0 +1,123 @@
1+'use client';
2+import { useMemo, useState } from 'react';
3+import { t } from '@/i18n';
4+import { formatTick, formatValue } from '@/lib/format';
5+import type { FormatSpec as Spec } from '@/lib/types';
6+import { ChartFrame, type TableData } from './chart-frame';
7+import { CHART, MARK } from './palette';
8+import { DEFAULT_MARGIN, extent } from './scales';
9+import { ChartTooltip, TooltipRow } from './tooltip';
10+import { useMeasure } from './use-measure';
11+import { scaleLinear, scaleLog } from 'd3-scale';
12+
13+export interface HistogramMarker {
14+ id: string;
15+ label: string;
16+ value: number;
17+ /** 'accent' for the selected country, 'ink' for medians. */
18+ tone?: 'accent' | 'ink' | 'muted';
19+}
20+
21+/**
22+ * Country distribution histogram: equal-width bins (log-x when `edges` come from a log histogram), one
23+ * neutral colour, vertical markers (world median, region median, selected country) with labels above the
24+ * plot, hover tooltip per bin, accessible table. Bins are given by the API (`edges` = n+1 boundaries).
25+ */
26+export function Histogram({ edges, counts, log = false, markers = [], spec, height = 220, title, subtitle, className, defaultWidth = 640, unitLabel }: { edges: number[]; counts: number[]; log?: boolean; markers?: HistogramMarker[]; spec: Spec; height?: number; title?: React.ReactNode; subtitle?: React.ReactNode; className?: string; defaultWidth?: number; unitLabel?: string }) {
27+ const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth);
28+ const [hover, setHover] = useState<number | null>(null);
29+ const m = { ...DEFAULT_MARGIN, top: markers.length ? 34 : 12, bottom: 28, left: 36 };
30+ const model = useMemo(() => {
31+ const innerW = Math.max(10, width - m.left - m.right);
32+ const innerH = Math.max(10, height - m.top - m.bottom);
33+ const lo = edges[0] ?? 0;
34+ const hi = edges[edges.length - 1] ?? 1;
35+ const x = log && lo > 0 ? scaleLog().domain([lo, hi]).range([0, innerW]) : scaleLinear().domain([lo, hi]).range([0, innerW]);
36+ const maxC = Math.max(1, ...counts);
37+ const y = scaleLinear().domain([0, maxC]).range([innerH, 0]).nice(4);
38+ const bars = counts.map((c, i) => {
39+ const x0 = x(edges[i]!);
40+ const x1 = x(edges[i + 1]!);
41+ return { i, x: x0, w: Math.max(1, x1 - x0 - 1), y: y(c), h: innerH - y(c), c, lo: edges[i]!, hi: edges[i + 1]! };
42+ });
43+ const xTicks = (log ? (x as ReturnType<typeof scaleLog>).ticks(4) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 90)))).filter((v) => v >= lo && v <= hi);
44+ // Marker label placement: stagger vertically when two labels would collide.
45+ const placed = markers
46+ .map((mk) => ({ ...mk, px: Math.min(innerW, Math.max(0, x(mk.value))) }))
47+ .sort((a, b) => a.px - b.px)
48+ .map((mk, i, arr) => ({ ...mk, row: i > 0 && mk.px - arr[i - 1]!.px < 90 ? (i % 2) : 0 }));
49+ return { innerW, innerH, x, y, bars, xTicks, yTicks: y.ticks(3), placed, dom: extent(edges) ?? [lo, hi] };
50+ // eslint-disable-next-line react-hooks/exhaustive-deps
51+ }, [edges, counts, log, markers, width, height]);
52+
53+ const total = counts.reduce((a, b) => a + b, 0);
54+ const summary = t('chart.summary.histogram', { n: total, min: formatValue(model.dom[0], spec), max: formatValue(model.dom[1], spec) });
55+ const table: TableData = useMemo(
56+ () => ({
57+ columns: [{ key: 'range', label: spec.name ?? t('common.value') }, { key: 'n', label: t('chart.histogram.countries'), numeric: true }],
58+ rows: model.bars.map((b) => ({ range: `${formatValue(b.lo, spec)} – ${formatValue(b.hi, spec)}`, n: String(b.c) })),
59+ }),
60+ [model.bars, spec],
61+ );
62+ const hb = hover != null ? model.bars[hover] : null;
63+
64+ return (
65+ <ChartFrame title={title} subtitle={subtitle} summary={summary} table={total ? table : undefined} className={className} minHeight={height}>
66+ <div ref={ref} className="relative w-full" style={{ height }}>
67+ {total === 0 ? (
68+ <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div>
69+ ) : (
70+ <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}>
71+ <title>{typeof title === 'string' ? title : spec.name ?? ''}</title>
72+ <desc>{summary}</desc>
73+ <g transform={`translate(${m.left},${m.top})`}>
74+ <g className="grid">
75+ {model.yTicks.map((tk) => (
76+ <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} />
77+ ))}
78+ </g>
79+ {model.bars.map((b) => (
80+ <rect key={b.i} x={b.x} y={b.y} width={b.w} height={b.h} fill="var(--series-1)" fillOpacity={hover === b.i ? 1 : 0.75} rx={1} onPointerEnter={() => setHover(b.i)} onPointerLeave={() => setHover(null)} onPointerDown={() => setHover(b.i)} />
81+ ))}
82+ {model.placed.map((mk) => (
83+ <g key={mk.id} transform={`translate(${mk.px},0)`}>
84+ <line y1={-4} y2={model.innerH} stroke={mk.tone === 'accent' ? CHART.accent : mk.tone === 'muted' ? CHART.ruleStrong : CHART.ink} strokeWidth={mk.tone === 'accent' ? 2 : 1.25} strokeDasharray={mk.tone === 'muted' ? '3 3' : undefined} />
85+ <text y={-8 - mk.row * 12} textAnchor={mk.px > model.innerW * 0.8 ? 'end' : mk.px < model.innerW * 0.2 ? 'start' : 'middle'} className={mk.tone === 'accent' ? 'label label-strong' : 'label'} style={{ fill: mk.tone === 'accent' ? CHART.accent : undefined, paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}>
86+ {mk.label} · {formatValue(mk.value, spec)}
87+ </text>
88+ </g>
89+ ))}
90+ <g className="axis">
91+ <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} />
92+ {model.xTicks.map((tk) => (
93+ <text key={tk} x={model.x(tk)} y={model.innerH + 16} textAnchor="middle">
94+ {formatTick(tk, spec)}
95+ </text>
96+ ))}
97+ {model.yTicks.map((tk) => (
98+ <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end">
99+ {tk}
100+ </text>
101+ ))}
102+ {unitLabel ? (
103+ <text x={model.innerW} y={model.innerH + 27} textAnchor="end" className="label">
104+ {unitLabel}
105+ </text>
106+ ) : null}
107+ </g>
108+ </g>
109+ </svg>
110+ )}
111+ {hb ? (
112+ <ChartTooltip x={m.left + hb.x + hb.w / 2} y={m.top + hb.y} width={width}>
113+ <div className="mb-0.5 text-2xs text-ink-3">
114+ {formatValue(hb.lo, spec)} – {formatValue(hb.hi, spec)}
115+ </div>
116+ <TooltipRow label={t('chart.histogram.countries')} value={String(hb.c)} />
117+ </ChartTooltip>
118+ ) : null}
119+ </div>
120+ <span className="sr-only">{MARK.line}</span>
121+ </ChartFrame>
122+ );
123+}
modified apps/web/src/components/layout/nav-links.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/search-trigger.tsx +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/components/layout/site-header.tsx +8 −3
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/platform/download-builder.tsx +239 −0
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/platform/endpoint-explorer.tsx +190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/platform/json-ld.tsx +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stories/resolve.ts +166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stories/story-blocks.tsx +198 −0
Large diff (198 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/stories/story-map-frames.tsx +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/i18n/en.platform.ts +225 −1
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/api-platform.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/client-api-platform.ts +37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/og.tsx +53 −11
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/seo.ts +99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/stories.ts +298 −0
Large diff (299 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme