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 | import type { Metadata } from 'next'; | 1 | import type { Metadata } from 'next'; | ||
| 3 | −import Link from 'next/link'; | ||||
| 4 | import { t } from '@/i18n'; | 2 | import { t } from '@/i18n'; | ||
| 5 | import { api, isNotBuilt, safe } from '@/lib/api'; | 3 | import { api, isNotBuilt, safe } from '@/lib/api'; | ||
| 4 | +import { apiAnalytics } from '@/lib/api-analytics'; | ||||
| 6 | import { apiExplore } from '@/lib/api-explore'; | 5 | import { apiExplore } from '@/lib/api-explore'; | ||
| 7 | −import { formatValue, grouped } from '@/lib/format'; | ||||
| 8 | import { routes } from '@/lib/site'; | 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 | import { NotBuiltState } from '@/components/data/empty-state'; | 7 | import { NotBuiltState } from '@/components/data/empty-state'; | ||
| 14 | −import { Section } from '@/components/data/section'; | 8 | +import { WorldExplorer } from '@/components/explorer/world-explorer'; | ||
| 15 | −import { EntityPickerNav } from '@/components/explore/entity-picker-nav'; | 9 | +import { baseFeatures } from '@/components/indicators/map-geometry'; | ||
| 16 | −import { PageHeader } from '@/components/explore/page-header'; | 10 | +import { DEFAULT_EXPLORER_INDICATOR, EXPLORER_VIEWS, explorerIndicators, parseYearParam, type ExplorerView } from '@/components/explorer/options'; | ||
| 17 | −import { SimilarityPlayground } from '@/components/explore/similarity-playground'; | ||||
| 18 | −import { RegionChips } from '@/components/home/region-chips'; | ||||
| 19 | 11 | ||||
| 20 | export const revalidate = 900; | 12 | export const revalidate = 900; | ||
| 21 | 13 | ||||
| 22 | −export const metadata: Metadata = { | 14 | +type SP = Record<string, string | string[] | undefined>; | ||
| 23 | − title: t('explore.title'), | 15 | +const SLUG = /^[a-z0-9][a-z0-9-]*$/; | ||
| 24 | − description: t('explore.sub'), | ||||
| 25 | − alternates: { canonical: routes.explore() }, | ||||
| 26 | −}; | ||||
| 27 | 16 | ||||
| 28 | −type RankQ = { kind: 'ranking'; key: string; indicator: string; sort?: 'asc' | 'desc' }; | 17 | +function first(sp: SP, k: string): string | null { | ||
| 29 | −type GroupQ = { kind: 'compare'; key: string; group: string }; | 18 | + const v = sp[k]; | ||
| 30 | −const QUESTIONS: Array<RankQ | GroupQ> = [ | 19 | + return (Array.isArray(v) ? v[0] : v) ?? null; | ||
| 31 | − { kind: 'ranking', key: 'age', indicator: 'median-age' }, | 20 | +} | ||
| 32 | − { kind: 'ranking', key: 'energy', indicator: 'renewable-electricity-share' }, | 21 | + | ||
| 33 | − { kind: 'ranking', key: 'housing', indicator: 'price-to-income-ratio' }, | 22 | +function parseState(sp: SP) { | ||
| 34 | − { kind: 'ranking', key: 'growth', indicator: 'gdp-growth' }, | 23 | + const ind = (first(sp, 'indicator') ?? '').toLowerCase(); | ||
| 35 | − { kind: 'ranking', key: 'life', indicator: 'life-expectancy' }, | 24 | + const view = first(sp, 'view') as ExplorerView | null; | ||
| 36 | − { kind: 'ranking', key: 'inflation', indicator: 'inflation', sort: 'desc' }, | 25 | + const country = (first(sp, 'country') ?? '').toLowerCase(); | ||
| 37 | − { kind: 'ranking', key: 'debt', indicator: 'general-government-gross-debt-pct-gdp', sort: 'desc' }, | 26 | + const group = (first(sp, 'group') ?? 'world').toLowerCase(); | ||
| 38 | − { kind: 'ranking', key: 'co2', indicator: 'co2-per-capita', sort: 'desc' }, | 27 | + return { | ||
| 39 | − { kind: 'ranking', key: 'internet', indicator: 'internet-users' }, | 28 | + indicator: SLUG.test(ind) ? ind : DEFAULT_EXPLORER_INDICATOR, | ||
| 40 | − { kind: 'ranking', key: 'unemployment', indicator: 'unemployment-rate', sort: 'asc' }, | 29 | + year: parseYearParam(first(sp, 'year')), | ||
| 41 | − { kind: 'ranking', key: 'fertility', indicator: 'fertility-rate', sort: 'desc' }, | 30 | + view: view && EXPLORER_VIEWS.includes(view) ? view : ('map' as ExplorerView), | ||
| 42 | − { kind: 'ranking', key: 'rd', indicator: 'rd-expenditure-pct-gdp' }, | 31 | + country: /^[a-z]{3}$/.test(country) ? country : null, | ||
| 43 | − { kind: 'compare', key: 'g7', group: 'g7' }, | 32 | + group: SLUG.test(group) ? group : 'world', | ||
| 44 | − { kind: 'compare', key: 'brics', group: 'brics' }, | 33 | + }; | ||
| 45 | − { kind: 'compare', key: 'nordic', group: 'nordic-countries' }, | 34 | +} | ||
| 46 | −]; | ||||
| 47 | 35 | ||||
| 48 | −export default async function ExplorePage() { | 36 | +export async function generateMetadata({ searchParams }: { searchParams: Promise<SP> }): Promise<Metadata> { | ||
| 49 | − let notBuilt = false; | 37 | + const sp = await searchParams; | ||
| 50 | − let indicatorsCount = 260; | 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 | try { | 52 | try { | ||
| 52 | − indicatorsCount = (await apiExplore.indicators()).n; | 53 | + countriesRes = await api.countries(); | ||
| 53 | } catch (e) { | 54 | } catch (e) { | ||
| 54 | − if (isNotBuilt(e)) notBuilt = true; | 55 | + if (isNotBuilt(e)) return <NotBuiltState />; | ||
| 55 | − else throw e; | 56 | + throw e; | ||
| 56 | − } | ||||
| 57 | − if (notBuilt) { | ||||
| 58 | − return ( | ||||
| 59 | − <> | ||||
| 60 | − <PageHeader title={t('explore.title')} lede={t('explore.sub')} /> | ||||
| 61 | − <NotBuiltState /> | ||||
| 62 | − </> | ||||
| 63 | − ); | ||||
| 64 | } | 57 | } | ||
| 65 | − | 58 | + const [indicatorsRes, groupsRes, frames] = await Promise.all([ | ||
| 66 | − const [changes, ...answers] = await Promise.all([ | 59 | + safe(apiExplore.indicators({ with_data: true })), | ||
| 67 | − safe(apiExplore.changes({ limit: 30 })), | 60 | + safe(apiExplore.regions()), | ||
| 68 | − ...QUESTIONS.map((q) => (q.kind === 'ranking' ? safe(api.ranking(q.indicator, { limit: 1, sort: q.sort, sparkline: false })) : safe(apiExplore.region(q.group)))), | 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 | return ( | 66 | return ( | ||
| 72 | − <> | 67 | + <WorldExplorer | ||
| 73 | − <PageHeader title={t('explore.title')} lede={t('explore.sub')} /> | 68 | + features={features} | ||
| 74 | − | 69 | + sphere={sphere} | ||
| 75 | − <Section id="country" title={t('explore.country.title')} subtitle={t('explore.country.sub')} className="border-t-0"> | 70 | + countries={countries.map((c) => ({ id: c.id, slug: c.slug, name: c.name, flag: c.flag, region: c.region, income: c.income }))} | ||
| 76 | − <div className="max-w-xl"> | 71 | + indicators={indicators} | ||
| 77 | − <EntityPickerNav type="country" placeholder={t('explore.country.search')} /> | 72 | + groups={groupsRes?.items ?? []} | ||
| 78 | − </div> | 73 | + initial={frames} | ||
| 79 | − <div className="mt-4"> | 74 | + initialState={state} | ||
| 80 | − <RegionChips /> | 75 | + /> | ||
| 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 | − </> | ||||
| 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 | label?: string; | 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 | /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */ | 48 | /** Fixed colour slot per World Bank region (order of lib/regions.ts WB_REGIONS). */ | ||
| 36 | export function regionColor(region: string | null | undefined): string { | 49 | export function regionColor(region: string | null | undefined): string { | ||
| 37 | const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase()); | 50 | const i = WB_REGIONS.findIndex((r) => r.id === (region ?? '').toUpperCase()); | ||
@@ -124,8 +137,8 @@ export function BubbleChart({ | |||||
| 124 | if (labelled.size >= labelCount + hl.size) break; | 137 | if (labelled.size >= labelCount + hl.size) break; | ||
| 125 | labelled.add(p.id); | 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]!); | 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]!); | ||
| 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]!); | 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 | let fitPath: string | null = null; | 142 | let fitPath: string | null = null; | ||
| 130 | if (fit) { | 143 | if (fit) { | ||
| 131 | const [x0, x1] = x.domain() as [number, number]; | 144 | const [x0, x1] = x.domain() as [number, number]; | ||
@@ -181,7 +194,8 @@ export function BubbleChart({ | |||||
| 181 | [model.clean, xSpec, ySpec, sizeSpec], | 194 | [model.clean, xSpec, ySpec, sizeSpec], | ||
| 182 | ); | 195 | ); | ||
| 183 | const regionsPresent = useMemo(() => WB_REGIONS.filter((rg) => model.clean.some((p) => (p.region ?? '').toUpperCase() === rg.id)), [model.clean]); | 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 | return ( | 200 | return ( | ||
| 187 | <ChartFrame title={title} subtitle={subtitle} summary={summary} table={model.clean.length ? table : undefined} className={className} minHeight={height}> | 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 | {xSpec.name} | 236 | {xSpec.name} | ||
| 223 | {logX ? ` (${t('common.log')})` : ''} → | 237 | {logX ? ` (${t('common.log')})` : ''} → | ||
| 224 | </text> | 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 | ↑ {ySpec.name} | 240 | ↑ {ySpec.name} | ||
| 227 | {logY ? ` (${t('common.log')})` : ''} | 241 | {logY ? ` (${t('common.log')})` : ''} | ||
| 228 | </text> | 242 | </text> | ||
@@ -242,8 +256,8 @@ export function BubbleChart({ | |||||
| 242 | const isHover = hover === p.id; | 256 | const isHover = hover === p.id; | ||
| 243 | const rr = model.radius(p); | 257 | const rr = model.radius(p); | ||
| 244 | return ( | 258 | return ( | ||
| 245 | − <g key={p.id} style={{ transform: `translate(${model.x(p.x)}px, ${model.y(p.y)}px)`, transition: trans }}> | 259 | + <g key={p.id} className={trans} style={{ transform: `translate(${px(model.x(p.x))}, ${px(model.y(p.y))})` }}> | ||
| 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 }} /> | 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 | </g> | 261 | </g> | ||
| 248 | ); | 262 | ); | ||
| 249 | })} | 263 | })} | ||
@@ -252,7 +266,7 @@ export function BubbleChart({ | |||||
| 252 | .map((p) => { | 266 | .map((p) => { | ||
| 253 | const rr = model.radius(p); | 267 | const rr = model.radius(p); | ||
| 254 | return ( | 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 | {p.label} | 270 | {p.label} | ||
| 257 | </text> | 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
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/explorer/scatter-view.tsx
+264 −0
Large diff (264 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/explorer/trajectories-view.tsx
+236 −0
Large diff (236 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/explorer/use-media.ts
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/components/explorer/world-explorer.tsx
+343 −0
Large diff (343 lines) is not loaded by default to keep the page fast.Load diff
modified
apps/web/src/i18n/en.flagship.ts
+164 −0
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/api-analytics.ts
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/client-api-analytics.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
apps/web/src/lib/finder-query.ts
+80 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff