spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { useEffect, useRef, useState } from 'react';3import { t } from '@/i18n';4import { clientCompare } from '@/lib/client-api-compare';5import { displayValue } from '@/lib/format';6import type { IndicatorCard, RankingRow } from '@/lib/types';7import { staleYear } from '@/components/charts/ranked-bars';89/** Small client-side fetch queue: at most 3 previews in flight, and no more requests after a 429. */10const queue: Array<() => void> = [];11let inFlight = 0;12let blocked = false;13function schedule(job: () => Promise<void>) {14 const run = () => {15 inFlight++;16 job().finally(() => {17 inFlight--;18 queue.shift()?.();19 });20 };21 if (inFlight < 3) run();22 else queue.push(run);23}2425/**26 * "Top 3" mini preview (flag · value) for a ranking. `initial` rows come from the server for featured27 * indicators; otherwise the preview loads when it scrolls into view (IntersectionObserver, throttled queue).28 */29export function Top3Preview({ slug, spec, initial }: { slug: string; spec: IndicatorCard; initial?: RankingRow[] | null }) {30 const ref = useRef<HTMLDivElement>(null);31 const [rows, setRows] = useState<RankingRow[] | null | undefined>(initial);32 const [visible, setVisible] = useState(initial !== undefined);3334 useEffect(() => {35 if (visible || !ref.current) return;36 const el = ref.current;37 if (typeof IntersectionObserver === 'undefined') return;38 const io = new IntersectionObserver(39 (entries) => {40 if (entries.some((e) => e.isIntersecting)) {41 setVisible(true);42 io.disconnect();43 }44 },45 { rootMargin: '200px 0px' },46 );47 io.observe(el);48 return () => io.disconnect();49 }, [visible]);5051 useEffect(() => {52 if (!visible || rows !== undefined || blocked) return;53 let cancelled = false;54 schedule(async () => {55 if (cancelled || blocked) return;56 try {57 const r = await clientCompare.rankingTop(slug, 3);58 if (!cancelled) setRows(r.rows);59 } catch (e) {60 if ((e as { status?: number }).status === 429) blocked = true;61 if (!cancelled) setRows(null);62 }63 });64 return () => {65 cancelled = true;66 };67 }, [visible, rows, slug]);6869 return (70 <div ref={ref} className="min-h-[1.25rem] text-xs text-ink-2" aria-label={t('rankings.top3')}>71 {rows === undefined ? (72 <span className="text-ink-3">{visible ? t('rankings.preview.loading') : ''}</span>73 ) : rows === null || rows.length === 0 ? (74 <span className="text-ink-3">{t('rankings.preview.na')}</span>75 ) : (76 <ol className="flex flex-wrap gap-x-3 gap-y-0.5">77 {rows.slice(0, 3).map((r) => {78 const stale = staleYear(r.year, Math.max(0, ...rows.map((x) => x.year ?? 0)));79 return (80 <li key={r.country.id} className="inline-flex items-center gap-1">81 <span className="tnum text-ink-3">{r.rank}</span>82 <span aria-hidden>{r.country.flag}</span>83 <span className="sr-only">{r.country.name}</span>84 <span className="tnum text-ink">{displayValue(r.value, spec, r.formatted)}</span>85 {stale ? <span className="tnum text-2xs text-ink-3">{stale}</span> : null}86 </li>87 );88 })}89 </ol>90 )}91 </div>92 );93}94