spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import 'server-only';2import { api, safe } from '@/lib/api';3import { apiCompare } from '@/lib/api-compare';4import { apiExplore } from '@/lib/api-explore';5import { formatValue, grouped } from '@/lib/format';6import type { Story, StoryBlock, TextParagraph, VarSpec } from '@/lib/stories';7import type { CountrySummary, FormatSpec, IndicatorCard, MapResponse, RankingResponse, Series } from '@/lib/types';8import type { TrendResponse } from '@/lib/types-explore';910/**11 * Fetches every payload a story needs (in parallel, deduplicated by request key) and resolves the text12 * variables from those payloads. All fetches are tolerant (`safe`): a missing payload makes the block render13 * an "unavailable" state and drops the sentences that depend on it.14 */15export interface StoryData {16 countries: CountrySummary[];17 trends: Map<string, TrendResponse | null>; // `${indicator}|${group}`18 maps: Map<string, MapResponse | null>; // `${indicator}|${year}` ('' = latest)19 rankings: Map<string, RankingResponse | null>; // `${indicator}|${year}|${sort}|${top}`20 series: Map<string, Series[] | null>; // `${indicator}|${countries}|${from}`21}2223const trendKey = (ind: string, group: string) => `${ind}|${group}`;24const mapKey = (ind: string, year: number | undefined) => `${ind}|${year ?? ''}`;25const rankKey = (ind: string, year: number | undefined, sort: string | undefined, top: number) => `${ind}|${year ?? ''}|${sort ?? ''}|${top}`;26const seriesKey = (ind: string, countries: string[], from: number | undefined) => `${ind}|${countries.join(',')}|${from ?? ''}`;2728export async function loadStoryData(story: Story): Promise<StoryData> {29 const trendKeys = new Set<string>();30 const mapKeys = new Set<string>();31 const rankKeys = new Set<string>();32 const seriesKeys = new Set<string>();33 const visitVar = (v: VarSpec) => {34 if (v.type === 'trend') trendKeys.add(trendKey(v.indicator, v.group ?? 'world'));35 else if (v.type === 'mapCount') mapKeys.add(mapKey(v.indicator, v.year));36 else if (v.type === 'rankTop') rankKeys.add(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3)));37 else if (v.type === 'country') seriesKeys.add(seriesKey(v.indicator, [v.country], undefined));38 else if (v.type === 'share') {39 trendKeys.add(trendKey(v.indicator, v.group));40 trendKeys.add(trendKey(v.indicator, 'world'));41 }42 };43 for (const b of story.blocks) {44 if (b.kind === 'text') for (const p of b.paragraphs) for (const v of Object.values(p.vars)) visitVar(v);45 else if (b.kind === 'map') for (const y of b.years) mapKeys.add(mapKey(b.indicator, y));46 else if (b.kind === 'trend') for (const g of b.groups ?? ['world']) trendKeys.add(trendKey(b.indicator, g));47 else if (b.kind === 'lines') seriesKeys.add(seriesKey(b.indicator, b.countries, b.from));48 else if (b.kind === 'ranked') rankKeys.add(rankKey(b.indicator, b.year, b.sort, b.top));49 else if (b.kind === 'shares') {50 for (const g of b.groups) trendKeys.add(trendKey(b.indicator, g));51 trendKeys.add(trendKey(b.indicator, 'world'));52 }53 }54 const [countriesRes, trendVals, mapVals, rankVals, seriesVals] = await Promise.all([55 safe(api.countries()),56 Promise.all([...trendKeys].map((k) => {57 const [ind, group] = k.split('|');58 // min_n 3: small groups (North America has three members) would otherwise return no points.59 return safe(apiExplore.indicatorTrend(ind!, group!, { min_n: 3 }));60 })),61 Promise.all([...mapKeys].map((k) => {62 const [ind, year] = k.split('|');63 return safe(api.indicatorMap(ind!, year ? { year: Number(year) } : {}));64 })),65 Promise.all([...rankKeys].map((k) => {66 const [ind, year, sort, top] = k.split('|');67 return safe(apiCompare.ranking(ind!, { year: year ? Number(year) : null, sort: (sort || null) as 'asc' | 'desc' | null, limit: Number(top), sparkline: false }));68 })),69 Promise.all([...seriesKeys].map((k) => {70 const [ind, cs, from] = k.split('|');71 return safe(apiExplore.seriesBundle(cs!.split(','), [ind!], from ? { from: Number(from), include_forecast: false } : { include_forecast: false }));72 })),73 ]);74 return {75 countries: countriesRes?.items ?? [],76 trends: new Map([...trendKeys].map((k, i) => [k, trendVals[i] ?? null])),77 maps: new Map([...mapKeys].map((k, i) => [k, mapVals[i] ?? null])),78 rankings: new Map([...rankKeys].map((k, i) => [k, rankVals[i] ?? null])),79 series: new Map([...seriesKeys].map((k, i) => [k, seriesVals[i]?.series ?? null])),80 };81}8283export const getTrend = (d: StoryData, ind: string, group = 'world') => d.trends.get(trendKey(ind, group)) ?? null;84export const getMap = (d: StoryData, ind: string, year?: number) => d.maps.get(mapKey(ind, year)) ?? null;85export const getRanking = (d: StoryData, b: Extract<StoryBlock, { kind: 'ranked' }>) => d.rankings.get(rankKey(b.indicator, b.year, b.sort, b.top)) ?? null;86export const getSeries = (d: StoryData, b: Extract<StoryBlock, { kind: 'lines' }>) => d.series.get(seriesKey(b.indicator, b.countries, b.from)) ?? null;8788type TrendStat = 'preferred' | 'median' | 'mean' | 'weighted_mean' | 'sum';8990function trendValue(tr: TrendResponse, year: 'first' | 'last' | number, stat: TrendStat = 'preferred'): { year: number; value: number | null; n: number } | null {91 const pts = tr.points;92 if (!pts.length) return null;93 const p = year === 'first' ? pts[0]! : year === 'last' ? pts[pts.length - 1]! : pts.find((q) => q.year === year);94 if (!p) return null;95 const key = stat === 'preferred' || !stat ? (tr.preferred as 'median' | 'mean' | 'weighted_mean' | 'sum') : stat;96 const v = (p as unknown as Record<string, number | null>)[key] ?? p.median;97 return { year: p.year, value: v ?? null, n: p.n };98}99100/** Resolve one variable to a display string, or null when it cannot be computed from the loaded data. */101export function resolveVar(v: VarSpec, d: StoryData): string | null {102 if (v.type === 'trend') {103 const tr = getTrend(d, v.indicator, v.group ?? 'world');104 if (!tr) return null;105 const r = trendValue(tr, v.year, v.stat);106 if (!r || r.value == null) return null;107 if (v.format === 'year') return String(r.year);108 if (v.format === 'n') return grouped(r.n);109 return formatValue(r.value, tr.indicator);110 }111 if (v.type === 'mapCount') {112 const m = getMap(d, v.indicator, v.year);113 if (!m || m.n === 0) return null;114 const vals = Object.values(m.values).filter((x): x is number => typeof x === 'number');115 const n = vals.filter((x) => (v.op === 'gte' ? x >= v.threshold : v.op === 'gt' ? x > v.threshold : v.op === 'lte' ? x <= v.threshold : x < v.threshold)).length;116 if (v.format === 'total') return grouped(vals.length);117 if (v.format === 'year') return String(m.year_used ?? v.year ?? '');118 if (v.format === 'pct') return `${Math.round((n / vals.length) * 100)} %`;119 return grouped(n);120 }121 if (v.type === 'rankTop') {122 const r = d.rankings.get(rankKey(v.indicator, v.year, v.sort, Math.max(v.pos ?? 1, 3)));123 const row = r?.rows[(v.pos ?? 1) - 1];124 if (!r || !row) return null;125 if (v.format === 'name') return row.country.name ?? row.country.id;126 if (v.format === 'year') return String(row.year ?? r.year_used ?? '');127 return formatValue(row.value, r.indicator);128 }129 if (v.type === 'country') {130 const s = d.series.get(seriesKey(v.indicator, [v.country], undefined))?.[0];131 if (!s) return null;132 const vals = s.values.filter((x) => x.value != null && !x.is_forecast);133 const p = v.year === 'first' ? vals[0] : v.year === 'last' ? vals[vals.length - 1] : vals.find((x) => x.year === v.year);134 if (!p) return null;135 if (v.format === 'year') return String(p.year ?? '');136 if (v.format === 'name') return s.country.name ?? s.country.id;137 return formatValue(p.value, s.indicator);138 }139 if (v.type === 'share') {140 const g = getTrend(d, v.indicator, v.group);141 const w = getTrend(d, v.indicator, 'world');142 if (!g || !w) return null;143 const gv = trendValue(g, v.year, 'sum');144 if (!gv || gv.value == null) return null;145 const wv = trendValue(w, gv.year, 'sum');146 if (!wv || !wv.value) return null;147 if (v.format === 'year') return String(gv.year);148 return `${((gv.value / wv.value) * 100).toFixed(1)} %`;149 }150 return null;151}152153/** Fill a paragraph template; null when any variable is missing (the sentence is then omitted). */154export function resolveParagraph(p: TextParagraph, d: StoryData): string | null {155 let out = p.template;156 for (const [k, spec] of Object.entries(p.vars)) {157 const val = resolveVar(spec, d);158 if (val == null) return null;159 out = out.split(`{${k}}`).join(val);160 }161 return out;162}163164export function specOf(ind: IndicatorCard): FormatSpec {165 return { format: ind.format, unit: ind.unit, unit_short: ind.unit_short, precision: ind.precision, frequency: ind.frequency, name: ind.short_name ?? ind.name, higher_is_better: ind.higher_is_better };166}167