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>
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 | ||