From 73cd8a9c927ff8ae5a1d5f7bca34fbd677d610bf Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Fri, 24 Apr 2026 19:09:05 +0400 Subject: [PATCH] =?UTF-8?q?feat(energy-atlas):=20EnergyDisruptionsPanel=20?= =?UTF-8?q?standalone=20timeline=20(=C2=A7L=20#4)=20(#3378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(energy-atlas): EnergyDisruptionsPanel standalone timeline (§L #4) Closes gap #4 from docs/internal/energy-atlas-registry-expansion.md §L. Before this PR, the 52 disruption events in `energy:disruptions:v1` were only reachable by drilling into a specific pipeline or storage facility — PipelineStatusPanel and StorageFacilityMapPanel each render an asset-scoped slice of the log inside their drawers, but no surface listed the global event log. This panel makes the full log first-class. Shape: - Reverse-chronological table (newest first) of every event. - Filter chips: event type (sabotage, sanction, maintenance, mechanical, weather, war, commercial, other) + "ongoing only" toggle. - Row click dispatches the existing `energy:open-pipeline-detail` or `energy:open-storage-facility-detail` CustomEvent with `{assetId, highlightEventId}` — no new open-panel protocol introduced. Mirrors the CountryDeepDivePanel disruption row contract from PR #3377. - Uses `src/shared/disruption-timeline.ts` formatters (formatEventWindow, formatCapacityOffline, statusForEvent) that PipelineStatus/StorageFacilityMap already use — consistent UI across all three disruption surfaces. Wiring: - `src/components/EnergyDisruptionsPanel.ts` — new (~230 lines). - `src/components/index.ts` — export. - `src/app/panel-layout.ts` — `this.createPanel('energy-disruptions', () => new EnergyDisruptionsPanel())` alongside the other three atlas panels at :892. - `src/config/panels.ts` — add to `FULL_PANELS` (priority 2, next to fuel-shortages) + `ENERGY_PANELS` (priority 1, top tier) + `PANEL_CATEGORY_MAP.marketsFinance` list alongside the other atlas panels. - `src/config/commands.ts` — CMD+K entry `panel:energy-disruptions` with keywords matching the user vocabulary (sabotage, sanctions events, force majeure, drone strike, nord stream sabotage). Not done in this PR: - No new map pin layer — per plan §Q (Codex approved), disruptions stay a tabular/timeline surface; map assets (pipelines + storage) already show disruption markers on click. - No direct globe-mode or SVG-fallback rendering needs — panel is pure DOM, not a map layer. Test plan: - [x] npm run typecheck (clean) - [x] npm run test:data (6694/6694 pass) - [ ] Manual: CMD+K "disruption log" → panel opens with 52 events, newest first. Click "Sabotage" chip → narrows to sabotage events only. Click a Nord Stream row → PipelineStatusPanel opens with that event highlighted. * fix(energy-atlas): drop highlightEventId emission + respect empty-state (review P2) Two Codex P2 findings on this PR: 1. Row click dispatched `highlightEventId` but neither PipelineStatusPanel nor StorageFacilityMapPanel consumes it. The UI's implicit promise (event-specific highlighting) wasn't delivered — clickthrough was asset-generic, and the extra field on the wire was a misleading API surface. Fix: drop `highlightEventId` from the dispatched detail. Row click now opens the asset drawer with just {pipelineId, facilityId}, the fields the receivers actually consume. User sees the full disruption timeline for that asset and locates the event visually. A future PR can add real highlight support by: - drawers accept `highlightEventId` in their openDetailHandler - loadDetail stores it and renderDisruptionTimeline scrolls + emphasises the matching event - re-add `highlightEventId` to the dispatch here, symmetrically in CountryDeepDivePanel (which has the same wire emission) The internal `_eventId` parameter is kept as a plumb-through so that future work is a drawer-side change, not a re-plumb. 2. `events.length === 0` was conflated with `upstreamUnavailable` and triggered the error UI. The server contract (list-energy-disruptions handler) returns `upstreamUnavailable: false` with an empty events array when Redis is up but has no entries matching the filter — a legitimate empty state, not a fetch failure. Fix: gate `showError` on `upstreamUnavailable` alone. Empty results fall through to the normal render, where the table's `No events match the current filter` row already handles the case. Typecheck clean, test:data 6694/6694 pass. * fix(energy-atlas): event delegation on persistent content (review P1) Codex P1: Panel.setContent() debounces the DOM write by 150ms (see Panel.ts:1025), so attaching listeners in render() via `this.element.querySelector(...)` targets the STALE DOM — chips, rows, and the ongoing-toggle button are silently non-interactive. Visually the panel renders correctly after the debounce fires, but every click is permanently dead. Fix: register a single delegated click handler on `this.content` (persistent element) in the constructor. The handler uses `closest('[data-filter-type]')`, `closest('[data-toggle-ongoing]')`, and `closest('tr.ed-row')` to route by data-attribute. Works regardless of when setContent flushes or how many times render() re-rewrites the inner HTML. Also fixes Codex P2 on the same PR: filterEvents() was called twice per render (once for row HTML, again for filteredCount). Now computed once, reused. Trivial for 52 events but eliminates the redundant sort. Typecheck clean. * fix(energy-atlas): remap orphan disruption assetIds to real pipelines Two events referenced pipeline ids that do not exist in scripts/data/pipelines-oil.json: - cpc-force-majeure-2022: assetId "cpc-pipeline" → "cpc" - pdvsa-designation-2019: assetId "ve-petrol-2026-q1" → "venezuela-anzoategui-puerto-la-cruz" Without this, clicking those rows in EnergyDisruptionsPanel dead-ends at "Pipeline detail unavailable", so the panel shipped with broken navigation on real data. Mirrors the same fix on PR #3377 (gap #5a registry); applying it on this branch as well so PR #3378 is independently correct regardless of merge order. The two changes will dedupe cleanly on rebase since the edits are byte-identical. --- src/app/panel-layout.ts | 2 + src/components/EnergyDisruptionsPanel.ts | 305 +++++++++++++++++++++++ src/components/index.ts | 1 + src/config/commands.ts | 1 + src/config/panels.ts | 4 +- 5 files changed, 312 insertions(+), 1 deletion(-) create mode 100644 src/components/EnergyDisruptionsPanel.ts diff --git a/src/app/panel-layout.ts b/src/app/panel-layout.ts index c6371c8e5..1527251ac 100644 --- a/src/app/panel-layout.ts +++ b/src/app/panel-layout.ts @@ -69,6 +69,7 @@ import { PipelineStatusPanel, StorageFacilityMapPanel, FuelShortagePanel, + EnergyDisruptionsPanel, MacroTilesPanel, FSIPanel, YieldCurvePanel, @@ -889,6 +890,7 @@ export class PanelLayoutManager implements AppModule { this.createPanel('pipeline-status', () => new PipelineStatusPanel()); this.createPanel('storage-facility-map', () => new StorageFacilityMapPanel()); this.createPanel('fuel-shortages', () => new FuelShortagePanel()); + this.createPanel('energy-disruptions', () => new EnergyDisruptionsPanel()); this.createPanel('polymarket', () => new PredictionPanel()); this.createNewsPanel('gov', 'panels.gov'); diff --git a/src/components/EnergyDisruptionsPanel.ts b/src/components/EnergyDisruptionsPanel.ts new file mode 100644 index 000000000..d14214d9f --- /dev/null +++ b/src/components/EnergyDisruptionsPanel.ts @@ -0,0 +1,305 @@ +import { Panel } from './Panel'; +import { escapeHtml } from '@/utils/sanitize'; +import { getRpcBaseUrl } from '@/services/rpc-client'; +import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer'; +import { SupplyChainServiceClient } from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; +import type { + ListEnergyDisruptionsResponse, + EnergyDisruptionEntry, +} from '@/generated/client/worldmonitor/supply_chain/v1/service_client'; +import { + formatEventWindow, + formatCapacityOffline, + statusForEvent, + type DisruptionStatus, +} from '@/shared/disruption-timeline'; + +const client = new SupplyChainServiceClient(getRpcBaseUrl(), { + fetch: (...args: Parameters) => globalThis.fetch(...args), +}); + +// One glyph per event type so readers can scan the timeline by nature of +// disruption. Kept terse — the type string itself is shown next to the glyph. +const EVENT_GLYPH: Record = { + sabotage: '💥', + sanction: '🚫', + maintenance: '🔧', + mechanical: '⚙️', + weather: '🌀', + commercial: '💼', + war: '⚔️', + other: '•', +}; + +const STATUS_COLOR: Record = { + ongoing: '#e74c3c', + resolved: '#7f8c8d', + unknown: '#95a5a6', +}; + +const EVENT_TYPE_FILTERS: Array<{ key: string; label: string }> = [ + { key: '', label: 'All events' }, + { key: 'sabotage', label: 'Sabotage' }, + { key: 'sanction', label: 'Sanction' }, + { key: 'mechanical', label: 'Mechanical' }, + { key: 'maintenance', label: 'Maintenance' }, + { key: 'war', label: 'War' }, + { key: 'weather', label: 'Weather' }, + { key: 'commercial', label: 'Commercial' }, + { key: 'other', label: 'Other' }, +]; + +function statusChip(status: DisruptionStatus): string { + const color = STATUS_COLOR[status] ?? STATUS_COLOR.unknown; + const label = status.charAt(0).toUpperCase() + status.slice(1); + return `${escapeHtml(label)}`; +} + +/** + * Standalone reverse-chronological timeline of every disruption event in + * `energy:disruptions:v1`. Per plan §L #4 this panel is the primary + * disruption surface — PipelineStatusPanel and StorageFacilityMapPanel + * each render an *asset-scoped* slice of the same events in their drawer, + * but neither lists the global log. + * + * Click-through dispatches the same events those panels already listen + * for (`energy:open-pipeline-detail` / `energy:open-storage-facility-detail`), + * so the event-routing contract stays the same as CountryDeepDivePanel's + * disruption row — no new panel-open protocol introduced. + */ +export class EnergyDisruptionsPanel extends Panel { + private data: ListEnergyDisruptionsResponse | null = null; + private activeTypeFilter = ''; + private ongoingOnly = false; + + constructor() { + super({ + id: 'energy-disruptions', + title: 'Energy Disruptions Log', + defaultRowSpan: 2, + infoTooltip: + 'Curated log of disruption events affecting oil & gas pipelines and ' + + 'storage facilities — sabotage, sanctions, maintenance, mechanical, ' + + 'weather, war, commercial. Each event ties back to a seeded asset; ' + + 'click a row to jump to the pipeline / storage panel with that event ' + + 'highlighted. See /docs/methodology/disruptions for the schema.', + }); + + // Event delegation on the persistent `content` element. Panel.setContent + // debounces the DOM write by 150ms (see Panel.ts:1025), so attaching + // listeners immediately after setContent() in render() would target the + // stale DOM — chips, rows, and the ongoing-toggle button would all be + // silently non-interactive. Codex P1 on PR #3378. + // + // Delegating from the persistent parent sidesteps the debounce entirely: + // the handler uses `closest(...)` on the clicked element to route by + // data-attributes, so it works regardless of whether the DOM has + // flushed yet or has been re-rendered since the last filter change. + this.content.addEventListener('click', this.handleContentClick); + } + + private handleContentClick = (e: Event): void => { + const target = e.target as HTMLElement | null; + if (!target) return; + + const filterBtn = target.closest('[data-filter-type]'); + if (filterBtn) { + this.setTypeFilter(filterBtn.dataset.filterType ?? ''); + return; + } + + const ongoingBtn = target.closest('[data-toggle-ongoing]'); + if (ongoingBtn) { + this.toggleOngoingOnly(); + return; + } + + const row = target.closest('tr.ed-row'); + if (row) { + const eventId = row.dataset.eventId; + const assetId = row.dataset.assetId; + const assetType = row.dataset.assetType; + if (eventId && assetId && assetType) { + this.dispatchOpenAsset(eventId, assetId, assetType); + } + } + }; + + public async fetchData(): Promise { + try { + const live = await client.listEnergyDisruptions({ + assetId: '', + assetType: '', + ongoingOnly: false, + }); + if (!this.element?.isConnected) return; + // Distinguish upstream unavailability from a healthy empty result. + // The server contract (see list-energy-disruptions.ts) returns + // `upstreamUnavailable: true` only when Redis itself can't be + // read; an empty `events` array with `upstreamUnavailable: false` + // is a valid response shape the UI should render as "no events + // match" rather than as an error. Conflating the two previously + // showed a retry button on what was a legitimate empty state. + if (live.upstreamUnavailable) { + this.showError('Energy disruptions log unavailable', () => void this.fetchData()); + return; + } + this.data = live; + this.render(); + } catch (err) { + if (this.isAbortError(err)) return; + if (!this.element?.isConnected) return; + this.showError('Energy disruptions log error', () => void this.fetchData()); + } + } + + private setTypeFilter(type: string): void { + this.activeTypeFilter = type; + this.render(); + } + + private toggleOngoingOnly(): void { + this.ongoingOnly = !this.ongoingOnly; + this.render(); + } + + private filterEvents(): EnergyDisruptionEntry[] { + if (!this.data) return []; + let events = this.data.events; + if (this.activeTypeFilter) { + events = events.filter(e => e.eventType === this.activeTypeFilter); + } + if (this.ongoingOnly) { + events = events.filter(e => !e.endAt); + } + // Newest first. Server already sorts by startAt DESC but defensive + // sort keeps the UI stable when filters reshuffle. + return [...events].sort((a, b) => b.startAt.localeCompare(a.startAt)); + } + + private render(): void { + if (!this.data) return; + + // Compute once — previously filterEvents() ran twice per render, once + // for the row HTML and again for filteredCount. Trivial for 52 events + // but the redundant sort on every render was noise. Codex P2. + const filtered = this.filterEvents(); + const rows = filtered.map(e => this.renderRow(e)).join(''); + const totalCount = this.data.events.length; + const ongoingCount = this.data.events.filter(e => !e.endAt).length; + const filteredCount = filtered.length; + const summary = this.activeTypeFilter || this.ongoingOnly + ? `${filteredCount} shown · ${totalCount} total · ${ongoingCount} ongoing` + : `${totalCount} events · ${ongoingCount} ongoing`; + + const typeButtons = EVENT_TYPE_FILTERS.map(f => { + const active = f.key === this.activeTypeFilter; + return ``; + }).join(''); + + const ongoingBtn = ``; + + const attribution = attributionFooterHtml({ + sourceType: 'classifier', + method: 'curated event log', + sampleSize: totalCount, + sampleLabel: 'disruption events', + updatedAt: this.data.fetchedAt, + classifierVersion: this.data.classifierVersion, + creditName: 'Operator press + regulator filings + OFAC/EU sanctions + major wire', + creditUrl: '/docs/methodology/disruptions', + }); + + this.setContent(` +
+
${escapeHtml(summary)}
+
${typeButtons}${ongoingBtn}
+ + + + + + + + + + + ${rows || ``} +
EventAssetWindowOfflineStatus
No events match the current filter.
+ ${attribution} +
+ ${ATTRIBUTION_FOOTER_CSS} + + `); + + // No inline listener attachment — the constructor registers a single + // delegated click handler on `this.content` that routes by data- + // attribute via `closest(...)`. Attaching listeners here would target + // the stale DOM because Panel.setContent() debounces by 150ms. + } + + private dispatchOpenAsset(_eventId: string, assetId: string, assetType: string): void { + // Dispatch only the {pipelineId, facilityId} field the receiving + // drawers actually consume today (see PipelineStatusPanel and + // StorageFacilityMapPanel `openDetailHandler`). The row click + // jumps the user to the asset — they see the full disruption + // timeline for that asset and can locate the specific event + // visually. A future PR can add a `highlightEventId` contract + // with matching drawer-side rendering (scroll-into-view + visual + // emphasis); until then, emitting an unread field was a misleading + // API surface (Codex P2). + const detail = assetType === 'storage' + ? { facilityId: assetId } + : { pipelineId: assetId }; + const eventName = assetType === 'storage' + ? 'energy:open-storage-facility-detail' + : 'energy:open-pipeline-detail'; + window.dispatchEvent(new CustomEvent(eventName, { detail })); + } + + private renderRow(e: EnergyDisruptionEntry): string { + const glyph = EVENT_GLYPH[e.eventType] ?? '•'; + const status = statusForEvent({ startAt: e.startAt, endAt: e.endAt || undefined }); + const eventWindow = formatEventWindow(e.startAt, e.endAt || undefined); + const offline = formatCapacityOffline(e.capacityOfflineBcmYr, e.capacityOfflineMbd); + const causeChain = e.causeChain.join(' → ') || '—'; + + return ` + + +
${glyph} ${escapeHtml(e.eventType)}
+
${escapeHtml(e.shortDescription)}
+
${escapeHtml(causeChain)}
+ + + ${escapeHtml(e.assetType)} + ${escapeHtml(e.assetId)} + + ${escapeHtml(eventWindow)} + ${escapeHtml(offline || '—')} + ${statusChip(status)} + `; + } +} diff --git a/src/components/index.ts b/src/components/index.ts index b9953d8b8..757c4343a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -91,6 +91,7 @@ export { ChokepointStripPanel } from './ChokepointStripPanel'; export { PipelineStatusPanel } from './PipelineStatusPanel'; export { StorageFacilityMapPanel } from './StorageFacilityMapPanel'; export { FuelShortagePanel } from './FuelShortagePanel'; +export { EnergyDisruptionsPanel } from './EnergyDisruptionsPanel'; export * from './ClimateNewsPanel'; export * from './DiseaseOutbreaksPanel'; export * from './SocialVelocityPanel'; diff --git a/src/config/commands.ts b/src/config/commands.ts index 29e61b7ec..8b1e6c9c9 100644 --- a/src/config/commands.ts +++ b/src/config/commands.ts @@ -107,6 +107,7 @@ export const COMMANDS: Command[] = [ { id: 'panel:pipeline-status', keywords: ['pipelines', 'pipeline status', 'oil pipelines', 'gas pipelines', 'nord stream', 'druzhba', 'sabotage', 'pipeline registry'], label: 'Panel: Oil & Gas Pipeline Status', icon: '\u{1F6E2}\uFE0F', category: 'panels' }, { id: 'panel:storage-facility-map', keywords: ['storage', 'storage facilities', 'strategic storage', 'spr', 'lng', 'lng terminals', 'ugs', 'tank farms', 'storage atlas'], label: 'Panel: Strategic Storage Atlas', icon: '\u{1F6E2}\uFE0F', category: 'panels' }, { id: 'panel:fuel-shortages', keywords: ['fuel shortages', 'shortage', 'petrol shortage', 'diesel shortage', 'jet fuel shortage', 'rationing', 'stations closed'], label: 'Panel: Global Fuel Shortage Registry', icon: '⛽', category: 'panels' }, + { id: 'panel:energy-disruptions', keywords: ['energy disruptions', 'disruption log', 'disruption timeline', 'energy events', 'sanctions events', 'pipeline sabotage', 'nord stream sabotage', 'drone strike', 'force majeure', 'mechanical failure'], label: 'Panel: Energy Disruptions Log', icon: '\u{1F4A5}', category: 'panels' }, { id: 'panel:gov', keywords: ['government', 'gov'], label: 'Panel: Government', icon: '\u{1F3DB}\uFE0F', category: 'panels' }, { id: 'panel:policy', keywords: ['policy', 'ai policy', 'regulation', 'tech policy'], label: 'Panel: AI Policy & Regulation', icon: '\u{1F4DC}', category: 'panels' }, { id: 'panel:thinktanks', keywords: ['think tanks', 'thinktanks', 'analysis'], label: 'Panel: Think Tanks', icon: '\u{1F9E0}', category: 'panels' }, diff --git a/src/config/panels.ts b/src/config/panels.ts index 00ccc8b03..36f1bf716 100644 --- a/src/config/panels.ts +++ b/src/config/panels.ts @@ -80,6 +80,7 @@ const FULL_PANELS: Record = { 'pipeline-status': { name: 'Oil & Gas Pipeline Status', enabled: true, priority: 2 }, 'storage-facility-map': { name: 'Strategic Storage Atlas', enabled: true, priority: 2 }, 'fuel-shortages': { name: 'Global Fuel Shortage Registry', enabled: true, priority: 2 }, + 'energy-disruptions': { name: 'Energy Disruptions Log', enabled: true, priority: 2 }, 'gulf-economies': { name: 'Gulf Economies', enabled: false, priority: 2 }, 'consumer-prices': { name: 'Consumer Prices', enabled: false, priority: 2 }, 'grocery-basket': { name: 'Grocery Index', enabled: false, priority: 2 }, @@ -914,6 +915,7 @@ const ENERGY_PANELS: Record = { 'pipeline-status': { name: 'Oil & Gas Pipeline Status', enabled: true, priority: 1 }, 'storage-facility-map': { name: 'Strategic Storage Atlas', enabled: true, priority: 1 }, 'fuel-shortages': { name: 'Global Fuel Shortage Registry', enabled: true, priority: 1 }, + 'energy-disruptions': { name: 'Energy Disruptions Log', enabled: true, priority: 1 }, 'live-news': { name: 'Energy Headlines', enabled: true, priority: 1 }, insights: { name: 'AI Energy Insights', enabled: true, priority: 1 }, // Energy complex — existing panels reused at launch @@ -1222,7 +1224,7 @@ export const PANEL_CATEGORY_MAP: Record