'use client'; import { Download, Info, X } from 'lucide-react'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { downloadSvgAsPng } from '@/components/charts/export-png'; import { apiModeOf } from '@/lib/types-compare'; import { t } from '@/i18n'; import { clientCompare } from '@/lib/client-api-compare'; import { cn } from '@/lib/cn'; import { compareQuery, type CompareState } from '@/lib/compare-state'; import { displayValue, formatDate, formatPeriod, formatValue } from '@/lib/format'; import { routes } from '@/lib/site'; import type { FormatSpec, IndicatorCard, MetricValue } from '@/lib/types'; import type { CompareSeries, CountryLite } from '@/lib/types-compare'; import { LineChart, type LineSeries } from '@/components/charts/line-chart'; import { seriesVar } from '@/components/charts/palette'; import { pointsFromSeries } from '@/components/charts/scales'; import { EmptyState } from '@/components/data/empty-state'; import { useProvenance, type ProvenancePayload } from '@/components/data/provenance-context'; const CHART_H = 220; const HERO_H = 340; const RANGE_PRESETS = [10, 20, 30] as const; /** Display spec for a compare series given the value mode (index → dimensionless, pct → percent). */ export function specForMode(ind: IndicatorCard, mode: CompareState['mode'], unitOverride?: string | null): FormatSpec { 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 }; 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 }; 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 }; 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 }; 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 }; } /** * One indicator, one line per country (colour = position of the country in the comparison). `initial` series * render at once; otherwise the chart mounts on scroll and fetches `/compare?indicators=` for the current * range/mode. Below: latest value per country and the list of sources (they may differ per country). */ export function CompareChart({ indicator, countries, state, initial, snapshot, hero = false, maxYear }: { indicator: IndicatorCard; countries: CountryLite[]; state: CompareState; initial?: CompareSeries[] | null; snapshot?: Record | null; hero?: boolean; maxYear?: number }) { const { open } = useProvenance(); const router = useRouter(); const pathname = usePathname(); const ref = useRef(null); const [series, setSeries] = useState(initial); const [visible, setVisible] = useState(!!initial || hero); const [error, setError] = useState(false); const ids = countries.map((c) => c.id); const key = `${ids.join(',')}|${state.from ?? ''}|${state.to ?? ''}|${state.mode}`; const loadedKey = useRef(initial ? key : ''); useEffect(() => { if (visible || !ref.current) return; const el = ref.current; if (typeof IntersectionObserver === 'undefined') { setVisible(true); return; } const io = new IntersectionObserver( (entries) => { if (entries.some((e) => e.isIntersecting)) { setVisible(true); io.disconnect(); } }, { rootMargin: '480px 0px' }, ); io.observe(el); return () => io.disconnect(); }, [visible]); useEffect(() => { if (!visible || loadedKey.current === key) return; const ctrl = new AbortController(); setError(false); clientCompare .compare(ids, [indicator.slug], { from: state.from, to: state.to, mode: apiModeOf(state.mode) }, ctrl.signal) .then((r) => { loadedKey.current = key; setSeries(r.series); }) .catch((e) => { if ((e as Error).name !== 'AbortError') { setError(true); setSeries(null); } }); return () => ctrl.abort(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, key, indicator.slug]); const unitOverride = series?.find((s) => s.transform?.unit)?.transform?.unit ?? (series?.[0]?.unit && state.mode !== 'absolute' ? series[0].unit : null); const spec = specForMode(indicator, state.mode, unitOverride); const name = indicator.name ?? indicator.slug; const lines: LineSeries[] = useMemo( () => countries .map((c, i) => { const s = series?.find((x) => x.country.id === c.id); let points = s ? pointsFromSeries(s.values) : []; if (state.mode === 'change' && points.length) { // Change since the first actual value of the range (client-side; the API returns absolute values). const base = points.find((p) => p.value != null && !p.is_forecast)?.value ?? null; points = base == null ? [] : points.map((p) => ({ ...p, value: p.value == null ? null : p.value - base })); } return { id: c.id, name: c.name, colorIndex: i, points }; }) .filter((l) => l.points.length > 0), [series, countries, state.mode], ); const latest = countries.map((c, i) => { const s = series?.find((x) => x.country.id === c.id); const last = s ? [...s.values].reverse().find((v) => v.value != null && !v.is_forecast) ?? null : null; const snap = snapshot?.[c.id]; return { c, i, last, snap: snap && snap.has_data ? snap : null, provenance: last?.provenance ?? s?.provenance ?? snap?.provenance ?? null }; }); const sources = useMemo(() => { const m = new Map(); for (const l of latest) { const p = l.provenance; if (!p) continue; const label = [p.source_name ?? p.source, p.dataset].filter(Boolean).join(' — '); const cur = m.get(label); if (cur) cur.names.push(l.c.name); else m.set(label, { label, names: [l.c.name], first: l }); } return Array.from(m.values()); }, [latest]); const payloadOf = (l: (typeof latest)[number]): ProvenancePayload => ({ 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 }, value: l.last ? { 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 } : l.snap ? { 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 } : null, country: { id: l.c.id, slug: l.c.slug, name: l.c.name, flag: l.c.flag }, }); const height = hero ? HERO_H : CHART_H; const setFrom = (from: number | null) => router.replace(`${pathname}${compareQuery({ ...state, from })}`, { scroll: false }); const rangeActive = (n: number | null) => (n == null ? state.from == null : maxYear != null && state.from === maxYear - n); 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); const exportPng = () => { const svg = ref.current?.querySelector('svg.ca-chart'); if (svg) void downloadSvgAsPng(svg, `countryatlas-${indicator.slug}-${ids.join('-')}`); }; return (
{hero ?
{t('compare.hero.title')}
: null}

{name}

{subtitleUnit ?

{subtitleUnit}

: null}
{hero && maxYear != null ? (
{[...RANGE_PRESETS, null].map((n) => ( ))}
) : null} {t('compare.hero.ranking')} {hero ? ( ) : null}
{error ? ( ) : series === undefined ? (
{t('compare.charts.loading')}
) : lines.length === 0 ? ( ) : ( )}
{/* Latest values per country */}
    {latest.map((l) => { const has = !!l.last || !!l.snap; 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'); const when = l.last ? formatPeriod(l.last.period, indicator.frequency) : l.snap?.year; return (
  • ); })}
{/* Sources — may differ per country */} {sources.length ? (

{sources.length > 1 ? t('compare.charts.sources') : t('common.source')}: {sources.map((s) => ( ))}

) : null}
); }