spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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