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%
8.7 KB · 167 lines typescript
Raw Blame History
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