spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { ArrowRight, ExternalLink, Scale, X } from 'lucide-react';3import Link from 'next/link';4import { t } from '@/i18n';5import { cn } from '@/lib/cn';6import { formatValue, grouped, ordinal } from '@/lib/format';7import { regionShort } from '@/lib/regions';8import { routes } from '@/lib/site';9import type { FormatSpec, Provenance } from '@/lib/types';10import type { PointCountry } from '@/lib/types-analytics';11import { seqVar } from '@/components/charts/palette';12import { Sparkline } from '@/components/charts/sparkline';13import type { SeriesPoint } from '@/components/charts/scales';14import { useProvenance } from '@/components/data/provenance-context';15import { stepFor } from './geo';1617export interface CountryYearInfo {18 country: PointCountry;19 value: number | null;20 year: number;21 rankWorld: number | null;22 nWorld: number;23 rankRegion: number | null;24 nRegion: number;25 series: SeriesPoint[];26 firstYear: number | null;27 lastYear: number | null;28 latestValue: number | null;29 latestYear: number | null;30}3132/** Legend for the pooled quantile classes (stable while scrubbing). */33export function ClassLegend({ breaks, min, max, spec, k, compact, className }: { breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; k: number; compact?: boolean; className?: string }) {34 const items = Array.from({ length: k }, (_, i) => {35 const lo = i === 0 ? min : breaks[i - 1]!;36 const hi = i === k - 1 ? max : breaks[i]!;37 return { cls: i, lo, hi };38 });39 return (40 <ol className={cn('flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-ink-2', className)} aria-label={t('common.legend')}>41 {items.map((it) => (42 <li key={it.cls} className="inline-flex items-center gap-1 tnum">43 <span aria-hidden className="inline-block h-2.5 w-3.5 rounded-xs" style={{ background: seqVar(stepFor(it.cls, k)) }} />44 {compact ? (it.cls === 0 ? formatValue(it.lo, spec) : it.cls === k - 1 ? formatValue(it.hi, spec) : '') : `${formatValue(it.lo, spec)} – ${formatValue(it.hi, spec)}`}45 </li>46 ))}47 <li className="inline-flex items-center gap-1">48 <span aria-hidden className="no-data-hatch inline-block h-2.5 w-3.5 rounded-xs" />49 {t('chart.legend.noData')}50 </li>51 </ol>52 );53}5455/** Floating hover label on the map: country · value · year · world / regional rank. */56export function MapTooltip({ info, spec, x, y, sticky, onOpen }: { info: CountryYearInfo; spec: FormatSpec; x: number; y: number; sticky: boolean; onOpen: () => void }) {57 return (58 <div className="pointer-events-none absolute z-10 max-w-[240px] rounded-sm border border-rule bg-surface/95 px-2.5 py-1.5 text-xs shadow-pop backdrop-blur" style={{ left: Math.min(x + 12, Math.max(0, x + 12)), top: Math.max(4, y - 64) }}>59 <div className="flex items-center gap-1.5 font-medium text-ink">60 {info.country.flag ? <span aria-hidden>{info.country.flag}</span> : null}61 <span className="truncate">{info.country.name}</span>62 </div>63 <div className="tnum text-ink">64 <span className="font-semibold">{formatValue(info.value, spec)}</span>65 <span className="text-ink-3"> · {info.year}</span>66 </div>67 {info.value != null && info.rankWorld != null ? (68 <div className="tnum text-2xs text-ink-2">69 {t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}70 {info.rankRegion != null && info.country.region ? ` · ${t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(info.country.region) ?? info.country.region })}` : ''}71 </div>72 ) : info.value == null ? (73 <div className="text-2xs text-ink-3">{t('explorer.noValueYear', { year: info.year })}</div>74 ) : null}75 {sticky ? (76 <button type="button" onClick={onOpen} className="pointer-events-auto mt-1 inline-flex min-h-[32px] items-center text-accent underline">77 {t('explorer.drawer.details')} →78 </button>79 ) : null}80 </div>81 );82}8384/** Country drawer content (right panel on desktop, bottom sheet on phones). */85export function CountryPanel({ info, spec, indicatorSlug, indicatorName, provenance, onClose, onCompareHref, trajectoriesHref, closeClassName }: { info: CountryYearInfo; spec: FormatSpec; indicatorSlug: string; indicatorName: string; provenance: Provenance | null; onClose: () => void; onCompareHref: string; trajectoriesHref: string; closeClassName?: string }) {86 const { open } = useProvenance();87 const c = info.country;88 const slug = c.slug ?? c.id.toLowerCase();89 const dir = info.series.length >= 2 ? Math.sign((info.series[info.series.length - 1]!.value ?? 0) - (info.series[info.series.length - 2]!.value ?? 0)) : 0;90 return (91 <div className="flex h-full min-h-0 flex-col">92 <div className="flex items-start gap-3 border-b border-rule pb-3">93 <span aria-hidden className="text-4xl leading-none">94 {c.flag}95 </span>96 <div className="min-w-0 flex-1">97 <h2 className="display truncate text-xl text-ink">{c.name}</h2>98 <p className="text-xs text-ink-3">99 {regionShort(c.region) ?? c.region ?? ''}100 {c.income ? ` · ${c.income}` : ''}101 </p>102 </div>103 <button type="button" onClick={onClose} className={cn('tap -mr-2 grid place-items-center rounded-sm text-ink-2 hover:bg-surface-2 md:min-h-[32px] md:min-w-[32px]', closeClassName)} aria-label={t('common.close')}>104 <X size={18} aria-hidden />105 </button>106 </div>107 <div className="min-h-0 flex-1 overflow-y-auto py-3">108 <div className="text-xs font-medium text-ink-2">{indicatorName}</div>109 <button110 type="button"111 onClick={() =>112 open({113 indicator: { slug: indicatorSlug, name: indicatorName, format: spec.format, unit: spec.unit, unit_short: spec.unit_short, precision: spec.precision, frequency: 'A', higher_is_better: spec.higher_is_better },114 value: info.value != null ? { value: info.value, period: `${info.year}-01-01`, year: info.year, unit: spec.unit, provenance } : null,115 country: { id: c.id, slug: c.slug, name: c.name ?? c.id, flag: c.flag },116 })117 }118 className="-mx-1 mt-1 flex min-h-[44px] flex-col items-start rounded-sm px-1 text-left hover:bg-surface-2"119 aria-label={t('common.openProvenance')}120 >121 <span className="pnum text-3xl font-semibold leading-none text-ink">{info.value != null ? formatValue(info.value, spec) : <span className="text-ink-3">{t('common.noData')}</span>}</span>122 <span className="tnum mt-1 text-xs text-ink-3">123 {info.year}124 {info.value == null && info.latestValue != null ? ` · ${t('explorer.drawer.latest', { value: formatValue(info.latestValue, spec), year: info.latestYear ?? '' })}` : ''}125 </span>126 </button>127 {info.value != null && info.rankWorld != null ? (128 <p className="tnum mt-2 text-sm text-ink-2">129 <span className="font-medium text-ink">{t('explorer.rank.world', { rank: ordinal(info.rankWorld), n: grouped(info.nWorld) })}</span>130 {info.rankRegion != null && c.region ? <span className="text-ink-3"> · {t('explorer.rank.region', { rank: ordinal(info.rankRegion), region: regionShort(c.region) ?? c.region })}</span> : null}131 </p>132 ) : null}133 {info.series.length >= 2 ? (134 <div className="mt-4">135 <div className="flex items-baseline justify-between text-2xs text-ink-3">136 <span>{t('explorer.drawer.history', { y0: info.firstYear ?? '', y1: info.lastYear ?? '' })}</span>137 </div>138 <Sparkline points={info.series} width={320} height={64} className="mt-1 h-16 w-full" direction={dir > 0 ? 'up' : dir < 0 ? 'down' : 'flat'} ariaLabel={t('explorer.drawer.sparkAria', { name: c.name ?? c.id, indicator: indicatorName })} />139 <div className="tnum flex justify-between text-2xs text-ink-3">140 <span>{info.firstYear}</span>141 <span>{info.lastYear}</span>142 </div>143 </div>144 ) : null}145 <div className="mt-5 flex flex-col gap-2">146 <Link href={routes.country(slug)} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink md:min-h-[38px]">147 {t('explorer.drawer.open')} <ArrowRight size={15} aria-hidden />148 </Link>149 <Link href={onCompareHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">150 <Scale size={15} aria-hidden /> {t('explorer.drawer.compare')}151 </Link>152 <Link href={trajectoriesHref} className="inline-flex min-h-[44px] items-center justify-center gap-2 rounded-sm border border-rule px-4 text-sm text-ink hover:bg-surface-2 md:min-h-[38px]">153 <ExternalLink size={14} aria-hidden /> {t('explorer.drawer.trajectories')}154 </Link>155 <Link href={routes.countryIndicator(slug, topicOf(indicatorSlug), indicatorSlug)} className="inline-flex min-h-[44px] items-center text-sm text-accent hover:underline md:min-h-[32px]">156 {t('explorer.drawer.series')} →157 </Link>158 </div>159 </div>160 </div>161 );162}163164/** Best-effort topic for the country topic page link (falls back to the indicator page when unknown). */165function topicOf(slug: string): string {166 return TOPIC_HINT[slug] ?? 'economy';167}168const TOPIC_HINT: Record<string, string> = {169 population: 'population', 'population-growth': 'population', 'median-age': 'population', 'fertility-rate': 'population', 'urban-population-share': 'population',170 gdp: 'economy', 'gdp-per-capita': 'economy', 'gdp-per-capita-ppp': 'economy', 'gdp-growth': 'economy', inflation: 'economy',171 'unemployment-rate': 'labor', 'life-expectancy': 'health', 'infant-mortality-rate': 'health', 'general-government-gross-debt-pct-gdp': 'government',172 'co2-per-capita': 'climate', 'co2-emissions': 'climate', 'renewable-electricity-share': 'energy', 'energy-use-per-capita': 'energy', 'internet-users': 'digital',173};174