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