#!/usr/bin/env node // Deterministic generator for the /research/ report family (issue #5668). // // Consumes a committed transit snapshot (docs/snapshots/) plus a report // definition (shared/research-reports/) and renders: the report page, the // /research/ hub, and the machine-readable CSV/JSON downloads. Every // quantitative value on the page is a computed metric with a documented // observation period, transformation, and source; prose references metrics // as {{m:}} tokens and the build fails on an unresolved token so text can // never drift from the data. No network access; inputs are repo files only. // // Template functions are injected by build-crawlable-corpus.mjs (the single // owner of the corpus HTML shell) to avoid a circular import. import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; // The website ID must match UMAMI_WEBSITE_ID in src/services/analytics.ts. export const UMAMI_SCRIPT_TAG = ''; const DATASET_LICENSE = { '@type': 'CreativeWork', name: 'World Monitor Terms of Service (27 July 2026)', url: 'https://www.worldmonitor.app/docs/terms', }; // Mirrors WORLD_MONITOR_ORG in scripts/build-crawlable-corpus.mjs. Declared here // rather than imported because that module injects this one's template functions, // so importing back would close a cycle. Carries `@type` + `name` alongside the // `@id`: parsers resolve `@id` within one document and no /research/ page declares // the canonical Organization, so a bare reference would not resolve (#7459b). const WORLD_MONITOR_ORG = Object.freeze({ '@id': 'https://www.worldmonitor.app/#organization', '@type': 'Organization', name: 'World Monitor', url: 'https://www.worldmonitor.app/', }); const CHART_WIDTH = 720; const LINE_CHART_HEIGHT = 260; const BAR_CHART_HEIGHT = 240; const CHART_MARGIN = { top: 18, right: 16, bottom: 34, left: 44 }; function round1(value) { return Math.round(value * 10) / 10; } // Throws rather than returning null: a fabricated 0.0 for an absent window is // indistinguishable from a real blockade reading, so averaging nothing is a // build failure, never a value. export function mean(rows, field) { if (!rows.length) { throw new Error(`No rows to average for "${field}" — refusing to fabricate a value for missing data`); } return rows.reduce((total, row) => total + row[field], 0) / rows.length; } function inRange(history, start, end) { return history.filter((row) => row.date >= start && row.date <= end); } const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; function isIsoCalendarDate(value) { if (!ISO_DATE_PATTERN.test(value ?? '')) return false; const parsed = new Date(`${value}T00:00:00Z`); return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; } function assertCompleteDailyCoverage(id, series) { const history = series?.history; if (!Array.isArray(history) || history.length === 0) { throw new Error(`${id} has incomplete daily coverage: no observations`); } const start = series.observationStart; const end = series.observationEnd; if (!isIsoCalendarDate(start) || !isIsoCalendarDate(end) || start > end) { throw new Error( `${id} has incomplete daily coverage: invalid observation range ${start ?? 'unknown'} → ${end ?? 'unknown'}`, ); } const present = new Set(); let previousDate = null; for (const row of history) { const date = row?.date; if (!isIsoCalendarDate(date)) { throw new Error(`${id} has incomplete daily coverage: invalid observation date ${date ?? 'unknown'}`); } if (previousDate !== null && date <= previousDate) { throw new Error( `${id} has incomplete daily coverage: dates must be unique and ascending (${previousDate}, ${date})`, ); } present.add(date); previousDate = date; } const missing = []; const cursor = new Date(`${start}T00:00:00Z`); const last = new Date(`${end}T00:00:00Z`); for (; cursor <= last; cursor.setUTCDate(cursor.getUTCDate() + 1)) { const date = cursor.toISOString().slice(0, 10); if (!present.has(date)) missing.push(date); } if (missing.length > 0) { const preview = missing.slice(0, 5).join(', '); const remainder = missing.length > 5 ? ` (+${missing.length - 5} more)` : ''; throw new Error(`${id} has incomplete daily coverage: missing ${preview}${remainder}`); } if (history[0].date !== start || history.at(-1).date !== end) { throw new Error( `${id} has incomplete daily coverage: history bounds do not match ${start} → ${end}`, ); } if (series.rowCount !== history.length) { throw new Error( `${id} has incomplete daily coverage: rowCount ${series.rowCount} does not match ${history.length} observations`, ); } if (!Array.isArray(series.missingDates) || series.missingDates.length > 0) { throw new Error( `${id} has incomplete daily coverage: missingDates metadata must be an empty array`, ); } } const SHORT_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; function monthLabel(isoMonth) { const [year, month] = isoMonth.split('-').map(Number); return `${SHORT_MONTHS[month - 1]} ${year}`; } function monthDayLabel(isoDate) { const [, month, day] = isoDate.split('-').map(Number); return `${SHORT_MONTHS[month - 1]} ${day}`; } // "Jul 1–19 2026" for an observation window that ends mid-month. function partialRangeLabel(isoDate) { const [year, month, day] = isoDate.split('-').map(Number); return `${SHORT_MONTHS[month - 1]} 1–${day} ${year}`; } function formatInt(value) { return Math.round(value).toLocaleString('en-US'); } // --------------------------------------------------------------------------- // Metrics // --------------------------------------------------------------------------- /** * Compute every published figure for a chokepoint transit report. Each metric * carries the observation period, the transformation ("method"), and a * machine-readable raw value alongside its formatted display string, so the * page, the downloads, and the provenance table stay in agreement by * construction. */ // The metric windows below are the 2026-07 edition's analysis, not generic // arithmetic. A future edition must update them deliberately; this tripwire // turns "silently republish July's analysis under an August title" into a // build failure. const METRIC_WINDOWS_EDITION = '2026-07'; export function computeReportMetrics(snapshot, report) { if (report.edition !== METRIC_WINDOWS_EDITION || snapshot.edition !== report.edition) { throw new Error( `computeReportMetrics implements the ${METRIC_WINDOWS_EDITION} edition windows; ` + `got report edition ${report.edition} with snapshot edition ${snapshot.edition}. ` + 'Update the metric windows for the new edition before publishing.', ); } const focus = snapshot.chokepoints[report.focusChokepointId]; if (!focus) throw new Error(`Snapshot has no focus chokepoint ${report.focusChokepointId}`); const publishedSeriesIds = new Set([ ...Object.keys(snapshot.chokepoints), report.focusChokepointId, ...(report.contextChokepointIds ?? []), ]); for (const id of publishedSeriesIds) { const series = snapshot.chokepoints[id]; if (!series) throw new Error(`Snapshot has no published chokepoint ${id}`); assertCompleteDailyCoverage(id, series); } const history = focus.history; const observationEnd = focus.observationEnd; const capturedAtDate = String(snapshot.capturedAt).slice(0, 10); const source = `IMF PortWatch snapshot ${snapshot.snapshotId} (retrieved ${capturedAtDate})`; const metrics = new Map(); const add = (id, metric) => { if (metrics.has(id)) throw new Error(`Duplicate metric id: ${id}`); metrics.set(id, { id, source, ...metric }); }; const addDailyAvg = (id, label, start, end, field = 'total', extra = {}) => { const rows = inRange(history, start, end); if (!rows.length) throw new Error(`No rows for metric ${id} (${start} → ${end})`); add(id, { label, kind: 'number', value: round1(mean(rows, field)), unit: 'transits/day', period: `${start} → ${end}`, method: `mean of daily ${field} transit calls`, ...extra, }); }; const feb = ['2026-02-01', '2026-02-28']; addDailyAvg('febAvgTotal', 'February 2026 daily average', ...feb); addDailyAvg('febAvgTanker', 'February 2026 tanker daily average', ...feb, 'tanker'); addDailyAvg('avg2025Total', '2025 full-year daily average', '2025-01-01', '2025-12-31'); addDailyAvg('marAvgTotal', 'March 2026 daily average', '2026-03-01', '2026-03-31'); addDailyAvg('aprAvgTotal', 'April 2026 daily average', '2026-04-01', '2026-04-30'); addDailyAvg('mayAvgTotal', 'May 2026 daily average', '2026-05-01', '2026-05-31'); addDailyAvg('junAvgTotal', 'June 2026 daily average', '2026-06-01', '2026-06-30'); addDailyAvg('junAvgTanker', 'June 2026 tanker daily average', '2026-06-01', '2026-06-30', 'tanker'); addDailyAvg('jun25AvgTanker', 'June 2025 tanker daily average', '2025-06-01', '2025-06-30', 'tanker'); addDailyAvg( 'julToDateAvgTotal', 'July 2026 daily average (partial month)', '2026-07-01', observationEnd, ); const mar1 = inRange(history, '2026-03-01', '2026-03-01'); if (!mar1.length) throw new Error('Snapshot is missing the 2026-03-01 observation required by mar1Total'); add('mar1Total', { label: 'Transits on 2026-03-01', kind: 'count', value: mar1[0].total, unit: 'transits', period: '2026-03-01', method: 'daily total transit calls', }); const disruption = inRange(history, '2026-03-01', observationEnd); const disruptionSum = disruption.reduce((total, row) => total + row.total, 0); add('disruptionDays', { label: 'Days in disruption window', kind: 'count', value: disruption.length, unit: 'days', period: `2026-03-01 → ${observationEnd}`, method: 'count of observed days', }); add('disruptionTransits', { label: 'Total transits since 2026-03-01', kind: 'count', value: disruptionSum, unit: 'transits', period: `2026-03-01 → ${observationEnd}`, method: 'sum of daily total transit calls', }); add('disruptionDailyAvg', { label: 'Daily average since 2026-03-01', kind: 'number', value: round1(disruptionSum / disruption.length), unit: 'transits/day', period: `2026-03-01 → ${observationEnd}`, method: 'sum of daily totals ÷ observed days', }); const sameWindow2025 = inRange(history, '2025-03-01', observationEnd.replace('2026-', '2025-')); const sameWindow2025Sum = sameWindow2025.reduce((total, row) => total + row.total, 0); add('sameWindow2025Transits', { label: 'Total transits, same 2025 window', kind: 'count', value: sameWindow2025Sum, unit: 'transits', period: `2025-03-01 → ${observationEnd.replace('2026-', '2025-')}`, method: 'sum of daily total transit calls', }); add('sameWindow2025DailyAvg', { label: 'Daily average, same 2025 window', kind: 'number', value: round1(sameWindow2025Sum / sameWindow2025.length), unit: 'transits/day', period: `2025-03-01 → ${observationEnd.replace('2026-', '2025-')}`, method: 'sum of daily totals ÷ observed days', }); add('declinePct', { label: 'Transit decline vs same 2025 window', kind: 'percent', value: round1((1 - disruptionSum / sameWindow2025Sum) * 100), unit: '%', period: `2026-03-01 → ${observationEnd} vs 2025 equivalent`, method: '1 − (2026 window sum ÷ 2025 window sum)', }); add('julVsFebShare', { label: 'July 2026 (partial) as share of February 2026', kind: 'percent-round', value: Math.round((metrics.get('julToDateAvgTotal').value / metrics.get('febAvgTotal').value) * 100), unit: '%', period: `2026-07-01 → ${observationEnd} vs 2026-02`, method: 'July partial-month daily average ÷ February daily average', }); const marZero = inRange(history, '2026-03-01', '2026-03-31').filter((row) => row.total === 0); const allZero = disruption.filter((row) => row.total === 0); const preCollapse = history.filter((row) => row.date < '2026-03-01'); add('marZeroDays', { label: 'Zero-transit days in March 2026', kind: 'count', value: marZero.length, unit: 'days', period: '2026-03-01 → 2026-03-31', method: 'count of days with total = 0', }); add('zeroTransitDays', { label: 'Zero-transit days since 2026-03-01', kind: 'count', value: allZero.length, unit: 'days', period: `2026-03-01 → ${observationEnd}`, method: 'count of days with total = 0', }); add('preCollapseMinTotal', { label: 'Pre-collapse daily minimum (2019 → February 2026)', kind: 'count', value: Math.min(...preCollapse.map((row) => row.total)), unit: 'transits', period: `${focus.observationStart} → 2026-02-28`, method: 'minimum daily total transit calls', }); const peak = disruption.reduce((max, row) => (row.total > max.total ? row : max), { total: -1 }); add('peakRecoveryDate', { label: 'Strongest day since the collapse', kind: 'date', value: peak.date, period: `2026-03-01 → ${observationEnd}`, method: 'date of maximum daily total', }); add('peakRecoveryTotal', { label: 'Transits on the strongest post-collapse day', kind: 'count', value: peak.total, unit: 'transits', period: peak.date, method: 'daily total transit calls', }); add('peakVsFebNote', { label: 'Strongest post-collapse day vs February average', kind: 'percent-round', value: Math.round((1 - peak.total / metrics.get('febAvgTotal').value) * 100), unit: '%', period: `${peak.date} vs 2026-02`, method: '1 − (peak daily total ÷ February daily average)', }); const last7 = history.slice(-7); add('last7Avg', { label: 'Final observed week daily average', kind: 'number', value: round1(mean(last7, 'total')), unit: 'transits/day', period: `${last7[0].date} → ${observationEnd}`, method: 'mean of daily total transit calls', }); const dwt = (id, label, start, end) => { const rows = inRange(history, start, end); add(id, { label, kind: 'number2', value: Math.round((mean(rows, 'capTanker') / 1e6) * 100) / 100, unit: 'million DWT/day', period: `${start} → ${end}`, method: 'mean of daily aggregate tanker deadweight tonnage ÷ 10⁶', }); }; dwt('febAvgTankerDwtM', 'February 2026 tanker capacity', '2026-02-01', '2026-02-28'); dwt('junAvgTankerDwtM', 'June 2026 tanker capacity', '2026-06-01', '2026-06-30'); add('observationEnd', { label: 'Observation window end', kind: 'date', value: observationEnd, period: observationEnd, method: 'latest date in the retrieved series', }); add('capturedAtDate', { label: 'Snapshot retrieval date', kind: 'date', value: capturedAtDate, period: capturedAtDate, method: 'UTC date of the snapshot retrieval', }); add('missingDatesTotal', { label: 'Missing days across all series', kind: 'count', value: Object.values(snapshot.chokepoints) .reduce((total, chokepoint) => total + chokepoint.missingDates.length, 0), unit: 'days', period: `${focus.observationStart} → ${observationEnd}`, method: 'sum of enumerated calendar gaps across snapshot series', }); return metrics; } export function formatMetric(metric) { switch (metric.kind) { case 'number': return metric.value.toFixed(1); case 'number2': return metric.value.toFixed(2); case 'count': return formatInt(metric.value); case 'percent': return `${metric.value.toFixed(1)}%`; case 'percent-round': return `${formatInt(metric.value)}%`; case 'date': return metric.value; default: throw new Error(`Unknown metric kind: ${metric.kind}`); } } // Replace {{m:id}} tokens with /