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