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%
4.0 KB · 83 lines tsx
Raw Blame History
1'use client';2import { X } from 'lucide-react';3import { usePathname, useRouter, useSearchParams } from 'next/navigation';4import { useEffect, useMemo, useState } from 'react';5import { t } from '@/i18n';6import { clientCompare } from '@/lib/client-api-compare';7import type { CountryLite, RankHistoryResponse } from '@/lib/types-compare';8import { seriesVar } from '@/components/charts/palette';9import { CountryTypeahead } from '@/components/compare/country-picker';10import { RankHistoryChart, type RankSeries } from './rank-history-chart';1112export const MAX_HISTORY = 5;1314/**15 * "Rank over time" panel: up to 5 countries (default top 3 + highlighted), rank-by-year chart. The selection is16 * written to `?history=ISO3,ISO3` (router.replace); new selections fetch `/rankings/{slug}/history` client-side.17 */18export function RankHistoryPanel({ slug, countries, initialIds, initial }: { slug: string; countries: CountryLite[]; initialIds: string[]; initial: RankHistoryResponse | null }) {19  const router = useRouter();20  const pathname = usePathname();21  const params = useSearchParams();22  const [ids, setIds] = useState<string[]>(initialIds);23  const [data, setData] = useState<RankHistoryResponse | null>(initial);24  const [loading, setLoading] = useState(false);25  const byId = useMemo(() => new Map(countries.map((c) => [c.id, c])), [countries]);26  const exclude = useMemo(() => new Set(ids.map((id) => byId.get(id)?.slug ?? id.toLowerCase())), [ids, byId]);2728  useEffect(() => {29    const key = ids.join(',');30    const have = data ? data.countries.map((c) => c.id).join(',') : '';31    if (!ids.length || key === have) return;32    const ctrl = new AbortController();33    setLoading(true);34    clientCompare35      .rankHistory(slug, ids, ctrl.signal)36      .then((r) => setData(r))37      .catch((e) => {38        if ((e as Error).name !== 'AbortError') setData(null);39      })40      .finally(() => setLoading(false));41    return () => ctrl.abort();42  }, [ids, slug, data]);4344  const apply = (next: string[]) => {45    setIds(next);46    const p = new URLSearchParams(params.toString());47    if (next.length) p.set('history', next.join(','));48    else p.delete('history');49    const s = p.toString();50    router.replace(`${pathname}${s ? `?${s}` : ''}`, { scroll: false });51  };5253  const series: RankSeries[] = ids.map((id, i) => {54    const c = byId.get(id) ?? data?.countries.find((x) => x.id === id);55    const pts = (data?.series[id] ?? []).filter((p) => p.rank != null).map((p) => ({ year: p.year, rank: p.rank!, n: p.n }));56    return { id, name: c?.name ?? id, flag: c?.flag ?? null, colorIndex: i, points: pts };57  });5859  return (60    <div>61      <div className="flex flex-wrap items-center gap-1.5">62        {ids.map((id, i) => {63          const c = byId.get(id);64          return (65            <span key={id} className="inline-flex h-11 items-center gap-1 rounded-sm border border-rule bg-surface pl-2 text-sm md:h-9">66              <span aria-hidden className="h-2 w-2 shrink-0 rounded-full" style={{ background: seriesVar(i) }} />67              <span aria-hidden>{c?.flag}</span>68              <span className="max-w-[9rem] truncate text-ink">{c?.name ?? id}</span>69              <button type="button" onClick={() => apply(ids.filter((x) => x !== id))} className="grid h-11 w-9 place-items-center text-ink-3 hover:text-down md:h-9 md:w-7" aria-label={t('ranking.history.remove', { name: c?.name ?? id })}>70                <X size={14} aria-hidden />71              </button>72            </span>73          );74        })}75        {ids.length < MAX_HISTORY ? <CountryTypeahead countries={countries} exclude={exclude} onPick={(c) => apply([...ids, c.id])} placeholder={t('ranking.history.pick')} className="w-full sm:w-64" /> : <span className="text-xs text-ink-3">{t('ranking.history.full', { max: MAX_HISTORY })}</span>}76      </div>77      <div className={loading ? 'mt-3 opacity-60 transition-opacity' : 'mt-3'} aria-busy={loading}>78        {ids.length === 0 ? <p className="py-6 text-sm text-ink-3">{t('ranking.history.empty')}</p> : <RankHistoryChart series={series} />}79      </div>80    </div>81  );82}83