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%
7.3 KB · 140 lines tsx
Raw Blame History
1'use client';2import { Activity, AlertTriangle, ArrowDownRight, ArrowUpRight, GitCommitHorizontal, Repeat, TrendingDown, TrendingUp, Trophy, Waves } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { t } from '@/i18n';6import { cn } from '@/lib/cn';7import { severityLevel } from '@/lib/severity';8import { routes } from '@/lib/site';9import { topicById } from '@/lib/topics';10import type { ChangeItem } from '@/lib/types';11import { kindLabel } from '@/components/data/change-list';1213const ICON: Record<string, typeof ArrowUpRight> = {14  yoy_jump: ArrowUpRight,15  yoy_drop: ArrowDownRight,16  record_high: Trophy,17  record_low: AlertTriangle,18  n_year_high: TrendingUp,19  n_year_low: TrendingDown,20  sign_flip: Repeat,21  accelerating: TrendingUp,22  decelerating: TrendingDown,23  structural_break: GitCommitHorizontal,24  trend_reversal: Repeat,25  volatility_spike: Waves,26};2728/** Topic filter chips → indicator topics. */29const FILTERS: Array<{ id: string; topics: string[] }> = [30  { id: 'all', topics: [] },31  { id: 'economy', topics: ['economy', 'government', 'trade', 'income'] },32  { id: 'population', topics: ['population'] },33  { id: 'health', topics: ['health'] },34  { id: 'energy', topics: ['energy'] },35  { id: 'climate', topics: ['climate', 'environment'] },36  { id: 'digital', topics: ['digital', 'innovation'] },37];38const PAGE = 40;3940/**41 * Country timeline 2.0: a vertical chronological rail grouped by decade → year, topic filters, kind glyphs and42 * severity emphasis (record highs/lows, sharp changes, reversals, structural breaks, volatility). Newest first.43 */44export function Timeline({ items, slug }: { items: ChangeItem[]; slug: string }) {45  const [filter, setFilter] = useState('all');46  const [shown, setShown] = useState(PAGE);47  const filtered = useMemo(() => {48    const f = FILTERS.find((x) => x.id === filter) ?? FILTERS[0]!;49    const rows = f.topics.length ? items.filter((it) => f.topics.includes(('topic' in it.indicator ? it.indicator.topic : null) ?? '')) : items;50    return [...rows].sort((a, b) => (b.year ?? 0) - (a.year ?? 0) || (b.severity ?? 0) - (a.severity ?? 0));51  }, [items, filter]);52  const visible = filtered.slice(0, shown);53  const decades = useMemo(() => {54    const m = new Map<number, Map<number, ChangeItem[]>>();55    for (const it of visible) {56      const y = it.year ?? 0;57      const d = Math.floor(y / 10) * 10;58      if (!m.has(d)) m.set(d, new Map());59      const ym = m.get(d)!;60      if (!ym.has(y)) ym.set(y, []);61      ym.get(y)!.push(it);62    }63    return Array.from(m.entries()).sort((a, b) => b[0] - a[0]);64  }, [visible]);65  const counts = useMemo(() => Object.fromEntries(FILTERS.map((f) => [f.id, f.topics.length ? items.filter((it) => f.topics.includes(('topic' in it.indicator ? it.indicator.topic : null) ?? '')).length : items.length])), [items]);6667  if (items.length === 0) return <p className="py-4 text-sm text-ink-3">{t('country.timeline.none')}</p>;68  return (69    <div className="min-w-0">70      <ul className="scrollbar-none -mx-4 flex gap-1.5 overflow-x-auto px-4 sm:mx-0 sm:flex-wrap sm:px-0" role="radiogroup" aria-label={t('country.timeline.filter')}>71        {FILTERS.filter((f) => (counts[f.id] ?? 0) > 0 || f.id === 'all').map((f) => (72          <li key={f.id} className="shrink-0">73            <button type="button" role="radio" aria-checked={filter === f.id} onClick={() => { setFilter(f.id); setShown(PAGE); }} className={cn('inline-flex h-10 items-center gap-1.5 rounded-sm border px-3 text-sm md:h-8 md:px-2.5 md:text-xs', filter === f.id ? 'border-ink bg-ink text-paper' : 'border-rule text-ink-2 hover:border-rule-strong hover:text-ink')}>74              {t(`country.timeline.f.${f.id}` as 'country.timeline.f.all')}75              <span className={cn('tnum text-2xs', filter === f.id ? 'text-paper/70' : 'text-ink-3')}>{counts[f.id] ?? 0}</span>76            </button>77          </li>78        ))}79      </ul>80      <ul className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-2xs text-ink-3" aria-label={t('common.legend')}>81        {(['record_high', 'yoy_jump', 'sign_flip', 'structural_break', 'volatility_spike'] as const).map((k) => {82          const Icon = ICON[k]!;83          return (84            <li key={k} className="inline-flex items-center gap-1">85              <Icon size={12} aria-hidden /> {kindLabel(k, 10)}86            </li>87          );88        })}89      </ul>9091      <ol className="relative mt-4 border-l-2 border-rule pl-5 md:pl-6">92        {decades.map(([decade, years]) => (93          <li key={decade} className="relative pb-6 last:pb-0">94            <span aria-hidden className="absolute -left-[calc(1.25rem+5px)] top-1 h-2 w-2 rounded-full bg-rule-strong md:-left-[calc(1.5rem+5px)]" />95            <div className="display text-lg text-ink-3">{t('country.timeline.decade', { d: decade })}</div>96            <ol className="mt-2 space-y-4">97              {Array.from(years.entries()).map(([year, events]) => (98                <li key={year} className="relative grid gap-x-4 sm:grid-cols-[3.25rem_minmax(0,1fr)]">99                  <span aria-hidden className="absolute -left-[calc(1.25rem+4px)] top-2 h-1.5 w-1.5 rounded-full bg-accent md:-left-[calc(1.5rem+4px)]" />100                  <span className="tnum display text-xl leading-none text-ink">{year}</span>101                  <ul className="mt-1 space-y-1.5 sm:mt-0">102                    {events.map((e, i) => {103                      const ind = e.indicator;104                      const indSlug = (ind as { slug?: string; id: string }).slug ?? ind.id;105                      const topic = 'topic' in ind ? topicById(ind.topic ?? '')?.id : undefined;106                      const href = topic ? routes.countryIndicator(slug, topic, indSlug) : routes.indicator(indSlug);107                      const lvl = severityLevel(e.severity);108                      const Icon = ICON[e.kind ?? ''] ?? Activity;109                      return (110                        <li key={e.id ?? i} className={cn('text-sm leading-snug', lvl === 'high' ? 'text-ink' : 'text-ink-2')}>111                          <Link href={href} className="link-quiet group/e flex items-start gap-2">112                            <span className={cn('mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-xs', lvl === 'high' ? 'bg-accent-soft text-accent' : 'bg-surface-2 text-ink-3')} aria-hidden>113                              <Icon size={12} />114                            </span>115                            <span className="min-w-0">116                              <span className="mr-1.5 text-2xs uppercase tracking-wide text-ink-3">{kindLabel(e.kind, e.window_years)}</span>117                              <span className={cn(lvl === 'high' && 'font-medium')}>{e.headline}</span>118                            </span>119                          </Link>120                        </li>121                      );122                    })}123                  </ul>124                </li>125              ))}126            </ol>127          </li>128        ))}129      </ol>130      {filtered.length > shown ? (131        <div className="mt-4">132          <button type="button" onClick={() => setShown((s) => s + PAGE)} className="inline-flex h-11 items-center rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:h-10">133            {t('common.showMore', { n: Math.min(PAGE, filtered.length - shown) })}134          </button>135        </div>136      ) : null}137    </div>138  );139}140