feat(route-explorer): Sprint 4 — strategic-product impact tab + get-route-impact RPC (#2996)

* feat(route-explorer): Sprint 4 — strategic-product impact tab

Adds the Impact tab to the Route Explorer, powered by a new
get-route-impact RPC that returns strategic-product trade data for
any country pair.

Backend:
- New proto get_route_impact.proto with GetRouteImpact{Request,Response}
  + StrategicProduct message
- New handler server/worldmonitor/supply-chain/v1/get-route-impact.ts:
  reads comtrade:bilateral-hs4:{iso2}:v1 store, computes lane value for
  selected HS2, top 5 strategic products by value with chokepoint
  exposure, resilience score (server-side from Redis), dependency flags
- Cache key ROUTE_IMPACT_KEY in cache-keys.ts (NOT in BOOTSTRAP_KEYS)
- Gateway + premium-paths registered as slow-browser premium RPC
- Client wrapper fetchRouteImpact in supply-chain/index.ts

Impact tab UI:
- CountryImpactTab.ts: strategic products table (top 5 by value),
  lane value card for selected HS2, hs2InSeededUniverse banner when
  HS2 is not in the 14 seeded sectors, comtradeSource states
  (missing/empty/bilateral-hs4), drill-sideways on product row click
- LeftRail.updateDependencyFlags: renders flags from Impact response
  with color-coded badges (compound_risk/single_source/diversifiable)

Data flow:
- fetchImpact fires in parallel with fetchResilience after lane data
  loads, generation-scoped
- Impact response updates left-rail flags + resilience score
- Drill-sideways: clicking a product row switches the explorer's HS2
  and re-queries all tabs

Server-side resilience:
- get-route-impact reads resilience:score:v8:{iso2} from Redis directly
  so the data is available for future email briefs without client calls

Plan: docs/plans/2026-04-11-001-feat-worldwide-route-explorer-plan.md

* fix(route-explorer): real exposure score for flags + tabstrip sync on drill

P1: computeDependencyFlags hardcoded primaryExposure=80 whenever any
chokepoint existed, fabricating SINGLE_CORRIDOR_CRITICAL without using
real exposure data. Replaced with computeRealExposureScore that uses the
same route-cluster overlap logic as get-sector-dependency, computing the
actual exposure percentage before comparing against the >80 threshold.

P2: handleDrillSideways set state.tab=1 directly without going through
setTab(), leaving the tabstrip visually and semantically on Impact while
content showed Current. Now calls setTab(1) which updates both the
tabstrip active state and aria-selected.

* fix(route-explorer): guard resilience overwrite + normalize HS2 filter

P1: fetchImpact could zero the left-rail resilience score when
get-route-impact returned resilienceScore=0 (Redis miss fallback),
overwriting a valid score set by the concurrent fetchResilience call.
Now only applies the server-side score when it is actually > 0.

P2: HS4-to-HS2 matching used a redundant dual-condition filter
(hs4ToHs2 + startsWith) that masked a potential normalization bug.
Simplified to normalize hs2 once via parseInt then use a single
hs4ToHs2 comparison.
This commit is contained in:
Elie Habib
2026-04-12 10:25:13 +04:00
committed by GitHub
parent 5939b78f4d
commit c72251178c
16 changed files with 878 additions and 8 deletions

View File

@@ -0,0 +1,46 @@
syntax = "proto3";
package worldmonitor.supply_chain.v1;
import "buf/validate/validate.proto";
import "sebuf/http/annotations.proto";
import "worldmonitor/supply_chain/v1/get_sector_dependency.proto";
message StrategicProduct {
string hs4 = 1;
string label = 2;
double total_value_usd = 3;
string top_exporter_iso2 = 4;
double top_exporter_share = 5;
string primary_chokepoint_id = 6;
}
message GetRouteImpactRequest {
string from_iso2 = 1 [
(buf.validate.field).required = true,
(buf.validate.field).string.len = 2,
(buf.validate.field).string.pattern = "^[A-Z]{2}$",
(sebuf.http.query) = {name: "fromIso2"}
];
string to_iso2 = 2 [
(buf.validate.field).required = true,
(buf.validate.field).string.len = 2,
(buf.validate.field).string.pattern = "^[A-Z]{2}$",
(sebuf.http.query) = {name: "toIso2"}
];
string hs2 = 3 [
(buf.validate.field).required = true,
(sebuf.http.query) = {name: "hs2"}
];
}
message GetRouteImpactResponse {
double lane_value_usd = 1;
string primary_exporter_iso2 = 2;
double primary_exporter_share = 3;
repeated StrategicProduct top_strategic_products = 4;
double resilience_score = 5;
repeated DependencyFlag dependency_flags = 6;
bool hs2_in_seeded_universe = 7;
string comtrade_source = 8;
string fetched_at = 9;
}

View File

@@ -12,6 +12,7 @@ import "worldmonitor/supply_chain/v1/get_bypass_options.proto";
import "worldmonitor/supply_chain/v1/get_country_cost_shock.proto";
import "worldmonitor/supply_chain/v1/get_sector_dependency.proto";
import "worldmonitor/supply_chain/v1/get_route_explorer_lane.proto";
import "worldmonitor/supply_chain/v1/get_route_impact.proto";
service SupplyChainService {
option (sebuf.http.service_config) = {base_path: "/api/supply-chain/v1"};
@@ -61,4 +62,8 @@ service SupplyChainService {
rpc GetRouteExplorerLane(GetRouteExplorerLaneRequest) returns (GetRouteExplorerLaneResponse) {
option (sebuf.http.config) = {path: "/get-route-explorer-lane", method: HTTP_METHOD_GET};
}
rpc GetRouteImpact(GetRouteImpactRequest) returns (GetRouteImpactResponse) {
option (sebuf.http.config) = {path: "/get-route-impact", method: HTTP_METHOD_GET};
}
}