SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
17.9 KB · 280 lines tsx
Raw Blame History
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