mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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:
@@ -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');
|
||||
|
||||
305
src/components/EnergyDisruptionsPanel.ts
Normal file
305
src/components/EnergyDisruptionsPanel.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user