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