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