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%
10.1 KB · 174 lines tsx
Raw Blame History
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