mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(variant): Energy Atlas — Release 1 Day 1 (variant scaffolding) (#3291)
* feat(variant): add energy variant scaffolding for energy.worldmonitor.app Release 1 Day 1 of the Energy Atlas plan — introduces src/config/variants/energy.ts modeled on the commodity variant. No new panels or RPCs yet; the variant reuses existing energy-related panels (energy-complex, oil-inventories, hormuz, energy-crisis, fuel-prices, renewable-energy) + supply-chain/sanctions context. Map layers enable pipelines, waterways, AIS, commodityPorts, minerals, climate, outages, natural, weather. All geopolitical/military/tech/finance/happy variant layers explicitly disabled per variant isolation conventions. Next PRs on feat/energy-atlas-release-1 add: - Pipeline & storage registries (curated critical assets, ~75 gas / ~75 oil / ~125 storage) - Global fuel-shortage registry with automated evidence-threshold promotion - Pipeline/storage disruption event log - Country drill-down Energy section - Atlas landing composition at variant root * feat(variant): wire energy variant into runtime + atlas landing composition Day 2 of the Energy Atlas Release 1 plan. The Day-1 commit added a canonical variants/energy.ts but discovery during Day 2 showed the app's runtime variant resolution lives in src/config/panels.ts (ENERGY_PANELS/ENERGY_MAP_LAYERS/etc.), not in variants/*.ts (which are orphans). This commit does the real wiring. Changes: - src/config/panels.ts — ENERGY_PANELS, ENERGY_MAP_LAYERS, ENERGY_MOBILE_MAP_LAYERS; registered in ALL_PANELS, VARIANT_DEFAULTS, VARIANT_PANEL_OVERRIDES; wired into DEFAULT_MAP_LAYERS + MOBILE_DEFAULT_MAP_LAYERS ternaries. Panels at launch: map, live-news, insights, energy-complex, oil-inventories, hormuz, energy-crisis, fuel-prices, renewable-energy, commodities, energy (news), macro-signals, supply-chain, sanctions-pressure, gulf-economies, gcc-investments, climate, monitors, world-clock, latest-brief. - src/config/variant.ts — recognize 'energy' as allowed SITE_VARIANT; resolve energy.worldmonitor.app subdomain to 'energy'; honor localStorage override. - src/config/variant-meta.ts — SEO entry for energy.worldmonitor.app (title, description, keywords targeting 'oil pipeline tracker', 'gas storage map', 'fuel shortage tracker', 'chokepoint monitor', etc.). - src/app/panel-layout.ts — desktop variant switcher + mobile menu both list energy with ⚡ icon and t('header.energy') label. - src/App.ts + src/app/data-loader.ts — energy variant enables trade-policy and supply-chain data loads (chokepoint exposure is a core Atlas surface). - src/app/data-loader.ts — daily-brief newsCategories override for energy variant (energy, energy-markets, oil-gas-news, pipeline-news, lng-news). - src/locales/en.json — 'header.energy' translation key. - src/config/variants/energy.ts — add clarifying comment that real wiring lives in panels.ts (same orphan pattern as commodity.ts/finance.ts/etc.). Atlas landing composition: the variant now renders its energy panel set with energy-specific names (Energy Atlas Map, Energy Headlines, AI Energy Insights) when SITE_VARIANT === 'energy'. Pipeline and commodity-port map layers enabled so Week 2's pipeline registry + storage-facility registry drop in with layers already toggled on. Typecheck clean; 175 pre-push tests expected to remain green. Subsequent PRs on feat/energy-atlas-release-1: - Week 2: pipeline registry + storage facility registry (evidence-based) - Week 3: fuel-shortage classifier + disruption log + country drill-down - Week 4: automated revision log, SEO polish, launch * feat(energy): chokepoint strip at top of atlas (7 chokepoints) Day 3 of the Energy Atlas Release 1 plan. Adds ChokepointStripPanel — a compact horizontal strip of chip-style cards, one per chokepoint, showing name + status color + flow-as-%-of-baseline + active-warnings badge. Ordered by volume: Hormuz, Malacca, Suez, Bab el-Mandeb, Turkish Straits, Danish Straits, Panama. GEF covers 5 chokepoints (Hormuz, Malacca, Suez, Bab el-Mandeb, Panama). We cover 7 — adds Turkish Straits + Danish Straits. One of the surpass vectors enumerated in §5.7 of the plan doc. Data: reuses the existing fetchChokepointStatus() RPC backed by supply_chain:chokepoints:v4 (Portwatch DWT + AIS calibration). No new backend work; this is pure UI composition. Changes: - src/components/ChokepointStripPanel.ts — new Panel subclass with in-line CSS for the chip strip; falls back gracefully when a chokepoint is missing from the response or FlowEstimate is absent. - src/components/index.ts — barrel export. - src/app/panel-layout.ts — import + createPanel registration near existing energy panels. - src/config/panels.ts — ENERGY_PANELS adds 'chokepoint-strip' at priority 1 (renders near top of atlas). Also fixes two panel-ID mismatches caught while wiring: 'hormuz' → 'hormuz-tracker' and 'renewable-energy' → 'renewable' (matches HormuzPanel.ts and RenewableEnergyPanel registration). Typecheck clean. No new tests required — panel renders real data. * feat(energy): attribution footer utility + methodology page stubs Days 4 & 5 of the Energy Atlas Release 1 plan. ## Day 4 — Attribution footer (src/utils/attribution-footer.ts) A reusable string-builder that stamps every energy-atlas number with its provenance. Design intent per plan §5.6 (quantitative rigour moat): "every flow number carries {value, baseline, n_vessels, methodology, confidence}". Input schema: - sourceType: operator | regulator | ais | satellite | press | classifier | derived - method: short free-text ("AIS-DWT calibrated", "GIE AGSI+ daily") - sampleSize + sampleLabel: observation count and unit - updatedAt: ISO8601 / Date / number — rendered as "Xm/h/d ago" - confidence: 0..1 — bucketed to high/medium/low - classifierVersion: surfaced when evidence-derived badges ship in Week 2+ - creditName / creditUrl: CC-BY / dataset credit (OWID, GEM pattern) Every field also writes data-attributes (data-attr-source, data-attr-n, data-attr-confidence, data-attr-classifier) so MCP / scraper / analyst agents can extract the same provenance the reader sees. Agent-native by default. Applied to ChokepointStripPanel — the panel now shows its evidence footer ("AIS calibration · Portwatch DWT + AIS · N AIS disruption signals · updated Xh ago · EIA World Oil Transit Chokepoints"). Future pipeline / storage / shortage panels drop the same helper in and hit the same rigour bar automatically. 7 unit tests (tests/attribution-footer.test.mts, node:test via tsx): minimal footer, method + sample size + credit, "X ago" formatting, confidence band mapping, full data-attribute emission, credit omission, HTML escaping. ## Day 5 — Public methodology page stubs (docs/methodology/) Four new MDX pages surfaced in docs/docs.json navigation under "Intelligence & Analysis": - chokepoints.mdx — 7 chokepoints, Portwatch+AIS calibration method, status badge derivation, known limits, revision-log link. - pipelines.mdx — curated critical-asset scope, GEM CC-BY attribution, evidence-schema (NOT conclusion labels), freshness SLA, corrections. - storage.mdx — curated ~125 facilities scope, "published not synthesized" fill % policy, country-aggregate fallback, attribution. - shortages.mdx — automated tiered evidence threshold, LLM second-pass gating, auto-decay cadence, evidence-source transparency, break-glass override policy (admin-only, off critical path). All four explicitly document WorldMonitor's automated-data-quality posture: no human review queues, quality via classifier rigour + evidence transparency + auto-decay + public revision log. Typecheck clean. attribution-footer.test.mts passes all 7 tests. * fix(variant): close three energy-variant isolation leaks from review Addresses three High findings from PR review: 1. Map-layer isolation (src/config/map-layer-definitions.ts) - Add 'energy' to the MapVariant type union. - Add energy entry to VARIANT_LAYER_ORDER with the curated energy subset (pipelines, waterways, commodityPorts, commodityHubs, ais, tradeRoutes, minerals, sanctions, fires, climate, weather, outages, natural, resilienceScore, dayNight). Without this, getLayersForVariant() and sanitizeLayersForVariant() (called from DeckGLMap and App.ts) fell back to VARIANT_LAYER_ORDER.full, letting the full geopolitical palette (military, nuclear, iranAttacks, conflicts, hotspots, bases, protests, flights, ucdpEvents, displacement, gpsJamming, satellites, ciiChoropleth, cables, datacenters, economic, cyberThreats, spaceports, irradiators, radiationWatch) into the desktop map tray and saved/URL layer sanitization — breaking the PR's stated no-geopolitical-bleed goal and violating multi-variant-site-data-isolation. 2. News feeds (src/config/feeds.ts + src/app/data-loader.ts) - Add ENERGY_FEEDS with three keys matching ENERGY_PANELS: live-news (broad energy headlines from OilPrice, Rigzone, Reuters/Bloomberg/FT energy), energy (OPEC + crude + NatGas/LNG + pipelines/chokepoints + crisis/shortages + refineries), supply-chain (tanker/shipping, chokepoints, energy sanctions, ports/terminals). - Add SITE_VARIANT === 'energy' branch to the FEEDS variant selector. - Correct newsCategories override in data-loader.ts — my earlier speculative values ['energy','energy-markets','oil-gas-news', 'pipeline-news','lng-news'] included keys that did not exist in any feed map. Replaced with real ENERGY_FEEDS keys ['live-news', 'energy', 'supply-chain']. Without this, FEEDS resolved to FULL_FEEDS for the energy variant — live-news + daily-brief both ingested the world/geopolitical feed set. 3. Insights / AI brief framing (src/components/InsightsPanel.ts) - Add SITE_VARIANT === 'energy' branch to geoContext: dedicated energy prompt focused on physical supply (pipelines, chokepoints, storage, days-of-cover, refineries, LNG, sanctions, shortages) with evidence-grounded attribution, no bare conclusions. - Add '⚡ ENERGY BRIEF' heading branch in renderWorldBrief(). Without this, the renamed 'AI Energy Insights' panel fell through to the empty default prompt and rendered 'WORLD BRIEF'. Typecheck clean. attribution-footer tests still pass (no coupling changed). * fix(variant): close energy-variant leak in SVG/mobile fallback map Fifth High finding from PR review: src/components/Map.ts createLayerToggles() (line 381-409) has no 'energy' branch in its variant ternary, so energy-variant users whose MapContainer routes to the SVG/mobile fallback (no WebGL, mobile with deviceMemory < 3, or DeckGL init throws) see the full geopolitical toggle set — iranAttacks, conflicts, hotspots, bases, nuclear, irradiators, military, protests, flights, gpsJamming, ciiChoropleth, cables, datacenters. Clicking any toggle flips the layer via toggleLayer() which is variant-blind (Map.ts:3383) — so users could enable military / nuclear layers on the energy variant despite the rest of the isolation work in panels.ts, map-layer-definitions.ts, feeds.ts, and InsightsPanel.ts. Fix: add energyLayers array with the SVG-capable subset of ENERGY_MAP_LAYERS — pipelines, waterways, ais, commodityHubs, minerals, sanctions, outages, natural, weather, fires, economic. Intentionally omitted: commodityPorts, climate, tradeRoutes, resilienceScore, dayNight — none of these have render handlers in Map.ts's SVG path, so including them would create toggles that do nothing. Extended the ternary with 'energy' → energyLayers between 'happy' and the 'full' fallback. Note (preexisting, NOT fixed here): the same ternary has no 'commodity' branch either, so commodity.worldmonitor.app also gets the full geopolitical toggle set on the SVG fallback. Out of scope for this PR; flagged for a separate fix. Defence-in-depth: sanitizeLayersForVariant() (now fixed in map-layer-definitions.ts) strips saved-URL layers to the energy subset before the SVG map sees them, so even if a user arrives with ?layers=military in the URL, it's gone by the time initialState reaches MapComponent. The toggle-list fix closes the UI-path leak; the sanitize fix closes the URL-path leak. Typecheck clean.
This commit is contained in:
@@ -87,6 +87,10 @@
|
||||
"ai-intelligence",
|
||||
"country-instability-index",
|
||||
"methodology/country-resilience-index",
|
||||
"methodology/chokepoints",
|
||||
"methodology/pipelines",
|
||||
"methodology/storage",
|
||||
"methodology/shortages",
|
||||
"geographic-convergence",
|
||||
"strategic-risk",
|
||||
"algorithms"
|
||||
|
||||
67
docs/methodology/chokepoints.mdx
Normal file
67
docs/methodology/chokepoints.mdx
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: "Chokepoint Methodology"
|
||||
description: "How World Monitor calibrates live flow estimates for the seven global oil & gas shipping chokepoints (Hormuz, Malacca, Suez, Bab el-Mandeb, Turkish Straits, Danish Straits, Panama)."
|
||||
---
|
||||
|
||||
## What we track
|
||||
|
||||
Seven oil & gas shipping chokepoints:
|
||||
|
||||
| Chokepoint | Baseline flow (mb/d) |
|
||||
|-------------------|----------------------|
|
||||
| Strait of Hormuz | 21.0 |
|
||||
| Strait of Malacca | 17.2 |
|
||||
| Suez Canal / SUMED| 7.6 |
|
||||
| Bab el-Mandeb | 6.2 |
|
||||
| Danish Straits | 3.0 |
|
||||
| Turkish Straits | 2.9 |
|
||||
| Panama Canal | 0.9 |
|
||||
|
||||
Baselines are EIA's 2023 World Oil Transit Chokepoints publication. Two chokepoints (Turkish + Danish Straits) are **not** covered by common competitor sites — they show up here.
|
||||
|
||||
## How live flow is derived
|
||||
|
||||
Not an editorial estimate. Every published flow number is calibrated from real observations on a 6-hour refresh:
|
||||
|
||||
1. **Portwatch DWT** — IMF / World Bank vessel-tracking aggregation produces per-chokepoint deadweight tonnage by vessel class.
|
||||
2. **AIS-relay hazard matching** — our AIS ingestion (Railway relay) tags vessels within 500 km of a chokepoint with observed disruption signals (GPS jamming, AIS-off, anchorage clusters, Red/Orange GDACS alerts).
|
||||
3. **Flow estimate** = current DWT ÷ 365-day baseline DWT, expressed as % of baseline.
|
||||
4. **Confidence band** = function of vessel sample size and disruption-signal density.
|
||||
|
||||
Refresh cadence: `energy:chokepoint-flows:v1` (6h Railway cron) + `supply_chain:chokepoints:v4` warm-ping (30 min).
|
||||
|
||||
## Status badge derivation
|
||||
|
||||
Each chokepoint has a derived public badge: `normal | restricted | disrupted | closed`. The badge is a deterministic function of:
|
||||
|
||||
- Flow ratio (current mb/d ÷ baseline)
|
||||
- Active AIS disruption count within 500 km
|
||||
- War-risk tier (low / medium / high / extreme)
|
||||
- Active navigational warnings (NGA)
|
||||
|
||||
We do not publish a bare conclusion label ("sanctions-blocked", "closed by Iran", etc.) without the underlying evidence fields visible alongside.
|
||||
|
||||
## Every public number carries its grounds
|
||||
|
||||
Every chokepoint card exposes its provenance via a standard attribution footer:
|
||||
|
||||
- Source type (`ais` for Portwatch + AIS, `regulator` for EIA baselines)
|
||||
- Sample size (N AIS observations)
|
||||
- Method (`AIS-DWT calibrated`)
|
||||
- Confidence band (`high | medium | low`)
|
||||
- Data freshness (`Xh ago`)
|
||||
|
||||
The footer is an `HTMLElement` with `data-attr-source`, `data-attr-n`, `data-attr-confidence` attributes so agents (MCP clients, scrapers, analyst tools) can extract the same provenance the eye can read.
|
||||
|
||||
## Known limits
|
||||
|
||||
- AIS signal drops over heavily-jammed regions (Bab el-Mandeb, Hormuz). Gaps are surfaced as reduced confidence, not synthesized away.
|
||||
- Baselines are annual averages. Seasonal swings (e.g. Suez winter/summer) aren't modeled yet.
|
||||
- No per-commodity breakdown (crude vs products vs LNG) at the strip level. Coming in a later release via the pipeline registry crosslink.
|
||||
|
||||
## Corrections
|
||||
|
||||
Every badge transition writes to the public revision log at
|
||||
[`/corrections`](/corrections). If you spot a wrong number, open a GitHub issue
|
||||
or email the address listed in the footer — we update on the next classifier
|
||||
pass.
|
||||
70
docs/methodology/pipelines.mdx
Normal file
70
docs/methodology/pipelines.mdx
Normal file
@@ -0,0 +1,70 @@
|
||||
---
|
||||
title: "Pipeline Registry Methodology"
|
||||
description: "How World Monitor curates and attributes status for the world's oil and gas pipelines shown on the Energy Atlas."
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Release 1 launches with a curated registry of critical oil & gas pipelines, not a claim of global completeness:
|
||||
|
||||
- ~75 critical gas pipelines (Nord Stream 1/2, TurkStream, Yamal, Brotherhood/Soyuz, Power of Siberia, Qatar–UAE Dolphin, Medgaz, Langeled, Europipe I/II, Franpipe, etc.)
|
||||
- ~75 critical oil pipelines (Druzhba N/S, CPC, ESPO, BTC, Trans-Alaska, Habshan–Fujairah, Keystone, Kirkuk–Ceyhan, Baku–Supsa, etc.)
|
||||
|
||||
Curation bias is toward pipelines with active geopolitical exposure, not theoretical global completeness. Expansion is a post-launch decision.
|
||||
|
||||
## Data sources
|
||||
|
||||
- **[Global Energy Monitor](https://globalenergymonitor.org) — Oil & Gas Pipeline Tracker** (CC-BY 4.0). Primary source for geometry, capacity, operator, country list.
|
||||
- **ENTSOG Transparency Platform** (public API) — EU gas pipeline nominations and sendout.
|
||||
- **Operator technical documentation** — route schematics, capacity plates, force-majeure notices.
|
||||
- **Regulator filings** — per-jurisdiction filings where applicable.
|
||||
|
||||
Every pipeline carries at least one primary source reference.
|
||||
|
||||
## Evidence schema (not conclusions)
|
||||
|
||||
We do not publish a bare `sanctions_blocked` or `political_cutoff` label. Public badges are derived server-side from an evidence bundle per pipeline:
|
||||
|
||||
```ts
|
||||
{
|
||||
physicalState: 'flowing' | 'reduced' | 'offline' | 'unknown',
|
||||
physicalStateSource: 'ais-relay' | 'operator' | 'satellite' | 'press',
|
||||
operatorStatement: { text, url, date } | null,
|
||||
commercialState: 'under_contract' | 'expired' | 'suspended' | 'unknown',
|
||||
sanctionRefs: [{ authority, listId, date, url }, ...],
|
||||
lastEvidenceUpdate: ISO8601,
|
||||
classifierVersion: 'vN',
|
||||
classifierConfidence: 0..1
|
||||
}
|
||||
```
|
||||
|
||||
The visible `publicBadge` (`flowing | reduced | offline | disputed`) is a deterministic function with freshness weights. When a pipeline reopens or a sanctions list changes, the evidence fields update and the badge re-derives automatically. We ship the evidence; the badge is a convenience view of it.
|
||||
|
||||
## How public badges move
|
||||
|
||||
Any transition that flips a public status writes an append-only entry to the public revision log at [`/corrections`](/corrections). Each entry records:
|
||||
|
||||
- `{ assetId, fieldChanged, previousValue, newValue, trigger, sourcesUsed[], classifierVersion }`
|
||||
|
||||
No human review queue gates the transition — quality comes from the tiered evidence threshold + an LLM second-pass sanity check + auto-decay of stale evidence. The classifier's version string ships with every public badge so scientific reproducibility is possible.
|
||||
|
||||
## Freshness SLA
|
||||
|
||||
- Pipeline registry fields (geometry, operator, capacity): 35 days
|
||||
- Pipeline public badge (derived state): 24 hours; auto-decay to `stale` at 48 h and excluded from "active disruptions" counts after 7 days
|
||||
|
||||
## Known limits
|
||||
|
||||
- Geometry is simplified (not engineering-grade routing). Do not use for field operations.
|
||||
- Flow direction is advertised but not always calibrated to metered reality; relative state (flowing / reduced / offline) is more reliable than absolute mb/d.
|
||||
- Sanction references are evidence, not legal interpretation. Every `sanctionRefs` entry cites the authority; the interpretation of whether a sanction "blocks" flow is made explicit in the evidence bundle, never implicit in a badge label.
|
||||
|
||||
## Attribution
|
||||
|
||||
Pipeline-registry data derived from [Global Energy Monitor](https://globalenergymonitor.org) (CC-BY 4.0), with additional operator and regulator material incorporated under fair-use for news reporting.
|
||||
|
||||
## Corrections
|
||||
|
||||
See the public revision log at [`/corrections`](/corrections). Spot a wrong
|
||||
status? Open a GitHub issue or email the address in the atlas footer — we
|
||||
update on the next classifier pass, typically within 6 hours.
|
||||
85
docs/methodology/shortages.mdx
Normal file
85
docs/methodology/shortages.mdx
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
title: "Fuel Shortage Alert Methodology"
|
||||
description: "How World Monitor classifies and publishes fuel shortage alerts (jet, petrol, diesel, heating oil) using an automated evidence-threshold pipeline."
|
||||
---
|
||||
|
||||
## What we track
|
||||
|
||||
Four product categories, two severity tiers, global coverage:
|
||||
|
||||
| Product | `watch` tier | `confirmed` tier |
|
||||
|-----------|--------------|------------------|
|
||||
| Jet fuel | ✓ | ✓ |
|
||||
| Petrol | ✓ | ✓ |
|
||||
| Diesel | ✓ | ✓ |
|
||||
| Heating oil | ✓ | ✓ (winter only) |
|
||||
|
||||
Price spikes alone are not shortage signals. We key on physical supply constraints: flight cancellations, station closures, formal rationing, import cuts.
|
||||
|
||||
## Fully automated — no human review queue
|
||||
|
||||
WorldMonitor runs every classifier fully automated. Shortage tiers are no exception. Quality comes from **tiered evidence thresholds + an LLM second-pass sanity check + auto-decay of stale rows**, not from a human reviewer.
|
||||
|
||||
### Watch tier (automated)
|
||||
|
||||
One credible source:
|
||||
|
||||
- A regulator announcement (EPRA, NMDPRA, OGRA, CAA UK, DGCA), **or**
|
||||
- An airline / airport operational bulletin, **or**
|
||||
- A national wire story with ≥ 1 corroborating observation
|
||||
|
||||
### Confirmed tier (automated, stricter bar)
|
||||
|
||||
The `watch → confirmed` promotion requires:
|
||||
|
||||
- **Two distinct outlets + one regulator**, or
|
||||
- **Three distinct outlets**, or
|
||||
- **One regulator + a direct operational-impact signal** (flight cancellation in an airline feed, formal rationing announcement, station-closure list)
|
||||
|
||||
AND — the LLM second-pass must agree with the promotion. If the LLM disagrees, the row stays at `watch` until the threshold is exceeded with additional sources.
|
||||
|
||||
### Auto-decay
|
||||
|
||||
- `confirmed` without new corroborating signal in 7 days → auto-demotes to `watch`
|
||||
- `watch` without new signal in 14 days → auto-removed
|
||||
|
||||
Stale shortages never persist silently. Every demotion writes to the public revision log.
|
||||
|
||||
## Evidence transparency
|
||||
|
||||
Every public shortage row exposes its `evidenceSources[]` inline — you can read the sources the classifier used. This is what makes WorldMonitor's shortage registry defensible without an editorial queue.
|
||||
|
||||
For any `confirmed` row, the panel surfaces:
|
||||
|
||||
- Outlet / regulator names
|
||||
- Source dates
|
||||
- Classifier version + confidence
|
||||
|
||||
Agents (MCP clients) receive the same structured evidence via `listActiveFuelShortages` (landing in Release 2).
|
||||
|
||||
## Root-cause attribution
|
||||
|
||||
Every shortage row carries a cause chain, not just a severity:
|
||||
|
||||
- `chokepoint` — physical supply constraint traced to a chokepoint status change
|
||||
- `pipeline_disruption` — specific pipeline in the registry is offline
|
||||
- `sanction` — new sanctions authority list triggered an import cut
|
||||
- `upstream_refinery` — refinery turnaround or outage
|
||||
- `logistics` — port / rail / truck bottleneck
|
||||
- `policy` — deliberate government restriction
|
||||
|
||||
## Break-glass overrides
|
||||
|
||||
A `energy_asset_overrides` table exists for the rare case where a reader emails a demonstrably wrong classification. Overrides are admin-only writes and are NOT on the critical path — the default flow is fully automated. Every override writes to the revision log.
|
||||
|
||||
## Known limits
|
||||
|
||||
- Non-English regulator feeds surface with some lag; we're adding them incrementally.
|
||||
- "Watch" tier in a politically-noisy country can churn — that's intentional; readers can filter to `confirmed` only if they want the stricter view.
|
||||
- Heating-oil shortages are seasonal and under-reported in wire coverage; winter months have higher signal.
|
||||
|
||||
## Corrections
|
||||
|
||||
Public revision log at [`/corrections`](/corrections). Every tier change ships
|
||||
with its trigger (`classifier` / `source` / `decay` / `override`) and the
|
||||
sources used. Corrections land on the next classifier pass (max 6 hours).
|
||||
52
docs/methodology/storage.mdx
Normal file
52
docs/methodology/storage.mdx
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: "Storage Facility Methodology"
|
||||
description: "How World Monitor curates the world's critical oil & gas storage facilities: underground gas storage, LNG terminals, SPR caverns, and crude tank farms."
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Release 1 launches with ~125 curated critical storage facilities, not a global census. Curation bias: facilities that move markets, not every tank farm on earth.
|
||||
|
||||
- **Underground gas storage (UGS):** top European sites (Rehden, Bergermeer, Haidach, Chiren, etc.), US salt caverns.
|
||||
- **Strategic Petroleum Reserve caverns:** US SPR (Bryan Mound, Big Hill, West Hackberry, Bayou Choctaw), plus major non-US stockholding facilities where published.
|
||||
- **LNG import & export terminals:** Sabine Pass, Corpus Christi, Ras Laffan, Yamal LNG, Asian regas hubs.
|
||||
- **Crude oil tank farms:** major trading hubs (Cushing OK, Rotterdam, Singapore, Fujairah) where operator data is public.
|
||||
|
||||
## Data sources
|
||||
|
||||
- **[Global Energy Monitor](https://globalenergymonitor.org) — Storage Tracker** (CC-BY 4.0) for facility identity, geometry, capacity.
|
||||
- **GIE AGSI+ & ALSI** for EU gas / LNG fill % at facility granularity (where published).
|
||||
- **EIA Form EIA-191** for US UGS.
|
||||
- **EIA SPR weekly stock report** for US SPR-level inventory.
|
||||
- **Operator dashboards** where the facility publishes real-time or near-real-time fill %.
|
||||
|
||||
## Fill % — published, not synthesized
|
||||
|
||||
If an operator publishes fill %, we show it. If they don't, we show `not disclosed`. **We never synthesize, interpolate, or country-share a missing fill.**
|
||||
|
||||
When fill data is unavailable at the facility level, the panel falls back to country-level aggregate (e.g. "DE gas storage: 62% overall; Rehden facility fill not disclosed") — both numbers labeled with their source.
|
||||
|
||||
## Freshness SLA
|
||||
|
||||
| Object | Target freshness | Stale policy |
|
||||
|--------|------------------|--------------|
|
||||
| Facility registry (identity, capacity, operator) | 35 days | Silent refresh |
|
||||
| Fill % where published | 24 hours | Fall back to country aggregate if stale |
|
||||
| Country aggregate (fallback) | 24 hours (gas) / weekly (oil SPR) | Show `stale` indicator if beyond SLA |
|
||||
|
||||
## Known limits
|
||||
|
||||
- Not every facility publishes fill %. We indicate "not disclosed" explicitly.
|
||||
- Capacity figures are typically nameplate, not current maximum working capacity. Working capacity tends to be 75–85% of nameplate for gas UGS.
|
||||
- No forecast of storage-days-to-empty here; that computation lands on the country energy drill-down once the disruption log ships in Week 3.
|
||||
|
||||
## Attribution
|
||||
|
||||
Storage-facility registry data derived from [Global Energy Monitor](https://globalenergymonitor.org) (CC-BY 4.0), GIE AGSI+/ALSI (registered API usage), and US EIA open data.
|
||||
|
||||
## Corrections
|
||||
|
||||
Public revision log at [`/corrections`](/corrections). If a facility you
|
||||
operate is misrepresented, we treat corrections directly from the operator as
|
||||
top-tier evidence — open a GitHub issue or email the address listed in the
|
||||
atlas footer.
|
||||
@@ -1357,7 +1357,7 @@ export class App {
|
||||
}
|
||||
|
||||
// WTO trade policy data — annual data, poll every 10 min to avoid hammering upstream
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity') {
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity' || SITE_VARIANT === 'energy') {
|
||||
this.refreshScheduler.scheduleRefresh('tradePolicy', () => this.dataLoader.loadTradePolicy(), REFRESH_INTERVALS.tradePolicy, () => this.isPanelNearViewport('trade-policy'));
|
||||
this.refreshScheduler.scheduleRefresh('supplyChain', () => this.dataLoader.loadSupplyChain(), REFRESH_INTERVALS.supplyChain, () => this.isPanelNearViewport('supply-chain'));
|
||||
}
|
||||
|
||||
@@ -471,8 +471,8 @@ export class DataLoaderManager implements AppModule {
|
||||
tasks.push({ name: 'oil', task: runGuarded('oil', () => this.loadOilAnalytics()) });
|
||||
}
|
||||
|
||||
// Trade policy data (FULL and FINANCE only)
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity') {
|
||||
// Trade policy + supply-chain data (FULL, FINANCE, COMMODITY, ENERGY variants use supply-chain surface)
|
||||
if (SITE_VARIANT === 'full' || SITE_VARIANT === 'finance' || SITE_VARIANT === 'commodity' || SITE_VARIANT === 'energy') {
|
||||
if (shouldLoad('trade-policy')) {
|
||||
tasks.push({ name: 'tradePolicy', task: runGuarded('tradePolicy', () => this.loadTradePolicy()) });
|
||||
}
|
||||
@@ -1621,7 +1621,9 @@ export class DataLoaderManager implements AppModule {
|
||||
frameworkAppend: getActiveFrameworkForPanel('daily-market-brief')?.systemPromptAppend,
|
||||
newsCategories: SITE_VARIANT === 'commodity'
|
||||
? ['commodity-news', 'gold-silver', 'mining-news', 'energy', 'critical-minerals']
|
||||
: undefined,
|
||||
: SITE_VARIANT === 'energy'
|
||||
? ['live-news', 'energy', 'supply-chain']
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (this.dailyBriefGeneration !== gen) return;
|
||||
|
||||
@@ -65,6 +65,7 @@ import {
|
||||
DisasterCorrelationPanel,
|
||||
DefensePatentsPanel,
|
||||
HormuzPanel,
|
||||
ChokepointStripPanel,
|
||||
MacroTilesPanel,
|
||||
FSIPanel,
|
||||
YieldCurvePanel,
|
||||
@@ -463,6 +464,15 @@ export class PanelLayoutManager implements AppModule {
|
||||
<span class="variant-label">${t('header.commodity')}</span>
|
||||
</a>
|
||||
<span class="variant-divider"></span>
|
||||
<a href="${vHref('energy', 'https://energy.worldmonitor.app')}"
|
||||
class="variant-option ${SITE_VARIANT === 'energy' ? 'active' : ''}"
|
||||
data-variant="energy"
|
||||
${vTarget('energy')}
|
||||
title="${t('header.energy')}${SITE_VARIANT === 'energy' ? ` ${t('common.currentVariant')}` : ''}">
|
||||
<span class="variant-icon">⚡</span>
|
||||
<span class="variant-label">${t('header.energy')}</span>
|
||||
</a>
|
||||
<span class="variant-divider"></span>
|
||||
<a href="${vHref('happy', 'https://happy.worldmonitor.app')}"
|
||||
class="variant-option ${SITE_VARIANT === 'happy' ? 'active' : ''}"
|
||||
data-variant="happy"
|
||||
@@ -528,6 +538,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
{ key: 'tech', icon: '💻', label: t('header.tech') },
|
||||
{ key: 'finance', icon: '📈', label: t('header.finance') },
|
||||
{ key: 'commodity', icon: '⛏️', label: t('header.commodity') },
|
||||
{ key: 'energy', icon: '⚡', label: t('header.energy') },
|
||||
{ key: 'happy', icon: '☀️', label: 'Good News' },
|
||||
];
|
||||
return variants.map(v =>
|
||||
@@ -871,6 +882,7 @@ export class PanelLayoutManager implements AppModule {
|
||||
this.createPanel('energy-complex', () => new EnergyComplexPanel());
|
||||
this.createPanel('oil-inventories', () => new OilInventoriesPanel());
|
||||
this.createPanel('energy-crisis', () => new EnergyCrisisPanel());
|
||||
this.createPanel('chokepoint-strip', () => new ChokepointStripPanel());
|
||||
this.createPanel('polymarket', () => new PredictionPanel());
|
||||
|
||||
this.createNewsPanel('gov', 'panels.gov');
|
||||
|
||||
149
src/components/ChokepointStripPanel.ts
Normal file
149
src/components/ChokepointStripPanel.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Panel } from './Panel';
|
||||
import { escapeHtml } from '@/utils/sanitize';
|
||||
import { getHydratedData } from '@/services/bootstrap';
|
||||
import { fetchChokepointStatus } from '@/services/supply-chain';
|
||||
import { attributionFooterHtml, ATTRIBUTION_FOOTER_CSS } from '@/utils/attribution-footer';
|
||||
import type { GetChokepointStatusResponse, ChokepointInfo } from '@/generated/client/worldmonitor/supply_chain/v1/service_client';
|
||||
|
||||
// Ordering for the atlas strip: highest-volume chokepoints first.
|
||||
// Matches scripts/seed-chokepoint-baselines.mjs ordering.
|
||||
const STRIP_ORDER = [
|
||||
'hormuz_strait',
|
||||
'malacca_strait',
|
||||
'suez',
|
||||
'bab_el_mandeb',
|
||||
'bosphorus',
|
||||
'dover_strait',
|
||||
'panama',
|
||||
];
|
||||
|
||||
const SHORT_NAME: Record<string, string> = {
|
||||
hormuz_strait: 'Hormuz',
|
||||
malacca_strait: 'Malacca',
|
||||
suez: 'Suez',
|
||||
bab_el_mandeb: 'Bab el-Mandeb',
|
||||
bosphorus: 'Turkish Straits',
|
||||
dover_strait: 'Danish Straits',
|
||||
panama: 'Panama',
|
||||
};
|
||||
|
||||
function statusColor(status: string): string {
|
||||
const s = (status || '').toLowerCase();
|
||||
if (s.includes('closed') || s.includes('critical')) return '#e74c3c';
|
||||
if (s.includes('disrupted') || s.includes('high')) return '#e67e22';
|
||||
if (s.includes('restricted') || s.includes('elevated') || s.includes('medium')) return '#f39c12';
|
||||
return '#2ecc71';
|
||||
}
|
||||
|
||||
function formatFlow(cp: ChokepointInfo): string {
|
||||
const est = cp.flowEstimate;
|
||||
if (!est || typeof est.currentMbd !== 'number' || typeof est.baselineMbd !== 'number') return '—';
|
||||
const pct = est.baselineMbd > 0 ? Math.round((est.currentMbd / est.baselineMbd) * 100) : null;
|
||||
if (pct == null) return `${est.currentMbd.toFixed(1)} mb/d`;
|
||||
return `${pct}% of baseline`;
|
||||
}
|
||||
|
||||
export class ChokepointStripPanel extends Panel {
|
||||
private data: GetChokepointStatusResponse | null = null;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
id: 'chokepoint-strip',
|
||||
title: 'Chokepoint Status',
|
||||
infoTooltip:
|
||||
'Live status for the seven global oil & gas shipping chokepoints. ' +
|
||||
'Flow estimates calibrated from Portwatch DWT + AIS observations. ' +
|
||||
'See /docs/methodology/chokepoints for methodology.',
|
||||
});
|
||||
}
|
||||
|
||||
public async fetchData(): Promise<void> {
|
||||
try {
|
||||
const hydrated = getHydratedData('chokepoints') as GetChokepointStatusResponse | undefined;
|
||||
if (hydrated?.chokepoints?.length) {
|
||||
this.data = hydrated;
|
||||
this.render();
|
||||
void fetchChokepointStatus().then(fresh => {
|
||||
if (!this.element?.isConnected || !fresh?.chokepoints?.length) return;
|
||||
this.data = fresh;
|
||||
this.render();
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const fresh = await fetchChokepointStatus();
|
||||
if (!this.element?.isConnected) return;
|
||||
this.data = fresh;
|
||||
this.render();
|
||||
} catch (err) {
|
||||
if (this.isAbortError(err)) return;
|
||||
if (!this.element?.isConnected) return;
|
||||
this.showError('Chokepoint status unavailable', () => void this.fetchData());
|
||||
}
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
if (!this.data?.chokepoints?.length) {
|
||||
this.showError('No chokepoint data yet', () => void this.fetchData());
|
||||
return;
|
||||
}
|
||||
|
||||
const byId = new Map(this.data.chokepoints.map(cp => [cp.id, cp]));
|
||||
const ordered = STRIP_ORDER
|
||||
.map(id => byId.get(id))
|
||||
.filter((cp): cp is ChokepointInfo => !!cp);
|
||||
|
||||
const chips = ordered.map(cp => {
|
||||
const color = statusColor(cp.status);
|
||||
const short = SHORT_NAME[cp.id] || cp.name;
|
||||
const flow = formatFlow(cp);
|
||||
const warnings = cp.activeWarnings > 0
|
||||
? `<span class="cp-chip-warn">${cp.activeWarnings}</span>`
|
||||
: '';
|
||||
return `
|
||||
<div class="cp-chip" data-cp="${escapeHtml(cp.id)}" title="${escapeHtml(cp.name)} — ${escapeHtml(cp.status || 'unknown')}">
|
||||
<div class="cp-chip-dot" style="background:${color}"></div>
|
||||
<div class="cp-chip-body">
|
||||
<div class="cp-chip-name">${escapeHtml(short)}${warnings}</div>
|
||||
<div class="cp-chip-flow">${escapeHtml(flow)}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const nAis = ordered.reduce((sum, cp) => sum + (cp.aisDisruptions ?? 0), 0);
|
||||
const footer = attributionFooterHtml({
|
||||
sourceType: 'ais',
|
||||
method: 'Portwatch DWT + AIS calibration',
|
||||
sampleSize: nAis || undefined,
|
||||
sampleLabel: 'AIS disruption signals',
|
||||
updatedAt: this.data.fetchedAt,
|
||||
creditName: 'EIA World Oil Transit Chokepoints',
|
||||
});
|
||||
|
||||
this.setContent(`
|
||||
<div class="cp-strip-wrap">
|
||||
<div class="cp-strip">${chips}</div>
|
||||
${footer}
|
||||
</div>
|
||||
${ATTRIBUTION_FOOTER_CSS}
|
||||
<style>
|
||||
.cp-strip-wrap { padding: 4px 0; }
|
||||
.cp-strip { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.cp-chip {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 8px;
|
||||
min-width: 120px;
|
||||
font-size: 11px;
|
||||
cursor: default;
|
||||
}
|
||||
.cp-chip-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 8px; }
|
||||
.cp-chip-body { display: flex; flex-direction: column; line-height: 1.2; }
|
||||
.cp-chip-name { font-weight: 600; color: var(--text, #eee); display: flex; align-items: center; gap: 4px; }
|
||||
.cp-chip-warn { background:#e74c3c;color:#fff;border-radius:9px;padding:0 5px;font-size:9px;font-weight:700; }
|
||||
.cp-chip-flow { color: var(--text-dim, #888); font-size: 10px; }
|
||||
</style>
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -396,12 +396,15 @@ export class InsightsPanel extends Panel {
|
||||
// Pass focal point context + theater posture to AI for correlation-aware summarization
|
||||
// Tech variant: no geopolitical context, just tech news summarization
|
||||
// Commodity variant: commodities-specific framing for gold/metals/energy markets
|
||||
// Energy variant: energy-specific framing — pipelines, chokepoints, shortages, disruptions
|
||||
const theaterContext = SITE_VARIANT === 'full' ? this.getTheaterPostureContext() : '';
|
||||
let geoContext = SITE_VARIANT === 'full'
|
||||
? (focalSummary.aiContext || signalSummary.aiContext) + theaterContext
|
||||
: SITE_VARIANT === 'commodity'
|
||||
? 'You are generating a commodities market brief. Focus on gold and precious metals price movements, mining supply risks, energy market dynamics, and macro factors driving commodity prices. Highlight supply disruptions, geopolitical risks to mining regions, central bank gold activity, and USD/inflation trends.'
|
||||
: '';
|
||||
: SITE_VARIANT === 'energy'
|
||||
? 'You are generating a global energy-intelligence brief. Focus on physical supply: oil & gas pipeline status and disruptions (Druzhba, Nord Stream, TurkStream, Power of Siberia, CPC), chokepoint flow (Hormuz, Malacca, Suez, Bab el-Mandeb, Turkish Straits, Danish Straits, Panama), storage levels (EU gas, US SPR, IEA stocks, days-of-cover), fuel shortages (jet / petrol / diesel / heating oil), refinery outages, LNG flows, OPEC+ production signals, and sanctions impacts. Prefer physical constraints and evidence-grounded status changes over price commentary. Attribute every flow figure to its source (AIS calibration, operator disclosure, regulator data) — never ship a bare conclusion.'
|
||||
: '';
|
||||
const insightsFw = getActiveFrameworkForPanel('insights');
|
||||
if (insightsFw) {
|
||||
geoContext = `${geoContext}\n\n---\nAnalytical Framework:\n${insightsFw.systemPromptAppend}`;
|
||||
@@ -551,9 +554,14 @@ export class InsightsPanel extends Panel {
|
||||
}
|
||||
|
||||
private renderWorldBrief(brief: string): string {
|
||||
const heading =
|
||||
SITE_VARIANT === 'tech' ? '🚀 TECH BRIEF'
|
||||
: SITE_VARIANT === 'commodity' ? '⛏️ COMMODITY BRIEF'
|
||||
: SITE_VARIANT === 'energy' ? '⚡ ENERGY BRIEF'
|
||||
: '🌍 WORLD BRIEF';
|
||||
return `
|
||||
<div class="insights-brief">
|
||||
<div class="insights-section-title">${SITE_VARIANT === 'tech' ? '🚀 TECH BRIEF' : SITE_VARIANT === 'commodity' ? '⛏️ COMMODITY BRIEF' : '🌍 WORLD BRIEF'}</div>
|
||||
<div class="insights-section-title">${heading}</div>
|
||||
<div class="insights-brief-text">${escapeHtml(brief)}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -406,7 +406,27 @@ export class MapComponent {
|
||||
const happyLayers: (keyof MapLayers)[] = [
|
||||
'positiveEvents', 'kindness', 'happiness', 'speciesRecovery', 'renewableInstallations',
|
||||
];
|
||||
const layers = SITE_VARIANT === 'tech' ? techLayers : SITE_VARIANT === 'finance' ? financeLayers : SITE_VARIANT === 'happy' ? happyLayers : fullLayers;
|
||||
// Energy variant — SVG/mobile fallback. Only include keys that actually render
|
||||
// in this file (commodityPorts/climate/tradeRoutes/resilienceScore/dayNight do
|
||||
// not, so they're omitted). Mirrors VARIANT_LAYER_ORDER.energy in
|
||||
// src/config/map-layer-definitions.ts but filtered to the SVG-capable subset.
|
||||
const energyLayers: (keyof MapLayers)[] = [
|
||||
'pipelines', // oil + gas pipeline registry (Week 2)
|
||||
'waterways', // strategic chokepoints
|
||||
'ais', // tanker positions at chokepoints
|
||||
'commodityHubs', // energy exchanges / hubs
|
||||
'minerals', // critical-minerals + energy-transition overlap
|
||||
'sanctions', // energy sanctions flows
|
||||
'outages', // power / energy system status
|
||||
'natural', // earthquakes near energy infrastructure
|
||||
'weather', 'fires', // operational risk
|
||||
'economic', // infrastructure context
|
||||
];
|
||||
const layers = SITE_VARIANT === 'tech' ? techLayers
|
||||
: SITE_VARIANT === 'finance' ? financeLayers
|
||||
: SITE_VARIANT === 'happy' ? happyLayers
|
||||
: SITE_VARIANT === 'energy' ? energyLayers
|
||||
: fullLayers;
|
||||
const layerLabelKeys: Partial<Record<keyof MapLayers, string>> = {
|
||||
hotspots: 'components.deckgl.layers.intelHotspots',
|
||||
conflicts: 'components.deckgl.layers.conflictZones',
|
||||
|
||||
@@ -87,6 +87,7 @@ export * from './LiquidityShiftsPanel';
|
||||
export * from './PositioningPanel';
|
||||
export * from './GoldIntelligencePanel';
|
||||
export { HormuzPanel } from './HormuzPanel';
|
||||
export { ChokepointStripPanel } from './ChokepointStripPanel';
|
||||
export * from './ClimateNewsPanel';
|
||||
export * from './DiseaseOutbreaksPanel';
|
||||
export * from './SocialVelocityPanel';
|
||||
|
||||
@@ -890,6 +890,39 @@ const COMMODITY_FEEDS: Record<string, Feed[]> = {
|
||||
],
|
||||
};
|
||||
|
||||
// Energy variant feeds — energy.worldmonitor.app
|
||||
// Keys are matched against panel IDs in src/config/panels.ts ENERGY_PANELS +
|
||||
// brief news-category overrides in src/app/data-loader.ts. Keep in sync when
|
||||
// ENERGY_PANELS changes.
|
||||
const ENERGY_FEEDS: Record<string, Feed[]> = {
|
||||
'live-news': [
|
||||
{ name: 'OilPrice.com', url: rss('https://oilprice.com/rss/main') },
|
||||
{ name: 'Rigzone', url: rss('https://www.rigzone.com/news/rss/rigzone_latest.aspx') },
|
||||
{ name: 'Reuters Energy', url: rss('https://news.google.com/rss/search?q=site:reuters.com+(oil+OR+gas+OR+energy+OR+OPEC+OR+pipeline+OR+LNG)+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Bloomberg Energy', url: rss('https://news.google.com/rss/search?q=site:bloomberg.com+(oil+OR+gas+OR+energy+OR+pipeline+OR+LNG)+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'FT Energy', url: rss('https://news.google.com/rss/search?q=site:ft.com+(oil+OR+gas+OR+energy+OR+LNG+OR+OPEC)+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'IEA News', url: rss('https://news.google.com/rss/search?q=site:iea.org+(oil+OR+gas+OR+energy)+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'S&P Global Platts', url: rss('https://news.google.com/rss/search?q=site:spglobal.com+(oil+OR+gas+OR+LNG+OR+pipeline)+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
],
|
||||
energy: [
|
||||
{ name: 'OilPrice.com', url: rss('https://oilprice.com/rss/main') },
|
||||
{ name: 'Rigzone', url: rss('https://www.rigzone.com/news/rss/rigzone_latest.aspx') },
|
||||
{ name: 'EIA Press Room', url: rss('https://www.eia.gov/rss/press_room.xml') },
|
||||
{ name: 'OPEC & Crude', url: rss('https://news.google.com/rss/search?q=(OPEC+OR+"oil+price"+OR+"crude+oil"+OR+WTI+OR+Brent+OR+"oil+production"+OR+"oil+inventory")+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Natural Gas & LNG', url: rss('https://news.google.com/rss/search?q=("natural+gas"+OR+LNG+OR+"gas+price"+OR+"Henry+Hub"+OR+TTF+OR+JKM+OR+"LNG+cargo")+when:1d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Pipelines & Chokepoints', url: rss('https://news.google.com/rss/search?q=(pipeline+OR+Druzhba+OR+"Nord+Stream"+OR+TurkStream+OR+"Strait+of+Hormuz"+OR+"Bab+el-Mandeb"+OR+"Suez+Canal"+OR+"Power+of+Siberia")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Energy Crisis & Shortages', url: rss('https://news.google.com/rss/search?q=("fuel+shortage"+OR+"gas+shortage"+OR+"diesel+shortage"+OR+"jet+fuel+shortage"+OR+"energy+crisis"+OR+rationing+OR+"petrol+shortage")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Refinery & Disruptions', url: rss('https://news.google.com/rss/search?q=(refinery+OR+"refinery+outage"+OR+"force+majeure"+OR+"pipeline+sabotage"+OR+"pipeline+attack")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Energy Intel', url: rss('https://news.google.com/rss/search?q=(energy+commodities+OR+"energy+market"+OR+"energy+prices"+OR+"energy+security")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
],
|
||||
'supply-chain': [
|
||||
{ name: 'Tanker & Shipping', url: rss('https://news.google.com/rss/search?q=(tanker+OR+VLCC+OR+Suezmax+OR+Aframax+OR+"oil+shipping"+OR+"LNG+carrier"+OR+"shadow+fleet")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Strategic Chokepoints', url: rss('https://news.google.com/rss/search?q=("Strait+of+Hormuz"+OR+"Strait+of+Malacca"+OR+"Bab+el-Mandeb"+OR+"Suez+Canal"+OR+"Panama+Canal"+OR+"Turkish+Straits"+OR+"Danish+Straits")+when:2d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Energy Sanctions', url: rss('https://news.google.com/rss/search?q=("oil+sanctions"+OR+"gas+sanctions"+OR+"price+cap"+OR+"energy+embargo"+OR+"LNG+sanctions")+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
{ name: 'Port & Terminal', url: rss('https://news.google.com/rss/search?q=("LNG+terminal"+OR+"crude+terminal"+OR+"oil+port"+OR+"Ras+Laffan"+OR+"Sabine+Pass"+OR+"Rotterdam+oil")+when:3d&hl=en-US&gl=US&ceid=US:en') },
|
||||
],
|
||||
};
|
||||
|
||||
// Variant-aware exports
|
||||
export const FEEDS = SITE_VARIANT === 'tech'
|
||||
? TECH_FEEDS
|
||||
@@ -899,7 +932,9 @@ export const FEEDS = SITE_VARIANT === 'tech'
|
||||
? HAPPY_FEEDS
|
||||
: SITE_VARIANT === 'commodity'
|
||||
? COMMODITY_FEEDS
|
||||
: FULL_FEEDS;
|
||||
: SITE_VARIANT === 'energy'
|
||||
? ENERGY_FEEDS
|
||||
: FULL_FEEDS;
|
||||
|
||||
export const SOURCE_REGION_MAP: Record<string, { labelKey: string; feedKeys: string[] }> = {
|
||||
// Full (geopolitical) variant regions
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { MapLayers } from '@/types';
|
||||
import { isDesktopRuntime } from '@/services/runtime';
|
||||
|
||||
export type MapRenderer = 'flat' | 'globe';
|
||||
export type MapVariant = 'full' | 'tech' | 'finance' | 'happy' | 'commodity';
|
||||
export type MapVariant = 'full' | 'tech' | 'finance' | 'happy' | 'commodity' | 'energy';
|
||||
|
||||
const _desktop = isDesktopRuntime();
|
||||
|
||||
@@ -117,6 +117,14 @@ const VARIANT_LAYER_ORDER: Record<MapVariant, Array<keyof MapLayers>> = {
|
||||
'ais', 'economic', 'fires', 'climate',
|
||||
'resilienceScore', 'natural', 'weather', 'outages', 'sanctions', 'dayNight',
|
||||
],
|
||||
energy: [
|
||||
// Core energy infrastructure — mirror of ENERGY_MAP_LAYERS in panels.ts
|
||||
'pipelines', 'waterways', 'commodityPorts', 'commodityHubs',
|
||||
'ais', 'tradeRoutes', 'minerals',
|
||||
// Energy-adjacent context
|
||||
'sanctions', 'fires', 'climate', 'weather', 'outages', 'natural',
|
||||
'resilienceScore', 'dayNight',
|
||||
],
|
||||
};
|
||||
|
||||
const I18N_PREFIX = 'components.deckgl.layers.';
|
||||
|
||||
@@ -900,6 +900,156 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
diseaseOutbreaks: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// ENERGY variant — energy.worldmonitor.app
|
||||
// Pipelines, storage, chokepoints, fuel shortages, disruption timeline.
|
||||
// See docs/internal/global-energy-flow-parity-and-surpass.md (not committed).
|
||||
// ============================================
|
||||
const ENERGY_PANELS: Record<string, PanelConfig> = {
|
||||
map: { name: 'Energy Atlas Map', enabled: true, priority: 1 },
|
||||
'chokepoint-strip': { name: 'Chokepoint Status', 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
|
||||
'energy-complex': { name: 'Oil & Gas Complex', enabled: true, priority: 1 },
|
||||
'oil-inventories': { name: 'Oil & Gas Inventories', enabled: true, priority: 1 },
|
||||
'hormuz-tracker': { name: 'Strait of Hormuz Tracker', enabled: true, priority: 1 },
|
||||
'energy-crisis': { name: 'Energy Crisis Policy Tracker', enabled: true, priority: 1 },
|
||||
'fuel-prices': { name: 'Retail Fuel Prices', enabled: true, priority: 1 },
|
||||
renewable: { name: 'Renewable Energy', enabled: true, priority: 2 },
|
||||
// Markets relevant to energy
|
||||
commodities: { name: 'Energy Commodities (WTI, Brent, NatGas)', enabled: true, priority: 1 },
|
||||
energy: { name: 'Energy Markets News', enabled: true, priority: 1 },
|
||||
'macro-signals': { name: 'Market Regime', enabled: true, priority: 2 },
|
||||
// Supply-chain & chokepoint context
|
||||
'supply-chain': { name: 'Chokepoints & Routes', enabled: true, priority: 1 },
|
||||
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 2 },
|
||||
// Gulf / OPEC
|
||||
'gulf-economies': { name: 'Gulf & OPEC Economies', enabled: true, priority: 2 },
|
||||
'gcc-investments': { name: 'GCC Energy Investments', enabled: true, priority: 2 },
|
||||
// Climate — demand driver (HDD / CDD future use)
|
||||
climate: { name: 'Climate & Weather Impact', enabled: true, priority: 2 },
|
||||
// Tracking
|
||||
monitors: { name: 'My Monitors', enabled: true, priority: 3 },
|
||||
'world-clock': { name: 'World Clock', enabled: true, priority: 3 },
|
||||
'latest-brief': { name: 'Latest Brief', enabled: true, priority: 1, premium: 'locked' as const },
|
||||
};
|
||||
|
||||
const ENERGY_MAP_LAYERS: MapLayers = {
|
||||
gpsJamming: false,
|
||||
satellites: false,
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
cables: false,
|
||||
pipelines: true, // First-class energy asset (Week 2 registry lands here)
|
||||
hotspots: false,
|
||||
ais: true, // Tanker positions at chokepoints
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
sanctions: true, // Energy sanctions flows
|
||||
weather: true,
|
||||
economic: false,
|
||||
waterways: true, // Strategic chokepoints (Hormuz, Suez, Bab el-Mandeb, etc.)
|
||||
outages: true, // Power / energy system status
|
||||
cyberThreats: false,
|
||||
datacenters: false,
|
||||
protests: false,
|
||||
flights: false,
|
||||
military: false,
|
||||
natural: true, // Earthquakes near energy infrastructure
|
||||
spaceports: false,
|
||||
minerals: true, // Critical-minerals + energy-transition overlap
|
||||
fires: true, // Fires near energy infrastructure / oilfields
|
||||
// Data source layers
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
climate: true,
|
||||
// Tech layers (disabled)
|
||||
startupHubs: false,
|
||||
cloudRegions: false,
|
||||
accelerators: false,
|
||||
techHQs: false,
|
||||
techEvents: false,
|
||||
// Finance layers (energy hubs = commodity hubs for our purposes)
|
||||
stockExchanges: false,
|
||||
financialCenters: false,
|
||||
centralBanks: false,
|
||||
commodityHubs: true,
|
||||
gulfInvestments: false,
|
||||
// Happy variant layers (disabled)
|
||||
positiveEvents: false,
|
||||
kindness: false,
|
||||
happiness: false,
|
||||
speciesRecovery: false,
|
||||
renewableInstallations: false,
|
||||
tradeRoutes: true,
|
||||
iranAttacks: false,
|
||||
ciiChoropleth: false,
|
||||
resilienceScore: false,
|
||||
dayNight: false,
|
||||
// Commodity layers — selected (energy-relevant subset)
|
||||
miningSites: false,
|
||||
processingPlants: false,
|
||||
commodityPorts: true, // LNG import/export + crude terminals
|
||||
webcams: false,
|
||||
diseaseOutbreaks: false,
|
||||
};
|
||||
|
||||
const ENERGY_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
gpsJamming: false,
|
||||
satellites: false,
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
cables: false,
|
||||
pipelines: true,
|
||||
hotspots: false,
|
||||
ais: false,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
sanctions: false,
|
||||
weather: false,
|
||||
economic: false,
|
||||
waterways: true,
|
||||
outages: false,
|
||||
cyberThreats: false,
|
||||
datacenters: false,
|
||||
protests: false,
|
||||
flights: false,
|
||||
military: false,
|
||||
natural: true,
|
||||
spaceports: false,
|
||||
minerals: false,
|
||||
fires: false,
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
climate: false,
|
||||
startupHubs: false,
|
||||
cloudRegions: false,
|
||||
accelerators: false,
|
||||
techHQs: false,
|
||||
techEvents: false,
|
||||
stockExchanges: false,
|
||||
financialCenters: false,
|
||||
centralBanks: false,
|
||||
commodityHubs: false,
|
||||
gulfInvestments: false,
|
||||
positiveEvents: false,
|
||||
kindness: false,
|
||||
happiness: false,
|
||||
speciesRecovery: false,
|
||||
renewableInstallations: false,
|
||||
tradeRoutes: false,
|
||||
iranAttacks: false,
|
||||
ciiChoropleth: false,
|
||||
resilienceScore: false,
|
||||
dayNight: false,
|
||||
miningSites: false,
|
||||
processingPlants: false,
|
||||
commodityPorts: true,
|
||||
webcams: false,
|
||||
diseaseOutbreaks: false,
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// UNIFIED PANEL REGISTRY
|
||||
// ============================================
|
||||
@@ -908,6 +1058,7 @@ const COMMODITY_MOBILE_MAP_LAYERS: MapLayers = {
|
||||
export const ALL_PANELS: Record<string, PanelConfig> = {
|
||||
...HAPPY_PANELS,
|
||||
...COMMODITY_PANELS,
|
||||
...ENERGY_PANELS,
|
||||
...TECH_PANELS,
|
||||
...FINANCE_PANELS,
|
||||
...FULL_PANELS,
|
||||
@@ -919,6 +1070,7 @@ export const VARIANT_DEFAULTS: Record<string, string[]> = {
|
||||
tech: Object.keys(TECH_PANELS),
|
||||
finance: Object.keys(FINANCE_PANELS),
|
||||
commodity: Object.keys(COMMODITY_PANELS),
|
||||
energy: Object.keys(ENERGY_PANELS),
|
||||
happy: Object.keys(HAPPY_PANELS),
|
||||
};
|
||||
|
||||
@@ -942,6 +1094,11 @@ export const VARIANT_PANEL_OVERRIDES: Partial<Record<string, Partial<Record<stri
|
||||
'live-news': { name: 'Commodity Headlines' },
|
||||
insights: { name: 'AI Commodity Insights' },
|
||||
},
|
||||
energy: {
|
||||
map: { name: 'Energy Atlas Map' },
|
||||
'live-news': { name: 'Energy Headlines' },
|
||||
insights: { name: 'AI Energy Insights' },
|
||||
},
|
||||
happy: {
|
||||
map: { name: 'World Map' },
|
||||
},
|
||||
@@ -988,25 +1145,29 @@ export const DEFAULT_PANELS: Record<string, PanelConfig> = Object.fromEntries(
|
||||
)
|
||||
);
|
||||
|
||||
export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy'
|
||||
? HAPPY_MAP_LAYERS
|
||||
: SITE_VARIANT === 'tech'
|
||||
? TECH_MAP_LAYERS
|
||||
: SITE_VARIANT === 'finance'
|
||||
? FINANCE_MAP_LAYERS
|
||||
export const DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy'
|
||||
? HAPPY_MAP_LAYERS
|
||||
: SITE_VARIANT === 'tech'
|
||||
? TECH_MAP_LAYERS
|
||||
: SITE_VARIANT === 'finance'
|
||||
? FINANCE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'commodity'
|
||||
? COMMODITY_MAP_LAYERS
|
||||
: FULL_MAP_LAYERS;
|
||||
: SITE_VARIANT === 'energy'
|
||||
? ENERGY_MAP_LAYERS
|
||||
: FULL_MAP_LAYERS;
|
||||
|
||||
export const MOBILE_DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy'
|
||||
? HAPPY_MOBILE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'tech'
|
||||
? TECH_MOBILE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'finance'
|
||||
? FINANCE_MOBILE_MAP_LAYERS
|
||||
export const MOBILE_DEFAULT_MAP_LAYERS = SITE_VARIANT === 'happy'
|
||||
? HAPPY_MOBILE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'tech'
|
||||
? TECH_MOBILE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'finance'
|
||||
? FINANCE_MOBILE_MAP_LAYERS
|
||||
: SITE_VARIANT === 'commodity'
|
||||
? COMMODITY_MOBILE_MAP_LAYERS
|
||||
: FULL_MOBILE_MAP_LAYERS;
|
||||
: SITE_VARIANT === 'energy'
|
||||
? ENERGY_MOBILE_MAP_LAYERS
|
||||
: FULL_MOBILE_MAP_LAYERS;
|
||||
|
||||
/** Maps map-layer toggle keys to their data-freshness source IDs (single source of truth). */
|
||||
export const LAYER_TO_SOURCE: Partial<Record<keyof MapLayers, DataSourceId[]>> = {
|
||||
|
||||
@@ -127,4 +127,27 @@ export const VARIANT_META: { full: VariantMeta; [k: string]: VariantMeta } = {
|
||||
'Futures market data',
|
||||
],
|
||||
},
|
||||
energy: {
|
||||
title: 'Energy Atlas - Real-Time Global Energy Intelligence Dashboard',
|
||||
description: 'Real-time global energy atlas tracking oil and gas pipelines, storage facilities, chokepoints, fuel shortages, tanker flows, and disruption events worldwide.',
|
||||
keywords: 'energy dashboard, oil pipeline tracker, gas pipeline map, LNG terminals, gas storage map, oil storage, SPR tracker, Strait of Hormuz, chokepoint monitor, fuel shortage tracker, pipeline disruption, energy crisis, OPEC, tanker tracking, energy infrastructure, natural gas storage, crude oil inventories, IEA oil stocks, days of cover, energy sanctions, petroleum, diesel, jet fuel, heating oil',
|
||||
url: 'https://energy.worldmonitor.app/',
|
||||
siteName: 'Energy Atlas',
|
||||
shortName: 'EnergyAtlas',
|
||||
subject: 'Global Energy Infrastructure, Supply, and Disruption Intelligence',
|
||||
classification: 'Energy Dashboard, Pipeline Tracker, Supply Disruption Monitor',
|
||||
categories: ['news', 'business'],
|
||||
features: [
|
||||
'Oil & gas pipeline registry with live status',
|
||||
'Storage facility map (UGS, LNG, SPR, tank farms)',
|
||||
'Chokepoint flow monitoring (Hormuz, Suez, Malacca, Bab el-Mandeb)',
|
||||
'Fuel shortage alerts (jet, petrol, diesel, heating oil)',
|
||||
'Pipeline & storage disruption timeline',
|
||||
'Oil & gas inventories (EU gas, US SPR, IEA stocks)',
|
||||
'Retail fuel prices by country',
|
||||
'Energy crisis policy tracker',
|
||||
'Evidence-based status badges with public revision log',
|
||||
'Country energy exposure drill-down',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ export const SITE_VARIANT: string = (() => {
|
||||
const isTauri = '__TAURI_INTERNALS__' in window || '__TAURI__' in window;
|
||||
if (isTauri) {
|
||||
const stored = localStorage.getItem('worldmonitor-variant');
|
||||
if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity') return stored;
|
||||
if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity' || stored === 'energy') return stored;
|
||||
return buildVariant;
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ export const SITE_VARIANT: string = (() => {
|
||||
if (h.startsWith('finance.')) return 'finance';
|
||||
if (h.startsWith('happy.')) return 'happy';
|
||||
if (h.startsWith('commodity.')) return 'commodity';
|
||||
if (h.startsWith('energy.')) return 'energy';
|
||||
|
||||
if (h === 'localhost' || h === '127.0.0.1') {
|
||||
const stored = localStorage.getItem('worldmonitor-variant');
|
||||
if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity') return stored;
|
||||
if (stored === 'tech' || stored === 'full' || stored === 'finance' || stored === 'happy' || stored === 'commodity' || stored === 'energy') return stored;
|
||||
return buildVariant;
|
||||
}
|
||||
|
||||
|
||||
176
src/config/variants/energy.ts
Normal file
176
src/config/variants/energy.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Energy variant - energy.worldmonitor.app
|
||||
// NOTE: This file is a structured canonical description for reference. The runtime
|
||||
// wiring lives in src/config/panels.ts (ENERGY_PANELS, ENERGY_MAP_LAYERS,
|
||||
// ENERGY_MOBILE_MAP_LAYERS) — modify both if the variant shape changes. Parallel
|
||||
// to commodity.ts / finance.ts / tech.ts / happy.ts / full.ts orphans.
|
||||
// See docs/internal/global-energy-flow-parity-and-surpass.md for the full plan.
|
||||
import type { PanelConfig, MapLayers } from '@/types';
|
||||
import type { VariantConfig } from './base';
|
||||
|
||||
// Re-export base config
|
||||
export * from './base';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PANEL CONFIGURATION — Energy-focused panels
|
||||
// IDs match existing panel registrations; new panels (PipelineStatusPanel,
|
||||
// StorageFacilityMapPanel, FuelShortagePanel) land on this variant in Week 2–3.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const DEFAULT_PANELS: Record<string, PanelConfig> = {
|
||||
// Core
|
||||
map: { name: 'Energy & Infrastructure Map', enabled: true, priority: 1 },
|
||||
'live-news': { name: 'Energy Headlines', enabled: true, priority: 1 },
|
||||
// Energy complex — existing panels we reuse in Week 1
|
||||
'energy-complex': { name: 'Oil & Gas Complex', enabled: true, priority: 1 },
|
||||
'oil-inventories': { name: 'Oil & Gas Inventories', enabled: true, priority: 1 },
|
||||
'fuel-prices': { name: 'Retail Fuel Prices', enabled: true, priority: 1 },
|
||||
hormuz: { name: 'Strait of Hormuz Tracker', enabled: true, priority: 1 },
|
||||
'energy-crisis': { name: 'Energy Crisis Policy Tracker', enabled: true, priority: 1 },
|
||||
'renewable-energy': { name: 'Renewable Energy', enabled: true, priority: 2 },
|
||||
// Markets relevant to energy
|
||||
commodities: { name: 'Energy Commodities (WTI, Brent, NatGas)', enabled: true, priority: 1 },
|
||||
'macro-signals': { name: 'Market Radar', enabled: true, priority: 2 },
|
||||
// Supply-chain & sanctions context
|
||||
'supply-chain': { name: 'Chokepoints & Routes', enabled: true, priority: 1 },
|
||||
'sanctions-pressure': { name: 'Sanctions Pressure', enabled: true, priority: 2 },
|
||||
// Gulf / OPEC context
|
||||
'gulf-economies': { name: 'Gulf & OPEC Economies', enabled: true, priority: 2 },
|
||||
// Climate — demand driver for heating/cooling
|
||||
climate: { name: 'Climate & Weather Impact', enabled: true, priority: 2 },
|
||||
// Tracking
|
||||
monitors: { name: 'My Monitors', enabled: true, priority: 3 },
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MAP LAYERS — Energy-focused
|
||||
// Only energy-relevant layers enabled; all others explicitly false.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const DEFAULT_MAP_LAYERS: MapLayers = {
|
||||
// ── Core energy map layers (ENABLED) ──────────────────────────────────────
|
||||
pipelines: true, // Oil & gas pipelines (first-class object from Week 2)
|
||||
waterways: true, // Strategic shipping chokepoints (Hormuz, Suez, etc.)
|
||||
tradeRoutes: true, // Tanker trade routes
|
||||
ais: true, // Tanker positions at chokepoints
|
||||
commodityPorts: true, // LNG / crude import & export ports
|
||||
minerals: true, // Critical minerals (battery / energy transition overlap)
|
||||
miningSites: false,
|
||||
processingPlants: false,
|
||||
commodityHubs: true, // Energy exchanges (ICE, NYMEX, TTF, JKM hubs)
|
||||
sanctions: true, // Sanctions directly impact energy trade
|
||||
economic: false,
|
||||
fires: true, // Fires near energy infrastructure / forestry
|
||||
climate: true, // Weather / heating & cooling demand
|
||||
outages: true, // Power outages — energy system status
|
||||
natural: true, // Earthquakes — infrastructure risk
|
||||
weather: true, // Weather impacting operations
|
||||
|
||||
// ── Non-energy layers (DISABLED) ──────────────────────────────────────────
|
||||
gpsJamming: false,
|
||||
satellites: false,
|
||||
iranAttacks: false,
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
hotspots: false,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
military: false,
|
||||
spaceports: false,
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
protests: false,
|
||||
flights: false,
|
||||
cables: false,
|
||||
datacenters: false,
|
||||
// Tech variant layers
|
||||
startupHubs: false,
|
||||
cloudRegions: false,
|
||||
accelerators: false,
|
||||
techHQs: false,
|
||||
techEvents: false,
|
||||
// Finance variant layers
|
||||
stockExchanges: false,
|
||||
financialCenters: false,
|
||||
centralBanks: false,
|
||||
gulfInvestments: false,
|
||||
// Happy variant layers
|
||||
positiveEvents: false,
|
||||
kindness: false,
|
||||
happiness: false,
|
||||
speciesRecovery: false,
|
||||
renewableInstallations: false,
|
||||
// Overlay
|
||||
dayNight: false,
|
||||
cyberThreats: false,
|
||||
ciiChoropleth: false,
|
||||
resilienceScore: false,
|
||||
webcams: false,
|
||||
diseaseOutbreaks: false,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// MOBILE MAP LAYERS — Minimal set for energy mobile view
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
export const MOBILE_DEFAULT_MAP_LAYERS: MapLayers = {
|
||||
// Core energy layers (limited for mobile perf)
|
||||
pipelines: true,
|
||||
waterways: true,
|
||||
tradeRoutes: false,
|
||||
ais: false,
|
||||
commodityPorts: true,
|
||||
minerals: false,
|
||||
miningSites: false,
|
||||
processingPlants: false,
|
||||
commodityHubs: false,
|
||||
sanctions: false,
|
||||
economic: false,
|
||||
fires: false,
|
||||
climate: false,
|
||||
outages: false,
|
||||
natural: true,
|
||||
weather: false,
|
||||
|
||||
// All others disabled on mobile
|
||||
gpsJamming: false,
|
||||
satellites: false,
|
||||
iranAttacks: false,
|
||||
conflicts: false,
|
||||
bases: false,
|
||||
hotspots: false,
|
||||
nuclear: false,
|
||||
irradiators: false,
|
||||
military: false,
|
||||
spaceports: false,
|
||||
ucdpEvents: false,
|
||||
displacement: false,
|
||||
protests: false,
|
||||
flights: false,
|
||||
cables: false,
|
||||
datacenters: false,
|
||||
startupHubs: false,
|
||||
cloudRegions: false,
|
||||
accelerators: false,
|
||||
techHQs: false,
|
||||
techEvents: false,
|
||||
stockExchanges: false,
|
||||
financialCenters: false,
|
||||
centralBanks: false,
|
||||
gulfInvestments: false,
|
||||
positiveEvents: false,
|
||||
kindness: false,
|
||||
happiness: false,
|
||||
speciesRecovery: false,
|
||||
renewableInstallations: false,
|
||||
dayNight: false,
|
||||
cyberThreats: false,
|
||||
ciiChoropleth: false,
|
||||
resilienceScore: false,
|
||||
webcams: false,
|
||||
diseaseOutbreaks: false,
|
||||
};
|
||||
|
||||
export const VARIANT_CONFIG: VariantConfig = {
|
||||
name: 'energy',
|
||||
description: 'Global energy intelligence — pipelines, storage, chokepoints, shortages, disruption timeline',
|
||||
panels: DEFAULT_PANELS,
|
||||
mapLayers: DEFAULT_MAP_LAYERS,
|
||||
mobileMapLayers: MOBILE_DEFAULT_MAP_LAYERS,
|
||||
};
|
||||
@@ -215,6 +215,7 @@
|
||||
"sourcesEnabled": "{{enabled}}/{{total}} enabled",
|
||||
"finance": "FINANCE",
|
||||
"commodity": "COMMODITY",
|
||||
"energy": "ENERGY",
|
||||
"toggleTheme": "Toggle dark/light mode",
|
||||
"panelDisplayCaption": "Choose which panels to show on the dashboard",
|
||||
"tabGeneral": "General",
|
||||
|
||||
133
src/utils/attribution-footer.ts
Normal file
133
src/utils/attribution-footer.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { escapeHtml } from './sanitize';
|
||||
|
||||
/**
|
||||
* Attribution footer for energy-atlas panels.
|
||||
*
|
||||
* Design: every public number on the Energy Atlas must expose its grounds —
|
||||
* source type, method, calibration sample size, freshness. This is the
|
||||
* "quantitative rigour" moat called out in §5.6 of the parity-and-surpass
|
||||
* plan. Reusable so future pipeline / storage / shortage panels hit the
|
||||
* same bar automatically.
|
||||
*
|
||||
* Intentionally a string builder (not a Panel helper) so it composes with
|
||||
* the existing panel render pattern (template-literal HTML). Data-
|
||||
* attributes are agent-readable so MCP tools can surface the same provenance.
|
||||
*/
|
||||
|
||||
export type AttributionSourceType =
|
||||
| 'operator' // operator disclosure / dashboard
|
||||
| 'regulator' // govt regulator / EIA / IEA / JODI
|
||||
| 'ais' // AIS-relay / Portwatch DWT calibration
|
||||
| 'satellite' // Sentinel / Landsat / commercial EO
|
||||
| 'press' // wire / outlet coverage
|
||||
| 'classifier' // internal LLM/heuristic classifier
|
||||
| 'derived'; // computed from other sources
|
||||
|
||||
export interface AttributionFooterInput {
|
||||
/** Primary source type. */
|
||||
sourceType: AttributionSourceType;
|
||||
/** Free-text method summary — short, e.g. "AIS-DWT calibrated" or "GIE AGSI+ daily". */
|
||||
method?: string;
|
||||
/** Observation sample size (vessels, stations, rows, etc.). */
|
||||
sampleSize?: number;
|
||||
sampleLabel?: string;
|
||||
/** ISO8601 timestamp of last data refresh (or a Date). */
|
||||
updatedAt?: string | Date | number | null;
|
||||
/** Confidence band 0..1 (shown as "high/medium/low" to end user). */
|
||||
confidence?: number;
|
||||
/** Optional URL / credit for the data source (e.g. OWID, GEM). */
|
||||
creditName?: string;
|
||||
creditUrl?: string;
|
||||
/** Optional classifier version string for evidence-derived badges. */
|
||||
classifierVersion?: string;
|
||||
}
|
||||
|
||||
function formatWhen(raw: AttributionFooterInput['updatedAt']): string | null {
|
||||
if (raw == null) return null;
|
||||
try {
|
||||
const d = raw instanceof Date ? raw : new Date(raw);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const deltaMs = Date.now() - d.getTime();
|
||||
const mins = Math.round(deltaMs / 60_000);
|
||||
if (mins < 1) return 'just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.round(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.round(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function confidenceLabel(c: number | undefined): string | null {
|
||||
if (c == null) return null;
|
||||
if (c >= 0.8) return 'high';
|
||||
if (c >= 0.5) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<AttributionSourceType, string> = {
|
||||
operator: 'operator disclosure',
|
||||
regulator: 'regulator data',
|
||||
ais: 'AIS calibration',
|
||||
satellite: 'satellite imagery',
|
||||
press: 'press / wire',
|
||||
classifier: 'evidence classifier',
|
||||
derived: 'derived metric',
|
||||
};
|
||||
|
||||
export function attributionFooterHtml(input: AttributionFooterInput): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const sourceLabel = SOURCE_LABEL[input.sourceType];
|
||||
parts.push(escapeHtml(sourceLabel));
|
||||
|
||||
if (input.method) parts.push(escapeHtml(input.method));
|
||||
|
||||
if (typeof input.sampleSize === 'number' && Number.isFinite(input.sampleSize)) {
|
||||
const label = input.sampleLabel || 'obs';
|
||||
parts.push(`${input.sampleSize.toLocaleString()} ${escapeHtml(label)}`);
|
||||
}
|
||||
|
||||
const when = formatWhen(input.updatedAt);
|
||||
if (when) parts.push(`updated ${when}`);
|
||||
|
||||
const conf = confidenceLabel(input.confidence);
|
||||
if (conf) parts.push(`${conf} confidence`);
|
||||
|
||||
if (input.classifierVersion) parts.push(`classifier ${escapeHtml(input.classifierVersion)}`);
|
||||
|
||||
const creditHtml = input.creditName
|
||||
? (input.creditUrl
|
||||
? ` · <a href="${escapeHtml(input.creditUrl)}" target="_blank" rel="noopener" class="attr-credit">${escapeHtml(input.creditName)}</a>`
|
||||
: ` · <span class="attr-credit">${escapeHtml(input.creditName)}</span>`)
|
||||
: '';
|
||||
|
||||
const dataAttrs = [
|
||||
`data-attr-source="${escapeHtml(input.sourceType)}"`,
|
||||
input.method ? `data-attr-method="${escapeHtml(input.method)}"` : '',
|
||||
typeof input.sampleSize === 'number' ? `data-attr-n="${input.sampleSize}"` : '',
|
||||
input.confidence != null ? `data-attr-confidence="${input.confidence.toFixed(2)}"` : '',
|
||||
input.classifierVersion ? `data-attr-classifier="${escapeHtml(input.classifierVersion)}"` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return `<div class="panel-attribution-footer" ${dataAttrs}>${parts.join(' · ')}${creditHtml}</div>`;
|
||||
}
|
||||
|
||||
/** Inline <style> block to accompany the footer. Include once per panel. */
|
||||
export const ATTRIBUTION_FOOTER_CSS = `
|
||||
<style>
|
||||
.panel-attribution-footer {
|
||||
margin-top: 8px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.05);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim, #888);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.panel-attribution-footer .attr-credit { color: var(--text-dim, #888); text-decoration: none; }
|
||||
.panel-attribution-footer .attr-credit:hover { text-decoration: underline; }
|
||||
</style>
|
||||
`;
|
||||
71
tests/attribution-footer.test.mts
Normal file
71
tests/attribution-footer.test.mts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { test, describe } from 'node:test';
|
||||
import { attributionFooterHtml } from '../src/utils/attribution-footer';
|
||||
|
||||
describe('attribution-footer', () => {
|
||||
test('renders minimal footer with only sourceType', () => {
|
||||
const html = attributionFooterHtml({ sourceType: 'ais' });
|
||||
assert.match(html, /panel-attribution-footer/);
|
||||
assert.match(html, /AIS calibration/);
|
||||
assert.match(html, /data-attr-source="ais"/);
|
||||
});
|
||||
|
||||
test('includes method, sample size, and credit when provided', () => {
|
||||
const html = attributionFooterHtml({
|
||||
sourceType: 'operator',
|
||||
method: 'GIE AGSI+ daily',
|
||||
sampleSize: 142,
|
||||
sampleLabel: 'facilities',
|
||||
creditName: 'GIE',
|
||||
creditUrl: 'https://agsi.gie.eu/',
|
||||
});
|
||||
assert.match(html, /GIE AGSI\+ daily/);
|
||||
assert.match(html, /142 facilities/);
|
||||
assert.match(html, /href="https:\/\/agsi\.gie\.eu\/"/);
|
||||
assert.match(html, /data-attr-n="142"/);
|
||||
});
|
||||
|
||||
test('formats "updated X ago" for a recent timestamp', () => {
|
||||
const tenMinAgo = new Date(Date.now() - 10 * 60_000).toISOString();
|
||||
const html = attributionFooterHtml({ sourceType: 'regulator', updatedAt: tenMinAgo });
|
||||
assert.match(html, /updated 10m ago/);
|
||||
});
|
||||
|
||||
test('maps confidence to high/medium/low bands', () => {
|
||||
assert.match(attributionFooterHtml({ sourceType: 'classifier', confidence: 0.95 }), /high confidence/);
|
||||
assert.match(attributionFooterHtml({ sourceType: 'classifier', confidence: 0.6 }), /medium confidence/);
|
||||
assert.match(attributionFooterHtml({ sourceType: 'classifier', confidence: 0.2 }), /low confidence/);
|
||||
assert.match(attributionFooterHtml({ sourceType: 'classifier', confidence: 0.5 }), /data-attr-confidence="0\.50"/);
|
||||
});
|
||||
|
||||
test('exposes agent-readable data-attributes on every public number', () => {
|
||||
const html = attributionFooterHtml({
|
||||
sourceType: 'ais',
|
||||
method: 'AIS-DWT calibrated',
|
||||
sampleSize: 2341,
|
||||
confidence: 0.78,
|
||||
classifierVersion: 'v3',
|
||||
});
|
||||
assert.match(html, /data-attr-source="ais"/);
|
||||
assert.match(html, /data-attr-method="AIS-DWT calibrated"/);
|
||||
assert.match(html, /data-attr-n="2341"/);
|
||||
assert.match(html, /data-attr-confidence="0\.78"/);
|
||||
assert.match(html, /data-attr-classifier="v3"/);
|
||||
});
|
||||
|
||||
test('omits credit section when creditName is absent', () => {
|
||||
const html = attributionFooterHtml({ sourceType: 'derived' });
|
||||
assert.doesNotMatch(html, /attr-credit/);
|
||||
});
|
||||
|
||||
test('escapes HTML in method and credit fields', () => {
|
||||
const html = attributionFooterHtml({
|
||||
sourceType: 'press',
|
||||
method: 'attack<script>alert(1)</script>',
|
||||
creditName: 'Rogue<a>',
|
||||
});
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
assert.doesNotMatch(html, /Rogue<a>/);
|
||||
assert.match(html, /<script>/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user