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%
13.2 KB · 240 lines tsx
Raw Blame History
1'use client';2import { Check, Copy, Download, X } from 'lucide-react';3import { useEffect, useMemo, useRef, useState } from 'react';4import { t } from '@/i18n';5import { clientPlatform } from '@/lib/client-api-platform';6import { cn } from '@/lib/cn';7import { grouped } from '@/lib/format';8import { routes } from '@/lib/site';9import { parseList, useUrlState } from '@/lib/url-state';10import type { CountryLite } from '@/lib/types-compare';11import { seriesVar } from '@/components/charts/palette';12import { CountryTypeahead } from '@/components/compare/country-picker';13import { IndicatorSelect, Segmented, type IndicatorOption } from '@/components/controls/indicator-select';1415const MAX_COUNTRIES = 20;16const MAX_INDICATORS = 20;17const MIN_YEAR = 1960;1819interface Preset {20  key: 'g7' | 'brics' | 'climate';21  countries: string[];22  indicators: string[];23}24const PRESETS: Preset[] = [25  { key: 'g7', countries: ['canada', 'france', 'germany', 'italy', 'japan', 'united-kingdom', 'united-states'], indicators: ['population', 'gdp', 'gdp-per-capita', 'gdp-growth', 'inflation', 'unemployment-rate', 'life-expectancy', 'general-government-gross-debt-pct-gdp'] },26  { key: 'brics', countries: ['brazil', 'russia', 'india', 'china', 'south-africa'], indicators: ['gdp', 'gdp-per-capita-ppp', 'gdp-growth', 'inflation', 'trade-pct-gdp', 'population'] },27  { key: 'climate', countries: ['china', 'united-states', 'india', 'russia', 'japan', 'germany', 'iran', 'saudi-arabia'], indicators: ['co2-emissions', 'co2-per-capita', 'renewable-electricity-share', 'energy-use-per-capita', 'carbon-intensity-electricity'] },28];2930/**31 * Dataset builder: countries (≤ 20) × indicators (≤ 20) × year range × format → the exact32 * `/api/v1/compare/download.{fmt}` URL, with a live row estimate for small selections. State lives in the URL33 * (`?countries=&indicators=&from=&to=&format=`) so a build is shareable.34 */35export function DownloadBuilder({ countries, indicators, maxYear }: { countries: CountryLite[]; indicators: IndicatorOption[]; maxYear: number }) {36  const { get, getNum, set } = useUrlState();37  const bySlug = useMemo(() => new Map(countries.map((c) => [c.slug, c])), [countries]);38  const byInd = useMemo(() => new Map(indicators.map((i) => [i.slug, i])), [indicators]);39  const selCountries = parseList(get('countries'), MAX_COUNTRIES).filter((s) => bySlug.has(s));40  const selIndicators = parseList(get('indicators'), MAX_INDICATORS).filter((s) => byInd.has(s));41  const from = getNum('from');42  const to = getNum('to');43  const format = (get('format') === 'json' ? 'json' : 'csv') as 'csv' | 'json';44  const forecast = get('forecast') === '1';45  const [copied, setCopied] = useState(false);46  const [estimate, setEstimate] = useState<{ key: string; rows: number } | null>(null);47  const [estimating, setEstimating] = useState(false);48  const abortRef = useRef<AbortController | null>(null);4950  const ids = selCountries.map((s) => bySlug.get(s)!.id);51  const ready = ids.length > 0 && selIndicators.length > 0;52  const q = ready ? routes.compareDownload(ids, selIndicators, { from, to }, format) + (forecast ? '' : '&include_forecast=false') : null;53  const estKey = `${ids.join(',')}|${selIndicators.join(',')}|${from ?? ''}|${to ?? ''}|${forecast}`;5455  useEffect(() => {56    if (!ready || ids.length * selIndicators.length > 60) {57      setEstimate(null);58      return;59    }60    if (estimate?.key === estKey) return;61    abortRef.current?.abort();62    const ctrl = new AbortController();63    abortRef.current = ctrl;64    setEstimating(true);65    const timer = setTimeout(() => {66      clientPlatform67        .seriesBundle(ids, selIndicators.slice(0, 12), { from, to }, ctrl.signal)68        .then((r) => {69          const rows = r.series.reduce((a, s) => a + s.values.filter((v) => forecast || !v.is_forecast).length, 0);70          const scale = selIndicators.length > 12 ? selIndicators.length / 12 : 1;71          setEstimate({ key: estKey, rows: Math.round(rows * scale) });72        })73        .catch(() => setEstimate(null))74        .finally(() => {75          if (!ctrl.signal.aborted) setEstimating(false);76        });77    }, 250);78    return () => {79      clearTimeout(timer);80      ctrl.abort();81    };82    // eslint-disable-next-line react-hooks/exhaustive-deps83  }, [estKey, ready]);8485  const years: number[] = [];86  for (let y = maxYear; y >= MIN_YEAR; y--) years.push(y);87  const copy = async () => {88    if (!q) return;89    try {90      await navigator.clipboard.writeText(`${window.location.origin}${q}`);91      setCopied(true);92      setTimeout(() => setCopied(false), 1600);93    } catch {94      /* clipboard unavailable */95    }96  };97  const setCountries = (next: string[]) => set({ countries: next.join(',') || null }, 0);98  const setIndicators = (next: string[]) => set({ indicators: next.join(',') || null }, 0);99  const excl = new Set(selCountries);100  const pickerOptions = indicators.filter((i) => !selIndicators.includes(i.slug));101102  return (103    <div className="min-w-0">104      {/* Quick starts */}105      <div className="flex flex-wrap items-center gap-1.5 text-sm">106        <span className="text-xs text-ink-3">{t('download.presetHint')}</span>107        {PRESETS.map((p) => (108          <button key={p.key} type="button" onClick={() => set({ countries: p.countries.join(','), indicators: p.indicators.join(',') }, 0)} className="inline-flex h-11 items-center 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">109            {t(`download.preset.${p.key}` as 'download.preset.g7')}110          </button>111        ))}112      </div>113114      <div className="mt-5 grid gap-x-10 gap-y-6 lg:grid-cols-2">115        {/* Countries */}116        <div className="min-w-0">117          <div className="mb-1.5 flex items-baseline justify-between">118            <div className="eyebrow">{t('download.countries')}</div>119            <span className="tnum text-xs text-ink-3">120              {selCountries.length}/{MAX_COUNTRIES}121            </span>122          </div>123          {selCountries.length < MAX_COUNTRIES ? <CountryTypeahead countries={countries} exclude={excl} onPick={(c) => setCountries([...selCountries, c.slug])} placeholder={t('download.addCountry')} /> : <p className="text-xs text-ink-3">{t('download.max', { n: MAX_COUNTRIES })}</p>}124          <ul className="mt-2 flex flex-wrap gap-1.5">125            {selCountries.map((s, i) => {126              const c = bySlug.get(s)!;127              return (128                <li key={s}>129                  <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">130                    <span aria-hidden className="h-2 w-2 rounded-full" style={{ background: seriesVar(i % 8) }} />131                    <span aria-hidden>{c.flag}</span>132                    <span className="max-w-[10rem] truncate">{c.name}</span>133                    <button type="button" onClick={() => setCountries(selCountries.filter((x) => x !== s))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('download.remove', { name: c.name })}>134                      <X size={14} aria-hidden />135                    </button>136                  </span>137                </li>138              );139            })}140          </ul>141        </div>142143        {/* Indicators */}144        <div className="min-w-0">145          <div className="mb-1.5 flex items-baseline justify-between">146            <div className="eyebrow">{t('download.indicators')}</div>147            <span className="tnum text-xs text-ink-3">148              {selIndicators.length}/{MAX_INDICATORS}149            </span>150          </div>151          {selIndicators.length < MAX_INDICATORS ? <IndicatorSelect options={pickerOptions} value="" onChange={(slug) => setIndicators([...selIndicators, slug])} label={t('download.addIndicator')} size="sm" /> : <p className="text-xs text-ink-3">{t('download.max', { n: MAX_INDICATORS })}</p>}152          <ul className="mt-2 flex flex-wrap gap-1.5">153            {selIndicators.map((s) => {154              const i = byInd.get(s)!;155              return (156                <li key={s}>157                  <span className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2.5 text-sm md:h-9">158                    <span className="max-w-[14rem] truncate">{i.short_name ?? i.name}</span>159                    <button type="button" onClick={() => setIndicators(selIndicators.filter((x) => x !== s))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('download.remove', { name: i.name })}>160                      <X size={14} aria-hidden />161                    </button>162                  </span>163                </li>164              );165            })}166          </ul>167        </div>168      </div>169170      {/* Years · format */}171      <div className="mt-6 flex flex-wrap items-end gap-x-6 gap-y-3 border-t border-rule pt-4">172        <fieldset className="flex items-center gap-1.5">173          <legend className="eyebrow mb-1.5">{t('download.years')}</legend>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('download.from')}</span>176            <select value={from ?? ''} onChange={(e) => set({ from: e.target.value || null }, 0)} className="tnum bg-transparent text-ink outline-none" aria-label={t('download.from')}>177              <option value="">{t('download.allYears')}</option>178              {years.map((y) => (179                <option key={y} value={y} disabled={to != null && y > to}>180                  {y}181                </option>182              ))}183            </select>184          </label>185          <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">186            <span className="text-2xs uppercase tracking-wide text-ink-3">{t('download.to')}</span>187            <select value={to ?? ''} onChange={(e) => set({ to: e.target.value || null }, 0)} className="tnum bg-transparent text-ink outline-none" aria-label={t('download.to')}>188              <option value="">{t('download.allYears')}</option>189              {years.map((y) => (190                <option key={y} value={y} disabled={from != null && y < from}>191                  {y}192                </option>193              ))}194            </select>195          </label>196        </fieldset>197        <div>198          <div className="eyebrow mb-1.5">{t('download.format')}</div>199          <Segmented value={format} onChange={(v) => set({ format: v === 'csv' ? null : v }, 0)} options={[{ value: 'csv', label: 'CSV' }, { value: 'json', label: 'JSON' }]} label={t('download.format')} />200        </div>201        <label className={cn('inline-flex h-11 cursor-pointer items-center gap-1.5 rounded-sm border px-2.5 text-sm md:h-9', forecast ? 'border-ink text-ink' : 'border-rule text-ink-2')}>202          <input type="checkbox" checked={forecast} onChange={(e) => set({ forecast: e.target.checked ? '1' : null }, 0)} className="h-5 w-5 accent-[var(--accent)]" />203          {t('download.forecast')}204        </label>205        {selCountries.length || selIndicators.length ? (206          <button type="button" onClick={() => set({ countries: null, indicators: null, from: null, to: null, format: null, forecast: null }, 0)} className="inline-flex h-11 items-center rounded-sm px-2 text-sm text-ink-2 hover:text-ink md:h-9">207            {t('download.clear')}208          </button>209        ) : null}210      </div>211212      {/* Result */}213      <div className="mt-5 border-t border-rule pt-4">214        <div className="flex flex-wrap items-center gap-3">215          <a href={q ?? '#'} aria-disabled={!q} className={cn('inline-flex h-11 items-center gap-2 rounded-sm px-4 text-sm font-medium md:h-10', q ? 'bg-ink text-paper hover:bg-accent hover:text-accent-ink' : 'pointer-events-none bg-surface-2 text-ink-3')} download>216            <Download size={15} aria-hidden /> {t('download.get', { fmt: format.toUpperCase() })}217          </a>218          <span className="tnum text-sm text-ink-2">219            {ready ? t('download.selection', { c: selCountries.length, i: selIndicators.length }) : t('download.needBoth')}220            {ready && (estimate?.key === estKey || estimating) ? <span className="text-ink-3"> · {estimating && estimate?.key !== estKey ? t('download.estimating') : t('download.estimate', { n: grouped(estimate?.rows ?? 0) })}</span> : null}221          </span>222        </div>223        {q ? (224          <div className="mt-3 min-w-0">225            <div className="eyebrow mb-1">{t('download.url')}</div>226            <div className="flex items-stretch gap-1">227              <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap rounded-sm border border-rule bg-surface px-2 py-2 font-mono text-xs text-ink-2">{q}</code>228              <button type="button" onClick={copy} className="inline-flex h-auto shrink-0 items-center gap-1 rounded-sm border border-rule px-2.5 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink" aria-live="polite">229                {copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}230                {copied ? t('common.copied') : t('download.copyUrl')}231              </button>232            </div>233            {ready && estimate ? <p className="mt-1 text-2xs text-ink-3">{t('download.estimateNote')}</p> : null}234          </div>235        ) : null}236      </div>237    </div>238  );239}240