feat: add Supply Chain Disruption Intelligence service (#387)

* feat: add Supply Chain Disruption Intelligence service

Add new supply_chain domain with 3 RPCs:
- GetShippingRates: FRED Baltic Dry Index with spike detection
- GetChokepointStatus: NGA nav warnings + AIS vessel aggregation
  for 6 global chokepoints with composite disruption scoring
- GetCriticalMinerals: HHI concentration analysis for 7 strategic
  minerals from USGS 2024 production data

Frontend: 3-tab SupplyChainPanel with event delegation, sparkline
SVG, escapeHtml XSS hardening, and circuit breakers per RPC.

13 unit tests for pure scoring functions (_scoring.mjs).

* fix: chokepoint upstream detection, FRED weekly frequency, missing CSS

P1: Chokepoint handler propagates upstreamUnavailable when maritime
sources fail or return empty, instead of always reporting false.

P1: FRED BDI uses frequency=w for true 52-week window.

P2: Add CSS for sc-status-dot, sc-dot-*, sc-risk-* classes.

* fix: allow partial chokepoint data when one upstream source succeeds
This commit is contained in:
Elie Habib
2026-02-26 08:20:33 +04:00
committed by GitHub
parent b0071d1a14
commit dc30693135
29 changed files with 1439 additions and 5 deletions

View File

@@ -52,6 +52,8 @@ import { createGivingServiceRoutes } from '../../../src/generated/server/worldmo
import { givingHandler } from '../../../server/worldmonitor/giving/v1/handler';
import { createTradeServiceRoutes } from '../../../src/generated/server/worldmonitor/trade/v1/service_server';
import { tradeHandler } from '../../../server/worldmonitor/trade/v1/handler';
import { createSupplyChainServiceRoutes } from '../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { supplyChainHandler } from '../../../server/worldmonitor/supply-chain/v1/handler';
import type { ServerOptions } from '../../../src/generated/server/worldmonitor/seismology/v1/service_server';
@@ -78,6 +80,7 @@ const allRoutes = [
...createPositiveEventsServiceRoutes(positiveEventsHandler, serverOptions),
...createGivingServiceRoutes(givingHandler, serverOptions),
...createTradeServiceRoutes(tradeHandler, serverOptions),
...createSupplyChainServiceRoutes(supplyChainHandler, serverOptions),
];
const router = createRouter(allRoutes);

View File

@@ -0,0 +1,13 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
message GetChokepointStatusRequest {}
message GetChokepointStatusResponse {
repeated ChokepointInfo chokepoints = 1;
string fetched_at = 2;
bool upstream_unavailable = 3;
}

View File

@@ -0,0 +1,13 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
message GetCriticalMineralsRequest {}
message GetCriticalMineralsResponse {
repeated CriticalMineral minerals = 1;
string fetched_at = 2;
bool upstream_unavailable = 3;
}

View File

@@ -0,0 +1,13 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "worldmonitor/supply_chain/v1/supply_chain_data.proto";
message GetShippingRatesRequest {}
message GetShippingRatesResponse {
repeated ShippingIndex indices = 1;
string fetched_at = 2;
bool upstream_unavailable = 3;
}

View File

@@ -0,0 +1,24 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "sebuf/http/annotations.proto";
import "worldmonitor/supply_chain/v1/get_shipping_rates.proto";
import "worldmonitor/supply_chain/v1/get_chokepoint_status.proto";
import "worldmonitor/supply_chain/v1/get_critical_minerals.proto";
service SupplyChainService {
option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"};
rpc GetShippingRates(GetShippingRatesRequest) returns (GetShippingRatesResponse) {
option (sebuf.http.config) = {path: "/get-shipping-rates"};
}
rpc GetChokepointStatus(GetChokepointStatusRequest) returns (GetChokepointStatusResponse) {
option (sebuf.http.config) = {path: "/get-chokepoint-status"};
}
rpc GetCriticalMinerals(GetCriticalMineralsRequest) returns (GetCriticalMineralsResponse) {
option (sebuf.http.config) = {path: "/get-critical-minerals"};
}
}

View File

@@ -0,0 +1,48 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
message ShippingRatePoint {
string date = 1;
double value = 2;
}
message ShippingIndex {
string index_id = 1;
string name = 2;
double current_value = 3;
double previous_value = 4;
double change_pct = 5;
string unit = 6;
repeated ShippingRatePoint history = 7;
bool spike_alert = 8;
}
message ChokepointInfo {
string id = 1;
string name = 2;
double lat = 3;
double lon = 4;
int32 disruption_score = 5;
string status = 6;
int32 active_warnings = 7;
string congestion_level = 8;
repeated string affected_routes = 9;
string description = 10;
}
message MineralProducer {
string country = 1;
string country_code = 2;
double production_tonnes = 3;
double share_pct = 4;
}
message CriticalMineral {
string mineral = 1;
repeated MineralProducer top_producers = 2;
double hhi = 3;
string risk_rating = 4;
double global_production = 5;
string unit = 6;
}

View File

@@ -0,0 +1,51 @@
export interface MineralProductionEntry {
mineral: string;
country: string;
countryCode: string;
productionTonnes: number;
unit: string;
}
export const MINERAL_PRODUCTION_2024: MineralProductionEntry[] = [
// Lithium (tonnes LCE)
{ mineral: 'Lithium', country: 'Australia', countryCode: 'AU', productionTonnes: 86000, unit: 'tonnes LCE' },
{ mineral: 'Lithium', country: 'Chile', countryCode: 'CL', productionTonnes: 44000, unit: 'tonnes LCE' },
{ mineral: 'Lithium', country: 'China', countryCode: 'CN', productionTonnes: 33000, unit: 'tonnes LCE' },
{ mineral: 'Lithium', country: 'Argentina', countryCode: 'AR', productionTonnes: 9600, unit: 'tonnes LCE' },
// Cobalt (tonnes)
{ mineral: 'Cobalt', country: 'DRC', countryCode: 'CD', productionTonnes: 130000, unit: 'tonnes' },
{ mineral: 'Cobalt', country: 'Indonesia', countryCode: 'ID', productionTonnes: 17000, unit: 'tonnes' },
{ mineral: 'Cobalt', country: 'Russia', countryCode: 'RU', productionTonnes: 8900, unit: 'tonnes' },
{ mineral: 'Cobalt', country: 'Australia', countryCode: 'AU', productionTonnes: 5600, unit: 'tonnes' },
// Rare Earths (tonnes REO)
{ mineral: 'Rare Earths', country: 'China', countryCode: 'CN', productionTonnes: 240000, unit: 'tonnes REO' },
{ mineral: 'Rare Earths', country: 'Myanmar', countryCode: 'MM', productionTonnes: 38000, unit: 'tonnes REO' },
{ mineral: 'Rare Earths', country: 'USA', countryCode: 'US', productionTonnes: 43000, unit: 'tonnes REO' },
{ mineral: 'Rare Earths', country: 'Australia', countryCode: 'AU', productionTonnes: 18000, unit: 'tonnes REO' },
// Nickel (tonnes)
{ mineral: 'Nickel', country: 'Indonesia', countryCode: 'ID', productionTonnes: 1800000, unit: 'tonnes' },
{ mineral: 'Nickel', country: 'Philippines', countryCode: 'PH', productionTonnes: 330000, unit: 'tonnes' },
{ mineral: 'Nickel', country: 'Russia', countryCode: 'RU', productionTonnes: 220000, unit: 'tonnes' },
{ mineral: 'Nickel', country: 'New Caledonia', countryCode: 'NC', productionTonnes: 190000, unit: 'tonnes' },
// Copper (tonnes)
{ mineral: 'Copper', country: 'Chile', countryCode: 'CL', productionTonnes: 5300000, unit: 'tonnes' },
{ mineral: 'Copper', country: 'DRC', countryCode: 'CD', productionTonnes: 2500000, unit: 'tonnes' },
{ mineral: 'Copper', country: 'Peru', countryCode: 'PE', productionTonnes: 2200000, unit: 'tonnes' },
{ mineral: 'Copper', country: 'China', countryCode: 'CN', productionTonnes: 1900000, unit: 'tonnes' },
// Gallium (tonnes)
{ mineral: 'Gallium', country: 'China', countryCode: 'CN', productionTonnes: 600, unit: 'tonnes' },
{ mineral: 'Gallium', country: 'Japan', countryCode: 'JP', productionTonnes: 10, unit: 'tonnes' },
{ mineral: 'Gallium', country: 'South Korea', countryCode: 'KR', productionTonnes: 8, unit: 'tonnes' },
{ mineral: 'Gallium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' },
// Germanium (tonnes)
{ mineral: 'Germanium', country: 'China', countryCode: 'CN', productionTonnes: 95, unit: 'tonnes' },
{ mineral: 'Germanium', country: 'Belgium', countryCode: 'BE', productionTonnes: 15, unit: 'tonnes' },
{ mineral: 'Germanium', country: 'Canada', countryCode: 'CA', productionTonnes: 9, unit: 'tonnes' },
{ mineral: 'Germanium', country: 'Russia', countryCode: 'RU', productionTonnes: 5, unit: 'tonnes' },
];

View File

@@ -0,0 +1,39 @@
export const SEVERITY_SCORE = {
'AIS_DISRUPTION_SEVERITY_LOW': 1,
'AIS_DISRUPTION_SEVERITY_ELEVATED': 2,
'AIS_DISRUPTION_SEVERITY_HIGH': 3,
};
export function computeDisruptionScore(warningCount, congestionSeverity) {
return Math.min(100, warningCount * 15 + congestionSeverity * 30);
}
export function scoreToStatus(score) {
if (score < 20) return 'green';
if (score < 50) return 'yellow';
return 'red';
}
export function computeHHI(shares) {
if (!shares || shares.length === 0) return 0;
return shares.reduce((sum, s) => sum + s * s, 0);
}
export function riskRating(hhi) {
if (hhi >= 5000) return 'critical';
if (hhi >= 2500) return 'high';
if (hhi >= 1500) return 'moderate';
return 'low';
}
export function detectSpike(history) {
if (!history || history.length < 3) return false;
const values = history.map(h => typeof h === 'number' ? h : h.value).filter(v => Number.isFinite(v));
if (values.length < 3) return false;
const mean = values.reduce((a, b) => a + b, 0) / values.length;
const variance = values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length;
const stdDev = Math.sqrt(variance);
if (stdDev === 0) return false;
const latest = values[values.length - 1];
return latest > mean + 2 * stdDev;
}

View File

@@ -0,0 +1,125 @@
import type {
ServerContext,
GetChokepointStatusRequest,
GetChokepointStatusResponse,
ChokepointInfo,
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import type {
ListNavigationalWarningsResponse,
GetVesselSnapshotResponse,
AisDisruption,
} from '../../../../src/generated/server/worldmonitor/maritime/v1/service_server';
import { cachedFetchJson } from '../../../_shared/redis';
import { listNavigationalWarnings } from '../../maritime/v1/list-navigational-warnings';
import { getVesselSnapshot } from '../../maritime/v1/get-vessel-snapshot';
// @ts-expect-error — .mjs module, no declaration file
import { computeDisruptionScore, scoreToStatus, SEVERITY_SCORE } from './_scoring.mjs';
const REDIS_CACHE_KEY = 'supply_chain:chokepoints:v1';
const REDIS_CACHE_TTL = 300;
interface ChokepointConfig {
id: string;
name: string;
lat: number;
lon: number;
areaKeywords: string[];
routes: string[];
}
const CHOKEPOINTS: ChokepointConfig[] = [
{ id: 'suez', name: 'Suez Canal', lat: 30.45, lon: 32.35, areaKeywords: ['suez', 'red sea'], routes: ['China-Europe (Suez)', 'Gulf-Europe Oil', 'Qatar LNG-Europe'] },
{ id: 'malacca', name: 'Malacca Strait', lat: 1.43, lon: 103.5, areaKeywords: ['malacca', 'singapore strait'], routes: ['China-Middle East Oil', 'China-Europe (via Suez)', 'Japan-Middle East Oil'] },
{ id: 'hormuz', name: 'Strait of Hormuz', lat: 26.56, lon: 56.25, areaKeywords: ['hormuz', 'persian gulf', 'arabian gulf'], routes: ['Gulf Oil Exports', 'Qatar LNG', 'Iran Exports'] },
{ id: 'bab_el_mandeb', name: 'Bab el-Mandeb', lat: 12.58, lon: 43.33, areaKeywords: ['bab el-mandeb', 'bab al-mandab', 'mandeb', 'aden'], routes: ['Suez-Indian Ocean', 'Gulf-Europe Oil', 'Red Sea Transit'] },
{ id: 'panama', name: 'Panama Canal', lat: 9.08, lon: -79.68, areaKeywords: ['panama'], routes: ['US East Coast-Asia', 'US East Coast-South America', 'Atlantic-Pacific Bulk'] },
{ id: 'taiwan', name: 'Taiwan Strait', lat: 24.0, lon: 119.5, areaKeywords: ['taiwan strait', 'formosa'], routes: ['China-Japan Trade', 'Korea-Southeast Asia', 'Pacific Semiconductor'] },
];
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;
}
async function fetchChokepointData(): Promise<ChokepointFetchResult> {
const ctx = makeInternalCtx();
let navFailed = false;
let vesselFailed = false;
const [navResult, vesselResult] = await Promise.all([
listNavigationalWarnings(ctx, { area: '' }).catch((): ListNavigationalWarningsResponse => { navFailed = true; return { warnings: [], pagination: undefined }; }),
getVesselSnapshot(ctx, {}).catch((): GetVesselSnapshotResponse => { vesselFailed = true; return { snapshot: undefined }; }),
]);
const warnings = navResult.warnings || [];
const disruptions: AisDisruption[] = vesselResult.snapshot?.disruptions || [];
const upstreamUnavailable = (navFailed && vesselFailed) || (navFailed && disruptions.length === 0) || (vesselFailed && warnings.length === 0);
const chokepoints = CHOKEPOINTS.map((cp): ChokepointInfo => {
const matchedWarnings = warnings.filter(w =>
cp.areaKeywords.some(kw => w.text.toLowerCase().includes(kw) || w.area.toLowerCase().includes(kw))
);
const matchedDisruptions = disruptions.filter(d =>
d.type === 'AIS_DISRUPTION_TYPE_CHOKEPOINT_CONGESTION' &&
cp.areaKeywords.some(kw => d.region.toLowerCase().includes(kw) || d.name.toLowerCase().includes(kw))
);
const maxSeverity = matchedDisruptions.reduce((max, d) => {
const score = (SEVERITY_SCORE as Record<string, number>)[d.severity] ?? 0;
return Math.max(max, score);
}, 0);
const disruptionScore = computeDisruptionScore(matchedWarnings.length, maxSeverity);
const status = scoreToStatus(disruptionScore);
const congestionLevel = maxSeverity >= 3 ? 'high' : maxSeverity >= 2 ? 'elevated' : maxSeverity >= 1 ? 'low' : 'normal';
const descriptions: string[] = [];
if (matchedWarnings.length > 0) descriptions.push(`${matchedWarnings.length} active navigational warning(s)`);
if (matchedDisruptions.length > 0) descriptions.push(`AIS congestion detected`);
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,
congestionLevel,
affectedRoutes: cp.routes,
description: descriptions.join('; '),
};
});
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();
return { chokepoints, fetchedAt: new Date().toISOString(), upstreamUnavailable };
},
);
return result ?? { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
} catch {
return { chokepoints: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
}
}

View File

@@ -0,0 +1,75 @@
import type {
ServerContext,
GetCriticalMineralsRequest,
GetCriticalMineralsResponse,
CriticalMineral,
MineralProducer,
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { cachedFetchJson } from '../../../_shared/redis';
import { MINERAL_PRODUCTION_2024 } from './_minerals-data';
// @ts-expect-error — .mjs module, no declaration file
import { computeHHI, riskRating } from './_scoring.mjs';
const REDIS_CACHE_KEY = 'supply_chain:minerals:v1';
const REDIS_CACHE_TTL = 86400;
function buildMineralsData(): CriticalMineral[] {
const byMineral = new Map<string, typeof MINERAL_PRODUCTION_2024>();
for (const entry of MINERAL_PRODUCTION_2024) {
const existing = byMineral.get(entry.mineral) || [];
existing.push(entry);
byMineral.set(entry.mineral, existing);
}
const minerals: CriticalMineral[] = [];
for (const [mineral, entries] of byMineral) {
const globalProduction = entries.reduce((sum, e) => sum + e.productionTonnes, 0);
const unit = entries[0]?.unit || 'tonnes';
const producers: MineralProducer[] = entries
.sort((a, b) => b.productionTonnes - a.productionTonnes)
.slice(0, 5)
.map(e => ({
country: e.country,
countryCode: e.countryCode,
productionTonnes: e.productionTonnes,
sharePct: globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0,
}));
const shares = entries.map(e => globalProduction > 0 ? (e.productionTonnes / globalProduction) * 100 : 0);
const hhi = computeHHI(shares);
minerals.push({
mineral,
topProducers: producers,
hhi,
riskRating: riskRating(hhi),
globalProduction,
unit,
});
}
return minerals.sort((a, b) => b.hhi - a.hhi);
}
export async function getCriticalMinerals(
_ctx: ServerContext,
_req: GetCriticalMineralsRequest,
): Promise<GetCriticalMineralsResponse> {
try {
const result = await cachedFetchJson<GetCriticalMineralsResponse>(
REDIS_CACHE_KEY,
REDIS_CACHE_TTL,
async () => {
const minerals = buildMineralsData();
return { minerals, fetchedAt: new Date().toISOString(), upstreamUnavailable: false };
},
);
return result ?? { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
} catch {
return { minerals: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
}
}

View File

@@ -0,0 +1,93 @@
declare const process: { env: Record<string, string | undefined> };
import type {
ServerContext,
GetShippingRatesRequest,
GetShippingRatesResponse,
ShippingIndex,
ShippingRatePoint,
} from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { cachedFetchJson } from '../../../_shared/redis';
import { CHROME_UA } from '../../../_shared/constants';
// @ts-expect-error — .mjs module, no declaration file
import { detectSpike } from './_scoring.mjs';
const FRED_API_BASE = 'https://api.stlouisfed.org/fred';
const REDIS_CACHE_KEY = 'supply_chain:shipping:v1';
const REDIS_CACHE_TTL = 3600;
async function fetchBDI(): Promise<ShippingIndex | null> {
const apiKey = process.env.FRED_API_KEY;
if (!apiKey) return null;
try {
const params = new URLSearchParams({
series_id: 'BDIY',
api_key: apiKey,
file_type: 'json',
frequency: 'w',
sort_order: 'desc',
limit: '52',
});
const response = await fetch(`${FRED_API_BASE}/series/observations?${params}`, {
headers: { Accept: 'application/json', 'User-Agent': CHROME_UA },
signal: AbortSignal.timeout(10000),
});
if (!response.ok) return null;
const data = await response.json() as { observations?: Array<{ date: string; value: string }> };
const observations = (data.observations || [])
.map((obs): ShippingRatePoint | null => {
const value = parseFloat(obs.value);
if (isNaN(value) || obs.value === '.') return null;
return { date: obs.date, value };
})
.filter((o): o is ShippingRatePoint => o !== null)
.reverse();
if (observations.length === 0) return null;
const currentValue = observations[observations.length - 1]!.value;
const previousValue = observations.length > 1 ? observations[observations.length - 2]!.value : currentValue;
const changePct = previousValue !== 0 ? ((currentValue - previousValue) / previousValue) * 100 : 0;
const spikeAlert = detectSpike(observations);
return {
indexId: 'BDIY',
name: 'Baltic Dry Index',
currentValue,
previousValue,
changePct,
unit: 'index points',
history: observations,
spikeAlert,
};
} catch {
return null;
}
}
export async function getShippingRates(
_ctx: ServerContext,
_req: GetShippingRatesRequest,
): Promise<GetShippingRatesResponse> {
try {
const result = await cachedFetchJson<GetShippingRatesResponse>(
REDIS_CACHE_KEY,
REDIS_CACHE_TTL,
async () => {
const bdi = await fetchBDI();
if (!bdi) return null;
return { indices: [bdi], fetchedAt: new Date().toISOString(), upstreamUnavailable: false };
},
);
return result ?? { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
} catch {
return { indices: [], fetchedAt: new Date().toISOString(), upstreamUnavailable: true };
}
}

View File

@@ -0,0 +1,11 @@
import type { SupplyChainServiceHandler } from '../../../../src/generated/server/worldmonitor/supply_chain/v1/service_server';
import { getShippingRates } from './get-shipping-rates';
import { getChokepointStatus } from './get-chokepoint-status';
import { getCriticalMinerals } from './get-critical-minerals';
export const supplyChainHandler: SupplyChainServiceHandler = {
getShippingRates,
getChokepointStatus,
getCriticalMinerals,
};

View File

@@ -485,6 +485,7 @@ export class App {
// WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), 10 * 60 * 1000);
this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), 10 * 60 * 1000);
}
// Refresh intelligence signals for CII (geopolitical variant only)

View File

@@ -52,6 +52,9 @@ import {
fetchTariffTrends,
fetchTradeFlows,
fetchTradeBarriers,
fetchShippingRates,
fetchChokepointStatus,
fetchCriticalMinerals,
} from '@/services';
import { mlWorker } from '@/services/ml-worker';
import { clusterNewsHybrid } from '@/services/clustering';
@@ -91,6 +94,7 @@ import {
ClimateAnomalyPanel,
PopulationExposurePanel,
TradePolicyPanel,
SupplyChainPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { classifyNewsItem } from '@/services/positive-classifier';
@@ -169,6 +173,7 @@ export class DataLoaderManager implements AppModule {
// Trade policy data (FULL and FINANCE only)
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
tasks.push({ name: 'tradePolicy', task: runGuarded('tradePolicy', () => this.loadTradePolicy()) });
tasks.push({ name: 'supplyChain', task: runGuarded('supplyChain', () => this.loadSupplyChain()) });
}
}
@@ -1544,6 +1549,42 @@ export class DataLoaderManager implements AppModule {
}
}
async loadSupplyChain(): Promise<void> {
const scPanel = this.ctx.panels['supply-chain'] as SupplyChainPanel | undefined;
if (!scPanel) return;
try {
const [shipping, chokepoints, minerals] = await Promise.allSettled([
fetchShippingRates(),
fetchChokepointStatus(),
fetchCriticalMinerals(),
]);
const shippingData = shipping.status === 'fulfilled' ? shipping.value : null;
const chokepointData = chokepoints.status === 'fulfilled' ? chokepoints.value : null;
const mineralsData = minerals.status === 'fulfilled' ? minerals.value : null;
if (shippingData) scPanel.updateShippingRates(shippingData);
if (chokepointData) scPanel.updateChokepointStatus(chokepointData);
if (mineralsData) scPanel.updateCriticalMinerals(mineralsData);
const totalItems = (shippingData?.indices.length || 0) + (chokepointData?.chokepoints.length || 0) + (mineralsData?.minerals.length || 0);
const anyUnavailable = shippingData?.upstreamUnavailable || chokepointData?.upstreamUnavailable || mineralsData?.upstreamUnavailable;
this.ctx.statusPanel?.updateApi('SupplyChain', { status: anyUnavailable ? 'warning' : totalItems > 0 ? 'ok' : 'error' });
if (totalItems > 0) {
dataFreshness.recordUpdate('supply_chain', totalItems);
} else if (anyUnavailable) {
dataFreshness.recordError('supply_chain', 'Supply chain upstream temporarily unavailable');
}
} catch (e) {
console.error('[App] Supply chain failed:', e);
this.ctx.statusPanel?.updateApi('SupplyChain', { status: 'error' });
dataFreshness.recordError('supply_chain', String(e));
}
}
updateMonitorResults(): void {
const monitorPanel = this.ctx.panels['monitors'] as MonitorPanel;
monitorPanel.renderResults(this.ctx.allNews);

View File

@@ -32,6 +32,7 @@ import {
PopulationExposurePanel,
InvestmentsPanel,
TradePolicyPanel,
SupplyChainPanel,
} from '@/components';
import { SatelliteFiresPanel } from '@/components/SatelliteFiresPanel';
import { PositiveNewsFeedPanel } from '@/components/PositiveNewsFeedPanel';
@@ -451,6 +452,9 @@ export class PanelLayoutManager implements AppModule {
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance') {
const tradePolicyPanel = new TradePolicyPanel();
this.ctx.panels['trade-policy'] = tradePolicyPanel;
const supplyChainPanel = new SupplyChainPanel();
this.ctx.panels['supply-chain'] = supplyChainPanel;
}
const africaPanel = new NewsPanel('africa', t('panels.africa'));

View File

@@ -39,7 +39,7 @@ const WORLD_FEEDS = new Set([
const WORLD_APIS = new Set([
'RSS2JSON', 'Finnhub', 'CoinGecko', 'Polymarket', 'USGS', 'FRED',
'AISStream', 'GDELT Doc', 'EIA', 'USASpending', 'PizzINT', 'FIRMS',
'Cyber Threats API', 'BIS', 'WTO'
'Cyber Threats API', 'BIS', 'WTO', 'SupplyChain'
]);
import { t } from '../services/i18n';

View File

@@ -0,0 +1,197 @@
import { Panel } from './Panel';
import type {
GetShippingRatesResponse,
GetChokepointStatusResponse,
GetCriticalMineralsResponse,
} from '@/services/supply-chain';
import { t } from '@/services/i18n';
import { escapeHtml } from '@/utils/sanitize';
import { isFeatureAvailable } from '@/services/runtime-config';
import { isDesktopRuntime } from '@/services/runtime';
type TabId = 'chokepoints' | 'shipping' | 'minerals';
export class SupplyChainPanel extends Panel {
private shippingData: GetShippingRatesResponse | null = null;
private chokepointData: GetChokepointStatusResponse | null = null;
private mineralsData: GetCriticalMineralsResponse | null = null;
private activeTab: TabId = 'chokepoints';
constructor() {
super({ id: 'supply-chain', title: t('panels.supplyChain') });
this.content.addEventListener('click', (e) => {
const target = (e.target as HTMLElement).closest('.economic-tab') as HTMLElement | null;
if (!target) return;
const tabId = target.dataset.tab as TabId;
if (tabId && tabId !== this.activeTab) {
this.activeTab = tabId;
this.render();
}
});
}
public updateShippingRates(data: GetShippingRatesResponse): void {
this.shippingData = data;
this.render();
}
public updateChokepointStatus(data: GetChokepointStatusResponse): void {
this.chokepointData = data;
this.render();
}
public updateCriticalMinerals(data: GetCriticalMineralsResponse): void {
this.mineralsData = data;
this.render();
}
private render(): void {
const tabsHtml = `
<div class="economic-tabs">
<button class="economic-tab ${this.activeTab === 'chokepoints' ? 'active' : ''}" data-tab="chokepoints">
${t('components.supplyChain.chokepoints')}
</button>
<button class="economic-tab ${this.activeTab === 'shipping' ? 'active' : ''}" data-tab="shipping">
${t('components.supplyChain.shipping')}
</button>
<button class="economic-tab ${this.activeTab === 'minerals' ? 'active' : ''}" data-tab="minerals">
${t('components.supplyChain.minerals')}
</button>
</div>
`;
const anyUnavailable = [this.shippingData, this.chokepointData, this.mineralsData]
.some(d => d?.upstreamUnavailable);
const unavailableBanner = anyUnavailable
? `<div class="economic-warning">${t('components.supplyChain.upstreamUnavailable')}</div>`
: '';
let contentHtml = '';
switch (this.activeTab) {
case 'chokepoints': contentHtml = this.renderChokepoints(); break;
case 'shipping': contentHtml = this.renderShipping(); break;
case 'minerals': contentHtml = this.renderMinerals(); break;
}
this.setContent(`
${tabsHtml}
${unavailableBanner}
<div class="economic-content">${contentHtml}</div>
<div class="economic-footer">
<span class="economic-source">${t('components.supplyChain.sources')}</span>
</div>
`);
}
private renderChokepoints(): string {
if (!this.chokepointData || this.chokepointData.chokepoints.length === 0) {
return `<div class="economic-empty">${t('components.supplyChain.noChokepoints')}</div>`;
}
return `<div class="trade-restrictions-list">
${this.chokepointData.chokepoints.map(cp => {
const statusClass = cp.status === 'red' ? 'status-active' : cp.status === 'yellow' ? 'status-notified' : 'status-terminated';
const statusDot = cp.status === 'red' ? 'sc-dot-red' : cp.status === 'yellow' ? 'sc-dot-yellow' : 'sc-dot-green';
return `<div class="trade-restriction-card">
<div class="trade-restriction-header">
<span class="trade-country">${escapeHtml(cp.name)}</span>
<span class="sc-status-dot ${statusDot}"></span>
<span class="trade-badge">${cp.disruptionScore}/100</span>
<span class="trade-status ${statusClass}">${escapeHtml(cp.status)}</span>
</div>
<div class="trade-restriction-body">
<div class="trade-sector">${cp.activeWarnings} ${t('components.supplyChain.warnings')}</div>
<div class="trade-description">${escapeHtml(cp.description)}</div>
<div class="trade-affected">${escapeHtml(cp.affectedRoutes.join(', '))}</div>
</div>
</div>`;
}).join('')}
</div>`;
}
private renderShipping(): string {
if (isDesktopRuntime() && !isFeatureAvailable('supplyChain')) {
return `<div class="economic-empty">${t('components.supplyChain.fredKeyMissing')}</div>`;
}
if (!this.shippingData || this.shippingData.indices.length === 0) {
return `<div class="economic-empty">${t('components.supplyChain.noShipping')}</div>`;
}
return `<div class="trade-restrictions-list">
${this.shippingData.indices.map(idx => {
const changeClass = idx.changePct >= 0 ? 'change-positive' : 'change-negative';
const changeArrow = idx.changePct >= 0 ? '\u25B2' : '\u25BC';
const sparkline = this.renderSparkline(idx.history.map(h => h.value));
const spikeBanner = idx.spikeAlert
? `<div class="economic-warning">${t('components.supplyChain.spikeAlert')}</div>`
: '';
return `<div class="trade-restriction-card">
${spikeBanner}
<div class="trade-restriction-header">
<span class="trade-country">${escapeHtml(idx.name)}</span>
<span class="trade-badge">${idx.currentValue.toFixed(0)} ${escapeHtml(idx.unit)}</span>
<span class="trade-flow-change ${changeClass}">${changeArrow} ${Math.abs(idx.changePct).toFixed(1)}%</span>
</div>
<div class="trade-restriction-body">
${sparkline}
</div>
</div>`;
}).join('')}
</div>`;
}
private renderSparkline(values: number[]): string {
if (values.length < 2) return '';
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 200;
const h = 40;
const points = values.map((v, i) => {
const x = (i / (values.length - 1)) * w;
const y = h - ((v - min) / range) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="display:block;margin:4px 0">
<polyline points="${points}" fill="none" stroke="var(--accent-primary, #4fc3f7)" stroke-width="1.5" />
</svg>`;
}
private renderMinerals(): string {
if (!this.mineralsData || this.mineralsData.minerals.length === 0) {
return `<div class="economic-empty">${t('components.supplyChain.noMinerals')}</div>`;
}
const rows = this.mineralsData.minerals.map(m => {
const riskClass = m.riskRating === 'critical' ? 'sc-risk-critical'
: m.riskRating === 'high' ? 'sc-risk-high'
: m.riskRating === 'moderate' ? 'sc-risk-moderate'
: 'sc-risk-low';
const top3 = m.topProducers.slice(0, 3).map(p =>
`${escapeHtml(p.country)} ${p.sharePct.toFixed(0)}%`
).join(', ');
return `<tr>
<td>${escapeHtml(m.mineral)}</td>
<td>${top3}</td>
<td>${m.hhi.toFixed(0)}</td>
<td><span class="${riskClass}">${escapeHtml(m.riskRating)}</span></td>
</tr>`;
}).join('');
return `<div class="trade-tariffs-table">
<table>
<thead>
<tr>
<th>${t('components.supplyChain.mineral')}</th>
<th>${t('components.supplyChain.topProducers')}</th>
<th>HHI</th>
<th>${t('components.supplyChain.risk')}</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
}

View File

@@ -40,3 +40,4 @@ export * from './PopulationExposurePanel';
export * from './InvestmentsPanel';
export * from './UnifiedSettings';
export * from './TradePolicyPanel';
export * from './SupplyChainPanel';

View File

@@ -83,6 +83,7 @@ export const COMMANDS: Command[] = [
{ id: 'panel:markets', keywords: ['markets', 'stocks', 'indices'], label: 'Jump to Markets', icon: '\u{1F4C8}', category: 'panels' },
{ id: 'panel:economic', keywords: ['economic', 'economy', 'fred'], label: 'Jump to Economic Indicators', icon: '\u{1F4CA}', category: 'panels' },
{ id: 'panel:trade-policy', keywords: ['trade', 'tariffs', 'wto', 'trade policy', 'sanctions', 'restrictions'], label: 'Jump to Trade Policy', icon: '\u{1F4CA}', category: 'panels' },
{ id: 'panel:supply-chain', keywords: ['supply chain', 'shipping', 'chokepoint', 'minerals', 'freight', 'logistics'], label: 'Jump to Supply Chain', icon: '\u{1F6A2}', category: 'panels' },
{ id: 'panel:finance', keywords: ['financial', 'finance news'], label: 'Jump to Financial', icon: '\u{1F4B5}', category: 'panels' },
{ id: 'panel:tech', keywords: ['technology', 'tech news'], label: 'Jump to Technology', icon: '\u{1F4BB}', category: 'panels' },
{ id: 'panel:crypto', keywords: ['crypto', 'bitcoin', 'ethereum'], label: 'Jump to Crypto', icon: '\u20BF', category: 'panels' },

View File

@@ -33,6 +33,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
markets: { name: 'Markets', enabled: true, priority: 1 },
economic: { name: 'Economic Indicators', enabled: true, priority: 1 },
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 },
finance: { name: 'Financial', enabled: true, priority: 1 },
tech: { name: 'Technology', enabled: true, priority: 2 },
crypto: { name: 'Crypto', enabled: true, priority: 2 },
@@ -302,6 +303,7 @@ const FINANCE_PANELS: Record<string, PanelConfig> = {
centralbanks: { name: 'Central Bank Watch', enabled: true, priority: 1 },
economic: { name: 'Economic Data', enabled: true, priority: 1 },
'trade-policy': { name: 'Trade Policy', enabled: true, priority: 1 },
'supply-chain': { name: 'Supply Chain', enabled: true, priority: 1 },
'economic-news': { name: 'Economic News', enabled: true, priority: 2 },
ipo: { name: 'IPOs, Earnings & M&A', enabled: true, priority: 1 },
heatmap: { name: 'Sector Heatmap', enabled: true, priority: 1 },
@@ -575,7 +577,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
},
marketsFinance: {
labelKey: 'header.panelCatMarketsFinance',
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'finance', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
panelKeys: ['commodities', 'markets', 'economic', 'trade-policy', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
variants: ['full'],
},
topical: {
@@ -634,7 +636,7 @@ export const PANEL_CATEGORY_MAP: Record<string, { labelKey: string; panelKeys: s
},
centralBanksEcon: {
labelKey: 'header.panelCatCentralBanks',
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'economic-news'],
panelKeys: ['centralbanks', 'economic', 'trade-policy', 'supply-chain', 'economic-news'],
variants: ['finance'],
},
dealsInstitutional: {

View File

@@ -0,0 +1,207 @@
// Code generated by protoc-gen-ts-client. DO NOT EDIT.
// source: worldmonitor/supply_chain/v1/service.proto
export interface ShippingRatePoint {
date: string;
value: number;
}
export interface ShippingIndex {
indexId: string;
name: string;
currentValue: number;
previousValue: number;
changePct: number;
unit: string;
history: ShippingRatePoint[];
spikeAlert: boolean;
}
export interface ChokepointInfo {
id: string;
name: string;
lat: number;
lon: number;
disruptionScore: number;
status: string;
activeWarnings: number;
congestionLevel: string;
affectedRoutes: string[];
description: string;
}
export interface MineralProducer {
country: string;
countryCode: string;
productionTonnes: number;
sharePct: number;
}
export interface CriticalMineral {
mineral: string;
topProducers: MineralProducer[];
hhi: number;
riskRating: string;
globalProduction: number;
unit: string;
}
export interface GetShippingRatesRequest {}
export interface GetShippingRatesResponse {
indices: ShippingIndex[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface GetChokepointStatusRequest {}
export interface GetChokepointStatusResponse {
chokepoints: ChokepointInfo[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface GetCriticalMineralsRequest {}
export interface GetCriticalMineralsResponse {
minerals: CriticalMineral[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface SupplyChainServiceClientOptions {
fetch?: typeof fetch;
defaultHeaders?: Record<string, string>;
}
export interface SupplyChainServiceCallOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
}
export class SupplyChainServiceClient {
private baseURL: string;
private fetchFn: typeof fetch;
private defaultHeaders: Record<string, string>;
constructor(baseURL: string, options?: SupplyChainServiceClientOptions) {
this.baseURL = baseURL.replace(/\/+$/, "");
this.fetchFn = options?.fetch ?? globalThis.fetch;
this.defaultHeaders = { ...options?.defaultHeaders };
}
async getShippingRates(req: GetShippingRatesRequest, options?: SupplyChainServiceCallOptions): Promise<GetShippingRatesResponse> {
let path = "/api/supply-chain/v1/get-shipping-rates";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetShippingRatesResponse;
}
async getChokepointStatus(req: GetChokepointStatusRequest, options?: SupplyChainServiceCallOptions): Promise<GetChokepointStatusResponse> {
let path = "/api/supply-chain/v1/get-chokepoint-status";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetChokepointStatusResponse;
}
async getCriticalMinerals(req: GetCriticalMineralsRequest, options?: SupplyChainServiceCallOptions): Promise<GetCriticalMineralsResponse> {
let path = "/api/supply-chain/v1/get-critical-minerals";
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
...this.defaultHeaders,
...options?.headers,
};
const resp = await this.fetchFn(url, {
method: "POST",
headers,
body: JSON.stringify(req),
signal: options?.signal,
});
if (!resp.ok) {
return this.handleError(resp);
}
return await resp.json() as GetCriticalMineralsResponse;
}
private async handleError(resp: Response): Promise<never> {
const body = await resp.text();
if (resp.status === 400) {
try {
const parsed = JSON.parse(body);
if (parsed.violations) {
throw new ValidationError(parsed.violations);
}
} catch (e) {
if (e instanceof ValidationError) throw e;
}
}
throw new ApiError(resp.status, `Request failed with status ${resp.status}`, body);
}
}

View File

@@ -0,0 +1,258 @@
// Code generated by protoc-gen-ts-server. DO NOT EDIT.
// source: worldmonitor/supply_chain/v1/service.proto
export interface ShippingRatePoint {
date: string;
value: number;
}
export interface ShippingIndex {
indexId: string;
name: string;
currentValue: number;
previousValue: number;
changePct: number;
unit: string;
history: ShippingRatePoint[];
spikeAlert: boolean;
}
export interface ChokepointInfo {
id: string;
name: string;
lat: number;
lon: number;
disruptionScore: number;
status: string;
activeWarnings: number;
congestionLevel: string;
affectedRoutes: string[];
description: string;
}
export interface MineralProducer {
country: string;
countryCode: string;
productionTonnes: number;
sharePct: number;
}
export interface CriticalMineral {
mineral: string;
topProducers: MineralProducer[];
hhi: number;
riskRating: string;
globalProduction: number;
unit: string;
}
export interface GetShippingRatesRequest {}
export interface GetShippingRatesResponse {
indices: ShippingIndex[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface GetChokepointStatusRequest {}
export interface GetChokepointStatusResponse {
chokepoints: ChokepointInfo[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface GetCriticalMineralsRequest {}
export interface GetCriticalMineralsResponse {
minerals: CriticalMineral[];
fetchedAt: string;
upstreamUnavailable: boolean;
}
export interface FieldViolation {
field: string;
description: string;
}
export class ValidationError extends Error {
violations: FieldViolation[];
constructor(violations: FieldViolation[]) {
super("Validation failed");
this.name = "ValidationError";
this.violations = violations;
}
}
export class ApiError extends Error {
statusCode: number;
body: string;
constructor(statusCode: number, message: string, body: string) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
this.body = body;
}
}
export interface ServerContext {
request: Request;
pathParams: Record<string, string>;
headers: Record<string, string>;
}
export interface ServerOptions {
onError?: (error: unknown, req: Request) => Response | Promise<Response>;
validateRequest?: (methodName: string, body: unknown) => FieldViolation[] | undefined;
}
export interface RouteDescriptor {
method: string;
path: string;
handler: (req: Request) => Promise<Response>;
}
export interface SupplyChainServiceHandler {
getShippingRates(ctx: ServerContext, req: GetShippingRatesRequest): Promise<GetShippingRatesResponse>;
getChokepointStatus(ctx: ServerContext, req: GetChokepointStatusRequest): Promise<GetChokepointStatusResponse>;
getCriticalMinerals(ctx: ServerContext, req: GetCriticalMineralsRequest): Promise<GetCriticalMineralsResponse>;
}
export function createSupplyChainServiceRoutes(
handler: SupplyChainServiceHandler,
options?: ServerOptions,
): RouteDescriptor[] {
return [
{
method: "POST",
path: "/api/supply-chain/v1/get-shipping-rates",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as GetShippingRatesRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getShippingRates", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getShippingRates(ctx, body);
return new Response(JSON.stringify(result as GetShippingRatesResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
{
method: "POST",
path: "/api/supply-chain/v1/get-chokepoint-status",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as GetChokepointStatusRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getChokepointStatus", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getChokepointStatus(ctx, body);
return new Response(JSON.stringify(result as GetChokepointStatusResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
{
method: "POST",
path: "/api/supply-chain/v1/get-critical-minerals",
handler: async (req: Request): Promise<Response> => {
try {
const pathParams: Record<string, string> = {};
const body = await req.json() as GetCriticalMineralsRequest;
if (options?.validateRequest) {
const bodyViolations = options.validateRequest("getCriticalMinerals", body);
if (bodyViolations) {
throw new ValidationError(bodyViolations);
}
}
const ctx: ServerContext = {
request: req,
pathParams,
headers: Object.fromEntries(req.headers.entries()),
};
const result = await handler.getCriticalMinerals(ctx, body);
return new Response(JSON.stringify(result as GetCriticalMineralsResponse), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err: unknown) {
if (err instanceof ValidationError) {
return new Response(JSON.stringify({ violations: err.violations }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (options?.onError) {
return options.onError(err, req);
}
const message = err instanceof Error ? err.message : String(err);
return new Response(JSON.stringify({ message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
},
},
];
}

View File

@@ -168,6 +168,7 @@
"commodities": "Commodities",
"economic": "Economic Indicators",
"tradePolicy": "Trade Policy",
"supplyChain": "Supply Chain",
"finance": "Financial",
"tech": "Technology",
"crypto": "Crypto",
@@ -566,6 +567,22 @@
"hike": "hike",
"hold": "hold"
},
"supplyChain": {
"chokepoints": "Chokepoints",
"shipping": "Shipping",
"minerals": "Minerals",
"noChokepoints": "Chokepoint data loading...",
"noShipping": "Shipping rate data not available",
"noMinerals": "Mineral data loading...",
"fredKeyMissing": "FRED API key required for shipping rates — add it in Settings. Chokepoints and minerals available without key.",
"upstreamUnavailable": "Supply chain data temporarily unavailable — showing cached data",
"spikeAlert": "Spike detected — rate significantly above 52-week average (weekly)",
"warnings": "warning(s)",
"mineral": "Mineral",
"topProducers": "Top Producers",
"risk": "Risk",
"sources": "FRED / NGA / USGS"
},
"tradePolicy": {
"restrictions": "Restrictions",
"tariffs": "Tariffs",

View File

@@ -34,7 +34,8 @@ export type DataSourceId =
| 'worldpop' // WorldPop population exposure
| 'giving' // Global giving activity data
| 'bis' // BIS central bank data
| 'wto_trade'; // WTO trade policy data
| 'wto_trade' // WTO trade policy data
| 'supply_chain'; // Supply chain disruption intelligence
export type FreshnessStatus = 'fresh' | 'stale' | 'very_stale' | 'no_data' | 'disabled' | 'error';
@@ -99,6 +100,7 @@ const SOURCE_METADATA: Record<DataSourceId, { name: string; requiredForRisk: boo
giving: { name: 'Global Giving Activity', requiredForRisk: false, panelId: 'giving' },
bis: { name: 'BIS Central Banks', requiredForRisk: false, panelId: 'economic' },
wto_trade: { name: 'WTO Trade Policy', requiredForRisk: false, panelId: 'trade-policy' },
supply_chain: { name: 'Supply Chain Intelligence', requiredForRisk: false, panelId: 'supply-chain' },
};
class DataFreshnessTracker {
@@ -356,6 +358,7 @@ const INTELLIGENCE_GAP_MESSAGES: Record<DataSourceId, string> = {
giving: 'Global giving activity data unavailable',
bis: 'Central bank policy data may be stale—BIS feed unavailable',
wto_trade: 'Trade policy intelligence unavailable—WTO data not updating',
supply_chain: 'Supply chain disruption status unavailable—chokepoint monitoring offline',
};
/**

View File

@@ -38,3 +38,4 @@ export * from './usa-spending';
export { generateSummary, translateText } from './summarization';
export * from './cached-theater-posture';
export * from './trade';
export * from './supply-chain';

View File

@@ -41,7 +41,8 @@ export type RuntimeFeatureId =
| 'finnhubMarkets'
| 'nasaFirms'
| 'aiOllama'
| 'wtoTrade';
| 'wtoTrade'
| 'supplyChain';
export interface RuntimeFeatureDefinition {
id: RuntimeFeatureId;
@@ -83,6 +84,7 @@ const defaultToggles: Record<RuntimeFeatureId, boolean> = {
nasaFirms: true,
aiOllama: true,
wtoTrade: true,
supplyChain: true,
};
export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
@@ -200,6 +202,13 @@ export const RUNTIME_FEATURES: RuntimeFeatureDefinition[] = [
requiredSecrets: ['WTO_API_KEY'],
fallback: 'Trade policy panel shows disabled state.',
},
{
id: 'supplyChain',
name: 'Supply Chain Intelligence',
description: 'Shipping rates via FRED Baltic Dry Index. Chokepoints and minerals use public data.',
requiredSecrets: ['FRED_API_KEY'],
fallback: 'Chokepoints and minerals always available; shipping requires FRED key.',
},
];
function readEnvSecret(key: RuntimeSecretKey): string {

View File

@@ -0,0 +1,65 @@
import {
SupplyChainServiceClient,
type GetShippingRatesResponse,
type GetChokepointStatusResponse,
type GetCriticalMineralsResponse,
type ShippingIndex,
type ChokepointInfo,
type CriticalMineral,
type MineralProducer,
type ShippingRatePoint,
} from '@/generated/client/worldmonitor/supply_chain/v1/service_client';
import { createCircuitBreaker } from '@/utils';
import { isFeatureAvailable } from '../runtime-config';
export type {
GetShippingRatesResponse,
GetChokepointStatusResponse,
GetCriticalMineralsResponse,
ShippingIndex,
ChokepointInfo,
CriticalMineral,
MineralProducer,
ShippingRatePoint,
};
const client = new SupplyChainServiceClient('', { fetch: (...args) => globalThis.fetch(...args) });
const shippingBreaker = createCircuitBreaker<GetShippingRatesResponse>({ name: 'Shipping Rates', cacheTtlMs: 15 * 60 * 1000, persistCache: true });
const chokepointBreaker = createCircuitBreaker<GetChokepointStatusResponse>({ name: 'Chokepoint Status', cacheTtlMs: 5 * 60 * 1000, persistCache: true });
const mineralsBreaker = createCircuitBreaker<GetCriticalMineralsResponse>({ name: 'Critical Minerals', cacheTtlMs: 60 * 60 * 1000, persistCache: true });
const emptyShipping: GetShippingRatesResponse = { indices: [], fetchedAt: '', upstreamUnavailable: false };
const emptyChokepoints: GetChokepointStatusResponse = { chokepoints: [], fetchedAt: '', upstreamUnavailable: false };
const emptyMinerals: GetCriticalMineralsResponse = { minerals: [], fetchedAt: '', upstreamUnavailable: false };
export async function fetchShippingRates(): Promise<GetShippingRatesResponse> {
if (!isFeatureAvailable('supplyChain')) return emptyShipping;
try {
return await shippingBreaker.execute(async () => {
return client.getShippingRates({});
}, emptyShipping);
} catch {
return emptyShipping;
}
}
export async function fetchChokepointStatus(): Promise<GetChokepointStatusResponse> {
try {
return await chokepointBreaker.execute(async () => {
return client.getChokepointStatus({});
}, emptyChokepoints);
} catch {
return emptyChokepoints;
}
}
export async function fetchCriticalMinerals(): Promise<GetCriticalMineralsResponse> {
try {
return await mineralsBreaker.execute(async () => {
return client.getCriticalMinerals({});
}, emptyMinerals);
} catch {
return emptyMinerals;
}
}

View File

@@ -7248,6 +7248,23 @@ a.prediction-link:hover {
color: var(--green);
}
.sc-status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin: 0 4px;
vertical-align: middle;
}
.sc-dot-red { background: var(--red, #ff5252); }
.sc-dot-yellow { background: var(--yellow, #ffd740); }
.sc-dot-green { background: var(--green, #69f0ae); }
.sc-risk-critical { color: var(--red, #ff5252); font-weight: 600; }
.sc-risk-high { color: var(--orange, #ffab40); font-weight: 600; }
.sc-risk-moderate { color: var(--yellow, #ffd740); }
.sc-risk-low { color: var(--green, #69f0ae); }
.trade-sector {
font-size: 10px;
color: var(--text-dim);

View File

@@ -0,0 +1,102 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
computeDisruptionScore,
scoreToStatus,
computeHHI,
riskRating,
detectSpike,
SEVERITY_SCORE,
} from '../server/worldmonitor/supply-chain/v1/_scoring.mjs';
describe('Chokepoint scoring', () => {
it('computes disruption score with cap at 100', () => {
assert.equal(computeDisruptionScore(3, 2), Math.min(100, 3 * 15 + 2 * 30));
assert.equal(computeDisruptionScore(5, 3), 100);
assert.equal(computeDisruptionScore(0, 0), 0);
});
it('maps score to status correctly', () => {
assert.equal(scoreToStatus(0), 'green');
assert.equal(scoreToStatus(15), 'green');
assert.equal(scoreToStatus(19), 'green');
assert.equal(scoreToStatus(20), 'yellow');
assert.equal(scoreToStatus(45), 'yellow');
assert.equal(scoreToStatus(49), 'yellow');
assert.equal(scoreToStatus(50), 'red');
assert.equal(scoreToStatus(65), 'red');
assert.equal(scoreToStatus(100), 'red');
});
it('has correct severity enum keys', () => {
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_LOW'], 1);
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_ELEVATED'], 2);
assert.equal(SEVERITY_SCORE['AIS_DISRUPTION_SEVERITY_HIGH'], 3);
});
});
describe('HHI computation', () => {
it('returns 10000 for pure monopoly', () => {
assert.equal(computeHHI([100]), 10000);
});
it('returns 2500 for four equal producers', () => {
assert.equal(computeHHI([25, 25, 25, 25]), 2500);
});
it('returns 0 for empty array', () => {
assert.equal(computeHHI([]), 0);
});
it('handles two equal producers', () => {
assert.equal(computeHHI([50, 50]), 5000);
});
});
describe('Risk rating', () => {
it('maps HHI to correct risk levels', () => {
assert.equal(riskRating(1499), 'low');
assert.equal(riskRating(1500), 'moderate');
assert.equal(riskRating(2499), 'moderate');
assert.equal(riskRating(2500), 'high');
assert.equal(riskRating(4999), 'high');
assert.equal(riskRating(5000), 'critical');
assert.equal(riskRating(5001), 'critical');
assert.equal(riskRating(10000), 'critical');
});
});
describe('Spike detection', () => {
it('detects spike when value > mean + 2*stdDev', () => {
assert.equal(detectSpike([100, 102, 98, 101, 99, 100, 103, 97, 100, 500]), true);
});
it('returns false for stable series', () => {
assert.equal(detectSpike([100, 101, 99, 100]), false);
});
it('returns false for empty array', () => {
assert.equal(detectSpike([]), false);
});
it('returns false for too few values', () => {
assert.equal(detectSpike([100, 200]), false);
});
it('handles ShippingRatePoint objects', () => {
const points = [
{ date: '2024-01-01', value: 100 },
{ date: '2024-01-08', value: 102 },
{ date: '2024-01-15', value: 98 },
{ date: '2024-01-22', value: 101 },
{ date: '2024-01-29', value: 99 },
{ date: '2024-02-05', value: 100 },
{ date: '2024-02-12', value: 103 },
{ date: '2024-02-19', value: 97 },
{ date: '2024-02-26', value: 100 },
{ date: '2024-03-04', value: 500 },
];
assert.equal(detectSpike(points), true);
});
});