mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(supply-chain): Sprint 0 — chokepoint registry, HS2 sectors, war_risk_tier
- src/config/chokepoint-registry.ts: single source of truth for all 13
canonical chokepoints with displayName, relayName, portwatchName,
corridorRiskName, baselineId, shockModelSupported, routeIds, lat/lon
- src/config/hs2-sectors.ts: static dictionary for all 99 HS2 chapters
with category, shockModelSupported (true only for HS27), cargoType
- server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts: migrated to
derive CANONICAL_CHOKEPOINTS from chokepoint-registry; no data duplication
- src/config/geo.ts + src/types/index.ts: added chokepointId field to
StrategicWaterway interface and all 13 STRATEGIC_WATERWAYS entries
- src/components/MapPopup.ts: switched chokepoint matching from fragile
name.toLowerCase() to direct chokepointId === id comparison
- server/worldmonitor/intelligence/v1/_shock-compute.ts: migrated from old
IDs (hormuz/malacca/babelm) to canonical IDs (hormuz_strait/malacca_strait/
bab_el_mandeb); same for CHOKEPOINT_LNG_EXPOSURE
- proto/worldmonitor/supply_chain/v1/supply_chain_data.proto: added
WarRiskTier enum + war_risk_tier field (field 16) on ChokepointInfo
- get-chokepoint-status.ts: populates warRiskTier from ChokepointConfig.threatLevel
via new threatLevelToWarRiskTier() helper (FREE field, no PRO gate)
* feat(supply-chain): Sprint 1 — country chokepoint exposure index + sector ring
S1.1: scripts/shared/country-port-clusters.json
~130 country → {nearestRouteIds, coastSide} mappings derived from trade route
waypoints; covers all 6 seeded Comtrade reporters plus major trading nations.
S1.2: scripts/seed-hs2-chokepoint-exposure.mjs
Daily cron seeder. Pure computation — reads country-port-clusters.json,
scores each country against CHOKEPOINT_REGISTRY route overlap, writes
supply-chain:exposure:{iso2}:{hs2}:v1 keys + seed-meta (24h TTL).
S1.3: RPC get-country-chokepoint-index (PRO-gated, request-varying)
- proto: GetCountryChokepointIndexRequest/Response + ChokepointExposureEntry
- handler: isCallerPremium gate; cachedFetchJson 24h; on-demand for any iso2
- cache-keys.ts: CHOKEPOINT_EXPOSURE_KEY(iso2, hs2) constant
- health.js: chokepointExposure SEED_META entry (48h threshold)
- gateway.ts: slow-browser cache tier
- service client: fetchCountryChokepointIndex() exported
S1.4: Chokepoint popup HS2 sector ring chart (PRO-gated)
Static trade-sector breakdown (IEA/UNCTAD estimates) per 9 major chokepoints.
SVG donut ring + legend shown for PRO users; blurred lockout + gate-hit
analytics for free users. Wired into renderWaterwayPopup().
🤖 Generated with Claude Sonnet 4.6 via Claude Code (https://claude.com/claude-code) + Compound Engineering v2.49.0
Co-Authored-By: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
* fix(tests): update energy-shock-v2 tests to use canonical chokepoint IDs
CHOKEPOINT_EXPOSURE and CHOKEPOINT_LNG_EXPOSURE keys were migrated from
short IDs (hormuz, malacca, babelm) to canonical registry IDs
(hormuz_strait, malacca_strait, bab_el_mandeb) in Sprint 0.
Test fixtures were not updated at the time; fix them now.
* fix(tests): update energy-shock-seed chokepoint ID to canonical form
VALID_CHOKEPOINTS changed to canonical IDs in Sprint 0; the seed test
that checks valid IDs was not updated alongside it.
* fix(cache-keys): reword JSDoc comment to avoid confusing bootstrap test regex
The comment "NOT in BOOTSTRAP_CACHE_KEYS" caused the bootstrap.test.mjs
regex to match the comment rather than the actual export declaration,
resulting in 0 entries found. Rephrase to "excluded from bootstrap".
* fix(supply-chain): address P1 review findings for chokepoint exposure index
- Add get-country-chokepoint-index to PREMIUM_RPC_PATHS (CDN bypass)
- Validate iso2/hs2 params before Redis key construction (cache injection)
- Fix seeder TTL to 172800s (2× interval) and extend TTL on skipped lock
- Fix CHOKEPOINT_EXPOSURE_SEED_META_KEY to match seeder write key
- Render placeholder sectors behind blur gate (DOM data leakage)
- Document get-country-chokepoint-index in widget agent system prompts
* fix(lint): resolve Biome CI failures
- Add biome.json overrides to silence noVar in HTML inline scripts,
disable linting for public/ vendor/build artifacts and pro-test/
- Remove duplicate NG and MW keys from country-port-clusters.json
- Use import attributes (with) instead of deprecated assert syntax
* fix(build): drop JSON import attribute — esbuild rejects `with` syntax
---------
Co-authored-by: Claude Sonnet 4.6 (200K context) <noreply@anthropic.com>
426 lines
22 KiB
TypeScript
426 lines
22 KiB
TypeScript
import type {
|
|
ServerContext,
|
|
GetChokepointStatusRequest,
|
|
GetChokepointStatusResponse,
|
|
ChokepointInfo,
|
|
WarRiskTier,
|
|
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
|
|
|
|
import type {
|
|
ListNavigationalWarningsResponse,
|
|
GetVesselSnapshotResponse,
|
|
NavigationalWarning,
|
|
AisDisruption,
|
|
} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server';
|
|
|
|
import { cachedFetchJson, getCachedJson, setCachedJson } from '../../../_shared/redis';
|
|
import { listNavigationalWarnings } from '../../maritime/v1/list-navigational-warnings';
|
|
import { getVesselSnapshot } from '../../maritime/v1/get-vessel-snapshot';
|
|
import type { PortWatchData } from './_portwatch-upstream';
|
|
import { CANONICAL_CHOKEPOINTS } from './_chokepoint-ids';
|
|
// @ts-expect-error — .mjs module, no declaration file
|
|
import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE, THREAT_LEVEL, detectTrafficAnomaly } from './_scoring.mjs';
|
|
|
|
const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v4';
|
|
const TRANSIT_SUMMARIES_KEY = 'supply_chain:transit-summaries:v1';
|
|
const PORTWATCH_FALLBACK_KEY = 'supply_chain:portwatch:v1';
|
|
const CORRIDORRISK_FALLBACK_KEY = 'supply_chain:corridorrisk:v1';
|
|
const TRANSIT_COUNTS_FALLBACK_KEY = 'supply_chain:chokepoint_transits:v1';
|
|
const FLOWS_KEY = 'energy:chokepoint-flows:v1';
|
|
const REDIS_CACHE_TTL = 300; // 5 min
|
|
const THREAT_CONFIG_MAX_AGE_DAYS = 120;
|
|
const NEARBY_CHOKEPOINT_RADIUS_KM = 300;
|
|
const THREAT_CONFIG_STALE_NOTE = `Threat baseline last reviewed > ${THREAT_CONFIG_MAX_AGE_DAYS} days ago — review recommended`;
|
|
|
|
type ThreatLevel = 'war_zone' | 'critical' | 'high' | 'elevated' | 'normal';
|
|
type GeoCoordinates = { latitude: number; longitude: number };
|
|
|
|
function threatLevelToWarRiskTier(threatLevel: ThreatLevel): WarRiskTier {
|
|
switch (threatLevel) {
|
|
case 'war_zone': return 'WAR_RISK_TIER_WAR_ZONE';
|
|
case 'critical': return 'WAR_RISK_TIER_CRITICAL';
|
|
case 'high': return 'WAR_RISK_TIER_HIGH';
|
|
case 'elevated': return 'WAR_RISK_TIER_ELEVATED';
|
|
case 'normal': return 'WAR_RISK_TIER_NORMAL';
|
|
}
|
|
}
|
|
|
|
interface ChokepointConfig {
|
|
id: string;
|
|
name: string;
|
|
lat: number;
|
|
lon: number;
|
|
/**
|
|
* Precise chokepoint aliases used for high-confidence text matching.
|
|
* A single primary hit is enough to classify an event.
|
|
*/
|
|
primaryKeywords: string[];
|
|
/**
|
|
* Broader contextual tokens used only as secondary signals.
|
|
* To reduce false positives, non-primary matching requires >=2 context hits.
|
|
*/
|
|
areaKeywords: string[];
|
|
routes: string[];
|
|
/**
|
|
* Geopolitical threat classification — based on Lloyd's Joint War Committee
|
|
* Listed Areas and real-world maritime security conditions.
|
|
*
|
|
* war_zone — Active naval conflict, blockade, or strait closure
|
|
* critical — Active attacks on commercial shipping (e.g. Houthi drone/missile strikes)
|
|
* high — Military seizure risk, armed escort zones
|
|
* elevated — Military tensions, disputed waters (e.g. cross-strait exercises)
|
|
* normal — No significant military threat
|
|
*/
|
|
threatLevel: ThreatLevel;
|
|
/** Short explanation of the threat classification, shown in description. */
|
|
threatDescription: string;
|
|
directions: DirectionLabel[];
|
|
}
|
|
|
|
type DirectionLabel = 'eastbound' | 'westbound' | 'northbound' | 'southbound';
|
|
|
|
interface PreBuiltTransitSummary {
|
|
todayTotal: number;
|
|
todayTanker: number;
|
|
todayCargo: number;
|
|
todayOther: number;
|
|
wowChangePct: number;
|
|
history: import('./_portwatch-upstream').TransitDayCount[];
|
|
riskLevel: string;
|
|
incidentCount7d: number;
|
|
disruptionPct: number;
|
|
riskSummary: string;
|
|
riskReportAction: string;
|
|
anomaly: { dropPct: number; signal: boolean };
|
|
}
|
|
|
|
interface TransitSummariesPayload {
|
|
summaries: Record<string, PreBuiltTransitSummary>;
|
|
fetchedAt: number;
|
|
}
|
|
|
|
/**
|
|
* Date the threat-level classifications and descriptions were last reviewed.
|
|
* Review quarterly or whenever a major geopolitical shift occurs.
|
|
* Source: Lloyd's Joint War Committee Listed Areas + OSINT.
|
|
*/
|
|
export const THREAT_CONFIG_LAST_REVIEWED = '2026-03-04';
|
|
|
|
export const CHOKEPOINTS: ChokepointConfig[] = [
|
|
{ id: 'suez', name: 'Suez Canal', lat: 30.45, lon: 32.35, primaryKeywords: ['suez canal', 'suez'], areaKeywords: ['suez canal', 'suez', 'gulf of suez', 'red sea'], routes: ['China-Europe (Suez)', 'Gulf-Europe Oil', 'Qatar LNG-Europe'], threatLevel: 'high', threatDescription: 'JWC Listed Area — adjacent to active Red Sea conflict and Iran-Israel war spillover', directions: ['northbound', 'southbound'] },
|
|
{ id: 'malacca_strait', name: 'Strait of Malacca', lat: 2.5, lon: 101.5, primaryKeywords: ['strait of malacca', 'malacca'], areaKeywords: ['strait of malacca', 'malacca', 'singapore strait'], routes: ['China-Middle East Oil', 'China-Europe (via Suez)', 'Japan-Middle East Oil'], threatLevel: 'normal', threatDescription: '', directions: ['northbound', 'southbound'] },
|
|
{ id: 'hormuz_strait', name: 'Strait of Hormuz', lat: 26.56, lon: 56.25, primaryKeywords: ['strait of hormuz', 'hormuz'], areaKeywords: ['strait of hormuz', 'hormuz', 'persian gulf', 'arabian gulf', 'gulf of oman', 'iran naval', 'iran military'], routes: ['Gulf Oil Exports', 'Qatar LNG', 'Iran Exports'], threatLevel: 'war_zone', threatDescription: 'Active conflict — Iran-Israel war; Iranian naval blockade risk and mines reported in Persian Gulf', directions: ['eastbound', 'westbound'] },
|
|
{ id: 'bab_el_mandeb', name: 'Bab el-Mandeb', lat: 12.58, lon: 43.33, primaryKeywords: ['bab el-mandeb', 'bab al-mandab'], areaKeywords: ['bab el-mandeb', 'bab al-mandab', 'mandeb', 'aden', 'houthi', 'yemen', 'gulf of aden', 'red sea'], routes: ['Suez-Indian Ocean', 'Gulf-Europe Oil', 'Red Sea Transit'], threatLevel: 'critical', threatDescription: 'JWC Listed Area — active Houthi attacks on commercial shipping', directions: ['northbound', 'southbound'] },
|
|
{ id: 'panama', name: 'Panama Canal', lat: 9.08, lon: -79.68, primaryKeywords: ['panama canal'], areaKeywords: ['panama canal', 'panama'], routes: ['US East Coast-Asia', 'US East Coast-South America', 'Atlantic-Pacific Bulk'], threatLevel: 'normal', threatDescription: '', directions: ['northbound', 'southbound'] },
|
|
{ id: 'taiwan_strait', name: 'Taiwan Strait', lat: 24.0, lon: 119.5, primaryKeywords: ['taiwan strait', 'formosa'], areaKeywords: ['taiwan strait', 'formosa', 'taiwan', 'south china sea'], routes: ['China-Japan Trade', 'Korea-Southeast Asia', 'Pacific Semiconductor'], threatLevel: 'elevated', threatDescription: 'Cross-strait military tensions and PLA exercises', directions: ['northbound', 'southbound'] },
|
|
{ id: 'cape_of_good_hope', name: 'Cape of Good Hope', lat: -34.36, lon: 18.49, primaryKeywords: ['cape of good hope', 'good hope'], areaKeywords: ['cape of good hope', 'good hope', 'cape town', 'south africa', 'cape agulhas'], routes: ['Asia-Europe (Cape Route)', 'Gulf-Americas Oil', 'Suez Bypass'], threatLevel: 'normal', threatDescription: '', directions: ['eastbound', 'westbound'] },
|
|
{ id: 'gibraltar', name: 'Strait of Gibraltar', lat: 35.96, lon: -5.35, primaryKeywords: ['strait of gibraltar', 'gibraltar'], areaKeywords: ['strait of gibraltar', 'gibraltar', 'mediterranean', 'algeciras', 'tangier'], routes: ['Atlantic-Mediterranean', 'Gulf-Europe Oil (final leg)', 'India-Europe'], threatLevel: 'normal', threatDescription: '', directions: ['eastbound', 'westbound'] },
|
|
{ id: 'bosphorus', name: 'Bosporus Strait', lat: 41.12, lon: 29.05, primaryKeywords: ['bosphorus', 'bosporus', 'dardanelles', 'canakkale', 'turkish straits'], areaKeywords: ['bosphorus', 'bosporus', 'dardanelles', 'canakkale', 'istanbul', 'marmara', 'black sea', 'turkish straits', 'gallipoli', 'aegean'], routes: ['Russia Black Sea Exports', 'Ukraine Grain', 'Caspian Oil Transit', 'Aegean-Marmara Transit'], threatLevel: 'elevated', threatDescription: 'Montreux Convention restrictions; elevated due to Russia-Ukraine war and periodic Turkish traffic controls', directions: ['northbound', 'southbound'] },
|
|
{ id: 'korea_strait', name: 'Korea Strait', lat: 34.0, lon: 129.0, primaryKeywords: ['korea strait', 'tsushima strait'], areaKeywords: ['korea strait', 'tsushima', 'busan', 'shimonoseki', 'sea of japan', 'east sea'], routes: ['Japan-Korea Trade', 'China-Japan (alternate)', 'Pacific-East Asia'], threatLevel: 'normal', threatDescription: '', directions: ['northbound', 'southbound'] },
|
|
{ id: 'dover_strait', name: 'Dover Strait', lat: 51.05, lon: 1.45, primaryKeywords: ['dover strait', 'strait of dover', 'english channel'], areaKeywords: ['dover', 'calais', 'english channel', 'north sea', 'pas-de-calais'], routes: ['North Sea-Atlantic', 'Europe Intra-Trade', 'UK-Continental Europe'], threatLevel: 'normal', threatDescription: '', directions: ['northbound', 'southbound'] },
|
|
{ id: 'kerch_strait', name: 'Kerch Strait', lat: 45.33, lon: 36.60, primaryKeywords: ['kerch strait', 'kerch bridge'], areaKeywords: ['kerch', 'crimea', 'azov', 'sea of azov', 'black sea'], routes: ['Ukraine Grain (Azov)', 'Russia Azov Ports', 'Crimea Supply'], threatLevel: 'war_zone', threatDescription: 'Active conflict zone; Russia controls Kerch Bridge; Ukraine grain exports via Azov severely restricted', directions: ['northbound', 'southbound'] },
|
|
{ id: 'lombok_strait', name: 'Lombok Strait', lat: -8.47, lon: 115.72, primaryKeywords: ['lombok strait'], areaKeywords: ['lombok', 'bali', 'indonesia', 'nusa tenggara'], routes: ['Malacca Bypass (VLCCs)', 'Australia-Asia', 'Indian Ocean-Pacific'], threatLevel: 'normal', threatDescription: '', directions: ['northbound', 'southbound'] },
|
|
];
|
|
|
|
function normalizeText(input: string): string {
|
|
return input
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function containsPhrase(normalizedHaystack: string, keyword: string): boolean {
|
|
const normalizedKeyword = normalizeText(keyword);
|
|
if (!normalizedKeyword) return false;
|
|
return ` ${normalizedHaystack} `.includes(` ${normalizedKeyword} `);
|
|
}
|
|
|
|
function haversineKm(aLat: number, aLon: number, bLat: number, bLon: number): number {
|
|
const toRad = (deg: number) => (deg * Math.PI) / 180;
|
|
const dLat = toRad(bLat - aLat);
|
|
const dLon = toRad(bLon - aLon);
|
|
const x = Math.sin(dLat / 2) ** 2
|
|
+ Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
|
|
return 6371 * (2 * Math.atan2(Math.sqrt(x), Math.sqrt(1 - x)));
|
|
}
|
|
|
|
function nearestChokepoint(location?: GeoCoordinates): { id: string; distanceKm: number } | null {
|
|
if (!location) return null;
|
|
|
|
let closest: { id: string; distanceKm: number } | null = null;
|
|
for (const cp of CHOKEPOINTS) {
|
|
const distanceKm = haversineKm(location.latitude, location.longitude, cp.lat, cp.lon);
|
|
if (!closest || distanceKm < closest.distanceKm) {
|
|
closest = { id: cp.id, distanceKm };
|
|
}
|
|
}
|
|
return closest;
|
|
}
|
|
|
|
function keywordScore(cp: ChokepointConfig, normalizedText: string): number {
|
|
if (!normalizedText) return 0;
|
|
|
|
const primaryMatches = cp.primaryKeywords.filter((kw) => containsPhrase(normalizedText, kw));
|
|
const primarySet = new Set(primaryMatches.map(normalizeText));
|
|
const areaMatches = cp.areaKeywords.filter((kw) => {
|
|
const normalizedKw = normalizeText(kw);
|
|
return !primarySet.has(normalizedKw) && containsPhrase(normalizedText, kw);
|
|
});
|
|
|
|
// A single broad area token (e.g. "Red Sea") is too weak and often ambiguous.
|
|
if (primaryMatches.length === 0 && areaMatches.length < 2) return 0;
|
|
|
|
return primaryMatches.length * 3 + areaMatches.length;
|
|
}
|
|
|
|
export function resolveChokepointId(input: { text: string; location?: GeoCoordinates }): string | null {
|
|
const normalizedText = normalizeText(input.text);
|
|
let best: { id: string; score: number; distanceKm: number } | null = null;
|
|
|
|
for (const cp of CHOKEPOINTS) {
|
|
const score = keywordScore(cp, normalizedText);
|
|
if (score <= 0) continue;
|
|
|
|
const distanceKm = input.location
|
|
? haversineKm(input.location.latitude, input.location.longitude, cp.lat, cp.lon)
|
|
: Number.POSITIVE_INFINITY;
|
|
|
|
if (!best || score > best.score || (score === best.score && distanceKm < best.distanceKm)) {
|
|
best = { id: cp.id, score, distanceKm };
|
|
}
|
|
}
|
|
|
|
if (best) return best.id;
|
|
|
|
const nearest = nearestChokepoint(input.location);
|
|
if (nearest && nearest.distanceKm <= NEARBY_CHOKEPOINT_RADIUS_KM) {
|
|
return nearest.id;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function groupWarningsByChokepoint(warnings: NavigationalWarning[]): Map<string, NavigationalWarning[]> {
|
|
const grouped = new Map<string, NavigationalWarning[]>();
|
|
for (const cp of CHOKEPOINTS) grouped.set(cp.id, []);
|
|
|
|
for (const warning of warnings) {
|
|
const id = resolveChokepointId({
|
|
text: `${warning.title} ${warning.area} ${warning.text}`,
|
|
location: warning.location,
|
|
});
|
|
if (!id) continue;
|
|
grouped.get(id)!.push(warning);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
function groupDisruptionsByChokepoint(disruptions: AisDisruption[]): Map<string, AisDisruption[]> {
|
|
const grouped = new Map<string, AisDisruption[]>();
|
|
for (const cp of CHOKEPOINTS) grouped.set(cp.id, []);
|
|
|
|
for (const disruption of disruptions) {
|
|
if (disruption.type !== 'AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION') continue;
|
|
|
|
const id = resolveChokepointId({
|
|
text: `${disruption.name} ${disruption.region} ${disruption.description}`,
|
|
location: disruption.location,
|
|
});
|
|
if (!id) continue;
|
|
grouped.get(id)!.push(disruption);
|
|
}
|
|
|
|
return grouped;
|
|
}
|
|
|
|
export function isThreatConfigFresh(asOfMs = Date.now()): boolean {
|
|
const reviewedAtMs = Date.parse(THREAT_CONFIG_LAST_REVIEWED);
|
|
if (!Number.isFinite(reviewedAtMs)) return false;
|
|
const maxAgeMs = THREAT_CONFIG_MAX_AGE_DAYS * 24 * 60 * 60 * 1000;
|
|
return asOfMs - reviewedAtMs <= maxAgeMs;
|
|
}
|
|
|
|
function makeInternalCtx(): { request: Request; pathParams: Record<string, string>; headers: Record<string, string> } {
|
|
return { request: new Request('http://internal'), pathParams: {}, headers: {} };
|
|
}
|
|
|
|
interface ChokepointFetchResult {
|
|
chokepoints: ChokepointInfo[];
|
|
upstreamUnavailable: boolean;
|
|
}
|
|
|
|
interface CorridorRiskEntry { riskLevel: string; incidentCount7d: number; disruptionPct: number; riskSummary: string; riskReportAction: string }
|
|
interface RelayTransitEntry { tanker: number; cargo: number; other: number; total: number }
|
|
interface FlowEstimateEntry { currentMbd: number; baselineMbd: number; flowRatio: number; disrupted: boolean; source: string; hazardAlertLevel: string | null; hazardAlertName: string | null }
|
|
interface RelayTransitPayload { transits: Record<string, RelayTransitEntry>; fetchedAt: number }
|
|
|
|
function buildFallbackSummaries(
|
|
portwatch: PortWatchData | null,
|
|
corridorRisk: Record<string, CorridorRiskEntry> | null,
|
|
transitData: RelayTransitPayload | null,
|
|
chokepoints: ChokepointConfig[],
|
|
): Record<string, PreBuiltTransitSummary> {
|
|
const summaries: Record<string, PreBuiltTransitSummary> = {};
|
|
const relayMap = new Map<string, RelayTransitEntry>();
|
|
if (transitData?.transits) {
|
|
for (const [relayName, entry] of Object.entries(transitData.transits)) {
|
|
const canonical = CANONICAL_CHOKEPOINTS.find(c => c.relayName === relayName);
|
|
if (canonical) relayMap.set(canonical.id, entry);
|
|
}
|
|
}
|
|
for (const cp of chokepoints) {
|
|
const pw = portwatch?.[cp.id];
|
|
const cr = corridorRisk?.[cp.id];
|
|
const relay = relayMap.get(cp.id);
|
|
const anomaly = detectTrafficAnomaly(pw?.history ?? [], cp.threatLevel);
|
|
summaries[cp.id] = {
|
|
todayTotal: relay?.total ?? 0,
|
|
todayTanker: relay?.tanker ?? 0,
|
|
todayCargo: relay?.cargo ?? 0,
|
|
todayOther: relay?.other ?? 0,
|
|
wowChangePct: pw?.wowChangePct ?? 0,
|
|
history: pw?.history ?? [],
|
|
riskLevel: cr?.riskLevel ?? '',
|
|
incidentCount7d: cr?.incidentCount7d ?? 0,
|
|
disruptionPct: cr?.disruptionPct ?? 0,
|
|
riskSummary: cr?.riskSummary ?? '',
|
|
riskReportAction: cr?.riskReportAction ?? '',
|
|
anomaly,
|
|
};
|
|
}
|
|
return summaries;
|
|
}
|
|
|
|
async function fetchChokepointData(): Promise<ChokepointFetchResult> {
|
|
const ctx = makeInternalCtx();
|
|
|
|
let navFailed = false;
|
|
let vesselFailed = false;
|
|
|
|
const [navResult, vesselResult, transitSummariesData, flowsData] = await Promise.all([
|
|
listNavigationalWarnings(ctx, { area: '', pageSize: 0, cursor: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }),
|
|
getVesselSnapshot(ctx, { neLat: 90, neLon: 180, swLat: -90, swLon: -180 }).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }),
|
|
getCachedJson(TRANSIT_SUMMARIES_KEY, true).catch(() => null) as Promise<TransitSummariesPayload | null>,
|
|
getCachedJson(FLOWS_KEY, true).catch(() => null) as Promise<Record<string, FlowEstimateEntry> | null>,
|
|
]);
|
|
|
|
let summaries = transitSummariesData?.summaries ?? {};
|
|
|
|
// Fallback: if pre-built summaries are empty, read raw upstream keys directly
|
|
if (Object.keys(summaries).length === 0) {
|
|
const [portwatch, corridorRisk, transitCounts] = await Promise.all([
|
|
getCachedJson(PORTWATCH_FALLBACK_KEY, true).catch(() => null) as Promise<PortWatchData | null>,
|
|
getCachedJson(CORRIDORRISK_FALLBACK_KEY, true).catch(() => null) as Promise<Record<string, CorridorRiskEntry> | null>,
|
|
getCachedJson(TRANSIT_COUNTS_FALLBACK_KEY, true).catch(() => null) as Promise<RelayTransitPayload | null>,
|
|
]);
|
|
if (portwatch && Object.keys(portwatch).length > 0) {
|
|
summaries = buildFallbackSummaries(portwatch, corridorRisk, transitCounts, CHOKEPOINTS);
|
|
}
|
|
}
|
|
const warnings = navResult.warnings || [];
|
|
const disruptions: AisDisruption[] = vesselResult.snapshot?.disruptions || [];
|
|
const upstreamUnavailable = (navFailed && vesselFailed) || (navFailed && disruptions.length === 0) || (vesselFailed && warnings.length === 0);
|
|
const warningsByChokepoint = groupWarningsByChokepoint(warnings);
|
|
const disruptionsByChokepoint = groupDisruptionsByChokepoint(disruptions);
|
|
const threatConfigFresh = isThreatConfigFresh();
|
|
|
|
const chokepoints = CHOKEPOINTS.map((cp): ChokepointInfo => {
|
|
const matchedWarnings = warningsByChokepoint.get(cp.id) ?? [];
|
|
const matchedDisruptions = disruptionsByChokepoint.get(cp.id) ?? [];
|
|
|
|
const maxSeverity = matchedDisruptions.reduce((max, d) => {
|
|
const score = (SEVERITY_SCORE as Record<string, number>)[d.severity] ?? 0;
|
|
return Math.max(max, score);
|
|
}, 0);
|
|
|
|
const threatScore = (THREAT_LEVEL as Record<string, number>)[cp.threatLevel] ?? 0;
|
|
const ts = summaries[cp.id];
|
|
const anomaly = ts?.anomaly ?? { dropPct: 0, signal: false };
|
|
const anomalyBonus = anomaly.signal ? 10 : 0;
|
|
const disruptionScore = Math.min(100, computeDisruptionScore(threatScore, matchedWarnings.length, maxSeverity) + anomalyBonus);
|
|
const status = scoreToStatus(disruptionScore);
|
|
|
|
const congestionLevel = maxSeverity >= 3 ? 'high' : maxSeverity >= 2 ? 'elevated' : maxSeverity >= 1 ? 'low' : 'normal';
|
|
|
|
const descriptions: string[] = [];
|
|
if (cp.threatDescription) {
|
|
descriptions.push(cp.threatDescription);
|
|
}
|
|
if (anomaly.signal) {
|
|
descriptions.push(`Traffic down ${anomaly.dropPct}% vs 30-day baseline, vessels may be transiting dark (AIS off)`);
|
|
}
|
|
if (!threatConfigFresh) {
|
|
descriptions.push(THREAT_CONFIG_STALE_NOTE);
|
|
}
|
|
if (descriptions.length === 0) {
|
|
descriptions.push('No active disruptions');
|
|
}
|
|
|
|
return {
|
|
id: cp.id,
|
|
name: cp.name,
|
|
lat: cp.lat,
|
|
lon: cp.lon,
|
|
disruptionScore,
|
|
status,
|
|
activeWarnings: matchedWarnings.length,
|
|
aisDisruptions: matchedDisruptions.length,
|
|
congestionLevel,
|
|
affectedRoutes: cp.routes,
|
|
description: descriptions.join('; '),
|
|
directions: cp.directions,
|
|
directionalDwt: [],
|
|
transitSummary: ts ? {
|
|
todayTotal: ts.todayTotal,
|
|
todayTanker: ts.todayTanker,
|
|
todayCargo: ts.todayCargo,
|
|
todayOther: ts.todayOther,
|
|
wowChangePct: ts.wowChangePct,
|
|
history: ts.history,
|
|
riskLevel: ts.riskLevel,
|
|
incidentCount7d: ts.incidentCount7d,
|
|
disruptionPct: ts.disruptionPct,
|
|
riskSummary: ts.riskSummary,
|
|
riskReportAction: ts.riskReportAction,
|
|
} : { todayTotal: 0, todayTanker: 0, todayCargo: 0, todayOther: 0, wowChangePct: 0, history: [], riskLevel: '', incidentCount7d: 0, disruptionPct: 0, riskSummary: '', riskReportAction: '' },
|
|
flowEstimate: flowsData?.[cp.id] ? {
|
|
currentMbd: flowsData[cp.id]!.currentMbd,
|
|
baselineMbd: flowsData[cp.id]!.baselineMbd,
|
|
flowRatio: flowsData[cp.id]!.flowRatio,
|
|
disrupted: flowsData[cp.id]!.disrupted,
|
|
source: flowsData[cp.id]!.source,
|
|
hazardAlertLevel: flowsData[cp.id]!.hazardAlertLevel ?? '',
|
|
hazardAlertName: flowsData[cp.id]!.hazardAlertName ?? '',
|
|
} : undefined,
|
|
warRiskTier: threatLevelToWarRiskTier(cp.threatLevel),
|
|
};
|
|
});
|
|
|
|
return { chokepoints, upstreamUnavailable };
|
|
}
|
|
|
|
export async function getChokepointStatus(
|
|
_ctx: ServerContext,
|
|
_req: GetChokepointStatusRequest,
|
|
): Promise<GetChokepointStatusResponse> {
|
|
try {
|
|
const result = await cachedFetchJson<GetChokepointStatusResponse>(
|
|
REDIS_CACHE_KEY,
|
|
REDIS_CACHE_TTL,
|
|
async () => {
|
|
const { chokepoints, upstreamUnavailable } = await fetchChokepointData();
|
|
if (upstreamUnavailable) return null;
|
|
const response = { chokepoints, fetchedAt: new Date().toISOString(), upstreamUnavailable };
|
|
setCachedJson('seed-meta:supply_chain:chokepoints', { fetchedAt: Date.now(), recordCount: chokepoints.length }, 604800).catch(() => {});
|
|
return response;
|
|
},
|
|
);
|
|
|
|
return result ?? { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
|
} catch {
|
|
return { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
|
|
}
|
|
}
|