scripts/build-research-reports.mjs
Rama main · réplica de lectura del repositorio original.
#!/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:<id>}} 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 =
'<script async defer src="https://abacus.worldmonitor.app/script.js" '
+ 'data-website-id="e8800335-c853-46a8-8497-c993ed2f58bc" '
+ 'data-domains="worldmonitor.app,www.worldmonitor.app,tech.worldmonitor.app,finance.worldmonitor.app,commodity.worldmonitor.app,happy.worldmonitor.app" '
+ 'nonce="wm-static-bootstrap"></script>';
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 <data>/<time> elements carrying the raw value,
// so tests can reconcile visible text, structured data, and downloads.
export function resolveMetricTokens(text, metrics, escapeHtml) {
return String(text).replace(/\{\{m:([a-zA-Z0-9]+)\}\}/g, (_, id) => {
const metric = metrics.get(id);
if (!metric) throw new Error(`Unresolved metric token: ${id}`);
const formatted = formatMetric(metric);
if (metric.kind === 'date') {
return `<time datetime="${escapeHtml(metric.value)}">${escapeHtml(metric.value)}</time>`;
}
return `<data data-metric="${escapeHtml(metric.id)}" value="${escapeHtml(String(metric.value))}">${escapeHtml(formatted)}</data>`;
});
}
// Plain-text variant for surfaces that cannot carry markup (meta description,
// JSON-LD, alt text). Same fail-closed contract as resolveMetricTokens.
export function resolvePlainMetricTokens(text, metrics) {
return String(text).replace(/\{\{m:([a-zA-Z0-9]+)\}\}/g, (_, id) => {
const metric = metrics.get(id);
if (!metric) throw new Error(`Unresolved metric token: ${id}`);
return formatMetric(metric);
});
}
// Backstop for tokens the resolver regex does not recognize (a snake_case or
// spaced id would otherwise ship literally): every rendered research document
// passes through this before being returned.
export function assertNoUnresolvedTokens(html) {
const match = html.match(/\{\{m:[^}]*\}\}/);
if (match) throw new Error(`Unresolved metric token in output: ${match[0]}`);
}
// ---------------------------------------------------------------------------
// Charts (inline SVG, no JavaScript requirement)
// ---------------------------------------------------------------------------
function svgScaleFactory(domainMax, plotWidth, plotHeight) {
return {
x: (index, count) => CHART_MARGIN.left + (index / Math.max(count - 1, 1)) * plotWidth,
y: (value) => CHART_MARGIN.top + plotHeight - (value / domainMax) * plotHeight,
};
}
function gridAndAxis(scale, domainMax, step, plotWidth) {
const lines = [];
for (let tick = 0; tick <= domainMax; tick += step) {
const y = scale.y(tick);
lines.push(
`<line x1="${CHART_MARGIN.left}" y1="${y}" x2="${CHART_MARGIN.left + plotWidth}" y2="${y}" stroke="#1b2b22" stroke-width="1"/>`,
`<text x="${CHART_MARGIN.left - 8}" y="${y + 4}" text-anchor="end" fill="#a8b8ad" font-size="11">${tick}</text>`,
);
}
return lines.join('');
}
export function renderDailyLineChart(history, { title, description, collapseDate, escapeHtml }) {
const plotWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right;
const plotHeight = LINE_CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom;
const domainMax = 160;
const scale = svgScaleFactory(domainMax, plotWidth, plotHeight);
const points = history.map((row, index) => {
const x = scale.x(index, history.length);
const y = scale.y(Math.min(row.total, domainMax));
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
const monthTicks = history
.map((row, index) => ({ row, index }))
.filter(({ row }) => row.date.endsWith('-01'))
.map(({ row, index }) => {
const x = scale.x(index, history.length);
return `<text x="${x.toFixed(1)}" y="${LINE_CHART_HEIGHT - 12}" text-anchor="middle" fill="#a8b8ad" font-size="11">${monthLabel(row.date.slice(0, 7))}</text>`;
})
.join('');
const collapseIndex = history.findIndex((row) => row.date === collapseDate);
if (collapseIndex === -1) throw new Error(`Daily chart annotation date ${collapseDate} is not in the plotted series`);
const collapseX = scale.x(collapseIndex, history.length);
const peak = history.reduce(
(max, row, index) => (row.date >= collapseDate && row.total > max.total ? { ...row, index } : max),
{ total: -1, index: -1 },
);
const peakX = scale.x(peak.index, history.length);
const peakY = scale.y(peak.total);
return `<figure role="group" aria-labelledby="daily-chart-caption">
<svg viewBox="0 0 ${CHART_WIDTH} ${LINE_CHART_HEIGHT}" role="img" aria-label="${escapeHtml(title)}" style="width:100%;height:auto" preserveAspectRatio="xMidYMid meet">
<title>${escapeHtml(title)}</title>
<desc>${escapeHtml(description)}</desc>
${gridAndAxis(scale, domainMax, 40, plotWidth)}
<line x1="${collapseX.toFixed(1)}" y1="${CHART_MARGIN.top}" x2="${collapseX.toFixed(1)}" y2="${CHART_MARGIN.top + plotHeight}" stroke="#a8b8ad" stroke-width="1" stroke-dasharray="4 4"/>
<text x="${(collapseX + 6).toFixed(1)}" y="${CHART_MARGIN.top + 12}" fill="#eef8f0" font-size="11">${escapeHtml(monthDayLabel(collapseDate))} — collapse begins</text>
<polyline points="${points.join(' ')}" fill="none" stroke="#4ade80" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${peakX.toFixed(1)}" cy="${peakY.toFixed(1)}" r="4" fill="#4ade80" stroke="#0c1210" stroke-width="2"/>
<text x="${(peakX - 6).toFixed(1)}" y="${(peakY - 8).toFixed(1)}" text-anchor="end" fill="#eef8f0" font-size="11">${escapeHtml(monthDayLabel(peak.date))} — ${peak.total} transits</text>
${monthTicks}
</svg>
<figcaption id="daily-chart-caption">${escapeHtml(description)}</figcaption>
</figure>`;
}
export function renderMonthlyBarChart(monthlyRows, { title, description, partialMonth, partialThrough, escapeHtml }) {
const plotWidth = CHART_WIDTH - CHART_MARGIN.left - CHART_MARGIN.right;
const plotHeight = BAR_CHART_HEIGHT - CHART_MARGIN.top - CHART_MARGIN.bottom;
const domainMax = 120;
const scale = svgScaleFactory(domainMax, plotWidth, plotHeight);
const slot = plotWidth / monthlyRows.length;
const barWidth = Math.floor(slot) - 6;
const labelledMonths = new Set(['2026-02', '2026-03', '2026-06', '2026-07']);
const bars = monthlyRows.map((row, index) => {
const x = CHART_MARGIN.left + index * slot + 3;
const y = scale.y(row.avg);
const height = CHART_MARGIN.top + plotHeight - y;
const isPartial = row.month === partialMonth;
const label = labelledMonths.has(row.month)
? `<text x="${(x + barWidth / 2).toFixed(1)}" y="${(y - 6).toFixed(1)}" text-anchor="middle" fill="#eef8f0" font-size="11">${row.avg.toFixed(row.avg < 20 ? 1 : 0)}</text>`
: '';
return `<g><rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barWidth}" height="${Math.max(height, 1).toFixed(1)}" rx="2" fill="#4ade80"${isPartial ? ' fill-opacity="0.55" stroke="#4ade80" stroke-dasharray="3 3"' : ''}><title>${escapeHtml(`${monthLabel(row.month)}${isPartial ? ` (partial: through ${partialThrough})` : ''}: ${row.avg.toFixed(1)} transits/day`)}</title></rect>${label}</g>`;
}).join('');
const monthTicks = monthlyRows.map((row, index) => {
const x = CHART_MARGIN.left + index * slot + 3 + barWidth / 2;
return `<text x="${x.toFixed(1)}" y="${BAR_CHART_HEIGHT - 12}" text-anchor="middle" fill="#a8b8ad" font-size="10">${monthLabel(row.month).replace(' 20', ' ’')}</text>`;
}).join('');
return `<figure role="group" aria-labelledby="monthly-chart-caption">
<svg viewBox="0 0 ${CHART_WIDTH} ${BAR_CHART_HEIGHT}" role="img" aria-label="${escapeHtml(title)}" style="width:100%;height:auto" preserveAspectRatio="xMidYMid meet">
<title>${escapeHtml(title)}</title>
<desc>${escapeHtml(description)}</desc>
${gridAndAxis(scale, domainMax, 40, plotWidth)}
${bars}
${monthTicks}
</svg>
<figcaption id="monthly-chart-caption">${escapeHtml(`${description} The ${monthLabel(partialMonth)} bar is partial (through ${partialThrough}) and drawn hatched.`)}</figcaption>
</figure>`;
}
export function computeMonthlyAverages(history, startMonth, endMonth) {
const byMonth = new Map();
for (const row of history) {
const month = row.date.slice(0, 7);
if (month < startMonth || month > endMonth) continue;
if (!byMonth.has(month)) byMonth.set(month, []);
byMonth.get(month).push(row.total);
}
return [...byMonth.entries()]
.sort(([a], [b]) => a.localeCompare(b, 'en-US'))
.map(([month, values]) => ({
month,
days: values.length,
avg: round1(values.reduce((total, value) => total + value, 0) / values.length),
}));
}
// ---------------------------------------------------------------------------
// Downloads
// ---------------------------------------------------------------------------
// Download filenames derive from the report id so a saved file self-identifies.
export function downloadFileNames(report) {
return { csv: `${report.id}.csv`, json: `${report.id}.json` };
}
const CSV_COLUMNS = [
['date', 'date', 'ISO 8601 date (UTC)'],
['container', 'container', 'container-vessel transit calls'],
['dry_bulk', 'dryBulk', 'dry-bulk transit calls'],
['general_cargo', 'generalCargo', 'general-cargo transit calls'],
['roro', 'roro', 'ro-ro transit calls'],
['tanker', 'tanker', 'tanker transit calls'],
['cargo_total', 'cargo', 'container + dry bulk + general cargo + ro-ro'],
['total', 'total', 'all transit calls'],
['cap_container_dwt', 'capContainer', 'aggregate container capacity, DWT'],
['cap_dry_bulk_dwt', 'capDryBulk', 'aggregate dry-bulk capacity, DWT'],
['cap_general_cargo_dwt', 'capGeneralCargo', 'aggregate general-cargo capacity, DWT'],
['cap_roro_dwt', 'capRoro', 'aggregate ro-ro capacity, DWT'],
['cap_tanker_dwt', 'capTanker', 'aggregate tanker capacity, DWT'],
];
export function buildCsvDownload(snapshot, report) {
const focus = snapshot.chokepoints[report.focusChokepointId];
const header = CSV_COLUMNS.map(([name]) => name).join(',');
const rows = focus.history.map((row) => CSV_COLUMNS.map(([, field]) => {
const value = row[field];
// Pin the injection-safety guarantee at the emission point: every cell is
// an ISO date or a finite number, never free text a spreadsheet could
// evaluate.
if (field === 'date') {
if (!/^\d{4}-\d{2}-\d{2}$/.test(String(value))) throw new Error(`Non-ISO date in CSV row: ${value}`);
} else if (!Number.isFinite(value)) {
throw new Error(`Non-numeric value for ${field} in CSV row ${row.date}: ${value}`);
}
return value;
}).join(','));
return `${[header, ...rows].join('\n')}\n`;
}
export function buildJsonDownload(snapshot, report, metrics, canonicalUrl) {
const metricsOut = {};
for (const metric of metrics.values()) {
metricsOut[metric.id] = {
label: metric.label,
value: metric.value,
unit: metric.unit ?? null,
observationPeriod: metric.period,
method: metric.method,
source: metric.source,
};
}
return `${JSON.stringify(
{
reportId: report.id,
title: report.title,
version: report.version,
author: report.author.name,
datePublished: report.datePublished,
dateModified: report.dateModified,
canonicalUrl,
license:
'Report text and derived figures: © World Monitor, citation welcome with attribution. Underlying transit data: IMF PortWatch (portwatch.imf.org); consult upstream terms.',
source: snapshot.source,
snapshotId: snapshot.snapshotId,
snapshotCapturedAt: snapshot.capturedAt,
units: snapshot.units,
csvSchema: CSV_COLUMNS.map(([name, , description]) => ({ column: name, description })),
metrics: metricsOut,
series: Object.fromEntries(
Object.entries(snapshot.chokepoints).map(([id, chokepoint]) => [
id,
{
portwatchName: chokepoint.portwatchName,
observationStart: chokepoint.observationStart,
observationEnd: chokepoint.observationEnd,
rowCount: chokepoint.rowCount,
missingDates: chokepoint.missingDates,
history: chokepoint.history,
},
]),
),
},
)}\n`;
}
// ---------------------------------------------------------------------------
// Page rendering
// ---------------------------------------------------------------------------
const LAYER_LABELS = {
observed: 'Observed transport data',
derived: 'Derived analysis',
news: 'News context',
methodology: 'Methodology',
data: 'Machine-readable data',
product: 'Live product',
};
function layerBadge(layer, escapeHtml) {
const label = LAYER_LABELS[layer];
return label ? `<p class="eyebrow">${escapeHtml(label)}</p>` : '';
}
function trackedLink(href, text, target, escapeHtml, extraAttrs = '') {
return `<a href="${escapeHtml(href)}" data-umami-event="research-cta" data-umami-event-target="${escapeHtml(target)}"${extraAttrs}>${text}</a>`;
}
export function renderResearchReportPage({
report,
snapshot,
metrics,
tpl,
baseUrl,
lastmod,
chokepointSlugById,
dataCatalog,
includedInDataCatalog,
}) {
const { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument } = tpl;
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
if (!SLUG_PATTERN.test(report.slug) || !SLUG_PATTERN.test(report.id)) {
throw new Error(`Report slug/id must match ${SLUG_PATTERN}: ${report.slug} / ${report.id}`);
}
for (const [role, ids] of [
['focusChokepointId', [report.focusChokepointId]],
['contextChokepointId', report.contextChokepointIds],
]) {
for (const id of ids) {
if (!SLUG_PATTERN.test(chokepointSlugById.get(id) ?? '')) {
throw new Error(`Report ${report.id}: ${role} ${id} has no valid route in the chokepoint registry`);
}
}
}
const chokepointSlug = chokepointSlugById.get(report.focusChokepointId);
const path = `/research/${report.slug}/`;
const canonical = absoluteUrl(baseUrl, path);
const focus = snapshot.chokepoints[report.focusChokepointId];
const resolve = (text) => resolveMetricTokens(escapeHtml(text), metrics, escapeHtml);
const m = (id) => {
const metric = metrics.get(id);
if (!metric) throw new Error(`Unknown metric: ${id}`);
return metric;
};
// Plain-text description for surfaces that cannot carry <data> markup; the
// definition writes it with {{m:...}} tokens so its numbers cannot drift
// from the computed metrics.
const description = resolvePlainMetricTokens(report.description, metrics);
const partialMonth = focus.observationEnd.slice(0, 7);
const daily2026 = focus.history.filter((row) => row.date >= '2026-01-01');
const monthly = computeMonthlyAverages(focus.history, '2025-07', partialMonth);
const statTiles = [
{ headline: `−${formatMetric(m('declinePct'))}`, label: `transits vs the same 2025 window (2026-03-01 → ${m('observationEnd').value})` },
{ headline: `${formatMetric(m('disruptionDailyAvg'))}/day`, label: `average since March 1 — versus ${formatMetric(m('sameWindow2025DailyAvg'))}/day in 2025` },
{ headline: `${formatMetric(m('junAvgTanker'))}/day`, label: `June 2026 tanker transits — versus ${formatMetric(m('jun25AvgTanker'))} in June 2025` },
{ headline: formatMetric(m('zeroTransitDays')), label: 'zero-transit days since March 1 — none in the prior seven years' },
];
const monthlyTable = `<div style="overflow-x:auto"><table>
<caption>Average daily transits by month, Strait of Hormuz (textual equivalent of the charts)</caption>
<thead><tr><th scope="col">Month</th><th scope="col">Avg daily transits</th><th scope="col">Observed days</th></tr></thead>
<tbody>
${monthly.map((row) => ` <tr><td>${monthLabel(row.month)}${row.month === partialMonth ? ' (partial)' : ''}</td><td>${row.avg.toFixed(1)}</td><td>${row.days}</td></tr>`).join('\n')}
</tbody>
</table></div>`;
const contextRows = [...new Set(report.contextChokepointIds)].map((id) => {
const chokepoint = snapshot.chokepoints[id];
const history = chokepoint.history;
const cell = (start, end) => round1(mean(inRange(history, start, end), 'total')).toFixed(1);
return ` <tr><td><a href="/chokepoints/${escapeHtml(chokepointSlugById.get(id))}/">${escapeHtml(chokepoint.portwatchName)}</a></td><td>${cell('2025-01-01', '2025-12-31')}</td><td>${cell('2026-02-01', '2026-02-28')}</td><td>${cell('2026-06-01', '2026-06-30')}</td><td>${cell('2026-07-01', focus.observationEnd)}</td></tr>`;
}).join('\n');
const contextTable = `<div style="overflow-x:auto"><table>
<caption>Context chokepoints: average daily transits (same snapshot, same units)</caption>
<thead><tr><th scope="col">Chokepoint</th><th scope="col">2025 avg</th><th scope="col">Feb 2026</th><th scope="col">Jun 2026</th><th scope="col">${escapeHtml(partialRangeLabel(focus.observationEnd))}</th></tr></thead>
<tbody>
${contextRows}
</tbody>
</table></div>`;
const newsItems = report.newsContext.map((item) => ` <li><p>${escapeHtml(item.claim)}</p><p class="source">Source: <a href="${escapeHtml(item.url)}" rel="noopener">${escapeHtml(item.source)}</a> · source date ${escapeHtml(item.sourceDate)} · retrieved ${escapeHtml(item.retrievedAt)}.</p></li>`).join('\n');
const notCovered = report.layersNotCovered.map((entry) => ` <li><strong>${escapeHtml(entry.layer)}:</strong> ${escapeHtml(entry.note)}</li>`).join('\n');
const provenanceRows = [...metrics.values()].map((metric) => ` <tr><td>${escapeHtml(metric.label)}</td><td><data data-metric-row="${escapeHtml(metric.id)}" value="${escapeHtml(String(metric.value))}">${escapeHtml(formatMetric(metric))}${metric.unit && metric.kind !== 'percent' && metric.kind !== 'percent-round' ? ` ${escapeHtml(metric.unit)}` : ''}</data></td><td>${escapeHtml(metric.period)}</td><td>${escapeHtml(metric.method)}</td></tr>`).join('\n');
const files = downloadFileNames(report);
const downloadsBlock = `<ul class="result-list">
<li>${trackedLink(`${path}${files.csv}`, files.csv, 'download-csv', escapeHtml, ' download')} — the focus-chokepoint daily series, ${escapeHtml(String(focus.rowCount))} rows (${escapeHtml(focus.observationStart)} → ${escapeHtml(focus.observationEnd)}), one row per day, columns documented in the JSON schema below.</li>
<li>${trackedLink(`${path}${files.json}`, files.json, 'download-json', escapeHtml, ' download')} — all four chokepoint series plus every published figure with its observation period, method, provenance, units, CSV schema, licensing/attribution notes, and version identifier.</li>
</ul>
<p>Both files are versioned (report version ${escapeHtml(report.version)}, snapshot ${escapeHtml(snapshot.snapshotId)}) and regenerate byte-identically from the committed snapshot. Underlying data: IMF PortWatch; cite it when reusing the raw series.</p>`;
const sectionsHtml = report.sections.map((section) => {
const parts = [` <section id="${escapeHtml(section.id)}">`,
` ${layerBadge(section.layer, escapeHtml)}`,
` <h2>${escapeHtml(section.heading)}</h2>`];
for (const paragraph of section.paragraphs ?? []) {
parts.push(` <p>${resolve(paragraph)}</p>`);
}
switch (section.block) {
case 'stats':
parts.push(` <div class="grid" aria-label="Key report figures">
${statTiles.map((tile) => ` <div class="metric"><span>${escapeHtml(tile.label)}</span><strong>${escapeHtml(tile.headline)}</strong></div>`).join('\n')}
</div>`);
break;
case 'charts':
parts.push(
` ${renderDailyLineChart(daily2026, {
title: 'Daily transit calls, Strait of Hormuz, January – July 2026',
description: `Daily AIS-observed transit calls from 2026-01-01 to ${focus.observationEnd}. Traffic averaged ${formatMetric(m('febAvgTotal'))}/day in February, collapsed from ${monthDayLabel(report.disruptionStart)}, and has partially recovered to ${formatMetric(m('julToDateAvgTotal'))}/day in July.`,
collapseDate: report.disruptionStart,
escapeHtml,
})}`,
` ${renderMonthlyBarChart(monthly, {
title: 'Average daily transits by month, July 2025 – July 2026',
description: 'Monthly averages of the same daily series, July 2025 through July 2026.',
partialMonth,
partialThrough: focus.observationEnd,
escapeHtml,
})}`,
` ${monthlyTable}`,
);
break;
case 'context-table':
parts.push(` ${contextTable}`);
break;
case 'news-context':
parts.push(` <ul class="result-list">
${newsItems}
</ul>`);
break;
case 'downloads':
parts.push(` ${downloadsBlock}`);
break;
case 'provenance':
parts.push(` <p>Every figure in this report, with its observation period, transformation, and source. Recompute any of them from the downloadable data.</p>
<div style="overflow-x:auto"><table>
<caption>Published figures and their provenance</caption>
<thead><tr><th scope="col">Figure</th><th scope="col">Value</th><th scope="col">Observation period</th><th scope="col">Method</th></tr></thead>
<tbody>
${provenanceRows}
</tbody>
</table></div>`);
break;
case 'citation':
// Generated from the report fields so a version bump can never leave a
// stale recommended citation behind.
parts.push(` <p>Cite the report as:</p>
<blockquote><p>${escapeHtml(`${report.author.name}, “${report.title}”, version ${report.version}, published ${report.datePublished}. ${canonical} — underlying transit data: IMF PortWatch (portwatch.imf.org).`)}</p></blockquote>
<p>The canonical URL is stable, editions are append-only, and corrections bump the version and modified date rather than silently rewriting figures.</p>`);
break;
case 'current-pointer':
// AI answers quote the direct answer on its own, so its age and the
// live page sit beside it rather than at the foot of the report.
parts.push(` <p>This answer covers IMF PortWatch data through ${escapeHtml(focus.observationEnd)}. For the latest IMF PortWatch daily count, which is published with a lag, and today's disruption score, see the ${trackedLink(`/chokepoints/${chokepointSlug}/`, `live ${escapeHtml(focus.portwatchName)} status page`, 'chokepoint-page', escapeHtml)}.</p>`);
break;
case 'live-handoff': {
const dashboardUrl = absoluteUrl(baseUrl, `/dashboard?chokepoint=${report.focusChokepointId}`);
parts.push(` <p>This report is a dated snapshot. For the current picture: the ${trackedLink(`/chokepoints/${chokepointSlug}/`, 'live Strait of Hormuz status page', 'chokepoint-page', escapeHtml)} shows today's disruption pulse, and the ${trackedLink(dashboardUrl, 'World Monitor dashboard', 'dashboard', escapeHtml)} adds map layers, alerts, and vessel context around it.</p>
<p>Programmatic access: the same chokepoint status and transit history are available through the ${trackedLink('/docs/api-reference', 'World Monitor REST API', 'developer', escapeHtml)} and the ${trackedLink('/docs/mcp-overview', 'MCP server', 'developer', escapeHtml)} for AI agents. Higher request limits and research briefings come with ${trackedLink(absoluteUrl(baseUrl, '/pro'), 'World Monitor Pro', 'pricing', escapeHtml)}. The research itself stays free and ungated.</p>`);
break;
}
default:
break;
}
parts.push(' </section>');
return parts.join('\n');
}).join('\n');
const justification = ` <section id="why-this-report">
<p class="eyebrow">Why this report exists</p>
<h2>Topic selection, from evidence</h2>
<ul class="result-list">
${report.topicJustification.map((reason) => ` <li>${escapeHtml(reason)}</li>`).join('\n')}
</ul>
</section>`;
const notCoveredSection = ` <section id="not-covered">
<p class="eyebrow">Declared gaps</p>
<h2>What this edition does not cover</h2>
<ul class="result-list">
${notCovered}
</ul>
</section>`;
const body = ` <p class="eyebrow">Research · ${escapeHtml(report.series)} · v${escapeHtml(report.version)}</p>
<h1>${escapeHtml(report.title)}</h1>
<p class="lede">${escapeHtml(description)}</p>
<p class="source">By ${escapeHtml(report.author.name)} · published <time datetime="${escapeHtml(report.datePublished)}">${escapeHtml(report.datePublished)}</time> · modified <time datetime="${escapeHtml(report.dateModified)}">${escapeHtml(report.dateModified)}</time> · data through <time datetime="${escapeHtml(focus.observationEnd)}">${escapeHtml(focus.observationEnd)}</time>.</p>
${sectionsHtml}
${notCoveredSection}
${justification}
<p class="source" data-snapshot-source="${escapeHtml(report.snapshotPath)}">Snapshot: World Monitor chokepoint transit snapshot, retrieved ${escapeHtml(String(snapshot.capturedAt).slice(0, 10))}. Attribution: ${escapeHtml(snapshot.source.attribution)} Methodology: <a href="/docs/methodology/chokepoints">chokepoint monitoring methodology</a>.</p>`;
const jsonLd = {
'@context': 'https://schema.org',
'@id': `${canonical}#report`,
'@type': 'Report',
headline: report.title,
name: report.title,
description,
url: canonical,
datePublished: report.datePublished,
dateModified: report.dateModified,
version: report.version,
inLanguage: 'en-US',
// The "World Monitor Research" byline stays in the visible page text and the
// citation block; in the entity graph it folds into the canonical Organization
// so the page stops publishing a second, differently-named org claiming the
// same homepage url (#7459b).
author: { ...WORLD_MONITOR_ORG },
publisher: { ...WORLD_MONITOR_ORG },
isBasedOn: 'https://portwatch.imf.org/',
temporalCoverage: `${focus.observationStart}/${focus.observationEnd}`,
hasPart: {
'@type': 'Dataset',
name: `Strait of Hormuz daily transit calls, ${focus.observationStart} to ${focus.observationEnd}`,
description:
'Daily AIS-observed vessel transit calls by class with deadweight-tonnage aggregates, from IMF PortWatch, frozen in a versioned snapshot.',
keywords: ['AIS vessel transits', 'Strait of Hormuz', 'maritime trade', 'IMF PortWatch'],
creator: { ...WORLD_MONITOR_ORG },
license: DATASET_LICENSE,
datePublished: report.datePublished,
temporalCoverage: `${focus.observationStart}/${focus.observationEnd}`,
isAccessibleForFree: true,
includedInDataCatalog,
variableMeasured: [
'Daily vessel transit calls',
'Transit calls by vessel class',
'Deadweight-tonnage aggregates',
],
spatialCoverage: {
'@type': 'Place',
name: focus.portwatchName,
identifier: report.focusChokepointId,
},
isBasedOn: 'https://portwatch.imf.org/',
distribution: [
{
'@type': 'DataDownload',
encodingFormat: 'text/csv',
contentUrl: `${canonical}${files.csv}`,
},
{
'@type': 'DataDownload',
encodingFormat: 'application/json',
contentUrl: `${canonical}${files.json}`,
},
],
},
};
const html = pageDocument({
baseUrl,
path,
title: `${report.metaTitle} | World Monitor`,
description,
lastmod,
jsonLd: [jsonLd, dataCatalog].filter(Boolean),
breadcrumbs: breadcrumbLd(baseUrl, [
{ name: 'Home', path: '/' },
{ name: 'Research', path: '/research/' },
{ name: report.title, path },
]),
body,
ogImage: `/research-assets/${report.slug}-og.png`,
ogImageAlt: `Chart of daily Strait of Hormuz transit calls collapsing from about ${formatMetric(m('febAvgTotal'))} per day in February 2026 to single digits in March, partially recovering to about ${formatMetric(m('julToDateAvgTotal'))} in July — ${report.title}, World Monitor`,
});
assertNoUnresolvedTokens(html);
return html;
}
export function renderResearchIndex({ reports, tpl, baseUrl, lastmod, dataCatalog }) {
const { escapeHtml, absoluteUrl, breadcrumbLd, pageDocument } = tpl;
const path = '/research/';
const description =
'Original, source-backed World Monitor research: dated, versioned reports built from committed data snapshots, with downloadable data, explicit methodology, and declared gaps.';
const body = ` <p class="eyebrow">Research corpus</p>
<h1>World Monitor research reports</h1>
<p class="lede">${escapeHtml(description)}</p>
<div class="grid">
${reports.map((report) => ` <a class="card" href="/research/${escapeHtml(report.slug)}/"><strong>${escapeHtml(report.title)}</strong><br><span>Published ${escapeHtml(report.datePublished)} · v${escapeHtml(report.version)}</span></a>`).join('\n')}
</div>
<h2>How these reports work</h2>
<p>Each report is generated deterministically from a versioned, committed data snapshot — no live fetches at build time — so every published figure can be recomputed from the downloadable data beside it. Observed data, derived analysis, and third-party context are labelled separately and never blended into a combined score. Missing or unverifiable data is declared, not zero-filled.</p>
<p>Editions are append-only: corrections bump the version and modified date. Report families expand only when the pilot demonstrates real demand, per the <a href="/docs/methodology/chokepoints">methodology</a> and measurement guardrails.</p>`;
const html = pageDocument({
baseUrl,
path,
title: 'Research Reports | World Monitor',
description,
lastmod,
jsonLd: [
{
'@context': 'https://schema.org',
'@type': 'CollectionPage',
name: 'World Monitor research reports',
description,
url: absoluteUrl(baseUrl, path),
inLanguage: 'en-US',
},
dataCatalog,
].filter(Boolean),
breadcrumbs: breadcrumbLd(baseUrl, [
{ name: 'Home', path: '/' },
{ name: 'Research', path },
]),
body,
});
assertNoUnresolvedTokens(html);
return html;
}
// Renders and writes the whole /research/ section (hub, report pages,
// downloads). Owns its own file IO so build-crawlable-corpus.mjs stays the
// template owner without also carrying the research wiring.
export function writeResearchSection({ data, outDir, baseUrl, tpl, dataCatalog, includedInDataCatalog }) {
if (!dataCatalog?.['@id'] || !includedInDataCatalog?.['@id']) {
throw new Error(
'writeResearchSection requires the canonical DataCatalog identity so research Datasets can join the catalog graph',
);
}
mkdirSync(join(outDir, 'research'), { recursive: true });
writeFileSync(
join(outDir, 'research', 'index.html'),
renderResearchIndex({
reports: data.researchReports.map(({ report }) => report),
tpl,
baseUrl,
lastmod: data.lastmod.research,
dataCatalog,
}),
);
const chokepointSlugById = new Map(data.chokepoints.map((entry) => [entry.id, entry.slug]));
for (const { report, snapshot } of data.researchReports) {
const metrics = computeReportMetrics(snapshot, report);
const reportDir = join(outDir, 'research', report.slug);
mkdirSync(reportDir, { recursive: true });
writeFileSync(
join(reportDir, 'index.html'),
renderResearchReportPage({
report,
snapshot,
metrics,
tpl,
baseUrl,
lastmod: data.lastmod.research,
chokepointSlugById,
dataCatalog,
includedInDataCatalog,
}),
);
const downloadFiles = downloadFileNames(report);
writeFileSync(join(reportDir, downloadFiles.csv), buildCsvDownload(snapshot, report));
writeFileSync(
join(reportDir, downloadFiles.json),
buildJsonDownload(snapshot, report, metrics, tpl.absoluteUrl(baseUrl, `/research/${report.slug}/`)),
);
}
}