Connectors IMF/OECD/Eurostat/WHO/FRED/BIS/ILO + per-connector mapping files; registry fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
169 changed files +19,065 −19
added
apps/web/.env.example
+4 −0
@@ -0,0 +1,4 @@ | |||||
| 1 | +# Server-side API base (FastAPI, loopback). Client code calls same-origin /api/v1/* which Next rewrites here. | ||||
| 2 | +API_URL=http://127.0.0.1:8291 | ||||
| 3 | +# Public site URL used for canonical links, OpenGraph and the sitemap. | ||||
| 4 | +NEXT_PUBLIC_SITE_URL=https://www.countryatlas.co | ||||
added
apps/web/next.config.ts
+50 −0
@@ -0,0 +1,50 @@ | |||||
| 1 | +import type { NextConfig } from 'next'; | ||||
| 2 | +import { existsSync } from 'node:fs'; | ||||
| 3 | +import path from 'node:path'; | ||||
| 4 | + | ||||
| 5 | +// Monorepo: a single `.env` may live at the repository root; Next only reads the app directory. | ||||
| 6 | +for (const candidate of [path.resolve(process.cwd(), '../../.env'), path.resolve(process.cwd(), '.env')]) { | ||||
| 7 | + if (existsSync(candidate)) { | ||||
| 8 | + try { | ||||
| 9 | + process.loadEnvFile(candidate); | ||||
| 10 | + } catch { | ||||
| 11 | + /* ignore malformed env */ | ||||
| 12 | + } | ||||
| 13 | + } | ||||
| 14 | +} | ||||
| 15 | + | ||||
| 16 | +const API_URL = process.env.API_URL ?? 'http://127.0.0.1:8291'; | ||||
| 17 | + | ||||
| 18 | +const nextConfig: NextConfig = { | ||||
| 19 | + reactStrictMode: true, | ||||
| 20 | + poweredByHeader: false, | ||||
| 21 | + allowedDevOrigins: ['127.0.0.1', 'localhost'], | ||||
| 22 | + outputFileTracingRoot: path.resolve(__dirname, '../..'), | ||||
| 23 | + experimental: { | ||||
| 24 | + optimizePackageImports: ['lucide-react'], | ||||
| 25 | + }, | ||||
| 26 | + // Browser-side fetches go to the same origin; the FastAPI service is loopback-only (ARCHITECTURE §1, §9). | ||||
| 27 | + async rewrites() { | ||||
| 28 | + return [{ source: '/api/v1/:path*', destination: `${API_URL}/api/v1/:path*` }]; | ||||
| 29 | + }, | ||||
| 30 | + async headers() { | ||||
| 31 | + const PUBLIC_CACHE = { key: 'Cache-Control', value: 'public, s-maxage=600, stale-while-revalidate=3600' }; | ||||
| 32 | + const NO_STORE = { key: 'Cache-Control', value: 'private, no-store' }; | ||||
| 33 | + return [ | ||||
| 34 | + { | ||||
| 35 | + source: '/(.*)', | ||||
| 36 | + headers: [ | ||||
| 37 | + { key: 'X-Content-Type-Options', value: 'nosniff' }, | ||||
| 38 | + { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, | ||||
| 39 | + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, | ||||
| 40 | + { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, | ||||
| 41 | + ], | ||||
| 42 | + }, | ||||
| 43 | + { source: '/countries/:path*', headers: [PUBLIC_CACHE] }, | ||||
| 44 | + { source: '/api/:path*', headers: [NO_STORE] }, | ||||
| 45 | + { source: '/admin/:path*', headers: [NO_STORE] }, | ||||
| 46 | + ]; | ||||
| 47 | + }, | ||||
| 48 | +}; | ||||
| 49 | + | ||||
| 50 | +export default nextConfig; | ||||
added
apps/web/package.json
+40 −0
@@ -0,0 +1,40 @@ | |||||
| 1 | +{ | ||||
| 2 | + "name": "@countryatlas/web", | ||||
| 3 | + "version": "0.1.0", | ||||
| 4 | + "private": true, | ||||
| 5 | + "scripts": { | ||||
| 6 | + "dev": "next dev -p 8290", | ||||
| 7 | + "build": "next build", | ||||
| 8 | + "start": "next start -p 8290 -H 0.0.0.0", | ||||
| 9 | + "typecheck": "tsc -p tsconfig.json --noEmit", | ||||
| 10 | + "lint": "next lint", | ||||
| 11 | + "qa": "node qa/screens.mjs", | ||||
| 12 | + "mock-api": "node qa/mock-api.mjs" | ||||
| 13 | + }, | ||||
| 14 | + "dependencies": { | ||||
| 15 | + "d3-geo": "^3.1.1", | ||||
| 16 | + "d3-scale": "^4.0.2", | ||||
| 17 | + "d3-shape": "^3.2.0", | ||||
| 18 | + "lucide-react": "^1.0.0", | ||||
| 19 | + "next": "16.3.4", | ||||
| 20 | + "react": "19.2.8", | ||||
| 21 | + "react-dom": "19.2.8", | ||||
| 22 | + "server-only": "^0.0.1", | ||||
| 23 | + "topojson-client": "^3.1.0", | ||||
| 24 | + "world-atlas": "^2.0.2" | ||||
| 25 | + }, | ||||
| 26 | + "devDependencies": { | ||||
| 27 | + "@tailwindcss/postcss": "^4", | ||||
| 28 | + "@types/d3-geo": "^3.1.1", | ||||
| 29 | + "@types/d3-scale": "^4.0.9", | ||||
| 30 | + "@types/d3-shape": "^3.1.7", | ||||
| 31 | + "@types/geojson": "^7946.0.16", | ||||
| 32 | + "@types/node": "^24.0.0", | ||||
| 33 | + "@types/react": "^19", | ||||
| 34 | + "@types/react-dom": "^19", | ||||
| 35 | + "@types/topojson-client": "^3.1.5", | ||||
| 36 | + "@types/topojson-specification": "^1.0.5", | ||||
| 37 | + "tailwindcss": "^4", | ||||
| 38 | + "typescript": "^5.9.3" | ||||
| 39 | + } | ||||
| 40 | +} | ||||
added
apps/web/postcss.config.mjs
+7 −0
@@ -0,0 +1,7 @@ | |||||
| 1 | +const config = { | ||||
| 2 | + plugins: { | ||||
| 3 | + '@tailwindcss/postcss': {}, | ||||
| 4 | + }, | ||||
| 5 | +}; | ||||
| 6 | + | ||||
| 7 | +export default config; | ||||
added
apps/web/src/app/globals.css
+348 −0
@@ -0,0 +1,348 @@ | |||||
| 1 | +@import 'tailwindcss'; | ||||
| 2 | + | ||||
| 3 | +/* | ||||
| 4 | + CountryAtlas design tokens. | ||||
| 5 | + Editorial, calm, data-dense. Raw values live on :root / .dark; Tailwind utilities read them through | ||||
| 6 | + `@theme inline`, so `bg-paper`, `text-ink-2`, `border-rule`, `text-up`, `fill-series-1` … follow the theme. | ||||
| 7 | + Series/sequential colours are the validated dataviz palette (skills/dataviz references/palette.md), | ||||
| 8 | + re-validated against these surfaces on 2026-09-11 (see apps/web/README.md → Design tokens). | ||||
| 9 | +*/ | ||||
| 10 | +@custom-variant dark (&:where(.dark, .dark *)); | ||||
| 11 | + | ||||
| 12 | +:root { | ||||
| 13 | + color-scheme: light; | ||||
| 14 | + --paper: #fbfaf7; /* page plane */ | ||||
| 15 | + --surface: #ffffff; /* chart surface, sheets, dialogs */ | ||||
| 16 | + --surface-2: #f3f1ec; /* hover wash, chips */ | ||||
| 17 | + --ink: #1a1917; | ||||
| 18 | + --ink-2: #5c5a55; | ||||
| 19 | + --ink-3: #8a877f; | ||||
| 20 | + --rule: #e6e3dc; | ||||
| 21 | + --rule-strong: #c9c6bd; | ||||
| 22 | + --accent: #1c5cab; | ||||
| 23 | + --accent-ink: #ffffff; | ||||
| 24 | + --accent-soft: #e8f0fb; | ||||
| 25 | + --up: #006300; | ||||
| 26 | + --down: #b02a2a; | ||||
| 27 | + --warn: #9a6a00; | ||||
| 28 | + --nodata: #d9d6ce; | ||||
| 29 | + --forecast: #8a877f; | ||||
| 30 | + | ||||
| 31 | + --series-1: #2a78d6; | ||||
| 32 | + --series-2: #eb6834; | ||||
| 33 | + --series-3: #1baf7a; | ||||
| 34 | + --series-4: #eda100; | ||||
| 35 | + --series-5: #e87ba4; | ||||
| 36 | + --series-6: #008300; | ||||
| 37 | + --series-7: #4a3aa7; | ||||
| 38 | + --series-8: #e34948; | ||||
| 39 | + | ||||
| 40 | + --seq-1: #cde2fb; | ||||
| 41 | + --seq-2: #9ec5f4; | ||||
| 42 | + --seq-3: #6da7ec; | ||||
| 43 | + --seq-4: #3987e5; | ||||
| 44 | + --seq-5: #256abf; | ||||
| 45 | + --seq-6: #184f95; | ||||
| 46 | + --seq-7: #0d366b; | ||||
| 47 | + | ||||
| 48 | + --shadow-sheet: 0 -8px 32px rgba(26, 25, 23, 0.12); | ||||
| 49 | + --shadow-pop: 0 4px 24px rgba(26, 25, 23, 0.14); | ||||
| 50 | +} | ||||
| 51 | + | ||||
| 52 | +.dark { | ||||
| 53 | + color-scheme: dark; | ||||
| 54 | + --paper: #151513; | ||||
| 55 | + --surface: #1c1c1a; | ||||
| 56 | + --surface-2: #24241f; | ||||
| 57 | + --ink: #f2f0ea; | ||||
| 58 | + --ink-2: #c9c6bd; | ||||
| 59 | + --ink-3: #8a877f; | ||||
| 60 | + --rule: #2c2b28; | ||||
| 61 | + --rule-strong: #3a3935; | ||||
| 62 | + --accent: #5598e7; | ||||
| 63 | + --accent-ink: #0d1b2e; | ||||
| 64 | + --accent-soft: #17304f; | ||||
| 65 | + --up: #0ca30c; | ||||
| 66 | + --down: #e66767; | ||||
| 67 | + --warn: #e0a100; | ||||
| 68 | + --nodata: #33322f; | ||||
| 69 | + --forecast: #8a877f; | ||||
| 70 | + | ||||
| 71 | + --series-1: #3987e5; | ||||
| 72 | + --series-2: #d95926; | ||||
| 73 | + --series-3: #199e70; | ||||
| 74 | + --series-4: #c98500; | ||||
| 75 | + --series-5: #d55181; | ||||
| 76 | + --series-6: #008300; | ||||
| 77 | + --series-7: #9085e9; | ||||
| 78 | + --series-8: #e66767; | ||||
| 79 | + | ||||
| 80 | + --seq-1: #0d366b; | ||||
| 81 | + --seq-2: #184f95; | ||||
| 82 | + --seq-3: #256abf; | ||||
| 83 | + --seq-4: #3987e5; | ||||
| 84 | + --seq-5: #6da7ec; | ||||
| 85 | + --seq-6: #9ec5f4; | ||||
| 86 | + --seq-7: #cde2fb; | ||||
| 87 | + | ||||
| 88 | + --shadow-sheet: 0 -8px 32px rgba(0, 0, 0, 0.5); | ||||
| 89 | + --shadow-pop: 0 4px 24px rgba(0, 0, 0, 0.55); | ||||
| 90 | +} | ||||
| 91 | + | ||||
| 92 | +@theme inline { | ||||
| 93 | + --color-paper: var(--paper); | ||||
| 94 | + --color-surface: var(--surface); | ||||
| 95 | + --color-surface-2: var(--surface-2); | ||||
| 96 | + --color-ink: var(--ink); | ||||
| 97 | + --color-ink-2: var(--ink-2); | ||||
| 98 | + --color-ink-3: var(--ink-3); | ||||
| 99 | + --color-rule: var(--rule); | ||||
| 100 | + --color-rule-strong: var(--rule-strong); | ||||
| 101 | + --color-accent: var(--accent); | ||||
| 102 | + --color-accent-ink: var(--accent-ink); | ||||
| 103 | + --color-accent-soft: var(--accent-soft); | ||||
| 104 | + --color-up: var(--up); | ||||
| 105 | + --color-down: var(--down); | ||||
| 106 | + --color-warn: var(--warn); | ||||
| 107 | + --color-nodata: var(--nodata); | ||||
| 108 | + --color-forecast: var(--forecast); | ||||
| 109 | + --color-series-1: var(--series-1); | ||||
| 110 | + --color-series-2: var(--series-2); | ||||
| 111 | + --color-series-3: var(--series-3); | ||||
| 112 | + --color-series-4: var(--series-4); | ||||
| 113 | + --color-series-5: var(--series-5); | ||||
| 114 | + --color-series-6: var(--series-6); | ||||
| 115 | + --color-series-7: var(--series-7); | ||||
| 116 | + --color-series-8: var(--series-8); | ||||
| 117 | + --color-seq-1: var(--seq-1); | ||||
| 118 | + --color-seq-2: var(--seq-2); | ||||
| 119 | + --color-seq-3: var(--seq-3); | ||||
| 120 | + --color-seq-4: var(--seq-4); | ||||
| 121 | + --color-seq-5: var(--seq-5); | ||||
| 122 | + --color-seq-6: var(--seq-6); | ||||
| 123 | + --color-seq-7: var(--seq-7); | ||||
| 124 | + | ||||
| 125 | + --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; | ||||
| 127 | + --font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; | ||||
| 128 | + | ||||
| 129 | + /* Typographic scale (rem). Body 15px on mobile, 16px from md. */ | ||||
| 130 | + --text-2xs: 0.6875rem; | ||||
| 131 | + --text-2xs--line-height: 1rem; | ||||
| 132 | + --text-xs: 0.75rem; | ||||
| 133 | + --text-xs--line-height: 1.1rem; | ||||
| 134 | + --text-sm: 0.875rem; | ||||
| 135 | + --text-sm--line-height: 1.3rem; | ||||
| 136 | + --text-base: 1rem; | ||||
| 137 | + --text-base--line-height: 1.55rem; | ||||
| 138 | + --text-lg: 1.125rem; | ||||
| 139 | + --text-lg--line-height: 1.6rem; | ||||
| 140 | + --text-xl: 1.3125rem; | ||||
| 141 | + --text-xl--line-height: 1.75rem; | ||||
| 142 | + --text-2xl: 1.625rem; | ||||
| 143 | + --text-2xl--line-height: 2rem; | ||||
| 144 | + --text-3xl: 2rem; | ||||
| 145 | + --text-3xl--line-height: 2.3rem; | ||||
| 146 | + --text-4xl: 2.5rem; | ||||
| 147 | + --text-4xl--line-height: 2.75rem; | ||||
| 148 | + --text-5xl: 3.25rem; | ||||
| 149 | + --text-5xl--line-height: 3.4rem; | ||||
| 150 | + | ||||
| 151 | + --shadow-sheet: var(--shadow-sheet); | ||||
| 152 | + --shadow-pop: var(--shadow-pop); | ||||
| 153 | + | ||||
| 154 | + --radius-xs: 2px; | ||||
| 155 | + --radius-sm: 4px; | ||||
| 156 | + --radius-md: 6px; | ||||
| 157 | +} | ||||
| 158 | + | ||||
| 159 | +@layer base { | ||||
| 160 | + html { | ||||
| 161 | + font-family: var(--font-ui); | ||||
| 162 | + background: var(--paper); | ||||
| 163 | + color: var(--ink); | ||||
| 164 | + -webkit-text-size-adjust: 100%; | ||||
| 165 | + text-rendering: optimizeLegibility; | ||||
| 166 | + scroll-padding-top: 6.5rem; | ||||
| 167 | + } | ||||
| 168 | + html, | ||||
| 169 | + body { | ||||
| 170 | + /* Never scroll horizontally; individual scrollers opt in with overflow-x-auto. */ | ||||
| 171 | + overflow-x: clip; | ||||
| 172 | + max-width: 100%; | ||||
| 173 | + } | ||||
| 174 | + body { | ||||
| 175 | + font-size: 15px; | ||||
| 176 | + line-height: 1.55; | ||||
| 177 | + min-height: 100dvh; | ||||
| 178 | + background: var(--paper); | ||||
| 179 | + color: var(--ink); | ||||
| 180 | + } | ||||
| 181 | + @media (min-width: 48rem) { | ||||
| 182 | + body { | ||||
| 183 | + font-size: 16px; | ||||
| 184 | + } | ||||
| 185 | + } | ||||
| 186 | + h1, | ||||
| 187 | + h2, | ||||
| 188 | + h3 { | ||||
| 189 | + text-wrap: balance; | ||||
| 190 | + } | ||||
| 191 | + p { | ||||
| 192 | + text-wrap: pretty; | ||||
| 193 | + } | ||||
| 194 | + a { | ||||
| 195 | + text-underline-offset: 0.15em; | ||||
| 196 | + } | ||||
| 197 | + :focus-visible { | ||||
| 198 | + outline: 2px solid var(--accent); | ||||
| 199 | + outline-offset: 2px; | ||||
| 200 | + border-radius: 2px; | ||||
| 201 | + } | ||||
| 202 | + ::selection { | ||||
| 203 | + background: var(--accent-soft); | ||||
| 204 | + color: var(--ink); | ||||
| 205 | + } | ||||
| 206 | + svg { | ||||
| 207 | + display: block; | ||||
| 208 | + } | ||||
| 209 | + button { | ||||
| 210 | + cursor: pointer; | ||||
| 211 | + } | ||||
| 212 | + [hidden] { | ||||
| 213 | + display: none !important; | ||||
| 214 | + } | ||||
| 215 | + @media (prefers-reduced-motion: reduce) { | ||||
| 216 | + *, | ||||
| 217 | + *::before, | ||||
| 218 | + *::after { | ||||
| 219 | + animation-duration: 0.01ms !important; | ||||
| 220 | + animation-iteration-count: 1 !important; | ||||
| 221 | + transition-duration: 0.01ms !important; | ||||
| 222 | + scroll-behavior: auto !important; | ||||
| 223 | + } | ||||
| 224 | + } | ||||
| 225 | +} | ||||
| 226 | + | ||||
| 227 | +@utility tnum { | ||||
| 228 | + font-variant-numeric: tabular-nums lining-nums; | ||||
| 229 | + font-feature-settings: 'tnum' 1, 'lnum' 1; | ||||
| 230 | +} | ||||
| 231 | +@utility pnum { | ||||
| 232 | + font-variant-numeric: proportional-nums lining-nums; | ||||
| 233 | +} | ||||
| 234 | +@utility display { | ||||
| 235 | + font-family: var(--font-display); | ||||
| 236 | + font-weight: 500; | ||||
| 237 | + letter-spacing: -0.01em; | ||||
| 238 | +} | ||||
| 239 | +@utility eyebrow { | ||||
| 240 | + font-size: var(--text-2xs); | ||||
| 241 | + line-height: 1rem; | ||||
| 242 | + letter-spacing: 0.08em; | ||||
| 243 | + text-transform: uppercase; | ||||
| 244 | + font-weight: 600; | ||||
| 245 | + color: var(--ink-3); | ||||
| 246 | +} | ||||
| 247 | +@utility hairline { | ||||
| 248 | + border-top: 1px solid var(--rule); | ||||
| 249 | +} | ||||
| 250 | +@utility tap { | ||||
| 251 | + min-height: 44px; | ||||
| 252 | + min-width: 44px; | ||||
| 253 | +} | ||||
| 254 | +@utility scrollbar-none { | ||||
| 255 | + scrollbar-width: none; | ||||
| 256 | + &::-webkit-scrollbar { | ||||
| 257 | + display: none; | ||||
| 258 | + } | ||||
| 259 | +} | ||||
| 260 | +@utility safe-bottom { | ||||
| 261 | + padding-bottom: env(safe-area-inset-bottom, 0px); | ||||
| 262 | +} | ||||
| 263 | +@utility no-data-hatch { | ||||
| 264 | + background-color: var(--nodata); | ||||
| 265 | + background-image: repeating-linear-gradient(135deg, transparent 0 3px, rgba(26, 25, 23, 0.14) 3px 4px); | ||||
| 266 | +} | ||||
| 267 | +@utility link-quiet { | ||||
| 268 | + color: inherit; | ||||
| 269 | + text-decoration: none; | ||||
| 270 | + &:hover { | ||||
| 271 | + color: var(--accent); | ||||
| 272 | + } | ||||
| 273 | +} | ||||
| 274 | +@utility container-x { | ||||
| 275 | + padding-left: 1rem; | ||||
| 276 | + padding-right: 1rem; | ||||
| 277 | + @media (min-width: 40rem) { | ||||
| 278 | + padding-left: 1.5rem; | ||||
| 279 | + padding-right: 1.5rem; | ||||
| 280 | + } | ||||
| 281 | +} | ||||
| 282 | + | ||||
| 283 | +/* Chart chrome shared by the SVG kit (class names instead of inline styles so theme swaps repaint). */ | ||||
| 284 | +.ca-chart text { | ||||
| 285 | + font-family: var(--font-ui); | ||||
| 286 | + font-variant-numeric: tabular-nums; | ||||
| 287 | + fill: var(--ink-3); | ||||
| 288 | + font-size: 11px; | ||||
| 289 | +} | ||||
| 290 | +.ca-chart .axis line, | ||||
| 291 | +.ca-chart .grid line { | ||||
| 292 | + stroke: var(--rule); | ||||
| 293 | + stroke-width: 1; | ||||
| 294 | + shape-rendering: crispEdges; | ||||
| 295 | +} | ||||
| 296 | +.ca-chart .baseline { | ||||
| 297 | + stroke: var(--rule-strong); | ||||
| 298 | +} | ||||
| 299 | +.ca-chart .series-line { | ||||
| 300 | + fill: none; | ||||
| 301 | + stroke-width: 2; | ||||
| 302 | + stroke-linejoin: round; | ||||
| 303 | + stroke-linecap: round; | ||||
| 304 | +} | ||||
| 305 | +.ca-chart .forecast { | ||||
| 306 | + stroke-dasharray: 4 4; | ||||
| 307 | +} | ||||
| 308 | +.ca-chart .ring { | ||||
| 309 | + stroke: var(--surface); | ||||
| 310 | + stroke-width: 2; | ||||
| 311 | +} | ||||
| 312 | +.ca-chart .label { | ||||
| 313 | + fill: var(--ink-2); | ||||
| 314 | + font-size: 11px; | ||||
| 315 | +} | ||||
| 316 | +.ca-chart .label-strong { | ||||
| 317 | + fill: var(--ink); | ||||
| 318 | + font-weight: 600; | ||||
| 319 | +} | ||||
| 320 | +.ca-hatch { | ||||
| 321 | + fill: url(#ca-nodata-hatch); | ||||
| 322 | +} | ||||
| 323 | + | ||||
| 324 | +/* Motion tokens */ | ||||
| 325 | +@keyframes ca-sheet-up { | ||||
| 326 | + from { | ||||
| 327 | + transform: translateY(24px); | ||||
| 328 | + opacity: 0; | ||||
| 329 | + } | ||||
| 330 | + to { | ||||
| 331 | + transform: none; | ||||
| 332 | + opacity: 1; | ||||
| 333 | + } | ||||
| 334 | +} | ||||
| 335 | +@keyframes ca-fade { | ||||
| 336 | + from { | ||||
| 337 | + opacity: 0; | ||||
| 338 | + } | ||||
| 339 | + to { | ||||
| 340 | + opacity: 1; | ||||
| 341 | + } | ||||
| 342 | +} | ||||
| 343 | +.animate-sheet { | ||||
| 344 | + animation: ca-sheet-up 220ms cubic-bezier(0.2, 0.8, 0.2, 1); | ||||
| 345 | +} | ||||
| 346 | +.animate-fade { | ||||
| 347 | + animation: ca-fade 160ms ease-out; | ||||
| 348 | +} | ||||
added
apps/web/src/app/icon.svg
+1 −0
@@ -0,0 +1 @@ | |||||
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 32 32" fill="none" stroke="#1c5cab" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect width="32" height="32" rx="7" fill="#fbfaf7"/><circle cx="16" cy="16" r="12"/><path d="M5 19.3h22"/><path d="M10.1 26.6 16 7.6l5.9 19"/><path d="M16 7.6c-3 2.9-4.3 7.1-4.3 11.7" opacity="0.55"/><path d="M16 7.6c3 2.9 4.3 7.1 4.3 11.7" opacity="0.55"/></svg> | ||||
added
apps/web/src/app/layout.tsx
+62 −0
@@ -0,0 +1,62 @@ | |||||
| 1 | +import type { Metadata, Viewport } from 'next'; | ||||
| 2 | +import './globals.css'; | ||||
| 3 | +import { t } from '@/i18n'; | ||||
| 4 | +import { fontDisplay, fontUi } from '@/lib/fonts'; | ||||
| 5 | +import { SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site'; | ||||
| 6 | +import { ProvenanceProvider } from '@/components/data/provenance-context'; | ||||
| 7 | +import { ProvenanceSheet } from '@/components/data/provenance-sheet'; | ||||
| 8 | +import { MobileTabBar } from '@/components/layout/mobile-tab-bar'; | ||||
| 9 | +import { SearchContainer } from '@/components/layout/search-container'; | ||||
| 10 | +import { SearchProvider } from '@/components/layout/search-context'; | ||||
| 11 | +import { SiteFooter } from '@/components/layout/site-footer'; | ||||
| 12 | +import { SiteHeader } from '@/components/layout/site-header'; | ||||
| 13 | +import { THEME_BOOT } from '@/components/layout/theme-toggle'; | ||||
| 14 | + | ||||
| 15 | +export const metadata: Metadata = { | ||||
| 16 | + metadataBase: new URL(SITE_URL), | ||||
| 17 | + title: { default: `${SITE_NAME} — ${TAGLINE}`, template: `%s — ${SITE_NAME}` }, | ||||
| 18 | + description: t('site.description'), | ||||
| 19 | + applicationName: SITE_NAME, | ||||
| 20 | + robots: { index: true, follow: true }, | ||||
| 21 | + alternates: { canonical: '/' }, | ||||
| 22 | + openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: t('site.description') }, | ||||
| 23 | + twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: t('site.description') }, | ||||
| 24 | + icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }, { url: '/icon.png', sizes: '512x512', type: 'image/png' }], apple: [{ url: '/apple-icon.png', sizes: '180x180' }] }, | ||||
| 25 | +}; | ||||
| 26 | + | ||||
| 27 | +export const viewport: Viewport = { | ||||
| 28 | + width: 'device-width', | ||||
| 29 | + initialScale: 1, | ||||
| 30 | + viewportFit: 'cover', | ||||
| 31 | + themeColor: [ | ||||
| 32 | + { media: '(prefers-color-scheme: light)', color: '#fbfaf7' }, | ||||
| 33 | + { media: '(prefers-color-scheme: dark)', color: '#151513' }, | ||||
| 34 | + ], | ||||
| 35 | +}; | ||||
| 36 | + | ||||
| 37 | +export default function RootLayout({ children }: { children: React.ReactNode }) { | ||||
| 38 | + return ( | ||||
| 39 | + <html lang="en" className={`${fontUi.variable} ${fontDisplay.variable} h-full antialiased`} suppressHydrationWarning> | ||||
| 40 | + <head> | ||||
| 41 | + <script dangerouslySetInnerHTML={{ __html: THEME_BOOT }} /> | ||||
| 42 | + </head> | ||||
| 43 | + <body className="flex min-h-full flex-col pb-[calc(56px+env(safe-area-inset-bottom,0px))] md:pb-0"> | ||||
| 44 | + <a href="#main" className="sr-only focus:not-sr-only focus:fixed focus:left-3 focus:top-3 focus:z-[200] focus:rounded-sm focus:bg-accent focus:px-3 focus:py-2 focus:text-sm focus:text-accent-ink"> | ||||
| 45 | + {t('site.skip')} | ||||
| 46 | + </a> | ||||
| 47 | + <ProvenanceProvider> | ||||
| 48 | + <SearchProvider> | ||||
| 49 | + <SiteHeader /> | ||||
| 50 | + <main id="main" className="container-x mx-auto w-full max-w-[1400px] flex-1"> | ||||
| 51 | + {children} | ||||
| 52 | + </main> | ||||
| 53 | + <SiteFooter /> | ||||
| 54 | + <MobileTabBar /> | ||||
| 55 | + <SearchContainer /> | ||||
| 56 | + <ProvenanceSheet /> | ||||
| 57 | + </SearchProvider> | ||||
| 58 | + </ProvenanceProvider> | ||||
| 59 | + </body> | ||||
| 60 | + </html> | ||||
| 61 | + ); | ||||
| 62 | +} | ||||
added
apps/web/src/app/manifest.ts
+19 −0
@@ -0,0 +1,19 @@ | |||||
| 1 | +import type { MetadataRoute } from 'next'; | ||||
| 2 | +import { SITE_NAME, TAGLINE } from '@/lib/site'; | ||||
| 3 | + | ||||
| 4 | +export default function manifest(): MetadataRoute.Manifest { | ||||
| 5 | + return { | ||||
| 6 | + name: SITE_NAME, | ||||
| 7 | + short_name: SITE_NAME, | ||||
| 8 | + description: TAGLINE, | ||||
| 9 | + start_url: '/', | ||||
| 10 | + display: 'standalone', | ||||
| 11 | + background_color: '#fbfaf7', | ||||
| 12 | + theme_color: '#1c5cab', | ||||
| 13 | + icons: [ | ||||
| 14 | + { src: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }, | ||||
| 15 | + { src: '/icon.png', type: 'image/png', sizes: '512x512' }, | ||||
| 16 | + { src: '/apple-icon.png', type: 'image/png', sizes: '180x180' }, | ||||
| 17 | + ], | ||||
| 18 | + }; | ||||
| 19 | +} | ||||
added
apps/web/src/components/brand/Logo.tsx
+53 −0
@@ -0,0 +1,53 @@ | |||||
| 1 | +import { cn } from '@/lib/cn'; | ||||
| 2 | +import { t } from '@/i18n'; | ||||
| 3 | + | ||||
| 4 | +/** | ||||
| 5 | + * CountryAtlas brand. | ||||
| 6 | + * Mark: a globe ring whose equator doubles as the crossbar of an "A" — two meridian-like legs rise to a | ||||
| 7 | + * single apex, so the glyph reads as globe + atlas + the letter A at once. Pure geometry, strokes only, | ||||
| 8 | + * `currentColor` → works on light/dark and in monochrome. Wordmark: "Country" regular + "Atlas" semibold. | ||||
| 9 | + * | ||||
| 10 | + * Usage: <Logo variant="full" /> (header), <Logo variant="mark" size={24} /> (favicons, OG), <Logo variant="wordmark" />. | ||||
| 11 | + */ | ||||
| 12 | +export function LogoMark({ size = 28, className, title, strokeWidth = 2 }: { size?: number; className?: string; title?: string; strokeWidth?: number }) { | ||||
| 13 | + return ( | ||||
| 14 | + <svg width={size} height={size} viewBox="0 0 32 32" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={cn('shrink-0', className)} role={title ? 'img' : undefined} aria-hidden={title ? undefined : true} aria-label={title}> | ||||
| 15 | + {title ? <title>{title}</title> : null} | ||||
| 16 | + {/* globe ring */} | ||||
| 17 | + <circle cx="16" cy="16" r="13" /> | ||||
| 18 | + {/* equator = crossbar of the A, clipped to the ring */} | ||||
| 19 | + <path d="M4.1 19.5h23.8" /> | ||||
| 20 | + {/* the A: two legs to a single apex */} | ||||
| 21 | + <path d="M9.6 27.2 16 6.8l6.4 20.4" /> | ||||
| 22 | + {/* inner meridian hint */} | ||||
| 23 | + <path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55" /> | ||||
| 24 | + <path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55" /> | ||||
| 25 | + </svg> | ||||
| 26 | + ); | ||||
| 27 | +} | ||||
| 28 | + | ||||
| 29 | +export function Wordmark({ className }: { className?: string }) { | ||||
| 30 | + return ( | ||||
| 31 | + <span className={cn('select-none whitespace-nowrap font-ui text-[1.0625rem] tracking-[-0.02em] text-ink', className)}> | ||||
| 32 | + <span className="font-normal">Country</span> | ||||
| 33 | + <span className="font-semibold">Atlas</span> | ||||
| 34 | + </span> | ||||
| 35 | + ); | ||||
| 36 | +} | ||||
| 37 | + | ||||
| 38 | +export function Logo({ variant = 'full', size = 26, className }: { variant?: 'full' | 'mark' | 'wordmark'; size?: number; className?: string }) { | ||||
| 39 | + if (variant === 'mark') return <LogoMark size={size} className={className} title={t('brand.logoAlt')} />; | ||||
| 40 | + if (variant === 'wordmark') return <Wordmark className={className} />; | ||||
| 41 | + return ( | ||||
| 42 | + <span className={cn('inline-flex items-center gap-2', className)}> | ||||
| 43 | + <LogoMark size={size} className="text-accent" /> | ||||
| 44 | + <Wordmark /> | ||||
| 45 | + </span> | ||||
| 46 | + ); | ||||
| 47 | +} | ||||
| 48 | + | ||||
| 49 | +/** Raw SVG string of the mark (for favicons / OG rasterisation); `color` is a CSS colour. */ | ||||
| 50 | +export function logoMarkSvg({ size = 512, color = '#1c5cab', background, radius = 0 }: { size?: number; color?: string; background?: string; radius?: number } = {}): string { | ||||
| 51 | + const bg = background ? `<rect width="32" height="32" rx="${radius}" fill="${background}"/>` : ''; | ||||
| 52 | + return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 32 32" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${bg}<circle cx="16" cy="16" r="13"/><path d="M4.1 19.5h23.8"/><path d="M9.6 27.2 16 6.8l6.4 20.4"/><path d="M16 6.8c-3.2 3.1-4.6 7.7-4.6 12.7" opacity="0.55"/><path d="M16 6.8c3.2 3.1 4.6 7.7 4.6 12.7" opacity="0.55"/></svg>`; | ||||
| 53 | +} | ||||
added
apps/web/src/components/charts/chart-frame.tsx
+136 −0
@@ -0,0 +1,136 @@ | |||||
| 1 | +'use client'; | ||||
| 2 | +import { Table2 } from 'lucide-react'; | ||||
| 3 | +import { useId, useState, type ReactNode } from 'react'; | ||||
| 4 | +import { t } from '@/i18n'; | ||||
| 5 | +import { cn } from '@/lib/cn'; | ||||
| 6 | +import type { Provenance } from '@/lib/types'; | ||||
| 7 | +import type { ProvenancePayload } from '@/components/data/provenance-context'; | ||||
| 8 | +import { SourceLine } from './source-line'; | ||||
| 9 | + | ||||
| 10 | +export interface TableColumn { | ||||
| 11 | + key: string; | ||||
| 12 | + label: string; | ||||
| 13 | + numeric?: boolean; | ||||
| 14 | +} | ||||
| 15 | +export interface TableData { | ||||
| 16 | + columns: TableColumn[]; | ||||
| 17 | + rows: Array<Record<string, string>>; | ||||
| 18 | +} | ||||
| 19 | + | ||||
| 20 | +/** | ||||
| 21 | + * Wraps any chart: optional heading, the chart, a source attribution line (clickable → provenance), | ||||
| 22 | + * and the accessible data-table toggle. `summary` is the auto-generated text summary announced to AT. | ||||
| 23 | + */ | ||||
| 24 | +export function ChartFrame({ | ||||
| 25 | + title, | ||||
| 26 | + subtitle, | ||||
| 27 | + summary, | ||||
| 28 | + provenance, | ||||
| 29 | + payload, | ||||
| 30 | + table, | ||||
| 31 | + actions, | ||||
| 32 | + legend, | ||||
| 33 | + note, | ||||
| 34 | + children, | ||||
| 35 | + className, | ||||
| 36 | + minHeight, | ||||
| 37 | +}: { | ||||
| 38 | + title?: ReactNode; | ||||
| 39 | + subtitle?: ReactNode; | ||||
| 40 | + summary: string; | ||||
| 41 | + provenance?: Provenance | null; | ||||
| 42 | + payload?: ProvenancePayload | null; | ||||
| 43 | + table?: TableData; | ||||
| 44 | + actions?: ReactNode; | ||||
| 45 | + legend?: ReactNode; | ||||
| 46 | + note?: ReactNode; | ||||
| 47 | + children: ReactNode; | ||||
| 48 | + className?: string; | ||||
| 49 | + /** Reserve height to avoid layout shift while the chart mounts. */ | ||||
| 50 | + minHeight?: number; | ||||
| 51 | +}) { | ||||
| 52 | + const [showTable, setShowTable] = useState(false); | ||||
| 53 | + const id = useId(); | ||||
| 54 | + return ( | ||||
| 55 | + <figure className={cn('min-w-0', className)} aria-describedby={`${id}-sum`}> | ||||
| 56 | + {title || actions ? ( | ||||
| 57 | + <figcaption className="mb-2 flex items-start justify-between gap-3"> | ||||
| 58 | + <div className="min-w-0"> | ||||
| 59 | + {title ? <div className="text-sm font-semibold text-ink">{title}</div> : null} | ||||
| 60 | + {subtitle ? <div className="text-xs text-ink-2">{subtitle}</div> : null} | ||||
| 61 | + </div> | ||||
| 62 | + {actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null} | ||||
| 63 | + </figcaption> | ||||
| 64 | + ) : null} | ||||
| 65 | + {legend} | ||||
| 66 | + <div style={minHeight ? { minHeight } : undefined}>{children}</div> | ||||
| 67 | + <p id={`${id}-sum`} className="sr-only"> | ||||
| 68 | + {summary} | ||||
| 69 | + </p> | ||||
| 70 | + <div className="mt-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1"> | ||||
| 71 | + <div className="min-w-0 flex-1"> | ||||
| 72 | + <SourceLine provenance={provenance} payload={payload} /> | ||||
| 73 | + {note ? <p className="text-2xs text-ink-3">{note}</p> : null} | ||||
| 74 | + </div> | ||||
| 75 | + {table ? ( | ||||
| 76 | + <button | ||||
| 77 | + type="button" | ||||
| 78 | + onClick={() => setShowTable((s) => !s)} | ||||
| 79 | + aria-expanded={showTable} | ||||
| 80 | + aria-controls={`${id}-table`} | ||||
| 81 | + className="inline-flex min-h-[32px] items-center gap-1 rounded-sm px-1.5 text-2xs text-ink-3 hover:bg-surface-2 hover:text-ink" | ||||
| 82 | + > | ||||
| 83 | + <Table2 size={12} aria-hidden /> | ||||
| 84 | + {showTable ? t('common.hideTable') : t('common.viewTable')} | ||||
| 85 | + </button> | ||||
| 86 | + ) : null} | ||||
| 87 | + </div> | ||||
| 88 | + {table && showTable ? ( | ||||
| 89 | + <div id={`${id}-table`} className="mt-2 max-h-72 overflow-auto border-y border-rule"> | ||||
| 90 | + <table className="w-full text-xs tnum"> | ||||
| 91 | + <caption className="sr-only">{t('chart.table.caption')}</caption> | ||||
| 92 | + <thead className="sticky top-0 bg-paper"> | ||||
| 93 | + <tr> | ||||
| 94 | + {table.columns.map((c) => ( | ||||
| 95 | + <th key={c.key} scope="col" className={cn('px-2 py-1.5 text-left font-medium text-ink-2', c.numeric && 'text-right')}> | ||||
| 96 | + {c.label} | ||||
| 97 | + </th> | ||||
| 98 | + ))} | ||||
| 99 | + </tr> | ||||
| 100 | + </thead> | ||||
| 101 | + <tbody className="divide-y divide-rule"> | ||||
| 102 | + {table.rows.map((r, i) => ( | ||||
| 103 | + <tr key={i}> | ||||
| 104 | + {table.columns.map((c) => ( | ||||
| 105 | + <td key={c.key} className={cn('px-2 py-1', c.numeric && 'text-right')}> | ||||
| 106 | + {r[c.key] ?? t('common.na')} | ||||
| 107 | + </td> | ||||
| 108 | + ))} | ||||
| 109 | + </tr> | ||||
| 110 | + ))} | ||||
| 111 | + </tbody> | ||||
| 112 | + </table> | ||||
| 113 | + </div> | ||||
| 114 | + ) : null} | ||||
| 115 | + </figure> | ||||
| 116 | + ); | ||||
| 117 | +} | ||||
| 118 | + | ||||
| 119 | +/** Legend for ≥ 2 series (a single series needs none — the title names it). */ | ||||
| 120 | +export function Legend({ items }: { items: Array<{ label: string; color: string; dashed?: boolean; shape?: 'line' | 'rect' }> }) { | ||||
| 121 | + if (items.length < 2) return null; | ||||
| 122 | + return ( | ||||
| 123 | + <ul className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2" aria-label={t('common.legend')}> | ||||
| 124 | + {items.map((it) => ( | ||||
| 125 | + <li key={it.label} className="inline-flex items-center gap-1.5"> | ||||
| 126 | + {it.shape === 'rect' ? ( | ||||
| 127 | + <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-xs" style={{ background: it.color }} /> | ||||
| 128 | + ) : ( | ||||
| 129 | + <span aria-hidden className="inline-block h-0.5 w-4 rounded-full" style={{ background: it.color, ...(it.dashed ? { backgroundImage: `repeating-linear-gradient(90deg, ${it.color} 0 3px, transparent 3px 6px)`, backgroundColor: 'transparent' } : {}) }} /> | ||||
| 130 | + )} | ||||
| 131 | + <span>{it.label}</span> | ||||
| 132 | + </li> | ||||
| 133 | + ))} | ||||
| 134 | + </ul> | ||||
| 135 | + ); | ||||
| 136 | +} | ||||
added
apps/web/src/components/charts/choropleth-view.tsx
+137 −0
@@ -0,0 +1,137 @@ | |||||
| 1 | +'use client'; | ||||
| 2 | +import { useRouter } from 'next/navigation'; | ||||
| 3 | +import { useCallback, useId, useState } from 'react'; | ||||
| 4 | +import { t } from '@/i18n'; | ||||
| 5 | +import { cn } from '@/lib/cn'; | ||||
| 6 | +import { formatValue } from '@/lib/format'; | ||||
| 7 | +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; | ||||
| 8 | +import { routes } from '@/lib/site'; | ||||
| 9 | +import type { FormatSpec as Spec } from '@/lib/types'; | ||||
| 10 | +import { seqVar } from './palette'; | ||||
| 11 | + | ||||
| 12 | +export interface ChoroplethFeature { | ||||
| 13 | + iso3: string | null; | ||||
| 14 | + name: string; | ||||
| 15 | + slug: string | null; | ||||
| 16 | + flag: string | null; | ||||
| 17 | + d: string; | ||||
| 18 | + value: number | null; | ||||
| 19 | + cls: number | null; | ||||
| 20 | +} | ||||
| 21 | + | ||||
| 22 | +/** Map a class index (0..k-1) onto the 7-step sequential ramp. */ | ||||
| 23 | +function stepFor(cls: number, k: number): number { | ||||
| 24 | + if (k <= 1) return 4; | ||||
| 25 | + const start = k >= 6 ? 1 : 2; | ||||
| 26 | + const end = 7; | ||||
| 27 | + return Math.round(start + (cls / (k - 1)) * (end - start)); | ||||
| 28 | +} | ||||
| 29 | + | ||||
| 30 | +/** | ||||
| 31 | + * Interactive SVG world map: hover (mouse) shows the floating label; on touch the first tap selects and | ||||
| 32 | + * shows the label with an "Open" link, a click with a mouse navigates. Explicit hatched fill for no data. | ||||
| 33 | + */ | ||||
| 34 | +export function ChoroplethView({ features, sphere, legend, k, spec, summary, title, height, compact, className }: { features: ChoroplethFeature[]; sphere: string; legend: Array<{ cls: number; label: string }>; k: number; spec: Spec; summary: string; title: string; height?: number; compact?: boolean; className?: string }) { | ||||
| 35 | + const router = useRouter(); | ||||
| 36 | + const id = useId(); | ||||
| 37 | + const [active, setActive] = useState<{ f: ChoroplethFeature; x: number; y: number; sticky: boolean } | null>(null); | ||||
| 38 | + | ||||
| 39 | + const place = useCallback((e: React.PointerEvent<SVGPathElement>, f: ChoroplethFeature, sticky: boolean) => { | ||||
| 40 | + const box = e.currentTarget.ownerSVGElement?.parentElement?.getBoundingClientRect(); | ||||
| 41 | + if (!box) return; | ||||
| 42 | + setActive({ f, x: e.clientX - box.left, y: e.clientY - box.top, sticky }); | ||||
| 43 | + }, []); | ||||
| 44 | + | ||||
| 45 | + const open = (f: ChoroplethFeature) => { | ||||
| 46 | + if (f.slug) router.push(routes.country(f.slug)); | ||||
| 47 | + }; | ||||
| 48 | + | ||||
| 49 | + return ( | ||||
| 50 | + <figure className={cn('min-w-0', className)}> | ||||
| 51 | + <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}`, maxHeight: height }} onPointerLeave={() => setActive((a) => (a?.sticky ? a : null))}> | ||||
| 52 | + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={summary}> | ||||
| 53 | + <title>{title}</title> | ||||
| 54 | + <desc>{summary}</desc> | ||||
| 55 | + <defs> | ||||
| 56 | + <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> | ||||
| 57 | + <rect width="6" height="6" fill="var(--nodata)" /> | ||||
| 58 | + <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> | ||||
| 59 | + </pattern> | ||||
| 60 | + </defs> | ||||
| 61 | + <path d={sphere} fill="var(--surface)" stroke="var(--rule)" strokeWidth={1} /> | ||||
| 62 | + <g stroke="var(--surface)" strokeWidth={0.6} strokeLinejoin="round"> | ||||
| 63 | + {features.map((f, i) => { | ||||
| 64 | + const hasData = f.cls != null; | ||||
| 65 | + const isActive = active?.f === f; | ||||
| 66 | + return ( | ||||
| 67 | + <path | ||||
| 68 | + key={f.iso3 ?? `${f.name}-${i}`} | ||||
| 69 | + d={f.d} | ||||
| 70 | + fill={hasData ? seqVar(stepFor(f.cls!, k)) : `url(#${id}-hatch)`} | ||||
| 71 | + className={cn(f.slug && 'cursor-pointer', 'transition-[fill-opacity] duration-100')} | ||||
| 72 | + fillOpacity={isActive ? 0.75 : 1} | ||||
| 73 | + tabIndex={f.slug ? 0 : -1} | ||||
| 74 | + role={f.slug ? 'link' : undefined} | ||||
| 75 | + aria-label={`${f.name}: ${formatValue(f.value, spec)}`} | ||||
| 76 | + onPointerMove={(e) => { | ||||
| 77 | + if (e.pointerType === 'mouse') place(e, f, false); | ||||
| 78 | + }} | ||||
| 79 | + onPointerDown={(e) => { | ||||
| 80 | + if (e.pointerType !== 'mouse') { | ||||
| 81 | + e.preventDefault(); | ||||
| 82 | + place(e, f, true); | ||||
| 83 | + } | ||||
| 84 | + }} | ||||
| 85 | + onClick={(e) => { | ||||
| 86 | + // Mouse: navigate. Touch: the label carries the link (first tap selects). | ||||
| 87 | + if ((e.nativeEvent as PointerEvent).pointerType === 'mouse' || (e as unknown as { detail: number }).detail === 0) open(f); | ||||
| 88 | + }} | ||||
| 89 | + onKeyDown={(e) => { | ||||
| 90 | + if (e.key === 'Enter' && f.slug) open(f); | ||||
| 91 | + }} | ||||
| 92 | + onFocus={(e) => { | ||||
| 93 | + const b = e.currentTarget.getBBox(); | ||||
| 94 | + setActive({ f, x: (b.x + b.width / 2) / MAP_WIDTH * (e.currentTarget.ownerSVGElement?.clientWidth ?? MAP_WIDTH), y: (b.y / MAP_HEIGHT) * (e.currentTarget.ownerSVGElement?.clientHeight ?? MAP_HEIGHT), sticky: false }); | ||||
| 95 | + }} | ||||
| 96 | + > | ||||
| 97 | + <title>{`${f.name}: ${formatValue(f.value, spec)}`}</title> | ||||
| 98 | + </path> | ||||
| 99 | + ); | ||||
| 100 | + })} | ||||
| 101 | + </g> | ||||
| 102 | + </svg> | ||||
| 103 | + {active ? ( | ||||
| 104 | + <div className="pointer-events-none absolute z-10 rounded-sm border border-rule bg-surface px-2.5 py-1.5 text-xs shadow-pop" style={{ left: Math.min(active.x + 10, Math.max(0, (typeof window !== 'undefined' ? 0 : 0) + active.x + 10)), top: active.y - 44, maxWidth: 220 }}> | ||||
| 105 | + <div className="flex items-center gap-1.5 font-medium text-ink"> | ||||
| 106 | + {active.f.flag ? <span aria-hidden>{active.f.flag}</span> : null} | ||||
| 107 | + <span className="truncate">{active.f.name}</span> | ||||
| 108 | + </div> | ||||
| 109 | + <div className="tnum text-ink-2">{formatValue(active.f.value, spec)}</div> | ||||
| 110 | + {active.sticky && active.f.slug ? ( | ||||
| 111 | + <button type="button" className="pointer-events-auto mt-1 text-accent underline" onClick={() => open(active.f)}> | ||||
| 112 | + {t('metric.open', { name: active.f.name })} → | ||||
| 113 | + </button> | ||||
| 114 | + ) : null} | ||||
| 115 | + </div> | ||||
| 116 | + ) : null} | ||||
| 117 | + </div> | ||||
| 118 | + <figcaption className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-2xs text-ink-2"> | ||||
| 119 | + {!compact ? <span className="mr-1 font-medium text-ink">{title}</span> : null} | ||||
| 120 | + <ul className="flex flex-wrap items-center gap-x-2.5 gap-y-1"> | ||||
| 121 | + {legend.map((l) => ( | ||||
| 122 | + <li key={l.cls} className="inline-flex items-center gap-1 tnum"> | ||||
| 123 | + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(l.cls, k)) }} /> | ||||
| 124 | + {l.label} | ||||
| 125 | + </li> | ||||
| 126 | + ))} | ||||
| 127 | + <li className="inline-flex items-center gap-1"> | ||||
| 128 | + <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> | ||||
| 129 | + {t('chart.legend.noData')} | ||||
| 130 | + </li> | ||||
| 131 | + </ul> | ||||
| 132 | + <span className="ml-auto hidden text-ink-3 md:inline">{t('chart.map.hoverHint')}</span> | ||||
| 133 | + <span className="ml-auto text-ink-3 md:hidden">{t('chart.map.tapHint')}</span> | ||||
| 134 | + </figcaption> | ||||
| 135 | + </figure> | ||||
| 136 | + ); | ||||
| 137 | +} | ||||
added
apps/web/src/components/charts/choropleth.tsx
+70 −0
@@ -0,0 +1,70 @@ | |||||
| 1 | +import { t } from '@/i18n'; | ||||
| 2 | +import { formatValue } from '@/lib/format'; | ||||
| 3 | +import { classIndex, isoLookupFromCountries, worldPaths } from '@/lib/map-geo'; | ||||
| 4 | +import type { CountrySummary, MapResponse } from '@/lib/types'; | ||||
| 5 | +import { ChoroplethView, type ChoroplethFeature } from './choropleth-view'; | ||||
| 6 | + | ||||
| 7 | +/** | ||||
| 8 | + * Server component: joins the world geometry with `/indicators/{slug}/map` values and the country list | ||||
| 9 | + * (for slugs and the ISO-numeric lookup), computes 5–7 quantile classes from the API legend, and hands | ||||
| 10 | + * plain data to the interactive client view. Sequential single-hue ramp (light → dark = low → high). | ||||
| 11 | + */ | ||||
| 12 | +export function Choropleth({ | ||||
| 13 | + map, | ||||
| 14 | + countries, | ||||
| 15 | + height, | ||||
| 16 | + compact = false, | ||||
| 17 | + className, | ||||
| 18 | + title, | ||||
| 19 | +}: { | ||||
| 20 | + map: MapResponse; | ||||
| 21 | + countries: CountrySummary[]; | ||||
| 22 | + height?: number; | ||||
| 23 | + compact?: boolean; | ||||
| 24 | + className?: string; | ||||
| 25 | + title?: string; | ||||
| 26 | +}) { | ||||
| 27 | + const { paths, sphere } = worldPaths(); | ||||
| 28 | + const byId = new Map(countries.map((c) => [c.id, c])); | ||||
| 29 | + // Registry numeric → ISO3 lookup (when the API exposes iso_numeric on the summary); static table otherwise. | ||||
| 30 | + const numeric = isoLookupFromCountries(countries as Array<{ id: string; iso_numeric?: string | null }>); | ||||
| 31 | + const breaks = (map.legend?.breaks ?? []).slice(0, 6); | ||||
| 32 | + const k = breaks.length + 1; // classes | ||||
| 33 | + const features: ChoroplethFeature[] = paths.map((p) => { | ||||
| 34 | + // Prefer registry numeric lookup when the static table has no code. | ||||
| 35 | + const iso3 = p.iso3 ?? (p.numeric ? numeric.get(p.numeric) ?? null : null); | ||||
| 36 | + const c = iso3 ? byId.get(iso3) : undefined; | ||||
| 37 | + const v = iso3 ? map.values[iso3] : undefined; | ||||
| 38 | + return { | ||||
| 39 | + iso3, | ||||
| 40 | + name: c?.name ?? p.name, | ||||
| 41 | + slug: c?.slug ?? null, | ||||
| 42 | + flag: c?.flag ?? null, | ||||
| 43 | + d: p.d, | ||||
| 44 | + value: typeof v === 'number' ? v : null, | ||||
| 45 | + cls: typeof v === 'number' ? classIndex(v, breaks) : null, | ||||
| 46 | + }; | ||||
| 47 | + }); | ||||
| 48 | + const spec = map.indicator; | ||||
| 49 | + const legendItems = Array.from({ length: k }, (_, i) => { | ||||
| 50 | + const lo = i === 0 ? map.legend.min : breaks[i - 1]!; | ||||
| 51 | + const hi = i === k - 1 ? map.legend.max : breaks[i]!; | ||||
| 52 | + return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` }; | ||||
| 53 | + }); | ||||
| 54 | + const year = map.year_used ?? map.year ?? ''; | ||||
| 55 | + const summary = t('chart.summary.map', { name: spec.name, year, n: map.n, min: formatValue(map.legend.min, spec), max: formatValue(map.legend.max, spec) }); | ||||
| 56 | + return ( | ||||
| 57 | + <ChoroplethView | ||||
| 58 | + features={features} | ||||
| 59 | + sphere={sphere} | ||||
| 60 | + legend={legendItems} | ||||
| 61 | + k={k} | ||||
| 62 | + spec={{ format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, name: spec.name }} | ||||
| 63 | + summary={summary} | ||||
| 64 | + title={title ?? t('chart.map.legend', { name: spec.name, year })} | ||||
| 65 | + height={height} | ||||
| 66 | + compact={compact} | ||||
| 67 | + className={className} | ||||
| 68 | + /> | ||||
| 69 | + ); | ||||
| 70 | +} | ||||
added
apps/web/src/components/charts/dna-radial.tsx
+71 −0
@@ -0,0 +1,71 @@ | |||||
| 1 | +import { t } from '@/i18n'; | ||||
| 2 | +import { cn } from '@/lib/cn'; | ||||
| 3 | +import { isNum } from '@/lib/format'; | ||||
| 4 | +import type { DNAResponse, DnaDimension } from '@/lib/types'; | ||||
| 5 | +import { CHART } from './palette'; | ||||
| 6 | + | ||||
| 7 | +export const DNA_DIMS: DnaDimension[] = ['income', 'demographics', 'urbanization', 'trade', 'energy', 'emissions', 'innovation', 'education', 'public_spending']; | ||||
| 8 | + | ||||
| 9 | +/** | ||||
| 10 | + * Radial "fingerprint": 9 dimensions, 0–100 percentile rank, drawn as a filled polygon on a 100-radius | ||||
| 11 | + * ring with 25/50/75 guide rings and labelled spokes. Server-rendered SVG in a square viewBox; a missing | ||||
| 12 | + * dimension collapses to the centre and is marked in the legend. | ||||
| 13 | + */ | ||||
| 14 | +export function DnaRadial({ dna, name, size = 320, className, compact = false }: { dna: Pick<DNAResponse, 'dims' | 'year_ref'> | null; name: string; size?: number; className?: string; compact?: boolean }) { | ||||
| 15 | + const dims = DNA_DIMS.map((d) => ({ key: d, label: t(`country.dna.${d}` as const), value: dna?.dims?.[d] ?? null })); | ||||
| 16 | + const present = dims.filter((d) => isNum(d.value)); | ||||
| 17 | + if (!dna || present.length === 0) return <p className="text-sm text-ink-3">{t('country.dna.none')}</p>; | ||||
| 18 | + | ||||
| 19 | + const R = 100; | ||||
| 20 | + const pad = compact ? 16 : 44; | ||||
| 21 | + const cx = R + pad; | ||||
| 22 | + const cy = R + pad; | ||||
| 23 | + const W = 2 * (R + pad); | ||||
| 24 | + const angle = (i: number) => -Math.PI / 2 + (i / dims.length) * 2 * Math.PI; | ||||
| 25 | + const pt = (i: number, r: number): [number, number] => [cx + r * Math.cos(angle(i)), cy + r * Math.sin(angle(i))]; | ||||
| 26 | + const poly = dims.map((d, i) => pt(i, isNum(d.value) ? (d.value / 100) * R : 0)); | ||||
| 27 | + const summary = t('chart.summary.dna', { name, dims: present.map((d) => `${d.label} ${Math.round(d.value as number)}`).join(', ') }); | ||||
| 28 | + | ||||
| 29 | + return ( | ||||
| 30 | + <figure className={cn('min-w-0', className)}> | ||||
| 31 | + <svg viewBox={`0 0 ${W} ${W}`} width="100%" style={{ maxWidth: size, aspectRatio: '1 / 1', margin: '0 auto' }} role="img" aria-label={summary} className="ca-chart"> | ||||
| 32 | + <title>{t('country.dna.title')}</title> | ||||
| 33 | + <desc>{summary}</desc> | ||||
| 34 | + {[25, 50, 75, 100].map((r) => ( | ||||
| 35 | + <circle key={r} cx={cx} cy={cy} r={(r / 100) * R} fill="none" stroke={CHART.rule} strokeWidth={r === 100 ? 1 : 0.75} /> | ||||
| 36 | + ))} | ||||
| 37 | + {dims.map((_, i) => { | ||||
| 38 | + const [x, y] = pt(i, R); | ||||
| 39 | + return <line key={i} x1={cx} y1={cy} x2={x} y2={y} stroke={CHART.rule} strokeWidth={0.75} />; | ||||
| 40 | + })} | ||||
| 41 | + <polygon points={poly.map((p) => p.join(',')).join(' ')} fill={CHART.accent} fillOpacity={0.16} stroke={CHART.accent} strokeWidth={2} strokeLinejoin="round" /> | ||||
| 42 | + {poly.map(([x, y], i) => ( | ||||
| 43 | + <circle key={i} cx={x} cy={y} r={isNum(dims[i]!.value) ? 4 : 0} fill={CHART.accent} stroke={CHART.surface} strokeWidth={2} /> | ||||
| 44 | + ))} | ||||
| 45 | + {!compact | ||||
| 46 | + ? dims.map((d, i) => { | ||||
| 47 | + const [x, y] = pt(i, R + 22); | ||||
| 48 | + const a = angle(i); | ||||
| 49 | + const anchor = Math.abs(Math.cos(a)) < 0.2 ? 'middle' : Math.cos(a) > 0 ? 'start' : 'end'; | ||||
| 50 | + return ( | ||||
| 51 | + <text key={d.key} x={x} y={y} textAnchor={anchor} dy="0.32em" className={isNum(d.value) ? 'label' : ''} style={{ fontSize: 10, fill: isNum(d.value) ? CHART.ink2 : CHART.ink3 }}> | ||||
| 52 | + {d.label} | ||||
| 53 | + {isNum(d.value) ? ` ${Math.round(d.value)}` : ' —'} | ||||
| 54 | + </text> | ||||
| 55 | + ); | ||||
| 56 | + }) | ||||
| 57 | + : null} | ||||
| 58 | + </svg> | ||||
| 59 | + {compact ? ( | ||||
| 60 | + <figcaption className="mt-2 grid grid-cols-3 gap-x-3 gap-y-1 text-2xs text-ink-2"> | ||||
| 61 | + {dims.map((d) => ( | ||||
| 62 | + <span key={d.key} className="flex justify-between gap-1"> | ||||
| 63 | + <span className="truncate">{d.label}</span> | ||||
| 64 | + <span className="tnum text-ink">{isNum(d.value) ? Math.round(d.value) : '—'}</span> | ||||
| 65 | + </span> | ||||
| 66 | + ))} | ||||
| 67 | + </figcaption> | ||||
| 68 | + ) : null} | ||||
| 69 | + </figure> | ||||
| 70 | + ); | ||||
| 71 | +} | ||||
added
apps/web/src/components/charts/line-chart.tsx
+302 −0
@@ -0,0 +1,302 @@ | |||||
| 1 | +'use client'; | ||||
| 2 | +import { area as d3Area, line as d3Line, stack as d3Stack } from 'd3-shape'; | ||||
| 3 | +import { useCallback, useMemo, useState, type PointerEvent } from 'react'; | ||||
| 4 | +import { t } from '@/i18n'; | ||||
| 5 | +import { formatPeriod, formatTick, formatValue } from '@/lib/format'; | ||||
| 6 | +import type { FormatSpec as Spec, Provenance } from '@/lib/types'; | ||||
| 7 | +import type { ProvenancePayload } from '@/components/data/provenance-context'; | ||||
| 8 | +import { ChartFrame, Legend, type TableData } from './chart-frame'; | ||||
| 9 | +import { CHART, MARK, seriesVar } from './palette'; | ||||
| 10 | +import { DEFAULT_MARGIN, extent, periodToX, splitForecast, xYearScale, yScale, yearTicks, type Margin, type SeriesPoint } from './scales'; | ||||
| 11 | +import { summarizeMulti, summarizeSeries } from './summary'; | ||||
| 12 | +import { ChartTooltip, TooltipRow } from './tooltip'; | ||||
| 13 | +import { useMeasure } from './use-measure'; | ||||
| 14 | + | ||||
| 15 | +export interface LineSeries { | ||||
| 16 | + id: string; | ||||
| 17 | + name: string; | ||||
| 18 | + points: SeriesPoint[]; | ||||
| 19 | + /** Fixed colour slot (0-based). Defaults to the array index — colour follows the entity, never its rank. */ | ||||
| 20 | + colorIndex?: number; | ||||
| 21 | + color?: string; | ||||
| 22 | +} | ||||
| 23 | + | ||||
| 24 | +export interface LineChartProps { | ||||
| 25 | + series: LineSeries[]; | ||||
| 26 | + spec: Spec; | ||||
| 27 | + /** Subject for the accessible summary ("Canada's GDP per capita"). */ | ||||
| 28 | + subject?: string; | ||||
| 29 | + variant?: 'line' | 'area' | 'stacked'; | ||||
| 30 | + log?: boolean; | ||||
| 31 | + height?: number; | ||||
| 32 | + margin?: Partial<Margin>; | ||||
| 33 | + title?: React.ReactNode; | ||||
| 34 | + subtitle?: React.ReactNode; | ||||
| 35 | + provenance?: Provenance | null; | ||||
| 36 | + payload?: ProvenancePayload | null; | ||||
| 37 | + actions?: React.ReactNode; | ||||
| 38 | + /** Direct end-labels for ≤ 4 series. */ | ||||
| 39 | + endLabels?: boolean; | ||||
| 40 | + /** Draw a highlighted marker on the latest actual point. */ | ||||
| 41 | + endDot?: boolean; | ||||
| 42 | + className?: string; | ||||
| 43 | + defaultWidth?: number; | ||||
| 44 | +} | ||||
| 45 | + | ||||
| 46 | +interface XY { | ||||
| 47 | + x: number; | ||||
| 48 | + y: number; | ||||
| 49 | + p: SeriesPoint; | ||||
| 50 | +} | ||||
| 51 | + | ||||
| 52 | +/** | ||||
| 53 | + * Multi-series line / area / stacked-area chart. Dashed segments for `is_forecast`, optional log scale, | ||||
| 54 | + * crosshair + one tooltip listing every series at the nearest X (pointer events → touch friendly). | ||||
| 55 | + * Index=100 transformations are data-side (pass already-indexed points). | ||||
| 56 | + */ | ||||
| 57 | +export function LineChart({ | ||||
| 58 | + series, | ||||
| 59 | + spec, | ||||
| 60 | + subject, | ||||
| 61 | + variant = 'line', | ||||
| 62 | + log = false, | ||||
| 63 | + height = 240, | ||||
| 64 | + margin: marginIn, | ||||
| 65 | + title, | ||||
| 66 | + subtitle, | ||||
| 67 | + provenance, | ||||
| 68 | + payload, | ||||
| 69 | + actions, | ||||
| 70 | + endLabels = true, | ||||
| 71 | + endDot = true, | ||||
| 72 | + className, | ||||
| 73 | + defaultWidth = 640, | ||||
| 74 | +}: LineChartProps) { | ||||
| 75 | + const { ref, width } = useMeasure<HTMLDivElement>(defaultWidth); | ||||
| 76 | + const margin: Margin = { ...DEFAULT_MARGIN, ...marginIn }; | ||||
| 77 | + if (endLabels && series.length > 1 && series.length <= 4) margin.right = Math.max(margin.right, 72); | ||||
| 78 | + const [hover, setHover] = useState<number | null>(null); // x-value (fractional year) | ||||
| 79 | + | ||||
| 80 | + const model = useMemo(() => { | ||||
| 81 | + const clean = series.map((s) => ({ ...s, points: s.points.filter((p) => p.value != null && Number.isFinite(p.value)) })); | ||||
| 82 | + const allX = clean.flatMap((s) => s.points.map(periodToX)); | ||||
| 83 | + const xDom = extent(allX) ?? [2000, 2024]; | ||||
| 84 | + const innerW = Math.max(10, width - margin.left - margin.right); | ||||
| 85 | + const innerH = Math.max(10, height - margin.top - margin.bottom); | ||||
| 86 | + const x = xYearScale(xDom, [0, innerW]); | ||||
| 87 | + | ||||
| 88 | + let stacks: Array<Array<[number, number, number]>> | null = null; // per series: [x, y0, y1] | ||||
| 89 | + let yDom: [number, number]; | ||||
| 90 | + if (variant === 'stacked') { | ||||
| 91 | + // Build a wide table keyed by x. | ||||
| 92 | + const keys = clean.map((s) => s.id); | ||||
| 93 | + const xs = Array.from(new Set(allX)).sort((a, b) => a - b); | ||||
| 94 | + const table = xs.map((xv) => { | ||||
| 95 | + const row: Record<string, number> = { __x: xv }; | ||||
| 96 | + for (const s of clean) { | ||||
| 97 | + const p = s.points.find((q) => periodToX(q) === xv); | ||||
| 98 | + row[s.id] = p?.value ?? 0; | ||||
| 99 | + } | ||||
| 100 | + return row; | ||||
| 101 | + }); | ||||
| 102 | + const st = d3Stack<Record<string, number>>().keys(keys)(table); | ||||
| 103 | + stacks = st.map((layer) => layer.map((d) => [d.data.__x!, d[0], d[1]] as [number, number, number])); | ||||
| 104 | + const top = Math.max(0, ...stacks.flat().map((d) => d[2])); | ||||
| 105 | + yDom = [0, top]; | ||||
| 106 | + } else { | ||||
| 107 | + yDom = extent(clean.flatMap((s) => s.points.map((p) => p.value))) ?? [0, 1]; | ||||
| 108 | + } | ||||
| 109 | + const y = yScale(yDom, [innerH, 0], { log, includeZero: variant !== 'line' || !log }); | ||||
| 110 | + const xTicks = yearTicks(xDom, Math.max(2, Math.floor(innerW / 90))); | ||||
| 111 | + const yTicks = y.ticks(4); | ||||
| 112 | + const pathFor = d3Line<XY>() | ||||
| 113 | + .x((d) => d.x) | ||||
| 114 | + .y((d) => d.y); | ||||
| 115 | + const areaFor = d3Area<XY>() | ||||
| 116 | + .x((d) => d.x) | ||||
| 117 | + .y0(() => y(Math.max(0, y.domain()[0]!))) | ||||
| 118 | + .y1((d) => d.y); | ||||
| 119 | + | ||||
| 120 | + const layers = clean.map((s, i) => { | ||||
| 121 | + const color = s.color ?? seriesVar(s.colorIndex ?? i); | ||||
| 122 | + const runs = splitForecast(s.points).map((r) => ({ | ||||
| 123 | + forecast: r.forecast, | ||||
| 124 | + xy: r.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })), | ||||
| 125 | + })); | ||||
| 126 | + const all = s.points.map((p) => ({ x: x(periodToX(p)), y: y(p.value!), p })); | ||||
| 127 | + const actual = s.points.filter((p) => !p.is_forecast); | ||||
| 128 | + const last = actual[actual.length - 1] ?? null; | ||||
| 129 | + return { s, color, runs, all, last: last ? { x: x(periodToX(last)), y: y(last.value!), p: last } : null }; | ||||
| 130 | + }); | ||||
| 131 | + | ||||
| 132 | + const stackPaths = | ||||
| 133 | + stacks && | ||||
| 134 | + stacks.map((layer, i) => { | ||||
| 135 | + const a = d3Area<[number, number, number]>() | ||||
| 136 | + .x((d) => x(d[0])) | ||||
| 137 | + .y0((d) => y(d[1])) | ||||
| 138 | + .y1((d) => y(d[2])); | ||||
| 139 | + return { d: a(layer) ?? '', color: clean[i]!.color ?? seriesVar(clean[i]!.colorIndex ?? i) }; | ||||
| 140 | + }); | ||||
| 141 | + | ||||
| 142 | + return { clean, x, y, innerW, innerH, xTicks, yTicks, pathFor, areaFor, layers, stackPaths, xDom }; | ||||
| 143 | + }, [series, width, height, margin.left, margin.right, margin.top, margin.bottom, variant, log]); | ||||
| 144 | + | ||||
| 145 | + const onMove = useCallback( | ||||
| 146 | + (e: PointerEvent<SVGRectElement>) => { | ||||
| 147 | + const rect = e.currentTarget.getBoundingClientRect(); | ||||
| 148 | + const px = e.clientX - rect.left; | ||||
| 149 | + const xv = model.x.invert(px); | ||||
| 150 | + // snap to nearest existing x | ||||
| 151 | + let best: number | null = null; | ||||
| 152 | + let bestD = Infinity; | ||||
| 153 | + for (const s of model.clean) | ||||
| 154 | + for (const p of s.points) { | ||||
| 155 | + const d = Math.abs(periodToX(p) - xv); | ||||
| 156 | + if (d < bestD) { | ||||
| 157 | + bestD = d; | ||||
| 158 | + best = periodToX(p); | ||||
| 159 | + } | ||||
| 160 | + } | ||||
| 161 | + setHover(best); | ||||
| 162 | + }, | ||||
| 163 | + [model], | ||||
| 164 | + ); | ||||
| 165 | + | ||||
| 166 | + const hoverRows = useMemo(() => { | ||||
| 167 | + if (hover == null) return null; | ||||
| 168 | + return model.layers | ||||
| 169 | + .map((l) => { | ||||
| 170 | + const p = l.s.points.find((q) => periodToX(q) === hover); | ||||
| 171 | + return p ? { name: l.s.name, color: l.color, p } : null; | ||||
| 172 | + }) | ||||
| 173 | + .filter((r): r is { name: string; color: string; p: SeriesPoint } => !!r); | ||||
| 174 | + }, [hover, model]); | ||||
| 175 | + | ||||
| 176 | + const summary = | ||||
| 177 | + series.length === 1 ? summarizeSeries(subject ?? series[0]!.name, series[0]!.points, spec) : summarizeMulti(series.map((s) => s.name), series.map((s) => s.points)); | ||||
| 178 | + | ||||
| 179 | + const table: TableData = useMemo(() => { | ||||
| 180 | + const xs = Array.from(new Set(model.clean.flatMap((s) => s.points.map((p) => p.period)))).sort(); | ||||
| 181 | + return { | ||||
| 182 | + columns: [{ key: 'period', label: t('common.period') }, ...model.clean.map((s) => ({ key: s.id, label: s.name, numeric: true }))], | ||||
| 183 | + rows: xs.map((period) => { | ||||
| 184 | + const row: Record<string, string> = { period: formatPeriod(period, spec.frequency ?? 'A') }; | ||||
| 185 | + for (const s of model.clean) { | ||||
| 186 | + const p = s.points.find((q) => q.period === period); | ||||
| 187 | + row[s.id] = p ? `${formatValue(p.value, spec)}${p.is_forecast ? ' *' : ''}` : t('common.na'); | ||||
| 188 | + } | ||||
| 189 | + return row; | ||||
| 190 | + }), | ||||
| 191 | + }; | ||||
| 192 | + }, [model, spec]); | ||||
| 193 | + | ||||
| 194 | + const hasForecast = series.some((s) => s.points.some((p) => p.is_forecast)); | ||||
| 195 | + const hoverX = hover != null ? model.x(hover) : null; | ||||
| 196 | + const legendItems = model.layers.map((l) => ({ label: l.s.name, color: l.color, shape: variant === 'line' ? ('line' as const) : ('rect' as const) })); | ||||
| 197 | + const empty = model.clean.every((s) => s.points.length === 0); | ||||
| 198 | + | ||||
| 199 | + return ( | ||||
| 200 | + <ChartFrame | ||||
| 201 | + title={title} | ||||
| 202 | + subtitle={subtitle} | ||||
| 203 | + summary={summary} | ||||
| 204 | + provenance={provenance} | ||||
| 205 | + payload={payload} | ||||
| 206 | + table={empty ? undefined : table} | ||||
| 207 | + actions={actions} | ||||
| 208 | + legend={<Legend items={legendItems} />} | ||||
| 209 | + note={hasForecast ? t('chart.forecastNote') : undefined} | ||||
| 210 | + className={className} | ||||
| 211 | + minHeight={height} | ||||
| 212 | + > | ||||
| 213 | + <div ref={ref} className="relative w-full" style={{ height }}> | ||||
| 214 | + {empty ? ( | ||||
| 215 | + <div className="grid h-full place-items-center text-sm text-ink-3">{t('chart.noData')}</div> | ||||
| 216 | + ) : ( | ||||
| 217 | + <svg className="ca-chart" width={width} height={height} viewBox={`0 0 ${width} ${height}`} role="img" aria-label={summary}> | ||||
| 218 | + <title>{typeof title === 'string' ? title : (spec.name ?? subject ?? '')}</title> | ||||
| 219 | + <desc>{summary}</desc> | ||||
| 220 | + <g transform={`translate(${margin.left},${margin.top})`}> | ||||
| 221 | + <g className="grid"> | ||||
| 222 | + {model.yTicks.map((tk) => ( | ||||
| 223 | + <line key={tk} x1={0} x2={model.innerW} y1={model.y(tk)} y2={model.y(tk)} /> | ||||
| 224 | + ))} | ||||
| 225 | + </g> | ||||
| 226 | + <g className="axis"> | ||||
| 227 | + <line className="baseline" x1={0} x2={model.innerW} y1={model.innerH} y2={model.innerH} /> | ||||
| 228 | + {model.xTicks.map((yr) => ( | ||||
| 229 | + <text key={yr} x={model.x(yr)} y={model.innerH + 16} textAnchor="middle"> | ||||
| 230 | + {yr} | ||||
| 231 | + </text> | ||||
| 232 | + ))} | ||||
| 233 | + {model.yTicks.map((tk) => ( | ||||
| 234 | + <text key={tk} x={-6} y={model.y(tk)} dy="0.32em" textAnchor="end"> | ||||
| 235 | + {formatTick(tk, spec)} | ||||
| 236 | + </text> | ||||
| 237 | + ))} | ||||
| 238 | + </g> | ||||
| 239 | + | ||||
| 240 | + {variant === 'stacked' && model.stackPaths | ||||
| 241 | + ? model.stackPaths.map((sp, i) => <path key={i} d={sp.d} fill={sp.color} fillOpacity={0.85} stroke={CHART.surface} strokeWidth={MARK.gap} />) | ||||
| 242 | + : null} | ||||
| 243 | + | ||||
| 244 | + {variant !== 'stacked' | ||||
| 245 | + ? model.layers.map((l) => ( | ||||
| 246 | + <g key={l.s.id}> | ||||
| 247 | + {variant === 'area' ? <path d={model.areaFor(l.all) ?? ''} fill={l.color} fillOpacity={MARK.areaOpacity} /> : null} | ||||
| 248 | + {l.runs.map((r, i) => ( | ||||
| 249 | + <path key={i} className={`series-line${r.forecast ? ' forecast' : ''}`} d={model.pathFor(r.xy) ?? ''} stroke={l.color} /> | ||||
| 250 | + ))} | ||||
| 251 | + {endDot && l.last ? ( | ||||
| 252 | + <g> | ||||
| 253 | + <circle className="ring" cx={l.last.x} cy={l.last.y} r={MARK.dotR + MARK.ringW / 2} fill={l.color} /> | ||||
| 254 | + </g> | ||||
| 255 | + ) : null} | ||||
| 256 | + {endLabels && series.length > 1 && series.length <= 4 && l.last ? ( | ||||
| 257 | + <text className="label" x={l.last.x + 8} y={l.last.y} dy="0.32em"> | ||||
| 258 | + {l.s.name} | ||||
| 259 | + </text> | ||||
| 260 | + ) : null} | ||||
| 261 | + </g> | ||||
| 262 | + )) | ||||
| 263 | + : null} | ||||
| 264 | + | ||||
| 265 | + {series.length === 1 && model.layers[0]?.last ? ( | ||||
| 266 | + <text className="label label-strong" x={Math.min(model.layers[0].last.x + 8, model.innerW - 4)} y={model.layers[0].last.y} dy="0.32em" textAnchor={model.layers[0].last.x + 60 > model.innerW ? 'end' : 'start'} dx={model.layers[0].last.x + 60 > model.innerW ? -10 : 0}> | ||||
| 267 | + {formatValue(model.layers[0].last.p.value, spec)} | ||||
| 268 | + </text> | ||||
| 269 | + ) : null} | ||||
| 270 | + | ||||
| 271 | + {hoverX != null ? <line x1={hoverX} x2={hoverX} y1={0} y2={model.innerH} stroke={CHART.ruleStrong} strokeWidth={1} /> : null} | ||||
| 272 | + {hoverRows?.map((r) => { | ||||
| 273 | + const l = model.layers.find((ly) => ly.s.name === r.name)!; | ||||
| 274 | + return <circle key={r.name} className="ring" cx={model.x(periodToX(r.p))} cy={model.y(r.p.value!)} r={MARK.dotR} fill={l.color} />; | ||||
| 275 | + })} | ||||
| 276 | + | ||||
| 277 | + <rect x={0} y={0} width={model.innerW} height={model.innerH} fill="transparent" style={{ touchAction: 'pan-y' }} onPointerMove={onMove} onPointerDown={onMove} onPointerLeave={() => setHover(null)} /> | ||||
| 278 | + </g> | ||||
| 279 | + </svg> | ||||
| 280 | + )} | ||||
| 281 | + {hover != null && hoverRows && hoverRows.length > 0 && hoverX != null ? ( | ||||
| 282 | + <ChartTooltip x={margin.left + hoverX} y={margin.top} width={width}> | ||||
| 283 | + <div className="mb-0.5 text-2xs text-ink-3"> | ||||
| 284 | + {formatPeriod(hoverRows[0]!.p.period, spec.frequency ?? 'A')} | ||||
| 285 | + {hoverRows.some((r) => r.p.is_forecast) ? ` · ${t('chart.tooltipForecast')}` : ''} | ||||
| 286 | + </div> | ||||
| 287 | + {hoverRows.map((r) => ( | ||||
| 288 | + <TooltipRow key={r.name} color={series.length > 1 ? r.color : undefined} label={series.length > 1 ? r.name : (spec.name ?? '')} value={formatValue(r.p.value, spec)} /> | ||||
| 289 | + ))} | ||||
| 290 | + </ChartTooltip> | ||||
| 291 | + ) : null} | ||||
| 292 | + </div> | ||||
| 293 | + </ChartFrame> | ||||
| 294 | + ); | ||||
| 295 | +} | ||||
| 296 | + | ||||
| 297 | +export function AreaChart(props: Omit<LineChartProps, 'variant'>) { | ||||
| 298 | + return <LineChart {...props} variant="area" />; | ||||
| 299 | +} | ||||
| 300 | +export function StackedArea(props: Omit<LineChartProps, 'variant' | 'log'>) { | ||||
| 301 | + return <LineChart {...props} variant="stacked" />; | ||||
| 302 | +} | ||||
added
apps/web/src/components/charts/palette.ts
+43 −0
@@ -0,0 +1,43 @@ | |||||
| 1 | +/** | ||||
| 2 | + * Chart colour roles — all CSS custom properties so light/dark swap without re-render. | ||||
| 3 | + * Categorical slots are assigned in FIXED order (dataviz rule: colour follows the entity, never its rank; | ||||
| 4 | + * never cycle past 8 — fold to "Other" or facet). | ||||
| 5 | + */ | ||||
| 6 | +export const SERIES_MAX = 8; | ||||
| 7 | + | ||||
| 8 | +export function seriesVar(index: number): string { | ||||
| 9 | + const i = Math.min(Math.max(index, 0), SERIES_MAX - 1) + 1; | ||||
| 10 | + return `var(--series-${i})`; | ||||
| 11 | +} | ||||
| 12 | + | ||||
| 13 | +/** Sequential (choropleth) class fill, k ∈ 1..7 light→dark. */ | ||||
| 14 | +export function seqVar(step: number): string { | ||||
| 15 | + const i = Math.min(Math.max(step, 1), 7); | ||||
| 16 | + return `var(--seq-${i})`; | ||||
| 17 | +} | ||||
| 18 | + | ||||
| 19 | +export const CHART = { | ||||
| 20 | + ink: 'var(--ink)', | ||||
| 21 | + ink2: 'var(--ink-2)', | ||||
| 22 | + ink3: 'var(--ink-3)', | ||||
| 23 | + rule: 'var(--rule)', | ||||
| 24 | + ruleStrong: 'var(--rule-strong)', | ||||
| 25 | + surface: 'var(--surface)', | ||||
| 26 | + accent: 'var(--accent)', | ||||
| 27 | + accentSoft: 'var(--accent-soft)', | ||||
| 28 | + up: 'var(--up)', | ||||
| 29 | + down: 'var(--down)', | ||||
| 30 | + nodata: 'var(--nodata)', | ||||
| 31 | + forecast: 'var(--forecast)', | ||||
| 32 | +} as const; | ||||
| 33 | + | ||||
| 34 | +/** Mark specs (dataviz marks-and-anatomy): thin marks, 2px lines, ≥8px end dots, 24px max bar thickness. */ | ||||
| 35 | +export const MARK = { | ||||
| 36 | + line: 2, | ||||
| 37 | + dotR: 4, | ||||
| 38 | + ringW: 2, | ||||
| 39 | + barMax: 24, | ||||
| 40 | + barRadius: 4, | ||||
| 41 | + gap: 2, | ||||
| 42 | + areaOpacity: 0.12, | ||||
| 43 | +} as const; | ||||
added
apps/web/src/components/charts/population-pyramid.tsx
+58 −0
@@ -0,0 +1,58 @@ | |||||
| 1 | +import { t } from '@/i18n'; | ||||
| 2 | +import { fixed, isNum } from '@/lib/format'; | ||||
| 3 | +import { seriesVar } from './palette'; | ||||
| 4 | + | ||||
| 5 | +/** | ||||
| 6 | + * Age structure as a single stacked bar (0–14 / 15–64 / 65+ shares, % of population). Ordinal colouring | ||||
| 7 | + * (one hue, three lightness steps via the sequential ramp) because the categories are ordered. | ||||
| 8 | + * A 2 px surface gap separates segments; labels inside only when the segment is wide enough. | ||||
| 9 | + */ | ||||
| 10 | +export function PopulationPyramid({ | ||||
| 11 | + shares, | ||||
| 12 | + name, | ||||
| 13 | + year, | ||||
| 14 | + className, | ||||
| 15 | +}: { | ||||
| 16 | + shares: { young: number | null; working: number | null; old: number | null }; | ||||
| 17 | + name: string; | ||||
| 18 | + year?: number | null; | ||||
| 19 | + className?: string; | ||||
| 20 | +}) { | ||||
| 21 | + const parts = [ | ||||
| 22 | + { key: 'young', label: '0–14', value: shares.young, color: 'var(--seq-2)' }, | ||||
| 23 | + { key: 'working', label: '15–64', value: shares.working, color: 'var(--seq-4)' }, | ||||
| 24 | + { key: 'old', label: '65+', value: shares.old, color: 'var(--seq-6)' }, | ||||
| 25 | + ]; | ||||
| 26 | + const valid = parts.filter((p) => isNum(p.value)); | ||||
| 27 | + if (valid.length === 0) return <p className="text-sm text-ink-3">{t('chart.noData')}</p>; | ||||
| 28 | + const total = valid.reduce((a, p) => a + (p.value as number), 0) || 100; | ||||
| 29 | + const summary = t('chart.summary.pyramid', { name, parts: valid.map((p) => `${p.label}: ${fixed(p.value as number, 1)} %`).join(', ') }); | ||||
| 30 | + return ( | ||||
| 31 | + <figure className={className}> | ||||
| 32 | + <div className="flex h-6 w-full overflow-hidden rounded-xs" role="img" aria-label={summary} style={{ gap: 2 }}> | ||||
| 33 | + {valid.map((p) => { | ||||
| 34 | + const pct = ((p.value as number) / total) * 100; | ||||
| 35 | + return ( | ||||
| 36 | + <div key={p.key} className="relative h-full" style={{ width: `${pct}%`, background: p.color }} title={`${p.label}: ${fixed(p.value as number, 1)} %`}> | ||||
| 37 | + {pct > 14 ? ( | ||||
| 38 | + <span className="tnum absolute inset-0 grid place-items-center text-2xs font-medium" style={{ color: p.key === 'young' ? 'var(--ink)' : '#fff' }}> | ||||
| 39 | + {fixed(p.value as number, 0)} % | ||||
| 40 | + </span> | ||||
| 41 | + ) : null} | ||||
| 42 | + </div> | ||||
| 43 | + ); | ||||
| 44 | + })} | ||||
| 45 | + </div> | ||||
| 46 | + <figcaption className="mt-1.5 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2"> | ||||
| 47 | + {valid.map((p) => ( | ||||
| 48 | + <span key={p.key} className="inline-flex items-center gap-1.5"> | ||||
| 49 | + <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-xs" style={{ background: p.color }} /> | ||||
| 50 | + {p.label} <span className="tnum text-ink">{fixed(p.value as number, 1)} %</span> | ||||
| 51 | + </span> | ||||
| 52 | + ))} | ||||
| 53 | + {year ? <span className="text-ink-3">{year}</span> : null} | ||||
| 54 | + </figcaption> | ||||
| 55 | + {seriesVar(0) ? null : null} | ||||
| 56 | + </figure> | ||||
| 57 | + ); | ||||
| 58 | +} | ||||
added
apps/web/src/components/charts/ranked-bars.tsx
+108 −0
@@ -0,0 +1,108 @@ | |||||
| 1 | +import Link from 'next/link'; | ||||
| 2 | +import { t } from '@/i18n'; | ||||
| 3 | +import { cn } from '@/lib/cn'; | ||||
| 4 | +import { formatValue, isNum, ordinal } from '@/lib/format'; | ||||
| 5 | +import { routes } from '@/lib/site'; | ||||
| 6 | +import type { FormatSpec as Spec, Provenance } from '@/lib/types'; | ||||
| 7 | +import { MARK } from './palette'; | ||||
| 8 | + | ||||
| 9 | +export interface RankedBarRow { | ||||
| 10 | + id: string; | ||||
| 11 | + label: string; | ||||
| 12 | + flag?: string | null; | ||||
| 13 | + href?: string | null; | ||||
| 14 | + value: number | null; | ||||
| 15 | + rank?: number | null; | ||||
| 16 | + /** Secondary text shown right of the value (e.g. change). */ | ||||
| 17 | + hint?: string | null; | ||||
| 18 | +} | ||||
| 19 | + | ||||
| 20 | +/** | ||||
| 21 | + * Horizontal ranked bars rendered as HTML (server component, fully responsive, no SVG text scaling). | ||||
| 22 | + * One series → one colour (slot 1); `highlightId` marks the country of interest with the accent and bold label. | ||||
| 23 | + * Bars ≤ 24 px thick, 4 px rounded data-end, square at the baseline; value labelled at the tip (text tokens). | ||||
| 24 | + */ | ||||
| 25 | +export function RankedBars({ | ||||
| 26 | + rows, | ||||
| 27 | + spec, | ||||
| 28 | + highlightId, | ||||
| 29 | + showRank = true, | ||||
| 30 | + className, | ||||
| 31 | + provenance, | ||||
| 32 | + ariaLabel, | ||||
| 33 | + linkRows = true, | ||||
| 34 | +}: { | ||||
| 35 | + rows: RankedBarRow[]; | ||||
| 36 | + spec: Spec; | ||||
| 37 | + highlightId?: string | null; | ||||
| 38 | + showRank?: boolean; | ||||
| 39 | + className?: string; | ||||
| 40 | + provenance?: Provenance | null; | ||||
| 41 | + ariaLabel?: string; | ||||
| 42 | + linkRows?: boolean; | ||||
| 43 | +}) { | ||||
| 44 | + const values = rows.map((r) => r.value).filter(isNum); | ||||
| 45 | + const max = values.length ? Math.max(...values.map(Math.abs)) : 0; | ||||
| 46 | + const top = rows[0]; | ||||
| 47 | + const summary = top ? t('chart.summary.bars', { top: top.label, value: formatValue(top.value, spec), n: rows.length }) : t('chart.noData'); | ||||
| 48 | + if (rows.length === 0) return <p className="text-sm text-ink-3">{t('chart.noData')}</p>; | ||||
| 49 | + return ( | ||||
| 50 | + <div className={cn('min-w-0', className)} role="img" aria-label={ariaLabel ?? summary}> | ||||
| 51 | + <ol className="divide-y divide-rule"> | ||||
| 52 | + {rows.map((r, i) => { | ||||
| 53 | + const pct = isNum(r.value) && max > 0 ? Math.max(0, (Math.abs(r.value) / max) * 100) : 0; | ||||
| 54 | + const hl = highlightId && r.id === highlightId; | ||||
| 55 | + const label = ( | ||||
| 56 | + <span className={cn('flex min-w-0 items-center gap-1.5 truncate text-sm', hl ? 'font-semibold text-ink' : 'text-ink')}> | ||||
| 57 | + {r.flag ? ( | ||||
| 58 | + <span aria-hidden className="text-base leading-none"> | ||||
| 59 | + {r.flag} | ||||
| 60 | + </span> | ||||
| 61 | + ) : null} | ||||
| 62 | + <span className="truncate">{r.label}</span> | ||||
| 63 | + </span> | ||||
| 64 | + ); | ||||
| 65 | + return ( | ||||
| 66 | + <li key={r.id} className="grid grid-cols-[minmax(0,11rem)_1fr] items-center gap-x-3 py-1.5 sm:grid-cols-[minmax(0,13rem)_1fr]"> | ||||
| 67 | + <div className="flex min-w-0 items-center gap-2"> | ||||
| 68 | + {showRank ? <span className="tnum w-6 shrink-0 text-right text-xs text-ink-3">{r.rank ?? i + 1}</span> : null} | ||||
| 69 | + {linkRows && r.href ? ( | ||||
| 70 | + <Link href={r.href} className="link-quiet min-w-0 truncate"> | ||||
| 71 | + {label} | ||||
| 72 | + </Link> | ||||
| 73 | + ) : ( | ||||
| 74 | + label | ||||
| 75 | + )} | ||||
| 76 | + </div> | ||||
| 77 | + <div className="flex min-w-0 items-center gap-2"> | ||||
| 78 | + <div className="h-4 min-w-0 flex-1" style={{ maxHeight: MARK.barMax }}> | ||||
| 79 | + <div | ||||
| 80 | + className={cn('h-full rounded-r-sm', hl ? 'bg-accent' : 'bg-series-1')} | ||||
| 81 | + style={{ width: `${pct}%`, minWidth: isNum(r.value) ? 2 : 0, borderRadius: `0 ${MARK.barRadius}px ${MARK.barRadius}px 0`, opacity: hl ? 1 : 0.85 }} | ||||
| 82 | + /> | ||||
| 83 | + </div> | ||||
| 84 | + <span className={cn('tnum shrink-0 text-right text-sm', hl ? 'font-semibold text-ink' : 'text-ink')} style={{ minWidth: '4.5rem' }}> | ||||
| 85 | + {formatValue(r.value, spec)} | ||||
| 86 | + </span> | ||||
| 87 | + {r.hint ? <span className="tnum hidden shrink-0 text-xs text-ink-3 sm:inline">{r.hint}</span> : null} | ||||
| 88 | + </div> | ||||
| 89 | + </li> | ||||
| 90 | + ); | ||||
| 91 | + })} | ||||
| 92 | + </ol> | ||||
| 93 | + {provenance ? ( | ||||
| 94 | + <p className="mt-1.5 text-2xs text-ink-2"> | ||||
| 95 | + <span className="text-ink-3">{t('common.source')}: </span> | ||||
| 96 | + {[provenance.source_name, provenance.dataset].filter(Boolean).join(' — ')} · {provenance.series_code} | ||||
| 97 | + </p> | ||||
| 98 | + ) : null} | ||||
| 99 | + </div> | ||||
| 100 | + ); | ||||
| 101 | +} | ||||
| 102 | + | ||||
| 103 | +/** Convenience: a country row for RankedBars from a ranking row. */ | ||||
| 104 | +export function rankedRowFromCountry(c: { id: string; slug: string | null; name: string | null; flag: string | null }, value: number | null, rank?: number | null, hint?: string | null): RankedBarRow { | ||||
| 105 | + return { id: c.id, label: c.name ?? c.id, flag: c.flag, href: c.slug ? routes.country(c.slug) : null, value, rank, hint }; | ||||
| 106 | +} | ||||
| 107 | + | ||||
| 108 | +export { ordinal }; | ||||
added
apps/web/src/components/charts/scales.ts
+133 −0
Large diff (133 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/scatter.tsx
+118 −0
Large diff (118 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/slope-chart.tsx
+70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/small-multiples.tsx
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/source-line.tsx
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/sparkline.tsx
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/summary.ts
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/tooltip.tsx
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/charts/use-measure.ts
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/mobile-tab-bar.tsx
+46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/nav-links.tsx
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-container.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-context.tsx
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-dialog.tsx
+224 −0
Large diff (224 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/search-trigger.tsx
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-footer.tsx
+86 −0
Large diff (86 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/site-header.tsx
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/layout/theme-toggle.tsx
+41 −0
Large diff (41 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/i18n/en.ts
+368 −0
Large diff (368 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/i18n/index.ts
+28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api.ts
+155 −0
Large diff (155 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api.ts
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/cn.ts
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.system.ts
+3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/fonts.ts
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/format.ts
+258 −0
Large diff (258 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/iso-numeric.ts
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/map-geo.ts
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/regions.ts
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/site.ts
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/topics.ts
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/types.ts
+575 −0
Large diff (575 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/tsconfig.json
+22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added
docs/sources-research.md
+316 −0
Large diff (316 lines) is not loaded by default to keep the page fast.Load diff
added
package.json
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-lock.yaml
+1220 −0
Large diff (1,220 lines) is not loaded by default to keep the page fast.Load diff
added
pnpm-workspace.yaml
+2 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
modified
pyproject.toml
+2 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
modified
registry/indicators.yaml
+15 −10
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
registry/insights.yaml
+103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added
registry/similarity.yaml
+88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/bis.yaml
+13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/eurostat.yaml
+119 −0
Large diff (119 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/fred.yaml
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/ilo.yaml
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/imf.yaml
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/oecd.yaml
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/owid.yaml
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
registry/sources/who.yaml
+34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified
registry/topics.yaml
+8 −9
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/run_connector.py
+81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/cache.py
+61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/common.py
+380 −0
Large diff (380 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/db.py
+250 −0
Large diff (250 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/errors.py
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/formatting.py
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/main.py
+207 −0
Large diff (207 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/provenance.py
+97 −0
Large diff (97 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/ratelimit.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/admin.py
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/changes.py
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/compare.py
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/countries.py
+425 −0
Large diff (425 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/download.py
+144 −0
Large diff (144 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/health.py
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/home.py
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/indicators.py
+339 −0
Large diff (339 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/methodology.py
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/rankings.py
+184 −0
Large diff (184 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/regions.py
+153 −0
Large diff (153 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/search.py
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/series.py
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/routers/sources.py
+68 −0
Large diff (68 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/api/schemas.py
+570 −0
Large diff (570 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/cli.py
+295 −0
Large diff (295 lines) is not loaded by default to keep the page fast.Load diff
modified
src/countryatlas/connectors/__init__.py
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/_series.py
+104 −0
Large diff (104 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/_util.py
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/bis.py
+186 −0
Large diff (186 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/eurostat.py
+253 −0
Large diff (253 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/fred.py
+287 −0
Large diff (287 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/ilo.py
+176 −0
Large diff (176 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/imf.py
+206 −0
Large diff (206 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/oecd.py
+239 −0
Large diff (239 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/owid.py
+258 −0
Large diff (258 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/who.py
+219 −0
Large diff (219 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/connectors/worldbank.py
+232 −0
Large diff (232 lines) is not loaded by default to keep the page fast.Load diff
modified
src/countryatlas/pipeline/__init__.py
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/build.py
+448 −0
Large diff (448 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/changes.py
+366 −0
Large diff (366 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/derived.py
+236 −0
Large diff (236 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/export.py
+106 −0
Large diff (106 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/fetch.py
+228 −0
Large diff (228 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/format.py
+90 −0
Large diff (90 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/insights.py
+222 −0
Large diff (222 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/scheduler.py
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/similarity.py
+195 −0
Large diff (195 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/staging.py
+221 −0
Large diff (221 lines) is not loaded by default to keep the page fast.Load diff
added
src/countryatlas/pipeline/validate.py
+200 −0
Large diff (200 lines) is not loaded by default to keep the page fast.Load diff
modified
src/countryatlas/registry/__init__.py
+35 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/conftest.py
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/test_countries.py
+153 −0
Large diff (153 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/test_indicators.py
+96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/test_infra.py
+199 −0
Large diff (199 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/test_misc_routers.py
+117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added
tests/api/test_rankings_compare_series.py
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
tests/conftest.py
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/_helpers.py
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/conftest.py
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_bis.py
+102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_eurostat.py
+126 −0
Large diff (126 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_fred.py
+148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_ilo.py
+77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_imf.py
+88 −0
Large diff (88 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_oecd.py
+127 −0
Large diff (127 lines) is not loaded by default to keep the page fast.Load diff
added
tests/connectors/test_who.py
+89 −0
Large diff (89 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/__init__.py
+0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bis/WS_CBPOL_M.csv
+62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bis/WS_SPP_R_628.csv
+37 −0
Large diff (37 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/bis/WS_SPP_R_771.csv
+11 −0
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/eurostat/rd_e_gerdtot_sample.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/eurostat/une_rt_a_sample.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/obs_CPIAUCSL.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/obs_FEDFUNDS.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/obs_MORTGAGE30US_m.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/obs_QCAR628BIS.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/series_CPIAUCSL.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/series_FEDFUNDS.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/series_MORTGAGE30US.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/fred/series_QCAR628BIS.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/ilo/DF_UNE_2EAP_SEX_AGE_RT.csv
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/ilo/dataflow.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/imf/weo_NGDPD_sample.csv
+9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/imf/weo_NGDP_RPCH_sample.csv
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/make_fixture_db.py
+362 −0
Large diff (362 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/oecd/house_prices_RHP_sample.csv
+29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/oecd/kei_PRVM_sample.csv
+14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/owid_co2_subset.csv
+151 −0
Large diff (151 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/owid_grapher_median-age.csv
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/wb_NY.GDP.PCAP.CD.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/wb_retired.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/who/Indicator_M_Est_tob_curr.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/who/M_Est_tob_curr.json
+680 −0
Large diff (681 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_build.py
+142 −0
Large diff (142 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_changes.py
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_owid.py
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_registry.py
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_validate.py
+55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_worldbank.py
+60 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff