Merge pull request #87 from adust09/feat/power-plants-layer

feat: add power plants layer (WRI Global Power Plant Database)
This commit is contained in:
Shadowbroker
2026-03-18 09:43:11 -06:00
committed by GitHub
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 {