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 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { t } from '@/i18n'; |
| 4 | 4 | import { api, isNotBuilt, safe } from '@/lib/api'; |
| 5 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 6 | +import { apiExplore } from '@/lib/api-explore'; | |
| 5 | 7 | import { routes } from '@/lib/site'; |
| 6 | −import { Choropleth } from '@/components/charts/choropleth'; | |
| 8 | +import { HEADLINE_INDICATORS } from '@/lib/topics'; | |
| 7 | 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 | 11 | import { NotBuiltState } from '@/components/data/empty-state'; |
| 10 | 12 | import { Section } from '@/components/data/section'; |
| 13 | +import { SimilarityPlayground } from '@/components/explore/similarity-playground'; | |
| 11 | 14 | import { CompareTeaser } from '@/components/home/compare-teaser'; |
| 12 | 15 | import { IndicatorList } from '@/components/home/featured-indicators'; |
| 13 | 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 | 20 | import { SnapshotStrip } from '@/components/home/snapshot-strip'; |
| 16 | 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 | 27 | export const metadata: Metadata = { alternates: { canonical: '/' } }; |
| 19 | 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). */ | |
| 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; | |
| 30 | +const DEFAULT_MAP = 'gdp-per-capita-ppp'; | |
| 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 | 35 | export default async function HomePage() { |
| 25 | 36 | let home: Awaited<ReturnType<typeof api.home>> | null = null; |
@@ -30,74 +41,117 @@ export default async function HomePage() { | ||
| 30 | 41 | if (isNotBuilt(e)) notBuilt = true; |
| 31 | 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 | 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 | 71 | return ( |
| 37 | 72 | <> |
| 38 | 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')}> | |
| 46 | − <RegionChips /> | |
| 47 | − {map && countries.length ? ( | |
| 48 | − <div className="mt-5 max-w-4xl"> | |
| 49 | − <Choropleth map={map} countries={countries} compact /> | |
| 75 | + {/* Global interactive hero map + time machine */} | |
| 76 | + <section id="map" aria-labelledby="map-h" className="pb-8 md:pb-10"> | |
| 77 | + <h2 id="map-h" className="sr-only"> | |
| 78 | + {t('home.map.title')} | |
| 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 | 125 | </div> |
| 51 | − ) : null} | |
| 52 | − </Section> | |
| 126 | + ); | |
| 127 | + })} | |
| 128 | + </div> | |
| 129 | + </Section> | |
| 53 | 130 | |
| 54 | − <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}> | |
| 55 | − <CompareTeaser countries={countries} /> | |
| 56 | − </Section> | |
| 131 | + {scatter && scatter.points.length ? ( | |
| 132 | + <Section id="trajectory" title={t('home.trajectory.title')} subtitle={t('home.trajectory.sub')}> | |
| 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>}> | |
| 59 | − <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2"> | |
| 60 | − {LIST_KEYS.map((key) => { | |
| 61 | − const list = home.lists[key]; | |
| 62 | − if (!list || !list.rows?.length) return null; | |
| 63 | − // Freshness honesty: a row ≥ 2 years older than the newest row of the list shows its year. | |
| 64 | − const latestYear = Math.max(0, ...list.rows.map((r) => r.year ?? 0)); | |
| 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> | |
| 137 | + <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> | |
| 138 | + <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')}> | |
| 139 | + <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={10} /> | |
| 140 | + </Section> | |
| 141 | + {updates ? ( | |
| 142 | + <Section id="updated" title={t('home.updates.title')} subtitle={t('home.updates.sub')}> | |
| 143 | + <LatestUpdates data={updates} /> | |
| 80 | 144 | </Section> |
| 81 | − | |
| 82 | − <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}> | |
| 83 | − <TopicsGrid /> | |
| 145 | + ) : ( | |
| 146 | + <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')}> | |
| 147 | + <IndicatorList items={home.recently_updated} showUpdated limit={8} /> | |
| 84 | 148 | </Section> |
| 149 | + )} | |
| 150 | + </div> | |
| 85 | 151 | |
| 86 | − <div className="grid gap-x-10 lg:grid-cols-2"> | |
| 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>}> | |
| 88 | − <ChangeList items={home.recent_changes.slice(0, 10)} showCountry /> | |
| 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 | − )} | |
| 152 | + <Section id="transparency" title={t('home.transparency.title')} subtitle={t('home.transparency.sub')}> | |
| 153 | + <Transparency sources={sources?.items ?? []} /> | |
| 154 | + </Section> | |
| 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 | 8 | import type { ChangesFeedResponse } from '@/lib/types-explore'; |
| 9 | 9 | import { ChangesFeed } from '@/components/changes/changes-feed'; |
| 10 | 10 | import { NotBuiltState } from '@/components/data/empty-state'; |
| 11 | +import { Section } from '@/components/data/section'; | |
| 11 | 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 | 17 | export const revalidate = 900; |
| 14 | 18 | |
@@ -18,20 +22,46 @@ export const metadata: Metadata = { | ||
| 18 | 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 | 31 | let data: ChangesFeedResponse | null = null; |
| 23 | 32 | try { |
| 24 | − data = await apiExplore.changes({ limit: 100 }); | |
| 33 | + data = await apiExplore.changes({ limit: 100, country }); | |
| 25 | 34 | } catch (e) { |
| 26 | 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 | 40 | return ( |
| 29 | 41 | <> |
| 30 | 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 | 51 | {!data ? ( |
| 32 | 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 | 65 | <details className="mb-2 border-y border-rule py-3 text-sm"> |
| 36 | 66 | <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink"> |
| 37 | 67 | <span aria-hidden>›</span> {t('changes.methodology')} |
@@ -44,6 +74,9 @@ export default async function ChangesPage() { | ||
| 44 | 74 | </p> |
| 45 | 75 | </details> |
| 46 | 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 | 7 | import { routes } from '@/lib/site'; |
| 8 | 8 | import { topicById } from '@/lib/topics'; |
| 9 | 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 | 13 | import { CompareChart } from '@/components/compare/compare-chart'; |
| 12 | 14 | import { ChartGrid, ChartSections } from '@/components/compare/chart-sections'; |
| 13 | 15 | import { CompareControls } from '@/components/compare/compare-controls'; |
@@ -59,7 +61,7 @@ export async function generateMetadata({ params, searchParams }: { params: Promi | ||
| 59 | 61 | const state = parseCompareState(sp); |
| 60 | 62 | const names = namesOf(r.countries); |
| 61 | 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 | 65 | const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' }); |
| 64 | 66 | const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`; |
| 65 | 67 | return { |
@@ -92,7 +94,7 @@ export default async function CompareViewPage({ params, searchParams }: { params | ||
| 92 | 94 | const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null; |
| 93 | 95 | const customSlugs = state.tab === 'custom' ? state.indicators : []; |
| 94 | 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 | 98 | const [snapshot, hero] = await Promise.all([snapshotP, heroP]); |
| 97 | 99 | |
| 98 | 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 | 108 | .filter((s) => s !== state.indicator) |
| 107 | 109 | .slice(0, state.tab === 'custom' ? 6 : EAGER); |
| 108 | 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 | 112 | if (bundle) { |
| 111 | 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 | 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 | 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 | 152 | <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} /> |
| 146 | 153 | </Section> |
| 147 | 154 | ) : null} |
modified
apps/web/src/app/countries/[slug]/page.tsx
+41 −28
@@ -3,12 +3,16 @@ import Link from 'next/link'; | ||
| 3 | 3 | import { notFound } from 'next/navigation'; |
| 4 | 4 | import { t } from '@/i18n'; |
| 5 | 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 | 8 | import { regionShort } from '@/lib/regions'; |
| 9 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | |
| 10 | +import { routes } from '@/lib/site'; | |
| 8 | 11 | import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics'; |
| 9 | −import type { CountryResponse } from '@/lib/types'; | |
| 10 | −import { DnaRadial } from '@/components/charts/dna-radial'; | |
| 12 | +import type { CountryResponse, FormatSpec } from '@/lib/types'; | |
| 11 | 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 | 16 | import { KeyFacts } from '@/components/country/key-facts'; |
| 13 | 17 | import { SimilarPanel } from '@/components/country/similar-panel'; |
| 14 | 18 | import { Timeline } from '@/components/country/timeline'; |
@@ -38,14 +42,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | ||
| 38 | 42 | const data = await loadCountry(slug); |
| 39 | 43 | if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } }; |
| 40 | 44 | const name = data.country.name ?? slug; |
| 41 | − const title = t('country.title', { name }); | |
| 45 | + const title = seoTitle.country(name); | |
| 42 | 46 | const description = t('country.description', { name }); |
| 43 | 47 | const canonical = routes.country(data.country.slug ?? slug); |
| 44 | 48 | return { |
| 45 | − title, | |
| 49 | + title: { absolute: `${title} | ${t('site.name')}` }, | |
| 46 | 50 | description, |
| 47 | 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 | 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 | 65 | const name = c.name ?? id; |
| 62 | 66 | const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag }; |
| 63 | 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 | 69 | safe(api.countryChanges(id, 8)), |
| 66 | 70 | safe(api.countrySimilar(id, 'overall', 8)), |
| 67 | 71 | safe(api.countryInsights(id)), |
| 68 | 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 | 78 | const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data])); |
| 72 | 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 | 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 | 87 | <TopicNav slug={c.slug ?? slug} counts={counts} /> |
| 78 | 88 | |
| 79 | 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 | 95 | </MetricGrid> |
| 86 | 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 | 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 | 106 | <ChangeList items={changes?.items ?? []} /> |
| 91 | 107 | </Section> |
| 92 | 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 | 110 | </Section> |
| 95 | 111 | </div> |
| 96 | 112 | |
| 97 | 113 | <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> |
| 98 | 114 | <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}> |
| 99 | − <DnaRadial dna={dna} name={name} size={360} /> | |
| 100 | − {dna?.year_ref ? <p className="tnum mt-2 text-center text-2xs text-ink-3">{dna.year_ref}</p> : null} | |
| 115 | + <DnaPanel countryId={id} name={name} initial={dna} /> | |
| 101 | 116 | </Section> |
| 102 | 117 | <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}> |
| 103 | 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 | 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 | 121 | <span className="text-ink-3">{t('country.languages')}: </span> |
| 121 | 122 | {c.languages.join(', ')} |
| 122 | 123 | </p> |
| 123 | 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 | 137 | </Section> |
| 125 | 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 | 4 | import { notFound } from 'next/navigation'; |
| 5 | 5 | import { t, tOpt } from '@/i18n'; |
| 6 | 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; |
| 7 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 7 | 8 | import { apiExplore } from '@/lib/api-explore'; |
| 8 | 9 | import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format'; |
| 10 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | |
| 9 | 11 | import { SITE_URL, routes } from '@/lib/site'; |
| 10 | 12 | import { topicById } from '@/lib/topics'; |
| 11 | 13 | import type { CountrySummary, FormatSpec, Series } from '@/lib/types'; |
| 14 | +import type { FramesResponse } from '@/lib/types-analytics'; | |
| 12 | 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 | 18 | import { EmptyState, NotBuiltState } from '@/components/data/empty-state'; |
| 15 | 19 | import type { ProvenancePayload } from '@/components/data/provenance-context'; |
| 20 | +import { QualityBadges } from '@/components/data/quality-badge'; | |
| 16 | 21 | import { Section } from '@/components/data/section'; |
| 17 | 22 | import { CodeBlock } from '@/components/explore/copy-button'; |
| 18 | 23 | import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; |
| 24 | +import { DistributionPanel } from '@/components/indicators/distribution-panel'; | |
| 19 | 25 | import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare'; |
| 20 | 26 | import { IndicatorMap } from '@/components/indicators/indicator-map'; |
| 21 | 27 | import { IndicatorTrend } from '@/components/indicators/indicator-trend'; |
| 22 | 28 | import { baseFeatures } from '@/components/indicators/map-geometry'; |
| 29 | +import { RelatedTable } from '@/components/indicators/related-table'; | |
| 23 | 30 | |
| 24 | 31 | export const revalidate = 900; |
| 25 | 32 | |
| 26 | 33 | type Params = { slug: string }; |
| 34 | +type SP = Record<string, string | string[] | undefined>; | |
| 27 | 35 | |
| 28 | 36 | async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> { |
| 29 | 37 | try { |
@@ -41,14 +49,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | ||
| 41 | 49 | if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } }; |
| 42 | 50 | const ind = data.indicator; |
| 43 | 51 | const name = titleCase(ind.short_name ?? ind.name ?? slug); |
| 44 | − const title = t('indicator.title', { name }); | |
| 52 | + const title = seoTitle.indicator(name); | |
| 45 | 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 | 54 | const canonical = routes.indicator(ind.slug); |
| 47 | 55 | return { |
| 48 | − title, | |
| 56 | + title: { absolute: `${title} | ${t('site.name')}` }, | |
| 49 | 57 | description, |
| 50 | 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 | 60 | twitter: { card: 'summary_large_image', title, description }, |
| 53 | 61 | }; |
| 54 | 62 | } |
@@ -66,8 +74,41 @@ function specOf(ind: IndicatorResponse['indicator']): FormatSpec { | ||
| 66 | 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> }) { | |
| 70 | − const { slug } = await params; | |
| 77 | +/** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */ | |
| 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 | 112 | const data = await load(slug); |
| 72 | 113 | if (data === null) notFound(); |
| 73 | 114 | if (data === 'not-built') return <NotBuiltState />; |
@@ -76,32 +117,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | ||
| 76 | 117 | const name = ind.name ?? ind.slug; |
| 77 | 118 | const spec = specOf(ind); |
| 78 | 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 | 123 | safe(api.countries()), |
| 82 | − safe(api.indicatorMap(ind.slug, yearUsed ? { year: yearUsed } : {})), | |
| 124 | + safe(apiAnalytics.indicatorFrames(ind.slug)), | |
| 83 | 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 | 131 | const countries = countriesRes?.items ?? []; |
| 87 | 132 | const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country'); |
| 133 | + const byId = new Map(countries.map((c) => [c.id, c])); | |
| 88 | 134 | |
| 89 | 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 | 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 | 140 | .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0)) |
| 93 | 141 | .slice(0, 3) |
| 94 | 142 | .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag })); |
| 95 | 143 | const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null; |
| 96 | 144 | const initialSeries: Series[] = bundle?.series ?? []; |
| 97 | − | |
| 98 | − const years = data.coverage.by_year.map((y) => y.year); | |
| 99 | 145 | const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; |
| 100 | 146 | |
| 101 | 147 | const wl = data.world_latest; |
| 102 | 148 | const worldPayload: ProvenancePayload = { |
| 103 | 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 | 151 | country: null, |
| 106 | 152 | downloadHref: routes.indicatorDownload(ind.slug), |
| 107 | 153 | }; |
@@ -110,28 +156,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | ||
| 110 | 156 | const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best'); |
| 111 | 157 | const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst'); |
| 112 | 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. | |
| 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); | |
| 163 | + const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : []; | |
| 117 | 164 | const topic = topicById(ind.topic ?? ''); |
| 118 | 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 | 166 | const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A'); |
| 121 | 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 | 173 | return ( |
| 124 | 174 | <> |
| 175 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} /> | |
| 125 | 176 | <PageHeader |
| 126 | 177 | crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]} |
| 127 | 178 | eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')} |
| 128 | 179 | title={name} |
| 129 | 180 | lede={ind.description} |
| 130 | 181 | meta={ |
| 131 | − <> | |
| 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) })} | |
| 133 | − {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''} | |
| 134 | − </> | |
| 182 | + <span className="flex flex-wrap items-center gap-x-2 gap-y-1"> | |
| 183 | + <span> | |
| 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) })} | |
| 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 | 191 | actions={ |
| 137 | 192 | <> |
@@ -140,6 +195,9 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | ||
| 140 | 195 | {t('common.ranking')} |
| 141 | 196 | </Link> |
| 142 | 197 | ) : null} |
| 198 | + <Link href={routes.explore({ indicator: ind.slug })} className={ACTION_CLS}> | |
| 199 | + {t('nav.explore')} | |
| 200 | + </Link> | |
| 143 | 201 | <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}> |
| 144 | 202 | <Download size={14} aria-hidden /> {t('common.downloadCsv')} |
| 145 | 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 | 209 | <section aria-label={t('indicator.freshness')} className="border-y border-rule"> |
| 152 | 210 | <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule"> |
| 153 | 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 | 213 | <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd> |
| 156 | 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 | 215 | </div> |
| 158 | 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 | 218 | <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd> |
| 161 | 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 | 221 | </div> |
| 163 | 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 | 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 | 226 | </div> |
| 167 | 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 | 229 | <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd> |
| 170 | 230 | {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null} |
| 171 | 231 | </div> |
@@ -173,11 +233,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | ||
| 173 | 233 | </section> |
| 174 | 234 | |
| 175 | 235 | <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0"> |
| 176 | − {map && features.length ? ( | |
| 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 | − )} | |
| 236 | + {features.length ? <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} frames={frames} spec={spec} payload={worldPayload} initialYear={yearUsed} /> : <EmptyState title={t('common.noDataLong')} />} | |
| 181 | 237 | </Section> |
| 182 | 238 | |
| 183 | 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 | 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 | 294 | <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}> |
| 207 | 295 | <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} /> |
| 208 | 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 | 302 | <Section id="definition" title={t('indicator.definition')} level={2}> |
| 211 | 303 | <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> |
| 212 | 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 | 306 | <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd> |
| 215 | 307 | </div> |
| 216 | 308 | {ind.methodology ? ( |
| 217 | 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 | 311 | <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd> |
| 220 | 312 | </div> |
| 221 | 313 | ) : null} |
| 222 | 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 | 316 | <dd className="mt-0.5 text-ink"> |
| 225 | 317 | {ind.unit ?? t('common.na')} |
| 226 | 318 | {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null} |
| 227 | 319 | </dd> |
| 228 | 320 | </div> |
| 229 | 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 | 323 | <dd className="mt-0.5 text-ink">{freqLabel}</dd> |
| 232 | 324 | </div> |
| 233 | 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 | 327 | <dd className="mt-0.5 text-ink"> |
| 236 | 328 | {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')} |
| 237 | 329 | </dd> |
| 238 | 330 | </div> |
| 239 | 331 | {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? ( |
| 240 | 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 | 334 | <dd className="tnum mt-0.5 text-ink"> |
| 243 | 335 | {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'} |
| 244 | 336 | </dd> |
@@ -260,7 +352,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params | ||
| 260 | 352 | {s.source_name ?? s.source_id} |
| 261 | 353 | </Link> |
| 262 | 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 | 356 | {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null} |
| 265 | 357 | </div> |
| 266 | 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 | 377 | </Section> |
| 286 | 378 | |
| 287 | 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 | 381 | {missing.length ? ( |
| 290 | 382 | <> |
| 291 | 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 | 405 | <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}> |
| 314 | 406 | <Download size={14} aria-hidden /> {t('indicator.downloads.json')} |
| 315 | 407 | </a> |
| 408 | + <Link href={routes.download({ indicators: ind.slug })} className={ACTION_CLS}> | |
| 409 | + {t('indicator.downloads.builder')} | |
| 410 | + </Link> | |
| 316 | 411 | </div> |
| 317 | 412 | <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3> |
| 318 | 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 | 419 | </p> |
| 325 | 420 | </Section> |
| 326 | 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 | 4 | import { notFound } from 'next/navigation'; |
| 5 | 5 | import { t } from '@/i18n'; |
| 6 | 6 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; |
| 7 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 7 | 8 | import { apiCompare } from '@/lib/api-compare'; |
| 8 | 9 | import { grouped } from '@/lib/format'; |
| 9 | 10 | import { parseRankingState, type RankingState } from '@/lib/ranking-state'; |
| 11 | +import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo'; | |
| 10 | 12 | import { routes } from '@/lib/site'; |
| 11 | 13 | import { topicById } from '@/lib/topics'; |
| 12 | 14 | import type { RankingResponse } from '@/lib/types'; |
| 13 | 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 | 17 | import { NotBuiltState } from '@/components/data/empty-state'; |
| 16 | 18 | import { Section } from '@/components/data/section'; |
| 19 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | |
| 17 | 20 | import { RankHistoryPanel } from '@/components/rankings/rank-history-panel'; |
| 18 | 21 | import { RankingView } from '@/components/rankings/ranking-view'; |
| 19 | 22 | |
@@ -21,6 +24,8 @@ export const revalidate = 900; | ||
| 21 | 24 | |
| 22 | 25 | type Params = { indicator: string }; |
| 23 | 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 | 30 | async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> { |
| 26 | 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 | 46 | const name = data.indicator.short_name ?? data.indicator.name ?? indicator; |
| 42 | 47 | const year = data.year_used ?? ''; |
| 43 | 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 | 50 | const top = data.rows |
| 46 | 51 | .slice(0, 3) |
| 47 | 52 | .map((r) => r.country.name) |
@@ -49,12 +54,13 @@ export async function generateMetadata({ params, searchParams }: { params: Promi | ||
| 49 | 54 | .join(', '); |
| 50 | 55 | const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top }); |
| 51 | 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 | 58 | return { |
| 53 | − title, | |
| 59 | + title: { absolute: `${title} | ${t('site.name')}` }, | |
| 54 | 60 | description, |
| 55 | 61 | alternates: { canonical }, |
| 56 | − robots: state.year != null || state.highlight || state.q ? { index: false, follow: true } : undefined, | |
| 57 | − openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, | |
| 62 | + robots: volatile ? { index: false, follow: true } : undefined, | |
| 63 | + openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, | |
| 58 | 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 | 77 | const year = data.year_used; |
| 72 | 78 | const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null; |
| 73 | 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 | 83 | safe(apiCompare.regions()), |
| 77 | 84 | safe(api.countries()), |
| 78 | − year != null ? safe(apiCompare.indicatorMap(ind.slug, { year })) : Promise.resolve(null), | |
| 79 | 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 | 91 | const topic = topicById(ind.topic ?? ''); |
| 83 | 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 | 97 | return ( |
| 86 | 98 | <> |
| 99 | + <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} /> | |
| 87 | 100 | <header className="pb-3 pt-6 md:pt-10"> |
| 88 | 101 | <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0"> |
| 89 | 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 | 121 | {t('ranking.openIndicator')} |
| 109 | 122 | <ExternalLink size={13} aria-hidden /> |
| 110 | 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 | 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 | 128 | <Download size={13} aria-hidden /> |
| 113 | 129 | {t('ranking.download')} |
@@ -120,16 +136,17 @@ export default async function RankingPage({ params, searchParams }: { params: Pr | ||
| 120 | 136 | </div> |
| 121 | 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"> | |
| 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}> | |
| 127 | − {map && countriesRes ? <Choropleth map={map} countries={countriesRes.items} compact /> : <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p>} | |
| 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} /> | |
| 141 | + {showRace ? ( | |
| 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 })}> | |
| 143 | + <RankRace data={race} spec={spec} top={10} initialYear={year} /> | |
| 131 | 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 | 3 | import { notFound } from 'next/navigation'; |
| 4 | 4 | import { t, tOpt } from '@/i18n'; |
| 5 | 5 | import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; |
| 6 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 6 | 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 | 10 | import { routes } from '@/lib/site'; |
| 9 | 11 | import type { IndicatorCard } from '@/lib/types'; |
| 10 | 12 | import type { RegionResponse } from '@/lib/types-explore'; |
| 11 | 13 | import { NotBuiltState } from '@/components/data/empty-state'; |
| 12 | 14 | import { Section } from '@/components/data/section'; |
| 13 | 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 | 18 | import { MemberMap } from '@/components/regions/member-map'; |
| 15 | 19 | import { MemberRanking } from '@/components/regions/member-ranking'; |
| 16 | 20 | import { MembersTable } from '@/components/regions/members-table'; |
@@ -34,10 +38,10 @@ export async function generateMetadata({ params }: { params: Promise<Params> }): | ||
| 34 | 38 | const data = await load(slug); |
| 35 | 39 | if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } }; |
| 36 | 40 | const name = data.group.name ?? slug; |
| 37 | − const title = t('region.title', { name }); | |
| 41 | + const title = seoTitle.region(name); | |
| 38 | 42 | const description = t('region.description', { name, n: data.n_members }); |
| 39 | 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 | 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 | 57 | const name = g.name ?? slug; |
| 54 | 58 | const gslug = g.slug ?? g.id; |
| 55 | 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 | 64 | const countries = countriesRes?.items ?? []; |
| 58 | 65 | const memberIds = data.members.map((m) => m.id); |
| 59 | 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 | 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 | 106 | {aggs.length === 0 ? ( |
| 84 | 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 | 132 | </Section> |
| 110 | 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 | 145 | <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}> |
| 113 | 146 | {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>} |
| 114 | 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 | 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 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { useEffect, useMemo, useRef, useState } from 'react'; |
| 5 | 5 | import { t } from '@/i18n'; |
@@ -25,6 +25,9 @@ const ICON: Record<string, typeof ArrowUpRight> = { | ||
| 25 | 25 | sign_flip: Repeat, |
| 26 | 26 | accelerating: TrendingUp, |
| 27 | 27 | decelerating: TrendingDown, |
| 28 | + structural_break: GitCommitHorizontal, | |
| 29 | + trend_reversal: Repeat, | |
| 30 | + volatility_spike: Waves, | |
| 28 | 31 | }; |
| 29 | 32 | |
| 30 | 33 | type Sev = 0 | 0.4 | 0.7; |
modified
apps/web/src/components/charts/choropleth-view.tsx
+102 −34
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | 2 | import { useRouter } from 'next/navigation'; |
| 3 | −import { useCallback, useId, useState } from 'react'; | |
| 3 | +import { useCallback, useId, useState, type ReactNode } from 'react'; | |
| 4 | 4 | import { t } from '@/i18n'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | 6 | import { formatValue } from '@/lib/format'; |
@@ -20,18 +20,72 @@ export interface ChoroplethFeature { | ||
| 20 | 20 | } |
| 21 | 21 | |
| 22 | 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 | 24 | if (k <= 1) return 4; |
| 25 | 25 | const start = k >= 6 ? 1 : 2; |
| 26 | 26 | const end = 7; |
| 27 | 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 | 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 | 89 | const router = useRouter(); |
| 36 | 90 | const id = useId(); |
| 37 | 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 | 99 | const open = (f: ChoroplethFeature) => { |
| 100 | + if (onSelect) { | |
| 101 | + onSelect(f); | |
| 102 | + setActive(null); | |
| 103 | + return; | |
| 104 | + } | |
| 46 | 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 | 110 | return ( |
| 50 | 111 | <figure className={cn('min-w-0', className)}> |
@@ -58,20 +119,20 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit | ||
| 58 | 119 | <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> |
| 59 | 120 | </pattern> |
| 60 | 121 | </defs> |
| 61 | − <path d={sphere} fill="var(--surface)" stroke="var(--rule)" strokeWidth={1} /> | |
| 62 | − <g stroke="var(--surface)" strokeWidth={0.6} strokeLinejoin="round"> | |
| 122 | + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} /> | |
| 123 | + <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round"> | |
| 63 | 124 | {features.map((f, i) => { |
| 64 | − const hasData = f.cls != null; | |
| 65 | 125 | const isActive = active?.f === f; |
| 126 | + const interactive = !!f.slug || (!!onSelect && !!f.iso3); | |
| 66 | 127 | return ( |
| 67 | 128 | <path |
| 68 | 129 | key={f.iso3 ?? `${f.name}-${i}`} |
| 69 | 130 | d={f.d} |
| 70 | − fill={hasData ? seqVar(stepFor(f.cls!, k)) : `url(#${id}-hatch)`} | |
| 71 | − className={cn(f.slug && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} | |
| 131 | + fill={fill(f)} | |
| 132 | + className={cn(interactive && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} | |
| 72 | 133 | fillOpacity={isActive ? 0.75 : 1} |
| 73 | − tabIndex={f.slug ? 0 : -1} | |
| 74 | − role={f.slug ? 'link' : undefined} | |
| 134 | + tabIndex={interactive ? 0 : -1} | |
| 135 | + role={interactive ? (onSelect ? 'button' : 'link') : undefined} | |
| 75 | 136 | aria-label={`${f.name}: ${formatValue(f.value, spec)}`} |
| 76 | 137 | onPointerMove={(e) => { |
| 77 | 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 | 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 | 148 | if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); |
| 88 | 149 | }} |
| 89 | 150 | onKeyDown={(e) => { |
| 90 | − if (e.key === 'Enter' && f.slug) open(f); | |
| 151 | + if (e.key === 'Enter' && interactive) open(f); | |
| 91 | 152 | }} |
| 92 | 153 | onFocus={(e) => { |
| 93 | 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 | 158 | <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title> |
| 98 | 159 | </path> |
| 99 | 160 | ); |
| 100 | 161 | })} |
| 162 | + {selected ? <path d={selected.d} fill="none" stroke="var(--ink)" strokeWidth={1.6} pointerEvents="none" /> : null} | |
| 101 | 163 | </g> |
| 102 | 164 | </svg> |
| 103 | 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 | 167 | <div className="flex items-center gap-1.5 font-medium text-ink"> |
| 106 | 168 | {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null} |
| 107 | 169 | <span className="truncate">{active.f.name}</span> |
| 108 | 170 | </div> |
| 109 | − <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div> | |
| 110 | − {active.sticky && active.f.slug ? ( | |
| 111 | − <button type="button" className="pointer-events-auto mt-1 text-accent underline" onClick={() => open(active.f)}> | |
| 171 | + {renderLabel ? renderLabel(active.f) : <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div>} | |
| 172 | + {active.sticky && (active.f.slug || onSelect) ? ( | |
| 173 | + <button type="button" className="pointer-events-auto mt-1 min-h-[32px] text-accent underline" onClick={() => open(active.f)}> | |
| 112 | 174 | {t('metric.open', { name: active.f.name })} → |
| 113 | 175 | </button> |
| 114 | 176 | ) : null} |
| 115 | 177 | </div> |
| 116 | 178 | ) : null} |
| 117 | 179 | </div> |
| 118 | − <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> | |
| 119 | − {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} | |
| 120 | − <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | |
| 121 | − {legend.map((l) => ( | |
| 122 | − <li key={l.cls} className="inline-flex items-center gap-1 tnum"> | |
| 123 | − <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} /> | |
| 124 | − {l.label} | |
| 180 | + {showLegend ? ( | |
| 181 | + <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> | |
| 182 | + {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} | |
| 183 | + <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | |
| 184 | + {legend.map((l) => ( | |
| 185 | + <li key={l.cls} className="inline-flex items-center gap-1 tnum"> | |
| 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 | 193 | </li> |
| 126 | − ))} | |
| 127 | − <li className="inline-flex items-center gap-1"> | |
| 128 | − <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> | |
| 129 | − {t('chart.legend.noData')} | |
| 130 | − </li> | |
| 131 | − </ul> | |
| 132 | − <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> | |
| 133 | − <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> | |
| 134 | − </figcaption> | |
| 194 | + </ul> | |
| 195 | + {showHint ? ( | |
| 196 | + <> | |
| 197 | + <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> | |
| 198 | + <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> | |
| 199 | + </> | |
| 200 | + ) : null} | |
| 201 | + </figcaption> | |
| 202 | + ) : null} | |
| 135 | 203 | </figure> |
| 136 | 204 | ); |
| 137 | 205 | } |
modified
apps/web/src/components/charts/dna-radial.tsx
+25 −11
@@ -7,12 +7,12 @@ import { CHART } from './palette'; | ||
| 7 | 7 | export const DNA_DIMS: DnaDimension[] = ['income', 'demographics', 'urbanization', 'trade', 'energy', 'emissions', 'innovation', 'education', 'public_spending']; |
| 8 | 8 | |
| 9 | 9 | /** |
| 10 | − * Radial "fingerprint": 9 dimensions, 0–100 percentile rank, drawn as a filled polygon on a 100-radius | |
| 11 | − * ring with 25/50/75 guide rings and labelled spokes. Server-rendered SVG in a square viewBox; a missing | |
| 12 | − * dimension collapses to the centre and is marked in the legend. | |
| 10 | + * Radial "fingerprint": 9 dimensions, 0–100 percentile rank, drawn as a filled polygon on a 100-radius ring | |
| 11 | + * with 25/50/75 guide rings and labelled spokes. Optional dashed `reference` polygon (world = 50, a region's | |
| 12 | + * median, another country) for comparison. Server-renderable SVG in a square viewBox. | |
| 13 | 13 | */ |
| 14 | −export function DnaRadial({ dna, name, size = 320, className, compact = false }: { dna: Pick<DNAResponse, 'dims' | 'year_ref'> | null; name: string; size?: number; className?: string; compact?: boolean }) { | |
| 15 | − const dims = DNA_DIMS.map((d) => ({ key: d, label: t(`country.dna.${d}` as const), value: dna?.dims?.[d] ?? null })); | |
| 14 | +export function DnaRadial({ dna, name, size = 320, className, compact = false, reference, referenceLabel }: { dna: Pick<DNAResponse, 'dims' | 'year_ref'> | null; name: string; size?: number; className?: string; compact?: boolean; reference?: Record<string, number | null> | null; referenceLabel?: string | null }) { | |
| 15 | + const dims = DNA_DIMS.map((d) => ({ key: d, label: t(`country.dna.${d}` as const), value: dna?.dims?.[d] ?? null, ref: reference?.[d] ?? null })); | |
| 16 | 16 | const present = dims.filter((d) => isNum(d.value)); |
| 17 | 17 | if (!dna || present.length === 0) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>; |
| 18 | 18 | |
@@ -24,6 +24,7 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }: | ||
| 24 | 24 | const angle = (i: number) => -Math.PI / 2 + (i / dims.length) * 2 * Math.PI; |
| 25 | 25 | const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))]; |
| 26 | 26 | const poly = dims.map((d, i) => pt(i, isNum(d.value) ? (d.value / 100) * R : 0)); |
| 27 | + const refPoly = reference ? dims.map((d, i) => pt(i, isNum(d.ref) ? (d.ref / 100) * R : 0)) : null; | |
| 27 | 28 | const summary = t('chart.summary.dna', { name, dims: present.map((d) => `${d.label} ${Math.round(d.value as number)}`).join(', ') }); |
| 28 | 29 | |
| 29 | 30 | return ( |
@@ -38,6 +39,7 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }: | ||
| 38 | 39 | const [x, y] = pt(i, R); |
| 39 | 40 | return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke={CHART.rule} strokeWidth={0.75} />; |
| 40 | 41 | })} |
| 42 | + {refPoly ? <polygon points={refPoly.map((p) => p.join(',')).join(' ')} fill={CHART.ink3} fillOpacity={0.08} stroke={CHART.ink2} strokeWidth={1.5} strokeDasharray="4 3" strokeLinejoin="round" /> : null} | |
| 41 | 43 | <polygon points={poly.map((p) => p.join(',')).join(' ')} fill={CHART.accent} fillOpacity={0.16} stroke={CHART.accent} strokeWidth={2} strokeLinejoin="round" /> |
| 42 | 44 | {poly.map(([x, y], i) => ( |
| 43 | 45 | <circle key={i} cx={x} cy={y} r={isNum(dims[i]!.value) ? 4 : 0} fill={CHART.accent} stroke={CHART.surface} strokeWidth={2} /> |
@@ -51,21 +53,33 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }: | ||
| 51 | 53 | <text key={d.key} x={x} y={y} textAnchor={anchor} dy="0.32em" className={isNum(d.value) ? 'label' : ''} style={{ fontSize: 11, fill: isNum(d.value) ? CHART.ink2 : CHART.ink3 }}> |
| 52 | 54 | {d.label} |
| 53 | 55 | {isNum(d.value) ? ` ${Math.round(d.value)}` : ' —'} |
| 56 | + {reference && isNum(d.ref) ? <tspan style={{ fill: CHART.ink3 }}>{` · ${Math.round(d.ref)}`}</tspan> : null} | |
| 54 | 57 | </text> |
| 55 | 58 | ); |
| 56 | 59 | }) |
| 57 | 60 | : null} |
| 58 | 61 | </svg> |
| 59 | − {compact ? ( | |
| 60 | − <figcaption className="mt-2 grid grid-cols-3 gap-x-3 gap-y-1 text-2xs text-ink-2"> | |
| 61 | − {dims.map((d) => ( | |
| 62 | + <figcaption className={cn('mt-2 text-2xs text-ink-2', compact ? 'grid grid-cols-3 gap-x-3 gap-y-1' : 'flex flex-wrap items-center justify-center gap-x-4 gap-y-1')}> | |
| 63 | + {compact ? ( | |
| 64 | + dims.map((d) => ( | |
| 62 | 65 | <span key={d.key} className="flex justify-between gap-1"> |
| 63 | 66 | <span className="truncate">{d.label}</span> |
| 64 | 67 | <span className="tnum text-ink">{isNum(d.value) ? Math.round(d.value) : '—'}</span> |
| 65 | 68 | </span> |
| 66 | − ))} | |
| 67 | − </figcaption> | |
| 68 | − ) : null} | |
| 69 | + )) | |
| 70 | + ) : ( | |
| 71 | + <> | |
| 72 | + <span className="inline-flex items-center gap-1.5"> | |
| 73 | + <span aria-hidden className="inline-block h-0.5 w-4 rounded-full" style={{ background: CHART.accent }} /> {name} | |
| 74 | + </span> | |
| 75 | + {reference && referenceLabel ? ( | |
| 76 | + <span className="inline-flex items-center gap-1.5"> | |
| 77 | + <span aria-hidden className="inline-block h-0.5 w-4 rounded-full border-t-2 border-dashed" style={{ borderColor: CHART.ink2 }} /> {referenceLabel} | |
| 78 | + </span> | |
| 79 | + ) : null} | |
| 80 | + </> | |
| 81 | + )} | |
| 82 | + </figcaption> | |
| 69 | 83 | </figure> |
| 70 | 84 | ); |
| 71 | 85 | } |
added
apps/web/src/components/charts/export-png.ts
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +/** | |
| 2 | + * Serialise an inline SVG chart to a PNG download (2× scale). CSS variables are resolved to literal colours | |
| 3 | + * first because the serialised SVG is rendered in an isolated <img>, where `var(--…)` has no value. | |
| 4 | + */ | |
| 5 | +export async function downloadSvgAsPng(svg: SVGSVGElement, filename: string, scale = 2): Promise<void> { | |
| 6 | + const clone = svg.cloneNode(true) as SVGSVGElement; | |
| 7 | + const rootStyle = getComputedStyle(document.documentElement); | |
| 8 | + const resolveVars = (s: string) => s.replace(/var\((--[\w-]+)\)/g, (_, name: string) => rootStyle.getPropertyValue(name).trim() || '#888'); | |
| 9 | + const all = [clone, ...Array.from(clone.querySelectorAll<SVGElement>('*'))]; | |
| 10 | + const src = [svg, ...Array.from(svg.querySelectorAll<SVGElement>('*'))]; | |
| 11 | + all.forEach((el, i) => { | |
| 12 | + const s = src[i]; | |
| 13 | + if (!s) return; | |
| 14 | + const cs = getComputedStyle(s); | |
| 15 | + for (const prop of ['fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'font-family', 'font-size', 'font-weight', 'opacity', 'fill-opacity', 'shape-rendering', 'stroke-linecap', 'stroke-linejoin']) { | |
| 16 | + const v = cs.getPropertyValue(prop); | |
| 17 | + if (v) el.style.setProperty(prop, v); | |
| 18 | + } | |
| 19 | + for (const attr of ['fill', 'stroke']) { | |
| 20 | + const a = el.getAttribute(attr); | |
| 21 | + if (a && a.includes('var(')) el.setAttribute(attr, resolveVars(a)); | |
| 22 | + } | |
| 23 | + const st = el.getAttribute('style'); | |
| 24 | + if (st && st.includes('var(')) el.setAttribute('style', resolveVars(st)); | |
| 25 | + }); | |
| 26 | + const width = svg.clientWidth || Number(svg.getAttribute('width')) || 800; | |
| 27 | + const height = svg.clientHeight || Number(svg.getAttribute('height')) || 400; | |
| 28 | + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); | |
| 29 | + clone.setAttribute('width', String(width)); | |
| 30 | + clone.setAttribute('height', String(height)); | |
| 31 | + const bg = rootStyle.getPropertyValue('--surface').trim() || '#ffffff'; | |
| 32 | + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); | |
| 33 | + rect.setAttribute('width', '100%'); | |
| 34 | + rect.setAttribute('height', '100%'); | |
| 35 | + rect.setAttribute('fill', bg); | |
| 36 | + clone.insertBefore(rect, clone.firstChild); | |
| 37 | + const xml = new XMLSerializer().serializeToString(clone); | |
| 38 | + const blob = new Blob([xml], { type: 'image/svg+xml;charset=utf-8' }); | |
| 39 | + const url = URL.createObjectURL(blob); | |
| 40 | + try { | |
| 41 | + const img = new Image(); | |
| 42 | + await new Promise<void>((resolve, reject) => { | |
| 43 | + img.onload = () => resolve(); | |
| 44 | + img.onerror = () => reject(new Error('svg load failed')); | |
| 45 | + img.src = url; | |
| 46 | + }); | |
| 47 | + const canvas = document.createElement('canvas'); | |
| 48 | + canvas.width = Math.round(width * scale); | |
| 49 | + canvas.height = Math.round(height * scale); | |
| 50 | + const ctx = canvas.getContext('2d'); | |
| 51 | + if (!ctx) throw new Error('no canvas'); | |
| 52 | + ctx.scale(scale, scale); | |
| 53 | + ctx.drawImage(img, 0, 0, width, height); | |
| 54 | + const png = canvas.toDataURL('image/png'); | |
| 55 | + const a = document.createElement('a'); | |
| 56 | + a.href = png; | |
| 57 | + a.download = filename.endsWith('.png') ? filename : `${filename}.png`; | |
| 58 | + a.click(); | |
| 59 | + } finally { | |
| 60 | + URL.revokeObjectURL(url); | |
| 61 | + } | |
| 62 | +} | |
added
apps/web/src/components/charts/rank-race.tsx
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useMemo, useState } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { formatValue } from '@/lib/format'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import type { FormatSpec } from '@/lib/types'; | |
| 9 | +import type { RaceResponse } from '@/lib/types-analytics'; | |
| 10 | +import { YearSlider } from '@/components/controls/year-slider'; | |
| 11 | +import { regionColor } from './bubble-chart'; | |
| 12 | +import { ChartFrame, type TableData } from './chart-frame'; | |
| 13 | + | |
| 14 | +const ROW_H = 36; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Bar chart race: the top N countries of a ranking, one horizontal bar per country keyed by id so bars slide | |
| 18 | + * to their new rank (CSS transitions on transform + width) as the year slider plays. Colour = region, flags + | |
| 19 | + * names + values, faded year behind. Data from `/rankings/{indicator}/race`. Table toggle lists the current frame. | |
| 20 | + */ | |
| 21 | +export function RankRace({ data, spec, top = 10, height, interval = 650, className, initialYear }: { data: RaceResponse; spec: FormatSpec; top?: number; height?: number; interval?: number; className?: string; initialYear?: number | null }) { | |
| 22 | + const years = data.years; | |
| 23 | + const [year, setYear] = useState<number>(initialYear && years.includes(initialYear) ? initialYear : years[years.length - 1] ?? 0); | |
| 24 | + const frame = useMemo(() => data.frames.find((f) => f.year === year) ?? data.frames[data.frames.length - 1], [data.frames, year]); | |
| 25 | + const rows = useMemo(() => (frame ? [...frame.rows].sort((a, b) => a.rank - b.rank).slice(0, top) : []), [frame, top]); | |
| 26 | + const max = rows.length ? Math.max(...rows.map((r) => Math.abs(r.value))) || 1 : 1; | |
| 27 | + const n = Math.min(top, data.top); | |
| 28 | + const h = height ?? n * ROW_H + 8; | |
| 29 | + // Every country that ever appears keeps a DOM node so its bar can slide in/out. | |
| 30 | + const ids = useMemo(() => Object.keys(data.countries), [data.countries]); | |
| 31 | + const byId = useMemo(() => new Map(rows.map((r) => [r.id, r])), [rows]); | |
| 32 | + const summary = t('chart.race.summary', { name: spec.name ?? '', y0: years[0] ?? '', y1: years[years.length - 1] ?? '', top: n }); | |
| 33 | + const table: TableData = useMemo( | |
| 34 | + () => ({ columns: [{ key: 'rank', label: t('common.rank'), numeric: true }, { key: 'country', label: t('common.country') }, { key: 'value', label: spec.name ?? t('common.value'), numeric: true }], rows: rows.map((r) => ({ rank: String(r.rank), country: data.countries[r.id]?.name ?? r.id, value: formatValue(r.value, spec) })) }), | |
| 35 | + [rows, data.countries, spec], | |
| 36 | + ); | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <ChartFrame summary={summary} table={rows.length ? table : undefined} className={className} minHeight={h + 64} provenance={data.provenance}> | |
| 40 | + <div className="relative w-full overflow-hidden" style={{ height: h }} aria-hidden> | |
| 41 | + <span className="display pointer-events-none absolute bottom-1 right-2 select-none text-[clamp(3rem,10vw,6rem)] font-semibold leading-none text-rule">{year}</span> | |
| 42 | + {ids.map((id) => { | |
| 43 | + const r = byId.get(id); | |
| 44 | + const c = data.countries[id]; | |
| 45 | + const visible = !!r; | |
| 46 | + const idx = r ? r.rank - 1 : n; | |
| 47 | + return ( | |
| 48 | + <div key={id} className={cn('absolute left-0 right-0 grid grid-cols-[2rem_minmax(0,1fr)_6rem] items-center gap-x-2 sm:grid-cols-[2rem_minmax(0,1fr)_7rem]', !visible && 'pointer-events-none')} style={{ top: 4, transform: `translateY(${idx * ROW_H}px)`, opacity: visible ? 1 : 0, transition: 'transform 600ms cubic-bezier(0.2,0.8,0.2,1), opacity 400ms ease', height: ROW_H - 6 }}> | |
| 49 | + <span className="tnum text-right text-xs text-ink-3">{r?.rank ?? ''}</span> | |
| 50 | + <div className="relative h-full min-w-0"> | |
| 51 | + <div className="absolute inset-y-0 left-0 rounded-r-sm" style={{ width: `${r ? Math.max(1, (Math.abs(r.value) / max) * 100) : 0}%`, background: regionColor(c?.region), opacity: 0.85, transition: 'width 600ms cubic-bezier(0.2,0.8,0.2,1)' }} /> | |
| 52 | + <span className="relative flex h-full items-center gap-1.5 pl-2 text-sm text-ink" style={{ paintOrder: 'stroke' }}> | |
| 53 | + <span aria-hidden>{c?.flag}</span> | |
| 54 | + <span className="truncate font-medium" style={{ textShadow: '0 0 6px var(--surface), 0 0 2px var(--surface)' }}> | |
| 55 | + {c?.name ?? id} | |
| 56 | + </span> | |
| 57 | + </span> | |
| 58 | + </div> | |
| 59 | + <span className="tnum text-right text-sm font-medium text-ink">{r ? formatValue(r.value, spec) : ''}</span> | |
| 60 | + </div> | |
| 61 | + ); | |
| 62 | + })} | |
| 63 | + </div> | |
| 64 | + <ul className="sr-only"> | |
| 65 | + {rows.map((r) => ( | |
| 66 | + <li key={r.id}> | |
| 67 | + {r.rank}. {data.countries[r.id]?.name ?? r.id}: {formatValue(r.value, spec)} ({year}) | |
| 68 | + </li> | |
| 69 | + ))} | |
| 70 | + </ul> | |
| 71 | + <div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-6"> | |
| 72 | + <YearSlider years={years} year={year} onChange={setYear} interval={interval} className="min-w-0 flex-1" ticks /> | |
| 73 | + <Link href={routes.ranking(data.indicator.slug, { year })} className="inline-flex min-h-[36px] shrink-0 items-center text-sm text-accent hover:underline"> | |
| 74 | + {t('chart.race.openYear', { year })} → | |
| 75 | + </Link> | |
| 76 | + </div> | |
| 77 | + </ChartFrame> | |
| 78 | + ); | |
| 79 | +} | |
modified
apps/web/src/components/compare/compare-chart.tsx
+25 −7
@@ -1,8 +1,10 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { Info, X } from 'lucide-react'; | |
| 2 | +import { Download, Info, X } from 'lucide-react'; | |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { usePathname, useRouter } from 'next/navigation'; |
| 5 | 5 | import { useEffect, useMemo, useRef, useState } from 'react'; |
| 6 | +import { downloadSvgAsPng } from '@/components/charts/export-png'; | |
| 7 | +import { apiModeOf } from '@/lib/types-compare'; | |
| 6 | 8 | import { t } from '@/i18n'; |
| 7 | 9 | import { clientCompare } from '@/lib/client-api-compare'; |
| 8 | 10 | import { cn } from '@/lib/cn'; |
@@ -25,6 +27,8 @@ const RANGE_PRESETS = [10, 20, 30] as const; | ||
| 25 | 27 | export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec { |
| 26 | 28 | if (mode === 'index100') return { format: 'index', precision: 1, unit: unitOverride ?? 'index', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better }; |
| 27 | 29 | if (mode === 'pct') return { format: 'percent', precision: 1, unit: '%', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better }; |
| 30 | + if (mode === 'percentile') return { format: 'index', precision: 0, unit: t('compare.mode.percentile'), name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better }; | |
| 31 | + if (mode === 'change') return { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: t('compare.mode.change'), unit_short: ind.unit_short, precision: ind.precision, name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better }; | |
| 28 | 32 | return { format: ind.format, unit: unitOverride ?? ind.unit, unit_short: unitOverride ? null : ind.unit_short, precision: ind.precision, name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better }; |
| 29 | 33 | } |
| 30 | 34 | |
@@ -70,7 +74,7 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h | ||
| 70 | 74 | const ctrl = new AbortController(); |
| 71 | 75 | setError(false); |
| 72 | 76 | clientCompare |
| 73 | − .compare(ids, [indicator.slug], { from: state.from, to: state.to, mode: state.mode }, ctrl.signal) | |
| 77 | + .compare(ids, [indicator.slug], { from: state.from, to: state.to, mode: apiModeOf(state.mode) }, ctrl.signal) | |
| 74 | 78 | .then((r) => { |
| 75 | 79 | loadedKey.current = key; |
| 76 | 80 | setSeries(r.series); |
@@ -94,10 +98,16 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h | ||
| 94 | 98 | countries |
| 95 | 99 | .map((c, i) => { |
| 96 | 100 | const s = series?.find((x) => x.country.id === c.id); |
| 97 | − return { id: c.id, name: c.name, colorIndex: i, points: s ? pointsFromSeries(s.values) : [] }; | |
| 101 | + let points = s ? pointsFromSeries(s.values) : []; | |
| 102 | + if (state.mode === 'change' && points.length) { | |
| 103 | + // Change since the first actual value of the range (client-side; the API returns absolute values). | |
| 104 | + const base = points.find((p) => p.value != null && !p.is_forecast)?.value ?? null; | |
| 105 | + points = base == null ? [] : points.map((p) => ({ ...p, value: p.value == null ? null : p.value - base })); | |
| 106 | + } | |
| 107 | + return { id: c.id, name: c.name, colorIndex: i, points }; | |
| 98 | 108 | }) |
| 99 | 109 | .filter((l) => l.points.length > 0), |
| 100 | − [series, countries], | |
| 110 | + [series, countries, state.mode], | |
| 101 | 111 | ); |
| 102 | 112 | |
| 103 | 113 | const latest = countries.map((c, i) => { |
@@ -133,7 +143,11 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h | ||
| 133 | 143 | const height = hero ? HERO_H : CHART_H; |
| 134 | 144 | const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false }); |
| 135 | 145 | const rangeActive = (n: number | null) => (n == null ? state.from == null : maxYear != null && state.from === maxYear - n); |
| 136 | − const subtitleUnit = state.mode === 'index100' ? t('compare.charts.indexBase', { year: series?.[0]?.transform?.base_year ?? series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'pct' ? t('compare.charts.pctUnit') : (unitOverride ?? indicator.unit); | |
| 146 | + const subtitleUnit = state.mode === 'index100' ? t('compare.charts.indexBase', { year: series?.[0]?.transform?.base_year ?? series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'pct' ? t('compare.charts.pctUnit') : state.mode === 'change' ? t('compare.charts.changeSince', { year: series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'percentile' ? t('compare.charts.percentileNote') : (unitOverride ?? indicator.unit); | |
| 147 | + const exportPng = () => { | |
| 148 | + const svg = ref.current?.querySelector<SVGSVGElement>('svg.ca-chart'); | |
| 149 | + if (svg) void downloadSvgAsPng(svg, `countryatlas-${indicator.slug}-${ids.join('-')}`); | |
| 150 | + }; | |
| 137 | 151 | |
| 138 | 152 | return ( |
| 139 | 153 | <article ref={ref} id={`chart-${indicator.slug}`} className={cn('min-w-0 scroll-mt-40', hero && 'rounded-sm border border-rule bg-surface p-4 md:p-5')} aria-labelledby={`chart-${indicator.slug}-h`}> |
@@ -141,7 +155,7 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h | ||
| 141 | 155 | <div className="min-w-0"> |
| 142 | 156 | {hero ? <div className="eyebrow mb-0.5">{t('compare.hero.title')}</div> : null} |
| 143 | 157 | <h3 id={`chart-${indicator.slug}-h`} className={cn('font-semibold leading-snug text-ink', hero ? 'display text-xl md:text-2xl' : 'text-sm')}> |
| 144 | − <Link href={routes.indicator(indicator.slug)} className="link-quiet"> | |
| 158 | + <Link href={routes.indicator(indicator.slug)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0"> | |
| 145 | 159 | {name} |
| 146 | 160 | </Link> |
| 147 | 161 | </h3> |
@@ -157,9 +171,13 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h | ||
| 157 | 171 | ))} |
| 158 | 172 | </div> |
| 159 | 173 | ) : null} |
| 160 | − <Link href={routes.ranking(indicator.slug)} className="inline-flex min-h-[32px] items-center rounded-sm px-1.5 text-ink-2 hover:text-accent"> | |
| 174 | + <Link href={routes.ranking(indicator.slug)} className="inline-flex min-h-[44px] items-center rounded-sm px-1.5 text-ink-2 hover:text-accent md:min-h-[32px]"> | |
| 161 | 175 | {t('compare.hero.ranking')} |
| 162 | 176 | </Link> |
| 177 | + <button type="button" onClick={exportPng} className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1 rounded-sm px-1.5 text-ink-2 hover:text-accent md:min-h-[32px] md:min-w-0" aria-label={t('compare.charts.png')} title={t('compare.charts.png')}> | |
| 178 | + <Download size={13} aria-hidden /> | |
| 179 | + <span className="hidden sm:inline">PNG</span> | |
| 180 | + </button> | |
| 163 | 181 | {hero ? ( |
| 164 | 182 | <button type="button" onClick={() => router.replace(`${pathname}${compareQuery({ ...state, indicator: null })}`, { scroll: false })} className="tap -mr-2 grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink md:min-h-[32px] md:min-w-[32px]" aria-label={t('compare.hero.close')}> |
| 165 | 183 | <X size={16} aria-hidden /> |
modified
apps/web/src/components/compare/compare-controls.tsx
+3 −3
@@ -6,7 +6,7 @@ import { t } from '@/i18n'; | ||
| 6 | 6 | import { cn } from '@/lib/cn'; |
| 7 | 7 | import { COMPARE_TABS, MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state'; |
| 8 | 8 | import { routes } from '@/lib/site'; |
| 9 | −import { COMPARE_MODES, type CompareMode, type CountryLite } from '@/lib/types-compare'; | |
| 9 | +import { COMPARE_UI_MODES, type CompareUiMode, type CountryLite } from '@/lib/types-compare'; | |
| 10 | 10 | import { seriesVar } from '@/components/charts/palette'; |
| 11 | 11 | import { BottomSheet } from '@/components/data/bottom-sheet'; |
| 12 | 12 | import { CountryPickerSheet } from './country-picker'; |
@@ -107,8 +107,8 @@ export function CompareControls({ countries, allCountries, state, maxYear, downl | ||
| 107 | 107 | </label> |
| 108 | 108 | </fieldset> |
| 109 | 109 | <div role="radiogroup" aria-label={t('compare.valueMode')} className="flex flex-wrap gap-1"> |
| 110 | − {COMPARE_MODES.map((m: CompareMode) => ( | |
| 111 | − <button key={m} type="button" role="radio" aria-checked={state.mode === m} onClick={() => setState({ mode: m })} className={cn('inline-flex h-11 items-center rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.mode === m ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 110 | + {COMPARE_UI_MODES.map((m: CompareUiMode) => ( | |
| 111 | + <button key={m} type="button" role="radio" aria-checked={state.mode === m} onClick={() => setState({ mode: m })} title={t(`compare.mode.hint.${m}` as 'compare.mode.hint.pct')} className={cn('inline-flex h-11 items-center rounded-sm border px-2.5 text-sm md:h-9 md:px-2', state.mode === m ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 112 | 112 | {t(`compare.mode.${m}` as 'compare.mode.absolute')} |
| 113 | 113 | </button> |
| 114 | 114 | ))} |
added
apps/web/src/components/compare/head-to-head.tsx
+118 −0
@@ -0,0 +1,118 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowDown, ArrowUp } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { displayValue, isNum, ordinal } from '@/lib/format'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import type { MetricValue } from '@/lib/types'; | |
| 9 | +import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare'; | |
| 10 | +import { seriesVar } from '@/components/charts/palette'; | |
| 11 | +import { payloadFor } from '@/components/data/metric'; | |
| 12 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 13 | +import { chartHrefFor } from './snapshot-table'; | |
| 14 | +import type { CompareState } from '@/lib/compare-state'; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * Head-to-head view for exactly two countries: the indicator in the middle, one value per side, mirrored bars | |
| 18 | + * proportional to the larger value, the year under each value, and a thin world-percentile track with both | |
| 19 | + * countries marked (from rank / n). No "winner" language — a small glyph shows the declared direction only. | |
| 20 | + */ | |
| 21 | +export function HeadToHead({ rows, countries, slugs, state }: { rows: CompareSnapshotRow[]; countries: CountryLite[]; slugs: string[]; state: CompareState }) { | |
| 22 | + const { open } = useProvenance(); | |
| 23 | + const [a, b] = countries; | |
| 24 | + if (!a || !b) return null; | |
| 25 | + const visible = rows.filter((r) => r.values[a.id]?.has_data || r.values[b.id]?.has_data); | |
| 26 | + if (!visible.length) return <p className="py-6 text-sm text-ink-3">{t('compare.snapshot.empty')}</p>; | |
| 27 | + const openCell = (m: MetricValue, c: CountryLite, name: string) => open(payloadFor(m, { id: c.id, slug: c.slug, name: c.name, flag: c.flag }, { name })); | |
| 28 | + | |
| 29 | + return ( | |
| 30 | + <div className="min-w-0"> | |
| 31 | + <div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3 border-b border-rule pb-3"> | |
| 32 | + {[a, b].map((c, i) => ( | |
| 33 | + <Link key={c.id} href={routes.country(c.slug)} className={cn('link-quiet flex min-w-0 items-center gap-2', i === 1 && 'flex-row-reverse text-right')}> | |
| 34 | + <span aria-hidden className="text-3xl leading-none md:text-4xl"> | |
| 35 | + {c.flag} | |
| 36 | + </span> | |
| 37 | + <span className="min-w-0"> | |
| 38 | + <span className="display block truncate text-xl leading-tight text-ink md:text-2xl">{c.name}</span> | |
| 39 | + <span className="mt-0.5 inline-flex items-center gap-1.5 text-2xs text-ink-3"> | |
| 40 | + <span aria-hidden className="inline-block h-2 w-2 rounded-full" style={{ background: seriesVar(i) }} /> | |
| 41 | + {c.id} | |
| 42 | + </span> | |
| 43 | + </span> | |
| 44 | + </Link> | |
| 45 | + ))} | |
| 46 | + <span className="display col-start-2 row-start-1 self-center text-base text-ink-3 md:text-lg">{t('compare.vs')}</span> | |
| 47 | + </div> | |
| 48 | + <ol className="divide-y divide-rule"> | |
| 49 | + {visible.map((r) => { | |
| 50 | + const ma = r.values[a.id]; | |
| 51 | + const mb = r.values[b.id]; | |
| 52 | + const va = ma?.has_data && isNum(ma.value) ? ma.value : null; | |
| 53 | + const vb = mb?.has_data && isNum(mb.value) ? mb.value : null; | |
| 54 | + const max = Math.max(Math.abs(va ?? 0), Math.abs(vb ?? 0)) || 1; | |
| 55 | + const name = r.indicator.short_name ?? r.indicator.name ?? r.indicator.slug; | |
| 56 | + const hib = r.indicator.higher_is_better; | |
| 57 | + const pctOf = (m: MetricValue | undefined) => (m && isNum(m.rank_world) && isNum(m.n_world) && m.n_world > 1 ? (1 - (m.rank_world - 1) / (m.n_world - 1)) * 100 : null); | |
| 58 | + const pa = pctOf(ma); | |
| 59 | + const pb = pctOf(mb); | |
| 60 | + return ( | |
| 61 | + <li key={r.indicator.slug} className="py-3"> | |
| 62 | + <div className="mb-1 flex items-center justify-center gap-1.5 text-center"> | |
| 63 | + <Link href={chartHrefFor(slugs, state, r.indicator)} 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-sm font-semibold text-ink"> | |
| 64 | + {name} | |
| 65 | + </Link> | |
| 66 | + {hib != null ? ( | |
| 67 | + <span className="inline-flex items-center text-2xs text-ink-3" title={hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}> | |
| 68 | + {hib ? <ArrowUp size={11} aria-hidden /> : <ArrowDown size={11} aria-hidden />} | |
| 69 | + <span className="sr-only">{hib ? t('compare.snapshot.hib.higher') : t('compare.snapshot.hib.lower')}</span> | |
| 70 | + </span> | |
| 71 | + ) : null} | |
| 72 | + <span className="text-2xs text-ink-3">{r.indicator.unit}</span> | |
| 73 | + </div> | |
| 74 | + <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-x-3"> | |
| 75 | + <Side m={ma} c={a} i={0} max={max} align="right" onOpen={() => ma && openCell(ma, a, r.indicator.name ?? name)} /> | |
| 76 | + <Side m={mb} c={b} i={1} max={max} align="left" onOpen={() => mb && openCell(mb, b, r.indicator.name ?? name)} /> | |
| 77 | + </div> | |
| 78 | + {pa != null || pb != null ? ( | |
| 79 | + <div className="mx-auto mt-2 max-w-md"> | |
| 80 | + <div className="relative h-1.5 rounded-xs bg-surface-2" aria-hidden> | |
| 81 | + {pa != null ? <span className="absolute top-1/2 h-3 w-1 -translate-x-1/2 -translate-y-1/2 rounded-xs" style={{ left: `${pa}%`, background: seriesVar(0) }} /> : null} | |
| 82 | + {pb != null ? <span className="absolute top-1/2 h-3 w-1 -translate-x-1/2 -translate-y-1/2 rounded-xs" style={{ left: `${pb}%`, background: seriesVar(1) }} /> : null} | |
| 83 | + </div> | |
| 84 | + <div className="tnum mt-0.5 flex justify-between text-2xs text-ink-3"> | |
| 85 | + <span>{t('compare.h2h.pctLow')}</span> | |
| 86 | + <span className="sr-only"> | |
| 87 | + {a.name}: {pa != null ? ordinal(Math.round(pa)) : t('common.na')} · {b.name}: {pb != null ? ordinal(Math.round(pb)) : t('common.na')} | |
| 88 | + </span> | |
| 89 | + <span>{t('compare.h2h.pctTrack', { n: ma?.n_world ?? mb?.n_world ?? '' })}</span> | |
| 90 | + <span>{t('compare.h2h.pctHigh')}</span> | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + ) : null} | |
| 94 | + </li> | |
| 95 | + ); | |
| 96 | + })} | |
| 97 | + </ol> | |
| 98 | + <p className="mt-3 text-xs text-ink-3">{t('compare.h2h.note')}</p> | |
| 99 | + </div> | |
| 100 | + ); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function Side({ m, c, i, max, align, onOpen }: { m: MetricValue | undefined; c: CountryLite; i: number; max: number; align: 'left' | 'right'; onOpen: () => void }) { | |
| 104 | + const has = !!m && m.has_data && isNum(m.value); | |
| 105 | + const pct = has ? Math.max(1.5, (Math.abs(m.value!) / max) * 100) : 0; | |
| 106 | + return ( | |
| 107 | + <button type="button" disabled={!has} onClick={onOpen} className={cn('flex min-h-[44px] min-w-0 flex-col justify-center rounded-sm px-1 hover:bg-surface-2 disabled:hover:bg-transparent', align === 'right' ? 'items-end text-right' : 'items-start text-left')} aria-label={has ? `${c.name}: ${displayValue(m!.value, m!, m!.formatted)} (${m!.year ?? ''}). ${t('common.openProvenance')}` : `${c.name}: ${t('common.noData')}`}> | |
| 108 | + <span className={cn('tnum text-lg font-semibold leading-tight md:text-xl', has ? 'text-ink' : 'text-ink-3')}>{has ? displayValue(m!.value, m!, m!.formatted) : t('common.noData')}</span> | |
| 109 | + <span className={cn('mt-1 flex h-2 w-full', align === 'right' ? 'justify-end' : 'justify-start')} aria-hidden> | |
| 110 | + <span className={cn('block h-full', align === 'right' ? 'rounded-l-xs' : 'rounded-r-xs')} style={{ width: `${pct}%`, background: seriesVar(i), opacity: 0.85 }} /> | |
| 111 | + </span> | |
| 112 | + <span className="tnum mt-0.5 text-2xs text-ink-3"> | |
| 113 | + {has ? m!.year : ''} | |
| 114 | + {has && isNum(m!.rank_world) && isNum(m!.n_world) ? ` · ${t('compare.snapshot.rank', { rank: m!.rank_world })} / ${m!.n_world}` : ''} | |
| 115 | + </span> | |
| 116 | + </button> | |
| 117 | + ); | |
| 118 | +} | |
modified
apps/web/src/components/compare/snapshot-table.tsx
+18 −5
@@ -4,7 +4,7 @@ import Link from 'next/link'; | ||
| 4 | 4 | import { t } from '@/i18n'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | 6 | import { COMPARE_TOPIC_TABS, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state'; |
| 7 | −import { displayValue, isNum, ordinal } from '@/lib/format'; | |
| 7 | +import { displayValue, formatChange, isNum, ordinal } from '@/lib/format'; | |
| 8 | 8 | import { routes } from '@/lib/site'; |
| 9 | 9 | import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare'; |
| 10 | 10 | import type { MetricValue } from '@/lib/types'; |
@@ -12,6 +12,19 @@ import { seriesVar } from '@/components/charts/palette'; | ||
| 12 | 12 | import { payloadFor } from '@/components/data/metric'; |
| 13 | 13 | import { useProvenance } from '@/components/data/provenance-context'; |
| 14 | 14 | |
| 15 | +/** Cell text for the current value mode: absolute value, world percentile (from rank / n) or 10-year change. */ | |
| 16 | +export function cellText(m: MetricValue, mode: CompareState['mode']): { text: string; sub: string | null } { | |
| 17 | + if (mode === 'percentile') { | |
| 18 | + if (isNum(m.rank_world) && isNum(m.n_world) && m.n_world > 1) return { text: t('compare.cell.percentile', { p: ordinal(Math.round((1 - (m.rank_world - 1) / (m.n_world - 1)) * 100)) }), sub: `${m.rank_world}/${m.n_world}` }; | |
| 19 | + return { text: t('common.na'), sub: null }; | |
| 20 | + } | |
| 21 | + if (mode === 'change') { | |
| 22 | + const c = m.change_10y ? formatChange(m.change_10y.abs, m.change_10y.pct, m) : null; | |
| 23 | + return c ? { text: m.change_10y?.formatted ?? c.text, sub: t('compare.cell.change10y') } : { text: t('common.na'), sub: null }; | |
| 24 | + } | |
| 25 | + return { text: displayValue(m.value, m, m.formatted), sub: null }; | |
| 26 | +} | |
| 27 | + | |
| 15 | 28 | /** Where a snapshot indicator's chart lives: its topic tab when it is one of the compare tabs, else the custom tab. */ |
| 16 | 29 | export function chartHrefFor(slugs: string[], state: CompareState, indicator: { slug: string; topic: string | null }): string { |
| 17 | 30 | const tab = (COMPARE_TOPIC_TABS as readonly string[]).includes(indicator.topic ?? '') ? (indicator.topic as CompareTab) : 'custom'; |
@@ -125,9 +138,9 @@ export function SnapshotTable({ rows, countries, slugs, state }: { rows: Compare | ||
| 125 | 138 | return ( |
| 126 | 139 | <td key={c.id} className={cn('py-1 pr-1 text-right align-top', best && 'bg-accent-soft/60')}> |
| 127 | 140 | <button type="button" onClick={() => openCell(m, c, r.indicator.name ?? name)} className="group/c -my-0 flex min-h-[44px] w-full flex-col items-end rounded-sm px-2 py-1 text-right hover:bg-surface-2" aria-label={t('common.openProvenance')}> |
| 128 | − <span className={cn('tnum text-base leading-tight', best ? 'font-semibold text-ink' : 'text-ink')}>{displayValue(m.value, m, m.formatted)}</span> | |
| 141 | + <span className={cn('tnum text-base leading-tight', best ? 'font-semibold text-ink' : 'text-ink')}>{cellText(m, state.mode).text}</span> | |
| 129 | 142 | <span className="tnum mt-0.5 flex items-center gap-1.5 text-2xs text-ink-3"> |
| 130 | − <span>{m.year ?? ''}</span> | |
| 143 | + <span>{cellText(m, state.mode).sub ?? m.year ?? ''}</span> | |
| 131 | 144 | {isNum(m.rank_world) && isNum(m.n_world) ? ( |
| 132 | 145 | <span className={cn('rounded-xs px-1 py-px', best ? 'bg-accent text-accent-ink' : 'bg-surface-2 text-ink-2')} title={t('compare.snapshot.rankOf', { rank: ordinal(m.rank_world), n: m.n_world })}> |
| 133 | 146 | {t('compare.snapshot.rank', { rank: m.rank_world })} |
@@ -186,8 +199,8 @@ export function SnapshotTable({ rows, countries, slugs, state }: { rows: Compare | ||
| 186 | 199 | </span> |
| 187 | 200 | </span> |
| 188 | 201 | <span className="tnum flex items-baseline gap-1.5 text-right"> |
| 189 | − <span className={cn('text-sm', best ? 'font-semibold text-ink' : 'text-ink')}>{has ? displayValue(m.value, m, m.formatted) : t('common.noData')}</span> | |
| 190 | − <span className="text-2xs text-ink-3">{has ? m.year : ''}</span> | |
| 202 | + <span className={cn('text-sm', best ? 'font-semibold text-ink' : 'text-ink')}>{has ? cellText(m, state.mode).text : t('common.noData')}</span> | |
| 203 | + <span className="text-2xs text-ink-3">{has ? cellText(m, state.mode).sub ?? m.year : ''}</span> | |
| 191 | 204 | </span> |
| 192 | 205 | </button> |
| 193 | 206 | </li> |
added
apps/web/src/components/country/copy-api-button.tsx
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Check, Code2 } from 'lucide-react'; | |
| 3 | +import { useState } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { SITE_URL } from '@/lib/site'; | |
| 6 | + | |
| 7 | +/** Copies the absolute API URL of the entity (country / indicator) to the clipboard; falls back to opening it. */ | |
| 8 | +export function CopyApiButton({ path, className }: { path: string; className?: string }) { | |
| 9 | + const [done, setDone] = useState(false); | |
| 10 | + const url = `${SITE_URL}${path}`; | |
| 11 | + const copy = async () => { | |
| 12 | + try { | |
| 13 | + await navigator.clipboard.writeText(url); | |
| 14 | + setDone(true); | |
| 15 | + setTimeout(() => setDone(false), 1800); | |
| 16 | + } catch { | |
| 17 | + window.open(path, '_blank', 'noopener'); | |
| 18 | + } | |
| 19 | + }; | |
| 20 | + return ( | |
| 21 | + <button type="button" onClick={copy} className={`inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 ${className ?? ''}`} aria-live="polite" title={url}> | |
| 22 | + {done ? <Check size={15} aria-hidden className="text-up" /> : <Code2 size={15} aria-hidden />} | |
| 23 | + {done ? t('common.copied') : t('country.api')} | |
| 24 | + </button> | |
| 25 | + ); | |
| 26 | +} | |
modified
apps/web/src/components/country/country-header.tsx
+88 −33
@@ -1,67 +1,122 @@ | ||
| 1 | 1 | import { Download, Scale } from 'lucide-react'; |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { t } from '@/i18n'; |
| 4 | −import { compact, formatDate, formatPct, grouped, isNum } from '@/lib/format'; | |
| 4 | +import { compact, displayValue, formatDate, formatPct, grouped, isNum } from '@/lib/format'; | |
| 5 | 5 | import { routes } from '@/lib/site'; |
| 6 | 6 | import type { CountryResponse } from '@/lib/types'; |
| 7 | +import type { CountryQualityResponse } from '@/lib/types-analytics'; | |
| 7 | 8 | import { FreshnessBadge } from '@/components/data/freshness-badge'; |
| 9 | +import { QualityBadges } from '@/components/data/quality-badge'; | |
| 10 | +import { CopyApiButton } from './copy-api-button'; | |
| 11 | +import { MiniMap } from './mini-map'; | |
| 8 | 12 | import { ShareButton } from './share-button'; |
| 9 | 13 | |
| 10 | 14 | /** |
| 11 | − * Compact country header: flag, name, official name, capital · region · income group, population, area, | |
| 12 | − * currency, freshness + coverage; primary actions Compare / Download / Share. No card: one rule below. | |
| 15 | + * Country hero 2.0: flag + display name + official name, chips (region · income · capital), a fact strip | |
| 16 | + * (population, GDP, GDP/capita, area, currency), locator mini map, bordering countries, freshness + coverage | |
| 17 | + * badges, actions Compare / Download / Share / API. Editorial: one rule below, no card. | |
| 13 | 18 | */ |
| 14 | −export function CountryHeader({ data }: { data: CountryResponse }) { | |
| 19 | +export function CountryHeader({ data, quality }: { data: CountryResponse; quality: CountryQualityResponse | null }) { | |
| 15 | 20 | const c = data.country; |
| 16 | 21 | const name = c.name ?? c.id; |
| 17 | − const pop = data.headline.find((m) => m.indicator === 'population'); | |
| 18 | − const facts: Array<[string, string]> = []; | |
| 19 | − if (c.capital) facts.push([t('country.capital'), c.capital]); | |
| 20 | − if (c.region_name) facts.push([t('country.region'), c.region_name]); | |
| 21 | − if (c.income_name) facts.push([t('country.incomeGroup'), c.income_name]); | |
| 22 | − if (pop?.has_data && isNum(pop.value)) facts.push([t('country.population'), `${pop.formatted ?? compact(pop.value)}${pop.year ? ` (${pop.year})` : ''}`]); | |
| 23 | − if (isNum(c.area_km2)) facts.push([t('country.area'), `${grouped(c.area_km2)} km²`]); | |
| 24 | − if (c.currency_name) facts.push([t('country.currency'), `${c.currency_name}${c.currency_code ? ` (${c.currency_code})` : ''}`]); | |
| 22 | + const pick = (id: string) => data.headline.find((m) => m.indicator === id && m.has_data) ?? null; | |
| 23 | + const pop = pick('population'); | |
| 24 | + const gdp = pick('gdp'); | |
| 25 | + const gpc = pick('gdp-per-capita'); | |
| 26 | + const facts: Array<{ k: string; v: string; sub?: string }> = []; | |
| 27 | + if (pop && isNum(pop.value)) facts.push({ k: t('country.population'), v: displayValue(pop.value, pop, pop.formatted), sub: pop.year ? String(pop.year) : undefined }); | |
| 28 | + if (gdp && isNum(gdp.value)) facts.push({ k: t('common.gdp'), v: displayValue(gdp.value, gdp, gdp.formatted), sub: gdp.year ? String(gdp.year) : undefined }); | |
| 29 | + if (gpc && isNum(gpc.value)) facts.push({ k: t('common.gdpPerCapita'), v: displayValue(gpc.value, gpc, gpc.formatted), sub: gpc.year ? String(gpc.year) : undefined }); | |
| 30 | + if (isNum(c.area_km2)) facts.push({ k: t('country.area'), v: `${c.area_km2 >= 1e6 ? compact(c.area_km2) : grouped(c.area_km2)} km²` }); | |
| 31 | + if (c.currency_code) facts.push({ k: t('country.currency'), v: c.currency_code, sub: c.currency_name ?? undefined }); | |
| 32 | + const chips = [c.region_name, c.income_name, c.capital ? `${t('country.capital')} ${c.capital}` : null].filter(Boolean) as string[]; | |
| 33 | + const badges = quality ? deriveCountryBadges(quality) : null; | |
| 25 | 34 | |
| 26 | 35 | return ( |
| 27 | − <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-10"> | |
| 28 | − <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> | |
| 36 | + <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-8"> | |
| 37 | + <div className="grid gap-x-8 gap-y-5 md:grid-cols-[minmax(0,1fr)_16rem] lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 29 | 38 | <div className="min-w-0"> |
| 30 | − <div className="flex items-start gap-3"> | |
| 31 | − <span aria-hidden className="text-4xl leading-none md:text-5xl"> | |
| 39 | + <div className="flex items-start gap-3 md:gap-4"> | |
| 40 | + <span aria-hidden className="text-4xl leading-none md:text-6xl"> | |
| 32 | 41 | {c.flag} |
| 33 | 42 | </span> |
| 34 | 43 | <div className="min-w-0"> |
| 35 | 44 | <h1 className="display text-3xl leading-none text-ink md:text-5xl">{name}</h1> |
| 36 | 45 | {c.official_name && c.official_name !== name ? <p className="mt-1.5 text-sm text-ink-2">{c.official_name}</p> : null} |
| 46 | + <ul className="mt-2 flex flex-wrap gap-1.5 text-xs"> | |
| 47 | + {chips.map((ch) => ( | |
| 48 | + <li key={ch} className="badge border-rule text-ink-2"> | |
| 49 | + {ch} | |
| 50 | + </li> | |
| 51 | + ))} | |
| 52 | + {c.landlocked ? <li className="badge border-rule text-ink-3">{t('country.landlocked')}</li> : null} | |
| 53 | + {c.status === 'territory' ? <li className="badge border-rule text-ink-3">{t('countries.territory')}</li> : null} | |
| 54 | + </ul> | |
| 37 | 55 | </div> |
| 38 | 56 | </div> |
| 39 | − <dl className="mt-4 flex flex-wrap gap-x-5 gap-y-1.5 text-sm"> | |
| 40 | − {facts.map(([k, v]) => ( | |
| 41 | − <div key={k} className="flex items-baseline gap-1.5"> | |
| 42 | − <dt className="text-ink-3">{k}</dt> | |
| 43 | − <dd className="tnum text-ink">{v}</dd> | |
| 57 | + | |
| 58 | + <dl className="ticker mt-5 gap-0 border-y border-rule sm:grid sm:grid-cols-5 sm:divide-x sm:divide-rule"> | |
| 59 | + {facts.map((f, i) => ( | |
| 60 | + <div key={f.k} className={`min-w-[8rem] py-3 pr-5 sm:min-w-0 sm:pr-4 ${i > 0 ? 'border-l border-rule pl-4 sm:border-l-0' : ''}`}> | |
| 61 | + <dt className="eyebrow">{f.k}</dt> | |
| 62 | + <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{f.v}</dd> | |
| 63 | + {f.sub ? <dd className="tnum mt-1 truncate text-xs text-ink-3">{f.sub}</dd> : null} | |
| 44 | 64 | </div> |
| 45 | 65 | ))} |
| 46 | 66 | </dl> |
| 47 | − <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-3"> | |
| 67 | + | |
| 68 | + <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-xs text-ink-3"> | |
| 48 | 69 | {data.freshness.retrieved_at ? <span className="tnum">{t('country.refreshed', { date: formatDate(data.freshness.built_at ?? data.freshness.retrieved_at) })}</span> : null} |
| 49 | 70 | <FreshnessBadge retrievedAt={data.freshness.retrieved_at} now={Date.parse(data.meta.generated_at) || Date.now()} /> |
| 50 | − {data.coverage ? <span className="tnum">{t('country.coverage', { pct: formatPct(data.coverage.coverage_pct), n: grouped(data.coverage.n_indicators ?? 0) })}</span> : null} | |
| 51 | − {c.landlocked ? <span className="rounded-xs border border-rule px-1 py-px">{t('country.landlocked')}</span> : null} | |
| 52 | − {c.status === 'territory' ? <span className="rounded-xs border border-rule px-1 py-px">{t('countries.territory')}</span> : null} | |
| 71 | + {quality ? <span className="tnum">{t('country.coverage', { pct: formatPct(quality.summary.coverage_pct), n: grouped(quality.summary.n_with_data) })}</span> : data.coverage ? <span className="tnum">{t('country.coverage', { pct: formatPct(data.coverage.coverage_pct), n: grouped(data.coverage.n_indicators ?? 0) })}</span> : null} | |
| 72 | + {quality ? <span className="tnum">{t('country.freshSplit', { fresh: quality.summary.n_fresh, stale: quality.summary.n_stale })}</span> : null} | |
| 73 | + <QualityBadges badges={badges} max={3} /> | |
| 74 | + </div> | |
| 75 | + | |
| 76 | + <div className="mt-4 flex flex-wrap gap-2"> | |
| 77 | + <Link href={routes.compare(c.slug ?? c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink" aria-label={t('country.compareWith', { name })}> | |
| 78 | + <Scale size={15} aria-hidden /> {t('country.compare')} | |
| 79 | + </Link> | |
| 80 | + <a href={routes.countryDownload(c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2" title={t('country.downloadHint')}> | |
| 81 | + <Download size={15} aria-hidden /> {t('country.download')} | |
| 82 | + </a> | |
| 83 | + <ShareButton title={t('country.title', { name })} /> | |
| 84 | + <CopyApiButton path={`/api/v1/countries/${c.id}`} /> | |
| 53 | 85 | </div> |
| 54 | 86 | </div> |
| 55 | − <div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch"> | |
| 56 | − <Link href={routes.compare(c.slug ?? c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink" aria-label={t('country.compareWith', { name })}> | |
| 57 | − <Scale size={15} aria-hidden /> {t('country.compare')} | |
| 58 | − </Link> | |
| 59 | − <a href={routes.countryDownload(c.id)} className="inline-flex h-10 items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2" title={t('country.downloadHint')}> | |
| 60 | − <Download size={15} aria-hidden /> {t('country.download')} | |
| 61 | − </a> | |
| 62 | − <ShareButton title={t('country.title', { name })} /> | |
| 87 | + | |
| 88 | + <div className="min-w-0"> | |
| 89 | + <MiniMap iso3={c.id} neighbours={c.borders ?? []} latitude={c.latitude} longitude={c.longitude} name={name} className="rounded-sm" /> | |
| 90 | + {data.neighbours.length ? ( | |
| 91 | + <div className="mt-2 text-xs"> | |
| 92 | + <span className="text-ink-3">{t('country.borders')}: </span> | |
| 93 | + <ul className="mt-1 flex flex-wrap gap-1"> | |
| 94 | + {data.neighbours.map((n) => ( | |
| 95 | + <li key={n.id}> | |
| 96 | + <Link href={routes.country(n.slug ?? n.id)} className="badge min-h-[28px] border-rule text-ink hover:border-accent hover:text-accent"> | |
| 97 | + <span aria-hidden>{n.flag}</span> {n.name} | |
| 98 | + </Link> | |
| 99 | + </li> | |
| 100 | + ))} | |
| 101 | + </ul> | |
| 102 | + </div> | |
| 103 | + ) : ( | |
| 104 | + <p className="mt-2 text-xs text-ink-3">{c.landlocked === false ? t('country.noLandBorders') : ''}</p> | |
| 105 | + )} | |
| 63 | 106 | </div> |
| 64 | 107 | </div> |
| 65 | 108 | </header> |
| 66 | 109 | ); |
| 67 | 110 | } |
| 111 | + | |
| 112 | +/** Coarse badges for the whole country from the quality summary. */ | |
| 113 | +function deriveCountryBadges(q: CountryQualityResponse): Array<'fresh' | 'stale' | 'limited-coverage' | 'sparse' | 'flagged'> { | |
| 114 | + const out: Array<'fresh' | 'stale' | 'limited-coverage' | 'sparse' | 'flagged'> = []; | |
| 115 | + const s = q.summary; | |
| 116 | + if (s.n_with_data && s.n_fresh / s.n_with_data >= 0.6) out.push('fresh'); | |
| 117 | + if (s.n_with_data && s.n_stale / s.n_with_data >= 0.4) out.push('stale'); | |
| 118 | + if ((s.coverage_pct ?? 100) < 50) out.push('limited-coverage'); | |
| 119 | + if (s.n_with_data && s.n_sparse / s.n_with_data >= 0.3) out.push('sparse'); | |
| 120 | + if (s.n_with_data && s.n_flagged / s.n_with_data >= 0.3) out.push('flagged'); | |
| 121 | + return out; | |
| 122 | +} | |
added
apps/web/src/components/country/country-story.tsx
+62 −0
@@ -0,0 +1,62 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { t } from '@/i18n'; | |
| 4 | +import { formatValue, grouped, ordinal } from '@/lib/format'; | |
| 5 | +import { routes } from '@/lib/site'; | |
| 6 | +import type { FormatSpec } from '@/lib/types'; | |
| 7 | +import type { StoryItem, StoryResponse } from '@/lib/types-analytics'; | |
| 8 | +import { LineChart } from '@/components/charts/line-chart'; | |
| 9 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * "How {name} changed": one small multiple per long-run indicator (full annual history), the templated sentence | |
| 13 | + * computed server-side underneath, first/last labels, peak/trough and rank change. Every chart opens provenance. | |
| 14 | + */ | |
| 15 | +export function CountryStory({ data, country }: { data: StoryResponse; country: { id: string; slug: string | null; name: string; flag?: string | null } }) { | |
| 16 | + const { open } = useProvenance(); | |
| 17 | + if (!data.items.length) return <p className="py-4 text-sm text-ink-3">{t('country.story.none')}</p>; | |
| 18 | + return ( | |
| 19 | + <div className="grid gap-x-6 gap-y-7 sm:grid-cols-2 lg:grid-cols-3"> | |
| 20 | + {data.items.map((it) => ( | |
| 21 | + <StoryCell key={it.indicator.slug} it={it} country={country} onProvenance={() => open({ indicator: { slug: it.indicator.slug, name: it.indicator.name ?? it.indicator.slug, format: it.indicator.format, unit: it.indicator.unit, unit_short: it.indicator.unit_short, precision: it.indicator.precision, frequency: it.indicator.frequency, higher_is_better: it.indicator.higher_is_better }, value: { value: it.last.value, formatted: it.last.formatted, period: `${it.last.year}-01-01`, year: it.last.year, unit: it.indicator.unit, provenance: it.provenance }, country })} /> | |
| 22 | + ))} | |
| 23 | + </div> | |
| 24 | + ); | |
| 25 | +} | |
| 26 | + | |
| 27 | +function StoryCell({ it, country, onProvenance }: { it: StoryItem; country: { slug: string | null }; onProvenance: () => void }) { | |
| 28 | + const spec: FormatSpec = { format: it.indicator.format, unit: it.indicator.unit, unit_short: it.indicator.unit_short, precision: it.indicator.precision, name: it.indicator.short_name ?? it.indicator.name, higher_is_better: it.indicator.higher_is_better }; | |
| 29 | + const points = it.series.map(([year, value]) => ({ period: `${year}-01-01`, year, value })); | |
| 30 | + const up = (it.change_abs ?? 0) >= 0; | |
| 31 | + const title = it.indicator.short_name ?? it.indicator.name ?? it.indicator.slug; | |
| 32 | + return ( | |
| 33 | + <article className="min-w-0 border-t border-rule pt-3" aria-labelledby={`story-${it.indicator.slug}`}> | |
| 34 | + <div className="flex items-baseline justify-between gap-2"> | |
| 35 | + <h3 id={`story-${it.indicator.slug}`} className="min-w-0 truncate text-sm font-semibold text-ink"> | |
| 36 | + <Link href={it.indicator.topic && country.slug ? routes.countryIndicator(country.slug, it.indicator.topic, it.indicator.slug) : routes.indicator(it.indicator.slug)} className="link-quiet"> | |
| 37 | + {title} | |
| 38 | + </Link> | |
| 39 | + </h3> | |
| 40 | + <span className={`tnum shrink-0 text-xs font-medium ${up ? 'text-inc' : 'text-dec'}`}> | |
| 41 | + {it.change_pct != null && Math.abs(it.change_pct) < 10000 && ['currency', 'number', 'tonnes', 'kwh'].includes(it.indicator.format ?? '') ? `${up ? '+' : '−'}${grouped(Math.abs(it.change_pct))} %` : it.change_abs != null ? `${up ? '+' : '−'}${formatValue(Math.abs(it.change_abs), { ...spec, format: spec.format === 'percent' ? 'percent' : spec.format }).replace(/^(US\$|intl \$)/, '$1')}` : ''} | |
| 42 | + </span> | |
| 43 | + </div> | |
| 44 | + <div className="tnum -mt-0.5 flex justify-between text-2xs text-ink-3"> | |
| 45 | + <span> | |
| 46 | + {it.first.year} · {it.first.formatted ?? formatValue(it.first.value, spec)} | |
| 47 | + </span> | |
| 48 | + <span> | |
| 49 | + {it.last.year} · <span className="font-medium text-ink">{it.last.formatted ?? formatValue(it.last.value, spec)}</span> | |
| 50 | + </span> | |
| 51 | + </div> | |
| 52 | + <LineChart series={[{ id: it.indicator.slug, name: title, points }]} spec={spec} subject={title} height={150} endLabels={false} margin={{ left: 40, right: 14, top: 22, bottom: 22 }} defaultWidth={360} className="mt-1 [&_figcaption]:hidden" provenance={it.provenance} payload={{ indicator: { slug: it.indicator.slug, name: it.indicator.name ?? title, format: it.indicator.format, unit: it.indicator.unit, unit_short: it.indicator.unit_short, precision: it.indicator.precision, frequency: it.indicator.frequency, higher_is_better: it.indicator.higher_is_better }, value: { value: it.last.value, formatted: it.last.formatted, period: `${it.last.year}-01-01`, year: it.last.year, unit: it.indicator.unit, provenance: it.provenance }, country: null }} /> | |
| 53 | + <button type="button" onClick={onProvenance} className="mt-1 text-left text-xs leading-snug text-ink-2 hover:text-ink" aria-label={t('common.openProvenance')}> | |
| 54 | + {it.text.replace(/^[^:]+:\s*/, '')} | |
| 55 | + </button> | |
| 56 | + <p className="tnum mt-1 text-2xs text-ink-3"> | |
| 57 | + {it.peak && it.peak.year !== it.last.year ? `${t('country.story.peak', { value: formatValue(it.peak.value, spec), year: it.peak.year })}` : ''} | |
| 58 | + {it.rank_first && it.rank_last ? `${it.peak && it.peak.year !== it.last.year ? ' · ' : ''}${t('country.story.rank', { r0: ordinal(it.rank_first.rank), y0: it.rank_first.year, r1: ordinal(it.rank_last.rank), n: it.rank_last.n, y1: it.rank_last.year })}` : ''} | |
| 59 | + </p> | |
| 60 | + </article> | |
| 61 | + ); | |
| 62 | +} | |
added
apps/web/src/components/country/dna-panel.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useEffect, useMemo, useState } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { clientApi } from '@/lib/client-api'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import type { DNAResponse, DnaDimension } from '@/lib/types'; | |
| 8 | +import { DNA_DIMS, DnaRadial } from '@/components/charts/dna-radial'; | |
| 9 | +import { Segmented } from '@/components/controls/indicator-select'; | |
| 10 | +import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker'; | |
| 11 | + | |
| 12 | +type RefKind = 'world' | 'region' | 'income' | 'country'; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Country DNA 2.0: the fingerprint with a reference profile — World (50 on every axis), the region's median, | |
| 16 | + * income peers' median or another country — fetched from `/countries/{id}/dna?reference=`. The table below lists | |
| 17 | + * the nine percentile dimensions with the reference values. Descriptive, never a score. | |
| 18 | + */ | |
| 19 | +export function DnaPanel({ countryId, name, initial }: { countryId: string; name: string; initial: DNAResponse | null }) { | |
| 20 | + const [kind, setKind] = useState<RefKind>('world'); | |
| 21 | + const [peer, setPeer] = useState<PickedEntity | null>(null); | |
| 22 | + const [cache, setCache] = useState<Record<string, DNAResponse | null>>({}); | |
| 23 | + const [loading, setLoading] = useState(false); | |
| 24 | + const refKey = kind === 'country' ? (peer ? `country:${peer.id}` : null) : kind === 'world' ? null : kind; | |
| 25 | + | |
| 26 | + useEffect(() => { | |
| 27 | + if (!refKey || cache[refKey] !== undefined) return; | |
| 28 | + const ctrl = new AbortController(); | |
| 29 | + setLoading(true); | |
| 30 | + const reference = refKey.startsWith('country:') ? refKey.slice(8) : refKey; | |
| 31 | + clientApi | |
| 32 | + .countryDna(countryId, reference, ctrl.signal) | |
| 33 | + .then((r) => setCache((c) => ({ ...c, [refKey]: r }))) | |
| 34 | + .catch(() => setCache((c) => ({ ...c, [refKey]: null }))) | |
| 35 | + .finally(() => { | |
| 36 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 37 | + }); | |
| 38 | + return () => ctrl.abort(); | |
| 39 | + }, [refKey, countryId, cache]); | |
| 40 | + | |
| 41 | + const base = initial; | |
| 42 | + const withRef = refKey ? cache[refKey] : null; | |
| 43 | + const reference: Record<string, number | null> | null = useMemo(() => { | |
| 44 | + if (kind === 'world') return Object.fromEntries(DNA_DIMS.map((d) => [d, 50])); | |
| 45 | + return withRef?.reference?.dims ?? null; | |
| 46 | + }, [kind, withRef]); | |
| 47 | + const refLabel = kind === 'world' ? t('country.dna.ref.worldLabel') : withRef?.reference?.label ?? (kind === 'country' ? peer?.name ?? null : null); | |
| 48 | + | |
| 49 | + if (!base) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>; | |
| 50 | + return ( | |
| 51 | + <div className="min-w-0"> | |
| 52 | + <div className="flex flex-wrap items-center gap-2"> | |
| 53 | + <Segmented<RefKind> value={kind} onChange={setKind} label={t('country.dna.ref')} size="sm" options={[{ value: 'world', label: t('country.dna.ref.world') }, { value: 'region', label: t('country.dna.ref.region') }, { value: 'income', label: t('country.dna.ref.income') }, { value: 'country', label: t('country.dna.ref.country') }]} /> | |
| 54 | + {kind === 'country' ? <EntityPicker type="country" placeholder={t('country.dna.ref.pick')} onPick={setPeer} exclude={[countryId]} keepValue className="w-56" size="sm" /> : null} | |
| 55 | + </div> | |
| 56 | + <div className={loading ? 'mt-3 opacity-60 transition-opacity' : 'mt-3'} aria-busy={loading}> | |
| 57 | + <DnaRadial dna={base} name={name} size={360} reference={kind === 'country' && !peer ? null : reference} referenceLabel={refLabel} /> | |
| 58 | + </div> | |
| 59 | + <p className="mt-2 text-center text-xs text-ink-3"> | |
| 60 | + {t('country.dna.notScore')} | |
| 61 | + {base.year_ref ? <span className="tnum"> · {base.year_ref}</span> : null} | |
| 62 | + </p> | |
| 63 | + <table className="mt-3 w-full border-collapse text-xs"> | |
| 64 | + <caption className="sr-only">{t('country.dna.title')}</caption> | |
| 65 | + <thead> | |
| 66 | + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3"> | |
| 67 | + <th scope="col" className="py-1 pr-2 font-medium"> | |
| 68 | + {t('country.dna.dimension')} | |
| 69 | + </th> | |
| 70 | + <th scope="col" className="py-1 pr-2 text-right font-medium"> | |
| 71 | + {name} | |
| 72 | + </th> | |
| 73 | + {reference && (kind !== 'country' || peer) ? ( | |
| 74 | + <th scope="col" className="py-1 text-right font-medium"> | |
| 75 | + {refLabel} | |
| 76 | + </th> | |
| 77 | + ) : null} | |
| 78 | + </tr> | |
| 79 | + </thead> | |
| 80 | + <tbody className="divide-y divide-rule"> | |
| 81 | + {DNA_DIMS.map((d: DnaDimension) => { | |
| 82 | + const v = base.dims[d]; | |
| 83 | + const r = reference?.[d]; | |
| 84 | + const row = base.dimensions.find((x) => x.id === d); | |
| 85 | + return ( | |
| 86 | + <tr key={d}> | |
| 87 | + <td className="py-1 pr-2 text-ink-2"> | |
| 88 | + {row?.indicator ? ( | |
| 89 | + <Link href={routes.indicator(row.indicator)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0"> | |
| 90 | + {t(`country.dna.${d}` as const)} | |
| 91 | + </Link> | |
| 92 | + ) : ( | |
| 93 | + t(`country.dna.${d}` as const) | |
| 94 | + )} | |
| 95 | + </td> | |
| 96 | + <td className="tnum py-1 pr-2 text-right font-medium text-ink">{v != null ? Math.round(v) : '—'}</td> | |
| 97 | + {reference && (kind !== 'country' || peer) ? <td className="tnum py-1 text-right text-ink-2">{r != null ? Math.round(r) : '—'}</td> : null} | |
| 98 | + </tr> | |
| 99 | + ); | |
| 100 | + })} | |
| 101 | + </tbody> | |
| 102 | + </table> | |
| 103 | + </div> | |
| 104 | + ); | |
| 105 | +} | |
added
apps/web/src/components/country/mini-map.tsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +import { geoEqualEarth, type GeoPermissibleObjects } from 'd3-geo'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { MAP_HEIGHT, MAP_WIDTH, worldPaths } from '@/lib/map-geo'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * Small static locator map for a country hero: the world in a muted tone, the country (and its neighbours, | |
| 8 | + * lighter) in the accent, plus a ring at the capital / centroid so micro-states stay visible. Server component. | |
| 9 | + */ | |
| 10 | +export function MiniMap({ iso3, neighbours = [], latitude, longitude, name, className }: { iso3: string; neighbours?: string[]; latitude?: number | null; longitude?: number | null; name: string; className?: string }) { | |
| 11 | + const { paths, sphere } = worldPaths(); | |
| 12 | + const projection = geoEqualEarth().fitExtent( | |
| 13 | + [ | |
| 14 | + [4, 4], | |
| 15 | + [MAP_WIDTH - 4, MAP_HEIGHT - 4], | |
| 16 | + ], | |
| 17 | + { type: 'Sphere' } as GeoPermissibleObjects, | |
| 18 | + ); | |
| 19 | + const pt = latitude != null && longitude != null ? projection([longitude, latitude]) : null; | |
| 20 | + const nb = new Set(neighbours); | |
| 21 | + const own = paths.find((p) => p.iso3 === iso3); | |
| 22 | + return ( | |
| 23 | + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className={cn('h-auto w-full', className)} role="img" aria-label={t('country.miniMap', { name })}> | |
| 24 | + <title>{t('country.miniMap', { name })}</title> | |
| 25 | + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} /> | |
| 26 | + <g stroke="var(--map-stroke)" strokeWidth={0.5} strokeLinejoin="round"> | |
| 27 | + {paths.map((p, i) => ( | |
| 28 | + <path key={p.iso3 ?? `${p.name}-${i}`} d={p.d} fill={p.iso3 === iso3 ? 'var(--accent)' : p.iso3 && nb.has(p.iso3) ? 'var(--accent-soft)' : 'var(--nodata)'} fillOpacity={p.iso3 === iso3 ? 1 : p.iso3 && nb.has(p.iso3) ? 1 : 0.7} /> | |
| 29 | + ))} | |
| 30 | + {own ? <path d={own.d} fill="none" stroke="var(--ink)" strokeWidth={0.8} /> : null} | |
| 31 | + </g> | |
| 32 | + {pt ? ( | |
| 33 | + <g> | |
| 34 | + <circle cx={pt[0]} cy={pt[1]} r={14} fill="none" stroke="var(--accent)" strokeWidth={2.5} opacity={0.9} /> | |
| 35 | + <circle cx={pt[0]} cy={pt[1]} r={4} fill="var(--accent)" stroke="var(--surface)" strokeWidth={1.5} /> | |
| 36 | + </g> | |
| 37 | + ) : null} | |
| 38 | + </svg> | |
| 39 | + ); | |
| 40 | +} | |
modified
apps/web/src/components/country/similar-panel.tsx
+51 −29
@@ -2,21 +2,30 @@ | ||
| 2 | 2 | import { ChevronDown } from 'lucide-react'; |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { useEffect, useState } from 'react'; |
| 5 | −import { t } from '@/i18n'; | |
| 5 | +import { t, tOpt } from '@/i18n'; | |
| 6 | 6 | import { clientApi } from '@/lib/client-api'; |
| 7 | 7 | import { cn } from '@/lib/cn'; |
| 8 | −import { fixed } from '@/lib/format'; | |
| 8 | +import { compact, fixed, formatValue } from '@/lib/format'; | |
| 9 | 9 | import { routes } from '@/lib/site'; |
| 10 | −import type { Contributions, SimilarResponse, SimilarityMode } from '@/lib/types'; | |
| 10 | +import type { Contributions, FormatSpec, SimilarResponse, SimilarityMode } from '@/lib/types'; | |
| 11 | 11 | |
| 12 | 12 | const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social']; |
| 13 | 13 | |
| 14 | +export type Closeness = 'very' | 'similar' | 'moderate' | 'different'; | |
| 15 | + | |
| 16 | +/** |z_a − z_b| → qualitative label. */ | |
| 17 | +export function closeness(za: number | null | undefined, zb: number | null | undefined): Closeness | null { | |
| 18 | + if (za == null || zb == null) return null; | |
| 19 | + const d = Math.abs(za - zb); | |
| 20 | + return d < 0.25 ? 'very' : d < 0.75 ? 'similar' : d < 1.5 ? 'moderate' : 'different'; | |
| 21 | +} | |
| 22 | + | |
| 14 | 23 | /** |
| 15 | − * "Countries similar to X": mode tabs (overall/economic/…), peers with a score bar (0–100) and a "why" | |
| 16 | − * expander listing the top contributing features. The server passes the `overall` payload; other modes are | |
| 17 | − * fetched on demand via the same-origin API and cached in state. | |
| 24 | + * "Countries like {name}": mode tabs (from the API), peers with a similarity bar (0–100), and a "Why similar?" | |
| 25 | + * expander turning contributions into qualitative labels with both raw values. Peers link to the pairwise | |
| 26 | + * comparison. Other modes are fetched on demand and cached. | |
| 18 | 27 | */ |
| 19 | −export function SimilarPanel({ countryId, initial }: { countryId: string; initial: SimilarResponse | null }) { | |
| 28 | +export function SimilarPanel({ countryId, countrySlug, countryName, initial, formats = {} }: { countryId: string; countrySlug: string | null; countryName: string; initial: SimilarResponse | null; formats?: Record<string, FormatSpec> }) { | |
| 20 | 29 | const [mode, setMode] = useState<SimilarityMode>('overall'); |
| 21 | 30 | const [data, setData] = useState<Partial<Record<SimilarityMode, SimilarResponse | null>>>({ overall: initial }); |
| 22 | 31 | const [loading, setLoading] = useState(false); |
@@ -40,7 +49,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia | ||
| 40 | 49 | |
| 41 | 50 | return ( |
| 42 | 51 | <div> |
| 43 | − <div role="tablist" aria-label={t('country.similar.title', { name: '' }).trim()} className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0"> | |
| 52 | + <div role="tablist" aria-label={t('country.similar.title', { name: countryName })} className="scrollbar-none -mx-4 flex gap-1 overflow-x-auto px-4 sm:mx-0 sm:px-0"> | |
| 44 | 53 | {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => ( |
| 45 | 54 | <button key={m} role="tab" aria-selected={mode === m} type="button" onClick={() => setMode(m)} className={cn('inline-flex h-11 shrink-0 items-center rounded-sm px-3 text-sm md:h-9', mode === m ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}> |
| 46 | 55 | {t(`country.similar.mode.${m}` as const)} |
@@ -57,7 +66,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia | ||
| 57 | 66 | const open = openPeer === key; |
| 58 | 67 | const contribs = parseContribs(p.contributions); |
| 59 | 68 | return ( |
| 60 | − <li key={p.country.id} className="py-1 sm:py-2"> | |
| 69 | + <li key={p.country.id} className="py-1 sm:py-1.5"> | |
| 61 | 70 | <div className="grid grid-cols-[1.5rem_minmax(0,1fr)_3.5rem_2rem] items-center gap-x-2 sm:grid-cols-[1.5rem_minmax(0,1fr)_minmax(6rem,12rem)_3.5rem_2rem]"> |
| 62 | 71 | <span className="tnum text-xs text-ink-3">{p.rank}</span> |
| 63 | 72 | <Link href={routes.country(p.country.slug ?? p.country.id)} className="link-quiet flex min-h-[44px] min-w-0 items-center gap-1.5 text-sm sm:min-h-0"> |
@@ -66,14 +75,14 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia | ||
| 66 | 75 | </span> |
| 67 | 76 | <span className="truncate">{p.country.name}</span> |
| 68 | 77 | </Link> |
| 69 | − <div className="order-last col-span-full mb-1 h-2 rounded-xs bg-surface-2 sm:order-none sm:col-span-1 sm:mb-0" aria-hidden> | |
| 78 | + <div className="col-span-full row-start-2 mb-1 h-2 rounded-xs bg-surface-2 sm:col-span-1 sm:row-start-auto sm:mb-0" aria-hidden> | |
| 70 | 79 | <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} /> |
| 71 | 80 | </div> |
| 72 | 81 | <span className="tnum text-right text-sm font-medium text-ink" aria-label={t('country.similar.score', { score: fixed(p.score ?? 0, 0) })}> |
| 73 | − {p.score != null ? fixed(p.score, 0) : t('common.na')} | |
| 82 | + {p.score != null ? `${fixed(p.score, 0)} %` : t('common.na')} | |
| 74 | 83 | </span> |
| 75 | 84 | {contribs.length ? ( |
| 76 | − <button type="button" onClick={() => setOpenPeer(open ? null : key)} aria-expanded={open} className="tap -mr-2 grid place-items-center text-ink-3 hover:text-ink" aria-label={t('common.why')}> | |
| 85 | + <button type="button" onClick={() => setOpenPeer(open ? null : key)} aria-expanded={open} className="tap -mr-2 grid place-items-center text-ink-3 hover:text-ink" aria-label={t('country.similar.whySimilar')}> | |
| 77 | 86 | <ChevronDown size={16} aria-hidden className={cn('transition-transform', open && 'rotate-180')} /> |
| 78 | 87 | </button> |
| 79 | 88 | ) : ( |
@@ -82,16 +91,33 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia | ||
| 82 | 91 | </div> |
| 83 | 92 | {open ? ( |
| 84 | 93 | <div className="mt-2 rounded-sm bg-surface-2/60 px-3 py-2 text-xs text-ink-2"> |
| 85 | − <div className="eyebrow mb-1">{t('country.similar.why')}</div> | |
| 86 | − <ul className="grid gap-x-6 gap-y-0.5 sm:grid-cols-2"> | |
| 87 | − {contribs.slice(0, 6).map((c) => ( | |
| 88 | − <li key={c.indicator} className="flex justify-between gap-2 tnum"> | |
| 89 | − <span className="truncate">{c.indicator.replace(/-/g, ' ')}</span> | |
| 90 | − <span className="text-ink-3"> | |
| 91 | − z {fmtZ(c.z_a)} vs {fmtZ(c.z_b)} | |
| 92 | − </span> | |
| 93 | − </li> | |
| 94 | − ))} | |
| 94 | + <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 95 | + <div className="eyebrow">{t('country.similar.whySimilar')}</div> | |
| 96 | + {countrySlug && p.country.slug ? ( | |
| 97 | + <Link href={routes.compare(countrySlug, p.country.slug)} className="text-accent hover:underline"> | |
| 98 | + {t('country.similar.compareWith', { a: countryName, b: p.country.name ?? p.country.id })} → | |
| 99 | + </Link> | |
| 100 | + ) : null} | |
| 101 | + </div> | |
| 102 | + <ul className="mt-1 grid gap-x-6 gap-y-1 sm:grid-cols-2"> | |
| 103 | + {contribs.map((c) => { | |
| 104 | + const spec = formats[c.indicator.split('/')[0] ?? ''] ?? null; | |
| 105 | + const label = closeness(c.z_a, c.z_b); | |
| 106 | + const fmt = (v: number | null) => (v == null ? t('common.na') : spec && !c.indicator.includes('/') ? formatValue(v, spec) : compact(v)); | |
| 107 | + return ( | |
| 108 | + <li key={c.indicator} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-2"> | |
| 109 | + <span className="min-w-0"> | |
| 110 | + <span className="block truncate text-ink">{spec?.name ?? c.indicator.replace(/-/g, ' ')}</span> | |
| 111 | + {c.value_a != null && c.value_b != null ? ( | |
| 112 | + <span className="tnum block text-ink-3"> | |
| 113 | + {fmt(c.value_a)} <span aria-hidden>vs</span> {fmt(c.value_b)} | |
| 114 | + </span> | |
| 115 | + ) : null} | |
| 116 | + </span> | |
| 117 | + <span className={cn('shrink-0 text-right', label === 'very' || label === 'similar' ? 'text-accent' : label === 'different' ? 'text-dec' : 'text-ink-2')}>{label ? tOpt(`country.similar.close.${label}`, label) : ''}</span> | |
| 118 | + </li> | |
| 119 | + ); | |
| 120 | + })} | |
| 95 | 121 | </ul> |
| 96 | 122 | </div> |
| 97 | 123 | ) : null} |
@@ -105,11 +131,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia | ||
| 105 | 131 | ); |
| 106 | 132 | } |
| 107 | 133 | |
| 108 | −function fmtZ(v: number | null | undefined): string { | |
| 109 | − return v == null ? '—' : (v >= 0 ? '+' : '−') + fixed(Math.abs(v), 1); | |
| 110 | −} | |
| 111 | − | |
| 112 | −function parseContribs(raw: Contributions | string | null): Array<{ indicator: string; z_a: number | null; z_b: number | null; contribution: number }> { | |
| 134 | +export function parseContribs(raw: Contributions | string | null): Array<{ indicator: string; z_a: number | null; z_b: number | null; value_a: number | null; value_b: number | null; contribution: number }> { | |
| 113 | 135 | if (!raw) return []; |
| 114 | 136 | let obj: Contributions; |
| 115 | 137 | try { |
@@ -118,6 +140,6 @@ function parseContribs(raw: Contributions | string | null): Array<{ indicator: s | ||
| 118 | 140 | return []; |
| 119 | 141 | } |
| 120 | 142 | return Object.entries(obj) |
| 121 | − .map(([indicator, c]) => ({ indicator, z_a: c.z_a ?? null, z_b: c.z_b ?? null, contribution: c.contribution ?? 0 })) | |
| 122 | − .sort((a, b) => Math.abs(a.contribution) - Math.abs(b.contribution)); // smallest distance contribution = most similar | |
| 143 | + .map(([indicator, c]) => ({ indicator, z_a: c.z_a ?? null, z_b: c.z_b ?? null, value_a: c.value_a ?? null, value_b: c.value_b ?? null, contribution: c.contribution ?? 0 })) | |
| 144 | + .sort((a, b) => Math.abs((a.z_a ?? 0) - (a.z_b ?? 0)) - Math.abs((b.z_a ?? 0) - (b.z_b ?? 0))); // most similar first, most different last | |
| 123 | 145 | } |
modified
apps/web/src/components/country/timeline.tsx
+125 −33
@@ -1,4 +1,7 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Activity, AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, TrendingDown, TrendingUp, Trophy, Waves } from 'lucide-react'; | |
| 1 | 3 | import Link from 'next/link'; |
| 4 | +import { useMemo, useState } from 'react'; | |
| 2 | 5 | import { t } from '@/i18n'; |
| 3 | 6 | import { cn } from '@/lib/cn'; |
| 4 | 7 | import { severityLevel } from '@/lib/severity'; |
@@ -7,41 +10,130 @@ import { topicById } from '@/lib/topics'; | ||
| 7 | 10 | import type { ChangeItem } from '@/lib/types'; |
| 8 | 11 | import { kindLabel } from '@/components/data/change-list'; |
| 9 | 12 | |
| 10 | −/** Events grouped by year, compact: a left year rail and one line per event. Server component. */ | |
| 11 | −export function Timeline({ items, slug, limit = 30 }: { items: ChangeItem[]; slug: string; limit?: number }) { | |
| 13 | +const ICON: Record<string, typeof ArrowUpRight> = { | |
| 14 | + yoy_jump: ArrowUpRight, | |
| 15 | + yoy_drop: ArrowDownRight, | |
| 16 | + record_high: Trophy, | |
| 17 | + record_low: AlertTriangle, | |
| 18 | + n_year_high: TrendingUp, | |
| 19 | + n_year_low: TrendingDown, | |
| 20 | + sign_flip: Repeat, | |
| 21 | + accelerating: TrendingUp, | |
| 22 | + decelerating: TrendingDown, | |
| 23 | + structural_break: GitCommitHorizontal, | |
| 24 | + trend_reversal: Repeat, | |
| 25 | + volatility_spike: Waves, | |
| 26 | +}; | |
| 27 | + | |
| 28 | +/** Topic filter chips → indicator topics. */ | |
| 29 | +const FILTERS: Array<{ id: string; topics: string[] }> = [ | |
| 30 | + { id: 'all', topics: [] }, | |
| 31 | + { id: 'economy', topics: ['economy', 'government', 'trade', 'income'] }, | |
| 32 | + { id: 'population', topics: ['population'] }, | |
| 33 | + { id: 'health', topics: ['health'] }, | |
| 34 | + { id: 'energy', topics: ['energy'] }, | |
| 35 | + { id: 'climate', topics: ['climate', 'environment'] }, | |
| 36 | + { id: 'digital', topics: ['digital', 'innovation'] }, | |
| 37 | +]; | |
| 38 | +const PAGE = 40; | |
| 39 | + | |
| 40 | +/** | |
| 41 | + * Country timeline 2.0: a vertical chronological rail grouped by decade → year, topic filters, kind glyphs and | |
| 42 | + * severity emphasis (record highs/lows, sharp changes, reversals, structural breaks, volatility). Newest first. | |
| 43 | + */ | |
| 44 | +export function Timeline({ items, slug }: { items: ChangeItem[]; slug: string }) { | |
| 45 | + const [filter, setFilter] = useState('all'); | |
| 46 | + const [shown, setShown] = useState(PAGE); | |
| 47 | + const filtered = useMemo(() => { | |
| 48 | + const f = FILTERS.find((x) => x.id === filter) ?? FILTERS[0]!; | |
| 49 | + const rows = f.topics.length ? items.filter((it) => f.topics.includes(('topic' in it.indicator ? it.indicator.topic : null) ?? '')) : items; | |
| 50 | + return [...rows].sort((a, b) => (b.year ?? 0) - (a.year ?? 0) || (b.severity ?? 0) - (a.severity ?? 0)); | |
| 51 | + }, [items, filter]); | |
| 52 | + const visible = filtered.slice(0, shown); | |
| 53 | + const decades = useMemo(() => { | |
| 54 | + const m = new Map<number, Map<number, ChangeItem[]>>(); | |
| 55 | + for (const it of visible) { | |
| 56 | + const y = it.year ?? 0; | |
| 57 | + const d = Math.floor(y / 10) * 10; | |
| 58 | + if (!m.has(d)) m.set(d, new Map()); | |
| 59 | + const ym = m.get(d)!; | |
| 60 | + if (!ym.has(y)) ym.set(y, []); | |
| 61 | + ym.get(y)!.push(it); | |
| 62 | + } | |
| 63 | + return Array.from(m.entries()).sort((a, b) => b[0] - a[0]); | |
| 64 | + }, [visible]); | |
| 65 | + const counts = useMemo(() => Object.fromEntries(FILTERS.map((f) => [f.id, f.topics.length ? items.filter((it) => f.topics.includes(('topic' in it.indicator ? it.indicator.topic : null) ?? '')).length : items.length])), [items]); | |
| 66 | + | |
| 12 | 67 | if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('country.timeline.none')}</p>; |
| 13 | − const byYear = new Map<number, ChangeItem[]>(); | |
| 14 | − for (const it of items.slice(0, limit)) { | |
| 15 | − const y = it.year ?? 0; | |
| 16 | − if (!byYear.has(y)) byYear.set(y, []); | |
| 17 | − byYear.get(y)!.push(it); | |
| 18 | − } | |
| 19 | − const years = Array.from(byYear.keys()).sort((a, b) => b - a); | |
| 20 | 68 | return ( |
| 21 | − <ol className="divide-y divide-rule"> | |
| 22 | − {years.map((y) => ( | |
| 23 | − <li key={y} className="grid grid-cols-[3.25rem_1fr] gap-x-3 py-2.5"> | |
| 24 | − <span className="tnum display pt-0.5 text-lg leading-none text-ink-2">{y || '—'}</span> | |
| 25 | − <ul className="space-y-1.5"> | |
| 26 | − {byYear.get(y)!.map((e, i) => { | |
| 27 | − const ind = e.indicator; | |
| 28 | − const indSlug = (ind as { slug?: string; id: string }).slug ?? ind.id; | |
| 29 | − const topic = 'topic' in ind ? topicById(ind.topic ?? '')?.id : undefined; | |
| 30 | − const href = topic ? routes.countryIndicator(slug, topic, indSlug) : routes.indicator(indSlug); | |
| 31 | − const lvl = severityLevel(e.severity); | |
| 32 | − return ( | |
| 33 | − <li key={e.id ?? i} className="text-sm leading-snug"> | |
| 34 | − <Link href={href} className="link-quiet"> | |
| 35 | − <span className={cn('mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle', lvl === 'high' ? 'bg-accent' : 'bg-rule-strong')} aria-hidden /> | |
| 36 | − <span className="mr-1.5 text-2xs uppercase tracking-wide text-ink-3">{kindLabel(e.kind, e.window_years)}</span> | |
| 37 | − <span className="text-ink">{e.headline}</span> | |
| 38 | − </Link> | |
| 69 | + <div className="min-w-0"> | |
| 70 | + <ul className="scrollbar-none -mx-4 flex gap-1.5 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:px-0" role="radiogroup" aria-label={t('country.timeline.filter')}> | |
| 71 | + {FILTERS.filter((f) => (counts[f.id] ?? 0) > 0 || f.id === 'all').map((f) => ( | |
| 72 | + <li key={f.id} className="shrink-0"> | |
| 73 | + <button type="button" role="radio" aria-checked={filter === f.id} onClick={() => { setFilter(f.id); setShown(PAGE); }} className={cn('inline-flex h-10 items-center gap-1.5 rounded-sm border px-3 text-sm md:h-8 md:px-2.5 md:text-xs', filter === f.id ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 74 | + {t(`country.timeline.f.${f.id}` as 'country.timeline.f.all')} | |
| 75 | + <span className={cn('tnum text-2xs', filter === f.id ? 'text-paper/70' : 'text-ink-3')}>{counts[f.id] ?? 0}</span> | |
| 76 | + </button> | |
| 77 | + </li> | |
| 78 | + ))} | |
| 79 | + </ul> | |
| 80 | + <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-ink-3" aria-label={t('common.legend')}> | |
| 81 | + {(['record_high', 'yoy_jump', 'sign_flip', 'structural_break', 'volatility_spike'] as const).map((k) => { | |
| 82 | + const Icon = ICON[k]!; | |
| 83 | + return ( | |
| 84 | + <li key={k} className="inline-flex items-center gap-1"> | |
| 85 | + <Icon size={12} aria-hidden /> {kindLabel(k, 10)} | |
| 86 | + </li> | |
| 87 | + ); | |
| 88 | + })} | |
| 89 | + </ul> | |
| 90 | + | |
| 91 | + <ol className="relative mt-4 border-l-2 border-rule pl-5 md:pl-6"> | |
| 92 | + {decades.map(([decade, years]) => ( | |
| 93 | + <li key={decade} className="relative pb-6 last:pb-0"> | |
| 94 | + <span aria-hidden className="absolute -left-[calc(1.25rem+5px)] top-1 h-2 w-2 rounded-full bg-rule-strong md:-left-[calc(1.5rem+5px)]" /> | |
| 95 | + <div className="display text-lg text-ink-3">{t('country.timeline.decade', { d: decade })}</div> | |
| 96 | + <ol className="mt-2 space-y-4"> | |
| 97 | + {Array.from(years.entries()).map(([year, events]) => ( | |
| 98 | + <li key={year} className="relative grid gap-x-4 sm:grid-cols-[3.25rem_minmax(0,1fr)]"> | |
| 99 | + <span aria-hidden className="absolute -left-[calc(1.25rem+4px)] top-2 h-1.5 w-1.5 rounded-full bg-accent md:-left-[calc(1.5rem+4px)]" /> | |
| 100 | + <span className="tnum display text-xl leading-none text-ink">{year}</span> | |
| 101 | + <ul className="mt-1 space-y-1.5 sm:mt-0"> | |
| 102 | + {events.map((e, i) => { | |
| 103 | + const ind = e.indicator; | |
| 104 | + const indSlug = (ind as { slug?: string; id: string }).slug ?? ind.id; | |
| 105 | + const topic = 'topic' in ind ? topicById(ind.topic ?? '')?.id : undefined; | |
| 106 | + const href = topic ? routes.countryIndicator(slug, topic, indSlug) : routes.indicator(indSlug); | |
| 107 | + const lvl = severityLevel(e.severity); | |
| 108 | + const Icon = ICON[e.kind ?? ''] ?? Activity; | |
| 109 | + return ( | |
| 110 | + <li key={e.id ?? i} className={cn('text-sm leading-snug', lvl === 'high' ? 'text-ink' : 'text-ink-2')}> | |
| 111 | + <Link href={href} className="link-quiet group/e flex items-start gap-2"> | |
| 112 | + <span className={cn('mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-xs', lvl === 'high' ? 'bg-accent-soft text-accent' : 'bg-surface-2 text-ink-3')} aria-hidden> | |
| 113 | + <Icon size={12} /> | |
| 114 | + </span> | |
| 115 | + <span className="min-w-0"> | |
| 116 | + <span className="mr-1.5 text-2xs uppercase tracking-wide text-ink-3">{kindLabel(e.kind, e.window_years)}</span> | |
| 117 | + <span className={cn(lvl === 'high' && 'font-medium')}>{e.headline}</span> | |
| 118 | + </span> | |
| 119 | + </Link> | |
| 120 | + </li> | |
| 121 | + ); | |
| 122 | + })} | |
| 123 | + </ul> | |
| 39 | 124 | </li> |
| 40 | − ); | |
| 41 | − })} | |
| 42 | − </ul> | |
| 43 | − </li> | |
| 44 | − ))} | |
| 45 | − </ol> | |
| 125 | + ))} | |
| 126 | + </ol> | |
| 127 | + </li> | |
| 128 | + ))} | |
| 129 | + </ol> | |
| 130 | + {filtered.length > shown ? ( | |
| 131 | + <div className="mt-4"> | |
| 132 | + <button type="button" onClick={() => setShown((s) => s + PAGE)} className="inline-flex h-11 items-center rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:h-10"> | |
| 133 | + {t('common.showMore', { n: Math.min(PAGE, filtered.length - shown) })} | |
| 134 | + </button> | |
| 135 | + </div> | |
| 136 | + ) : null} | |
| 137 | + </div> | |
| 46 | 138 | ); |
| 47 | 139 | } |
modified
apps/web/src/components/data/change-list.tsx
+4 −1
@@ -1,4 +1,4 @@ | ||
| 1 | −import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, TrendingDown, TrendingUp, Trophy } from 'lucide-react'; | |
| 1 | +import { AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, TrendingDown, TrendingUp, Trophy, Waves } from 'lucide-react'; | |
| 2 | 2 | import Link from 'next/link'; |
| 3 | 3 | import { t, tOpt } from '@/i18n'; |
| 4 | 4 | import { cn } from '@/lib/cn'; |
@@ -17,6 +17,9 @@ const ICON: Record<string, typeof ArrowUpRight> = { | ||
| 17 | 17 | sign_flip: Repeat, |
| 18 | 18 | accelerating: TrendingUp, |
| 19 | 19 | decelerating: TrendingDown, |
| 20 | + structural_break: GitCommitHorizontal, | |
| 21 | + trend_reversal: Repeat, | |
| 22 | + volatility_spike: Waves, | |
| 20 | 23 | }; |
| 21 | 24 | |
| 22 | 25 | export function kindLabel(kind: string | null | undefined, windowYears?: number | null): string { |
modified
apps/web/src/components/data/metric.tsx
+29 −7
@@ -3,7 +3,7 @@ import { ChevronRight } from 'lucide-react'; | ||
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { t } from '@/i18n'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | −import { displayValue, formatPeriod } from '@/lib/format'; | |
| 6 | +import { displayValue, formatPeriod, ordinal } from '@/lib/format'; | |
| 7 | 7 | import type { MetricValue } from '@/lib/types'; |
| 8 | 8 | import { pointsFromSpark } from '@/components/charts/scales'; |
| 9 | 9 | import { Sparkline } from '@/components/charts/sparkline'; |
@@ -28,16 +28,30 @@ export function payloadFor(m: MetricValue, country?: MetricCountry | null, extra | ||
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | /** |
| 31 | − * Headline metric: label, big value, period, change chip, rank line, tiny sparkline. Click on the value → | |
| 32 | − * provenance sheet; `href` (optional) renders a quiet chevron link to the topic page. 1 px separators, no card. | |
| 33 | − * Heights are reserved (sparkline box, rank line) so the grid does not shift while fonts/data load. | |
| 31 | + * Percentile of the latest value within the country's own history (share of earlier points strictly below it). | |
| 32 | + * Null with fewer than 5 earlier points. 100 = highest ever, 0 = lowest ever. | |
| 34 | 33 | */ |
| 35 | −export function Metric({ metric, country, regionName, href, className, size = 'md' }: { metric: MetricValue; country?: MetricCountry | null; regionName?: string | null; href?: string | null; className?: string; size?: 'sm' | 'md' | 'lg' }) { | |
| 34 | +export function ownHistoryPercentile(m: MetricValue): number | null { | |
| 35 | + const pts = m.sparkline.filter((p) => typeof p[1] === 'number') as Array<[number, number]>; | |
| 36 | + if (pts.length < 6 || m.value == null) return null; | |
| 37 | + const earlier = pts.slice(0, -1).map((p) => p[1]); | |
| 38 | + const below = earlier.filter((v) => v < m.value!).length; | |
| 39 | + return Math.round((below / earlier.length) * 100); | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** | |
| 43 | + * Headline metric module: label (link) + sparkline, big value, period, change chip, ranks (world · region), | |
| 44 | + * percentile of own history. Click on the value → provenance sheet. 1 px separators, no card. Heights are | |
| 45 | + * reserved so the grid does not shift while data loads. | |
| 46 | + */ | |
| 47 | +export function Metric({ metric, country, regionName, href, className, size = 'md', showOwnPercentile = true }: { metric: MetricValue; country?: MetricCountry | null; regionName?: string | null; href?: string | null; className?: string; size?: 'sm' | 'md' | 'lg'; showOwnPercentile?: boolean }) { | |
| 36 | 48 | const { open } = useProvenance(); |
| 37 | 49 | const m = metric; |
| 38 | 50 | const hasValue = m.has_data && m.value != null; |
| 39 | 51 | const dir = m.change?.abs == null ? null : m.change.abs > 0 ? 'up' : m.change.abs < 0 ? 'down' : 'flat'; |
| 40 | 52 | const points = pointsFromSpark(m.sparkline); |
| 53 | + const pct = showOwnPercentile && hasValue ? ownHistoryPercentile(m) : null; | |
| 54 | + const firstYear = points[0]?.year; | |
| 41 | 55 | return ( |
| 42 | 56 | <div className={cn('flex min-w-0 flex-col gap-1 border-t border-rule py-3', className)} id={m.indicator}> |
| 43 | 57 | <div className="flex items-start justify-between gap-2"> |
@@ -49,7 +63,7 @@ export function Metric({ metric, country, regionName, href, className, size = 'm | ||
| 49 | 63 | ) : ( |
| 50 | 64 | <div className="line-clamp-2 min-w-0 py-1 text-xs font-medium leading-snug text-ink-2">{m.indicator_name ?? m.indicator}</div> |
| 51 | 65 | )} |
| 52 | − {points.length >= 2 ? <Sparkline points={points} width={64} height={20} direction={dir} className="mt-0.5 shrink-0 opacity-90" /> : <span className="inline-block h-[20px] w-[64px] shrink-0" aria-hidden />} | |
| 66 | + {points.length >= 2 ? <Sparkline points={points} width={72} height={22} direction={dir} className="mt-0.5 shrink-0 opacity-90" ariaLabel={firstYear ? t('metric.sparkAria', { name: m.indicator_name ?? m.indicator, y0: firstYear, y1: m.year ?? '' }) : undefined} /> : <span className="inline-block h-[22px] w-[72px] shrink-0" aria-hidden />} | |
| 53 | 67 | </div> |
| 54 | 68 | <button type="button" onClick={() => open(payloadFor(m, country))} className="group -mx-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2 focus-visible:bg-surface-2" aria-label={t('common.openProvenance')}> |
| 55 | 69 | <span className={cn('pnum font-semibold leading-none text-ink', size === 'lg' ? 'text-3xl md:text-4xl' : size === 'sm' ? 'text-xl' : 'text-2xl md:text-[1.75rem]')}> |
@@ -64,11 +78,19 @@ export function Metric({ metric, country, regionName, href, className, size = 'm | ||
| 64 | 78 | <div className="min-h-[1.1rem] min-w-0 truncate"> |
| 65 | 79 | <RankBadge rank={m} regionName={regionName} /> |
| 66 | 80 | </div> |
| 81 | + {pct != null && firstYear ? ( | |
| 82 | + <div className="tnum min-h-[1.1rem] flex items-center gap-1.5 text-2xs text-ink-3" title={t('metric.ownPercentileHint', { y0: firstYear })}> | |
| 83 | + <span className="inline-flex h-1 w-12 overflow-hidden rounded-xs bg-surface-2" aria-hidden> | |
| 84 | + <span className="h-full bg-ink-3" style={{ width: `${pct}%` }} /> | |
| 85 | + </span> | |
| 86 | + {t('metric.ownPercentile', { p: ordinal(pct), y0: firstYear })} | |
| 87 | + </div> | |
| 88 | + ) : null} | |
| 67 | 89 | </div> |
| 68 | 90 | ); |
| 69 | 91 | } |
| 70 | 92 | |
| 71 | −/** Responsive editorial grid for Metrics: 1 col ≤ 360 px, 2 on phones, 3–4 on desktop. */ | |
| 93 | +/** Responsive editorial grid for Metrics: swipeable strip on phones (2 visible), 3–4 columns on desktop. */ | |
| 72 | 94 | export function MetricGrid({ children, className, cols = 4 }: { children: React.ReactNode; className?: string; cols?: 3 | 4 }) { |
| 73 | 95 | return <div className={cn('grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-3', cols === 4 && 'xl:grid-cols-4', className)}>{children}</div>; |
| 74 | 96 | } |
added
apps/web/src/components/home/hero-map.tsx
+221 −0
@@ -0,0 +1,221 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowRight, Maximize2 } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { t } from '@/i18n'; | |
| 6 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { formatValue, grouped, ordinal } from '@/lib/format'; | |
| 9 | +import { regionShort } from '@/lib/regions'; | |
| 10 | +import { routes } from '@/lib/site'; | |
| 11 | +import type { FormatSpec } from '@/lib/types'; | |
| 12 | +import type { FramesResponse } from '@/lib/types-analytics'; | |
| 13 | +import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view'; | |
| 14 | +import { Sparkline } from '@/components/charts/sparkline'; | |
| 15 | +import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; | |
| 16 | +import { YearSlider } from '@/components/controls/year-slider'; | |
| 17 | +import { BottomSheet } from '@/components/data/bottom-sheet'; | |
| 18 | +import { EmptyState } from '@/components/data/empty-state'; | |
| 19 | +import type { BaseFeature } from '@/components/indicators/indicator-map'; | |
| 20 | + | |
| 21 | +export interface HeroCountry { | |
| 22 | + id: string; | |
| 23 | + slug: string | null; | |
| 24 | + name: string; | |
| 25 | + flag: string | null; | |
| 26 | + region: string | null; | |
| 27 | +} | |
| 28 | + | |
| 29 | +/** | |
| 30 | + * Homepage hero map: indicator chips (headline set) + full picker, time machine (frames endpoint, one request per | |
| 31 | + * indicator, pooled quantile legend so colours stay comparable while scrubbing), hover label with value · year · | |
| 32 | + * world rank · regional rank, tap/click → quick panel (bottom sheet / drawer) with sparkline and links. | |
| 33 | + */ | |
| 34 | +export function HeroMap({ geometry, sphere, initial, options, chips, countries }: { geometry: BaseFeature[]; sphere: string; initial: FramesResponse | null; options: IndicatorOption[]; chips: string[]; countries: HeroCountry[] }) { | |
| 35 | + const [slug, setSlug] = useState(initial?.indicator.slug ?? chips[0] ?? 'gdp-per-capita-ppp'); | |
| 36 | + const [cache, setCache] = useState<Record<string, FramesResponse | null>>(() => (initial ? { [initial.indicator.slug]: initial } : {})); | |
| 37 | + const [year, setYear] = useState<number | null>(initial?.years[initial.years.length - 1] ?? null); | |
| 38 | + const [selected, setSelected] = useState<string | null>(null); | |
| 39 | + const [loading, setLoading] = useState(false); | |
| 40 | + const abortRef = useRef<AbortController | null>(null); | |
| 41 | + const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]); | |
| 42 | + | |
| 43 | + useEffect(() => { | |
| 44 | + if (cache[slug] !== undefined) return; | |
| 45 | + abortRef.current?.abort(); | |
| 46 | + const ctrl = new AbortController(); | |
| 47 | + abortRef.current = ctrl; | |
| 48 | + setLoading(true); | |
| 49 | + clientAnalytics | |
| 50 | + .indicatorFrames(slug, {}, ctrl.signal) | |
| 51 | + .then((f) => { | |
| 52 | + setCache((c) => ({ ...c, [slug]: f })); | |
| 53 | + setYear((y) => (y != null && f.years.includes(y) ? y : f.years[f.years.length - 1] ?? null)); | |
| 54 | + }) | |
| 55 | + .catch((e) => { | |
| 56 | + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [slug]: null })); | |
| 57 | + }) | |
| 58 | + .finally(() => { | |
| 59 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 60 | + }); | |
| 61 | + return () => ctrl.abort(); | |
| 62 | + }, [slug, cache]); | |
| 63 | + | |
| 64 | + const frames = cache[slug] ?? null; | |
| 65 | + const yearIdx = frames && year != null ? frames.years.indexOf(year) : -1; | |
| 66 | + const spec: FormatSpec | null = frames ? { format: frames.indicator.format, unit: frames.indicator.unit, unit_short: frames.indicator.unit_short, precision: frames.indicator.precision, name: frames.indicator.short_name ?? frames.indicator.name, higher_is_better: frames.indicator.higher_is_better } : null; | |
| 67 | + | |
| 68 | + const model = useMemo(() => { | |
| 69 | + if (!frames || yearIdx < 0) return null; | |
| 70 | + const breaks = frames.legend.breaks.slice(0, 6); | |
| 71 | + const values = new Map<string, number>(); | |
| 72 | + for (const [iso, arr] of Object.entries(frames.values)) { | |
| 73 | + const v = arr[yearIdx]; | |
| 74 | + if (typeof v === 'number') values.set(iso, v); | |
| 75 | + } | |
| 76 | + const desc = frames.indicator.higher_is_better !== false; | |
| 77 | + const sorted = Array.from(values.entries()).sort((a, b) => (desc ? b[1] - a[1] : a[1] - b[1])); | |
| 78 | + const rank = new Map<string, number>(); | |
| 79 | + const regionRank = new Map<string, [number, number]>(); | |
| 80 | + const regionCount = new Map<string, number>(); | |
| 81 | + sorted.forEach(([iso], i) => rank.set(iso, i + 1)); | |
| 82 | + for (const [iso] of sorted) { | |
| 83 | + const rg = byId.get(iso)?.region ?? ''; | |
| 84 | + const n = (regionCount.get(rg) ?? 0) + 1; | |
| 85 | + regionCount.set(rg, n); | |
| 86 | + regionRank.set(iso, [n, 0]); | |
| 87 | + } | |
| 88 | + for (const [iso, rr] of regionRank) rr[1] = regionCount.get(byId.get(iso)?.region ?? '') ?? 0; | |
| 89 | + const features: ChoroplethFeature[] = geometry.map((g) => { | |
| 90 | + const v = g.iso3 ? values.get(g.iso3) : undefined; | |
| 91 | + return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null }; | |
| 92 | + }); | |
| 93 | + return { features, breaks, values, rank, regionRank, n: values.size, k: breaks.length + 1 }; | |
| 94 | + }, [frames, yearIdx, geometry, byId]); | |
| 95 | + | |
| 96 | + const legend = useMemo(() => (frames && spec ? legendFromBreaks(frames.legend.breaks.slice(0, 6), frames.legend.min, frames.legend.max, spec) : []), [frames, spec]); | |
| 97 | + const sel = selected && byId.get(selected) ? byId.get(selected)! : null; | |
| 98 | + const selSeries = selected && frames ? frames.values[selected] ?? null : null; | |
| 99 | + const selPoints = selSeries ? frames!.years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selSeries[i] ?? null })) : []; | |
| 100 | + const selValue = selected ? model?.values.get(selected) ?? null : null; | |
| 101 | + const summary = frames && spec && year != null && model ? t('chart.summary.map', { name: spec.name, year, n: model.n, min: formatValue(frames.legend.min, spec), max: formatValue(frames.legend.max, spec) }) : t('chart.noData'); | |
| 102 | + | |
| 103 | + return ( | |
| 104 | + <div className="min-w-0"> | |
| 105 | + {/* Indicator switcher */} | |
| 106 | + <div className="flex flex-wrap items-center gap-2"> | |
| 107 | + <ul className="ticker -mx-4 max-w-[calc(100%+2rem)] gap-1.5 px-4 sm:mx-0 sm:max-w-none sm:flex-wrap sm:px-0" aria-label={t('control.indicator')}> | |
| 108 | + {chips.map((s) => { | |
| 109 | + const o = options.find((x) => x.slug === s); | |
| 110 | + if (!o) return null; | |
| 111 | + return ( | |
| 112 | + <li key={s}> | |
| 113 | + <button type="button" onClick={() => setSlug(s)} aria-pressed={slug === s} className={cn('inline-flex h-10 items-center whitespace-nowrap rounded-sm border px-3 text-sm md:h-8 md:px-2.5 md:text-xs', slug === s ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 114 | + {o.short_name ?? o.name} | |
| 115 | + </button> | |
| 116 | + </li> | |
| 117 | + ); | |
| 118 | + })} | |
| 119 | + </ul> | |
| 120 | + <IndicatorSelect options={options} value={slug} onChange={setSlug} size="sm" className="w-full sm:ml-auto sm:w-72" align="right" /> | |
| 121 | + </div> | |
| 122 | + | |
| 123 | + <div className={cn('relative mt-3 transition-opacity', loading && 'opacity-60')} aria-busy={loading}> | |
| 124 | + {frames === null ? ( | |
| 125 | + <EmptyState title={t('home.map.unavailable')} hint={t('indicator.nodata.why')} /> | |
| 126 | + ) : model && spec ? ( | |
| 127 | + <ChoroplethView | |
| 128 | + features={model.features} | |
| 129 | + sphere={sphere} | |
| 130 | + legend={legend} | |
| 131 | + k={model.k} | |
| 132 | + spec={spec} | |
| 133 | + summary={summary} | |
| 134 | + title={t('chart.map.legend', { name: spec.name, year: year ?? '' })} | |
| 135 | + compact | |
| 136 | + selectedId={selected} | |
| 137 | + onSelect={(f) => f.iso3 && setSelected(f.iso3)} | |
| 138 | + renderLabel={(f) => { | |
| 139 | + const r = f.iso3 ? model.rank.get(f.iso3) : undefined; | |
| 140 | + const rr = f.iso3 ? model.regionRank.get(f.iso3) : undefined; | |
| 141 | + const c = f.iso3 ? byId.get(f.iso3) : undefined; | |
| 142 | + return ( | |
| 143 | + <div className="tnum text-ink-2"> | |
| 144 | + <div> | |
| 145 | + <span className="font-semibold text-ink">{formatValue(f.value, spec)}</span> <span className="text-ink-3">{year}</span> | |
| 146 | + </div> | |
| 147 | + {r ? ( | |
| 148 | + <div className="text-2xs text-ink-3"> | |
| 149 | + {t('home.map.world', { rank: r, n: model.n })} | |
| 150 | + {rr && c?.region ? ` · ${t('home.map.region', { rank: rr[0], n: rr[1], region: regionShort(c.region) ?? c.region })}` : ''} | |
| 151 | + </div> | |
| 152 | + ) : null} | |
| 153 | + </div> | |
| 154 | + ); | |
| 155 | + }} | |
| 156 | + /> | |
| 157 | + ) : ( | |
| 158 | + <div className="grid aspect-[960/470] w-full place-items-center rounded-sm bg-map-water text-sm text-ink-3">{t('common.loading')}</div> | |
| 159 | + )} | |
| 160 | + </div> | |
| 161 | + | |
| 162 | + {/* Time machine */} | |
| 163 | + {frames && year != null ? ( | |
| 164 | + <div className="mt-3 flex flex-col gap-3 md:flex-row md:items-center md:gap-6"> | |
| 165 | + <YearSlider years={frames.years} year={year} onChange={setYear} className="min-w-0 flex-1" interval={600} /> | |
| 166 | + <div className="flex shrink-0 items-center gap-3 text-xs text-ink-3"> | |
| 167 | + <span className="tnum">{t('indicator.map.n', { n: grouped(model?.n ?? 0) })}</span> | |
| 168 | + <Link href={routes.explore({ indicator: slug, year })} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-accent hover:underline md:min-h-[32px]"> | |
| 169 | + <Maximize2 size={14} aria-hidden /> | |
| 170 | + {t('home.map.openExplorer')} | |
| 171 | + </Link> | |
| 172 | + </div> | |
| 173 | + </div> | |
| 174 | + ) : null} | |
| 175 | + | |
| 176 | + {/* Quick panel */} | |
| 177 | + <BottomSheet open={!!sel} onClose={() => setSelected(null)} side="drawer" title={sel ? `${sel.flag ?? ''} ${sel.name}`.trim() : ''}> | |
| 178 | + {sel && spec && frames ? ( | |
| 179 | + <div className="space-y-4 text-sm"> | |
| 180 | + <div> | |
| 181 | + <div className="eyebrow">{spec.name}</div> | |
| 182 | + <div className="mt-1 flex flex-wrap items-baseline gap-x-3"> | |
| 183 | + <span className="pnum text-3xl font-semibold text-ink">{formatValue(selValue, spec)}</span> | |
| 184 | + <span className="tnum text-ink-3">{year}</span> | |
| 185 | + </div> | |
| 186 | + {model?.rank.get(sel.id) ? ( | |
| 187 | + <p className="tnum mt-1 text-xs text-ink-2"> | |
| 188 | + {t('metric.rankWorld', { rank: ordinal(model.rank.get(sel.id)!), n: grouped(model.n) })} | |
| 189 | + {model.regionRank.get(sel.id) && sel.region ? ` · ${t('metric.rankRegion', { rank: ordinal(model.regionRank.get(sel.id)![0]), region: regionShort(sel.region) ?? sel.region })}` : ''} | |
| 190 | + </p> | |
| 191 | + ) : ( | |
| 192 | + <p className="mt-1 text-xs text-ink-3">{t('home.map.noValue', { year: year ?? '' })}</p> | |
| 193 | + )} | |
| 194 | + </div> | |
| 195 | + {selPoints.filter((p) => p.value != null).length >= 2 ? ( | |
| 196 | + <div> | |
| 197 | + <div className="eyebrow mb-1">{t('home.map.history', { y0: frames.years[0] ?? '', y1: frames.years[frames.years.length - 1] ?? '' })}</div> | |
| 198 | + <Sparkline points={selPoints} width={360} height={72} className="h-[72px] w-full" ariaLabel={t('metric.history')} /> | |
| 199 | + </div> | |
| 200 | + ) : null} | |
| 201 | + <div className="flex flex-wrap gap-2 pt-1"> | |
| 202 | + {sel.slug ? ( | |
| 203 | + <Link href={routes.country(sel.slug)} className="tap inline-flex items-center gap-1.5 rounded-sm bg-ink px-3 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink"> | |
| 204 | + {t('home.map.openCountry')} <ArrowRight size={14} aria-hidden /> | |
| 205 | + </Link> | |
| 206 | + ) : null} | |
| 207 | + {sel.slug ? ( | |
| 208 | + <Link href={routes.compare(sel.slug)} className="tap inline-flex items-center rounded-sm border border-rule px-3 text-sm hover:bg-surface-2"> | |
| 209 | + {t('common.compare')} | |
| 210 | + </Link> | |
| 211 | + ) : null} | |
| 212 | + <Link href={routes.explore({ indicator: slug, year, country: sel.id })} className="tap inline-flex items-center rounded-sm border border-rule px-3 text-sm hover:bg-surface-2"> | |
| 213 | + {t('home.map.openExplorer')} | |
| 214 | + </Link> | |
| 215 | + </div> | |
| 216 | + </div> | |
| 217 | + ) : null} | |
| 218 | + </BottomSheet> | |
| 219 | + </div> | |
| 220 | + ); | |
| 221 | +} | |
modified
apps/web/src/components/home/hero.tsx
+8 −6
@@ -1,14 +1,16 @@ | ||
| 1 | 1 | import { t } from '@/i18n'; |
| 2 | 2 | import { SearchTrigger } from '@/components/layout/search-trigger'; |
| 3 | 3 | |
| 4 | −/** Compact editorial hero: tagline + search box. No marketing block — data starts right below. */ | |
| 4 | +/** Compact editorial hero: one headline, one line, the search box. The map right below is the real hero. */ | |
| 5 | 5 | export function Hero() { |
| 6 | 6 | return ( |
| 7 | − <section className="pb-6 pt-8 md:pb-10 md:pt-14"> | |
| 8 | − <div className="max-w-3xl"> | |
| 9 | − <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('home.hero.title')}</h1> | |
| 10 | − <p className="mt-3 max-w-2xl text-base text-ink-2 md:text-lg">{t('home.hero.sub')}</p> | |
| 11 | − <div className="mt-5 max-w-xl"> | |
| 7 | + <section className="pb-4 pt-6 md:pb-6 md:pt-10"> | |
| 8 | + <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"> | |
| 9 | + <div className="max-w-2xl"> | |
| 10 | + <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('home.hero.title')}</h1> | |
| 11 | + <p className="mt-2 max-w-xl text-base text-ink-2 md:text-lg">{t('home.hero.sub')}</p> | |
| 12 | + </div> | |
| 13 | + <div className="w-full md:max-w-md md:flex-1"> | |
| 12 | 14 | <SearchTrigger variant="hero" /> |
| 13 | 15 | </div> |
| 14 | 16 | </div> |
added
apps/web/src/components/home/latest-updates.tsx
+39 −0
@@ -0,0 +1,39 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { t, tOpt } from '@/i18n'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { compact, formatDate, grouped } from '@/lib/format'; | |
| 5 | +import { routes } from '@/lib/site'; | |
| 6 | +import type { UpdatesResponse } from '@/lib/types-analytics'; | |
| 7 | + | |
| 8 | +/** Latest data updates: one row per source (status, last import, vintage, latest year, values changed). */ | |
| 9 | +export function LatestUpdates({ data, limit = 9 }: { data: UpdatesResponse; limit?: number }) { | |
| 10 | + const rows = [...data.sources].sort((a, b) => (b.last_success_at ?? '').localeCompare(a.last_success_at ?? '')).slice(0, limit); | |
| 11 | + return ( | |
| 12 | + <div className="min-w-0"> | |
| 13 | + <ul className="divide-y divide-rule border-y border-rule text-sm"> | |
| 14 | + {rows.map((s) => ( | |
| 15 | + <li key={s.source.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 py-2 md:grid-cols-[minmax(0,12rem)_6rem_7rem_5rem_minmax(0,1fr)]"> | |
| 16 | + <Link href={routes.source(s.source.id)} className="link-quiet flex min-h-[32px] min-w-0 items-center gap-2 font-medium text-ink"> | |
| 17 | + <span className={cn('inline-block h-2 w-2 shrink-0 rounded-full', s.status === 'ok' ? 'bg-up' : s.status === 'partial' ? 'bg-warn' : s.status === 'failed' ? 'bg-down' : 'bg-rule-strong')} aria-hidden /> | |
| 18 | + <span className="truncate">{s.source.name ?? s.source.id}</span> | |
| 19 | + <span className="sr-only">{tOpt(`home.updates.status.${s.status}`, s.status)}</span> | |
| 20 | + </Link> | |
| 21 | + <span className="tnum text-right text-xs text-ink-2 md:text-left">{formatDate(s.last_success_at ?? s.last_retrieved_at)}</span> | |
| 22 | + <span className="tnum hidden text-xs text-ink-3 md:block">{s.source_updated_at ? t('home.updates.vintage', { date: formatDate(s.source_updated_at) }) : ''}</span> | |
| 23 | + <span className="tnum hidden text-xs text-ink-3 md:block">{s.latest_year ? `→ ${s.latest_year}` : ''}</span> | |
| 24 | + <span className="tnum hidden truncate text-xs text-ink-3 md:block"> | |
| 25 | + {t('home.updates.counts', { obs: compact(s.n_observations), ind: grouped(s.n_indicators) })} | |
| 26 | + {s.values_changed ? ` · ${t('home.updates.changed', { n: grouped(s.values_changed), c: grouped(s.countries_affected) })}` : ''} | |
| 27 | + </span> | |
| 28 | + </li> | |
| 29 | + ))} | |
| 30 | + </ul> | |
| 31 | + <div className="mt-2 flex flex-wrap items-center justify-between gap-2 text-xs text-ink-3"> | |
| 32 | + <span className="tnum">{t('home.updates.snapshot', { run: data.snapshot.run_id ?? '', date: formatDate(data.snapshot.built_at) })}</span> | |
| 33 | + <Link href={routes.updates()} className="inline-flex min-h-[36px] items-center text-sm text-accent hover:underline"> | |
| 34 | + {t('home.updates.all')} → | |
| 35 | + </Link> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + ); | |
| 39 | +} | |
added
apps/web/src/components/home/movers.tsx
+142 −0
@@ -0,0 +1,142 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowDownRight, ArrowUpRight } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { t, tOpt } from '@/i18n'; | |
| 6 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { severityLevel } from '@/lib/severity'; | |
| 9 | +import { routes } from '@/lib/site'; | |
| 10 | +import type { MoverCategory, MoverItem, MoverKindFilter, MoverWindow, MoversResponse } from '@/lib/types-analytics'; | |
| 11 | +import { kindLabel } from '@/components/data/change-list'; | |
| 12 | +import { CountryChip } from '@/components/data/country-chip'; | |
| 13 | +import { Segmented } from '@/components/controls/indicator-select'; | |
| 14 | + | |
| 15 | +const WINDOWS: MoverWindow[] = [1, 5, 10]; | |
| 16 | +const CATEGORIES: MoverCategory[] = ['all', 'economic', 'demographic', 'health', 'energy', 'climate', 'digital', 'housing', 'labor']; | |
| 17 | +const KINDS_1: MoverKindFilter[] = ['all', 'improvement', 'deterioration', 'record', 'reversal', 'acceleration', 'structural']; | |
| 18 | +const KINDS_N: MoverKindFilter[] = ['all', 'improvement', 'deterioration', 'increase', 'decrease']; | |
| 19 | + | |
| 20 | +/** | |
| 21 | + * Biggest movers: window tabs (24 months / 5 years / 10 years), category and kind chips; rows show country, | |
| 22 | + * indicator, ref → now, delta, severity dot and the templated headline. The server passes the first payload | |
| 23 | + * (window 1, all); every change refetches `/movers` client-side with request cancellation. | |
| 24 | + */ | |
| 25 | +export function Movers({ initial, limit = 12, compact = false, initialWindow }: { initial: MoversResponse | null; limit?: number; compact?: boolean; initialWindow?: MoverWindow }) { | |
| 26 | + const [win, setWin] = useState<MoverWindow>(initialWindow ?? initial?.window ?? 1); | |
| 27 | + const [category, setCategory] = useState<MoverCategory>('all'); | |
| 28 | + const [kind, setKind] = useState<MoverKindFilter>('all'); | |
| 29 | + const [cache, setCache] = useState<Record<string, MoversResponse | null>>(() => (initial ? { [`${initial.window}|all|all`]: initial } : {})); | |
| 30 | + const [loading, setLoading] = useState(false); | |
| 31 | + const abortRef = useRef<AbortController | null>(null); | |
| 32 | + const key = `${win}|${category}|${kind}`; | |
| 33 | + | |
| 34 | + useEffect(() => { | |
| 35 | + if (cache[key] !== undefined) return; | |
| 36 | + abortRef.current?.abort(); | |
| 37 | + const ctrl = new AbortController(); | |
| 38 | + abortRef.current = ctrl; | |
| 39 | + setLoading(true); | |
| 40 | + clientAnalytics | |
| 41 | + .movers({ window: win, category, kind, limit }, ctrl.signal) | |
| 42 | + .then((r) => setCache((c) => ({ ...c, [key]: r }))) | |
| 43 | + .catch((e) => { | |
| 44 | + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null })); | |
| 45 | + }) | |
| 46 | + .finally(() => { | |
| 47 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 48 | + }); | |
| 49 | + return () => ctrl.abort(); | |
| 50 | + }, [key, win, category, kind, limit, cache]); | |
| 51 | + | |
| 52 | + const data = cache[key]; | |
| 53 | + const kinds = win === 1 ? KINDS_1 : KINDS_N; | |
| 54 | + useEffect(() => { | |
| 55 | + if (!kinds.includes(kind)) setKind('all'); | |
| 56 | + }, [kinds, kind]); | |
| 57 | + const items = useMemo(() => (data?.items ?? []).slice(0, limit), [data, limit]); | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div className="min-w-0"> | |
| 61 | + <div className="flex flex-wrap items-center gap-2"> | |
| 62 | + <Segmented value={String(win) as '1' | '5' | '10'} onChange={(v) => setWin(Number(v) as MoverWindow)} label={t('home.movers.window')} options={WINDOWS.map((w) => ({ value: String(w) as '1' | '5' | '10', label: t(`home.movers.window.${w}` as 'home.movers.window.1') }))} size="sm" /> | |
| 63 | + <label className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-8 md:text-xs"> | |
| 64 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('home.movers.category')}</span> | |
| 65 | + <select value={category} onChange={(e) => setCategory(e.target.value as MoverCategory)} className="bg-transparent text-ink outline-none" aria-label={t('home.movers.category')}> | |
| 66 | + {CATEGORIES.map((c) => ( | |
| 67 | + <option key={c} value={c}> | |
| 68 | + {t(`home.movers.cat.${c}` as 'home.movers.cat.all')} | |
| 69 | + </option> | |
| 70 | + ))} | |
| 71 | + </select> | |
| 72 | + </label> | |
| 73 | + {data?.filter_note ? <span className="text-2xs text-ink-3">{data.filter_note}</span> : null} | |
| 74 | + </div> | |
| 75 | + <ul className="scrollbar-none -mx-4 mt-2 flex gap-1.5 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:px-0" role="radiogroup" aria-label={t('home.movers.kind')}> | |
| 76 | + {kinds.map((k) => ( | |
| 77 | + <li key={k} className="shrink-0"> | |
| 78 | + <button type="button" role="radio" aria-checked={kind === k} onClick={() => setKind(k)} className={cn('inline-flex h-9 items-center rounded-sm border px-2.5 text-xs md:h-8', kind === k ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 79 | + {t(`home.movers.kind.${k}` as 'home.movers.kind.all')} | |
| 80 | + </button> | |
| 81 | + </li> | |
| 82 | + ))} | |
| 83 | + </ul> | |
| 84 | + | |
| 85 | + <div className={cn('mt-3 min-h-[240px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}> | |
| 86 | + {data === null ? ( | |
| 87 | + <p className="py-6 text-sm text-ink-3">{t('common.errorHint')}</p> | |
| 88 | + ) : data && items.length === 0 ? ( | |
| 89 | + <p className="py-6 text-sm text-ink-3">{t('home.movers.none')}</p> | |
| 90 | + ) : ( | |
| 91 | + <ol className={cn('divide-y divide-rule border-y border-rule', compact && 'md:grid md:grid-cols-2 md:gap-x-10 md:border-y-0')}> | |
| 92 | + {items.map((m) => ( | |
| 93 | + <MoverRow key={`${m.country.id}-${m.indicator.slug}-${m.kind}-${m.year}`} m={m} /> | |
| 94 | + ))} | |
| 95 | + </ol> | |
| 96 | + )} | |
| 97 | + </div> | |
| 98 | + <div className="mt-3 flex flex-wrap gap-x-4 text-sm"> | |
| 99 | + <Link href={routes.changes()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline"> | |
| 100 | + {t('home.changes.all')} → | |
| 101 | + </Link> | |
| 102 | + <Link href={routes.extremes({ window: String(win === 1 ? 1 : win) })} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-ink-2 hover:text-accent hover:underline"> | |
| 103 | + {t('home.movers.extremes')} → | |
| 104 | + </Link> | |
| 105 | + </div> | |
| 106 | + </div> | |
| 107 | + ); | |
| 108 | +} | |
| 109 | + | |
| 110 | +function MoverRow({ m }: { m: MoverItem }) { | |
| 111 | + const up = m.direction === 'up'; | |
| 112 | + const Icon = up ? ArrowUpRight : ArrowDownRight; | |
| 113 | + const lvl = severityLevel(m.severity); | |
| 114 | + const tone = m.interpretation === 'improvement' ? 'text-up' : m.interpretation === 'deterioration' ? 'text-down' : up ? 'text-inc' : 'text-dec'; | |
| 115 | + const deltaText = m.delta_pct != null && ['currency', 'number', 'tonnes', 'kwh'].includes(m.indicator.format ?? '') ? `${m.delta_pct > 0 ? '+' : '−'}${Math.abs(m.delta_pct).toFixed(1)} %` : m.delta != null ? `${m.delta > 0 ? '+' : '−'}${Math.abs(m.delta).toFixed(1)}${m.indicator.format === 'percent' ? ' pts' : ''}` : ''; | |
| 116 | + return ( | |
| 117 | + <li className="py-2.5 md:border-b md:border-rule"> | |
| 118 | + <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3"> | |
| 119 | + <span className={cn('inline-block h-1.5 w-1.5 rounded-full', lvl === 'high' ? 'bg-accent' : lvl === 'medium' ? 'bg-ink-3' : 'bg-rule-strong')} aria-hidden /> | |
| 120 | + <CountryChip country={{ slug: m.country.slug ?? m.country.id, name: m.country.name ?? m.country.id, flag: m.country.flag }} size="sm" className="text-ink" /> | |
| 121 | + <span className="uppercase tracking-wide">{kindLabel(m.kind, null)}</span> | |
| 122 | + {m.interpretation ? <span className={cn('uppercase tracking-wide', tone)}>{tOpt(`home.movers.interp.${m.interpretation}`, m.interpretation)}</span> : null} | |
| 123 | + </div> | |
| 124 | + <div className="mt-0.5 flex flex-wrap items-baseline gap-x-2"> | |
| 125 | + <Link href={routes.indicator(m.indicator.slug)} 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-sm font-medium text-ink"> | |
| 126 | + {m.indicator.short_name ?? m.indicator.name} | |
| 127 | + </Link> | |
| 128 | + <span className="tnum text-sm text-ink-2"> | |
| 129 | + {m.formatted_ref ?? ''} → <span className="font-semibold text-ink">{m.formatted ?? ''}</span> | |
| 130 | + </span> | |
| 131 | + <span className={cn('tnum inline-flex items-center gap-0.5 text-sm font-medium', tone)}> | |
| 132 | + <Icon size={13} aria-hidden strokeWidth={2.25} /> | |
| 133 | + {deltaText} | |
| 134 | + </span> | |
| 135 | + <span className="tnum text-2xs text-ink-3"> | |
| 136 | + {m.ref_year}–{m.year} | |
| 137 | + </span> | |
| 138 | + </div> | |
| 139 | + {m.headline ? <p className="mt-0.5 text-xs leading-snug text-ink-2">{m.headline}</p> : null} | |
| 140 | + </li> | |
| 141 | + ); | |
| 142 | +} | |
modified
apps/web/src/components/home/snapshot-strip.tsx
+12 −8
@@ -2,24 +2,28 @@ import { t } from '@/i18n'; | ||
| 2 | 2 | import { compact, fixed, formatDate, grouped, isNum } from '@/lib/format'; |
| 3 | 3 | import type { GlobalSnapshot } from '@/lib/types'; |
| 4 | 4 | |
| 5 | −/** Global snapshot strip: 6 figures on one rule, wrapping to 2–3 columns on phones. */ | |
| 5 | +/** | |
| 6 | + * Global snapshot as an information ticker: seven figures on one rule. Horizontal snap-scroll strip on phones | |
| 7 | + * (`ticker`), one row with hairline dividers from lg. Uppercase eyebrow labels, large tabular figures, year/sub. | |
| 8 | + */ | |
| 6 | 9 | export function SnapshotStrip({ s }: { s: GlobalSnapshot }) { |
| 7 | 10 | const cells: Array<{ label: string; value: string; sub?: string }> = [ |
| 8 | 11 | { label: t('home.snapshot.population'), value: s.world_population_formatted ?? (isNum(s.world_population) ? compact(s.world_population) : t('common.na')), sub: s.world_population_year ? String(s.world_population_year) : undefined }, |
| 9 | 12 | { label: t('home.snapshot.gdp'), value: s.world_gdp_formatted ?? (isNum(s.world_gdp) ? `US$${compact(s.world_gdp)}` : t('common.na')), sub: s.world_gdp_year ? String(s.world_gdp_year) : undefined }, |
| 10 | 13 | { label: t('home.snapshot.lifeExpectancy'), value: isNum(s.median_life_expectancy) ? `${fixed(s.median_life_expectancy, 1)} yrs` : t('common.na'), sub: s.median_life_expectancy_year ? String(s.median_life_expectancy_year) : undefined }, |
| 11 | − { label: t('home.snapshot.countries'), value: grouped(s.n_countries + (s.n_territories ?? 0)), sub: `${grouped(s.n_countries)} countries` }, | |
| 12 | − { label: t('home.snapshot.indicators'), value: grouped(s.n_indicators), sub: s.n_indicators_with_data ? `${grouped(s.n_indicators_with_data)} with data` : undefined }, | |
| 13 | − { label: t('home.snapshot.observations'), value: compact(s.n_observations), sub: s.built_at ? t('home.snapshot.refreshed', { date: formatDate(s.built_at) }) : undefined }, | |
| 14 | + { label: t('home.snapshot.countries'), value: grouped(s.n_countries + (s.n_territories ?? 0)), sub: t('home.snapshot.countriesSub') }, | |
| 15 | + { label: t('home.snapshot.indicators'), value: grouped(s.n_indicators), sub: s.n_indicators_with_data ? t('home.snapshot.withData', { n: grouped(s.n_indicators_with_data) }) : undefined }, | |
| 16 | + { label: t('home.snapshot.observations'), value: compact(s.n_observations), sub: t('home.snapshot.sources', { n: s.n_sources }) }, | |
| 17 | + { label: t('home.snapshot.updated'), value: s.built_at ? formatDate(s.built_at) : t('common.na'), sub: s.run_id ? t('site.footer.build', { run: s.run_id }) : undefined }, | |
| 14 | 18 | ]; |
| 15 | 19 | return ( |
| 16 | 20 | <section aria-label={t('home.snapshot.title')} className="border-y border-rule"> |
| 17 | − <dl className="grid grid-cols-2 divide-rule sm:grid-cols-3 lg:grid-cols-6 lg:divide-x"> | |
| 21 | + <dl className="ticker gap-0 lg:grid lg:grid-cols-7 lg:divide-x lg:divide-rule"> | |
| 18 | 22 | {cells.map((c, i) => ( |
| 19 | − <div key={c.label} className={`py-4 lg:px-4 ${i % 2 === 1 ? 'pl-4 sm:pl-0' : ''} ${i >= 2 ? 'border-t border-rule sm:border-t-0' : ''} ${i >= 3 ? 'sm:border-t sm:border-rule lg:border-t-0' : ''} lg:first:pl-0`}> | |
| 20 | − <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{c.label}</dt> | |
| 23 | + <div key={c.label} className={`min-w-[9.5rem] py-3.5 pr-6 lg:min-w-0 lg:pr-4 ${i > 0 ? 'lg:pl-4' : ''} ${i === 0 ? '' : 'border-l border-rule pl-4 lg:border-l-0'}`}> | |
| 24 | + <dt className="eyebrow">{c.label}</dt> | |
| 21 | 25 | <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{c.value}</dd> |
| 22 | − {c.sub ? <dd className="tnum mt-1 text-xs text-ink-3">{c.sub}</dd> : null} | |
| 26 | + {c.sub ? <dd className="tnum mt-1 truncate text-xs text-ink-3">{c.sub}</dd> : null} | |
| 23 | 27 | </div> |
| 24 | 28 | ))} |
| 25 | 29 | </dl> |
added
apps/web/src/components/home/trajectory-teaser.tsx
+29 −0
@@ -0,0 +1,29 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowRight } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { fixed } from '@/lib/format'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import type { ScatterResponse } from '@/lib/types-analytics'; | |
| 8 | +import { BubbleChart } from '@/components/charts/bubble-chart'; | |
| 9 | + | |
| 10 | +/** Homepage teaser of the trajectories view: one static frame (latest year) of income vs life expectancy. */ | |
| 11 | +export function TrajectoryTeaser({ data }: { data: ScatterResponse }) { | |
| 12 | + const x = { format: data.x.format, unit: data.x.unit, unit_short: data.x.unit_short, precision: data.x.precision, name: data.x.short_name ?? data.x.name }; | |
| 13 | + const y = { format: data.y.format, unit: data.y.unit, unit_short: data.y.unit_short, precision: data.y.precision, name: data.y.short_name ?? data.y.name }; | |
| 14 | + const size = data.size ? { format: data.size.format, unit: data.size.unit, unit_short: data.size.unit_short, precision: data.size.precision, name: data.size.short_name ?? data.size.name } : null; | |
| 15 | + const points = data.points.map((p) => ({ id: p.id, label: p.name ?? p.id, flag: p.flag, x: p.x, y: p.y, size: p.size, region: p.region, yearX: p.year_x, yearY: p.year_y })); | |
| 16 | + return ( | |
| 17 | + <div className="min-w-0"> | |
| 18 | + <BubbleChart points={points} xSpec={x} ySpec={y} sizeSpec={size} logX={data.stats.log_x} logY={data.stats.log_y} height={340} labelCount={8} animate={false} yearLabel={data.year_used} highlight={['CAN']} /> | |
| 19 | + <div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-ink-3"> | |
| 20 | + <span className="tnum"> | |
| 21 | + {t('home.trajectory.stats', { n: data.n, rho: data.stats.spearman != null ? fixed(data.stats.spearman, 2) : t('common.na'), year: data.year_used ?? '' })} · {data.note} | |
| 22 | + </span> | |
| 23 | + <Link href={routes.trajectories({ x: data.x.slug, y: data.y.slug })} className="inline-flex min-h-[36px] items-center gap-1 text-sm text-accent hover:underline"> | |
| 24 | + {t('home.trajectory.open')} <ArrowRight size={14} aria-hidden /> | |
| 25 | + </Link> | |
| 26 | + </div> | |
| 27 | + </div> | |
| 28 | + ); | |
| 29 | +} | |
added
apps/web/src/components/home/transparency.tsx
+51 −0
@@ -0,0 +1,51 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { compact, grouped } from '@/lib/format'; | |
| 4 | +import { routes } from '@/lib/site'; | |
| 5 | +import type { Source } from '@/lib/types-explore'; | |
| 6 | + | |
| 7 | +/** Data transparency block: the sources with licence + counts, and the trust links (methodology, provenance, API). */ | |
| 8 | +export function Transparency({ sources }: { sources: Source[] }) { | |
| 9 | + const rows = [...sources].sort((a, b) => (b.n_observations ?? 0) - (a.n_observations ?? 0)); | |
| 10 | + return ( | |
| 11 | + <div className="grid gap-x-10 gap-y-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 12 | + <ul className="divide-y divide-rule border-y border-rule text-sm"> | |
| 13 | + {rows.map((s) => ( | |
| 14 | + <li key={s.id} className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-4 py-2 md:grid-cols-[minmax(0,11rem)_minmax(0,1fr)_7rem_6rem]"> | |
| 15 | + <Link href={routes.source(s.id)} className="link-quiet font-medium text-ink"> | |
| 16 | + {s.name ?? s.id} | |
| 17 | + </Link> | |
| 18 | + <span className="hidden truncate text-xs text-ink-3 md:block">{s.organization}</span> | |
| 19 | + <span className="tnum text-right text-xs text-ink-2 md:text-left">{s.n_observations != null ? t('home.transparency.obs', { n: compact(s.n_observations) }) : ''}</span> | |
| 20 | + <span className="hidden truncate text-xs text-ink-3 md:block">{s.licence ?? ''}</span> | |
| 21 | + </li> | |
| 22 | + ))} | |
| 23 | + </ul> | |
| 24 | + <div className="max-w-prose text-sm leading-relaxed text-ink-2"> | |
| 25 | + <p>{t('home.transparency.text', { n: grouped(sources.length) })}</p> | |
| 26 | + <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-sm"> | |
| 27 | + <li> | |
| 28 | + <Link href={routes.methodology()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline"> | |
| 29 | + {t('site.footer.methodology')} → | |
| 30 | + </Link> | |
| 31 | + </li> | |
| 32 | + <li> | |
| 33 | + <Link href={routes.sources()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline"> | |
| 34 | + {t('site.footer.sources')} → | |
| 35 | + </Link> | |
| 36 | + </li> | |
| 37 | + <li> | |
| 38 | + <Link href={routes.api()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline"> | |
| 39 | + {t('site.footer.api')} → | |
| 40 | + </Link> | |
| 41 | + </li> | |
| 42 | + <li> | |
| 43 | + <Link href={routes.download()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline"> | |
| 44 | + {t('site.footer.data')} → | |
| 45 | + </Link> | |
| 46 | + </li> | |
| 47 | + </ul> | |
| 48 | + </div> | |
| 49 | + </div> | |
| 50 | + ); | |
| 51 | +} | |
added
apps/web/src/components/home/world-pulse.tsx
+70 −0
@@ -0,0 +1,70 @@ | ||
| 1 | +import { ArrowDownRight, ArrowUpRight } from 'lucide-react'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { t } from '@/i18n'; | |
| 4 | +import { cn } from '@/lib/cn'; | |
| 5 | +import { formatValue, grouped } from '@/lib/format'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import type { PulseItem, PulseResponse } from '@/lib/types-analytics'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * World Pulse: a dense editorial list of what is changing globally, one row per headline/featured indicator. | |
| 11 | + * Direction uses the neutral increase/decrease colours (--inc/--dec); "improvement" tones apply only when the | |
| 12 | + * indicator declares a direction. Share bar = countries up vs down; right column = biggest mover. | |
| 13 | + */ | |
| 14 | +export function WorldPulse({ data, limit = 14 }: { data: PulseResponse; limit?: number }) { | |
| 15 | + const items = [...data.items].sort((a, b) => Math.max(b.share_up, b.share_down) - Math.max(a.share_up, a.share_down)).slice(0, limit); | |
| 16 | + return ( | |
| 17 | + <div className="min-w-0"> | |
| 18 | + <ol className="divide-y divide-rule border-y border-rule"> | |
| 19 | + {items.map((it) => ( | |
| 20 | + <PulseRow key={it.indicator.slug} it={it} /> | |
| 21 | + ))} | |
| 22 | + </ol> | |
| 23 | + <p className="mt-3 text-xs text-ink-3"> | |
| 24 | + {t('home.pulse.footnote', { year: data.year_reference, n: grouped(data.summary.n_countries_reporting) })} {t('semantics.note')} | |
| 25 | + </p> | |
| 26 | + </div> | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +function PulseRow({ it }: { it: PulseItem }) { | |
| 31 | + const up = it.share_up >= it.share_down; | |
| 32 | + const Icon = up ? ArrowUpRight : ArrowDownRight; | |
| 33 | + const spec = { format: it.indicator.format, unit: it.indicator.unit, unit_short: it.indicator.unit_short, precision: it.indicator.precision }; | |
| 34 | + const mover = up ? it.top_up : it.top_down; | |
| 35 | + const total = it.n_up + it.n_down + it.n_flat || 1; | |
| 36 | + return ( | |
| 37 | + <li className="grid gap-x-4 gap-y-1.5 py-3 md:grid-cols-[minmax(0,12rem)_minmax(0,1fr)_minmax(0,14rem)] md:items-center"> | |
| 38 | + <div className="flex min-w-0 items-center gap-2"> | |
| 39 | + <span className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-sm', up ? 'bg-inc/15 text-inc' : 'bg-dec/15 text-dec')} aria-hidden> | |
| 40 | + <Icon size={15} strokeWidth={2.25} /> | |
| 41 | + </span> | |
| 42 | + <Link href={routes.indicator(it.indicator.slug)} className="link-quiet flex min-h-[44px] min-w-0 items-center text-sm font-semibold text-ink md:min-h-0"> | |
| 43 | + <span className="truncate">{it.indicator.short_name ?? it.indicator.name}</span> | |
| 44 | + </Link> | |
| 45 | + </div> | |
| 46 | + <div className="min-w-0"> | |
| 47 | + <p className="text-sm leading-snug text-ink">{it.headline}</p> | |
| 48 | + <div className="mt-1 flex items-center gap-2 text-2xs text-ink-3" aria-label={t('home.pulse.shareLabel', { up: it.n_up, down: it.n_down })}> | |
| 49 | + <span className="tnum shrink-0 text-inc">↑ {it.n_up}</span> | |
| 50 | + <span className="flex h-1.5 min-w-0 flex-1 overflow-hidden rounded-xs bg-surface-2" aria-hidden> | |
| 51 | + <span className="h-full bg-inc" style={{ width: `${(it.n_up / total) * 100}%` }} /> | |
| 52 | + <span className="h-full bg-dec" style={{ width: `${(it.n_down / total) * 100}%` }} /> | |
| 53 | + </span> | |
| 54 | + <span className="tnum shrink-0 text-dec">↓ {it.n_down}</span> | |
| 55 | + {it.record_highs ? <span className="tnum hidden shrink-0 sm:inline">· {t('home.pulse.records', { n: it.record_highs })}</span> : null} | |
| 56 | + </div> | |
| 57 | + </div> | |
| 58 | + <div className="min-w-0 text-xs text-ink-2"> | |
| 59 | + {mover ? ( | |
| 60 | + <Link href={mover.country.slug ? routes.country(mover.country.slug) : routes.indicator(it.indicator.slug)} className="link-quiet flex min-h-[32px] items-center gap-1.5"> | |
| 61 | + <span className="shrink-0 text-ink-3">{up ? t('home.pulse.largestRise') : t('home.pulse.largestFall')}</span> | |
| 62 | + <span aria-hidden>{mover.country.flag}</span> | |
| 63 | + <span className="truncate font-medium text-ink">{mover.country.name}</span> | |
| 64 | + <span className="tnum shrink-0 text-ink-3">{formatValue(mover.value, spec)}</span> | |
| 65 | + </Link> | |
| 66 | + ) : null} | |
| 67 | + </div> | |
| 68 | + </li> | |
| 69 | + ); | |
| 70 | +} | |
added
apps/web/src/components/indicators/distribution-panel.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { t } from '@/i18n'; | |
| 4 | +import { formatValue, grouped, ordinal } from '@/lib/format'; | |
| 5 | +import { routes } from '@/lib/site'; | |
| 6 | +import type { FormatSpec } from '@/lib/types'; | |
| 7 | +import type { DistributionResponse } from '@/lib/types-analytics'; | |
| 8 | +import { Histogram, type HistogramMarker } from '@/components/charts/histogram'; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Distribution of an indicator across countries: histogram with markers (world median, the highlighted | |
| 12 | + * country, its region median) and a table of medians by region and income group. | |
| 13 | + */ | |
| 14 | +export function DistributionPanel({ data, spec }: { data: DistributionResponse; spec: FormatSpec }) { | |
| 15 | + const markers: HistogramMarker[] = []; | |
| 16 | + if (data.stats.median != null) markers.push({ id: 'world', label: t('indicator.dist.worldMedian'), value: data.stats.median, tone: 'ink' }); | |
| 17 | + const h = data.highlight; | |
| 18 | + if (h && h.value != null) markers.push({ id: 'hl', label: h.country.name ?? h.country.id, value: h.value, tone: 'accent' }); | |
| 19 | + if (h && h.region_median != null && h.region) markers.push({ id: 'region', label: t('indicator.dist.regionMedian', { region: h.region.name ?? '' }), value: h.region_median, tone: 'muted' }); | |
| 20 | + return ( | |
| 21 | + <div className="grid gap-x-10 gap-y-6 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)]"> | |
| 22 | + <div className="min-w-0"> | |
| 23 | + <Histogram edges={data.histogram.edges} counts={data.histogram.counts} log={data.histogram.log} markers={markers} spec={spec} height={240} defaultWidth={720} unitLabel={spec.unit ?? undefined} title={t('indicator.dist.title', { year: data.year_used ?? '' })} subtitle={t('indicator.dist.sub', { n: grouped(data.n), p10: formatValue(data.stats.p10, spec), p90: formatValue(data.stats.p90, spec) })} /> | |
| 24 | + {h && h.value != null ? ( | |
| 25 | + <p className="mt-2 text-sm text-ink-2"> | |
| 26 | + <Link href={routes.country(h.country.slug ?? h.country.id)} className="link-quiet font-medium text-ink"> | |
| 27 | + <span aria-hidden>{h.country.flag} </span> | |
| 28 | + {h.country.name} | |
| 29 | + </Link>{' '} | |
| 30 | + {t('indicator.dist.highlight', { value: formatValue(h.value, spec), pct: h.percentile != null ? ordinal(Math.round(h.percentile)) : t('common.na'), rank: h.rank ?? '', n: h.n ?? '' })} | |
| 31 | + </p> | |
| 32 | + ) : null} | |
| 33 | + </div> | |
| 34 | + <div className="min-w-0"> | |
| 35 | + <table className="w-full border-collapse text-sm"> | |
| 36 | + <caption className="sr-only">{t('indicator.dist.medians')}</caption> | |
| 37 | + <thead> | |
| 38 | + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3"> | |
| 39 | + <th scope="col" className="py-1.5 pr-3 font-medium"> | |
| 40 | + {t('indicator.dist.group')} | |
| 41 | + </th> | |
| 42 | + <th scope="col" className="py-1.5 pr-3 text-right font-medium"> | |
| 43 | + {t('indicator.trend.median')} | |
| 44 | + </th> | |
| 45 | + <th scope="col" className="py-1.5 text-right font-medium"> | |
| 46 | + {t('indicator.dist.n')} | |
| 47 | + </th> | |
| 48 | + </tr> | |
| 49 | + </thead> | |
| 50 | + <tbody className="divide-y divide-rule"> | |
| 51 | + <tr className="font-medium"> | |
| 52 | + <td className="py-1.5 pr-3 text-ink">{t('common.world')}</td> | |
| 53 | + <td className="tnum py-1.5 pr-3 text-right text-ink">{formatValue(data.stats.median, spec)}</td> | |
| 54 | + <td className="tnum py-1.5 text-right text-ink-2">{grouped(data.n)}</td> | |
| 55 | + </tr> | |
| 56 | + {data.by_region.map((r) => ( | |
| 57 | + <tr key={r.group.id}> | |
| 58 | + <td className="py-1.5 pr-3 text-ink-2"> | |
| 59 | + <Link href={routes.region(r.group.slug ?? r.group.id)} className="link-quiet"> | |
| 60 | + {r.group.name} | |
| 61 | + </Link> | |
| 62 | + </td> | |
| 63 | + <td className="tnum py-1.5 pr-3 text-right text-ink">{formatValue(r.median, spec)}</td> | |
| 64 | + <td className="tnum py-1.5 text-right text-ink-3">{grouped(r.n)}</td> | |
| 65 | + </tr> | |
| 66 | + ))} | |
| 67 | + {data.by_income.map((r) => ( | |
| 68 | + <tr key={r.group.id}> | |
| 69 | + <td className="py-1.5 pr-3 text-ink-2"> | |
| 70 | + <Link href={routes.region(r.group.slug ?? r.group.id)} className="link-quiet"> | |
| 71 | + {r.group.name} | |
| 72 | + </Link> | |
| 73 | + </td> | |
| 74 | + <td className="tnum py-1.5 pr-3 text-right text-ink">{formatValue(r.median, spec)}</td> | |
| 75 | + <td className="tnum py-1.5 text-right text-ink-3">{grouped(r.n)}</td> | |
| 76 | + </tr> | |
| 77 | + ))} | |
| 78 | + </tbody> | |
| 79 | + </table> | |
| 80 | + <dl className="tnum mt-3 grid grid-cols-4 gap-x-3 text-xs"> | |
| 81 | + {( | |
| 82 | + [ | |
| 83 | + ['p10', data.stats.p10], | |
| 84 | + ['p25', data.stats.p25], | |
| 85 | + ['p75', data.stats.p75], | |
| 86 | + ['p90', data.stats.p90], | |
| 87 | + ] as Array<[string, number | null]> | |
| 88 | + ).map(([k, v]) => ( | |
| 89 | + <div key={k}> | |
| 90 | + <dt className="text-2xs uppercase tracking-wide text-ink-3">{k}</dt> | |
| 91 | + <dd className="text-ink">{formatValue(v, spec)}</dd> | |
| 92 | + </div> | |
| 93 | + ))} | |
| 94 | + </dl> | |
| 95 | + </div> | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | +} | |
modified
apps/web/src/components/indicators/indicator-map.tsx
+40 −102
@@ -1,13 +1,17 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 2 | +import { Maximize2 } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useMemo, useState } from 'react'; | |
| 3 | 5 | import { t } from '@/i18n'; |
| 4 | −import { clientExplore } from '@/lib/client-api-explore'; | |
| 5 | 6 | import { formatValue, grouped } from '@/lib/format'; |
| 6 | −import type { FormatSpec, MapResponse } from '@/lib/types'; | |
| 7 | −import { ChoroplethView, type ChoroplethFeature } from '@/components/charts/choropleth-view'; | |
| 8 | −import { EmptyState } from '@/components/data/empty-state'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import type { FormatSpec } from '@/lib/types'; | |
| 9 | +import type { FramesResponse } from '@/lib/types-analytics'; | |
| 10 | +import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view'; | |
| 9 | 11 | import { SourceLine } from '@/components/charts/source-line'; |
| 12 | +import { YearSlider } from '@/components/controls/year-slider'; | |
| 10 | 13 | import type { ProvenancePayload } from '@/components/data/provenance-context'; |
| 14 | +import { EmptyState } from '@/components/data/empty-state'; | |
| 11 | 15 | |
| 12 | 16 | /** Geometry the server computes once (no values): one entry per drawn country. */ |
| 13 | 17 | export interface BaseFeature { |
@@ -18,116 +22,50 @@ export interface BaseFeature { | ||
| 18 | 22 | d: string; |
| 19 | 23 | } |
| 20 | 24 | |
| 21 | −function classIndex(value: number, breaks: number[]): number { | |
| 22 | − let i = 0; | |
| 23 | − while (i < breaks.length && value >= breaks[i]!) i++; | |
| 24 | − return i; | |
| 25 | −} | |
| 26 | − | |
| 27 | 25 | /** |
| 28 | − * Indicator world map with a year slider + select over `years` (years with data). The server renders the | |
| 29 | − * latest common year; changing the year fetches `/indicators/{slug}/map?year=` client-side and re-classes | |
| 30 | − * the same geometry. Legend from the API quantile breaks, hatched no-data, click/tap → country. | |
| 26 | + * Indicator world map with a time machine: every year comes from one `/indicators/{slug}/frames` payload | |
| 27 | + * (pooled quantile legend, so colours stay comparable while scrubbing), so the slider and play button need no | |
| 28 | + * further requests. Click/tap → country page. Hatched = no data for the selected year. | |
| 31 | 29 | */ |
| 32 | −export function IndicatorMap({ slug, geometry, sphere, initial, years, spec, payload }: { slug: string; geometry: BaseFeature[]; sphere: string; initial: MapResponse; years: number[]; spec: FormatSpec; payload: ProvenancePayload | null }) { | |
| 33 | − const [year, setYear] = useState<number>(initial.year_used ?? years[years.length - 1] ?? new Date().getUTCFullYear()); | |
| 34 | − const [cache, setCache] = useState<Record<number, MapResponse>>(() => (initial.year_used != null ? { [initial.year_used]: initial } : {})); | |
| 35 | − const [loading, setLoading] = useState(false); | |
| 36 | − const [error, setError] = useState(false); | |
| 37 | − const abortRef = useRef<AbortController | null>(null); | |
| 30 | +export function IndicatorMap({ slug, geometry, sphere, frames, spec, payload, initialYear }: { slug: string; geometry: BaseFeature[]; sphere: string; frames: FramesResponse | null; spec: FormatSpec; payload: ProvenancePayload | null; initialYear?: number | null }) { | |
| 31 | + const years = frames?.years ?? []; | |
| 32 | + const [year, setYear] = useState<number>(initialYear && years.includes(initialYear) ? initialYear : years[years.length - 1] ?? new Date().getUTCFullYear()); | |
| 33 | + const idx = years.indexOf(year); | |
| 38 | 34 | |
| 39 | − useEffect(() => { | |
| 40 | − if (cache[year]) return; | |
| 41 | − abortRef.current?.abort(); | |
| 42 | − const ctrl = new AbortController(); | |
| 43 | − abortRef.current = ctrl; | |
| 44 | − setLoading(true); | |
| 45 | − setError(false); | |
| 46 | − const timer = setTimeout(() => { | |
| 47 | − clientExplore | |
| 48 | − .indicatorMap(slug, year, ctrl.signal) | |
| 49 | − .then((m) => setCache((c) => ({ ...c, [year]: m }))) | |
| 50 | − .catch((e) => { | |
| 51 | − if ((e as Error).name !== 'AbortError') setError(true); | |
| 52 | − }) | |
| 53 | − .finally(() => { | |
| 54 | − if (!ctrl.signal.aborted) setLoading(false); | |
| 55 | − }); | |
| 56 | − }, 120); | |
| 57 | − return () => clearTimeout(timer); | |
| 58 | − }, [year, slug, cache]); | |
| 59 | − | |
| 60 | − const map = cache[year] ?? null; | |
| 61 | − const yearIdx = Math.max(0, years.indexOf(year)); | |
| 62 | − | |
| 63 | − const { features, legend, k } = useMemo(() => { | |
| 64 | − const breaks = (map?.legend?.breaks ?? []).slice(0, 6); | |
| 65 | − const kk = breaks.length + 1; | |
| 35 | + const { features, legend, k, n } = useMemo(() => { | |
| 36 | + if (!frames || idx < 0) return { features: [] as ChoroplethFeature[], legend: [], k: 1, n: 0 }; | |
| 37 | + const breaks = frames.legend.breaks.slice(0, 6); | |
| 38 | + let count = 0; | |
| 66 | 39 | const feats: ChoroplethFeature[] = geometry.map((g) => { |
| 67 | − const v = g.iso3 && map ? map.values[g.iso3] : undefined; | |
| 68 | − return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classIndex(v, breaks) : null }; | |
| 40 | + const v = g.iso3 ? frames.values[g.iso3]?.[idx] : undefined; | |
| 41 | + if (typeof v === 'number') count++; | |
| 42 | + return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classFor(v, breaks) : null }; | |
| 69 | 43 | }); |
| 70 | − const leg = map | |
| 71 | − ? Array.from({ length: kk }, (_, i) => { | |
| 72 | − const lo = i === 0 ? map.legend.min : breaks[i - 1]!; | |
| 73 | − const hi = i === kk - 1 ? map.legend.max : breaks[i]!; | |
| 74 | − return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` }; | |
| 75 | − }) | |
| 76 | − : []; | |
| 77 | − return { features: feats, legend: leg, k: kk }; | |
| 78 | − }, [map, geometry, spec]); | |
| 44 | + return { features: feats, legend: legendFromBreaks(breaks, frames.legend.min, frames.legend.max, spec), k: breaks.length + 1, n: count }; | |
| 45 | + }, [frames, idx, geometry, spec]); | |
| 79 | 46 | |
| 80 | − const summary = map ? t('chart.summary.map', { name: spec.name ?? slug, year, n: map.n, min: formatValue(map.legend.min, spec), max: formatValue(map.legend.max, spec) }) : t('chart.noData'); | |
| 47 | + if (!frames || !years.length) return <EmptyState compact title={t('indicator.map.none', { year })} hint={t('indicator.nodata.why')} />; | |
| 48 | + const summary = t('chart.summary.map', { name: spec.name ?? slug, year, n, min: formatValue(frames.legend.min, spec), max: formatValue(frames.legend.max, spec) }); | |
| 81 | 49 | const title = t('chart.map.legend', { name: spec.name ?? slug, year }); |
| 82 | − const minY = years[0] ?? year; | |
| 83 | − const maxY = years[years.length - 1] ?? year; | |
| 84 | 50 | |
| 85 | 51 | return ( |
| 86 | 52 | <div className="min-w-0"> |
| 87 | − <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2"> | |
| 88 | − <label className="flex min-w-0 flex-1 basis-64 items-center gap-3"> | |
| 89 | − <span className="shrink-0 text-xs font-medium text-ink-2">{t('indicator.map.year')}</span> | |
| 90 | − <input | |
| 91 | − type="range" | |
| 92 | − min={0} | |
| 93 | − max={Math.max(0, years.length - 1)} | |
| 94 | − step={1} | |
| 95 | − value={yearIdx} | |
| 96 | − onChange={(e) => setYear(years[Number(e.target.value)] ?? year)} | |
| 97 | − aria-label={t('indicator.map.year')} | |
| 98 | − aria-valuetext={String(year)} | |
| 99 | − className="h-11 min-w-0 flex-1 accent-[var(--accent)] md:h-9" | |
| 100 | − style={{ touchAction: 'pan-y' }} | |
| 101 | − /> | |
| 102 | − <span className="tnum w-12 shrink-0 text-right text-sm font-semibold text-ink" aria-hidden> | |
| 103 | − {year} | |
| 53 | + {n === 0 ? <EmptyState compact title={t('indicator.map.none', { year })} /> : <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={title} compact />} | |
| 54 | + <div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-6"> | |
| 55 | + <YearSlider years={years} year={year} onChange={setYear} className="min-w-0 flex-1" interval={600} /> | |
| 56 | + <div className="flex shrink-0 flex-wrap items-center gap-x-3 text-xs text-ink-3"> | |
| 57 | + <span className="tnum"> | |
| 58 | + {years[0]}–{years[years.length - 1]} · {t('indicator.map.n', { n: grouped(n) })} | |
| 104 | 59 | </span> |
| 105 | − </label> | |
| 106 | − <select value={year} onChange={(e) => setYear(Number(e.target.value))} aria-label={t('indicator.map.year')} className="h-11 rounded-sm border border-rule bg-surface px-2 text-sm text-ink outline-none focus:border-accent md:h-9"> | |
| 107 | − {[...years].reverse().map((y) => ( | |
| 108 | − <option key={y} value={y}> | |
| 109 | − {y} | |
| 110 | − </option> | |
| 111 | − ))} | |
| 112 | − </select> | |
| 113 | − <span className="tnum text-xs text-ink-3"> | |
| 114 | − {minY}–{maxY} | |
| 115 | − {map ? ` · ${t('indicator.map.n', { n: grouped(map.n) })}` : ''} | |
| 116 | − </span> | |
| 117 | − </div> | |
| 118 | − | |
| 119 | − <div className={loading ? 'opacity-60 transition-opacity' : 'transition-opacity'} aria-busy={loading}> | |
| 120 | − {error ? ( | |
| 121 | − <EmptyState compact title={t('common.errorHint')} /> | |
| 122 | − ) : map && map.n === 0 ? ( | |
| 123 | − <EmptyState compact title={t('indicator.map.none', { year })} /> | |
| 124 | − ) : ( | |
| 125 | − <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={title} /> | |
| 126 | − )} | |
| 60 | + <Link href={routes.explore({ indicator: slug, year })} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-accent hover:underline md:min-h-[32px]"> | |
| 61 | + <Maximize2 size={14} aria-hidden /> | |
| 62 | + {t('home.map.openExplorer')} | |
| 63 | + </Link> | |
| 64 | + </div> | |
| 127 | 65 | </div> |
| 128 | − {map?.nearest && map.years ? <p className="mt-1 text-2xs text-ink-3">{t('indicator.map.nearest', { n: Object.values(map.years).filter((y) => y !== map.year_used).length })}</p> : null} | |
| 66 | + <p className="mt-1 text-2xs text-ink-3">{t('indicator.map.legendNote')}</p> | |
| 129 | 67 | <div className="mt-1"> |
| 130 | − <SourceLine provenance={map?.provenance ?? initial.provenance} payload={payload} /> | |
| 68 | + <SourceLine provenance={frames.provenance} payload={payload} /> | |
| 131 | 69 | </div> |
| 132 | 70 | </div> |
| 133 | 71 | ); |
added
apps/web/src/components/indicators/related-table.tsx
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +import Link from 'next/link'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { cn } from '@/lib/cn'; | |
| 4 | +import { fixed, grouped } from '@/lib/format'; | |
| 5 | +import { routes } from '@/lib/site'; | |
| 6 | +import { topicById } from '@/lib/topics'; | |
| 7 | +import type { RelatedResponse } from '@/lib/types-analytics'; | |
| 8 | + | |
| 9 | +/** Statistically related indicators: ρ, r, N, year, direction; each row opens the scatter of the pair. */ | |
| 10 | +export function RelatedTable({ data, slug }: { data: RelatedResponse; slug: string }) { | |
| 11 | + if (!data.items.length) return <p className="text-sm text-ink-3">{t('indicator.related.noneStat')}</p>; | |
| 12 | + return ( | |
| 13 | + <div className="min-w-0"> | |
| 14 | + <ul className="divide-y divide-rule border-y border-rule"> | |
| 15 | + {data.items.map((it) => { | |
| 16 | + const rho = it.spearman ?? it.pearson ?? 0; | |
| 17 | + const w = Math.min(100, Math.abs(rho) * 100); | |
| 18 | + return ( | |
| 19 | + <li key={it.indicator.slug} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 py-2 md:grid-cols-[minmax(0,16rem)_minmax(6rem,1fr)_4rem_4rem_4rem_3.5rem_5rem]"> | |
| 20 | + <Link href={routes.scatter({ x: slug, y: it.indicator.slug, year: it.year })} className="link-quiet min-w-0"> | |
| 21 | + <span className="block truncate text-sm font-medium text-ink">{it.indicator.short_name ?? it.indicator.name}</span> | |
| 22 | + <span className="block truncate text-2xs text-ink-3">{topicById(it.indicator.topic ?? '')?.short ?? it.indicator.topic}</span> | |
| 23 | + </Link> | |
| 24 | + <div className="col-span-full flex items-center gap-2 md:col-span-1" aria-hidden> | |
| 25 | + <span className="tnum w-8 text-right text-2xs text-ink-3">−1</span> | |
| 26 | + <span className="relative h-1.5 min-w-0 flex-1 rounded-xs bg-surface-2"> | |
| 27 | + <span className="absolute inset-y-0 left-1/2 w-px bg-rule-strong" /> | |
| 28 | + <span className={cn('absolute inset-y-0 rounded-xs', rho >= 0 ? 'bg-inc' : 'bg-dec')} style={rho >= 0 ? { left: '50%', width: `${w / 2}%` } : { right: '50%', width: `${w / 2}%` }} /> | |
| 29 | + </span> | |
| 30 | + <span className="tnum w-8 text-2xs text-ink-3">+1</span> | |
| 31 | + </div> | |
| 32 | + <span className="tnum text-right text-sm font-medium text-ink" title={t('scatter.stats.spearman')}> | |
| 33 | + {it.spearman != null ? fixed(it.spearman, 2) : t('common.na')} | |
| 34 | + </span> | |
| 35 | + <span className="tnum hidden text-right text-xs text-ink-2 md:block" title={t('scatter.stats.pearson')}> | |
| 36 | + r {it.pearson != null ? fixed(it.pearson, 2) : t('common.na')} | |
| 37 | + </span> | |
| 38 | + <span className="tnum hidden text-right text-xs text-ink-3 md:block">n {grouped(it.n)}</span> | |
| 39 | + <span className="tnum hidden text-right text-xs text-ink-3 md:block">{it.year ?? ''}</span> | |
| 40 | + <span className={cn('hidden text-right text-2xs uppercase tracking-wide md:block', it.direction === 'positive' ? 'text-inc' : 'text-dec')}>{t(`indicator.related.${it.direction}` as 'indicator.related.positive')}</span> | |
| 41 | + </li> | |
| 42 | + ); | |
| 43 | + })} | |
| 44 | + </ul> | |
| 45 | + <p className="mt-2 text-xs text-ink-3"> | |
| 46 | + {t('indicator.related.method', { year: data.year_used ?? '', n: grouped(data.n_candidates) })} <strong className="font-medium text-ink-2">{data.note}</strong> | |
| 47 | + </p> | |
| 48 | + </div> | |
| 49 | + ); | |
| 50 | +} | |
modified
apps/web/src/components/layout/search-dialog.tsx
+21 −6
@@ -49,7 +49,7 @@ function saveRecent(h: SearchHit) { | ||
| 49 | 49 | } |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | −const TYPE_ORDER: string[] = ['country', 'country_topic', 'country_indicator', 'indicator', 'topic', 'region', 'source']; | |
| 52 | +const TYPE_ORDER: string[] = ['action', 'country', 'country_topic', 'country_indicator', 'indicator', 'topic', 'region', 'source']; | |
| 53 | 53 | |
| 54 | 54 | /** |
| 55 | 55 | * Global search (⌘K / "/" / tab bar). Debounced `/api/v1/search?q=`, grouped hits with type chips, keyboard |
@@ -106,7 +106,7 @@ export function SearchDialog() { | ||
| 106 | 106 | |
| 107 | 107 | const list = useMemo(() => { |
| 108 | 108 | const src = q.trim() ? hits : recent; |
| 109 | − return [...src].sort((a, b) => (q.trim() ? b.score - a.score : 0) || TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type)); | |
| 109 | + return [...src].sort((a, b) => Number(b.type === 'action') - Number(a.type === 'action') || (q.trim() ? b.score - a.score : 0) || TYPE_ORDER.indexOf(a.type) - TYPE_ORDER.indexOf(b.type)); | |
| 110 | 110 | }, [hits, recent, q]); |
| 111 | 111 | |
| 112 | 112 | const go = useCallback( |
@@ -183,7 +183,22 @@ export function SearchDialog() { | ||
| 183 | 183 | </button> |
| 184 | 184 | </div> |
| 185 | 185 | ) : null} |
| 186 | − {!q.trim() && recent.length === 0 ? <p className="py-6 text-center text-ink-3">{t('search.start')}</p> : null} | |
| 186 | + {!q.trim() ? ( | |
| 187 | + <div className={cn('text-ink-3', recent.length === 0 ? 'py-6 text-center' : 'pb-3')}> | |
| 188 | + {recent.length === 0 ? <p>{t('search.start')}</p> : null} | |
| 189 | + <p className={cn('flex flex-wrap items-center gap-1.5 text-xs', recent.length === 0 ? 'mt-3 justify-center' : '')}> | |
| 190 | + <span>{t('search.examples')}</span> | |
| 191 | + {(['compare', 'rank', 'indicatorCountry', 'indicatorGroup'] as const).map((k) => { | |
| 192 | + const ex = t(`search.example.${k}` as 'search.example.compare'); | |
| 193 | + return ( | |
| 194 | + <button key={k} type="button" onClick={() => setQ(ex)} className="inline-flex min-h-[32px] items-center rounded-sm border border-rule px-2 font-mono text-2xs text-ink-2 hover:border-accent hover:text-accent"> | |
| 195 | + {ex} | |
| 196 | + </button> | |
| 197 | + ); | |
| 198 | + })} | |
| 199 | + </p> | |
| 200 | + </div> | |
| 201 | + ) : null} | |
| 187 | 202 | {state === 'error' ? <p className="py-6 text-center text-down">{t('search.error')}</p> : null} |
| 188 | 203 | {q.trim() && state !== 'error' && state !== 'loading' && list.length === 0 ? <p className="py-6 text-center text-ink-3">{t('search.empty', { q: q.trim() })}</p> : null} |
| 189 | 204 | <ul id="search-results" role="listbox" aria-label={t('nav.search')} className="divide-y divide-rule"> |
@@ -196,7 +211,7 @@ export function SearchDialog() { | ||
| 196 | 211 | className={cn('flex min-h-[48px] w-full items-center gap-3 rounded-sm px-2 py-2 text-left', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60')} |
| 197 | 212 | > |
| 198 | 213 | <span aria-hidden className="w-6 text-center text-lg leading-none"> |
| 199 | − {h.country?.flag ?? ''} | |
| 214 | + {h.type === 'action' ? <span className="text-accent">→</span> : h.country?.flag ?? ''} | |
| 200 | 215 | </span> |
| 201 | 216 | <span className="min-w-0 flex-1"> |
| 202 | 217 | <span className="block truncate text-ink">{h.name}</span> |
@@ -217,8 +232,8 @@ export function SearchDialog() { | ||
| 217 | 232 | ); |
| 218 | 233 | } |
| 219 | 234 | |
| 220 | −const CHIP_TYPE: Record<string, SearchHitType> = { country: 'country', indicator: 'indicator', topic: 'topic', region: 'region', source: 'source', country_topic: 'topic', country_indicator: 'indicator' }; | |
| 235 | +const CHIP_TYPE: Record<string, SearchHitType> = { country: 'country', indicator: 'indicator', topic: 'topic', region: 'region', source: 'source', country_topic: 'topic', country_indicator: 'indicator', action: 'action' }; | |
| 221 | 236 | export function TypeChip({ type }: { type: SearchHitType | string }) { |
| 222 | 237 | const key = CHIP_TYPE[type] ?? 'country'; |
| 223 | − return <span className="shrink-0 rounded-xs border border-rule px-1.5 py-0.5 text-2xs uppercase tracking-wide text-ink-3">{t(`search.type.${key}` as 'search.type.country')}</span>; | |
| 238 | + return <span className={cn('shrink-0 rounded-xs border px-1.5 py-0.5 text-2xs uppercase tracking-wide', key === 'action' ? 'border-accent bg-accent-soft text-accent' : 'border-rule text-ink-3')}>{t(`search.type.${key}` as 'search.type.country')}</span>; | |
| 224 | 239 | } |
added
apps/web/src/components/peers/peers-view.tsx
+106 −0
@@ -0,0 +1,106 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useMemo, useState } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { fixed, formatValue, grouped } from '@/lib/format'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import { useUrlState } from '@/lib/url-state'; | |
| 9 | +import type { FormatSpec } from '@/lib/types'; | |
| 10 | +import type { PeerPoint, PeersResponse } from '@/lib/types-analytics'; | |
| 11 | +import { BubbleChart } from '@/components/charts/bubble-chart'; | |
| 12 | +import { Segmented } from '@/components/controls/indicator-select'; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * Above / below expected: pair selector (URL-synced), bubble chart with the robust fitted line, the ten largest | |
| 16 | + * positive and negative residuals. Wording stays descriptive: "above / below the fitted line". | |
| 17 | + */ | |
| 18 | +export function PeersView({ data }: { data: PeersResponse }) { | |
| 19 | + const { set } = useUrlState(); | |
| 20 | + const [selected, setSelected] = useState<string | null>(null); | |
| 21 | + const xs: FormatSpec = { format: data.x.format, unit: data.x.unit, unit_short: data.x.unit_short, precision: data.x.precision, name: data.x.short_name ?? data.x.name }; | |
| 22 | + const ys: FormatSpec = { format: data.y.format, unit: data.y.unit, unit_short: data.y.unit_short, precision: data.y.precision, name: data.y.short_name ?? data.y.name }; | |
| 23 | + const points = useMemo(() => data.points.filter((p) => p.x != null && p.y != null).map((p) => ({ id: p.id, label: p.name ?? p.id, flag: p.flag, x: p.x, y: p.y, region: p.region })), [data.points]); | |
| 24 | + const highlight = useMemo(() => [...data.above.slice(0, 5), ...data.below.slice(0, 5)].map((p) => p.id).concat(selected ? [selected] : []), [data.above, data.below, selected]); | |
| 25 | + const pairKey = `${data.x.slug}|${data.y.slug}`; | |
| 26 | + const pairs = data.pairs.map((p) => ({ value: `${p.x}|${p.y}`, label: p.label })); | |
| 27 | + const current = pairs.find((p) => p.value === pairKey) ? pairKey : pairs[0]?.value ?? pairKey; | |
| 28 | + | |
| 29 | + return ( | |
| 30 | + <div className="min-w-0"> | |
| 31 | + <div className="flex flex-wrap items-center gap-2"> | |
| 32 | + <label className="inline-flex h-11 max-w-full items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-10"> | |
| 33 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('peers.pair')}</span> | |
| 34 | + <select | |
| 35 | + value={current} | |
| 36 | + onChange={(e) => { | |
| 37 | + const [x, y] = e.target.value.split('|'); | |
| 38 | + set({ x, y }, 0); | |
| 39 | + }} | |
| 40 | + className="max-w-[18rem] truncate bg-transparent text-ink outline-none" | |
| 41 | + aria-label={t('peers.pair')} | |
| 42 | + > | |
| 43 | + {pairs.map((p) => ( | |
| 44 | + <option key={p.value} value={p.value}> | |
| 45 | + {p.label} | |
| 46 | + </option> | |
| 47 | + ))} | |
| 48 | + </select> | |
| 49 | + </label> | |
| 50 | + <Segmented value={data.method === 'ols' ? 'ols' : 'theil-sen'} onChange={(v) => set({ method: v }, 0)} label={t('peers.method')} size="sm" options={[{ value: 'theil-sen', label: t('peers.method.theilSen') }, { value: 'ols', label: t('peers.method.ols') }]} /> | |
| 51 | + <span className="tnum text-xs text-ink-3"> | |
| 52 | + {t('peers.stats', { n: grouped(data.n), year: data.year_used ?? '', r2: data.fit?.r2 != null ? fixed(data.fit.r2, 2) : t('common.na') })} | |
| 53 | + </span> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <div className="mt-4"> | |
| 57 | + <BubbleChart points={points} xSpec={xs} ySpec={ys} logX={data.fit?.log_x ?? false} fit={data.fit ? { slope: data.fit.slope, intercept: data.fit.intercept, logX: data.fit.log_x, logY: false, label: t('peers.fitLine') } : null} highlight={highlight} labelCount={0} height={460} animate={false} onSelect={setSelected} defaultWidth={960} /> | |
| 58 | + </div> | |
| 59 | + <p className="mt-2 text-xs text-ink-3">{data.note}</p> | |
| 60 | + | |
| 61 | + <div className="mt-6 grid gap-x-10 gap-y-6 lg:grid-cols-2"> | |
| 62 | + <ResidualList title={t('peers.above')} rows={data.above} spec={ys} tone="inc" onPick={setSelected} selected={selected} /> | |
| 63 | + <ResidualList title={t('peers.below')} rows={data.below} spec={ys} tone="dec" onPick={setSelected} selected={selected} /> | |
| 64 | + </div> | |
| 65 | + | |
| 66 | + <div className="mt-8 max-w-prose border-t border-rule pt-4 text-sm leading-relaxed text-ink-2"> | |
| 67 | + <h3 className="mb-1 text-sm font-semibold text-ink">{t('peers.methodology')}</h3> | |
| 68 | + <p>{data.methodology}</p> | |
| 69 | + <p className="mt-2 font-medium text-ink">{t('peers.disclaimer')}</p> | |
| 70 | + </div> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} | |
| 74 | + | |
| 75 | +function ResidualList({ title, rows, spec, tone, onPick, selected }: { title: string; rows: PeerPoint[]; spec: FormatSpec; tone: 'inc' | 'dec'; onPick: (id: string) => void; selected: string | null }) { | |
| 76 | + const max = Math.max(1, ...rows.map((r) => Math.abs(r.residual_z ?? 0))); | |
| 77 | + return ( | |
| 78 | + <div className="min-w-0"> | |
| 79 | + <h3 className={cn('mb-2 text-sm font-semibold', tone === 'inc' ? 'text-inc' : 'text-dec')}>{title}</h3> | |
| 80 | + <ol className="divide-y divide-rule border-y border-rule"> | |
| 81 | + {rows.map((r, i) => ( | |
| 82 | + <li key={r.id} className={cn('grid grid-cols-[1.5rem_minmax(0,1fr)_5rem_4rem] items-center gap-x-2 py-1.5 text-sm', selected === r.id && 'bg-accent-soft/40')}> | |
| 83 | + <span className="tnum text-xs text-ink-3">{i + 1}</span> | |
| 84 | + <button type="button" onClick={() => onPick(r.id)} className="flex min-h-[36px] min-w-0 items-center gap-1.5 text-left"> | |
| 85 | + <span aria-hidden>{r.flag}</span> | |
| 86 | + <span className="min-w-0"> | |
| 87 | + <Link href={routes.country(r.slug ?? r.id)} className="link-quiet block truncate font-medium text-ink"> | |
| 88 | + {r.name} | |
| 89 | + </Link> | |
| 90 | + <span className="tnum block text-2xs text-ink-3"> | |
| 91 | + {t('peers.actualVsExpected', { actual: formatValue(r.y, spec), expected: formatValue(r.expected, spec) })} | |
| 92 | + </span> | |
| 93 | + </span> | |
| 94 | + </button> | |
| 95 | + <span className="relative h-2 overflow-hidden rounded-xs bg-surface-2" aria-hidden> | |
| 96 | + <span className={cn('absolute inset-y-0 left-0 rounded-xs', tone === 'inc' ? 'bg-inc' : 'bg-dec')} style={{ width: `${(Math.abs(r.residual_z ?? 0) / max) * 100}%` }} /> | |
| 97 | + </span> | |
| 98 | + <span className={cn('tnum whitespace-nowrap text-right text-sm font-medium', tone === 'inc' ? 'text-inc' : 'text-dec')}> | |
| 99 | + {r.residual != null ? `${r.residual > 0 ? '+' : '−'}${formatValue(Math.abs(r.residual), spec)}` : t('common.na')} | |
| 100 | + </span> | |
| 101 | + </li> | |
| 102 | + ))} | |
| 103 | + </ol> | |
| 104 | + </div> | |
| 105 | + ); | |
| 106 | +} | |
modified
apps/web/src/components/rankings/ranking-view.tsx
+123 −12
@@ -1,17 +1,21 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import { ArrowDownWideNarrow, ArrowUpNarrowWide, Search, SlidersHorizontal, X } from 'lucide-react'; | |
| 2 | +import { ArrowDownWideNarrow, ArrowUpNarrowWide, BarChart3, Map as MapIcon, Search, SlidersHorizontal, Table2, X } from 'lucide-react'; | |
| 3 | 3 | import Link from 'next/link'; |
| 4 | 4 | import { usePathname, useRouter } from 'next/navigation'; |
| 5 | 5 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; |
| 6 | 6 | import { t } from '@/i18n'; |
| 7 | 7 | import { cn } from '@/lib/cn'; |
| 8 | −import { displayValue, grouped, isNum, ordinal } from '@/lib/format'; | |
| 9 | −import { rankingQuery, type RankingState } from '@/lib/ranking-state'; | |
| 8 | +import { compact, displayValue, formatValue, grouped, isNum, ordinal } from '@/lib/format'; | |
| 9 | +import { INCOME_GROUPS } from '@/lib/regions'; | |
| 10 | +import { MIN_COV_OPTIONS, MIN_POP_OPTIONS, rankingQuery, type RankingState, type RankingView as ViewKind } from '@/lib/ranking-state'; | |
| 10 | 11 | import { routes } from '@/lib/site'; |
| 11 | 12 | import type { RankingResponse, RankingRow } from '@/lib/types'; |
| 12 | 13 | import type { CountryLite, RegionItem } from '@/lib/types-compare'; |
| 14 | +import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view'; | |
| 13 | 15 | import { MARK } from '@/components/charts/palette'; |
| 14 | −import { staleYear } from '@/components/charts/ranked-bars'; | |
| 16 | +import { RankedBars, rankedRowFromCountry, staleYear } from '@/components/charts/ranked-bars'; | |
| 17 | +import { Segmented } from '@/components/controls/indicator-select'; | |
| 18 | +import type { BaseFeature } from '@/components/indicators/indicator-map'; | |
| 15 | 19 | import { pointsFromSpark } from '@/components/charts/scales'; |
| 16 | 20 | import { Sparkline } from '@/components/charts/sparkline'; |
| 17 | 21 | import { CountryTypeahead } from '@/components/compare/country-picker'; |
@@ -27,7 +31,7 @@ const GROUP_KINDS = ['region', 'continent', 'income', 'org'] as const; | ||
| 27 | 31 | * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per |
| 28 | 32 | * page with "Show more". Year/group/sort/highlight/q live in the URL (server re-renders the data). |
| 29 | 33 | */ |
| 30 | −export function RankingView({ data, regions, countries, state }: { data: RankingResponse & { label?: string }; regions: RegionItem[]; countries: CountryLite[]; state: RankingState }) { | |
| 34 | +export function RankingView({ data, regions, countries, state, geometry, sphere }: { data: RankingResponse & { label?: string }; regions: RegionItem[]; countries: CountryLite[]; state: RankingState; geometry?: BaseFeature[]; sphere?: string }) { | |
| 31 | 35 | const router = useRouter(); |
| 32 | 36 | const pathname = usePathname(); |
| 33 | 37 | const { open } = useProvenance(); |
@@ -52,7 +56,17 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 52 | 56 | return () => clearTimeout(id); |
| 53 | 57 | }, [q, state.q, setState]); |
| 54 | 58 | |
| 55 | − const rows = data.rows; | |
| 59 | + // Client-side filters (income group · minimum population · minimum coverage) then re-rank within the result. | |
| 60 | + const popById = useMemo(() => new Map(countries.map((c) => [c.id, c.population ?? null])), [countries]); | |
| 61 | + const incomeCode = state.income ? INCOME_GROUPS.find((g) => g.slug === state.income || g.id.toLowerCase() === state.income)?.id ?? null : null; | |
| 62 | + const rows = useMemo(() => { | |
| 63 | + let r = data.rows; | |
| 64 | + if (incomeCode) r = r.filter((x) => (x.country.income ?? '').toUpperCase() === incomeCode); | |
| 65 | + if (state.minpop) r = r.filter((x) => (popById.get(x.country.id) ?? 0) >= state.minpop!); | |
| 66 | + if (state.mincov && data.year_used != null) r = r.filter((x) => (x.year ?? 0) >= data.year_used! - state.mincov!); | |
| 67 | + if (r.length !== data.rows.length) r = r.map((x, i) => ({ ...x, rank: i + 1 })); | |
| 68 | + return r; | |
| 69 | + }, [data.rows, data.year_used, incomeCode, state.minpop, state.mincov, popById]); | |
| 56 | 70 | // Freshness honesty: rows ≥ 2 years older than the ranking year show their year next to the value. |
| 57 | 71 | const refYear = useMemo(() => Math.max(data.year_used ?? 0, ...rows.map((r) => r.year ?? 0)), [rows, data.year_used]); |
| 58 | 72 | const max = useMemo(() => Math.max(0, ...rows.map((r) => (isNum(r.value) ? Math.abs(r.value) : 0))), [rows]); |
@@ -82,7 +96,7 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 82 | 96 | }); |
| 83 | 97 | |
| 84 | 98 | const groupsByKind = GROUP_KINDS.map((k) => ({ kind: k, items: regions.filter((g) => g.kind === k).sort((a, b) => a.name.localeCompare(b.name)) })).filter((g) => g.items.length); |
| 85 | − const activeFilters = (state.group !== 'world' ? 1 : 0) + (state.sort ? 1 : 0) + (state.highlight ? 1 : 0); | |
| 99 | + const activeFilters = (state.group !== 'world' ? 1 : 0) + (state.sort ? 1 : 0) + (state.highlight ? 1 : 0) + (state.income ? 1 : 0) + (state.minpop ? 1 : 0) + (state.mincov ? 1 : 0); | |
| 86 | 100 | const years = [...data.years_available].sort((a, b) => b - a); |
| 87 | 101 | |
| 88 | 102 | const yearSelect = ( |
@@ -120,6 +134,46 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 120 | 134 | {sort === 'desc' ? t('ranking.sort.desc') : t('ranking.sort.asc')} |
| 121 | 135 | </button> |
| 122 | 136 | ); |
| 137 | + const incomeSelect = ( | |
| 138 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 139 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('common.income')}</span> | |
| 140 | + <select value={state.income ?? ''} onChange={(e) => setState({ income: e.target.value || null })} className="max-w-[10rem] truncate bg-transparent text-ink outline-none" aria-label={t('common.income')}> | |
| 141 | + <option value="">{t('common.all')}</option> | |
| 142 | + {INCOME_GROUPS.map((g) => ( | |
| 143 | + <option key={g.id} value={g.slug}> | |
| 144 | + {g.name} | |
| 145 | + </option> | |
| 146 | + ))} | |
| 147 | + </select> | |
| 148 | + </label> | |
| 149 | + ); | |
| 150 | + const minPopSelect = ( | |
| 151 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 152 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.minpop')}</span> | |
| 153 | + <select value={state.minpop ?? 0} onChange={(e) => setState({ minpop: Number(e.target.value) || null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.minpop')}> | |
| 154 | + {MIN_POP_OPTIONS.map((v) => ( | |
| 155 | + <option key={v} value={v}> | |
| 156 | + {v ? `≥ ${compact(v)}` : t('common.all')} | |
| 157 | + </option> | |
| 158 | + ))} | |
| 159 | + </select> | |
| 160 | + </label> | |
| 161 | + ); | |
| 162 | + const minCovSelect = ( | |
| 163 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 164 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('ranking.mincov')}</span> | |
| 165 | + <select value={state.mincov ?? 0} onChange={(e) => setState({ mincov: Number(e.target.value) || null })} className="tnum bg-transparent text-ink outline-none" aria-label={t('ranking.mincov')}> | |
| 166 | + {MIN_COV_OPTIONS.map((v) => ( | |
| 167 | + <option key={v} value={v}> | |
| 168 | + {v ? t('ranking.mincov.within', { n: v }) : t('common.all')} | |
| 169 | + </option> | |
| 170 | + ))} | |
| 171 | + </select> | |
| 172 | + </label> | |
| 173 | + ); | |
| 174 | + const viewSwitch = ( | |
| 175 | + <Segmented<ViewKind> value={state.view} onChange={(v) => setState({ view: v })} label={t('control.view')} size="sm" options={[{ value: 'table', label: t('ranking.view.table'), icon: <Table2 size={13} aria-hidden /> }, { value: 'bars', label: t('ranking.view.bars'), icon: <BarChart3 size={13} aria-hidden /> }, { value: 'map', label: t('ranking.view.map'), icon: <MapIcon size={13} aria-hidden /> }]} /> | |
| 176 | + ); | |
| 123 | 177 | const highlightControl = highlightCountry ? ( |
| 124 | 178 | <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-accent bg-accent-soft pl-2.5 text-sm text-accent md:h-9"> |
| 125 | 179 | <span aria-hidden>{highlightCountry.flag}</span> |
@@ -169,10 +223,17 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 169 | 223 | <div className="hidden flex-wrap items-center gap-2 py-3 md:flex"> |
| 170 | 224 | {yearSelect} |
| 171 | 225 | {groupSelect} |
| 226 | + {incomeSelect} | |
| 227 | + {minPopSelect} | |
| 228 | + {minCovSelect} | |
| 172 | 229 | {sortToggle} |
| 173 | 230 | {highlightControl} |
| 174 | − <div className="ml-auto w-64">{searchBox}</div> | |
| 231 | + <div className="ml-auto flex items-center gap-2"> | |
| 232 | + {viewSwitch} | |
| 233 | + <div className="w-56">{searchBox}</div> | |
| 234 | + </div> | |
| 175 | 235 | </div> |
| 236 | + <div className="flex items-center justify-between gap-2 py-2 md:hidden">{viewSwitch}</div> | |
| 176 | 237 | |
| 177 | 238 | <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2 text-xs text-ink-3"> |
| 178 | 239 | <span>{rankNote}</span> |
@@ -194,8 +255,21 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 194 | 255 | <p className="mb-2 border-y border-dashed border-rule-strong py-2 text-xs text-ink-3">{t('ranking.highlight.notInGroup', { name: highlightCountry.name, year: data.year_used ?? '' })}</p> |
| 195 | 256 | ) : null} |
| 196 | 257 | |
| 258 | + {state.view === 'bars' ? ( | |
| 259 | + <div className="py-2"> | |
| 260 | + <RankedBars rows={filtered.slice(0, 25).map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.change_10y?.formatted ?? null, staleYear(r.year, refYear)))} spec={ind} highlightId={highlightRow?.country.id ?? null} provenance={filtered[0]?.provenance ?? null} /> | |
| 261 | + {filtered.length > 25 ? <p className="mt-2 text-xs text-ink-3">{t('ranking.view.barsNote', { n: 25, total: filtered.length })}</p> : null} | |
| 262 | + </div> | |
| 263 | + ) : null} | |
| 264 | + {state.view === 'map' ? ( | |
| 265 | + geometry && sphere ? ( | |
| 266 | + <RankingMap rows={filtered} geometry={geometry} sphere={sphere} spec={ind} year={data.year_used} highlight={highlightRow?.country.id ?? null} /> | |
| 267 | + ) : ( | |
| 268 | + <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p> | |
| 269 | + ) | |
| 270 | + ) : null} | |
| 197 | 271 | {/* Header (sm+) */} |
| 198 | − <div className="hidden grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem] gap-x-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3 sm:grid"> | |
| 272 | + <div className={cn('grid-cols-[2.25rem_minmax(0,1fr)_minmax(6rem,1.4fr)_6rem_5rem_5rem_4.5rem] gap-x-3 border-b border-rule pb-1.5 text-2xs font-medium uppercase tracking-wide text-ink-3', state.view === 'table' ? 'hidden sm:grid' : 'hidden')}> | |
| 199 | 273 | <span className="text-right">{t('ranking.col.rank')}</span> |
| 200 | 274 | <span>{t('ranking.col.country')}</span> |
| 201 | 275 | <span /> |
@@ -206,12 +280,12 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 206 | 280 | </div> |
| 207 | 281 | |
| 208 | 282 | {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{ql ? t('ranking.noMatch', { q }) : t('ranking.empty')}</p> : null} |
| 209 | − <ol ref={listRef} className="divide-y divide-rule" role="list"> | |
| 283 | + <ol ref={listRef} className={cn('divide-y divide-rule', state.view !== 'table' && 'hidden')} role="list"> | |
| 210 | 284 | {visible.map((r) => ( |
| 211 | 285 | <Row key={r.country.id} r={r} max={max} spec={ind} refYear={refYear} highlight={highlightRow?.country.id === r.country.id} onValue={() => open(payloadOf(r))} /> |
| 212 | 286 | ))} |
| 213 | 287 | </ol> |
| 214 | − {filtered.length > shown ? ( | |
| 288 | + {state.view === 'table' && filtered.length > shown ? ( | |
| 215 | 289 | <div className="flex justify-center py-4"> |
| 216 | 290 | <button type="button" onClick={() => setShown((s) => s + PAGE)} className="inline-flex h-11 items-center rounded-sm border border-rule px-5 text-sm text-ink hover:bg-surface-2 md:h-10"> |
| 217 | 291 | {t('ranking.showMore', { n: Math.min(PAGE, filtered.length - shown) })} |
@@ -225,6 +299,14 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 225 | 299 | <div className="eyebrow mb-1.5">{t('ranking.group')}</div> |
| 226 | 300 | {groupSelect} |
| 227 | 301 | </div> |
| 302 | + <div> | |
| 303 | + <div className="eyebrow mb-1.5">{t('common.income')}</div> | |
| 304 | + {incomeSelect} | |
| 305 | + </div> | |
| 306 | + <div className="flex flex-wrap gap-2"> | |
| 307 | + {minPopSelect} | |
| 308 | + {minCovSelect} | |
| 309 | + </div> | |
| 228 | 310 | <div> |
| 229 | 311 | <div className="eyebrow mb-1.5">{t('ranking.sort')}</div> |
| 230 | 312 | {sortToggle} |
@@ -235,7 +317,7 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking | ||
| 235 | 317 | </div> |
| 236 | 318 | </div> |
| 237 | 319 | <div className="mt-6 flex justify-between"> |
| 238 | − <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => setState({ group: 'world', sort: null, highlight: null })}> | |
| 320 | + <button type="button" className="tap rounded-sm px-3 text-sm text-ink-2 hover:bg-surface-2" onClick={() => setState({ group: 'world', income: null, minpop: null, mincov: null, sort: null, highlight: null })}> | |
| 239 | 321 | {t('common.reset')} |
| 240 | 322 | </button> |
| 241 | 323 | <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}> |
@@ -298,3 +380,32 @@ function Row({ r, max, spec, refYear, highlight, onValue }: { r: RankingRow; max | ||
| 298 | 380 | </li> |
| 299 | 381 | ); |
| 300 | 382 | } |
| 383 | + | |
| 384 | + | |
| 385 | +/** Map view of the (filtered) ranking rows: quantile classes computed client-side, highlighted country outlined. */ | |
| 386 | +function RankingMap({ rows, geometry, sphere, spec, year, highlight }: { rows: RankingRow[]; geometry: BaseFeature[]; sphere: string; spec: RankingResponse['indicator']; year: number | null; highlight: string | null }) { | |
| 387 | + const model = useMemo(() => { | |
| 388 | + const values = new Map(rows.filter((r) => isNum(r.value)).map((r) => [r.country.id, r.value as number])); | |
| 389 | + const sorted = Array.from(values.values()).sort((a, b) => a - b); | |
| 390 | + const k = sorted.length >= 40 ? 6 : Math.max(3, Math.min(5, sorted.length)); | |
| 391 | + const breaks: number[] = []; | |
| 392 | + for (let i = 1; i < k; i++) { | |
| 393 | + const pos = (i / k) * (sorted.length - 1); | |
| 394 | + const lo = Math.floor(pos); | |
| 395 | + const hi = Math.min(lo + 1, sorted.length - 1); | |
| 396 | + const v = sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (pos - lo); | |
| 397 | + if (!breaks.length || v > breaks[breaks.length - 1]!) breaks.push(v); | |
| 398 | + } | |
| 399 | + const features: ChoroplethFeature[] = geometry.map((g) => { | |
| 400 | + const v = g.iso3 ? values.get(g.iso3) : undefined; | |
| 401 | + return { ...g, value: v ?? null, cls: v != null ? classFor(v, breaks) : null }; | |
| 402 | + }); | |
| 403 | + const fs = { format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, name: spec.short_name ?? spec.name }; | |
| 404 | + return { features, legend: legendFromBreaks(breaks, sorted[0] ?? null, sorted[sorted.length - 1] ?? null, fs), k: breaks.length + 1, fs, n: values.size, min: sorted[0], max: sorted[sorted.length - 1] }; | |
| 405 | + }, [rows, geometry, spec]); | |
| 406 | + return ( | |
| 407 | + <div className="py-2"> | |
| 408 | + <ChoroplethView features={model.features} sphere={sphere} legend={model.legend} k={model.k} spec={model.fs} summary={t('chart.summary.map', { name: model.fs.name ?? '', year: year ?? '', n: model.n, min: formatValue(model.min ?? null, model.fs), max: formatValue(model.max ?? null, model.fs) })} title={t('chart.map.legend', { name: model.fs.name ?? '', year: year ?? '' })} compact selectedId={highlight} /> | |
| 409 | + </div> | |
| 410 | + ); | |
| 411 | +} | |
added
apps/web/src/components/regions/group-compare-picker.tsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowLeftRight } from 'lucide-react'; | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { routes } from '@/lib/site'; | |
| 6 | +import type { RegionItem } from '@/lib/types-explore'; | |
| 7 | + | |
| 8 | +const KIND_ORDER = ['world', 'region', 'continent', 'income', 'org']; | |
| 9 | + | |
| 10 | +/** Two group selects (grouped by kind) → /regions/compare?a=&b=. Also used inline on a group page with `fixedA`. */ | |
| 11 | +export function GroupComparePicker({ groups, a, b, fixedA = false }: { groups: RegionItem[]; a: string; b: string; fixedA?: boolean }) { | |
| 12 | + const router = useRouter(); | |
| 13 | + const go = (na: string, nb: string) => router.push(routes.regionCompare(na, nb)); | |
| 14 | + const byKind = KIND_ORDER.map((k) => ({ kind: k, items: groups.filter((g) => (g.kind ?? 'custom') === k).sort((x, y) => (x.name ?? '').localeCompare(y.name ?? '')) })).filter((g) => g.items.length); | |
| 15 | + const select = (value: string, onChange: (v: string) => void, label: string) => ( | |
| 16 | + <label className="inline-flex h-11 max-w-full items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-10"> | |
| 17 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{label}</span> | |
| 18 | + <select value={value} onChange={(e) => onChange(e.target.value)} className="max-w-[14rem] truncate bg-transparent text-ink outline-none" aria-label={label}> | |
| 19 | + {byKind.map((g) => ( | |
| 20 | + <optgroup key={g.kind} label={t(`regions.kind.${g.kind}` as 'regions.kind.region')}> | |
| 21 | + {g.items.map((it) => ( | |
| 22 | + <option key={it.slug ?? it.id} value={it.slug ?? it.id}> | |
| 23 | + {it.name} | |
| 24 | + </option> | |
| 25 | + ))} | |
| 26 | + </optgroup> | |
| 27 | + ))} | |
| 28 | + </select> | |
| 29 | + </label> | |
| 30 | + ); | |
| 31 | + return ( | |
| 32 | + <div className="flex flex-wrap items-center gap-2"> | |
| 33 | + {fixedA ? <span className="inline-flex h-11 items-center rounded-sm border border-rule bg-surface-2 px-3 text-sm font-medium text-ink md:h-10">{groups.find((g) => g.slug === a)?.name ?? a}</span> : select(a, (v) => go(v, b), t('regions.compare.a'))} | |
| 34 | + <button type="button" onClick={() => go(b, a)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink md:min-h-[40px] md:min-w-[40px]" aria-label={t('regions.compare.swap')} disabled={fixedA}> | |
| 35 | + <ArrowLeftRight size={16} aria-hidden /> | |
| 36 | + </button> | |
| 37 | + {select(b, (v) => go(a, v), t('regions.compare.b'))} | |
| 38 | + </div> | |
| 39 | + ); | |
| 40 | +} | |
added
apps/web/src/components/regions/group-history.tsx
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { t, tOpt } from '@/i18n'; | |
| 3 | +import type { FormatSpec } from '@/lib/types'; | |
| 4 | +import type { RegionCompareResponse } from '@/lib/types-analytics'; | |
| 5 | +import { LineChart, type LineSeries } from '@/components/charts/line-chart'; | |
| 6 | + | |
| 7 | +/** Historical aggregates of one or two groups from `/regions/compare` (history block): one small chart per indicator. */ | |
| 8 | +export function GroupHistory({ data, ids, names }: { data: RegionCompareResponse; ids: string[]; names: Record<string, string> }) { | |
| 9 | + const entries = Object.entries(data.history); | |
| 10 | + if (!entries.length) return <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>; | |
| 11 | + return ( | |
| 12 | + <div className="grid gap-x-8 gap-y-6 sm:grid-cols-2 lg:grid-cols-4"> | |
| 13 | + {entries.map(([slug, h]) => { | |
| 14 | + const row = data.rows.find((r) => r.indicator.slug === slug); | |
| 15 | + const ind = row?.indicator; | |
| 16 | + const spec: FormatSpec = { format: ind?.format ?? 'number', unit: ind?.unit, unit_short: ind?.unit_short, precision: ind?.precision, name: ind?.short_name ?? ind?.name ?? slug }; | |
| 17 | + const years = h.years; | |
| 18 | + const series: LineSeries[] = []; | |
| 19 | + ids.forEach((id, i) => { | |
| 20 | + const arr = (h as Record<string, unknown>)[id]; | |
| 21 | + if (!Array.isArray(arr)) return; | |
| 22 | + const points = years.map((y, k) => ({ period: `${y}-01-01`, year: y, value: (arr[k] as number | null) ?? null })).filter((p) => p.value != null); | |
| 23 | + if (points.length) series.push({ id, name: names[id] ?? id, colorIndex: i, points }); | |
| 24 | + }); | |
| 25 | + const kind = (h as { kind?: string }).kind; | |
| 26 | + return ( | |
| 27 | + <LineChart key={slug} series={series} spec={spec} height={200} title={spec.name} subtitle={kind ? tOpt(`regions.${kind}`, kind) : undefined} endLabels={false} defaultWidth={320} margin={{ right: 14 }} /> | |
| 28 | + ); | |
| 29 | + })} | |
| 30 | + </div> | |
| 31 | + ); | |
| 32 | +} | |
modified
apps/web/src/i18n/en.compare.ts
+29 −2
@@ -46,9 +46,9 @@ export const enCompare = { | ||
| 46 | 46 | 'compare.minCountries': 'A comparison needs at least two countries.', |
| 47 | 47 | 'compare.notFound': 'Comparison not found', |
| 48 | 48 | 'compare.notFoundHint': 'At least two valid countries are needed, e.g. /compare/canada/united-states.', |
| 49 | − 'compare.tab.snapshot': 'Snapshot', | |
| 49 | + 'compare.tab.snapshot': 'Overview', | |
| 50 | 50 | 'compare.tab.economy': 'Economy', |
| 51 | − 'compare.tab.population': 'Population', | |
| 51 | + 'compare.tab.population': 'Demographics', | |
| 52 | 52 | 'compare.tab.health': 'Health', |
| 53 | 53 | 'compare.tab.housing': 'Housing', |
| 54 | 54 | 'compare.tab.energy': 'Energy', |
@@ -71,6 +71,22 @@ export const enCompare = { | ||
| 71 | 71 | 'compare.mode.hint.index100': 'Each series rebased to 100 at the first year of the range.', |
| 72 | 72 | 'compare.mode.hint.pct': 'Year-over-year change in percent.', |
| 73 | 73 | 'compare.mode.hint.per-capita': 'Totals divided by population; per-capita and share indicators are unchanged.', |
| 74 | + 'compare.mode.hint.absolute': 'Values as published, in the indicator unit.', | |
| 75 | + 'compare.mode.percentile': 'Percentile', | |
| 76 | + 'compare.mode.change': 'Change since year', | |
| 77 | + 'compare.mode.hint.percentile': 'World percentile of the latest value (from the world rank). Table only; charts show absolute values.', | |
| 78 | + 'compare.mode.hint.change': 'Value minus the value at the start of the range (charts); 10-year change in the table.', | |
| 79 | + 'compare.cell.percentile': '{p} pct.', | |
| 80 | + 'compare.cell.change10y': '10-year change', | |
| 81 | + 'compare.charts.changeSince': 'Change since {year}', | |
| 82 | + 'compare.charts.percentileNote': 'Absolute values (percentiles apply to the table).', | |
| 83 | + 'compare.charts.png': 'Download chart as PNG', | |
| 84 | + 'compare.h2h.title': 'Head to head', | |
| 85 | + 'compare.h2h.sub': 'Headline indicators side by side. Bars are proportional to the larger value; the thin track places both countries among all ranked countries.', | |
| 86 | + 'compare.h2h.pctLow': 'lowest', | |
| 87 | + 'compare.h2h.pctHigh': 'highest', | |
| 88 | + 'compare.h2h.pctTrack': 'world percentile · {n} countries', | |
| 89 | + 'compare.h2h.note': 'Higher is not always better: the arrow marks the direction the indicator itself declares, when it declares one.', | |
| 74 | 90 | 'compare.log': 'Log scale', |
| 75 | 91 | 'compare.download': 'Download CSV', |
| 76 | 92 | 'compare.share': 'Share', |
@@ -195,4 +211,15 @@ export const enCompare = { | ||
| 195 | 211 | 'ranking.pctRank': 'Top {pct} %', |
| 196 | 212 | 'ranking.years': 'Years', |
| 197 | 213 | 'ranking.jumpToHighlight': 'Jump to {name}', |
| 214 | + 'ranking.minpop': 'Min. population', | |
| 215 | + 'ranking.mincov': 'Coverage', | |
| 216 | + 'ranking.mincov.within': 'within {n} y', | |
| 217 | + 'ranking.view.table': 'Table', | |
| 218 | + 'ranking.view.bars': 'Bars', | |
| 219 | + 'ranking.view.map': 'Map', | |
| 220 | + 'ranking.view.barsNote': 'Top {n} of {total} shown as bars; switch to the table for the full list.', | |
| 221 | + 'ranking.race.title': 'Ranking over time', | |
| 222 | + 'ranking.race.sub': '{name}: the top {top} each year from {y0} to {y1}. Press play to watch the race.', | |
| 223 | + 'chart.race.summary': 'Bar chart race of {name}: top {top} countries per year from {y0} to {y1}.', | |
| 224 | + 'chart.race.openYear': 'Open the {year} ranking', | |
| 198 | 225 | } as const; |
modified
apps/web/src/i18n/en.core.ts
+212 −0
@@ -35,6 +35,218 @@ export const enCore = { | ||
| 35 | 35 | 'control.view': 'View', |
| 36 | 36 | 'control.reset': 'Reset view', |
| 37 | 37 | |
| 38 | + // --- charts (shared additions) | |
| 39 | + 'chart.summary.histogram': 'Distribution of {n} countries from {min} to {max}.', | |
| 40 | + 'chart.histogram.countries': 'Countries', | |
| 41 | + 'chart.bubble.size': 'Bubble size = {name}', | |
| 42 | + | |
| 43 | + // --- home 2.0 | |
| 44 | + 'home.map.title': 'Interactive world map', | |
| 45 | + 'home.map.unavailable': 'The map data for this indicator could not be loaded.', | |
| 46 | + 'home.map.world': 'World #{rank} / {n}', | |
| 47 | + 'home.map.region': '{region} #{rank} / {n}', | |
| 48 | + 'home.map.openExplorer': 'Open in World Explorer', | |
| 49 | + 'home.map.openCountry': 'Open country page', | |
| 50 | + 'home.map.history': 'History {y0}–{y1}', | |
| 51 | + 'home.map.noValue': 'No value for {year}.', | |
| 52 | + 'home.snapshot.updated': 'Data updated', | |
| 53 | + 'home.snapshot.countriesSub': 'countries & territories', | |
| 54 | + 'home.snapshot.withData': '{n} with data', | |
| 55 | + 'home.snapshot.sources': '{n} sources', | |
| 56 | + 'home.pulse.title': 'World Pulse', | |
| 57 | + 'home.pulse.sub': 'What is changing globally — computed from every country’s latest year versus the previous one.', | |
| 58 | + 'home.pulse.footnote': 'Reference year {year}; {n} countries and territories in the snapshot.', | |
| 59 | + 'home.pulse.shareLabel': '{up} countries up, {down} down', | |
| 60 | + 'home.pulse.records': '{n} record highs', | |
| 61 | + 'home.pulse.largestRise': 'Largest rise', | |
| 62 | + 'home.pulse.largestFall': 'Largest fall', | |
| 63 | + 'home.movers.title': 'Biggest movers', | |
| 64 | + 'home.movers.sub': 'The largest statistically unusual movements, detected deterministically. Pick a window, a category and a kind.', | |
| 65 | + 'home.movers.window': 'Window', | |
| 66 | + 'home.movers.window.1': '24 months', | |
| 67 | + 'home.movers.window.5': '5 years', | |
| 68 | + 'home.movers.window.10': '10 years', | |
| 69 | + 'home.movers.category': 'Category', | |
| 70 | + 'home.movers.cat.all': 'All topics', | |
| 71 | + 'home.movers.cat.economic': 'Economic', | |
| 72 | + 'home.movers.cat.demographic': 'Demographic', | |
| 73 | + 'home.movers.cat.health': 'Health', | |
| 74 | + 'home.movers.cat.energy': 'Energy', | |
| 75 | + 'home.movers.cat.climate': 'Climate', | |
| 76 | + 'home.movers.cat.digital': 'Digital', | |
| 77 | + 'home.movers.cat.housing': 'Housing', | |
| 78 | + 'home.movers.cat.labor': 'Labor', | |
| 79 | + 'home.movers.kind': 'Kind', | |
| 80 | + 'home.movers.kind.all': 'All kinds', | |
| 81 | + 'home.movers.kind.improvement': 'Fastest improvement', | |
| 82 | + 'home.movers.kind.deterioration': 'Largest deterioration', | |
| 83 | + 'home.movers.kind.increase': 'Largest increase', | |
| 84 | + 'home.movers.kind.decrease': 'Largest decrease', | |
| 85 | + 'home.movers.kind.record': 'Record breakers', | |
| 86 | + 'home.movers.kind.reversal': 'Reversals', | |
| 87 | + 'home.movers.kind.acceleration': 'Accelerations', | |
| 88 | + 'home.movers.kind.structural': 'Structural change', | |
| 89 | + 'home.movers.interp.improvement': 'improvement', | |
| 90 | + 'home.movers.interp.deterioration': 'deterioration', | |
| 91 | + 'home.movers.none': 'No movement matches these filters in the current snapshot.', | |
| 92 | + 'home.movers.extremes': 'Extremes over longer windows', | |
| 93 | + 'home.similar.title': 'Find statistical neighbours', | |
| 94 | + 'home.similar.sub': 'Pick any country to see which countries resemble it most, on each dimension.', | |
| 95 | + 'home.rankings.sub': 'Three rankings from the latest data — every indicator has one.', | |
| 96 | + 'home.trajectory.title': 'Income and longevity, every country', | |
| 97 | + 'home.trajectory.sub': 'One bubble per country, sized by population, coloured by region. Open the trajectories view to watch it move from 1960 to today.', | |
| 98 | + 'home.trajectory.stats': '{n} countries · Spearman ρ {rho} · {year}', | |
| 99 | + 'home.trajectory.open': 'Open trajectories', | |
| 100 | + 'home.updates.title': 'Latest data updates', | |
| 101 | + 'home.updates.sub': 'When each source was last imported and what changed in the current snapshot.', | |
| 102 | + 'home.updates.vintage': 'vintage {date}', | |
| 103 | + 'home.updates.counts': '{obs} obs. · {ind} indicators', | |
| 104 | + 'home.updates.changed': '{n} values changed · {c} countries', | |
| 105 | + 'home.updates.snapshot': 'Snapshot {run}, built {date}.', | |
| 106 | + 'home.updates.all': 'Freshness dashboard', | |
| 107 | + 'home.updates.status.ok': 'OK', | |
| 108 | + 'home.updates.status.partial': 'Partial', | |
| 109 | + 'home.updates.status.failed': 'Failed', | |
| 110 | + 'home.updates.status.stale': 'Stale', | |
| 111 | + 'home.updates.status.unknown': 'Unknown', | |
| 112 | + 'home.transparency.title': 'Data transparency', | |
| 113 | + 'home.transparency.sub': 'Every number on this site is traceable to source → dataset → series → retrieval date.', | |
| 114 | + 'home.transparency.obs': '{n} obs.', | |
| 115 | + 'home.transparency.text': 'CountryAtlas compiles {n} public sources into one registry of countries and indicators. A value is never mixed across sources within a series, forecasts are flagged and excluded from rankings, and unusual values are flagged rather than deleted. Click any number to open its provenance panel.', | |
| 116 | + | |
| 117 | + // --- country 2.0 | |
| 118 | + 'country.miniMap': 'Locator map of {name}', | |
| 119 | + 'country.api': 'API', | |
| 120 | + 'country.freshSplit': '{fresh} fresh · {stale} stale series', | |
| 121 | + 'country.noLandBorders': 'No land borders.', | |
| 122 | + 'country.memberOf': 'Member of', | |
| 123 | + 'country.story.title': 'How {name} changed', | |
| 124 | + 'country.story.sub': 'Long-run indicators since {since}, {n} charts. Sentences are templates filled with the numbers charted — nothing generated.', | |
| 125 | + 'country.story.none': 'Not enough long-run series to tell this story yet.', | |
| 126 | + 'country.story.peak': 'Peak {value} in {year}', | |
| 127 | + 'country.story.rank': 'Rank {r0} ({y0}) → {r1} of {n} ({y1})', | |
| 128 | + 'country.timeline.filter': 'Filter the timeline by topic', | |
| 129 | + 'country.timeline.f.all': 'All', | |
| 130 | + 'country.timeline.f.economy': 'Economy', | |
| 131 | + 'country.timeline.f.population': 'Population', | |
| 132 | + 'country.timeline.f.health': 'Health', | |
| 133 | + 'country.timeline.f.energy': 'Energy', | |
| 134 | + 'country.timeline.f.climate': 'Climate', | |
| 135 | + 'country.timeline.f.digital': 'Digital', | |
| 136 | + 'country.timeline.decade': '{d}s', | |
| 137 | + 'country.dna.ref': 'Compare against', | |
| 138 | + 'country.dna.ref.world': 'World', | |
| 139 | + 'country.dna.ref.region': 'Region', | |
| 140 | + 'country.dna.ref.income': 'Income peers', | |
| 141 | + 'country.dna.ref.country': 'Country', | |
| 142 | + 'country.dna.ref.pick': 'Pick a country…', | |
| 143 | + 'country.dna.ref.worldLabel': 'World median (50)', | |
| 144 | + 'country.dna.notScore': 'Percentile profile, not an overall country score.', | |
| 145 | + 'country.dna.dimension': 'Dimension', | |
| 146 | + 'country.similar.whySimilar': 'Why similar?', | |
| 147 | + 'country.similar.compareWith': 'Compare {a} with {b}', | |
| 148 | + 'country.similar.close.very': 'very similar', | |
| 149 | + 'country.similar.close.similar': 'similar', | |
| 150 | + 'country.similar.close.moderate': 'moderately similar', | |
| 151 | + 'country.similar.close.different': 'different', | |
| 152 | + 'metric.ownPercentile': '{p} percentile of own history since {y0}', | |
| 153 | + 'metric.ownPercentileHint': 'Share of this country’s earlier values (since {y0}) below the latest value.', | |
| 154 | + 'metric.sparkAria': '{name}, {y0}–{y1}', | |
| 155 | + 'change.kind.structural_break': 'Structural break', | |
| 156 | + 'change.kind.trend_reversal': 'Trend reversal', | |
| 157 | + 'change.kind.volatility_spike': 'Volatility spike', | |
| 158 | + | |
| 159 | + // --- indicator 2.0 | |
| 160 | + 'indicator.map.legendNote': 'Quantile classes computed over every year, so colours stay comparable while you scrub.', | |
| 161 | + 'indicator.quality.years': '{n} years with ≥ 50 countries', | |
| 162 | + 'indicator.quality.flagged': '{n} flagged values', | |
| 163 | + 'indicator.movers.title': 'Fastest movers, {y0}–{y1}', | |
| 164 | + 'indicator.movers.sub': 'Ten-year change among countries above 1 M inhabitants. Improvement follows the direction this indicator declares.', | |
| 165 | + 'indicator.movers.subNeutral': 'Ten-year change among countries above 1 M inhabitants. “Better” is not defined for this indicator: these are increases and decreases.', | |
| 166 | + 'indicator.movers.fastestImproving': 'Fastest improving', | |
| 167 | + 'indicator.movers.fastestDeclining': 'Fastest declining', | |
| 168 | + 'indicator.movers.largestIncrease': 'Largest increase', | |
| 169 | + 'indicator.movers.largestDecrease': 'Largest decrease', | |
| 170 | + 'indicator.movers.note': 'Change from {y0} to {y1}, {kind}. Computed from the same yearly values as the map.', | |
| 171 | + 'indicator.movers.relative': 'in percent of the starting value', | |
| 172 | + 'indicator.movers.absolute': 'in the indicator unit', | |
| 173 | + 'indicator.dist.section': 'Distribution across countries', | |
| 174 | + 'indicator.dist.sectionSub': 'How the world is spread: histogram of country values with the world and regional medians.', | |
| 175 | + 'indicator.dist.title': 'Country distribution, {year}', | |
| 176 | + 'indicator.dist.sub': '{n} countries · middle 80 % between {p10} and {p90}', | |
| 177 | + 'indicator.dist.worldMedian': 'World median', | |
| 178 | + 'indicator.dist.regionMedian': '{region} median', | |
| 179 | + 'indicator.dist.highlight': '{value} — {pct} percentile, rank {rank} of {n}.', | |
| 180 | + 'indicator.dist.medians': 'Medians by group', | |
| 181 | + 'indicator.dist.group': 'Group', | |
| 182 | + 'indicator.dist.n': 'n', | |
| 183 | + 'indicator.race.title': 'Historical rankings', | |
| 184 | + 'indicator.related.statTitle': 'Related indicators', | |
| 185 | + 'indicator.related.statSub': 'Indicators that move together with this one across countries (latest values). Descriptive statistics only.', | |
| 186 | + 'indicator.related.noneStat': 'Not enough overlapping countries to compute relationships.', | |
| 187 | + 'indicator.related.method': 'Cross-section of latest values around {year}, {n} candidate indicators, Spearman ρ first, pairs need ≥ 40 countries.', | |
| 188 | + 'indicator.related.positive': 'positive', | |
| 189 | + 'indicator.related.negative': 'negative', | |
| 190 | + 'indicator.related.openScatter': 'Open the scatter explorer', | |
| 191 | + 'indicator.downloads.builder': 'Dataset builder', | |
| 192 | + | |
| 193 | + // --- regions 2.0 | |
| 194 | + 'regions.compare.title': '{a} vs {b} — Group Comparison', | |
| 195 | + 'regions.compare.description': 'Compare {a} and {b}: population, GDP and share of the world economy, GDP per capita, life expectancy, CO₂, internet use, growth and inflation, with history since 1960.', | |
| 196 | + 'regions.compare.pageTitle': 'Compare groups', | |
| 197 | + 'regions.compare.heading': '{a} vs {b}', | |
| 198 | + 'regions.compare.lede': 'Two country groups side by side: aggregates computed by CountryAtlas from their members, plus their history.', | |
| 199 | + 'regions.compare.a': 'Group A', | |
| 200 | + 'regions.compare.b': 'Group B', | |
| 201 | + 'regions.compare.swap': 'Swap the two groups', | |
| 202 | + 'regions.compare.unavailable': 'One of the groups could not be resolved. Pick two groups above.', | |
| 203 | + 'regions.compare.shares': 'Share of the world', | |
| 204 | + 'regions.compare.sharesSub': 'Members’ share of world population and world GDP (sums across countries in the snapshot, latest year).', | |
| 205 | + 'regions.compare.gdpShare': 'Share of world GDP', | |
| 206 | + 'regions.compare.popShare': 'Share of world population', | |
| 207 | + 'regions.compare.table': 'Aggregates', | |
| 208 | + 'regions.compare.tableSub': 'Sum, median or population-weighted mean across members, as labelled; the year and the number of reporting members are shown under each value.', | |
| 209 | + 'regions.compare.history': 'History', | |
| 210 | + 'regions.compare.historySub': 'Aggregates year by year since 1960 (years where at least 60 % of members report).', | |
| 211 | + 'regions.compare.withOther': 'Compare with another group', | |
| 212 | + 'regions.compare.withOtherSub': 'G7 vs BRICS, EU vs USMCA, high income vs low income…', | |
| 213 | + 'regions.compare.open': 'Open the comparison', | |
| 214 | + 'region.history.title': 'Historical aggregates', | |
| 215 | + 'region.history.sub': '{name} over time: total population and GDP, population-weighted GDP per capita, median life expectancy.', | |
| 216 | + | |
| 217 | + // --- above / below expected | |
| 218 | + 'peers.title': 'Above and below expected', | |
| 219 | + 'peers.metaTitle': 'Above / Below Expected — Countries vs the Cross-Country Relationship', | |
| 220 | + 'peers.description': 'Which countries sit above or below the fitted relationship between two indicators, e.g. life expectancy given income. Descriptive statistics, not causal claims.', | |
| 221 | + 'peers.lede': 'A robust line fitted across all countries; residuals show who sits above or below it. Descriptive statistical deviation, not a causal judgement.', | |
| 222 | + 'peers.pair': 'Relationship', | |
| 223 | + 'peers.method': 'Fit', | |
| 224 | + 'peers.method.theilSen': 'Theil–Sen (robust)', | |
| 225 | + 'peers.method.ols': 'Least squares', | |
| 226 | + 'peers.stats': '{n} countries · {year} · R² {r2}', | |
| 227 | + 'peers.fitLine': 'Fitted line', | |
| 228 | + 'peers.above': 'Above the fitted line', | |
| 229 | + 'peers.below': 'Below the fitted line', | |
| 230 | + 'peers.actualVsExpected': '{actual} vs {expected} expected', | |
| 231 | + 'peers.methodology': 'Methodology', | |
| 232 | + 'peers.disclaimer': 'Descriptive statistical deviation, not a causal judgement.', | |
| 233 | + 'peers.unavailable': 'The relationship could not be computed for this pair.', | |
| 234 | + | |
| 235 | + // --- changes 2.0 | |
| 236 | + 'changes.filteredCountry': 'Showing changes for {country}.', | |
| 237 | + 'changes.clearCountry': 'Show all countries →', | |
| 238 | + 'changes.windows.title': 'Longer windows', | |
| 239 | + 'changes.windows.nav': 'Window:', | |
| 240 | + 'changes.windows.sub': 'Five- and ten-year movements, ranked by how unusual they are across countries.', | |
| 241 | + | |
| 242 | + // --- search 2.0 | |
| 243 | + 'search.type.action': 'Go', | |
| 244 | + 'search.examples': 'Try:', | |
| 245 | + 'search.example.compare': 'compare canada usa', | |
| 246 | + 'search.example.rank': 'rank gdp', | |
| 247 | + 'search.example.indicatorCountry': 'life expectancy japan', | |
| 248 | + 'search.example.indicatorGroup': 'population africa', | |
| 249 | + | |
| 38 | 250 | // --- semantics |
| 39 | 251 | 'semantics.increase': 'Increase', |
| 40 | 252 | 'semantics.decrease': 'Decrease', |
modified
apps/web/src/i18n/en.ts
+2 −2
@@ -211,9 +211,9 @@ export const en = { | ||
| 211 | 211 | 'chart.axisYear': 'Year', |
| 212 | 212 | |
| 213 | 213 | // --- home |
| 214 | − 'home.hero.title': 'Understand the world, one country at a time.', | |
| 214 | + 'home.hero.title': 'Explore the world through data.', | |
| 215 | 215 | 'home.hero.sub': |
| 216 | − 'Compare 218 countries and territories across 260 indicators — with every number traceable to its source.', | |
| 216 | + '218 countries and territories, 260 indicators, two million observations — every number traceable to its source.', | |
| 217 | 217 | 'home.snapshot.title': 'Global snapshot', |
| 218 | 218 | 'home.snapshot.population': 'World population', |
| 219 | 219 | 'home.snapshot.gdp': 'World GDP', |
modified
apps/web/src/lib/client-api.ts
+3 −1
@@ -1,5 +1,5 @@ | ||
| 1 | 1 | 'use client'; |
| 2 | −import type { SearchResponse, SeriesResponse, SimilarResponse } from './types'; | |
| 2 | +import type { DNAResponse, SearchResponse, SeriesResponse, SimilarResponse } from './types'; | |
| 3 | 3 | |
| 4 | 4 | /** |
| 5 | 5 | * Browser-side fetch helpers: same-origin `/api/v1/*` (rewritten by next.config.ts to the FastAPI service). |
@@ -28,4 +28,6 @@ export const clientApi = { | ||
| 28 | 28 | get<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, signal), |
| 29 | 29 | countrySimilar: (id: string, mode: string, signal?: AbortSignal) => |
| 30 | 30 | get<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar?mode=${encodeURIComponent(mode)}`, signal), |
| 31 | + countryDna: (id: string, reference: string, signal?: AbortSignal) => | |
| 32 | + get<DNAResponse>(`/countries/${encodeURIComponent(id)}/dna?reference=${encodeURIComponent(reference)}`, signal), | |
| 31 | 33 | }; |
modified
apps/web/src/lib/compare-state.ts
+6 −6
@@ -1,4 +1,4 @@ | ||
| 1 | −import { COMPARE_MODES, type CompareMode } from './types-compare'; | |
| 1 | +import { COMPARE_UI_MODES, type CompareUiMode } from './types-compare'; | |
| 2 | 2 | import type { TopicId } from './types'; |
| 3 | 3 | |
| 4 | 4 | /** |
@@ -6,16 +6,16 @@ import type { TopicId } from './types'; | ||
| 6 | 6 | * shareable and survives a reload: |
| 7 | 7 | * |
| 8 | 8 | * /compare/canada/united-states/france |
| 9 | − * ?tab=snapshot|economy|population|health|housing|energy|climate|digital|custom (default snapshot) | |
| 9 | + * ?tab=snapshot|economy|population|health|energy|climate|digital|housing|custom (default snapshot = Overview) | |
| 10 | 10 | * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab) |
| 11 | 11 | * &indicators=a,b,c custom tab only, ≤ 6 slugs |
| 12 | 12 | * &from=1990&to=2025 year range (omitted = full history) |
| 13 | − * &mode=absolute|per-capita|index100|pct (default absolute) | |
| 13 | + * &mode=absolute|per-capita|index100|pct|percentile|change (default absolute; percentile/change are client-side) | |
| 14 | 14 | * &log=1 log y-scale (charts only) |
| 15 | 15 | * |
| 16 | 16 | * Canonical URL = path + `tab` (when not snapshot); everything else is volatile. |
| 17 | 17 | */ |
| 18 | −export const COMPARE_TOPIC_TABS = ['economy', 'population', 'health', 'housing', 'energy', 'climate', 'digital'] as const satisfies readonly TopicId[]; | |
| 18 | +export const COMPARE_TOPIC_TABS = ['economy', 'population', 'health', 'energy', 'climate', 'digital', 'housing'] as const satisfies readonly TopicId[]; | |
| 19 | 19 | export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number]; |
| 20 | 20 | export type CompareTab = 'snapshot' | CompareTopicTab | 'custom'; |
| 21 | 21 | export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom']; |
@@ -30,7 +30,7 @@ export interface CompareState { | ||
| 30 | 30 | indicators: string[]; |
| 31 | 31 | from: number | null; |
| 32 | 32 | to: number | null; |
| 33 | − mode: CompareMode; | |
| 33 | + mode: CompareUiMode; | |
| 34 | 34 | log: boolean; |
| 35 | 35 | } |
| 36 | 36 | |
@@ -68,7 +68,7 @@ export function parseCompareState(params: ParamsLike): CompareState { | ||
| 68 | 68 | let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot'; |
| 69 | 69 | if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab; |
| 70 | 70 | const modeRaw = read(params, 'mode'); |
| 71 | − const mode = (COMPARE_MODES as readonly string[]).includes(modeRaw ?? '') ? (modeRaw as CompareMode) : 'absolute'; | |
| 71 | + const mode = (COMPARE_UI_MODES as readonly string[]).includes(modeRaw ?? '') ? (modeRaw as CompareUiMode) : 'absolute'; | |
| 72 | 72 | let from = yearOf(read(params, 'from')); |
| 73 | 73 | let to = yearOf(read(params, 'to')); |
| 74 | 74 | if (from != null && to != null && from > to) [from, to] = [to, from]; |
modified
apps/web/src/lib/ranking-state.ts
+34 −2
@@ -2,15 +2,28 @@ | ||
| 2 | 2 | * URL contract of `/rankings/[indicator]`: |
| 3 | 3 | * ?year=2020 ranking year (default: latest; the API falls back to the nearest available year) |
| 4 | 4 | * &group=oecd group slug (world · regions · continents · income groups · organisations); default world |
| 5 | + * &income=high-income income-group slug applied client-side on top of the group (hic/umc/lmc/lic slugs) | |
| 6 | + * &minpop=1000000 minimum population (client-side; 0/absent = none) | |
| 7 | + * &mincov=2 minimum coverage: rows whose year ≥ ranking year − N (client-side) | |
| 8 | + * &view=table|bars|map presentation (default table) | |
| 5 | 9 | * &sort=asc|desc default = the API's ("asc" when lower is better, else "desc") |
| 6 | 10 | * &highlight=canada country slug pinned at the top of the list |
| 7 | 11 | * &q=fra text filter within the ranking |
| 8 | 12 | * &history=CAN,USA,FRA countries of the rank-over-time panel (≤ 5 ISO3) |
| 9 | − * Canonical URL = path + `group` (when not world); `year`, `sort`, `highlight`, `q`, `history` are volatile. | |
| 13 | + * Canonical URL = path + `group` (when not world); everything else is volatile. | |
| 10 | 14 | */ |
| 15 | +export type RankingView = 'table' | 'bars' | 'map'; | |
| 16 | +export const RANKING_VIEWS: readonly RankingView[] = ['table', 'bars', 'map']; | |
| 17 | +export const MIN_POP_OPTIONS = [0, 1_000_000, 5_000_000, 10_000_000, 50_000_000] as const; | |
| 18 | +export const MIN_COV_OPTIONS = [0, 1, 2, 5] as const; | |
| 19 | + | |
| 11 | 20 | export interface RankingState { |
| 12 | 21 | year: number | null; |
| 13 | 22 | group: string; |
| 23 | + income: string | null; | |
| 24 | + minpop: number | null; | |
| 25 | + mincov: number | null; | |
| 26 | + view: RankingView; | |
| 14 | 27 | sort: 'asc' | 'desc' | null; |
| 15 | 28 | highlight: string | null; |
| 16 | 29 | q: string; |
@@ -28,19 +41,38 @@ export function parseRankingState(params: ParamsLike): RankingState { | ||
| 28 | 41 | const y = Number(read(params, 'year')); |
| 29 | 42 | const sort = read(params, 'sort'); |
| 30 | 43 | const group = (read(params, 'group') ?? 'world').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'world'; |
| 44 | + const income = (read(params, 'income') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null; | |
| 45 | + const minpop = Number(read(params, 'minpop')); | |
| 46 | + const mincov = Number(read(params, 'mincov')); | |
| 47 | + const viewRaw = read(params, 'view'); | |
| 31 | 48 | const highlight = (read(params, 'highlight') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null; |
| 32 | 49 | const history = (read(params, 'history') ?? '') |
| 33 | 50 | .split(',') |
| 34 | 51 | .map((s) => s.trim().toUpperCase()) |
| 35 | 52 | .filter((s) => /^[A-Z]{3}$/.test(s)) |
| 36 | 53 | .slice(0, 5); |
| 37 | − return { year: Number.isInteger(y) && y >= 1800 && y <= 2100 ? y : null, group, sort: sort === 'asc' || sort === 'desc' ? sort : null, highlight, q: (read(params, 'q') ?? '').slice(0, 60), history }; | |
| 54 | + return { | |
| 55 | + year: Number.isInteger(y) && y >= 1800 && y <= 2100 ? y : null, | |
| 56 | + group, | |
| 57 | + income, | |
| 58 | + minpop: Number.isFinite(minpop) && minpop > 0 ? minpop : null, | |
| 59 | + mincov: Number.isFinite(mincov) && mincov > 0 ? mincov : null, | |
| 60 | + view: (RANKING_VIEWS as readonly string[]).includes(viewRaw ?? '') ? (viewRaw as RankingView) : 'table', | |
| 61 | + sort: sort === 'asc' || sort === 'desc' ? sort : null, | |
| 62 | + highlight, | |
| 63 | + q: (read(params, 'q') ?? '').slice(0, 60), | |
| 64 | + history, | |
| 65 | + }; | |
| 38 | 66 | } |
| 39 | 67 | |
| 40 | 68 | export function rankingQuery(state: Partial<RankingState>): string { |
| 41 | 69 | const p = new URLSearchParams(); |
| 42 | 70 | if (state.year != null) p.set('year', String(state.year)); |
| 43 | 71 | if (state.group && state.group !== 'world') p.set('group', state.group); |
| 72 | + if (state.income) p.set('income', state.income); | |
| 73 | + if (state.minpop) p.set('minpop', String(state.minpop)); | |
| 74 | + if (state.mincov) p.set('mincov', String(state.mincov)); | |
| 75 | + if (state.view && state.view !== 'table') p.set('view', state.view); | |
| 44 | 76 | if (state.sort) p.set('sort', state.sort); |
| 45 | 77 | if (state.highlight) p.set('highlight', state.highlight); |
| 46 | 78 | if (state.q) p.set('q', state.q); |
modified
apps/web/src/lib/types-compare.ts
+11 −2
@@ -11,6 +11,13 @@ import type { CountryCard, IndicatorCard, IndicatorSummary, Meta, MetricValue, S | ||
| 11 | 11 | /** `mode` query of `/compare` (docs/API.md § Compare). */ |
| 12 | 12 | export type CompareMode = 'absolute' | 'per-capita' | 'index100' | 'pct'; |
| 13 | 13 | export const COMPARE_MODES: readonly CompareMode[] = ['absolute', 'per-capita', 'index100', 'pct']; |
| 14 | +/** UI-only modes computed client-side: `percentile` (world percentile from rank/n) and `change` (value − value at the range start). */ | |
| 15 | +export type CompareUiMode = CompareMode | 'percentile' | 'change'; | |
| 16 | +export const COMPARE_UI_MODES: readonly CompareUiMode[] = ['absolute', 'per-capita', 'index100', 'pct', 'percentile', 'change']; | |
| 17 | +/** Map a UI mode to the API mode (client-side modes fetch the absolute series). */ | |
| 18 | +export function apiModeOf(mode: CompareUiMode): CompareMode { | |
| 19 | + return mode === 'percentile' || mode === 'change' ? 'absolute' : mode; | |
| 20 | +} | |
| 14 | 21 | |
| 15 | 22 | /** Transformation echo added by the router on every series (`extra="allow"`). */ |
| 16 | 23 | export interface CompareTransform { |
@@ -117,8 +124,10 @@ export interface CountryLite { | ||
| 117 | 124 | flag: string | null; |
| 118 | 125 | region: string | null; |
| 119 | 126 | income: string | null; |
| 127 | + /** Optional: latest population (rankings min-population filter). */ | |
| 128 | + population?: number | null; | |
| 120 | 129 | } |
| 121 | 130 | |
| 122 | −export function toCountryLite(c: CountryCard): CountryLite { | |
| 123 | − return { id: c.id, slug: c.slug ?? c.id.toLowerCase(), name: c.name ?? c.id, flag: c.flag, region: c.region, income: c.income }; | |
| 131 | +export function toCountryLite(c: CountryCard & { population_latest?: number | null }): CountryLite { | |
| 132 | + return { id: c.id, slug: c.slug ?? c.id.toLowerCase(), name: c.name ?? c.id, flag: c.flag, region: c.region, income: c.income, population: c.population_latest ?? null }; | |
| 124 | 133 | } |
modified
apps/web/src/lib/types.ts
+16 −2
@@ -39,8 +39,11 @@ export type ChangeKind = | ||
| 39 | 39 | | 'n_year_low' |
| 40 | 40 | | 'sign_flip' |
| 41 | 41 | | 'accelerating' |
| 42 | − | 'decelerating'; | |
| 43 | −export type SearchHitType = 'country' | 'indicator' | 'topic' | 'region' | 'source' | 'country_topic' | 'country_indicator'; | |
| 42 | + | 'decelerating' | |
| 43 | + | 'structural_break' | |
| 44 | + | 'trend_reversal' | |
| 45 | + | 'volatility_spike'; | |
| 46 | +export type SearchHitType = 'country' | 'indicator' | 'topic' | 'region' | 'source' | 'country_topic' | 'country_indicator' | 'action'; | |
| 44 | 47 | export type TopicId = |
| 45 | 48 | | 'economy' |
| 46 | 49 | | 'government' |
@@ -411,12 +414,21 @@ export interface DnaDimensionRow { | ||
| 411 | 414 | value: number | null; |
| 412 | 415 | } |
| 413 | 416 | |
| 417 | +export interface DnaReference { | |
| 418 | + kind: 'world' | 'region' | 'income' | 'country' | string; | |
| 419 | + id: string | null; | |
| 420 | + label: string; | |
| 421 | + dims: Record<string, number | null>; | |
| 422 | +} | |
| 423 | + | |
| 414 | 424 | export interface DNAResponse { |
| 415 | 425 | meta: Meta; |
| 416 | 426 | country: CountryCard; |
| 417 | 427 | dims: Partial<Record<DnaDimension, number | null>>; |
| 418 | 428 | year_ref: number | null; |
| 419 | 429 | dimensions: DnaDimensionRow[]; |
| 430 | + /** Present when `?reference=` was requested (API 1.1). */ | |
| 431 | + reference?: DnaReference | null; | |
| 420 | 432 | } |
| 421 | 433 | |
| 422 | 434 | // ---------------------------------------------------------------------------------------------- maps & rankings |
@@ -542,6 +554,8 @@ export interface SearchHit { | ||
| 542 | 554 | country: CountryCard | null; |
| 543 | 555 | topic: string | null; |
| 544 | 556 | indicator: string | null; |
| 557 | + /** `type: "action"` intent hits (API 1.1): compare | ranking | group_ranking | explore. */ | |
| 558 | + action?: string | null; | |
| 545 | 559 | } |
| 546 | 560 | |
| 547 | 561 | export interface SearchResponse { |
| 548 | 562 | |