spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowDown, ArrowUp, Download, Plus, Scale, X } from 'lucide-react';3import Link from 'next/link';4import { useRouter } from 'next/navigation';5import { useEffect, useMemo, useRef, useState } from 'react';6import { t } from '@/i18n';7import { clientAnalytics } from '@/lib/client-api-analytics';8import { cn } from '@/lib/cn';9import { FINDER_OPS, finderStateQuery, type FinderFilterInput, type FinderOp, type FinderState } from '@/lib/finder-query';10import { formatValue, grouped } from '@/lib/format';11import { INCOME_GROUPS, WB_REGIONS } from '@/lib/regions';12import { routes } from '@/lib/site';13import type { FormatSpec } from '@/lib/types';14import type { FinderResponse } from '@/lib/types-analytics';15import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';16import { useProvenance } from '@/components/data/provenance-context';17import type { BaseFeature } from '@/components/indicators/indicator-map';18import { MAP_HEIGHT, MAP_WIDTH } from '@/lib/map-geo';1920interface Preset {21 id: string;22 filters: FinderFilterInput[];23}24const 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];3132function 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}3738/** Query builder over /finder: filters live in the URL exactly as the API reads them. */39export 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);4748 // 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 clientAnalytics67 .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]);7879 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;9596 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>102103 {/* 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>117118 {/* 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>187188 {/* 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}280