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