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
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/layout/search-trigger.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/layout/site-header.tsx
+8 −3
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/platform/download-builder.tsx
+239 −0
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/platform/endpoint-explorer.tsx
+190 −0
Large diff (190 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/platform/json-ld.tsx
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stories/resolve.ts
+166 −0
Large diff (166 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stories/story-blocks.tsx
+198 −0
Large diff (198 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/stories/story-map-frames.tsx
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/i18n/en.platform.ts
+225 −1
Large diff (233 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api-platform.ts
+10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api-platform.ts
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/og.tsx
+53 −11
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/seo.ts
+99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/stories.ts
+298 −0
Large diff (299 lines) is not loaded by default to keep the page fast.Load diff