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 2 import Link from 'next/link';
3 3 import { t } from '@/i18n';
4 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 6 import { Section } from '@/components/data/section';
7 7 import { CodeBlock } from '@/components/explore/copy-button';
8 8 import { PageHeader } from '@/components/explore/page-header';
9 9 import { FooterCredits } from '@/components/layout/site-footer';
10 +import { EndpointExplorer, type ExplorerEndpoint } from '@/components/platform/endpoint-explorer';
10 11
11 12 export const revalidate = 3600;
12 13
@@ -18,38 +19,106 @@ export const metadata: Metadata = {
18 19
19 20 const BASE = `${SITE_URL}/api/v1`;
20 21
21 −const ENDPOINTS: Array<[string, string]> = [
22 − ['GET /health', t('apiPage.ep.health')],
23 − ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries')],
24 − ['GET /countries/{id}', t('apiPage.ep.country')],
25 − ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic')],
26 − ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries')],
27 − ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges')],
28 − ['GET /countries/{id}/events', t('apiPage.ep.countryEvents')],
29 − ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar')],
30 − ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights')],
31 − ['GET /countries/{id}/dna', t('apiPage.ep.countryDna')],
32 − ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload')],
33 − ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators')],
34 − ['GET /indicators/{slug}', t('apiPage.ep.indicator')],
35 − ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap')],
36 − ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend')],
37 − ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload')],
38 − ['GET /series?country=&indicator=', t('apiPage.ep.series')],
39 − ['GET /rankings?topic=', t('apiPage.ep.rankings')],
40 − ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking')],
41 − ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory')],
42 − ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare')],
43 − ['GET /compare/snapshot?countries=&topic=', t('apiPage.ep.compareSnapshot')],
44 − ['GET /regions?kind=', t('apiPage.ep.regions')],
45 − ['GET /regions/{slug}?indicator=', t('apiPage.ep.region')],
46 − ['GET /search?q=&type=', t('apiPage.ep.search')],
47 − ['GET /home', t('apiPage.ep.home')],
48 − ['GET /changes?kind=&indicator=&country=', t('apiPage.ep.changes')],
49 − ['GET /sources', t('apiPage.ep.sources')],
50 − ['GET /sources/{id}', t('apiPage.ep.source')],
51 − ['GET /methodology', t('apiPage.ep.methodology')],
22 +type Row = [string, string, string]; // endpoint, description, group key
23 +const ENDPOINTS: Row[] = [
24 + ['GET /health', t('apiPage.ep.health'), 'reference'],
25 + ['GET /countries?region=&income=&q=&sort=', t('apiPage.ep.countries'), 'countries'],
26 + ['GET /countries/{id}', t('apiPage.ep.country'), 'countries'],
27 + ['GET /countries/{id}/topics/{topic}', t('apiPage.ep.countryTopic'), 'countries'],
28 + ['GET /countries/{id}/series/{indicator}?from=&to=', t('apiPage.ep.countrySeries'), 'countries'],
29 + ['GET /countries/{id}/changes', t('apiPage.ep.countryChanges'), 'countries'],
30 + ['GET /countries/{id}/events', t('apiPage.ep.countryEvents'), 'countries'],
31 + ['GET /countries/{id}/similar?mode=', t('apiPage.ep.countrySimilar'), 'countries'],
32 + ['GET /countries/{id}/insights', t('apiPage.ep.countryInsights'), 'countries'],
33 + ['GET /countries/{id}/dna?reference=', t('apiPage.ep.countryDna'), 'countries'],
34 + ['GET /countries/{id}/story', t('apiPage.ep.story'), 'analytics'],
35 + ['GET /countries/{id}/quality', t('apiPage.ep.countryQuality'), 'analytics'],
36 + ['GET /countries/{id}/download.csv|json', t('apiPage.ep.countryDownload'), 'countries'],
37 + ['GET /indicators?topic=&q=&source=', t('apiPage.ep.indicators'), 'indicators'],
38 + ['GET /indicators/{slug}', t('apiPage.ep.indicator'), 'indicators'],
39 + ['GET /indicators/{slug}/map?year=', t('apiPage.ep.indicatorMap'), 'indicators'],
40 + ['GET /indicators/{slug}/trend?group=', t('apiPage.ep.indicatorTrend'), 'indicators'],
41 + ['GET /indicators/{slug}/frames?from=&to=', t('apiPage.ep.frames'), 'analytics'],
42 + ['GET /indicators/{slug}/distribution?year=&highlight=', t('apiPage.ep.distribution'), 'analytics'],
43 + ['GET /indicators/{slug}/related?limit=', t('apiPage.ep.related'), 'analytics'],
44 + ['GET /indicators/{slug}/quality', t('apiPage.ep.indicatorQuality'), 'analytics'],
45 + ['GET /indicators/{slug}/download.csv|json', t('apiPage.ep.indicatorDownload'), 'indicators'],
46 + ['GET /series?country=&indicator=', t('apiPage.ep.series'), 'indicators'],
47 + ['GET /rankings?topic=', t('apiPage.ep.rankings'), 'rankings'],
48 + ['GET /rankings/{indicator}?year=&group=&sort=', t('apiPage.ep.ranking'), 'rankings'],
49 + ['GET /rankings/{indicator}/history?countries=', t('apiPage.ep.rankingHistory'), 'rankings'],
50 + ['GET /rankings/{indicator}/race?from=&to=&top=', t('apiPage.ep.race'), 'analytics'],
51 + ['GET /compare?countries=&indicators=&mode=', t('apiPage.ep.compare'), 'rankings'],
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 123 const PROVENANCE_FIELDS: Array<[string, string]> = [
55 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 130 ['licence', 'Licence of the source for this series.'],
62 131 ];
63 132
64 −type Example = { key: string; path: string; pick: (j: unknown) => unknown };
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> {
133 +async function liveVersion(): Promise<string | null> {
118 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 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 139 } catch {
123 140 return null;
124 141 }
125 142 }
126 143
127 144 export default async function ApiPage() {
128 − const bodies = await Promise.all(EXAMPLES.map(fetchExample));
145 + const runId = await liveVersion();
129 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 158 <p className="max-w-prose text-base leading-relaxed text-ink-2">{t('apiPage.intro')}</p>
133 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 160 <div>
@@ -153,38 +179,45 @@ export default async function ApiPage() {
153 179 </div>
154 180 </dl>
155 181
156 − <Section id="endpoints" title={t('apiPage.endpoints.title')} subtitle={t('apiPage.endpoints.sub')} className="border-t-0">
157 − <table className="w-full border-collapse text-sm">
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>
182 + <Section id="explorer" title={t('apiPage.explorer.title')} subtitle={t('apiPage.explorer.sub')} className="border-t-0">
183 + <EndpointExplorer endpoints={EXPLORER} base={BASE} />
175 184 </Section>
176 185
177 − <Section id="examples" title={t('apiPage.examples.title')} subtitle={t('apiPage.examples.sub')}>
178 − <div className="space-y-8">
179 − {EXAMPLES.map((ex, i) => (
180 − <div key={ex.key} className="min-w-0">
181 − <h3 className="mb-2 text-sm font-semibold text-ink">{t(`apiPage.ex.${ex.key}` as 'apiPage.ex.country')}</h3>
182 − <CodeBlock code={`curl -s "${BASE}${ex.path}"`} lang="bash" />
183 − <div className="mt-2 text-2xs text-ink-3">{t('apiPage.examples.live')}</div>
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 + <Section id="new" title={t('apiPage.new.title')} subtitle={t('apiPage.new.sub')}>
187 + <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
188 + {NEW_IN_11.map((k) => (
189 + <li key={k} className="border-t border-rule py-2.5">
190 + <code className="block break-all font-mono text-xs text-ink">GET {NEW_PATHS[k]}</code>
191 + <span className="mt-0.5 block text-sm text-ink-2">{t(`apiPage.ep.${k}` as 'apiPage.ep.pulse')}</span>
192 + </li>
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 221 </Section>
189 222
190 223 <Section id="provenance" title={t('apiPage.provenance.title')} subtitle={t('apiPage.provenance.sub')}>
@@ -197,7 +230,7 @@ export default async function ApiPage() {
197 230 value: 55697.66,
198 231 period: '2025-01-01',
199 232 year: 2025,
200 − formatted: '55.7k',
233 + formatted: 'US$55.7k',
201 234 status: 'imported',
202 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 275 <Link href={routes.methodology()} className="text-accent hover:underline">
243 276 {t('method.title')} →
244 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 282 </p>
246 283 <div className="mt-6 text-sm text-ink-2">
247 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 4 export default function manifest(): MetadataRoute.Manifest {
5 5 return {
6 − name: SITE_NAME,
6 + name: `${SITE_NAME} — ${TAGLINE}`,
7 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 9 start_url: '/',
10 10 display: 'standalone',
11 11 background_color: '#fbfaf7',
12 12 theme_color: '#1c5cab',
13 + categories: ['education', 'news', 'reference'],
13 14 icons: [
14 − { src: '/icon.svg', type: 'image/svg+xml', sizes: 'any' },
15 − { src: '/icon.png', type: 'image/png', sizes: '512x512' },
16 − { src: '/apple-icon.png', type: 'image/png', sizes: '180x180' },
15 + { src: '/icon.svg', type: 'image/svg+xml', sizes: 'any', purpose: 'any' },
16 + { src: '/icon.png', type: 'image/png', sizes: '512x512', purpose: 'any' },
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 1 import { ImageResponse } from 'next/og';
2 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 8 export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
7 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). */
10 −export default function OpenGraphImage() {
12 +/** Default share card: dark editorial "wallpaper" with the world map, wordmark, tagline and the live headline counts. */
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 22 return new ImageResponse(
12 23 (
13 − <OgFrame>
24 + <OgFrame map>
14 25 <OgWordmark size={40} />
15 − <div style={{ display: 'flex', flexDirection: 'column', marginTop: 'auto', maxWidth: 720 }}>
16 − <div style={{ fontSize: 62, lineHeight: 1.08, fontWeight: 600, letterSpacing: -1.5 }}>{t('home.hero.title')}</div>
17 − <div style={{ marginTop: 22, fontSize: 26, color: OG_INK2, lineHeight: 1.35 }}>{t('home.hero.sub')}</div>
26 + <div style={{ display: 'flex', flexDirection: 'column', marginTop: 'auto', maxWidth: 760 }}>
27 + <div style={{ fontSize: 66, lineHeight: 1.05, fontWeight: 600, letterSpacing: -1.8, display: 'flex' }}>{t('site.tagline')}</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 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 34 </OgFrame>
21 35 ),
22 36 { ...size },
modified apps/web/src/app/robots.ts +10 −2
@@ -1,10 +1,18 @@
1 1 import type { MetadataRoute } from 'next';
2 2 import { SITE_URL } from '@/lib/site';
3 +import { SITEMAP_IDS } from './sitemap';
3 4
4 5 export default function robots(): MetadataRoute.Robots {
5 6 return {
6 − rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api/'] }],
7 − sitemap: `${SITE_URL}/sitemap.xml`,
7 + rules: [
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 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 3 import { apiCompare } from '@/lib/api-compare';
4 4 import { apiExplore } from '@/lib/api-explore';
5 5 import { SITE_URL, routes } from '@/lib/site';
6 +import { STORIES } from '@/lib/stories';
6 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 − * here (same pattern: fetch the list with `safe()`, map to URLs, tolerate an empty API).
10 + * Sharded sitemap (Next `generateSitemaps`): one file per URL family so no shard grows past a few thousand URLs.
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> {
13 − const res = await safe(api.countries());
14 − const lastModified = res?.meta.built_at ? new Date(res.meta.built_at) : new Date();
15 − const staticEntries: MetadataRoute.Sitemap = [
16 − { url: SITE_URL, lastModified, changeFrequency: 'daily', priority: 1 },
17 − { url: `${SITE_URL}${routes.countries()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
18 − ];
19 − const countries = res?.items ?? [];
20 − const countryEntries: MetadataRoute.Sitemap = countries.flatMap((c) => {
21 − const slug = c.slug ?? c.id;
22 − return [
23 − { url: `${SITE_URL}${routes.country(slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.8 },
24 − ...TOPICS.map((tp) => ({ url: `${SITE_URL}${routes.countryTopic(slug, tp.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.6 })),
25 − ];
26 − });
27 − // Indicators, regions, sources + static reference pages (explore agent).
28 − const [indicatorsRes, regionsRes, sourcesRes] = await Promise.all([safe(apiExplore.indicators()), safe(apiExplore.regions()), safe(apiExplore.sources())]);
29 − const exploreStatic: MetadataRoute.Sitemap = [
30 − { url: `${SITE_URL}${routes.indicators()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
31 − { url: `${SITE_URL}${routes.regions()}`, lastModified, changeFrequency: 'weekly', priority: 0.7 },
32 − { url: `${SITE_URL}${routes.explore()}`, lastModified, changeFrequency: 'daily', priority: 0.7 },
33 − { url: `${SITE_URL}${routes.changes()}`, lastModified, changeFrequency: 'daily', priority: 0.6 },
34 − { url: `${SITE_URL}${routes.data()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
35 − { url: `${SITE_URL}${routes.sources()}`, lastModified, changeFrequency: 'weekly', priority: 0.5 },
36 − { url: `${SITE_URL}${routes.methodology()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
37 − { url: `${SITE_URL}${routes.api()}`, lastModified, changeFrequency: 'monthly', priority: 0.5 },
38 − ];
39 − const indicatorEntries: MetadataRoute.Sitemap = (indicatorsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.indicator(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 }));
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 }));
41 − const sourceEntries: MetadataRoute.Sitemap = (sourcesRes?.items ?? []).map((s) => ({ url: `${SITE_URL}${routes.source(s.id)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.4 }));
42 − // Compare landing + rankings (compare/rankings agent).
43 − const rankingsRes = await safe(apiCompare.rankings());
44 − const rankingEntries: MetadataRoute.Sitemap = [
45 − { url: `${SITE_URL}${routes.compare()}`, lastModified, changeFrequency: 'monthly', priority: 0.7 },
46 − { url: `${SITE_URL}${routes.rankings()}`, lastModified, changeFrequency: 'daily', priority: 0.9 },
47 − ...(rankingsRes?.items ?? []).map((i) => ({ url: `${SITE_URL}${routes.ranking(i.slug)}`, lastModified, changeFrequency: 'weekly' as const, priority: 0.7 })),
48 − ];
49 − return [...staticEntries, ...countryEntries, ...exploreStatic, ...indicatorEntries, ...regionEntries, ...sourceEntries, ...rankingEntries];
14 +export const SITEMAP_IDS = ['core', 'countries', 'country-topics', 'indicators', 'rankings', 'regions', 'stories'] as const;
15 +export type SitemapId = (typeof SITEMAP_IDS)[number];
16 +
17 +export async function generateSitemaps(): Promise<Array<{ id: SitemapId }>> {
18 + return SITEMAP_IDS.map((id) => ({ id }));
19 +}
20 +
21 +export default async function sitemap(props: { id: Promise<string> | string }): Promise<MetadataRoute.Sitemap> {
22 + const id = (await props.id) as SitemapId;
23 + const health = await safe(api.health());
24 + const lastModified = health?.built_at ? new Date(health.built_at) : new Date();
25 + const entry = (path: string, changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency'], priority: number): MetadataRoute.Sitemap[number] => ({ url: `${SITE_URL}${path}`, lastModified, changeFrequency, priority });
26 +
27 + switch (id) {
28 + case 'core':
29 + return [
30 + entry('/', 'daily', 1),
31 + entry(routes.explore(), 'daily', 0.9),
32 + entry(routes.countries(), 'daily', 0.9),
33 + entry(routes.compare(), 'monthly', 0.7),
34 + entry(routes.rankings(), 'daily', 0.9),
35 + entry(routes.indicators(), 'daily', 0.9),
36 + entry(routes.changes(), 'daily', 0.6),
37 + entry(routes.regions(), 'weekly', 0.7),
38 + entry(routes.trajectories(), 'monthly', 0.6),
39 + entry(routes.scatter(), 'monthly', 0.6),
40 + entry(routes.finder(), 'monthly', 0.6),
41 + entry(routes.extremes(), 'weekly', 0.6),
42 + entry(routes.peers(), 'monthly', 0.5),
43 + entry(routes.stories(), 'weekly', 0.7),
44 + entry(routes.download(), 'monthly', 0.5),
45 + entry(routes.updates(), 'daily', 0.4),
46 + entry(routes.sources(), 'weekly', 0.5),
47 + entry(routes.methodology(), 'monthly', 0.5),
48 + entry(routes.api(), 'monthly', 0.5),
49 + ...((await safe(apiExplore.sources()))?.items ?? []).map((s) => entry(routes.source(s.id), 'weekly', 0.4)),
50 + ];
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 2 import { t } from '@/i18n';
3 3
4 4 /**
5 − * CountryAtlas brand.
6 − * Mark: a globe ring whose equator doubles as the crossbar of an "A" — two meridian-like legs rise to a
7 − * single apex, so the glyph reads as globe + atlas + the letter A at once. Pure geometry, strokes only,
8 − * `currentColor` → works on light/dark and in monochrome. Wordmark: "Country" regular + "Atlas" semibold.
5 + * CountryAtlas brand mark — "the graticule A".
6 + * A globe ring carrying a light graticule (one inner meridian ellipse and a tropic parallel), whose equator is
7 + * the crossbar of a capital "A" drawn by two meridian legs meeting at the pole. Globe + atlas + the letter A in one
8 + * glyph; strokes only, `currentColor`, monochrome-capable, legible at 16 px (the graticule fades, the A and the
9 + * ring stay).
9 10 *
10 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 21 export function LogoMark({ size = 28, className, title, strokeWidth = 2 }: { size?: number; className?: string; title?: string; strokeWidth?: number }) {
22 + const m = MARK_PATHS;
13 23 return (
14 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 25 {title ? <title>{title}</title> : null}
16 − {/* globe ring */}
17 − <circle cx="16" cy="16" r="13" />
18 − {/* equator = crossbar of the A, clipped to the ring */}
19 − <path d="M4.1 19.5h23.8" />
20 − {/* the A: two legs to a single apex */}
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" />
26 + <circle cx={m.ring.cx} cy={m.ring.cy} r={m.ring.r} />
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" />
28 + <path d={m.tropic} strokeWidth={strokeWidth * 0.6} opacity="0.5" />
29 + <path d={m.equator} />
30 + <path d={m.legs} />
25 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 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 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