spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Download, Info, X } from 'lucide-react';3import Link from 'next/link';4import { usePathname, useRouter } from 'next/navigation';5import { useEffect, useMemo, useRef, useState } from 'react';6import { downloadSvgAsPng } from '@/components/charts/export-png';7import { apiModeOf } from '@/lib/types-compare';8import { t } from '@/i18n';9import { clientCompare } from '@/lib/client-api-compare';10import { cn } from '@/lib/cn';11import { compareQuery, type CompareState } from '@/lib/compare-state';12import { displayValue, formatDate, formatPeriod, formatValue } from '@/lib/format';13import { routes } from '@/lib/site';14import type { FormatSpec, IndicatorCard, MetricValue } from '@/lib/types';15import type { CompareSeries, CountryLite } from '@/lib/types-compare';16import { LineChart, type LineSeries } from '@/components/charts/line-chart';17import { seriesVar } from '@/components/charts/palette';18import { pointsFromSeries } from '@/components/charts/scales';19import { EmptyState } from '@/components/data/empty-state';20import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context';2122const CHART_H = 220;23const HERO_H = 340;24const RANGE_PRESETS = [10, 20, 30] as const;2526/** Display spec for a compare series given the value mode (index → dimensionless, pct → percent). */27export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec {28 if (mode === 'index100') return { format: 'index', precision: 1, unit: unitOverride ?? 'index', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };29 if (mode === 'pct') return { format: 'percent', precision: 1, unit: '%', name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };30 if (mode === 'percentile') return { format: 'index', precision: 0, unit: t('compare.mode.percentile'), name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };31 if (mode === 'change') return { format: ind.format === 'percent' || ind.format === 'years' || ind.format === 'index' || ind.format === 'ratio' ? ind.format : ind.format, unit: t('compare.mode.change'), unit_short: ind.unit_short, precision: ind.precision, name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };32 return { format: ind.format, unit: unitOverride ?? ind.unit, unit_short: unitOverride ? null : ind.unit_short, precision: ind.precision, name: ind.short_name ?? ind.name, frequency: ind.frequency, higher_is_better: ind.higher_is_better };33}3435/**36 * One indicator, one line per country (colour = position of the country in the comparison). `initial` series37 * render at once; otherwise the chart mounts on scroll and fetches `/compare?indicators=<slug>` for the current38 * range/mode. Below: latest value per country and the list of sources (they may differ per country).39 */40export function CompareChart({ indicator, countries, state, initial, snapshot, hero = false, maxYear }: { indicator: IndicatorCard; countries: CountryLite[]; state: CompareState; initial?: CompareSeries[] | null; snapshot?: Record<string, MetricValue> | null; hero?: boolean; maxYear?: number }) {41 const { open } = useProvenance();42 const router = useRouter();43 const pathname = usePathname();44 const ref = useRef<HTMLElement>(null);45 const [series, setSeries] = useState<CompareSeries[] | null | undefined>(initial);46 const [visible, setVisible] = useState(!!initial || hero);47 const [error, setError] = useState(false);48 const ids = countries.map((c) => c.id);49 const key = `${ids.join(',')}|${state.from ?? ''}|${state.to ?? ''}|${state.mode}`;50 const loadedKey = useRef(initial ? key : '');5152 useEffect(() => {53 if (visible || !ref.current) return;54 const el = ref.current;55 if (typeof IntersectionObserver === 'undefined') {56 setVisible(true);57 return;58 }59 const io = new IntersectionObserver(60 (entries) => {61 if (entries.some((e) => e.isIntersecting)) {62 setVisible(true);63 io.disconnect();64 }65 },66 { rootMargin: '480px 0px' },67 );68 io.observe(el);69 return () => io.disconnect();70 }, [visible]);7172 useEffect(() => {73 if (!visible || loadedKey.current === key) return;74 const ctrl = new AbortController();75 setError(false);76 clientCompare77 .compare(ids, [indicator.slug], { from: state.from, to: state.to, mode: apiModeOf(state.mode) }, ctrl.signal)78 .then((r) => {79 loadedKey.current = key;80 setSeries(r.series);81 })82 .catch((e) => {83 if ((e as Error).name !== 'AbortError') {84 setError(true);85 setSeries(null);86 }87 });88 return () => ctrl.abort();89 // eslint-disable-next-line react-hooks/exhaustive-deps90 }, [visible, key, indicator.slug]);9192 const unitOverride = series?.find((s) => s.transform?.unit)?.transform?.unit ?? (series?.[0]?.unit && state.mode !== 'absolute' ? series[0].unit : null);93 const spec = specForMode(indicator, state.mode, unitOverride);94 const name = indicator.name ?? indicator.slug;9596 const lines: LineSeries[] = useMemo(97 () =>98 countries99 .map((c, i) => {100 const s = series?.find((x) => x.country.id === c.id);101 let points = s ? pointsFromSeries(s.values) : [];102 if (state.mode === 'change' && points.length) {103 // Change since the first actual value of the range (client-side; the API returns absolute values).104 const base = points.find((p) => p.value != null && !p.is_forecast)?.value ?? null;105 points = base == null ? [] : points.map((p) => ({ ...p, value: p.value == null ? null : p.value - base }));106 }107 return { id: c.id, name: c.name, colorIndex: i, points };108 })109 .filter((l) => l.points.length > 0),110 [series, countries, state.mode],111 );112113 const latest = countries.map((c, i) => {114 const s = series?.find((x) => x.country.id === c.id);115 const last = s ? [...s.values].reverse().find((v) => v.value != null && !v.is_forecast) ?? null : null;116 const snap = snapshot?.[c.id];117 return { c, i, last, snap: snap && snap.has_data ? snap : null, provenance: last?.provenance ?? s?.provenance ?? snap?.provenance ?? null };118 });119120 const sources = useMemo(() => {121 const m = new Map<string, { label: string; names: string[]; first: (typeof latest)[number] }>();122 for (const l of latest) {123 const p = l.provenance;124 if (!p) continue;125 const label = [p.source_name ?? p.source, p.dataset].filter(Boolean).join(' — ');126 const cur = m.get(label);127 if (cur) cur.names.push(l.c.name);128 else m.set(label, { label, names: [l.c.name], first: l });129 }130 return Array.from(m.values());131 }, [latest]);132133 const payloadOf = (l: (typeof latest)[number]): ProvenancePayload => ({134 indicator: { slug: indicator.slug, name, format: indicator.format, unit: indicator.unit, unit_short: indicator.unit_short, precision: indicator.precision, frequency: indicator.frequency, higher_is_better: indicator.higher_is_better },135 value: l.last136 ? { value: l.last.value, period: l.last.period, year: l.last.year, unit: indicator.unit, is_estimate: l.last.is_estimate, is_forecast: l.last.is_forecast, status: l.last.status, provenance: l.last.provenance }137 : l.snap138 ? { value: l.snap.value, formatted: l.snap.formatted, period: l.snap.period, year: l.snap.year, unit: l.snap.unit, is_estimate: l.snap.is_estimate, is_forecast: l.snap.is_forecast, status: l.snap.status, provenance: l.snap.provenance }139 : null,140 country: { id: l.c.id, slug: l.c.slug, name: l.c.name, flag: l.c.flag },141 });142143 const height = hero ? HERO_H : CHART_H;144 const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false });145 const rangeActive = (n: number | null) => (n == null ? state.from == null : maxYear != null && state.from === maxYear - n);146 const subtitleUnit = state.mode === 'index100' ? t('compare.charts.indexBase', { year: series?.[0]?.transform?.base_year ?? series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'pct' ? t('compare.charts.pctUnit') : state.mode === 'change' ? t('compare.charts.changeSince', { year: series?.[0]?.stats.first?.year ?? state.from ?? '' }) : state.mode === 'percentile' ? t('compare.charts.percentileNote') : (unitOverride ?? indicator.unit);147 const exportPng = () => {148 const svg = ref.current?.querySelector<SVGSVGElement>('svg.ca-chart');149 if (svg) void downloadSvgAsPng(svg, `countryatlas-${indicator.slug}-${ids.join('-')}`);150 };151152 return (153 <article ref={ref} id={`chart-${indicator.slug}`} className={cn('min-w-0 scroll-mt-40', hero && 'rounded-sm border border-rule bg-surface p-4 md:p-5')} aria-labelledby={`chart-${indicator.slug}-h`}>154 <header className="mb-2 flex items-start justify-between gap-3">155 <div className="min-w-0">156 {hero ? <div className="eyebrow mb-0.5">{t('compare.hero.title')}</div> : null}157 <h3 id={`chart-${indicator.slug}-h`} className={cn('font-semibold leading-snug text-ink', hero ? 'display text-xl md:text-2xl' : 'text-sm')}>158 <Link href={routes.indicator(indicator.slug)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0">159 {name}160 </Link>161 </h3>162 {subtitleUnit ? <p className="text-xs text-ink-2">{subtitleUnit}</p> : null}163 </div>164 <div className="flex shrink-0 items-center gap-1 text-xs">165 {hero && maxYear != null ? (166 <div role="radiogroup" aria-label={t('compare.range.preset')} className="hidden items-center gap-0.5 sm:flex">167 {[...RANGE_PRESETS, null].map((n) => (168 <button key={n ?? 'all'} type="button" role="radio" aria-checked={rangeActive(n)} onClick={() => setFrom(n == null ? null : maxYear - n)} className={cn('tnum inline-flex h-8 items-center rounded-sm px-2', rangeActive(n) ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2')}>169 {n == null ? t('compare.range.all') : `${n} y`}170 </button>171 ))}172 </div>173 ) : null}174 <Link href={routes.ranking(indicator.slug)} className="inline-flex min-h-[44px] items-center rounded-sm px-1.5 text-ink-2 hover:text-accent md:min-h-[32px]">175 {t('compare.hero.ranking')}176 </Link>177 <button type="button" onClick={exportPng} className="inline-flex min-h-[44px] min-w-[44px] items-center justify-center gap-1 rounded-sm px-1.5 text-ink-2 hover:text-accent md:min-h-[32px] md:min-w-0" aria-label={t('compare.charts.png')} title={t('compare.charts.png')}>178 <Download size={13} aria-hidden />179 <span className="hidden sm:inline">PNG</span>180 </button>181 {hero ? (182 <button type="button" onClick={() => router.replace(`${pathname}${compareQuery({ ...state, indicator: null })}`, { scroll: false })} className="tap -mr-2 grid place-items-center rounded-sm text-ink-3 hover:bg-surface-2 hover:text-ink md:min-h-[32px] md:min-w-[32px]" aria-label={t('compare.hero.close')}>183 <X size={16} aria-hidden />184 </button>185 ) : null}186 </div>187 </header>188189 <div className="min-w-0" style={{ minHeight: height }}>190 {error ? (191 <EmptyState compact title={t('compare.charts.error')} />192 ) : series === undefined ? (193 <div className="grid place-items-center text-sm text-ink-3" style={{ height }}>194 {t('compare.charts.loading')}195 </div>196 ) : lines.length === 0 ? (197 <EmptyState compact title={t('chart.noData')} />198 ) : (199 <LineChart series={lines} spec={spec} log={state.log} height={height} endLabels={false} defaultWidth={hero ? 960 : 560} className="[&_ul[aria-label=Legend]]:hidden" margin={{ right: 16 }} />200 )}201 </div>202203 {/* Latest values per country */}204 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs" aria-label={t('compare.charts.latest')}>205 {latest.map((l) => {206 const has = !!l.last || !!l.snap;207 const text = l.last ? (state.mode === 'absolute' && l.snap?.formatted && l.snap.year === l.last.year ? displayValue(l.last.value, spec, l.snap.formatted) : formatValue(l.last.value, spec)) : l.snap ? displayValue(l.snap.value, l.snap, l.snap.formatted) : t('common.noData');208 const when = l.last ? formatPeriod(l.last.period, indicator.frequency) : l.snap?.year;209 return (210 <li key={l.c.id}>211 <button type="button" disabled={!has} onClick={() => has && open(payloadOf(l))} className="inline-flex min-h-[32px] items-center gap-1.5 rounded-sm px-1 hover:bg-surface-2 disabled:hover:bg-transparent" aria-label={`${l.c.name}: ${text}. ${t('common.openProvenance')}`}>212 <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(l.i) }} />213 <span aria-hidden>{l.c.flag}</span>214 <span className={cn('tnum font-medium', has ? 'text-ink' : 'text-ink-3')}>{text}</span>215 {when ? <span className="tnum text-ink-3">{when}</span> : null}216 </button>217 </li>218 );219 })}220 </ul>221222 {/* Sources — may differ per country */}223 {sources.length ? (224 <p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-2">225 <span className="text-ink-3">{sources.length > 1 ? t('compare.charts.sources') : t('common.source')}:</span>226 {sources.map((s) => (227 <button key={s.label} type="button" onClick={() => open(payloadOf(s.first))} className="inline-flex min-h-[28px] items-center gap-1 rounded-sm px-1 text-left hover:text-accent" aria-label={`${t('compare.charts.sourceFor', { name: s.names.join(', ') })}: ${s.label}. ${t('common.openProvenance')}`}>228 <span>229 {s.label}230 {sources.length > 1 ? <span className="text-ink-3"> ({s.names.join(', ')})</span> : null}231 {s.first.provenance?.retrieved_at && sources.length === 1 ? <span className="text-ink-3"> · {t('common.retrieved', { date: formatDate(s.first.provenance.retrieved_at) })}</span> : null}232 </span>233 <Info size={11} aria-hidden className="shrink-0 text-ink-3" />234 </button>235 ))}236 </p>237 ) : null}238 </article>239 );240}241