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%
3.3 KB · 94 lines tsx
Raw Blame History
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