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>
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®ion=&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 | ||