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

@@ -323,6 +323,47 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Error'
/api/supply-chain/v1/get-route-impact:
get:
tags:
- SupplyChainService
summary: GetRouteImpact
operationId: GetRouteImpact
parameters:
- name: fromIso2
in: query
required: false
schema:
type: string
- name: toIso2
in: query
required: false
schema:
type: string
- name: hs2
in: query
required: false
schema:
type: string
responses:
"200":
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/GetRouteImpactResponse'
"400":
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
default:
description: Error response
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
components:
schemas:
Error:
@@ -1065,3 +1106,70 @@ components:
type: integer
format: int32
description: Inclusive integer range for transit days / freight USD estimates.
GetRouteImpactRequest:
type: object
properties:
fromIso2:
type: string
pattern: ^[A-Z]{2}$
toIso2:
type: string
pattern: ^[A-Z]{2}$
hs2:
type: string
required:
- fromIso2
- toIso2
- hs2
GetRouteImpactResponse:
type: object
properties:
laneValueUsd:
type: number
format: double
primaryExporterIso2:
type: string
primaryExporterShare:
type: number
format: double
topStrategicProducts:
type: array
items:
$ref: '#/components/schemas/StrategicProduct'
resilienceScore:
type: number
format: double
dependencyFlags:
type: array
items:
type: string
enum:
- DEPENDENCY_FLAG_UNSPECIFIED
- DEPENDENCY_FLAG_SINGLE_SOURCE_CRITICAL
- DEPENDENCY_FLAG_SINGLE_CORRIDOR_CRITICAL
- DEPENDENCY_FLAG_COMPOUND_RISK
- DEPENDENCY_FLAG_DIVERSIFIABLE
description: DependencyFlag classifies how a country+sector dependency can fail.
hs2InSeededUniverse:
type: boolean
comtradeSource:
type: string
fetchedAt:
type: string
StrategicProduct:
type: object
properties:
hs4:
type: string
label:
type: string
totalValueUsd:
type: number
format: double
topExporterIso2:
type: string
topExporterShare:
type: number
format: double
primaryChokepointId:
type: string