SPB Git

spb/countryatlas

Public

Connectors IMF/OECD/Eurostat/WHO/FRED/BIS/ILO + per-connector mapping files; registry fixes

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

169 changed files +19,065 −19

169 changed files
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
Menu

Theme