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