SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%

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 12 days ago (Sep 12, 2026) parent 96daba8

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
@@ -13,7 +13,7 @@ export function NavLinks({ items }: { items: Array<{ href: string; label: string
13 13 const active = pathname === it.href || pathname.startsWith(`${it.href}/`);
14 14 return (
15 15 <li key={it.href}>
16 − <Link href={it.href} aria-current={active ? 'page' : undefined} className={cn('inline-flex h-9 items-center rounded-sm px-2.5 text-sm', active ? 'font-medium text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
16 + <Link href={it.href} aria-current={active ? 'page' : undefined} className={cn('inline-flex h-9 items-center rounded-sm px-2 text-sm xl:px-2.5', active ? 'font-medium text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
17 17 {it.label}
18 18 </Link>
19 19 </li>
modified apps/web/src/components/layout/search-trigger.tsx +1 −1
@@ -24,7 +24,7 @@ export function SearchTrigger({ variant = 'field', className }: { variant?: 'fie
24 24 );
25 25 }
26 26 return (
27 − <button type="button" onClick={open} className={cn('flex h-9 w-56 items-center gap-2 rounded-sm border border-rule bg-surface px-2.5 text-left text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 lg:w-72', className)} aria-label={t('search.open')}>
27 + <button type="button" onClick={open} className={cn('flex h-9 w-44 items-center gap-2 rounded-sm border border-rule bg-surface px-2.5 text-left text-sm text-ink-3 hover:border-rule-strong hover:text-ink-2 xl:w-72', className)} aria-label={t('search.open')}>
28 28 <Search size={15} aria-hidden />
29 29 <span className="flex-1 truncate">{t('search.placeholder.short')}</span>
30 30 <kbd className="rounded-xs border border-rule px-1 py-px font-ui text-2xs">{t('search.shortcut')}</kbd>
modified apps/web/src/components/layout/site-header.tsx +8 −3
@@ -38,17 +38,22 @@ export function SiteHeader() {
38 38 <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85">
39 39 <div className="container-x mx-auto flex h-[52px] max-w-[1400px] items-center gap-3 md:h-14">
40 40 <Link href={routes.home()} className="flex h-11 items-center rounded-sm pr-1" aria-label={t('brand.home')}>
41 − <Logo variant="full" />
41 + <span className="md:hidden lg:inline-flex">
42 + <Logo variant="full" />
43 + </span>
44 + <span className="hidden md:inline-flex lg:hidden">
45 + <Logo variant="mark" />
46 + </span>
42 47 </Link>
43 48 <NavLinks items={PRIMARY_NAV.map((n) => ({ href: n.href, label: t(n.key) }))} />
44 49 <div className="hidden md:block">
45 50 <MoreMenu items={MORE_NAV.map((n) => ({ href: n.href, label: t(n.key as 'nav.regions'), group: n.group }))} />
46 51 </div>
47 52 <div className="ml-auto flex items-center gap-1">
48 − <div className="hidden md:block">
53 + <div className="hidden lg:block">
49 54 <SearchTrigger variant="field" />
50 55 </div>
51 − <div className="md:hidden">
56 + <div className="lg:hidden">
52 57 <SearchTrigger variant="icon" />
53 58 </div>
54 59 <ThemeToggle />
added apps/web/src/components/platform/download-builder.tsx +239 −0
@@ -0,0 +1,239 @@
1 +'use client';
2 +import { Check, Copy, Download, X } from 'lucide-react';
3 +import { useEffect, useMemo, useRef, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { clientPlatform } from '@/lib/client-api-platform';
6 +import { cn } from '@/lib/cn';
7 +import { grouped } from '@/lib/format';
8 +import { routes } from '@/lib/site';
9 +import { parseList, useUrlState } from '@/lib/url-state';
10 +import type { CountryLite } from '@/lib/types-compare';
11 +import { seriesVar } from '@/components/charts/palette';
12 +import { CountryTypeahead } from '@/components/compare/country-picker';
13 +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';
14 +
15 +const MAX_COUNTRIES = 20;
16 +const MAX_INDICATORS = 20;
17 +const MIN_YEAR = 1960;
18 +
19 +interface Preset {
20 + key: 'g7' | 'brics' | 'climate';
21 + countries: string[];
22 + indicators: string[];
23 +}
24 +const PRESETS: Preset[] = [
25 + { key: 'g7', countries: ['canada', 'france', 'germany', 'italy', 'japan', 'united-kingdom', 'united-states'], indicators: ['population', 'gdp', 'gdp-per-capita', 'gdp-growth', 'inflation', 'unemployment-rate', 'life-expectancy', 'general-government-gross-debt-pct-gdp'] },
26 + { key: 'brics', countries: ['brazil', 'russia', 'india', 'china', 'south-africa'], indicators: ['gdp', 'gdp-per-capita-ppp', 'gdp-growth', 'inflation', 'trade-pct-gdp', 'population'] },
27 + { key: 'climate', countries: ['china', 'united-states', 'india', 'russia', 'japan', 'germany', 'iran', 'saudi-arabia'], indicators: ['co2-emissions', 'co2-per-capita', 'renewable-electricity-share', 'energy-use-per-capita', 'carbon-intensity-electricity'] },
28 +];
29 +
30 +/**
31 + * Dataset builder: countries (≤ 20) × indicators (≤ 20) × year range × format → the exact
32 + * `/api/v1/compare/download.{fmt}` URL, with a live row estimate for small selections. State lives in the URL
33 + * (`?countries=&indicators=&from=&to=&format=`) so a build is shareable.
34 + */
35 +export function DownloadBuilder({ countries, indicators, maxYear }: { countries: CountryLite[]; indicators: IndicatorOption[]; maxYear: number }) {
36 + const { get, getNum, set } = useUrlState();
37 + const bySlug = useMemo(() => new Map(countries.map((c) => [c.slug, c])), [countries]);
38 + const byInd = useMemo(() => new Map(indicators.map((i) => [i.slug, i])), [indicators]);
39 + const selCountries = parseList(get('countries'), MAX_COUNTRIES).filter((s) => bySlug.has(s));
40 + const selIndicators = parseList(get('indicators'), MAX_INDICATORS).filter((s) => byInd.has(s));
41 + const from = getNum('from');
42 + const to = getNum('to');
43 + const format = (get('format') === 'json' ? 'json' : 'csv') as 'csv' | 'json';
44 + const forecast = get('forecast') === '1';
45 + const [copied, setCopied] = useState(false);
46 + const [estimate, setEstimate] = useState<{ key: string; rows: number } | null>(null);
47 + const [estimating, setEstimating] = useState(false);
48 + const abortRef = useRef<AbortController | null>(null);
49 +
50 + const ids = selCountries.map((s) => bySlug.get(s)!.id);
51 + const ready = ids.length > 0 && selIndicators.length > 0;
52 + const q = ready ? routes.compareDownload(ids, selIndicators, { from, to }, format) + (forecast ? '' : '&include_forecast=false') : null;
53 + const estKey = `${ids.join(',')}|${selIndicators.join(',')}|${from ?? ''}|${to ?? ''}|${forecast}`;
54 +
55 + useEffect(() => {
56 + if (!ready || ids.length * selIndicators.length > 60) {
57 + setEstimate(null);
58 + return;
59 + }
60 + if (estimate?.key === estKey) return;
61 + abortRef.current?.abort();
62 + const ctrl = new AbortController();
63 + abortRef.current = ctrl;
64 + setEstimating(true);
65 + const timer = setTimeout(() => {
66 + clientPlatform
67 + .seriesBundle(ids, selIndicators.slice(0, 12), { from, to }, ctrl.signal)
68 + .then((r) => {
69 + const rows = r.series.reduce((a, s) => a + s.values.filter((v) => forecast || !v.is_forecast).length, 0);
70 + const scale = selIndicators.length > 12 ? selIndicators.length / 12 : 1;
71 + setEstimate({ key: estKey, rows: Math.round(rows * scale) });
72 + })
73 + .catch(() => setEstimate(null))
74 + .finally(() => {
75 + if (!ctrl.signal.aborted) setEstimating(false);
76 + });
77 + }, 250);
78 + return () => {
79 + clearTimeout(timer);
80 + ctrl.abort();
81 + };
82 + // eslint-disable-next-line react-hooks/exhaustive-deps
83 + }, [estKey, ready]);
84 +
85 + const years: number[] = [];
86 + for (let y = maxYear; y >= MIN_YEAR; y--) years.push(y);
87 + const copy = async () => {
88 + if (!q) return;
89 + try {
90 + await navigator.clipboard.writeText(`${window.location.origin}${q}`);
91 + setCopied(true);
92 + setTimeout(() => setCopied(false), 1600);
93 + } catch {
94 + /* clipboard unavailable */
95 + }
96 + };
97 + const setCountries = (next: string[]) => set({ countries: next.join(',') || null }, 0);
98 + const setIndicators = (next: string[]) => set({ indicators: next.join(',') || null }, 0);
99 + const excl = new Set(selCountries);
100 + const pickerOptions = indicators.filter((i) => !selIndicators.includes(i.slug));
101 +
102 + return (
103 + <div className="min-w-0">
104 + {/* Quick starts */}
105 + <div className="flex flex-wrap items-center gap-1.5 text-sm">
106 + <span className="text-xs text-ink-3">{t('download.presetHint')}</span>
107 + {PRESETS.map((p) => (
108 + <button key={p.key} type="button" onClick={() => set({ countries: p.countries.join(','), indicators: p.indicators.join(',') }, 0)} className="inline-flex h-11 items-center rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5">
109 + {t(`download.preset.${p.key}` as 'download.preset.g7')}
110 + </button>
111 + ))}
112 + </div>
113 +
114 + <div className="mt-5 grid gap-x-10 gap-y-6 lg:grid-cols-2">
115 + {/* Countries */}
116 + <div className="min-w-0">
117 + <div className="mb-1.5 flex items-baseline justify-between">
118 + <div className="eyebrow">{t('download.countries')}</div>
119 + <span className="tnum text-xs text-ink-3">
120 + {selCountries.length}/{MAX_COUNTRIES}
121 + </span>
122 + </div>
123 + {selCountries.length < MAX_COUNTRIES ? <CountryTypeahead countries={countries} exclude={excl} onPick={(c) => setCountries([...selCountries, c.slug])} placeholder={t('download.addCountry')} /> : <p className="text-xs text-ink-3">{t('download.max', { n: MAX_COUNTRIES })}</p>}
124 + <ul className="mt-2 flex flex-wrap gap-1.5">
125 + {selCountries.map((s, i) => {
126 + const c = bySlug.get(s)!;
127 + return (
128 + <li key={s}>
129 + <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">
130 + <span aria-hidden className="h-2 w-2 rounded-full" style={{ background: seriesVar(i % 8) }} />
131 + <span aria-hidden>{c.flag}</span>
132 + <span className="max-w-[10rem] truncate">{c.name}</span>
133 + <button type="button" onClick={() => setCountries(selCountries.filter((x) => x !== s))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('download.remove', { name: c.name })}>
134 + <X size={14} aria-hidden />
135 + </button>
136 + </span>
137 + </li>
138 + );
139 + })}
140 + </ul>
141 + </div>
142 +
143 + {/* Indicators */}
144 + <div className="min-w-0">
145 + <div className="mb-1.5 flex items-baseline justify-between">
146 + <div className="eyebrow">{t('download.indicators')}</div>
147 + <span className="tnum text-xs text-ink-3">
148 + {selIndicators.length}/{MAX_INDICATORS}
149 + </span>
150 + </div>
151 + {selIndicators.length < MAX_INDICATORS ? <IndicatorSelect options={pickerOptions} value="" onChange={(slug) => setIndicators([...selIndicators, slug])} label={t('download.addIndicator')} size="sm" /> : <p className="text-xs text-ink-3">{t('download.max', { n: MAX_INDICATORS })}</p>}
152 + <ul className="mt-2 flex flex-wrap gap-1.5">
153 + {selIndicators.map((s) => {
154 + const i = byInd.get(s)!;
155 + return (
156 + <li key={s}>
157 + <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2.5 text-sm md:h-9">
158 + <span className="max-w-[14rem] truncate">{i.short_name ?? i.name}</span>
159 + <button type="button" onClick={() => setIndicators(selIndicators.filter((x) => x !== s))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('download.remove', { name: i.name })}>
160 + <X size={14} aria-hidden />
161 + </button>
162 + </span>
163 + </li>
164 + );
165 + })}
166 + </ul>
167 + </div>
168 + </div>
169 +
170 + {/* Years · format */}
171 + <div className="mt-6 flex flex-wrap items-end gap-x-6 gap-y-3 border-t border-rule pt-4">
172 + <fieldset className="flex items-center gap-1.5">
173 + <legend className="eyebrow mb-1.5">{t('download.years')}</legend>
174 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
175 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('download.from')}</span>
176 + <select value={from ?? ''} onChange={(e) => set({ from: e.target.value || null }, 0)} className="tnum bg-transparent text-ink outline-none" aria-label={t('download.from')}>
177 + <option value="">{t('download.allYears')}</option>
178 + {years.map((y) => (
179 + <option key={y} value={y} disabled={to != null && y > to}>
180 + {y}
181 + </option>
182 + ))}
183 + </select>
184 + </label>
185 + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">
186 + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('download.to')}</span>
187 + <select value={to ?? ''} onChange={(e) => set({ to: e.target.value || null }, 0)} className="tnum bg-transparent text-ink outline-none" aria-label={t('download.to')}>
188 + <option value="">{t('download.allYears')}</option>
189 + {years.map((y) => (
190 + <option key={y} value={y} disabled={from != null && y < from}>
191 + {y}
192 + </option>
193 + ))}
194 + </select>
195 + </label>
196 + </fieldset>
197 + <div>
198 + <div className="eyebrow mb-1.5">{t('download.format')}</div>
199 + <Segmented value={format} onChange={(v) => set({ format: v === 'csv' ? null : v }, 0)} options={[{ value: 'csv', label: 'CSV' }, { value: 'json', label: 'JSON' }]} label={t('download.format')} />
200 + </div>
201 + <label className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border px-2.5 text-sm md:h-9', forecast ? 'border-ink text-ink' : 'border-rule text-ink-2')}>
202 + <input type="checkbox" checked={forecast} onChange={(e) => set({ forecast: e.target.checked ? '1' : null }, 0)} className="h-5 w-5 accent-[var(--accent)]" />
203 + {t('download.forecast')}
204 + </label>
205 + {selCountries.length || selIndicators.length ? (
206 + <button type="button" onClick={() => set({ countries: null, indicators: null, from: null, to: null, format: null, forecast: null }, 0)} className="inline-flex h-11 items-center rounded-sm px-2 text-sm text-ink-2 hover:text-ink md:h-9">
207 + {t('download.clear')}
208 + </button>
209 + ) : null}
210 + </div>
211 +
212 + {/* Result */}
213 + <div className="mt-5 border-t border-rule pt-4">
214 + <div className="flex flex-wrap items-center gap-3">
215 + <a href={q ?? '#'} aria-disabled={!q} className={cn('inline-flex h-11 items-center gap-2 rounded-sm px-4 text-sm font-medium md:h-10', q ? 'bg-ink text-paper hover:bg-accent hover:text-accent-ink' : 'pointer-events-none bg-surface-2 text-ink-3')} download>
216 + <Download size={15} aria-hidden /> {t('download.get', { fmt: format.toUpperCase() })}
217 + </a>
218 + <span className="tnum text-sm text-ink-2">
219 + {ready ? t('download.selection', { c: selCountries.length, i: selIndicators.length }) : t('download.needBoth')}
220 + {ready && (estimate?.key === estKey || estimating) ? <span className="text-ink-3"> · {estimating && estimate?.key !== estKey ? t('download.estimating') : t('download.estimate', { n: grouped(estimate?.rows ?? 0) })}</span> : null}
221 + </span>
222 + </div>
223 + {q ? (
224 + <div className="mt-3 min-w-0">
225 + <div className="eyebrow mb-1">{t('download.url')}</div>
226 + <div className="flex items-stretch gap-1">
227 + <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-sm border border-rule bg-surface px-2 py-2 font-mono text-xs text-ink-2">{q}</code>
228 + <button type="button" onClick={copy} className="inline-flex h-auto shrink-0 items-center gap-1 rounded-sm border border-rule px-2.5 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink" aria-live="polite">
229 + {copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
230 + {copied ? t('common.copied') : t('download.copyUrl')}
231 + </button>
232 + </div>
233 + {ready && estimate ? <p className="mt-1 text-2xs text-ink-3">{t('download.estimateNote')}</p> : null}
234 + </div>
235 + ) : null}
236 + </div>
237 + </div>
238 + );
239 +}
added apps/web/src/components/platform/endpoint-explorer.tsx +190 −0
@@ -0,0 +1,190 @@
1 +'use client';
2 +import { Check, Copy, Play } from 'lucide-react';
3 +import { useMemo, useState } from 'react';
4 +import { t } from '@/i18n';
5 +import { rawRequest } from '@/lib/client-api-platform';
6 +import { cn } from '@/lib/cn';
7 +import { compact } from '@/lib/format';
8 +
9 +export interface ExplorerParam {
10 + name: string;
11 + /** `path` params are substituted in the template; `query` params appended. */
12 + in: 'path' | 'query';
13 + description: string;
14 + example?: string;
15 + required?: boolean;
16 + options?: string[];
17 +}
18 +export interface ExplorerEndpoint {
19 + id: string;
20 + group: string;
21 + /** e.g. "/countries/{id}/series/{indicator}" */
22 + template: string;
23 + summary: string;
24 + params: ExplorerParam[];
25 +}
26 +
27 +const MAX_SHOW = 40_000;
28 +
29 +function buildPath(ep: ExplorerEndpoint, values: Record<string, string>): string {
30 + let path = ep.template;
31 + const q = new URLSearchParams();
32 + for (const p of ep.params) {
33 + const v = (values[p.name] ?? p.example ?? '').trim();
34 + if (p.in === 'path') path = path.replace(`{${p.name}}`, encodeURIComponent(v || p.example || ''));
35 + else if (v) q.append(p.name, v);
36 + }
37 + const s = q.toString();
38 + return `/api/v1${path}${s ? `?${s}` : ''}`;
39 +}
40 +
41 +function snippets(url: string): Record<'curl' | 'js' | 'py', string> {
42 + return {
43 + curl: `curl -s "${url}" | jq .`,
44 + js: `const res = await fetch("${url}", { headers: { accept: "application/json" } });\nconst data = await res.json();\nconsole.log(data.meta, data);`,
45 + py: `import requests\n\nr = requests.get("${url}", headers={"accept": "application/json"}, timeout=30)\nr.raise_for_status()\ndata = r.json()\nprint(data["meta"], list(data)[:8])`,
46 + };
47 +}
48 +
49 +/**
50 + * Interactive endpoint explorer: pick an endpoint, fill typed parameters, run it against the same-origin API,
51 + * see status/latency/size and the pretty JSON (capped), and copy the request as curl / JavaScript / Python.
52 + */
53 +export function EndpointExplorer({ endpoints, base }: { endpoints: ExplorerEndpoint[]; base: string }) {
54 + const [id, setId] = useState(endpoints[0]?.id ?? '');
55 + const ep = useMemo(() => endpoints.find((e) => e.id === id) ?? endpoints[0]!, [endpoints, id]);
56 + const [values, setValues] = useState<Record<string, string>>({});
57 + const [lang, setLang] = useState<'curl' | 'js' | 'py'>('curl');
58 + const [copied, setCopied] = useState(false);
59 + const [state, setState] = useState<'idle' | 'running' | 'done' | 'error'>('idle');
60 + const [result, setResult] = useState<{ status: number; ms: number; bytes: number; text: string } | null>(null);
61 + const [error, setError] = useState<string | null>(null);
62 + const path = buildPath(ep, values);
63 + const url = `${base}${path}`;
64 + const snip = snippets(url);
65 + const groups = Array.from(new Set(endpoints.map((e) => e.group)));
66 +
67 + const run = async () => {
68 + setState('running');
69 + setError(null);
70 + try {
71 + const r = await rawRequest(path);
72 + const pretty = r.json != null ? JSON.stringify(r.json, null, 2) : r.text;
73 + setResult({ status: r.status, ms: r.ms, bytes: r.bytes, text: pretty });
74 + setState('done');
75 + } catch (e) {
76 + setError((e as Error).message);
77 + setState('error');
78 + }
79 + };
80 + const copy = async () => {
81 + try {
82 + await navigator.clipboard.writeText(snip[lang]);
83 + setCopied(true);
84 + setTimeout(() => setCopied(false), 1600);
85 + } catch {
86 + /* ignore */
87 + }
88 + };
89 + const shown = result ? (result.text.length > MAX_SHOW ? result.text.slice(0, MAX_SHOW) : result.text) : '';
90 +
91 + return (
92 + <div className="grid gap-x-10 gap-y-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
93 + <div className="min-w-0">
94 + <label className="block">
95 + <span className="eyebrow mb-1.5 block">{t('apiPage.explorer.endpoint')}</span>
96 + <select
97 + value={ep.id}
98 + onChange={(e) => {
99 + setId(e.target.value);
100 + setValues({});
101 + setResult(null);
102 + setState('idle');
103 + }}
104 + className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none focus:border-accent md:h-10"
105 + aria-label={t('apiPage.explorer.endpoint')}
106 + >
107 + {groups.map((g) => (
108 + <optgroup key={g} label={g}>
109 + {endpoints
110 + .filter((e) => e.group === g)
111 + .map((e) => (
112 + <option key={e.id} value={e.id}>
113 + GET {e.template}
114 + </option>
115 + ))}
116 + </optgroup>
117 + ))}
118 + </select>
119 + </label>
120 + <p className="mt-1.5 text-sm text-ink-2">{ep.summary}</p>
121 +
122 + {ep.params.length ? (
123 + <div className="mt-4">
124 + <div className="eyebrow mb-1.5">{t('apiPage.explorer.params')}</div>
125 + <ul className="divide-y divide-rule border-y border-rule">
126 + {ep.params.map((p) => (
127 + <li key={p.name} className="grid gap-x-3 gap-y-1 py-2 sm:grid-cols-[9rem_minmax(0,1fr)]">
128 + <label htmlFor={`p-${ep.id}-${p.name}`} className="pt-2 font-mono text-xs text-ink">
129 + {p.name}
130 + <span className="ml-1 font-ui text-2xs text-ink-3">{p.in === 'path' ? t('apiPage.explorer.pathParam') : p.required ? t('apiPage.explorer.required') : t('apiPage.explorer.optional')}</span>
131 + </label>
132 + <div className="min-w-0">
133 + {p.options ? (
134 + <select id={`p-${ep.id}-${p.name}`} value={values[p.name] ?? p.example ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none focus:border-accent md:h-9">
135 + {!p.required ? <option value="">—</option> : null}
136 + {p.options.map((o) => (
137 + <option key={o} value={o}>
138 + {o}
139 + </option>
140 + ))}
141 + </select>
142 + ) : (
143 + <input id={`p-${ep.id}-${p.name}`} type="text" value={values[p.name] ?? ''} placeholder={p.example ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-9" />
144 + )}
145 + <p className="mt-0.5 text-xs text-ink-3">{p.description}</p>
146 + </div>
147 + </li>
148 + ))}
149 + </ul>
150 + </div>
151 + ) : null}
152 +
153 + <div className="mt-4">
154 + <div className="eyebrow mb-1.5">{t('apiPage.explorer.request')}</div>
155 + <div className="flex flex-wrap items-center gap-1">
156 + {(['curl', 'js', 'py'] as const).map((l) => (
157 + <button key={l} type="button" onClick={() => setLang(l)} className={cn('inline-flex h-11 items-center rounded-sm px-2.5 text-xs md:h-8', lang === l ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')} aria-pressed={lang === l}>
158 + {t(`apiPage.explorer.copy.${l}` as 'apiPage.explorer.copy.curl')}
159 + </button>
160 + ))}
161 + <button type="button" onClick={copy} className="ml-auto inline-flex h-11 items-center gap-1 rounded-sm border border-rule px-2 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink md:h-8" aria-live="polite">
162 + {copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}
163 + {copied ? t('common.copied') : t('indicator.copy')}
164 + </button>
165 + </div>
166 + <pre className="mt-1 overflow-x-auto rounded-sm border border-rule bg-surface p-3 font-mono text-xs leading-relaxed text-ink">
167 + <code>{snip[lang]}</code>
168 + </pre>
169 + <button type="button" onClick={run} disabled={state === 'running'} className="mt-3 inline-flex h-11 items-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink disabled:opacity-60 md:h-10">
170 + <Play size={14} aria-hidden /> {state === 'running' ? t('apiPage.explorer.running') : t('apiPage.explorer.run')}
171 + </button>
172 + </div>
173 + </div>
174 +
175 + <div className="min-w-0">
176 + <div className="eyebrow mb-1.5">{t('apiPage.explorer.response')}</div>
177 + {state === 'error' ? <p className="text-sm text-down">{t('apiPage.explorer.error', { msg: error ?? '' })}</p> : null}
178 + {result ? (
179 + <div className="tnum mb-1 text-xs text-ink-3">
180 + <span className={result.status < 400 ? 'text-up' : 'text-down'}>{t('apiPage.explorer.status', { status: result.status, ms: result.ms, size: `${compact(result.bytes)}B` })}</span>
181 + {result.text.length > MAX_SHOW ? <span> · {t('apiPage.explorer.truncated', { n: compact(MAX_SHOW) })}</span> : null}
182 + </div>
183 + ) : null}
184 + <pre className={cn('max-h-[36rem] min-h-[12rem] overflow-auto rounded-sm border border-rule bg-surface-2/60 p-3 font-mono text-xs leading-relaxed text-ink-2', state === 'running' && 'opacity-60')} aria-live="polite" aria-busy={state === 'running'}>
185 + <code>{shown || `GET ${path}`}</code>
186 + </pre>
187 + </div>
188 + </div>
189 + );
190 +}
added apps/web/src/components/platform/json-ld.tsx +6 −0
@@ -0,0 +1,6 @@
1 +import { jsonLdString } from '@/lib/seo';
2 +
3 +/** Server component: one JSON-LD script tag (pass one object or an array). */
4 +export function JsonLd({ data }: { data: Record<string, unknown> | Array<Record<string, unknown>> }) {
5 + return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(data) }} />;
6 +}
added apps/web/src/components/stories/resolve.ts +166 −0
@@ -0,0 +1,166 @@
1 +import 'server-only';
2 +import { api, safe } from '@/lib/api';
3 +import { apiCompare } from '@/lib/api-compare';
4 +import { apiExplore } from '@/lib/api-explore';
5 +import { formatValue, grouped } from '@/lib/format';
6 +import type { Story, StoryBlock, TextParagraph, VarSpec } from '@/lib/stories';
7 +import type { CountrySummary, FormatSpec, IndicatorCard, MapResponse, RankingResponse, Series } from '@/lib/types';
8 +import type { TrendResponse } from '@/lib/types-explore';
9 +
10 +/**
11 + * Fetches every payload a story needs (in parallel, deduplicated by request key) and resolves the text
12 + * variables from those payloads. All fetches are tolerant (`safe`): a missing payload makes the block render
13 + * an "unavailable" state and drops the sentences that depend on it.
14 + */
15 +export interface StoryData {
16 + countries: CountrySummary[];
17 + trends: Map<string, TrendResponse | null>; // `${indicator}|${group}`
18 + maps: Map<string, MapResponse | null>; // `${indicator}|${year}` ('' = latest)
19 + rankings: Map<string, RankingResponse | null>; // `${indicator}|${year}|${sort}|${top}`
20 + series: Map<string, Series[] | null>; // `${indicator}|${countries}|${from}`
21 +}
22 +
23 +const trendKey = (ind: string, group: string) => `${ind}|${group}`;
24 +const mapKey = (ind: string, year: number | undefined) => `${ind}|${year ?? ''}`;
25 +const rankKey = (ind: string, year: number | undefined, sort: string | undefined, top: number) => `${ind}|${year ?? ''}|${sort ?? ''}|${top}`;
26 +const seriesKey = (ind: string, countries: string[], from: number | undefined) => `${ind}|${countries.join(',')}|${from ?? ''}`;
27 +
28 +export async function loadStoryData(story: Story): Promise<StoryData> {
29 + const trendKeys = new Set<string>();
30 + const mapKeys = new Set<string>();
31 + const rankKeys = new Set<string>();
32 + const seriesKeys = new Set<string>();
33 + const visitVar = (v: VarSpec) => {
34 + if (v.type === 'trend') trendKeys.add(trendKey(v.indicator, v.group ?? 'world'));
35 + else if (v.type === 'mapCount') mapKeys.add(mapKey(v.indicator, v.year));
36 + else if (v.type === 'rankTop') rankKeys.add(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3)));
37 + else if (v.type === 'country') seriesKeys.add(seriesKey(v.indicator, [v.country], undefined));
38 + else if (v.type === 'share') {
39 + trendKeys.add(trendKey(v.indicator, v.group));
40 + trendKeys.add(trendKey(v.indicator, 'world'));
41 + }
42 + };
43 + for (const b of story.blocks) {
44 + if (b.kind === 'text') for (const p of b.paragraphs) for (const v of Object.values(p.vars)) visitVar(v);
45 + else if (b.kind === 'map') for (const y of b.years) mapKeys.add(mapKey(b.indicator, y));
46 + else if (b.kind === 'trend') for (const g of b.groups ?? ['world']) trendKeys.add(trendKey(b.indicator, g));
47 + else if (b.kind === 'lines') seriesKeys.add(seriesKey(b.indicator, b.countries, b.from));
48 + else if (b.kind === 'ranked') rankKeys.add(rankKey(b.indicator, b.year, b.sort, b.top));
49 + else if (b.kind === 'shares') {
50 + for (const g of b.groups) trendKeys.add(trendKey(b.indicator, g));
51 + trendKeys.add(trendKey(b.indicator, 'world'));
52 + }
53 + }
54 + const [countriesRes, trendVals, mapVals, rankVals, seriesVals] = await Promise.all([
55 + safe(api.countries()),
56 + Promise.all([...trendKeys].map((k) => {
57 + const [ind, group] = k.split('|');
58 + // min_n 3: small groups (North America has three members) would otherwise return no points.
59 + return safe(apiExplore.indicatorTrend(ind!, group!, { min_n: 3 }));
60 + })),
61 + Promise.all([...mapKeys].map((k) => {
62 + const [ind, year] = k.split('|');
63 + return safe(api.indicatorMap(ind!, year ? { year: Number(year) } : {}));
64 + })),
65 + Promise.all([...rankKeys].map((k) => {
66 + const [ind, year, sort, top] = k.split('|');
67 + return safe(apiCompare.ranking(ind!, { year: year ? Number(year) : null, sort: (sort || null) as 'asc' | 'desc' | null, limit: Number(top), sparkline: false }));
68 + })),
69 + Promise.all([...seriesKeys].map((k) => {
70 + const [ind, cs, from] = k.split('|');
71 + return safe(apiExplore.seriesBundle(cs!.split(','), [ind!], from ? { from: Number(from), include_forecast: false } : { include_forecast: false }));
72 + })),
73 + ]);
74 + return {
75 + countries: countriesRes?.items ?? [],
76 + trends: new Map([...trendKeys].map((k, i) => [k, trendVals[i] ?? null])),
77 + maps: new Map([...mapKeys].map((k, i) => [k, mapVals[i] ?? null])),
78 + rankings: new Map([...rankKeys].map((k, i) => [k, rankVals[i] ?? null])),
79 + series: new Map([...seriesKeys].map((k, i) => [k, seriesVals[i]?.series ?? null])),
80 + };
81 +}
82 +
83 +export const getTrend = (d: StoryData, ind: string, group = 'world') => d.trends.get(trendKey(ind, group)) ?? null;
84 +export const getMap = (d: StoryData, ind: string, year?: number) => d.maps.get(mapKey(ind, year)) ?? null;
85 +export const getRanking = (d: StoryData, b: Extract<StoryBlock, { kind: 'ranked' }>) => d.rankings.get(rankKey(b.indicator, b.year, b.sort, b.top)) ?? null;
86 +export const getSeries = (d: StoryData, b: Extract<StoryBlock, { kind: 'lines' }>) => d.series.get(seriesKey(b.indicator, b.countries, b.from)) ?? null;
87 +
88 +type TrendStat = 'preferred' | 'median' | 'mean' | 'weighted_mean' | 'sum';
89 +
90 +function trendValue(tr: TrendResponse, year: 'first' | 'last' | number, stat: TrendStat = 'preferred'): { year: number; value: number | null; n: number } | null {
91 + const pts = tr.points;
92 + if (!pts.length) return null;
93 + const p = year === 'first' ? pts[0]! : year === 'last' ? pts[pts.length - 1]! : pts.find((q) => q.year === year);
94 + if (!p) return null;
95 + const key = stat === 'preferred' || !stat ? (tr.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') : stat;
96 + const v = (p as unknown as Record<string, number | null>)[key] ?? p.median;
97 + return { year: p.year, value: v ?? null, n: p.n };
98 +}
99 +
100 +/** Resolve one variable to a display string, or null when it cannot be computed from the loaded data. */
101 +export function resolveVar(v: VarSpec, d: StoryData): string | null {
102 + if (v.type === 'trend') {
103 + const tr = getTrend(d, v.indicator, v.group ?? 'world');
104 + if (!tr) return null;
105 + const r = trendValue(tr, v.year, v.stat);
106 + if (!r || r.value == null) return null;
107 + if (v.format === 'year') return String(r.year);
108 + if (v.format === 'n') return grouped(r.n);
109 + return formatValue(r.value, tr.indicator);
110 + }
111 + if (v.type === 'mapCount') {
112 + const m = getMap(d, v.indicator, v.year);
113 + if (!m || m.n === 0) return null;
114 + const vals = Object.values(m.values).filter((x): x is number => typeof x === 'number');
115 + const n = vals.filter((x) => (v.op === 'gte' ? x >= v.threshold : v.op === 'gt' ? x > v.threshold : v.op === 'lte' ? x <= v.threshold : x < v.threshold)).length;
116 + if (v.format === 'total') return grouped(vals.length);
117 + if (v.format === 'year') return String(m.year_used ?? v.year ?? '');
118 + if (v.format === 'pct') return `${Math.round((n / vals.length) * 100)} %`;
119 + return grouped(n);
120 + }
121 + if (v.type === 'rankTop') {
122 + const r = d.rankings.get(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3)));
123 + const row = r?.rows[(v.pos ?? 1) - 1];
124 + if (!r || !row) return null;
125 + if (v.format === 'name') return row.country.name ?? row.country.id;
126 + if (v.format === 'year') return String(row.year ?? r.year_used ?? '');
127 + return formatValue(row.value, r.indicator);
128 + }
129 + if (v.type === 'country') {
130 + const s = d.series.get(seriesKey(v.indicator, [v.country], undefined))?.[0];
131 + if (!s) return null;
132 + const vals = s.values.filter((x) => x.value != null && !x.is_forecast);
133 + const p = v.year === 'first' ? vals[0] : v.year === 'last' ? vals[vals.length - 1] : vals.find((x) => x.year === v.year);
134 + if (!p) return null;
135 + if (v.format === 'year') return String(p.year ?? '');
136 + if (v.format === 'name') return s.country.name ?? s.country.id;
137 + return formatValue(p.value, s.indicator);
138 + }
139 + if (v.type === 'share') {
140 + const g = getTrend(d, v.indicator, v.group);
141 + const w = getTrend(d, v.indicator, 'world');
142 + if (!g || !w) return null;
143 + const gv = trendValue(g, v.year, 'sum');
144 + if (!gv || gv.value == null) return null;
145 + const wv = trendValue(w, gv.year, 'sum');
146 + if (!wv || !wv.value) return null;
147 + if (v.format === 'year') return String(gv.year);
148 + return `${((gv.value / wv.value) * 100).toFixed(1)} %`;
149 + }
150 + return null;
151 +}
152 +
153 +/** Fill a paragraph template; null when any variable is missing (the sentence is then omitted). */
154 +export function resolveParagraph(p: TextParagraph, d: StoryData): string | null {
155 + let out = p.template;
156 + for (const [k, spec] of Object.entries(p.vars)) {
157 + const val = resolveVar(spec, d);
158 + if (val == null) return null;
159 + out = out.split(`{${k}}`).join(val);
160 + }
161 + return out;
162 +}
163 +
164 +export function specOf(ind: IndicatorCard): FormatSpec {
165 + return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };
166 +}
added apps/web/src/components/stories/story-blocks.tsx +198 −0
@@ -0,0 +1,198 @@
1 +import Link from 'next/link';
2 +import { t, tOpt } from '@/i18n';
3 +import { formatValue } from '@/lib/format';
4 +import { routes } from '@/lib/site';
5 +import type { Story, StoryBlock } from '@/lib/stories';
6 +import type { FormatSpec } from '@/lib/types';
7 +import { LineChart, StackedArea, type LineSeries } from '@/components/charts/line-chart';
8 +import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars';
9 +import { pointsFromSeries } from '@/components/charts/scales';
10 +import type { SeriesPoint } from '@/components/charts/scales';
11 +import { EmptyState } from '@/components/data/empty-state';
12 +import { baseFeatures } from '@/components/indicators/map-geometry';
13 +import { getMap, getRanking, getSeries, getTrend, resolveParagraph, specOf, type StoryData } from './resolve';
14 +import { StoryMapFrames, type StoryFrame } from './story-map-frames';
15 +
16 +/** Short group labels for chart legends (API names are long: "Middle East, North Africa, Afghanistan & Pakistan"). */
17 +const GROUP_LABEL: Record<string, string> = {
18 + world: 'World',
19 + 'north-america': 'North America',
20 + 'latin-america-caribbean': 'Latin America & Caribbean',
21 + 'europe-central-asia': 'Europe & Central Asia',
22 + 'middle-east-north-africa': 'Middle East & North Africa',
23 + 'south-asia': 'South Asia',
24 + 'east-asia-pacific': 'East Asia & Pacific',
25 + 'sub-saharan-africa': 'Sub-Saharan Africa',
26 + 'high-income': 'High income',
27 + 'upper-middle-income': 'Upper middle income',
28 + 'lower-middle-income': 'Lower middle income',
29 + 'low-income': 'Low income',
30 + 'european-union': 'European Union',
31 +};
32 +
33 +function quantileBreaks(values: number[], k = 6): number[] {
34 + const vals = [...values].sort((a, b) => a - b);
35 + const n = vals.length;
36 + if (n < 2) return [];
37 + const out: number[] = [];
38 + for (let i = 1; i < k; i++) {
39 + const pos = (i / k) * (n - 1);
40 + const lo = Math.floor(pos);
41 + const hi = Math.min(lo + 1, n - 1);
42 + const v = vals[lo]! + (vals[hi]! - vals[lo]!) * (pos - lo);
43 + if (!out.length || v > out[out.length - 1]!) out.push(v);
44 + }
45 + return out;
46 +}
47 +
48 +/** Server component: renders every block of a story from the pre-loaded data. */
49 +export function StoryBlocks({ story, data }: { story: Story; data: StoryData }) {
50 + // Figures are numbered 01, 02… over chart blocks only (text blocks carry no number).
51 + let n = 0;
52 + return (
53 + <div className="space-y-10 md:space-y-14">
54 + {story.blocks.map((b, i) => (
55 + <Block key={i} block={b} data={data} index={b.kind === 'text' ? 0 : ++n} />
56 + ))}
57 + </div>
58 + );
59 +}
60 +
61 +function Block({ block, data, index }: { block: StoryBlock; data: StoryData; index: number }) {
62 + if (block.kind === 'text') {
63 + const paras = block.paragraphs.map((p) => resolveParagraph(p, data)).filter((s): s is string => !!s);
64 + if (!paras.length) return null;
65 + return (
66 + <div className="max-w-prose space-y-4 text-lg leading-relaxed text-ink md:text-xl">
67 + {paras.map((p, i) => (
68 + <p key={i} className="tnum">
69 + {p}
70 + </p>
71 + ))}
72 + </div>
73 + );
74 + }
75 +
76 + if (block.kind === 'map') {
77 + const maps = block.years.map((y) => ({ year: y, map: getMap(data, block.indicator, y) })).filter((m) => m.map && m.map.n > 0);
78 + const first = maps[0]?.map;
79 + if (!first || !data.countries.length) return <Unavailable title={block.title} />;
80 + const { features, sphere } = baseFeatures(data.countries);
81 + const pooled: number[] = [];
82 + const frames: StoryFrame[] = maps.map((m) => {
83 + const vals = m.map!.values;
84 + for (const v of Object.values(vals)) if (typeof v === 'number') pooled.push(v);
85 + return { year: m.map!.year_used ?? m.year, values: vals, n: m.map!.n };
86 + });
87 + const breaks = quantileBreaks(pooled, 6);
88 + const spec = specOf(first.indicator);
89 + return (
90 + <Figure index={index} eyebrow={t('stories.block.map')} title={block.title ?? first.indicator.name ?? block.indicator} indicator={block.indicator}>
91 + <StoryMapFrames geometry={features} sphere={sphere} frames={frames} breaks={breaks} min={pooled.length ? Math.min(...pooled) : null} max={pooled.length ? Math.max(...pooled) : null} spec={spec} provenance={first.provenance} />
92 + </Figure>
93 + );
94 + }
95 +
96 + if (block.kind === 'trend') {
97 + const groups = block.groups ?? ['world'];
98 + const trends = groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length);
99 + if (!trends.length) return <Unavailable title={block.title} />;
100 + const ref = trends[0]!.tr!;
101 + const pref = (ref.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') ?? 'median';
102 + const series: LineSeries[] = trends.map((x, i) => ({
103 + id: x.g,
104 + name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g,
105 + colorIndex: i,
106 + points: x.tr!.points.filter((p) => (block.from == null || p.year >= block.from) && (p as unknown as Record<string, number | null>)[pref] != null).map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p as unknown as Record<string, number | null>)[pref] ?? null })),
107 + }));
108 + const ns = ref.points.map((p) => p.n);
109 + const spec: FormatSpec = specOf(ref.indicator);
110 + return (
111 + <Figure index={index} eyebrow={t('stories.block.trend')} title={block.title ?? ref.indicator.name ?? block.indicator} indicator={block.indicator} note={t('stories.block.trendNote', { kind: tOpt(`stories.kind.${pref}`, pref), n: `${Math.min(...ns)}–${Math.max(...ns)}` })}>
112 + <LineChart series={series} spec={spec} height={300} log={block.log} provenance={ref.provenance[0] ?? null} defaultWidth={860} endLabels={false} />
113 + </Figure>
114 + );
115 + }
116 +
117 + if (block.kind === 'lines') {
118 + const list = getSeries(data, block);
119 + if (!list || !list.length) return <Unavailable title={block.title} />;
120 + const series: LineSeries[] = [];
121 + block.countries.forEach((id, i) => {
122 + const s = list.find((x) => x.country.id === id);
123 + if (!s) return;
124 + const points = pointsFromSeries(s.values);
125 + if (points.length > 1) series.push({ id, name: s.country.name ?? id, colorIndex: i, points });
126 + });
127 + if (!series.length) return <Unavailable title={block.title} />;
128 + const ind = list[0]!.indicator;
129 + const slugs = block.countries.map((id) => list.find((x) => x.country.id === id)?.country.slug ?? id.toLowerCase());
130 + return (
131 + <Figure index={index} eyebrow={t('stories.block.lines')} title={block.title ?? ind.name ?? block.indicator} indicator={block.indicator} extra={<Link href={`${routes.compare(...slugs)}?indicator=${block.indicator}`} className="text-accent hover:underline">{t('stories.compare')} →</Link>}>
132 + <LineChart series={series} spec={specOf(ind)} height={300} log={block.log} provenance={list[0]!.provenance} defaultWidth={860} />
133 + </Figure>
134 + );
135 + }
136 +
137 + if (block.kind === 'ranked') {
138 + const r = getRanking(data, block);
139 + if (!r || !r.rows.length) return <Unavailable title={block.title} />;
140 + const spec = specOf(r.indicator);
141 + const latestYear = Math.max(r.year_used ?? 0, ...r.rows.map((x) => x.year ?? 0));
142 + return (
143 + <Figure index={index} eyebrow={t('stories.block.ranked', { year: r.year_used ?? '' })} title={block.title ?? r.indicator.name ?? block.indicator} indicator={block.indicator} extra={<Link href={routes.ranking(r.indicator.slug, { year: r.year_used })} className="text-accent hover:underline">{t('stories.openRanking')} →</Link>}>
144 + <RankedBars rows={r.rows.map((row) => rankedRowFromCountry(row.country, row.value, row.rank, null, row.year != null && latestYear - row.year >= 2 ? row.year : null))} spec={spec} provenance={r.rows[0]?.provenance ?? null} />
145 + </Figure>
146 + );
147 + }
148 +
149 + if (block.kind === 'shares') {
150 + const world = getTrend(data, block.indicator, 'world');
151 + const groups = block.groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length);
152 + if (!world || !groups.length) return <Unavailable title={block.title} />;
153 + const worldSum = new Map(world.points.map((p) => [p.year, p.sum]));
154 + const series: LineSeries[] = groups.map((x, i) => ({
155 + id: x.g,
156 + name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g,
157 + colorIndex: i,
158 + points: x.tr!.points
159 + .filter((p) => (block.from == null || p.year >= block.from) && p.sum != null && worldSum.get(p.year))
160 + .map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p.sum! / worldSum.get(p.year)!) * 100 })),
161 + }));
162 + const spec: FormatSpec = { format: 'percent', precision: 1, unit: '% of world total', name: block.title ?? block.indicator };
163 + const last = series.map((s) => ({ name: s.name, v: s.points[s.points.length - 1]?.value ?? null })).filter((x) => x.v != null);
164 + return (
165 + <Figure index={index} eyebrow={t('stories.block.trend')} title={block.title ?? block.indicator} indicator={block.indicator} note={last.length ? last.map((x) => `${x.name} ${formatValue(x.v, spec)}`).join(' · ') : undefined}>
166 + <StackedArea series={series} spec={spec} height={340} provenance={world.provenance[0] ?? null} defaultWidth={860} />
167 + </Figure>
168 + );
169 + }
170 + return null;
171 +}
172 +
173 +function Figure({ index, eyebrow, title, indicator, note, extra, children }: { index: number; eyebrow: string; title: string; indicator: string; note?: string; extra?: React.ReactNode; children: React.ReactNode }) {
174 + return (
175 + <figure className="min-w-0 border-t border-rule pt-4">
176 + <figcaption className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-1">
177 + <div className="min-w-0">
178 + <div className="eyebrow">
179 + <span className="tnum">{String(index).padStart(2, '0')}</span> · {eyebrow}
180 + </div>
181 + <h2 className="display mt-0.5 text-xl text-ink md:text-2xl">{title}</h2>
182 + {note ? <p className="tnum mt-0.5 text-xs text-ink-3">{note}</p> : null}
183 + </div>
184 + <div className="flex flex-wrap items-center gap-x-4 text-sm">
185 + <Link href={routes.indicator(indicator)} className="text-accent hover:underline">
186 + {t('stories.explore')} →
187 + </Link>
188 + {extra}
189 + </div>
190 + </figcaption>
191 + {children}
192 + </figure>
193 + );
194 +}
195 +
196 +function Unavailable({ title }: { title?: string }) {
197 + return <EmptyState compact title={title ?? t('common.noDataLong')} hint={t('stories.unavailable')} />;
198 +}
added apps/web/src/components/stories/story-map-frames.tsx +60 −0
@@ -0,0 +1,60 @@
1 +'use client';
2 +import { useMemo, useState } from 'react';
3 +import { t } from '@/i18n';
4 +import { formatValue } from '@/lib/format';
5 +import type { FormatSpec } from '@/lib/types';
6 +import { ChoroplethView, type ChoroplethFeature } from '@/components/charts/choropleth-view';
7 +import { SourceLine } from '@/components/charts/source-line';
8 +import { YearSlider } from '@/components/controls/year-slider';
9 +import type { BaseFeature } from '@/components/indicators/indicator-map';
10 +import type { Provenance } from '@/lib/types';
11 +
12 +export interface StoryFrame {
13 + year: number;
14 + values: Record<string, number | null>;
15 + n: number;
16 +}
17 +
18 +function classIndex(value: number, breaks: number[]): number {
19 + let i = 0;
20 + while (i < breaks.length && value >= breaks[i]!) i++;
21 + return i;
22 +}
23 +
24 +/**
25 + * Story map: a handful of year frames (server-fetched) behind one year slider with play, a legend shared by every
26 + * frame (pooled quantile breaks computed server-side) so colours mean the same thing in 1960 and today.
27 + */
28 +export function StoryMapFrames({ geometry, sphere, frames, breaks, min, max, spec, provenance }: { geometry: BaseFeature[]; sphere: string; frames: StoryFrame[]; breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; provenance: Provenance | null }) {
29 + const years = frames.map((f) => f.year);
30 + const [year, setYear] = useState(years[years.length - 1] ?? 0);
31 + const frame = frames.find((f) => f.year === year) ?? frames[frames.length - 1];
32 + const k = breaks.length + 1;
33 + const features: ChoroplethFeature[] = useMemo(
34 + () =>
35 + geometry.map((g) => {
36 + const v = g.iso3 && frame ? frame.values[g.iso3] : undefined;
37 + return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classIndex(v, breaks) : null };
38 + }),
39 + [geometry, frame, breaks],
40 + );
41 + const legend = Array.from({ length: k }, (_, i) => {
42 + const lo = i === 0 ? min : breaks[i - 1]!;
43 + const hi = i === k - 1 ? max : breaks[i]!;
44 + return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };
45 + });
46 + const summary = frame ? t('chart.summary.map', { name: spec.name ?? '', year: frame.year, n: frame.n, min: formatValue(min, spec), max: formatValue(max, spec) }) : t('chart.noData');
47 + return (
48 + <div className="min-w-0">
49 + <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">
50 + <YearSlider years={years} year={year} onChange={setYear} interval={1400} className="min-w-0 flex-1 basis-72" compact ticks={false} label={t('stories.frame.year')} />
51 + <span className="tnum text-xs text-ink-3">{frame ? t('indicator.map.n', { n: frame.n }) : ''}</span>
52 + </div>
53 + <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={t('chart.map.legend', { name: spec.name ?? '', year })} />
54 + <p className="mt-1 text-2xs text-ink-3">{t('stories.block.mapHint')}</p>
55 + <div className="mt-1">
56 + <SourceLine provenance={provenance} />
57 + </div>
58 + </div>
59 + );
60 +}
modified apps/web/src/i18n/en.platform.ts +225 −1
@@ -1,8 +1,232 @@
1 1 /**
2 2 * Strings for the platform features: Data Stories, download builder, API explorer, provenance panel 2.0,
3 − * data quality, /updates. Merged into `en.ts`. Owned by the "platform" stream.
3 + * data quality, /updates, share cards. Merged into `en.ts`. Owned by the "platform" stream.
4 4 */
5 5 export const enPlatform = {
6 + // --- share cards
7 + 'og.sub': 'The interactive data atlas of the world — every number traceable to its source.',
8 + 'og.countries': 'Countries',
9 + 'og.indicators': 'Indicators',
10 + 'og.observations': 'Observations',
11 + 'og.sources': 'Sources',
12 +
13 + // --- stories
6 14 'stories.title': 'Data stories',
7 15 'stories.description': 'Interactive stories generated from the CountryAtlas datasets — every chart deterministic, every number sourced.',
16 + 'stories.sub': 'Long-run narratives built entirely from the atlas: the charts are live, the numbers are computed from the same data, nothing is written by a model.',
17 + 'stories.eyebrow': 'Story',
18 + 'stories.read': 'Read the story',
19 + 'stories.minutes': '{n} min read',
20 + 'stories.charts': '{n} charts',
21 + 'stories.indicators': 'Indicators used',
22 + 'stories.indicatorsN': '{n} indicators used',
23 + 'stories.indicatorsOne': '1 indicator used',
24 + 'stories.sources': 'Sources',
25 + 'stories.sourcesNote': 'Every figure on this page is computed from the CountryAtlas snapshot {run} (built {date}). Values carry the licence of their source; the compilation is CC BY 4.0.',
26 + 'stories.method': 'How this story is built',
27 + 'stories.methodText': 'A story is a declarative template: a list of blocks (world map by year, aggregate trend, country lines, a ranking) bound to indicators. Sentences are templates filled with numbers computed server-side from exactly the payloads charted below; when a value is missing the sentence is omitted rather than guessed.',
28 + 'stories.more': 'More stories',
29 + 'stories.notFound': 'Unknown story',
30 + 'stories.frame.year': 'Year',
31 + 'stories.block.map': 'World map',
32 + 'stories.block.trend': 'Trend',
33 + 'stories.block.lines': 'Selected countries',
34 + 'stories.block.ranked': 'Ranking, {year}',
35 + 'stories.block.mapHint': 'Drag the year slider or press play. Hatched = no data.',
36 + 'stories.block.trendNote': '{kind} across {n} countries per year, computed by CountryAtlas from country values.',
37 + 'stories.explore': 'Explore this indicator',
38 + 'stories.openRanking': 'Full ranking',
39 + 'stories.compare': 'Compare these countries',
40 + 'stories.unavailable': 'This block could not be computed from the current snapshot.',
41 + 'stories.kind.weighted_mean': 'Population-weighted mean',
42 + 'stories.kind.median': 'Median',
43 + 'stories.kind.sum': 'Sum',
44 + 'stories.kind.mean': 'Mean',
45 +
46 + // --- download builder
47 + 'download.title': 'Download data',
48 + 'download.description': 'Build a dataset: pick countries, indicators, years and a format. Every row carries its source, series code, retrieval date and licence.',
49 + 'download.sub': 'Build your own extract, or grab a whole country or indicator in one click. Every row carries provenance columns.',
50 + 'download.builder.title': 'Dataset builder',
51 + 'download.builder.sub': 'Up to 20 countries × 20 indicators, all years or a range, CSV or JSON.',
52 + 'download.countries': 'Countries',
53 + 'download.indicators': 'Indicators',
54 + 'download.addCountry': 'Add a country…',
55 + 'download.addIndicator': 'Add an indicator',
56 + 'download.years': 'Years',
57 + 'download.from': 'From',
58 + 'download.to': 'To',
59 + 'download.allYears': 'All years',
60 + 'download.format': 'Format',
61 + 'download.forecast': 'Include projections',
62 + 'download.estimate': 'About {n} rows',
63 + 'download.estimating': 'Estimating…',
64 + 'download.estimateNote': 'Estimate from the annual series; monthly and quarterly series add rows.',
65 + 'download.selection': '{c} countries · {i} indicators',
66 + 'download.needBoth': 'Pick at least one country and one indicator.',
67 + 'download.max': 'Maximum of {n}.',
68 + 'download.get': 'Download {fmt}',
69 + 'download.url': 'Direct URL',
70 + 'download.copyUrl': 'Copy URL',
71 + 'download.remove': 'Remove {name}',
72 + 'download.clear': 'Clear',
73 + 'download.presetHint': 'Quick starts',
74 + 'download.preset.g7': 'G7 headline indicators',
75 + 'download.preset.brics': 'BRICS economy',
76 + 'download.preset.climate': 'Top emitters, climate',
77 + 'download.quick.title': 'One-click datasets',
78 + 'download.quick.sub': 'A whole country (all indicators, all years) or a whole indicator (all countries, all years).',
79 + 'download.quick.country': 'Per-country dataset',
80 + 'download.quick.countrySub': 'All indicators, all years, for one country.',
81 + 'download.quick.indicator': 'Per-indicator dataset',
82 + 'download.quick.indicatorSub': 'All countries, all years, for one indicator.',
83 + 'download.bulk.title': 'Bulk data',
84 + 'download.bulk.sub': 'How to get everything.',
85 + 'download.bulk.text':
86 + 'There is no single archive yet. The fastest way to the whole snapshot is the API: list indicators with /api/v1/indicators, then fetch /api/v1/indicators/{slug}/download.csv for each. Please keep to the rate limit (120 requests per minute) and cache locally; the snapshot changes once a day at most. For a full copy of the database, write to the address below.',
87 + 'download.columns.title': 'Columns',
88 + 'download.columns.sub': 'The same columns in every export. CSV files start with one comment line naming the snapshot.',
89 + 'download.licence.title': 'Licence & attribution',
90 + 'download.licence.sub': 'Please credit the original sources, not only CountryAtlas.',
91 + 'download.licence.compilation': 'The CountryAtlas compilation (harmonised series, derived metrics, rankings, change detection) is released under CC BY 4.0.',
92 + 'download.licence.sources': 'Each observation carries its source licence in the export (column licence). Check it before redistributing: most sources are CC BY 4.0, but the WHO Global Health Observatory is CC BY-NC-SA 3.0 IGO (no commercial use, share alike) and some portals apply their own terms.',
93 + 'download.licence.example': 'Suggested attribution',
94 + 'download.licence.exampleText': 'Data: {sources} via CountryAtlas (countryatlas.co), CC BY 4.0 compilation. Retrieved {date}.',
95 + 'download.licence.source': 'Source',
96 + 'download.licence.licence': 'Licence',
97 + 'download.licence.attribution': 'Attribution',
98 + 'download.snapshot': 'Current snapshot {run}, built {date}.',
99 + 'download.api.title': 'API',
100 + 'download.api.sub': 'Programmatic access to everything on the site.',
101 + 'download.api.text': 'Every page is built from the public JSON API. Base URL {base}. Read the endpoint list and try requests live on the API page.',
102 + 'download.api.link': 'API documentation',
103 +
104 + // --- api page
105 + 'apiPage.version': 'API {v}',
106 + 'apiPage.explorer.title': 'Try it live',
107 + 'apiPage.explorer.sub': 'Pick an endpoint, fill the parameters, run it against the current snapshot. Copy the request as curl, JavaScript or Python.',
108 + 'apiPage.explorer.endpoint': 'Endpoint',
109 + 'apiPage.explorer.params': 'Parameters',
110 + 'apiPage.explorer.run': 'Run request',
111 + 'apiPage.explorer.running': 'Running…',
112 + 'apiPage.explorer.response': 'Response',
113 + 'apiPage.explorer.status': 'HTTP {status} · {ms} ms · {size}',
114 + 'apiPage.explorer.truncated': 'Response truncated for display ({n} characters shown).',
115 + 'apiPage.explorer.error': 'The request failed: {msg}',
116 + 'apiPage.explorer.copy.curl': 'curl',
117 + 'apiPage.explorer.copy.js': 'JavaScript',
118 + 'apiPage.explorer.copy.py': 'Python',
119 + 'apiPage.explorer.request': 'Request',
120 + 'apiPage.explorer.optional': 'optional',
121 + 'apiPage.explorer.required': 'required',
122 + 'apiPage.explorer.pathParam': 'Path parameter',
123 + 'apiPage.new.title': 'New in API 1.1',
124 + 'apiPage.new.sub': 'Additive, read-only analytics endpoints. Existing endpoints are unchanged.',
125 + 'apiPage.group.countries': 'Countries',
126 + 'apiPage.group.indicators': 'Indicators',
127 + 'apiPage.group.rankings': 'Rankings & comparisons',
128 + 'apiPage.group.analytics': 'Analytics (1.1)',
129 + 'apiPage.group.reference': 'Reference',
130 + 'apiPage.ep.pulse': 'World Pulse: what is changing globally, latest year vs previous.',
131 + 'apiPage.ep.movers': 'Biggest movers by window (1 / 5 / 10 years), category and kind.',
132 + 'apiPage.ep.extremes': 'Curated extremes facets: fastest ageing, urbanising, digital adoption…',
133 + 'apiPage.ep.scatter': 'Cross-section scatter with Pearson, Spearman and OLS (descriptive).',
134 + 'apiPage.ep.trajectory': 'Gapminder-style frames: x, y, size per country per year.',
135 + 'apiPage.ep.finder': 'Structured country finder over latest values (AND / OR filters).',
136 + 'apiPage.ep.peers': 'Above / below expected: robust cross-sectional fit and residuals.',
137 + 'apiPage.ep.related': 'Statistically related indicators (correlation, n, period).',
138 + 'apiPage.ep.distribution': 'Histogram, world / region / income medians, percentile of a country.',
139 + 'apiPage.ep.frames': 'Multi-year map frames with a stable legend (time machine).',
140 + 'apiPage.ep.indicatorQuality': 'Coverage, freshness and continuity summary for an indicator.',
141 + 'apiPage.ep.race': 'Rank race frames: top N per year.',
142 + 'apiPage.ep.regionsCompare': 'Group vs group aggregates and history.',
143 + 'apiPage.ep.story': '"How X changed": long-run indicators with templated text.',
144 + 'apiPage.ep.countryQuality': 'Per-indicator data quality for one country.',
145 + 'apiPage.ep.updates': 'Freshness dashboard: sources, runs, changed values.',
146 + 'apiPage.ep.download': 'Compare bundle download (CSV or JSON).',
147 +
148 + // --- provenance panel 2.0
149 + 'prov.country': 'Country',
150 + 'prov.period': 'Period',
151 + 'prov.quality': 'Data quality',
152 + 'prov.copyApi': 'API',
153 + 'prov.copiedApi': 'API URL copied',
154 + 'prov.apiHint': 'Copies the exact API request for this series and opens the API documentation.',
155 + 'prov.downloadSeries': 'Download series',
156 + 'prov.years': 'Years',
157 + 'prov.pointsN': '{n} points',
158 + 'prov.status.forecastNote': 'Projection published by the source; never used in rankings or change detection.',
159 + 'prov.definition': 'Definition',
160 + 'prov.noValue': 'No observation for this selection.',
161 +
162 + // --- quality badges
163 + 'quality.fresh': 'Fresh',
164 + 'quality.historical': 'Historical',
165 + 'quality.sparse': 'Sparse',
166 + 'quality.limited-coverage': 'Limited coverage',
167 + 'quality.stale': 'Stale',
168 + 'quality.flagged': 'Flagged',
169 + 'quality.forecast': 'Projections',
170 + 'quality.hint.fresh': 'Latest observation within a year of the reference year.',
171 + 'quality.hint.historical': 'Series starts in 1970 or earlier.',
172 + 'quality.hint.sparse': 'Fewer than 10 points per country in the median.',
173 + 'quality.hint.limited-coverage': 'Fewer than half of the countries report this indicator.',
174 + 'quality.hint.stale': 'Latest observation three or more years behind the reference year.',
175 + 'quality.hint.flagged': 'More than 5 % of values carry a validation warning.',
176 + 'quality.hint.forecast': 'The chosen source publishes projections for this series.',
177 + 'quality.title': 'Data quality',
178 + 'quality.latestYear': 'Latest year',
179 + 'quality.firstYear': 'First year',
180 + 'quality.points': 'Points',
181 + 'quality.missing': 'Missing years',
182 + 'quality.continuity': 'Continuity',
183 + 'quality.coverage': 'Coverage',
184 + 'quality.source': 'Primary source',
185 + 'quality.noScore': 'Badges describe coverage and freshness; CountryAtlas does not compute an overall quality score.',
186 +
187 + // --- updates dashboard
188 + 'updates.title': 'Data updates',
189 + 'updates.description': 'When each source was last imported, what changed in the current snapshot, and which indicators moved.',
190 + 'updates.sub': 'Freshness of every source, the last import runs and the values that changed in the current snapshot.',
191 + 'updates.snapshot.title': 'Current snapshot',
192 + 'updates.snapshot.built': 'Built',
193 + 'updates.snapshot.run': 'Run',
194 + 'updates.snapshot.observations': 'Observations',
195 + 'updates.snapshot.indicators': 'Indicators',
196 + 'updates.snapshot.countries': 'Countries',
197 + 'updates.snapshot.changed': 'Values changed',
198 + 'updates.snapshot.changedHint': 'Observations whose value or source differs from the previous snapshot.',
199 + 'updates.sources.title': 'Sources',
200 + 'updates.sources.sub': 'One row per data source. Status reflects the last import attempt and the age of the source vintage.',
201 + 'updates.col.source': 'Source',
202 + 'updates.col.status': 'Status',
203 + 'updates.col.lastImport': 'Last import',
204 + 'updates.col.vintage': 'Source vintage',
205 + 'updates.col.datasets': 'Datasets',
206 + 'updates.col.indicators': 'Indicators',
207 + 'updates.col.observations': 'Observations',
208 + 'updates.col.latestYear': 'Latest year',
209 + 'updates.col.changed': 'Changed',
210 + 'updates.col.countries': 'Countries affected',
211 + 'updates.status.ok': 'OK',
212 + 'updates.status.partial': 'Partial',
213 + 'updates.status.failed': 'Failed',
214 + 'updates.status.stale': 'Stale',
215 + 'updates.status.unknown': 'Unknown',
216 + 'updates.runs.title': 'Recent import runs',
217 + 'updates.runs.sub': 'Latest attempts per series, most recent first.',
218 + 'updates.col.dataset': 'Dataset → indicator',
219 + 'updates.col.started': 'Started',
220 + 'updates.col.rows': 'Rows',
221 + 'updates.col.warnings': 'Warnings',
222 + 'updates.col.errors': 'Errors',
223 + 'updates.col.message': 'Message',
224 + 'updates.indicators.title': 'Recently updated indicators',
225 + 'updates.indicators.sub': 'Ordered by the vintage the source advertises.',
226 + 'updates.schedule': 'The pipeline refreshes every source daily; a new snapshot is published only when validation passes.',
227 + 'updates.none': 'No import run recorded yet.',
228 + 'updates.unavailable': 'The updates feed is not available in this snapshot.',
229 +
230 + // --- data page redirect
231 + 'data.redirect': 'Redirecting to the download builder…',
8 232 } as const;
added apps/web/src/lib/api-platform.ts +10 −0
@@ -0,0 +1,10 @@
1 +import 'server-only';
2 +import { request } from './api';
3 +import type { CountryQualityResponse, IndicatorQualityResponse, UpdatesResponse } from './types-analytics';
4 +
5 +/** Server-side endpoints for the platform pages (/updates, quality). Same `request<T>()` conventions as lib/api.ts. */
6 +export const apiPlatform = {
7 + updates: () => request<UpdatesResponse>('/updates', undefined, { revalidate: 600 }),
8 + countryQuality: (id: string) => request<CountryQualityResponse>(`/countries/${encodeURIComponent(id)}/quality`),
9 + indicatorQuality: (slug: string) => request<IndicatorQualityResponse>(`/indicators/${encodeURIComponent(slug)}/quality`),
10 +};
added apps/web/src/lib/client-api-platform.ts +37 −0
@@ -0,0 +1,37 @@
1 +'use client';
2 +import { ClientApiError } from './client-api';
3 +import type { MultiSeriesResponse } from './types';
4 +
5 +/**
6 + * Browser-side helpers for the platform widgets (download builder estimate, API explorer). Same-origin `/api/v1`.
7 + */
8 +export async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
9 + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal });
10 + if (!res.ok) throw new ClientApiError(res.status, `API ${res.status}`);
11 + return (await res.json()) as T;
12 +}
13 +
14 +/** Raw request for the API explorer: returns status, elapsed ms, byte size and the parsed/pretty body. */
15 +export async function rawRequest(path: string, signal?: AbortSignal): Promise<{ status: number; ms: number; bytes: number; text: string; json: unknown | null; contentType: string | null }> {
16 + const t0 = performance.now();
17 + const res = await fetch(path, { headers: { accept: 'application/json' }, signal });
18 + const text = await res.text();
19 + const ms = Math.round(performance.now() - t0);
20 + let json: unknown | null = null;
21 + try {
22 + json = JSON.parse(text);
23 + } catch {
24 + json = null;
25 + }
26 + return { status: res.status, ms, bytes: new TextEncoder().encode(text).length, text, json, contentType: res.headers.get('content-type') };
27 +}
28 +
29 +export const clientPlatform = {
30 + /** Series bundle for row estimates (≤ 20 countries × 12 indicators). */
31 + seriesBundle: (countries: string[], indicators: string[], q: { from?: number | null; to?: number | null } = {}, signal?: AbortSignal) => {
32 + const p = new URLSearchParams({ country: countries.join(','), indicator: indicators.join(',') });
33 + if (q.from != null) p.set('from', String(q.from));
34 + if (q.to != null) p.set('to', String(q.to));
35 + return getJson<MultiSeriesResponse>(`/series?${p.toString()}`, signal);
36 + },
37 +};
modified apps/web/src/lib/og.tsx +53 −11
@@ -1,10 +1,12 @@
1 1 import type { ReactNode } from 'react';
2 +import { MARK_PATHS } from '@/components/brand/Logo';
3 +import { MAP_HEIGHT, MAP_WIDTH, worldPaths } from '@/lib/map-geo';
2 4
3 5 /**
4 6 * Shared building blocks for the OG/Twitter images (next/og ImageResponse → Satori). Satori supports a flex
5 − * subset of CSS and inline SVG; no CSS variables, so colours are literal. Dark editorial background, the logo
6 − * mark, a meridian motif. Fonts: Satori's bundled default sans (loading Google fonts at build would make the
7 − * build network-dependent — avoided on purpose).
7 + * subset of CSS and inline SVG; no CSS variables, so colours are literal. Dark editorial background, the brand
8 + * mark, a faint world map (Equal Earth, world-atlas 110m) as the "wallpaper" motif. Fonts: Satori's bundled
9 + * default sans (loading Google fonts at build would make the build network-dependent — avoided on purpose).
8 10 */
9 11 export const OG_BG = '#151513';
10 12 export const OG_INK = '#f2f0ea';
@@ -12,20 +14,23 @@ export const OG_INK2 = '#c9c6bd';
12 14 export const OG_INK3 = '#8a877f';
13 15 export const OG_ACCENT = '#5598e7';
14 16 export const OG_RULE = '#2c2b28';
17 +export const OG_LAND = '#26364a';
18 +export const OG_LAND_STROKE = '#151513';
15 19
16 20 export function OgMark({ size = 96, color = OG_ACCENT }: { size?: number; color?: string }) {
21 + const m = MARK_PATHS;
17 22 return (
18 23 <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke={color} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
19 − <circle cx="16" cy="16" r="13" />
20 − <path d="M4.1 19.5h23.8" />
21 − <path d="M9.6 27.2 16 6.8l6.4 20.4" />
22 − <path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55" />
23 − <path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55" />
24 + <circle cx={m.ring.cx} cy={m.ring.cy} r={m.ring.r} />
25 + <ellipse cx={m.meridian.cx} cy={m.meridian.cy} rx={m.meridian.rx} ry={m.meridian.ry} strokeWidth={1.2} opacity="0.5" />
26 + <path d={m.tropic} strokeWidth={1.2} opacity="0.5" />
27 + <path d={m.equator} />
28 + <path d={m.legs} />
24 29 </svg>
25 30 );
26 31 }
27 32
28 −/** Large, faint globe with meridians and parallels, positioned at the right edge. */
33 +/** Large, faint globe with meridians and parallels, positioned at the right edge (kept for the entity cards). */
29 34 export function OgMeridians({ size = 760, x = 640, y = -80 }: { size?: number; x?: number; y?: number }) {
30 35 return (
31 36 <svg width={size} height={size} viewBox="0 0 200 200" fill="none" stroke={OG_ACCENT} strokeWidth={0.6} style={{ position: 'absolute', left: x, top: y, opacity: 0.35 }}>
@@ -41,10 +46,33 @@ export function OgMeridians({ size = 760, x = 640, y = -80 }: { size?: number; x
41 46 );
42 47 }
43 48
44 −export function OgFrame({ children }: { children: ReactNode }) {
49 +/**
50 + * Faint world map wallpaper: every country of the 110m atlas as a filled path (Equal Earth), plus the sphere
51 + * outline and a light graticule. `width` in px; positioned absolutely by the caller through `style`.
52 + * `highlight` (ISO3 list) fills those countries in the accent colour.
53 + */
54 +export function OgWorldMap({ width = 1320, style, opacity = 0.9, highlight = [] }: { width?: number; style?: React.CSSProperties; opacity?: number; highlight?: string[] }) {
55 + const { paths, sphere } = worldPaths();
56 + const height = Math.round((width * MAP_HEIGHT) / MAP_WIDTH);
57 + const hl = new Set(highlight);
58 + return (
59 + <svg width={width} height={height} viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} style={{ position: 'absolute', opacity, ...style }}>
60 + <path d={sphere} fill="#191a1c" stroke={OG_RULE} strokeWidth={1} />
61 + {[-60, -30, 0, 30, 60].map((lat) => {
62 + const y = MAP_HEIGHT / 2 - (lat / 90) * (MAP_HEIGHT / 2) * 0.98;
63 + return <line key={lat} x1={0} x2={MAP_WIDTH} y1={y} y2={y} stroke={OG_RULE} strokeWidth={0.6} />;
64 + })}
65 + {paths.map((p, i) => (
66 + <path key={p.iso3 ?? i} d={p.d} fill={p.iso3 && hl.has(p.iso3) ? OG_ACCENT : OG_LAND} stroke={OG_LAND_STROKE} strokeWidth={0.5} />
67 + ))}
68 + </svg>
69 + );
70 +}
71 +
72 +export function OgFrame({ children, map = false, highlight }: { children: ReactNode; map?: boolean; highlight?: string[] }) {
45 73 return (
46 74 <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', background: OG_BG, color: OG_INK, position: 'relative', overflow: 'hidden', padding: 64, fontFamily: 'sans-serif' }}>
47 − <OgMeridians />
75 + {map ? <OgWorldMap width={1320} style={{ left: -60, top: 40 }} opacity={0.55} highlight={highlight} /> : <OgMeridians />}
48 76 <div style={{ position: 'absolute', left: 64, right: 64, bottom: 56, height: 1, background: OG_RULE }} />
49 77 {children}
50 78 </div>
@@ -62,3 +90,17 @@ export function OgWordmark({ size = 34 }: { size?: number }) {
62 90 </div>
63 91 );
64 92 }
93 +
94 +/** Row of three or four headline figures: label above, big number below (used by the default share card). */
95 +export function OgFigures({ items }: { items: Array<{ label: string; value: string }> }) {
96 + return (
97 + <div style={{ display: 'flex', gap: 56 }}>
98 + {items.map((it) => (
99 + <div key={it.label} style={{ display: 'flex', flexDirection: 'column' }}>
100 + <div style={{ fontSize: 18, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 1.6, display: 'flex' }}>{it.label}</div>
101 + <div style={{ fontSize: 44, fontWeight: 600, marginTop: 6, display: 'flex' }}>{it.value}</div>
102 + </div>
103 + ))}
104 + </div>
105 + );
106 +}
added apps/web/src/lib/seo.ts +99 −0
@@ -0,0 +1,99 @@
1 +import { SITE_NAME, SITE_URL, routes } from './site';
2 +
3 +/**
4 + * SEO helpers shared by every page family: title patterns from the product spec and JSON-LD builders.
5 + * Titles are returned WITHOUT the site suffix when used through the layout template (`%s — CountryAtlas`); use
6 + * `withSite()` for places that need the full "… | CountryAtlas" string (OG, JSON-LD names).
7 + */
8 +export const seoTitle = {
9 + country: (name: string) => `${name} Data, Economy, Population & Statistics`,
10 + countryTopic: (name: string, topic: string) => `${name} ${topic} — Data & Statistics`,
11 + indicator: (name: string) => `${name} by Country — Data & Rankings`,
12 + ranking: (name: string, year: number | string | null | undefined) => (year ? `${name} by Country — ${year} Ranking` : `${name} by Country — Ranking`),
13 + region: (name: string) => `${name} Data, Countries & Statistics`,
14 + compare: (names: string[]) => `${names.join(' vs ')} — Country Comparison`,
15 + story: (title: string) => `${title} — Data Story`,
16 +} as const;
17 +
18 +export function withSite(title: string): string {
19 + return `${title} | ${SITE_NAME}`;
20 +}
21 +
22 +export function absoluteUrl(path: string): string {
23 + return path.startsWith('http') ? path : `${SITE_URL}${path.startsWith('/') ? path : `/${path}`}`;
24 +}
25 +
26 +type JsonLd = Record<string, unknown>;
27 +
28 +export const jsonLd = {
29 + website: (): JsonLd => ({
30 + '@context': 'https://schema.org',
31 + '@type': 'WebSite',
32 + name: SITE_NAME,
33 + url: SITE_URL,
34 + potentialAction: { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: `${SITE_URL}${routes.search('{search_term_string}')}`.replace('%7Bsearch_term_string%7D', '{search_term_string}') }, 'query-input': 'required name=search_term_string' },
35 + }),
36 + organization: (): JsonLd => ({
37 + '@context': 'https://schema.org',
38 + '@type': 'Organization',
39 + name: SITE_NAME,
40 + url: SITE_URL,
41 + logo: `${SITE_URL}/icon.png`,
42 + email: 'contact@spboucher.ai',
43 + founder: { '@type': 'Person', name: 'Simon-Pierre Boucher' },
44 + }),
45 + breadcrumbs: (items: Array<{ name: string; path: string }>): JsonLd => ({
46 + '@context': 'https://schema.org',
47 + '@type': 'BreadcrumbList',
48 + itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: absoluteUrl(it.path) })),
49 + }),
50 + /** schema.org Dataset for an indicator page. */
51 + dataset: (d: { slug: string; name: string; description?: string | null; unit?: string | null; firstYear?: number | null; lastYear?: number | null; nCountries?: number | null; sources?: Array<{ name: string | null; url: string | null; licence?: string | null }>; modified?: string | null }): JsonLd => ({
52 + '@context': 'https://schema.org',
53 + '@type': 'Dataset',
54 + name: `${d.name} by country`,
55 + description: d.description ?? `${d.name} for every country, with sources.`,
56 + url: absoluteUrl(routes.indicator(d.slug)),
57 + identifier: d.slug,
58 + isAccessibleForFree: true,
59 + license: 'https://creativecommons.org/licenses/by/4.0/',
60 + creator: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL },
61 + ...(d.modified ? { dateModified: d.modified } : {}),
62 + ...(d.firstYear && d.lastYear ? { temporalCoverage: `${d.firstYear}/${d.lastYear}` } : {}),
63 + ...(d.nCountries ? { spatialCoverage: `${d.nCountries} countries and territories` } : {}),
64 + variableMeasured: d.unit ? { '@type': 'PropertyValue', name: d.name, unitText: d.unit } : d.name,
65 + distribution: [
66 + { '@type': 'DataDownload', encodingFormat: 'text/csv', contentUrl: absoluteUrl(routes.indicatorDownload(d.slug, 'csv')) },
67 + { '@type': 'DataDownload', encodingFormat: 'application/json', contentUrl: absoluteUrl(routes.indicatorDownload(d.slug, 'json')) },
68 + ],
69 + ...(d.sources?.length ? { isBasedOn: d.sources.filter((s) => s.url).map((s) => ({ '@type': 'Dataset', name: s.name ?? undefined, url: s.url })) } : {}),
70 + }),
71 + /** schema.org Article for a data story. */
72 + article: (a: { slug: string; title: string; description: string; published: string; modified?: string | null; image?: string | null }): JsonLd => ({
73 + '@context': 'https://schema.org',
74 + '@type': 'Article',
75 + headline: a.title,
76 + description: a.description,
77 + url: absoluteUrl(routes.story(a.slug)),
78 + datePublished: a.published,
79 + dateModified: a.modified ?? a.published,
80 + author: { '@type': 'Person', name: 'Simon-Pierre Boucher' },
81 + publisher: { '@type': 'Organization', name: SITE_NAME, url: SITE_URL, logo: { '@type': 'ImageObject', url: `${SITE_URL}/icon.png` } },
82 + ...(a.image ? { image: absoluteUrl(a.image) } : {}),
83 + isAccessibleForFree: true,
84 + }),
85 + /** schema.org Place/Country for a country page. */
86 + country: (c: { slug: string; name: string; iso3: string; capital?: string | null }): JsonLd => ({
87 + '@context': 'https://schema.org',
88 + '@type': 'Country',
89 + name: c.name,
90 + identifier: c.iso3,
91 + url: absoluteUrl(routes.country(c.slug)),
92 + ...(c.capital ? { containsPlace: { '@type': 'City', name: c.capital } } : {}),
93 + }),
94 +};
95 +
96 +/** Serialise for a `<script type="application/ld+json">` — escapes `<` so the payload cannot close the tag. */
97 +export function jsonLdString(obj: JsonLd | JsonLd[]): string {
98 + return JSON.stringify(obj).replace(/</g, '\\u003c');
99 +}
added apps/web/src/lib/stories.ts +298 −0
@@ -0,0 +1,298 @@
1 +/**
2 + * Data stories — declarative templates. A story is a slug, a title, a standfirst and an ordered list of blocks
3 + * bound to indicators. Blocks are rendered by `components/stories/*` from live API payloads; `text` blocks are
4 + * template sentences whose variables are computed server-side from the SAME payloads (see
5 + * components/stories/resolve.ts). A sentence whose variable cannot be computed is omitted — never guessed.
6 + *
7 + * Adding a story = adding an entry to STORIES. No code elsewhere.
8 + */
9 +
10 +export type VarSpec =
11 + /** Aggregate trend value (world or a group) at the first / last year or a given year. */
12 + | { type: 'trend'; indicator: string; group?: string; year: 'first' | 'last' | number; stat?: 'preferred' | 'median' | 'mean' | 'weighted_mean' | 'sum'; format?: 'value' | 'year' | 'n' }
13 + /** Number of countries whose value in `year` (latest when omitted) satisfies op threshold. */
14 + | { type: 'mapCount'; indicator: string; year?: number; op: 'gte' | 'lte' | 'gt' | 'lt'; threshold: number; format?: 'n' | 'total' | 'pct' | 'year' }
15 + /** Top-ranked country (name or value) for an indicator in a year (latest when omitted). */
16 + | { type: 'rankTop'; indicator: string; year?: number; pos?: number; sort?: 'asc' | 'desc'; format?: 'name' | 'value' | 'year' }
17 + /** One country's value at the first / last year of its series (or a given year). */
18 + | { type: 'country'; country: string; indicator: string; year: 'first' | 'last' | number; format?: 'value' | 'year' | 'name' }
19 + /** Share (%) of a group aggregate in the world aggregate (sum indicators) at a year. */
20 + | { type: 'share'; indicator: string; group: string; year: 'first' | 'last' | number; format?: 'pct' | 'year' };
21 +
22 +export interface TextParagraph {
23 + /** `{var}` placeholders resolved from `vars`. */
24 + template: string;
25 + vars: Record<string, VarSpec>;
26 +}
27 +
28 +export type StoryBlock =
29 + | { kind: 'text'; paragraphs: TextParagraph[] }
30 + | { kind: 'map'; indicator: string; years: number[]; title?: string; note?: string }
31 + | { kind: 'trend'; indicator: string; groups?: string[]; title?: string; log?: boolean; from?: number }
32 + | { kind: 'lines'; indicator: string; countries: string[]; title?: string; from?: number; log?: boolean }
33 + | { kind: 'ranked'; indicator: string; year?: number; top: number; sort?: 'asc' | 'desc'; title?: string }
34 + | { kind: 'shares'; indicator: string; groups: string[]; title?: string; from?: number };
35 +
36 +export interface Story {
37 + slug: string;
38 + title: string;
39 + standfirst: string;
40 + /** ISO date of first publication (stories are regenerated from data on every request). */
41 + published: string;
42 + topics: string[];
43 + blocks: StoryBlock[];
44 +}
45 +
46 +const WB_REGIONS = ['east-asia-pacific', 'europe-central-asia', 'north-america', 'latin-america-caribbean', 'middle-east-north-africa', 'south-asia', 'sub-saharan-africa'];
47 +
48 +export const STORIES: readonly Story[] = [
49 + {
50 + slug: 'the-world-is-getting-older',
51 + title: 'The world is getting older',
52 + standfirst: 'Median age has climbed on every continent, the share of people over 65 has more than doubled since 1960, and fertility has fallen below replacement in most rich countries.',
53 + published: '2026-09-11',
54 + topics: ['population'],
55 + blocks: [
56 + {
57 + kind: 'text',
58 + paragraphs: [
59 + {
60 + template: 'In {y0}, the population-weighted median age of the world was {v0}. By {y1} it had risen to {v1}.',
61 + vars: {
62 + y0: { type: 'trend', indicator: 'median-age', year: 'first', format: 'year' },
63 + v0: { type: 'trend', indicator: 'median-age', year: 'first' },
64 + y1: { type: 'trend', indicator: 'median-age', year: 'last', format: 'year' },
65 + v1: { type: 'trend', indicator: 'median-age', year: 'last' },
66 + },
67 + },
68 + {
69 + template: 'The oldest population in {y} is {name}, with a median age of {v}.',
70 + vars: {
71 + y: { type: 'rankTop', indicator: 'median-age', format: 'year' },
72 + name: { type: 'rankTop', indicator: 'median-age', format: 'name' },
73 + v: { type: 'rankTop', indicator: 'median-age', format: 'value' },
74 + },
75 + },
76 + ],
77 + },
78 + { kind: 'map', indicator: 'median-age', years: [1960, 1980, 2000, 2023], title: 'Median age, decade by decade' },
79 + {
80 + kind: 'text',
81 + paragraphs: [
82 + {
83 + template: 'People aged 65 and over were {v0} of the world population in {y0}; in {y1} they were {v1}.',
84 + vars: {
85 + v0: { type: 'trend', indicator: 'population-65-plus-share', year: 'first' },
86 + y0: { type: 'trend', indicator: 'population-65-plus-share', year: 'first', format: 'year' },
87 + v1: { type: 'trend', indicator: 'population-65-plus-share', year: 'last' },
88 + y1: { type: 'trend', indicator: 'population-65-plus-share', year: 'last', format: 'year' },
89 + },
90 + },
91 + ],
92 + },
93 + { kind: 'trend', indicator: 'population-65-plus-share', groups: ['world', 'high-income', 'low-income'], title: 'Share of population aged 65+' },
94 + { kind: 'lines', indicator: 'fertility-rate', countries: ['JPN', 'ITA', 'KOR', 'CHN', 'IND', 'NGA'], title: 'Fertility rate, births per woman' },
95 + { kind: 'ranked', indicator: 'median-age', top: 10, title: 'Oldest populations' },
96 + ],
97 + },
98 + {
99 + slug: 'rise-of-renewable-electricity',
100 + title: 'The rise of renewable electricity',
101 + standfirst: 'Renewables were a hydro story for decades. Since 2010 solar and wind have changed the shape of electricity systems on every continent.',
102 + published: '2026-09-11',
103 + topics: ['energy'],
104 + blocks: [
105 + {
106 + kind: 'text',
107 + paragraphs: [
108 + {
109 + template: 'Weighted by population, renewables supplied {v0} of electricity in {y0} and {v1} in {y1}.',
110 + vars: {
111 + v0: { type: 'trend', indicator: 'renewable-electricity-share', year: 'first' },
112 + y0: { type: 'trend', indicator: 'renewable-electricity-share', year: 'first', format: 'year' },
113 + v1: { type: 'trend', indicator: 'renewable-electricity-share', year: 'last' },
114 + y1: { type: 'trend', indicator: 'renewable-electricity-share', year: 'last', format: 'year' },
115 + },
116 + },
117 + {
118 + template: '{n} of {total} countries with data generated more than half of their electricity from renewables in {y}.',
119 + vars: {
120 + n: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'n' },
121 + total: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'total' },
122 + y: { type: 'mapCount', indicator: 'renewable-electricity-share', op: 'gte', threshold: 50, format: 'year' },
123 + },
124 + },
125 + ],
126 + },
127 + { kind: 'map', indicator: 'renewable-electricity-share', years: [1990, 2005, 2015, 2025], title: 'Share of electricity from renewables' },
128 + { kind: 'trend', indicator: 'renewable-electricity-share', groups: ['world', 'european-union', 'east-asia-pacific'], title: 'Renewable share of electricity' },
129 + { kind: 'lines', indicator: 'solar-generation', countries: ['CHN', 'USA', 'IND', 'DEU', 'JPN', 'BRA'], from: 2000, title: 'Solar electricity generation' },
130 + { kind: 'lines', indicator: 'wind-generation', countries: ['CHN', 'USA', 'DEU', 'IND', 'BRA', 'GBR'], from: 2000, title: 'Wind electricity generation' },
131 + { kind: 'ranked', indicator: 'renewable-electricity-share', top: 12, title: 'Highest renewable shares' },
132 + ],
133 + },
134 + {
135 + slug: 'internet-adoption-since-1990',
136 + title: 'Internet adoption since 1990',
137 + standfirst: 'From a laboratory network to the default way most people read, pay and talk: three decades of one of the fastest diffusions of a technology ever measured.',
138 + published: '2026-09-11',
139 + topics: ['digital'],
140 + blocks: [
141 + {
142 + kind: 'text',
143 + paragraphs: [
144 + {
145 + template: 'In {y0}, {v0} of the world population used the Internet. In {y1} the population-weighted share was {v1}.',
146 + vars: {
147 + y0: { type: 'trend', indicator: 'internet-users', year: 2000, format: 'year' },
148 + v0: { type: 'trend', indicator: 'internet-users', year: 2000 },
149 + y1: { type: 'trend', indicator: 'internet-users', year: 2023, format: 'year' },
150 + v1: { type: 'trend', indicator: 'internet-users', year: 2023 },
151 + },
152 + },
153 + {
154 + template: '{n} of {total} countries with data had at least nine users in ten in {y}.',
155 + vars: {
156 + n: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'n' },
157 + total: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'total' },
158 + y: { type: 'mapCount', indicator: 'internet-users', year: 2023, op: 'gte', threshold: 90, format: 'year' },
159 + },
160 + },
161 + ],
162 + },
163 + { kind: 'map', indicator: 'internet-users', years: [1995, 2005, 2015, 2023], title: 'Internet users, % of population' },
164 + { kind: 'trend', indicator: 'internet-users', groups: ['world', 'high-income', 'lower-middle-income', 'low-income'], title: 'Internet users by income group' },
165 + { kind: 'lines', indicator: 'internet-users', countries: ['KOR', 'USA', 'BRA', 'CHN', 'IND', 'NGA'], title: 'Six adoption curves' },
166 + { kind: 'ranked', indicator: 'internet-users', year: 2023, top: 10, title: 'Most connected countries' },
167 + ],
168 + },
169 + {
170 + slug: 'shifting-centre-of-the-world-economy',
171 + title: 'The shifting centre of the world economy',
172 + standfirst: 'Summing the GDP of every country by region shows the world economy tilting toward East Asia and the Pacific since 1990, while North America holds and Europe recedes in share.',
173 + published: '2026-09-11',
174 + topics: ['economy'],
175 + blocks: [
176 + {
177 + kind: 'text',
178 + paragraphs: [
179 + {
180 + template: 'East Asia & Pacific produced {s0} of the world total in {y0} and {s1} in {y1}.',
181 + vars: {
182 + s0: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 1990 },
183 + y0: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 1990, format: 'year' },
184 + s1: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 'last' },
185 + y1: { type: 'share', indicator: 'gdp', group: 'east-asia-pacific', year: 'last', format: 'year' },
186 + },
187 + },
188 + {
189 + template: 'Europe & Central Asia went from {s0} to {s1} over the same period; North America from {n0} to {n1}.',
190 + vars: {
191 + s0: { type: 'share', indicator: 'gdp', group: 'europe-central-asia', year: 1990 },
192 + s1: { type: 'share', indicator: 'gdp', group: 'europe-central-asia', year: 'last' },
193 + n0: { type: 'share', indicator: 'gdp', group: 'north-america', year: 1990 },
194 + n1: { type: 'share', indicator: 'gdp', group: 'north-america', year: 'last' },
195 + },
196 + },
197 + ],
198 + },
199 + { kind: 'shares', indicator: 'gdp', groups: WB_REGIONS, from: 1970, title: 'Share of world GDP by World Bank region' },
200 + { kind: 'trend', indicator: 'gdp', groups: ['east-asia-pacific', 'north-america', 'europe-central-asia', 'south-asia'], from: 1970, log: true, title: 'GDP by region, current US$' },
201 + { kind: 'map', indicator: 'gdp-per-capita-ppp', years: [1990, 2005, 2025], title: 'GDP per capita (PPP)' },
202 + { kind: 'ranked', indicator: 'gdp', top: 12, title: 'Largest economies' },
203 + ],
204 + },
205 + {
206 + slug: 'global-fertility-collapse',
207 + title: 'Global fertility collapse',
208 + standfirst: 'In 1960 the average woman had about five children. Today most of humanity lives in countries below the replacement rate of 2.1.',
209 + published: '2026-09-11',
210 + topics: ['population'],
211 + blocks: [
212 + {
213 + kind: 'text',
214 + paragraphs: [
215 + {
216 + template: 'The population-weighted fertility rate fell from {v0} births per woman in {y0} to {v1} in {y1}.',
217 + vars: {
218 + v0: { type: 'trend', indicator: 'fertility-rate', year: 'first' },
219 + y0: { type: 'trend', indicator: 'fertility-rate', year: 'first', format: 'year' },
220 + v1: { type: 'trend', indicator: 'fertility-rate', year: 'last' },
221 + y1: { type: 'trend', indicator: 'fertility-rate', year: 'last', format: 'year' },
222 + },
223 + },
224 + {
225 + template: '{n0} countries were below the 2.1 replacement rate in {y0}; {n1} of {total} were in {y1}.',
226 + vars: {
227 + n0: { type: 'mapCount', indicator: 'fertility-rate', year: 1990, op: 'lt', threshold: 2.1, format: 'n' },
228 + y0: { type: 'mapCount', indicator: 'fertility-rate', year: 1990, op: 'lt', threshold: 2.1, format: 'year' },
229 + n1: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'n' },
230 + total: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'total' },
231 + y1: { type: 'mapCount', indicator: 'fertility-rate', op: 'lt', threshold: 2.1, format: 'year' },
232 + },
233 + },
234 + ],
235 + },
236 + { kind: 'map', indicator: 'fertility-rate', years: [1960, 1980, 2000, 2024], title: 'Births per woman' },
237 + { kind: 'trend', indicator: 'fertility-rate', groups: ['world', 'sub-saharan-africa', 'east-asia-pacific', 'europe-central-asia'], title: 'Fertility by region' },
238 + { kind: 'lines', indicator: 'fertility-rate', countries: ['KOR', 'CHN', 'IRN', 'BRA', 'BGD', 'NER'], title: 'Six trajectories' },
239 + { kind: 'ranked', indicator: 'fertility-rate', top: 10, sort: 'asc', title: 'Lowest fertility rates' },
240 + ],
241 + },
242 + {
243 + slug: 'fifty-years-of-life-expectancy',
244 + title: '50 years of life expectancy',
245 + standfirst: 'Half a century of gains on every continent — interrupted by AIDS, transitions and a pandemic, but rarely reversed for long.',
246 + published: '2026-09-11',
247 + topics: ['health'],
248 + blocks: [
249 + {
250 + kind: 'text',
251 + paragraphs: [
252 + {
253 + template: 'World life expectancy at birth, weighted by population, was {v0} in {y0} and {v1} in {y1}.',
254 + vars: {
255 + v0: { type: 'trend', indicator: 'life-expectancy', year: 1974 },
256 + y0: { type: 'trend', indicator: 'life-expectancy', year: 1974, format: 'year' },
257 + v1: { type: 'trend', indicator: 'life-expectancy', year: 'last' },
258 + y1: { type: 'trend', indicator: 'life-expectancy', year: 'last', format: 'year' },
259 + },
260 + },
261 + {
262 + template: '{n} of {total} countries with data reached 80 years or more in {y}; the highest is {name} at {v}.',
263 + vars: {
264 + n: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'n' },
265 + total: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'total' },
266 + y: { type: 'mapCount', indicator: 'life-expectancy', op: 'gte', threshold: 80, format: 'year' },
267 + name: { type: 'rankTop', indicator: 'life-expectancy', format: 'name' },
268 + v: { type: 'rankTop', indicator: 'life-expectancy', format: 'value' },
269 + },
270 + },
271 + ],
272 + },
273 + { kind: 'map', indicator: 'life-expectancy', years: [1974, 1990, 2005, 2024], title: 'Life expectancy at birth' },
274 + { kind: 'trend', indicator: 'life-expectancy', groups: ['world', 'sub-saharan-africa', 'south-asia', 'high-income'], from: 1974, title: 'Life expectancy by group' },
275 + { kind: 'lines', indicator: 'life-expectancy', countries: ['JPN', 'KOR', 'CHN', 'IND', 'NGA', 'USA'], from: 1974, title: 'Six countries, fifty years' },
276 + { kind: 'ranked', indicator: 'life-expectancy', top: 10, title: 'Longest lives' },
277 + ],
278 + },
279 +];
280 +
281 +export function storyBySlug(slug: string): Story | undefined {
282 + return STORIES.find((s) => s.slug === slug);
283 +}
284 +
285 +/** Distinct indicator slugs a story draws on (for metadata, JSON-LD and the "indicators used" list). */
286 +export function storyIndicators(story: Story): string[] {
287 + const out: string[] = [];
288 + for (const b of story.blocks) {
289 + if (b.kind === 'text') {
290 + for (const p of b.paragraphs) for (const v of Object.values(p.vars)) if (!out.includes(v.indicator)) out.push(v.indicator);
291 + } else if (!out.includes(b.indicator)) out.push(b.indicator);
292 + }
293 + return out;
294 +}
295 +
296 +export function storyChartCount(story: Story): number {
297 + return story.blocks.filter((b) => b.kind !== 'text').length;
298 +}
299