SPB Git

spb/countryatlas

Public

Core pages 2.0: home (hero map, world pulse, movers, ticker, trajectory teaser), country page 2.0 (story, timeline, DNA reference, similar 2.0, mini map), compare 2.0 (head-to-head, percentile/change modes, PNG export), rankings 2.0 (filters, views, rank race), indicator page 2.0 (frames map, distribution, related, quality), regions compare, /peers, changes 2.0, search intents

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 12, 2026) parent b993f86 Browse files

52 changed files +3,077 −491

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

Theme