SPB Git

spb/countryatlas

Public

Foundation for the 2.0 upgrade: analytics API contract, stats engine, design tokens, navigation, shared controls

- docs/API.md: contract for the analytics endpoints (pulse, movers, extremes, scatter, trajectory, finder, peers, related, distribution, frames, race, regions/compare, story, quality, updates, search intents) and change-detection 2.0 kinds
- src/countryatlas/stats.py + tests: deterministic YoY/CAGR/rolling/trend/volatility, ranks/percentiles/z-scores, records, structural break, persistent reversal, Pearson/Spearman/OLS/Theil–Sen, quantile breaks, histogram, convergence
- web: direction-of-change tokens (inc/dec, diverging ramp, second sequential ramp), container/full-bleed/ticker/badge utilities, styled range input
- web: navigation 2.0 (Explore · Countries · Compare · Rankings · Indicators · Changes · More ▾; mobile Explore · Countries · Compare · Rank · Search), MainFrame full-bleed for explorers, routes for every new page, i18n split (core/flagship/platform), types-analytics.ts, useUrlState, YearSlider, IndicatorSelect/Segmented
- fix: compare preset label and member count were adjacent without a separator ("G7 7 countries" read as "G77 countries"); package.json mock-api script pointed at a missing file

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

21 changed files +1,856 −23

modified apps/web/package.json +2 −1
@@ -9,7 +9,8 @@
9 "typecheck": "tsc -p tsconfig.json --noEmit", 9 "typecheck": "tsc -p tsconfig.json --noEmit",
10 "lint": "next lint", 10 "lint": "next lint",
11 "qa": "node qa/screens.mjs", 11 "qa": "node qa/screens.mjs",
12− "mock-api": "node qa/mock-api.mjs" 12+ "qa:polish": "node qa/polish-sweep.mjs",
13+ "mock-api": "python3 qa/mock_api.py"
13 }, 14 },
14 "dependencies": { 15 "dependencies": {
15 "d3-geo": "^3.1.1", 16 "d3-geo": "^3.1.1",
modified apps/web/src/app/globals.css +146 −0
@@ -45,6 +45,27 @@
45 --seq-6: #184f95; 45 --seq-6: #184f95;
46 --seq-7: #0d366b; 46 --seq-7: #0d366b;
47 47
48+ /* Direction of change (neutral semantics: increase / decrease, NOT good / bad). Teal ↔ sienna diverging ramp. */
49+ --inc: #0f7b8a;
50+ --dec: #b4632d;
51+ --div-1: #8c4a12; /* strong decrease */
52+ --div-2: #c98a4e;
53+ --div-3: #e8c9a6;
54+ --div-4: #ece9e1; /* no change */
55+ --div-5: #a6d4d9;
56+ --div-6: #4fa2ad;
57+ --div-7: #0b5f6b; /* strong increase */
58+ /* Second sequential ramp (warm) for a second map layer / category maps */
59+ --seq2-1: #fbe4d0;
60+ --seq2-2: #f5c39c;
61+ --seq2-3: #eb9a63;
62+ --seq2-4: #d97636;
63+ --seq2-5: #b55b21;
64+ --seq2-6: #8a4416;
65+ --seq2-7: #5c2d0c;
66+ --map-water: #f4f2ec;
67+ --map-stroke: #ffffff;
68+
48 --shadow-sheet: 0 -8px 32px rgba(26, 25, 23, 0.12); 69 --shadow-sheet: 0 -8px 32px rgba(26, 25, 23, 0.12);
49 --shadow-pop: 0 4px 24px rgba(26, 25, 23, 0.14); 70 --shadow-pop: 0 4px 24px rgba(26, 25, 23, 0.14);
50 } 71 }
@@ -85,6 +106,25 @@
85 --seq-6: #9ec5f4; 106 --seq-6: #9ec5f4;
86 --seq-7: #cde2fb; 107 --seq-7: #cde2fb;
87 108
109+ --inc: #4fb6c2;
110+ --dec: #e0955f;
111+ --div-1: #d58a4a;
112+ --div-2: #a86a37;
113+ --div-3: #5e4530;
114+ --div-4: #2a2926;
115+ --div-5: #234f55;
116+ --div-6: #2e8791;
117+ --div-7: #63c3cf;
118+ --seq2-1: #3a2410;
119+ --seq2-2: #5c3512;
120+ --seq2-3: #8a4a16;
121+ --seq2-4: #b8641f;
122+ --seq2-5: #d98a3c;
123+ --seq2-6: #eeb277;
124+ --seq2-7: #f8dcb8;
125+ --map-water: #101010;
126+ --map-stroke: #1c1c1a;
127+
88 --shadow-sheet: 0 -8px 32px rgba(0, 0, 0, 0.5); 128 --shadow-sheet: 0 -8px 32px rgba(0, 0, 0, 0.5);
89 --shadow-pop: 0 4px 24px rgba(0, 0, 0, 0.55); 129 --shadow-pop: 0 4px 24px rgba(0, 0, 0, 0.55);
90 } 130 }
@@ -121,6 +161,20 @@
121 --color-seq-5: var(--seq-5); 161 --color-seq-5: var(--seq-5);
122 --color-seq-6: var(--seq-6); 162 --color-seq-6: var(--seq-6);
123 --color-seq-7: var(--seq-7); 163 --color-seq-7: var(--seq-7);
164+ --color-inc: var(--inc);
165+ --color-dec: var(--dec);
166+ --color-div-1: var(--div-1);
167+ --color-div-2: var(--div-2);
168+ --color-div-3: var(--div-3);
169+ --color-div-4: var(--div-4);
170+ --color-div-5: var(--div-5);
171+ --color-div-6: var(--div-6);
172+ --color-div-7: var(--div-7);
173+ --color-map-water: var(--map-water);
174+
175+ /* Container widths: editorial pages ~1280, wide tables 1400, explorers = viewport. */
176+ --container-editorial: 80rem;
177+ --container-wide: 87.5rem;
124 178
125 --font-ui: var(--font-ui), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif; 179 --font-ui: var(--font-ui), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
126 --font-display: var(--font-display), 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; 180 --font-display: var(--font-display), 'Iowan Old Style', 'Palatino Linotype', Georgia, serif;
@@ -280,6 +334,98 @@
280 } 334 }
281 } 335 }
282 336
337+@utility container-editorial {
338+ max-width: var(--container-editorial);
339+ margin-left: auto;
340+ margin-right: auto;
341+}
342+@utility container-wide {
343+ max-width: var(--container-wide);
344+ margin-left: auto;
345+ margin-right: auto;
346+}
347+/* Break out of the centred container to the viewport edges. */
348+@utility full-bleed {
349+ width: 100vw;
350+ margin-left: calc(50% - 50vw);
351+ margin-right: calc(50% - 50vw);
352+}
353+/* Horizontal ticker strip: snap-scrolling figures on one rule, no scrollbar. */
354+@utility ticker {
355+ display: flex;
356+ overflow-x: auto;
357+ scroll-snap-type: x proximity;
358+ scrollbar-width: none;
359+ &::-webkit-scrollbar {
360+ display: none;
361+ }
362+ & > * {
363+ scroll-snap-align: start;
364+ flex: 0 0 auto;
365+ }
366+}
367+/* Small data badges (quality, freshness, direction). Text + glyph always accompany the colour. */
368+@utility badge {
369+ display: inline-flex;
370+ align-items: center;
371+ gap: 0.25rem;
372+ border-radius: 2px;
373+ padding: 1px 6px;
374+ font-size: var(--text-2xs);
375+ line-height: 1rem;
376+ font-weight: 500;
377+ letter-spacing: 0.02em;
378+ border: 1px solid var(--rule);
379+ color: var(--ink-2);
380+}
381+
382+/* Range inputs (year slider): large thumb, hairline track, accent fill via CSS var --pct set by the component. */
383+.ca-range {
384+ -webkit-appearance: none;
385+ appearance: none;
386+ background: transparent;
387+ cursor: pointer;
388+}
389+.ca-range:focus-visible {
390+ outline: none;
391+}
392+.ca-range::-webkit-slider-runnable-track {
393+ height: 4px;
394+ border-radius: 2px;
395+ background: var(--rule-strong);
396+}
397+.ca-range::-moz-range-track {
398+ height: 4px;
399+ border-radius: 2px;
400+ background: var(--rule-strong);
401+}
402+.ca-range::-webkit-slider-thumb {
403+ -webkit-appearance: none;
404+ appearance: none;
405+ width: 22px;
406+ height: 22px;
407+ margin-top: -9px;
408+ border-radius: 50%;
409+ background: var(--accent);
410+ border: 3px solid var(--surface);
411+ box-shadow: 0 0 0 1px var(--rule-strong);
412+}
413+.ca-range::-moz-range-thumb {
414+ width: 22px;
415+ height: 22px;
416+ border-radius: 50%;
417+ background: var(--accent);
418+ border: 3px solid var(--surface);
419+ box-shadow: 0 0 0 1px var(--rule-strong);
420+}
421+.ca-range:focus-visible::-webkit-slider-thumb {
422+ box-shadow: 0 0 0 3px var(--accent-soft), 0 0 0 4px var(--accent);
423+}
424+.ca-range:disabled {
425+ opacity: 0.4;
426+ cursor: default;
427+}
428+
283 /* Chart chrome shared by the SVG kit (class names instead of inline styles so theme swaps repaint). */ 429 /* Chart chrome shared by the SVG kit (class names instead of inline styles so theme swaps repaint). */
284 .ca-chart text { 430 .ca-chart text {
285 font-family: var(--font-ui); 431 font-family: var(--font-ui);
modified apps/web/src/app/layout.tsx +2 −3
@@ -5,6 +5,7 @@ import { fontDisplay, fontUi } from '@/lib/fonts';
5 import { SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site'; 5 import { SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
6 import { ProvenanceProvider } from '@/components/data/provenance-context'; 6 import { ProvenanceProvider } from '@/components/data/provenance-context';
7 import { ProvenanceSheet } from '@/components/data/provenance-sheet'; 7 import { ProvenanceSheet } from '@/components/data/provenance-sheet';
8+import { MainFrame } from '@/components/layout/main-frame';
8 import { MobileTabBar } from '@/components/layout/mobile-tab-bar'; 9 import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
9 import { SearchContainer } from '@/components/layout/search-container'; 10 import { SearchContainer } from '@/components/layout/search-container';
10 import { SearchProvider } from '@/components/layout/search-context'; 11 import { SearchProvider } from '@/components/layout/search-context';
@@ -47,9 +48,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
47 <ProvenanceProvider> 48 <ProvenanceProvider>
48 <SearchProvider> 49 <SearchProvider>
49 <SiteHeader /> 50 <SiteHeader />
50− <main id="main" className="container-x mx-auto w-full max-w-[1400px] flex-1"> 51+ <MainFrame>{children}</MainFrame>
51− {children}
52− </main>
53 <SiteFooter /> 52 <SiteFooter />
54 <MobileTabBar /> 53 <MobileTabBar />
55 <SearchContainer /> 54 <SearchContainer />
modified apps/web/src/components/compare/compare-builder.tsx +5 −2
@@ -153,9 +153,12 @@ export function CompareBuilder({ countries, peers, initial = [] }: { countries:
153 <li key={p.id} className="border-t border-rule"> 153 <li key={p.id} className="border-t border-rule">
154 <div className="flex min-h-[64px] items-center gap-3 py-2.5"> 154 <div className="flex min-h-[64px] items-center gap-3 py-2.5">
155 <Link href={routes.compare(...members.map((m) => m.slug))} className="group min-w-0 flex-1"> 155 <Link href={routes.compare(...members.map((m) => m.slug))} className="group min-w-0 flex-1">
156− <span className="flex items-center gap-2"> 156+ <span className="flex items-baseline gap-2">
157 <span className="text-sm font-medium text-ink group-hover:text-accent">{p.label}</span> 157 <span className="text-sm font-medium text-ink group-hover:text-accent">{p.label}</span>
158− <span className="tnum text-2xs text-ink-3">{t('compare.presets.count', { n: members.length })}</span> 158+ <span className="tnum text-2xs text-ink-3" aria-label={t('compare.presets.count', { n: members.length })}>
159+ <span aria-hidden className="mr-1.5">·</span>
160+ {t('compare.presets.count', { n: members.length })}
161+ </span>
159 </span> 162 </span>
160 <span className="mt-0.5 flex flex-wrap gap-x-1 text-base leading-tight" aria-hidden> 163 <span className="mt-0.5 flex flex-wrap gap-x-1 text-base leading-tight" aria-hidden>
161 {members.map((m) => ( 164 {members.map((m) => (
added apps/web/src/components/controls/indicator-select.tsx +183 −0
@@ -0,0 +1,183 @@
1+'use client';
2+import { ChevronDown, Search, X } from 'lucide-react';
3+import { useEffect, useId, useMemo, useRef, useState } from 'react';
4+import { t } from '@/i18n';
5+import { cn } from '@/lib/cn';
6+import { topicById } from '@/lib/topics';
7+import type { IndicatorSummary } from '@/lib/types';
8+
9+export interface IndicatorOption {
10+ slug: string;
11+ name: string;
12+ short_name?: string | null;
13+ topic?: string | null;
14+ unit?: string | null;
15+ featured?: boolean | null;
16+ first_year?: number | null;
17+ last_year?: number | null;
18+ n_countries?: number | null;
19+}
20+
21+export function toIndicatorOption(i: IndicatorSummary): IndicatorOption {
22+ return { 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 };
23+}
24+
25+/**
26+ * Indicator picker for the analytical views: a button showing the current indicator, opening a searchable
27+ * list grouped by topic (featured first). Client-side filtering over the full list (≈ 260 rows, passed from
28+ * the server). Keyboard: type to filter, ↑↓ ↵ esc. Closes on outside pointerdown.
29+ */
30+export function IndicatorSelect({
31+ options,
32+ value,
33+ onChange,
34+ label,
35+ className,
36+ size = 'md',
37+ align = 'left',
38+}: {
39+ options: IndicatorOption[];
40+ value: string;
41+ onChange: (slug: string) => void;
42+ label?: string;
43+ className?: string;
44+ size?: 'sm' | 'md';
45+ align?: 'left' | 'right';
46+}) {
47+ const id = useId();
48+ const [open, setOpen] = useState(false);
49+ const [q, setQ] = useState('');
50+ const [active, setActive] = useState(0);
51+ const wrap = useRef<HTMLDivElement>(null);
52+ const input = useRef<HTMLInputElement>(null);
53+ const current = options.find((o) => o.slug === value) ?? null;
54+
55+ useEffect(() => {
56+ if (!open) return;
57+ setTimeout(() => input.current?.focus(), 20);
58+ const onDoc = (e: PointerEvent) => {
59+ if (!wrap.current?.contains(e.target as Node)) setOpen(false);
60+ };
61+ document.addEventListener('pointerdown', onDoc);
62+ return () => document.removeEventListener('pointerdown', onDoc);
63+ }, [open]);
64+
65+ const groups = useMemo(() => {
66+ const ql = q.trim().toLowerCase();
67+ const pool = ql ? options.filter((o) => `${o.name} ${o.short_name ?? ''} ${o.slug} ${o.unit ?? ''}`.toLowerCase().includes(ql)) : options;
68+ const featured = ql ? [] : pool.filter((o) => o.featured);
69+ const byTopic = new Map<string, IndicatorOption[]>();
70+ for (const o of pool) {
71+ const k = o.topic ?? 'other';
72+ if (!byTopic.has(k)) byTopic.set(k, []);
73+ byTopic.get(k)!.push(o);
74+ }
75+ const out: Array<{ key: string; label: string; items: IndicatorOption[] }> = [];
76+ if (featured.length) out.push({ key: 'featured', label: t('control.featured'), items: featured });
77+ for (const [k, items] of Array.from(byTopic.entries()).sort((a, b) => (topicById(a[0])?.order ?? 99) - (topicById(b[0])?.order ?? 99))) out.push({ key: k, label: topicById(k)?.name ?? k, items });
78+ return out;
79+ }, [options, q]);
80+ const flat = useMemo(() => groups.flatMap((g) => g.items), [groups]);
81+
82+ useEffect(() => setActive(0), [q]);
83+
84+ const pick = (o: IndicatorOption) => {
85+ onChange(o.slug);
86+ setOpen(false);
87+ setQ('');
88+ };
89+ const onKey = (e: React.KeyboardEvent) => {
90+ if (e.key === 'ArrowDown') {
91+ e.preventDefault();
92+ setActive((a) => Math.min(flat.length - 1, a + 1));
93+ } else if (e.key === 'ArrowUp') {
94+ e.preventDefault();
95+ setActive((a) => Math.max(0, a - 1));
96+ } else if (e.key === 'Enter') {
97+ e.preventDefault();
98+ const o = flat[active];
99+ if (o) pick(o);
100+ } else if (e.key === 'Escape') {
101+ setOpen(false);
102+ }
103+ };
104+ useEffect(() => {
105+ if (!open) return;
106+ const el = wrap.current?.querySelector<HTMLElement>(`[data-idx="${active}"]`);
107+ el?.scrollIntoView({ block: 'nearest' });
108+ }, [active, open]);
109+
110+ return (
111+ <div ref={wrap} className={cn('relative min-w-0', className)}>
112+ <button
113+ type="button"
114+ onClick={() => setOpen((o) => !o)}
115+ aria-haspopup="listbox"
116+ aria-expanded={open}
117+ aria-controls={`${id}-list`}
118+ className={cn('flex w-full min-w-0 items-center gap-2 rounded-sm border border-rule bg-surface text-left text-ink hover:border-rule-strong', size === 'sm' ? 'h-11 px-2.5 text-sm md:h-9' : 'h-12 px-3 text-base md:h-10 md:text-sm')}
119+ >
120+ <span className="min-w-0 flex-1">
121+ {label ? <span className="block text-2xs uppercase tracking-wide text-ink-3">{label}</span> : null}
122+ <span className="block truncate font-medium">{current?.short_name ?? current?.name ?? value}</span>
123+ </span>
124+ <ChevronDown size={15} aria-hidden className={cn('shrink-0 text-ink-3 transition-transform', open && 'rotate-180')} />
125+ </button>
126+ {open ? (
127+ <div className={cn('absolute top-full z-40 mt-1 w-[min(92vw,28rem)] rounded-sm border border-rule bg-surface shadow-pop', align === 'right' ? 'right-0' : 'left-0')}>
128+ <div className="relative border-b border-rule p-2">
129+ <Search size={14} aria-hidden className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-ink-3" />
130+ <input ref={input} type="search" value={q} onChange={(e) => setQ(e.target.value)} onKeyDown={onKey} placeholder={t('control.searchIndicators')} aria-label={t('control.searchIndicators')} role="combobox" aria-expanded={open} aria-controls={`${id}-list`} aria-autocomplete="list" aria-activedescendant={flat[active] ? `${id}-opt-${flat[active].slug}` : undefined} autoComplete="off" className="h-10 w-full rounded-sm border border-rule bg-paper pl-8 pr-8 text-sm outline-none placeholder:text-ink-3 focus:border-accent" />
131+ {q ? (
132+ <button type="button" onClick={() => setQ('')} className="absolute right-3 top-1/2 grid h-8 w-8 -translate-y-1/2 place-items-center text-ink-3 hover:text-ink" aria-label={t('search.clear')}>
133+ <X size={13} aria-hidden />
134+ </button>
135+ ) : null}
136+ </div>
137+ <ul id={`${id}-list`} role="listbox" className="max-h-[min(60vh,22rem)] overflow-y-auto py-1">
138+ {flat.length === 0 ? <li className="px-3 py-3 text-sm text-ink-3">{t('control.noMatch')}</li> : null}
139+ {groups.map((g) => (
140+ <li key={g.key} role="presentation">
141+ <div className="eyebrow px-3 pb-1 pt-2">{g.label}</div>
142+ <ul role="group" aria-label={g.label}>
143+ {g.items.map((o) => {
144+ const i = flat.indexOf(o);
145+ const sel = o.slug === value;
146+ return (
147+ <li key={`${g.key}-${o.slug}`} id={`${id}-opt-${o.slug}`} role="option" aria-selected={sel} data-idx={i}>
148+ <button type="button" onMouseEnter={() => setActive(i)} onClick={() => pick(o)} className={cn('flex min-h-[44px] w-full items-center gap-2 px-3 py-1.5 text-left text-sm md:min-h-[36px]', i === active ? 'bg-surface-2' : 'hover:bg-surface-2/60', sel && 'font-medium text-accent')}>
149+ <span className="min-w-0 flex-1">
150+ <span className="block truncate">{o.name}</span>
151+ <span className="tnum block truncate text-2xs text-ink-3">
152+ {o.unit ?? ''}
153+ {o.first_year && o.last_year ? ` · ${o.first_year}–${o.last_year}` : ''}
154+ {o.n_countries != null ? ` · ${o.n_countries} countries` : ''}
155+ </span>
156+ </span>
157+ </button>
158+ </li>
159+ );
160+ })}
161+ </ul>
162+ </li>
163+ ))}
164+ </ul>
165+ </div>
166+ ) : null}
167+ </div>
168+ );
169+}
170+
171+/** Small segmented control (radio group look) used by the analytical views for view/mode switches. */
172+export function Segmented<T extends string>({ value, onChange, options, label, className, size = 'md' }: { value: T; onChange: (v: T) => void; options: Array<{ value: T; label: string; icon?: React.ReactNode }>; label: string; className?: string; size?: 'sm' | 'md' }) {
173+ return (
174+ <div role="radiogroup" aria-label={label} className={cn('inline-flex max-w-full items-center gap-0.5 rounded-sm border border-rule bg-surface p-0.5', className)}>
175+ {options.map((o) => (
176+ <button key={o.value} type="button" role="radio" aria-checked={value === o.value} onClick={() => onChange(o.value)} className={cn('inline-flex shrink-0 items-center gap-1 rounded-xs px-2.5 text-sm', size === 'sm' ? 'h-9 md:h-8 md:px-2 md:text-xs' : 'h-10 md:h-8', value === o.value ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
177+ {o.icon}
178+ <span className="truncate">{o.label}</span>
179+ </button>
180+ ))}
181+ </div>
182+ );
183+}
added apps/web/src/components/controls/year-slider.tsx +155 −0
@@ -0,0 +1,155 @@
1+'use client';
2+import { Pause, Play, SkipBack, SkipForward } from 'lucide-react';
3+import { useCallback, useEffect, useId, useRef, useState } from 'react';
4+import { t } from '@/i18n';
5+import { cn } from '@/lib/cn';
6+
7+/**
8+ * Time machine control: a year slider over an explicit list of available years (gaps allowed), with
9+ * play / pause (steps every `interval` ms, loops back to the first year), first / last jumps, keyboard
10+ * arrows on the range input, a large tap-friendly thumb and a compact year readout. Fully controlled:
11+ * the parent owns `year` (usually mirrored in the URL via `useUrlState`).
12+ */
13+export function YearSlider({
14+ years,
15+ year,
16+ onChange,
17+ playable = true,
18+ interval = 700,
19+ compact = false,
20+ className,
21+ label,
22+ autoplay = false,
23+ onPlayingChange,
24+ ticks = true,
25+}: {
26+ years: number[];
27+ year: number;
28+ onChange: (year: number) => void;
29+ playable?: boolean;
30+ interval?: number;
31+ compact?: boolean;
32+ className?: string;
33+ label?: string;
34+ autoplay?: boolean;
35+ onPlayingChange?: (playing: boolean) => void;
36+ ticks?: boolean;
37+}) {
38+ const id = useId();
39+ const [playing, setPlaying] = useState(autoplay);
40+ const idx = Math.max(0, years.indexOf(year));
41+ const timer = useRef<ReturnType<typeof setInterval> | null>(null);
42+ const idxRef = useRef(idx);
43+ idxRef.current = idx;
44+
45+ const stop = useCallback(() => {
46+ setPlaying(false);
47+ }, []);
48+
49+ useEffect(() => {
50+ onPlayingChange?.(playing);
51+ if (!playing) {
52+ if (timer.current) clearInterval(timer.current);
53+ timer.current = null;
54+ return;
55+ }
56+ if (years.length < 2) {
57+ setPlaying(false);
58+ return;
59+ }
60+ timer.current = setInterval(() => {
61+ const next = idxRef.current + 1;
62+ if (next >= years.length) {
63+ setPlaying(false);
64+ return;
65+ }
66+ onChange(years[next]!);
67+ }, interval);
68+ return () => {
69+ if (timer.current) clearInterval(timer.current);
70+ };
71+ // eslint-disable-next-line react-hooks/exhaustive-deps
72+ }, [playing, interval, years.length]);
73+
74+ const first = years[0] ?? year;
75+ const last = years[years.length - 1] ?? year;
76+ const start = () => {
77+ if (idxRef.current >= years.length - 1) onChange(first);
78+ setPlaying(true);
79+ };
80+ const tickYears = ticks ? pickTicks(years, compact ? 3 : 6) : [];
81+
82+ return (
83+ <div className={cn('min-w-0', className)} role="group" aria-label={label ?? t('control.year')}>
84+ <div className="flex items-center gap-2">
85+ {playable ? (
86+ <div className="flex shrink-0 items-center">
87+ <button type="button" onClick={() => { stop(); onChange(first); }} disabled={idx === 0} className="tap hidden place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30 sm:grid md:min-h-[36px] md:min-w-[36px]" aria-label={t('control.first')}>
88+ <SkipBack size={15} aria-hidden />
89+ </button>
90+ <button type="button" onClick={() => (playing ? stop() : start())} className={cn('tap grid place-items-center rounded-sm md:min-h-[36px] md:min-w-[36px]', playing ? 'bg-ink text-paper' : 'bg-accent text-accent-ink hover:opacity-90')} aria-label={playing ? t('control.pause') : t('control.play')} aria-pressed={playing}>
91+ {playing ? <Pause size={16} aria-hidden /> : <Play size={16} aria-hidden />}
92+ </button>
93+ <button type="button" onClick={() => { stop(); onChange(last); }} disabled={idx >= years.length - 1} className="tap hidden place-items-center rounded-sm text-ink-2 hover:bg-surface-2 disabled:opacity-30 sm:grid md:min-h-[36px] md:min-w-[36px]" aria-label={t('control.last')}>
94+ <SkipForward size={15} aria-hidden />
95+ </button>
96+ </div>
97+ ) : null}
98+ <div className="relative min-w-0 flex-1">
99+ <input
100+ id={`${id}-range`}
101+ type="range"
102+ min={0}
103+ max={Math.max(0, years.length - 1)}
104+ step={1}
105+ value={idx}
106+ onChange={(e) => {
107+ stop();
108+ onChange(years[Number(e.target.value)] ?? year);
109+ }}
110+ aria-label={label ?? t('control.year')}
111+ aria-valuetext={String(year)}
112+ aria-valuemin={first}
113+ aria-valuemax={last}
114+ aria-valuenow={year}
115+ className="ca-range h-11 w-full min-w-0 md:h-9"
116+ style={{ touchAction: 'pan-y' }}
117+ disabled={years.length < 2}
118+ />
119+ {tickYears.length ? (
120+ <div aria-hidden className="tnum pointer-events-none relative -mt-1 h-3 text-2xs text-ink-3">
121+ {tickYears.map((y) => {
122+ const pos = years.length > 1 ? (years.indexOf(y) / (years.length - 1)) * 100 : 0;
123+ return (
124+ <span key={y} className="absolute -translate-x-1/2" style={{ left: `calc(${pos}% )` }}>
125+ {y}
126+ </span>
127+ );
128+ })}
129+ </div>
130+ ) : null}
131+ </div>
132+ <output htmlFor={`${id}-range`} className={cn('tnum shrink-0 text-right font-semibold text-ink', compact ? 'w-12 text-base' : 'w-16 text-xl md:text-2xl')}>
133+ {year}
134+ </output>
135+ </div>
136+ </div>
137+ );
138+}
139+
140+/** Evenly spaced tick years including first and last, snapping to round decades when the span allows. */
141+export function pickTicks(years: number[], n: number): number[] {
142+ if (years.length < 2) return years;
143+ const first = years[0]!;
144+ const last = years[years.length - 1]!;
145+ const span = last - first;
146+ const step = span / Math.max(1, n - 1);
147+ const out = new Set<number>([first, last]);
148+ for (let i = 1; i < n - 1; i++) {
149+ const target = first + i * step;
150+ const decade = Math.round(target / 10) * 10;
151+ const candidate = years.includes(decade) ? decade : years.reduce((a, b) => (Math.abs(b - target) < Math.abs(a - target) ? b : a), first);
152+ if (candidate - first > step / 2 && last - candidate > step / 2) out.add(candidate);
153+ }
154+ return Array.from(out).sort((a, b) => a - b);
155+}
added apps/web/src/components/layout/main-frame.tsx +25 −0
@@ -0,0 +1,25 @@
1+'use client';
2+import { usePathname } from 'next/navigation';
3+import type { ReactNode } from 'react';
4+import { cn } from '@/lib/cn';
5+
6+/** Routes rendered edge to edge (full-viewport explorers): no horizontal padding, no max width. */
7+export const FULL_BLEED_PREFIXES = ['/explore', '/trajectories'];
8+
9+export function isFullBleed(pathname: string): boolean {
10+ return FULL_BLEED_PREFIXES.some((p) => pathname === p || pathname.startsWith(`${p}/`) || pathname.startsWith(`${p}?`));
11+}
12+
13+/**
14+ * `<main>` wrapper: editorial container (1400 px, side padding) for content pages, full bleed for the
15+ * explorer views. Pathname-driven so the App Router tree needs no route-group shuffle.
16+ */
17+export function MainFrame({ children }: { children: ReactNode }) {
18+ const pathname = usePathname();
19+ const full = isFullBleed(pathname);
20+ return (
21+ <main id="main" data-layout={full ? 'full' : 'page'} className={cn('w-full flex-1', full ? 'min-w-0' : 'container-x mx-auto max-w-[1400px]')}>
22+ {children}
23+ </main>
24+ );
25+}
modified apps/web/src/components/layout/mobile-tab-bar.tsx +11 −7
@@ -1,30 +1,34 @@
1 'use client'; 1 'use client';
2−import { BarChart3, Globe2, Home, Scale, Search } from 'lucide-react'; 2+import { BarChart3, Globe2, Map, Scale, Search } from 'lucide-react';
3 import Link from 'next/link'; 3 import Link from 'next/link';
4 import { usePathname } from 'next/navigation'; 4 import { usePathname } from 'next/navigation';
5 import { t } from '@/i18n'; 5 import { t } from '@/i18n';
6 import { cn } from '@/lib/cn'; 6 import { cn } from '@/lib/cn';
7 import { routes } from '@/lib/site'; 7 import { routes } from '@/lib/site';
8+import { isFullBleed } from './main-frame';
8 import { useOpenSearch } from './search-context'; 9 import { useOpenSearch } from './search-context';
9 10
10 /** 11 /**
11− * Fixed bottom tab bar (< md): Home · Countries · Compare · Rankings · Search. Safe-area padding; the 12+ * Fixed bottom tab bar (< md): Explore · Countries · Compare · Rank · Search. Safe-area padding; the body
12− * body reserves space (`pb-[calc(56px+env(safe-area-inset-bottom))]`) so it never covers the footer. 13+ * reserves space (`pb-[calc(56px+env(safe-area-inset-bottom))]`) so it never covers the footer. Hidden on the
14+ * full-viewport explorers, which carry their own bottom controls.
13 */ 15 */
14 export function MobileTabBar() { 16 export function MobileTabBar() {
15 const pathname = usePathname(); 17 const pathname = usePathname();
16 const openSearch = useOpenSearch(); 18 const openSearch = useOpenSearch();
19+ if (isFullBleed(pathname)) return null;
17 const tabs = [ 20 const tabs = [
18− { href: routes.home(), label: t('nav.home'), Icon: Home, exact: true }, 21+ { href: routes.explore(), label: t('nav.explore'), Icon: Map },
19 { href: routes.countries(), label: t('nav.countries'), Icon: Globe2 }, 22 { href: routes.countries(), label: t('nav.countries'), Icon: Globe2 },
20 { href: routes.compare(), label: t('nav.compare'), Icon: Scale }, 23 { href: routes.compare(), label: t('nav.compare'), Icon: Scale },
21− { href: routes.rankings(), label: t('nav.rankings'), Icon: BarChart3 }, 24+ { href: routes.rankings(), label: t('nav.rank'), Icon: BarChart3 },
22 ]; 25 ];
23 return ( 26 return (
24 <nav aria-label={t('nav.primary')} className="fixed inset-x-0 bottom-0 z-30 border-t border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/90 md:hidden safe-bottom"> 27 <nav aria-label={t('nav.primary')} className="fixed inset-x-0 bottom-0 z-30 border-t border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/90 md:hidden safe-bottom">
25 <ul className="grid h-14 grid-cols-5"> 28 <ul className="grid h-14 grid-cols-5">
26− {tabs.map(({ href, label, Icon, exact }) => { 29+ {tabs.map(({ href, label, Icon }) => {
27− const active = exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`); 30+ const base = href.split('?')[0]!;
31+ const active = pathname === base || pathname.startsWith(`${base}/`);
28 return ( 32 return (
29 <li key={href} className="min-w-0"> 33 <li key={href} className="min-w-0">
30 <Link href={href} aria-current={active ? 'page' : undefined} className={cn('flex h-full flex-col items-center justify-center gap-0.5 text-2xs', active ? 'text-accent' : 'text-ink-2')}> 34 <Link href={href} aria-current={active ? 'page' : undefined} className={cn('flex h-full flex-col items-center justify-center gap-0.5 text-2xs', active ? 'text-accent' : 'text-ink-2')}>
added apps/web/src/components/layout/more-menu.tsx +77 −0
@@ -0,0 +1,77 @@
1+'use client';
2+import { ChevronDown } from 'lucide-react';
3+import Link from 'next/link';
4+import { usePathname } from 'next/navigation';
5+import { useEffect, useId, useRef, useState } from 'react';
6+import { t } from '@/i18n';
7+import { cn } from '@/lib/cn';
8+
9+interface Item {
10+ href: string;
11+ label: string;
12+ group: 'explore' | 'reference';
13+}
14+
15+/** "More ▾" disclosure in the desktop header: two columns (Explore · Reference), keyboard + outside-click closing. */
16+export function MoreMenu({ items }: { items: Item[] }) {
17+ const [open, setOpen] = useState(false);
18+ const id = useId();
19+ const ref = useRef<HTMLDivElement>(null);
20+ const pathname = usePathname();
21+ const active = items.some((it) => pathname === it.href || pathname.startsWith(`${it.href}/`));
22+
23+ useEffect(() => {
24+ setOpen(false);
25+ }, [pathname]);
26+ useEffect(() => {
27+ if (!open) return;
28+ const onDoc = (e: PointerEvent) => {
29+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
30+ };
31+ const onKey = (e: KeyboardEvent) => {
32+ if (e.key === 'Escape') setOpen(false);
33+ };
34+ document.addEventListener('pointerdown', onDoc);
35+ document.addEventListener('keydown', onKey);
36+ return () => {
37+ document.removeEventListener('pointerdown', onDoc);
38+ document.removeEventListener('keydown', onKey);
39+ };
40+ }, [open]);
41+
42+ const groups: Array<{ key: Item['group']; label: string }> = [
43+ { key: 'explore', label: t('site.footer.explore') },
44+ { key: 'reference', label: t('site.footer.reference') },
45+ ];
46+ return (
47+ <div ref={ref} className="relative">
48+ <button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} aria-controls={id} aria-haspopup="menu" className={cn('inline-flex h-9 items-center gap-1 rounded-sm px-2.5 text-sm', active ? 'font-medium text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
49+ {t('nav.more')}
50+ <ChevronDown size={14} aria-hidden className={cn('transition-transform', open && 'rotate-180')} />
51+ </button>
52+ {open ? (
53+ <div id={id} role="menu" aria-label={t('nav.moreMenu')} className="animate-fade absolute left-0 top-full z-40 mt-1 grid w-[26rem] grid-cols-2 gap-x-6 rounded-sm border border-rule bg-surface p-3 shadow-pop">
54+ {groups.map((g) => (
55+ <div key={g.key}>
56+ <div className="eyebrow mb-1 px-2">{g.label}</div>
57+ <ul>
58+ {items
59+ .filter((it) => it.group === g.key)
60+ .map((it) => {
61+ const cur = pathname === it.href || pathname.startsWith(`${it.href}/`);
62+ return (
63+ <li key={it.href}>
64+ <Link href={it.href} role="menuitem" aria-current={cur ? 'page' : undefined} className={cn('flex h-9 items-center rounded-sm px-2 text-sm', cur ? 'font-medium text-ink' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')}>
65+ {it.label}
66+ </Link>
67+ </li>
68+ );
69+ })}
70+ </ul>
71+ </div>
72+ ))}
73+ </div>
74+ ) : null}
75+ </div>
76+ );
77+}
modified apps/web/src/components/layout/site-footer.tsx +11 −3
@@ -11,23 +11,31 @@ import { Logo } from '@/components/brand/Logo';
11 */ 11 */
12 export function SiteFooter({ builtAt, runId }: { builtAt?: string | null; runId?: string | null }) { 12 export function SiteFooter({ builtAt, runId }: { builtAt?: string | null; runId?: string | null }) {
13 const explore = [ 13 const explore = [
14+ { href: routes.explore(), label: t('nav.explore') },
14 { href: routes.countries(), label: t('nav.countries') }, 15 { href: routes.countries(), label: t('nav.countries') },
15 { href: routes.compare(), label: t('nav.compare') }, 16 { href: routes.compare(), label: t('nav.compare') },
16 { href: routes.rankings(), label: t('nav.rankings') }, 17 { href: routes.rankings(), label: t('nav.rankings') },
17 { href: routes.indicators(), label: t('nav.indicators') }, 18 { href: routes.indicators(), label: t('nav.indicators') },
18 { href: routes.regions(), label: t('nav.regions') }, 19 { href: routes.regions(), label: t('nav.regions') },
19 { href: routes.changes(), label: t('site.footer.changes') }, 20 { href: routes.changes(), label: t('site.footer.changes') },
21+ { href: routes.trajectories(), label: t('nav.trajectories') },
22+ { href: routes.scatter(), label: t('nav.scatter') },
23+ { href: routes.finder(), label: t('nav.finder') },
24+ { href: routes.extremes(), label: t('nav.extremes') },
25+ { href: routes.stories(), label: t('nav.stories') },
20 ]; 26 ];
21 const reference = [ 27 const reference = [
22 { href: routes.sources(), label: t('site.footer.sources') }, 28 { href: routes.sources(), label: t('site.footer.sources') },
23 { href: routes.methodology(), label: t('site.footer.methodology') }, 29 { href: routes.methodology(), label: t('site.footer.methodology') },
24 { href: routes.api(), label: t('site.footer.api') }, 30 { href: routes.api(), label: t('site.footer.api') },
25− { href: routes.data(), label: t('site.footer.data') }, 31+ { href: routes.download(), label: t('site.footer.data') },
32+ { href: routes.updates(), label: t('nav.updates') },
33+ { href: routes.peers(), label: t('nav.peers') },
26 ]; 34 ];
27 return ( 35 return (
28 <footer className="mt-16 border-t border-rule bg-surface-2/40 text-sm"> 36 <footer className="mt-16 border-t border-rule bg-surface-2/40 text-sm">
29 <div className="container-x mx-auto max-w-[1400px] py-10"> 37 <div className="container-x mx-auto max-w-[1400px] py-10">
30− <div className="grid gap-8 md:grid-cols-[1.4fr_1fr_1fr]"> 38+ <div className="grid gap-8 md:grid-cols-[1.4fr_1.2fr_1fr]">
31 <div className="max-w-md"> 39 <div className="max-w-md">
32 <Logo variant="full" /> 40 <Logo variant="full" />
33 <p className="mt-3 text-ink-2">{t('site.tagline')}</p> 41 <p className="mt-3 text-ink-2">{t('site.tagline')}</p>
@@ -55,7 +63,7 @@ function FooterColumn({ title, items }: { title: string; items: Array<{ href: st
55 return ( 63 return (
56 <div> 64 <div>
57 <div className="eyebrow mb-2">{title}</div> 65 <div className="eyebrow mb-2">{title}</div>
58− <ul className="space-y-1"> 66+ <ul className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-1">
59 {items.map((it) => ( 67 {items.map((it) => (
60 <li key={it.href}> 68 <li key={it.href}>
61 <Link href={it.href} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent md:min-h-[32px]"> 69 <Link href={it.href} className="inline-flex min-h-[44px] items-center text-ink-2 hover:text-accent md:min-h-[32px]">
modified apps/web/src/components/layout/site-header.tsx +23 −3
@@ -2,20 +2,37 @@ import Link from 'next/link';
2 import { t } from '@/i18n'; 2 import { t } from '@/i18n';
3 import { routes } from '@/lib/site'; 3 import { routes } from '@/lib/site';
4 import { Logo } from '@/components/brand/Logo'; 4 import { Logo } from '@/components/brand/Logo';
5+import { MoreMenu } from './more-menu';
5 import { NavLinks } from './nav-links'; 6 import { NavLinks } from './nav-links';
6 import { SearchTrigger } from './search-trigger'; 7 import { SearchTrigger } from './search-trigger';
7 import { ThemeToggle } from './theme-toggle'; 8 import { ThemeToggle } from './theme-toggle';
8 9
10+/** Primary desktop navigation (order from the product spec); everything else lives under "More". */
9 export const PRIMARY_NAV = [ 11 export const PRIMARY_NAV = [
12+ { key: 'nav.explore', href: routes.explore() },
10 { key: 'nav.countries', href: routes.countries() }, 13 { key: 'nav.countries', href: routes.countries() },
11 { key: 'nav.compare', href: routes.compare() }, 14 { key: 'nav.compare', href: routes.compare() },
12 { key: 'nav.rankings', href: routes.rankings() }, 15 { key: 'nav.rankings', href: routes.rankings() },
13 { key: 'nav.indicators', href: routes.indicators() }, 16 { key: 'nav.indicators', href: routes.indicators() },
14− { key: 'nav.regions', href: routes.regions() }, 17+ { key: 'nav.changes', href: routes.changes() },
15− { key: 'nav.data', href: routes.data() },
16 ] as const; 18 ] as const;
17 19
18−/** Sticky top bar: wordmark, primary nav (md+), search trigger, theme toggle. 52 px tall on mobile, 56 on md. */ 20+export const MORE_NAV: ReadonlyArray<{ key: string; href: string; group: 'explore' | 'reference' }> = [
21+ { key: 'nav.regions', href: routes.regions(), group: 'explore' },
22+ { key: 'nav.trajectories', href: routes.trajectories(), group: 'explore' },
23+ { key: 'nav.scatter', href: routes.scatter(), group: 'explore' },
24+ { key: 'nav.finder', href: routes.finder(), group: 'explore' },
25+ { key: 'nav.extremes', href: routes.extremes(), group: 'explore' },
26+ { key: 'nav.peers', href: routes.peers(), group: 'explore' },
27+ { key: 'nav.stories', href: routes.stories(), group: 'explore' },
28+ { key: 'nav.sources', href: routes.sources(), group: 'reference' },
29+ { key: 'nav.api', href: routes.api(), group: 'reference' },
30+ { key: 'nav.downloads', href: routes.download(), group: 'reference' },
31+ { key: 'nav.updates', href: routes.updates(), group: 'reference' },
32+ { key: 'nav.methodology', href: routes.methodology(), group: 'reference' },
33+];
34+
35+/** Sticky top bar: wordmark, primary nav (md+), "More" menu, search trigger, theme toggle. 52 px tall on mobile, 56 on md. */
19 export function SiteHeader() { 36 export function SiteHeader() {
20 return ( 37 return (
21 <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85"> 38 <header className="sticky top-0 z-30 border-b border-rule bg-paper/95 backdrop-blur supports-[backdrop-filter]:bg-paper/85">
@@ -24,6 +41,9 @@ export function SiteHeader() {
24 <Logo variant="full" /> 41 <Logo variant="full" />
25 </Link> 42 </Link>
26 <NavLinks items={PRIMARY_NAV.map((n) => ({ href: n.href, label: t(n.key) }))} /> 43 <NavLinks items={PRIMARY_NAV.map((n) => ({ href: n.href, label: t(n.key) }))} />
44+ <div className="hidden md:block">
45+ <MoreMenu items={MORE_NAV.map((n) => ({ href: n.href, label: t(n.key as 'nav.regions'), group: n.group }))} />
46+ </div>
27 <div className="ml-auto flex items-center gap-1"> 47 <div className="ml-auto flex items-center gap-1">
28 <div className="hidden md:block"> 48 <div className="hidden md:block">
29 <SearchTrigger variant="field" /> 49 <SearchTrigger variant="field" />
added apps/web/src/i18n/en.core.ts +45 −0
@@ -0,0 +1,45 @@
1+/**
2+ * Strings for the core-page upgrade (home 2.0, country page 2.0, compare, rankings, indicator page, regions).
3+ * Merged into `en.ts`. Same conventions: flat dotted keys, `{name}` placeholders. Owned by the "core" stream —
4+ * other streams add strings to en.flagship.ts / en.platform.ts.
5+ */
6+export const enCore = {
7+ // --- navigation (2.0)
8+ 'nav.explore': 'Explore',
9+ 'nav.changes': 'Changes',
10+ 'nav.more': 'More',
11+ 'nav.rank': 'Rank',
12+ 'nav.trajectories': 'Trajectories',
13+ 'nav.scatter': 'Scatter',
14+ 'nav.finder': 'Finder',
15+ 'nav.extremes': 'Extremes',
16+ 'nav.stories': 'Stories',
17+ 'nav.sources': 'Sources',
18+ 'nav.api': 'API',
19+ 'nav.downloads': 'Downloads',
20+ 'nav.methodology': 'Methodology',
21+ 'nav.updates': 'Data updates',
22+ 'nav.peers': 'Above / below expected',
23+ 'nav.moreMenu': 'More pages',
24+
25+ // --- shared controls
26+ 'control.year': 'Year',
27+ 'control.play': 'Play',
28+ 'control.pause': 'Pause',
29+ 'control.first': 'First year',
30+ 'control.last': 'Latest year',
31+ 'control.featured': 'Featured',
32+ 'control.searchIndicators': 'Search indicators…',
33+ 'control.noMatch': 'No indicator matches.',
34+ 'control.indicator': 'Indicator',
35+ 'control.view': 'View',
36+ 'control.reset': 'Reset view',
37+
38+ // --- semantics
39+ 'semantics.increase': 'Increase',
40+ 'semantics.decrease': 'Decrease',
41+ 'semantics.higherBetter': 'Higher is better',
42+ 'semantics.lowerBetter': 'Lower is better',
43+ 'semantics.noDirection': 'No inherent direction',
44+ 'semantics.note': 'Colours show direction of change, not whether the change is desirable.',
45+} as const;
added apps/web/src/i18n/en.flagship.ts +8 −0
@@ -0,0 +1,8 @@
1+/**
2+ * Strings for the flagship exploration views: World Explorer (/explore), Trajectories, Scatter, Finder, Extremes.
3+ * Merged into `en.ts`. Owned by the "flagship" stream.
4+ */
5+export const enFlagship = {
6+ 'explorer.title': 'World Explorer',
7+ 'explorer.description': 'Full-screen interactive world map of any indicator, with a year slider from 1960 to today. Google Earth for statistics.',
8+} as const;
added apps/web/src/i18n/en.platform.ts +8 −0
@@ -0,0 +1,8 @@
1+/**
2+ * Strings for the platform features: Data Stories, download builder, API explorer, provenance panel 2.0,
3+ * data quality, /updates. Merged into `en.ts`. Owned by the "platform" stream.
4+ */
5+export const enPlatform = {
6+ 'stories.title': 'Data stories',
7+ 'stories.description': 'Interactive stories generated from the CountryAtlas datasets — every chart deterministic, every number sourced.',
8+} as const;
modified apps/web/src/i18n/en.ts +8 −2
@@ -4,14 +4,17 @@
4 * they belong to so diffs stay readable. 4 * they belong to so diffs stay readable.
5 */ 5 */
6 import { enCompare } from './en.compare'; 6 import { enCompare } from './en.compare';
7+import { enCore } from './en.core';
7 import { enExplore } from './en.explore'; 8 import { enExplore } from './en.explore';
9+import { enFlagship } from './en.flagship';
10+import { enPlatform } from './en.platform';
8 11
9 export const en = { 12 export const en = {
10 // --- site 13 // --- site
11 'site.name': 'CountryAtlas', 14 'site.name': 'CountryAtlas',
12− 'site.tagline': 'Understand the world, one country at a time.', 15+ 'site.tagline': 'Explore the world through data.',
13 'site.description': 16 'site.description':
14− 'Country-level data and statistics for 218 countries and territories: economy, population, health, energy, climate and more, every number traceable to its source.', 17+ 'The interactive data atlas of the world: 218 countries and territories, 260+ indicators, 2 million observations across economy, population, health, energy, climate and more — every number traceable to its source.',
15 'site.skip': 'Skip to content', 18 'site.skip': 'Skip to content',
16 'site.licence': 19 'site.licence':
17 'Data: World Bank, IMF, OECD, Eurostat, WHO, Our World in Data, BIS, ILO — CC BY 4.0 where applicable. Each value links to its source.', 20 'Data: World Bank, IMF, OECD, Eurostat, WHO, Our World in Data, BIS, ILO — CC BY 4.0 where applicable. Each value links to its source.',
@@ -379,6 +382,9 @@ export const en = {
379 'og.site': 'countryatlas.co', 382 'og.site': 'countryatlas.co',
380 ...enCompare, 383 ...enCompare,
381 ...enExplore, 384 ...enExplore,
385+ ...enCore,
386+ ...enFlagship,
387+ ...enPlatform,
382 } as const; 388 } as const;
383 389
384 export type DictKey = keyof typeof en; 390 export type DictKey = keyof typeof en;
modified apps/web/src/lib/site.ts +24 −2
@@ -2,8 +2,19 @@
2 export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.countryatlas.co').replace(/\/$/, ''); 2 export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.countryatlas.co').replace(/\/$/, '');
3 export const SITE_NAME = 'CountryAtlas'; 3 export const SITE_NAME = 'CountryAtlas';
4 export const SITE_DOMAIN = 'countryatlas.co'; 4 export const SITE_DOMAIN = 'countryatlas.co';
5−export const TAGLINE = 'Understand the world, one country at a time.'; 5+export const TAGLINE = 'Explore the world through data.';
6 export const OG_SIZE = { width: 1200, height: 630 } as const; 6 export const OG_SIZE = { width: 1200, height: 630 } as const;
7+/** Public API version shown in docs and the footer (path stays /api/v1; the minor tracks additive endpoints). */
8+export const API_VERSION = '1.1';
9+
10+type Q = Record<string, string | number | boolean | null | undefined>;
11+function query(q?: Q): string {
12+ if (!q) return '';
13+ const p = new URLSearchParams();
14+ for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== '' && v !== false) p.set(k, String(v));
15+ const s = p.toString();
16+ return s ? `?${s}` : '';
17+}
7 18
8 /** Route helpers — keep every internal href in one place so the next agent can add pages consistently. */ 19 /** Route helpers — keep every internal href in one place so the next agent can add pages consistently. */
9 export const routes = { 20 export const routes = {
@@ -30,13 +41,24 @@ export const routes = {
30 indicatorDownload: (slug: string, fmt: 'csv' | 'json' = 'csv') => `/api/v1/indicators/${slug}/download.${fmt}`, 41 indicatorDownload: (slug: string, fmt: 'csv' | 'json' = 'csv') => `/api/v1/indicators/${slug}/download.${fmt}`,
31 regions: () => '/regions', 42 regions: () => '/regions',
32 region: (slug: string) => `/regions/${slug}`, 43 region: (slug: string) => `/regions/${slug}`,
44+ regionCompare: (a: string, b: string) => `/regions/compare?a=${a}&b=${b}`,
33 sources: () => '/sources', 45 sources: () => '/sources',
34 source: (id: string) => `/sources/${id}`, 46 source: (id: string) => `/sources/${id}`,
35 methodology: () => '/methodology', 47 methodology: () => '/methodology',
36 data: () => '/data', 48 data: () => '/data',
49+ download: (q?: Q) => `/download${query(q)}`,
37 api: () => '/api', 50 api: () => '/api',
38 changes: () => '/changes', 51 changes: () => '/changes',
39− explore: () => '/explore', 52+ /** World Explorer — full-viewport map + time slider. */
53+ explore: (q?: { indicator?: string; year?: number | null; view?: 'map' | 'rank' | 'trend' | 'distribution'; country?: string | null }) => `/explore${query(q)}`,
54+ trajectories: (q?: { x?: string; y?: string; size?: string; year?: number | null; group?: string | null }) => `/trajectories${query(q)}`,
55+ scatter: (q?: { x?: string; y?: string; size?: string; year?: number | null; group?: string | null; log?: string }) => `/scatter${query(q)}`,
56+ finder: (q?: Q) => `/finder${query(q)}`,
57+ extremes: (q?: { window?: string; topic?: string }) => `/extremes${query(q)}`,
58+ stories: () => '/stories',
59+ story: (slug: string) => `/stories/${slug}`,
60+ updates: () => '/updates',
61+ peers: (q?: { y?: string; x?: string; year?: number | null }) => `/peers${query(q)}`,
40 search: (q: string) => `/search?q=${encodeURIComponent(q)}`, 62 search: (q: string) => `/search?q=${encodeURIComponent(q)}`,
41 apiSwagger: () => '/api/v1/docs', 63 apiSwagger: () => '/api/v1/docs',
42 apiOpenapi: () => '/api/v1/openapi.json', 64 apiOpenapi: () => '/api/v1/openapi.json',
added apps/web/src/lib/types-analytics.ts +423 −0
@@ -0,0 +1,423 @@
1+/**
2+ * TypeScript mirror of the analytics endpoints (docs/API.md § "Analytics endpoints (API 1.1)"). The Python side
3+ * implements exactly these shapes; keep the two in sync. Base cards live in `./types`.
4+ */
5+import type { CountryCard, GroupCard, IndicatorCard, IndicatorSummary, Meta, Provenance } from './types';
6+
7+export type Direction = 'up' | 'down';
8+export type Interpretation = 'improvement' | 'deterioration' | null;
9+export type DirectionSemantics = 'higher_is_better' | 'lower_is_better' | 'neutral';
10+export type MoverWindow = 1 | 5 | 10;
11+export type MoverCategory = 'all' | 'economic' | 'demographic' | 'health' | 'energy' | 'climate' | 'digital' | 'housing' | 'labor';
12+export type MoverKindFilter = 'all' | 'improvement' | 'deterioration' | 'increase' | 'decrease' | 'record' | 'reversal' | 'acceleration' | 'structural';
13+export type ExtremesWindow = '1' | '5' | '10' | '25' | 'since1990';
14+export type QualityBadge = 'fresh' | 'historical' | 'sparse' | 'limited-coverage' | 'stale' | 'flagged' | 'forecast';
15+
16+export interface PointCountry {
17+ id: string;
18+ slug: string | null;
19+ name: string | null;
20+ flag: string | null;
21+ region: string | null;
22+ income: string | null;
23+}
24+
25+// ---------------------------------------------------------------------------------------------- pulse
26+
27+export interface MoverLite {
28+ country: CountryCard;
29+ value: number | null;
30+ ref_value: number | null;
31+ delta: number | null;
32+ delta_pct: number | null;
33+ formatted: string | null;
34+ year: number | null;
35+}
36+
37+export interface Convergence {
38+ direction: 'converging' | 'diverging' | 'stable';
39+ cv_start: number;
40+ cv_end: number;
41+ n: number;
42+ from_year: number;
43+}
44+
45+export interface PulseItem {
46+ indicator: IndicatorCard;
47+ year: number;
48+ n: number;
49+ n_up: number;
50+ n_down: number;
51+ n_flat: number;
52+ share_up: number;
53+ share_down: number;
54+ median_change_abs: number | null;
55+ median_change_pct: number | null;
56+ direction_semantics: DirectionSemantics;
57+ record_highs: number;
58+ record_lows: number;
59+ headline: string;
60+ top_up: MoverLite | null;
61+ top_down: MoverLite | null;
62+ convergence: Convergence | null;
63+ provenance: Provenance | null;
64+}
65+
66+export interface PulseResponse {
67+ meta: Meta;
68+ year_reference: number;
69+ summary: { n_indicators: number; n_countries_reporting: number; n_record_highs: number; n_record_lows: number; n_changes: number };
70+ items: PulseItem[];
71+}
72+
73+// ---------------------------------------------------------------------------------------------- movers / extremes
74+
75+export interface MoverItem {
76+ country: CountryCard;
77+ indicator: IndicatorCard;
78+ kind: string;
79+ year: number | null;
80+ ref_year: number | null;
81+ value: number | null;
82+ ref_value: number | null;
83+ delta: number | null;
84+ delta_pct: number | null;
85+ formatted: string | null;
86+ formatted_ref: string | null;
87+ severity: number | null;
88+ direction: Direction;
89+ interpretation: Interpretation;
90+ headline: string | null;
91+ provenance: Provenance | null;
92+}
93+
94+export interface MoversResponse {
95+ meta: Meta;
96+ window: MoverWindow;
97+ category: MoverCategory;
98+ kind: MoverKindFilter;
99+ min_population: number | null;
100+ filter_note: string | null;
101+ categories: MoverCategory[];
102+ kinds: MoverKindFilter[];
103+ n: number;
104+ items: MoverItem[];
105+}
106+
107+export interface ExtremeRow {
108+ country: CountryCard;
109+ value_start: number | null;
110+ value_end: number | null;
111+ year_start: number | null;
112+ year_end: number | null;
113+ delta: number | null;
114+ delta_pct: number | null;
115+ formatted_start: string | null;
116+ formatted_end: string | null;
117+}
118+
119+export interface ExtremeFacet {
120+ id: string;
121+ title: string;
122+ indicator: IndicatorCard;
123+ direction: Direction;
124+ metric: 'abs' | 'pct' | 'points';
125+ rows: ExtremeRow[];
126+ n: number;
127+ provenance: Provenance | null;
128+}
129+
130+export interface ExtremesResponse {
131+ meta: Meta;
132+ window: ExtremesWindow | string;
133+ from_year: number | null;
134+ to_year: number | null;
135+ min_population: number | null;
136+ filter_note: string | null;
137+ facets: ExtremeFacet[];
138+}
139+
140+// ---------------------------------------------------------------------------------------------- scatter / trajectory / peers
141+
142+export interface ScatterPointRow extends PointCountry {
143+ x: number | null;
144+ y: number | null;
145+ size: number | null;
146+ year_x: number | null;
147+ year_y: number | null;
148+}
149+
150+export interface ScatterStats {
151+ n: number;
152+ pearson: number | null;
153+ spearman: number | null;
154+ ols: { slope: number; intercept: number; r2: number | null } | null;
155+ log_x: boolean;
156+ log_y: boolean;
157+}
158+
159+export interface ScatterResponse {
160+ meta: Meta;
161+ x: IndicatorCard;
162+ y: IndicatorCard;
163+ size: IndicatorCard | null;
164+ year: number | null;
165+ year_used: number | null;
166+ nearest_years: number;
167+ group: GroupCard;
168+ n: number;
169+ points: ScatterPointRow[];
170+ stats: ScatterStats;
171+ note: string;
172+ provenance?: Provenance[];
173+}
174+
175+export interface TrajectoryResponse {
176+ meta: Meta;
177+ x: IndicatorCard;
178+ y: IndicatorCard;
179+ size: IndicatorCard | null;
180+ group: GroupCard;
181+ years: number[];
182+ countries: PointCountry[];
183+ series: Record<string, { x: Array<number | null>; y: Array<number | null>; size: Array<number | null> }>;
184+ domains: { x: [number, number]; y: [number, number]; size: [number, number] | null };
185+ log_x: boolean;
186+ log_y: boolean;
187+ provenance: Provenance[];
188+}
189+
190+export interface PeerPoint extends PointCountry {
191+ x: number | null;
192+ y: number | null;
193+ expected: number | null;
194+ residual: number | null;
195+ residual_z: number | null;
196+}
197+
198+export interface PeersResponse {
199+ meta: Meta;
200+ x: IndicatorCard;
201+ y: IndicatorCard;
202+ year_used: number | null;
203+ n: number;
204+ method: 'theil-sen' | 'ols' | string;
205+ fit: { slope: number; intercept: number; r2: number | null; log_x: boolean; residual_scale: number | null } | null;
206+ points: PeerPoint[];
207+ above: PeerPoint[];
208+ below: PeerPoint[];
209+ pairs: Array<{ x: string; y: string; label: string }>;
210+ note: string;
211+ methodology: string;
212+}
213+
214+// ---------------------------------------------------------------------------------------------- finder
215+
216+export type FinderOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'between';
217+
218+export interface FinderFilter {
219+ indicator: IndicatorCard;
220+ op: FinderOp | string;
221+ value: number;
222+ value2: number | null;
223+ year_used: number | null;
224+}
225+
226+export interface FinderItem {
227+ country: CountryCard;
228+ matched: string[];
229+ values: Record<string, { value: number | null; year: number | null; formatted: string | null; provenance: Provenance | null }>;
230+}
231+
232+export interface FinderResponse {
233+ meta: Meta;
234+ mode: 'and' | 'or';
235+ filters: FinderFilter[];
236+ n_matching: number;
237+ n_evaluated: number;
238+ items: FinderItem[];
239+}
240+
241+// ---------------------------------------------------------------------------------------------- indicator analytics
242+
243+export interface RelatedItem {
244+ indicator: IndicatorCard;
245+ pearson: number | null;
246+ spearman: number | null;
247+ n: number;
248+ year: number | null;
249+ log_x: boolean;
250+ log_y: boolean;
251+ direction: 'positive' | 'negative';
252+}
253+
254+export interface RelatedResponse {
255+ meta: Meta;
256+ indicator: IndicatorCard;
257+ year_used: number | null;
258+ n_candidates: number;
259+ items: RelatedItem[];
260+ note: string;
261+}
262+
263+export interface Histogram {
264+ edges: number[];
265+ counts: number[];
266+ log: boolean;
267+}
268+
269+export interface DistributionResponse {
270+ meta: Meta;
271+ indicator: IndicatorCard;
272+ year: number | null;
273+ year_used: number | null;
274+ n: number;
275+ log: boolean;
276+ histogram: Histogram;
277+ stats: { min: number | null; p10: number | null; p25: number | null; median: number | null; mean: number | null; p75: number | null; p90: number | null; max: number | null };
278+ highlight: { country: CountryCard; value: number | null; percentile: number | null; rank: number | null; n: number | null; region: GroupCard | null; region_median: number | null; income: GroupCard | null; income_median: number | null } | null;
279+ by_region: Array<{ group: GroupCard; median: number | null; n: number }>;
280+ by_income: Array<{ group: GroupCard; median: number | null; n: number }>;
281+ provenance: Provenance | null;
282+}
283+
284+export interface FramesResponse {
285+ meta: Meta;
286+ indicator: IndicatorCard;
287+ group: GroupCard;
288+ years: number[];
289+ values: Record<string, Array<number | null>>;
290+ legend: { min: number | null; max: number | null; breaks: number[]; n_classes: number };
291+ n_by_year: number[];
292+ provenance: Provenance | null;
293+}
294+
295+export interface IndicatorQualityResponse {
296+ meta: Meta;
297+ indicator: IndicatorCard;
298+ n_countries: number;
299+ n_countries_total: number;
300+ coverage_pct: number;
301+ first_year: number | null;
302+ last_year: number | null;
303+ latest_common_year: number | null;
304+ n_years: number;
305+ years_with_50plus: number;
306+ median_points_per_country: number | null;
307+ sparse_countries: number;
308+ stale_countries: number;
309+ flagged_values: number;
310+ sources: Provenance[];
311+ badges: QualityBadge[];
312+}
313+
314+// ---------------------------------------------------------------------------------------------- race / regions compare
315+
316+export interface RaceResponse {
317+ meta: Meta;
318+ indicator: IndicatorCard;
319+ group: GroupCard;
320+ top: number;
321+ years: number[];
322+ frames: Array<{ year: number; rows: Array<{ id: string; value: number; rank: number }> }>;
323+ countries: Record<string, CountryCard>;
324+ max_value: number | null;
325+ provenance: Provenance | null;
326+}
327+
328+export interface RegionCompareResponse {
329+ meta: Meta;
330+ groups: [GroupCard, GroupCard];
331+ rows: Array<{ indicator: IndicatorCard; kind: 'sum' | 'median' | 'weighted_mean' | string; label: string; values: Record<string, { value: number | null; formatted: string | null; n: number | null; year: number | null }> }>;
332+ shares: Record<string, { population_share_pct: number | null; gdp_share_pct: number | null }>;
333+ history: Record<string, { years: number[] } & Record<string, Array<number | null> | number[]>>;
334+}
335+
336+// ---------------------------------------------------------------------------------------------- country analytics
337+
338+export interface StoryItem {
339+ indicator: IndicatorCard;
340+ first: { year: number; value: number; formatted: string | null };
341+ last: { year: number; value: number; formatted: string | null };
342+ change_abs: number | null;
343+ change_pct: number | null;
344+ cagr: number | null;
345+ peak: { year: number; value: number } | null;
346+ trough: { year: number; value: number } | null;
347+ rank_first: { rank: number; n: number; year: number } | null;
348+ rank_last: { rank: number; n: number; year: number } | null;
349+ series: Array<[number, number | null]>;
350+ text: string;
351+ provenance: Provenance | null;
352+}
353+
354+export interface StoryResponse {
355+ meta: Meta;
356+ country: CountryCard;
357+ since: number | null;
358+ items: StoryItem[];
359+}
360+
361+export interface DnaReference {
362+ kind: 'world' | 'region' | 'income' | 'country' | string;
363+ id: string | null;
364+ label: string;
365+ dims: Record<string, number | null>;
366+}
367+
368+export interface CountryQualityItem {
369+ indicator: IndicatorCard;
370+ latest_year: number | null;
371+ first_year: number | null;
372+ n_points: number;
373+ expected_points: number;
374+ missing_years: number;
375+ continuity_pct: number | null;
376+ status: string | null;
377+ source: string | null;
378+ source_updated_at: string | null;
379+ retrieved_at: string | null;
380+ badges: QualityBadge[];
381+}
382+
383+export interface CountryQualityResponse {
384+ meta: Meta;
385+ country: CountryCard;
386+ summary: { n_indicators: number; n_with_data: number; coverage_pct: number | null; latest_year: number | null; n_fresh: number; n_stale: number; n_sparse: number; n_flagged: number };
387+ items: CountryQualityItem[];
388+}
389+
390+// ---------------------------------------------------------------------------------------------- updates
391+
392+export interface UpdatesSource {
393+ source: { id: string; name: string | null; organization: string | null; url: string | null; licence: string | null };
394+ status: 'ok' | 'partial' | 'failed' | 'stale' | 'unknown' | string;
395+ last_success_at: string | null;
396+ last_retrieved_at: string | null;
397+ source_updated_at: string | null;
398+ n_datasets: number;
399+ n_indicators: number;
400+ n_observations: number;
401+ latest_year: number | null;
402+ values_changed: number;
403+ countries_affected: number;
404+}
405+
406+export interface UpdatesResponse {
407+ meta: Meta;
408+ snapshot: { run_id: string | null; built_at: string | null; observations: number; indicators: number; countries: number; values_changed: number; values_changed_by_source: Record<string, number> };
409+ sources: UpdatesSource[];
410+ recent_runs: Array<{ run_id: string | null; connector: string; dataset: string | null; started_at: string | null; finished_at: string | null; status: string | null; rows_valid: number | null; warnings: number | null; errors: number | null; message: string | null }>;
411+ indicators_recently_updated: IndicatorSummary[];
412+}
413+
414+/** Search hit for a parsed intent ("compare canada usa", "rank gdp"). */
415+export interface ActionHit {
416+ type: 'action';
417+ action: 'compare' | 'ranking' | 'group_ranking' | 'explore' | string;
418+ id: string;
419+ name: string;
420+ hint: string | null;
421+ url: string;
422+ score: number;
423+}
added apps/web/src/lib/url-state.ts +84 −0
@@ -0,0 +1,84 @@
1+'use client';
2+import { usePathname, useRouter, useSearchParams } from 'next/navigation';
3+import { useCallback, useMemo, useRef } from 'react';
4+
5+/**
6+ * URL state for the analytical views (explore, trajectories, scatter, finder, extremes…): every meaningful
7+ * piece of view state lives in the query string so a view is shareable and survives a reload. `replace`
8+ * batches rapid updates (a dragged slider) with `router.replace` and never scrolls.
9+ *
10+ * const { get, set, url } = useUrlState();
11+ * set({ year: 1990, indicator: 'gdp-per-capita' }) // null/undefined/'' remove the key
12+ */
13+export type UrlPatch = Record<string, string | number | boolean | null | undefined>;
14+
15+export function applyPatch(current: URLSearchParams, patch: UrlPatch): URLSearchParams {
16+ const p = new URLSearchParams(current.toString());
17+ for (const [k, v] of Object.entries(patch)) {
18+ if (v === undefined || v === null || v === '' || v === false) p.delete(k);
19+ else p.set(k, v === true ? '1' : String(v));
20+ }
21+ return p;
22+}
23+
24+export function useUrlState(defaults: Record<string, string> = {}) {
25+ const router = useRouter();
26+ const pathname = usePathname();
27+ const params = useSearchParams();
28+ const pending = useRef<UrlPatch>({});
29+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
30+
31+ const get = useCallback((key: string): string | null => params.get(key) ?? defaults[key] ?? null, [params, defaults]);
32+ const getNum = useCallback(
33+ (key: string): number | null => {
34+ const v = params.get(key) ?? defaults[key];
35+ if (v == null || v === '') return null;
36+ const n = Number(v);
37+ return Number.isFinite(n) ? n : null;
38+ },
39+ [params, defaults],
40+ );
41+
42+ const flush = useCallback(() => {
43+ const patch = pending.current;
44+ pending.current = {};
45+ timer.current = null;
46+ const next = applyPatch(new URLSearchParams(window.location.search), patch);
47+ // Drop keys equal to their default so canonical URLs stay short.
48+ for (const [k, v] of Object.entries(defaults)) if (next.get(k) === v) next.delete(k);
49+ const s = next.toString();
50+ router.replace(`${pathname}${s ? `?${s}` : ''}`, { scroll: false });
51+ }, [router, pathname, defaults]);
52+
53+ /** Merge a patch into the URL; coalesces updates within `delay` ms (default 120 — slider friendly). */
54+ const set = useCallback(
55+ (patch: UrlPatch, delay = 120) => {
56+ pending.current = { ...pending.current, ...patch };
57+ if (timer.current) clearTimeout(timer.current);
58+ if (delay <= 0) flush();
59+ else timer.current = setTimeout(flush, delay);
60+ },
61+ [flush],
62+ );
63+
64+ const url = useMemo(() => `${pathname}${params.toString() ? `?${params.toString()}` : ''}`, [pathname, params]);
65+ return { get, getNum, set, params, url } as const;
66+}
67+
68+/** Parse "a,b,c" → ["a","b","c"] limited to slug-safe tokens. */
69+export function parseList(v: string | null | undefined, max = 8): string[] {
70+ if (!v) return [];
71+ const out: string[] = [];
72+ for (const part of v.split(',')) {
73+ const s = part.trim().toLowerCase();
74+ if (s && /^[a-z0-9][a-z0-9-]*$/.test(s) && !out.includes(s)) out.push(s);
75+ if (out.length >= max) break;
76+ }
77+ return out;
78+}
79+
80+export function parseYear(v: string | null | undefined): number | null {
81+ if (!v) return null;
82+ const n = Number(v);
83+ return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null;
84+}
modified docs/API.md +135 −0
@@ -191,3 +191,138 @@ POST /admin/cache/clear
191 * **Home lists**: each curated list carries `min_population` and `filter_note` (e.g. `"Countries above 1M inhabitants"`) when a 191 * **Home lists**: each curated list carries `min_population` and `filter_note` (e.g. `"Countries above 1M inhabitants"`) when a
192 population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M; 192 population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M;
193 lowest unemployment: ≥ 5M). 193 lowest unemployment: ≥ 5M).
194+
195+## Analytics endpoints (API 1.1 — contract, 2026-09-11)
196+
197+All additive, read-only, GET, cached per snapshot like the rest. Paths stay under `/api/v1`. Country-only pools (`kind='country'`),
198+annual canonical frequency, non-forecast, non-quarantined. Every value object still carries `provenance` where a single source
199+applies; aggregates carry `provenance[]` (one per contributing source, with `n_values`). Neutral wording everywhere: a change is an
200+*increase* / *decrease*; it is called an *improvement* / *deterioration* only when the indicator declares `higher_is_better`.
201+
202+```
203+GET /pulse World Pulse — what is changing globally (latest year vs previous)
204+GET /movers?window=1|5|10&category=&kind=&limit=&min_population= Biggest movers by window / category / kind
205+GET /extremes?window=1|5|10|25|since1990&topic=&min_population= Curated extremes facets (fastest ageing, urbanising…)
206+GET /scatter?x=&y=&size=&year=&group=&log_x=&log_y= Cross-section scatter + Pearson / Spearman / OLS
207+GET /trajectory?x=&y=&size=&from=&to=&group= Gapminder-style frames (compact arrays per country)
208+GET /finder?f=slug:op:value&f=…&mode=and|or&region=&income=&sort=&limit= Structured country finder over `latest`
209+GET /peers?y=&x=&year=&method=theil-sen|ols&log_x= Above / below expected (robust cross-sectional fit)
210+GET /indicators/{slug}/related?limit=&min_n= Statistically related indicators (descriptive)
211+GET /indicators/{slug}/distribution?year=&highlight=&bins= Histogram + medians (world / region / income) + percentile
212+GET /indicators/{slug}/frames?from=&to=&step=&group= Multi-year map frames for the time machine
213+GET /indicators/{slug}/quality Coverage / freshness / continuity summary
214+GET /rankings/{indicator}/race?from=&to=&top=&group= Rank race frames (top N per year)
215+GET /regions/compare?a=&b=&indicators= Group vs group aggregates + history
216+GET /countries/{id}/story "How X changed": long-run indicators, templated text
217+GET /countries/{id}/dna?reference=world|region|income|<ISO3> DNA + reference profile
218+GET /countries/{id}/quality Per-indicator data quality for a country
219+GET /updates Freshness dashboard (sources, runs, changed values)
220+GET /search?q=compare canada usa Intent hits (type action) — see below
221+```
222+
223+### Shapes
224+
225+`MoverItem` — `{country: CountryCard, indicator: IndicatorCard, kind, year, ref_year, value, ref_value, delta, delta_pct, formatted,
226+formatted_ref, severity (0–1), direction: "up"|"down", interpretation: "improvement"|"deterioration"|null, headline, provenance}`.
227+`kind` ∈ `yoy_jump | yoy_drop | record_high | record_low | n_year_high | n_year_low | sign_flip | accelerating | decelerating |
228+structural_break | trend_reversal | volatility_spike | change_5y | change_10y`. Categories: `economic` = economy+government+trade+income,
229+`demographic` = population, `health`, `energy`, `climate` = climate+environment, `digital` = digital+innovation, `housing`, `labor`.
230+Kinds filter: `improvement | deterioration | increase | decrease | record | reversal | acceleration | structural | all`.
231+
232+`/pulse` → `{meta, year_reference, summary: {n_indicators, n_countries_reporting, n_record_highs, n_record_lows, n_changes},
233+items: [{indicator, year, n, n_up, n_down, n_flat, share_up, share_down, median_change_abs, median_change_pct, direction_semantics:
234+"higher_is_better"|"lower_is_better"|"neutral", record_highs, record_lows, headline, top_up: MoverLite, top_down: MoverLite,
235+convergence: {direction, cv_start, cv_end, n, from_year} | null, provenance}]}` where `MoverLite = {country, value, ref_value, delta,
236+delta_pct, formatted, year}`. Only headline + featured indicators whose latest year is ≥ reference − 1. Headlines are templates, e.g.
237+"Inflation fell in 73 % of 176 reporting countries" (share_down ≥ 60), "Population is shrinking in 31 countries", "Renewable
238+electricity hit a record high in 42 countries".
239+
240+`/extremes` → `{meta, window, from_year, to_year, min_population, filter_note, facets: [{id, title, indicator, direction: "up"|"down",
241+metric: "abs"|"pct"|"points", rows: [{country, value_start, value_end, year_start, year_end, delta, delta_pct, formatted_start,
242+formatted_end}], n, provenance}]}`. Facets (fixed, in this order, skipped when the indicator has < 30 countries in the window):
243+`aging` median-age ↑ · `urbanizing` urban-population-share ↑ · `fertility-decline` fertility-rate ↓ · `life-expectancy-gains` ↑ ·
244+`gdp-transformations` gdp-per-capita-ppp ↑ (pct) · `digital-adoption` internet-users ↑ · `renewable-transitions` renewable-electricity-share ↑ ·
245+`co2-reductions` co2-per-capita ↓ (pct) · `population-decline` population ↓ (pct) · `population-boom` population ↑ (pct) ·
246+`inflation-surges` inflation ↑ (points) · `debt-buildup` general-government-gross-debt-pct-gdp ↑ (points) · `unemployment-falls` ↓ (points).
247+`topic` filters facets by their indicator topic. `window=since1990` → from_year 1990.
248+
249+`/scatter` → `{meta, x, y, size, year, year_used, nearest_years: 3, group, n, points: [{id, slug, name, flag, region, income, x, y,
250+size, year_x, year_y}], stats: {n, pearson, spearman, ols: {slope, intercept, r2} | null, log_x, log_y}, note}`. `log_x/log_y` accept
251+`true|false|auto` (auto = true when the indicator's format is currency/number with bounds ≥ 0 and max/min > 50). `size` defaults to
252+`population`; `size=none` disables. Each axis uses the country's value in `year_used` or the nearest within 3 years.
253+
254+`/trajectory` → `{meta, x, y, size, group, years: [int…], countries: [{id, slug, name, flag, region, income}], series: {ISO3: {x: [num|null…],
255+y: [...], size: [...]}}, domains: {x: [min,max], y: [min,max], size: [min,max]}, log_x, log_y, provenance: [...]}` — arrays aligned on
256+`years`, no interpolation. Default `from` = first year with ≥ 50 countries on both axes, `to` = last year with ≥ 50. Countries with
257+< 30 % of frames on both axes are dropped.
258+
259+`/finder` → filters `f=slug:op:value` (repeatable; ops `gt gte lt lte eq between` — between uses `a..b`), `mode=and|or` (default and),
260+`region`/`income` group slug, `sort=slug:asc|desc` (default: first filter, desc), `limit` ≤ 218. Response `{meta, mode, filters: [{indicator,
261+op, value, value2, year_used}], n_matching, n_evaluated, items: [{country: CountryCard, matched: [slug…], values: {slug: {value, year,
262+formatted, provenance}}}]}`. `year_used` = latest year of each country (mixed years, honest).
263+
264+`/peers` → `{meta, x, y, year_used, n, method, fit: {slope, intercept, r2, log_x, residual_scale}, points: [{id, slug, name, flag, region,
265+income, x, y, expected, residual, residual_z}], above: [top 12 by residual_z], below: [bottom 12], pairs: [{x, y, label}], note, methodology}`.
266+`residual_z` = residual / (1.4826·MAD of residuals). Default pair y=life-expectancy, x=gdp-per-capita-ppp, log_x=true. Suggested pairs:
267+(life-expectancy, gdp-per-capita-ppp), (expected-years-of-schooling, gdp-per-capita-ppp), (co2-per-capita, gdp-per-capita-ppp),
268+(life-expectancy, health-expenditure-per-capita), (internet-users, gdp-per-capita-ppp), (infant-mortality-rate, gdp-per-capita-ppp).
269+Wording must stay descriptive: "above the fitted line", never "outperforms because".
270+
271+`/indicators/{slug}/related` → `{meta, indicator, year_used, n_candidates, items: [{indicator, pearson, spearman, n, year, log_x, log_y,
272+direction: "positive"|"negative"}], note: "Correlation does not imply causation."}` — cross-section on `latest` (values within 3 years of
273+the indicator's max year), pairs need `n ≥ min_n` (default 40), sorted by |Spearman| desc, self and per-capita twins of the same
274+quantity excluded when both are present (e.g. gdp vs gdp-ppp is allowed; gdp-per-capita vs gdp-per-capita-ppp is allowed — no
275+hand rules beyond self).
276+
277+`/indicators/{slug}/distribution` → `{meta, indicator, year, year_used, n, log, histogram: {edges, counts, log}, stats: {min, p10, p25,
278+median, mean, p75, p90, max}, highlight: {country: CountryCard, value, percentile, rank, n, region: GroupCard|null, region_median,
279+income: GroupCard|null, income_median} | null, by_region: [{group: GroupCard, median, n}], by_income: [{group, median, n}], provenance}`.
280+
281+`/indicators/{slug}/frames` → `{meta, indicator, group, years: [...], values: {ISO3: [num|null…]}, legend: {min, max, breaks: [...],
282+n_classes}, n_by_year: [...], provenance}` — breaks are quantiles over the pooled values of all years (a stable legend while scrubbing).
283+Years limited to those with ≥ 20 countries; ≤ 80 frames.
284+
285+`/indicators/{slug}/quality` → `{meta, indicator, n_countries, n_countries_total, coverage_pct, first_year, last_year, latest_common_year,
286+n_years, years_with_50plus, median_points_per_country, sparse_countries, stale_countries, flagged_values, sources: [...], badges: [...]}`.
287+Badges vocabulary (shared with the country endpoint): `fresh` (latest year ≥ reference − 1), `historical` (first year ≤ 1970),
288+`sparse` (median points per country < 10), `limited-coverage` (< 50 % of countries), `stale` (latest year ≤ reference − 3), `flagged`
289+(> 5 % of values with status warning), `forecast` (chosen source publishes projections).
290+
291+`/rankings/{indicator}/race` → `{meta, indicator, group, top, years: [...], frames: [{year, rows: [{id, value, rank}]}], countries: {ISO3:
292+CountryCard}, max_value, provenance}`. Union of countries that appear in the top N in any frame; only years with ≥ 20 ranked countries.
293+
294+`/regions/compare` → `{meta, groups: [GroupCard, GroupCard], rows: [{indicator, kind: "sum"|"median"|"weighted_mean", label, values: {<group
295+id>: {value, formatted, n, year}}}], shares: {<group id>: {population_share_pct, gdp_share_pct}}, history: {<indicator slug>: {years: [...],
296+<group id>: [num|null…]}}}` for indicators (default) population, gdp, gdp-per-capita, life-expectancy, co2-per-capita, internet-users,
297+gdp-growth, inflation; history for the first four (sum/weighted/median per year, ≥ 60 % of members reporting).
298+
299+`/countries/{id}/story` → `{meta, country, since, items: [{indicator, first: {year, value, formatted}, last: {year, value, formatted},
300+change_abs, change_pct, cagr, peak: {year, value}, trough: {year, value}, rank_first: {rank, n, year} | null, rank_last: {...} | null,
301+series: [[year, value]…], text, provenance}]}` — indicators in this order when ≥ 10 annual points: population, gdp-per-capita-ppp
302+(fallback gdp-per-capita), life-expectancy, fertility-rate, urban-population-share, co2-per-capita, internet-users,
303+renewable-electricity-share, general-government-gross-debt-pct-gdp, median-age, energy-use-per-capita, unemployment-rate; max 10 items.
304+`text` template: "{country}'s {indicator} {rose|fell} from {first} in {y0} to {last} in {y1} ({signed pct} / {signed points})."
305+
306+`/countries/{id}/dna` (extended) → adds `reference: {kind: "world"|"region"|"income"|"country", id, label, dims: {...}} | null`
307+(`world` → 50 on every dimension; region/income → median of the members' dims; country → that country's dims).
308+
309+`/countries/{id}/quality` → `{meta, country, summary: {n_indicators, n_with_data, coverage_pct, latest_year, n_fresh, n_stale, n_sparse,
310+n_flagged}, items: [{indicator, latest_year, first_year, n_points, expected_points, missing_years, continuity_pct, status, source,
311+source_updated_at, retrieved_at, badges: [...]}]}`.
312+
313+`/updates` → `{meta, snapshot: {run_id, built_at, observations, indicators, countries, values_changed, values_changed_by_source: {...}},
314+sources: [{source: Source, status: "ok"|"partial"|"failed"|"stale"|"unknown", last_success_at, last_retrieved_at, source_updated_at,
315+n_datasets, n_indicators, n_observations, latest_year, values_changed, countries_affected}], recent_runs: [{run_id, connector, dataset,
316+started_at, finished_at, status, rows_valid, warnings, errors, message}], indicators_recently_updated: [IndicatorSummary…]}`.
317+No file paths, hosts or secrets in the payload.
318+
319+`/search` intents — deterministic parsing before the index lookup; each intent yields a hit `{type: "action", action, id, name, hint,
320+url, score: 1.0}`: `compare <c1> <c2> [<c3>…]` → `/compare/<slugs>`; `rank[ing] <indicator>` → `/rankings/<slug>`; `<indicator> <country>`
321+(already `country_indicator`); `<indicator> <group>` → `/rankings/<slug>?group=<group>`; `<indicator> map|explore` → `/explore?indicator=`;
322+`<c1> vs <c2>` → compare.
323+
324+### Change detection 2.0 kinds (pipeline)
325+
326+`structural_break` (single mean shift with gain ≥ 0.5 and shift ≥ 1.5 × series sd, segments ≥ 5 years, break within the last 10 years for
327+`changes`), `trend_reversal` (three consecutive yearly moves of one sign after three of the opposite sign), `volatility_spike` (sd of the
328+last 5 yearly differences ≥ 3 × the sd of the previous 15). Same row shape; `detail` documents the parameters.
added src/countryatlas/stats.py +399 −0
Large diff (399 lines) is not loaded by default to keep the page fast.Load diff
added tests/test_stats.py +82 −0
Large diff (83 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme