SPB Git

spb/countryatlas

Public

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>
Simon-Pierre Boucher committed (Sep 12, 2026) parent b993f86 Browse files

52 changed files +3,077 −491

52 changed files
Unified Split
added apps/web/qa/core-qa.mjs +122 −0
@@ -0,0 +1,122 @@
1+/**
2+ * Core pages QA (real API): routes × widths → screenshots + checks (overflow, console errors, failed requests,
3+ * tap targets < 44 px on phones, "undefined/NaN/null" text). Output: qa/screens/core/<route>-<width>.png + report.json.
4+ *
5+ * node qa/core-qa.mjs [BASE_URL] [--quick] [--routes=/a,/b] [--widths=390,1440]
6+ */
7+import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
8+import { mkdirSync, writeFileSync } from 'node:fs';
9+
10+const args = process.argv.slice(2);
11+const quick = args.includes('--quick');
12+const BASE = args.find((a) => a.startsWith('http')) ?? process.env.BASE_URL ?? 'http://localhost:8290';
13+const routesArg = args.find((a) => a.startsWith('--routes='));
14+const widthsArg = args.find((a) => a.startsWith('--widths='));
15+const OUT = new URL('./screens/core/', import.meta.url).pathname;
16+mkdirSync(OUT, { recursive: true });
17+
18+const ROUTES = routesArg
19+ ? routesArg.slice('--routes='.length).split(',')
20+ : ['/', '/countries/canada', '/compare/canada/australia', '/compare/canada/united-states/france?tab=economy', '/rankings/gdp-per-capita', '/indicators/life-expectancy', '/regions/g7', '/regions/compare', '/peers', '/changes'];
21+const WIDTHS = widthsArg ? widthsArg.slice('--widths='.length).split(',').map(Number) : quick ? [390, 1440] : [320, 360, 375, 390, 430, 768, 1440, 1920];
22+
23+const slug = (p) => (p === '/' ? 'home' : p.slice(1).replace(/[/?=&]+/g, '_'));
24+const report = [];
25+const browser = await chromium.launch();
26+for (const width of WIDTHS) {
27+ const mobile = width < 768;
28+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile });
29+ const page = await ctx.newPage();
30+ const errors = [];
31+ const failed = [];
32+ page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 200)}`));
33+ page.on('console', (m) => {
34+ if (m.type() === 'error') errors.push(m.text().slice(0, 200));
35+ });
36+ page.on('response', (r) => {
37+ if (r.status() >= 400 && r.url().includes('/api/')) failed.push(`${r.status()} ${r.url().slice(0, 140)}`);
38+ });
39+ for (const path of ROUTES) {
40+ let status = 0;
41+ try {
42+ const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 });
43+ status = resp?.status() ?? 0;
44+ } catch (e) {
45+ report.push({ path, width, status: 'ERR', error: String(e).slice(0, 200) });
46+ continue;
47+ }
48+ await page.evaluate(() => document.fonts.ready);
49+ await page.evaluate(async () => {
50+ const h = document.documentElement.scrollHeight;
51+ for (let y = 0; y < h; y += 700) {
52+ window.scrollTo(0, y);
53+ await new Promise((r) => setTimeout(r, 50));
54+ }
55+ window.scrollTo(0, 0);
56+ });
57+ await page.waitForLoadState('networkidle').catch(() => {});
58+ await page.waitForTimeout(500);
59+ const m = await page.evaluate(
60+ ({ mobile }) => {
61+ const de = document.documentElement;
62+ const overflow = de.scrollWidth - de.clientWidth;
63+ const wide = [...document.querySelectorAll('body *')]
64+ .filter((el) => el.getBoundingClientRect().right > de.clientWidth + 1 && el.getBoundingClientRect().width > 0)
65+ .slice(0, 5)
66+ .map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
67+ const vis = (el) => {
68+ const r = el.getBoundingClientRect();
69+ if (!r.width || !r.height) return false;
70+ const cs = getComputedStyle(el);
71+ return cs.visibility !== 'hidden' && cs.display !== 'none';
72+ };
73+ const small = mobile
74+ ? [...document.querySelectorAll('a,button,[role=button],input,select,summary,[role=radio],[role=tab]')]
75+ .filter(vis)
76+ .filter((el) => !el.closest('svg') && el.getBoundingClientRect().height < 44 && el.getBoundingClientRect().width < 44)
77+ .map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`)
78+ : [];
79+ const text = document.body.innerText || '';
80+ const bad = [];
81+ for (const re of [/\bundefined\b/g, /\bNaN\b/g, /(?<![\w"':])null(?![\w"':])/g]) {
82+ let mm;
83+ let n = 0;
84+ while ((mm = re.exec(text)) && n < 3) {
85+ bad.push(`${mm[0]} @ "${text.slice(Math.max(0, mm.index - 40), mm.index + 30).replace(/\s+/g, ' ')}"`);
86+ n++;
87+ }
88+ }
89+ const footer = document.querySelector('footer')?.innerText ?? '';
90+ const credits = /Simon-Pierre Boucher/.test(footer) && /contact@spboucher\.ai/.test(footer) && /MacLustr/.test(footer);
91+ return { overflow, wide, small: small.slice(0, 8), nSmall: small.length, bad, credits, title: document.title, docH: de.scrollHeight };
92+ },
93+ { mobile },
94+ );
95+ const file = `${slug(path)}-${width}.png`;
96+ await page.screenshot({ path: OUT + file, fullPage: true }).catch(() => {});
97+ report.push({ path, width, status, ...m, errors: errors.splice(0), failed: failed.splice(0), file });
98+ writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
99+ }
100+ await ctx.close();
101+}
102+await browser.close();
103+
104+let fails = 0;
105+for (const r of report) {
106+ const flags = [];
107+ if (r.status !== 200) flags.push(`HTTP ${r.status}`);
108+ if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`);
109+ if (r.nSmall) flags.push(`${r.nSmall} small targets`);
110+ if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`);
111+ if (r.errors?.length) flags.push(`${r.errors.length} console errors`);
112+ if (r.failed?.length) flags.push(`${r.failed.length} failed API`);
113+ if (r.credits === false) flags.push('NO CREDITS');
114+ if (flags.length) fails++;
115+ console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(52)} ${flags.join(' · ') || 'ok'} (h=${r.docH})`);
116+ if (r.wide?.length) console.log(' wide:', r.wide.join(' | '));
117+ if (r.small?.length) console.log(' small:', r.small.slice(0, 5).join(' | '));
118+ if (r.bad?.length) console.log(' bad:', r.bad.join(' | '));
119+ if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));
120+ if (r.failed?.length) console.log(' failed:', r.failed.slice(0, 3).join(' | '));
121+}
122+console.log(`\n${report.length} renders, ${fails} with flags`);
modified apps/web/src/app/(home)/page.tsx +115 −61
@@ -2,24 +2,35 @@ import type { Metadata } from 'next';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { t } from '@/i18n'; 3 import { t } from '@/i18n';
4 import { api, isNotBuilt, safe } from '@/lib/api'; 4 import { api, isNotBuilt, safe } from '@/lib/api';
5+import { apiAnalytics } from '@/lib/api-analytics';
6+import { apiExplore } from '@/lib/api-explore';
5 import { routes } from '@/lib/site'; 7 import { routes } from '@/lib/site';
6−import { Choropleth } from '@/components/charts/choropleth'; 8+import { HEADLINE_INDICATORS } from '@/lib/topics';
7 import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; 9 import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars';
8−import { ChangeList } from '@/components/data/change-list'; 10+import type { IndicatorOption } from '@/components/controls/indicator-select';
9 import { NotBuiltState } from '@/components/data/empty-state'; 11 import { NotBuiltState } from '@/components/data/empty-state';
10 import { Section } from '@/components/data/section'; 12 import { Section } from '@/components/data/section';
13+import { SimilarityPlayground } from '@/components/explore/similarity-playground';
11 import { CompareTeaser } from '@/components/home/compare-teaser'; 14 import { CompareTeaser } from '@/components/home/compare-teaser';
12 import { IndicatorList } from '@/components/home/featured-indicators'; 15 import { IndicatorList } from '@/components/home/featured-indicators';
13 import { Hero } from '@/components/home/hero'; 16 import { Hero } from '@/components/home/hero';
14−import { RegionChips } from '@/components/home/region-chips'; 17+import { HeroMap, type HeroCountry } from '@/components/home/hero-map';
18+import { LatestUpdates } from '@/components/home/latest-updates';
19+import { Movers } from '@/components/home/movers';
15 import { SnapshotStrip } from '@/components/home/snapshot-strip'; 20 import { SnapshotStrip } from '@/components/home/snapshot-strip';
16 import { TopicsGrid } from '@/components/home/topics-grid'; 21 import { TopicsGrid } from '@/components/home/topics-grid';
22+import { TrajectoryTeaser } from '@/components/home/trajectory-teaser';
23+import { Transparency } from '@/components/home/transparency';
24+import { WorldPulse } from '@/components/home/world-pulse';
25+import { baseFeatures } from '@/components/indicators/map-geometry';
17 26
18 export const metadata: Metadata = { alternates: { canonical: '/' } }; 27 export const metadata: Metadata = { alternates: { canonical: '/' } };
19 export const revalidate = 900; 28 export const revalidate = 900;
20 29
21−/** Home lists in display order; a key missing from the API payload is simply skipped (e.g. fastest_gdp_growth). */ 30+const DEFAULT_MAP = 'gdp-per-capita-ppp';
22−const LIST_KEYS = ['largest_economies', 'fastest_gdp_growth', 'highest_gdp_per_capita_ppp', 'highest_life_expectancy', 'fastest_population_growth', 'energy_transition_leaders'] as const; 31+const MAP_CHIPS = ['gdp-per-capita-ppp', 'population', 'gdp', 'gdp-growth', 'inflation', 'life-expectancy', 'fertility-rate', 'internet-users', 'co2-per-capita', 'renewable-electricity-share', 'unemployment-rate', 'population-growth'];
32+/** The three curated lists kept on the home page (the rest lives on /rankings). */
33+const LIST_KEYS = ['largest_economies', 'highest_life_expectancy', 'energy_transition_leaders'] as const;
23 34
24 export default async function HomePage() { 35 export default async function HomePage() {
25 let home: Awaited<ReturnType<typeof api.home>> | null = null; 36 let home: Awaited<ReturnType<typeof api.home>> | null = null;
@@ -30,74 +41,117 @@ export default async function HomePage() {
30 if (isNotBuilt(e)) notBuilt = true; 41 if (isNotBuilt(e)) notBuilt = true;
31 else throw e; 42 else throw e;
32 } 43 }
33− const [countriesRes, map] = await Promise.all([safe(api.countries()), safe(api.indicatorMap('gdp-per-capita-ppp', { nearest: true }))]); 44+ if (notBuilt || !home) {
45+ return (
46+ <>
47+ <Hero />
48+ <NotBuiltState />
49+ </>
50+ );
51+ }
52+ const [countriesRes, frames, indicators, pulse, movers, scatter, updates, sources] = await Promise.all([
53+ safe(api.countries()),
54+ safe(apiAnalytics.indicatorFrames(DEFAULT_MAP)),
55+ safe(apiExplore.indicators({ with_data: true })),
56+ safe(apiAnalytics.pulse()),
57+ safe(apiAnalytics.movers({ window: 1, limit: 12 })),
58+ safe(apiAnalytics.scatter({ x: 'gdp-per-capita-ppp', y: 'life-expectancy', size: 'population' })),
59+ safe(apiAnalytics.updates()),
60+ safe(apiExplore.sources()),
61+ ]);
34 const countries = countriesRes?.items ?? []; 62 const countries = countriesRes?.items ?? [];
63+ const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' };
64+ const heroCountries: HeroCountry[] = countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag, region: c.region }));
65+ const options: IndicatorOption[] = (indicators?.items ?? [])
66+ .filter((i) => (i.n_countries ?? 0) >= 20 && i.frequency === 'A')
67+ .map((i) => ({ slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries }));
68+ const chips = MAP_CHIPS.filter((s) => options.some((o) => o.slug === s));
69+ const s = home.snapshot;
35 70
36 return ( 71 return (
37 <> 72 <>
38 <Hero /> 73 <Hero />
39− {notBuilt || !home ? (
40− <NotBuiltState />
41− ) : (
42− <>
43− <SnapshotStrip s={home.snapshot} />
44 74
45− <Section id="explore" title={t('home.explore.title')} subtitle={t('home.explore.sub')}> 75+ {/* Global interactive hero map + time machine */}
46− <RegionChips /> 76+ <section id="map" aria-labelledby="map-h" className="pb-8 md:pb-10">
47− {map && countries.length ? ( 77+ <h2 id="map-h" className="sr-only">
48− <div className="mt-5 max-w-4xl"> 78+ {t('home.map.title')}
49− <Choropleth map={map} countries={countries} compact /> 79+ </h2>
80+ {features.length ? <HeroMap geometry={features} sphere={sphere} initial={frames} options={options.length ? options : HEADLINE_INDICATORS.map((sl) => ({ slug: sl, name: sl }))} chips={chips.length ? chips : [DEFAULT_MAP]} countries={heroCountries} /> : null}
81+ </section>
82+
83+ <SnapshotStrip s={s} />
84+
85+ {pulse && pulse.items.length ? (
86+ <Section id="pulse" title={t('home.pulse.title')} subtitle={t('home.pulse.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('home.changes.all')} →</Link>}>
87+ <WorldPulse data={pulse} />
88+ </Section>
89+ ) : null}
90+
91+ <Section id="movers" title={t('home.movers.title')} subtitle={t('home.movers.sub')}>
92+ <Movers initial={movers} limit={12} compact />
93+ </Section>
94+
95+ <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}>
96+ <TopicsGrid />
97+ </Section>
98+
99+ <div className="grid gap-x-10 lg:grid-cols-2">
100+ <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}>
101+ <CompareTeaser countries={countries} />
102+ </Section>
103+ <Section id="similar" title={t('home.similar.title')} subtitle={t('home.similar.sub')}>
104+ <SimilarityPlayground />
105+ </Section>
106+ </div>
107+
108+ <Section id="rankings" title={t('nav.rankings')} subtitle={t('home.rankings.sub')} actions={<Link href={routes.rankings()} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}>
109+ <div className="grid gap-x-10 gap-y-8 lg:grid-cols-3">
110+ {LIST_KEYS.map((key) => {
111+ const list = home.lists[key];
112+ if (!list || !list.rows?.length) return null;
113+ const latestYear = Math.max(0, ...list.rows.map((r) => r.year ?? 0));
114+ return (
115+ <div key={key} className="min-w-0">
116+ <div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
117+ <h3 className="text-base font-semibold text-ink">{list.title}</h3>
118+ <span className="text-xs text-ink-3">{list.description}</span>
119+ </div>
120+ {list.filter_note ? <p className="-mt-1 mb-2 text-xs text-ink-3">{list.filter_note}</p> : null}
121+ <RankedBars rows={list.rows.slice(0, 6).map((r) => rankedRowFromCountry(r.country, r.value, r.rank, null, r.year != null && latestYear - r.year >= 2 ? r.year : null))} spec={list.indicator} provenance={list.rows[0]?.provenance ?? null} />
122+ <Link href={routes.ranking(list.indicator.slug)} className="mt-2 inline-flex min-h-[36px] items-center text-sm text-accent hover:underline">
123+ {t('common.seeFullRanking')} →
124+ </Link>
50 </div> 125 </div>
51− ) : null} 126+ );
52− </Section> 127+ })}
128+ </div>
129+ </Section>
53 130
54− <Section id="compare" title={t('home.compare.title')} subtitle={t('home.compare.sub')}> 131+ {scatter && scatter.points.length ? (
55− <CompareTeaser countries={countries} /> 132+ <Section id="trajectory" title={t('home.trajectory.title')} subtitle={t('home.trajectory.sub')}>
56− </Section> 133+ <TrajectoryTeaser data={scatter} />
134+ </Section>
135+ ) : null}
57 136
58− <Section id="rankings" title={t('nav.rankings')} actions={<Link href={routes.rankings()} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}> 137+ <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
59− <div className="grid gap-x-10 gap-y-8 lg:grid-cols-2"> 138+ <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')}>
60− {LIST_KEYS.map((key) => { 139+ <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={10} />
61− const list = home.lists[key]; 140+ </Section>
62− if (!list || !list.rows?.length) return null; 141+ {updates ? (
63− // Freshness honesty: a row ≥ 2 years older than the newest row of the list shows its year. 142+ <Section id="updated" title={t('home.updates.title')} subtitle={t('home.updates.sub')}>
64− const latestYear = Math.max(0, ...list.rows.map((r) => r.year ?? 0)); 143+ <LatestUpdates data={updates} />
65− return (
66− <div key={key} className="min-w-0">
67− <div className="mb-2 flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5">
68− <h3 className="text-base font-semibold text-ink">{list.title}</h3>
69− <span className="text-xs text-ink-3">{list.description}</span>
70− </div>
71− {list.filter_note ? <p className="-mt-1 mb-2 text-xs text-ink-3">{list.filter_note}</p> : null}
72− <RankedBars rows={list.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, null, r.year != null && latestYear - r.year >= 2 ? r.year : null))} spec={list.indicator} provenance={list.rows[0]?.provenance ?? null} />
73− <Link href={routes.ranking(list.indicator.slug)} className="mt-2 inline-flex min-h-[36px] items-center text-sm text-accent hover:underline">
74− {t('common.seeFullRanking')} →
75− </Link>
76− </div>
77− );
78− })}
79− </div>
80 </Section> 144 </Section>
81− 145+ ) : (
82− <Section id="topics" title={t('home.topics.title')} subtitle={t('home.topics.sub')}> 146+ <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')}>
83− <TopicsGrid /> 147+ <IndicatorList items={home.recently_updated} showUpdated limit={8} />
84 </Section> 148 </Section>
149+ )}
150+ </div>
85 151
86− <div className="grid gap-x-10 lg:grid-cols-2"> 152+ <Section id="transparency" title={t('home.transparency.title')} subtitle={t('home.transparency.sub')}>
87− <Section id="changes" title={t('home.changes.title')} subtitle={t('home.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('home.changes.all')} →</Link>}> 153+ <Transparency sources={sources?.items ?? []} />
88− <ChangeList items={home.recent_changes.slice(0, 10)} showCountry /> 154+ </Section>
89− </Section>
90− <div>
91− <Section id="featured" title={t('home.featured.title')} subtitle={t('home.featured.sub')} tight>
92− <IndicatorList items={home.trending.length ? home.trending : home.featured_indicators} limit={8} />
93− </Section>
94− <Section id="updated" title={t('home.updated.title')} subtitle={t('home.updated.sub')} tight>
95− <IndicatorList items={home.recently_updated} showUpdated limit={8} />
96− </Section>
97− </div>
98− </div>
99− </>
100− )}
101 </> 155 </>
102 ); 156 );
103 } 157 }
modified apps/web/src/app/changes/page.tsx +35 −2
@@ -8,7 +8,11 @@ import { routes } from '@/lib/site';
8 import type { ChangesFeedResponse } from '@/lib/types-explore'; 8 import type { ChangesFeedResponse } from '@/lib/types-explore';
9 import { ChangesFeed } from '@/components/changes/changes-feed'; 9 import { ChangesFeed } from '@/components/changes/changes-feed';
10 import { NotBuiltState } from '@/components/data/empty-state'; 10 import { NotBuiltState } from '@/components/data/empty-state';
11+import { Section } from '@/components/data/section';
11 import { PageHeader } from '@/components/explore/page-header'; 12 import { PageHeader } from '@/components/explore/page-header';
13+import { Movers } from '@/components/home/movers';
14+import { apiAnalytics } from '@/lib/api-analytics';
15+import { safe } from '@/lib/api';
12 16
13 export const revalidate = 900; 17 export const revalidate = 900;
14 18
@@ -18,20 +22,46 @@ export const metadata: Metadata = {
18 alternates: { canonical: routes.changes() }, 22 alternates: { canonical: routes.changes() },
19 }; 23 };
20 24
21−export default async function ChangesPage() { 25+type SP = Record<string, string | string[] | undefined>;
26+
27+export default async function ChangesPage({ searchParams }: { searchParams: Promise<SP> }) {
28+ const sp = await searchParams;
29+ const countryRaw = Array.isArray(sp.country) ? sp.country[0] : sp.country;
30+ const country = countryRaw && /^[a-z0-9-]+$/i.test(countryRaw) ? countryRaw : undefined;
22 let data: ChangesFeedResponse | null = null; 31 let data: ChangesFeedResponse | null = null;
23 try { 32 try {
24− data = await apiExplore.changes({ limit: 100 }); 33+ data = await apiExplore.changes({ limit: 100, country });
25 } catch (e) { 34 } catch (e) {
26 if (!isNotBuilt(e)) throw e; 35 if (!isNotBuilt(e)) throw e;
27 } 36 }
37+ const winRaw = Array.isArray(sp.window) ? sp.window[0] : sp.window;
38+ const win: 5 | 10 = winRaw === '10' ? 10 : 5;
39+ const movers = await safe(apiAnalytics.movers({ window: win, limit: 12 }));
28 return ( 40 return (
29 <> 41 <>
30 <PageHeader title={t('changes.title')} lede={t('changes.sub')} meta={data?.meta.built_at ? t('site.footer.refreshed', { date: formatDate(data.meta.built_at) }) : undefined} /> 42 <PageHeader title={t('changes.title')} lede={t('changes.sub')} meta={data?.meta.built_at ? t('site.footer.refreshed', { date: formatDate(data.meta.built_at) }) : undefined} />
43+ {country ? (
44+ <p className="mb-3 text-sm text-ink-2">
45+ {t('changes.filteredCountry', { country })}{' '}
46+ <Link href={routes.changes()} className="text-accent hover:underline">
47+ {t('changes.clearCountry')}
48+ </Link>
49+ </p>
50+ ) : null}
31 {!data ? ( 51 {!data ? (
32 <NotBuiltState /> 52 <NotBuiltState />
33 ) : ( 53 ) : (
34 <> 54 <>
55+ <nav aria-label={t('changes.windows.nav')} className="mb-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-2">
56+ <span>{t('changes.windows.nav')}</span>
57+ <span className="inline-flex min-h-[36px] items-center rounded-sm bg-ink px-2 text-xs text-paper">{t('home.movers.window.1')}</span>
58+ <Link href={`${routes.changes()}?window=5#windows`} className="inline-flex min-h-[36px] items-center text-accent hover:underline">
59+ {t('home.movers.window.5')} ↓
60+ </Link>
61+ <Link href={`${routes.changes()}?window=10#windows`} className="inline-flex min-h-[36px] items-center text-accent hover:underline">
62+ {t('home.movers.window.10')} ↓
63+ </Link>
64+ </nav>
35 <details className="mb-2 border-y border-rule py-3 text-sm"> 65 <details className="mb-2 border-y border-rule py-3 text-sm">
36 <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink"> 66 <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 font-medium text-ink-2 hover:text-ink">
37 <span aria-hidden>›</span> {t('changes.methodology')} 67 <span aria-hidden>›</span> {t('changes.methodology')}
@@ -44,6 +74,9 @@ export default async function ChangesPage() {
44 </p> 74 </p>
45 </details> 75 </details>
46 <ChangesFeed initial={data.items} kinds={data.kinds ?? []} limit={100} /> 76 <ChangesFeed initial={data.items} kinds={data.kinds ?? []} limit={100} />
77+ <Section id="windows" title={t('changes.windows.title')} subtitle={t('changes.windows.sub')}>
78+ <Movers key={win} initial={movers} initialWindow={win} limit={12} compact />
79+ </Section>
47 </> 80 </>
48 )} 81 )}
49 </> 82 </>
modified apps/web/src/app/compare/[...slugs]/page.tsx +12 −5
@@ -7,7 +7,9 @@ import { MIN_COMPARE_COUNTRIES, compareCanonicalQuery, parseCompareState, splitC
7 import { routes } from '@/lib/site'; 7 import { routes } from '@/lib/site';
8 import { topicById } from '@/lib/topics'; 8 import { topicById } from '@/lib/topics';
9 import type { CountrySummary, IndicatorCard } from '@/lib/types'; 9 import type { CountrySummary, IndicatorCard } from '@/lib/types';
10−import { toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare'; 10+import { apiModeOf, toCountryLite, type CompareSeries, type CompareSnapshotRow, type CountryLite } from '@/lib/types-compare';
11+import { seoTitle } from '@/lib/seo';
12+import { HeadToHead } from '@/components/compare/head-to-head';
11 import { CompareChart } from '@/components/compare/compare-chart'; 13 import { CompareChart } from '@/components/compare/compare-chart';
12 import { ChartGrid, ChartSections } from '@/components/compare/chart-sections'; 14 import { ChartGrid, ChartSections } from '@/components/compare/chart-sections';
13 import { CompareControls } from '@/components/compare/compare-controls'; 15 import { CompareControls } from '@/components/compare/compare-controls';
@@ -59,7 +61,7 @@ export async function generateMetadata({ params, searchParams }: { params: Promi
59 const state = parseCompareState(sp); 61 const state = parseCompareState(sp);
60 const names = namesOf(r.countries); 62 const names = namesOf(r.countries);
61 const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab; 63 const tabName = state.tab === 'snapshot' ? null : state.tab === 'custom' ? t('compare.tab.custom') : topicById(state.tab)?.name ?? state.tab;
62− const title = tabName ? `${names} — ${tabName} — ${t('compare.title')}` : t('compare.pageTitle', { names }); 64+ const title = tabName ? `${seoTitle.compare(r.countries.map((c) => c.name ?? c.id))} — ${tabName}` : seoTitle.compare(r.countries.map((c) => c.name ?? c.id));
63 const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' }); 65 const description = t('compare.pageDescription', { names, list: 'GDP, GDP per capita, growth, inflation, unemployment, life expectancy' });
64 const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`; 66 const canonical = `${routes.compare(...r.countries.map((c) => c.slug ?? c.id))}${compareCanonicalQuery(state)}`;
65 return { 67 return {
@@ -92,7 +94,7 @@ export default async function CompareViewPage({ params, searchParams }: { params
92 const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null; 94 const topic = state.tab !== 'snapshot' && state.tab !== 'custom' ? state.tab : null;
93 const customSlugs = state.tab === 'custom' ? state.indicators : []; 95 const customSlugs = state.tab === 'custom' ? state.indicators : [];
94 const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic })); 96 const snapshotP = state.tab === 'custom' ? (customSlugs.length ? safe(apiCompare.snapshot(ids, { indicators: customSlugs })) : Promise.resolve(null)) : safe(apiCompare.snapshot(ids, { topic }));
95− const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: state.mode })) : Promise.resolve(null); 97+ const heroP = state.indicator && state.tab !== 'snapshot' ? safe(apiCompare.compare(ids, [state.indicator], { from: state.from, to: state.to, mode: apiModeOf(state.mode) })) : Promise.resolve(null);
96 const [snapshot, hero] = await Promise.all([snapshotP, heroP]); 98 const [snapshot, hero] = await Promise.all([snapshotP, heroP]);
97 99
98 const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data)); 100 const rows: CompareSnapshotRow[] = (snapshot?.rows ?? []).filter((row) => ids.some((id) => row.values[id]?.has_data));
@@ -106,7 +108,7 @@ export default async function CompareViewPage({ params, searchParams }: { params
106 .filter((s) => s !== state.indicator) 108 .filter((s) => s !== state.indicator)
107 .slice(0, state.tab === 'custom' ? 6 : EAGER); 109 .slice(0, state.tab === 'custom' ? 6 : EAGER);
108 if (eagerSlugs.length) { 110 if (eagerSlugs.length) {
109− const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: state.mode })); 111+ const bundle = await safe(apiCompare.compare(ids, eagerSlugs, { from: state.from, to: state.to, mode: apiModeOf(state.mode) }));
110 if (bundle) { 112 if (bundle) {
111 eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)])); 113 eager = new Map(eagerSlugs.map((s) => [s, bundle.series.filter((x) => x.indicator.slug === s)]));
112 } 114 }
@@ -140,8 +142,13 @@ export default async function CompareViewPage({ params, searchParams }: { params
140 142
141 <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} /> 143 <CompareControls countries={countries} allCountries={all} state={state} maxYear={maxYear} downloadHref={downloadHref} />
142 144
145+ {state.tab === 'snapshot' && countries.length === 2 ? (
146+ <Section id="h2h" title={t('compare.h2h.title')} subtitle={t('compare.h2h.sub')} className="border-t-0">
147+ <HeadToHead rows={rows} countries={countries} slugs={slugs} state={state} />
148+ </Section>
149+ ) : null}
143 {state.tab === 'snapshot' ? ( 150 {state.tab === 'snapshot' ? (
144− <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className="border-t-0"> 151+ <Section id="snapshot" title={t('compare.snapshot.title')} subtitle={t('compare.snapshot.sub')} className={countries.length === 2 ? undefined : 'border-t-0'}>
145 <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} /> 152 <SnapshotTable rows={rows} countries={countries} slugs={slugs} state={state} />
146 </Section> 153 </Section>
147 ) : null} 154 ) : null}
modified apps/web/src/app/countries/[slug]/page.tsx +41 −28
@@ -3,12 +3,16 @@ import Link from 'next/link';
3 import { notFound } from 'next/navigation'; 3 import { notFound } from 'next/navigation';
4 import { t } from '@/i18n'; 4 import { t } from '@/i18n';
5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; 5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6−import { routes } from '@/lib/site'; 6+import { apiAnalytics } from '@/lib/api-analytics';
7+import { apiExplore } from '@/lib/api-explore';
7 import { regionShort } from '@/lib/regions'; 8 import { regionShort } from '@/lib/regions';
9+import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';
10+import { routes } from '@/lib/site';
8 import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics'; 11 import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';
9−import type { CountryResponse } from '@/lib/types'; 12+import type { CountryResponse, FormatSpec } from '@/lib/types';
10−import { DnaRadial } from '@/components/charts/dna-radial';
11 import { CountryHeader } from '@/components/country/country-header'; 13 import { CountryHeader } from '@/components/country/country-header';
14+import { CountryStory } from '@/components/country/country-story';
15+import { DnaPanel } from '@/components/country/dna-panel';
12 import { KeyFacts } from '@/components/country/key-facts'; 16 import { KeyFacts } from '@/components/country/key-facts';
13 import { SimilarPanel } from '@/components/country/similar-panel'; 17 import { SimilarPanel } from '@/components/country/similar-panel';
14 import { Timeline } from '@/components/country/timeline'; 18 import { Timeline } from '@/components/country/timeline';
@@ -38,14 +42,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
38 const data = await loadCountry(slug); 42 const data = await loadCountry(slug);
39 if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } }; 43 if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } };
40 const name = data.country.name ?? slug; 44 const name = data.country.name ?? slug;
41− const title = t('country.title', { name }); 45+ const title = seoTitle.country(name);
42 const description = t('country.description', { name }); 46 const description = t('country.description', { name });
43 const canonical = routes.country(data.country.slug ?? slug); 47 const canonical = routes.country(data.country.slug ?? slug);
44 return { 48 return {
45− title, 49+ title: { absolute: `${title} | ${t('site.name')}` },
46 description, 50 description,
47 alternates: { canonical }, 51 alternates: { canonical },
48− openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, 52+ openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },
49 twitter: { card: 'summary_large_image', title, description }, 53 twitter: { card: 'summary_large_image', title, description },
50 }; 54 };
51 } 55 }
@@ -61,19 +65,25 @@ export default async function CountryPage({ params }: { params: Promise<Params>
61 const name = c.name ?? id; 65 const name = c.name ?? id;
62 const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag }; 66 const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag };
63 // Optional panels in parallel; each tolerates failure independently. 67 // Optional panels in parallel; each tolerates failure independently.
64− const [changes, similar, insights, dna, events] = await Promise.all([ 68+ const [changes, similar, insights, dna, events, story, quality, indicators] = await Promise.all([
65 safe(api.countryChanges(id, 8)), 69 safe(api.countryChanges(id, 8)),
66 safe(api.countrySimilar(id, 'overall', 8)), 70 safe(api.countrySimilar(id, 'overall', 8)),
67 safe(api.countryInsights(id)), 71 safe(api.countryInsights(id)),
68 safe(api.countryDna(id)), 72 safe(api.countryDna(id)),
69− safe(api.countryEvents(id, 30)), 73+ safe(api.countryEvents(id, 120)),
74+ safe(apiAnalytics.countryStory(id)),
75+ safe(apiAnalytics.countryQuality(id)),
76+ safe(apiExplore.indicators({ with_data: true })),
70 ]); 77 ]);
71 const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data])); 78 const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data]));
72 const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0); 79 const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0);
80+ const formats: Record<string, FormatSpec> = Object.fromEntries((indicators?.items ?? []).map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }]));
81+ const ld = [jsonLd.country({ slug: c.slug ?? slug, name, iso3: c.iso3 ?? id, capital: c.capital }), jsonLd.breadcrumbs([{ name: t('nav.countries'), path: routes.countries() }, { name, path: routes.country(c.slug ?? slug) }])];
73 82
74 return ( 83 return (
75 <> 84 <>
76− <CountryHeader data={data} /> 85+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />
86+ <CountryHeader data={data} quality={quality} />
77 <TopicNav slug={c.slug ?? slug} counts={counts} /> 87 <TopicNav slug={c.slug ?? slug} counts={counts} />
78 88
79 <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0"> 89 <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0">
@@ -85,42 +95,45 @@ export default async function CountryPage({ params }: { params: Promise<Params>
85 </MetricGrid> 95 </MetricGrid>
86 </Section> 96 </Section>
87 97
98+ {story && story.items.length ? (
99+ <Section id="story" title={t('country.story.title', { name })} subtitle={t('country.story.sub', { since: story.since ?? '', n: story.items.length })}>
100+ <CountryStory data={story} country={countryRef} />
101+ </Section>
102+ ) : null}
103+
88 <div className="grid gap-x-10 lg:grid-cols-2"> 104 <div className="grid gap-x-10 lg:grid-cols-2">
89− <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')}> 105+ <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')} actions={<Link href={`${routes.changes()}?country=${c.slug ?? id}`} className="text-accent hover:underline">{t('common.seeAll')} →</Link>}>
90 <ChangeList items={changes?.items ?? []} /> 106 <ChangeList items={changes?.items ?? []} />
91 </Section> 107 </Section>
92 <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}> 108 <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}>
93− <SimilarPanel countryId={id} initial={similar} /> 109+ <SimilarPanel countryId={id} countrySlug={c.slug} countryName={name} initial={similar} formats={formats} />
94 </Section> 110 </Section>
95 </div> 111 </div>
96 112
97 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]"> 113 <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
98 <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}> 114 <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}>
99− <DnaRadial dna={dna} name={name} size={360} /> 115+ <DnaPanel countryId={id} name={name} initial={dna} />
100− {dna?.year_ref ? <p className="tnum mt-2 text-center text-2xs text-ink-3">{dna.year_ref}</p> : null}
101 </Section> 116 </Section>
102 <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}> 117 <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}>
103 <KeyFacts items={insights?.items ?? []} country={countryRef} /> 118 <KeyFacts items={insights?.items ?? []} country={countryRef} />
104− {data.neighbours.length ? (
105− <p className="mt-4 text-sm text-ink-2">
106− <span className="text-ink-3">{t('country.borders')}: </span>
107− {data.neighbours.map((n, i) => (
108− <span key={n.id}>
109− {i > 0 ? ', ' : ''}
110− <Link href={routes.country(n.slug ?? n.id)} className="link-quiet text-ink hover:text-accent">
111− <span aria-hidden>{n.flag} </span>
112− {n.name}
113− </Link>
114− </span>
115− ))}
116− </p>
117− ) : null}
118 {c.languages?.length ? ( 119 {c.languages?.length ? (
119− <p className="mt-1 text-sm text-ink-2"> 120+ <p className="mt-4 text-sm text-ink-2">
120 <span className="text-ink-3">{t('country.languages')}: </span> 121 <span className="text-ink-3">{t('country.languages')}: </span>
121 {c.languages.join(', ')} 122 {c.languages.join(', ')}
122 </p> 123 </p>
123 ) : null} 124 ) : null}
125+ {data.groups.length ? (
126+ <p className="mt-1 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-ink-2">
127+ <span className="text-ink-3">{t('country.memberOf')}: </span>
128+ {data.groups
129+ .filter((g) => g.kind === 'org')
130+ .map((g) => (
131+ <Link key={g.id} href={routes.region(g.slug ?? g.id)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-ink hover:text-accent">
132+ {g.name}
133+ </Link>
134+ ))}
135+ </p>
136+ ) : null}
124 </Section> 137 </Section>
125 </div> 138 </div>
126 139
modified apps/web/src/app/indicators/[slug]/page.tsx +134 −60
@@ -4,26 +4,34 @@ import Link from 'next/link';
4 import { notFound } from 'next/navigation'; 4 import { notFound } from 'next/navigation';
5 import { t, tOpt } from '@/i18n'; 5 import { t, tOpt } from '@/i18n';
6 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; 6 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
7+import { apiAnalytics } from '@/lib/api-analytics';
7 import { apiExplore } from '@/lib/api-explore'; 8 import { apiExplore } from '@/lib/api-explore';
8 import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format'; 9 import { formatDate, formatPct, formatValue, grouped, isNum } from '@/lib/format';
10+import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';
9 import { SITE_URL, routes } from '@/lib/site'; 11 import { SITE_URL, routes } from '@/lib/site';
10 import { topicById } from '@/lib/topics'; 12 import { topicById } from '@/lib/topics';
11 import type { CountrySummary, FormatSpec, Series } from '@/lib/types'; 13 import type { CountrySummary, FormatSpec, Series } from '@/lib/types';
14+import type { FramesResponse } from '@/lib/types-analytics';
12 import type { IndicatorResponse, RankedValue } from '@/lib/types-explore'; 15 import type { IndicatorResponse, RankedValue } from '@/lib/types-explore';
13−import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; 16+import { RankRace } from '@/components/charts/rank-race';
17+import { RankedBars, rankedRowFromCountry, type RankedBarRow } from '@/components/charts/ranked-bars';
14 import { EmptyState, NotBuiltState } from '@/components/data/empty-state'; 18 import { EmptyState, NotBuiltState } from '@/components/data/empty-state';
15 import type { ProvenancePayload } from '@/components/data/provenance-context'; 19 import type { ProvenancePayload } from '@/components/data/provenance-context';
20+import { QualityBadges } from '@/components/data/quality-badge';
16 import { Section } from '@/components/data/section'; 21 import { Section } from '@/components/data/section';
17 import { CodeBlock } from '@/components/explore/copy-button'; 22 import { CodeBlock } from '@/components/explore/copy-button';
18 import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; 23 import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';
24+import { DistributionPanel } from '@/components/indicators/distribution-panel';
19 import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare'; 25 import { IndicatorCompare, type CompareCountry } from '@/components/indicators/indicator-compare';
20 import { IndicatorMap } from '@/components/indicators/indicator-map'; 26 import { IndicatorMap } from '@/components/indicators/indicator-map';
21 import { IndicatorTrend } from '@/components/indicators/indicator-trend'; 27 import { IndicatorTrend } from '@/components/indicators/indicator-trend';
22 import { baseFeatures } from '@/components/indicators/map-geometry'; 28 import { baseFeatures } from '@/components/indicators/map-geometry';
29+import { RelatedTable } from '@/components/indicators/related-table';
23 30
24 export const revalidate = 900; 31 export const revalidate = 900;
25 32
26 type Params = { slug: string }; 33 type Params = { slug: string };
34+type SP = Record<string, string | string[] | undefined>;
27 35
28 async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> { 36 async function load(slug: string): Promise<IndicatorResponse | 'not-built' | null> {
29 try { 37 try {
@@ -41,14 +49,14 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
41 if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } }; 49 if (!data || data === 'not-built') return { title: t('indicator.notFound'), robots: { index: false } };
42 const ind = data.indicator; 50 const ind = data.indicator;
43 const name = titleCase(ind.short_name ?? ind.name ?? slug); 51 const name = titleCase(ind.short_name ?? ind.name ?? slug);
44− const title = t('indicator.title', { name }); 52+ const title = seoTitle.indicator(name);
45 const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' }); 53 const description = t('indicator.description', { name: ind.name ?? name, unit: ind.unit ?? '', n: data.coverage.n_countries ?? 0, y0: data.years.first ?? '', y1: data.years.last_actual ?? data.years.last ?? '' });
46 const canonical = routes.indicator(ind.slug); 54 const canonical = routes.indicator(ind.slug);
47 return { 55 return {
48− title, 56+ title: { absolute: `${title} | ${t('site.name')}` },
49 description, 57 description,
50 alternates: { canonical }, 58 alternates: { canonical },
51− openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, 59+ openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },
52 twitter: { card: 'summary_large_image', title, description }, 60 twitter: { card: 'summary_large_image', title, description },
53 }; 61 };
54 } 62 }
@@ -66,8 +74,41 @@ function specOf(ind: IndicatorResponse['indicator']): FormatSpec {
66 return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; 74 return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };
67 } 75 }
68 76
69−export default async function IndicatorPage({ params }: { params: Promise<Params> }) { 77+/** 10-year movers from the frames payload: change between the latest frame and the frame ten years earlier (± 2). */
70− const { slug } = await params; 78+function decadeMovers(frames: FramesResponse | null, countries: Map<string, CountrySummary>, hib: boolean | null | undefined, relative: boolean): { up: RankedBarRow[]; down: RankedBarRow[]; from: number; to: number } | null {
79+ if (!frames || frames.years.length < 11) return null;
80+ const years = frames.years;
81+ const toIdx = years.length - 1;
82+ const to = years[toIdx]!;
83+ let fromIdx = years.findIndex((y) => y >= to - 10);
84+ if (fromIdx < 0 || fromIdx === toIdx) return null;
85+ if (years[fromIdx]! > to - 8) fromIdx = Math.max(0, fromIdx - 1);
86+ const from = years[fromIdx]!;
87+ const rows: Array<{ id: string; delta: number; v0: number; v1: number }> = [];
88+ for (const [iso, arr] of Object.entries(frames.values)) {
89+ const v1 = arr[toIdx];
90+ const v0 = arr[fromIdx];
91+ if (typeof v1 !== 'number' || typeof v0 !== 'number') continue;
92+ const pop = countries.get(iso)?.population_latest ?? 0;
93+ if (pop < 1_000_000) continue;
94+ const delta = relative ? (v0 !== 0 ? ((v1 - v0) / Math.abs(v0)) * 100 : NaN) : v1 - v0;
95+ if (!Number.isFinite(delta)) continue;
96+ rows.push({ id: iso, delta, v0, v1 });
97+ }
98+ if (rows.length < 10) return null;
99+ const sorted = [...rows].sort((a, b) => b.delta - a.delta);
100+ const mk = (r: { id: string; delta: number; v0: number; v1: number }, i: number): RankedBarRow => {
101+ const c = countries.get(r.id);
102+ return { id: r.id, label: c?.name ?? r.id, flag: c?.flag ?? null, href: c?.slug ? routes.country(c.slug) : null, value: r.delta, rank: i + 1 };
103+ };
104+ const up = sorted.slice(0, 6).map(mk);
105+ const down = sorted.slice(-6).reverse().map(mk);
106+ // Direction semantics: when higher is better, "up" is the improvement list; when lower is better, swap.
107+ return { up: hib === false ? down : up, down: hib === false ? up : down, from, to };
108+}
109+
110+export default async function IndicatorPage({ params, searchParams }: { params: Promise<Params>; searchParams: Promise<SP> }) {
111+ const [{ slug }, sp] = await Promise.all([params, searchParams]);
71 const data = await load(slug); 112 const data = await load(slug);
72 if (data === null) notFound(); 113 if (data === null) notFound();
73 if (data === 'not-built') return <NotBuiltState />; 114 if (data === 'not-built') return <NotBuiltState />;
@@ -76,32 +117,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
76 const name = ind.name ?? ind.slug; 117 const name = ind.name ?? ind.slug;
77 const spec = specOf(ind); 118 const spec = specOf(ind);
78 const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null; 119 const yearUsed = data.years.latest_common ?? data.world_latest?.year ?? data.years.last_actual ?? null;
120+ const highlight = typeof sp.country === 'string' && /^[a-z0-9-]+$/i.test(sp.country) ? sp.country : null;
79 121
80− const [countriesRes, map, trend, related] = await Promise.all([ 122+ const [countriesRes, frames, trend, related, quality, distribution, race] = await Promise.all([
81 safe(api.countries()), 123 safe(api.countries()),
82− safe(api.indicatorMap(ind.slug, yearUsed ? { year: yearUsed } : {})), 124+ safe(apiAnalytics.indicatorFrames(ind.slug)),
83 safe(apiExplore.indicatorTrend(ind.slug, 'world')), 125 safe(apiExplore.indicatorTrend(ind.slug, 'world')),
84− ind.topic ? safe(apiExplore.indicators({ topic: ind.topic })) : Promise.resolve(null), 126+ safe(apiAnalytics.indicatorRelated(ind.slug, { limit: 10 })),
127+ safe(apiAnalytics.indicatorQuality(ind.slug)),
128+ safe(apiAnalytics.indicatorDistribution(ind.slug, { highlight })),
129+ ind.ranking_eligible !== false ? safe(apiAnalytics.race(ind.slug, { top: 10 })) : Promise.resolve(null),
85 ]); 130 ]);
86 const countries = countriesRes?.items ?? []; 131 const countries = countriesRes?.items ?? [];
87 const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country'); 132 const onlyCountries = countries.filter((c) => (c.kind ?? 'country') === 'country');
133+ const byId = new Map(countries.map((c) => [c.id, c]));
88 134
89 // Default comparison: the 3 largest economies that have data for this indicator. 135 // Default comparison: the 3 largest economies that have data for this indicator.
136+ const lastIdx = frames ? frames.years.length - 1 : -1;
137+ const hasValue = (id: string) => (frames && lastIdx >= 0 ? typeof frames.values[id]?.[lastIdx] === 'number' : false);
90 const defaults: CompareCountry[] = [...onlyCountries] 138 const defaults: CompareCountry[] = [...onlyCountries]
91− .filter((c) => isNum(c.gdp_latest) && map && map.values[c.id] != null) 139+ .filter((c) => isNum(c.gdp_latest) && hasValue(c.id))
92 .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0)) 140 .sort((a, b) => (b.gdp_latest ?? 0) - (a.gdp_latest ?? 0))
93 .slice(0, 3) 141 .slice(0, 3)
94 .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag })); 142 .map((c) => ({ id: c.id, slug: c.slug ?? c.id, name: c.name ?? c.id, flag: c.flag }));
95 const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null; 143 const bundle = defaults.length ? await safe(apiExplore.seriesBundle(defaults.map((c) => c.id), [ind.slug])) : null;
96 const initialSeries: Series[] = bundle?.series ?? []; 144 const initialSeries: Series[] = bundle?.series ?? [];
97−
98− const years = data.coverage.by_year.map((y) => y.year);
99 const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' }; 145 const { features, sphere } = countries.length ? baseFeatures(countries) : { features: [], sphere: '' };
100 146
101 const wl = data.world_latest; 147 const wl = data.world_latest;
102 const worldPayload: ProvenancePayload = { 148 const worldPayload: ProvenancePayload = {
103 indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description }, 149 indicator: { slug: ind.slug, name, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, frequency: ind.frequency, higher_is_better: ind.higher_is_better, methodology: ind.methodology, description: ind.description },
104− value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: map?.provenance ?? data.top5[0]?.provenance ?? null } : null, 150+ value: wl ? { value: wl.value, formatted: wl.formatted, period: wl.year ? `${wl.year}-01-01` : null, year: wl.year, unit: ind.unit, provenance: frames?.provenance ?? data.top5[0]?.provenance ?? null } : null,
105 country: null, 151 country: null,
106 downloadHref: routes.indicatorDownload(ind.slug), 152 downloadHref: routes.indicatorDownload(ind.slug),
107 }; 153 };
@@ -110,28 +156,37 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
110 const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best'); 156 const topLabel = hib == null ? t('indicator.top.highest') : t('indicator.top.best');
111 const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst'); 157 const bottomLabel = hib == null ? t('indicator.top.lowest') : t('indicator.top.worst');
112 const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank)); 158 const toRows = (rows: RankedValue[]) => rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank));
159+ const relative = ['currency', 'number', 'tonnes', 'kwh'].includes(ind.format ?? '');
160+ const movers = decadeMovers(frames, byId, hib, relative);
161+ const deltaSpec: FormatSpec = relative ? { format: 'percent', precision: 0, unit: '%' } : { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision };
113 162
114− // Countries without data for the map year. 163+ const missing: CountrySummary[] = frames && lastIdx >= 0 ? onlyCountries.filter((c) => !hasValue(c.id)).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : [];
115− const missing: CountrySummary[] = map ? onlyCountries.filter((c) => map.values[c.id] == null).sort((a, b) => (b.population_latest ?? 0) - (a.population_latest ?? 0)) : [];
116− const relatedRows = (related?.items ?? []).filter((i) => i.slug !== ind.slug && (!ind.subtopic || i.subtopic === ind.subtopic)).slice(0, 8);
117 const topic = topicById(ind.topic ?? ''); 164 const topic = topicById(ind.topic ?? '');
118 const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`; 165 const apiSnippet = `curl -s "${SITE_URL}/api/v1/series?country=CAN&indicator=${ind.slug}" | jq '.series[0] | {country: .country.name, unit, last: .stats.last, source: .provenance.source_name}'`;
119−
120 const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A'); 166 const freqLabel = tOpt(`indicator.frequency.${ind.frequency ?? 'A'}`, ind.frequency ?? 'A');
121 const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null; 167 const worldKindLabel = wl ? tOpt(`indicator.world.${wl.kind}`, t('indicator.world', { kind: wl.kind })) : null;
168+ const ld = [
169+ jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, description: ind.description, unit: ind.unit, firstYear: data.years.first, lastYear: data.years.last_actual, nCountries: data.coverage.n_countries, sources: data.sources.map((s) => ({ name: s.source_name, url: s.url, licence: s.licence })), modified: data.meta.built_at }),
170+ jsonLd.breadcrumbs([{ name: t('indicators.title'), path: routes.indicators() }, ...(topic ? [{ name: topic.short, path: routes.indicators(topic.id) }] : []), { name: ind.short_name ?? name, path: routes.indicator(ind.slug) }]),
171+ ];
122 172
123 return ( 173 return (
124 <> 174 <>
175+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />
125 <PageHeader 176 <PageHeader
126 crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]} 177 crumbs={[{ href: routes.indicators(), label: t('indicators.title') }, ...(topic ? [{ href: routes.indicators(topic.id), label: topic.short }] : [])]}
127 eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')} 178 eyebrow={[topic?.name, ind.subtopic].filter(Boolean).join(' · ')}
128 title={name} 179 title={name}
129 lede={ind.description} 180 lede={ind.description}
130 meta={ 181 meta={
131− <> 182+ <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
132− {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })} 183+ <span>
133− {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''} 184+ {ind.unit} · {freqLabel} · {t('indicator.coverage', { n: grouped(data.coverage.n_countries ?? 0), total: grouped(data.coverage.n_countries_total), pct: formatPct(data.coverage.coverage_pct) })}
134− </> 185+ {data.years.first && data.years.last_actual ? ` · ${data.years.first}–${data.years.last_actual}` : ''}
186+ {data.sources[0]?.source_name ? ` · ${data.sources[0].source_name}` : ''}
187+ </span>
188+ <QualityBadges badges={quality?.badges ?? null} />
189+ </span>
135 } 190 }
136 actions={ 191 actions={
137 <> 192 <>
@@ -140,6 +195,9 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
140 {t('common.ranking')} 195 {t('common.ranking')}
141 </Link> 196 </Link>
142 ) : null} 197 ) : null}
198+ <Link href={routes.explore({ indicator: ind.slug })} className={ACTION_CLS}>
199+ {t('nav.explore')}
200+ </Link>
143 <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}> 201 <a href={routes.indicatorDownload(ind.slug)} className={ACTION_CLS}>
144 <Download size={14} aria-hidden /> {t('common.downloadCsv')} 202 <Download size={14} aria-hidden /> {t('common.downloadCsv')}
145 </a> 203 </a>
@@ -147,25 +205,27 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
147 } 205 }
148 /> 206 />
149 207
150− {/* World figure + freshness — three lines, per spec */} 208+ {/* World figure + freshness */}
151 <section aria-label={t('indicator.freshness')} className="border-y border-rule"> 209 <section aria-label={t('indicator.freshness')} className="border-y border-rule">
152 <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule"> 210 <dl className="grid grid-cols-2 gap-y-4 py-4 sm:grid-cols-4 lg:divide-x lg:divide-rule">
153 <div className="col-span-2 sm:col-span-1 lg:pr-4"> 211 <div className="col-span-2 sm:col-span-1 lg:pr-4">
154− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{worldKindLabel ?? t('indicator.world.median')}</dt> 212+ <dt className="eyebrow">{worldKindLabel ?? t('indicator.world.median')}</dt>
155 <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd> 213 <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink md:text-3xl">{formatValue(wl?.value, spec)}</dd>
156 {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null} 214 {wl?.year ? <dd className="tnum mt-1 text-xs text-ink-3">{t('indicator.world.n', { n: grouped(wl.n ?? 0), year: wl.year })}</dd> : null}
157 </div> 215 </div>
158 <div className="lg:px-4"> 216 <div className="lg:px-4">
159− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.latest')}</dt> 217+ <dt className="eyebrow">{t('indicator.freshness.latest')}</dt>
160 <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd> 218 <dd className="tnum mt-1 text-base font-semibold text-ink">{data.years.last_actual ?? t('common.na')}</dd>
161 {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null} 219 {data.years.last && data.years.last_actual && data.years.last > data.years.last_actual ? <dd className="text-2xs text-ink-3">{t('common.forecast')} → {data.years.last}</dd> : null}
220+ {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.years', { n: quality.years_with_50plus })}</dd> : null}
162 </div> 221 </div>
163 <div className="lg:px-4"> 222 <div className="lg:px-4">
164− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.source')}</dt> 223+ <dt className="eyebrow">{t('indicator.freshness.source')}</dt>
165 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd> 224 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.source_updated_at)}</dd>
225+ {quality ? <dd className="tnum text-2xs text-ink-3">{t('indicator.quality.flagged', { n: grouped(quality.flagged_values) })}</dd> : null}
166 </div> 226 </div>
167 <div className="lg:pl-4"> 227 <div className="lg:pl-4">
168− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.freshness.refreshed')}</dt> 228+ <dt className="eyebrow">{t('indicator.freshness.refreshed')}</dt>
169 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd> 229 <dd className="tnum mt-1 text-base font-semibold text-ink">{formatDate(data.freshness.retrieved_at ?? data.freshness.built_at)}</dd>
170 {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null} 230 {data.meta.run_id ? <dd className="text-2xs text-ink-3">{t('site.footer.build', { run: data.meta.run_id })}</dd> : null}
171 </div> 231 </div>
@@ -173,11 +233,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
173 </section> 233 </section>
174 234
175 <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0"> 235 <Section id="map" title={t('indicator.map.title')} subtitle={t('indicator.map.sub')} className="border-t-0">
176− {map && features.length ? ( 236+ {features.length ? <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} frames={frames} spec={spec} payload={worldPayload} initialYear={yearUsed} /> : <EmptyState title={t('common.noDataLong')} />}
177− <IndicatorMap slug={ind.slug} geometry={features} sphere={sphere} initial={map} years={years.length ? years : [map.year_used ?? 0]} spec={spec} payload={worldPayload} />
178− ) : (
179− <EmptyState title={t('common.noDataLong')} />
180− )}
181 </Section> 237 </Section>
182 238
183 <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}> 239 <Section id="trend" title={t('indicator.trend.title')} subtitle={t('indicator.trend.sub')}>
@@ -203,42 +259,78 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
203 )} 259 )}
204 </Section> 260 </Section>
205 261
262+ {movers ? (
263+ <Section id="movers" title={t('indicator.movers.title', { y0: movers.from, y1: movers.to })} subtitle={hib == null ? t('indicator.movers.subNeutral') : t('indicator.movers.sub')}>
264+ <div className="grid gap-x-10 gap-y-6 lg:grid-cols-2">
265+ <div>
266+ <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">
267+ <span aria-hidden className={hib == null ? 'text-inc' : 'text-up'}>↑</span> {hib == null ? t('indicator.movers.largestIncrease') : t('indicator.movers.fastestImproving')}
268+ </h3>
269+ <RankedBars rows={movers.up} spec={deltaSpec} showRank={false} />
270+ </div>
271+ <div>
272+ <h3 className="mb-2 flex items-center gap-1.5 text-sm font-semibold text-ink">
273+ <span aria-hidden className={hib == null ? 'text-dec' : 'text-down'}>↓</span> {hib == null ? t('indicator.movers.largestDecrease') : t('indicator.movers.fastestDeclining')}
274+ </h3>
275+ <RankedBars rows={movers.down} spec={deltaSpec} showRank={false} />
276+ </div>
277+ </div>
278+ <p className="mt-3 text-xs text-ink-3">{t('indicator.movers.note', { y0: movers.from, y1: movers.to, kind: relative ? t('indicator.movers.relative') : t('indicator.movers.absolute') })}</p>
279+ </Section>
280+ ) : null}
281+
282+ {distribution && distribution.n >= 10 ? (
283+ <Section id="distribution" title={t('indicator.dist.section')} subtitle={t('indicator.dist.sectionSub')}>
284+ <DistributionPanel data={distribution} spec={spec} />
285+ </Section>
286+ ) : null}
287+
288+ {race && race.frames.length >= 20 ? (
289+ <Section id="race" title={t('indicator.race.title')} subtitle={t('ranking.race.sub', { name: spec.name ?? name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>
290+ <RankRace data={race} spec={spec} top={10} initialYear={yearUsed} />
291+ </Section>
292+ ) : null}
293+
206 <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}> 294 <Section id="compare" title={t('indicator.compare.title')} subtitle={t('indicator.compare.sub')}>
207 <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} /> 295 <IndicatorCompare slug={ind.slug} spec={spec} initialCountries={defaults} initialSeries={initialSeries} payload={worldPayload} />
208 </Section> 296 </Section>
209 297
298+ <Section id="related" title={t('indicator.related.statTitle')} subtitle={t('indicator.related.statSub')} actions={<Link href={routes.scatter({ x: ind.slug })} className="text-accent hover:underline">{t('indicator.related.openScatter')} →</Link>}>
299+ {related ? <RelatedTable data={related} slug={ind.slug} /> : <p className="text-sm text-ink-3">{t('indicator.related.noneStat')}</p>}
300+ </Section>
301+
210 <Section id="definition" title={t('indicator.definition')} level={2}> 302 <Section id="definition" title={t('indicator.definition')} level={2}>
211 <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3"> 303 <dl className="grid gap-x-8 gap-y-3 text-sm sm:grid-cols-2 lg:grid-cols-3">
212 <div> 304 <div>
213− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.definition')}</dt> 305+ <dt className="eyebrow">{t('indicator.definition')}</dt>
214 <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd> 306 <dd className="mt-0.5 text-ink-2">{ind.description ?? t('common.na')}</dd>
215 </div> 307 </div>
216 {ind.methodology ? ( 308 {ind.methodology ? (
217 <div> 309 <div>
218− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.methodology')}</dt> 310+ <dt className="eyebrow">{t('indicator.methodology')}</dt>
219 <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd> 311 <dd className="mt-0.5 text-ink-2">{ind.methodology}</dd>
220 </div> 312 </div>
221 ) : null} 313 ) : null}
222 <div> 314 <div>
223− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.unit')}</dt> 315+ <dt className="eyebrow">{t('indicator.unit')}</dt>
224 <dd className="mt-0.5 text-ink"> 316 <dd className="mt-0.5 text-ink">
225 {ind.unit ?? t('common.na')} 317 {ind.unit ?? t('common.na')}
226 {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null} 318 {ind.unit_short && ind.unit_short !== ind.unit ? <span className="text-ink-3"> ({ind.unit_short})</span> : null}
227 </dd> 319 </dd>
228 </div> 320 </div>
229 <div> 321 <div>
230− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.frequency')}</dt> 322+ <dt className="eyebrow">{t('indicator.frequency')}</dt>
231 <dd className="mt-0.5 text-ink">{freqLabel}</dd> 323 <dd className="mt-0.5 text-ink">{freqLabel}</dd>
232 </div> 324 </div>
233 <div> 325 <div>
234− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.aggregation')}</dt> 326+ <dt className="eyebrow">{t('indicator.aggregation')}</dt>
235 <dd className="mt-0.5 text-ink"> 327 <dd className="mt-0.5 text-ink">
236 {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')} 328 {ind.aggregation ?? 'none'} · {t(`indicator.higherIsBetter.${hib == null ? 'null' : hib ? 'true' : 'false'}` as 'indicator.higherIsBetter.null')}
237 </dd> 329 </dd>
238 </div> 330 </div>
239 {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? ( 331 {ind.bounds && (ind.bounds[0] != null || ind.bounds[1] != null) ? (
240 <div> 332 <div>
241− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{t('indicator.bounds')}</dt> 333+ <dt className="eyebrow">{t('indicator.bounds')}</dt>
242 <dd className="tnum mt-0.5 text-ink"> 334 <dd className="tnum mt-0.5 text-ink">
243 {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'} 335 {ind.bounds[0] ?? '−∞'} – {ind.bounds[1] ?? '∞'}
244 </dd> 336 </dd>
@@ -260,7 +352,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
260 {s.source_name ?? s.source_id} 352 {s.source_name ?? s.source_id}
261 </Link> 353 </Link>
262 <span className="text-ink-3">{s.dataset}</span> 354 <span className="text-ink-3">{s.dataset}</span>
263− <code className="rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code> 355+ <code className="break-all rounded-xs bg-surface-2 px-1 font-mono text-xs text-ink-2">{s.series_code}</code>
264 {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null} 356 {s.last_status ? <span className="text-2xs uppercase tracking-wide text-ink-3">{s.last_status}</span> : null}
265 </div> 357 </div>
266 <div className="tnum mt-0.5 text-xs text-ink-3"> 358 <div className="tnum mt-0.5 text-xs text-ink-3">
@@ -285,7 +377,7 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
285 </Section> 377 </Section>
286 378
287 <div className="grid gap-x-10 lg:grid-cols-2"> 379 <div className="grid gap-x-10 lg:grid-cols-2">
288− <Section id="no-data" title={t('indicator.nodata.title')} subtitle={map && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: map.year_used ?? yearUsed }) : t('indicator.nodata.none', { year: map.year_used ?? yearUsed })) : undefined}> 380+ <Section id="no-data" title={t('indicator.nodata.title')} subtitle={frames && yearUsed ? (missing.length ? t('indicator.nodata.sub', { n: grouped(missing.length), total: grouped(onlyCountries.length), year: frames.years[lastIdx] ?? yearUsed }) : t('indicator.nodata.none', { year: frames.years[lastIdx] ?? yearUsed })) : undefined}>
289 {missing.length ? ( 381 {missing.length ? (
290 <> 382 <>
291 <ul className="flex flex-wrap gap-1.5 text-xs"> 383 <ul className="flex flex-wrap gap-1.5 text-xs">
@@ -313,6 +405,9 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
313 <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}> 405 <a href={routes.indicatorDownload(ind.slug, 'json')} className={ACTION_CLS}>
314 <Download size={14} aria-hidden /> {t('indicator.downloads.json')} 406 <Download size={14} aria-hidden /> {t('indicator.downloads.json')}
315 </a> 407 </a>
408+ <Link href={routes.download({ indicators: ind.slug })} className={ACTION_CLS}>
409+ {t('indicator.downloads.builder')}
410+ </Link>
316 </div> 411 </div>
317 <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3> 412 <h3 className="mt-5 text-sm font-semibold text-ink">{t('indicator.downloads.api')}</h3>
318 <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p> 413 <p className="mb-2 text-xs text-ink-3">{t('indicator.downloads.apiHint')}</p>
@@ -324,27 +419,6 @@ export default async function IndicatorPage({ params }: { params: Promise<Params
324 </p> 419 </p>
325 </Section> 420 </Section>
326 </div> 421 </div>
327−
328− <Section id="related" title={t('indicator.related.title')} subtitle={ind.subtopic ? t('indicator.related.sub', { subtopic: ind.subtopic }) : undefined}>
329− {relatedRows.length === 0 ? (
330− <p className="text-sm text-ink-3">{t('indicator.related.none')}</p>
331− ) : (
332− <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
333− {relatedRows.map((r) => (
334− <li key={r.slug} className="border-t border-rule">
335− <Link href={routes.indicator(r.slug)} className="group flex min-h-[56px] flex-col justify-center py-2">
336− <span className="text-sm text-ink group-hover:text-accent">{r.name}</span>
337− <span className="tnum text-xs text-ink-3">
338− {r.unit}
339− {r.n_countries != null ? ` · ${t('indicators.coverageShort', { n: grouped(r.n_countries) })}` : ''}
340− {r.last_year ? ` · → ${r.last_year}` : ''}
341− </span>
342− </Link>
343− </li>
344− ))}
345− </ul>
346− )}
347− </Section>
348 </> 422 </>
349 ); 423 );
350 } 424 }
added apps/web/src/app/peers/page.tsx +49 −0
@@ -0,0 +1,49 @@
1+import type { Metadata } from 'next';
2+import { Suspense } from 'react';
3+import { t } from '@/i18n';
4+import { isNotBuilt } from '@/lib/api';
5+import { apiAnalytics } from '@/lib/api-analytics';
6+import { routes } from '@/lib/site';
7+import type { PeersResponse } from '@/lib/types-analytics';
8+import { NotBuiltState } from '@/components/data/empty-state';
9+import { PageHeader } from '@/components/explore/page-header';
10+import { PeersView } from '@/components/peers/peers-view';
11+
12+export const revalidate = 900;
13+type SP = Record<string, string | string[] | undefined>;
14+
15+function slug(v: string | string[] | undefined): string | undefined {
16+ const s = (Array.isArray(v) ? v[0] : v) ?? '';
17+ return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s : undefined;
18+}
19+
20+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
21+ const sp = await searchParams;
22+ const custom = !!(slug(sp.x) || slug(sp.y) || sp.year || sp.method);
23+ return { title: t('peers.metaTitle'), description: t('peers.description'), alternates: { canonical: routes.peers() }, robots: custom ? { index: false, follow: true } : undefined };
24+}
25+
26+export default async function PeersPage({ searchParams }: { searchParams: Promise<SP> }) {
27+ const sp = await searchParams;
28+ const year = Number(Array.isArray(sp.year) ? sp.year[0] : sp.year);
29+ const method = (Array.isArray(sp.method) ? sp.method[0] : sp.method) === 'ols' ? 'ols' : 'theil-sen';
30+ let data: PeersResponse | null = null;
31+ try {
32+ data = await apiAnalytics.peers({ x: slug(sp.x), y: slug(sp.y), year: Number.isInteger(year) && year > 1800 ? year : null, method });
33+ } catch (e) {
34+ if (isNotBuilt(e)) return <NotBuiltState />;
35+ data = null;
36+ }
37+ return (
38+ <>
39+ <PageHeader title={t('peers.title')} lede={t('peers.lede')} />
40+ {!data ? (
41+ <p className="py-8 text-sm text-ink-3">{t('peers.unavailable')}</p>
42+ ) : (
43+ <Suspense fallback={null}>
44+ <PeersView data={data} />
45+ </Suspense>
46+ )}
47+ </>
48+ );
49+}
modified apps/web/src/app/rankings/[indicator]/page.tsx +33 −16
@@ -4,16 +4,19 @@ import Link from 'next/link';
4 import { notFound } from 'next/navigation'; 4 import { notFound } from 'next/navigation';
5 import { t } from '@/i18n'; 5 import { t } from '@/i18n';
6 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; 6 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
7+import { apiAnalytics } from '@/lib/api-analytics';
7 import { apiCompare } from '@/lib/api-compare'; 8 import { apiCompare } from '@/lib/api-compare';
8 import { grouped } from '@/lib/format'; 9 import { grouped } from '@/lib/format';
9 import { parseRankingState, type RankingState } from '@/lib/ranking-state'; 10 import { parseRankingState, type RankingState } from '@/lib/ranking-state';
11+import { jsonLd, jsonLdString, seoTitle } from '@/lib/seo';
10 import { routes } from '@/lib/site'; 12 import { routes } from '@/lib/site';
11 import { topicById } from '@/lib/topics'; 13 import { topicById } from '@/lib/topics';
12 import type { RankingResponse } from '@/lib/types'; 14 import type { RankingResponse } from '@/lib/types';
13 import { toCountryLite, type CountryLite } from '@/lib/types-compare'; 15 import { toCountryLite, type CountryLite } from '@/lib/types-compare';
14−import { Choropleth } from '@/components/charts/choropleth'; 16+import { RankRace } from '@/components/charts/rank-race';
15 import { NotBuiltState } from '@/components/data/empty-state'; 17 import { NotBuiltState } from '@/components/data/empty-state';
16 import { Section } from '@/components/data/section'; 18 import { Section } from '@/components/data/section';
19+import { baseFeatures } from '@/components/indicators/map-geometry';
17 import { RankHistoryPanel } from '@/components/rankings/rank-history-panel'; 20 import { RankHistoryPanel } from '@/components/rankings/rank-history-panel';
18 import { RankingView } from '@/components/rankings/ranking-view'; 21 import { RankingView } from '@/components/rankings/ranking-view';
19 22
@@ -21,6 +24,8 @@ export const revalidate = 900;
21 24
22 type Params = { indicator: string }; 25 type Params = { indicator: string };
23 type SP = Record<string, string | string[] | undefined>; 26 type SP = Record<string, string | string[] | undefined>;
27+/** Indicators with a rank race on their page (long histories, ≥ 30 ranked years). */
28+const RACE_SLUGS = new Set(['gdp', 'gdp-ppp', 'population', 'gdp-per-capita', 'gdp-per-capita-ppp', 'life-expectancy', 'co2-emissions', 'co2-per-capita', 'internet-users', 'exports-goods-services', 'urban-population-share', 'fertility-rate', 'median-age']);
24 29
25 async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> { 30 async function load(slug: string, state: RankingState): Promise<(RankingResponse & { label?: string }) | 'not-built' | null> {
26 if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null; 31 if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) return null;
@@ -41,7 +46,7 @@ export async function generateMetadata({ params, searchParams }: { params: Promi
41 const name = data.indicator.short_name ?? data.indicator.name ?? indicator; 46 const name = data.indicator.short_name ?? data.indicator.name ?? indicator;
42 const year = data.year_used ?? ''; 47 const year = data.year_used ?? '';
43 const world = state.group === 'world'; 48 const world = state.group === 'world';
44− const title = world ? t('ranking.pageTitle', { name, year }) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year }); 49+ const title = world ? seoTitle.ranking(name, year) : t('ranking.pageTitleGroup', { name, group: data.group.name ?? state.group, year });
45 const top = data.rows 50 const top = data.rows
46 .slice(0, 3) 51 .slice(0, 3)
47 .map((r) => r.country.name) 52 .map((r) => r.country.name)
@@ -49,12 +54,13 @@ export async function generateMetadata({ params, searchParams }: { params: Promi
49 .join(', '); 54 .join(', ');
50 const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top }); 55 const description = t('ranking.pageDescription', { name: data.indicator.name ?? name, n: grouped(data.n), year, top });
51 const canonical = routes.ranking(data.indicator.slug, { group: state.group }); 56 const canonical = routes.ranking(data.indicator.slug, { group: state.group });
57+ const volatile = state.year != null || state.highlight || state.q || state.income || state.minpop || state.mincov || state.view !== 'table';
52 return { 58 return {
53− title, 59+ title: { absolute: `${title} | ${t('site.name')}` },
54 description, 60 description,
55 alternates: { canonical }, 61 alternates: { canonical },
56− robots: state.year != null || state.highlight || state.q ? { index: false, follow: true } : undefined, 62+ robots: volatile ? { index: false, follow: true } : undefined,
57− openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, 63+ openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' },
58 twitter: { card: 'summary_large_image', title, description }, 64 twitter: { card: 'summary_large_image', title, description },
59 }; 65 };
60 } 66 }
@@ -71,19 +77,26 @@ export default async function RankingPage({ params, searchParams }: { params: Pr
71 const year = data.year_used; 77 const year = data.year_used;
72 const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null; 78 const highlightRow = state.highlight ? data.rows.find((r) => (r.country.slug ?? '') === state.highlight) : null;
73 const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5); 79 const historyIds = state.history.length ? state.history : Array.from(new Set([...data.rows.slice(0, 3).map((r) => r.country.id), ...(highlightRow ? [highlightRow.country.id] : [])])).slice(0, 5);
80+ const wantRace = RACE_SLUGS.has(ind.slug) || !!ind.featured;
74 81
75− const [regions, countriesRes, map, history] = await Promise.all([ 82+ const [regions, countriesRes, history, race] = await Promise.all([
76 safe(apiCompare.regions()), 83 safe(apiCompare.regions()),
77 safe(api.countries()), 84 safe(api.countries()),
78− year != null ? safe(apiCompare.indicatorMap(ind.slug, { year })) : Promise.resolve(null),
79 historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null), 85 historyIds.length ? safe(apiCompare.rankHistory(ind.slug, historyIds)) : Promise.resolve(null),
86+ wantRace ? safe(apiAnalytics.race(ind.slug, { top: 10, group: state.group !== 'world' ? state.group : null })) : Promise.resolve(null),
80 ]); 87 ]);
81− const countries: CountryLite[] = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate').map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name)); 88+ const all = (countriesRes?.items ?? []).filter((c) => c.kind !== 'aggregate');
89+ const countries: CountryLite[] = all.map(toCountryLite).sort((a, b) => a.name.localeCompare(b.name));
90+ const { features, sphere } = all.length ? baseFeatures(all) : { features: [], sphere: '' };
82 const topic = topicById(ind.topic ?? ''); 91 const topic = topicById(ind.topic ?? '');
83 const groupName = data.group.name ?? state.group; 92 const groupName = data.group.name ?? state.group;
93+ const spec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, name, higher_is_better: ind.higher_is_better };
94+ const showRace = race && race.frames.length >= 30;
95+ const ld = [jsonLd.breadcrumbs([{ name: t('rankings.title'), path: routes.rankings() }, { name, path: routes.ranking(ind.slug) }]), jsonLd.dataset({ slug: ind.slug, name: ind.name ?? name, unit: ind.unit, nCountries: data.n, modified: data.meta.built_at })];
84 96
85 return ( 97 return (
86 <> 98 <>
99+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLdString(ld) }} />
87 <header className="pb-3 pt-6 md:pt-10"> 100 <header className="pb-3 pt-6 md:pt-10">
88 <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0"> 101 <nav aria-label="Breadcrumb" className="-my-3 text-xs text-ink-3 md:my-0">
89 <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0"> 102 <Link href={routes.rankings()} className="inline-flex min-h-[44px] items-center hover:text-accent md:min-h-0">
@@ -108,6 +121,9 @@ export default async function RankingPage({ params, searchParams }: { params: Pr
108 {t('ranking.openIndicator')} 121 {t('ranking.openIndicator')}
109 <ExternalLink size={13} aria-hidden /> 122 <ExternalLink size={13} aria-hidden />
110 </Link> 123 </Link>
124+ <Link href={routes.explore({ indicator: ind.slug, year })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]">
125+ {t('home.map.openExplorer')}
126+ </Link>
111 <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]"> 127 <a href={routes.indicatorDownload(ind.slug)} download className="inline-flex min-h-[44px] items-center gap-1 text-ink-2 hover:text-accent hover:underline md:min-h-[32px]">
112 <Download size={13} aria-hidden /> 128 <Download size={13} aria-hidden />
113 {t('ranking.download')} 129 {t('ranking.download')}
@@ -120,16 +136,17 @@ export default async function RankingPage({ params, searchParams }: { params: Pr
120 </div> 136 </div>
121 </header> 137 </header>
122 138
123− <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} /> 139+ <RankingView data={data} regions={regions?.items ?? []} countries={countries} state={state} geometry={features} sphere={sphere} />
124 140
125− <div className="grid gap-x-10 lg:grid-cols-2"> 141+ {showRace ? (
126− <Section id="map" title={t('ranking.map.title')} subtitle={t('ranking.map.sub', { name: ind.short_name ?? ind.name, year: map?.year_used ?? year ?? '' })} level={3}> 142+ <Section id="race" title={t('ranking.race.title')} subtitle={t('ranking.race.sub', { name, y0: race.years[0] ?? '', y1: race.years[race.years.length - 1] ?? '', top: race.top })}>
127− {map && countriesRes ? <Choropleth map={map} countries={countriesRes.items} compact /> : <p className="py-6 text-sm text-ink-3">{t('ranking.map.none')}</p>} 143+ <RankRace data={race} spec={spec} top={10} initialYear={year} />
128− </Section>
129− <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={3}>
130− <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} />
131 </Section> 144 </Section>
132− </div> 145+ ) : null}
146+
147+ <Section id="history" title={t('ranking.history.title')} subtitle={t('ranking.history.sub', { max: 5 })} level={showRace ? 3 : 2}>
148+ <RankHistoryPanel slug={ind.slug} countries={countries} initialIds={historyIds} initial={history} />
149+ </Section>
133 </> 150 </>
134 ); 151 );
135 } 152 }
modified apps/web/src/app/regions/[slug]/page.tsx +37 −4
@@ -3,14 +3,18 @@ import Link from 'next/link';
3 import { notFound } from 'next/navigation'; 3 import { notFound } from 'next/navigation';
4 import { t, tOpt } from '@/i18n'; 4 import { t, tOpt } from '@/i18n';
5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api'; 5 import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6+import { apiAnalytics } from '@/lib/api-analytics';
6 import { apiExplore } from '@/lib/api-explore'; 7 import { apiExplore } from '@/lib/api-explore';
7−import { formatValue, grouped } from '@/lib/format'; 8+import { fixed, formatValue, grouped } from '@/lib/format';
9+import { seoTitle } from '@/lib/seo';
8 import { routes } from '@/lib/site'; 10 import { routes } from '@/lib/site';
9 import type { IndicatorCard } from '@/lib/types'; 11 import type { IndicatorCard } from '@/lib/types';
10 import type { RegionResponse } from '@/lib/types-explore'; 12 import type { RegionResponse } from '@/lib/types-explore';
11 import { NotBuiltState } from '@/components/data/empty-state'; 13 import { NotBuiltState } from '@/components/data/empty-state';
12 import { Section } from '@/components/data/section'; 14 import { Section } from '@/components/data/section';
13 import { ACTION_CLS, PageHeader } from '@/components/explore/page-header'; 15 import { ACTION_CLS, PageHeader } from '@/components/explore/page-header';
16+import { GroupComparePicker } from '@/components/regions/group-compare-picker';
17+import { GroupHistory } from '@/components/regions/group-history';
14 import { MemberMap } from '@/components/regions/member-map'; 18 import { MemberMap } from '@/components/regions/member-map';
15 import { MemberRanking } from '@/components/regions/member-ranking'; 19 import { MemberRanking } from '@/components/regions/member-ranking';
16 import { MembersTable } from '@/components/regions/members-table'; 20 import { MembersTable } from '@/components/regions/members-table';
@@ -34,10 +38,10 @@ export async function generateMetadata({ params }: { params: Promise<Params> }):
34 const data = await load(slug); 38 const data = await load(slug);
35 if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } }; 39 if (!data || data === 'not-built') return { title: t('region.notFound'), robots: { index: false } };
36 const name = data.group.name ?? slug; 40 const name = data.group.name ?? slug;
37− const title = t('region.title', { name }); 41+ const title = seoTitle.region(name);
38 const description = t('region.description', { name, n: data.n_members }); 42 const description = t('region.description', { name, n: data.n_members });
39 const canonical = routes.region(data.group.slug ?? slug); 43 const canonical = routes.region(data.group.slug ?? slug);
40− return { title, description, alternates: { canonical }, openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } }; 44+ return { title: { absolute: `${title} | ${t('site.name')}` }, description, alternates: { canonical }, openGraph: { title: `${title} | ${t('site.name')}`, description, url: canonical, type: 'article' }, twitter: { card: 'summary_large_image', title, description } };
41 } 45 }
42 46
43 /** Extra indicators offered in the member ranking picker besides the headline ones. */ 47 /** Extra indicators offered in the member ranking picker besides the headline ones. */
@@ -53,7 +57,10 @@ export default async function RegionPage({ params }: { params: Promise<Params> }
53 const name = g.name ?? slug; 57 const name = g.name ?? slug;
54 const gslug = g.slug ?? g.id; 58 const gslug = g.slug ?? g.id;
55 const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? ''); 59 const kindLabel = tOpt(`regions.kindLabel.${g.kind ?? 'custom'}`, g.kind ?? '');
56− const [countriesRes, extraInd] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true }))]); 60+ const isWorld = g.id === 'world';
61+ const [countriesRes, extraInd, vsWorld, regions] = await Promise.all([safe(api.countries()), safe(apiExplore.indicators({ featured: true })), isWorld ? Promise.resolve(null) : safe(apiAnalytics.regionsCompare(gslug, 'world')), safe(apiExplore.regions())]);
62+ const shares = vsWorld?.shares[g.id] ?? vsWorld?.shares[gslug] ?? null;
63+ const otherDefault = gslug === 'g7' ? 'brics' : 'g7';
57 const countries = countriesRes?.items ?? []; 64 const countries = countriesRes?.items ?? [];
58 const memberIds = data.members.map((m) => m.id); 65 const memberIds = data.members.map((m) => m.id);
59 const aggs = Object.values(data.aggregates); 66 const aggs = Object.values(data.aggregates);
@@ -80,6 +87,22 @@ export default async function RegionPage({ params }: { params: Promise<Params> }
80 /> 87 />
81 88
82 <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0"> 89 <Section id="aggregates" title={t('region.aggregates.title')} subtitle={t('region.aggregates.sub')} className="border-t-0">
90+ {shares ? (
91+ <dl className="mb-4 grid gap-x-6 min-[361px]:grid-cols-2 md:grid-cols-4">
92+ {(['gdp_share_pct', 'population_share_pct'] as const).map((k) => {
93+ const v = shares[k];
94+ return (
95+ <div key={k} className="border-t border-rule py-3">
96+ <dt className="eyebrow">{k === 'gdp_share_pct' ? t('regions.compare.gdpShare') : t('regions.compare.popShare')}</dt>
97+ <dd className="pnum mt-1 text-2xl font-semibold leading-none text-ink">{v != null ? `${fixed(v, 1)} %` : t('common.na')}</dd>
98+ <dd className="mt-1.5 h-1.5 w-full max-w-[12rem] overflow-hidden rounded-xs bg-surface-2" aria-hidden>
99+ <span className="block h-full bg-accent" style={{ width: `${Math.min(100, v ?? 0)}%` }} />
100+ </dd>
101+ </div>
102+ );
103+ })}
104+ </dl>
105+ ) : null}
83 {aggs.length === 0 ? ( 106 {aggs.length === 0 ? (
84 <p className="text-sm text-ink-3">{t('common.noDataLong')}</p> 107 <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>
85 ) : ( 108 ) : (
@@ -109,6 +132,16 @@ export default async function RegionPage({ params }: { params: Promise<Params> }
109 </Section> 132 </Section>
110 </div> 133 </div>
111 134
135+ {vsWorld && Object.keys(vsWorld.history).length ? (
136+ <Section id="history" title={t('region.history.title')} subtitle={t('region.history.sub', { name })}>
137+ <GroupHistory data={vsWorld} ids={[g.id]} names={{ [g.id]: name }} />
138+ </Section>
139+ ) : null}
140+
141+ <Section id="compare-group" title={t('regions.compare.withOther')} subtitle={t('regions.compare.withOtherSub')} actions={<Link href={routes.regionCompare(gslug, otherDefault)} className="text-accent hover:underline">{t('regions.compare.open')} →</Link>}>
142+ <GroupComparePicker groups={regions?.items ?? []} a={gslug} b={otherDefault} fixedA />
143+ </Section>
144+
112 <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}> 145 <Section id="members" title={t('region.members.title')} subtitle={t('region.members.sub', { n: grouped(data.n_members) })}>
113 {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>} 146 {data.members.length ? <MembersTable members={data.members} indicators={data.headline_indicators} /> : <p className="text-sm text-ink-3">{t('common.noDataLong')}</p>}
114 </Section> 147 </Section>
added apps/web/src/app/regions/compare/page.tsx +138 −0
@@ -0,0 +1,138 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { t, tOpt } from '@/i18n';
4+import { isNotBuilt, safe } from '@/lib/api';
5+import { apiAnalytics } from '@/lib/api-analytics';
6+import { apiExplore } from '@/lib/api-explore';
7+import { fixed, formatValue, grouped } from '@/lib/format';
8+import { routes } from '@/lib/site';
9+import type { RegionCompareResponse } from '@/lib/types-analytics';
10+import { NotBuiltState } from '@/components/data/empty-state';
11+import { Section } from '@/components/data/section';
12+import { PageHeader } from '@/components/explore/page-header';
13+import { GroupComparePicker } from '@/components/regions/group-compare-picker';
14+import { GroupHistory } from '@/components/regions/group-history';
15+
16+export const revalidate = 900;
17+type SP = Record<string, string | string[] | undefined>;
18+
19+function slugOf(v: string | string[] | undefined, fallback: string): string {
20+ const s = (Array.isArray(v) ? v[0] : v) ?? '';
21+ return /^[a-z0-9][a-z0-9-]*$/.test(s) ? s : fallback;
22+}
23+
24+export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> {
25+ const sp = await searchParams;
26+ const a = slugOf(sp.a, 'g7');
27+ const b = slugOf(sp.b, 'brics');
28+ const data = await safe(apiAnalytics.regionsCompare(a, b));
29+ const names = data ? data.groups.map((g) => g.name ?? g.id) : [a, b];
30+ const title = t('regions.compare.title', { a: names[0] ?? a, b: names[1] ?? b });
31+ const canonical = routes.regionCompare(a, b);
32+ return { title, description: t('regions.compare.description', { a: names[0] ?? a, b: names[1] ?? b }), alternates: { canonical }, robots: a === 'g7' && b === 'brics' ? undefined : { index: false, follow: true } };
33+}
34+
35+export default async function RegionsComparePage({ searchParams }: { searchParams: Promise<SP> }) {
36+ const sp = await searchParams;
37+ const a = slugOf(sp.a, 'g7');
38+ const b = slugOf(sp.b, 'brics');
39+ let data: RegionCompareResponse | null = null;
40+ try {
41+ data = await apiAnalytics.regionsCompare(a, b);
42+ } catch (e) {
43+ if (isNotBuilt(e)) return <NotBuiltState />;
44+ data = null;
45+ }
46+ const regions = await safe(apiExplore.regions());
47+ const groups = regions?.items ?? [];
48+ const names: Record<string, string> = data ? Object.fromEntries(data.groups.map((g) => [g.id, g.name ?? g.id])) : {};
49+ const ids = data ? data.groups.map((g) => g.id) : [];
50+
51+ return (
52+ <>
53+ <PageHeader crumbs={[{ href: routes.regions(), label: t('regions.title') }]} title={data ? t('regions.compare.heading', { a: names[ids[0] ?? ''] ?? a, b: names[ids[1] ?? ''] ?? b }) : t('regions.compare.pageTitle')} lede={t('regions.compare.lede')} />
54+ <div className="pb-4">
55+ <GroupComparePicker groups={groups} a={a} b={b} />
56+ </div>
57+ {!data ? (
58+ <p className="py-8 text-sm text-ink-3">{t('regions.compare.unavailable')}</p>
59+ ) : (
60+ <>
61+ <Section id="shares" title={t('regions.compare.shares')} subtitle={t('regions.compare.sharesSub')} className="border-t-0">
62+ <div className="grid gap-x-10 gap-y-4 sm:grid-cols-2">
63+ {(['population_share_pct', 'gdp_share_pct'] as const).map((k) => (
64+ <div key={k}>
65+ <div className="eyebrow mb-2">{k === 'gdp_share_pct' ? t('regions.compare.gdpShare') : t('regions.compare.popShare')}</div>
66+ <ol className="space-y-2">
67+ {data.groups.map((g, i) => {
68+ const v = data.shares[g.id]?.[k] ?? null;
69+ return (
70+ <li key={g.id} className="grid grid-cols-[minmax(0,9rem)_minmax(0,1fr)_4rem] items-center gap-x-3 text-sm">
71+ <Link href={routes.region(g.slug ?? g.id)} className="link-quiet truncate font-medium text-ink">
72+ {g.name}
73+ </Link>
74+ <span className="h-3 overflow-hidden rounded-xs bg-surface-2" aria-hidden>
75+ <span className="block h-full" style={{ width: `${Math.min(100, v ?? 0)}%`, background: `var(--series-${i + 1})` }} />
76+ </span>
77+ <span className="tnum text-right text-ink">{v != null ? `${fixed(v, 1)} %` : t('common.na')}</span>
78+ </li>
79+ );
80+ })}
81+ </ol>
82+ </div>
83+ ))}
84+ </div>
85+ </Section>
86+
87+ <Section id="table" title={t('regions.compare.table')} subtitle={t('regions.compare.tableSub')}>
88+ <table className="w-full border-collapse text-sm">
89+ <caption className="sr-only">{t('regions.compare.table')}</caption>
90+ <thead>
91+ <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3">
92+ <th scope="col" className="py-2 pr-3 font-medium">
93+ {t('common.indicator')}
94+ </th>
95+ {data.groups.map((g, i) => (
96+ <th key={g.id} scope="col" className="py-2 pr-3 text-right font-medium">
97+ <span className="inline-flex items-center gap-1.5">
98+ <span aria-hidden className="inline-block h-2 w-2 rounded-full" style={{ background: `var(--series-${i + 1})` }} />
99+ {g.name}
100+ </span>
101+ </th>
102+ ))}
103+ </tr>
104+ </thead>
105+ <tbody className="divide-y divide-rule">
106+ {data.rows.map((r) => (
107+ <tr key={r.indicator.slug}>
108+ <th scope="row" className="py-2 pr-3 text-left font-normal">
109+ <Link href={routes.indicator(r.indicator.slug)} className="link-quiet block text-ink">
110+ {r.indicator.short_name ?? r.indicator.name}
111+ </Link>
112+ <span className="block text-2xs text-ink-3">
113+ {tOpt(`regions.${r.kind}`, r.kind)} · {r.indicator.unit}
114+ </span>
115+ </th>
116+ {data.groups.map((g) => {
117+ const v = r.values[g.id];
118+ return (
119+ <td key={g.id} className="tnum py-2 pr-3 text-right align-top">
120+ <span className="block text-base font-medium text-ink">{v ? formatValue(v.value, r.indicator) : t('common.na')}</span>
121+ {v ? <span className="block text-2xs text-ink-3">{t('region.aggregates.n', { n: grouped(v.n ?? 0), year: v.year ?? '' })}</span> : null}
122+ </td>
123+ );
124+ })}
125+ </tr>
126+ ))}
127+ </tbody>
128+ </table>
129+ </Section>
130+
131+ <Section id="history" title={t('regions.compare.history')} subtitle={t('regions.compare.historySub')}>
132+ <GroupHistory data={data} ids={ids} names={names} />
133+ </Section>
134+ </>
135+ )}
136+ </>
137+ );
138+}
modified apps/web/src/components/changes/changes-feed.tsx +4 −1
@@ -1,5 +1,5 @@
1 'use client'; 1 'use client';
2−import { AlertTriangle, ArrowDownRight, ArrowUpRight, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, X } from 'lucide-react'; 2+import { AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, SlidersHorizontal, TrendingDown, TrendingUp, Trophy, Waves, X } from 'lucide-react';
3 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { useEffect, useMemo, useRef, useState } from 'react'; 4 import { useEffect, useMemo, useRef, useState } from 'react';
5 import { t } from '@/i18n'; 5 import { t } from '@/i18n';
@@ -25,6 +25,9 @@ const ICON: Record<string, typeof ArrowUpRight> = {
25 sign_flip: Repeat, 25 sign_flip: Repeat,
26 accelerating: TrendingUp, 26 accelerating: TrendingUp,
27 decelerating: TrendingDown, 27 decelerating: TrendingDown,
28+ structural_break: GitCommitHorizontal,
29+ trend_reversal: Repeat,
30+ volatility_spike: Waves,
28 }; 31 };
29 32
30 type Sev = 0 | 0.4 | 0.7; 33 type Sev = 0 | 0.4 | 0.7;
modified apps/web/src/components/charts/choropleth-view.tsx +102 −34
@@ -1,6 +1,6 @@
1 'use client'; 1 'use client';
2 import { useRouter } from 'next/navigation'; 2 import { useRouter } from 'next/navigation';
3−import { useCallback, useId, useState } from 'react'; 3+import { useCallback, useId, useState, type ReactNode } from 'react';
4 import { t } from '@/i18n'; 4 import { t } from '@/i18n';
5 import { cn } from '@/lib/cn'; 5 import { cn } from '@/lib/cn';
6 import { formatValue } from '@/lib/format'; 6 import { formatValue } from '@/lib/format';
@@ -20,18 +20,72 @@ export interface ChoroplethFeature {
20 } 20 }
21 21
22 /** Map a class index (0..k-1) onto the 7-step sequential ramp. */ 22 /** Map a class index (0..k-1) onto the 7-step sequential ramp. */
23−function stepFor(cls: number, k: number): number { 23+export function stepFor(cls: number, k: number): number {
24 if (k <= 1) return 4; 24 if (k <= 1) return 4;
25 const start = k >= 6 ? 1 : 2; 25 const start = k >= 6 ? 1 : 2;
26 const end = 7; 26 const end = 7;
27 return Math.round(start + (cls / (k - 1)) * (end - start)); 27 return Math.round(start + (cls / (k - 1)) * (end - start));
28 } 28 }
29 29
30+/** Legend items (class → label) from API quantile breaks; shared by every choropleth wrapper. */
31+export function legendFromBreaks(breaks: number[], min: number | null, max: number | null, spec: Spec): Array<{ cls: number; label: string }> {
32+ const k = breaks.length + 1;
33+ return Array.from({ length: k }, (_, i) => {
34+ const lo = i === 0 ? min : breaks[i - 1]!;
35+ const hi = i === k - 1 ? max : breaks[i]!;
36+ return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };
37+ });
38+}
39+
40+/** Class index 0..k for a value against sorted quantile breaks. */
41+export function classFor(value: number, breaks: number[]): number {
42+ let i = 0;
43+ while (i < breaks.length && value >= breaks[i]!) i++;
44+ return i;
45+}
46+
30 /** 47 /**
31 * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and 48 * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and
32− * shows the label with an "Open" link, a click with a mouse navigates. Explicit hatched fill for no data. 49+ * shows the label with an "Open" link, a click with a mouse navigates (or calls `onSelect` when given).
50+ * Explicit hatched fill for no data. `selectedId` outlines one country; `renderLabel` customises the tooltip.
33 */ 51 */
34−export function ChoroplethView({ features, sphere, legend, k, spec, summary, title, height, compact, className }: { features: ChoroplethFeature[]; sphere: string; legend: Array<{ cls: number; label: string }>; k: number; spec: Spec; summary: string; title: string; height?: number; compact?: boolean; className?: string }) { 52+export function ChoroplethView({
53+ features,
54+ sphere,
55+ legend,
56+ k,
57+ spec,
58+ summary,
59+ title,
60+ height,
61+ compact,
62+ className,
63+ onSelect,
64+ selectedId,
65+ renderLabel,
66+ showLegend = true,
67+ showHint = true,
68+ fillFor,
69+}: {
70+ features: ChoroplethFeature[];
71+ sphere: string;
72+ legend: Array<{ cls: number; label: string }>;
73+ k: number;
74+ spec: Spec;
75+ summary: string;
76+ title: string;
77+ height?: number;
78+ compact?: boolean;
79+ className?: string;
80+ /** When given, clicking / "Open" calls this instead of navigating to the country page. */
81+ onSelect?: (f: ChoroplethFeature) => void;
82+ selectedId?: string | null;
83+ renderLabel?: (f: ChoroplethFeature) => ReactNode;
84+ showLegend?: boolean;
85+ showHint?: boolean;
86+ /** Override the class → colour mapping (e.g. a diverging ramp). */
87+ fillFor?: (f: ChoroplethFeature) => string;
88+}) {
35 const router = useRouter(); 89 const router = useRouter();
36 const id = useId(); 90 const id = useId();
37 const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null); 91 const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null);
@@ -43,8 +97,15 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit
43 }, []); 97 }, []);
44 98
45 const open = (f: ChoroplethFeature) => { 99 const open = (f: ChoroplethFeature) => {
100+ if (onSelect) {
101+ onSelect(f);
102+ setActive(null);
103+ return;
104+ }
46 if (f.slug) router.push(routes.country(f.slug)); 105 if (f.slug) router.push(routes.country(f.slug));
47 }; 106 };
107+ const fill = (f: ChoroplethFeature) => (fillFor ? fillFor(f) : f.cls != null ? seqVar(stepFor(f.cls, k)) : `url(#${id}-hatch)`);
108+ const selected = selectedId ? features.find((f) => f.iso3 === selectedId) ?? null : null;
48 109
49 return ( 110 return (
50 <figure className={cn('min-w-0', className)}> 111 <figure className={cn('min-w-0', className)}>
@@ -58,20 +119,20 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit
58 <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> 119 <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" />
59 </pattern> 120 </pattern>
60 </defs> 121 </defs>
61− <path d={sphere} fill="var(--surface)" stroke="var(--rule)" strokeWidth={1} /> 122+ <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} />
62− <g stroke="var(--surface)" strokeWidth={0.6} strokeLinejoin="round"> 123+ <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round">
63 {features.map((f, i) => { 124 {features.map((f, i) => {
64− const hasData = f.cls != null;
65 const isActive = active?.f === f; 125 const isActive = active?.f === f;
126+ const interactive = !!f.slug || (!!onSelect && !!f.iso3);
66 return ( 127 return (
67 <path 128 <path
68 key={f.iso3 ?? `${f.name}-${i}`} 129 key={f.iso3 ?? `${f.name}-${i}`}
69 d={f.d} 130 d={f.d}
70− fill={hasData ? seqVar(stepFor(f.cls!, k)) : `url(#${id}-hatch)`} 131+ fill={fill(f)}
71− className={cn(f.slug && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} 132+ className={cn(interactive && 'cursor-pointer', 'transition-[fill-opacity] duration-100')}
72 fillOpacity={isActive ? 0.75 : 1} 133 fillOpacity={isActive ? 0.75 : 1}
73− tabIndex={f.slug ? 0 : -1} 134+ tabIndex={interactive ? 0 : -1}
74− role={f.slug ? 'link' : undefined} 135+ role={interactive ? (onSelect ? 'button' : 'link') : undefined}
75 aria-label={`${f.name}: ${formatValue(f.value, spec)}`} 136 aria-label={`${f.name}: ${formatValue(f.value, spec)}`}
76 onPointerMove={(e) => { 137 onPointerMove={(e) => {
77 if (e.pointerType === 'mouse') place(e, f, false); 138 if (e.pointerType === 'mouse') place(e, f, false);
@@ -83,55 +144,62 @@ export function ChoroplethView({ features, sphere, legend, k, spec, summary, tit
83 } 144 }
84 }} 145 }}
85 onClick={(e) => { 146 onClick={(e) => {
86− // Mouse: navigate. Touch: the label carries the link (first tap selects). 147+ // Mouse: open. Touch: the label carries the action (first tap selects).
87 if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); 148 if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f);
88 }} 149 }}
89 onKeyDown={(e) => { 150 onKeyDown={(e) => {
90− if (e.key === 'Enter' && f.slug) open(f); 151+ if (e.key === 'Enter' && interactive) open(f);
91 }} 152 }}
92 onFocus={(e) => { 153 onFocus={(e) => {
93 const b = e.currentTarget.getBBox(); 154 const b = e.currentTarget.getBBox();
94− setActive({ f, x: (b.x + b.width / 2) / MAP_WIDTH * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false }); 155+ setActive({ f, x: ((b.x + b.width / 2) / MAP_WIDTH) * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false });
95 }} 156 }}
96 > 157 >
97 <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title> 158 <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title>
98 </path> 159 </path>
99 ); 160 );
100 })} 161 })}
162+ {selected ? <path d={selected.d} fill="none" stroke="var(--ink)" strokeWidth={1.6} pointerEvents="none" /> : null}
101 </g> 163 </g>
102 </svg> 164 </svg>
103 {active ? ( 165 {active ? (
104− <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.min(active.x + 10, Math.max(0, (typeof window !== 'undefined' ? 0 : 0) + active.x + 10)), top: active.y - 44, maxWidth: 220 }}> 166+ <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.max(0, Math.min(active.x + 10, (typeof window !== 'undefined' ? window.innerWidth : 9999) - 240)), top: Math.max(0, active.y - 44), maxWidth: 230 }}>
105 <div className="flex items-center gap-1.5 font-medium text-ink"> 167 <div className="flex items-center gap-1.5 font-medium text-ink">
106 {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null} 168 {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null}
107 <span className="truncate">{active.f.name}</span> 169 <span className="truncate">{active.f.name}</span>
108 </div> 170 </div>
109− <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div> 171+ {renderLabel ? renderLabel(active.f) : <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div>}
110− {active.sticky && active.f.slug ? ( 172+ {active.sticky && (active.f.slug || onSelect) ? (
111− <button type="button" className="pointer-events-auto mt-1 text-accent underline" onClick={() => open(active.f)}> 173+ <button type="button" className="pointer-events-auto mt-1 min-h-[32px] text-accent underline" onClick={() => open(active.f)}>
112 {t('metric.open', { name: active.f.name })} → 174 {t('metric.open', { name: active.f.name })} →
113 </button> 175 </button>
114 ) : null} 176 ) : null}
115 </div> 177 </div>
116 ) : null} 178 ) : null}
117 </div> 179 </div>
118− <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> 180+ {showLegend ? (
119− {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} 181+ <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2">
120− <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> 182+ {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null}
121− {legend.map((l) => ( 183+ <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
122− <li key={l.cls} className="inline-flex items-center gap-1 tnum"> 184+ {legend.map((l) => (
123− <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} /> 185+ <li key={l.cls} className="inline-flex items-center gap-1 tnum">
124− {l.label} 186+ <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} />
187+ {l.label}
188+ </li>
189+ ))}
190+ <li className="inline-flex items-center gap-1">
191+ <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />
192+ {t('chart.legend.noData')}
125 </li> 193 </li>
126− ))} 194+ </ul>
127− <li className="inline-flex items-center gap-1"> 195+ {showHint ? (
128− <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> 196+ <>
129− {t('chart.legend.noData')} 197+ <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span>
130− </li> 198+ <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span>
131− </ul> 199+ </>
132− <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> 200+ ) : null}
133− <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> 201+ </figcaption>
134− </figcaption> 202+ ) : null}
135 </figure> 203 </figure>
136 ); 204 );
137 } 205 }
modified apps/web/src/components/charts/dna-radial.tsx +25 −11
@@ -7,12 +7,12 @@ import { CHART } from './palette';
7 export const DNA_DIMS: DnaDimension[] = ['income', 'demographics', 'urbanization', 'trade', 'energy', 'emissions', 'innovation', 'education', 'public_spending']; 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 10+ * Radial "fingerprint": 9 dimensions, 0–100 percentile rank, drawn as a filled polygon on a 100-radius ring
11− * ring with 25/50/75 guide rings and labelled spokes. Server-rendered SVG in a square viewBox; a missing 11+ * with 25/50/75 guide rings and labelled spokes. Optional dashed `reference` polygon (world = 50, a region's
12− * dimension collapses to the centre and is marked in the legend. 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 }) { 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 })); 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 const present = dims.filter((d) => isNum(d.value)); 16 const present = dims.filter((d) => isNum(d.value));
17 if (!dna || present.length === 0) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>; 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 const angle = (i: number) => -Math.PI / 2 + (i / dims.length) * 2 * Math.PI; 24 const angle = (i: number) => -Math.PI / 2 + (i / dims.length) * 2 * Math.PI;
25 const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))]; 25 const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))];
26 const poly = dims.map((d, i) => pt(i, isNum(d.value) ? (d.value / 100) * R : 0)); 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 const summary = t('chart.summary.dna', { name, dims: present.map((d) => `${d.label} ${Math.round(d.value as number)}`).join(', ') }); 28 const summary = t('chart.summary.dna', { name, dims: present.map((d) => `${d.label} ${Math.round(d.value as number)}`).join(', ') });
28 29
29 return ( 30 return (
@@ -38,6 +39,7 @@ export function DnaRadial({ dna, name, size = 320, className, compact = false }:
38 const [x, y] = pt(i, R); 39 const [x, y] = pt(i, R);
39 return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke={CHART.rule} strokeWidth={0.75} />; 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 <polygon points={poly.map((p) => p.join(',')).join(' ')} fill={CHART.accent} fillOpacity={0.16} stroke={CHART.accent} strokeWidth={2} strokeLinejoin="round" /> 43 <polygon points={poly.map((p) => p.join(',')).join(' ')} fill={CHART.accent} fillOpacity={0.16} stroke={CHART.accent} strokeWidth={2} strokeLinejoin="round" />
42 {poly.map(([x, y], i) => ( 44 {poly.map(([x, y], i) => (
43 <circle key={i} cx={x} cy={y} r={isNum(dims[i]!.value) ? 4 : 0} fill={CHART.accent} stroke={CHART.surface} strokeWidth={2} /> 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 <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 }}> 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 {d.label} 54 {d.label}
53 {isNum(d.value) ? ` ${Math.round(d.value)}` : ' —'} 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 </text> 57 </text>
55 ); 58 );
56 }) 59 })
57 : null} 60 : null}
58 </svg> 61 </svg>
59− {compact ? ( 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')}>
60− <figcaption className="mt-2 grid grid-cols-3 gap-x-3 gap-y-1 text-2xs text-ink-2"> 63+ {compact ? (
61− {dims.map((d) => ( 64+ dims.map((d) => (
62 <span key={d.key} className="flex justify-between gap-1"> 65 <span key={d.key} className="flex justify-between gap-1">
63 <span className="truncate">{d.label}</span> 66 <span className="truncate">{d.label}</span>
64 <span className="tnum text-ink">{isNum(d.value) ? Math.round(d.value) : '—'}</span> 67 <span className="tnum text-ink">{isNum(d.value) ? Math.round(d.value) : '—'}</span>
65 </span> 68 </span>
66− ))} 69+ ))
67− </figcaption> 70+ ) : (
68− ) : null} 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 </figure> 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 'use client'; 1 'use client';
2−import { Info, X } from 'lucide-react'; 2+import { Download, Info, X } from 'lucide-react';
3 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { usePathname, useRouter } from 'next/navigation'; 4 import { usePathname, useRouter } from 'next/navigation';
5 import { useEffect, useMemo, useRef, useState } from 'react'; 5 import { useEffect, useMemo, useRef, useState } from 'react';
6+import { downloadSvgAsPng } from '@/components/charts/export-png';
7+import { apiModeOf } from '@/lib/types-compare';
6 import { t } from '@/i18n'; 8 import { t } from '@/i18n';
7 import { clientCompare } from '@/lib/client-api-compare'; 9 import { clientCompare } from '@/lib/client-api-compare';
8 import { cn } from '@/lib/cn'; 10 import { cn } from '@/lib/cn';
@@ -25,6 +27,8 @@ const RANGE_PRESETS = [10, 20, 30] as const;
25 export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec { 27 export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec {
26 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 }; 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 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 }; 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 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 }; 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 const ctrl = new AbortController(); 74 const ctrl = new AbortController();
71 setError(false); 75 setError(false);
72 clientCompare 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 .then((r) => { 78 .then((r) => {
75 loadedKey.current = key; 79 loadedKey.current = key;
76 setSeries(r.series); 80 setSeries(r.series);
@@ -94,10 +98,16 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h
94 countries 98 countries
95 .map((c, i) => { 99 .map((c, i) => {
96 const s = series?.find((x) => x.country.id === c.id); 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 .filter((l) => l.points.length > 0), 109 .filter((l) => l.points.length > 0),
100− [series, countries], 110+ [series, countries, state.mode],
101 ); 111 );
102 112
103 const latest = countries.map((c, i) => { 113 const latest = countries.map((c, i) => {
@@ -133,7 +143,11 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h
133 const height = hero ? HERO_H : CHART_H; 143 const height = hero ? HERO_H : CHART_H;
134 const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false }); 144 const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false });
135 const rangeActive = (n: number | null) => (n == null ? state.from == null : maxYear != null && state.from === maxYear - n); 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 return ( 152 return (
139 <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`}> 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 <div className="min-w-0"> 155 <div className="min-w-0">
142 {hero ? <div className="eyebrow mb-0.5">{t('compare.hero.title')}</div> : null} 156 {hero ? <div className="eyebrow mb-0.5">{t('compare.hero.title')}</div> : null}
143 <h3 id={`chart-${indicator.slug}-h`} className={cn('font-semibold leading-snug text-ink', hero ? 'display text-xl md:text-2xl' : 'text-sm')}> 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 {name} 159 {name}
146 </Link> 160 </Link>
147 </h3> 161 </h3>
@@ -157,9 +171,13 @@ export function CompareChart({ indicator, countries, state, initial, snapshot, h
157 ))} 171 ))}
158 </div> 172 </div>
159 ) : null} 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 {t('compare.hero.ranking')} 175 {t('compare.hero.ranking')}
162 </Link> 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 {hero ? ( 181 {hero ? (
164 <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')}> 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 <X size={16} aria-hidden /> 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 import { cn } from '@/lib/cn'; 6 import { cn } from '@/lib/cn';
7 import { COMPARE_TABS, MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state'; 7 import { COMPARE_TABS, MAX_COMPARE_COUNTRIES, MIN_COMPARE_COUNTRIES, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state';
8 import { routes } from '@/lib/site'; 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 import { seriesVar } from '@/components/charts/palette'; 10 import { seriesVar } from '@/components/charts/palette';
11 import { BottomSheet } from '@/components/data/bottom-sheet'; 11 import { BottomSheet } from '@/components/data/bottom-sheet';
12 import { CountryPickerSheet } from './country-picker'; 12 import { CountryPickerSheet } from './country-picker';
@@ -107,8 +107,8 @@ export function CompareControls({ countries, allCountries, state, maxYear, downl
107 </label> 107 </label>
108 </fieldset> 108 </fieldset>
109 <div role="radiogroup" aria-label={t('compare.valueMode')} className="flex flex-wrap gap-1"> 109 <div role="radiogroup" aria-label={t('compare.valueMode')} className="flex flex-wrap gap-1">
110− {COMPARE_MODES.map((m: CompareMode) => ( 110+ {COMPARE_UI_MODES.map((m: CompareUiMode) => (
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')}> 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 {t(`compare.mode.${m}` as 'compare.mode.absolute')} 112 {t(`compare.mode.${m}` as 'compare.mode.absolute')}
113 </button> 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 import { t } from '@/i18n'; 4 import { t } from '@/i18n';
5 import { cn } from '@/lib/cn'; 5 import { cn } from '@/lib/cn';
6 import { COMPARE_TOPIC_TABS, compareQuery, type CompareState, type CompareTab } from '@/lib/compare-state'; 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 import { routes } from '@/lib/site'; 8 import { routes } from '@/lib/site';
9 import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare'; 9 import type { CompareSnapshotRow, CountryLite } from '@/lib/types-compare';
10 import type { MetricValue } from '@/lib/types'; 10 import type { MetricValue } from '@/lib/types';
@@ -12,6 +12,19 @@ import { seriesVar } from '@/components/charts/palette';
12 import { payloadFor } from '@/components/data/metric'; 12 import { payloadFor } from '@/components/data/metric';
13 import { useProvenance } from '@/components/data/provenance-context'; 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 /** Where a snapshot indicator's chart lives: its topic tab when it is one of the compare tabs, else the custom tab. */ 28 /** Where a snapshot indicator's chart lives: its topic tab when it is one of the compare tabs, else the custom tab. */
16 export function chartHrefFor(slugs: string[], state: CompareState, indicator: { slug: string; topic: string | null }): string { 29 export function chartHrefFor(slugs: string[], state: CompareState, indicator: { slug: string; topic: string | null }): string {
17 const tab = (COMPARE_TOPIC_TABS as readonly string[]).includes(indicator.topic ?? '') ? (indicator.topic as CompareTab) : 'custom'; 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 return ( 138 return (
126 <td key={c.id} className={cn('py-1 pr-1 text-right align-top', best && 'bg-accent-soft/60')}> 139 <td key={c.id} className={cn('py-1 pr-1 text-right align-top', best && 'bg-accent-soft/60')}>
127 <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')}> 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 <span className="tnum mt-0.5 flex items-center gap-1.5 text-2xs text-ink-3"> 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 {isNum(m.rank_world) && isNum(m.n_world) ? ( 144 {isNum(m.rank_world) && isNum(m.n_world) ? (
132 <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 })}> 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 {t('compare.snapshot.rank', { rank: m.rank_world })} 146 {t('compare.snapshot.rank', { rank: m.rank_world })}
@@ -186,8 +199,8 @@ export function SnapshotTable({ rows, countries, slugs, state }: { rows: Compare
186 </span> 199 </span>
187 </span> 200 </span>
188 <span className="tnum flex items-baseline gap-1.5 text-right"> 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> 202+ <span className={cn('text-sm', best ? 'font-semibold text-ink' : 'text-ink')}>{has ? cellText(m, state.mode).text : t('common.noData')}</span>
190− <span className="text-2xs text-ink-3">{has ? m.year : ''}</span> 203+ <span className="text-2xs text-ink-3">{has ? cellText(m, state.mode).sub ?? m.year : ''}</span>
191 </span> 204 </span>
192 </button> 205 </button>
193 </li> 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 import { Download, Scale } from 'lucide-react'; 1 import { Download, Scale } from 'lucide-react';
2 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { t } from '@/i18n'; 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 import { routes } from '@/lib/site'; 5 import { routes } from '@/lib/site';
6 import type { CountryResponse } from '@/lib/types'; 6 import type { CountryResponse } from '@/lib/types';
7+import type { CountryQualityResponse } from '@/lib/types-analytics';
7 import { FreshnessBadge } from '@/components/data/freshness-badge'; 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 import { ShareButton } from './share-button'; 12 import { ShareButton } from './share-button';
9 13
10 /** 14 /**
11− * Compact country header: flag, name, official name, capital · region · income group, population, area, 15+ * Country hero 2.0: flag + display name + official name, chips (region · income · capital), a fact strip
12− * currency, freshness + coverage; primary actions Compare / Download / Share. No card: one rule below. 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 const c = data.country; 20 const c = data.country;
16 const name = c.name ?? c.id; 21 const name = c.name ?? c.id;
17− const pop = data.headline.find((m) => m.indicator === 'population'); 22+ const pick = (id: string) => data.headline.find((m) => m.indicator === id && m.has_data) ?? null;
18− const facts: Array<[string, string]> = []; 23+ const pop = pick('population');
19− if (c.capital) facts.push([t('country.capital'), c.capital]); 24+ const gdp = pick('gdp');
20− if (c.region_name) facts.push([t('country.region'), c.region_name]); 25+ const gpc = pick('gdp-per-capita');
21− if (c.income_name) facts.push([t('country.incomeGroup'), c.income_name]); 26+ const facts: Array<{ k: string; v: string; sub?: string }> = [];
22− if (pop?.has_data && isNum(pop.value)) facts.push([t('country.population'), `${pop.formatted ?? compact(pop.value)}${pop.year ? ` (${pop.year})` : ''}`]); 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 });
23− if (isNum(c.area_km2)) facts.push([t('country.area'), `${grouped(c.area_km2)} km²`]); 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 });
24− if (c.currency_name) facts.push([t('country.currency'), `${c.currency_name}${c.currency_code ? ` (${c.currency_code})` : ''}`]); 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 return ( 35 return (
27− <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-10"> 36+ <header className="border-b border-rule pb-5 pt-6 md:pb-6 md:pt-8">
28− <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> 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 <div className="min-w-0"> 38 <div className="min-w-0">
30− <div className="flex items-start gap-3"> 39+ <div className="flex items-start gap-3 md:gap-4">
31− <span aria-hidden className="text-4xl leading-none md:text-5xl"> 40+ <span aria-hidden className="text-4xl leading-none md:text-6xl">
32 {c.flag} 41 {c.flag}
33 </span> 42 </span>
34 <div className="min-w-0"> 43 <div className="min-w-0">
35 <h1 className="display text-3xl leading-none text-ink md:text-5xl">{name}</h1> 44 <h1 className="display text-3xl leading-none text-ink md:text-5xl">{name}</h1>
36 {c.official_name && c.official_name !== name ? <p className="mt-1.5 text-sm text-ink-2">{c.official_name}</p> : null} 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 </div> 55 </div>
38 </div> 56 </div>
39− <dl className="mt-4 flex flex-wrap gap-x-5 gap-y-1.5 text-sm"> 57+
40− {facts.map(([k, v]) => ( 58+ <dl className="ticker mt-5 gap-0 border-y border-rule sm:grid sm:grid-cols-5 sm:divide-x sm:divide-rule">
41− <div key={k} className="flex items-baseline gap-1.5"> 59+ {facts.map((f, i) => (
42− <dt className="text-ink-3">{k}</dt> 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' : ''}`}>
43− <dd className="tnum text-ink">{v}</dd> 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 </div> 64 </div>
45 ))} 65 ))}
46 </dl> 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 {data.freshness.retrieved_at ? <span className="tnum">{t('country.refreshed', { date: formatDate(data.freshness.built_at ?? data.freshness.retrieved_at) })}</span> : null} 69 {data.freshness.retrieved_at ? <span className="tnum">{t('country.refreshed', { date: formatDate(data.freshness.built_at ?? data.freshness.retrieved_at) })}</span> : null}
49 <FreshnessBadge retrievedAt={data.freshness.retrieved_at} now={Date.parse(data.meta.generated_at) || Date.now()} /> 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} 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}
51− {c.landlocked ? <span className="rounded-xs border border-rule px-1 py-px">{t('country.landlocked')}</span> : null} 72+ {quality ? <span className="tnum">{t('country.freshSplit', { fresh: quality.summary.n_fresh, stale: quality.summary.n_stale })}</span> : null}
52− {c.status === 'territory' ? <span className="rounded-xs border border-rule px-1 py-px">{t('countries.territory')}</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 </div> 85 </div>
54 </div> 86 </div>
55− <div className="flex shrink-0 flex-wrap gap-2 md:flex-col md:items-stretch"> 87+
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 })}> 88+ <div className="min-w-0">
57− <Scale size={15} aria-hidden /> {t('country.compare')} 89+ <MiniMap iso3={c.id} neighbours={c.borders ?? []} latitude={c.latitude} longitude={c.longitude} name={name} className="rounded-sm" />
58− </Link> 90+ {data.neighbours.length ? (
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')}> 91+ <div className="mt-2 text-xs">
60− <Download size={15} aria-hidden /> {t('country.download')} 92+ <span className="text-ink-3">{t('country.borders')}: </span>
61− </a> 93+ <ul className="mt-1 flex flex-wrap gap-1">
62− <ShareButton title={t('country.title', { name })} /> 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 </div> 106 </div>
64 </div> 107 </div>
65 </header> 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 import { ChevronDown } from 'lucide-react'; 2 import { ChevronDown } from 'lucide-react';
3 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { useEffect, useState } from 'react'; 4 import { useEffect, useState } from 'react';
5−import { t } from '@/i18n'; 5+import { t, tOpt } from '@/i18n';
6 import { clientApi } from '@/lib/client-api'; 6 import { clientApi } from '@/lib/client-api';
7 import { cn } from '@/lib/cn'; 7 import { cn } from '@/lib/cn';
8−import { fixed } from '@/lib/format'; 8+import { compact, fixed, formatValue } from '@/lib/format';
9 import { routes } from '@/lib/site'; 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 const MODES: SimilarityMode[] = ['overall', 'economic', 'demographic', 'energy', 'social']; 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" 24+ * "Countries like {name}": mode tabs (from the API), peers with a similarity bar (0–100), and a "Why similar?"
16− * expander listing the top contributing features. The server passes the `overall` payload; other modes are 25+ * expander turning contributions into qualitative labels with both raw values. Peers link to the pairwise
17− * fetched on demand via the same-origin API and cached in state. 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 const [mode, setMode] = useState<SimilarityMode>('overall'); 29 const [mode, setMode] = useState<SimilarityMode>('overall');
21 const [data, setData] = useState<Partial<Record<SimilarityMode, SimilarResponse | null>>>({ overall: initial }); 30 const [data, setData] = useState<Partial<Record<SimilarityMode, SimilarResponse | null>>>({ overall: initial });
22 const [loading, setLoading] = useState(false); 31 const [loading, setLoading] = useState(false);
@@ -40,7 +49,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia
40 49
41 return ( 50 return (
42 <div> 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 {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => ( 53 {MODES.filter((m) => available.has(m) || m === 'overall').map((m) => (
45 <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')}> 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 {t(`country.similar.mode.${m}` as const)} 55 {t(`country.similar.mode.${m}` as const)}
@@ -57,7 +66,7 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia
57 const open = openPeer === key; 66 const open = openPeer === key;
58 const contribs = parseContribs(p.contributions); 67 const contribs = parseContribs(p.contributions);
59 return ( 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 <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]"> 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 <span className="tnum text-xs text-ink-3">{p.rank}</span> 71 <span className="tnum text-xs text-ink-3">{p.rank}</span>
63 <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"> 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 </span> 75 </span>
67 <span className="truncate">{p.country.name}</span> 76 <span className="truncate">{p.country.name}</span>
68 </Link> 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 <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} /> 79 <div className="h-full rounded-xs bg-accent" style={{ width: `${Math.max(2, Math.min(100, p.score ?? 0))}%` }} />
71 </div> 80 </div>
72 <span className="tnum text-right text-sm font-medium text-ink" aria-label={t('country.similar.score', { score: fixed(p.score ?? 0, 0) })}> 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 </span> 83 </span>
75 {contribs.length ? ( 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 <ChevronDown size={16} aria-hidden className={cn('transition-transform', open && 'rotate-180')} /> 86 <ChevronDown size={16} aria-hidden className={cn('transition-transform', open && 'rotate-180')} />
78 </button> 87 </button>
79 ) : ( 88 ) : (
@@ -82,16 +91,33 @@ export function SimilarPanel({ countryId, initial }: { countryId: string; initia
82 </div> 91 </div>
83 {open ? ( 92 {open ? (
84 <div className="mt-2 rounded-sm bg-surface-2/60 px-3 py-2 text-xs text-ink-2"> 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> 94+ <div className="flex flex-wrap items-baseline justify-between gap-2">
86− <ul className="grid gap-x-6 gap-y-0.5 sm:grid-cols-2"> 95+ <div className="eyebrow">{t('country.similar.whySimilar')}</div>
87− {contribs.slice(0, 6).map((c) => ( 96+ {countrySlug && p.country.slug ? (
88− <li key={c.indicator} className="flex justify-between gap-2 tnum"> 97+ <Link href={routes.compare(countrySlug, p.country.slug)} className="text-accent hover:underline">
89− <span className="truncate">{c.indicator.replace(/-/g, ' ')}</span> 98+ {t('country.similar.compareWith', { a: countryName, b: p.country.name ?? p.country.id })} →
90− <span className="text-ink-3"> 99+ </Link>
91− z {fmtZ(c.z_a)} vs {fmtZ(c.z_b)} 100+ ) : null}
92− </span> 101+ </div>
93− </li> 102+ <ul className="mt-1 grid gap-x-6 gap-y-1 sm:grid-cols-2">
94− ))} 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 </ul> 121 </ul>
96 </div> 122 </div>
97 ) : null} 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 { 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 }> {
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 }> {
113 if (!raw) return []; 135 if (!raw) return [];
114 let obj: Contributions; 136 let obj: Contributions;
115 try { 137 try {
@@ -118,6 +140,6 @@ function parseContribs(raw: Contributions | string | null): Array<{ indicator: s
118 return []; 140 return [];
119 } 141 }
120 return Object.entries(obj) 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 })) 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 }))
122− .sort((a, b) => Math.abs(a.contribution) - Math.abs(b.contribution)); // smallest distance contribution = most similar 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 import Link from 'next/link'; 3 import Link from 'next/link';
4+import { useMemo, useState } from 'react';
2 import { t } from '@/i18n'; 5 import { t } from '@/i18n';
3 import { cn } from '@/lib/cn'; 6 import { cn } from '@/lib/cn';
4 import { severityLevel } from '@/lib/severity'; 7 import { severityLevel } from '@/lib/severity';
@@ -7,41 +10,130 @@ import { topicById } from '@/lib/topics';
7 import type { ChangeItem } from '@/lib/types'; 10 import type { ChangeItem } from '@/lib/types';
8 import { kindLabel } from '@/components/data/change-list'; 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. */ 13+const ICON: Record<string, typeof ArrowUpRight> = {
11−export function Timeline({ items, slug, limit = 30 }: { items: ChangeItem[]; slug: string; limit?: number }) { 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 if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('country.timeline.none')}</p>; 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 return ( 68 return (
21− <ol className="divide-y divide-rule"> 69+ <div className="min-w-0">
22− {years.map((y) => ( 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')}>
23− <li key={y} className="grid grid-cols-[3.25rem_1fr] gap-x-3 py-2.5"> 71+ {FILTERS.filter((f) => (counts[f.id] ?? 0) > 0 || f.id === 'all').map((f) => (
24− <span className="tnum display pt-0.5 text-lg leading-none text-ink-2">{y || '—'}</span> 72+ <li key={f.id} className="shrink-0">
25− <ul className="space-y-1.5"> 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')}>
26− {byYear.get(y)!.map((e, i) => { 74+ {t(`country.timeline.f.${f.id}` as 'country.timeline.f.all')}
27− const ind = e.indicator; 75+ <span className={cn('tnum text-2xs', filter === f.id ? 'text-paper/70' : 'text-ink-3')}>{counts[f.id] ?? 0}</span>
28− const indSlug = (ind as { slug?: string; id: string }).slug ?? ind.id; 76+ </button>
29− const topic = 'topic' in ind ? topicById(ind.topic ?? '')?.id : undefined; 77+ </li>
30− const href = topic ? routes.countryIndicator(slug, topic, indSlug) : routes.indicator(indSlug); 78+ ))}
31− const lvl = severityLevel(e.severity); 79+ </ul>
32− return ( 80+ <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-ink-3" aria-label={t('common.legend')}>
33− <li key={e.id ?? i} className="text-sm leading-snug"> 81+ {(['record_high', 'yoy_jump', 'sign_flip', 'structural_break', 'volatility_spike'] as const).map((k) => {
34− <Link href={href} className="link-quiet"> 82+ const Icon = ICON[k]!;
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 /> 83+ return (
36− <span className="mr-1.5 text-2xs uppercase tracking-wide text-ink-3">{kindLabel(e.kind, e.window_years)}</span> 84+ <li key={k} className="inline-flex items-center gap-1">
37− <span className="text-ink">{e.headline}</span> 85+ <Icon size={12} aria-hidden /> {kindLabel(k, 10)}
38− </Link> 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 </li> 124 </li>
40− ); 125+ ))}
41− })} 126+ </ol>
42− </ul> 127+ </li>
43− </li> 128+ ))}
44− ))} 129+ </ol>
45− </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 import Link from 'next/link'; 2 import Link from 'next/link';
3 import { t, tOpt } from '@/i18n'; 3 import { t, tOpt } from '@/i18n';
4 import { cn } from '@/lib/cn'; 4 import { cn } from '@/lib/cn';
@@ -17,6 +17,9 @@ const ICON: Record<string, typeof ArrowUpRight> = {
17 sign_flip: Repeat, 17 sign_flip: Repeat,
18 accelerating: TrendingUp, 18 accelerating: TrendingUp,
19 decelerating: TrendingDown, 19 decelerating: TrendingDown,
20+ structural_break: GitCommitHorizontal,
21+ trend_reversal: Repeat,
22+ volatility_spike: Waves,
20 }; 23 };
21 24
22 export function kindLabel(kind: string | null | undefined, windowYears?: number | null): string { 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 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { t } from '@/i18n'; 4 import { t } from '@/i18n';
5 import { cn } from '@/lib/cn'; 5 import { cn } from '@/lib/cn';
6−import { displayValue, formatPeriod } from '@/lib/format'; 6+import { displayValue, formatPeriod, ordinal } from '@/lib/format';
7 import type { MetricValue } from '@/lib/types'; 7 import type { MetricValue } from '@/lib/types';
8 import { pointsFromSpark } from '@/components/charts/scales'; 8 import { pointsFromSpark } from '@/components/charts/scales';
9 import { Sparkline } from '@/components/charts/sparkline'; 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 → 31+ * Percentile of the latest value within the country's own history (share of earlier points strictly below it).
32− * provenance sheet; `href` (optional) renders a quiet chevron link to the topic page. 1 px separators, no card. 32+ * Null with fewer than 5 earlier points. 100 = highest ever, 0 = lowest ever.
33− * Heights are reserved (sparkline box, rank line) so the grid does not shift while fonts/data load.
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 const { open } = useProvenance(); 48 const { open } = useProvenance();
37 const m = metric; 49 const m = metric;
38 const hasValue = m.has_data && m.value != null; 50 const hasValue = m.has_data && m.value != null;
39 const dir = m.change?.abs == null ? null : m.change.abs > 0 ? 'up' : m.change.abs < 0 ? 'down' : 'flat'; 51 const dir = m.change?.abs == null ? null : m.change.abs > 0 ? 'up' : m.change.abs < 0 ? 'down' : 'flat';
40 const points = pointsFromSpark(m.sparkline); 52 const points = pointsFromSpark(m.sparkline);
53+ const pct = showOwnPercentile && hasValue ? ownHistoryPercentile(m) : null;
54+ const firstYear = points[0]?.year;
41 return ( 55 return (
42 <div className={cn('flex min-w-0 flex-col gap-1 border-t border-rule py-3', className)} id={m.indicator}> 56 <div className={cn('flex min-w-0 flex-col gap-1 border-t border-rule py-3', className)} id={m.indicator}>
43 <div className="flex items-start justify-between gap-2"> 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 <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> 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 </div> 67 </div>
54 <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')}> 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 <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]')}> 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 <div className="min-h-[1.1rem] min-w-0 truncate"> 78 <div className="min-h-[1.1rem] min-w-0 truncate">
65 <RankBadge rank={m} regionName={regionName} /> 79 <RankBadge rank={m} regionName={regionName} />
66 </div> 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 </div> 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 export function MetricGrid({ children, className, cols = 4 }: { children: React.ReactNode; className?: string; cols?: 3 | 4 }) { 94 export function MetricGrid({ children, className, cols = 4 }: { children: React.ReactNode; className?: string; cols?: 3 | 4 }) {
73 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>; 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 import { t } from '@/i18n'; 1 import { t } from '@/i18n';
2 import { SearchTrigger } from '@/components/layout/search-trigger'; 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 export function Hero() { 5 export function Hero() {
6 return ( 6 return (
7− <section className="pb-6 pt-8 md:pb-10 md:pt-14"> 7+ <section className="pb-4 pt-6 md:pb-6 md:pt-10">
8− <div className="max-w-3xl"> 8+ <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
9− <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('home.hero.title')}</h1> 9+ <div className="max-w-2xl">
10− <p className="mt-3 max-w-2xl text-base text-ink-2 md:text-lg">{t('home.hero.sub')}</p> 10+ <h1 className="display text-3xl leading-tight text-ink md:text-5xl">{t('home.hero.title')}</h1>
11− <div className="mt-5 max-w-xl"> 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 <SearchTrigger variant="hero" /> 14 <SearchTrigger variant="hero" />
13 </div> 15 </div>
14 </div> 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 import { compact, fixed, formatDate, grouped, isNum } from '@/lib/format'; 2 import { compact, fixed, formatDate, grouped, isNum } from '@/lib/format';
3 import type { GlobalSnapshot } from '@/lib/types'; 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 export function SnapshotStrip({ s }: { s: GlobalSnapshot }) { 9 export function SnapshotStrip({ s }: { s: GlobalSnapshot }) {
7 const cells: Array<{ label: string; value: string; sub?: string }> = [ 10 const cells: Array<{ label: string; value: string; sub?: string }> = [
8 { 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 }, 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 { 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 }, 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 { 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 }, 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` }, 14+ { label: t('home.snapshot.countries'), value: grouped(s.n_countries + (s.n_territories ?? 0)), sub: t('home.snapshot.countriesSub') },
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 }, 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 },
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 }, 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 return ( 19 return (
16 <section aria-label={t('home.snapshot.title')} className="border-y border-rule"> 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 {cells.map((c, i) => ( 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`}> 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'}`}>
20− <dt className="text-2xs font-medium uppercase tracking-wide text-ink-3">{c.label}</dt> 24+ <dt className="eyebrow">{c.label}</dt>
21 <dd className="pnum mt-1 text-xl font-semibold leading-none text-ink md:text-2xl">{c.value}</dd> 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 </div> 27 </div>
24 ))} 28 ))}
25 </dl> 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 'use client'; 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 import { t } from '@/i18n'; 5 import { t } from '@/i18n';
4−import { clientExplore } from '@/lib/client-api-explore';
5 import { formatValue, grouped } from '@/lib/format'; 6 import { formatValue, grouped } from '@/lib/format';
6−import type { FormatSpec, MapResponse } from '@/lib/types'; 7+import { routes } from '@/lib/site';
7−import { ChoroplethView, type ChoroplethFeature } from '@/components/charts/choropleth-view'; 8+import type { FormatSpec } from '@/lib/types';
8−import { EmptyState } from '@/components/data/empty-state'; 9+import type { FramesResponse } from '@/lib/types-analytics';
10+import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view';
9 import { SourceLine } from '@/components/charts/source-line'; 11 import { SourceLine } from '@/components/charts/source-line';
12+import { YearSlider } from '@/components/controls/year-slider';
10 import type { ProvenancePayload } from '@/components/data/provenance-context'; 13 import type { ProvenancePayload } from '@/components/data/provenance-context';
14+import { EmptyState } from '@/components/data/empty-state';
11 15
12 /** Geometry the server computes once (no values): one entry per drawn country. */ 16 /** Geometry the server computes once (no values): one entry per drawn country. */
13 export interface BaseFeature { 17 export interface BaseFeature {
@@ -18,116 +22,50 @@ export interface BaseFeature {
18 d: string; 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 26+ * Indicator world map with a time machine: every year comes from one `/indicators/{slug}/frames` payload
29− * latest common year; changing the year fetches `/indicators/{slug}/map?year=` client-side and re-classes 27+ * (pooled quantile legend, so colours stay comparable while scrubbing), so the slider and play button need no
30− * the same geometry. Legend from the API quantile breaks, hatched no-data, click/tap → country. 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 }) { 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 }) {
33− const [year, setYear] = useState<number>(initial.year_used ?? years[years.length - 1] ?? new Date().getUTCFullYear()); 31+ const years = frames?.years ?? [];
34− const [cache, setCache] = useState<Record<number, MapResponse>>(() => (initial.year_used != null ? { [initial.year_used]: initial } : {})); 32+ const [year, setYear] = useState<number>(initialYear && years.includes(initialYear) ? initialYear : years[years.length - 1] ?? new Date().getUTCFullYear());
35− const [loading, setLoading] = useState(false); 33+ const idx = years.indexOf(year);
36− const [error, setError] = useState(false);
37− const abortRef = useRef<AbortController | null>(null);
38 34
39− useEffect(() => { 35+ const { features, legend, k, n } = useMemo(() => {
40− if (cache[year]) return; 36+ if (!frames || idx < 0) return { features: [] as ChoroplethFeature[], legend: [], k: 1, n: 0 };
41− abortRef.current?.abort(); 37+ const breaks = frames.legend.breaks.slice(0, 6);
42− const ctrl = new AbortController(); 38+ let count = 0;
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;
66 const feats: ChoroplethFeature[] = geometry.map((g) => { 39 const feats: ChoroplethFeature[] = geometry.map((g) => {
67− const v = g.iso3 && map ? map.values[g.iso3] : undefined; 40+ const v = g.iso3 ? frames.values[g.iso3]?.[idx] : undefined;
68− return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classIndex(v, breaks) : null }; 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 44+ return { features: feats, legend: legendFromBreaks(breaks, frames.legend.min, frames.legend.max, spec), k: breaks.length + 1, n: count };
71− ? Array.from({ length: kk }, (_, i) => { 45+ }, [frames, idx, geometry, spec]);
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]);
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 const title = t('chart.map.legend', { name: spec.name ?? slug, year }); 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 return ( 51 return (
86 <div className="min-w-0"> 52 <div className="min-w-0">
87− <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2"> 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 />}
88− <label className="flex min-w-0 flex-1 basis-64 items-center gap-3"> 54+ <div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
89− <span className="shrink-0 text-xs font-medium text-ink-2">{t('indicator.map.year')}</span> 55+ <YearSlider years={years} year={year} onChange={setYear} className="min-w-0 flex-1" interval={600} />
90− <input 56+ <div className="flex shrink-0 flex-wrap items-center gap-x-3 text-xs text-ink-3">
91− type="range" 57+ <span className="tnum">
92− min={0} 58+ {years[0]}–{years[years.length - 1]} · {t('indicator.map.n', { n: grouped(n) })}
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}
104 </span> 59 </span>
105− </label> 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]">
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"> 61+ <Maximize2 size={14} aria-hidden />
107− {[...years].reverse().map((y) => ( 62+ {t('home.map.openExplorer')}
108− <option key={y} value={y}> 63+ </Link>
109− {y} 64+ </div>
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− )}
127 </div> 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 <div className="mt-1"> 67 <div className="mt-1">
130− <SourceLine provenance={map?.provenance ?? initial.provenance} payload={payload} /> 68+ <SourceLine provenance={frames.provenance} payload={payload} />
131 </div> 69 </div>
132 </div> 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 * Global search (⌘K / "/" / tab bar). Debounced `/api/v1/search?q=`, grouped hits with type chips, keyboard 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 const list = useMemo(() => { 107 const list = useMemo(() => {
108 const src = q.trim() ? hits : recent; 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 }, [hits, recent, q]); 110 }, [hits, recent, q]);
111 111
112 const go = useCallback( 112 const go = useCallback(
@@ -183,7 +183,22 @@ export function SearchDialog() {
183 </button> 183 </button>
184 </div> 184 </div>
185 ) : null} 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 {state === 'error' ? <p className="py-6 text-center text-down">{t('search.error')}</p> : null} 202 {state === 'error' ? <p className="py-6 text-center text-down">{t('search.error')}</p> : null}
188 {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} 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 <ul id="search-results" role="listbox" aria-label={t('nav.search')} className="divide-y divide-rule"> 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 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')} 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 <span aria-hidden className="w-6 text-center text-lg leading-none"> 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 </span> 215 </span>
201 <span className="min-w-0 flex-1"> 216 <span className="min-w-0 flex-1">
202 <span className="block truncate text-ink">{h.name}</span> 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 export function TypeChip({ type }: { type: SearchHitType | string }) { 236 export function TypeChip({ type }: { type: SearchHitType | string }) {
222 const key = CHIP_TYPE[type] ?? 'country'; 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 'use client'; 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 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { usePathname, useRouter } from 'next/navigation'; 4 import { usePathname, useRouter } from 'next/navigation';
5 import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; 5 import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6 import { t } from '@/i18n'; 6 import { t } from '@/i18n';
7 import { cn } from '@/lib/cn'; 7 import { cn } from '@/lib/cn';
8−import { displayValue, grouped, isNum, ordinal } from '@/lib/format'; 8+import { compact, displayValue, formatValue, grouped, isNum, ordinal } from '@/lib/format';
9−import { rankingQuery, type RankingState } from '@/lib/ranking-state'; 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 import { routes } from '@/lib/site'; 11 import { routes } from '@/lib/site';
11 import type { RankingResponse, RankingRow } from '@/lib/types'; 12 import type { RankingResponse, RankingRow } from '@/lib/types';
12 import type { CountryLite, RegionItem } from '@/lib/types-compare'; 13 import type { CountryLite, RegionItem } from '@/lib/types-compare';
14+import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view';
13 import { MARK } from '@/components/charts/palette'; 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 import { pointsFromSpark } from '@/components/charts/scales'; 19 import { pointsFromSpark } from '@/components/charts/scales';
16 import { Sparkline } from '@/components/charts/sparkline'; 20 import { Sparkline } from '@/components/charts/sparkline';
17 import { CountryTypeahead } from '@/components/compare/country-picker'; 21 import { CountryTypeahead } from '@/components/compare/country-picker';
@@ -27,7 +31,7 @@ const GROUP_KINDS = ['region', 'continent', 'income', 'org'] as const;
27 * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per 31 * sheet on phones), the ranked list (rank, flag+name, bar, value, 1 y / 10 y change, sparkline), 25 rows per
28 * page with "Show more". Year/group/sort/highlight/q live in the URL (server re-renders the data). 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 const router = useRouter(); 35 const router = useRouter();
32 const pathname = usePathname(); 36 const pathname = usePathname();
33 const { open } = useProvenance(); 37 const { open } = useProvenance();
@@ -52,7 +56,17 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
52 return () => clearTimeout(id); 56 return () => clearTimeout(id);
53 }, [q, state.q, setState]); 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 // Freshness honesty: rows ≥ 2 years older than the ranking year show their year next to the value. 70 // Freshness honesty: rows ≥ 2 years older than the ranking year show their year next to the value.
57 const refYear = useMemo(() => Math.max(data.year_used ?? 0, ...rows.map((r) => r.year ?? 0)), [rows, data.year_used]); 71 const refYear = useMemo(() => Math.max(data.year_used ?? 0, ...rows.map((r) => r.year ?? 0)), [rows, data.year_used]);
58 const max = useMemo(() => Math.max(0, ...rows.map((r) => (isNum(r.value) ? Math.abs(r.value) : 0))), [rows]); 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 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); 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 const years = [...data.years_available].sort((a, b) => b - a); 100 const years = [...data.years_available].sort((a, b) => b - a);
87 101
88 const yearSelect = ( 102 const yearSelect = (
@@ -120,6 +134,46 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
120 {sort === 'desc' ? t('ranking.sort.desc') : t('ranking.sort.asc')} 134 {sort === 'desc' ? t('ranking.sort.desc') : t('ranking.sort.asc')}
121 </button> 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 const highlightControl = highlightCountry ? ( 177 const highlightControl = highlightCountry ? (
124 <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"> 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 <span aria-hidden>{highlightCountry.flag}</span> 179 <span aria-hidden>{highlightCountry.flag}</span>
@@ -169,10 +223,17 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
169 <div className="hidden flex-wrap items-center gap-2 py-3 md:flex"> 223 <div className="hidden flex-wrap items-center gap-2 py-3 md:flex">
170 {yearSelect} 224 {yearSelect}
171 {groupSelect} 225 {groupSelect}
226+ {incomeSelect}
227+ {minPopSelect}
228+ {minCovSelect}
172 {sortToggle} 229 {sortToggle}
173 {highlightControl} 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 </div> 235 </div>
236+ <div className="flex items-center justify-between gap-2 py-2 md:hidden">{viewSwitch}</div>
176 237
177 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2 text-xs text-ink-3"> 238 <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 py-2 text-xs text-ink-3">
178 <span>{rankNote}</span> 239 <span>{rankNote}</span>
@@ -194,8 +255,21 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
194 <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> 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 ) : null} 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 {/* Header (sm+) */} 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 <span className="text-right">{t('ranking.col.rank')}</span> 273 <span className="text-right">{t('ranking.col.rank')}</span>
200 <span>{t('ranking.col.country')}</span> 274 <span>{t('ranking.col.country')}</span>
201 <span /> 275 <span />
@@ -206,12 +280,12 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
206 </div> 280 </div>
207 281
208 {filtered.length === 0 ? <p className="py-10 text-center text-sm text-ink-3">{ql ? t('ranking.noMatch', { q }) : t('ranking.empty')}</p> : null} 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 {visible.map((r) => ( 284 {visible.map((r) => (
211 <Row key={r.country.id} r={r} max={max} spec={ind} refYear={refYear} highlight={highlightRow?.country.id === r.country.id} onValue={() => open(payloadOf(r))} /> 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 </ol> 287 </ol>
214− {filtered.length > shown ? ( 288+ {state.view === 'table' && filtered.length > shown ? (
215 <div className="flex justify-center py-4"> 289 <div className="flex justify-center py-4">
216 <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"> 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 {t('ranking.showMore', { n: Math.min(PAGE, filtered.length - shown) })} 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 <div className="eyebrow mb-1.5">{t('ranking.group')}</div> 299 <div className="eyebrow mb-1.5">{t('ranking.group')}</div>
226 {groupSelect} 300 {groupSelect}
227 </div> 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 <div> 310 <div>
229 <div className="eyebrow mb-1.5">{t('ranking.sort')}</div> 311 <div className="eyebrow mb-1.5">{t('ranking.sort')}</div>
230 {sortToggle} 312 {sortToggle}
@@ -235,7 +317,7 @@ export function RankingView({ data, regions, countries, state }: { data: Ranking
235 </div> 317 </div>
236 </div> 318 </div>
237 <div className="mt-6 flex justify-between"> 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 {t('common.reset')} 321 {t('common.reset')}
240 </button> 322 </button>
241 <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}> 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 </li> 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 'compare.minCountries': 'A comparison needs at least two countries.', 46 'compare.minCountries': 'A comparison needs at least two countries.',
47 'compare.notFound': 'Comparison not found', 47 'compare.notFound': 'Comparison not found',
48 'compare.notFoundHint': 'At least two valid countries are needed, e.g. /compare/canada/united-states.', 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 'compare.tab.economy': 'Economy', 50 'compare.tab.economy': 'Economy',
51− 'compare.tab.population': 'Population', 51+ 'compare.tab.population': 'Demographics',
52 'compare.tab.health': 'Health', 52 'compare.tab.health': 'Health',
53 'compare.tab.housing': 'Housing', 53 'compare.tab.housing': 'Housing',
54 'compare.tab.energy': 'Energy', 54 'compare.tab.energy': 'Energy',
@@ -71,6 +71,22 @@ export const enCompare = {
71 'compare.mode.hint.index100': 'Each series rebased to 100 at the first year of the range.', 71 'compare.mode.hint.index100': 'Each series rebased to 100 at the first year of the range.',
72 'compare.mode.hint.pct': 'Year-over-year change in percent.', 72 'compare.mode.hint.pct': 'Year-over-year change in percent.',
73 'compare.mode.hint.per-capita': 'Totals divided by population; per-capita and share indicators are unchanged.', 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 'compare.log': 'Log scale', 90 'compare.log': 'Log scale',
75 'compare.download': 'Download CSV', 91 'compare.download': 'Download CSV',
76 'compare.share': 'Share', 92 'compare.share': 'Share',
@@ -195,4 +211,15 @@ export const enCompare = {
195 'ranking.pctRank': 'Top {pct} %', 211 'ranking.pctRank': 'Top {pct} %',
196 'ranking.years': 'Years', 212 'ranking.years': 'Years',
197 'ranking.jumpToHighlight': 'Jump to {name}', 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 } as const; 225 } as const;
modified apps/web/src/i18n/en.core.ts +212 −0
@@ -35,6 +35,218 @@ export const enCore = {
35 'control.view': 'View', 35 'control.view': 'View',
36 'control.reset': 'Reset view', 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 // --- semantics 250 // --- semantics
39 'semantics.increase': 'Increase', 251 'semantics.increase': 'Increase',
40 'semantics.decrease': 'Decrease', 252 'semantics.decrease': 'Decrease',
modified apps/web/src/i18n/en.ts +2 −2
@@ -211,9 +211,9 @@ export const en = {
211 'chart.axisYear': 'Year', 211 'chart.axisYear': 'Year',
212 212
213 // --- home 213 // --- home
214− 'home.hero.title': 'Understand the world, one country at a time.', 214+ 'home.hero.title': 'Explore the world through data.',
215 'home.hero.sub': 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 'home.snapshot.title': 'Global snapshot', 217 'home.snapshot.title': 'Global snapshot',
218 'home.snapshot.population': 'World population', 218 'home.snapshot.population': 'World population',
219 'home.snapshot.gdp': 'World GDP', 219 'home.snapshot.gdp': 'World GDP',
modified apps/web/src/lib/client-api.ts +3 −1
@@ -1,5 +1,5 @@
1 'use client'; 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 * Browser-side fetch helpers: same-origin `/api/v1/*` (rewritten by next.config.ts to the FastAPI service). 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 get<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, signal), 28 get<SeriesResponse>(`/countries/${encodeURIComponent(id)}/series/${encodeURIComponent(indicator)}`, signal),
29 countrySimilar: (id: string, mode: string, signal?: AbortSignal) => 29 countrySimilar: (id: string, mode: string, signal?: AbortSignal) =>
30 get<SimilarResponse>(`/countries/${encodeURIComponent(id)}/similar?mode=${encodeURIComponent(mode)}`, signal), 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 import type { TopicId } from './types'; 2 import type { TopicId } from './types';
3 3
4 /** 4 /**
@@ -6,16 +6,16 @@ import type { TopicId } from './types';
6 * shareable and survives a reload: 6 * shareable and survives a reload:
7 * 7 *
8 * /compare/canada/united-states/france 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 * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab) 10 * &indicator=gdp-per-capita hero chart at the top of a topic tab (also switches to that tab)
11 * &indicators=a,b,c custom tab only, ≤ 6 slugs 11 * &indicators=a,b,c custom tab only, ≤ 6 slugs
12 * &from=1990&to=2025 year range (omitted = full history) 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 * &log=1 log y-scale (charts only) 14 * &log=1 log y-scale (charts only)
15 * 15 *
16 * Canonical URL = path + `tab` (when not snapshot); everything else is volatile. 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 export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number]; 19 export type CompareTopicTab = (typeof COMPARE_TOPIC_TABS)[number];
20 export type CompareTab = 'snapshot' | CompareTopicTab | 'custom'; 20 export type CompareTab = 'snapshot' | CompareTopicTab | 'custom';
21 export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom']; 21 export const COMPARE_TABS: readonly CompareTab[] = ['snapshot', ...COMPARE_TOPIC_TABS, 'custom'];
@@ -30,7 +30,7 @@ export interface CompareState {
30 indicators: string[]; 30 indicators: string[];
31 from: number | null; 31 from: number | null;
32 to: number | null; 32 to: number | null;
33− mode: CompareMode; 33+ mode: CompareUiMode;
34 log: boolean; 34 log: boolean;
35 } 35 }
36 36
@@ -68,7 +68,7 @@ export function parseCompareState(params: ParamsLike): CompareState {
68 let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot'; 68 let tab: CompareTab = isCompareTab(tabRaw) ? tabRaw : 'snapshot';
69 if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab; 69 if (tab === 'snapshot' && (indicators.length || indicator)) tab = indicators.length ? 'custom' : tab;
70 const modeRaw = read(params, 'mode'); 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 let from = yearOf(read(params, 'from')); 72 let from = yearOf(read(params, 'from'));
73 let to = yearOf(read(params, 'to')); 73 let to = yearOf(read(params, 'to'));
74 if (from != null && to != null && from > to) [from, to] = [to, from]; 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 * URL contract of `/rankings/[indicator]`: 2 * URL contract of `/rankings/[indicator]`:
3 * ?year=2020 ranking year (default: latest; the API falls back to the nearest available year) 3 * ?year=2020 ranking year (default: latest; the API falls back to the nearest available year)
4 * &group=oecd group slug (world · regions · continents · income groups · organisations); default world 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 * &sort=asc|desc default = the API's ("asc" when lower is better, else "desc") 9 * &sort=asc|desc default = the API's ("asc" when lower is better, else "desc")
6 * &highlight=canada country slug pinned at the top of the list 10 * &highlight=canada country slug pinned at the top of the list
7 * &q=fra text filter within the ranking 11 * &q=fra text filter within the ranking
8 * &history=CAN,USA,FRA countries of the rank-over-time panel (≤ 5 ISO3) 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 export interface RankingState { 20 export interface RankingState {
12 year: number | null; 21 year: number | null;
13 group: string; 22 group: string;
23+ income: string | null;
24+ minpop: number | null;
25+ mincov: number | null;
26+ view: RankingView;
14 sort: 'asc' | 'desc' | null; 27 sort: 'asc' | 'desc' | null;
15 highlight: string | null; 28 highlight: string | null;
16 q: string; 29 q: string;
@@ -28,19 +41,38 @@ export function parseRankingState(params: ParamsLike): RankingState {
28 const y = Number(read(params, 'year')); 41 const y = Number(read(params, 'year'));
29 const sort = read(params, 'sort'); 42 const sort = read(params, 'sort');
30 const group = (read(params, 'group') ?? 'world').toLowerCase().replace(/[^a-z0-9-]/g, '') || 'world'; 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 const highlight = (read(params, 'highlight') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null; 48 const highlight = (read(params, 'highlight') ?? '').toLowerCase().replace(/[^a-z0-9-]/g, '') || null;
32 const history = (read(params, 'history') ?? '') 49 const history = (read(params, 'history') ?? '')
33 .split(',') 50 .split(',')
34 .map((s) => s.trim().toUpperCase()) 51 .map((s) => s.trim().toUpperCase())
35 .filter((s) => /^[A-Z]{3}$/.test(s)) 52 .filter((s) => /^[A-Z]{3}$/.test(s))
36 .slice(0, 5); 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 export function rankingQuery(state: Partial<RankingState>): string { 68 export function rankingQuery(state: Partial<RankingState>): string {
41 const p = new URLSearchParams(); 69 const p = new URLSearchParams();
42 if (state.year != null) p.set('year', String(state.year)); 70 if (state.year != null) p.set('year', String(state.year));
43 if (state.group && state.group !== 'world') p.set('group', state.group); 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 if (state.sort) p.set('sort', state.sort); 76 if (state.sort) p.set('sort', state.sort);
45 if (state.highlight) p.set('highlight', state.highlight); 77 if (state.highlight) p.set('highlight', state.highlight);
46 if (state.q) p.set('q', state.q); 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 /** `mode` query of `/compare` (docs/API.md § Compare). */ 11 /** `mode` query of `/compare` (docs/API.md § Compare). */
12 export type CompareMode = 'absolute' | 'per-capita' | 'index100' | 'pct'; 12 export type CompareMode = 'absolute' | 'per-capita' | 'index100' | 'pct';
13 export const COMPARE_MODES: readonly CompareMode[] = ['absolute', 'per-capita', 'index100', 'pct']; 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 /** Transformation echo added by the router on every series (`extra="allow"`). */ 22 /** Transformation echo added by the router on every series (`extra="allow"`). */
16 export interface CompareTransform { 23 export interface CompareTransform {
@@ -117,8 +124,10 @@ export interface CountryLite {
117 flag: string | null; 124 flag: string | null;
118 region: string | null; 125 region: string | null;
119 income: string | null; 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 { 131+export function toCountryLite(c: CountryCard & { population_latest?: number | null }): 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 }; 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 | 'n_year_low' 39 | 'n_year_low'
40 | 'sign_flip' 40 | 'sign_flip'
41 | 'accelerating' 41 | 'accelerating'
42− | 'decelerating'; 42+ | 'decelerating'
43−export type SearchHitType = 'country' | 'indicator' | 'topic' | 'region' | 'source' | 'country_topic' | 'country_indicator'; 43+ | 'structural_break'
44+ | 'trend_reversal'
45+ | 'volatility_spike';
46+export type SearchHitType = 'country' | 'indicator' | 'topic' | 'region' | 'source' | 'country_topic' | 'country_indicator' | 'action';
44 export type TopicId = 47 export type TopicId =
45 | 'economy' 48 | 'economy'
46 | 'government' 49 | 'government'
@@ -411,12 +414,21 @@ export interface DnaDimensionRow {
411 value: number | null; 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 export interface DNAResponse { 424 export interface DNAResponse {
415 meta: Meta; 425 meta: Meta;
416 country: CountryCard; 426 country: CountryCard;
417 dims: Partial<Record<DnaDimension, number | null>>; 427 dims: Partial<Record<DnaDimension, number | null>>;
418 year_ref: number | null; 428 year_ref: number | null;
419 dimensions: DnaDimensionRow[]; 429 dimensions: DnaDimensionRow[];
430+ /** Present when `?reference=` was requested (API 1.1). */
431+ reference?: DnaReference | null;
420 } 432 }
421 433
422 // ---------------------------------------------------------------------------------------------- maps & rankings 434 // ---------------------------------------------------------------------------------------------- maps & rankings
@@ -542,6 +554,8 @@ export interface SearchHit {
542 country: CountryCard | null; 554 country: CountryCard | null;
543 topic: string | null; 555 topic: string | null;
544 indicator: string | null; 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 export interface SearchResponse { 561 export interface SearchResponse {
548 562
Menu

Theme