SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%

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 12 days ago (Sep 12, 2026) parent b993f86

52 changed files +3,077 −491

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