feat(supply-chain): scenario UX — rich banner + projected score + faster poll (#3193)

* feat(supply-chain): rich scenario banner + projected score per chokepoint + faster poll

User reported Simulate Closure adds only a thin banner with no context —
"not clear what value user is getting, takes many many seconds". Four
targeted UX improvements in one PR:

A. Rich banner (scenario params + tagline)
   Banner now reads:
     ⚠ Hormuz Tanker Blockade · 14d · +110% cost
        CN 100% · IN 84% · TW 82% · IR 80% · US 39%
        Simulating 14d / 100% closure / +110% cost on 1 chokepoint.
        Chokepoint card below shows projected score; map highlights…
   Surfaces the scenario template fields (durationDays, disruptionPct,
   costShockMultiplier) + a one-line explainer so a first-time user
   understands what "CN 100%" actually means.

B. Projected score on each affected chokepoint card
   Card header now shows: `[current]/100 → [projected]/100` with a red
   trailing badge + red left border on the card body.
   Body prepends: "⚠ Projected under scenario: X% closure for N days
   (+Y% cost)".
   Projected = max(current, template.disruptionPct) — conservative
   floor since the real scoring mixes threat + warnings + anomaly.

C. Faster polling
   Status poll interval 2s → 1s. Max iterations 30→60 (unchanged 60s
   budget). Worker processes in <1s; perceived latency drops from
   2–3s to <2s in the common case. First poll still immediate.

D. ScenarioResult interface widened
   Added optional `template` and `currentDisruptionScores` fields in
   scenario-templates.ts to match what the scenario-worker already
   emits. Optional = backward-compat with map-only consumers.

Dependent on PR #3192 (already merged) which fixed the 10000% banner
% inflation.

* fix(supply-chain): trigger render() on scenario activate/dismiss — cards must re-render

PR review caught a real bug in the new scenario UX: showScenarioSummary
and hideScenarioSummary were mutating the banner DOM directly without
triggering render(). renderChokepoints() reads activeScenarioState to
paint the projected score + red border + callout, but those only run
during render() — so the cards stayed stale on activate AND on dismiss
until some unrelated re-render happened.

Refactor to split public API from internal rendering:

- showScenarioSummary(scenarioId, result) — now just sets state + calls
  render(). Was: set state + inline DOM mutation (bypassing card render).
- renderScenarioBanner() — new private helper that builds the banner
  DOM from activeScenarioState. Called from render()'s postlude
  (replacing the old self-recursive showScenarioSummary() call — which
  only worked because it had a side-effectful early-exit path that
  happened to terminate, but was a latent recursion risk).
- hideScenarioSummary() — now just sets state=null + calls render().
  Was: clear state + manual banner removal + manual button-text reset
  loop. The button loop is redundant now — the freshly-rendered card
  template produces buttons with default "Simulate Closure" text by
  construction.

Net effect: activating a scenario paints the banner AND the affected
chokepoint cards in a single render tick. Dismissing strips both in
the same tick.

* fix(supply-chain): derive scenario button state from activeScenarioState, not imperative mutation

PR review caught: the earlier re-render fix (showScenarioSummary → render())
correctly repaints cards on activate, but the button-state logic in
runScenario() is now wrong. render() detaches the old btn reference, so
the post-onScenarioActivate `resetButton('Active') + btn.disabled = true`
touches a detached node and no-ops (resetButton() explicitly skips
!btn.isConnected). The fresh button painted by render() uses the default
template text — visible button reads "Simulate Closure" enabled, and users
can queue duplicate runs of an already-active scenario.

Fix: make button state a function of panel state.

- renderChokepoints() scenario section: check
  activeScenarioState.scenarioId === template.id and, when matched, emit
  the button with class `sc-scenario-btn--active`, text "Active", and
  `disabled` attribute. On dismiss, the next render strips those
  automatically — same pattern as the card projection styling.
- runScenario(): drop the dead `resetButton('Active')` + `btn.disabled`
  lines after onScenarioActivate. That path is now template-driven;
  touching the detached btn was the defect.

Catch-path resets ('Simulate Closure' on abort, 'Error — retry' on real
error) are unchanged — those fire BEFORE any render could detach the btn,
so the imperative path is still correct there.

* fix(supply-chain): hide scenario projection arrow when current already ≥ template

Greptile P1: projected badge was rendered as `N/100 → N/100` whenever
current disruptionScore already met or exceeded template.disruptionPct.
Visible for Suez (80%) or Panama (50%) scenarios when a chokepoint is
already elevated — read as "scenario has zero effect", which is misleading.

The two values live on different scales — cp.disruptionScore is a
computed risk score (threat + warnings + anomaly) while
template.disruptionPct is "% of capacity blocked" — but they share the
0–100 axis so directional comparison is still meaningful for the
"does this scenario escalate things?" signal.

Fix: arrow only renders when template.disruptionPct > cp.disruptionScore.
When current already equals or exceeds the scenario level, show the
single current badge. The card's red left border + "⚠ Projected under
scenario" callout still indicate the card is the scenario target —
only the escalation arrow is suppressed.
This commit is contained in:
Elie Habib
2026-04-19 09:25:55 +04:00
committed by GitHub
parent 85d6308ed0
commit 63464775a5
2 changed files with 143 additions and 18 deletions

View File

@@ -132,10 +132,22 @@ export interface ScenarioVisualState {
}
/**
* Subset of the scenario worker result consumed by the map layer.
* Subset of the scenario worker result consumed by the map layer and panel UI.
* Full result shape lives in the scenario worker (scenario-worker.mjs).
*
* Fields beyond the map-level minimum (template, currentDisruptionScores) are
* optional to keep backward-compat with any consumer that only cares about
* chokepoint IDs + country impacts.
*/
export interface ScenarioResult {
affectedChokepointIds: string[];
topImpactCountries: Array<{ iso2: string; totalImpact: number; impactPct: number }>;
template?: {
name: string;
disruptionPct: number;
durationDays: number;
costShockMultiplier: number;
};
/** Map of chokepointId → its pre-scenario disruptionScore (0100). */
currentDisruptionScores?: Record<string, number | null>;
}