mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#3032)
* fix(country-brief): display bugs — slugs, self-imports, N/A, HS labels (#2970) Six user-facing display fixes that individually looked minor but together eroded trust in the Country Brief panel. 1. Incorrect chokepoint attribution per supplier. Intra-regional pairs (e.g. Greek/Italian refined petroleum to Turkey) overlapped on long pass-through routes like gulf-europe-oil, attributing Hormuz and Bab el-Mandeb to Mediterranean trade. Added a coastSide-based filter: when exporter and importer share the same coast, transit chokepoints are restricted to a regional whitelist (e.g. med -> bosphorus, gibraltar, suez only). 2. Self-imports. Rows where partnerIso2 equals the importer ISO2 are now filtered out of Product Imports. 3. "N/A" supplier rows. Unresolved ISO2 codes (seeder emits partnerIso2 = '' when a UN code does not map) are now dropped from the render instead of surfacing as "N/A" at 14-16% share. 4. Raw slug "hormuz_strait" in shock-scenario prose. buildAssessment() now resolves chokepoint IDs to their display names ("Strait of Hormuz", "Suez Canal", etc.) via a small local map. 5. Raw ISO2 "TR can bridge" in shock-scenario prose. buildAssessment() now uses Intl.DisplayNames to render country names, with a raw-code fallback if instantiation fails. 6. HS chapter numbers instead of sector names in Cost Shock table. The empty-skeleton branch of /api/supply-chain/v1/multi-sector-cost-shock was returning hs2Label = hs2 (raw code); it now uses MULTI_SECTOR_HS2_LABELS. Frontend also adds an HS2_SHORT_LABELS fallback so the table never shows raw codes even if hs2Label is empty. All 4973 data-suite tests pass. Closes #2970. * fix(country-brief): apply supplier filter to recommendations + empty state (#2970) Address PR #3032 review (P2): - The supplier filter (drop self-imports + unmapped ISO2) only reached the table; the recommendation pane still iterated the unfiltered enriched array, so hidden rows could still produce recommendation text and safeAlternative pointers. - Build a single visibleEnriched list and use it for both the row table and the recommendation pane. - Short-circuit to an explicit "No external suppliers in available trade data" empty state when filtering removes every row, so the detail area never goes silently blank. - Skip safeAlternative suggestions that would point at filtered-out partners (self or unmapped). * test(country-brief): defensive ISO2 assertion for ICU variation (#2970) Address PR #3032 review (P2): CLDR behaviour for unrecognised 2-letter codes like 'XZ' varies across ICU versions; allow either raw 'XZ' or the resolved 'Unknown Region' form.
This commit is contained in:
@@ -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<Response> {
|
||||
// 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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<string, number> = {
|
||||
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<string, number> = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, Set<string>> = {
|
||||
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))
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user