diff --git a/api/supply-chain/v1/multi-sector-cost-shock.ts b/api/supply-chain/v1/multi-sector-cost-shock.ts index 163d6766c..6cdb34d14 100644 --- a/api/supply-chain/v1/multi-sector-cost-shock.ts +++ b/api/supply-chain/v1/multi-sector-cost-shock.ts @@ -7,6 +7,7 @@ import { aggregateAnnualImportsByHs2, clampClosureDays, computeMultiSectorShocks, + MULTI_SECTOR_HS2_LABELS, SEEDED_HS2_CODES, type MultiSectorCostShock, type SeededProduct, @@ -111,7 +112,7 @@ export default async function handler(req: Request): Promise { // Still emit the empty sector skeleton so the UI can render rows at 0. sectors: SEEDED_HS2_CODES.map(hs2 => ({ hs2, - hs2Label: hs2, + hs2Label: MULTI_SECTOR_HS2_LABELS[hs2] ?? `HS ${hs2}`, importValueAnnual: 0, freightAddedPctPerTon: 0, warRiskPremiumBps: 0, diff --git a/server/worldmonitor/intelligence/v1/_shock-compute.ts b/server/worldmonitor/intelligence/v1/_shock-compute.ts index 7c2137dd0..b9b82c2ef 100644 --- a/server/worldmonitor/intelligence/v1/_shock-compute.ts +++ b/server/worldmonitor/intelligence/v1/_shock-compute.ts @@ -2,6 +2,31 @@ export const GULF_PARTNER_CODES = new Set(['682', '784', '368', '414', '364']); export const VALID_CHOKEPOINTS = new Set(['hormuz_strait', 'malacca_strait', 'suez', 'bab_el_mandeb']); +const CHOKEPOINT_DISPLAY_NAMES: Record = { + hormuz_strait: 'Strait of Hormuz', + malacca_strait: 'Strait of Malacca', + suez: 'Suez Canal', + bab_el_mandeb: 'Bab el-Mandeb', +}; + +function chokepointLabel(id: string): string { + return CHOKEPOINT_DISPLAY_NAMES[id] ?? id.replace(/_/g, ' '); +} + +// Intl.DisplayNames is available in Node 20+ and Vercel edge runtime; fall back to the raw ISO2 +// if instantiation fails (unexpected locales, etc.) so assessment text never crashes. +let regionNames: { of(code: string): string | undefined } | null = null; +try { + regionNames = new Intl.DisplayNames(['en'], { type: 'region' }); +} catch { + regionNames = null; +} + +function countryLabel(iso2: string): string { + const resolved = regionNames?.of(iso2); + return resolved && resolved !== iso2 ? resolved : iso2; +} + export const CHOKEPOINT_EXPOSURE: Record = { hormuz_strait: 1.0, bab_el_mandeb: 1.0, @@ -102,14 +127,16 @@ export function buildAssessment( ieaStocksCoverage?: boolean, comtradeCoverage?: boolean, ): string { + const country = countryLabel(code); + const cp = chokepointLabel(chokepointId); if (coverageLevel === 'unsupported' || !dataAvailable) { - return `Insufficient import data for ${code} to model ${chokepointId} exposure.`; + return `Insufficient import data for ${country} to model ${cp} exposure.`; } if (effectiveCoverDays === -1) { - return `${code} is a net oil exporter; ${chokepointId} disruption affects export revenue, not domestic supply.`; + return `${country} is a net oil exporter; ${cp} disruption affects export revenue, not domestic supply.`; } if (gulfCrudeShare < 0.1 && comtradeCoverage !== false) { - return `${code} has low Gulf crude dependence (${Math.round(gulfCrudeShare * 100)}%); ${chokepointId} disruption has limited direct impact.`; + return `${country} has low Gulf crude dependence (${Math.round(gulfCrudeShare * 100)}%); ${cp} disruption has limited direct impact.`; } const degradedNote = degraded ? ' (live flow data unavailable, using historical baseline)' : ''; const ieaCoverText = ieaStocksCoverage === false ? 'unknown' : `${daysOfCover} days`; @@ -117,7 +144,7 @@ export function buildAssessment( return `With ${daysOfCover} days IEA cover, ${code} is indefinitely bridgeable against a ${disruptionPct}% ${chokepointId} disruption at this deficit rate${degradedNote}.`; } if (effectiveCoverDays > 90) { - return `With ${daysOfCover} days IEA cover, ${code} can bridge a ${disruptionPct}% ${chokepointId} disruption for ~${effectiveCoverDays} days${degradedNote}.`; + return `With ${daysOfCover} days IEA cover, ${country} can bridge a ${disruptionPct}% ${cp} disruption for ~${effectiveCoverDays} days${degradedNote}.`; } const worst = products.reduce<{ product: string; deficitPct: number }>( (best, p) => (p.deficitPct > best.deficitPct ? p : best), @@ -126,7 +153,7 @@ export function buildAssessment( const worstDeficit = worst.deficitPct; const worstProduct = worst.product.toLowerCase(); const proxyNote = comtradeCoverage === false ? '. Gulf share proxied at 40%' : ''; - return `${code} faces ${worstDeficit.toFixed(1)}% ${worstProduct} deficit under ${disruptionPct}% ${chokepointId} disruption; IEA cover: ${ieaCoverText}${proxyNote}${degradedNote}.`; + return `${country} faces ${worstDeficit.toFixed(1)}% ${worstProduct} deficit under ${disruptionPct}% ${cp} disruption; IEA cover: ${ieaCoverText}${proxyNote}${degradedNote}.`; } export const CHOKEPOINT_LNG_EXPOSURE: Record = { diff --git a/src/components/CountryDeepDivePanel.ts b/src/components/CountryDeepDivePanel.ts index 4866b6292..0b8b31962 100644 --- a/src/components/CountryDeepDivePanel.ts +++ b/src/components/CountryDeepDivePanel.ts @@ -39,7 +39,7 @@ import type { MultiSectorShockResponse, MultiSectorShock, } from '@/services/supply-chain'; -import { fetchMultiSectorCostShock } from '@/services/supply-chain'; +import { fetchMultiSectorCostShock, HS2_SHORT_LABELS } from '@/services/supply-chain'; import type { MapContainer } from './MapContainer'; import { ResilienceWidget } from './ResilienceWidget'; @@ -700,7 +700,7 @@ export class CountryDeepDivePanel implements CountryBriefPanel { let total = 0; for (const s of sorted) { const tr = this.el('tr', 'cdp-cost-shock-calc-row'); - const labelCell = this.el('td', 'cdp-cost-shock-calc-sector', s.hs2Label || `HS${s.hs2}`); + const labelCell = this.el('td', 'cdp-cost-shock-calc-sector', s.hs2Label || HS2_SHORT_LABELS[s.hs2] || `HS${s.hs2}`); const costCell = this.el('td', 'cdp-cost-shock-calc-cost', this.formatMoney(s.totalCostShock)); if (s.totalCostShock === 0) costCell.classList.add('cdp-cost-shock-calc-cost--zero'); tr.append(labelCell, costCell); @@ -1763,18 +1763,31 @@ export class CountryDeepDivePanel implements CountryBriefPanel { tbody.replaceChildren(); recsMount.replaceChildren(); - const rows: ExporterRow[] = enriched ?? product.topExporters.map(exp => ({ + const importerCode = this.currentCode; + const rawRows: ExporterRow[] = enriched ?? product.topExporters.map(exp => ({ partnerIso2: exp.partnerIso2, share: exp.share, value: exp.value, risk: null, })); + // Drop self-imports (receiver = supplier) and rows with unresolved partner ISO2 codes; + // the seeder emits partnerIso2='' when a UN code can't be mapped, which surfaced as "N/A" rows. + const isVisible = (iso2: string) => Boolean(iso2) && iso2 !== importerCode; + const rows = rawRows.filter(r => isVisible(r.partnerIso2)); + const visibleEnriched = enriched ? enriched.filter(e => isVisible(e.partnerIso2)) : null; + + if (rows.length === 0) { + const empty = this.el('div', 'cdp-recommendation-item'); + empty.textContent = '\u2139 No external suppliers in available trade data.'; + recsMount.append(empty); + return; + } for (const exp of rows) { const tr = this.el('tr'); const supplierTd = this.el('td', 'cdp-product-supplier'); const flag = exp.partnerIso2 ? CountryDeepDivePanel.toFlagEmoji(exp.partnerIso2) : ''; - supplierTd.textContent = `${flag} ${exp.partnerIso2 || 'N/A'}`; + supplierTd.textContent = `${flag} ${exp.partnerIso2}`; tr.append(supplierTd); const shareTd = this.el('td', 'cdp-product-share'); @@ -1812,13 +1825,13 @@ export class CountryDeepDivePanel implements CountryBriefPanel { tbody.append(tr); } - if (enriched) { - const hasCritical = enriched.some(e => e.risk.riskLevel === 'critical'); - const hasAtRisk = enriched.some(e => e.risk.riskLevel === 'at_risk'); - const hasUnknown = enriched.some(e => e.risk.riskLevel === 'unknown'); - const hasSafe = enriched.some(e => e.risk.riskLevel === 'safe'); + if (visibleEnriched) { + const hasCritical = visibleEnriched.some(e => e.risk.riskLevel === 'critical'); + const hasAtRisk = visibleEnriched.some(e => e.risk.riskLevel === 'at_risk'); + const hasUnknown = visibleEnriched.some(e => e.risk.riskLevel === 'unknown'); + const hasSafe = visibleEnriched.some(e => e.risk.riskLevel === 'safe'); if (hasCritical || hasAtRisk) { - for (const exp of enriched) { + for (const exp of visibleEnriched) { if (exp.risk.riskLevel === 'safe' || exp.risk.riskLevel === 'unknown') continue; const recCls = exp.risk.riskLevel === 'critical' ? 'cdp-recommendation-critical' : 'cdp-recommendation-warn'; const item = this.el('div', `cdp-recommendation-item ${recCls}`); @@ -1827,8 +1840,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel { if (exp.risk.transitChokepoints.length === 0) continue; const worstCp = exp.risk.transitChokepoints.reduce((a, b) => a.disruptionScore > b.disruptionScore ? a : b); text += ` ${worstCp.chokepointName} (disruption ${worstCp.disruptionScore}/100).`; - if (exp.safeAlternative) { - const alt = enriched.find(e => e.partnerIso2 === exp.safeAlternative); + if (exp.safeAlternative && isVisible(exp.safeAlternative)) { + const alt = visibleEnriched.find(e => e.partnerIso2 === exp.safeAlternative); const altPct = alt ? Math.round(alt.share * 100) : 0; const altFlag = CountryDeepDivePanel.toFlagEmoji(exp.safeAlternative); text += ` ${altFlag} ${exp.safeAlternative} supplies ${altPct}% via routes avoiding this chokepoint.`; @@ -1841,8 +1854,8 @@ export class CountryDeepDivePanel implements CountryBriefPanel { item.textContent = '\u2139 No modeled maritime route data available for these suppliers. Risk cannot be assessed.'; recsMount.append(item); } else if (hasUnknown && hasSafe) { - const safeCount = enriched.filter(e => e.risk.riskLevel === 'safe').length; - const unknownCount = enriched.filter(e => e.risk.riskLevel === 'unknown').length; + const safeCount = visibleEnriched.filter(e => e.risk.riskLevel === 'safe').length; + const unknownCount = visibleEnriched.filter(e => e.risk.riskLevel === 'unknown').length; const item = this.el('div', 'cdp-recommendation-item'); item.textContent = `\u2139 ${safeCount} supplier(s) verified safe. ${unknownCount} supplier(s) have no modeled route data.`; recsMount.append(item); diff --git a/src/utils/supplier-route-risk.ts b/src/utils/supplier-route-risk.ts index 6f881083d..63182c20b 100644 --- a/src/utils/supplier-route-risk.ts +++ b/src/utils/supplier-route-risk.ts @@ -46,6 +46,16 @@ for (const cp of CHOKEPOINT_REGISTRY) { chokepointNameMap.set(cp.id, cp.displayName); } +// Chokepoints plausibly traversed for intra-regional trade within a coastSide. +// When both exporter and importer share the same coastSide, drop waypoints outside this set +// so routes like gulf-europe-oil don't attribute Hormuz/Bab el-Mandeb to GR→TR refined petroleum. +const INTRA_REGIONAL_CHOKEPOINTS: Record> = { + med: new Set(['bosphorus', 'gibraltar', 'suez']), + atlantic: new Set(['panama', 'gibraltar', 'dover_strait', 'cape_of_good_hope']), + pacific: new Set(['panama', 'malacca_strait', 'taiwan_strait', 'korea_strait', 'lombok_strait']), + indian: new Set(['malacca_strait', 'bab_el_mandeb', 'hormuz_strait', 'cape_of_good_hope', 'lombok_strait']), +}; + function getCluster(iso2: string): ClusterEntry | undefined { const entry = clusters[iso2]; if (!entry || typeof entry === 'string') return undefined; @@ -113,11 +123,21 @@ export function computeSupplierRouteRisk( importerIso2: string, chokepointScores: ChokepointScoreMap, ): SupplierRouteRisk { - const hasExporterCluster = !!getCluster(exporterIso2); - const hasImporterCluster = !!getCluster(importerIso2); + const exporterCluster = getCluster(exporterIso2); + const importerCluster = getCluster(importerIso2); + const hasExporterCluster = !!exporterCluster; + const hasImporterCluster = !!importerCluster; const routeIds = findOverlappingRoutes(exporterIso2, importerIso2); const hasRouteData = hasExporterCluster && hasImporterCluster && routeIds.length > 0; - const transitChokepoints = collectTransitChokepoints(routeIds, chokepointScores); + let transitChokepoints = collectTransitChokepoints(routeIds, chokepointScores); + // For intra-regional pairs (same coastSide), overlapping "pass-through" routes like + // gulf-europe-oil falsely attribute distant waypoints. Restrict transit to chokepoints + // that plausibly sit on an intra-regional path. + const sharedCoast = exporterCluster?.coastSide === importerCluster?.coastSide ? exporterCluster?.coastSide : null; + if (sharedCoast && INTRA_REGIONAL_CHOKEPOINTS[sharedCoast]) { + const allowed = INTRA_REGIONAL_CHOKEPOINTS[sharedCoast]!; + transitChokepoints = transitChokepoints.filter(cp => allowed.has(cp.chokepointId)); + } const riskLevel = determineRiskLevel(transitChokepoints, hasRouteData); const maxDisruptionScore = transitChokepoints.length > 0 ? Math.max(...transitChokepoints.map(cp => cp.disruptionScore)) diff --git a/tests/energy-shock-seed.test.mts b/tests/energy-shock-seed.test.mts index b1b0ec6b4..47f84582a 100644 --- a/tests/energy-shock-seed.test.mts +++ b/tests/energy-shock-seed.test.mts @@ -204,8 +204,11 @@ describe('energy shock scenario computation', () => { it('uses insufficient data message when dataAvailable is false', () => { const assessment = buildAssessment('XZ', 'suez', false, 0, 0, 0, 50, []); assert.ok(assessment.includes('Insufficient import data')); - assert.ok(assessment.includes('XZ')); - assert.ok(assessment.includes('suez')); + // CLDR behaviour for unrecognised codes varies across ICU versions; + // most return the raw code, but some may resolve to "Unknown Region". + assert.ok(assessment.includes('XZ') || assessment.includes('Unknown Region')); + // chokepoint id is resolved to its display name ("Suez Canal") + assert.ok(assessment.includes('Suez')); }); it('uses net-exporter branch when effectiveCoverDays === -1', () => { diff --git a/tests/energy-shock-v2.test.mjs b/tests/energy-shock-v2.test.mjs index 6bbc157ca..b7522040c 100644 --- a/tests/energy-shock-v2.test.mjs +++ b/tests/energy-shock-v2.test.mjs @@ -73,7 +73,8 @@ describe('buildAssessment — unsupported country', () => { it('returns structured insufficient data message for unsupported country', () => { const msg = buildAssessment('ZZ', 'hormuz', false, 0, 0, 0, 50, [], 'unsupported', false); assert.ok(msg.includes('Insufficient import data')); - assert.ok(msg.includes('ZZ')); + // ZZ resolves to "Unknown Region" via Intl.DisplayNames; hormuz falls back to "hormuz" (no underscore) + assert.ok(msg.includes('Unknown Region') || msg.includes('ZZ')); assert.ok(msg.includes('hormuz')); }); diff --git a/tests/helpers/country-deep-dive-panel-harness.mjs b/tests/helpers/country-deep-dive-panel-harness.mjs index dba09fdb7..837b6920a 100644 --- a/tests/helpers/country-deep-dive-panel-harness.mjs +++ b/tests/helpers/country-deep-dive-panel-harness.mjs @@ -104,6 +104,7 @@ async function loadCountryDeepDivePanel() { export function getCountryChokepointIndex() { return null; } export function fetchChokepointStatus() { return Promise.resolve({ chokepoints: [], fetchedAt: '', upstreamUnavailable: false }); } export function fetchMultiSectorCostShock() { return Promise.resolve({ iso2: '', chokepointId: '', closureDays: 30, warRiskTier: 'WAR_RISK_TIER_UNSPECIFIED', sectors: [], totalAddedCost: 0, fetchedAt: '', unavailableReason: '' }); } + export const HS2_SHORT_LABELS = { '27': 'Energy', '84': 'Machinery', '85': 'Electronics', '87': 'Vehicles', '30': 'Pharma', '72': 'Iron & Steel', '39': 'Plastics', '29': 'Chemicals', '10': 'Cereals', '62': 'Apparel' }; `], ['runtime-stub', ` export function toApiUrl(path) { return path; }