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.5 KB · 86 lines tsx
Raw Blame History
1'use client';2import Link from 'next/link';3import { useEffect, useMemo, useState } from 'react';4import { t } from '@/i18n';5import { clientExplore } from '@/lib/client-api-explore';6import { cn } from '@/lib/cn';7import { routes } from '@/lib/site';8import type { IndicatorCard, RankingResponse } from '@/lib/types';9import type { RegionResponse } from '@/lib/types-explore';10import { RankedBars, rankedRowFromCountry, staleYear, type RankedBarRow } from '@/components/charts/ranked-bars';11import { EmptyState } from '@/components/data/empty-state';1213/**14 * Member ranking for a group: indicator picker (headline indicators + a few more) → RankedBars of the members.15 * The default indicator comes server-side from `/regions/{slug}` (`ranking`); others are fetched from16 * `/rankings/{indicator}?group={slug}` client-side.17 */18export function MemberRanking({ groupSlug, groupName, initial, options }: { groupSlug: string; groupName: string; initial: RegionResponse['ranking']; options: IndicatorCard[] }) {19  const [indicator, setIndicator] = useState(initial.indicator.slug);20  const [cache, setCache] = useState<Record<string, RankingResponse | null>>({});21  const [loading, setLoading] = useState(false);22  const all = useMemo(() => {23    const seen = new Set<string>();24    return [initial.indicator, ...options].filter((o) => (seen.has(o.slug) ? false : (seen.add(o.slug), true)));25  }, [initial.indicator, options]);2627  useEffect(() => {28    if (indicator === initial.indicator.slug || cache[indicator] !== undefined) return;29    const ctrl = new AbortController();30    setLoading(true);31    clientExplore32      .ranking(indicator, groupSlug, 80, ctrl.signal)33      .then((r) => setCache((c) => ({ ...c, [indicator]: r })))34      .catch(() => setCache((c) => ({ ...c, [indicator]: null })))35      .finally(() => {36        if (!ctrl.signal.aborted) setLoading(false);37      });38    return () => ctrl.abort();39  }, [indicator, groupSlug, initial.indicator.slug, cache]);4041  const isDefault = indicator === initial.indicator.slug;42  // `undefined` = not fetched yet (loading), `null` = fetch failed.43  const fetched = isDefault ? undefined : cache[indicator];44  const spec: IndicatorCard = fetched ? fetched.indicator : all.find((o) => o.slug === indicator) ?? initial.indicator;45  const srcRows = isDefault ? initial.rows : fetched?.rows ?? [];46  const refYear = Math.max(0, ...srcRows.map((r) => r.year ?? 0));47  const rows: RankedBarRow[] = isDefault48    ? initial.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.rank_world && r.n_world ? t('region.ranking.worldRank', { rank: r.rank_world, n: r.n_world }) : null, staleYear(r.year, refYear)))49    : fetched50      ? fetched.rows.map((r) => rankedRowFromCountry(r.country, r.value, r.rank, r.change_1y?.formatted ?? null, staleYear(r.year, refYear)))51      : [];52  const prov = isDefault ? initial.rows[0]?.provenance ?? null : fetched?.rows[0]?.provenance ?? null;53  const empty = (!isDefault && fetched === null) || (rows.length === 0 && !loading && (isDefault || fetched !== undefined));5455  return (56    <div className="min-w-0">57      <div className="mb-3 flex flex-wrap items-center gap-2">58        <label className="inline-flex h-11 max-w-full items-center gap-2 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-9">59          <span className="shrink-0 text-xs font-medium">{t('region.ranking.indicator')}</span>60          <select value={indicator} onChange={(e) => setIndicator(e.target.value)} className="min-w-0 max-w-[16rem] bg-transparent pr-1 text-ink outline-none" aria-label={t('region.ranking.indicator')}>61            {all.map((o) => (62              <option key={o.slug} value={o.slug}>63                {o.short_name ?? o.name}64              </option>65            ))}66          </select>67        </label>68        <Link href={routes.ranking(indicator, { group: groupSlug })} className="ml-auto inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]">69          {t('region.ranking.full', { group: groupName })} →70        </Link>71      </div>72      <div className={cn('transition-opacity', loading && 'opacity-60')} aria-busy={loading} style={{ minHeight: 200 }}>73        {empty ? (74          <EmptyState compact title={t('region.ranking.none')} />75        ) : rows.length ? (76          <RankedBars rows={rows} spec={spec} provenance={prov} />77        ) : (78          <div className="grid place-items-center text-sm text-ink-3" style={{ minHeight: 160 }}>79            {t('common.loading')}80          </div>81        )}82      </div>83    </div>84  );85}86