spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Maximize2 } from 'lucide-react';3import Link from 'next/link';4import { useMemo, useState } from 'react';5import { t } from '@/i18n';6import { formatValue, grouped } from '@/lib/format';7import { routes } from '@/lib/site';8import type { FormatSpec } from '@/lib/types';9import type { FramesResponse } from '@/lib/types-analytics';10import { ChoroplethView, classFor, legendFromBreaks, type ChoroplethFeature } from '@/components/charts/choropleth-view';11import { SourceLine } from '@/components/charts/source-line';12import { YearSlider } from '@/components/controls/year-slider';13import type { ProvenancePayload } from '@/components/data/provenance-context';14import { EmptyState } from '@/components/data/empty-state';1516/** Geometry the server computes once (no values): one entry per drawn country. */17export interface BaseFeature {18 iso3: string | null;19 name: string;20 slug: string | null;21 flag: string | null;22 d: string;23}2425/**26 * Indicator world map with a time machine: every year comes from one `/indicators/{slug}/frames` payload27 * (pooled quantile legend, so colours stay comparable while scrubbing), so the slider and play button need no28 * further requests. Click/tap → country page. Hatched = no data for the selected year.29 */30export function IndicatorMap({ slug, geometry, sphere, frames, spec, payload, initialYear }: { slug: string; geometry: BaseFeature[]; sphere: string; frames: FramesResponse | null; spec: FormatSpec; payload: ProvenancePayload | null; initialYear?: number | null }) {31 const years = frames?.years ?? [];32 const [year, setYear] = useState<number>(initialYear && years.includes(initialYear) ? initialYear : years[years.length - 1] ?? new Date().getUTCFullYear());33 const idx = years.indexOf(year);3435 const { features, legend, k, n } = useMemo(() => {36 if (!frames || idx < 0) return { features: [] as ChoroplethFeature[], legend: [], k: 1, n: 0 };37 const breaks = frames.legend.breaks.slice(0, 6);38 let count = 0;39 const feats: ChoroplethFeature[] = geometry.map((g) => {40 const v = g.iso3 ? frames.values[g.iso3]?.[idx] : undefined;41 if (typeof v === 'number') count++;42 return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classFor(v, breaks) : null };43 });44 return { features: feats, legend: legendFromBreaks(breaks, frames.legend.min, frames.legend.max, spec), k: breaks.length + 1, n: count };45 }, [frames, idx, geometry, spec]);4647 if (!frames || !years.length) return <EmptyState compact title={t('indicator.map.none', { year })} hint={t('indicator.nodata.why')} />;48 const summary = t('chart.summary.map', { name: spec.name ?? slug, year, n, min: formatValue(frames.legend.min, spec), max: formatValue(frames.legend.max, spec) });49 const title = t('chart.map.legend', { name: spec.name ?? slug, year });5051 return (52 <div className="min-w-0">53 {n === 0 ? <EmptyState compact title={t('indicator.map.none', { year })} /> : <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={title} compact />}54 <div className="mt-3 flex flex-col gap-2 md:flex-row md:items-center md:gap-6">55 <YearSlider years={years} year={year} onChange={setYear} className="min-w-0 flex-1" interval={600} />56 <div className="flex shrink-0 flex-wrap items-center gap-x-3 text-xs text-ink-3">57 <span className="tnum">58 {years[0]}–{years[years.length - 1]} · {t('indicator.map.n', { n: grouped(n) })}59 </span>60 <Link href={routes.explore({ indicator: slug, year })} className="inline-flex min-h-[44px] items-center gap-1.5 text-sm text-accent hover:underline md:min-h-[32px]">61 <Maximize2 size={14} aria-hidden />62 {t('home.map.openExplorer')}63 </Link>64 </div>65 </div>66 <p className="mt-1 text-2xs text-ink-3">{t('indicator.map.legendNote')}</p>67 <div className="mt-1">68 <SourceLine provenance={frames.provenance} payload={payload} />69 </div>70 </div>71 );72}73