spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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