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%
8.1 KB · 143 lines tsx
Raw Blame History
1'use client';2import { ArrowDownRight, ArrowUpRight } from 'lucide-react';3import Link from 'next/link';4import { useEffect, useMemo, useRef, useState } from 'react';5import { t, tOpt } from '@/i18n';6import { clientAnalytics } from '@/lib/client-api-analytics';7import { cn } from '@/lib/cn';8import { severityLevel } from '@/lib/severity';9import { routes } from '@/lib/site';10import type { MoverCategory, MoverItem, MoverKindFilter, MoverWindow, MoversResponse } from '@/lib/types-analytics';11import { kindLabel } from '@/components/data/change-list';12import { CountryChip } from '@/components/data/country-chip';13import { Segmented } from '@/components/controls/indicator-select';1415const WINDOWS: MoverWindow[] = [1, 5, 10];16const CATEGORIES: MoverCategory[] = ['all', 'economic', 'demographic', 'health', 'energy', 'climate', 'digital', 'housing', 'labor'];17const KINDS_1: MoverKindFilter[] = ['all', 'improvement', 'deterioration', 'record', 'reversal', 'acceleration', 'structural'];18const KINDS_N: MoverKindFilter[] = ['all', 'improvement', 'deterioration', 'increase', 'decrease'];1920/**21 * Biggest movers: window tabs (24 months / 5 years / 10 years), category and kind chips; rows show country,22 * indicator, ref → now, delta, severity dot and the templated headline. The server passes the first payload23 * (window 1, all); every change refetches `/movers` client-side with request cancellation.24 */25export function Movers({ initial, limit = 12, compact = false, initialWindow }: { initial: MoversResponse | null; limit?: number; compact?: boolean; initialWindow?: MoverWindow }) {26  const [win, setWin] = useState<MoverWindow>(initialWindow ?? initial?.window ?? 1);27  const [category, setCategory] = useState<MoverCategory>('all');28  const [kind, setKind] = useState<MoverKindFilter>('all');29  const [cache, setCache] = useState<Record<string, MoversResponse | null>>(() => (initial ? { [`${initial.window}|all|all`]: initial } : {}));30  const [loading, setLoading] = useState(false);31  const abortRef = useRef<AbortController | null>(null);32  const key = `${win}|${category}|${kind}`;3334  useEffect(() => {35    if (cache[key] !== undefined) return;36    abortRef.current?.abort();37    const ctrl = new AbortController();38    abortRef.current = ctrl;39    setLoading(true);40    clientAnalytics41      .movers({ window: win, category, kind, limit }, ctrl.signal)42      .then((r) => setCache((c) => ({ ...c, [key]: r })))43      .catch((e) => {44        if ((e as Error).name !== 'AbortError') setCache((c) => ({ ...c, [key]: null }));45      })46      .finally(() => {47        if (!ctrl.signal.aborted) setLoading(false);48      });49    return () => ctrl.abort();50  }, [key, win, category, kind, limit, cache]);5152  const data = cache[key];53  const kinds = win === 1 ? KINDS_1 : KINDS_N;54  useEffect(() => {55    if (!kinds.includes(kind)) setKind('all');56  }, [kinds, kind]);57  const items = useMemo(() => (data?.items ?? []).slice(0, limit), [data, limit]);5859  return (60    <div className="min-w-0">61      <div className="flex flex-wrap items-center gap-2">62        <Segmented value={String(win) as '1' | '5' | '10'} onChange={(v) => setWin(Number(v) as MoverWindow)} label={t('home.movers.window')} options={WINDOWS.map((w) => ({ value: String(w) as '1' | '5' | '10', label: t(`home.movers.window.${w}` as 'home.movers.window.1') }))} size="sm" />63        <label className="inline-flex h-9 items-center gap-1 rounded-sm border border-rule bg-surface px-2 text-sm text-ink-2 md:h-8 md:text-xs">64          <span className="text-2xs uppercase tracking-wide text-ink-3">{t('home.movers.category')}</span>65          <select value={category} onChange={(e) => setCategory(e.target.value as MoverCategory)} className="bg-transparent text-ink outline-none" aria-label={t('home.movers.category')}>66            {CATEGORIES.map((c) => (67              <option key={c} value={c}>68                {t(`home.movers.cat.${c}` as 'home.movers.cat.all')}69              </option>70            ))}71          </select>72        </label>73        {data?.filter_note ? <span className="text-2xs text-ink-3">{data.filter_note}</span> : null}74      </div>75      <ul className="scrollbar-none -mx-4 mt-2 flex gap-1.5 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:px-0" role="radiogroup" aria-label={t('home.movers.kind')}>76        {kinds.map((k) => (77          <li key={k} className="shrink-0">78            <button type="button" role="radio" aria-checked={kind === k} onClick={() => setKind(k)} className={cn('inline-flex h-9 items-center rounded-sm border px-2.5 text-xs md:h-8', kind === k ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>79              {t(`home.movers.kind.${k}` as 'home.movers.kind.all')}80            </button>81          </li>82        ))}83      </ul>8485      <div className={cn('mt-3 min-h-[240px] transition-opacity', loading && 'opacity-50')} aria-busy={loading}>86        {data === null ? (87          <p className="py-6 text-sm text-ink-3">{t('common.errorHint')}</p>88        ) : data && items.length === 0 ? (89          <p className="py-6 text-sm text-ink-3">{t('home.movers.none')}</p>90        ) : (91          <ol className={cn('divide-y divide-rule border-y border-rule', compact && 'md:grid md:grid-cols-2 md:gap-x-10 md:border-y-0')}>92            {items.map((m) => (93              <MoverRow key={`${m.country.id}-${m.indicator.slug}-${m.kind}-${m.year}`} m={m} />94            ))}95          </ol>96        )}97      </div>98      <div className="mt-3 flex flex-wrap gap-x-4 text-sm">99        <Link href={routes.changes()} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-accent hover:underline">100          {t('home.changes.all')} →101        </Link>102        <Link href={routes.extremes({ window: String(win === 1 ? 1 : win) })} className="inline-flex min-h-[44px] items-center md:min-h-[36px] text-ink-2 hover:text-accent hover:underline">103          {t('home.movers.extremes')} →104        </Link>105      </div>106    </div>107  );108}109110function MoverRow({ m }: { m: MoverItem }) {111  const up = m.direction === 'up';112  const Icon = up ? ArrowUpRight : ArrowDownRight;113  const lvl = severityLevel(m.severity);114  const tone = m.interpretation === 'improvement' ? 'text-up' : m.interpretation === 'deterioration' ? 'text-down' : up ? 'text-inc' : 'text-dec';115  const deltaText = m.delta_pct != null && ['currency', 'number', 'tonnes', 'kwh'].includes(m.indicator.format ?? '') ? `${m.delta_pct > 0 ? '+' : '−'}${Math.abs(m.delta_pct).toFixed(1)} %` : m.delta != null ? `${m.delta > 0 ? '+' : '−'}${Math.abs(m.delta).toFixed(1)}${m.indicator.format === 'percent' ? ' pts' : ''}` : '';116  return (117    <li className="py-2.5 md:border-b md:border-rule">118      <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-2xs text-ink-3">119        <span className={cn('inline-block h-1.5 w-1.5 rounded-full', lvl === 'high' ? 'bg-accent' : lvl === 'medium' ? 'bg-ink-3' : 'bg-rule-strong')} aria-hidden />120        <CountryChip country={{ slug: m.country.slug ?? m.country.id, name: m.country.name ?? m.country.id, flag: m.country.flag }} size="sm" className="text-ink" />121        <span className="uppercase tracking-wide">{kindLabel(m.kind, null)}</span>122        {m.interpretation ? <span className={cn('uppercase tracking-wide', tone)}>{tOpt(`home.movers.interp.${m.interpretation}`, m.interpretation)}</span> : null}123      </div>124      <div className="mt-0.5 flex flex-wrap items-baseline gap-x-2">125        <Link href={routes.indicator(m.indicator.slug)} className="link-quiet -my-2 inline-flex min-h-[44px] items-center py-2 md:my-0 md:min-h-0 md:py-0 text-sm font-medium text-ink">126          {m.indicator.short_name ?? m.indicator.name}127        </Link>128        <span className="tnum text-sm text-ink-2">129          {m.formatted_ref ?? ''} → <span className="font-semibold text-ink">{m.formatted ?? ''}</span>130        </span>131        <span className={cn('tnum inline-flex items-center gap-0.5 text-sm font-medium', tone)}>132          <Icon size={13} aria-hidden strokeWidth={2.25} />133          {deltaText}134        </span>135        <span className="tnum text-2xs text-ink-3">136          {m.ref_year}–{m.year}137        </span>138      </div>139      {m.headline ? <p className="mt-0.5 text-xs leading-snug text-ink-2">{m.headline}</p> : null}140    </li>141  );142}143