SPB Git forge

spb/countryatlas

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

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

21 changed files +1,856 −23

modified apps/web/package.json +2 −1
@@ -9,7 +9,8 @@
9 9 "typecheck": "tsc -p tsconfig.json --noEmit",
10 10 "lint": "next lint",
11 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 15 "dependencies": {
15 16 "d3-geo": "^3.1.1",
modified apps/web/src/app/globals.css +146 −0
@@ -45,6 +45,27 @@
45 45 --seq-6: #184f95;
46 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 69 --shadow-sheet: 0 -8px 32px rgba(26, 25, 23, 0.12);
49 70 --shadow-pop: 0 4px 24px rgba(26, 25, 23, 0.14);
50 71 }
@@ -85,6 +106,25 @@
85 106 --seq-6: #9ec5f4;
86 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 128 --shadow-sheet: 0 -8px 32px rgba(0, 0, 0, 0.5);
89 129 --shadow-pop: 0 4px 24px rgba(0, 0, 0, 0.55);
90 130 }
@@ -121,6 +161,20 @@
121 161 --color-seq-5: var(--seq-5);
122 162 --color-seq-6: var(--seq-6);
123 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 179 --font-ui: var(--font-ui), ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif;
126 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 429 /* Chart chrome shared by the SVG kit (class names instead of inline styles so theme swaps repaint). */
284 430 .ca-chart text {
285 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 5 import { SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
6 6 import { ProvenanceProvider } from '@/components/data/provenance-context';
7 7 import { ProvenanceSheet } from '@/components/data/provenance-sheet';
8 +import { MainFrame } from '@/components/layout/main-frame';
8 9 import { MobileTabBar } from '@/components/layout/mobile-tab-bar';
9 10 import { SearchContainer } from '@/components/layout/search-container';
10 11 import { SearchProvider } from '@/components/layout/search-context';
@@ -47,9 +48,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
47 48 <ProvenanceProvider>
48 49 <SearchProvider>
49 50 <SiteHeader />
50 − <main id="main" className="container-x mx-auto w-full max-w-[1400px] flex-1">
51 − {children}
52 − </main>
51 + <MainFrame>{children}</MainFrame>
53 52 <SiteFooter />
54 53 <MobileTabBar />
55 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 153 <li key={p.id} className="border-t border-rule">
154 154 <div className="flex min-h-[64px] items-center gap-3 py-2.5">
155 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 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 162 </span>
160 163 <span className="mt-0.5 flex flex-wrap gap-x-1 text-base leading-tight" aria-hidden>
161 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 1 'use client';
2 −import { BarChart3, Globe2, Home, Scale, Search } from 'lucide-react';
2 +import { BarChart3, Globe2, Map, Scale, Search } from 'lucide-react';
3 3 import Link from 'next/link';
4 4 import { usePathname } from 'next/navigation';
5 5 import { t } from '@/i18n';
6 6 import { cn } from '@/lib/cn';
7 7 import { routes } from '@/lib/site';
8 +import { isFullBleed } from './main-frame';
8 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 − * body reserves space (`pb-[calc(56px+env(safe-area-inset-bottom))]`) so it never covers the footer.
12 + * Fixed bottom tab bar (< md): Explore · Countries · Compare · Rank · Search. Safe-area padding; the body
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 16 export function MobileTabBar() {
15 17 const pathname = usePathname();
16 18 const openSearch = useOpenSearch();
19 + if (isFullBleed(pathname)) return null;
17 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 22 { href: routes.countries(), label: t('nav.countries'), Icon: Globe2 },
20 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 26 return (
24 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 28 <ul className="grid h-14 grid-cols-5">
26 − {tabs.map(({ href, label, Icon, exact }) => {
27 − const active = exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
29 + {tabs.map(({ href, label, Icon }) => {
30 + const base = href.split('?')[0]!;
31 + const active = pathname === base || pathname.startsWith(`${base}/`);
28 32 return (
29 33 <li key={href} className="min-w-0">
30 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 12 export function SiteFooter({ builtAt, runId }: { builtAt?: string | null; runId?: string | null }) {
13 13 const explore = [
14 + { href: routes.explore(), label: t('nav.explore') },
14 15 { href: routes.countries(), label: t('nav.countries') },
15 16 { href: routes.compare(), label: t('nav.compare') },
16 17 { href: routes.rankings(), label: t('nav.rankings') },
17 18 { href: routes.indicators(), label: t('nav.indicators') },
18 19 { href: routes.regions(), label: t('nav.regions') },
19 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 27 const reference = [
22 28 { href: routes.sources(), label: t('site.footer.sources') },
23 29 { href: routes.methodology(), label: t('site.footer.methodology') },
24 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 35 return (
28 36 <footer className="mt-16 border-t border-rule bg-surface-2/40 text-sm">
29 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 39 <div className="max-w-md">
32 40 <Logo variant="full" />
33 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 63 return (
56 64 <div>
57 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 67 {items.map((it) => (
60 68 <li key={it.href}>
61 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 2 import { t } from '@/i18n';
3 3 import { routes } from '@/lib/site';
4 4 import { Logo } from '@/components/brand/Logo';
5 +import { MoreMenu } from './more-menu';
5 6 import { NavLinks } from './nav-links';
6 7 import { SearchTrigger } from './search-trigger';
7 8 import { ThemeToggle } from './theme-toggle';
8 9
10 +/** Primary desktop navigation (order from the product spec); everything else lives under "More". */
9 11 export const PRIMARY_NAV = [
12 + { key: 'nav.explore', href: routes.explore() },
10 13 { key: 'nav.countries', href: routes.countries() },
11 14 { key: 'nav.compare', href: routes.compare() },
12 15 { key: 'nav.rankings', href: routes.rankings() },
13 16 { key: 'nav.indicators', href: routes.indicators() },
14 − { key: 'nav.regions', href: routes.regions() },
15 − { key: 'nav.data', href: routes.data() },
17 + { key: 'nav.changes', href: routes.changes() },
16 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 36 export function SiteHeader() {
20 37 return (
21 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 41 <Logo variant="full" />
25 42 </Link>
26 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 47 <div className="ml-auto flex items-center gap-1">
28 48 <div className="hidden md:block">
29 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 4 * they belong to so diffs stay readable.
5 5 */
6 6 import { enCompare } from './en.compare';
7 +import { enCore } from './en.core';
7 8 import { enExplore } from './en.explore';
9 +import { enFlagship } from './en.flagship';
10 +import { enPlatform } from './en.platform';
8 11
9 12 export const en = {
10 13 // --- site
11 14 'site.name': 'CountryAtlas',
12 − 'site.tagline': 'Understand the world, one country at a time.',
15 + 'site.tagline': 'Explore the world through data.',
13 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 18 'site.skip': 'Skip to content',
16 19 'site.licence':
17 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 382 'og.site': 'countryatlas.co',
380 383 ...enCompare,
381 384 ...enExplore,
385 + ...enCore,
386 + ...enFlagship,
387 + ...enPlatform,
382 388 } as const;
383 389
384 390 export type DictKey = keyof typeof en;
modified apps/web/src/lib/site.ts +24 −2
@@ -2,8 +2,19 @@
2 2 export const SITE_URL = (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.countryatlas.co').replace(/\/$/, '');
3 3 export const SITE_NAME = 'CountryAtlas';
4 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 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 19 /** Route helpers — keep every internal href in one place so the next agent can add pages consistently. */
9 20 export const routes = {
@@ -30,13 +41,24 @@ export const routes = {
30 41 indicatorDownload: (slug: string, fmt: 'csv' | 'json' = 'csv') => `/api/v1/indicators/${slug}/download.${fmt}`,
31 42 regions: () => '/regions',
32 43 region: (slug: string) => `/regions/${slug}`,
44 + regionCompare: (a: string, b: string) => `/regions/compare?a=${a}&b=${b}`,
33 45 sources: () => '/sources',
34 46 source: (id: string) => `/sources/${id}`,
35 47 methodology: () => '/methodology',
36 48 data: () => '/data',
49 + download: (q?: Q) => `/download${query(q)}`,
37 50 api: () => '/api',
38 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 62 search: (q: string) => `/search?q=${encodeURIComponent(q)}`,
41 63 apiSwagger: () => '/api/v1/docs',
42 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 191 * **Home lists**: each curated list carries `min_population` and `filter_note` (e.g. `"Countries above 1M inhabitants"`) when a
192 192 population floor applies (fastest GDP growth, fastest population growth, highest life expectancy, energy transition leaders: ≥ 1M;
193 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
@@ -0,0 +1,399 @@
1 +"""Deterministic statistical utilities shared by the pipeline (derived tables, change detection) and the API
2 +(analytics endpoints). Pure functions on Python lists / numpy arrays, nan-aware, no randomness anywhere.
3 +
4 +Every function documents its exact definition so results are reproducible and testable (tests/test_stats.py).
5 +"""
6 +from __future__ import annotations
7 +
8 +import math
9 +from collections.abc import Iterable, Sequence
10 +from typing import Any
11 +
12 +import numpy as np
13 +
14 +MAD_SCALE = 1.4826 # 1.4826 × MAD ≈ σ for a normal distribution
15 +
16 +
17 +def _arr(values: Iterable[Any]) -> np.ndarray:
18 + out = np.asarray([float("nan") if v is None else float(v) for v in values], dtype=float)
19 + return out
20 +
21 +
22 +def finite(values: Iterable[Any]) -> np.ndarray:
23 + """Finite values only, as a float array."""
24 + a = _arr(values)
25 + return a[np.isfinite(a)]
26 +
27 +
28 +# ------------------------------------------------------------------------------------------------- growth
29 +def yoy(current: float | None, previous: float | None) -> float | None:
30 + """Year-over-year relative change in percent: (cur − prev) / |prev| × 100. None when undefined."""
31 + if current is None or previous is None or previous == 0 or not (math.isfinite(current) and math.isfinite(previous)):
32 + return None
33 + return (current - previous) / abs(previous) * 100.0
34 +
35 +
36 +def cagr(first: float | None, last: float | None, years: float) -> float | None:
37 + """Compound annual growth rate in percent between two strictly positive values `years` apart."""
38 + if first is None or last is None or years <= 0 or first <= 0 or last <= 0:
39 + return None
40 + if not (math.isfinite(first) and math.isfinite(last)):
41 + return None
42 + return ((last / first) ** (1.0 / years) - 1.0) * 100.0
43 +
44 +
45 +def rolling_mean(values: Sequence[float | None], window: int) -> list[float | None]:
46 + """Trailing mean over `window` points; None until the window is full or when any point in it is missing."""
47 + out: list[float | None] = []
48 + a = _arr(values)
49 + for i in range(len(a)):
50 + if i + 1 < window:
51 + out.append(None)
52 + continue
53 + w = a[i + 1 - window : i + 1]
54 + out.append(float(np.mean(w)) if np.all(np.isfinite(w)) else None)
55 + return out
56 +
57 +
58 +def trend_slope(years: Sequence[float], values: Sequence[float | None]) -> float | None:
59 + """Ordinary least-squares slope of value on year (units per year) over finite pairs; None with < 3 points."""
60 + y = _arr(values)
61 + x = np.asarray(years, dtype=float)
62 + ok = np.isfinite(y) & np.isfinite(x)
63 + if ok.sum() < 3:
64 + return None
65 + xs, ys = x[ok], y[ok]
66 + xm, ym = xs.mean(), ys.mean()
67 + den = float(((xs - xm) ** 2).sum())
68 + if den == 0:
69 + return None
70 + return float(((xs - xm) * (ys - ym)).sum() / den)
71 +
72 +
73 +def volatility(values: Sequence[float | None], relative: bool = False) -> float | None:
74 + """Standard deviation of first differences (log-differences × 100 when `relative`, for positive level series)."""
75 + a = _arr(values)
76 + a = a[np.isfinite(a)]
77 + if len(a) < 3:
78 + return None
79 + if relative:
80 + if np.any(a <= 0):
81 + return None
82 + d = np.diff(np.log(a)) * 100.0
83 + else:
84 + d = np.diff(a)
85 + return float(np.std(d, ddof=1)) if len(d) >= 2 else None
86 +
87 +
88 +# ------------------------------------------------------------------------------------------------- ranks / scores
89 +def rank(values: Sequence[float | None], descending: bool = True) -> list[int | None]:
90 + """Competition rank (1 = first; ties share the same rank, next rank skips). None for missing values."""
91 + a = _arr(values)
92 + ok = np.isfinite(a)
93 + out: list[int | None] = [None] * len(a)
94 + if not ok.any():
95 + return out
96 + vals = a[ok]
97 + order = -vals if descending else vals
98 + # ties: rank = 1 + number of strictly better values
99 + for idx, v in zip(np.flatnonzero(ok), order, strict=True):
100 + out[int(idx)] = int(1 + np.sum(order < v))
101 + return out
102 +
103 +
104 +def percentile_rank(values: Sequence[float | None]) -> list[float | None]:
105 + """Percentile rank 0–100 among finite values (average ranks for ties, min → 0, max → 100). None for missing."""
106 + a = _arr(values)
107 + ok = np.isfinite(a)
108 + n = int(ok.sum())
109 + out: list[float | None] = [None] * len(a)
110 + if n < 2:
111 + if n == 1:
112 + out[int(np.flatnonzero(ok)[0])] = 50.0
113 + return out
114 + x = a[ok]
115 + order = np.argsort(x, kind="mergesort")
116 + ranks = np.empty(n)
117 + ranks[order] = np.arange(n, dtype=float)
118 + sx = x[order]
119 + i = 0
120 + while i < n:
121 + j = i
122 + while j + 1 < n and sx[j + 1] == sx[i]:
123 + j += 1
124 + if j > i:
125 + ranks[order[i : j + 1]] = (i + j) / 2.0
126 + i = j + 1
127 + pct = ranks / (n - 1) * 100.0
128 + for k, idx in enumerate(np.flatnonzero(ok)):
129 + out[int(idx)] = float(pct[k])
130 + return out
131 +
132 +
133 +def zscore(values: Sequence[float | None]) -> list[float | None]:
134 + """(x − mean) / sd over finite values (population sd); all None when sd = 0 or n < 2."""
135 + a = _arr(values)
136 + ok = np.isfinite(a)
137 + out: list[float | None] = [None] * len(a)
138 + if ok.sum() < 2:
139 + return out
140 + mu = float(a[ok].mean())
141 + sd = float(a[ok].std())
142 + if sd == 0:
143 + return out
144 + for idx in np.flatnonzero(ok):
145 + out[int(idx)] = float((a[idx] - mu) / sd)
146 + return out
147 +
148 +
149 +def robust_zscore(values: Sequence[float | None]) -> list[float | None]:
150 + """(x − median) / (1.4826 × MAD); all None when MAD = 0 or n < 2."""
151 + a = _arr(values)
152 + ok = np.isfinite(a)
153 + out: list[float | None] = [None] * len(a)
154 + if ok.sum() < 2:
155 + return out
156 + med = float(np.median(a[ok]))
157 + mad = float(np.median(np.abs(a[ok] - med))) * MAD_SCALE
158 + if mad == 0:
159 + return out
160 + for idx in np.flatnonzero(ok):
161 + out[int(idx)] = float((a[idx] - med) / mad)
162 + return out
163 +
164 +
165 +def median(values: Iterable[Any]) -> float | None:
166 + a = finite(values)
167 + return float(np.median(a)) if len(a) else None
168 +
169 +
170 +def weighted_mean(values: Sequence[float | None], weights: Sequence[float | None]) -> float | None:
171 + """Σ v·w / Σ w over pairs where both are finite and w > 0."""
172 + v = _arr(values)
173 + w = _arr(weights)
174 + ok = np.isfinite(v) & np.isfinite(w) & (w > 0)
175 + if not ok.any():
176 + return None
177 + return float(np.sum(v[ok] * w[ok]) / np.sum(w[ok]))
178 +
179 +
180 +# ------------------------------------------------------------------------------------------------- records / breaks
181 +def is_record_high(values: Sequence[float | None]) -> bool:
182 + """True when the last finite value strictly exceeds every earlier finite value (≥ 2 points)."""
183 + a = finite(values)
184 + return len(a) >= 2 and bool(a[-1] > np.max(a[:-1]))
185 +
186 +
187 +def is_record_low(values: Sequence[float | None]) -> bool:
188 + a = finite(values)
189 + return len(a) >= 2 and bool(a[-1] < np.min(a[:-1]))
190 +
191 +
192 +def structural_break(values: Sequence[float | None], min_segment: int = 5) -> dict[str, Any] | None:
193 + """Single mean-shift break (binary segmentation, one split): the index maximising the reduction in the sum of
194 + squared residuals when the series is split into two constant-mean segments of ≥ `min_segment` points each.
195 +
196 + Returns {index, gain, before_mean, after_mean, shift, shift_ratio} where `gain` = 1 − SSR_split / SSR_total and
197 + `shift_ratio` = |shift| / sd(whole series). None with too few points or a flat series.
198 + """
199 + a = _arr(values)
200 + if not np.all(np.isfinite(a)) or len(a) < 2 * min_segment:
201 + return None
202 + total_ssr = float(((a - a.mean()) ** 2).sum())
203 + if total_ssr == 0:
204 + return None
205 + best_i, best_ssr = -1, total_ssr
206 + for i in range(min_segment, len(a) - min_segment + 1):
207 + left, right = a[:i], a[i:]
208 + ssr = float(((left - left.mean()) ** 2).sum() + ((right - right.mean()) ** 2).sum())
209 + if ssr < best_ssr:
210 + best_ssr, best_i = ssr, i
211 + if best_i < 0:
212 + return None
213 + before, after = float(a[:best_i].mean()), float(a[best_i:].mean())
214 + sd = float(a.std(ddof=1)) if len(a) > 1 else 0.0
215 + return {
216 + "index": best_i,
217 + "gain": 1.0 - best_ssr / total_ssr,
218 + "before_mean": before,
219 + "after_mean": after,
220 + "shift": after - before,
221 + "shift_ratio": (abs(after - before) / sd) if sd > 0 else 0.0,
222 + }
223 +
224 +
225 +def persistent_reversal(values: Sequence[float | None], run: int = 3) -> str | None:
226 + """'up' / 'down' when the last `run` first differences all have one sign and the `run` before them all had the
227 + opposite sign (a trend that persisted for `run` years and then reversed for `run` years). None otherwise."""
228 + a = finite(values)
229 + if len(a) < 2 * run + 1:
230 + return None
231 + d = np.diff(a)
232 + recent, before = d[-run:], d[-2 * run : -run]
233 + if np.all(recent > 0) and np.all(before < 0):
234 + return "up"
235 + if np.all(recent < 0) and np.all(before > 0):
236 + return "down"
237 + return None
238 +
239 +
240 +# ------------------------------------------------------------------------------------------------- association
241 +def pearson(x: Sequence[float | None], y: Sequence[float | None]) -> tuple[float | None, int]:
242 + """Pearson r over pairs where both are finite; (None, n) when n < 3 or a series is constant."""
243 + a, b = _arr(x), _arr(y)
244 + ok = np.isfinite(a) & np.isfinite(b)
245 + n = int(ok.sum())
246 + if n < 3:
247 + return None, n
248 + xa, yb = a[ok], b[ok]
249 + sx, sy = xa.std(), yb.std()
250 + if sx == 0 or sy == 0:
251 + return None, n
252 + r = float(np.mean((xa - xa.mean()) * (yb - yb.mean())) / (sx * sy))
253 + return max(-1.0, min(1.0, r)), n
254 +
255 +
256 +def _avg_ranks(v: np.ndarray) -> np.ndarray:
257 + order = np.argsort(v, kind="mergesort")
258 + ranks = np.empty(len(v))
259 + ranks[order] = np.arange(1, len(v) + 1, dtype=float)
260 + sv = v[order]
261 + i = 0
262 + while i < len(v):
263 + j = i
264 + while j + 1 < len(v) and sv[j + 1] == sv[i]:
265 + j += 1
266 + if j > i:
267 + ranks[order[i : j + 1]] = (i + j) / 2.0 + 1.0
268 + i = j + 1
269 + return ranks
270 +
271 +
272 +def spearman(x: Sequence[float | None], y: Sequence[float | None]) -> tuple[float | None, int]:
273 + """Spearman ρ = Pearson r of the (average) ranks over pairs where both are finite."""
274 + a, b = _arr(x), _arr(y)
275 + ok = np.isfinite(a) & np.isfinite(b)
276 + n = int(ok.sum())
277 + if n < 3:
278 + return None, n
279 + r, _ = pearson(_avg_ranks(a[ok]), _avg_ranks(b[ok]))
280 + return r, n
281 +
282 +
283 +def ols(x: Sequence[float | None], y: Sequence[float | None]) -> dict[str, Any] | None:
284 + """Ordinary least squares y = a + b·x over finite pairs: {slope, intercept, r2, n, residual_sd}."""
285 + a, b = _arr(x), _arr(y)
286 + ok = np.isfinite(a) & np.isfinite(b)
287 + n = int(ok.sum())
288 + if n < 3:
289 + return None
290 + xa, yb = a[ok], b[ok]
291 + xm, ym = xa.mean(), yb.mean()
292 + den = float(((xa - xm) ** 2).sum())
293 + if den == 0:
294 + return None
295 + slope = float(((xa - xm) * (yb - ym)).sum() / den)
296 + intercept = float(ym - slope * xm)
297 + pred = intercept + slope * xa
298 + ss_res = float(((yb - pred) ** 2).sum())
299 + ss_tot = float(((yb - ym) ** 2).sum())
300 + r2 = 1.0 - ss_res / ss_tot if ss_tot > 0 else None
301 + return {"slope": slope, "intercept": intercept, "r2": r2, "n": n, "residual_sd": math.sqrt(ss_res / (n - 2)) if n > 2 else None}
302 +
303 +
304 +def theil_sen(x: Sequence[float | None], y: Sequence[float | None], max_pairs: int = 200_000) -> dict[str, Any] | None:
305 + """Theil–Sen robust line: slope = median of pairwise slopes, intercept = median(y − slope·x). Deterministic:
306 + when the number of pairs exceeds `max_pairs` the pairs are thinned with a fixed stride (no randomness)."""
307 + a, b = _arr(x), _arr(y)
308 + ok = np.isfinite(a) & np.isfinite(b)
309 + n = int(ok.sum())
310 + if n < 3:
311 + return None
312 + xa, yb = a[ok], b[ok]
313 + i, j = np.triu_indices(n, k=1)
314 + dx = xa[j] - xa[i]
315 + keep = dx != 0
316 + i, j, dx = i[keep], j[keep], dx[keep]
317 + if len(dx) == 0:
318 + return None
319 + if len(dx) > max_pairs:
320 + stride = int(math.ceil(len(dx) / max_pairs))
321 + i, j, dx = i[::stride], j[::stride], dx[::stride]
322 + slopes = (yb[j] - yb[i]) / dx
323 + slope = float(np.median(slopes))
324 + intercept = float(np.median(yb - slope * xa))
325 + pred = intercept + slope * xa
326 + resid = yb - pred
327 + mad = float(np.median(np.abs(resid - np.median(resid)))) * MAD_SCALE
328 + ss_res = float((resid**2).sum())
329 + ss_tot = float(((yb - yb.mean()) ** 2).sum())
330 + return {"slope": slope, "intercept": intercept, "r2": (1.0 - ss_res / ss_tot) if ss_tot > 0 else None, "n": n,
331 + "residual_mad": mad}
332 +
333 +
334 +def quantile_breaks(values: Iterable[Any], k: int = 6) -> list[float]:
335 + """k − 1 interior quantile breaks (linear interpolation), deduplicated, over finite values."""
336 + vals = np.sort(finite(values))
337 + n = len(vals)
338 + if n < 2:
339 + return []
340 + k = max(2, min(k, n))
341 + out: list[float] = []
342 + for i in range(1, k):
343 + pos = (i / k) * (n - 1)
344 + lo = int(math.floor(pos))
345 + hi = min(lo + 1, n - 1)
346 + v = float(vals[lo] + (vals[hi] - vals[lo]) * (pos - lo))
347 + if not out or v > out[-1]:
348 + out.append(v)
349 + return out
350 +
351 +
352 +def histogram(values: Iterable[Any], bins: int = 20, log: bool = False) -> dict[str, Any]:
353 + """Equal-width histogram (on log10 when `log` and all values > 0): {edges, counts, log}. Empty → zero bins."""
354 + a = finite(values)
355 + if log:
356 + a = a[a > 0]
357 + if len(a) == 0:
358 + return {"edges": [], "counts": [], "log": log}
359 + x = np.log10(a) if log else a
360 + lo, hi = float(x.min()), float(x.max())
361 + if lo == hi:
362 + pad = abs(lo) * 0.05 or 0.5
363 + lo, hi = lo - pad, hi + pad
364 + counts, edges = np.histogram(x, bins=bins, range=(lo, hi))
365 + if log:
366 + edges = 10.0**edges
367 + return {"edges": [float(e) for e in edges], "counts": [int(c) for c in counts], "log": log}
368 +
369 +
370 +def convergence(values_start: Sequence[float | None], values_end: Sequence[float | None]) -> dict[str, Any] | None:
371 + """Cross-country dispersion at two dates: coefficient of variation and interquartile ratio at start and end,
372 + with the direction ('converging' when both fall, 'diverging' when both rise, 'mixed' otherwise)."""
373 + a, b = _arr(values_start), _arr(values_end)
374 + ok = np.isfinite(a) & np.isfinite(b)
375 + if ok.sum() < 10:
376 + return None
377 + xa, yb = a[ok], b[ok]
378 +
379 + def cv(v: np.ndarray) -> float | None:
380 + m = float(v.mean())
381 + return float(v.std(ddof=1) / abs(m)) if m != 0 else None
382 +
383 + def iqr_ratio(v: np.ndarray) -> float | None:
384 + q1, q3 = np.percentile(v, [25, 75])
385 + return float(q3 / q1) if q1 > 0 else None
386 +
387 + cv0, cv1 = cv(xa), cv(yb)
388 + if cv0 is None or cv1 is None:
389 + return None
390 + direction = "converging" if cv1 < cv0 * 0.97 else "diverging" if cv1 > cv0 * 1.03 else "stable"
391 + return {"n": int(ok.sum()), "cv_start": cv0, "cv_end": cv1, "iqr_ratio_start": iqr_ratio(xa), "iqr_ratio_end": iqr_ratio(yb),
392 + "direction": direction}
393 +
394 +
395 +__all__ = [
396 + "cagr", "convergence", "finite", "histogram", "is_record_high", "is_record_low", "median", "ols", "pearson", "percentile_rank",
397 + "persistent_reversal", "quantile_breaks", "rank", "robust_zscore", "rolling_mean", "spearman", "structural_break",
398 + "theil_sen", "trend_slope", "volatility", "weighted_mean", "yoy", "zscore",
399 +]
added tests/test_stats.py +82 −0
@@ -0,0 +1,82 @@
1 +"""Deterministic statistical utilities (src/countryatlas/stats.py)."""
2 +from __future__ import annotations
3 +
4 +import math
5 +
6 +import pytest
7 +
8 +from countryatlas import stats
9 +
10 +
11 +def test_growth_helpers():
12 + assert stats.yoy(110, 100) == pytest.approx(10.0)
13 + assert stats.yoy(90, -100) == pytest.approx(190.0) # relative to |prev|
14 + assert stats.yoy(1, 0) is None and stats.yoy(None, 1) is None
15 + assert stats.cagr(100, 200, 10) == pytest.approx((2 ** 0.1 - 1) * 100)
16 + assert stats.cagr(0, 5, 3) is None and stats.cagr(5, 5, 0) is None
17 + assert stats.rolling_mean([1, 2, 3, None, 5], 2) == [None, 1.5, 2.5, None, None]
18 + assert stats.trend_slope([2000, 2001, 2002, 2003], [1, 3, 5, 7]) == pytest.approx(2.0)
19 + assert stats.trend_slope([2000, 2001], [1, 2]) is None
20 + assert stats.volatility([1, 1, 1, 1]) == pytest.approx(0.0)
21 + assert stats.volatility([100, 110, 121], relative=True) == pytest.approx(0.0, abs=1e-9)
22 +
23 +
24 +def test_ranks_and_percentiles():
25 + assert stats.rank([3, 1, None, 3, 2]) == [1, 4, None, 1, 3]
26 + assert stats.rank([3, 1, 2], descending=False) == [3, 1, 2]
27 + pct = stats.percentile_rank([10, 20, 30, None])
28 + assert pct[:3] == [0.0, 50.0, 100.0] and pct[3] is None
29 + tie = stats.percentile_rank([1, 2, 2, 3])
30 + assert tie[1] == tie[2] == pytest.approx(50.0)
31 + z = stats.zscore([1, 2, 3])
32 + assert z[1] == pytest.approx(0.0) and z[2] == pytest.approx(math.sqrt(1.5))
33 + assert stats.zscore([5, 5, 5]) == [None, None, None]
34 + rz = stats.robust_zscore([1, 2, 3, 4, 100])
35 + assert rz[4] > 10 and abs(rz[2]) < 1e-9
36 + assert stats.median([3, None, 1, 2]) == 2.0
37 + assert stats.weighted_mean([10, 20], [1, 3]) == pytest.approx(17.5)
38 + assert stats.weighted_mean([10, 20], [0, None]) is None
39 +
40 +
41 +def test_records_and_breaks():
42 + assert stats.is_record_high([1, 2, 3]) and not stats.is_record_high([3, 2, 1]) and not stats.is_record_high([3])
43 + assert stats.is_record_low([3, 2, 1]) and not stats.is_record_low([1, 2, 3])
44 + series = [10] * 8 + [20] * 8
45 + b = stats.structural_break(series, min_segment=5)
46 + assert b is not None and b["index"] == 8 and b["gain"] == pytest.approx(1.0) and b["shift"] == pytest.approx(10.0)
47 + assert stats.structural_break([1, 1, 1, 1, 1, 1, 1, 1, 1, 1]) is None # flat
48 + assert stats.structural_break([1, 2, 3], min_segment=5) is None # too short
49 + assert stats.persistent_reversal([5, 4, 3, 2, 3, 4, 5]) == "up"
50 + assert stats.persistent_reversal([1, 2, 3, 4, 3, 2, 1]) == "down"
51 + assert stats.persistent_reversal([1, 2, 3, 4, 5, 6, 7]) is None
52 +
53 +
54 +def test_association():
55 + x = [1, 2, 3, 4, 5]
56 + r, n = stats.pearson(x, [2, 4, 6, 8, 10])
57 + assert r == pytest.approx(1.0) and n == 5
58 + r, n = stats.pearson(x, [5, 4, 3, 2, 1])
59 + assert r == pytest.approx(-1.0)
60 + assert stats.pearson(x, [1, 1, 1, 1, 1]) == (None, 5)
61 + rho, _ = stats.spearman(x, [1, 10, 100, 1000, 10000])
62 + assert rho == pytest.approx(1.0)
63 + fit = stats.ols(x, [3, 5, 7, 9, 11])
64 + assert fit["slope"] == pytest.approx(2.0) and fit["intercept"] == pytest.approx(1.0) and fit["r2"] == pytest.approx(1.0)
65 + ts = stats.theil_sen(x, [3, 5, 7, 9, 100]) # one outlier does not move the robust slope
66 + assert ts["slope"] == pytest.approx(2.0) and ts["intercept"] == pytest.approx(1.0)
67 + assert stats.ols([1, 1, 1], [1, 2, 3]) is None
68 +
69 +
70 +def test_breaks_histogram_convergence():
71 + br = stats.quantile_breaks(list(range(1, 101)), 4)
72 + assert len(br) == 3 and br[1] == pytest.approx(50.5)
73 + assert stats.quantile_breaks([1], 5) == []
74 + h = stats.histogram([1, 2, 3, 4, 5], bins=5)
75 + assert sum(h["counts"]) == 5 and len(h["edges"]) == 6
76 + hl = stats.histogram([1, 10, 100, 1000], bins=3, log=True)
77 + assert hl["log"] and hl["edges"][0] == pytest.approx(1.0) and hl["edges"][-1] == pytest.approx(1000.0)
78 + start = [10 + i for i in range(20)]
79 + end = [20 + i * 0.2 for i in range(20)]
80 + c = stats.convergence(start, end)
81 + assert c["direction"] == "converging" and c["n"] == 20
82 + assert stats.convergence([1, 2], [1, 2]) is None
83