mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
46
proto/worldmonitor/supply_chain/v1/get_route_impact.proto
Normal file
46
proto/worldmonitor/supply_chain/v1/get_route_impact.proto
Normal 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;
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user