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%
5.4 KB · 118 lines tsx
Raw Blame History
1'use client';2import { X } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useMemo, useRef, useState } from 'react';5import { t } from '@/i18n';6import { clientExplore } from '@/lib/client-api-explore';7import { cn } from '@/lib/cn';8import { routes } from '@/lib/site';9import type { FormatSpec, Series } from '@/lib/types';10import { LineChart, type LineSeries } from '@/components/charts/line-chart';11import { pointsFromSeries } from '@/components/charts/scales';12import { seriesVar } from '@/components/charts/palette';13import { EmptyState } from '@/components/data/empty-state';14import type { ProvenancePayload } from '@/components/data/provenance-context';15import { EntityPicker, type PickedEntity } from '@/components/explore/entity-picker';1617export interface CompareCountry {18  id: string;19  slug: string;20  name: string;21  flag: string | null;22}2324const MAX = 5;2526/**27 * Country comparison teaser on an indicator page: chips for the selected countries (≤ 5), a typeahead to add28 * one, and a multi-series LineChart from `/series?country=&indicator=`. Colour slot follows the country's29 * position in the selection (stable while it stays selected).30 */31export function IndicatorCompare({ slug, spec, initialCountries, initialSeries, payload }: { slug: string; spec: FormatSpec; initialCountries: CompareCountry[]; initialSeries: Series[]; payload: ProvenancePayload | null }) {32  const [countries, setCountries] = useState<CompareCountry[]>(initialCountries);33  const [seriesById, setSeriesById] = useState<Record<string, Series | null>>(() => Object.fromEntries(initialSeries.map((s) => [s.country.id, s])));34  const [loading, setLoading] = useState(false);35  const abortRef = useRef<AbortController | null>(null);3637  useEffect(() => {38    const missing = countries.filter((c) => seriesById[c.id] === undefined).map((c) => c.id);39    if (missing.length === 0) return;40    abortRef.current?.abort();41    const ctrl = new AbortController();42    abortRef.current = ctrl;43    setLoading(true);44    clientExplore45      .seriesBundle(missing, slug, ctrl.signal)46      .then((r) => {47        const next: Record<string, Series | null> = {};48        for (const id of missing) next[id] = r.series.find((s) => s.country.id === id) ?? null;49        setSeriesById((m) => ({ ...m, ...next }));50      })51      .catch((e) => {52        if ((e as Error).name !== 'AbortError') setSeriesById((m) => ({ ...m, ...Object.fromEntries(missing.map((id) => [id, null])) }));53      })54      .finally(() => {55        if (!ctrl.signal.aborted) setLoading(false);56      });57    return () => ctrl.abort();58  }, [countries, slug, seriesById]);5960  const add = (e: PickedEntity) => {61    if (countries.length >= MAX || countries.some((c) => c.id === e.id)) return;62    setCountries((cs) => [...cs, { id: e.id, slug: e.slug, name: e.name, flag: e.flag ?? null }]);63  };64  const remove = (id: string) => setCountries((cs) => cs.filter((c) => c.id !== id));6566  const lines: LineSeries[] = useMemo(() => {67    const out: LineSeries[] = [];68    countries.forEach((c, i) => {69      const s = seriesById[c.id];70      if (!s) return;71      const points = pointsFromSeries(s.values);72      if (points.length) out.push({ id: c.id, name: c.name, points, colorIndex: i });73    });74    return out;75  }, [countries, seriesById]);76  const firstProv = countries.map((c) => seriesById[c.id]?.provenance).find(Boolean) ?? null;7778  return (79    <div className="min-w-0">80      <div className="mb-3 flex flex-wrap items-center gap-2">81        {countries.map((c, i) => (82          <span key={c.id} className="inline-flex h-11 items-center gap-1.5 rounded-sm border border-rule pl-2.5 pr-1 text-sm text-ink md:h-9">83            <span aria-hidden className="inline-block h-2.5 w-2.5 rounded-xs" style={{ background: seriesVar(i) }} />84            {c.flag ? <span aria-hidden>{c.flag}</span> : null}85            <Link href={routes.country(c.slug)} className="link-quiet inline-flex h-11 items-center md:h-9">86              {c.name}87            </Link>88            <button type="button" onClick={() => remove(c.id)} className="grid h-11 w-11 place-items-center text-ink-3 hover:text-ink md:h-7 md:w-8" aria-label={t('indicator.compare.remove', { name: c.name })}>89              <X size={13} aria-hidden />90            </button>91          </span>92        ))}93        {countries.length < MAX ? (94          <EntityPicker type="country" placeholder={countries.length ? t('indicator.compare.add') : t('indicator.compare.search')} onPick={add} exclude={countries.map((c) => c.id)} className="w-56" size="sm" />95        ) : (96          <span className="text-xs text-ink-3">{t('indicator.compare.max')}</span>97        )}98      </div>99      <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 300 }}>100        {lines.length === 0 && !loading ? (101          <EmptyState compact title={t('indicator.compare.none')} />102        ) : lines.length ? (103          <LineChart series={lines} spec={spec} height={260} provenance={firstProv} payload={payload} defaultWidth={720} endLabels={false} />104        ) : (105          <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 260 }}>106            {t('common.loading')}107          </div>108        )}109      </div>110      {countries.length >= 2 ? (111        <Link href={routes.compare(...countries.map((c) => c.slug))} className="mt-2 inline-flex min-h-[40px] items-center text-sm text-accent hover:underline">112          {t('indicator.compare.open')} →113        </Link>114      ) : null}115    </div>116  );117}118