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