import Link from 'next/link'; import { t, tOpt } from '@/i18n'; import { formatValue } from '@/lib/format'; import { routes } from '@/lib/site'; import type { Story, StoryBlock } from '@/lib/stories'; import type { FormatSpec } from '@/lib/types'; import { LineChart, StackedArea, type LineSeries } from '@/components/charts/line-chart'; import { RankedBars, rankedRowFromCountry } from '@/components/charts/ranked-bars'; import { pointsFromSeries } from '@/components/charts/scales'; import type { SeriesPoint } from '@/components/charts/scales'; import { EmptyState } from '@/components/data/empty-state'; import { baseFeatures } from '@/components/indicators/map-geometry'; import { getMap, getRanking, getSeries, getTrend, resolveParagraph, specOf, type StoryData } from './resolve'; import { StoryMapFrames, type StoryFrame } from './story-map-frames'; /** Short group labels for chart legends (API names are long: "Middle East, North Africa, Afghanistan & Pakistan"). */ const GROUP_LABEL: Record = { world: 'World', 'north-america': 'North America', 'latin-america-caribbean': 'Latin America & Caribbean', 'europe-central-asia': 'Europe & Central Asia', 'middle-east-north-africa': 'Middle East & North Africa', 'south-asia': 'South Asia', 'east-asia-pacific': 'East Asia & Pacific', 'sub-saharan-africa': 'Sub-Saharan Africa', 'high-income': 'High income', 'upper-middle-income': 'Upper middle income', 'lower-middle-income': 'Lower middle income', 'low-income': 'Low income', 'european-union': 'European Union', }; function quantileBreaks(values: number[], k = 6): number[] { const vals = [...values].sort((a, b) => a - b); const n = vals.length; if (n < 2) return []; const out: number[] = []; for (let i = 1; i < k; i++) { const pos = (i / k) * (n - 1); const lo = Math.floor(pos); const hi = Math.min(lo + 1, n - 1); const v = vals[lo]! + (vals[hi]! - vals[lo]!) * (pos - lo); if (!out.length || v > out[out.length - 1]!) out.push(v); } return out; } /** Server component: renders every block of a story from the pre-loaded data. */ export function StoryBlocks({ story, data }: { story: Story; data: StoryData }) { // Figures are numbered 01, 02… over chart blocks only (text blocks carry no number). let n = 0; return (
{story.blocks.map((b, i) => ( ))}
); } function Block({ block, data, index }: { block: StoryBlock; data: StoryData; index: number }) { if (block.kind === 'text') { const paras = block.paragraphs.map((p) => resolveParagraph(p, data)).filter((s): s is string => !!s); if (!paras.length) return null; return (
{paras.map((p, i) => (

{p}

))}
); } if (block.kind === 'map') { const maps = block.years.map((y) => ({ year: y, map: getMap(data, block.indicator, y) })).filter((m) => m.map && m.map.n > 0); const first = maps[0]?.map; if (!first || !data.countries.length) return ; const { features, sphere } = baseFeatures(data.countries); const pooled: number[] = []; const frames: StoryFrame[] = maps.map((m) => { const vals = m.map!.values; for (const v of Object.values(vals)) if (typeof v === 'number') pooled.push(v); return { year: m.map!.year_used ?? m.year, values: vals, n: m.map!.n }; }); const breaks = quantileBreaks(pooled, 6); const spec = specOf(first.indicator); return (
); } if (block.kind === 'trend') { const groups = block.groups ?? ['world']; const trends = groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length); if (!trends.length) return ; const ref = trends[0]!.tr!; const pref = (ref.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') ?? 'median'; const series: LineSeries[] = trends.map((x, i) => ({ id: x.g, name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g, colorIndex: i, points: x.tr!.points.filter((p) => (block.from == null || p.year >= block.from) && (p as unknown as Record)[pref] != null).map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p as unknown as Record)[pref] ?? null })), })); const ns = ref.points.map((p) => p.n); const spec: FormatSpec = specOf(ref.indicator); return (
); } if (block.kind === 'lines') { const list = getSeries(data, block); if (!list || !list.length) return ; const series: LineSeries[] = []; block.countries.forEach((id, i) => { const s = list.find((x) => x.country.id === id); if (!s) return; const points = pointsFromSeries(s.values); if (points.length > 1) series.push({ id, name: s.country.name ?? id, colorIndex: i, points }); }); if (!series.length) return ; const ind = list[0]!.indicator; const slugs = block.countries.map((id) => list.find((x) => x.country.id === id)?.country.slug ?? id.toLowerCase()); return (
{t('stories.compare')} →}>
); } if (block.kind === 'ranked') { const r = getRanking(data, block); if (!r || !r.rows.length) return ; const spec = specOf(r.indicator); const latestYear = Math.max(r.year_used ?? 0, ...r.rows.map((x) => x.year ?? 0)); return (
{t('stories.openRanking')} →}> 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} />
); } if (block.kind === 'shares') { const world = getTrend(data, block.indicator, 'world'); const groups = block.groups.map((g) => ({ g, tr: getTrend(data, block.indicator, g) })).filter((x) => x.tr && x.tr.points.length); if (!world || !groups.length) return ; const worldSum = new Map(world.points.map((p) => [p.year, p.sum])); const series: LineSeries[] = groups.map((x, i) => ({ id: x.g, name: GROUP_LABEL[x.g] ?? x.tr!.group.name ?? x.g, colorIndex: i, points: x.tr!.points .filter((p) => (block.from == null || p.year >= block.from) && p.sum != null && worldSum.get(p.year)) .map((p): SeriesPoint => ({ period: `${p.year}-01-01`, year: p.year, value: (p.sum! / worldSum.get(p.year)!) * 100 })), })); const spec: FormatSpec = { format: 'percent', precision: 1, unit: '% of world total', name: block.title ?? block.indicator }; const last = series.map((s) => ({ name: s.name, v: s.points[s.points.length - 1]?.value ?? null })).filter((x) => x.v != null); return (
`${x.name} ${formatValue(x.v, spec)}`).join(' · ') : undefined}>
); } return null; } function Figure({ index, eyebrow, title, indicator, note, extra, children }: { index: number; eyebrow: string; title: string; indicator: string; note?: string; extra?: React.ReactNode; children: React.ReactNode }) { return (
{String(index).padStart(2, '0')} · {eyebrow}

{title}

{note ?

{note}

: null}
{t('stories.explore')} → {extra}
{children}
); } function Unavailable({ title }: { title?: string }) { return ; }