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%
14.3 KB · 241 lines tsx
Raw Blame History
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