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 · 199 lines tsx
Raw Blame History
1import Link from 'next/link';2import { t, tOpt } from '@/i18n';3import { formatValue } from '@/lib/format';4import { routes } from '@/lib/site';5import type { Story, StoryBlock } from '@/lib/stories';6import type { FormatSpec } from '@/lib/types';7import { LineChart, StackedArea, type LineSeries } from '@/components/charts/line-chart';8import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars';9import { pointsFromSeries } from '@/components/charts/scales';10import type { SeriesPoint } from '@/components/charts/scales';11import { EmptyState } from '@/components/data/empty-state';12import { baseFeatures } from '@/components/indicators/map-geometry';13import { getMap, getRanking, getSeries, getTrend, resolveParagraph, specOf, type StoryData } from './resolve';14import { StoryMapFrames, type StoryFrame } from './story-map-frames';1516/** Short group labels for chart legends (API names are long: "Middle East, North Africa, Afghanistan & Pakistan"). */17const GROUP_LABEL: Record<string, string> = {18  world: 'World',19  'north-america': 'North America',20  'latin-america-caribbean': 'Latin America & Caribbean',21  'europe-central-asia': 'Europe & Central Asia',22  'middle-east-north-africa': 'Middle East & North Africa',23  'south-asia': 'South Asia',24  'east-asia-pacific': 'East Asia & Pacific',25  'sub-saharan-africa': 'Sub-Saharan Africa',26  'high-income': 'High income',27  'upper-middle-income': 'Upper middle income',28  'lower-middle-income': 'Lower middle income',29  'low-income': 'Low income',30  'european-union': 'European Union',31};3233function quantileBreaks(values: number[], k = 6): number[] {34  const vals = [...values].sort((a, b) => a - b);35  const n = vals.length;36  if (n < 2) return [];37  const out: number[] = [];38  for (let i = 1; i < k; i++) {39    const pos = (i / k) * (n - 1);40    const lo = Math.floor(pos);41    const hi = Math.min(lo + 1, n - 1);42    const v = vals[lo]! + (vals[hi]! - vals[lo]!) * (pos - lo);43    if (!out.length || v > out[out.length - 1]!) out.push(v);44  }45  return out;46}4748/** Server component: renders every block of a story from the pre-loaded data. */49export function StoryBlocks({ story, data }: { story: Story; data: StoryData }) {50  // Figures are numbered 01, 02… over chart blocks only (text blocks carry no number).51  let n = 0;52  return (53    <div className="space-y-10 md:space-y-14">54      {story.blocks.map((b, i) => (55        <Block key={i} block={b} data={data} index={b.kind === 'text' ? 0 : ++n} />56      ))}57    </div>58  );59}6061function Block({ block, data, index }: { block: StoryBlock; data: StoryData; index: number }) {62  if (block.kind === 'text') {63    const paras = block.paragraphs.map((p) => resolveParagraph(p, data)).filter((s): s is string => !!s);64    if (!paras.length) return null;65    return (66      <div className="max-w-prose space-y-4 text-lg leading-relaxed text-ink md:text-xl">67        {paras.map((p, i) => (68          <p key={i} className="tnum">69            {p}70          </p>71        ))}72      </div>73    );74  }7576  if (block.kind === 'map') {77    const maps = block.years.map((y) => ({ year: y, map: getMap(data, block.indicator, y) })).filter((m) => m.map && m.map.n > 0);78    const first = maps[0]?.map;79    if (!first || !data.countries.length) return <Unavailable title={block.title} />;80    const { features, sphere } = baseFeatures(data.countries);81    const pooled: number[] = [];82    const frames: StoryFrame[] = maps.map((m) => {83      const vals = m.map!.values;84      for (const v of Object.values(vals)) if (typeof v === 'number') pooled.push(v);85      return { year: m.map!.year_used ?? m.year, values: vals, n: m.map!.n };86    });87    const breaks = quantileBreaks(pooled, 6);88    const spec = specOf(first.indicator);89    return (90      <Figure index={index} eyebrow={t('stories.block.map')} title={block.title ?? first.indicator.name ?? block.indicator} indicator={block.indicator}>91        <StoryMapFrames geometry={features} sphere={sphere} frames={frames} breaks={breaks} min={pooled.length ? Math.min(...pooled) : null} max={pooled.length ? Math.max(...pooled) : null} spec={spec} provenance={first.provenance} />92      </Figure>93    );94  }9596  if (block.kind === 'trend') {97    const groups = block.groups ?? ['world'];98    const trends = groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length);99    if (!trends.length) return <Unavailable title={block.title} />;100    const ref = trends[0]!.tr!;101    const pref = (ref.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') ?? 'median';102    const series: LineSeries[] = trends.map((x, i) => ({103      id: x.g,104      name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g,105      colorIndex: i,106      points: x.tr!.points.filter((p) => (block.from == null || p.year >= block.from) && (p as unknown as Record<string, number | null>)[pref] != null).map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p as unknown as Record<string, number | null>)[pref] ?? null })),107    }));108    const ns = ref.points.map((p) => p.n);109    const spec: FormatSpec = specOf(ref.indicator);110    return (111      <Figure index={index} eyebrow={t('stories.block.trend')} title={block.title ?? ref.indicator.name ?? block.indicator} indicator={block.indicator} note={t('stories.block.trendNote', { kind: tOpt(`stories.kind.${pref}`, pref), n: `${Math.min(...ns)}–${Math.max(...ns)}` })}>112        <LineChart series={series} spec={spec} height={300} log={block.log} provenance={ref.provenance[0] ?? null} defaultWidth={860} endLabels={false} />113      </Figure>114    );115  }116117  if (block.kind === 'lines') {118    const list = getSeries(data, block);119    if (!list || !list.length) return <Unavailable title={block.title} />;120    const series: LineSeries[] = [];121    block.countries.forEach((id, i) => {122      const s = list.find((x) => x.country.id === id);123      if (!s) return;124      const points = pointsFromSeries(s.values);125      if (points.length > 1) series.push({ id, name: s.country.name ?? id, colorIndex: i, points });126    });127    if (!series.length) return <Unavailable title={block.title} />;128    const ind = list[0]!.indicator;129    const slugs = block.countries.map((id) => list.find((x) => x.country.id === id)?.country.slug ?? id.toLowerCase());130    return (131      <Figure index={index} eyebrow={t('stories.block.lines')} title={block.title ?? ind.name ?? block.indicator} indicator={block.indicator} extra={<Link href={`${routes.compare(...slugs)}?indicator=${block.indicator}`} className="text-accent hover:underline">{t('stories.compare')} →</Link>}>132        <LineChart series={series} spec={specOf(ind)} height={300} log={block.log} provenance={list[0]!.provenance} defaultWidth={860} />133      </Figure>134    );135  }136137  if (block.kind === 'ranked') {138    const r = getRanking(data, block);139    if (!r || !r.rows.length) return <Unavailable title={block.title} />;140    const spec = specOf(r.indicator);141    const latestYear = Math.max(r.year_used ?? 0, ...r.rows.map((x) => x.year ?? 0));142    return (143      <Figure index={index} eyebrow={t('stories.block.ranked', { year: r.year_used ?? '' })} title={block.title ?? r.indicator.name ?? block.indicator} indicator={block.indicator} extra={<Link href={routes.ranking(r.indicator.slug, { year: r.year_used })} className="text-accent hover:underline">{t('stories.openRanking')} →</Link>}>144        <RankedBars rows={r.rows.map((row) => rankedRowFromCountry(row.country, row.value, row.rank, null, row.year != null && latestYear - row.year >= 2 ? row.year : null))} spec={spec} provenance={r.rows[0]?.provenance ?? null} />145      </Figure>146    );147  }148149  if (block.kind === 'shares') {150    const world = getTrend(data, block.indicator, 'world');151    const groups = block.groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length);152    if (!world || !groups.length) return <Unavailable title={block.title} />;153    const worldSum = new Map(world.points.map((p) => [p.year, p.sum]));154    const series: LineSeries[] = groups.map((x, i) => ({155      id: x.g,156      name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g,157      colorIndex: i,158      points: x.tr!.points159        .filter((p) => (block.from == null || p.year >= block.from) && p.sum != null && worldSum.get(p.year))160        .map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p.sum! / worldSum.get(p.year)!) * 100 })),161    }));162    const spec: FormatSpec = { format: 'percent', precision: 1, unit: '% of world total', name: block.title ?? block.indicator };163    const last = series.map((s) => ({ name: s.name, v: s.points[s.points.length - 1]?.value ?? null })).filter((x) => x.v != null);164    return (165      <Figure index={index} eyebrow={t('stories.block.trend')} title={block.title ?? block.indicator} indicator={block.indicator} note={last.length ? last.map((x) => `${x.name} ${formatValue(x.v, spec)}`).join(' · ') : undefined}>166        <StackedArea series={series} spec={spec} height={340} provenance={world.provenance[0] ?? null} defaultWidth={860} />167      </Figure>168    );169  }170  return null;171}172173function Figure({ index, eyebrow, title, indicator, note, extra, children }: { index: number; eyebrow: string; title: string; indicator: string; note?: string; extra?: React.ReactNode; children: React.ReactNode }) {174  return (175    <figure className="min-w-0 border-t border-rule pt-4">176      <figcaption className="mb-3 flex flex-wrap items-end justify-between gap-x-6 gap-y-1">177        <div className="min-w-0">178          <div className="eyebrow">179            <span className="tnum">{String(index).padStart(2, '0')}</span> · {eyebrow}180          </div>181          <h2 className="display mt-0.5 text-xl text-ink md:text-2xl">{title}</h2>182          {note ? <p className="tnum mt-0.5 text-xs text-ink-3">{note}</p> : null}183        </div>184        <div className="flex flex-wrap items-center gap-x-4 text-sm">185          <Link href={routes.indicator(indicator)} className="text-accent hover:underline">186            {t('stories.explore')} →187          </Link>188          {extra}189        </div>190      </figcaption>191      {children}192    </figure>193  );194}195196function Unavailable({ title }: { title?: string }) {197  return <EmptyState compact title={title ?? t('common.noDataLong')} hint={t('stories.unavailable')} />;198}199