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