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