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