feat(energy-atlas): EnergyDisruptionsPanel standalone timeline (§L #4) (#3378)

* 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.
This commit is contained in:
Elie Habib
2026-04-24 19:09:05 +04:00
committed by GitHub
parent 7c0c08ad89
commit 73cd8a9c92
5 changed files with 312 additions and 1 deletions

View File

@@ -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');

View File

@@ -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<typeof fetch>) => 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<string, string> = {
sabotage: '💥',
sanction: '🚫',
maintenance: '🔧',
mechanical: '⚙️',
weather: '🌀',
commercial: '💼',
war: '⚔️',
other: '•',
};
const STATUS_COLOR: Record<DisruptionStatus, string> = {
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 `<span class="ed-badge" style="background:${color}">${escapeHtml(label)}</span>`;
}
/**
* 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<HTMLButtonElement>('[data-filter-type]');
if (filterBtn) {
this.setTypeFilter(filterBtn.dataset.filterType ?? '');
return;
}
const ongoingBtn = target.closest<HTMLButtonElement>('[data-toggle-ongoing]');
if (ongoingBtn) {
this.toggleOngoingOnly();
return;
}
const row = target.closest<HTMLTableRowElement>('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<void> {
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 `<button class="ed-chip${active ? ' ed-chip-active' : ''}" data-filter-type="${escapeHtml(f.key)}">${escapeHtml(f.label)}</button>`;
}).join('');
const ongoingBtn = `<button class="ed-chip${this.ongoingOnly ? ' ed-chip-active' : ''}" data-toggle-ongoing>Ongoing only</button>`;
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(`
<div class="ed-wrap">
<div class="ed-summary">${escapeHtml(summary)}</div>
<div class="ed-filters">${typeButtons}${ongoingBtn}</div>
<table class="ed-table">
<thead>
<tr>
<th>Event</th>
<th>Asset</th>
<th>Window</th>
<th>Offline</th>
<th>Status</th>
</tr>
</thead>
<tbody>${rows || `<tr><td colspan="5" class="ed-empty">No events match the current filter.</td></tr>`}</tbody>
</table>
${attribution}
</div>
${ATTRIBUTION_FOOTER_CSS}
<style>
.ed-wrap { font-size: 11px; }
.ed-summary { font-size: 10px; color: var(--text-dim, #888); text-transform: uppercase; letter-spacing: 0.04em; margin: 4px 0 6px 0; }
.ed-filters { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
.ed-chip { background: rgba(255,255,255,0.04); color: var(--text-dim, #aaa); border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 2px 8px; font-size: 10px; cursor: pointer; }
.ed-chip:hover { background: rgba(255,255,255,0.08); color: var(--text, #eee); }
.ed-chip-active { background: #2980b9; border-color: #2980b9; color: #fff; }
.ed-chip-active:hover { background: #2471a3; }
.ed-table { width: 100%; border-collapse: collapse; }
.ed-table th { text-align: left; font-size: 9px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim, #888); padding: 4px 6px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.ed-table td { padding: 6px; border-bottom: 1px solid rgba(255,255,255,0.04); vertical-align: top; }
.ed-row { cursor: pointer; }
.ed-row:hover td { background: rgba(255,255,255,0.03); }
.ed-event { font-weight: 600; color: var(--text, #eee); }
.ed-sub { font-size: 9px; color: var(--text-dim, #888); text-transform: uppercase; letter-spacing: 0.04em; }
.ed-asset-type { display: inline-block; padding: 1px 6px; border-radius: 8px; font-size: 8px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; background: rgba(255,255,255,0.08); color: var(--text-dim, #aaa); margin-right: 4px; }
.ed-badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 9px; font-weight: 700; color: #fff; text-transform: uppercase; letter-spacing: 0.04em; }
.ed-empty { text-align: center; color: var(--text-dim, #888); padding: 20px; font-style: italic; }
.ed-offline { font-family: monospace; font-size: 10px; color: var(--text, #eee); }
</style>
`);
// 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 `
<tr class="ed-row"
data-event-id="${escapeHtml(e.id)}"
data-asset-id="${escapeHtml(e.assetId)}"
data-asset-type="${escapeHtml(e.assetType)}">
<td>
<div class="ed-event">${glyph} ${escapeHtml(e.eventType)}</div>
<div class="ed-sub">${escapeHtml(e.shortDescription)}</div>
<div class="ed-sub">${escapeHtml(causeChain)}</div>
</td>
<td>
<span class="ed-asset-type">${escapeHtml(e.assetType)}</span>
<span class="ed-asset-id">${escapeHtml(e.assetId)}</span>
</td>
<td>${escapeHtml(eventWindow)}</td>
<td><span class="ed-offline">${escapeHtml(offline || '—')}</span></td>
<td>${statusChip(status)}</td>
</tr>`;
}
}

View File

@@ -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';

View File

@@ -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' },

View File

@@ -80,6 +80,7 @@ const FULL_PANELS: Record<string, PanelConfig> = {
'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<string, PanelConfig> = {
'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<string, { labelKey: string; panelKeys: s
},
marketsFinance: {
labelKey: 'header.panelCatMarketsFinance',
panelKeys: ['commodities', 'energy-complex', 'pipeline-status', 'storage-facility-map', 'fuel-shortages', 'hormuz-tracker', 'energy-crisis', 'markets', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
panelKeys: ['commodities', 'energy-complex', 'pipeline-status', 'storage-facility-map', 'fuel-shortages', 'energy-disruptions', 'hormuz-tracker', 'energy-crisis', 'markets', 'economic', 'trade-policy', 'sanctions-pressure', 'supply-chain', 'finance', 'polymarket', 'macro-signals', 'gulf-economies', 'etf-flows', 'stablecoins', 'crypto', 'heatmap'],
},
topical: {
labelKey: 'header.panelCatTopical',