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%
2.9 KB · 61 lines tsx
Raw Blame History
1'use client';2import { useMemo, useState } from 'react';3import { t } from '@/i18n';4import { formatValue } from '@/lib/format';5import type { FormatSpec } from '@/lib/types';6import { ChoroplethView, type ChoroplethFeature } from '@/components/charts/choropleth-view';7import { SourceLine } from '@/components/charts/source-line';8import { YearSlider } from '@/components/controls/year-slider';9import type { BaseFeature } from '@/components/indicators/indicator-map';10import type { Provenance } from '@/lib/types';1112export interface StoryFrame {13  year: number;14  values: Record<string, number | null>;15  n: number;16}1718function classIndex(value: number, breaks: number[]): number {19  let i = 0;20  while (i < breaks.length && value >= breaks[i]!) i++;21  return i;22}2324/**25 * Story map: a handful of year frames (server-fetched) behind one year slider with play, a legend shared by every26 * frame (pooled quantile breaks computed server-side) so colours mean the same thing in 1960 and today.27 */28export function StoryMapFrames({ geometry, sphere, frames, breaks, min, max, spec, provenance }: { geometry: BaseFeature[]; sphere: string; frames: StoryFrame[]; breaks: number[]; min: number | null; max: number | null; spec: FormatSpec; provenance: Provenance | null }) {29  const years = frames.map((f) => f.year);30  const [year, setYear] = useState(years[years.length - 1] ?? 0);31  const frame = frames.find((f) => f.year === year) ?? frames[frames.length - 1];32  const k = breaks.length + 1;33  const features: ChoroplethFeature[] = useMemo(34    () =>35      geometry.map((g) => {36        const v = g.iso3 && frame ? frame.values[g.iso3] : undefined;37        return { ...g, value: typeof v === 'number' ? v : null, cls: typeof v === 'number' ? classIndex(v, breaks) : null };38      }),39    [geometry, frame, breaks],40  );41  const legend = Array.from({ length: k }, (_, i) => {42    const lo = i === 0 ? min : breaks[i - 1]!;43    const hi = i === k - 1 ? max : breaks[i]!;44    return { cls: i, label: `${formatValue(lo, spec)} – ${formatValue(hi, spec)}` };45  });46  const summary = frame ? t('chart.summary.map', { name: spec.name ?? '', year: frame.year, n: frame.n, min: formatValue(min, spec), max: formatValue(max, spec) }) : t('chart.noData');47  return (48    <div className="min-w-0">49      <div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-2">50        <YearSlider years={years} year={year} onChange={setYear} interval={1400} className="min-w-0 flex-1 basis-72" compact ticks={false} label={t('stories.frame.year')} />51        <span className="tnum text-xs text-ink-3">{frame ? t('indicator.map.n', { n: frame.n }) : ''}</span>52      </div>53      <ChoroplethView features={features} sphere={sphere} legend={legend} k={k} spec={spec} summary={summary} title={t('chart.map.legend', { name: spec.name ?? '', year })} />54      <p className="mt-1 text-2xs text-ink-3">{t('stories.block.mapHint')}</p>55      <div className="mt-1">56        <SourceLine provenance={provenance} />57      </div>58    </div>59  );60}61