Core pages 2.0: home (hero map, world pulse, movers, ticker, trajectory teaser), country page 2.0 (story, timeline, DNA reference, similar 2.0, mini map), compare 2.0 (head-to-head, percentile/change modes, PNG export), rankings 2.0 (filters, views, rank race), indicator page 2.0 (frames map, distribution, related, quality), regions compare, /peers, changes 2.0, search intents
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
52 changed files +3,077 −491
added
apps/web/qa/core-qa.mjs
+122 −0
@@ -0,0 +1,122 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Core pages QA (real API): routes × widths → screenshots + checks (overflow, console errors, failed requests, | ||||
| 3 | + * tap targets < 44 px on phones, "undefined/NaN/null" text). Output: qa/screens/core/<route>-<width>.png + report.json. | ||||
| 4 | + * | ||||
| 5 | + * node qa/core-qa.mjs [BASE_URL] [--quick] [--routes=/a,/b] [--widths=390,1440] | ||||
| 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 args = process.argv.slice(2); | ||||
| 11 | +const quick = args.includes('--quick'); | ||||
| 12 | +const BASE = args.find((a) => a.startsWith('http')) ?? process.env.BASE_URL ?? 'http://localhost:8290'; | ||||
| 13 | +const routesArg = args.find((a) => a.startsWith('--routes=')); | ||||
| 14 | +const widthsArg = args.find((a) => a.startsWith('--widths=')); | ||||
| 15 | +const OUT = new URL('./screens/core/', import.meta.url).pathname; | ||||
| 16 | +mkdirSync(OUT, { recursive: true }); | ||||
| 17 | + | ||||
| 18 | +const ROUTES = routesArg | ||||
| 19 | + ? routesArg.slice('--routes='.length).split(',') | ||||
| 20 | + : ['/', '/countries/canada', '/compare/canada/australia', '/compare/canada/united-states/france?tab=economy', '/rankings/gdp-per-capita', '/indicators/life-expectancy', '/regions/g7', '/regions/compare', '/peers', '/changes']; | ||||
| 21 | +const WIDTHS = widthsArg ? widthsArg.slice('--widths='.length).split(',').map(Number) : quick ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1440, 1920]; | ||||
| 22 | + | ||||
| 23 | +const slug = (p) => (p === '/' ? 'home' : p.slice(1).replace(/[/?=&]+/g, '_')); | ||||
| 24 | +const report = []; | ||||
| 25 | +const browser = await chromium.launch(); | ||||
| 26 | +for (const width of WIDTHS) { | ||||
| 27 | + const mobile = width < 768; | ||||
| 28 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile }); | ||||
| 29 | + const page = await ctx.newPage(); | ||||
| 30 | + const errors = []; | ||||
| 31 | + const failed = []; | ||||
| 32 | + page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`)); | ||||
| 33 | + page.on('console', (m) => { | ||||
| 34 | + if (m.type() === 'error') errors.push(m.text().slice(0, 200)); | ||||
| 35 | + }); | ||||
| 36 | + page.on('response', (r) => { | ||||
| 37 | + if (r.status() >= 400 && r.url().includes('/api/')) failed.push(`${r.status()} ${r.url().slice(0, 140)}`); | ||||
| 38 | + }); | ||||
| 39 | + for (const path of ROUTES) { | ||||
| 40 | + let status = 0; | ||||
| 41 | + try { | ||||
| 42 | + const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 }); | ||||
| 43 | + status = resp?.status() ?? 0; | ||||
| 44 | + } catch (e) { | ||||
| 45 | + report.push({ path, width, status: 'ERR', error: String(e).slice(0, 200) }); | ||||
| 46 | + continue; | ||||
| 47 | + } | ||||
| 48 | + await page.evaluate(() => document.fonts.ready); | ||||
| 49 | + await page.evaluate(async () => { | ||||
| 50 | + const h = document.documentElement.scrollHeight; | ||||
| 51 | + for (let y = 0; y < h; y += 700) { | ||||
| 52 | + window.scrollTo(0, y); | ||||
| 53 | + await new Promise((r) => setTimeout(r, 50)); | ||||
| 54 | + } | ||||
| 55 | + window.scrollTo(0, 0); | ||||
| 56 | + }); | ||||
| 57 | + await page.waitForLoadState('networkidle').catch(() => {}); | ||||
| 58 | + await page.waitForTimeout(500); | ||||
| 59 | + const m = await page.evaluate( | ||||
| 60 | + ({ mobile }) => { | ||||
| 61 | + const de = document.documentElement; | ||||
| 62 | + const overflow = de.scrollWidth - de.clientWidth; | ||||
| 63 | + const wide = [...document.querySelectorAll('body *')] | ||||
| 64 | + .filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1 && el.getBoundingClientRect().width > 0) | ||||
| 65 | + .slice(0, 5) | ||||
| 66 | + .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`); | ||||
| 67 | + const vis = (el) => { | ||||
| 68 | + const r = el.getBoundingClientRect(); | ||||
| 69 | + if (!r.width || !r.height) return false; | ||||
| 70 | + const cs = getComputedStyle(el); | ||||
| 71 | + return cs.visibility !== 'hidden' && cs.display !== 'none'; | ||||
| 72 | + }; | ||||
| 73 | + const small = mobile | ||||
| 74 | + ? [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')] | ||||
| 75 | + .filter(vis) | ||||
| 76 | + .filter((el) => !el.closest('svg') && el.getBoundingClientRect().height < 44 && el.getBoundingClientRect().width < 44) | ||||
| 77 | + .map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`) | ||||
| 78 | + : []; | ||||
| 79 | + const text = document.body.innerText || ''; | ||||
| 80 | + const bad = []; | ||||
| 81 | + for (const re of [/\bundefined\b/g, /\bNaN\b/g, /(?<![\w"':])null(?![\w"':])/g]) { | ||||
| 82 | + let mm; | ||||
| 83 | + let n = 0; | ||||
| 84 | + while ((mm = re.exec(text)) && n < 3) { | ||||
| 85 | + bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`); | ||||
| 86 | + n++; | ||||
| 87 | + } | ||||
| 88 | + } | ||||
| 89 | + const footer = document.querySelector('footer')?.innerText ?? ''; | ||||
| 90 | + const credits = /Simon-Pierre Boucher/.test(footer) && /contact@spboucher\.ai/.test(footer) && /MacLustr/.test(footer); | ||||
| 91 | + return { overflow, wide, small: small.slice(0, 8), nSmall: small.length, bad, credits, title: document.title, docH: de.scrollHeight }; | ||||
| 92 | + }, | ||||
| 93 | + { mobile }, | ||||
| 94 | + ); | ||||
| 95 | + const file = `${slug(path)}-${width}.png`; | ||||
| 96 | + await page.screenshot({ path: OUT + file, fullPage: true }).catch(() => {}); | ||||
| 97 | + report.push({ path, width, status, ...m, errors: errors.splice(0), failed: failed.splice(0), file }); | ||||
| 98 | + writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); | ||||
| 99 | + } | ||||
| 100 | + await ctx.close(); | ||||
| 101 | +} | ||||
| 102 | +await browser.close(); | ||||
| 103 | + | ||||
| 104 | +let fails = 0; | ||||
| 105 | +for (const r of report) { | ||||
| 106 | + const flags = []; | ||||
| 107 | + if (r.status !== 200) flags.push(`HTTP ${r.status}`); | ||||
| 108 | + if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`); | ||||
| 109 | + if (r.nSmall) flags.push(`${r.nSmall} small targets`); | ||||
| 110 | + if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`); | ||||
| 111 | + if (r.errors?.length) flags.push(`${r.errors.length} console errors`); | ||||
| 112 | + if (r.failed?.length) flags.push(`${r.failed.length} failed API`); | ||||
| 113 | + if (r.credits === false) flags.push('NO CREDITS'); | ||||
| 114 | + if (flags.length) fails++; | ||||
| 115 | + console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(52)} ${flags.join(' · ') || 'ok'} (h=${r.docH})`); | ||||
| 116 | + if (r.wide?.length) console.log(' wide:', r.wide.join(' | ')); | ||||
| 117 | + if (r.small?.length) console.log(' small:', r.small.slice(0, 5).join(' | ')); | ||||
| 118 | + if (r.bad?.length) console.log(' bad:', r.bad.join(' | ')); | ||||
| 119 | + if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | ')); | ||||
| 120 | + if (r.failed?.length) console.log(' failed:', r.failed.slice(0, 3).join(' | ')); | ||||
| 121 | +} | ||||
| 122 | +console.log(`\n${report.length} renders, ${fails} with flags`); | ||||
modified
apps/web/src/app/(home)/page.tsx
+115 −61
@@ -2,24 +2,35 @@ import type { Metadata } from 'next'; | |||||
| 2 | import Link from 'next/link'; | 2 | import Link from 'next/link'; | ||
| 3 | import { t } from '@/i18n'; | 3 | import { t } from '@/i18n'; | ||
| 4 | import { api, isNotBuilt, safe } from '@/lib/api'; | 4 | import { api, isNotBuilt, safe } from '@/lib/api'; | ||
| 5 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 6 | +import { apiExplore } from '@/lib/api-explore'; | ||||
| 5 | import { routes } from '@/lib/site'; | 7 | import { routes } from '@/lib/site'; | ||
| 6 | −import { Choropleth } from '@/components/charts/choropleth'; | 8 | +import { HEADLINE_INDICATORS } from '@/lib/topics'; | ||
| 7 | import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; | 9 | import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; | ||
| 8 | −import { ChangeList } from '@/components/data/change-list'; | 10 | +import type { IndicatorOption } from '@/components/controls/indicator-select'; | ||
| 9 | import { NotBuiltState } from '@/components/data/empty-state'; | 11 | import { NotBuiltState } from '@/components/data/empty-state'; | ||
| 10 | import { Section } from '@/components/data/section'; | 12 | import { Section } from '@/components/data/section'; | ||
| 13 | +import { SimilarityPlayground } from '@/components/explore/similarity-playground'; | ||||
| 11 | import { CompareTeaser } from '@/components/home/compare-teaser'; | 14 | import { CompareTeaser } from '@/components/home/compare-teaser'; | ||
| 12 | import { IndicatorList } from '@/components/home/featured-indicators'; | 15 | import { IndicatorList } from '@/components/home/featured-indicators'; | ||
| 13 | import { Hero } from '@/components/home/hero'; | 16 | import { Hero } from '@/components/home/hero'; | ||
| 14 | −import { RegionChips } from '@/components/home/region-chips'; | 17 | +import { HeroMap, type HeroCountry } from '@/components/home/hero-map'; | ||
| 18 | +import { LatestUpdates } from '@/components/home/latest-updates'; | ||||
| 19 | +import { Movers } from '@/components/home/movers'; | ||||
| 15 | import { SnapshotStrip } from '@/components/home/snapshot-strip'; | 20 | import { SnapshotStrip } from '@/components/home/snapshot-strip'; | ||
| 16 | import { TopicsGrid } from '@/components/home/topics-grid'; | 21 | import { TopicsGrid } from '@/components/home/topics-grid'; | ||
| 22 | +import { TrajectoryTeaser } from '@/components/home/trajectory-teaser'; | ||||
| 23 | +import { Transparency } from '@/components/home/transparency'; | ||||
| 24 | +import { WorldPulse } from '@/components/home/world-pulse'; | ||||
| 25 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | ||||
| 17 | 26 | ||||
| 18 | export const metadata: Metadata = { alternates: { canonical: '/' } }; | 27 | export const metadata: Metadata = { alternates: { canonical: '/' } }; | ||
| 19 | export const revalidate = 900; | 28 | export const revalidate = 900; | ||
| 20 | 29 | ||||
| 21 | −/** Home lists in display order; a key missing from the API payload is simply skipped (e.g. fastest_gdp_growth). */ | 30 | +const DEFAULT_MAP = 'gdp-per-capita-ppp'; | ||
| 22 | −const LIST_KEYS = ['largest_economies', 'fastest_gdp_growth', 'highest_gdp_per_capita_ppp', 'highest_life_expectancy', 'fastest_population_growth', 'energy_transition_leaders'] as const; | 31 | +const MAP_CHIPS = ['gdp-per-capita-ppp', 'population', 'gdp', 'gdp-growth', 'inflation', 'life-expectancy', 'fertility-rate', 'internet-users', 'co2-per-capita', 'renewable-electricity-share', 'unemployment-rate', 'population-growth']; | ||
| 32 | +/** The three curated lists kept on the home page (the rest lives on /rankings). */ | ||||
| 33 | +const LIST_KEYS = ['largest_economies', 'highest_life_expectancy', 'energy_transition_leaders'] as const; | ||||
| 23 | 34 | ||||
| 24 | export default async function HomePage() { | 35 | export default async function HomePage() { | ||
| 25 | let home: Awaited<ReturnType<typeof api.home>> | null = null; | 36 | let home: Awaited<ReturnType<typeof api.home>> | null = null; | ||
@@ -30,74 +41,117 @@ export default async function HomePage() { | |||||
| 30 | if (isNotBuilt(e)) notBuilt = true; | 41 | if (isNotBuilt(e)) notBuilt = true; | ||
| 31 | else throw e; | 42 | else throw e; | ||
| 32 | } | 43 | } | ||
| 33 | − const [countriesRes, map] = await Promise.all([safe(api.countries()), safe(api.indicatorMap('gdp-per-capita-ppp', { nearest: true }))]); | 44 | + if (notBuilt || !home) { | ||
| 45 | + return ( | ||||
| 46 | + <> | ||||
| 47 | + <Hero /> | ||||
| 48 | + <NotBuiltState /> | ||||
| 49 | + </> | ||||
| 50 | + ); | ||||
| 51 | + } | ||||
| 52 | + const [countriesRes, frames, indicators, pulse, movers, scatter, updates, sources] = await Promise.all([ | ||||
| 53 | + safe(api.countries()), | ||||
| 54 | + safe(apiAnalytics.indicatorFrames(DEFAULT_MAP)), | ||||
| 55 | + safe(apiExplore.indicators({ with_data: true })), | ||||
| 56 | + safe(apiAnalytics.pulse()), | ||||
| 57 | + safe(apiAnalytics.movers({ window: 1, limit: 12 })), | ||||
| 58 | + safe(apiAnalytics.scatter({ x: 'gdp-per-capita-ppp', y: 'life-expectancy', size: 'population' })), | ||||
| 59 | + safe(apiAnalytics.updates()), | ||||
| 60 | + safe(apiExplore.sources()), | ||||
| 61 | + ]); | ||||
| 34 | const countries = countriesRes?.items ?? []; | 62 | const countries = countriesRes?.items ?? []; | ||
| 63 | + const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; | ||||
| 64 | + const heroCountries: HeroCountry[] = countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag, region: c.region })); | ||||
| 65 | + const options: IndicatorOption[] = (indicators?.items ?? []) | ||||
| 66 | + .filter((i) => (i.n_countries ?? 0) >= 20 && i.frequency === 'A') | ||||
| 67 | + .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 })); | ||||
| 68 | + const chips = MAP_CHIPS.filter((s) => options.some((o) => o.slug === s)); | ||||
| 69 | + const s = home.snapshot; | ||||
| 35 | 70 | ||||
| 36 | return ( | 71 | return ( | ||
| 37 | <> | 72 | <> | ||
| 38 | <Hero /> | 73 | <Hero /> | ||
| 39 | − {notBuilt || !home ? ( | ||||
| 40 | − <NotBuiltState /> | ||||
| 41 | − ) : ( | ||||
| 42 | − <> | ||||
| 43 | − <SnapshotStrip s={home.snapshot} /> | ||||
| 44 | 74 | ||||
| 45 | − <Section id="explore" title={t('home.explore.title')} subtitle={t('home.explore.sub')}> | 75 | + {/* Global interactive hero map + time machine */} | ||
| 46 | − <RegionChips /> | 76 | + <section id="map" aria-labelledby="map-h" className="pb-8 md:pb-10"> | ||
| 47 | − {map && countries.length ? ( | 77 | + <h2 id="map-h" className="sr-only"> | ||
| 48 | − <div className="mt-5 max-w-4xl"> | 78 | + {t('home.map.title')} | ||
| 49 | − <Choropleth map={map} countries={countries} compact /> | 79 | + </h2> | ||
| 80 | + {features.length ? <HeroMap geometry={features} sphere={sphere} initial={frames} options={options.length ? options : HEADLINE_INDICATORS.map((sl) => ({ slug: sl, name: sl }))} chips={chips.length ? chips : [DEFAULT_MAP]} countries={heroCountries} /> : null} | ||||
| 81 | + </section> | ||||
| 82 | + | ||||
| 83 | + <SnapshotStrip s={s} /> | ||||
| 84 | + | ||||
| 85 | + {pulse && pulse.items.length ? ( | ||||
| 86 | + <Section id="pulse" title={t('home.pulse.title')} subtitle={t('home.pulse.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('home.changes.all')} →</Link>}> | ||||
| 87 | + <WorldPulse data={pulse} /> | ||||
| 88 | + </Section> | ||||
| 89 | + ) : null} | ||||
| 90 | + | ||||
| 91 | + <Section id="movers" title={t('home.movers.title')} subtitle={t('home.movers.sub')}> | ||||
| 92 | + <Movers initial={movers} limit={12} compact /> | ||||
| 93 | + </Section> | ||||
| 94 | + | ||||
| 95 | + <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}> | ||||
| 96 | + <TopicsGrid /> | ||||
| 97 | + </Section> | ||||
| 98 | + | ||||
| 99 | + <div className="grid gap-x-10 lg:grid-cols-2"> | ||||
| 100 | + <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}> | ||||
| 101 | + <CompareTeaser countries={countries} /> | ||||
| 102 | + </Section> | ||||
| 103 | + <Section id="similar" title={t('home.similar.title')} subtitle={t('home.similar.sub')}> | ||||
| 104 | + <SimilarityPlayground /> | ||||
| 105 | + </Section> | ||||
| 106 | + </div> | ||||
| 107 | + | ||||
| 108 | + <Section id="rankings" title={t('nav.rankings')} subtitle={t('home.rankings.sub')} actions={<Link href={routes.rankings()} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}> | ||||
| 109 | + <div className="grid gap-x-10 gap-y-8 lg:grid-cols-3"> | ||||
| 110 | + {LIST_KEYS.map((key) => { | ||||
| 111 | + const list = home.lists[key]; | ||||
| 112 | + if (!list || !list.rows?.length) return null; | ||||
| 113 | + const latestYear = Math.max(0, ...list.rows.map((r) => r.year ?? 0)); | ||||
| 114 | + return ( | ||||
| 115 | + <div key={key} className="min-w-0"> | ||||
| 116 | + <div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5"> | ||||
| 117 | + <h3 className="text-base font-semibold text-ink">{list.title}</h3> | ||||
| 118 | + <span className="text-xs text-ink-3">{list.description}</span> | ||||
| 119 | + </div> | ||||
| 120 | + {list.filter_note ? <p className="-mt-1 mb-2 text-xs text-ink-3">{list.filter_note}</p> : null} | ||||
| 121 | + <RankedBars rows={list.rows.slice(0, 6).map((r) => rankedRowFromCountry(r.country, r.value, r.rank, null, r.year != null && latestYear - r.year >= 2 ? r.year : null))} spec={list.indicator} provenance={list.rows[0]?.provenance ?? null} /> | ||||
| 122 | + <Link href={routes.ranking(list.indicator.slug)} className="mt-2 inline-flex min-h-[36px] items-center text-sm text-accent hover:underline"> | ||||
| 123 | + {t('common.seeFullRanking')} → | ||||
| 124 | + </Link> | ||||
| 50 | </div> | 125 | </div> | ||
| 51 | − ) : null} | 126 | + ); | ||
| 52 | − </Section> | 127 | + })} | ||
| 128 | + </div> | ||||
| 129 | + </Section> | ||||
| 53 | 130 | ||||
| 54 | − <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}> | 131 | + {scatter && scatter.points.length ? ( | ||
| 55 | − <CompareTeaser countries={countries} /> | 132 | + <Section id="trajectory" title={t('home.trajectory.title')} subtitle={t('home.trajectory.sub')}> | ||
| 56 | − </Section> | 133 | + <TrajectoryTeaser data={scatter} /> | ||
| 134 | + </Section> | ||||
| 135 | + ) : null} | ||||
| 57 | 136 | ||||
| 58 | − <Section id="rankings" title={t('nav.rankings')} actions={<Link href={routes.rankings()} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}> | 137 | + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | ||
| 59 | − <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2"> | 138 | + <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')}> | ||
| 60 | − {LIST_KEYS.map((key) => { | 139 | + <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={10} /> | ||
| 61 | − const list = home.lists[key]; | 140 | + </Section> | ||
| 62 | − if (!list || !list.rows?.length) return null; | 141 | + {updates ? ( | ||
| 63 | − // Freshness honesty: a row ≥ 2 years older than the newest row of the list shows its year. | 142 | + <Section id="updated" title={t('home.updates.title')} subtitle={t('home.updates.sub')}> | ||
| 64 | − const latestYear = Math.max(0, ...list.rows.map((r) => r.year ?? 0)); | 143 | + <LatestUpdates data={updates} /> | ||
| 65 | − return ( | ||||
| 66 | − <div key={key} className="min-w-0"> | ||||
| 67 | − <div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5"> | ||||
| 68 | − <h3 className="text-base font-semibold text-ink">{list.title}</h3> | ||||
| 69 | − <span className="text-xs text-ink-3">{list.description}</span> | ||||
| 70 | − </div> | ||||
| 71 | − {list.filter_note ? <p className="-mt-1 mb-2 text-xs text-ink-3">{list.filter_note}</p> : null} | ||||
| 72 | − <RankedBars rows={list.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, null, r.year != null && latestYear - r.year >= 2 ? r.year : null))} spec={list.indicator} provenance={list.rows[0]?.provenance ?? null} /> | ||||
| 73 | − <Link href={routes.ranking(list.indicator.slug)} className="mt-2 inline-flex min-h-[36px] items-center text-sm text-accent hover:underline"> | ||||
| 74 | − {t('common.seeFullRanking')} → | ||||
| 75 | − </Link> | ||||
| 76 | − </div> | ||||
| 77 | − ); | ||||
| 78 | − })} | ||||
| 79 | − </div> | ||||
| 80 | </Section> | 144 | </Section> | ||
| 81 | − | 145 | + ) : ( | ||
| 82 | − <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}> | 146 | + <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')}> | ||
| 83 | − <TopicsGrid /> | 147 | + <IndicatorList items={home.recently_updated} showUpdated limit={8} /> | ||
| 84 | </Section> | 148 | </Section> | ||
| 149 | + )} | ||||
| 150 | + </div> | ||||
| 85 | 151 | ||||
| 86 | − <div className="grid gap-x-10 lg:grid-cols-2"> | 152 | + <Section id="transparency" title={t('home.transparency.title')} subtitle={t('home.transparency.sub')}> | ||
| 87 | − <Section id="changes" title={t('home.changes.title')} subtitle={t('home.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('home.changes.all')} →</Link>}> | 153 | + <Transparency sources={sources?.items ?? []} /> | ||
| 88 | − <ChangeList items={home.recent_changes.slice(0, 10)} showCountry /> | 154 | + </Section> | ||
| 89 | − </Section> | ||||
| 90 | − <div> | ||||
| 91 | − <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')} tight> | ||||
| 92 | − <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={8} /> | ||||
| 93 | − </Section> | ||||
| 94 | − <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')} tight> | ||||
| 95 | − <IndicatorList items={home.recently_updated} showUpdated limit={8} /> | ||||
| 96 | − </Section> | ||||
| 97 | − </div> | ||||
| 98 | − </div> | ||||
| 99 | − </> | ||||
| 100 | − )} | ||||
| 101 | </> | 155 | </> | ||
| 102 | ); | 156 | ); | ||
| 103 | } | 157 | } | ||
modified
apps/web/src/app/changes/page.tsx
+35 −2
@@ -8,7 +8,11 @@ import { routes } from '@/lib/site'; | |||||
| 8 | import type { ChangesFeedResponse } from '@/lib/types-explore'; | 8 | import type { ChangesFeedResponse } from '@/lib/types-explore'; | ||
| 9 | import { ChangesFeed } from '@/components/changes/changes-feed'; | 9 | import { ChangesFeed } from '@/components/changes/changes-feed'; | ||
| 10 | import { NotBuiltState } from '@/components/data/empty-state'; | 10 | import { NotBuiltState } from '@/components/data/empty-state'; | ||
| 11 | +import { Section } from '@/components/data/section'; | ||||
| 11 | import { PageHeader } from '@/components/explore/page-header'; | 12 | import { PageHeader } from '@/components/explore/page-header'; | ||
| 13 | +import { Movers } from '@/components/home/movers'; | ||||
| 14 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 15 | +import { safe } from '@/lib/api'; | ||||
| 12 | 16 | ||||
| 13 | export const revalidate = 900; | 17 | export const revalidate = 900; | ||
| 14 | 18 | ||||
@@ -18,20 +22,46 @@ export const metadata: Metadata = { | |||||
| 18 | alternates: { canonical: routes.changes() }, | 22 | alternates: { canonical: routes.changes() }, | ||
| 19 | }; | 23 | }; | ||
| 20 | 24 | ||||
| 21 | −export default async function ChangesPage() { | 25 | +type SP = Record<string, string | string[] | undefined>; | ||
| 26 | + | ||||
| 27 | +export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) { | ||||
| 28 | + const sp = await searchParams; | ||||
| 29 | + const countryRaw = Array.isArray(sp.country) ? sp.country[0] : sp.country; | ||||
| 30 | + const country = countryRaw && /^[a-z0-9-]+$/i.test(countryRaw) ? countryRaw : undefined; | ||||
| 22 | let data: ChangesFeedResponse | null = null; | 31 | let data: ChangesFeedResponse | null = null; | ||
| 23 | try { | 32 | try { | ||
| 24 | − data = await apiExplore.changes({ limit: 100 }); | 33 | + data = await apiExplore.changes({ limit: 100, country }); | ||
| 25 | } catch (e) { | 34 | } catch (e) { | ||
| 26 | if (!isNotBuilt(e)) throw e; | 35 | if (!isNotBuilt(e)) throw e; | ||
| 27 | } | 36 | } | ||
| 37 | + const winRaw = Array.isArray(sp.window) ? sp.window[0] : sp.window; | ||||
| 38 | + const win: 5 | 10 = winRaw === '10' ? 10 : 5; | ||||
| 39 | + const movers = await safe(apiAnalytics.movers({ window: win, limit: 12 })); | ||||
| 28 | return ( | 40 | return ( | ||
| 29 | <> | 41 | <> | ||
| 30 | <PageHeader title={t('changes.title')} lede={t('changes.sub')} meta={data?.meta.built_at ? t('site.footer.refreshed', { date: formatDate(data.meta.built_at) }) : undefined} /> | 42 | <PageHeader title={t('changes.title')} lede={t('changes.sub')} meta={data?.meta.built_at ? t('site.footer.refreshed', { date: formatDate(data.meta.built_at) }) : undefined} /> | ||
| 43 | + {country ? ( | ||||
| 44 | + <p className="mb-3 text-sm text-ink-2"> | ||||
| 45 | + {t('changes.filteredCountry', { country })}{' '} | ||||
| 46 | + <Link href={routes.changes()} className="text-accent hover:underline"> | ||||
| 47 | + {t('changes.clearCountry')} | ||||
| 48 | + </Link> | ||||
| 49 | + </p> | ||||
| 50 | + ) : null} | ||||
| 31 | {!data ? ( | 51 | {!data ? ( | ||
| 32 | <NotBuiltState /> | 52 | <NotBuiltState /> | ||
| 33 | ) : ( | 53 | ) : ( | ||
| 34 | <> | 54 | <> | ||
| 55 | + <nav aria-label={t('changes.windows.nav')} className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2"> | ||||
| 56 | + <span>{t('changes.windows.nav')}</span> | ||||
| 57 | + <span className="inline-flex min-h-[36px] items-center rounded-sm bg-ink px-2 text-xs text-paper">{t('home.movers.window.1')}</span> | ||||
| 58 | + <Link href={`${routes.changes()}?window=5#windows`} className="inline-flex min-h-[36px] items-center text-accent hover:underline"> | ||||
| 59 | + {t('home.movers.window.5')} ↓ | ||||
| 60 | + </Link> | ||||
| 61 | + <Link href={`${routes.changes()}?window=10#windows`} className="inline-flex min-h-[36px] items-center text-accent hover:underline"> | ||||
| 62 | + {t('home.movers.window.10')} ↓ | ||||
| 63 | + </Link> | ||||
| 64 | + </nav> | ||||
| 35 | <details className="mb-2 border-y border-rule py-3 text-sm"> | 65 | <details className="mb-2 border-y border-rule py-3 text-sm"> | ||
| 36 | <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink"> | 66 | <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink"> | ||
| 37 | <span aria-hidden>›</span> {t('changes.methodology')} | 67 | <span aria-hidden>›</span> {t('changes.methodology')} | ||
@@ -44,6 +74,9 @@ export default async function ChangesPage() { | |||||
| 44 | </p> | 74 | </p> | ||
| 45 | </details> | 75 | </details> | ||
| 46 | <ChangesFeed initial={data.items} kinds={data.kinds ?? []} limit={100} /> | 76 | <ChangesFeed initial={data.items} kinds={data.kinds ?? []} limit={100} /> | ||
| 77 | + <Section id="windows" title={t('changes.windows.title')} subtitle={t('changes.windows.sub')}> | ||||
| 78 | + <Movers key={win} initial={movers} initialWindow={win} limit={12} compact /> | ||||
| 79 | + </Section> | ||||
| 47 | </> | 80 | </> | ||
| 48 | )} | 81 | )} | ||
| 49 | </> | 82 | </> | ||
modified
apps/web/src/app/compare/[...slugs]/page.tsx
+12 −5
@@ -7,7 +7,9 @@ import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitC | |||||
| 7 | import { routes } from '@/lib/site'; | 7 | import { routes } from '@/lib/site'; | ||
| 8 | import { topicById } from '@/lib/topics'; | 8 | import { topicById } from '@/lib/topics'; | ||
| 9 | import type { CountrySummary, IndicatorCard } from '@/lib/types'; | 9 | import type { CountrySummary, IndicatorCard } from '@/lib/types'; | ||
| 10 | −import { toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare'; | 10 | +import { apiModeOf, toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare'; | ||
| 11 | +import { seoTitle } from '@/lib/seo'; | ||||
| 12 | +import { HeadToHead } from '@/components/compare/head-to-head'; | ||||
| 11 | import { CompareChart } from '@/components/compare/compare-chart'; | 13 | import { CompareChart } from '@/components/compare/compare-chart'; | ||
| 12 | import { ChartGrid, ChartSections } from '@/components/compare/chart-sections'; | 14 | import { ChartGrid, ChartSections } from '@/components/compare/chart-sections'; | ||
| 13 | import { CompareControls } from '@/components/compare/compare-controls'; | 15 | import { CompareControls } from '@/components/compare/compare-controls'; | ||
@@ -59,7 +61,7 @@ export async function generateMetadata({ params, searchParams }: { params: Promi | |||||
| 59 | const state = parseCompareState(sp); | 61 | const state = parseCompareState(sp); | ||
| 60 | const names = namesOf(r.countries); | 62 | const names = namesOf(r.countries); | ||
| 61 | const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab; | 63 | const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab; | ||
| 62 | − const title = tabName ? `${names} — ${tabName} — ${t('compare.title')}` : t('compare.pageTitle', { names }); | 64 | + const title = tabName ? `${seoTitle.compare(r.countries.map((c) => c.name ?? c.id))} — ${tabName}` : seoTitle.compare(r.countries.map((c) => c.name ?? c.id)); | ||
| 63 | const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' }); | 65 | const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' }); | ||
| 64 | const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`; | 66 | const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`; | ||
| 65 | return { | 67 | return { | ||
@@ -92,7 +94,7 @@ export default async function CompareViewPage({ params, searchParams }: { params | |||||
| 92 | const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null; | 94 | const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null; | ||
| 93 | const customSlugs = state.tab === 'custom' ? state.indicators : []; | 95 | const customSlugs = state.tab === 'custom' ? state.indicators : []; | ||
| 94 | const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic })); | 96 | const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic })); | ||
| 95 | − const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: state.mode })) : Promise.resolve(null); | 97 | + const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: apiModeOf(state.mode) })) : Promise.resolve(null); | ||
| 96 | const [snapshot, hero] = await Promise.all([snapshotP, heroP]); | 98 | const [snapshot, hero] = await Promise.all([snapshotP, heroP]); | ||
| 97 | 99 | ||||
| 98 | const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data)); | 100 | const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data)); | ||
@@ -106,7 +108,7 @@ export default async function CompareViewPage({ params, searchParams }: { params | |||||
| 106 | .filter((s) => s !== state.indicator) | 108 | .filter((s) => s !== state.indicator) | ||
| 107 | .slice(0, state.tab === 'custom' ? 6 : EAGER); | 109 | .slice(0, state.tab === 'custom' ? 6 : EAGER); | ||
| 108 | if (eagerSlugs.length) { | 110 | if (eagerSlugs.length) { | ||
| 109 | − const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: state.mode })); | 111 | + const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: apiModeOf(state.mode) })); | ||
| 110 | if (bundle) { | 112 | if (bundle) { | ||
| 111 | eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)])); | 113 | eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)])); | ||
| 112 | } | 114 | } | ||
@@ -140,8 +142,13 @@ export default async function CompareViewPage({ params, searchParams }: { params | |||||
| 140 | 142 | ||||
| 141 | <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} /> | 143 | <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} /> | ||
| 142 | 144 | ||||
| 145 | + {state.tab === 'snapshot' && countries.length === 2 ? ( | ||||
| 146 | + <Section id="h2h" title={t('compare.h2h.title')} subtitle={t('compare.h2h.sub')} className="border-t-0"> | ||||
| 147 | + <HeadToHead rows={rows} countries={countries} slugs={slugs} state={state} /> | ||||
| 148 | + </Section> | ||||
| 149 | + ) : null} | ||||
| 143 | {state.tab === 'snapshot' ? ( | 150 | {state.tab === 'snapshot' ? ( | ||
| 144 | − <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className="border-t-0"> | 151 | + <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className={countries.length === 2 ? undefined : 'border-t-0'}> | ||
| 145 | <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} /> | 152 | <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} /> | ||
| 146 | </Section> | 153 | </Section> | ||
| 147 | ) : null} | 154 | ) : null} | ||
modified
apps/web/src/app/countries/[slug]/page.tsx
+41 −28
@@ -3,12 +3,16 @@ import Link from 'next/link'; | |||||
| 3 | import { notFound } from 'next/navigation'; | 3 | import { notFound } from 'next/navigation'; | ||
| 4 | import { t } from '@/i18n'; | 4 | import { t } from '@/i18n'; | ||
| 5 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | 5 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | ||
| 6 | −import { routes } from '@/lib/site'; | 6 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||
| 7 | +import { apiExplore } from '@/lib/api-explore'; | ||||
| 7 | import { regionShort } from '@/lib/regions'; | 8 | import { regionShort } from '@/lib/regions'; | ||
| 9 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | ||||
| 10 | +import { routes } from '@/lib/site'; | ||||
| 8 | import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics'; | 11 | import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics'; | ||
| 9 | −import type { CountryResponse } from '@/lib/types'; | 12 | +import type { CountryResponse, FormatSpec } from '@/lib/types'; | ||
| 10 | −import { DnaRadial } from '@/components/charts/dna-radial'; | ||||
| 11 | import { CountryHeader } from '@/components/country/country-header'; | 13 | import { CountryHeader } from '@/components/country/country-header'; | ||
| 14 | +import { CountryStory } from '@/components/country/country-story'; | ||||
| 15 | +import { DnaPanel } from '@/components/country/dna-panel'; | ||||
| 12 | import { KeyFacts } from '@/components/country/key-facts'; | 16 | import { KeyFacts } from '@/components/country/key-facts'; | ||
| 13 | import { SimilarPanel } from '@/components/country/similar-panel'; | 17 | import { SimilarPanel } from '@/components/country/similar-panel'; | ||
| 14 | import { Timeline } from '@/components/country/timeline'; | 18 | import { Timeline } from '@/components/country/timeline'; | ||
@@ -38,14 +42,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | |||||
| 38 | const data = await loadCountry(slug); | 42 | const data = await loadCountry(slug); | ||
| 39 | if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } }; | 43 | if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } }; | ||
| 40 | const name = data.country.name ?? slug; | 44 | const name = data.country.name ?? slug; | ||
| 41 | − const title = t('country.title', { name }); | 45 | + const title = seoTitle.country(name); | ||
| 42 | const description = t('country.description', { name }); | 46 | const description = t('country.description', { name }); | ||
| 43 | const canonical = routes.country(data.country.slug ?? slug); | 47 | const canonical = routes.country(data.country.slug ?? slug); | ||
| 44 | return { | 48 | return { | ||
| 45 | − title, | 49 | + title: { absolute: `${title} | ${t('site.name')}` }, | ||
| 46 | description, | 50 | description, | ||
| 47 | alternates: { canonical }, | 51 | alternates: { canonical }, | ||
| 48 | − openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, | 52 | + openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, | ||
| 49 | twitter: { card: 'summary_large_image', title, description }, | 53 | twitter: { card: 'summary_large_image', title, description }, | ||
| 50 | }; | 54 | }; | ||
| 51 | } | 55 | } | ||
@@ -61,19 +65,25 @@ export default async function CountryPage({ params }: { params: Promise<Params> | |||||
| 61 | const name = c.name ?? id; | 65 | const name = c.name ?? id; | ||
| 62 | const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag }; | 66 | const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag }; | ||
| 63 | // Optional panels in parallel; each tolerates failure independently. | 67 | // Optional panels in parallel; each tolerates failure independently. | ||
| 64 | − const [changes, similar, insights, dna, events] = await Promise.all([ | 68 | + const [changes, similar, insights, dna, events, story, quality, indicators] = await Promise.all([ | ||
| 65 | safe(api.countryChanges(id, 8)), | 69 | safe(api.countryChanges(id, 8)), | ||
| 66 | safe(api.countrySimilar(id, 'overall', 8)), | 70 | safe(api.countrySimilar(id, 'overall', 8)), | ||
| 67 | safe(api.countryInsights(id)), | 71 | safe(api.countryInsights(id)), | ||
| 68 | safe(api.countryDna(id)), | 72 | safe(api.countryDna(id)), | ||
| 69 | − safe(api.countryEvents(id, 30)), | 73 | + safe(api.countryEvents(id, 120)), | ||
| 74 | + safe(apiAnalytics.countryStory(id)), | ||||
| 75 | + safe(apiAnalytics.countryQuality(id)), | ||||
| 76 | + safe(apiExplore.indicators({ with_data: true })), | ||||
| 70 | ]); | 77 | ]); | ||
| 71 | const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data])); | 78 | const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data])); | ||
| 72 | const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0); | 79 | const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0); | ||
| 80 | + const formats: Record<string, FormatSpec> = Object.fromEntries((indicators?.items ?? []).map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }])); | ||||
| 81 | + const ld = [jsonLd.country({ slug: c.slug ?? slug, name, iso3: c.iso3 ?? id, capital: c.capital }), jsonLd.breadcrumbs([{ name: t('nav.countries'), path: routes.countries() }, { name, path: routes.country(c.slug ?? slug) }])]; | ||||
| 73 | 82 | ||||
| 74 | return ( | 83 | return ( | ||
| 75 | <> | 84 | <> | ||
| 76 | − <CountryHeader data={data} /> | 85 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} /> | ||
| 86 | + <CountryHeader data={data} quality={quality} /> | ||||
| 77 | <TopicNav slug={c.slug ?? slug} counts={counts} /> | 87 | <TopicNav slug={c.slug ?? slug} counts={counts} /> | ||
| 78 | 88 | ||||
| 79 | <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0"> | 89 | <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0"> | ||
@@ -85,42 +95,45 @@ export default async function CountryPage({ params }: { params: Promise<Params> | |||||
| 85 | </MetricGrid> | 95 | </MetricGrid> | ||
| 86 | </Section> | 96 | </Section> | ||
| 87 | 97 | ||||
| 98 | + {story && story.items.length ? ( | ||||
| 99 | + <Section id="story" title={t('country.story.title', { name })} subtitle={t('country.story.sub', { since: story.since ?? '', n: story.items.length })}> | ||||
| 100 | + <CountryStory data={story} country={countryRef} /> | ||||
| 101 | + </Section> | ||||
| 102 | + ) : null} | ||||
| 103 | + | ||||
| 88 | <div className="grid gap-x-10 lg:grid-cols-2"> | 104 | <div className="grid gap-x-10 lg:grid-cols-2"> | ||
| 89 | − <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')}> | 105 | + <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')} actions={<Link href={`${routes.changes()}?country=${c.slug ?? id}`} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}> | ||
| 90 | <ChangeList items={changes?.items ?? []} /> | 106 | <ChangeList items={changes?.items ?? []} /> | ||
| 91 | </Section> | 107 | </Section> | ||
| 92 | <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}> | 108 | <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}> | ||
| 93 | − <SimilarPanel countryId={id} initial={similar} /> | 109 | + <SimilarPanel countryId={id} countrySlug={c.slug} countryName={name} initial={similar} formats={formats} /> | ||
| 94 | </Section> | 110 | </Section> | ||
| 95 | </div> | 111 | </div> | ||
| 96 | 112 | ||||
| 97 | <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | 113 | <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | ||
| 98 | <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}> | 114 | <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}> | ||
| 99 | − <DnaRadial dna={dna} name={name} size={360} /> | 115 | + <DnaPanel countryId={id} name={name} initial={dna} /> | ||
| 100 | − {dna?.year_ref ? <p className="tnum mt-2 text-center text-2xs text-ink-3">{dna.year_ref}</p> : null} | ||||
| 101 | </Section> | 116 | </Section> | ||
| 102 | <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}> | 117 | <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}> | ||
| 103 | <KeyFacts items={insights?.items ?? []} country={countryRef} /> | 118 | <KeyFacts items={insights?.items ?? []} country={countryRef} /> | ||
| 104 | − {data.neighbours.length ? ( | ||||
| 105 | − <p className="mt-4 text-sm text-ink-2"> | ||||
| 106 | − <span className="text-ink-3">{t('country.borders')}: </span> | ||||
| 107 | − {data.neighbours.map((n, i) => ( | ||||
| 108 | − <span key={n.id}> | ||||
| 109 | − {i > 0 ? ', ' : ''} | ||||
| 110 | − <Link href={routes.country(n.slug ?? n.id)} className="link-quiet text-ink hover:text-accent"> | ||||
| 111 | − <span aria-hidden>{n.flag} </span> | ||||
| 112 | − {n.name} | ||||
| 113 | − </Link> | ||||
| 114 | − </span> | ||||
| 115 | − ))} | ||||
| 116 | − </p> | ||||
| 117 | − ) : null} | ||||
| 118 | {c.languages?.length ? ( | 119 | {c.languages?.length ? ( | ||
| 119 | − <p className="mt-1 text-sm text-ink-2"> | 120 | + <p className="mt-4 text-sm text-ink-2"> | ||
| 120 | <span className="text-ink-3">{t('country.languages')}: </span> | 121 | <span className="text-ink-3">{t('country.languages')}: </span> | ||
| 121 | {c.languages.join(', ')} | 122 | {c.languages.join(', ')} | ||
| 122 | </p> | 123 | </p> | ||
| 123 | ) : null} | 124 | ) : null} | ||
| 125 | + {data.groups.length ? ( | ||||
| 126 | + <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-ink-2"> | ||||
| 127 | + <span className="text-ink-3">{t('country.memberOf')}: </span> | ||||
| 128 | + {data.groups | ||||
| 129 | + .filter((g) => g.kind === 'org') | ||||
| 130 | + .map((g) => ( | ||||
| 131 | + <Link key={g.id} href={routes.region(g.slug ?? g.id)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-ink hover:text-accent"> | ||||
| 132 | + {g.name} | ||||
| 133 | + </Link> | ||||
| 134 | + ))} | ||||
| 135 | + </p> | ||||
| 136 | + ) : null} | ||||
| 124 | </Section> | 137 | </Section> | ||
| 125 | </div> | 138 | </div> | ||
| 126 | 139 | ||||
modified
apps/web/src/app/indicators/[slug]/page.tsx
+134 −60
@@ -4,26 +4,34 @@ import Link from 'next/link'; | |||||
| 4 | import { notFound } from 'next/navigation'; | 4 | import { notFound } from 'next/navigation'; | ||
| 5 | import { t, tOpt } from '@/i18n'; | 5 | import { t, tOpt } from '@/i18n'; | ||
| 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | ||
| 7 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 7 | import { apiExplore } from '@/lib/api-explore'; | 8 | import { apiExplore } from '@/lib/api-explore'; | ||
| 8 | import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format'; | 9 | import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format'; | ||
| 10 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | ||||
| 9 | import { SITE_URL, routes } from '@/lib/site'; | 11 | import { SITE_URL, routes } from '@/lib/site'; | ||
| 10 | import { topicById } from '@/lib/topics'; | 12 | import { topicById } from '@/lib/topics'; | ||
| 11 | import type { CountrySummary, FormatSpec, Series } from '@/lib/types'; | 13 | import type { CountrySummary, FormatSpec, Series } from '@/lib/types'; | ||
| 14 | +import type { FramesResponse } from '@/lib/types-analytics'; | ||||
| 12 | import type { IndicatorResponse, RankedValue } from '@/lib/types-explore'; | 15 | import type { IndicatorResponse, RankedValue } from '@/lib/types-explore'; | ||
| 13 | −import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; | 16 | +import { RankRace } from '@/components/charts/rank-race'; | ||
| 17 | +import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars'; | ||||
| 14 | import { EmptyState, NotBuiltState } from '@/components/data/empty-state'; | 18 | import { EmptyState, NotBuiltState } from '@/components/data/empty-state'; | ||
| 15 | import type { ProvenancePayload } from '@/components/data/provenance-context'; | 19 | import type { ProvenancePayload } from '@/components/data/provenance-context'; | ||
| 20 | +import { QualityBadges } from '@/components/data/quality-badge'; | ||||
| 16 | import { Section } from '@/components/data/section'; | 21 | import { Section } from '@/components/data/section'; | ||
| 17 | import { CodeBlock } from '@/components/explore/copy-button'; | 22 | import { CodeBlock } from '@/components/explore/copy-button'; | ||
| 18 | import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; | 23 | import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; | ||
| 24 | +import { DistributionPanel } from '@/components/indicators/distribution-panel'; | ||||
| 19 | import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare'; | 25 | import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare'; | ||
| 20 | import { IndicatorMap } from '@/components/indicators/indicator-map'; | 26 | import { IndicatorMap } from '@/components/indicators/indicator-map'; | ||
| 21 | import { IndicatorTrend } from '@/components/indicators/indicator-trend'; | 27 | import { IndicatorTrend } from '@/components/indicators/indicator-trend'; | ||
| 22 | import { baseFeatures } from '@/components/indicators/map-geometry'; | 28 | import { baseFeatures } from '@/components/indicators/map-geometry'; | ||
| 29 | +import { RelatedTable } from '@/components/indicators/related-table'; | ||||
| 23 | 30 | ||||
| 24 | export const revalidate = 900; | 31 | export const revalidate = 900; | ||
| 25 | 32 | ||||
| 26 | type Params = { slug: string }; | 33 | type Params = { slug: string }; | ||
| 34 | +type SP = Record<string, string | string[] | undefined>; | ||||
| 27 | 35 | ||||
| 28 | async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> { | 36 | async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> { | ||
| 29 | try { | 37 | try { | ||
@@ -41,14 +49,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | |||||
| 41 | if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } }; | 49 | if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } }; | ||
| 42 | const ind = data.indicator; | 50 | const ind = data.indicator; | ||
| 43 | const name = titleCase(ind.short_name ?? ind.name ?? slug); | 51 | const name = titleCase(ind.short_name ?? ind.name ?? slug); | ||
| 44 | − const title = t('indicator.title', { name }); | 52 | + const title = seoTitle.indicator(name); | ||
| 45 | const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' }); | 53 | const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' }); | ||
| 46 | const canonical = routes.indicator(ind.slug); | 54 | const canonical = routes.indicator(ind.slug); | ||
| 47 | return { | 55 | return { | ||
| 48 | − title, | 56 | + title: { absolute: `${title} | ${t('site.name')}` }, | ||
| 49 | description, | 57 | description, | ||
| 50 | alternates: { canonical }, | 58 | alternates: { canonical }, | ||
| 51 | − openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, | 59 | + openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, | ||
| 52 | twitter: { card: 'summary_large_image', title, description }, | 60 | twitter: { card: 'summary_large_image', title, description }, | ||
| 53 | }; | 61 | }; | ||
| 54 | } | 62 | } | ||
@@ -66,8 +74,41 @@ function specOf(ind: IndicatorResponse['indicator']): FormatSpec { | |||||
| 66 | return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; | 74 | return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; | ||
| 67 | } | 75 | } | ||
| 68 | 76 | ||||
| 69 | −export default async function IndicatorPage({ params }: { params: Promise<Params> }) { | 77 | +/** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */ | ||
| 70 | − const { slug } = await params; | 78 | +function decadeMovers(frames: FramesResponse | null, countries: Map<string, CountrySummary>, hib: boolean | null | undefined, relative: boolean): { up: RankedBarRow[]; down: RankedBarRow[]; from: number; to: number } | null { | ||
| 79 | + if (!frames || frames.years.length < 11) return null; | ||||
| 80 | + const years = frames.years; | ||||
| 81 | + const toIdx = years.length - 1; | ||||
| 82 | + const to = years[toIdx]!; | ||||
| 83 | + let fromIdx = years.findIndex((y) => y >= to - 10); | ||||
| 84 | + if (fromIdx < 0 || fromIdx === toIdx) return null; | ||||
| 85 | + if (years[fromIdx]! > to - 8) fromIdx = Math.max(0, fromIdx - 1); | ||||
| 86 | + const from = years[fromIdx]!; | ||||
| 87 | + const rows: Array<{ id: string; delta: number; v0: number; v1: number }> = []; | ||||
| 88 | + for (const [iso, arr] of Object.entries(frames.values)) { | ||||
| 89 | + const v1 = arr[toIdx]; | ||||
| 90 | + const v0 = arr[fromIdx]; | ||||
| 91 | + if (typeof v1 !== 'number' || typeof v0 !== 'number') continue; | ||||
| 92 | + const pop = countries.get(iso)?.population_latest ?? 0; | ||||
| 93 | + if (pop < 1_000_000) continue; | ||||
| 94 | + const delta = relative ? (v0 !== 0 ? ((v1 - v0) / Math.abs(v0)) * 100 : NaN) : v1 - v0; | ||||
| 95 | + if (!Number.isFinite(delta)) continue; | ||||
| 96 | + rows.push({ id: iso, delta, v0, v1 }); | ||||
| 97 | + } | ||||
| 98 | + if (rows.length < 10) return null; | ||||
| 99 | + const sorted = [...rows].sort((a, b) => b.delta - a.delta); | ||||
| 100 | + const mk = (r: { id: string; delta: number; v0: number; v1: number }, i: number): RankedBarRow => { | ||||
| 101 | + const c = countries.get(r.id); | ||||
| 102 | + return { id: r.id, label: c?.name ?? r.id, flag: c?.flag ?? null, href: c?.slug ? routes.country(c.slug) : null, value: r.delta, rank: i + 1 }; | ||||
| 103 | + }; | ||||
| 104 | + const up = sorted.slice(0, 6).map(mk); | ||||
| 105 | + const down = sorted.slice(-6).reverse().map(mk); | ||||
| 106 | + // Direction semantics: when higher is better, "up" is the improvement list; when lower is better, swap. | ||||
| 107 | + return { up: hib === false ? down : up, down: hib === false ? up : down, from, to }; | ||||
| 108 | +} | ||||
| 109 | + | ||||
| 110 | +export default async function IndicatorPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) { | ||||
| 111 | + const [{ slug }, sp] = await Promise.all([params, searchParams]); | ||||
| 71 | const data = await load(slug); | 112 | const data = await load(slug); | ||
| 72 | if (data === null) notFound(); | 113 | if (data === null) notFound(); | ||
| 73 | if (data === 'not-built') return <NotBuiltState />; | 114 | if (data === 'not-built') return <NotBuiltState />; | ||
@@ -76,32 +117,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 76 | const name = ind.name ?? ind.slug; | 117 | const name = ind.name ?? ind.slug; | ||
| 77 | const spec = specOf(ind); | 118 | const spec = specOf(ind); | ||
| 78 | const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null; | 119 | const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null; | ||
| 120 | + const highlight = typeof sp.country === 'string' && /^[a-z0-9-]+$/i.test(sp.country) ? sp.country : null; | ||||
| 79 | 121 | ||||
| 80 | − const [countriesRes, map, trend, related] = await Promise.all([ | 122 | + const [countriesRes, frames, trend, related, quality, distribution, race] = await Promise.all([ | ||
| 81 | safe(api.countries()), | 123 | safe(api.countries()), | ||
| 82 | − safe(api.indicatorMap(ind.slug, yearUsed ? { year: yearUsed } : {})), | 124 | + safe(apiAnalytics.indicatorFrames(ind.slug)), | ||
| 83 | safe(apiExplore.indicatorTrend(ind.slug, 'world')), | 125 | safe(apiExplore.indicatorTrend(ind.slug, 'world')), | ||
| 84 | − ind.topic ? safe(apiExplore.indicators({ topic: ind.topic })) : Promise.resolve(null), | 126 | + safe(apiAnalytics.indicatorRelated(ind.slug, { limit: 10 })), | ||
| 127 | + safe(apiAnalytics.indicatorQuality(ind.slug)), | ||||
| 128 | + safe(apiAnalytics.indicatorDistribution(ind.slug, { highlight })), | ||||
| 129 | + ind.ranking_eligible !== false ? safe(apiAnalytics.race(ind.slug, { top: 10 })) : Promise.resolve(null), | ||||
| 85 | ]); | 130 | ]); | ||
| 86 | const countries = countriesRes?.items ?? []; | 131 | const countries = countriesRes?.items ?? []; | ||
| 87 | const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country'); | 132 | const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country'); | ||
| 133 | + const byId = new Map(countries.map((c) => [c.id, c])); | ||||
| 88 | 134 | ||||
| 89 | // Default comparison: the 3 largest economies that have data for this indicator. | 135 | // Default comparison: the 3 largest economies that have data for this indicator. | ||
| 136 | + const lastIdx = frames ? frames.years.length - 1 : -1; | ||||
| 137 | + const hasValue = (id: string) => (frames && lastIdx >= 0 ? typeof frames.values[id]?.[lastIdx] === 'number' : false); | ||||
| 90 | const defaults: CompareCountry[] = [...onlyCountries] | 138 | const defaults: CompareCountry[] = [...onlyCountries] | ||
| 91 | − .filter((c) => isNum(c.gdp_latest) && map && map.values[c.id] != null) | 139 | + .filter((c) => isNum(c.gdp_latest) && hasValue(c.id)) | ||
| 92 | .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0)) | 140 | .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0)) | ||
| 93 | .slice(0, 3) | 141 | .slice(0, 3) | ||
| 94 | .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag })); | 142 | .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag })); | ||
| 95 | const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null; | 143 | const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null; | ||
| 96 | const initialSeries: Series[] = bundle?.series ?? []; | 144 | const initialSeries: Series[] = bundle?.series ?? []; | ||
| 97 | − | ||||
| 98 | − const years = data.coverage.by_year.map((y) => y.year); | ||||
| 99 | const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; | 145 | const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; | ||
| 100 | 146 | ||||
| 101 | const wl = data.world_latest; | 147 | const wl = data.world_latest; | ||
| 102 | const worldPayload: ProvenancePayload = { | 148 | const worldPayload: ProvenancePayload = { | ||
| 103 | indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description }, | 149 | indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description }, | ||
| 104 | − value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: map?.provenance ?? data.top5[0]?.provenance ?? null } : null, | 150 | + value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: frames?.provenance ?? data.top5[0]?.provenance ?? null } : null, | ||
| 105 | country: null, | 151 | country: null, | ||
| 106 | downloadHref: routes.indicatorDownload(ind.slug), | 152 | downloadHref: routes.indicatorDownload(ind.slug), | ||
| 107 | }; | 153 | }; | ||
@@ -110,28 +156,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 110 | const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best'); | 156 | const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best'); | ||
| 111 | const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst'); | 157 | const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst'); | ||
| 112 | const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank)); | 158 | const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank)); | ||
| 159 | + const relative = ['currency', 'number', 'tonnes', 'kwh'].includes(ind.format ?? ''); | ||||
| 160 | + const movers = decadeMovers(frames, byId, hib, relative); | ||||
| 161 | + const deltaSpec: FormatSpec = relative ? { format: 'percent', precision: 0, unit: '%' } : { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision }; | ||||
| 113 | 162 | ||||
| 114 | − // Countries without data for the map year. | 163 | + const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : []; | ||
| 115 | − const missing: CountrySummary[] = map ? onlyCountries.filter((c) => map.values[c.id] == null).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : []; | ||||
| 116 | − const relatedRows = (related?.items ?? []).filter((i) => i.slug !== ind.slug && (!ind.subtopic || i.subtopic === ind.subtopic)).slice(0, 8); | ||||
| 117 | const topic = topicById(ind.topic ?? ''); | 164 | const topic = topicById(ind.topic ?? ''); | ||
| 118 | const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`; | 165 | const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`; | ||
| 119 | − | ||||
| 120 | const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A'); | 166 | const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A'); | ||
| 121 | const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null; | 167 | const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null; | ||
| 168 | + const ld = [ | ||||
| 169 | + jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, description: ind.description, unit: ind.unit, firstYear: data.years.first, lastYear: data.years.last_actual, nCountries: data.coverage.n_countries, sources: data.sources.map((s) => ({ name: s.source_name, url: s.url, licence: s.licence })), modified: data.meta.built_at }), | ||||
| 170 | + jsonLd.breadcrumbs([{ name: t('indicators.title'), path: routes.indicators() }, ...(topic ? [{ name: topic.short, path: routes.indicators(topic.id) }] : []), { name: ind.short_name ?? name, path: routes.indicator(ind.slug) }]), | ||||
| 171 | + ]; | ||||
| 122 | 172 | ||||
| 123 | return ( | 173 | return ( | ||
| 124 | <> | 174 | <> | ||
| 175 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} /> | ||||
| 125 | <PageHeader | 176 | <PageHeader | ||
| 126 | crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]} | 177 | crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]} | ||
| 127 | eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')} | 178 | eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')} | ||
| 128 | title={name} | 179 | title={name} | ||
| 129 | lede={ind.description} | 180 | lede={ind.description} | ||
| 130 | meta={ | 181 | meta={ | ||
| 131 | − <> | 182 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-1"> | ||
| 132 | − {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })} | 183 | + <span> | ||
| 133 | − {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''} | 184 | + {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })} | ||
| 134 | − </> | 185 | + {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''} | ||
| 186 | + {data.sources[0]?.source_name ? ` · ${data.sources[0].source_name}` : ''} | ||||
| 187 | + </span> | ||||
| 188 | + <QualityBadges badges={quality?.badges ?? null} /> | ||||
| 189 | + </span> | ||||
| 135 | } | 190 | } | ||
| 136 | actions={ | 191 | actions={ | ||
| 137 | <> | 192 | <> | ||
@@ -140,6 +195,9 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 140 | {t('common.ranking')} | 195 | {t('common.ranking')} | ||
| 141 | </Link> | 196 | </Link> | ||
| 142 | ) : null} | 197 | ) : null} | ||
| 198 | + <Link href={routes.explore({ indicator: ind.slug })} className={ACTION_CLS}> | ||||
| 199 | + {t('nav.explore')} | ||||
| 200 | + </Link> | ||||
| 143 | <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}> | 201 | <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}> | ||
| 144 | <Download size={14} aria-hidden /> {t('common.downloadCsv')} | 202 | <Download size={14} aria-hidden /> {t('common.downloadCsv')} | ||
| 145 | </a> | 203 | </a> | ||
@@ -147,25 +205,27 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 147 | } | 205 | } | ||
| 148 | /> | 206 | /> | ||
| 149 | 207 | ||||
| 150 | − {/* World figure + freshness — three lines, per spec */} | 208 | + {/* World figure + freshness */} | ||
| 151 | <section aria-label={t('indicator.freshness')} className="border-y border-rule"> | 209 | <section aria-label={t('indicator.freshness')} className="border-y border-rule"> | ||
| 152 | <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule"> | 210 | <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule"> | ||
| 153 | <div className="col-span-2 sm:col-span-1 lg:pr-4"> | 211 | <div className="col-span-2 sm:col-span-1 lg:pr-4"> | ||
| 154 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{worldKindLabel ?? t('indicator.world.median')}</dt> | 212 | + <dt className="eyebrow">{worldKindLabel ?? t('indicator.world.median')}</dt> | ||
| 155 | <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd> | 213 | <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd> | ||
| 156 | {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null} | 214 | {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null} | ||
| 157 | </div> | 215 | </div> | ||
| 158 | <div className="lg:px-4"> | 216 | <div className="lg:px-4"> | ||
| 159 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.latest')}</dt> | 217 | + <dt className="eyebrow">{t('indicator.freshness.latest')}</dt> | ||
| 160 | <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd> | 218 | <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd> | ||
| 161 | {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null} | 219 | {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null} | ||
| 220 | + {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.years', { n: quality.years_with_50plus })}</dd> : null} | ||||
| 162 | </div> | 221 | </div> | ||
| 163 | <div className="lg:px-4"> | 222 | <div className="lg:px-4"> | ||
| 164 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.source')}</dt> | 223 | + <dt className="eyebrow">{t('indicator.freshness.source')}</dt> | ||
| 165 | <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd> | 224 | <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd> | ||
| 225 | + {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.flagged', { n: grouped(quality.flagged_values) })}</dd> : null} | ||||
| 166 | </div> | 226 | </div> | ||
| 167 | <div className="lg:pl-4"> | 227 | <div className="lg:pl-4"> | ||
| 168 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.refreshed')}</dt> | 228 | + <dt className="eyebrow">{t('indicator.freshness.refreshed')}</dt> | ||
| 169 | <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd> | 229 | <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd> | ||
| 170 | {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null} | 230 | {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null} | ||
| 171 | </div> | 231 | </div> | ||
@@ -173,11 +233,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 173 | </section> | 233 | </section> | ||
| 174 | 234 | ||||
| 175 | <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0"> | 235 | <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0"> | ||
| 176 | − {map && features.length ? ( | 236 | + {features.length ? <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} frames={frames} spec={spec} payload={worldPayload} initialYear={yearUsed} /> : <EmptyState title={t('common.noDataLong')} />} | ||
| 177 | − <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} initial={map} years={years.length ? years : [map.year_used ?? 0]} spec={spec} payload={worldPayload} /> | ||||
| 178 | − ) : ( | ||||
| 179 | − <EmptyState title={t('common.noDataLong')} /> | ||||
| 180 | − )} | ||||
| 181 | </Section> | 237 | </Section> | ||
| 182 | 238 | ||||
| 183 | <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}> | 239 | <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}> | ||
@@ -203,42 +259,78 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 203 | )} | 259 | )} | ||
| 204 | </Section> | 260 | </Section> | ||
| 205 | 261 | ||||
| 262 | + {movers ? ( | ||||
| 263 | + <Section id="movers" title={t('indicator.movers.title', { y0: movers.from, y1: movers.to })} subtitle={hib == null ? t('indicator.movers.subNeutral') : t('indicator.movers.sub')}> | ||||
| 264 | + <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2"> | ||||
| 265 | + <div> | ||||
| 266 | + <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink"> | ||||
| 267 | + <span aria-hidden className={hib == null ? 'text-inc' : 'text-up'}>↑</span> {hib == null ? t('indicator.movers.largestIncrease') : t('indicator.movers.fastestImproving')} | ||||
| 268 | + </h3> | ||||
| 269 | + <RankedBars rows={movers.up} spec={deltaSpec} showRank={false} /> | ||||
| 270 | + </div> | ||||
| 271 | + <div> | ||||
| 272 | + <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink"> | ||||
| 273 | + <span aria-hidden className={hib == null ? 'text-dec' : 'text-down'}>↓</span> {hib == null ? t('indicator.movers.largestDecrease') : t('indicator.movers.fastestDeclining')} | ||||
| 274 | + </h3> | ||||
| 275 | + <RankedBars rows={movers.down} spec={deltaSpec} showRank={false} /> | ||||
| 276 | + </div> | ||||
| 277 | + </div> | ||||
| 278 | + <p className="mt-3 text-xs text-ink-3">{t('indicator.movers.note', { y0: movers.from, y1: movers.to, kind: relative ? t('indicator.movers.relative') : t('indicator.movers.absolute') })}</p> | ||||
| 279 | + </Section> | ||||
| 280 | + ) : null} | ||||
| 281 | + | ||||
| 282 | + {distribution && distribution.n >= 10 ? ( | ||||
| 283 | + <Section id="distribution" title={t('indicator.dist.section')} subtitle={t('indicator.dist.sectionSub')}> | ||||
| 284 | + <DistributionPanel data={distribution} spec={spec} /> | ||||
| 285 | + </Section> | ||||
| 286 | + ) : null} | ||||
| 287 | + | ||||
| 288 | + {race && race.frames.length >= 20 ? ( | ||||
| 289 | + <Section id="race" title={t('indicator.race.title')} subtitle={t('ranking.race.sub', { name: spec.name ?? name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}> | ||||
| 290 | + <RankRace data={race} spec={spec} top={10} initialYear={yearUsed} /> | ||||
| 291 | + </Section> | ||||
| 292 | + ) : null} | ||||
| 293 | + | ||||
| 206 | <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}> | 294 | <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}> | ||
| 207 | <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} /> | 295 | <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} /> | ||
| 208 | </Section> | 296 | </Section> | ||
| 209 | 297 | ||||
| 298 | + <Section id="related" title={t('indicator.related.statTitle')} subtitle={t('indicator.related.statSub')} actions={<Link href={routes.scatter({ x: ind.slug })} className="text-accent hover:underline">{t('indicator.related.openScatter')} →</Link>}> | ||||
| 299 | + {related ? <RelatedTable data={related} slug={ind.slug} /> : <p className="text-sm text-ink-3">{t('indicator.related.noneStat')}</p>} | ||||
| 300 | + </Section> | ||||
| 301 | + | ||||
| 210 | <Section id="definition" title={t('indicator.definition')} level={2}> | 302 | <Section id="definition" title={t('indicator.definition')} level={2}> | ||
| 211 | <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> | 303 | <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> | ||
| 212 | <div> | 304 | <div> | ||
| 213 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.definition')}</dt> | 305 | + <dt className="eyebrow">{t('indicator.definition')}</dt> | ||
| 214 | <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd> | 306 | <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd> | ||
| 215 | </div> | 307 | </div> | ||
| 216 | {ind.methodology ? ( | 308 | {ind.methodology ? ( | ||
| 217 | <div> | 309 | <div> | ||
| 218 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.methodology')}</dt> | 310 | + <dt className="eyebrow">{t('indicator.methodology')}</dt> | ||
| 219 | <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd> | 311 | <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd> | ||
| 220 | </div> | 312 | </div> | ||
| 221 | ) : null} | 313 | ) : null} | ||
| 222 | <div> | 314 | <div> | ||
| 223 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.unit')}</dt> | 315 | + <dt className="eyebrow">{t('indicator.unit')}</dt> | ||
| 224 | <dd className="mt-0.5 text-ink"> | 316 | <dd className="mt-0.5 text-ink"> | ||
| 225 | {ind.unit ?? t('common.na')} | 317 | {ind.unit ?? t('common.na')} | ||
| 226 | {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null} | 318 | {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null} | ||
| 227 | </dd> | 319 | </dd> | ||
| 228 | </div> | 320 | </div> | ||
| 229 | <div> | 321 | <div> | ||
| 230 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.frequency')}</dt> | 322 | + <dt className="eyebrow">{t('indicator.frequency')}</dt> | ||
| 231 | <dd className="mt-0.5 text-ink">{freqLabel}</dd> | 323 | <dd className="mt-0.5 text-ink">{freqLabel}</dd> | ||
| 232 | </div> | 324 | </div> | ||
| 233 | <div> | 325 | <div> | ||
| 234 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.aggregation')}</dt> | 326 | + <dt className="eyebrow">{t('indicator.aggregation')}</dt> | ||
| 235 | <dd className="mt-0.5 text-ink"> | 327 | <dd className="mt-0.5 text-ink"> | ||
| 236 | {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')} | 328 | {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')} | ||
| 237 | </dd> | 329 | </dd> | ||
| 238 | </div> | 330 | </div> | ||
| 239 | {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? ( | 331 | {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? ( | ||
| 240 | <div> | 332 | <div> | ||
| 241 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.bounds')}</dt> | 333 | + <dt className="eyebrow">{t('indicator.bounds')}</dt> | ||
| 242 | <dd className="tnum mt-0.5 text-ink"> | 334 | <dd className="tnum mt-0.5 text-ink"> | ||
| 243 | {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'} | 335 | {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'} | ||
| 244 | </dd> | 336 | </dd> | ||
@@ -260,7 +352,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 260 | {s.source_name ?? s.source_id} | 352 | {s.source_name ?? s.source_id} | ||
| 261 | </Link> | 353 | </Link> | ||
| 262 | <span className="text-ink-3">{s.dataset}</span> | 354 | <span className="text-ink-3">{s.dataset}</span> | ||
| 263 | − <code className="rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code> | 355 | + <code className="break-all rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code> | ||
| 264 | {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null} | 356 | {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null} | ||
| 265 | </div> | 357 | </div> | ||
| 266 | <div className="tnum mt-0.5 text-xs text-ink-3"> | 358 | <div className="tnum mt-0.5 text-xs text-ink-3"> | ||
@@ -285,7 +377,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 285 | </Section> | 377 | </Section> | ||
| 286 | 378 | ||||
| 287 | <div className="grid gap-x-10 lg:grid-cols-2"> | 379 | <div className="grid gap-x-10 lg:grid-cols-2"> | ||
| 288 | − <Section id="no-data" title={t('indicator.nodata.title')} subtitle={map && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: map.year_used ?? yearUsed }) : t('indicator.nodata.none', { year: map.year_used ?? yearUsed })) : undefined}> | 380 | + <Section id="no-data" title={t('indicator.nodata.title')} subtitle={frames && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: frames.years[lastIdx] ?? yearUsed }) : t('indicator.nodata.none', { year: frames.years[lastIdx] ?? yearUsed })) : undefined}> | ||
| 289 | {missing.length ? ( | 381 | {missing.length ? ( | ||
| 290 | <> | 382 | <> | ||
| 291 | <ul className="flex flex-wrap gap-1.5 text-xs"> | 383 | <ul className="flex flex-wrap gap-1.5 text-xs"> | ||
@@ -313,6 +405,9 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 313 | <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}> | 405 | <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}> | ||
| 314 | <Download size={14} aria-hidden /> {t('indicator.downloads.json')} | 406 | <Download size={14} aria-hidden /> {t('indicator.downloads.json')} | ||
| 315 | </a> | 407 | </a> | ||
| 408 | + <Link href={routes.download({ indicators: ind.slug })} className={ACTION_CLS}> | ||||
| 409 | + {t('indicator.downloads.builder')} | ||||
| 410 | + </Link> | ||||
| 316 | </div> | 411 | </div> | ||
| 317 | <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3> | 412 | <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3> | ||
| 318 | <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p> | 413 | <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p> | ||
@@ -324,27 +419,6 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | |||||
| 324 | </p> | 419 | </p> | ||
| 325 | </Section> | 420 | </Section> | ||
| 326 | </div> | 421 | </div> | ||
| 327 | − | ||||
| 328 | − <Section id="related" title={t('indicator.related.title')} subtitle={ind.subtopic ? t('indicator.related.sub', { subtopic: ind.subtopic }) : undefined}> | ||||
| 329 | − {relatedRows.length === 0 ? ( | ||||
| 330 | − <p className="text-sm text-ink-3">{t('indicator.related.none')}</p> | ||||
| 331 | − ) : ( | ||||
| 332 | − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | ||||
| 333 | − {relatedRows.map((r) => ( | ||||
| 334 | − <li key={r.slug} className="border-t border-rule"> | ||||
| 335 | − <Link href={routes.indicator(r.slug)} className="group flex min-h-[56px] flex-col justify-center py-2"> | ||||
| 336 | − <span className="text-sm text-ink group-hover:text-accent">{r.name}</span> | ||||
| 337 | − <span className="tnum text-xs text-ink-3"> | ||||
| 338 | − {r.unit} | ||||
| 339 | − {r.n_countries != null ? ` · ${t('indicators.coverageShort', { n: grouped(r.n_countries) })}` : ''} | ||||
| 340 | − {r.last_year ? ` · → ${r.last_year}` : ''} | ||||
| 341 | − </span> | ||||
| 342 | − </Link> | ||||
| 343 | − </li> | ||||
| 344 | − ))} | ||||
| 345 | − </ul> | ||||
| 346 | − )} | ||||
| 347 | − </Section> | ||||
| 348 | </> | 422 | </> | ||
| 349 | ); | 423 | ); | ||
| 350 | } | 424 | } | ||
added
apps/web/src/app/peers/page.tsx
+49 −0
@@ -0,0 +1,49 @@ | |||||
| 1 | +import type { Metadata } from 'next'; | ||||
| 2 | +import { Suspense } from 'react'; | ||||
| 3 | +import { t } from '@/i18n'; | ||||
| 4 | +import { isNotBuilt } from '@/lib/api'; | ||||
| 5 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 6 | +import { routes } from '@/lib/site'; | ||||
| 7 | +import type { PeersResponse } from '@/lib/types-analytics'; | ||||
| 8 | +import { NotBuiltState } from '@/components/data/empty-state'; | ||||
| 9 | +import { PageHeader } from '@/components/explore/page-header'; | ||||
| 10 | +import { PeersView } from '@/components/peers/peers-view'; | ||||
| 11 | + | ||||
| 12 | +export const revalidate = 900; | ||||
| 13 | +type SP = Record<string, string | string[] | undefined>; | ||||
| 14 | + | ||||
| 15 | +function slug(v: string | string[] | undefined): string | undefined { | ||||
| 16 | + const s = (Array.isArray(v) ? v[0] : v) ?? ''; | ||||
| 17 | + return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s : undefined; | ||||
| 18 | +} | ||||
| 19 | + | ||||
| 20 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | ||||
| 21 | + const sp = await searchParams; | ||||
| 22 | + const custom = !!(slug(sp.x) || slug(sp.y) || sp.year || sp.method); | ||||
| 23 | + return { title: t('peers.metaTitle'), description: t('peers.description'), alternates: { canonical: routes.peers() }, robots: custom ? { index: false, follow: true } : undefined }; | ||||
| 24 | +} | ||||
| 25 | + | ||||
| 26 | +export default async function PeersPage({ searchParams }: { searchParams: Promise<SP> }) { | ||||
| 27 | + const sp = await searchParams; | ||||
| 28 | + const year = Number(Array.isArray(sp.year) ? sp.year[0] : sp.year); | ||||
| 29 | + const method = (Array.isArray(sp.method) ? sp.method[0] : sp.method) === 'ols' ? 'ols' : 'theil-sen'; | ||||
| 30 | + let data: PeersResponse | null = null; | ||||
| 31 | + try { | ||||
| 32 | + data = await apiAnalytics.peers({ x: slug(sp.x), y: slug(sp.y), year: Number.isInteger(year) && year > 1800 ? year : null, method }); | ||||
| 33 | + } catch (e) { | ||||
| 34 | + if (isNotBuilt(e)) return <NotBuiltState />; | ||||
| 35 | + data = null; | ||||
| 36 | + } | ||||
| 37 | + return ( | ||||
| 38 | + <> | ||||
| 39 | + <PageHeader title={t('peers.title')} lede={t('peers.lede')} /> | ||||
| 40 | + {!data ? ( | ||||
| 41 | + <p className="py-8 text-sm text-ink-3">{t('peers.unavailable')}</p> | ||||
| 42 | + ) : ( | ||||
| 43 | + <Suspense fallback={null}> | ||||
| 44 | + <PeersView data={data} /> | ||||
| 45 | + </Suspense> | ||||
| 46 | + )} | ||||
| 47 | + </> | ||||
| 48 | + ); | ||||
| 49 | +} | ||||
modified
apps/web/src/app/rankings/[indicator]/page.tsx
+33 −16
@@ -4,16 +4,19 @@ import Link from 'next/link'; | |||||
| 4 | import { notFound } from 'next/navigation'; | 4 | import { notFound } from 'next/navigation'; | ||
| 5 | import { t } from '@/i18n'; | 5 | import { t } from '@/i18n'; | ||
| 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | ||
| 7 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 7 | import { apiCompare } from '@/lib/api-compare'; | 8 | import { apiCompare } from '@/lib/api-compare'; | ||
| 8 | import { grouped } from '@/lib/format'; | 9 | import { grouped } from '@/lib/format'; | ||
| 9 | import { parseRankingState, type RankingState } from '@/lib/ranking-state'; | 10 | import { parseRankingState, type RankingState } from '@/lib/ranking-state'; | ||
| 11 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | ||||
| 10 | import { routes } from '@/lib/site'; | 12 | import { routes } from '@/lib/site'; | ||
| 11 | import { topicById } from '@/lib/topics'; | 13 | import { topicById } from '@/lib/topics'; | ||
| 12 | import type { RankingResponse } from '@/lib/types'; | 14 | import type { RankingResponse } from '@/lib/types'; | ||
| 13 | import { toCountryLite, type CountryLite } from '@/lib/types-compare'; | 15 | import { toCountryLite, type CountryLite } from '@/lib/types-compare'; | ||
| 14 | −import { Choropleth } from '@/components/charts/choropleth'; | 16 | +import { RankRace } from '@/components/charts/rank-race'; | ||
| 15 | import { NotBuiltState } from '@/components/data/empty-state'; | 17 | import { NotBuiltState } from '@/components/data/empty-state'; | ||
| 16 | import { Section } from '@/components/data/section'; | 18 | import { Section } from '@/components/data/section'; | ||
| 19 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | ||||
| 17 | import { RankHistoryPanel } from '@/components/rankings/rank-history-panel'; | 20 | import { RankHistoryPanel } from '@/components/rankings/rank-history-panel'; | ||
| 18 | import { RankingView } from '@/components/rankings/ranking-view'; | 21 | import { RankingView } from '@/components/rankings/ranking-view'; | ||
| 19 | 22 | ||||
@@ -21,6 +24,8 @@ export const revalidate = 900; | |||||
| 21 | 24 | ||||
| 22 | type Params = { indicator: string }; | 25 | type Params = { indicator: string }; | ||
| 23 | type SP = Record<string, string | string[] | undefined>; | 26 | type SP = Record<string, string | string[] | undefined>; | ||
| 27 | +/** Indicators with a rank race on their page (long histories, ≥ 30 ranked years). */ | ||||
| 28 | +const RACE_SLUGS = new Set(['gdp', 'gdp-ppp', 'population', 'gdp-per-capita', 'gdp-per-capita-ppp', 'life-expectancy', 'co2-emissions', 'co2-per-capita', 'internet-users', 'exports-goods-services', 'urban-population-share', 'fertility-rate', 'median-age']); | ||||
| 24 | 29 | ||||
| 25 | async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> { | 30 | async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> { | ||
| 26 | if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null; | 31 | if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null; | ||
@@ -41,7 +46,7 @@ export async function generateMetadata({ params, searchParams }: { params: Promi | |||||
| 41 | const name = data.indicator.short_name ?? data.indicator.name ?? indicator; | 46 | const name = data.indicator.short_name ?? data.indicator.name ?? indicator; | ||
| 42 | const year = data.year_used ?? ''; | 47 | const year = data.year_used ?? ''; | ||
| 43 | const world = state.group === 'world'; | 48 | const world = state.group === 'world'; | ||
| 44 | − const title = world ? t('ranking.pageTitle', { name, year }) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year }); | 49 | + const title = world ? seoTitle.ranking(name, year) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year }); | ||
| 45 | const top = data.rows | 50 | const top = data.rows | ||
| 46 | .slice(0, 3) | 51 | .slice(0, 3) | ||
| 47 | .map((r) => r.country.name) | 52 | .map((r) => r.country.name) | ||
@@ -49,12 +54,13 @@ export async function generateMetadata({ params, searchParams }: { params: Promi | |||||
| 49 | .join(', '); | 54 | .join(', '); | ||
| 50 | const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top }); | 55 | const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top }); | ||
| 51 | const canonical = routes.ranking(data.indicator.slug, { group: state.group }); | 56 | const canonical = routes.ranking(data.indicator.slug, { group: state.group }); | ||
| 57 | + const volatile = state.year != null || state.highlight || state.q || state.income || state.minpop || state.mincov || state.view !== 'table'; | ||||
| 52 | return { | 58 | return { | ||
| 53 | − title, | 59 | + title: { absolute: `${title} | ${t('site.name')}` }, | ||
| 54 | description, | 60 | description, | ||
| 55 | alternates: { canonical }, | 61 | alternates: { canonical }, | ||
| 56 | − robots: state.year != null || state.highlight || state.q ? { index: false, follow: true } : undefined, | 62 | + robots: volatile ? { index: false, follow: true } : undefined, | ||
| 57 | − openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, | 63 | + openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, | ||
| 58 | twitter: { card: 'summary_large_image', title, description }, | 64 | twitter: { card: 'summary_large_image', title, description }, | ||
| 59 | }; | 65 | }; | ||
| 60 | } | 66 | } | ||
@@ -71,19 +77,26 @@ export default async function RankingPage({ params, searchParams }: { params: Pr | |||||
| 71 | const year = data.year_used; | 77 | const year = data.year_used; | ||
| 72 | const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null; | 78 | const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null; | ||
| 73 | const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5); | 79 | const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5); | ||
| 80 | + const wantRace = RACE_SLUGS.has(ind.slug) || !!ind.featured; | ||||
| 74 | 81 | ||||
| 75 | − const [regions, countriesRes, map, history] = await Promise.all([ | 82 | + const [regions, countriesRes, history, race] = await Promise.all([ | ||
| 76 | safe(apiCompare.regions()), | 83 | safe(apiCompare.regions()), | ||
| 77 | safe(api.countries()), | 84 | safe(api.countries()), | ||
| 78 | − year != null ? safe(apiCompare.indicatorMap(ind.slug, { year })) : Promise.resolve(null), | ||||
| 79 | historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null), | 85 | historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null), | ||
| 86 | + wantRace ? safe(apiAnalytics.race(ind.slug, { top: 10, group: state.group !== 'world' ? state.group : null })) : Promise.resolve(null), | ||||
| 80 | ]); | 87 | ]); | ||
| 81 | − const countries: CountryLite[] = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name)); | 88 | + const all = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate'); | ||
| 89 | + const countries: CountryLite[] = all.map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name)); | ||||
| 90 | + const { features, sphere } = all.length ? baseFeatures(all) : { features: [], sphere: '' }; | ||||
| 82 | const topic = topicById(ind.topic ?? ''); | 91 | const topic = topicById(ind.topic ?? ''); | ||
| 83 | const groupName = data.group.name ?? state.group; | 92 | const groupName = data.group.name ?? state.group; | ||
| 93 | + const spec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, name, higher_is_better: ind.higher_is_better }; | ||||
| 94 | + const showRace = race && race.frames.length >= 30; | ||||
| 95 | + const ld = [jsonLd.breadcrumbs([{ name: t('rankings.title'), path: routes.rankings() }, { name, path: routes.ranking(ind.slug) }]), jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, unit: ind.unit, nCountries: data.n, modified: data.meta.built_at })]; | ||||
| 84 | 96 | ||||
| 85 | return ( | 97 | return ( | ||
| 86 | <> | 98 | <> | ||
| 99 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} /> | ||||
| 87 | <header className="pb-3 pt-6 md:pt-10"> | 100 | <header className="pb-3 pt-6 md:pt-10"> | ||
| 88 | <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0"> | 101 | <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0"> | ||
| 89 | <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0"> | 102 | <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0"> | ||
@@ -108,6 +121,9 @@ export default async function RankingPage({ params, searchParams }: { params: Pr | |||||
| 108 | {t('ranking.openIndicator')} | 121 | {t('ranking.openIndicator')} | ||
| 109 | <ExternalLink size={13} aria-hidden /> | 122 | <ExternalLink size={13} aria-hidden /> | ||
| 110 | </Link> | 123 | </Link> | ||
| 124 | + <Link href={routes.explore({ indicator: ind.slug, year })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]"> | ||||
| 125 | + {t('home.map.openExplorer')} | ||||
| 126 | + </Link> | ||||
| 111 | <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]"> | 127 | <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]"> | ||
| 112 | <Download size={13} aria-hidden /> | 128 | <Download size={13} aria-hidden /> | ||
| 113 | {t('ranking.download')} | 129 | {t('ranking.download')} | ||
@@ -120,16 +136,17 @@ export default async function RankingPage({ params, searchParams }: { params: Pr | |||||
| 120 | </div> | 136 | </div> | ||
| 121 | </header> | 137 | </header> | ||
| 122 | 138 | ||||
| 123 | − <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} /> | 139 | + <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} geometry={features} sphere={sphere} /> | ||
| 124 | 140 | ||||
| 125 | − <div className="grid gap-x-10 lg:grid-cols-2"> | 141 | + {showRace ? ( | ||
| 126 | − <Section id="map" title={t('ranking.map.title')} subtitle={t('ranking.map.sub', { name: ind.short_name ?? ind.name, year: map?.year_used ?? year ?? '' })} level={3}> | 142 | + <Section id="race" title={t('ranking.race.title')} subtitle={t('ranking.race.sub', { name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}> | ||
| 127 | − {map && countriesRes ? <Choropleth map={map} countries={countriesRes.items} compact /> : <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p>} | 143 | + <RankRace data={race} spec={spec} top={10} initialYear={year} /> | ||
| 128 | − </Section> | ||||
| 129 | − <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={3}> | ||||
| 130 | − <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} /> | ||||
| 131 | </Section> | 144 | </Section> | ||
| 132 | − </div> | 145 | + ) : null} | ||
| 146 | + | ||||
| 147 | + <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={showRace ? 3 : 2}> | ||||
| 148 | + <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} /> | ||||
| 149 | + </Section> | ||||
| 133 | </> | 150 | </> | ||
| 134 | ); | 151 | ); | ||
| 135 | } | 152 | } | ||
modified
apps/web/src/app/regions/[slug]/page.tsx
+37 −4
@@ -3,14 +3,18 @@ import Link from 'next/link'; | |||||
| 3 | import { notFound } from 'next/navigation'; | 3 | import { notFound } from 'next/navigation'; | ||
| 4 | import { t, tOpt } from '@/i18n'; | 4 | import { t, tOpt } from '@/i18n'; | ||
| 5 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | 5 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; | ||
| 6 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 6 | import { apiExplore } from '@/lib/api-explore'; | 7 | import { apiExplore } from '@/lib/api-explore'; | ||
| 7 | −import { formatValue, grouped } from '@/lib/format'; | 8 | +import { fixed, formatValue, grouped } from '@/lib/format'; | ||
| 9 | +import { seoTitle } from '@/lib/seo'; | ||||
| 8 | import { routes } from '@/lib/site'; | 10 | import { routes } from '@/lib/site'; | ||
| 9 | import type { IndicatorCard } from '@/lib/types'; | 11 | import type { IndicatorCard } from '@/lib/types'; | ||
| 10 | import type { RegionResponse } from '@/lib/types-explore'; | 12 | import type { RegionResponse } from '@/lib/types-explore'; | ||
| 11 | import { NotBuiltState } from '@/components/data/empty-state'; | 13 | import { NotBuiltState } from '@/components/data/empty-state'; | ||
| 12 | import { Section } from '@/components/data/section'; | 14 | import { Section } from '@/components/data/section'; | ||
| 13 | import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; | 15 | import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; | ||
| 16 | +import { GroupComparePicker } from '@/components/regions/group-compare-picker'; | ||||
| 17 | +import { GroupHistory } from '@/components/regions/group-history'; | ||||
| 14 | import { MemberMap } from '@/components/regions/member-map'; | 18 | import { MemberMap } from '@/components/regions/member-map'; | ||
| 15 | import { MemberRanking } from '@/components/regions/member-ranking'; | 19 | import { MemberRanking } from '@/components/regions/member-ranking'; | ||
| 16 | import { MembersTable } from '@/components/regions/members-table'; | 20 | import { MembersTable } from '@/components/regions/members-table'; | ||
@@ -34,10 +38,10 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | |||||
| 34 | const data = await load(slug); | 38 | const data = await load(slug); | ||
| 35 | if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } }; | 39 | if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } }; | ||
| 36 | const name = data.group.name ?? slug; | 40 | const name = data.group.name ?? slug; | ||
| 37 | − const title = t('region.title', { name }); | 41 | + const title = seoTitle.region(name); | ||
| 38 | const description = t('region.description', { name, n: data.n_members }); | 42 | const description = t('region.description', { name, n: data.n_members }); | ||
| 39 | const canonical = routes.region(data.group.slug ?? slug); | 43 | const canonical = routes.region(data.group.slug ?? slug); | ||
| 40 | − return { title, description, alternates: { canonical }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } }; | 44 | + return { title: { absolute: `${title} | ${t('site.name')}` }, description, alternates: { canonical }, openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } }; | ||
| 41 | } | 45 | } | ||
| 42 | 46 | ||||
| 43 | /** Extra indicators offered in the member ranking picker besides the headline ones. */ | 47 | /** Extra indicators offered in the member ranking picker besides the headline ones. */ | ||
@@ -53,7 +57,10 @@ export default async function RegionPage({ params }: { params: Promise<Params> } | |||||
| 53 | const name = g.name ?? slug; | 57 | const name = g.name ?? slug; | ||
| 54 | const gslug = g.slug ?? g.id; | 58 | const gslug = g.slug ?? g.id; | ||
| 55 | const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? ''); | 59 | const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? ''); | ||
| 56 | − const [countriesRes, extraInd] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true }))]); | 60 | + const isWorld = g.id === 'world'; | ||
| 61 | + const [countriesRes, extraInd, vsWorld, regions] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true })), isWorld ? Promise.resolve(null) : safe(apiAnalytics.regionsCompare(gslug, 'world')), safe(apiExplore.regions())]); | ||||
| 62 | + const shares = vsWorld?.shares[g.id] ?? vsWorld?.shares[gslug] ?? null; | ||||
| 63 | + const otherDefault = gslug === 'g7' ? 'brics' : 'g7'; | ||||
| 57 | const countries = countriesRes?.items ?? []; | 64 | const countries = countriesRes?.items ?? []; | ||
| 58 | const memberIds = data.members.map((m) => m.id); | 65 | const memberIds = data.members.map((m) => m.id); | ||
| 59 | const aggs = Object.values(data.aggregates); | 66 | const aggs = Object.values(data.aggregates); | ||
@@ -80,6 +87,22 @@ export default async function RegionPage({ params }: { params: Promise<Params> } | |||||
| 80 | /> | 87 | /> | ||
| 81 | 88 | ||||
| 82 | <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0"> | 89 | <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0"> | ||
| 90 | + {shares ? ( | ||||
| 91 | + <dl className="mb-4 grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-4"> | ||||
| 92 | + {(['gdp_share_pct', 'population_share_pct'] as const).map((k) => { | ||||
| 93 | + const v = shares[k]; | ||||
| 94 | + return ( | ||||
| 95 | + <div key={k} className="border-t border-rule py-3"> | ||||
| 96 | + <dt className="eyebrow">{k === 'gdp_share_pct' ? t('regions.compare.gdpShare') : t('regions.compare.popShare')}</dt> | ||||
| 97 | + <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink">{v != null ? `${fixed(v, 1)} %` : t('common.na')}</dd> | ||||
| 98 | + <dd className="mt-1.5 h-1.5 w-full max-w-[12rem] overflow-hidden rounded-xs bg-surface-2" aria-hidden> | ||||
| 99 | + <span className="block h-full bg-accent" style={{ width: `${Math.min(100, v ?? 0)}%` }} /> | ||||
| 100 | + </dd> | ||||
| 101 | + </div> | ||||
| 102 | + ); | ||||
| 103 | + })} | ||||
| 104 | + </dl> | ||||
| 105 | + ) : null} | ||||
| 83 | {aggs.length === 0 ? ( | 106 | {aggs.length === 0 ? ( | ||
| 84 | <p className="text-sm text-ink-3">{t('common.noDataLong')}</p> | 107 | <p className="text-sm text-ink-3">{t('common.noDataLong')}</p> | ||
| 85 | ) : ( | 108 | ) : ( | ||
@@ -109,6 +132,16 @@ export default async function RegionPage({ params }: { params: Promise<Params> } | |||||
| 109 | </Section> | 132 | </Section> | ||
| 110 | </div> | 133 | </div> | ||
| 111 | 134 | ||||
| 135 | + {vsWorld && Object.keys(vsWorld.history).length ? ( | ||||
| 136 | + <Section id="history" title={t('region.history.title')} subtitle={t('region.history.sub', { name })}> | ||||
| 137 | + <GroupHistory data={vsWorld} ids={[g.id]} names={{ [g.id]: name }} /> | ||||
| 138 | + </Section> | ||||
| 139 | + ) : null} | ||||
| 140 | + | ||||
| 141 | + <Section id="compare-group" title={t('regions.compare.withOther')} subtitle={t('regions.compare.withOtherSub')} actions={<Link href={routes.regionCompare(gslug, otherDefault)} className="text-accent hover:underline">{t('regions.compare.open')} →</Link>}> | ||||
| 142 | + <GroupComparePicker groups={regions?.items ?? []} a={gslug} b={otherDefault} fixedA /> | ||||
| 143 | + </Section> | ||||
| 144 | + | ||||
| 112 | <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}> | 145 | <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}> | ||
| 113 | {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>} | 146 | {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>} | ||
| 114 | </Section> | 147 | </Section> | ||
added
apps/web/src/app/regions/compare/page.tsx
+138 −0
@@ -0,0 +1,138 @@ | |||||
| 1 | +import type { Metadata } from 'next'; | ||||
| 2 | +import Link from 'next/link'; | ||||
| 3 | +import { t, tOpt } from '@/i18n'; | ||||
| 4 | +import { isNotBuilt, safe } from '@/lib/api'; | ||||
| 5 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 6 | +import { apiExplore } from '@/lib/api-explore'; | ||||
| 7 | +import { fixed, formatValue, grouped } from '@/lib/format'; | ||||
| 8 | +import { routes } from '@/lib/site'; | ||||
| 9 | +import type { RegionCompareResponse } from '@/lib/types-analytics'; | ||||
| 10 | +import { NotBuiltState } from '@/components/data/empty-state'; | ||||
| 11 | +import { Section } from '@/components/data/section'; | ||||
| 12 | +import { PageHeader } from '@/components/explore/page-header'; | ||||
| 13 | +import { GroupComparePicker } from '@/components/regions/group-compare-picker'; | ||||
| 14 | +import { GroupHistory } from '@/components/regions/group-history'; | ||||
| 15 | + | ||||
| 16 | +export const revalidate = 900; | ||||
| 17 | +type SP = Record<string, string | string[] | undefined>; | ||||
| 18 | + | ||||
| 19 | +function slugOf(v: string | string[] | undefined, fallback: string): string { | ||||
| 20 | + const s = (Array.isArray(v) ? v[0] : v) ?? ''; | ||||
| 21 | + return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s : fallback; | ||||
| 22 | +} | ||||
| 23 | + | ||||
| 24 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | ||||
| 25 | + const sp = await searchParams; | ||||
| 26 | + const a = slugOf(sp.a, 'g7'); | ||||
| 27 | + const b = slugOf(sp.b, 'brics'); | ||||
| 28 | + const data = await safe(apiAnalytics.regionsCompare(a, b)); | ||||
| 29 | + const names = data ? data.groups.map((g) => g.name ?? g.id) : [a, b]; | ||||
| 30 | + const title = t('regions.compare.title', { a: names[0] ?? a, b: names[1] ?? b }); | ||||
| 31 | + const canonical = routes.regionCompare(a, b); | ||||
| 32 | + return { title, description: t('regions.compare.description', { a: names[0] ?? a, b: names[1] ?? b }), alternates: { canonical }, robots: a === 'g7' && b === 'brics' ? undefined : { index: false, follow: true } }; | ||||
| 33 | +} | ||||
| 34 | + | ||||
| 35 | +export default async function RegionsComparePage({ searchParams }: { searchParams: Promise<SP> }) { | ||||
| 36 | + const sp = await searchParams; | ||||
| 37 | + const a = slugOf(sp.a, 'g7'); | ||||
| 38 | + const b = slugOf(sp.b, 'brics'); | ||||
| 39 | + let data: RegionCompareResponse | null = null; | ||||
| 40 | + try { | ||||
| 41 | + data = await apiAnalytics.regionsCompare(a, b); | ||||
| 42 | + } catch (e) { | ||||
| 43 | + if (isNotBuilt(e)) return <NotBuiltState />; | ||||
| 44 | + data = null; | ||||
| 45 | + } | ||||
| 46 | + const regions = await safe(apiExplore.regions()); | ||||
| 47 | + const groups = regions?.items ?? []; | ||||
| 48 | + const names: Record<string, string> = data ? Object.fromEntries(data.groups.map((g) => [g.id, g.name ?? g.id])) : {}; | ||||
| 49 | + const ids = data ? data.groups.map((g) => g.id) : []; | ||||
| 50 | + | ||||
| 51 | + return ( | ||||
| 52 | + <> | ||||
| 53 | + <PageHeader crumbs={[{ href: routes.regions(), label: t('regions.title') }]} title={data ? t('regions.compare.heading', { a: names[ids[0] ?? ''] ?? a, b: names[ids[1] ?? ''] ?? b }) : t('regions.compare.pageTitle')} lede={t('regions.compare.lede')} /> | ||||
| 54 | + <div className="pb-4"> | ||||
| 55 | + <GroupComparePicker groups={groups} a={a} b={b} /> | ||||
| 56 | + </div> | ||||
| 57 | + {!data ? ( | ||||
| 58 | + <p className="py-8 text-sm text-ink-3">{t('regions.compare.unavailable')}</p> | ||||
| 59 | + ) : ( | ||||
| 60 | + <> | ||||
| 61 | + <Section id="shares" title={t('regions.compare.shares')} subtitle={t('regions.compare.sharesSub')} className="border-t-0"> | ||||
| 62 | + <div className="grid gap-x-10 gap-y-4 sm:grid-cols-2"> | ||||
| 63 | + {(['population_share_pct', 'gdp_share_pct'] as const).map((k) => ( | ||||
| 64 | + <div key={k}> | ||||
| 65 | + <div className="eyebrow mb-2">{k === 'gdp_share_pct' ? t('regions.compare.gdpShare') : t('regions.compare.popShare')}</div> | ||||
| 66 | + <ol className="space-y-2"> | ||||
| 67 | + {data.groups.map((g, i) => { | ||||
| 68 | + const v = data.shares[g.id]?.[k] ?? null; | ||||
| 69 | + return ( | ||||
| 70 | + <li key={g.id} className="grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_4rem] items-center gap-x-3 text-sm"> | ||||
| 71 | + <Link href={routes.region(g.slug ?? g.id)} className="link-quiet truncate font-medium text-ink"> | ||||
| 72 | + {g.name} | ||||
| 73 | + </Link> | ||||
| 74 | + <span className="h-3 overflow-hidden rounded-xs bg-surface-2" aria-hidden> | ||||
| 75 | + <span className="block h-full" style={{ width: `${Math.min(100, v ?? 0)}%`, background: `var(--series-${i + 1})` }} /> | ||||
| 76 | + </span> | ||||
| 77 | + <span className="tnum text-right text-ink">{v != null ? `${fixed(v, 1)} %` : t('common.na')}</span> | ||||
| 78 | + </li> | ||||
| 79 | + ); | ||||
| 80 | + })} | ||||
| 81 | + </ol> | ||||
| 82 | + </div> | ||||
| 83 | + ))} | ||||
| 84 | + </div> | ||||
| 85 | + </Section> | ||||
| 86 | + | ||||
| 87 | + <Section id="table" title={t('regions.compare.table')} subtitle={t('regions.compare.tableSub')}> | ||||
| 88 | + <table className="w-full border-collapse text-sm"> | ||||
| 89 | + <caption className="sr-only">{t('regions.compare.table')}</caption> | ||||
| 90 | + <thead> | ||||
| 91 | + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3"> | ||||
| 92 | + <th scope="col" className="py-2 pr-3 font-medium"> | ||||
| 93 | + {t('common.indicator')} | ||||
| 94 | + </th> | ||||
| 95 | + {data.groups.map((g, i) => ( | ||||
| 96 | + <th key={g.id} scope="col" className="py-2 pr-3 text-right font-medium"> | ||||
| 97 | + <span className="inline-flex items-center gap-1.5"> | ||||
| 98 | + <span aria-hidden className="inline-block h-2 w-2 rounded-full" style={{ background: `var(--series-${i + 1})` }} /> | ||||
| 99 | + {g.name} | ||||
| 100 | + </span> | ||||
| 101 | + </th> | ||||
| 102 | + ))} | ||||
| 103 | + </tr> | ||||
| 104 | + </thead> | ||||
| 105 | + <tbody className="divide-y divide-rule"> | ||||
| 106 | + {data.rows.map((r) => ( | ||||
| 107 | + <tr key={r.indicator.slug}> | ||||
| 108 | + <th scope="row" className="py-2 pr-3 text-left font-normal"> | ||||
| 109 | + <Link href={routes.indicator(r.indicator.slug)} className="link-quiet block text-ink"> | ||||
| 110 | + {r.indicator.short_name ?? r.indicator.name} | ||||
| 111 | + </Link> | ||||
| 112 | + <span className="block text-2xs text-ink-3"> | ||||
| 113 | + {tOpt(`regions.${r.kind}`, r.kind)} · {r.indicator.unit} | ||||
| 114 | + </span> | ||||
| 115 | + </th> | ||||
| 116 | + {data.groups.map((g) => { | ||||
| 117 | + const v = r.values[g.id]; | ||||
| 118 | + return ( | ||||
| 119 | + <td key={g.id} className="tnum py-2 pr-3 text-right align-top"> | ||||
| 120 | + <span className="block text-base font-medium text-ink">{v ? formatValue(v.value, r.indicator) : t('common.na')}</span> | ||||
| 121 | + {v ? <span className="block text-2xs text-ink-3">{t('region.aggregates.n', { n: grouped(v.n ?? 0), year: v.year ?? '' })}</span> : null} | ||||
| 122 | + </td> | ||||
| 123 | + ); | ||||
| 124 | + })} | ||||
| 125 | + </tr> | ||||
| 126 | + ))} | ||||
| 127 | + </tbody> | ||||
| 128 | + </table> | ||||
| 129 | + </Section> | ||||
| 130 | + | ||||
| 131 | + <Section id="history" title={t('regions.compare.history')} subtitle={t('regions.compare.historySub')}> | ||||
| 132 | + <GroupHistory data={data} ids={ids} names={names} /> | ||||
| 133 | + </Section> | ||||
| 134 | + </> | ||||
| 135 | + )} | ||||
| 136 | + </> | ||||
| 137 | + ); | ||||
| 138 | +} | ||||
modified
apps/web/src/components/changes/changes-feed.tsx
+4 −1
@@ -1,5 +1,5 @@ | |||||
| 1 | 'use client'; | 1 | 'use client'; | ||
| 2 | −import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, X } from 'lucide-react'; | 2 | +import { AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, Waves, X } from 'lucide-react'; | ||
| 3 | import Link from 'next/link'; | 3 | import Link from 'next/link'; | ||
| 4 | import { useEffect, useMemo, useRef, useState } from 'react'; | 4 | import { useEffect, useMemo, useRef, useState } from 'react'; | ||
| 5 | import { t } from '@/i18n'; | 5 | import { t } from '@/i18n'; | ||
@@ -25,6 +25,9 @@ const ICON: Record<string, typeof ArrowUpRight> = { | |||||
| 25 | sign_flip: Repeat, | 25 | sign_flip: Repeat, | ||
| 26 | accelerating: TrendingUp, | 26 | accelerating: TrendingUp, | ||
| 27 | decelerating: TrendingDown, | 27 | decelerating: TrendingDown, | ||
| 28 | + structural_break: GitCommitHorizontal, | ||||
| 29 | + trend_reversal: Repeat, | ||||
| 30 | + volatility_spike: Waves, | ||||
| 28 | }; | 31 | }; | ||
| 29 | 32 | ||||
| 30 | type Sev = 0 | 0.4 | 0.7; | 33 | type Sev = 0 | 0.4 | 0.7; | ||
modified
apps/web/src/components/charts/choropleth-view.tsx
+102 −34
@@ -1,6 +1,6 @@ | |||||
| 1 | 'use client'; | 1 | 'use client'; | ||
| 2 | import { useRouter } from 'next/navigation'; | 2 | import { useRouter } from 'next/navigation'; | ||
| 3 | −import { useCallback, useId, useState } from 'react'; | 3 | +import { useCallback, useId, useState, type ReactNode } from 'react'; | ||
| 4 | import { t } from '@/i18n'; | 4 | import { t } from '@/i18n'; | ||
| 5 | import { cn } from '@/lib/cn'; | 5 | import { cn } from '@/lib/cn'; | ||
| 6 | import { formatValue } from '@/lib/format'; | 6 | import { formatValue } from '@/lib/format'; | ||
@@ -20,18 +20,72 @@ export interface ChoroplethFeature { | |||||
| 20 | } | 20 | } | ||
| 21 | 21 | ||||
| 22 | /** Map a class index (0..k-1) onto the 7-step sequential ramp. */ | 22 | /** Map a class index (0..k-1) onto the 7-step sequential ramp. */ | ||
| 23 | −function stepFor(cls: number, k: number): number { | 23 | +export function stepFor(cls: number, k: number): number { | ||
| 24 | if (k <= 1) return 4; | 24 | if (k <= 1) return 4; | ||
| 25 | const start = k >= 6 ? 1 : 2; | 25 | const start = k >= 6 ? 1 : 2; | ||
| 26 | const end = 7; | 26 | const end = 7; | ||
| 27 | return Math.round(start + (cls / (k - 1)) * (end - start)); | 27 | return Math.round(start + (cls / (k - 1)) * (end - start)); | ||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | +/** Legend items (class → label) from API quantile breaks; shared by every choropleth wrapper. */ | ||||
| 31 | +export function legendFromBreaks(breaks: number[], min: number | null, max: number | null, spec: Spec): Array<{ cls: number; label: string }> { | ||||
| 32 | + const k = breaks.length + 1; | ||||
| 33 | + return Array.from({ length: k }, (_, i) => { | ||||
| 34 | + const lo = i === 0 ? min : breaks[i - 1]!; | ||||
| 35 | + const hi = i === k - 1 ? max : breaks[i]!; | ||||
| 36 | + return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` }; | ||||
| 37 | + }); | ||||
| 38 | +} | ||||
| 39 | + | ||||
| 40 | +/** Class index 0..k for a value against sorted quantile breaks. */ | ||||
| 41 | +export function classFor(value: number, breaks: number[]): number { | ||||
| 42 | + let i = 0; | ||||
| 43 | + while (i < breaks.length && value >= breaks[i]!) i++; | ||||
| 44 | + return i; | ||||
| 45 | +} | ||||
| 46 | + | ||||
| 30 | /** | 47 | /** | ||
| 31 | * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and | 48 | * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and | ||
| 32 | − * shows the label with an "Open" link, a click with a mouse navigates. Explicit hatched fill for no data. | 49 | + * shows the label with an "Open" link, a click with a mouse navigates (or calls `onSelect` when given). | ||
| 50 | + * Explicit hatched fill for no data. `selectedId` outlines one country; `renderLabel` customises the tooltip. | ||||
| 33 | */ | 51 | */ | ||
| 34 | −export function ChoroplethView({ features, sphere, legend, k, spec, summary, title, height, compact, className }: { features: ChoroplethFeature[]; sphere: string; legend: Array<{ cls: number; label: string }>; k: number; spec: Spec; summary: string; title: string; height?: number; compact?: boolean; className?: string }) { | 52 | +export function ChoroplethView({ | ||
| 53 | + features, | ||||
| 54 | + sphere, | ||||
| 55 | + legend, | ||||
| 56 | + k, | ||||
| 57 | + spec, | ||||
| 58 | + summary, | ||||
| 59 | + title, | ||||
| 60 | + height, | ||||
| 61 | + compact, | ||||
| 62 | + className, | ||||
| 63 | + onSelect, | ||||
| 64 | + selectedId, | ||||
| 65 | + renderLabel, | ||||
| 66 | + showLegend = true, | ||||
| 67 | + showHint = true, | ||||
| 68 | + fillFor, | ||||
| 69 | +}: { | ||||
| 70 | + features: ChoroplethFeature[]; | ||||
| 71 | + sphere: string; | ||||
| 72 | + legend: Array<{ cls: number; label: string }>; | ||||
| 73 | + k: number; | ||||
| 74 | + spec: Spec; | ||||
| 75 | + summary: string; | ||||
| 76 | + title: string; | ||||
| 77 | + height?: number; | ||||
| 78 | + compact?: boolean; | ||||
| 79 | + className?: string; | ||||
| 80 | + /** When given, clicking / "Open" calls this instead of navigating to the country page. */ | ||||
| 81 | + onSelect?: (f: ChoroplethFeature) => void; | ||||
| 82 | + selectedId?: string | null; | ||||
| 83 | + renderLabel?: (f: ChoroplethFeature) => ReactNode; | ||||
| 84 | + showLegend?: boolean; | ||||
| 85 | + showHint?: boolean; | ||||
| 86 | + /** Override the class → colour mapping (e.g. a diverging ramp). */ | ||||
| 87 | + fillFor?: (f: ChoroplethFeature) => string; | ||||
| 88 | +}) { | ||||
| 35 | const router = useRouter(); | 89 | const router = useRouter(); | ||
| 36 | const id = useId(); | 90 | const id = useId(); | ||
| 37 | const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null); | 91 | const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null); | ||
@@ -43,8 +97,15 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit | |||||
| 43 | }, []); | 97 | }, []); | ||
| 44 | 98 | ||||
| 45 | const open = (f: ChoroplethFeature) => { | 99 | const open = (f: ChoroplethFeature) => { | ||
| 100 | + if (onSelect) { | ||||
| 101 | + onSelect(f); | ||||
| 102 | + setActive(null); | ||||
| 103 | + return; | ||||
| 104 | + } | ||||
| 46 | if (f.slug) router.push(routes.country(f.slug)); | 105 | if (f.slug) router.push(routes.country(f.slug)); | ||
| 47 | }; | 106 | }; | ||
| 107 | + const fill = (f: ChoroplethFeature) => (fillFor ? fillFor(f) : f.cls != null ? seqVar(stepFor(f.cls, k)) : `url(#${id}-hatch)`); | ||||
| 108 | + const selected = selectedId ? features.find((f) => f.iso3 === selectedId) ?? null : null; | ||||
| 48 | 109 | ||||
| 49 | return ( | 110 | return ( | ||
| 50 | <figure className={cn('min-w-0', className)}> | 111 | <figure className={cn('min-w-0', className)}> | ||
@@ -58,20 +119,20 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit | |||||
| 58 | <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> | 119 | <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> | ||
| 59 | </pattern> | 120 | </pattern> | ||
| 60 | </defs> | 121 | </defs> | ||
| 61 | − <path d={sphere} fill="var(--surface)" stroke="var(--rule)" strokeWidth={1} /> | 122 | + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} /> | ||
| 62 | − <g stroke="var(--surface)" strokeWidth={0.6} strokeLinejoin="round"> | 123 | + <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round"> | ||
| 63 | {features.map((f, i) => { | 124 | {features.map((f, i) => { | ||
| 64 | − const hasData = f.cls != null; | ||||
| 65 | const isActive = active?.f === f; | 125 | const isActive = active?.f === f; | ||
| 126 | + const interactive = !!f.slug || (!!onSelect && !!f.iso3); | ||||
| 66 | return ( | 127 | return ( | ||
| 67 | <path | 128 | <path | ||
| 68 | key={f.iso3 ?? `${f.name}-${i}`} | 129 | key={f.iso3 ?? `${f.name}-${i}`} | ||
| 69 | d={f.d} | 130 | d={f.d} | ||
| 70 | − fill={hasData ? seqVar(stepFor(f.cls!, k)) : `url(#${id}-hatch)`} | 131 | + fill={fill(f)} | ||
| 71 | − className={cn(f.slug && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} | 132 | + className={cn(interactive && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} | ||
| 72 | fillOpacity={isActive ? 0.75 : 1} | 133 | fillOpacity={isActive ? 0.75 : 1} | ||
| 73 | − tabIndex={f.slug ? 0 : -1} | 134 | + tabIndex={interactive ? 0 : -1} | ||
| 74 | − role={f.slug ? 'link' : undefined} | 135 | + role={interactive ? (onSelect ? 'button' : 'link') : undefined} | ||
| 75 | aria-label={`${f.name}: ${formatValue(f.value, spec)}`} | 136 | aria-label={`${f.name}: ${formatValue(f.value, spec)}`} | ||
| 76 | onPointerMove={(e) => { | 137 | onPointerMove={(e) => { | ||
| 77 | if (e.pointerType === 'mouse') place(e, f, false); | 138 | if (e.pointerType === 'mouse') place(e, f, false); | ||
@@ -83,55 +144,62 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit | |||||
| 83 | } | 144 | } | ||
| 84 | }} | 145 | }} | ||
| 85 | onClick={(e) => { | 146 | onClick={(e) => { | ||
| 86 | − // Mouse: navigate. Touch: the label carries the link (first tap selects). | 147 | + // Mouse: open. Touch: the label carries the action (first tap selects). | ||
| 87 | if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); | 148 | if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); | ||
| 88 | }} | 149 | }} | ||
| 89 | onKeyDown={(e) => { | 150 | onKeyDown={(e) => { | ||
| 90 | − if (e.key === 'Enter' && f.slug) open(f); | 151 | + if (e.key === 'Enter' && interactive) open(f); | ||
| 91 | }} | 152 | }} | ||
| 92 | onFocus={(e) => { | 153 | onFocus={(e) => { | ||
| 93 | const b = e.currentTarget.getBBox(); | 154 | const b = e.currentTarget.getBBox(); | ||
| 94 | − setActive({ f, x: (b.x + b.width / 2) / MAP_WIDTH * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false }); | 155 | + setActive({ f, x: ((b.x + b.width / 2) / MAP_WIDTH) * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false }); | ||
| 95 | }} | 156 | }} | ||
| 96 | > | 157 | > | ||
| 97 | <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title> | 158 | <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title> | ||
| 98 | </path> | 159 | </path> | ||
| 99 | ); | 160 | ); | ||
| 100 | })} | 161 | })} | ||
| 162 | + {selected ? <path d={selected.d} fill="none" stroke="var(--ink)" strokeWidth={1.6} pointerEvents="none" /> : null} | ||||
| 101 | </g> | 163 | </g> | ||
| 102 | </svg> | 164 | </svg> | ||
| 103 | {active ? ( | 165 | {active ? ( | ||
| 104 | − <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.min(active.x + 10, Math.max(0, (typeof window !== 'undefined' ? 0 : 0) + active.x + 10)), top: active.y - 44, maxWidth: 220 }}> | 166 | + <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.max(0, Math.min(active.x + 10, (typeof window !== 'undefined' ? window.innerWidth : 9999) - 240)), top: Math.max(0, active.y - 44), maxWidth: 230 }}> | ||
| 105 | <div className="flex items-center gap-1.5 font-medium text-ink"> | 167 | <div className="flex items-center gap-1.5 font-medium text-ink"> | ||
| 106 | {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null} | 168 | {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null} | ||
| 107 | <span className="truncate">{active.f.name}</span> | 169 | <span className="truncate">{active.f.name}</span> | ||
| 108 | </div> | 170 | </div> | ||
| 109 | − <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div> | 171 | + {renderLabel ? renderLabel(active.f) : <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div>} | ||
| 110 | − {active.sticky && active.f.slug ? ( | 172 | + {active.sticky && (active.f.slug || onSelect) ? ( | ||
| 111 | − <button type="button" className="pointer-events-auto mt-1 text-accent underline" onClick={() => open(active.f)}> | 173 | + <button type="button" className="pointer-events-auto mt-1 min-h-[32px] text-accent underline" onClick={() => open(active.f)}> | ||
| 112 | {t('metric.open', { name: active.f.name })} → | 174 | {t('metric.open', { name: active.f.name })} → | ||
| 113 | </button> | 175 | </button> | ||
| 114 | ) : null} | 176 | ) : null} | ||
| 115 | </div> | 177 | </div> | ||
| 116 | ) : null} | 178 | ) : null} | ||
| 117 | </div> | 179 | </div> | ||
| 118 | − <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> | 180 | + {showLegend ? ( | ||
| 119 | − {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} | 181 | + <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> | ||
| 120 | − <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | 182 | + {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} | ||
| 121 | − {legend.map((l) => ( | 183 | + <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | ||
| 122 | − <li key={l.cls} className="inline-flex items-center gap-1 tnum"> | 184 | + {legend.map((l) => ( | ||
| 123 | − <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} /> | 185 | + <li key={l.cls} className="inline-flex items-center gap-1 tnum"> | ||
| 124 | − {l.label} | 186 | + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} /> | ||
| 187 | + {l.label} | ||||
| 188 | + </li> | ||||
| 189 | + ))} | ||||
| 190 | + <li className="inline-flex items-center gap-1"> | ||||
| 191 | + <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> | ||||
| 192 | + {t('chart.legend.noData')} | ||||
| 125 | </li> | 193 | </li> | ||
| 126 | − ))} | 194 | + </ul> | ||
| 127 | − <li className="inline-flex items-center gap-1"> | 195 | + {showHint ? ( | ||
| 128 | − <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> | 196 | + <> | ||
| 129 | − {t('chart.legend.noData')} | 197 | + <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> | ||
| 130 | − </li> | 198 | + <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> | ||
| 131 | − </ul> | 199 | + </> | ||
| 132 | − <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> | 200 | + ) : null} | ||
| 133 | − <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> | 201 | + </figcaption> | ||
| 134 | − </figcaption> | 202 | + ) : null} | ||
| 135 | </figure> | 203 | </figure> | ||
| 136 | ); | 204 | ); | ||
| 137 | } | 205 | } | ||
modified
apps/web/src/components/charts/dna-radial.tsx
+25 −11
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/export-png.ts
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/rank-race.tsx
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/compare/compare-chart.tsx
+25 −7
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/compare/compare-controls.tsx
+3 −3
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/compare/head-to-head.tsx
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/compare/snapshot-table.tsx
+18 −5
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/country/copy-api-button.tsx
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/country/country-header.tsx
+88 −33
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/country/country-story.tsx
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/country/dna-panel.tsx
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/country/mini-map.tsx
+40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/country/similar-panel.tsx
+51 −29
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/country/timeline.tsx
+125 −33
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/data/change-list.tsx
+4 −1
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/data/metric.tsx
+29 −7
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/hero-map.tsx
+221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/home/hero.tsx
+8 −6
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/latest-updates.tsx
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/movers.tsx
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/home/snapshot-strip.tsx
+12 −8
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/trajectory-teaser.tsx
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/transparency.tsx
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/home/world-pulse.tsx
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/indicators/distribution-panel.tsx
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/indicators/indicator-map.tsx
+40 −102
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/indicators/related-table.tsx
+50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/layout/search-dialog.tsx
+21 −6
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/peers/peers-view.tsx
+106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/components/rankings/ranking-view.tsx
+123 −12
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/regions/group-compare-picker.tsx
+40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/regions/group-history.tsx
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/i18n/en.compare.ts
+29 −2
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/i18n/en.core.ts
+212 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/i18n/en.ts
+2 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/client-api.ts
+3 −1
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/compare-state.ts
+6 −6
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/ranking-state.ts
+34 −2
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/types-compare.ts
+11 −2
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/lib/types.ts
+16 −2
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff