Flagship exploration: World Explorer (/explore, zoom/pan map + time machine + views + country drawer), /trajectories, /scatter, /finder, /extremes; analytics API clients; bubble chart log ticks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
23 changed files +2,765 −148
added
apps/web/qa/flagship-qa.mjs
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +/** | |
| 2 | + * Flagship views QA: /explore, /trajectories, /scatter, /finder, /extremes × 320/375/390/430/768/1440/1920. | |
| 3 | + * Checks: HTTP 200, no horizontal overflow, no console errors, no failed requests, tap targets ≥ 44 px on phones; | |
| 4 | + * exercises the year slider (arrow keys + play 2 s) and a country tap on /explore at 390 and 1440. | |
| 5 | + * Screenshots → qa/screens/flagship/<route>-<width>.png; report → qa/screens/flagship/report.json. | |
| 6 | + * node qa/flagship-qa.mjs [BASE_URL] | |
| 7 | + */ | |
| 8 | +import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; | |
| 9 | +import { mkdirSync, writeFileSync } from 'node:fs'; | |
| 10 | + | |
| 11 | +const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290'; | |
| 12 | +const OUT = new URL('./screens/flagship/', import.meta.url).pathname; | |
| 13 | +mkdirSync(OUT, { recursive: true }); | |
| 14 | +const ROUTES = ['/explore', '/explore?indicator=life-expectancy&year=1990&view=rank', '/trajectories', '/scatter', '/finder?f=gdp-per-capita:gt:40000&f=population:gt:10000000', '/extremes?window=10']; | |
| 15 | +const WIDTHS = [320, 375, 390, 430, 768, 1440, 1920]; | |
| 16 | +const slug = (p) => p.slice(1).replace(/[/?=&:,]+/g, '_'); | |
| 17 | +const report = []; | |
| 18 | +const browser = await chromium.launch(); | |
| 19 | +for (const width of WIDTHS) { | |
| 20 | + const mobile = width < 768; | |
| 21 | + const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 1000 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile }); | |
| 22 | + const page = await ctx.newPage(); | |
| 23 | + const errors = []; | |
| 24 | + const failed = []; | |
| 25 | + page.on('pageerror', (e) => errors.push(`pageerror: ${String(e).slice(0, 160)}`)); | |
| 26 | + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text().slice(0, 160)); }); | |
| 27 | + page.on('requestfailed', (r) => { if (!r.url().includes('_rsc')) failed.push(r.url().slice(BASE.length)); }); | |
| 28 | + for (const path of ROUTES) { | |
| 29 | + let status = 0; | |
| 30 | + try { | |
| 31 | + const resp = await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 90_000 }); | |
| 32 | + status = resp?.status() ?? 0; | |
| 33 | + } catch (e) { | |
| 34 | + report.push({ path, width, status: 'ERR', error: String(e).slice(0, 160) }); | |
| 35 | + continue; | |
| 36 | + } | |
| 37 | + await page.evaluate(() => document.fonts.ready); | |
| 38 | + await page.waitForTimeout(600); | |
| 39 | + if (path.startsWith('/explore') && (width === 390 || width === 1440)) { | |
| 40 | + const range = page.locator('input[type=range]').first(); | |
| 41 | + if (await range.count()) { | |
| 42 | + await range.focus(); | |
| 43 | + for (let i = 0; i < 8; i++) await page.keyboard.press('ArrowLeft'); | |
| 44 | + await page.waitForTimeout(300); | |
| 45 | + const play = page.locator('button[aria-label="Play"]').first(); | |
| 46 | + if (await play.count()) { await play.click(); await page.waitForTimeout(2000); await page.locator('button[aria-label="Pause"]').first().click().catch(() => {}); } | |
| 47 | + const br = page.locator('path[aria-label^="Brazil"]').first(); | |
| 48 | + if (await br.count()) { await br.click({ force: true }).catch(() => {}); await page.waitForTimeout(600); } | |
| 49 | + } | |
| 50 | + } | |
| 51 | + const m = await page.evaluate(({ mobile }) => { | |
| 52 | + const de = document.documentElement; | |
| 53 | + const overflow = de.scrollWidth - de.clientWidth; | |
| 54 | + const vis = (el) => { const r = el.getBoundingClientRect(); if (!r.width || !r.height) return false; const cs = getComputedStyle(el); return cs.visibility !== 'hidden' && cs.display !== 'none'; }; | |
| 55 | + const targets = [...document.querySelectorAll('a,button,[role=button],input,select,[role=radio],[role=tab]')].filter(vis).filter((el) => !el.closest('svg')); | |
| 56 | + const small = mobile ? targets.filter((el) => { const r = el.getBoundingClientRect(); return r.height < 44 && r.width < 44; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`) : []; | |
| 57 | + const text = document.body.innerText || ''; | |
| 58 | + const bad = []; | |
| 59 | + for (const re of [/\bundefined\b/g, /\bNaN\b/g]) { let mm; while ((mm = re.exec(text)) && bad.length < 3) bad.push(text.slice(Math.max(0, mm.index - 30), mm.index + 20)); } | |
| 60 | + return { overflow, small: small.slice(0, 8), nSmall: small.length, bad, title: document.title, url: location.href }; | |
| 61 | + }, { mobile }); | |
| 62 | + const file = `${slug(path)}-${width}.png`; | |
| 63 | + await page.screenshot({ path: OUT + file, fullPage: !path.startsWith('/explore') && !path.startsWith('/trajectories') }).catch(() => {}); | |
| 64 | + report.push({ path, width, status, ...m, errors: errors.splice(0), failed: failed.splice(0), file }); | |
| 65 | + } | |
| 66 | + await ctx.close(); | |
| 67 | +} | |
| 68 | +await browser.close(); | |
| 69 | +writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2)); | |
| 70 | +let fails = 0; | |
| 71 | +for (const r of report) { | |
| 72 | + const flags = []; | |
| 73 | + if (r.status !== 200) flags.push(`HTTP ${r.status}`); | |
| 74 | + if (r.overflow > 0) flags.push(`OVERFLOW +${r.overflow}px`); | |
| 75 | + if (r.nSmall) flags.push(`${r.nSmall} small targets`); | |
| 76 | + if (r.bad?.length) flags.push(`BAD TEXT ${r.bad.length}`); | |
| 77 | + if (r.errors?.length) flags.push(`${r.errors.length} console errors`); | |
| 78 | + if (r.failed?.length) flags.push(`${r.failed.length} failed requests`); | |
| 79 | + if (flags.length) fails++; | |
| 80 | + console.log(`${String(r.width).padStart(4)} ${r.path.padEnd(60)} ${flags.join(' · ') || 'ok'}`); | |
| 81 | + if (r.small?.length) console.log(' small:', r.small.slice(0, 4).join(' | ')); | |
| 82 | + if (r.errors?.length) console.log(' errors:', r.errors.slice(0, 2).join(' | ')); | |
| 83 | + if (r.failed?.length) console.log(' failed:', r.failed.slice(0, 3).join(' | ')); | |
| 84 | + if (r.bad?.length) console.log(' bad:', r.bad.join(' | ')); | |
| 85 | +} | |
| 86 | +console.log(`\n${report.length} renders, ${fails} with flags`); | |
modified
apps/web/src/app/explore/page.tsx
+59 −141
@@ -1,159 +1,77 @@ | ||
| 1 | −import { ArrowRight } from 'lucide-react'; | |
| 2 | 1 | import type { Metadata } from 'next'; |
| 3 | −import Link from 'next/link'; | |
| 4 | 2 | import { t } from '@/i18n'; |
| 5 | 3 | import { api, isNotBuilt, safe } from '@/lib/api'; |
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 6 | 5 | import { apiExplore } from '@/lib/api-explore'; |
| 7 | −import { formatValue, grouped } from '@/lib/format'; | |
| 8 | 6 | import { routes } from '@/lib/site'; |
| 9 | −import { TOPICS } from '@/lib/topics'; | |
| 10 | −import type { RankingResponse } from '@/lib/types'; | |
| 11 | −import type { RegionResponse } from '@/lib/types-explore'; | |
| 12 | −import { ChangesFeed } from '@/components/changes/changes-feed'; | |
| 13 | 7 | import { NotBuiltState } from '@/components/data/empty-state'; |
| 14 | −import { Section } from '@/components/data/section'; | |
| 15 | −import { EntityPickerNav } from '@/components/explore/entity-picker-nav'; | |
| 16 | −import { PageHeader } from '@/components/explore/page-header'; | |
| 17 | −import { SimilarityPlayground } from '@/components/explore/similarity-playground'; | |
| 18 | −import { RegionChips } from '@/components/home/region-chips'; | |
| 8 | +import { WorldExplorer } from '@/components/explorer/world-explorer'; | |
| 9 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | |
| 10 | +import { DEFAULT_EXPLORER_INDICATOR, EXPLORER_VIEWS, explorerIndicators, parseYearParam, type ExplorerView } from '@/components/explorer/options'; | |
| 19 | 11 | |
| 20 | 12 | export const revalidate = 900; |
| 21 | 13 | |
| 22 | −export const metadata: Metadata = { | |
| 23 | − title: t('explore.title'), | |
| 24 | − description: t('explore.sub'), | |
| 25 | − alternates: { canonical: routes.explore() }, | |
| 26 | −}; | |
| 14 | +type SP = Record<string, string | string[] | undefined>; | |
| 15 | +const SLUG = /^[a-z0-9][a-z0-9-]*$/; | |
| 27 | 16 | |
| 28 | −type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' }; | |
| 29 | −type GroupQ = { kind: 'compare'; key: string; group: string }; | |
| 30 | −const QUESTIONS: Array<RankQ | GroupQ> = [ | |
| 31 | − { kind: 'ranking', key: 'age', indicator: 'median-age' }, | |
| 32 | − { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' }, | |
| 33 | − { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' }, | |
| 34 | − { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' }, | |
| 35 | − { kind: 'ranking', key: 'life', indicator: 'life-expectancy' }, | |
| 36 | − { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' }, | |
| 37 | − { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' }, | |
| 38 | − { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' }, | |
| 39 | − { kind: 'ranking', key: 'internet', indicator: 'internet-users' }, | |
| 40 | − { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' }, | |
| 41 | − { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' }, | |
| 42 | − { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' }, | |
| 43 | − { kind: 'compare', key: 'g7', group: 'g7' }, | |
| 44 | − { kind: 'compare', key: 'brics', group: 'brics' }, | |
| 45 | − { kind: 'compare', key: 'nordic', group: 'nordic-countries' }, | |
| 46 | −]; | |
| 17 | +function first(sp: SP, k: string): string | null { | |
| 18 | + const v = sp[k]; | |
| 19 | + return (Array.isArray(v) ? v[0] : v) ?? null; | |
| 20 | +} | |
| 21 | + | |
| 22 | +function parseState(sp: SP) { | |
| 23 | + const ind = (first(sp, 'indicator') ?? '').toLowerCase(); | |
| 24 | + const view = first(sp, 'view') as ExplorerView | null; | |
| 25 | + const country = (first(sp, 'country') ?? '').toLowerCase(); | |
| 26 | + const group = (first(sp, 'group') ?? 'world').toLowerCase(); | |
| 27 | + return { | |
| 28 | + indicator: SLUG.test(ind) ? ind : DEFAULT_EXPLORER_INDICATOR, | |
| 29 | + year: parseYearParam(first(sp, 'year')), | |
| 30 | + view: view && EXPLORER_VIEWS.includes(view) ? view : ('map' as ExplorerView), | |
| 31 | + country: /^[a-z]{3}$/.test(country) ? country : null, | |
| 32 | + group: SLUG.test(group) ? group : 'world', | |
| 33 | + }; | |
| 34 | +} | |
| 47 | 35 | |
| 48 | −export default async function ExplorePage() { | |
| 49 | − let notBuilt = false; | |
| 50 | − let indicatorsCount = 260; | |
| 36 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 37 | + const sp = await searchParams; | |
| 38 | + const parameterized = Object.keys(sp).length > 0; | |
| 39 | + return { | |
| 40 | + title: t('explorer.metaTitle'), | |
| 41 | + description: t('explorer.description'), | |
| 42 | + alternates: { canonical: routes.explore() }, | |
| 43 | + robots: parameterized ? { index: false, follow: true } : undefined, | |
| 44 | + openGraph: { title: `${t('explorer.metaTitle')} — ${t('site.name')}`, description: t('explorer.description'), url: routes.explore(), type: 'website' }, | |
| 45 | + }; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export default async function ExplorePage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 49 | + const sp = await searchParams; | |
| 50 | + const state = parseState(sp); | |
| 51 | + let countriesRes; | |
| 51 | 52 | try { |
| 52 | − indicatorsCount = (await apiExplore.indicators()).n; | |
| 53 | + countriesRes = await api.countries(); | |
| 53 | 54 | } catch (e) { |
| 54 | − if (isNotBuilt(e)) notBuilt = true; | |
| 55 | − else throw e; | |
| 56 | − } | |
| 57 | − if (notBuilt) { | |
| 58 | − return ( | |
| 59 | − <> | |
| 60 | − <PageHeader title={t('explore.title')} lede={t('explore.sub')} /> | |
| 61 | − <NotBuiltState /> | |
| 62 | − </> | |
| 63 | − ); | |
| 55 | + if (isNotBuilt(e)) return <NotBuiltState />; | |
| 56 | + throw e; | |
| 64 | 57 | } |
| 65 | − | |
| 66 | − const [changes, ...answers] = await Promise.all([ | |
| 67 | − safe(apiExplore.changes({ limit: 30 })), | |
| 68 | − ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))), | |
| 58 | + const [indicatorsRes, groupsRes, frames] = await Promise.all([ | |
| 59 | + safe(apiExplore.indicators({ with_data: true })), | |
| 60 | + safe(apiExplore.regions()), | |
| 61 | + safe(apiAnalytics.indicatorFrames(state.indicator, { group: state.group !== 'world' ? state.group : null })), | |
| 69 | 62 | ]); |
| 70 | − | |
| 63 | + const countries = countriesRes.items.filter((c) => (c.kind ?? 'country') === 'country'); | |
| 64 | + const { features, sphere } = baseFeatures(countriesRes.items); | |
| 65 | + const indicators = explorerIndicators(indicatorsRes?.items ?? []); | |
| 71 | 66 | return ( |
| 72 | − <> | |
| 73 | − <PageHeader title={t('explore.title')} lede={t('explore.sub')} /> | |
| 74 | − | |
| 75 | − <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0"> | |
| 76 | − <div className="max-w-xl"> | |
| 77 | − <EntityPickerNav type="country" placeholder={t('explore.country.search')} /> | |
| 78 | − </div> | |
| 79 | − <div className="mt-4"> | |
| 80 | − <RegionChips /> | |
| 81 | − </div> | |
| 82 | − </Section> | |
| 83 | − | |
| 84 | − <Section id="topic" title={t('explore.topic.title')} subtitle={t('explore.topic.sub', { n: grouped(indicatorsCount) })}> | |
| 85 | − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 86 | − {TOPICS.map((tp) => ( | |
| 87 | − <li key={tp.id} className="border-t border-rule"> | |
| 88 | − <Link href={routes.indicators(tp.id)} className="group flex min-h-[56px] flex-col justify-center py-2.5"> | |
| 89 | − <span className="text-sm font-semibold text-ink group-hover:text-accent">{tp.name}</span> | |
| 90 | − <span className="mt-0.5 text-xs text-ink-2">{tp.blurb}</span> | |
| 91 | − </Link> | |
| 92 | − </li> | |
| 93 | − ))} | |
| 94 | − </ul> | |
| 95 | − </Section> | |
| 96 | − | |
| 97 | − <Section id="question" title={t('explore.question.title')} subtitle={t('explore.question.sub')}> | |
| 98 | − <ul className="grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3"> | |
| 99 | − {QUESTIONS.map((q, i) => { | |
| 100 | − const a = answers[i] ?? null; | |
| 101 | − const title = t(`explore.q.${q.key}` as 'explore.q.age'); | |
| 102 | − const hint = t(`explore.q.${q.key}.hint` as 'explore.q.age.hint'); | |
| 103 | − if (q.kind === 'ranking') { | |
| 104 | − const r = a as RankingResponse | null; | |
| 105 | − const top = r?.rows[0]; | |
| 106 | − if (!top) return null; // no live figure → no static filler | |
| 107 | − return ( | |
| 108 | − <li key={q.key} className="border-t border-rule"> | |
| 109 | − <Link href={routes.ranking(q.indicator)} className="group flex min-h-[84px] flex-col justify-center py-3"> | |
| 110 | − <span className="flex items-start justify-between gap-2"> | |
| 111 | − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span> | |
| 112 | − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" /> | |
| 113 | − </span> | |
| 114 | − <span className="mt-0.5 text-xs text-ink-3">{hint}</span> | |
| 115 | − <span className="tnum mt-1.5 text-xs text-ink-2"> | |
| 116 | − <span aria-hidden>{top.country.flag} </span> | |
| 117 | − {t('explore.question.leader', { country: top.country.name ?? top.country.id, value: formatValue(top.value, r.indicator), year: top.year ?? r.year_used ?? '' })} | |
| 118 | − </span> | |
| 119 | − </Link> | |
| 120 | − </li> | |
| 121 | − ); | |
| 122 | − } | |
| 123 | − const g = a as RegionResponse | null; | |
| 124 | − if (!g || g.members.length < 2) return null; | |
| 125 | − const slugs = [...g.members] | |
| 126 | − .sort((x, y) => (y.values.population?.value ?? 0) - (x.values.population?.value ?? 0)) | |
| 127 | − .slice(0, 8) | |
| 128 | − .map((m) => m.slug ?? m.id); | |
| 129 | − const gdp = g.aggregates.gdp; | |
| 130 | − return ( | |
| 131 | − <li key={q.key} className="border-t border-rule"> | |
| 132 | − <Link href={routes.compare(...slugs)} className="group flex min-h-[84px] flex-col justify-center py-3"> | |
| 133 | − <span className="flex items-start justify-between gap-2"> | |
| 134 | − <span className="text-sm font-semibold text-ink group-hover:text-accent">{title}</span> | |
| 135 | − <ArrowRight size={14} aria-hidden className="mt-1 shrink-0 text-ink-3 group-hover:text-accent" /> | |
| 136 | − </span> | |
| 137 | − <span className="mt-0.5 text-xs text-ink-3">{hint}</span> | |
| 138 | − <span className="tnum mt-1.5 text-xs text-ink-2"> | |
| 139 | − {g.members.slice(0, 8).map((m) => m.flag).join(' ')} · {t('explore.q.members', { n: g.n_members })} | |
| 140 | − {gdp?.value != null ? ` · ${t('regions.gdp')} ${formatValue(gdp.value, gdp.indicator)}` : ''} | |
| 141 | − </span> | |
| 142 | − </Link> | |
| 143 | − </li> | |
| 144 | − ); | |
| 145 | − })} | |
| 146 | − </ul> | |
| 147 | − </Section> | |
| 148 | − | |
| 149 | − <div className="grid gap-x-10 lg:grid-cols-2"> | |
| 150 | − <Section id="changes" title={t('explore.changes.title')} subtitle={t('explore.changes.sub')} actions={<Link href={routes.changes()} className="text-accent hover:underline">{t('explore.changes.all')} →</Link>}> | |
| 151 | − <ChangesFeed initial={changes?.items ?? []} kinds={changes?.kinds ?? []} limit={30} compact /> | |
| 152 | − </Section> | |
| 153 | − <Section id="similar" title={t('explore.similar.title')} subtitle={t('explore.similar.sub')}> | |
| 154 | − <SimilarityPlayground /> | |
| 155 | − </Section> | |
| 156 | − </div> | |
| 157 | − </> | |
| 67 | + <WorldExplorer | |
| 68 | + features={features} | |
| 69 | + sphere={sphere} | |
| 70 | + countries={countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name, flag: c.flag, region: c.region, income: c.income }))} | |
| 71 | + indicators={indicators} | |
| 72 | + groups={groupsRes?.items ?? []} | |
| 73 | + initial={frames} | |
| 74 | + initialState={state} | |
| 75 | + /> | |
| 158 | 76 | ); |
| 159 | 77 | } |
added
apps/web/src/app/extremes/page.tsx
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { isNotBuilt } from '@/lib/api'; | |
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 5 | +import { grouped } from '@/lib/format'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import { isTopicId } from '@/lib/topics'; | |
| 8 | +import type { ExtremesResponse } from '@/lib/types-analytics'; | |
| 9 | +import { NotBuiltState } from '@/components/data/empty-state'; | |
| 10 | +import { ExtremeFacetBlock } from '@/components/explorer/extreme-facet'; | |
| 11 | +import { ExtremesControls } from '@/components/explorer/extremes-controls'; | |
| 12 | +import { firstParam, type SP } from '@/components/explorer/options'; | |
| 13 | + | |
| 14 | +export const revalidate = 900; | |
| 15 | +const WINDOWS = new Set(['1', '5', '10', '25', 'since1990']); | |
| 16 | + | |
| 17 | +function parseState(sp: SP) { | |
| 18 | + const w = firstParam(sp, 'window') ?? '10'; | |
| 19 | + const topic = (firstParam(sp, 'topic') ?? '').toLowerCase(); | |
| 20 | + return { window: WINDOWS.has(w) ? w : '10', topic: isTopicId(topic) ? topic : null, all: firstParam(sp, 'all') === '1' }; | |
| 21 | +} | |
| 22 | + | |
| 23 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 24 | + const sp = await searchParams; | |
| 25 | + return { | |
| 26 | + title: t('extremes.metaTitle'), | |
| 27 | + description: t('extremes.description'), | |
| 28 | + alternates: { canonical: routes.extremes() }, | |
| 29 | + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined, | |
| 30 | + openGraph: { title: `${t('extremes.metaTitle')} — ${t('site.name')}`, description: t('extremes.description'), url: routes.extremes(), type: 'website' }, | |
| 31 | + }; | |
| 32 | +} | |
| 33 | + | |
| 34 | +export default async function ExtremesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 35 | + const state = parseState(await searchParams); | |
| 36 | + let data: ExtremesResponse; | |
| 37 | + try { | |
| 38 | + data = await apiAnalytics.extremes({ window: state.window, topic: state.topic, min_population: state.all ? 0 : null }); | |
| 39 | + } catch (e) { | |
| 40 | + if (isNotBuilt(e)) return <NotBuiltState />; | |
| 41 | + throw e; | |
| 42 | + } | |
| 43 | + return ( | |
| 44 | + <> | |
| 45 | + <header className="pb-3 pt-6 md:pt-10"> | |
| 46 | + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('extremes.title')}</h1> | |
| 47 | + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('extremes.lede')}</p> | |
| 48 | + <p className="tnum mt-1 text-xs text-ink-3"> | |
| 49 | + {t('extremes.facets', { n: grouped(data.facets.length), y0: data.from_year ?? '', y1: data.to_year ?? '' })} | |
| 50 | + {data.filter_note ? ` · ${data.filter_note}` : ` · ${t('extremes.allCountries')}`} | |
| 51 | + </p> | |
| 52 | + </header> | |
| 53 | + <ExtremesControls window={state.window} topic={state.topic} all={state.all} /> | |
| 54 | + <p className="max-w-prose py-3 text-xs text-ink-3">{t('extremes.semantics')}</p> | |
| 55 | + {data.facets.length ? ( | |
| 56 | + <> | |
| 57 | + <nav aria-label={t('extremes.title')} className="-mx-4 px-4 sm:mx-0 sm:px-0"> | |
| 58 | + <ul className="scrollbar-none flex gap-1.5 overflow-x-auto py-1 sm:flex-wrap"> | |
| 59 | + {data.facets.map((f) => ( | |
| 60 | + <li key={f.id} className="shrink-0"> | |
| 61 | + <a href={`#${f.id}`} className="inline-flex h-11 items-center whitespace-nowrap rounded-sm border border-rule px-3 text-sm text-ink-2 hover:border-accent hover:text-accent md:h-9 md:px-2.5"> | |
| 62 | + {f.title} | |
| 63 | + </a> | |
| 64 | + </li> | |
| 65 | + ))} | |
| 66 | + </ul> | |
| 67 | + </nav> | |
| 68 | + {data.facets.map((f) => ( | |
| 69 | + <ExtremeFacetBlock key={f.id} facet={f} /> | |
| 70 | + ))} | |
| 71 | + </> | |
| 72 | + ) : ( | |
| 73 | + <p className="py-10 text-center text-sm text-ink-3">{t('extremes.none')}</p> | |
| 74 | + )} | |
| 75 | + </> | |
| 76 | + ); | |
| 77 | +} | |
added
apps/web/src/app/finder/page.tsx
+40 −0
@@ -0,0 +1,40 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { api, isNotBuilt, safe } from '@/lib/api'; | |
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 5 | +import { apiExplore } from '@/lib/api-explore'; | |
| 6 | +import { parseFinderState } from '@/lib/finder-query'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import type { FormatSpec } from '@/lib/types'; | |
| 9 | +import { NotBuiltState } from '@/components/data/empty-state'; | |
| 10 | +import { FinderView } from '@/components/explorer/finder-view'; | |
| 11 | +import { explorerIndicators, type SP } from '@/components/explorer/options'; | |
| 12 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | |
| 13 | + | |
| 14 | +export const revalidate = 900; | |
| 15 | + | |
| 16 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 17 | + const sp = await searchParams; | |
| 18 | + return { | |
| 19 | + title: t('finder.metaTitle'), | |
| 20 | + description: t('finder.description'), | |
| 21 | + alternates: { canonical: routes.finder() }, | |
| 22 | + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined, | |
| 23 | + openGraph: { title: `${t('finder.metaTitle')} — ${t('site.name')}`, description: t('finder.description'), url: routes.finder(), type: 'website' }, | |
| 24 | + }; | |
| 25 | +} | |
| 26 | + | |
| 27 | +export default async function FinderPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 28 | + const state = parseFinderState(await searchParams); | |
| 29 | + let indicatorsRes; | |
| 30 | + try { | |
| 31 | + indicatorsRes = await apiExplore.indicators({ with_data: true }); | |
| 32 | + } catch (e) { | |
| 33 | + if (isNotBuilt(e)) return <NotBuiltState />; | |
| 34 | + throw e; | |
| 35 | + } | |
| 36 | + const [countriesRes, result] = await Promise.all([safe(api.countries()), state.filters.length ? safe(apiAnalytics.finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 })) : Promise.resolve(null)]); | |
| 37 | + const { features, sphere } = baseFeatures(countriesRes?.items ?? []); | |
| 38 | + const specs: Record<string, FormatSpec> = Object.fromEntries(indicatorsRes.items.map((i) => [i.slug, { format: i.format, unit: i.unit, unit_short: i.unit_short, precision: i.precision, frequency: i.frequency, name: i.short_name ?? i.name, higher_is_better: i.higher_is_better }])); | |
| 39 | + return <FinderView indicators={explorerIndicators(indicatorsRes.items, 10)} features={features} sphere={sphere} initial={result} initialState={state} specs={specs} />; | |
| 40 | +} | |
added
apps/web/src/app/scatter/page.tsx
+56 −0
@@ -0,0 +1,56 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { isNotBuilt, safe } from '@/lib/api'; | |
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 5 | +import { apiExplore } from '@/lib/api-explore'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import { NotBuiltState } from '@/components/data/empty-state'; | |
| 8 | +import { DEFAULT_TRAJ, explorerIndicators, firstParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options'; | |
| 9 | +import { ScatterView } from '@/components/explorer/scatter-view'; | |
| 10 | + | |
| 11 | +export const revalidate = 900; | |
| 12 | + | |
| 13 | +function parseState(sp: SP) { | |
| 14 | + const log = (firstParam(sp, 'log') ?? '').toLowerCase(); | |
| 15 | + const country = (firstParam(sp, 'country') ?? '').toLowerCase(); | |
| 16 | + return { | |
| 17 | + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!, | |
| 18 | + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!, | |
| 19 | + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!, | |
| 20 | + year: parseYearParam(firstParam(sp, 'year')), | |
| 21 | + group: slugParam(firstParam(sp, 'group'), 'world')!, | |
| 22 | + log: /^(x|y|x,y|y,x|none)$/.test(log) ? log : null, | |
| 23 | + fit: firstParam(sp, 'fit') === '1', | |
| 24 | + country: /^[a-z]{3}$/.test(country) ? country : null, | |
| 25 | + }; | |
| 26 | +} | |
| 27 | + | |
| 28 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 29 | + const sp = await searchParams; | |
| 30 | + return { | |
| 31 | + title: t('scatter.metaTitle'), | |
| 32 | + description: t('scatter.description'), | |
| 33 | + alternates: { canonical: routes.scatter() }, | |
| 34 | + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined, | |
| 35 | + openGraph: { title: `${t('scatter.metaTitle')} — ${t('site.name')}`, description: t('scatter.description'), url: routes.scatter(), type: 'website' }, | |
| 36 | + }; | |
| 37 | +} | |
| 38 | + | |
| 39 | +export default async function ScatterPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 40 | + const state = parseState(await searchParams); | |
| 41 | + let indicatorsRes; | |
| 42 | + try { | |
| 43 | + indicatorsRes = await apiExplore.indicators({ with_data: true }); | |
| 44 | + } catch (e) { | |
| 45 | + if (isNotBuilt(e)) return <NotBuiltState />; | |
| 46 | + throw e; | |
| 47 | + } | |
| 48 | + const logX = state.log == null ? null : state.log.includes('x'); | |
| 49 | + const logY = state.log == null ? null : state.log.includes('y'); | |
| 50 | + const [groupsRes, scatter, related] = await Promise.all([ | |
| 51 | + safe(apiExplore.regions()), | |
| 52 | + safe(apiAnalytics.scatter({ x: state.x, y: state.y, size: state.size, year: state.year, group: state.group !== 'world' ? state.group : null, log_x: logX == null ? null : String(logX), log_y: logY == null ? null : String(logY) })), | |
| 53 | + safe(apiAnalytics.indicatorRelated(state.x, { limit: 8 })), | |
| 54 | + ]); | |
| 55 | + return <ScatterView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={scatter} initialRelated={related} initialState={state} />; | |
| 56 | +} | |
added
apps/web/src/app/trajectories/page.tsx
+46 −0
@@ -0,0 +1,46 @@ | ||
| 1 | +import type { Metadata } from 'next'; | |
| 2 | +import { t } from '@/i18n'; | |
| 3 | +import { isNotBuilt, safe } from '@/lib/api'; | |
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | |
| 5 | +import { apiExplore } from '@/lib/api-explore'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import { NotBuiltState } from '@/components/data/empty-state'; | |
| 8 | +import { DEFAULT_TRAJ, explorerIndicators, firstParam, listParam, parseYearParam, slugParam, type SP } from '@/components/explorer/options'; | |
| 9 | +import { TrajectoriesView } from '@/components/explorer/trajectories-view'; | |
| 10 | + | |
| 11 | +export const revalidate = 900; | |
| 12 | + | |
| 13 | +function parseState(sp: SP) { | |
| 14 | + return { | |
| 15 | + x: slugParam(firstParam(sp, 'x'), DEFAULT_TRAJ.x)!, | |
| 16 | + y: slugParam(firstParam(sp, 'y'), DEFAULT_TRAJ.y)!, | |
| 17 | + size: slugParam(firstParam(sp, 'size'), DEFAULT_TRAJ.size)!, | |
| 18 | + year: parseYearParam(firstParam(sp, 'year')), | |
| 19 | + group: slugParam(firstParam(sp, 'group'), 'world')!, | |
| 20 | + select: listParam(firstParam(sp, 'select'), 4).map((s) => s.toUpperCase()), | |
| 21 | + }; | |
| 22 | +} | |
| 23 | + | |
| 24 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | |
| 25 | + const sp = await searchParams; | |
| 26 | + return { | |
| 27 | + title: t('traj.metaTitle'), | |
| 28 | + description: t('traj.description'), | |
| 29 | + alternates: { canonical: routes.trajectories() }, | |
| 30 | + robots: Object.keys(sp).length ? { index: false, follow: true } : undefined, | |
| 31 | + openGraph: { title: `${t('traj.metaTitle')} — ${t('site.name')}`, description: t('traj.description'), url: routes.trajectories(), type: 'website' }, | |
| 32 | + }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +export default async function TrajectoriesPage({ searchParams }: { searchParams: Promise<SP> }) { | |
| 36 | + const state = parseState(await searchParams); | |
| 37 | + let indicatorsRes; | |
| 38 | + try { | |
| 39 | + indicatorsRes = await apiExplore.indicators({ with_data: true }); | |
| 40 | + } catch (e) { | |
| 41 | + if (isNotBuilt(e)) return <NotBuiltState />; | |
| 42 | + throw e; | |
| 43 | + } | |
| 44 | + const [groupsRes, traj] = await Promise.all([safe(apiExplore.regions()), safe(apiAnalytics.trajectory({ x: state.x, y: state.y, size: state.size, group: state.group !== 'world' ? state.group : null }))]); | |
| 45 | + return <TrajectoriesView indicators={explorerIndicators(indicatorsRes.items)} groups={groupsRes?.items ?? []} initial={traj} initialState={state} />; | |
| 46 | +} | |
modified
apps/web/src/components/charts/bubble-chart.tsx
+21 −7
@@ -32,6 +32,19 @@ export interface BubbleFit { | ||
| 32 | 32 | label?: string; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | +/** Log-axis ticks: powers of ten; 2× and 5× steps are added only when fewer than three decades are visible. */ | |
| 36 | +export function logTicks(domain: [number, number], n: number): number[] { | |
| 37 | + const [lo, hi] = domain; | |
| 38 | + if (!(lo > 0) || !(hi > lo)) return []; | |
| 39 | + const inRange = (v: number) => v >= lo && v <= hi; | |
| 40 | + const decades: number[] = []; | |
| 41 | + for (let e = Math.floor(Math.log10(lo)); e <= Math.ceil(Math.log10(hi)); e++) decades.push(Math.pow(10, e)); | |
| 42 | + const shown = decades.filter(inRange); | |
| 43 | + if (shown.length >= Math.min(3, n)) return shown; | |
| 44 | + const extra = decades.flatMap((d) => [d, 2 * d, 5 * d]).filter(inRange); | |
| 45 | + return Array.from(new Set(extra)).sort((a, b) => a - b); | |
| 46 | +} | |
| 47 | + | |
| 35 | 48 | /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */ |
| 36 | 49 | export function regionColor(region: string | null | undefined): string { |
| 37 | 50 | const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase()); |
@@ -124,8 +137,8 @@ export function BubbleChart({ | ||
| 124 | 137 | if (labelled.size >= labelCount + hl.size) break; |
| 125 | 138 | labelled.add(p.id); |
| 126 | 139 | } |
| 127 | − const xTicks = (logX ? (x as ReturnType<typeof scaleLog>).ticks(5) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!); | |
| 128 | − const yTicks = (logY ? (y as ReturnType<typeof scaleLog>).ticks(5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!); | |
| 140 | + const xTicks = (logX ? logTicks(x.domain() as [number, number], Math.max(3, Math.floor(innerW / 110))) : (x as ReturnType<typeof scaleLinear>).ticks(Math.max(3, Math.floor(innerW / 110)))).filter((v) => v >= x.domain()[0]! && v <= x.domain()[1]!); | |
| 141 | + const yTicks = (logY ? logTicks(y.domain() as [number, number], 5) : (y as ReturnType<typeof scaleLinear>).ticks(5)).filter((v) => v >= y.domain()[0]! && v <= y.domain()[1]!); | |
| 129 | 142 | let fitPath: string | null = null; |
| 130 | 143 | if (fit) { |
| 131 | 144 | const [x0, x1] = x.domain() as [number, number]; |
@@ -181,7 +194,8 @@ export function BubbleChart({ | ||
| 181 | 194 | [model.clean, xSpec, ySpec, sizeSpec], |
| 182 | 195 | ); |
| 183 | 196 | const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]); |
| 184 | − const trans = animate ? 'transform 380ms cubic-bezier(0.2,0.8,0.2,1), r 380ms ease' : undefined; | |
| 197 | + const trans = animate ? '[transition:transform_380ms_cubic-bezier(0.2,0.8,0.2,1),r_380ms_ease]' : ''; | |
| 198 | + const px = (v: number) => `${Math.round(v * 100) / 100}px`; | |
| 185 | 199 | |
| 186 | 200 | return ( |
| 187 | 201 | <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}> |
@@ -222,7 +236,7 @@ export function BubbleChart({ | ||
| 222 | 236 | {xSpec.name} |
| 223 | 237 | {logX ? ` (${t('common.log')})` : ''} → |
| 224 | 238 | </text> |
| 225 | − <text x={-8} y={-4} textAnchor="end" className="label label-strong"> | |
| 239 | + <text x={6} y={-4} textAnchor="start" className="label label-strong"> | |
| 226 | 240 | ↑ {ySpec.name} |
| 227 | 241 | {logY ? ` (${t('common.log')})` : ''} |
| 228 | 242 | </text> |
@@ -242,8 +256,8 @@ export function BubbleChart({ | ||
| 242 | 256 | const isHover = hover === p.id; |
| 243 | 257 | const rr = model.radius(p); |
| 244 | 258 | return ( |
| 245 | − <g key={p.id} style={{ transform: `translate(${model.x(p.x)}px, ${model.y(p.y)}px)`, transition: trans }}> | |
| 246 | − <circle r={rr} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} style={{ transition: trans }} /> | |
| 259 | + <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}> | |
| 260 | + <circle r={Math.round(rr * 100) / 100} fill={regionColor(p.region)} fillOpacity={isH || isHover ? 0.95 : hl.size ? 0.35 : 0.7} stroke={isH ? CHART.ink : 'var(--surface)'} strokeWidth={isH ? 2 : 1} className={trans} /> | |
| 247 | 261 | </g> |
| 248 | 262 | ); |
| 249 | 263 | })} |
@@ -252,7 +266,7 @@ export function BubbleChart({ | ||
| 252 | 266 | .map((p) => { |
| 253 | 267 | const rr = model.radius(p); |
| 254 | 268 | return ( |
| 255 | − <text key={`l-${p.id}`} x={model.x(p.x) + rr + 4} y={model.y(p.y)} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3, transition: trans }}> | |
| 269 | + <text key={`l-${p.id}`} x={Math.round((model.x(p.x) + rr + 4) * 100) / 100} y={Math.round(model.y(p.y) * 100) / 100} dy="0.32em" className={`label${hl.has(p.id) ? ' label-strong' : ''} ${trans}`} style={{ paintOrder: 'stroke', stroke: 'var(--surface)', strokeWidth: 3 }}> | |
| 256 | 270 | {p.label} |
| 257 | 271 | </text> |
| 258 | 272 | ); |
added
apps/web/src/components/explorer/explorer-panels.tsx
+173 −0
@@ -0,0 +1,173 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowRight, ExternalLink, Scale, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { formatValue, grouped, ordinal } from '@/lib/format'; | |
| 7 | +import { regionShort } from '@/lib/regions'; | |
| 8 | +import { routes } from '@/lib/site'; | |
| 9 | +import type { FormatSpec, Provenance } from '@/lib/types'; | |
| 10 | +import type { PointCountry } from '@/lib/types-analytics'; | |
| 11 | +import { seqVar } from '@/components/charts/palette'; | |
| 12 | +import { Sparkline } from '@/components/charts/sparkline'; | |
| 13 | +import type { SeriesPoint } from '@/components/charts/scales'; | |
| 14 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 15 | +import { stepFor } from './geo'; | |
| 16 | + | |
| 17 | +export interface CountryYearInfo { | |
| 18 | + country: PointCountry; | |
| 19 | + value: number | null; | |
| 20 | + year: number; | |
| 21 | + rankWorld: number | null; | |
| 22 | + nWorld: number; | |
| 23 | + rankRegion: number | null; | |
| 24 | + nRegion: number; | |
| 25 | + series: SeriesPoint[]; | |
| 26 | + firstYear: number | null; | |
| 27 | + lastYear: number | null; | |
| 28 | + latestValue: number | null; | |
| 29 | + latestYear: number | null; | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** Legend for the pooled quantile classes (stable while scrubbing). */ | |
| 33 | +export function ClassLegend({ breaks, min, max, spec, k, compact, className }: { breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; k: number; compact?: boolean; className?: string }) { | |
| 34 | + const items = Array.from({ length: k }, (_, i) => { | |
| 35 | + const lo = i === 0 ? min : breaks[i - 1]!; | |
| 36 | + const hi = i === k - 1 ? max : breaks[i]!; | |
| 37 | + return { cls: i, lo, hi }; | |
| 38 | + }); | |
| 39 | + return ( | |
| 40 | + <ol className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-2', className)} aria-label={t('common.legend')}> | |
| 41 | + {items.map((it) => ( | |
| 42 | + <li key={it.cls} className="inline-flex items-center gap-1 tnum"> | |
| 43 | + <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(it.cls, k)) }} /> | |
| 44 | + {compact ? (it.cls === 0 ? formatValue(it.lo, spec) : it.cls === k - 1 ? formatValue(it.hi, spec) : '') : `${formatValue(it.lo, spec)} – ${formatValue(it.hi, spec)}`} | |
| 45 | + </li> | |
| 46 | + ))} | |
| 47 | + <li className="inline-flex items-center gap-1"> | |
| 48 | + <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" /> | |
| 49 | + {t('chart.legend.noData')} | |
| 50 | + </li> | |
| 51 | + </ol> | |
| 52 | + ); | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Floating hover label on the map: country · value · year · world / regional rank. */ | |
| 56 | +export function MapTooltip({ info, spec, x, y, sticky, onOpen }: { info: CountryYearInfo; spec: FormatSpec; x: number; y: number; sticky: boolean; onOpen: () => void }) { | |
| 57 | + return ( | |
| 58 | + <div className="pointer-events-none absolute z-10 max-w-[240px] rounded-sm border border-rule bg-surface/95 px-2.5 py-1.5 text-xs shadow-pop backdrop-blur" style={{ left: Math.min(x + 12, Math.max(0, x + 12)), top: Math.max(4, y - 64) }}> | |
| 59 | + <div className="flex items-center gap-1.5 font-medium text-ink"> | |
| 60 | + {info.country.flag ? <span aria-hidden>{info.country.flag}</span> : null} | |
| 61 | + <span className="truncate">{info.country.name}</span> | |
| 62 | + </div> | |
| 63 | + <div className="tnum text-ink"> | |
| 64 | + <span className="font-semibold">{formatValue(info.value, spec)}</span> | |
| 65 | + <span className="text-ink-3"> · {info.year}</span> | |
| 66 | + </div> | |
| 67 | + {info.value != null && info.rankWorld != null ? ( | |
| 68 | + <div className="tnum text-2xs text-ink-2"> | |
| 69 | + {t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })} | |
| 70 | + {info.rankRegion != null && info.country.region ? ` · ${t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(info.country.region) ?? info.country.region })}` : ''} | |
| 71 | + </div> | |
| 72 | + ) : info.value == null ? ( | |
| 73 | + <div className="text-2xs text-ink-3">{t('explorer.noValueYear', { year: info.year })}</div> | |
| 74 | + ) : null} | |
| 75 | + {sticky ? ( | |
| 76 | + <button type="button" onClick={onOpen} className="pointer-events-auto mt-1 inline-flex min-h-[32px] items-center text-accent underline"> | |
| 77 | + {t('explorer.drawer.details')} → | |
| 78 | + </button> | |
| 79 | + ) : null} | |
| 80 | + </div> | |
| 81 | + ); | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Country drawer content (right panel on desktop, bottom sheet on phones). */ | |
| 85 | +export function CountryPanel({ info, spec, indicatorSlug, indicatorName, provenance, onClose, onCompareHref, trajectoriesHref, closeClassName }: { info: CountryYearInfo; spec: FormatSpec; indicatorSlug: string; indicatorName: string; provenance: Provenance | null; onClose: () => void; onCompareHref: string; trajectoriesHref: string; closeClassName?: string }) { | |
| 86 | + const { open } = useProvenance(); | |
| 87 | + const c = info.country; | |
| 88 | + const slug = c.slug ?? c.id.toLowerCase(); | |
| 89 | + const dir = info.series.length >= 2 ? Math.sign((info.series[info.series.length - 1]!.value ?? 0) - (info.series[info.series.length - 2]!.value ?? 0)) : 0; | |
| 90 | + return ( | |
| 91 | + <div className="flex h-full min-h-0 flex-col"> | |
| 92 | + <div className="flex items-start gap-3 border-b border-rule pb-3"> | |
| 93 | + <span aria-hidden className="text-4xl leading-none"> | |
| 94 | + {c.flag} | |
| 95 | + </span> | |
| 96 | + <div className="min-w-0 flex-1"> | |
| 97 | + <h2 className="display truncate text-xl text-ink">{c.name}</h2> | |
| 98 | + <p className="text-xs text-ink-3"> | |
| 99 | + {regionShort(c.region) ?? c.region ?? ''} | |
| 100 | + {c.income ? ` · ${c.income}` : ''} | |
| 101 | + </p> | |
| 102 | + </div> | |
| 103 | + <button type="button" onClick={onClose} className={cn('tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 md:min-h-[32px] md:min-w-[32px]', closeClassName)} aria-label={t('common.close')}> | |
| 104 | + <X size={18} aria-hidden /> | |
| 105 | + </button> | |
| 106 | + </div> | |
| 107 | + <div className="min-h-0 flex-1 overflow-y-auto py-3"> | |
| 108 | + <div className="text-xs font-medium text-ink-2">{indicatorName}</div> | |
| 109 | + <button | |
| 110 | + type="button" | |
| 111 | + onClick={() => | |
| 112 | + open({ | |
| 113 | + indicator: { slug: indicatorSlug, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A', higher_is_better: spec.higher_is_better }, | |
| 114 | + value: info.value != null ? { value: info.value, period: `${info.year}-01-01`, year: info.year, unit: spec.unit, provenance } : null, | |
| 115 | + country: { id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag }, | |
| 116 | + }) | |
| 117 | + } | |
| 118 | + className="-mx-1 mt-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2" | |
| 119 | + aria-label={t('common.openProvenance')} | |
| 120 | + > | |
| 121 | + <span className="pnum text-3xl font-semibold leading-none text-ink">{info.value != null ? formatValue(info.value, spec) : <span className="text-ink-3">{t('common.noData')}</span>}</span> | |
| 122 | + <span className="tnum mt-1 text-xs text-ink-3"> | |
| 123 | + {info.year} | |
| 124 | + {info.value == null && info.latestValue != null ? ` · ${t('explorer.drawer.latest', { value: formatValue(info.latestValue, spec), year: info.latestYear ?? '' })}` : ''} | |
| 125 | + </span> | |
| 126 | + </button> | |
| 127 | + {info.value != null && info.rankWorld != null ? ( | |
| 128 | + <p className="tnum mt-2 text-sm text-ink-2"> | |
| 129 | + <span className="font-medium text-ink">{t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}</span> | |
| 130 | + {info.rankRegion != null && c.region ? <span className="text-ink-3"> · {t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(c.region) ?? c.region })}</span> : null} | |
| 131 | + </p> | |
| 132 | + ) : null} | |
| 133 | + {info.series.length >= 2 ? ( | |
| 134 | + <div className="mt-4"> | |
| 135 | + <div className="flex items-baseline justify-between text-2xs text-ink-3"> | |
| 136 | + <span>{t('explorer.drawer.history', { y0: info.firstYear ?? '', y1: info.lastYear ?? '' })}</span> | |
| 137 | + </div> | |
| 138 | + <Sparkline points={info.series} width={320} height={64} className="mt-1 h-16 w-full" direction={dir > 0 ? 'up' : dir < 0 ? 'down' : 'flat'} ariaLabel={t('explorer.drawer.sparkAria', { name: c.name ?? c.id, indicator: indicatorName })} /> | |
| 139 | + <div className="tnum flex justify-between text-2xs text-ink-3"> | |
| 140 | + <span>{info.firstYear}</span> | |
| 141 | + <span>{info.lastYear}</span> | |
| 142 | + </div> | |
| 143 | + </div> | |
| 144 | + ) : null} | |
| 145 | + <div className="mt-5 flex flex-col gap-2"> | |
| 146 | + <Link href={routes.country(slug)} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[38px]"> | |
| 147 | + {t('explorer.drawer.open')} <ArrowRight size={15} aria-hidden /> | |
| 148 | + </Link> | |
| 149 | + <Link href={onCompareHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]"> | |
| 150 | + <Scale size={15} aria-hidden /> {t('explorer.drawer.compare')} | |
| 151 | + </Link> | |
| 152 | + <Link href={trajectoriesHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]"> | |
| 153 | + <ExternalLink size={14} aria-hidden /> {t('explorer.drawer.trajectories')} | |
| 154 | + </Link> | |
| 155 | + <Link href={routes.countryIndicator(slug, topicOf(indicatorSlug), indicatorSlug)} className="inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]"> | |
| 156 | + {t('explorer.drawer.series')} → | |
| 157 | + </Link> | |
| 158 | + </div> | |
| 159 | + </div> | |
| 160 | + </div> | |
| 161 | + ); | |
| 162 | +} | |
| 163 | + | |
| 164 | +/** Best-effort topic for the country topic page link (falls back to the indicator page when unknown). */ | |
| 165 | +function topicOf(slug: string): string { | |
| 166 | + return TOPIC_HINT[slug] ?? 'economy'; | |
| 167 | +} | |
| 168 | +const TOPIC_HINT: Record<string, string> = { | |
| 169 | + population: 'population', 'population-growth': 'population', 'median-age': 'population', 'fertility-rate': 'population', 'urban-population-share': 'population', | |
| 170 | + gdp: 'economy', 'gdp-per-capita': 'economy', 'gdp-per-capita-ppp': 'economy', 'gdp-growth': 'economy', inflation: 'economy', | |
| 171 | + 'unemployment-rate': 'labor', 'life-expectancy': 'health', 'infant-mortality-rate': 'health', 'general-government-gross-debt-pct-gdp': 'government', | |
| 172 | + 'co2-per-capita': 'climate', 'co2-emissions': 'climate', 'renewable-electricity-share': 'energy', 'energy-use-per-capita': 'energy', 'internet-users': 'digital', | |
| 173 | +}; | |
added
apps/web/src/components/explorer/explorer-views.tsx
+73 −0
@@ -0,0 +1,73 @@ | ||
| 1 | +'use client'; | |
| 2 | +import Link from 'next/link'; | |
| 3 | +import { useMemo } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { formatValue, grouped } from '@/lib/format'; | |
| 6 | +import { routes } from '@/lib/site'; | |
| 7 | +import type { FormatSpec } from '@/lib/types'; | |
| 8 | +import type { PointCountry } from '@/lib/types-analytics'; | |
| 9 | +import { Histogram } from '@/components/charts/histogram'; | |
| 10 | +import { LineChart, type LineSeries } from '@/components/charts/line-chart'; | |
| 11 | +import { RankedBars, type RankedBarRow } from '@/components/charts/ranked-bars'; | |
| 12 | +import { histogramOf, median, wantsLog } from './geo'; | |
| 13 | + | |
| 14 | +export interface YearRow { | |
| 15 | + country: PointCountry; | |
| 16 | + value: number; | |
| 17 | + rank: number; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** Rank view: the top N of the current year, plus the selected country pinned when outside the top. */ | |
| 21 | +export function RankView({ rows, year, spec, selectedId, indicatorSlug, top = 25 }: { rows: YearRow[]; year: number; spec: FormatSpec; selectedId: string | null; indicatorSlug: string; top?: number }) { | |
| 22 | + const shown = rows.slice(0, top); | |
| 23 | + const sel = selectedId ? rows.find((r) => r.country.id === selectedId) : null; | |
| 24 | + if (sel && !shown.some((r) => r.country.id === sel.country.id)) shown.push(sel); | |
| 25 | + const bars: RankedBarRow[] = shown.map((r) => ({ id: r.country.id, label: r.country.name ?? r.country.id, flag: r.country.flag, href: r.country.slug ? routes.country(r.country.slug) : null, value: r.value, rank: r.rank })); | |
| 26 | + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>; | |
| 27 | + return ( | |
| 28 | + <div className="mx-auto max-w-3xl px-4 py-4"> | |
| 29 | + <div className="mb-2 flex flex-wrap items-baseline justify-between gap-2 text-xs text-ink-3"> | |
| 30 | + <span>{t('explorer.rank.sub', { n: grouped(rows.length), year })}</span> | |
| 31 | + <Link href={routes.ranking(indicatorSlug, { year })} className="text-accent hover:underline"> | |
| 32 | + {t('explorer.rank.full')} → | |
| 33 | + </Link> | |
| 34 | + </div> | |
| 35 | + <RankedBars rows={bars} spec={spec} highlightId={selectedId} /> | |
| 36 | + </div> | |
| 37 | + ); | |
| 38 | +} | |
| 39 | + | |
| 40 | +/** Trend view: world median (and the country count), plus the selected country's own series. */ | |
| 41 | +export function TrendView({ years, values, spec, selected, subject }: { years: number[]; values: Record<string, Array<number | null>>; spec: FormatSpec; selected: { country: PointCountry; series: Array<number | null> } | null; subject: string }) { | |
| 42 | + const series: LineSeries[] = useMemo(() => { | |
| 43 | + const med = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: median(Object.values(values).map((arr) => arr[i])) })); | |
| 44 | + const out: LineSeries[] = [{ id: 'median', name: t('explorer.trend.median'), points: med.filter((p) => p.value != null), colorIndex: 0 }]; | |
| 45 | + if (selected) out.push({ id: selected.country.id, name: selected.country.name ?? selected.country.id, points: years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: selected.series[i] ?? null })).filter((p) => p.value != null), colorIndex: 1 }); | |
| 46 | + return out; | |
| 47 | + }, [years, values, selected]); | |
| 48 | + const counts = years.map((_, i) => Object.values(values).filter((arr) => arr[i] != null).length); | |
| 49 | + return ( | |
| 50 | + <div className="mx-auto max-w-4xl px-4 py-4"> | |
| 51 | + <LineChart series={series} spec={spec} subject={subject} height={320} title={t('explorer.trend.title', { name: spec.name ?? '' })} subtitle={t('explorer.trend.sub', { min: Math.min(...counts), max: Math.max(...counts) })} defaultWidth={860} endLabels /> | |
| 52 | + <p className="mt-2 text-2xs text-ink-3">{t('explorer.trend.note')}</p> | |
| 53 | + </div> | |
| 54 | + ); | |
| 55 | +} | |
| 56 | + | |
| 57 | +/** Distribution view: histogram of the current year with world median and selected country markers. */ | |
| 58 | +export function DistributionView({ rows, year, spec, selected }: { rows: YearRow[]; year: number; spec: FormatSpec; selected: { country: PointCountry; value: number | null } | null }) { | |
| 59 | + const vals = rows.map((r) => r.value); | |
| 60 | + const log = wantsLog(spec, vals); | |
| 61 | + const h = useMemo(() => histogramOf(vals, 20, log), [vals, log]); | |
| 62 | + const med = median(vals); | |
| 63 | + const markers = [ | |
| 64 | + ...(med != null ? [{ id: 'median', label: t('explorer.trend.median'), value: med, tone: 'ink' as const }] : []), | |
| 65 | + ...(selected && selected.value != null ? [{ id: selected.country.id, label: selected.country.name ?? selected.country.id, value: selected.value, tone: 'accent' as const }] : []), | |
| 66 | + ]; | |
| 67 | + if (!rows.length) return <p className="py-8 text-center text-sm text-ink-3">{t('explorer.noDataYear', { indicator: spec.name ?? '', year })}</p>; | |
| 68 | + return ( | |
| 69 | + <div className="mx-auto max-w-4xl px-4 py-4"> | |
| 70 | + <Histogram edges={h.edges} counts={h.counts} log={h.log} markers={markers} spec={spec} height={300} title={t('explorer.dist.title', { name: spec.name ?? '', year })} subtitle={t('explorer.dist.sub', { n: grouped(rows.length), median: formatValue(med, spec) })} defaultWidth={860} unitLabel={spec.unit ?? undefined} /> | |
| 71 | + </div> | |
| 72 | + ); | |
| 73 | +} | |
added
apps/web/src/components/explorer/extreme-facet.tsx
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowDownRight, ArrowUpRight } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { fixed, formatValue } from '@/lib/format'; | |
| 7 | +import { routes } from '@/lib/site'; | |
| 8 | +import type { FormatSpec } from '@/lib/types'; | |
| 9 | +import type { ExtremeFacet } from '@/lib/types-analytics'; | |
| 10 | +import { SourceLine } from '@/components/charts/source-line'; | |
| 11 | + | |
| 12 | +function changeText(row: ExtremeFacet['rows'][number], metric: ExtremeFacet['metric'], spec: FormatSpec): string { | |
| 13 | + if (metric === 'pct' && row.delta_pct != null) return `${row.delta_pct >= 0 ? '+' : '−'}${fixed(Math.abs(row.delta_pct), 1)} %`; | |
| 14 | + if (row.delta == null) return t('common.na'); | |
| 15 | + const sign = row.delta >= 0 ? '+' : '−'; | |
| 16 | + if (metric === 'points') return `${sign}${fixed(Math.abs(row.delta), spec.precision ?? 1)} pts`; | |
| 17 | + return `${sign}${formatValue(Math.abs(row.delta), spec)}`; | |
| 18 | +} | |
| 19 | + | |
| 20 | +/** One extremes facet: title, direction, rows start → end with the change bar. Neutral colours (increase/decrease). */ | |
| 21 | +export function ExtremeFacetBlock({ facet }: { facet: ExtremeFacet }) { | |
| 22 | + const ind = facet.indicator; | |
| 23 | + const spec: FormatSpec = { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A', name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better }; | |
| 24 | + const up = facet.direction === 'up'; | |
| 25 | + const Icon = up ? ArrowUpRight : ArrowDownRight; | |
| 26 | + const mags = facet.rows.map((r) => Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0)); | |
| 27 | + const max = Math.max(1e-9, ...mags); | |
| 28 | + const y0 = facet.rows[0]?.year_start; | |
| 29 | + const y1 = facet.rows[0]?.year_end; | |
| 30 | + const payload = { indicator: { slug: ind.slug, name: ind.name ?? ind.slug, format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: 'A' as const, higher_is_better: ind.higher_is_better }, value: null, country: null, downloadHref: routes.indicatorDownload(ind.slug) }; | |
| 31 | + return ( | |
| 32 | + <section id={facet.id} className="hairline min-w-0 scroll-mt-28 pb-8 pt-6" aria-labelledby={`${facet.id}-h`}> | |
| 33 | + <div className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-2"> | |
| 34 | + <div className="min-w-0"> | |
| 35 | + <h2 id={`${facet.id}-h`} className="display text-xl text-ink md:text-2xl"> | |
| 36 | + {facet.title} | |
| 37 | + </h2> | |
| 38 | + <p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm text-ink-2"> | |
| 39 | + <span className={cn('inline-flex items-center gap-0.5 font-medium', up ? 'text-inc' : 'text-dec')}> | |
| 40 | + <Icon size={14} aria-hidden /> {up ? t('extremes.direction.up') : t('extremes.direction.down')} | |
| 41 | + </span> | |
| 42 | + <span className="text-ink-3">·</span> | |
| 43 | + <Link href={routes.indicator(ind.slug)} className="link-quiet text-ink"> | |
| 44 | + {ind.name} | |
| 45 | + </Link> | |
| 46 | + {y0 && y1 ? ( | |
| 47 | + <span className="tnum text-ink-3"> | |
| 48 | + · {y0}–{y1} · {t('extremes.n', { n: facet.n })} | |
| 49 | + </span> | |
| 50 | + ) : null} | |
| 51 | + </p> | |
| 52 | + </div> | |
| 53 | + <div className="flex flex-wrap gap-x-4 text-sm"> | |
| 54 | + <Link href={routes.ranking(ind.slug)} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]"> | |
| 55 | + {t('extremes.openRanking')} → | |
| 56 | + </Link> | |
| 57 | + <Link href={routes.explore({ indicator: ind.slug, year: y1 ?? null })} className="inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]"> | |
| 58 | + {t('extremes.explore')} → | |
| 59 | + </Link> | |
| 60 | + </div> | |
| 61 | + </div> | |
| 62 | + <ol className="divide-y divide-rule"> | |
| 63 | + {facet.rows.map((r, i) => { | |
| 64 | + const mag = Math.abs((facet.metric === 'pct' ? r.delta_pct : r.delta) ?? 0); | |
| 65 | + const pct = (mag / max) * 100; | |
| 66 | + return ( | |
| 67 | + <li key={r.country.id} className="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-center gap-x-3 gap-y-0.5 py-1.5 sm:grid-cols-[1.5rem_minmax(0,12rem)_minmax(0,1fr)_minmax(9rem,auto)]"> | |
| 68 | + <span className="tnum text-right text-xs text-ink-3">{i + 1}</span> | |
| 69 | + <Link href={routes.country(r.country.slug ?? r.country.id.toLowerCase())} className="link-quiet col-span-2 flex min-h-[40px] min-w-0 items-center gap-1.5 text-sm text-ink sm:col-span-1 sm:min-h-[32px]"> | |
| 70 | + <span aria-hidden>{r.country.flag}</span> | |
| 71 | + <span className="truncate">{r.country.name}</span> | |
| 72 | + </Link> | |
| 73 | + <div className="col-start-2 row-start-2 h-2.5 min-w-0 self-center sm:col-start-3 sm:row-start-1 sm:h-3.5" aria-hidden> | |
| 74 | + <div className={cn('h-full rounded-r-sm', up ? 'bg-inc' : 'bg-dec')} style={{ width: `${Math.max(1.5, pct)}%`, opacity: 0.85 }} /> | |
| 75 | + </div> | |
| 76 | + <div className="tnum col-start-3 row-start-2 text-right text-sm sm:col-start-4 sm:row-start-1"> | |
| 77 | + <span className={cn('font-semibold', up ? 'text-inc' : 'text-dec')}>{changeText(r, facet.metric, spec)}</span> | |
| 78 | + <span className="block text-2xs text-ink-3"> | |
| 79 | + {r.formatted_start ?? formatValue(r.value_start, spec)} <span aria-hidden>→</span> <span className="sr-only">{t('extremes.to')}</span> {r.formatted_end ?? formatValue(r.value_end, spec)} | |
| 80 | + {r.year_start && r.year_end && (r.year_start !== y0 || r.year_end !== y1) ? ` (${r.year_start}–${r.year_end})` : ''} | |
| 81 | + </span> | |
| 82 | + </div> | |
| 83 | + </li> | |
| 84 | + ); | |
| 85 | + })} | |
| 86 | + </ol> | |
| 87 | + <div className="mt-2"> | |
| 88 | + <SourceLine provenance={facet.provenance} payload={payload} /> | |
| 89 | + </div> | |
| 90 | + </section> | |
| 91 | + ); | |
| 92 | +} | |
added
apps/web/src/components/explorer/extremes-controls.tsx
+44 −0
@@ -0,0 +1,44 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useRouter } from 'next/navigation'; | |
| 3 | +import { t } from '@/i18n'; | |
| 4 | +import { routes } from '@/lib/site'; | |
| 5 | +import { TOPICS } from '@/lib/topics'; | |
| 6 | +import { Segmented } from '@/components/controls/indicator-select'; | |
| 7 | + | |
| 8 | +const WINDOWS = ['1', '5', '10', '25', 'since1990'] as const; | |
| 9 | + | |
| 10 | +/** Window · topic · population toggle — every change navigates (server re-renders the facets). */ | |
| 11 | +export function ExtremesControls({ window, topic, all }: { window: string; topic: string | null; all: boolean }) { | |
| 12 | + const router = useRouter(); | |
| 13 | + const go = (patch: { window?: string; topic?: string | null; all?: boolean }) => { | |
| 14 | + const w = patch.window ?? window; | |
| 15 | + const tp = patch.topic === undefined ? topic : patch.topic; | |
| 16 | + const a = patch.all ?? all; | |
| 17 | + const p = new URLSearchParams(); | |
| 18 | + if (w !== '10') p.set('window', w); | |
| 19 | + if (tp) p.set('topic', tp); | |
| 20 | + if (a) p.set('all', '1'); | |
| 21 | + const s = p.toString(); | |
| 22 | + router.replace(`${routes.extremes()}${s ? `?${s}` : ''}`, { scroll: false }); | |
| 23 | + }; | |
| 24 | + return ( | |
| 25 | + <div className="flex flex-wrap items-center gap-2 border-y border-rule py-3"> | |
| 26 | + <Segmented value={window} onChange={(w) => go({ window: w })} options={WINDOWS.map((w) => ({ value: w, label: t(`extremes.window.${w}` as 'extremes.window.1') }))} label={t('extremes.window')} size="sm" className="max-w-full overflow-x-auto" /> | |
| 27 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 28 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('extremes.topic')}</span> | |
| 29 | + <select value={topic ?? ''} onChange={(e) => go({ topic: e.target.value || null })} className="bg-transparent text-ink outline-none" aria-label={t('extremes.topic')}> | |
| 30 | + <option value="">{t('extremes.allTopics')}</option> | |
| 31 | + {TOPICS.map((tp) => ( | |
| 32 | + <option key={tp.id} value={tp.id}> | |
| 33 | + {tp.short} | |
| 34 | + </option> | |
| 35 | + ))} | |
| 36 | + </select> | |
| 37 | + </label> | |
| 38 | + <label className="inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border border-rule px-2.5 text-sm text-ink-2 md:h-9"> | |
| 39 | + <input type="checkbox" checked={!all} onChange={(e) => go({ all: !e.target.checked })} className="accent-[var(--accent)]" /> | |
| 40 | + {t('extremes.minPop')} | |
| 41 | + </label> | |
| 42 | + </div> | |
| 43 | + ); | |
| 44 | +} | |
added
apps/web/src/components/explorer/finder-view.tsx
+279 −0
@@ -0,0 +1,279 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowDown, ArrowUp, Download, Plus, Scale, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useRouter } from 'next/navigation'; | |
| 5 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 6 | +import { t } from '@/i18n'; | |
| 7 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 8 | +import { cn } from '@/lib/cn'; | |
| 9 | +import { FINDER_OPS, finderStateQuery, type FinderFilterInput, type FinderOp, type FinderState } from '@/lib/finder-query'; | |
| 10 | +import { formatValue, grouped } from '@/lib/format'; | |
| 11 | +import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions'; | |
| 12 | +import { routes } from '@/lib/site'; | |
| 13 | +import type { FormatSpec } from '@/lib/types'; | |
| 14 | +import type { FinderResponse } from '@/lib/types-analytics'; | |
| 15 | +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select'; | |
| 16 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 17 | +import type { BaseFeature } from '@/components/indicators/indicator-map'; | |
| 18 | +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; | |
| 19 | + | |
| 20 | +interface Preset { | |
| 21 | + id: string; | |
| 22 | + filters: FinderFilterInput[]; | |
| 23 | +} | |
| 24 | +const PRESETS: Preset[] = [ | |
| 25 | + { id: 'rich-large', filters: [{ slug: 'gdp-per-capita', op: 'gt', value: 40000 }, { slug: 'population', op: 'gt', value: 10_000_000 }] }, | |
| 26 | + { id: 'green-connected', filters: [{ slug: 'renewable-electricity-share', op: 'gt', value: 50 }, { slug: 'internet-users', op: 'gt', value: 80 }] }, | |
| 27 | + { id: 'ageing', filters: [{ slug: 'median-age', op: 'gt', value: 42 }] }, | |
| 28 | + { id: 'young-growing', filters: [{ slug: 'median-age', op: 'lt', value: 25 }, { slug: 'population-growth', op: 'gt', value: 2 }] }, | |
| 29 | + { id: 'long-lives-low-co2', filters: [{ slug: 'life-expectancy', op: 'gt', value: 80 }, { slug: 'co2-per-capita', op: 'lt', value: 5 }] }, | |
| 30 | +]; | |
| 31 | + | |
| 32 | +function stepFor(spec: FormatSpec, value: number): number { | |
| 33 | + if (spec.format === 'percent' || spec.format === 'years' || spec.format === 'ratio' || spec.format === 'per_1000') return 1; | |
| 34 | + const mag = Math.pow(10, Math.max(0, Math.floor(Math.log10(Math.max(1, Math.abs(value)))) - 1)); | |
| 35 | + return mag; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Query builder over /finder: filters live in the URL exactly as the API reads them. */ | |
| 39 | +export function FinderView({ indicators, features, sphere, initial, initialState, specs }: { indicators: IndicatorOption[]; features: BaseFeature[]; sphere: string; initial: FinderResponse | null; initialState: FinderState; specs: Record<string, FormatSpec> }) { | |
| 40 | + const router = useRouter(); | |
| 41 | + const { open: openProv } = useProvenance(); | |
| 42 | + const [state, setState] = useState<FinderState>(initialState); | |
| 43 | + const [data, setData] = useState<FinderResponse | null>(initial); | |
| 44 | + const [status, setStatus] = useState<'idle' | 'loading' | 'error'>('idle'); | |
| 45 | + const abort = useRef<AbortController | null>(null); | |
| 46 | + const first = useRef(true); | |
| 47 | + | |
| 48 | + // Debounced fetch + URL sync whenever the state changes. | |
| 49 | + useEffect(() => { | |
| 50 | + if (first.current) { | |
| 51 | + first.current = false; | |
| 52 | + return; | |
| 53 | + } | |
| 54 | + const q = finderStateQuery(state); | |
| 55 | + const timer = setTimeout(() => { | |
| 56 | + router.replace(`${routes.finder()}${q}`, { scroll: false }); | |
| 57 | + if (!state.filters.length) { | |
| 58 | + setData(null); | |
| 59 | + setStatus('idle'); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + abort.current?.abort(); | |
| 63 | + const ctrl = new AbortController(); | |
| 64 | + abort.current = ctrl; | |
| 65 | + setStatus('loading'); | |
| 66 | + clientAnalytics | |
| 67 | + .finder(state.filters, { mode: state.mode, region: state.region, income: state.income, sort: state.sort, limit: 218 }, ctrl.signal) | |
| 68 | + .then((r) => { | |
| 69 | + setData(r); | |
| 70 | + setStatus('idle'); | |
| 71 | + }) | |
| 72 | + .catch((e) => { | |
| 73 | + if ((e as Error).name !== 'AbortError') setStatus('error'); | |
| 74 | + }); | |
| 75 | + }, 350); | |
| 76 | + return () => clearTimeout(timer); | |
| 77 | + }, [state, router]); | |
| 78 | + | |
| 79 | + const update = (i: number, patch: Partial<FinderFilterInput>) => setState((s) => ({ ...s, filters: s.filters.map((f, k) => (k === i ? { ...f, ...patch } : f)) })); | |
| 80 | + const remove = (i: number) => setState((s) => ({ ...s, filters: s.filters.filter((_, k) => k !== i) })); | |
| 81 | + const add = () => { | |
| 82 | + const used = new Set(state.filters.map((f) => f.slug)); | |
| 83 | + const next = ['gdp-per-capita', 'population', 'life-expectancy', 'median-age', 'internet-users', 'co2-per-capita'].find((s) => !used.has(s) && indicators.some((i) => i.slug === s)) ?? indicators[0]?.slug; | |
| 84 | + if (!next) return; | |
| 85 | + setState((s) => ({ ...s, filters: [...s.filters, { slug: next, op: 'gt' as FinderOp, value: 0 }].slice(0, 8) })); | |
| 86 | + }; | |
| 87 | + const specFor = (slug: string): FormatSpec => specs[slug] ?? { format: 'number', name: indicators.find((i) => i.slug === slug)?.name ?? slug }; | |
| 88 | + const matches = data?.items ?? []; | |
| 89 | + const matchSet = useMemo(() => new Set(matches.map((m) => m.country.id)), [matches]); | |
| 90 | + const sortSlug = state.sort?.split(':')[0] ?? state.filters[0]?.slug ?? null; | |
| 91 | + const sortDir = (state.sort?.split(':')[1] as 'asc' | 'desc' | undefined) ?? 'desc'; | |
| 92 | + const toggleSort = (slug: string) => setState((s) => ({ ...s, sort: sortSlug === slug && sortDir === 'desc' ? `${slug}:asc` : `${slug}:desc` })); | |
| 93 | + const downloadHref = matches.length && state.filters.length ? routes.compareDownload(matches.slice(0, 20).map((m) => m.country.id), Array.from(new Set(state.filters.map((f) => f.slug))).slice(0, 20)) : null; | |
| 94 | + const compareHref = matches.length >= 2 ? routes.compare(...matches.slice(0, 8).map((m) => m.country.slug ?? m.country.id.toLowerCase())) : null; | |
| 95 | + | |
| 96 | + return ( | |
| 97 | + <div> | |
| 98 | + <header className="pb-3 pt-6 md:pt-10"> | |
| 99 | + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('finder.title')}</h1> | |
| 100 | + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('finder.lede')}</p> | |
| 101 | + </header> | |
| 102 | + | |
| 103 | + {/* Presets */} | |
| 104 | + <section aria-label={t('finder.presets')} className="border-y border-rule py-3"> | |
| 105 | + <div className="eyebrow mb-1.5">{t('finder.presets')}</div> | |
| 106 | + <ul className="flex flex-wrap gap-1.5"> | |
| 107 | + {PRESETS.map((p) => ( | |
| 108 | + <li key={p.id}> | |
| 109 | + <button type="button" onClick={() => setState((s) => ({ ...s, filters: p.filters, mode: 'and' }))} className="inline-flex h-11 flex-col items-start justify-center rounded-sm border border-rule px-3 text-left hover:border-accent md:h-auto md:py-1.5" title={t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}> | |
| 110 | + <span className="text-sm text-ink">{t(`finder.preset.${p.id}` as 'finder.preset.rich-large')}</span> | |
| 111 | + <span className="hidden text-2xs text-ink-3 md:block">{t(`finder.preset.${p.id}.hint` as 'finder.preset.rich-large.hint')}</span> | |
| 112 | + </button> | |
| 113 | + </li> | |
| 114 | + ))} | |
| 115 | + </ul> | |
| 116 | + </section> | |
| 117 | + | |
| 118 | + {/* Builder */} | |
| 119 | + <section aria-label={t('finder.add')} className="py-4"> | |
| 120 | + <ol className="space-y-2"> | |
| 121 | + {state.filters.map((f, i) => { | |
| 122 | + const spec = specFor(f.slug); | |
| 123 | + return ( | |
| 124 | + <li key={`${i}-${f.slug}`} className="grid grid-cols-[minmax(0,1fr)_2.75rem] items-start gap-2 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_minmax(0,1fr)_2.75rem]"> | |
| 125 | + <IndicatorSelect options={indicators} value={f.slug} onChange={(slug) => update(i, { slug })} size="sm" label={i === 0 ? t('finder.indicator') : undefined} /> | |
| 126 | + <button type="button" onClick={() => remove(i)} className="tap grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-down sm:order-last md:min-h-[36px]" aria-label={t('finder.remove')}> | |
| 127 | + <X size={16} aria-hidden /> | |
| 128 | + </button> | |
| 129 | + <label className="col-span-2 flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm sm:col-span-1 md:h-9"> | |
| 130 | + <span className="sr-only">{t('finder.op')}</span> | |
| 131 | + <select value={f.op} onChange={(e) => update(i, { op: e.target.value as FinderOp, value2: e.target.value === 'between' ? (f.value2 ?? f.value) : null })} className="h-11 min-w-0 flex-1 bg-transparent text-ink outline-none md:h-8"> | |
| 132 | + {FINDER_OPS.map((op) => ( | |
| 133 | + <option key={op} value={op}> | |
| 134 | + {t(`finder.op.${op}` as 'finder.op.gt')} | |
| 135 | + </option> | |
| 136 | + ))} | |
| 137 | + </select> | |
| 138 | + </label> | |
| 139 | + <div className="col-span-2 flex items-center gap-1.5 sm:col-span-1"> | |
| 140 | + <label className="flex h-11 min-w-0 flex-1 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm md:h-9" title={t('finder.unit', { unit: spec.unit ?? '' })}> | |
| 141 | + <span className="sr-only">{t('finder.value')}</span> | |
| 142 | + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={Number.isFinite(f.value) ? f.value : ''} onChange={(e) => update(i, { value: e.target.value === '' ? 0 : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={`${t('finder.value')} (${spec.unit ?? ''})`} /> | |
| 143 | + <span className="shrink-0 truncate text-2xs text-ink-3">{spec.unit_short ?? spec.unit ?? ''}</span> | |
| 144 | + </label> | |
| 145 | + {f.op === 'between' ? ( | |
| 146 | + <> | |
| 147 | + <span className="text-xs text-ink-3">{t('finder.value2')}</span> | |
| 148 | + <label className="flex h-11 min-w-0 flex-1 items-center rounded-sm border border-rule bg-surface px-2 text-sm md:h-9"> | |
| 149 | + <input type="number" inputMode="decimal" step={stepFor(spec, f.value)} value={f.value2 ?? ''} onChange={(e) => update(i, { value2: e.target.value === '' ? null : Number(e.target.value) })} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={t('finder.value2')} /> | |
| 150 | + </label> | |
| 151 | + </> | |
| 152 | + ) : null} | |
| 153 | + </div> | |
| 154 | + </li> | |
| 155 | + ); | |
| 156 | + })} | |
| 157 | + </ol> | |
| 158 | + <div className="mt-3 flex flex-wrap items-center gap-2"> | |
| 159 | + <button type="button" onClick={add} disabled={state.filters.length >= 8} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-dashed border-rule-strong px-3 text-sm text-ink-2 hover:border-accent hover:text-accent disabled:opacity-40 md:h-9"> | |
| 160 | + <Plus size={14} aria-hidden /> {t('finder.add')} | |
| 161 | + </button> | |
| 162 | + <Segmented value={state.mode} onChange={(m) => setState((s) => ({ ...s, mode: m }))} options={[{ value: 'and' as const, label: t('finder.mode.and') }, { value: 'or' as const, label: t('finder.mode.or') }]} label={t('finder.mode')} size="sm" /> | |
| 163 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 164 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.region')}</span> | |
| 165 | + <select value={state.region ?? ''} onChange={(e) => setState((s) => ({ ...s, region: e.target.value || null }))} className="bg-transparent text-ink outline-none"> | |
| 166 | + <option value="">{t('finder.any')}</option> | |
| 167 | + {WB_REGIONS.map((r) => ( | |
| 168 | + <option key={r.id} value={r.slug}> | |
| 169 | + {r.short} | |
| 170 | + </option> | |
| 171 | + ))} | |
| 172 | + </select> | |
| 173 | + </label> | |
| 174 | + <label className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9"> | |
| 175 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('finder.income')}</span> | |
| 176 | + <select value={state.income ?? ''} onChange={(e) => setState((s) => ({ ...s, income: e.target.value || null }))} className="bg-transparent text-ink outline-none"> | |
| 177 | + <option value="">{t('finder.any')}</option> | |
| 178 | + {INCOME_GROUPS.map((g) => ( | |
| 179 | + <option key={g.id} value={g.slug}> | |
| 180 | + {g.name} | |
| 181 | + </option> | |
| 182 | + ))} | |
| 183 | + </select> | |
| 184 | + </label> | |
| 185 | + </div> | |
| 186 | + </section> | |
| 187 | + | |
| 188 | + {/* Results */} | |
| 189 | + <section aria-live="polite" className="border-t border-rule pt-4"> | |
| 190 | + <div className="flex flex-wrap items-baseline justify-between gap-2"> | |
| 191 | + <h2 className="display text-xl text-ink md:text-2xl"> | |
| 192 | + {status === 'loading' ? t('finder.loading') : status === 'error' ? t('finder.error') : data ? t('finder.results', { n: grouped(data.n_matching), m: grouped(data.n_evaluated) }) : ''} | |
| 193 | + </h2> | |
| 194 | + <div className="flex flex-wrap items-center gap-2 text-sm"> | |
| 195 | + {downloadHref ? ( | |
| 196 | + <a href={downloadHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm border border-rule px-3 text-ink hover:bg-surface-2 md:min-h-[36px]"> | |
| 197 | + <Download size={14} aria-hidden /> {t('finder.download')} | |
| 198 | + </a> | |
| 199 | + ) : null} | |
| 200 | + {compareHref ? ( | |
| 201 | + <Link href={compareHref} className="inline-flex min-h-[44px] items-center gap-1.5 rounded-sm bg-ink px-3 font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[36px]"> | |
| 202 | + <Scale size={14} aria-hidden /> {t('finder.compare', { n: Math.min(8, matches.length) })} | |
| 203 | + </Link> | |
| 204 | + ) : null} | |
| 205 | + </div> | |
| 206 | + </div> | |
| 207 | + {!state.filters.length ? <p className="mt-3 text-sm text-ink-3">{t('finder.empty')}</p> : null} | |
| 208 | + {state.filters.length && data && data.n_matching === 0 && status === 'idle' ? <p className="mt-3 text-sm text-ink-3">{t('finder.none')}</p> : null} | |
| 209 | + {data && matches.length ? ( | |
| 210 | + <div className={cn('mt-4 grid gap-8 lg:grid-cols-[minmax(0,3fr)_minmax(0,2fr)] transition-opacity', status === 'loading' && 'opacity-60')}> | |
| 211 | + <div className="min-w-0 overflow-x-auto"> | |
| 212 | + <table className="w-full min-w-[32rem] text-sm"> | |
| 213 | + <caption className="sr-only">{t('finder.results', { n: data.n_matching, m: data.n_evaluated })}</caption> | |
| 214 | + <thead> | |
| 215 | + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3"> | |
| 216 | + <th scope="col" className="py-1.5 pr-3 font-medium">{t('finder.table.country')}</th> | |
| 217 | + {data.filters.map((f) => ( | |
| 218 | + <th key={f.indicator.slug} scope="col" className="py-1.5 pl-3 text-right font-medium"> | |
| 219 | + <button type="button" onClick={() => toggleSort(f.indicator.slug)} className="inline-flex min-h-[32px] items-center gap-1 hover:text-ink" aria-label={t('finder.sort', { name: f.indicator.short_name ?? f.indicator.name })}> | |
| 220 | + {f.indicator.short_name ?? f.indicator.name} | |
| 221 | + {sortSlug === f.indicator.slug ? sortDir === 'desc' ? <ArrowDown size={11} aria-hidden /> : <ArrowUp size={11} aria-hidden /> : null} | |
| 222 | + </button> | |
| 223 | + </th> | |
| 224 | + ))} | |
| 225 | + </tr> | |
| 226 | + </thead> | |
| 227 | + <tbody className="divide-y divide-rule"> | |
| 228 | + {matches.map((m) => ( | |
| 229 | + <tr key={m.country.id}> | |
| 230 | + <td className="py-1.5 pr-3"> | |
| 231 | + <Link href={routes.country(m.country.slug ?? m.country.id.toLowerCase())} className="link-quiet inline-flex min-h-[36px] items-center gap-1.5 text-ink"> | |
| 232 | + <span aria-hidden>{m.country.flag}</span> | |
| 233 | + <span className="truncate">{m.country.name}</span> | |
| 234 | + </Link> | |
| 235 | + </td> | |
| 236 | + {data.filters.map((f) => { | |
| 237 | + const v = m.values[f.indicator.slug]; | |
| 238 | + const spec = specFor(f.indicator.slug); | |
| 239 | + const matched = m.matched.includes(f.indicator.slug); | |
| 240 | + return ( | |
| 241 | + <td key={f.indicator.slug} className="tnum py-1.5 pl-3 text-right"> | |
| 242 | + {v && v.value != null ? ( | |
| 243 | + <button type="button" onClick={() => openProv({ indicator: { slug: f.indicator.slug, name: f.indicator.name ?? f.indicator.slug, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' }, value: { value: v.value, formatted: v.formatted, period: v.year ? `${v.year}-01-01` : null, year: v.year, unit: spec.unit, provenance: v.provenance }, country: { id: m.country.id, slug: m.country.slug, name: m.country.name ?? m.country.id, flag: m.country.flag } })} className={cn('inline-flex min-h-[36px] items-baseline gap-1 hover:text-accent', matched ? 'font-medium text-ink' : 'text-ink-3')} aria-label={t('common.openProvenance')}> | |
| 244 | + {formatValue(v.value, spec)} | |
| 245 | + <span className="text-2xs text-ink-3">{v.year}</span> | |
| 246 | + </button> | |
| 247 | + ) : ( | |
| 248 | + <span className="text-ink-3">{t('common.noData')}</span> | |
| 249 | + )} | |
| 250 | + </td> | |
| 251 | + ); | |
| 252 | + })} | |
| 253 | + </tr> | |
| 254 | + ))} | |
| 255 | + </tbody> | |
| 256 | + </table> | |
| 257 | + <p className="mt-2 text-2xs text-ink-3">{t('finder.latestNote')}</p> | |
| 258 | + </div> | |
| 259 | + <figure className="min-w-0 lg:order-first"> | |
| 260 | + <div className="relative w-full" style={{ aspectRatio: `${MAP_WIDTH} / ${MAP_HEIGHT}` }}> | |
| 261 | + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('finder.map')}> | |
| 262 | + <title>{t('finder.map')}</title> | |
| 263 | + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} /> | |
| 264 | + <g stroke="var(--map-stroke)" strokeWidth={0.6} strokeLinejoin="round"> | |
| 265 | + {features.map((f, i) => { | |
| 266 | + const on = f.iso3 ? matchSet.has(f.iso3) : false; | |
| 267 | + return <path key={f.iso3 ?? `${f.name}-${i}`} d={f.d} fill={on ? 'var(--accent)' : 'var(--nodata)'} fillOpacity={on ? 0.9 : 0.5} />; | |
| 268 | + })} | |
| 269 | + </g> | |
| 270 | + </svg> | |
| 271 | + </div> | |
| 272 | + <figcaption className="mt-1 text-2xs text-ink-3">{t('finder.map')}</figcaption> | |
| 273 | + </figure> | |
| 274 | + </div> | |
| 275 | + ) : null} | |
| 276 | + </section> | |
| 277 | + </div> | |
| 278 | + ); | |
| 279 | +} | |
added
apps/web/src/components/explorer/geo.ts
+86 −0
@@ -0,0 +1,86 @@ | ||
| 1 | +/** Small geometry helpers for the explorer map (client-safe, pure). */ | |
| 2 | + | |
| 3 | +export interface BBox { | |
| 4 | + x: number; | |
| 5 | + y: number; | |
| 6 | + w: number; | |
| 7 | + h: number; | |
| 8 | +} | |
| 9 | + | |
| 10 | +/** Bounding box of an SVG path made of absolute M/L/Z commands (what d3 geoPath emits). */ | |
| 11 | +export function pathBBox(d: string): BBox | null { | |
| 12 | + const nums = d.match(/-?\d+(?:\.\d+)?/g); | |
| 13 | + if (!nums || nums.length < 4) return null; | |
| 14 | + let minX = Infinity; | |
| 15 | + let minY = Infinity; | |
| 16 | + let maxX = -Infinity; | |
| 17 | + let maxY = -Infinity; | |
| 18 | + for (let i = 0; i + 1 < nums.length; i += 2) { | |
| 19 | + const x = Number(nums[i]); | |
| 20 | + const y = Number(nums[i + 1]); | |
| 21 | + if (!Number.isFinite(x) || !Number.isFinite(y)) continue; | |
| 22 | + if (x < minX) minX = x; | |
| 23 | + if (x > maxX) maxX = x; | |
| 24 | + if (y < minY) minY = y; | |
| 25 | + if (y > maxY) maxY = y; | |
| 26 | + } | |
| 27 | + if (minX === Infinity) return null; | |
| 28 | + return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Class index 0..breaks.length for a value against sorted quantile breaks. */ | |
| 32 | +export function classIndex(value: number, breaks: number[]): number { | |
| 33 | + let i = 0; | |
| 34 | + while (i < breaks.length && value >= breaks[i]!) i++; | |
| 35 | + return i; | |
| 36 | +} | |
| 37 | + | |
| 38 | +/** Map a class index (0..k-1) onto the 7-step sequential ramp (same rule as ChoroplethView). */ | |
| 39 | +export function stepFor(cls: number, k: number): number { | |
| 40 | + if (k <= 1) return 4; | |
| 41 | + const start = k >= 6 ? 1 : 2; | |
| 42 | + return Math.round(start + (cls / (k - 1)) * (7 - start)); | |
| 43 | +} | |
| 44 | + | |
| 45 | +/** Median of finite numbers (null when empty). */ | |
| 46 | +export function median(values: Array<number | null | undefined>): number | null { | |
| 47 | + const v = values.filter((x): x is number => typeof x === 'number' && Number.isFinite(x)).sort((a, b) => a - b); | |
| 48 | + if (!v.length) return null; | |
| 49 | + const mid = Math.floor(v.length / 2); | |
| 50 | + return v.length % 2 ? v[mid]! : (v[mid - 1]! + v[mid]!) / 2; | |
| 51 | +} | |
| 52 | + | |
| 53 | +/** Equal-width histogram (log10 when `log`), 20 bins by default. */ | |
| 54 | +export function histogramOf(values: number[], bins = 20, log = false): { edges: number[]; counts: number[]; log: boolean } { | |
| 55 | + const v = log ? values.filter((x) => x > 0) : values; | |
| 56 | + if (!v.length) return { edges: [], counts: [], log }; | |
| 57 | + const t = log ? v.map((x) => Math.log10(x)) : v; | |
| 58 | + let lo = Math.min(...t); | |
| 59 | + let hi = Math.max(...t); | |
| 60 | + if (lo === hi) { | |
| 61 | + const pad = Math.abs(lo) * 0.05 || 0.5; | |
| 62 | + lo -= pad; | |
| 63 | + hi += pad; | |
| 64 | + } | |
| 65 | + const counts = new Array<number>(bins).fill(0); | |
| 66 | + const w = (hi - lo) / bins; | |
| 67 | + for (const x of t) { | |
| 68 | + let i = Math.floor((x - lo) / w); | |
| 69 | + if (i >= bins) i = bins - 1; | |
| 70 | + if (i < 0) i = 0; | |
| 71 | + counts[i]!++; | |
| 72 | + } | |
| 73 | + const edges = Array.from({ length: bins + 1 }, (_, i) => { | |
| 74 | + const e = lo + i * w; | |
| 75 | + return log ? Math.pow(10, e) : e; | |
| 76 | + }); | |
| 77 | + return { edges, counts, log }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** Heuristic: a level series spanning > 50× on positive values reads better on a log axis. */ | |
| 81 | +export function wantsLog(spec: { format?: string | null }, values: number[]): boolean { | |
| 82 | + if (!['currency', 'number', 'tonnes', 'kwh', 'per_million'].includes(spec.format ?? '')) return false; | |
| 83 | + const pos = values.filter((v) => v > 0); | |
| 84 | + if (pos.length < 10 || pos.length < values.length * 0.95) return false; | |
| 85 | + return Math.max(...pos) / Math.min(...pos) > 50; | |
| 86 | +} | |
added
apps/web/src/components/explorer/map-canvas.tsx
+345 −0
@@ -0,0 +1,345 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Minus, Plus, RotateCcw } from 'lucide-react'; | |
| 3 | +import { useCallback, useEffect, useId, useMemo, useRef, useState, type PointerEvent as RPointerEvent } from 'react'; | |
| 4 | +import { t } from '@/i18n'; | |
| 5 | +import { cn } from '@/lib/cn'; | |
| 6 | +import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo'; | |
| 7 | +import type { BaseFeature } from '@/components/indicators/indicator-map'; | |
| 8 | +import { seqVar } from '@/components/charts/palette'; | |
| 9 | +import { pathBBox, stepFor } from './geo'; | |
| 10 | + | |
| 11 | +export interface MapHover { | |
| 12 | + iso3: string; | |
| 13 | + x: number; | |
| 14 | + y: number; | |
| 15 | + sticky: boolean; | |
| 16 | +} | |
| 17 | + | |
| 18 | +const MIN_K = 1; | |
| 19 | +const MAX_K = 8; | |
| 20 | + | |
| 21 | +/** | |
| 22 | + * Zoomable, pannable Equal Earth world map (SVG). Paths are memoised once; only the fill class per country changes | |
| 23 | + * with the year. Wheel zooms around the cursor, drag pans, two pointers pinch; +/− and reset buttons; `focusId` | |
| 24 | + * flies to a country's bounding box. Mouse hover reports a floating position; touch taps select (`onSelect`). | |
| 25 | + * The transform is applied straight to the <g> during gestures (rAF) and mirrored to React state at rest. | |
| 26 | + */ | |
| 27 | +export function MapCanvas({ | |
| 28 | + features, | |
| 29 | + sphere, | |
| 30 | + classOf, | |
| 31 | + k, | |
| 32 | + selectedId, | |
| 33 | + focusId, | |
| 34 | + onHover, | |
| 35 | + onSelect, | |
| 36 | + onOpen, | |
| 37 | + className, | |
| 38 | + labelOf, | |
| 39 | +}: { | |
| 40 | + features: BaseFeature[]; | |
| 41 | + sphere: string; | |
| 42 | + /** ISO3 → class index (0..k−1) or null for no data. */ | |
| 43 | + classOf: Map<string, number | null>; | |
| 44 | + k: number; | |
| 45 | + selectedId: string | null; | |
| 46 | + focusId: string | null; | |
| 47 | + onHover: (h: MapHover | null) => void; | |
| 48 | + onSelect: (iso3: string | null) => void; | |
| 49 | + /** Double click / double tap → open the country page. */ | |
| 50 | + onOpen?: (iso3: string) => void; | |
| 51 | + className?: string; | |
| 52 | + labelOf: (iso3: string) => string; | |
| 53 | +}) { | |
| 54 | + const id = useId(); | |
| 55 | + const wrap = useRef<HTMLDivElement>(null); | |
| 56 | + const gRef = useRef<SVGGElement>(null); | |
| 57 | + const view = useRef({ k: 1, tx: 0, ty: 0 }); | |
| 58 | + const [zoom, setZoom] = useState(1); | |
| 59 | + const raf = useRef<number | null>(null); | |
| 60 | + const pointers = useRef(new Map<number, { x: number; y: number }>()); | |
| 61 | + const gesture = useRef<{ startDist: number; startK: number; startTx: number; startTy: number; cx: number; cy: number; lastX: number; lastY: number; moved: boolean } | null>(null); | |
| 62 | + const lastTap = useRef<{ iso: string; t: number } | null>(null); | |
| 63 | + const bboxes = useMemo(() => new Map(features.filter((f) => f.iso3).map((f) => [f.iso3!, pathBBox(f.d)])), [features]); | |
| 64 | + | |
| 65 | + const apply = useCallback(() => { | |
| 66 | + raf.current = null; | |
| 67 | + const g = gRef.current; | |
| 68 | + if (!g) return; | |
| 69 | + const { k: kk, tx, ty } = view.current; | |
| 70 | + g.setAttribute('transform', `translate(${tx.toFixed(2)},${ty.toFixed(2)}) scale(${kk.toFixed(4)})`); | |
| 71 | + g.style.setProperty('--k', String(kk)); | |
| 72 | + }, []); | |
| 73 | + const schedule = useCallback(() => { | |
| 74 | + if (raf.current == null) raf.current = requestAnimationFrame(apply); | |
| 75 | + }, [apply]); | |
| 76 | + | |
| 77 | + /** Geometry of the `meet`-fitted viewBox inside the wrapper: CSS px per user unit and the centring offsets. */ | |
| 78 | + const fit = useCallback(() => { | |
| 79 | + const el = wrap.current; | |
| 80 | + if (!el) return { s: 1, ox: 0, oy: 0, left: 0, top: 0 }; | |
| 81 | + const r = el.getBoundingClientRect(); | |
| 82 | + const s = Math.min(r.width / MAP_WIDTH, r.height / MAP_HEIGHT) || 1; | |
| 83 | + return { s, ox: (r.width - MAP_WIDTH * s) / 2, oy: (r.height - MAP_HEIGHT * s) / 2, left: r.left, top: r.top }; | |
| 84 | + }, []); | |
| 85 | + /** Scale factor from CSS pixels to SVG user units. */ | |
| 86 | + const unitsPerPx = useCallback(() => 1 / fit().s, [fit]); | |
| 87 | + | |
| 88 | + const clampView = useCallback(() => { | |
| 89 | + const v = view.current; | |
| 90 | + v.k = Math.min(MAX_K, Math.max(MIN_K, v.k)); | |
| 91 | + // keep the map covering the viewport: translation bounds | |
| 92 | + const maxTx = 0; | |
| 93 | + const minTx = MAP_WIDTH - MAP_WIDTH * v.k; | |
| 94 | + const maxTy = MAP_HEIGHT * 0.25 * (v.k - 1); | |
| 95 | + const minTy = MAP_HEIGHT - MAP_HEIGHT * v.k - MAP_HEIGHT * 0.25 * (v.k - 1); | |
| 96 | + if (v.k <= 1) { | |
| 97 | + v.tx = 0; | |
| 98 | + v.ty = 0; | |
| 99 | + } else { | |
| 100 | + v.tx = Math.min(maxTx, Math.max(minTx, v.tx)); | |
| 101 | + v.ty = Math.min(maxTy, Math.max(minTy, v.ty)); | |
| 102 | + } | |
| 103 | + }, []); | |
| 104 | + | |
| 105 | + const zoomAt = useCallback( | |
| 106 | + (factor: number, ux: number, uy: number) => { | |
| 107 | + const v = view.current; | |
| 108 | + const nk = Math.min(MAX_K, Math.max(MIN_K, v.k * factor)); | |
| 109 | + const f = nk / v.k; | |
| 110 | + v.tx = ux - (ux - v.tx) * f; | |
| 111 | + v.ty = uy - (uy - v.ty) * f; | |
| 112 | + v.k = nk; | |
| 113 | + clampView(); | |
| 114 | + schedule(); | |
| 115 | + setZoom(v.k); | |
| 116 | + }, | |
| 117 | + [clampView, schedule], | |
| 118 | + ); | |
| 119 | + | |
| 120 | + const toUnits = useCallback( | |
| 121 | + (clientX: number, clientY: number) => { | |
| 122 | + const f = fit(); | |
| 123 | + return { ux: (clientX - f.left - f.ox) / f.s, uy: (clientY - f.top - f.oy) / f.s }; | |
| 124 | + }, | |
| 125 | + [fit], | |
| 126 | + ); | |
| 127 | + | |
| 128 | + // Wheel zoom (non-passive so we can prevent the page from scrolling while over the map). | |
| 129 | + useEffect(() => { | |
| 130 | + const el = wrap.current; | |
| 131 | + if (!el) return; | |
| 132 | + const onWheel = (e: WheelEvent) => { | |
| 133 | + e.preventDefault(); | |
| 134 | + const { ux, uy } = toUnits(e.clientX, e.clientY); | |
| 135 | + zoomAt(Math.exp(-e.deltaY * 0.0015), ux, uy); | |
| 136 | + }; | |
| 137 | + el.addEventListener('wheel', onWheel, { passive: false }); | |
| 138 | + return () => el.removeEventListener('wheel', onWheel); | |
| 139 | + }, [toUnits, zoomAt]); | |
| 140 | + | |
| 141 | + // Fly to a country. | |
| 142 | + useEffect(() => { | |
| 143 | + if (!focusId) return; | |
| 144 | + const b = bboxes.get(focusId); | |
| 145 | + if (!b) return; | |
| 146 | + const v = view.current; | |
| 147 | + const target = Math.min(MAX_K, Math.max(1.6, Math.min((MAP_WIDTH * 0.35) / Math.max(b.w, 1), (MAP_HEIGHT * 0.35) / Math.max(b.h, 1)))); | |
| 148 | + const cx = b.x + b.w / 2; | |
| 149 | + const cy = b.y + b.h / 2; | |
| 150 | + const start = { ...v }; | |
| 151 | + const end = { k: target, tx: MAP_WIDTH / 2 - cx * target, ty: MAP_HEIGHT / 2 - cy * target }; | |
| 152 | + const t0 = performance.now(); | |
| 153 | + const dur = 480; | |
| 154 | + const g = gRef.current; | |
| 155 | + if (g) g.style.transition = 'none'; | |
| 156 | + const step = (now: number) => { | |
| 157 | + const p = Math.min(1, (now - t0) / dur); | |
| 158 | + const e = 1 - Math.pow(1 - p, 3); | |
| 159 | + v.k = start.k + (end.k - start.k) * e; | |
| 160 | + v.tx = start.tx + (end.tx - start.tx) * e; | |
| 161 | + v.ty = start.ty + (end.ty - start.ty) * e; | |
| 162 | + clampView(); | |
| 163 | + apply(); | |
| 164 | + if (p < 1) requestAnimationFrame(step); | |
| 165 | + else setZoom(v.k); | |
| 166 | + }; | |
| 167 | + requestAnimationFrame(step); | |
| 168 | + }, [focusId, bboxes, apply, clampView]); | |
| 169 | + | |
| 170 | + // Portrait viewports (phones): start at 1.5× so countries are legible; landscape keeps the whole world. | |
| 171 | + useEffect(() => { | |
| 172 | + const el = wrap.current; | |
| 173 | + if (!el) return; | |
| 174 | + const r = el.getBoundingClientRect(); | |
| 175 | + if (r.height / Math.max(1, r.width) > (MAP_HEIGHT / MAP_WIDTH) * 1.4 && view.current.k === 1) { | |
| 176 | + const v = view.current; | |
| 177 | + v.k = 1.5; | |
| 178 | + v.tx = MAP_WIDTH / 2 - (MAP_WIDTH / 2) * v.k; | |
| 179 | + v.ty = MAP_HEIGHT / 2 - (MAP_HEIGHT / 2) * v.k; | |
| 180 | + clampView(); | |
| 181 | + apply(); | |
| 182 | + setZoom(v.k); | |
| 183 | + } | |
| 184 | + // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 185 | + }, []); | |
| 186 | + | |
| 187 | + const reset = () => { | |
| 188 | + view.current = { k: 1, tx: 0, ty: 0 }; | |
| 189 | + schedule(); | |
| 190 | + setZoom(1); | |
| 191 | + }; | |
| 192 | + | |
| 193 | + const downIso = useRef<string | null>(null); | |
| 194 | + const touch = useRef(false); | |
| 195 | + const onPointerDown = (e: RPointerEvent<HTMLDivElement>) => { | |
| 196 | + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); | |
| 197 | + touch.current = e.pointerType !== 'mouse'; | |
| 198 | + if (touch.current) onHover(null); | |
| 199 | + downIso.current = pointers.current.size === 0 ? ((e.target as Element).closest?.('path[data-iso]') as SVGPathElement | null)?.dataset.iso ?? null : null; | |
| 200 | + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 201 | + const pts = Array.from(pointers.current.values()); | |
| 202 | + const v = view.current; | |
| 203 | + if (pts.length === 1) { | |
| 204 | + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: e.clientX, cy: e.clientY, lastX: e.clientX, lastY: e.clientY, moved: false }; | |
| 205 | + } else if (pts.length === 2) { | |
| 206 | + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }]; | |
| 207 | + gesture.current = { startDist: Math.hypot(a.x - b.x, a.y - b.y), startK: v.k, startTx: v.tx, startTy: v.ty, cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, lastX: (a.x + b.x) / 2, lastY: (a.y + b.y) / 2, moved: true }; | |
| 208 | + } | |
| 209 | + }; | |
| 210 | + const onPointerMove = (e: RPointerEvent<HTMLDivElement>) => { | |
| 211 | + if (!pointers.current.has(e.pointerId)) return; | |
| 212 | + pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); | |
| 213 | + const g = gesture.current; | |
| 214 | + if (!g) return; | |
| 215 | + const pts = Array.from(pointers.current.values()); | |
| 216 | + const s = unitsPerPx(); | |
| 217 | + const v = view.current; | |
| 218 | + if (pts.length >= 2) { | |
| 219 | + const [a, b] = pts as [{ x: number; y: number }, { x: number; y: number }]; | |
| 220 | + const dist = Math.hypot(a.x - b.x, a.y - b.y); | |
| 221 | + const mx = (a.x + b.x) / 2; | |
| 222 | + const my = (a.y + b.y) / 2; | |
| 223 | + const f = g.startDist > 0 ? dist / g.startDist : 1; | |
| 224 | + const nk = Math.min(MAX_K, Math.max(MIN_K, g.startK * f)); | |
| 225 | + const { ux, uy } = toUnits(g.cx, g.cy); | |
| 226 | + const ratio = nk / g.startK; | |
| 227 | + v.tx = ux - (ux - g.startTx) * ratio + (mx - g.cx) * s; | |
| 228 | + v.ty = uy - (uy - g.startTy) * ratio + (my - g.cy) * s; | |
| 229 | + v.k = nk; | |
| 230 | + g.moved = true; | |
| 231 | + } else { | |
| 232 | + const dx = e.clientX - g.lastX; | |
| 233 | + const dy = e.clientY - g.lastY; | |
| 234 | + if (Math.abs(e.clientX - g.cx) + Math.abs(e.clientY - g.cy) > 4) g.moved = true; | |
| 235 | + if (v.k > 1 || g.moved) { | |
| 236 | + v.tx += dx * s; | |
| 237 | + v.ty += dy * s; | |
| 238 | + } | |
| 239 | + g.lastX = e.clientX; | |
| 240 | + g.lastY = e.clientY; | |
| 241 | + } | |
| 242 | + clampView(); | |
| 243 | + schedule(); | |
| 244 | + }; | |
| 245 | + const endGesture = (e: RPointerEvent<HTMLDivElement>) => { | |
| 246 | + pointers.current.delete(e.pointerId); | |
| 247 | + if (pointers.current.size === 0) { | |
| 248 | + const moved = gesture.current?.moved ?? false; | |
| 249 | + gesture.current = null; | |
| 250 | + setZoom(view.current.k); | |
| 251 | + if (moved) lastTap.current = null; | |
| 252 | + else if (downIso.current) countryTap(downIso.current); | |
| 253 | + downIso.current = null; | |
| 254 | + } else if (pointers.current.size === 1) { | |
| 255 | + const [p] = Array.from(pointers.current.values()) as [{ x: number; y: number }]; | |
| 256 | + const v = view.current; | |
| 257 | + gesture.current = { startDist: 0, startK: v.k, startTx: v.tx, startTy: v.ty, cx: p.x, cy: p.y, lastX: p.x, lastY: p.y, moved: true }; | |
| 258 | + } | |
| 259 | + }; | |
| 260 | + | |
| 261 | + const place = useCallback( | |
| 262 | + (e: RPointerEvent<SVGPathElement>, iso: string, sticky: boolean) => { | |
| 263 | + const box = wrap.current?.getBoundingClientRect(); | |
| 264 | + if (!box) return; | |
| 265 | + onHover({ iso3: iso, x: e.clientX - box.left, y: e.clientY - box.top, sticky }); | |
| 266 | + }, | |
| 267 | + [onHover], | |
| 268 | + ); | |
| 269 | + | |
| 270 | + const countryTap = (iso: string) => { | |
| 271 | + const now = performance.now(); | |
| 272 | + if (lastTap.current && lastTap.current.iso === iso && now - lastTap.current.t < 380) { | |
| 273 | + lastTap.current = null; | |
| 274 | + onOpen?.(iso); | |
| 275 | + return; | |
| 276 | + } | |
| 277 | + lastTap.current = { iso, t: now }; | |
| 278 | + onSelect(iso); | |
| 279 | + }; | |
| 280 | + | |
| 281 | + return ( | |
| 282 | + <div ref={wrap} className={cn('relative h-full w-full touch-none select-none overflow-hidden', className)} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endGesture} onPointerCancel={endGesture} onPointerLeave={() => onHover(null)}> | |
| 283 | + <svg viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`} className="h-full w-full" role="img" aria-label={t('explorer.map.aria')} preserveAspectRatio="xMidYMid meet"> | |
| 284 | + <defs> | |
| 285 | + <pattern id={`${id}-hatch`} width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"> | |
| 286 | + <rect width="6" height="6" fill="var(--nodata)" /> | |
| 287 | + <line x1="0" y1="0" x2="0" y2="6" stroke="var(--rule-strong)" strokeWidth="1.5" /> | |
| 288 | + </pattern> | |
| 289 | + </defs> | |
| 290 | + <g ref={gRef} style={{ ['--k' as string]: 1 }}> | |
| 291 | + <path d={sphere} fill="var(--map-water)" stroke="var(--rule)" strokeWidth={1} vectorEffect="non-scaling-stroke" /> | |
| 292 | + <g stroke="var(--map-stroke)" strokeWidth={0.7} strokeLinejoin="round"> | |
| 293 | + {features.map((f, i) => { | |
| 294 | + const cls = f.iso3 ? classOf.get(f.iso3) ?? null : null; | |
| 295 | + const sel = f.iso3 != null && f.iso3 === selectedId; | |
| 296 | + return ( | |
| 297 | + <path | |
| 298 | + key={f.iso3 ?? `${f.name}-${i}`} | |
| 299 | + d={f.d} | |
| 300 | + fill={cls != null ? seqVar(stepFor(cls, k)) : `url(#${id}-hatch)`} | |
| 301 | + className={cn('outline-none transition-[fill] duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-accent', f.iso3 && 'cursor-pointer', sel && 'stroke-ink [stroke-width:1.6]')} | |
| 302 | + vectorEffect="non-scaling-stroke" | |
| 303 | + tabIndex={f.iso3 ? 0 : -1} | |
| 304 | + role={f.iso3 ? 'button' : undefined} | |
| 305 | + aria-label={f.iso3 ? labelOf(f.iso3) : undefined} | |
| 306 | + aria-pressed={sel || undefined} | |
| 307 | + onPointerMove={(e) => { | |
| 308 | + if (e.pointerType === 'mouse' && f.iso3 && !gesture.current?.moved) place(e, f.iso3, false); | |
| 309 | + }} | |
| 310 | + onPointerEnter={(e) => { | |
| 311 | + if (e.pointerType === 'mouse' && f.iso3) place(e, f.iso3, false); | |
| 312 | + }} | |
| 313 | + data-iso={f.iso3 ?? undefined} | |
| 314 | + onKeyDown={(e) => { | |
| 315 | + if ((e.key === 'Enter' || e.key === ' ') && f.iso3) { | |
| 316 | + e.preventDefault(); | |
| 317 | + onSelect(f.iso3); | |
| 318 | + } | |
| 319 | + }} | |
| 320 | + onFocus={(e) => { | |
| 321 | + if (!f.iso3 || touch.current) return; | |
| 322 | + const b = e.currentTarget.getBoundingClientRect(); | |
| 323 | + const box = wrap.current?.getBoundingClientRect(); | |
| 324 | + if (box) onHover({ iso3: f.iso3, x: b.left - box.left + b.width / 2, y: b.top - box.top, sticky: false }); | |
| 325 | + }} | |
| 326 | + /> | |
| 327 | + ); | |
| 328 | + })} | |
| 329 | + </g> | |
| 330 | + </g> | |
| 331 | + </svg> | |
| 332 | + <div className="absolute bottom-3 right-3 flex flex-col gap-1" role="group" aria-label={t('explorer.map.zoom')}> | |
| 333 | + <button type="button" onClick={() => zoomAt(1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom >= MAX_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomIn')}> | |
| 334 | + <Plus size={16} aria-hidden /> | |
| 335 | + </button> | |
| 336 | + <button type="button" onClick={() => zoomAt(1 / 1.5, MAP_WIDTH / 2, MAP_HEIGHT / 2)} disabled={zoom <= MIN_K} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.zoomOut')}> | |
| 337 | + <Minus size={16} aria-hidden /> | |
| 338 | + </button> | |
| 339 | + <button type="button" onClick={reset} disabled={zoom === 1} className="tap grid place-items-center rounded-sm border border-rule bg-surface/95 text-ink shadow-pop hover:bg-surface-2 disabled:opacity-40 md:min-h-[36px] md:min-w-[36px]" aria-label={t('explorer.map.reset')}> | |
| 340 | + <RotateCcw size={15} aria-hidden /> | |
| 341 | + </button> | |
| 342 | + </div> | |
| 343 | + </div> | |
| 344 | + ); | |
| 345 | +} | |
added
apps/web/src/components/explorer/options.ts
+50 −0
@@ -0,0 +1,50 @@ | ||
| 1 | +/** | |
| 2 | + * Server-safe helpers for the explorer pages (no 'use client' — the shared `toIndicatorOption` in | |
| 3 | + * components/controls/indicator-select.tsx and `parseYear` in lib/url-state.ts live in client modules and cannot | |
| 4 | + * be invoked from server components). | |
| 5 | + */ | |
| 6 | +import type { IndicatorOption } from '@/components/controls/indicator-select'; | |
| 7 | +import type { IndicatorSummary } from '@/lib/types'; | |
| 8 | + | |
| 9 | +export function indicatorOption(i: IndicatorSummary): IndicatorOption { | |
| 10 | + return { slug: i.slug, name: i.name ?? i.slug, short_name: i.short_name, topic: i.topic, unit: i.unit, featured: i.featured, first_year: i.first_year, last_year: i.last_year, n_countries: i.n_countries }; | |
| 11 | +} | |
| 12 | + | |
| 13 | +export function parseYearParam(v: string | null | undefined): number | null { | |
| 14 | + if (!v) return null; | |
| 15 | + const n = Number(v); | |
| 16 | + return Number.isInteger(n) && n >= 1750 && n <= 2100 ? n : null; | |
| 17 | +} | |
| 18 | + | |
| 19 | +const SLUG = /^[a-z0-9][a-z0-9-]*$/; | |
| 20 | +export function slugParam(v: string | null | undefined, fallback: string | null = null): string | null { | |
| 21 | + const s = (v ?? '').toLowerCase(); | |
| 22 | + return SLUG.test(s) ? s : fallback; | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function listParam(v: string | null | undefined, max = 8): string[] { | |
| 26 | + if (!v) return []; | |
| 27 | + const out: string[] = []; | |
| 28 | + for (const part of v.split(',')) { | |
| 29 | + const s = part.trim(); | |
| 30 | + if (s && /^[A-Za-z0-9-]+$/.test(s) && !out.includes(s)) out.push(s); | |
| 31 | + if (out.length >= max) break; | |
| 32 | + } | |
| 33 | + return out; | |
| 34 | +} | |
| 35 | + | |
| 36 | +export type SP = Record<string, string | string[] | undefined>; | |
| 37 | +export function firstParam(sp: SP, k: string): string | null { | |
| 38 | + const v = sp[k]; | |
| 39 | + return (Array.isArray(v) ? v[0] : v) ?? null; | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** Annual indicators with enough countries for cross-country views. */ | |
| 43 | +export function explorerIndicators(items: IndicatorSummary[], minCountries = 20): IndicatorOption[] { | |
| 44 | + return items.filter((i) => (i.frequency ?? 'A') === 'A' && (i.n_countries ?? 0) >= minCountries).map(indicatorOption); | |
| 45 | +} | |
| 46 | + | |
| 47 | +export type ExplorerView = 'map' | 'rank' | 'trend' | 'distribution'; | |
| 48 | +export const EXPLORER_VIEWS: ExplorerView[] = ['map', 'rank', 'trend', 'distribution']; | |
| 49 | +export const DEFAULT_EXPLORER_INDICATOR = 'gdp-per-capita-ppp'; | |
| 50 | +export const DEFAULT_TRAJ = { x: 'gdp-per-capita-ppp', y: 'life-expectancy', size: 'population' } as const; | |
added
apps/web/src/components/explorer/scatter-view.tsx
+264 −0
@@ -0,0 +1,264 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { ArrowRight, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { t } from '@/i18n'; | |
| 6 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { fixed, formatValue, grouped } from '@/lib/format'; | |
| 9 | +import { regionShort } from '@/lib/regions'; | |
| 10 | +import { routes } from '@/lib/site'; | |
| 11 | +import { useUrlState } from '@/lib/url-state'; | |
| 12 | +import type { FormatSpec } from '@/lib/types'; | |
| 13 | +import type { RelatedResponse, ScatterResponse } from '@/lib/types-analytics'; | |
| 14 | +import type { RegionItem } from '@/lib/types-explore'; | |
| 15 | +import { BubbleChart, type BubbleFit, type BubblePoint } from '@/components/charts/bubble-chart'; | |
| 16 | +import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; | |
| 17 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 18 | +import { DEFAULT_TRAJ } from './options'; | |
| 19 | +import { useIsDesktop } from './use-media'; | |
| 20 | +import { BottomSheet } from '@/components/data/bottom-sheet'; | |
| 21 | + | |
| 22 | +export interface ScatterState { | |
| 23 | + x: string; | |
| 24 | + y: string; | |
| 25 | + size: string; | |
| 26 | + year: number | null; | |
| 27 | + group: string; | |
| 28 | + log: string | null; | |
| 29 | + fit: boolean; | |
| 30 | + country: string | null; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function specOf(i: ScatterResponse['x'] | null | undefined, fallback: string): FormatSpec { | |
| 34 | + return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallback, higher_is_better: i?.higher_is_better }; | |
| 35 | +} | |
| 36 | + | |
| 37 | +/** Cross-section scatter with descriptive statistics; every control is in the URL. */ | |
| 38 | +export function ScatterView({ indicators, groups, initial, initialRelated, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: ScatterResponse | null; initialRelated: RelatedResponse | null; initialState: ScatterState }) { | |
| 39 | + const { get, getNum, set } = useUrlState(); | |
| 40 | + const { open: openProv } = useProvenance(); | |
| 41 | + const desktop = useIsDesktop(); | |
| 42 | + const x = get('x') ?? initialState.x; | |
| 43 | + const y = get('y') ?? initialState.y; | |
| 44 | + const size = get('size') ?? initialState.size; | |
| 45 | + const group = get('group') ?? initialState.group; | |
| 46 | + const year = getNum('year') ?? initialState.year; | |
| 47 | + const logParam = get('log') ?? initialState.log; | |
| 48 | + const fit = (get('fit') ?? (initialState.fit ? '1' : null)) === '1'; | |
| 49 | + const selected = (get('country') ?? initialState.country)?.toUpperCase() ?? null; | |
| 50 | + const logX = logParam == null ? null : logParam.includes('x'); | |
| 51 | + const logY = logParam == null ? null : logParam.includes('y'); | |
| 52 | + const key = `${x}|${y}|${size}|${group}|${year ?? ''}|${logParam ?? ''}`; | |
| 53 | + const [cache, setCache] = useState<Record<string, ScatterResponse | null>>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}|${initialState.year ?? ''}|${initialState.log ?? ''}`]: initial } : {})); | |
| 54 | + const [related, setRelated] = useState<Record<string, RelatedResponse | null>>(() => (initialRelated ? { [initialState.x]: initialRelated } : {})); | |
| 55 | + const [loading, setLoading] = useState(false); | |
| 56 | + const abort = useRef<AbortController | null>(null); | |
| 57 | + | |
| 58 | + useEffect(() => { | |
| 59 | + if (cache[key] !== undefined) return; | |
| 60 | + abort.current?.abort(); | |
| 61 | + const ctrl = new AbortController(); | |
| 62 | + abort.current = ctrl; | |
| 63 | + setLoading(true); | |
| 64 | + clientAnalytics | |
| 65 | + .scatter({ x, y, size: size === 'none' ? 'none' : size, year, group: group !== 'world' ? group : null, log_x: logX == null ? null : String(logX), log_y: logY == null ? null : String(logY) }, ctrl.signal) | |
| 66 | + .then((r) => setCache((c) => ({ ...c, [key]: r }))) | |
| 67 | + .catch((e) => { | |
| 68 | + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null })); | |
| 69 | + }) | |
| 70 | + .finally(() => { | |
| 71 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 72 | + }); | |
| 73 | + return () => ctrl.abort(); | |
| 74 | + }, [key, x, y, size, group, year, logX, logY, cache]); | |
| 75 | + | |
| 76 | + useEffect(() => { | |
| 77 | + if (related[x] !== undefined) return; | |
| 78 | + const ctrl = new AbortController(); | |
| 79 | + clientAnalytics | |
| 80 | + .indicatorRelated(x, 8, ctrl.signal) | |
| 81 | + .then((r) => setRelated((c) => ({ ...c, [x]: r }))) | |
| 82 | + .catch(() => setRelated((c) => ({ ...c, [x]: null }))); | |
| 83 | + return () => ctrl.abort(); | |
| 84 | + }, [x, related]); | |
| 85 | + | |
| 86 | + const data = cache[key] ?? null; | |
| 87 | + const xSpec = specOf(data?.x, x); | |
| 88 | + const ySpec = specOf(data?.y, y); | |
| 89 | + const sizeSpec = data?.size ? specOf(data.size, size) : null; | |
| 90 | + const points: BubblePoint[] = useMemo(() => (data?.points ?? []).map((p) => ({ id: p.id, label: p.name ?? p.id, flag: p.flag, x: p.x, y: p.y, size: p.size, region: p.region, yearX: p.year_x, yearY: p.year_y })), [data]); | |
| 91 | + const fitLine: BubbleFit | null = fit && data?.stats.ols ? { slope: data.stats.ols.slope, intercept: data.stats.ols.intercept, logX: data.stats.log_x, logY: data.stats.log_y } : null; | |
| 92 | + const sel = selected ? data?.points.find((p) => p.id === selected) ?? null : null; | |
| 93 | + const xOpt = indicators.find((i) => i.slug === x); | |
| 94 | + const yOpt = indicators.find((i) => i.slug === y); | |
| 95 | + const yearRange = useMemo(() => { | |
| 96 | + const lo = Math.max(xOpt?.first_year ?? 1960, yOpt?.first_year ?? 1960); | |
| 97 | + const hi = Math.min(xOpt?.last_year ?? 2025, yOpt?.last_year ?? 2025); | |
| 98 | + const out: number[] = []; | |
| 99 | + for (let yy = hi; yy >= lo; yy--) out.push(yy); | |
| 100 | + return out; | |
| 101 | + }, [xOpt, yOpt]); | |
| 102 | + const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world') }, ...groups.filter((g) => ['region', 'income', 'continent', 'org'].includes(g.kind ?? '')).map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id }))], [groups]); | |
| 103 | + const sizeOptions: IndicatorOption[] = useMemo(() => [{ slug: 'none', name: t('traj.sizeNone') }, ...indicators.filter((i) => ['population', 'gdp', 'gdp-ppp', 'co2-emissions', 'labor-force', 'electricity-generation'].includes(i.slug) || i.slug === size)], [indicators, size]); | |
| 104 | + const setLog = (axis: 'x' | 'y', on: boolean) => { | |
| 105 | + const cur = new Set((logParam ?? `${data?.stats.log_x ? 'x' : ''}${data?.stats.log_y ? 'y' : ''}`).split('')); | |
| 106 | + if (on) cur.add(axis); | |
| 107 | + else cur.delete(axis); | |
| 108 | + const v = ['x', 'y'].filter((a) => cur.has(a)).join(','); | |
| 109 | + set({ log: v || 'none' }, 0); | |
| 110 | + }; | |
| 111 | + const effLogX = data?.stats.log_x ?? false; | |
| 112 | + const effLogY = data?.stats.log_y ?? false; | |
| 113 | + const rel = related[x] ?? null; | |
| 114 | + const country = sel ? { id: sel.id, slug: sel.slug, name: sel.name ?? sel.id, flag: sel.flag } : null; | |
| 115 | + | |
| 116 | + const selPanel = sel ? ( | |
| 117 | + <div className="text-sm"> | |
| 118 | + <div className="flex items-start gap-2"> | |
| 119 | + <span aria-hidden className="text-3xl leading-none"> | |
| 120 | + {sel.flag} | |
| 121 | + </span> | |
| 122 | + <div className="min-w-0 flex-1"> | |
| 123 | + <div className="display text-lg text-ink">{sel.name}</div> | |
| 124 | + <div className="text-xs text-ink-3">{regionShort(sel.region) ?? sel.region}</div> | |
| 125 | + </div> | |
| 126 | + <button type="button" onClick={() => set({ country: null }, 0)} className="tap -mr-2 grid place-items-center text-ink-2 md:min-h-[32px] md:min-w-[32px]" aria-label={t('common.close')}> | |
| 127 | + <X size={16} aria-hidden /> | |
| 128 | + </button> | |
| 129 | + </div> | |
| 130 | + <dl className="mt-3 divide-y divide-rule border-y border-rule"> | |
| 131 | + {[ | |
| 132 | + { spec: xSpec, v: sel.x, yr: sel.year_x, slug: x }, | |
| 133 | + { spec: ySpec, v: sel.y, yr: sel.year_y, slug: y }, | |
| 134 | + ...(sizeSpec ? [{ spec: sizeSpec, v: sel.size, yr: null, slug: size }] : []), | |
| 135 | + ].map((row) => ( | |
| 136 | + <div key={row.slug} className="flex items-baseline justify-between gap-3 py-2"> | |
| 137 | + <dt className="text-ink-2">{row.spec.name}</dt> | |
| 138 | + <dd className="tnum text-right"> | |
| 139 | + <button type="button" onClick={() => openProv({ indicator: { slug: row.slug, name: row.spec.name ?? row.slug, format: row.spec.format, unit: row.spec.unit, unit_short: row.spec.unit_short, precision: row.spec.precision, frequency: 'A' }, value: { value: row.v, period: row.yr ? `${row.yr}-01-01` : null, year: row.yr, unit: row.spec.unit, provenance: data?.provenance?.[0] ?? null }, country })} className="font-semibold text-ink hover:text-accent" aria-label={t('common.openProvenance')}> | |
| 140 | + {formatValue(row.v, row.spec)} | |
| 141 | + </button> | |
| 142 | + {row.yr ? <span className="text-ink-3"> · {row.yr}</span> : null} | |
| 143 | + </dd> | |
| 144 | + </div> | |
| 145 | + ))} | |
| 146 | + </dl> | |
| 147 | + <Link href={routes.country(sel.slug ?? sel.id.toLowerCase())} className="mt-3 inline-flex min-h-[44px] items-center gap-1 text-accent hover:underline md:min-h-[32px]"> | |
| 148 | + {t('explorer.drawer.open')} <ArrowRight size={14} aria-hidden /> | |
| 149 | + </Link> | |
| 150 | + </div> | |
| 151 | + ) : null; | |
| 152 | + | |
| 153 | + return ( | |
| 154 | + <div> | |
| 155 | + <header className="pb-3 pt-6 md:pt-10"> | |
| 156 | + <h1 className="display text-3xl leading-tight text-ink md:text-4xl">{t('scatter.title')}</h1> | |
| 157 | + <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{t('scatter.lede')}</p> | |
| 158 | + </header> | |
| 159 | + <div className="grid gap-2 border-y border-rule py-3 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_9rem_10rem]"> | |
| 160 | + <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v, log: null }, 0)} label={t('traj.x')} size="sm" /> | |
| 161 | + <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v, log: null }, 0)} label={t('traj.y')} size="sm" /> | |
| 162 | + <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" /> | |
| 163 | + <label className="flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 md:h-9"> | |
| 164 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('scatter.year')}</span> | |
| 165 | + <select value={data?.year_used ?? year ?? ''} onChange={(e) => set({ year: Number(e.target.value) }, 0)} className="tnum min-w-0 flex-1 bg-transparent text-ink outline-none" aria-label={t('scatter.year')}> | |
| 166 | + {yearRange.map((yy) => ( | |
| 167 | + <option key={yy} value={yy}> | |
| 168 | + {yy} | |
| 169 | + </option> | |
| 170 | + ))} | |
| 171 | + </select> | |
| 172 | + </label> | |
| 173 | + <label className="flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 md:h-9"> | |
| 174 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.group')}</span> | |
| 175 | + <select value={group} onChange={(e) => set({ group: e.target.value === 'world' ? null : e.target.value }, 0)} className="min-w-0 flex-1 truncate bg-transparent text-ink outline-none" aria-label={t('traj.group')}> | |
| 176 | + {groupOptions.map((g) => ( | |
| 177 | + <option key={g.slug} value={g.slug}> | |
| 178 | + {g.name} | |
| 179 | + </option> | |
| 180 | + ))} | |
| 181 | + </select> | |
| 182 | + </label> | |
| 183 | + </div> | |
| 184 | + <div className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm"> | |
| 185 | + <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8"> | |
| 186 | + <input type="checkbox" checked={effLogX} onChange={(e) => setLog('x', e.target.checked)} className="accent-[var(--accent)]" /> {t('scatter.logX')} | |
| 187 | + </label> | |
| 188 | + <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8"> | |
| 189 | + <input type="checkbox" checked={effLogY} onChange={(e) => setLog('y', e.target.checked)} className="accent-[var(--accent)]" /> {t('scatter.logY')} | |
| 190 | + </label> | |
| 191 | + <label className="inline-flex h-11 items-center gap-1.5 text-ink-2 md:h-8"> | |
| 192 | + <input type="checkbox" checked={fit} onChange={(e) => set({ fit: e.target.checked ? '1' : null }, 0)} className="accent-[var(--accent)]" /> {t('scatter.fit')} | |
| 193 | + </label> | |
| 194 | + <Link href={routes.trajectories({ x, y, size: size !== DEFAULT_TRAJ.size ? size : undefined, year: data?.year_used ?? year, group: group !== 'world' ? group : null })} className="ml-auto inline-flex min-h-[44px] items-center text-accent hover:underline md:min-h-[32px]"> | |
| 195 | + {t('scatter.openTrajectories')} → | |
| 196 | + </Link> | |
| 197 | + </div> | |
| 198 | + | |
| 199 | + <div className="grid gap-x-8 lg:grid-cols-[minmax(0,1fr)_18rem]"> | |
| 200 | + <div className={cn('min-w-0 transition-opacity', loading && 'opacity-60')} aria-busy={loading}> | |
| 201 | + {data === null && !loading ? ( | |
| 202 | + <p className="py-16 text-center text-sm text-ink-3">{t('scatter.noData', { year: year ?? '' })}</p> | |
| 203 | + ) : data && data.n === 0 ? ( | |
| 204 | + <p className="py-16 text-center text-sm text-ink-3">{t('scatter.noData', { year: data.year_used ?? year ?? '' })}</p> | |
| 205 | + ) : data ? ( | |
| 206 | + <BubbleChart points={points} xSpec={xSpec} ySpec={ySpec} sizeSpec={sizeSpec} logX={effLogX} logY={effLogY} fit={fitLine} highlight={selected ? [selected] : []} onSelect={(id) => set({ country: id ? id.toLowerCase() : null }, 0)} height={desktop ? 480 : 380} defaultWidth={900} /> | |
| 207 | + ) : ( | |
| 208 | + <div className="grid min-h-[420px] place-items-center text-sm text-ink-3">{t('common.loading')}</div> | |
| 209 | + )} | |
| 210 | + {data ? ( | |
| 211 | + <div className="mt-3 border-t border-rule pt-3"> | |
| 212 | + <dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm sm:grid-cols-5"> | |
| 213 | + {[ | |
| 214 | + [t('scatter.stats.pearson'), data.stats.pearson != null ? fixed(data.stats.pearson, 2) : t('common.na')], | |
| 215 | + [t('scatter.stats.spearman'), data.stats.spearman != null ? fixed(data.stats.spearman, 2) : t('common.na')], | |
| 216 | + [t('scatter.stats.r2'), data.stats.ols?.r2 != null ? fixed(data.stats.ols.r2, 2) : t('common.na')], | |
| 217 | + [t('scatter.stats.n'), grouped(data.n)], | |
| 218 | + [t('scatter.stats.year'), String(data.year_used ?? '')], | |
| 219 | + ].map(([k, v]) => ( | |
| 220 | + <div key={k}> | |
| 221 | + <dt className="text-2xs uppercase tracking-wide text-ink-3">{k}</dt> | |
| 222 | + <dd className="tnum text-xl font-semibold text-ink">{v}</dd> | |
| 223 | + </div> | |
| 224 | + ))} | |
| 225 | + </dl> | |
| 226 | + <p className="mt-2 text-xs font-medium text-ink-2">{t('scatter.caveat')}</p> | |
| 227 | + <p className="text-2xs text-ink-3"> | |
| 228 | + {t('scatter.nearest', { year: data.year_used ?? '', n: data.nearest_years })} | |
| 229 | + {effLogX || effLogY ? ` ${t('common.log')}: ${[effLogX ? 'x' : null, effLogY ? 'y' : null].filter(Boolean).join(', ')}.` : ''} | |
| 230 | + </p> | |
| 231 | + </div> | |
| 232 | + ) : null} | |
| 233 | + </div> | |
| 234 | + <aside className="min-w-0"> | |
| 235 | + {desktop && selPanel ? <div className="border-t border-rule pt-3 lg:border-t-0 lg:pt-0">{selPanel}</div> : null} | |
| 236 | + <div className={cn('border-t border-rule pt-3', desktop && selPanel && 'mt-6')}> | |
| 237 | + <div className="text-sm font-semibold text-ink">{t('scatter.related', { name: xSpec.name ?? x })}</div> | |
| 238 | + <p className="mb-2 text-2xs text-ink-3">{t('scatter.relatedHint')}</p> | |
| 239 | + {rel && rel.items.length ? ( | |
| 240 | + <ul className="divide-y divide-rule"> | |
| 241 | + {rel.items | |
| 242 | + .filter((it) => it.indicator.slug !== y) | |
| 243 | + .slice(0, 6) | |
| 244 | + .map((it) => ( | |
| 245 | + <li key={it.indicator.slug}> | |
| 246 | + <button type="button" onClick={() => set({ y: it.indicator.slug, log: null }, 0)} className="flex min-h-[44px] w-full items-center justify-between gap-2 text-left text-sm hover:text-accent md:min-h-[36px]"> | |
| 247 | + <span className="truncate">{it.indicator.short_name ?? it.indicator.name}</span> | |
| 248 | + <span className={cn('tnum shrink-0 text-xs', it.direction === 'negative' ? 'text-dec' : 'text-inc')}>ρ {it.spearman != null ? fixed(it.spearman, 2) : '—'}</span> | |
| 249 | + </button> | |
| 250 | + </li> | |
| 251 | + ))} | |
| 252 | + </ul> | |
| 253 | + ) : ( | |
| 254 | + <p className="text-xs text-ink-3">{rel === null ? t('common.noDataLong') : t('common.loading')}</p> | |
| 255 | + )} | |
| 256 | + </div> | |
| 257 | + </aside> | |
| 258 | + </div> | |
| 259 | + <BottomSheet open={!desktop && !!selPanel} onClose={() => set({ country: null }, 0)} side="drawer" title={<span className="sr-only">{sel?.name}</span>}> | |
| 260 | + {selPanel} | |
| 261 | + </BottomSheet> | |
| 262 | + </div> | |
| 263 | + ); | |
| 264 | +} | |
added
apps/web/src/components/explorer/trajectories-view.tsx
+236 −0
@@ -0,0 +1,236 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { Check, RotateCcw, Share2, SlidersHorizontal, X } from 'lucide-react'; | |
| 3 | +import Link from 'next/link'; | |
| 4 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { t } from '@/i18n'; | |
| 6 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { grouped } from '@/lib/format'; | |
| 9 | +import { routes } from '@/lib/site'; | |
| 10 | +import { useUrlState } from '@/lib/url-state'; | |
| 11 | +import type { FormatSpec } from '@/lib/types'; | |
| 12 | +import type { TrajectoryResponse } from '@/lib/types-analytics'; | |
| 13 | +import type { RegionItem } from '@/lib/types-explore'; | |
| 14 | +import { BubbleChart, type BubblePoint } from '@/components/charts/bubble-chart'; | |
| 15 | +import { IndicatorSelect, type IndicatorOption } from '@/components/controls/indicator-select'; | |
| 16 | +import { YearSlider } from '@/components/controls/year-slider'; | |
| 17 | +import { BottomSheet } from '@/components/data/bottom-sheet'; | |
| 18 | +import { EntityPicker } from '@/components/explore/entity-picker'; | |
| 19 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 20 | +import { DEFAULT_TRAJ } from './options'; | |
| 21 | + | |
| 22 | +export interface TrajectoriesState { | |
| 23 | + x: string; | |
| 24 | + y: string; | |
| 25 | + size: string; | |
| 26 | + year: number | null; | |
| 27 | + group: string; | |
| 28 | + select: string[]; | |
| 29 | +} | |
| 30 | + | |
| 31 | +function specOf(i: TrajectoryResponse['x'] | null | undefined, fallback: string): FormatSpec { | |
| 32 | + return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallback, higher_is_better: i?.higher_is_better }; | |
| 33 | +} | |
| 34 | + | |
| 35 | +/** Gapminder-style animated bubbles: one /trajectory payload per (x, y, size, group); the year only re-indexes arrays. */ | |
| 36 | +export function TrajectoriesView({ indicators, groups, initial, initialState }: { indicators: IndicatorOption[]; groups: RegionItem[]; initial: TrajectoryResponse | null; initialState: TrajectoriesState }) { | |
| 37 | + const { get, getNum, set } = useUrlState(); | |
| 38 | + const { open: openProv } = useProvenance(); | |
| 39 | + const x = get('x') ?? initialState.x; | |
| 40 | + const y = get('y') ?? initialState.y; | |
| 41 | + const size = get('size') ?? initialState.size; | |
| 42 | + const group = get('group') ?? initialState.group; | |
| 43 | + const select = useMemo(() => (get('select') ?? initialState.select.join(',')).split(',').map((s) => s.trim().toUpperCase()).filter(Boolean).slice(0, 4), [get, initialState.select]); | |
| 44 | + const key = `${x}|${y}|${size}|${group}`; | |
| 45 | + const [cache, setCache] = useState<Record<string, TrajectoryResponse | null>>(() => (initial ? { [`${initialState.x}|${initialState.y}|${initialState.size}|${initialState.group}`]: initial } : {})); | |
| 46 | + const [loading, setLoading] = useState(false); | |
| 47 | + const [sheet, setSheet] = useState(false); | |
| 48 | + const [copied, setCopied] = useState(false); | |
| 49 | + const [playing, setPlaying] = useState(false); | |
| 50 | + const abort = useRef<AbortController | null>(null); | |
| 51 | + const [chartH, setChartH] = useState(480); | |
| 52 | + useEffect(() => { | |
| 53 | + const apply = () => setChartH(Math.max(360, Math.min(640, window.innerHeight - 330))); | |
| 54 | + apply(); | |
| 55 | + window.addEventListener('resize', apply); | |
| 56 | + return () => window.removeEventListener('resize', apply); | |
| 57 | + }, []); | |
| 58 | + | |
| 59 | + useEffect(() => { | |
| 60 | + if (cache[key] !== undefined) return; | |
| 61 | + abort.current?.abort(); | |
| 62 | + const ctrl = new AbortController(); | |
| 63 | + abort.current = ctrl; | |
| 64 | + setLoading(true); | |
| 65 | + clientAnalytics | |
| 66 | + .trajectory({ x, y, size: size === 'none' ? 'none' : size, group: group !== 'world' ? group : null }, ctrl.signal) | |
| 67 | + .then((r) => setCache((c) => ({ ...c, [key]: r }))) | |
| 68 | + .catch((e) => { | |
| 69 | + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null })); | |
| 70 | + }) | |
| 71 | + .finally(() => { | |
| 72 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 73 | + }); | |
| 74 | + return () => ctrl.abort(); | |
| 75 | + }, [key, x, y, size, group, cache]); | |
| 76 | + | |
| 77 | + const data = cache[key] ?? null; | |
| 78 | + const years = data?.years ?? []; | |
| 79 | + const urlYear = getNum('year') ?? initialState.year; | |
| 80 | + const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear()); | |
| 81 | + const yi = years.indexOf(year); | |
| 82 | + const xSpec = specOf(data?.x, x); | |
| 83 | + const ySpec = specOf(data?.y, y); | |
| 84 | + const sizeSpec = data?.size ? specOf(data.size, size) : null; | |
| 85 | + | |
| 86 | + const points: BubblePoint[] = useMemo(() => { | |
| 87 | + if (!data || yi < 0) return []; | |
| 88 | + return data.countries.map((c) => { | |
| 89 | + const s = data.series[c.id]; | |
| 90 | + return { id: c.id, label: c.name ?? c.id, flag: c.flag, x: s?.x[yi] ?? null, y: s?.y[yi] ?? null, size: s?.size[yi] ?? null, region: c.region, yearX: year, yearY: year }; | |
| 91 | + }); | |
| 92 | + }, [data, yi, year]); | |
| 93 | + const trails = useMemo(() => { | |
| 94 | + const out: Record<string, Array<{ x: number; y: number }>> = {}; | |
| 95 | + if (!data || yi < 0) return out; | |
| 96 | + for (const id of select) { | |
| 97 | + const s = data.series[id]; | |
| 98 | + if (!s) continue; | |
| 99 | + const pts: Array<{ x: number; y: number }> = []; | |
| 100 | + for (let i = 0; i <= yi; i++) { | |
| 101 | + const px = s.x[i]; | |
| 102 | + const py = s.y[i]; | |
| 103 | + if (px != null && py != null) pts.push({ x: px, y: py }); | |
| 104 | + } | |
| 105 | + out[id] = pts; | |
| 106 | + } | |
| 107 | + return out; | |
| 108 | + }, [data, select, yi]); | |
| 109 | + const byId = useMemo(() => new Map((data?.countries ?? []).map((c) => [c.id, c])), [data]); | |
| 110 | + const nYear = points.filter((p) => p.x != null && p.y != null).length; | |
| 111 | + | |
| 112 | + const setYear = (v: number) => set({ year: years.length && v === years[years.length - 1] ? null : v }, playing ? 250 : 80); | |
| 113 | + const setSelect = (ids: string[]) => set({ select: ids.length ? ids.join(',') : null }, 0); | |
| 114 | + const reset = () => set({ x: null, y: null, size: null, group: null, select: null, year: null }, 0); | |
| 115 | + const share = async () => { | |
| 116 | + try { | |
| 117 | + const url = window.location.href; | |
| 118 | + if (navigator.share) await navigator.share({ title: document.title, url }); | |
| 119 | + else { | |
| 120 | + await navigator.clipboard.writeText(url); | |
| 121 | + setCopied(true); | |
| 122 | + setTimeout(() => setCopied(false), 1600); | |
| 123 | + } | |
| 124 | + } catch { | |
| 125 | + /* cancelled */ | |
| 126 | + } | |
| 127 | + }; | |
| 128 | + const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world') }, ...groups.filter((g) => ['region', 'income', 'continent', 'org'].includes(g.kind ?? '')).map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id }))], [groups]); | |
| 129 | + const sizeOptions: IndicatorOption[] = useMemo(() => [{ slug: 'none', name: t('traj.sizeNone') }, ...indicators.filter((i) => ['population', 'gdp', 'gdp-ppp', 'co2-emissions', 'area-km2', 'labor-force', 'electricity-generation', 'primary-energy-consumption'].includes(i.slug) || i.slug === size)], [indicators, size]); | |
| 130 | + | |
| 131 | + const controls = ( | |
| 132 | + <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-4"> | |
| 133 | + <IndicatorSelect options={indicators} value={x} onChange={(v) => set({ x: v === DEFAULT_TRAJ.x ? null : v }, 0)} label={t('traj.x')} size="sm" /> | |
| 134 | + <IndicatorSelect options={indicators} value={y} onChange={(v) => set({ y: v === DEFAULT_TRAJ.y ? null : v }, 0)} label={t('traj.y')} size="sm" /> | |
| 135 | + <IndicatorSelect options={sizeOptions} value={size} onChange={(v) => set({ size: v === DEFAULT_TRAJ.size ? null : v }, 0)} label={t('traj.size')} size="sm" /> | |
| 136 | + <label className="flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface px-2.5 text-sm text-ink-2 md:h-9"> | |
| 137 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.group')}</span> | |
| 138 | + <select value={group} onChange={(e) => set({ group: e.target.value === 'world' ? null : e.target.value }, 0)} className="min-w-0 flex-1 truncate bg-transparent text-ink outline-none" aria-label={t('traj.group')}> | |
| 139 | + {groupOptions.map((g) => ( | |
| 140 | + <option key={g.slug} value={g.slug}> | |
| 141 | + {g.name} | |
| 142 | + </option> | |
| 143 | + ))} | |
| 144 | + </select> | |
| 145 | + </label> | |
| 146 | + </div> | |
| 147 | + ); | |
| 148 | + const follow = ( | |
| 149 | + <div className="flex flex-wrap items-center gap-1.5"> | |
| 150 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('traj.select')}</span> | |
| 151 | + {select.map((id) => { | |
| 152 | + const c = byId.get(id); | |
| 153 | + return ( | |
| 154 | + <span key={id} className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm"> | |
| 155 | + <span aria-hidden>{c?.flag}</span> | |
| 156 | + <span className="max-w-[8rem] truncate">{c?.name ?? id}</span> | |
| 157 | + <button type="button" onClick={() => setSelect(select.filter((s) => s !== id))} className="grid h-9 w-8 place-items-center text-ink-3 hover:text-down" aria-label={t('traj.remove', { name: c?.name ?? id })}> | |
| 158 | + <X size={13} aria-hidden /> | |
| 159 | + </button> | |
| 160 | + </span> | |
| 161 | + ); | |
| 162 | + })} | |
| 163 | + {select.length < 4 ? <EntityPicker type="country" placeholder={t('traj.selectHint')} onPick={(e) => setSelect(Array.from(new Set([...select, e.id])))} exclude={select} size="sm" className="w-56" /> : null} | |
| 164 | + </div> | |
| 165 | + ); | |
| 166 | + | |
| 167 | + return ( | |
| 168 | + <div className="flex min-h-[calc(100dvh-52px)] flex-col md:min-h-[calc(100dvh-56px)]"> | |
| 169 | + <div className="container-x mx-auto w-full max-w-[1400px]"> | |
| 170 | + <header className="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 pb-2 pt-4 md:pt-6"> | |
| 171 | + <div className="min-w-0"> | |
| 172 | + <h1 className="display text-2xl text-ink md:text-3xl">{t('traj.title')}</h1> | |
| 173 | + <p className="mt-0.5 text-sm text-ink-2">{t('traj.lede')}</p> | |
| 174 | + </div> | |
| 175 | + <div className="flex items-center gap-1.5 text-sm"> | |
| 176 | + <button type="button" onClick={() => setSheet(true)} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-3 text-ink-2 md:hidden"> | |
| 177 | + <SlidersHorizontal size={15} aria-hidden /> {t('traj.controls')} | |
| 178 | + </button> | |
| 179 | + <button type="button" onClick={reset} className="inline-flex h-11 items-center gap-1.5 rounded-sm px-2.5 text-ink-2 hover:bg-surface-2 hover:text-ink md:h-9"> | |
| 180 | + <RotateCcw size={14} aria-hidden /> <span className="hidden sm:inline">{t('traj.reset')}</span> | |
| 181 | + </button> | |
| 182 | + <button type="button" onClick={share} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule px-2.5 text-ink-2 hover:text-ink md:h-9" aria-live="polite"> | |
| 183 | + {copied ? <Check size={14} aria-hidden className="text-up" /> : <Share2 size={14} aria-hidden />} {copied ? t('traj.copied') : t('traj.share')} | |
| 184 | + </button> | |
| 185 | + </div> | |
| 186 | + </header> | |
| 187 | + <div className="hidden space-y-2 pb-3 md:block"> | |
| 188 | + {controls} | |
| 189 | + {follow} | |
| 190 | + </div> | |
| 191 | + </div> | |
| 192 | + | |
| 193 | + <div className={cn('container-x mx-auto w-full max-w-[1400px] flex-1 transition-opacity', loading && 'opacity-60')} aria-busy={loading}> | |
| 194 | + {data === null && !loading ? ( | |
| 195 | + <p className="py-16 text-center text-sm text-ink-3">{t('traj.noData')}</p> | |
| 196 | + ) : data ? ( | |
| 197 | + <> | |
| 198 | + <BubbleChart points={points} xSpec={xSpec} ySpec={ySpec} sizeSpec={sizeSpec} xDomain={data.domains.x} yDomain={data.domains.y} sizeDomain={data.domains.size} logX={data.log_x} logY={data.log_y} highlight={select} trails={trails} onSelect={(id) => id && setSelect(Array.from(new Set([...select, id])).slice(-4))} height={chartH} yearLabel={year} defaultWidth={1100} /> | |
| 199 | + <div className="mt-1 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-2xs text-ink-3"> | |
| 200 | + <span className="tnum">{t('traj.countries', { n: grouped(nYear), y0: years[0] ?? '', y1: years[years.length - 1] ?? '' })}</span> | |
| 201 | + <span className="flex flex-wrap items-center gap-x-3"> | |
| 202 | + <button type="button" onClick={() => openProv({ indicator: { slug: x, name: xSpec.name ?? x, format: xSpec.format, unit: xSpec.unit, frequency: 'A' }, value: null, country: null, downloadHref: routes.indicatorDownload(x) })} className="inline-flex min-h-[32px] items-center hover:text-accent"> | |
| 203 | + {t('common.source')}: {data.provenance.map((p) => p.source_name ?? p.source).filter((v, i, a) => v && a.indexOf(v) === i).join(', ')} | |
| 204 | + </button> | |
| 205 | + <Link href={routes.scatter({ x, y, size: size !== DEFAULT_TRAJ.size ? size : undefined, year, group: group !== 'world' ? group : null })} className="text-accent hover:underline"> | |
| 206 | + {t('traj.openScatter')} → | |
| 207 | + </Link> | |
| 208 | + </span> | |
| 209 | + </div> | |
| 210 | + <p className="mt-1 text-2xs text-ink-3">{t('traj.note')}</p> | |
| 211 | + </> | |
| 212 | + ) : ( | |
| 213 | + <div className="grid min-h-[420px] place-items-center text-sm text-ink-3">{t('common.loading')}</div> | |
| 214 | + )} | |
| 215 | + </div> | |
| 216 | + | |
| 217 | + <div className="sticky bottom-0 z-20 border-t border-rule bg-paper/95 backdrop-blur safe-bottom"> | |
| 218 | + <div className="container-x mx-auto max-w-[1400px] py-2"> | |
| 219 | + <YearSlider years={years} year={year} onChange={setYear} interval={450} onPlayingChange={setPlaying} label={t('control.year')} compact /> | |
| 220 | + </div> | |
| 221 | + </div> | |
| 222 | + | |
| 223 | + <BottomSheet open={sheet} onClose={() => setSheet(false)} side="center" title={t('traj.controls')}> | |
| 224 | + <div className="space-y-4"> | |
| 225 | + {controls} | |
| 226 | + {follow} | |
| 227 | + </div> | |
| 228 | + <div className="mt-6 flex justify-end"> | |
| 229 | + <button type="button" className="tap rounded-sm bg-ink px-4 text-sm font-medium text-paper" onClick={() => setSheet(false)}> | |
| 230 | + {t('common.apply')} | |
| 231 | + </button> | |
| 232 | + </div> | |
| 233 | + </BottomSheet> | |
| 234 | + </div> | |
| 235 | + ); | |
| 236 | +} | |
added
apps/web/src/components/explorer/use-media.ts
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { useEffect, useState } from 'react'; | |
| 3 | + | |
| 4 | +/** True at ≥ 768 px (Tailwind `md`). False during SSR and the first client render (mobile-first). */ | |
| 5 | +export function useIsDesktop(): boolean { | |
| 6 | + const [desktop, setDesktop] = useState(false); | |
| 7 | + useEffect(() => { | |
| 8 | + const mq = window.matchMedia('(min-width: 768px)'); | |
| 9 | + const apply = () => setDesktop(mq.matches); | |
| 10 | + apply(); | |
| 11 | + mq.addEventListener('change', apply); | |
| 12 | + return () => mq.removeEventListener('change', apply); | |
| 13 | + }, []); | |
| 14 | + return desktop; | |
| 15 | +} | |
added
apps/web/src/components/explorer/world-explorer.tsx
+343 −0
@@ -0,0 +1,343 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { BarChart3, ChevronDown, ChevronUp, Globe2, Info, LineChart as LineIcon, Sigma, Table2 } from 'lucide-react'; | |
| 3 | +import { useRouter } from 'next/navigation'; | |
| 4 | +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; | |
| 5 | +import { t } from '@/i18n'; | |
| 6 | +import { clientAnalytics } from '@/lib/client-api-analytics'; | |
| 7 | +import { cn } from '@/lib/cn'; | |
| 8 | +import { formatValue, grouped } from '@/lib/format'; | |
| 9 | +import { routes } from '@/lib/site'; | |
| 10 | +import { useUrlState } from '@/lib/url-state'; | |
| 11 | +import type { FormatSpec } from '@/lib/types'; | |
| 12 | +import type { FramesResponse, PointCountry } from '@/lib/types-analytics'; | |
| 13 | +import type { RegionItem } from '@/lib/types-explore'; | |
| 14 | +import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select'; | |
| 15 | +import { YearSlider } from '@/components/controls/year-slider'; | |
| 16 | +import { BottomSheet } from '@/components/data/bottom-sheet'; | |
| 17 | +import { EntityPicker } from '@/components/explore/entity-picker'; | |
| 18 | +import type { BaseFeature } from '@/components/indicators/indicator-map'; | |
| 19 | +import { useProvenance } from '@/components/data/provenance-context'; | |
| 20 | +import { classIndex } from './geo'; | |
| 21 | +import { DEFAULT_EXPLORER_INDICATOR, type ExplorerView } from './options'; | |
| 22 | +import { useIsDesktop } from './use-media'; | |
| 23 | +import { MapCanvas, type MapHover } from './map-canvas'; | |
| 24 | +import { ClassLegend, CountryPanel, MapTooltip, type CountryYearInfo } from './explorer-panels'; | |
| 25 | +import { DistributionView, RankView, TrendView, type YearRow } from './explorer-views'; | |
| 26 | + | |
| 27 | +const QUICK = ['population', 'gdp', 'gdp-per-capita-ppp', 'gdp-growth', 'inflation', 'life-expectancy', 'fertility-rate', 'internet-users', 'co2-per-capita', 'renewable-electricity-share', 'unemployment-rate', 'population-growth']; | |
| 28 | + | |
| 29 | +export interface ExplorerProps { | |
| 30 | + features: BaseFeature[]; | |
| 31 | + sphere: string; | |
| 32 | + countries: PointCountry[]; | |
| 33 | + indicators: IndicatorOption[]; | |
| 34 | + groups: RegionItem[]; | |
| 35 | + initial: FramesResponse | null; | |
| 36 | + initialState: { indicator: string; year: number | null; view: ExplorerView; country: string | null; group: string }; | |
| 37 | +} | |
| 38 | + | |
| 39 | +function specOf(fr: FramesResponse | null, fallbackName: string): FormatSpec { | |
| 40 | + const i = fr?.indicator; | |
| 41 | + return { format: i?.format ?? 'number', unit: i?.unit, unit_short: i?.unit_short, precision: i?.precision, frequency: 'A', name: i?.short_name ?? i?.name ?? fallbackName, higher_is_better: i?.higher_is_better }; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** | |
| 45 | + * World Explorer: indicator × year × view, all in the URL. One /frames request per indicator (cached in state) | |
| 46 | + * feeds the map, the rank list, the trend and the distribution — no further requests while scrubbing. | |
| 47 | + */ | |
| 48 | +export function WorldExplorer({ features, sphere, countries, indicators, groups, initial, initialState }: ExplorerProps) { | |
| 49 | + const router = useRouter(); | |
| 50 | + const { get, getNum, set } = useUrlState(); | |
| 51 | + const { open: openProv } = useProvenance(); | |
| 52 | + const indicator = get('indicator') ?? initialState.indicator; | |
| 53 | + const group = get('group') ?? initialState.group; | |
| 54 | + const view = ((get('view') as ExplorerView | null) ?? initialState.view) as ExplorerView; | |
| 55 | + const selectedId = (get('country') ?? initialState.country)?.toUpperCase() ?? null; | |
| 56 | + const cacheKey = `${indicator}|${group}`; | |
| 57 | + const [cache, setCache] = useState<Record<string, FramesResponse | null>>(() => (initial ? { [`${initialState.indicator}|${initialState.group}`]: initial } : {})); | |
| 58 | + const [loading, setLoading] = useState(false); | |
| 59 | + const [hover, setHover] = useState<MapHover | null>(null); | |
| 60 | + const [focus, setFocus] = useState<string | null>(null); | |
| 61 | + const [sheet, setSheet] = useState<'country' | 'table' | null>(null); | |
| 62 | + const [legendOpen, setLegendOpen] = useState(false); | |
| 63 | + const [playing, setPlaying] = useState(false); | |
| 64 | + const abort = useRef<AbortController | null>(null); | |
| 65 | + const desktop = useIsDesktop(); | |
| 66 | + | |
| 67 | + useEffect(() => { | |
| 68 | + if (cache[cacheKey] !== undefined) return; | |
| 69 | + abort.current?.abort(); | |
| 70 | + const ctrl = new AbortController(); | |
| 71 | + abort.current = ctrl; | |
| 72 | + setLoading(true); | |
| 73 | + clientAnalytics | |
| 74 | + .indicatorFrames(indicator, { group: group !== 'world' ? group : null }, ctrl.signal) | |
| 75 | + .then((r) => setCache((c) => ({ ...c, [cacheKey]: r }))) | |
| 76 | + .catch((e) => { | |
| 77 | + if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [cacheKey]: null })); | |
| 78 | + }) | |
| 79 | + .finally(() => { | |
| 80 | + if (!ctrl.signal.aborted) setLoading(false); | |
| 81 | + }); | |
| 82 | + return () => ctrl.abort(); | |
| 83 | + }, [cacheKey, indicator, group, cache]); | |
| 84 | + | |
| 85 | + const frames = cache[cacheKey] ?? null; | |
| 86 | + const years = frames?.years ?? []; | |
| 87 | + const urlYear = getNum('year') ?? initialState.year; | |
| 88 | + const year = years.length ? (urlYear != null && years.includes(urlYear) ? urlYear : years[years.length - 1]!) : (urlYear ?? new Date().getUTCFullYear()); | |
| 89 | + const yi = years.indexOf(year); | |
| 90 | + const spec = useMemo(() => specOf(frames, indicators.find((i) => i.slug === indicator)?.name ?? indicator), [frames, indicators, indicator]); | |
| 91 | + const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]); | |
| 92 | + const breaks = frames?.legend.breaks ?? []; | |
| 93 | + const k = breaks.length + 1; | |
| 94 | + | |
| 95 | + /** Values of the current year, ranks (world + region) and the class per country. */ | |
| 96 | + const yearData = useMemo(() => { | |
| 97 | + const rows: YearRow[] = []; | |
| 98 | + const cls = new Map<string, number | null>(); | |
| 99 | + if (!frames || yi < 0) return { rows, cls, rankOf: new Map<string, { world: number; region: number | null; nRegion: number }>() }; | |
| 100 | + for (const [iso, arr] of Object.entries(frames.values)) { | |
| 101 | + const v = arr[yi]; | |
| 102 | + const c = byId.get(iso); | |
| 103 | + if (v != null && Number.isFinite(v) && c) rows.push({ country: c, value: v, rank: 0 }); | |
| 104 | + cls.set(iso, v != null && Number.isFinite(v) ? classIndex(v, breaks) : null); | |
| 105 | + } | |
| 106 | + const desc = spec.higher_is_better !== false; | |
| 107 | + rows.sort((a, b) => (desc ? b.value - a.value : a.value - b.value)); | |
| 108 | + const rankOf = new Map<string, { world: number; region: number | null; nRegion: number }>(); | |
| 109 | + const regionCount = new Map<string, number>(); | |
| 110 | + rows.forEach((r, i) => { | |
| 111 | + r.rank = i + 1; | |
| 112 | + const reg = r.country.region ?? ''; | |
| 113 | + const rr = (regionCount.get(reg) ?? 0) + 1; | |
| 114 | + regionCount.set(reg, rr); | |
| 115 | + rankOf.set(r.country.id, { world: i + 1, region: reg ? rr : null, nRegion: 0 }); | |
| 116 | + }); | |
| 117 | + for (const [iso, info] of rankOf) info.nRegion = regionCount.get(byId.get(iso)?.region ?? '') ?? 0; | |
| 118 | + return { rows, cls, rankOf }; | |
| 119 | + }, [frames, yi, byId, breaks, spec.higher_is_better]); | |
| 120 | + | |
| 121 | + const infoFor = useCallback( | |
| 122 | + (iso: string): CountryYearInfo | null => { | |
| 123 | + const c = byId.get(iso); | |
| 124 | + if (!c) return null; | |
| 125 | + const arr = frames?.values[iso] ?? []; | |
| 126 | + const series = years.map((y, i) => ({ period: `${y}-01-01`, year: y, value: arr[i] ?? null })).filter((p) => p.value != null); | |
| 127 | + const rk = yearData.rankOf.get(iso); | |
| 128 | + const last = series[series.length - 1]; | |
| 129 | + return { | |
| 130 | + country: c, | |
| 131 | + value: yi >= 0 ? arr[yi] ?? null : null, | |
| 132 | + year, | |
| 133 | + rankWorld: rk?.world ?? null, | |
| 134 | + nWorld: yearData.rows.length, | |
| 135 | + rankRegion: rk?.region ?? null, | |
| 136 | + nRegion: rk?.nRegion ?? 0, | |
| 137 | + series, | |
| 138 | + firstYear: series[0]?.year ?? null, | |
| 139 | + lastYear: last?.year ?? null, | |
| 140 | + latestValue: last?.value ?? null, | |
| 141 | + latestYear: last?.year ?? null, | |
| 142 | + }; | |
| 143 | + }, | |
| 144 | + [byId, frames, years, yearData, yi, year], | |
| 145 | + ); | |
| 146 | + | |
| 147 | + const selected = selectedId ? infoFor(selectedId) : null; | |
| 148 | + const hoverInfo = hover ? infoFor(hover.iso3) : null; | |
| 149 | + const indicatorName = frames?.indicator.name ?? spec.name ?? indicator; | |
| 150 | + | |
| 151 | + const setIndicator = (slug: string) => set({ indicator: slug === DEFAULT_EXPLORER_INDICATOR ? null : slug }, 0); | |
| 152 | + const setYear = (y: number) => set({ year: years.length && y === years[years.length - 1] ? null : y }, playing ? 250 : 80); | |
| 153 | + const setView = (v: ExplorerView) => set({ view: v === 'map' ? null : v }, 0); | |
| 154 | + const selectCountry = (iso: string | null) => { | |
| 155 | + set({ country: iso ? iso.toLowerCase() : null }, 0); | |
| 156 | + if (iso) setSheet('country'); | |
| 157 | + else setSheet(null); | |
| 158 | + }; | |
| 159 | + const openCountry = (iso: string) => { | |
| 160 | + const c = byId.get(iso); | |
| 161 | + if (c?.slug) router.push(routes.country(c.slug)); | |
| 162 | + }; | |
| 163 | + const flyTo = (iso: string) => { | |
| 164 | + setFocus(null); | |
| 165 | + requestAnimationFrame(() => setFocus(iso)); | |
| 166 | + selectCountry(iso); | |
| 167 | + }; | |
| 168 | + | |
| 169 | + const viewOptions = [ | |
| 170 | + { value: 'map' as const, label: t('explorer.view.map'), icon: <Globe2 size={14} aria-hidden /> }, | |
| 171 | + { value: 'rank' as const, label: t('explorer.view.rank'), icon: <BarChart3 size={14} aria-hidden /> }, | |
| 172 | + { value: 'trend' as const, label: t('explorer.view.trend'), icon: <LineIcon size={14} aria-hidden /> }, | |
| 173 | + { value: 'distribution' as const, label: t('explorer.view.distribution'), icon: <Sigma size={14} aria-hidden /> }, | |
| 174 | + ]; | |
| 175 | + const groupOptions = useMemo(() => [{ slug: 'world', name: t('common.world'), kind: 'world' }, ...groups.filter((g) => g.kind === 'region' || g.kind === 'income' || g.kind === 'continent' || g.kind === 'org').map((g) => ({ slug: g.slug ?? g.id, name: g.name ?? g.id, kind: g.kind ?? 'org' }))], [groups]); | |
| 176 | + const nYear = yearData.rows.length; | |
| 177 | + const compareHref = selected ? routes.compare(selected.country.slug ?? selected.country.id.toLowerCase()) : routes.compare(); | |
| 178 | + const trajHref = selected ? routes.trajectories({ year, group: group !== 'world' ? group : null }) + `&select=${selected.country.id}` : routes.trajectories(); | |
| 179 | + | |
| 180 | + const provPayload = frames | |
| 181 | + ? { | |
| 182 | + indicator: { slug: indicator, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A' as const, higher_is_better: spec.higher_is_better }, | |
| 183 | + value: null, | |
| 184 | + country: null, | |
| 185 | + downloadHref: routes.indicatorDownload(indicator), | |
| 186 | + } | |
| 187 | + : null; | |
| 188 | + | |
| 189 | + const countryPanel = selected ? <CountryPanel info={selected} spec={spec} indicatorSlug={indicator} indicatorName={indicatorName} provenance={frames?.provenance ?? null} onClose={() => selectCountry(null)} onCompareHref={compareHref} trajectoriesHref={trajHref} closeClassName="hidden md:grid" /> : null; | |
| 190 | + | |
| 191 | + const legend = frames ? <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} /> : null; | |
| 192 | + | |
| 193 | + return ( | |
| 194 | + <div className="flex h-[calc(100dvh-52px)] min-h-[520px] flex-col bg-paper md:h-[calc(100dvh-56px)] md:flex-row" data-testid="world-explorer"> | |
| 195 | + {/* Left rail (desktop) */} | |
| 196 | + <aside className="hidden w-[19rem] shrink-0 flex-col border-r border-rule md:flex" aria-label={t('explorer.rail')}> | |
| 197 | + <div className="space-y-3 p-4"> | |
| 198 | + <div> | |
| 199 | + <h1 className="display text-xl text-ink">{t('explorer.title')}</h1> | |
| 200 | + <p className="mt-0.5 text-xs text-ink-3">{t('explorer.lede')}</p> | |
| 201 | + </div> | |
| 202 | + <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} label={t('control.indicator')} size="sm" /> | |
| 203 | + <label className="flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2"> | |
| 204 | + <span className="text-2xs uppercase tracking-wide text-ink-3">{t('explorer.group')}</span> | |
| 205 | + <select value={group} onChange={(e) => set({ group: e.target.value === 'world' ? null : e.target.value }, 0)} className="min-w-0 flex-1 truncate bg-transparent text-ink outline-none" aria-label={t('explorer.group')}> | |
| 206 | + {groupOptions.map((g) => ( | |
| 207 | + <option key={g.slug} value={g.slug}> | |
| 208 | + {g.name} | |
| 209 | + </option> | |
| 210 | + ))} | |
| 211 | + </select> | |
| 212 | + </label> | |
| 213 | + <EntityPicker type="country" placeholder={t('explorer.searchCountry')} onPick={(e) => flyTo(e.id)} size="sm" /> | |
| 214 | + </div> | |
| 215 | + <div className="min-h-0 flex-1 overflow-y-auto border-t border-rule px-4 py-3"> | |
| 216 | + <div className="eyebrow mb-1.5">{t('explorer.quick')}</div> | |
| 217 | + <ul className="flex flex-wrap gap-1"> | |
| 218 | + {QUICK.filter((q) => indicators.some((i) => i.slug === q)).map((q) => { | |
| 219 | + const o = indicators.find((i) => i.slug === q)!; | |
| 220 | + return ( | |
| 221 | + <li key={q}> | |
| 222 | + <button type="button" onClick={() => setIndicator(q)} className={cn('inline-flex h-8 items-center rounded-sm border px-2 text-xs', indicator === q ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}> | |
| 223 | + {o.short_name ?? o.name} | |
| 224 | + </button> | |
| 225 | + </li> | |
| 226 | + ); | |
| 227 | + })} | |
| 228 | + </ul> | |
| 229 | + {frames ? ( | |
| 230 | + <div className="mt-5"> | |
| 231 | + <div className="eyebrow mb-1.5">{t('explorer.legendTitle', { name: spec.name ?? '' })}</div> | |
| 232 | + <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} className="flex-col items-start gap-y-1.5 [&>li]:text-xs" /> | |
| 233 | + <p className="mt-2 text-2xs text-ink-3">{t('explorer.legendNote')}</p> | |
| 234 | + </div> | |
| 235 | + ) : null} | |
| 236 | + {frames?.provenance ? ( | |
| 237 | + <button type="button" onClick={() => provPayload && openProv(provPayload)} className="mt-4 inline-flex min-h-[32px] items-center gap-1 text-left text-2xs text-ink-2 hover:text-accent" aria-label={t('common.openProvenance')}> | |
| 238 | + <Info size={12} aria-hidden className="shrink-0" /> | |
| 239 | + <span className="truncate"> | |
| 240 | + {t('common.source')}: {[frames.provenance.source_name, frames.provenance.dataset].filter(Boolean).join(' — ')} · {frames.provenance.series_code} | |
| 241 | + </span> | |
| 242 | + </button> | |
| 243 | + ) : null} | |
| 244 | + </div> | |
| 245 | + </aside> | |
| 246 | + | |
| 247 | + {/* Main column */} | |
| 248 | + <div className="relative flex min-h-0 min-w-0 flex-1 flex-col"> | |
| 249 | + {/* Top bar */} | |
| 250 | + <div className="flex items-center gap-2 border-b border-rule px-3 py-2 md:px-4"> | |
| 251 | + <div className="min-w-0 flex-1 md:hidden"> | |
| 252 | + <IndicatorSelect options={indicators} value={indicator} onChange={setIndicator} size="sm" /> | |
| 253 | + </div> | |
| 254 | + <div className="hidden min-w-0 flex-1 items-baseline gap-2 md:flex"> | |
| 255 | + <h2 className="truncate text-base font-semibold text-ink">{indicatorName}</h2> | |
| 256 | + <span className="tnum shrink-0 text-xs text-ink-3">{frames ? t('explorer.countriesYear', { n: grouped(nYear), year }) : loading ? t('common.loading') : ''}</span> | |
| 257 | + </div> | |
| 258 | + <Segmented value={view} onChange={setView} options={viewOptions.map((o) => ({ ...o, label: o.label }))} label={t('control.view')} size="sm" className="shrink-0 [&>button]:h-11 md:[&>button]:h-8 [&_span]:hidden sm:[&_span]:inline" /> | |
| 259 | + </div> | |
| 260 | + | |
| 261 | + {/* Stage */} | |
| 262 | + <div className={cn('relative min-h-0 flex-1', view !== 'map' && 'overflow-y-auto')} aria-busy={loading}> | |
| 263 | + {frames === null && !loading ? ( | |
| 264 | + <div className="grid h-full place-items-center px-6 text-center text-sm text-ink-3">{t('explorer.noFrames', { indicator: indicatorName })}</div> | |
| 265 | + ) : view === 'map' ? ( | |
| 266 | + <> | |
| 267 | + <MapCanvas features={features} sphere={sphere} classOf={yearData.cls} k={k} selectedId={selectedId} focusId={focus} onHover={setHover} onSelect={(iso) => selectCountry(iso)} onOpen={openCountry} labelOf={(iso) => `${byId.get(iso)?.name ?? iso}: ${formatValue(frames?.values[iso]?.[yi] ?? null, spec)} (${year})`} className={cn(loading && 'opacity-60 transition-opacity')} /> | |
| 268 | + {hover && hoverInfo && !(sheet === 'country' && selectedId === hover.iso3) ? <MapTooltip info={hoverInfo} spec={spec} x={hover.x} y={hover.y} sticky={hover.sticky} onOpen={() => selectCountry(hover.iso3)} /> : null} | |
| 269 | + {/* Year watermark + count (map only) */} | |
| 270 | + <div className="pointer-events-none absolute left-3 top-2 md:left-4"> | |
| 271 | + <div className="display tnum text-4xl leading-none text-ink/80 md:text-6xl">{year}</div> | |
| 272 | + <div className="tnum mt-1 text-2xs text-ink-3 md:text-xs">{t('explorer.countriesYear', { n: grouped(nYear), year })}</div> | |
| 273 | + </div> | |
| 274 | + {/* Mobile legend (collapsible) */} | |
| 275 | + {frames ? ( | |
| 276 | + <div className="absolute bottom-2 left-2 right-14 md:hidden"> | |
| 277 | + <button type="button" onClick={() => setLegendOpen((o) => !o)} aria-expanded={legendOpen} className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-xs text-ink-2 shadow-pop"> | |
| 278 | + {legendOpen ? <ChevronDown size={14} aria-hidden /> : <ChevronUp size={14} aria-hidden />} | |
| 279 | + {t('common.legend')} | |
| 280 | + </button> | |
| 281 | + {legendOpen ? ( | |
| 282 | + <div className="mt-1 rounded-sm border border-rule bg-surface/95 p-2 shadow-pop"> | |
| 283 | + <ClassLegend breaks={breaks} min={frames.legend.min} max={frames.legend.max} spec={spec} k={k} compact /> | |
| 284 | + </div> | |
| 285 | + ) : null} | |
| 286 | + </div> | |
| 287 | + ) : null} | |
| 288 | + <button type="button" onClick={() => setSheet('table')} className="absolute right-3 top-2 hidden h-8 items-center gap-1 rounded-sm border border-rule bg-surface/95 px-2 text-2xs text-ink-2 shadow-pop hover:text-ink md:inline-flex" aria-label={t('common.viewTable')}> | |
| 289 | + <Table2 size={12} aria-hidden /> {t('common.viewTable')} | |
| 290 | + </button> | |
| 291 | + </> | |
| 292 | + ) : view === 'rank' ? ( | |
| 293 | + <RankView rows={yearData.rows} year={year} spec={spec} selectedId={selectedId} indicatorSlug={indicator} /> | |
| 294 | + ) : view === 'trend' ? ( | |
| 295 | + <TrendView years={years} values={frames?.values ?? {}} spec={spec} selected={selected ? { country: selected.country, series: frames?.values[selected.country.id] ?? [] } : null} subject={indicatorName} /> | |
| 296 | + ) : ( | |
| 297 | + <DistributionView rows={yearData.rows} year={year} spec={spec} selected={selected ? { country: selected.country, value: selected.value } : null} /> | |
| 298 | + )} | |
| 299 | + </div> | |
| 300 | + | |
| 301 | + {/* Time machine */} | |
| 302 | + <div className="border-t border-rule bg-paper px-3 py-2 md:px-4 safe-bottom"> | |
| 303 | + <YearSlider years={years} year={year} onChange={setYear} compact interval={650} onPlayingChange={setPlaying} label={t('explorer.timeMachine')} /> | |
| 304 | + </div> | |
| 305 | + | |
| 306 | + {/* Desktop drawer */} | |
| 307 | + {selected ? ( | |
| 308 | + <div className="absolute right-0 top-[45px] bottom-[57px] z-20 hidden w-[22rem] border-l border-rule bg-surface/95 p-4 shadow-pop backdrop-blur md:block">{countryPanel}</div> | |
| 309 | + ) : null} | |
| 310 | + </div> | |
| 311 | + | |
| 312 | + {/* Phone sheets */} | |
| 313 | + <BottomSheet open={!desktop && sheet === 'country' && !!selected} onClose={() => setSheet(null)} side="drawer" title={<span className="sr-only">{selected?.country.name}</span>}> | |
| 314 | + {countryPanel} | |
| 315 | + </BottomSheet> | |
| 316 | + <BottomSheet open={sheet === 'table'} onClose={() => setSheet(null)} side="drawer" title={t('explorer.table.title', { name: spec.name ?? '', year })}> | |
| 317 | + <table className="w-full text-sm tnum"> | |
| 318 | + <caption className="sr-only">{t('explorer.table.title', { name: spec.name ?? '', year })}</caption> | |
| 319 | + <thead> | |
| 320 | + <tr className="border-b border-rule text-left text-2xs uppercase tracking-wide text-ink-3"> | |
| 321 | + <th scope="col" className="py-1 pr-2 font-medium">#</th> | |
| 322 | + <th scope="col" className="py-1 pr-2 font-medium">{t('common.country')}</th> | |
| 323 | + <th scope="col" className="py-1 text-right font-medium">{spec.name}</th> | |
| 324 | + </tr> | |
| 325 | + </thead> | |
| 326 | + <tbody className="divide-y divide-rule"> | |
| 327 | + {yearData.rows.map((r) => ( | |
| 328 | + <tr key={r.country.id} className={cn(r.country.id === selectedId && 'bg-accent-soft/50')}> | |
| 329 | + <td className="py-1 pr-2 text-ink-3">{r.rank}</td> | |
| 330 | + <td className="py-1 pr-2 text-ink"> | |
| 331 | + <span aria-hidden>{r.country.flag} </span> | |
| 332 | + {r.country.name} | |
| 333 | + </td> | |
| 334 | + <td className="py-1 text-right text-ink">{formatValue(r.value, spec)}</td> | |
| 335 | + </tr> | |
| 336 | + ))} | |
| 337 | + </tbody> | |
| 338 | + </table> | |
| 339 | + {legend ? <div className="mt-3">{legend}</div> : null} | |
| 340 | + </BottomSheet> | |
| 341 | + </div> | |
| 342 | + ); | |
| 343 | +} | |
modified
apps/web/src/i18n/en.flagship.ts
+164 −0
@@ -3,6 +3,170 @@ | ||
| 3 | 3 | * Merged into `en.ts`. Owned by the "flagship" stream. |
| 4 | 4 | */ |
| 5 | 5 | export const enFlagship = { |
| 6 | + // --- world explorer | |
| 6 | 7 | 'explorer.title': 'World Explorer', |
| 8 | + 'explorer.metaTitle': 'World Explorer — Interactive Map of Every Indicator, 1960 to Today', | |
| 7 | 9 | 'explorer.description': 'Full-screen interactive world map of any indicator, with a year slider from 1960 to today. Google Earth for statistics.', |
| 10 | + 'explorer.lede': 'Pick an indicator, scrub through time, tap a country.', | |
| 11 | + 'explorer.rail': 'Explorer controls', | |
| 12 | + 'explorer.group': 'Countries', | |
| 13 | + 'explorer.searchCountry': 'Find a country on the map…', | |
| 14 | + 'explorer.quick': 'Quick picks', | |
| 15 | + 'explorer.legendTitle': 'Legend · {name}', | |
| 16 | + 'explorer.legendNote': 'Quantile classes over all years, so colours stay comparable while you scrub.', | |
| 17 | + 'explorer.countriesYear': '{n} countries · {year}', | |
| 18 | + 'explorer.noValueYear': 'No observation for {year}', | |
| 19 | + 'explorer.noDataYear': 'No observations exist for {indicator} in {year}.', | |
| 20 | + 'explorer.noFrames': 'No annual series is available for {indicator}.', | |
| 21 | + 'explorer.timeMachine': 'Time machine', | |
| 22 | + 'explorer.map.aria': 'World map coloured by the selected indicator; use the table view for the values.', | |
| 23 | + 'explorer.map.zoom': 'Map zoom', | |
| 24 | + 'explorer.map.zoomIn': 'Zoom in', | |
| 25 | + 'explorer.map.zoomOut': 'Zoom out', | |
| 26 | + 'explorer.map.reset': 'Reset the map view', | |
| 27 | + 'explorer.view.map': 'Map', | |
| 28 | + 'explorer.view.rank': 'Rank', | |
| 29 | + 'explorer.view.trend': 'Trend', | |
| 30 | + 'explorer.view.distribution': 'Distribution', | |
| 31 | + 'explorer.rank.world': '{rank} of {n} worldwide', | |
| 32 | + 'explorer.rank.region': '{rank} in {region}', | |
| 33 | + 'explorer.rank.sub': '{n} countries with a value in {year}. Rank 1 = highest, or best when the indicator has a direction.', | |
| 34 | + 'explorer.rank.full': 'Full ranking', | |
| 35 | + 'explorer.trend.title': '{name} — world median', | |
| 36 | + 'explorer.trend.sub': 'Countries per year: {min}–{max}', | |
| 37 | + 'explorer.trend.median': 'World median', | |
| 38 | + 'explorer.trend.note': 'Computed by CountryAtlas from country values in this snapshot, not a published aggregate.', | |
| 39 | + 'explorer.dist.title': '{name}, {year}', | |
| 40 | + 'explorer.dist.sub': '{n} countries · median {median}', | |
| 41 | + 'explorer.drawer.details': 'Details', | |
| 42 | + 'explorer.drawer.latest': 'latest {value} ({year})', | |
| 43 | + 'explorer.drawer.history': 'History {y0}–{y1}', | |
| 44 | + 'explorer.drawer.sparkAria': '{name}: {indicator} over time', | |
| 45 | + 'explorer.drawer.open': 'Open country page', | |
| 46 | + 'explorer.drawer.compare': 'Compare', | |
| 47 | + 'explorer.drawer.trajectories': 'Add to trajectories', | |
| 48 | + 'explorer.drawer.series': 'Full series with sources', | |
| 49 | + 'explorer.table.title': '{name}, {year} — all countries', | |
| 50 | + | |
| 51 | + // --- trajectories | |
| 52 | + 'traj.title': 'Trajectories', | |
| 53 | + 'traj.metaTitle': 'Country Trajectories — Animated Bubble Chart, 1960 to Today', | |
| 54 | + 'traj.description': 'Gapminder-style animation: pick two indicators and a bubble size, press play and watch every country move from 1960 to today.', | |
| 55 | + 'traj.lede': 'Two indicators, one bubble per country, sixty years in motion.', | |
| 56 | + 'traj.x': 'X axis', | |
| 57 | + 'traj.y': 'Y axis', | |
| 58 | + 'traj.size': 'Bubble size', | |
| 59 | + 'traj.sizeNone': 'Same size', | |
| 60 | + 'traj.group': 'Countries', | |
| 61 | + 'traj.select': 'Follow countries', | |
| 62 | + 'traj.selectHint': 'Up to 4 countries draw a trail.', | |
| 63 | + 'traj.selected': 'Following', | |
| 64 | + 'traj.reset': 'Reset', | |
| 65 | + 'traj.share': 'Share', | |
| 66 | + 'traj.copied': 'Link copied', | |
| 67 | + 'traj.controls': 'Chart controls', | |
| 68 | + 'traj.countries': '{n} countries · {y0}–{y1}', | |
| 69 | + 'traj.noData': 'Not enough countries report both indicators over the same years.', | |
| 70 | + 'traj.remove': 'Stop following {name}', | |
| 71 | + 'traj.note': 'Positions are the values of each year (no interpolation); a bubble disappears when a year is missing. Colours are World Bank regions.', | |
| 72 | + 'traj.openScatter': 'Open this year as a scatter with statistics', | |
| 73 | + | |
| 74 | + // --- scatter | |
| 75 | + 'scatter.title': 'Scatter explorer', | |
| 76 | + 'scatter.metaTitle': 'Scatter Explorer — Compare Any Two Indicators Across Countries', | |
| 77 | + 'scatter.description': 'Plot any indicator against any other for every country, with Pearson and Spearman correlations. Descriptive, not causal.', | |
| 78 | + 'scatter.lede': 'Any indicator against any other, one dot per country.', | |
| 79 | + 'scatter.year': 'Year', | |
| 80 | + 'scatter.fit': 'Regression line', | |
| 81 | + 'scatter.logX': 'Log x', | |
| 82 | + 'scatter.logY': 'Log y', | |
| 83 | + 'scatter.stats.pearson': 'Pearson r', | |
| 84 | + 'scatter.stats.spearman': 'Spearman ρ', | |
| 85 | + 'scatter.stats.n': 'Countries', | |
| 86 | + 'scatter.stats.r2': 'R²', | |
| 87 | + 'scatter.stats.year': 'Year used', | |
| 88 | + 'scatter.caveat': 'Correlation does not imply causation. Descriptive only.', | |
| 89 | + 'scatter.nearest': 'Each axis uses the country’s value for {year} or the nearest year within {n}.', | |
| 90 | + 'scatter.related': 'Related to {name}', | |
| 91 | + 'scatter.relatedHint': 'Strongest cross-sectional associations — tap one to put it on the Y axis.', | |
| 92 | + 'scatter.noData': 'No country reports both indicators for {year}.', | |
| 93 | + 'scatter.openTrajectories': 'Animate over time', | |
| 94 | + 'scatter.drawer.both': '{x}: {vx} ({yx}) · {y}: {vy} ({yy})', | |
| 95 | + 'scatter.selected': 'Selected country', | |
| 96 | + | |
| 97 | + // --- finder | |
| 98 | + 'finder.title': 'Country finder', | |
| 99 | + 'finder.metaTitle': 'Country Finder — Filter the World Like a Database', | |
| 100 | + 'finder.description': 'Structured questions without AI: countries with GDP per capita above 40,000, population above 10 million, life expectancy above 80… combine filters and get a map, a table and a download.', | |
| 101 | + 'finder.lede': 'Countries with… combine conditions and see who matches.', | |
| 102 | + 'finder.add': 'Add a condition', | |
| 103 | + 'finder.indicator': 'Indicator', | |
| 104 | + 'finder.op': 'Condition', | |
| 105 | + 'finder.value': 'Value', | |
| 106 | + 'finder.value2': 'and', | |
| 107 | + 'finder.remove': 'Remove condition', | |
| 108 | + 'finder.mode': 'Match', | |
| 109 | + 'finder.mode.and': 'All conditions', | |
| 110 | + 'finder.mode.or': 'Any condition', | |
| 111 | + 'finder.region': 'Region', | |
| 112 | + 'finder.income': 'Income group', | |
| 113 | + 'finder.any': 'Any', | |
| 114 | + 'finder.op.gt': 'greater than', | |
| 115 | + 'finder.op.gte': 'at least', | |
| 116 | + 'finder.op.lt': 'less than', | |
| 117 | + 'finder.op.lte': 'at most', | |
| 118 | + 'finder.op.eq': 'equal to', | |
| 119 | + 'finder.op.between': 'between', | |
| 120 | + 'finder.presets': 'Try a question', | |
| 121 | + 'finder.preset.rich-large': 'Rich and large', | |
| 122 | + 'finder.preset.rich-large.hint': 'GDP per capita above US$40,000 and more than 10 million people', | |
| 123 | + 'finder.preset.green-connected': 'Green and connected', | |
| 124 | + 'finder.preset.green-connected.hint': 'Renewables above 50 % of electricity and internet use above 80 %', | |
| 125 | + 'finder.preset.ageing': 'Ageing societies', | |
| 126 | + 'finder.preset.ageing.hint': 'Median age above 42 years', | |
| 127 | + 'finder.preset.young-growing': 'Young and fast-growing', | |
| 128 | + 'finder.preset.young-growing.hint': 'Median age below 25 and population growth above 2 % a year', | |
| 129 | + 'finder.preset.long-lives-low-co2': 'Long lives, low emissions', | |
| 130 | + 'finder.preset.long-lives-low-co2.hint': 'Life expectancy above 80 years and CO₂ below 5 tonnes per person', | |
| 131 | + 'finder.results': '{n} of {m} countries match', | |
| 132 | + 'finder.none': 'No country matches every condition. Loosen a threshold or switch to “any condition”.', | |
| 133 | + 'finder.empty': 'Add a condition to start. Values compare against each country’s latest observation.', | |
| 134 | + 'finder.download': 'Download CSV', | |
| 135 | + 'finder.compare': 'Compare the top {n}', | |
| 136 | + 'finder.map': 'Matching countries', | |
| 137 | + 'finder.table.country': 'Country', | |
| 138 | + 'finder.sort': 'Sort by {name}', | |
| 139 | + 'finder.latestNote': 'Latest available year per country (shown next to each value).', | |
| 140 | + 'finder.loading': 'Searching…', | |
| 141 | + 'finder.error': 'The finder is unavailable right now.', | |
| 142 | + 'finder.unit': 'Unit: {unit}', | |
| 143 | + | |
| 144 | + // --- extremes | |
| 145 | + 'extremes.title': 'Extremes', | |
| 146 | + 'extremes.metaTitle': 'Extremes — Fastest Changing Countries Over 1, 5, 10 and 25 Years', | |
| 147 | + 'extremes.description': 'The largest increases and decreases in the world: fastest ageing, fastest urbanising, biggest life-expectancy gains, largest CO₂ reductions and more.', | |
| 148 | + 'extremes.lede': 'The largest moves in the world, by window.', | |
| 149 | + 'extremes.window': 'Window', | |
| 150 | + 'extremes.window.1': '1 year', | |
| 151 | + 'extremes.window.5': '5 years', | |
| 152 | + 'extremes.window.10': '10 years', | |
| 153 | + 'extremes.window.25': '25 years', | |
| 154 | + 'extremes.window.since1990': 'Since 1990', | |
| 155 | + 'extremes.topic': 'Topic', | |
| 156 | + 'extremes.allTopics': 'All topics', | |
| 157 | + 'extremes.minPop': 'Countries above 1M inhabitants', | |
| 158 | + 'extremes.allCountries': 'All countries and territories', | |
| 159 | + 'extremes.facets': '{n} facets · {y0}–{y1}', | |
| 160 | + 'extremes.direction.up': 'Largest increases', | |
| 161 | + 'extremes.direction.down': 'Largest decreases', | |
| 162 | + 'extremes.n': '{n} countries evaluated', | |
| 163 | + 'extremes.change.abs': 'change', | |
| 164 | + 'extremes.change.pct': '% change', | |
| 165 | + 'extremes.change.points': 'points', | |
| 166 | + 'extremes.openRanking': 'Open ranking', | |
| 167 | + 'extremes.explore': 'Explore on the map', | |
| 168 | + 'extremes.none': 'No facet has enough countries for this window and topic.', | |
| 169 | + 'extremes.semantics': 'Increase and decrease are directions of change, not judgements — a rising share of renewables and a rising debt ratio are both “increases”.', | |
| 170 | + 'extremes.from': 'from', | |
| 171 | + 'extremes.to': 'to', | |
| 8 | 172 | } as const; |
added
apps/web/src/lib/api-analytics.ts
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +import 'server-only'; | |
| 2 | +import { request } from './api'; | |
| 3 | +import { finderQuery, type FinderFilterInput } from './finder-query'; | |
| 4 | +import type { | |
| 5 | + CountryQualityResponse, | |
| 6 | + DistributionResponse, | |
| 7 | + ExtremesResponse, | |
| 8 | + ExtremesWindow, | |
| 9 | + FinderResponse, | |
| 10 | + FramesResponse, | |
| 11 | + IndicatorQualityResponse, | |
| 12 | + MoverCategory, | |
| 13 | + MoverKindFilter, | |
| 14 | + MoverWindow, | |
| 15 | + MoversResponse, | |
| 16 | + PeersResponse, | |
| 17 | + PulseResponse, | |
| 18 | + RaceResponse, | |
| 19 | + RegionCompareResponse, | |
| 20 | + RelatedResponse, | |
| 21 | + ScatterResponse, | |
| 22 | + StoryResponse, | |
| 23 | + TrajectoryResponse, | |
| 24 | + UpdatesResponse, | |
| 25 | +} from './types-analytics'; | |
| 26 | + | |
| 27 | +/** | |
| 28 | + * Server-side endpoints of the analytics API (API 1.1 — docs/API.md § Analytics). Same conventions as | |
| 29 | + * `lib/api.ts` (`request<T>` → ApiError, ISR 900 s). Browser code uses `lib/client-api-analytics.ts`. | |
| 30 | + */ | |
| 31 | +export type { FinderFilterInput } from './finder-query'; | |
| 32 | + | |
| 33 | +export const apiAnalytics = { | |
| 34 | + pulse: () => request<PulseResponse>('/pulse'), | |
| 35 | + | |
| 36 | + movers: (q: { window?: MoverWindow; category?: MoverCategory; kind?: MoverKindFilter; limit?: number; min_population?: number | null } = {}) => | |
| 37 | + request<MoversResponse>('/movers', { window: q.window, category: q.category, kind: q.kind, limit: q.limit, min_population: q.min_population ?? undefined }), | |
| 38 | + | |
| 39 | + extremes: (q: { window?: ExtremesWindow | string; topic?: string | null; min_population?: number | null } = {}) => | |
| 40 | + request<ExtremesResponse>('/extremes', { window: q.window, topic: q.topic ?? undefined, min_population: q.min_population ?? undefined }), | |
| 41 | + | |
| 42 | + scatter: (q: { x: string; y: string; size?: string | null; year?: number | null; group?: string | null; log_x?: string | boolean | null; log_y?: string | boolean | null }) => | |
| 43 | + request<ScatterResponse>('/scatter', { x: q.x, y: q.y, size: q.size ?? undefined, year: q.year ?? undefined, group: q.group ?? undefined, log_x: q.log_x ?? undefined, log_y: q.log_y ?? undefined }), | |
| 44 | + | |
| 45 | + trajectory: (q: { x?: string; y?: string; size?: string | null; from?: number | null; to?: number | null; group?: string | null } = {}) => | |
| 46 | + request<TrajectoryResponse>('/trajectory', { x: q.x, y: q.y, size: q.size ?? undefined, from: q.from ?? undefined, to: q.to ?? undefined, group: q.group ?? undefined }), | |
| 47 | + | |
| 48 | + finder: (filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}) => | |
| 49 | + request<FinderResponse>(`/finder${finderQuery(filters, q)}`), | |
| 50 | + | |
| 51 | + peers: (q: { y?: string; x?: string; year?: number | null; method?: 'theil-sen' | 'ols'; log_x?: string | boolean | null } = {}) => | |
| 52 | + request<PeersResponse>('/peers', { y: q.y, x: q.x, year: q.year ?? undefined, method: q.method, log_x: q.log_x ?? undefined }), | |
| 53 | + | |
| 54 | + indicatorRelated: (slug: string, q: { limit?: number; min_n?: number } = {}) => request<RelatedResponse>(`/indicators/${encodeURIComponent(slug)}/related`, q), | |
| 55 | + | |
| 56 | + indicatorDistribution: (slug: string, q: { year?: number | null; highlight?: string | null; bins?: number } = {}) => | |
| 57 | + request<DistributionResponse>(`/indicators/${encodeURIComponent(slug)}/distribution`, { year: q.year ?? undefined, highlight: q.highlight ?? undefined, bins: q.bins }), | |
| 58 | + | |
| 59 | + indicatorFrames: (slug: string, q: { from?: number | null; to?: number | null; step?: number; group?: string | null } = {}) => | |
| 60 | + request<FramesResponse>(`/indicators/${encodeURIComponent(slug)}/frames`, { from: q.from ?? undefined, to: q.to ?? undefined, step: q.step, group: q.group ?? undefined }), | |
| 61 | + | |
| 62 | + indicatorQuality: (slug: string) => request<IndicatorQualityResponse>(`/indicators/${encodeURIComponent(slug)}/quality`), | |
| 63 | + | |
| 64 | + race: (indicator: string, q: { from?: number | null; to?: number | null; top?: number; group?: string | null } = {}) => | |
| 65 | + request<RaceResponse>(`/rankings/${encodeURIComponent(indicator)}/race`, { from: q.from ?? undefined, to: q.to ?? undefined, top: q.top, group: q.group ?? undefined }), | |
| 66 | + | |
| 67 | + regionsCompare: (a: string, b: string, indicators?: string[]) => request<RegionCompareResponse>('/regions/compare', { a, b, indicators: indicators?.length ? indicators.join(',') : undefined }), | |
| 68 | + | |
| 69 | + countryStory: (id: string) => request<StoryResponse>(`/countries/${encodeURIComponent(id)}/story`), | |
| 70 | + | |
| 71 | + countryQuality: (id: string) => request<CountryQualityResponse>(`/countries/${encodeURIComponent(id)}/quality`), | |
| 72 | + | |
| 73 | + updates: () => request<UpdatesResponse>('/updates', undefined, { revalidate: 300 }), | |
| 74 | +}; | |
| 75 | + | |
| 76 | +export type ApiAnalytics = typeof apiAnalytics; | |
added
apps/web/src/lib/client-api-analytics.ts
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +'use client'; | |
| 2 | +import { finderQuery, type FinderFilterInput } from './finder-query'; | |
| 3 | +import { ClientApiError } from './client-api'; | |
| 4 | +import type { | |
| 5 | + CountryQualityResponse, | |
| 6 | + DistributionResponse, | |
| 7 | + ExtremesResponse, | |
| 8 | + FinderResponse, | |
| 9 | + FramesResponse, | |
| 10 | + IndicatorQualityResponse, | |
| 11 | + MoversResponse, | |
| 12 | + PeersResponse, | |
| 13 | + PulseResponse, | |
| 14 | + RaceResponse, | |
| 15 | + RegionCompareResponse, | |
| 16 | + RelatedResponse, | |
| 17 | + ScatterResponse, | |
| 18 | + StoryResponse, | |
| 19 | + TrajectoryResponse, | |
| 20 | + UpdatesResponse, | |
| 21 | +} from './types-analytics'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Browser-side fetches for the analytics API (same-origin `/api/v1/*`, rewritten to FastAPI). Every call takes an | |
| 25 | + * optional AbortSignal so interactive views cancel superseded requests (slider scrubs, selector changes). | |
| 26 | + */ | |
| 27 | +async function get<T>(path: string, signal?: AbortSignal): Promise<T> { | |
| 28 | + const res = await fetch(`/api/v1${path}`, { headers: { accept: 'application/json' }, signal }); | |
| 29 | + if (!res.ok) throw new ClientApiError(res.status, `API ${res.status}`); | |
| 30 | + return (await res.json()) as T; | |
| 31 | +} | |
| 32 | + | |
| 33 | +function qs(query: Record<string, string | number | boolean | null | undefined>): string { | |
| 34 | + const p = new URLSearchParams(); | |
| 35 | + for (const [k, v] of Object.entries(query)) if (v !== undefined && v !== null && v !== '') p.set(k, String(v)); | |
| 36 | + const s = p.toString(); | |
| 37 | + return s ? `?${s}` : ''; | |
| 38 | +} | |
| 39 | + | |
| 40 | +export const clientAnalytics = { | |
| 41 | + pulse: (signal?: AbortSignal) => get<PulseResponse>('/pulse', signal), | |
| 42 | + movers: (q: { window?: number; category?: string; kind?: string; limit?: number; min_population?: number | null }, signal?: AbortSignal) => get<MoversResponse>(`/movers${qs(q)}`, signal), | |
| 43 | + extremes: (q: { window?: string; topic?: string | null; min_population?: number | null }, signal?: AbortSignal) => get<ExtremesResponse>(`/extremes${qs(q)}`, signal), | |
| 44 | + scatter: (q: { x: string; y: string; size?: string | null; year?: number | null; group?: string | null; log_x?: string | null; log_y?: string | null }, signal?: AbortSignal) => get<ScatterResponse>(`/scatter${qs(q)}`, signal), | |
| 45 | + trajectory: (q: { x?: string; y?: string; size?: string | null; from?: number | null; to?: number | null; group?: string | null }, signal?: AbortSignal) => get<TrajectoryResponse>(`/trajectory${qs(q)}`, signal), | |
| 46 | + finder: (filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}, signal?: AbortSignal) => | |
| 47 | + get<FinderResponse>(`/finder${finderQuery(filters, q)}`, signal), | |
| 48 | + peers: (q: { y?: string; x?: string; year?: number | null; method?: string; log_x?: string | null }, signal?: AbortSignal) => get<PeersResponse>(`/peers${qs(q)}`, signal), | |
| 49 | + indicatorRelated: (slug: string, limit = 12, signal?: AbortSignal) => get<RelatedResponse>(`/indicators/${encodeURIComponent(slug)}/related${qs({ limit })}`, signal), | |
| 50 | + indicatorDistribution: (slug: string, q: { year?: number | null; highlight?: string | null; bins?: number }, signal?: AbortSignal) => get<DistributionResponse>(`/indicators/${encodeURIComponent(slug)}/distribution${qs(q)}`, signal), | |
| 51 | + indicatorFrames: (slug: string, q: { from?: number | null; to?: number | null; group?: string | null } = {}, signal?: AbortSignal) => get<FramesResponse>(`/indicators/${encodeURIComponent(slug)}/frames${qs(q)}`, signal), | |
| 52 | + indicatorQuality: (slug: string, signal?: AbortSignal) => get<IndicatorQualityResponse>(`/indicators/${encodeURIComponent(slug)}/quality`, signal), | |
| 53 | + race: (indicator: string, q: { from?: number | null; to?: number | null; top?: number; group?: string | null } = {}, signal?: AbortSignal) => get<RaceResponse>(`/rankings/${encodeURIComponent(indicator)}/race${qs(q)}`, signal), | |
| 54 | + regionsCompare: (a: string, b: string, indicators?: string[], signal?: AbortSignal) => get<RegionCompareResponse>(`/regions/compare${qs({ a, b, indicators: indicators?.join(',') })}`, signal), | |
| 55 | + countryStory: (id: string, signal?: AbortSignal) => get<StoryResponse>(`/countries/${encodeURIComponent(id)}/story`, signal), | |
| 56 | + countryQuality: (id: string, signal?: AbortSignal) => get<CountryQualityResponse>(`/countries/${encodeURIComponent(id)}/quality`, signal), | |
| 57 | + updates: (signal?: AbortSignal) => get<UpdatesResponse>('/updates', signal), | |
| 58 | +}; | |
| 59 | + | |
| 60 | +export type { FinderFilterInput }; | |
added
apps/web/src/lib/finder-query.ts
+80 −0
@@ -0,0 +1,80 @@ | ||
| 1 | +/** | |
| 2 | + * /finder URL contract, shared by the page (server + client) and the API helpers. The site URL and the API use | |
| 3 | + * the SAME encoding: repeated `f=slug:op:value` (between → `slug:between:a..b`), `mode=and|or`, `region`, `income`, | |
| 4 | + * `sort=slug:asc|desc`. | |
| 5 | + */ | |
| 6 | +export type FinderOp = 'gt' | 'gte' | 'lt' | 'lte' | 'eq' | 'between'; | |
| 7 | +export const FINDER_OPS: readonly FinderOp[] = ['gt', 'gte', 'lt', 'lte', 'eq', 'between']; | |
| 8 | + | |
| 9 | +export interface FinderFilterInput { | |
| 10 | + slug: string; | |
| 11 | + op: FinderOp; | |
| 12 | + value: number; | |
| 13 | + value2?: number | null; | |
| 14 | +} | |
| 15 | + | |
| 16 | +export interface FinderState { | |
| 17 | + filters: FinderFilterInput[]; | |
| 18 | + mode: 'and' | 'or'; | |
| 19 | + region: string | null; | |
| 20 | + income: string | null; | |
| 21 | + sort: string | null; | |
| 22 | +} | |
| 23 | + | |
| 24 | +const SLUG = /^[a-z0-9][a-z0-9-]*$/; | |
| 25 | + | |
| 26 | +export function finderFilterParam(f: FinderFilterInput): string { | |
| 27 | + return f.op === 'between' ? `${f.slug}:between:${f.value}..${f.value2 ?? f.value}` : `${f.slug}:${f.op}:${f.value}`; | |
| 28 | +} | |
| 29 | + | |
| 30 | +export function parseFinderFilter(raw: string): FinderFilterInput | null { | |
| 31 | + const m = /^([a-z0-9][a-z0-9-]*):(gt|gte|lt|lte|eq|between):(.+)$/.exec(raw.trim().toLowerCase()); | |
| 32 | + if (!m) return null; | |
| 33 | + const [, slug, op, rest] = m; | |
| 34 | + if (op === 'between') { | |
| 35 | + const [a, b] = rest!.split('..'); | |
| 36 | + const v1 = Number(a); | |
| 37 | + const v2 = Number(b); | |
| 38 | + if (!Number.isFinite(v1) || !Number.isFinite(v2)) return null; | |
| 39 | + return { slug: slug!, op: 'between', value: Math.min(v1, v2), value2: Math.max(v1, v2) }; | |
| 40 | + } | |
| 41 | + const v = Number(rest); | |
| 42 | + if (!Number.isFinite(v)) return null; | |
| 43 | + return { slug: slug!, op: op as FinderOp, value: v }; | |
| 44 | +} | |
| 45 | + | |
| 46 | +export function finderQuery(filters: FinderFilterInput[], q: { mode?: 'and' | 'or'; region?: string | null; income?: string | null; sort?: string | null; limit?: number } = {}): string { | |
| 47 | + const p = new URLSearchParams(); | |
| 48 | + for (const f of filters) p.append('f', finderFilterParam(f)); | |
| 49 | + if (q.mode && q.mode !== 'and') p.set('mode', q.mode); | |
| 50 | + if (q.region) p.set('region', q.region); | |
| 51 | + if (q.income) p.set('income', q.income); | |
| 52 | + if (q.sort) p.set('sort', q.sort); | |
| 53 | + if (q.limit) p.set('limit', String(q.limit)); | |
| 54 | + const s = p.toString(); | |
| 55 | + return s ? `?${s}` : ''; | |
| 56 | +} | |
| 57 | + | |
| 58 | +type ParamsLike = { getAll(name: string): string[]; get(name: string): string | null } | Record<string, string | string[] | undefined>; | |
| 59 | + | |
| 60 | +function all(params: ParamsLike, key: string): string[] { | |
| 61 | + if (typeof (params as { getAll?: unknown }).getAll === 'function') return (params as { getAll(name: string): string[] }).getAll(key); | |
| 62 | + const v = (params as Record<string, string | string[] | undefined>)[key]; | |
| 63 | + return Array.isArray(v) ? v : v != null ? [v] : []; | |
| 64 | +} | |
| 65 | +function one(params: ParamsLike, key: string): string | null { | |
| 66 | + return all(params, key)[0] ?? null; | |
| 67 | +} | |
| 68 | + | |
| 69 | +export function parseFinderState(params: ParamsLike): FinderState { | |
| 70 | + const filters = all(params, 'f').map(parseFinderFilter).filter((f): f is FinderFilterInput => !!f).slice(0, 8); | |
| 71 | + const mode = one(params, 'mode') === 'or' ? 'or' : 'and'; | |
| 72 | + const clean = (v: string | null) => (v && SLUG.test(v.toLowerCase()) ? v.toLowerCase() : null); | |
| 73 | + const sortRaw = one(params, 'sort'); | |
| 74 | + const sort = sortRaw && /^[a-z0-9][a-z0-9-]*:(asc|desc)$/.test(sortRaw.toLowerCase()) ? sortRaw.toLowerCase() : null; | |
| 75 | + return { filters, mode, region: clean(one(params, 'region')), income: clean(one(params, 'income')), sort }; | |
| 76 | +} | |
| 77 | + | |
| 78 | +export function finderStateQuery(s: FinderState): string { | |
| 79 | + return finderQuery(s.filters, { mode: s.mode, region: s.region, income: s.income, sort: s.sort }); | |
| 80 | +} | |
| 81 | ||