feat: add power plants layer with WRI Global Power Plant Database

Map ~35,000 power generation facilities from 164 countries using the
WRI Global Power Plant Database (CC BY 4.0). Follows the existing
datacenter layer pattern with clustered icon symbols, amber color
scheme, and click popups showing fuel type, capacity, and operator.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adust09
2026-03-18 16:56:24 +09:00
parent 2812d43f49
commit b40f9d1fd0
12 changed files with 253 additions and 6 deletions
File diff suppressed because one or more lines are too long
+1
View File
@@ -306,6 +306,7 @@ async def live_data_slow(request: Request,
"firms_fires": _f(d.get("firms_fires", [])),
"datacenters": _f(d.get("datacenters", [])),
"military_bases": _f(d.get("military_bases", [])),
"power_plants": _f(d.get("power_plants", [])),
"freshness": dict(source_timestamps),
}
bbox_tag = f"{s},{w},{n},{e}" if has_bbox else "full"
+58
View File
@@ -0,0 +1,58 @@
"""Download WRI Global Power Plant Database CSV and convert to compact JSON.
Usage:
python backend/scripts/convert_power_plants.py
Output:
backend/data/power_plants.json
"""
import csv
import json
import io
import zipfile
import urllib.request
from pathlib import Path
# WRI Global Power Plant Database v1.3.0 (GitHub release)
CSV_URL = "https://raw.githubusercontent.com/wri/global-power-plant-database/master/output_database/global_power_plant_database.csv"
OUT_PATH = Path(__file__).parent.parent / "data" / "power_plants.json"
def main() -> None:
print(f"Downloading WRI Global Power Plant Database from GitHub...")
req = urllib.request.Request(CSV_URL, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
with urllib.request.urlopen(req, timeout=60) as resp:
raw = resp.read().decode("utf-8")
reader = csv.DictReader(io.StringIO(raw))
plants: list[dict] = []
skipped = 0
for row in reader:
try:
lat = float(row["latitude"])
lng = float(row["longitude"])
except (ValueError, KeyError):
skipped += 1
continue
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
skipped += 1
continue
capacity_raw = row.get("capacity_mw", "")
capacity_mw = float(capacity_raw) if capacity_raw else None
plants.append({
"name": row.get("name", "Unknown"),
"country": row.get("country_long", ""),
"fuel_type": row.get("primary_fuel", "Unknown"),
"capacity_mw": capacity_mw,
"owner": row.get("owner", ""),
"lat": round(lat, 5),
"lng": round(lng, 5),
})
OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUT_PATH.write_text(json.dumps(plants, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
print(f"Wrote {len(plants)} power plants to {OUT_PATH} (skipped {skipped})")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -40,7 +40,8 @@ from services.fetchers.earth_observation import ( # noqa: F401
fetch_earthquakes, fetch_firms_fires, fetch_space_weather, fetch_weather,
)
from services.fetchers.infrastructure import ( # noqa: F401
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_cctv, fetch_kiwisdr,
fetch_internet_outages, fetch_datacenters, fetch_military_bases, fetch_power_plants,
fetch_cctv, fetch_kiwisdr,
)
from services.fetchers.geo import ( # noqa: F401
fetch_ships, fetch_airports, find_nearest_airport, cached_airports,
@@ -86,6 +87,7 @@ def update_slow_data():
fetch_gdelt,
fetch_datacenters,
fetch_military_bases,
fetch_power_plants,
]
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
futures = [executor.submit(func) for func in slow_funcs]
+2 -1
View File
@@ -31,7 +31,8 @@ latest_data = {
"internet_outages": [],
"firms_fires": [],
"datacenters": [],
"military_bases": []
"military_bases": [],
"power_plants": []
}
# Per-source freshness timestamps
@@ -180,6 +180,44 @@ def fetch_military_bases():
_mark_fresh("military_bases")
# ---------------------------------------------------------------------------
# Power Plants (WRI Global Power Plant Database)
# ---------------------------------------------------------------------------
_POWER_PLANTS_PATH = Path(__file__).parent.parent.parent / "data" / "power_plants.json"
def fetch_power_plants():
"""Load WRI Global Power Plant Database (~35K facilities)."""
plants = []
try:
if not _POWER_PLANTS_PATH.exists():
logger.warning(f"Power plants file not found: {_POWER_PLANTS_PATH}")
return
raw = json.loads(_POWER_PLANTS_PATH.read_text(encoding="utf-8"))
for entry in raw:
lat = entry.get("lat")
lng = entry.get("lng")
if lat is None or lng is None:
continue
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
continue
plants.append({
"name": entry.get("name", "Unknown"),
"country": entry.get("country", ""),
"fuel_type": entry.get("fuel_type", "Unknown"),
"capacity_mw": entry.get("capacity_mw"),
"owner": entry.get("owner", ""),
"lat": lat, "lng": lng,
})
logger.info(f"Power plants: {len(plants)} facilities loaded")
except Exception as e:
logger.error(f"Error loading power plants: {e}")
with _data_lock:
latest_data["power_plants"] = plants
if plants:
_mark_fresh("power_plants")
# ---------------------------------------------------------------------------
# CCTV Cameras
# ---------------------------------------------------------------------------
+1
View File
@@ -163,6 +163,7 @@ export default function Dashboard() {
internet_outages: false,
datacenters: false,
military_bases: false,
power_plants: false,
});
// NASA GIBS satellite imagery state
+110 -2
View File
@@ -19,7 +19,7 @@ import {
svgTanker, svgRecon, svgPlanePink, svgPlaneAlertRed, svgPlaneDarkBlue,
svgPlaneWhiteAlert, svgHeliPink, svgHeliAlertRed, svgHeliDarkBlue,
svgHeliBlue, svgHeliLime, svgHeliWhiteAlert, svgPlaneBlack, svgHeliBlack,
svgDrone, svgDataCenter, svgRadioTower, svgShipGray, svgShipRed, svgShipYellow,
svgDrone, svgDataCenter, svgPowerPlant, svgRadioTower, svgShipGray, svgShipRed, svgShipYellow,
svgShipBlue, svgShipWhite, svgShipPink, svgCarrier, svgCctv, svgWarning, svgThreat,
svgTriangleYellow, svgTriangleRed,
svgFireYellow, svgFireOrange, svgFireRed, svgFireDarkRed,
@@ -50,7 +50,7 @@ import { useClusterLabels } from "@/components/map/hooks/useClusterLabels";
import { spreadAlertItems } from "@/utils/alertSpread";
import {
buildEarthquakesGeoJSON, buildJammingGeoJSON, buildCctvGeoJSON, buildKiwisdrGeoJSON,
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON, buildMilitaryBasesGeoJSON,
buildFirmsGeoJSON, buildInternetOutagesGeoJSON, buildDataCentersGeoJSON, buildPowerPlantsGeoJSON, buildMilitaryBasesGeoJSON,
buildGdeltGeoJSON, buildLiveuaGeoJSON, buildFrontlineGeoJSON,
buildFlightLayerGeoJSON, buildUavGeoJSON,
buildSatellitesGeoJSON, buildShipsGeoJSON, buildCarriersGeoJSON,
@@ -222,6 +222,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
activeLayers.datacenters ? buildDataCentersGeoJSON(data?.datacenters) : null,
[activeLayers.datacenters, data?.datacenters]);
const powerPlantsGeoJSON = useMemo(() =>
activeLayers.power_plants ? buildPowerPlantsGeoJSON(data?.power_plants) : null,
[activeLayers.power_plants, data?.power_plants]);
const militaryBasesGeoJSON = useMemo(() =>
activeLayers.military_bases ? buildMilitaryBasesGeoJSON(data?.military_bases) : null,
[activeLayers.military_bases, data?.military_bases]);
@@ -353,6 +357,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
loadImg('fire-cluster-xl', svgFireClusterXL);
// Data center icon
loadImg('datacenter', svgDataCenter);
// Power plant icon
loadImg('power-plant', svgPowerPlant);
// Satellite mission-type icons
loadImg('sat-mil', makeSatSvg('#ff3333'));
loadImg('sat-sar', makeSatSvg('#00e5ff'));
@@ -592,6 +598,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
kiwisdrGeoJSON && 'kiwisdr-layer',
internetOutagesGeoJSON && 'internet-outages-layer',
dataCentersGeoJSON && 'datacenters-layer',
powerPlantsGeoJSON && 'power-plants-layer',
militaryBasesGeoJSON && 'military-bases-layer',
firmsGeoJSON && 'firms-viirs-layer'
].filter(Boolean) as string[];
@@ -1468,6 +1475,61 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{/* Power Plant positions */}
{powerPlantsGeoJSON && (
<Source id="power-plants" type="geojson" data={powerPlantsGeoJSON as any} cluster={true} clusterRadius={30} clusterMaxZoom={8}>
{/* Cluster circles */}
<Layer
id="power-plants-clusters"
type="circle"
filter={['has', 'point_count']}
paint={{
'circle-color': '#92400e',
'circle-radius': ['step', ['get', 'point_count'], 12, 10, 16, 50, 20],
'circle-opacity': 0.7,
'circle-stroke-width': 1,
'circle-stroke-color': '#f59e0b',
}}
/>
<Layer
id="power-plants-cluster-count"
type="symbol"
filter={['has', 'point_count']}
layout={{
'text-field': '{point_count_abbreviated}',
'text-font': ['Noto Sans Bold'],
'text-size': 10,
'text-allow-overlap': true,
}}
paint={{
'text-color': '#fde68a',
}}
/>
{/* Individual power plant icons */}
<Layer
id="power-plants-layer"
type="symbol"
filter={['!', ['has', 'point_count']]}
layout={{
'icon-image': 'power-plant',
'icon-size': ['interpolate', ['linear'], ['zoom'], 2, 0.5, 6, 0.7, 10, 1.0],
'icon-allow-overlap': true,
'text-field': ['step', ['zoom'], '', 6, ['get', 'name']],
'text-font': ['Noto Sans Regular'],
'text-size': 9,
'text-offset': [0, 1.2],
'text-anchor': 'top',
'text-allow-overlap': false,
}}
paint={{
'text-color': '#fbbf24',
'text-halo-color': 'rgba(0,0,0,0.9)',
'text-halo-width': 1,
}}
/>
</Source>
)}
{/* Military Base positions */}
{militaryBasesGeoJSON && (
<Source id="military-bases" type="geojson" data={militaryBasesGeoJSON as any}>
@@ -1885,6 +1947,52 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
);
})()}
{/* Power Plant click popup */}
{selectedEntity?.type === 'power_plant' && (() => {
const pp = data?.power_plants?.find((_: any, i: number) => `pp-${i}` === selectedEntity.id);
if (!pp) return null;
return (
<Popup
longitude={pp.lng}
latitude={pp.lat}
closeButton={false}
closeOnClick={false}
onClose={() => onEntityClick?.(null)}
className="threat-popup"
maxWidth="280px"
>
<div className="map-popup bg-[#1a0f00] border border-amber-400/40 text-[#fde68a] min-w-[200px]">
<div className="map-popup-title text-amber-400 border-b border-amber-400/20 pb-1">
{pp.name}
</div>
{pp.fuel_type && (
<div className="map-popup-row">
Fuel: <span className="text-[#fbbf24]">{pp.fuel_type}</span>
</div>
)}
{pp.capacity_mw != null && (
<div className="map-popup-row">
Capacity: <span className="text-white">{pp.capacity_mw.toLocaleString()} MW</span>
</div>
)}
{pp.owner && (
<div className="map-popup-row">
Operator: <span className="text-white">{pp.owner}</span>
</div>
)}
{pp.country && (
<div className="map-popup-row">
Country: <span className="text-white">{pp.country}</span>
</div>
)}
<div className="mt-1.5 text-[9px] text-amber-600 tracking-wider">
POWER PLANT
</div>
</div>
</Popup>
);
})()}
{selectedEntity?.type === 'military_base' && (() => {
const base = data?.military_bases?.find((_: any, i: number) => `milbase-${i}` === selectedEntity.id);
if (!base) return null;
@@ -2,7 +2,7 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight, Palette } from "lucide-react";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, Zap, ToggleLeft, ToggleRight, Palette } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext";
@@ -41,6 +41,7 @@ const FRESHNESS_MAP: Record<string, string> = {
firms: "firms_fires",
internet_outages: "internet_outages",
datacenters: "datacenters",
power_plants: "power_plants",
};
// POTUS fleet ICAO hex codes for client-side filtering
@@ -147,6 +148,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "firms", name: "Fire Hotspots (24h)", source: "NASA FIRMS VIIRS", count: data?.firms_fires?.length || 0, icon: Flame },
{ id: "internet_outages", name: "Internet Outages", source: "IODA / Georgia Tech", count: data?.internet_outages?.length || 0, icon: Wifi },
{ id: "datacenters", name: "Data Centers", source: "DC Map (GitHub)", count: data?.datacenters?.length || 0, icon: Server },
{ id: "power_plants", name: "Power Plants", source: "WRI (Static)", count: data?.power_plants?.length || 0, icon: Zap },
{ id: "military_bases", name: "Military Bases", source: "OSINT (Static)", count: data?.military_bases?.length || 0, icon: Shield },
{ id: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
];
+23 -1
View File
@@ -2,7 +2,7 @@
// Extracted from MaplibreViewer to reduce component size and enable unit testing.
// Each function takes data arrays + optional helpers and returns a GeoJSON FeatureCollection or null.
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, MilitaryBase, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
import type { Earthquake, GPSJammingZone, FireHotspot, InternetOutage, DataCenter, MilitaryBase, PowerPlant, GDELTIncident, LiveUAmapIncident, CCTVCamera, KiwiSDR, FrontlineGeoJSON, UAV, Satellite, Ship, ActiveLayers } from "@/types/dashboard";
import { classifyAircraft } from "@/utils/aircraftClassification";
import { MISSION_COLORS, MISSION_ICON_MAP } from "@/components/map/icons/SatelliteIcons";
@@ -195,6 +195,28 @@ export function buildDataCentersGeoJSON(datacenters?: DataCenter[]): FC {
};
}
// ─── Power Plants ──────────────────────────────────────────────────────────
export function buildPowerPlantsGeoJSON(plants?: PowerPlant[]): FC {
if (!plants?.length) return null;
return {
type: 'FeatureCollection',
features: plants.map((p, i) => ({
type: 'Feature' as const,
properties: {
id: `pp-${i}`,
type: 'power_plant',
name: p.name || 'Unknown',
country: p.country || '',
fuel_type: p.fuel_type || 'Unknown',
capacity_mw: p.capacity_mw ?? 0,
owner: p.owner || '',
},
geometry: { type: 'Point' as const, coordinates: [p.lng, p.lat] }
}))
};
}
// ─── Military Bases ─────────────────────────────────────────────────────────
// Classify base alignment: red = adversary, blue = US/allied, green = ROC
@@ -25,6 +25,7 @@ export const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`
export const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M21 16v-2l-8-5V3.5c0-.83-.67-1.5-1.5-1.5S10 2.67 10 3.5V9l-8 5v2l8-2.5V19l-2 1.5V22l3.5-1 3.5 1v-1.5L13 19v-5.5l8 2.5z" /></svg>`)}`;
export const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
export const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
export const svgPowerPlant = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#f59e0b" stroke-width="1.5"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="#78350f" stroke="#f59e0b" stroke-linejoin="round" stroke-linecap="round"/></svg>`)}`;
export const svgDataCenter = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/><line x1="11" y1="6" x2="17" y2="6" stroke="#a78bfa" stroke-width="1"/><line x1="11" y1="14" x2="17" y2="14" stroke="#a78bfa" stroke-width="1"/><line x1="12" y1="19" x2="12" y2="22" stroke="#a78bfa" stroke-width="1.5"/></svg>`)}`;
export const svgShipGray = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="12" height="24" viewBox="0 0 24 24" fill="none"><path d="M6 20 L6 8 L12 2 L18 8 L18 20 C18 22 6 22 6 20 Z" fill="gray" stroke="#000" stroke-width="1"/><polygon points="12,6 16,16 8,16" fill="#fff" stroke="#000" stroke-width="1"/></svg>`)}`;
export const svgShipRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="32" viewBox="0 0 24 24" fill="none"><path d="M6 22 L6 6 L12 2 L18 6 L18 22 Z" fill="#ff2222" stroke="#000" stroke-width="1"/><rect x="8" y="15" width="8" height="4" fill="#880000" stroke="#000" stroke-width="1"/><rect x="8" y="7" width="8" height="6" fill="#444" stroke="#000" stroke-width="1"/></svg>`)}`;
+12
View File
@@ -232,6 +232,16 @@ export interface DataCenter {
lng: number;
}
export interface PowerPlant {
name: string;
country: string;
fuel_type: string;
capacity_mw: number | null;
owner: string;
lat: number;
lng: number;
}
export interface MilitaryBase {
name: string;
country: string;
@@ -422,6 +432,7 @@ export interface DashboardData {
firms_fires?: FireHotspot[];
datacenters?: DataCenter[];
military_bases?: MilitaryBase[];
power_plants?: PowerPlant[];
}
// ─── COMPONENT PROPS ────────────────────────────────────────────────────────
@@ -451,6 +462,7 @@ export interface ActiveLayers {
internet_outages: boolean;
datacenters: boolean;
military_bases: boolean;
power_plants: boolean;
}
export interface SelectedEntity {