mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-04-26 04:26:26 +02:00
v0.5.0: FIRMS fire hotspots, space weather, internet outages
New intelligence layers: - NASA FIRMS VIIRS fire hotspots (5K+ global thermal anomalies, flame icons) - NOAA space weather badge (Kp index in status bar) - IODA regional internet outage monitoring (grey markers, BGP/ping only) Key improvements: - Fire clusters use flame-shaped icons (not circles) for clear differentiation - Internet outages are region-level with reliable datasources only - Removed radiation layer (no viable free real-time API) - All outage markers grey to avoid color confusion with other layers - Filtered out merit-nt telescope data that produced misleading percentages Updated changelog modal, README, and package.json for v0.5.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -116,6 +116,12 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
* Red overlay squares with "GPS JAM XX%" severity labels
|
||||
* **Radio Intercept Panel** — Scanner-style UI for monitoring communications
|
||||
|
||||
### 🔥 Environmental & Infrastructure Monitoring
|
||||
|
||||
* **NASA FIRMS Fire Hotspots (24h)** — 5,000+ global thermal anomalies from NOAA-20 VIIRS satellite, updated every cycle. Flame-shaped icons color-coded by fire radiative power (FRP): yellow (low), orange, red, dark red (intense). Clustered at low zoom with fire-shaped cluster markers.
|
||||
* **Space Weather Badge** — Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1–G5). Data from SWPC planetary K-index 1-minute feed.
|
||||
* **Internet Outage Monitoring** — Regional internet connectivity alerts from Georgia Tech IODA. Grey markers at affected regions with severity percentage. Uses only reliable datasources (BGP routing tables, active ping probing) — no telescope or interpolated data.
|
||||
|
||||
### 🌐 Additional Layers
|
||||
|
||||
* **Earthquakes (24h)** — USGS real-time earthquake feed with magnitude-scaled markers
|
||||
@@ -156,6 +162,9 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ DeepState│ RSS │ Region │ GPS │ │ │
|
||||
│ │ │ Frontline│ Intel │ Dossier │ Jamming │ │ │
|
||||
│ │ ├──────────┼──────────┼──────────┼───────────┤ │ │
|
||||
│ │ │ NASA │ NOAA │ IODA │ KiwiSDR │ │ │
|
||||
│ │ │ FIRMS │ Space Wx│ Outages │ Radios │ │ │
|
||||
│ │ └──────────┴──────────┴──────────┴───────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
@@ -186,6 +195,9 @@ Do not append a trailing `.` to that command; Compose treats it as a service nam
|
||||
| [MS Planetary Computer](https://planetarycomputer.microsoft.com) | Sentinel-2 L2A scenes (right-click) | On-demand | No |
|
||||
| [KiwiSDR](https://kiwisdr.com) | Public SDR receiver locations | ~30min | No |
|
||||
| [OSM Nominatim](https://nominatim.openstreetmap.org) | Place name geocoding (LOCATE bar) | On-demand | No |
|
||||
| [NASA FIRMS](https://firms.modaps.eosdis.nasa.gov) | NOAA-20 VIIRS fire/thermal hotspots | ~120s | No |
|
||||
| [NOAA SWPC](https://services.swpc.noaa.gov) | Space weather Kp index & solar events | ~120s | No |
|
||||
| [IODA (Georgia Tech)](https://ioda.inetintel.cc.gatech.edu) | Regional internet outage alerts | ~120s | No |
|
||||
| [CARTO Basemaps](https://carto.com) | Dark map tiles | Continuous | No |
|
||||
|
||||
---
|
||||
@@ -320,6 +332,8 @@ All layers are independently toggleable from the left panel:
|
||||
| MODIS Terra (Daily) | ❌ OFF | NASA GIBS daily satellite imagery |
|
||||
| High-Res Satellite | ❌ OFF | Esri sub-meter satellite imagery |
|
||||
| KiwiSDR Receivers | ❌ OFF | Public SDR radio receivers |
|
||||
| Fire Hotspots (24h) | ❌ OFF | NASA FIRMS VIIRS thermal anomalies |
|
||||
| Internet Outages | ❌ OFF | IODA regional connectivity alerts |
|
||||
| Day / Night Cycle | ✅ ON | Solar terminator overlay |
|
||||
|
||||
---
|
||||
|
||||
+2
-2
@@ -95,8 +95,8 @@ async def live_data_slow(request: Request):
|
||||
"satellites": d.get("satellites", []),
|
||||
"kiwisdr": d.get("kiwisdr", []),
|
||||
"space_weather": d.get("space_weather"),
|
||||
"radiation": d.get("radiation", []),
|
||||
"internet_outages": d.get("internet_outages", [])
|
||||
"internet_outages": d.get("internet_outages", []),
|
||||
"firms_fires": d.get("firms_fires", [])
|
||||
}
|
||||
# ETag based on last_updated + item counts
|
||||
last_updated = d.get("last_updated", "")
|
||||
|
||||
@@ -103,8 +103,8 @@ latest_data = {
|
||||
"liveuamap": [],
|
||||
"kiwisdr": [],
|
||||
"space_weather": None,
|
||||
"radiation": [],
|
||||
"internet_outages": []
|
||||
"internet_outages": [],
|
||||
"firms_fires": []
|
||||
}
|
||||
|
||||
# Thread lock for safe reads/writes to latest_data
|
||||
@@ -1272,6 +1272,45 @@ def fetch_kiwisdr():
|
||||
logger.error(f"Error fetching KiwiSDR nodes: {e}")
|
||||
latest_data["kiwisdr"] = []
|
||||
|
||||
def fetch_firms_fires():
|
||||
"""Fetch global fire/thermal anomalies from NASA FIRMS (NOAA-20 VIIRS, 24h, no key needed)."""
|
||||
fires = []
|
||||
try:
|
||||
url = "https://firms.modaps.eosdis.nasa.gov/data/active_fire/noaa-20-viirs-c2/csv/J1_VIIRS_C2_Global_24h.csv"
|
||||
response = fetch_with_curl(url, timeout=30)
|
||||
if response.status_code == 200:
|
||||
import csv
|
||||
import io
|
||||
reader = csv.DictReader(io.StringIO(response.text))
|
||||
all_rows = []
|
||||
for row in reader:
|
||||
try:
|
||||
lat = float(row.get("latitude", 0))
|
||||
lng = float(row.get("longitude", 0))
|
||||
frp = float(row.get("frp", 0)) # Fire Radiative Power (MW)
|
||||
conf = row.get("confidence", "nominal")
|
||||
daynight = row.get("daynight", "")
|
||||
bright = float(row.get("bright_ti4", 0))
|
||||
all_rows.append({
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"frp": frp,
|
||||
"brightness": bright,
|
||||
"confidence": conf,
|
||||
"daynight": daynight,
|
||||
"acq_date": row.get("acq_date", ""),
|
||||
"acq_time": row.get("acq_time", ""),
|
||||
})
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# Sort by FRP descending, keep top 5000 (most intense fires first)
|
||||
all_rows.sort(key=lambda x: x["frp"], reverse=True)
|
||||
fires = all_rows[:5000]
|
||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||
latest_data["firms_fires"] = fires
|
||||
|
||||
def fetch_space_weather():
|
||||
"""Fetch NOAA SWPC Kp index and recent solar events."""
|
||||
try:
|
||||
@@ -1313,66 +1352,96 @@ def fetch_space_weather():
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching space weather: {e}")
|
||||
|
||||
def fetch_radiation():
|
||||
"""Fetch global radiation measurements from Safecast (CC0, no key)."""
|
||||
measurements = []
|
||||
# Cache geocoded region coordinates so we only hit Nominatim once per region
|
||||
_region_geocode_cache: dict = {}
|
||||
|
||||
def _geocode_region(region_name: str, country_name: str) -> tuple:
|
||||
"""Geocode a region using OpenStreetMap Nominatim (cached, respects rate limit)."""
|
||||
cache_key = f"{region_name}|{country_name}"
|
||||
if cache_key in _region_geocode_cache:
|
||||
return _region_geocode_cache[cache_key]
|
||||
try:
|
||||
url = "https://api.safecast.org/en-US/measurements.json?distance=10000&latitude=0&longitude=0"
|
||||
response = fetch_with_curl(url, timeout=15)
|
||||
import urllib.parse
|
||||
query = urllib.parse.quote(f"{region_name}, {country_name}")
|
||||
url = f"https://nominatim.openstreetmap.org/search?q={query}&format=json&limit=1"
|
||||
response = fetch_with_curl(url, timeout=8, headers={"User-Agent": "ShadowBroker-OSINT/1.0"})
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
for m in data:
|
||||
lat = m.get("latitude")
|
||||
lng = m.get("longitude")
|
||||
value = m.get("value")
|
||||
if lat is None or lng is None or value is None:
|
||||
continue
|
||||
measurements.append({
|
||||
"lat": lat,
|
||||
"lng": lng,
|
||||
"cpm": value,
|
||||
"captured_at": m.get("captured_at", ""),
|
||||
})
|
||||
measurements = measurements[:500]
|
||||
logger.info(f"Radiation: {len(measurements)} sensors")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching radiation data: {e}")
|
||||
latest_data["radiation"] = measurements
|
||||
results = response.json()
|
||||
if results:
|
||||
lat = float(results[0]["lat"])
|
||||
lon = float(results[0]["lon"])
|
||||
_region_geocode_cache[cache_key] = (lat, lon)
|
||||
return (lat, lon)
|
||||
except Exception:
|
||||
pass
|
||||
_region_geocode_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
def fetch_internet_outages():
|
||||
"""Fetch internet outage alerts from IODA (Georgia Tech)."""
|
||||
"""Fetch regional internet outage alerts from IODA (Georgia Tech).
|
||||
Region-level only — higher fidelity than country-level. If an entire country
|
||||
is down, all its regions will show up individually.
|
||||
|
||||
Only uses reliable datasources (bgp, ping-slash24) that measure actual
|
||||
connectivity. Excludes merit-nt (network telescope with tiny sample sizes
|
||||
that produces wildly misleading percentages for large regions)."""
|
||||
# Datasources that actually measure real internet connectivity
|
||||
RELIABLE_DATASOURCES = {"bgp", "ping-slash24"}
|
||||
outages = []
|
||||
try:
|
||||
now = int(time.time())
|
||||
start = now - 86400
|
||||
url = f"https://api.ioda.inetintel.cc.gatech.edu/v2/outages/alerts?from={start}&until={now}"
|
||||
url = f"https://api.ioda.inetintel.cc.gatech.edu/v2/outages/alerts?from={start}&until={now}&limit=500"
|
||||
response = fetch_with_curl(url, timeout=15)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
alerts = data.get("data", [])
|
||||
# Collect region-level outages (deduplicate by region code, keep worst)
|
||||
region_outages = {}
|
||||
for alert in alerts:
|
||||
entity = alert.get("entity", {})
|
||||
if entity.get("type") != "country":
|
||||
etype = entity.get("type", "")
|
||||
level = alert.get("level", "")
|
||||
if level == "normal" or etype != "region":
|
||||
continue
|
||||
datasource = alert.get("datasource", "")
|
||||
if datasource not in RELIABLE_DATASOURCES:
|
||||
continue # Skip merit-nt and other unreliable sources
|
||||
code = entity.get("code", "")
|
||||
name = entity.get("name", "")
|
||||
level = alert.get("level", "")
|
||||
score = alert.get("condition", alert.get("score", 0))
|
||||
if level == "normal":
|
||||
continue
|
||||
outages.append({
|
||||
"country_code": code,
|
||||
"country_name": name,
|
||||
"level": level,
|
||||
"score": score if isinstance(score, (int, float)) else 0,
|
||||
})
|
||||
seen = {}
|
||||
for o in outages:
|
||||
cc = o["country_code"]
|
||||
if cc not in seen or o["score"] > seen[cc]["score"]:
|
||||
seen[cc] = o
|
||||
outages = list(seen.values())[:100]
|
||||
logger.info(f"Internet outages: {len(outages)} countries affected")
|
||||
attrs = entity.get("attrs", {})
|
||||
country_code = attrs.get("country_code", "")
|
||||
country_name = attrs.get("country_name", "")
|
||||
value = alert.get("value", 0)
|
||||
history_value = alert.get("historyValue", 0)
|
||||
severity = 0
|
||||
if history_value and history_value > 0:
|
||||
severity = round((1 - value / history_value) * 100)
|
||||
severity = max(0, min(severity, 100))
|
||||
if severity < 10:
|
||||
continue # Skip minor fluctuations (<10% is normal jitter)
|
||||
if code not in region_outages or severity > region_outages[code]["severity"]:
|
||||
region_outages[code] = {
|
||||
"region_code": code,
|
||||
"region_name": name,
|
||||
"country_code": country_code,
|
||||
"country_name": country_name,
|
||||
"level": level,
|
||||
"datasource": datasource,
|
||||
"severity": severity,
|
||||
}
|
||||
# Geocode regions and build final list
|
||||
geocoded = []
|
||||
for rcode, r in region_outages.items():
|
||||
coords = _geocode_region(r["region_name"], r["country_name"])
|
||||
if coords:
|
||||
r["lat"] = coords[0]
|
||||
r["lng"] = coords[1]
|
||||
geocoded.append(r)
|
||||
# Sort by severity descending, cap at 100
|
||||
geocoded.sort(key=lambda x: x["severity"], reverse=True)
|
||||
outages = geocoded[:100]
|
||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching internet outages: {e}")
|
||||
latest_data["internet_outages"] = outages
|
||||
@@ -1934,8 +2003,8 @@ def update_slow_data():
|
||||
fetch_geopolitics,
|
||||
fetch_kiwisdr,
|
||||
fetch_space_weather,
|
||||
fetch_radiation,
|
||||
fetch_internet_outages,
|
||||
fetch_firms_fires,
|
||||
]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor:
|
||||
futures = [executor.submit(func) for func in slow_funcs]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
|
||||
|
||||
@@ -147,7 +147,6 @@ export default function Dashboard() {
|
||||
highres_satellite: false,
|
||||
kiwisdr: false,
|
||||
firms: false,
|
||||
radiation: false,
|
||||
internet_outages: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,54 +2,43 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Satellite, Radio, MapPin, Image, Layers, Bug } from "lucide-react";
|
||||
import { X, Flame, Sun, Wifi, Activity, Bug } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.4";
|
||||
const CURRENT_VERSION = "0.5";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Satellite size={14} className="text-cyan-400" />,
|
||||
title: "NASA GIBS Satellite Imagery",
|
||||
desc: "Daily MODIS Terra true-color imagery with 30-day time slider, play/pause animation, and opacity control.",
|
||||
color: "cyan",
|
||||
icon: <Flame size={14} className="text-orange-400" />,
|
||||
title: "NASA FIRMS Fire Hotspots (24h)",
|
||||
desc: "5,000+ global thermal anomalies from NOAA-20 VIIRS satellite. Flame-shaped icons color-coded by fire radiative power — yellow (low), orange, red, dark red (intense). Clusters show fire counts.",
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
icon: <Layers size={14} className="text-green-400" />,
|
||||
title: "High-Res Satellite (Esri)",
|
||||
desc: "Sub-meter resolution imagery — zoom into buildings and terrain. Toggle in Data Layers or cycle to SATELLITE style.",
|
||||
color: "green",
|
||||
icon: <Sun size={14} className="text-yellow-400" />,
|
||||
title: "Space Weather Badge",
|
||||
desc: "Live NOAA geomagnetic storm indicator in the bottom status bar. Color-coded Kp index: green (quiet), yellow (active), red (storm G1-G5). Sourced from SWPC planetary K-index.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Radio size={14} className="text-amber-400" />,
|
||||
title: "KiwiSDR Radio Receivers",
|
||||
desc: "500+ public SDR receivers plotted worldwide. Click any node to open a live radio tuner directly in the SIGINT panel.",
|
||||
color: "amber",
|
||||
icon: <Wifi size={14} className="text-gray-400" />,
|
||||
title: "Internet Outage Monitoring",
|
||||
desc: "Regional internet connectivity alerts from Georgia Tech IODA. Grey markers show affected regions with severity percentage — powered by BGP and active probing data. No false positives.",
|
||||
color: "gray",
|
||||
},
|
||||
{
|
||||
icon: <Image size={14} className="text-blue-400" />,
|
||||
title: "Sentinel-2 Intel Card",
|
||||
desc: "Right-click anywhere — a floating intel card shows the latest Sentinel-2 satellite photo with capture date and cloud cover. Click to open full resolution.",
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
icon: <MapPin size={14} className="text-purple-400" />,
|
||||
title: "LOCATE Bar",
|
||||
desc: "New search bar above coordinates — enter coordinates (31.8, 34.8) or place names (Tehran, Strait of Hormuz) to fly directly there.",
|
||||
color: "purple",
|
||||
},
|
||||
{
|
||||
icon: <Layers size={14} className="text-cyan-400" />,
|
||||
title: "SATELLITE Style Preset",
|
||||
desc: "STYLE button now cycles: DEFAULT → SATELLITE. SATELLITE auto-enables high-res imagery.",
|
||||
icon: <Activity size={14} className="text-cyan-400" />,
|
||||
title: "Enhanced Layer Differentiation",
|
||||
desc: "Fire hotspots use distinct flame icons (not circles) to prevent confusion with Global Incidents. Internet outages use grey markers. Each layer is now instantly recognizable at a glance.",
|
||||
color: "cyan",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"Satellite imagery renders below all data icons — flights, ships, markers always visible on top",
|
||||
"Sentinel-2 click now opens the actual high-res PNG image directly in browser",
|
||||
"Light/dark theme fixed — UI stays dark, only the map basemap switches",
|
||||
"All data sourced from verified OSINT feeds — no fabricated or interpolated data points",
|
||||
"Internet outages filtered to reliable datasources only (BGP, ping) — no misleading telescope data",
|
||||
"Fire clusters use flame-shaped icons instead of circles for clear visual separation",
|
||||
"MapLibre font errors resolved — switched to Noto Sans (universally available)",
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
|
||||
@@ -53,6 +53,32 @@ const TURBOPROP_PATH = "M12 3C11.3 3 10.8 3.5 10.8 4V9L3 12V13.5L10.8 11.5V18.5L
|
||||
// Bizjet: sleek, small swept wings, T-tail
|
||||
const BIZJET_PATH = "M12 1.5C11.4 1.5 11 2 11 2.8V9L5 12.5V14L11 12V18.5L8.5 20V21.5L12 20.5L15.5 21.5V20L13 18.5V12L19 14V12.5L13 9V2.8C13 2 12.6 1.5 12 1.5Z";
|
||||
|
||||
// --- Fire icon SVGs for FIRMS hotspots (multi-tongue flame, unmistakably fire) ---
|
||||
function makeFireSvg(fill: string, innerFill: string, size = 18) {
|
||||
// Multi-forked flame: main body + left tongue + right tongue + inner glow
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 28">` +
|
||||
// Main flame body (wide base, pointed top)
|
||||
`<path d="M12 1C12 1 9 5 8 8C7 11 5.5 13 5.5 16.5C5.5 20.5 8 23.5 12 23.5C16 23.5 18.5 20.5 18.5 16.5C18.5 13 17 11 16 8C15 5 12 1 12 1Z" fill="${fill}" stroke="rgba(0,0,0,0.7)" stroke-width="0.7"/>` +
|
||||
// Left tongue (forks out left from top)
|
||||
`<path d="M10 8C10 8 7.5 4.5 7 2.5C7 2.5 6 5.5 7 9C7.5 10.5 8.5 11.5 9.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Right tongue (forks out right from top)
|
||||
`<path d="M14 8C14 8 16.5 4.5 17 2.5C17 2.5 18 5.5 17 9C16.5 10.5 15.5 11.5 14.5 12" fill="${fill}" stroke="rgba(0,0,0,0.5)" stroke-width="0.4"/>` +
|
||||
// Inner bright core
|
||||
`<path d="M12 8C12 8 10.5 11 10.5 14.5C10.5 17.5 11 19.5 12 20C13 19.5 13.5 17.5 13.5 14.5C13.5 11 12 8 12 8Z" fill="${innerFill}" opacity="0.85"/>` +
|
||||
`</svg>`
|
||||
)}`;
|
||||
}
|
||||
const svgFireYellow = makeFireSvg('#ffcc00', '#fff5aa', 16);
|
||||
const svgFireOrange = makeFireSvg('#ff8800', '#ffcc00', 18);
|
||||
const svgFireRed = makeFireSvg('#ff2200', '#ff8800', 20);
|
||||
const svgFireDarkRed = makeFireSvg('#cc0000', '#ff2200', 22);
|
||||
// Larger fire icons for cluster markers (visually distinct from Global Incidents circles)
|
||||
const svgFireClusterSmall = makeFireSvg('#ff6600', '#ffcc00', 32);
|
||||
const svgFireClusterMed = makeFireSvg('#ff3300', '#ff8800', 40);
|
||||
const svgFireClusterLarge = makeFireSvg('#cc0000', '#ff3300', 48);
|
||||
const svgFireClusterXL = makeFireSvg('#880000', '#cc0000', 56);
|
||||
|
||||
function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', fill: string, stroke = 'black', size = 20) {
|
||||
const paths: Record<string, string> = { airliner: AIRLINER_PATH, turboprop: TURBOPROP_PATH, bizjet: BIZJET_PATH, generic: "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" };
|
||||
const p = paths[type] || paths.generic;
|
||||
@@ -411,48 +437,62 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
};
|
||||
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
|
||||
|
||||
// Radiation monitors — green/red dots based on CPM level
|
||||
const radiationGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.radiation || !data?.radiation?.length) return null;
|
||||
// FIRMS fires — heat-colored dots by FRP (Fire Radiative Power)
|
||||
const firmsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.firms || !data?.firms_fires?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: data.radiation.filter((r: any) => r.lat != null && r.lng != null).map((r: any, i: number) => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { id: i, type: 'radiation', cpm: r.cpm || 0, captured_at: r.captured_at || '' },
|
||||
geometry: { type: 'Point' as const, coordinates: [r.lng, r.lat] }
|
||||
}))
|
||||
features: data.firms_fires.map((f: any, i: number) => {
|
||||
const frp = f.frp || 0;
|
||||
const iconId = frp >= 100 ? 'fire-darkred' : frp >= 20 ? 'fire-red' : frp >= 5 ? 'fire-orange' : 'fire-yellow';
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: {
|
||||
id: i,
|
||||
type: 'firms_fire',
|
||||
name: `Fire ${frp.toFixed(1)} MW`,
|
||||
frp,
|
||||
iconId,
|
||||
brightness: f.brightness || 0,
|
||||
confidence: f.confidence || '',
|
||||
daynight: f.daynight === 'D' ? 'Day' : 'Night',
|
||||
acq_date: f.acq_date || '',
|
||||
acq_time: f.acq_time || '',
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [f.lng, f.lat] }
|
||||
};
|
||||
})
|
||||
};
|
||||
}, [activeLayers.radiation, data?.radiation]);
|
||||
}, [activeLayers.firms, data?.firms_fires]);
|
||||
|
||||
// Internet outages — country centroids
|
||||
const COUNTRY_CENTROIDS: Record<string, [number, number]> = {
|
||||
'AF': [67.7, 33.9], 'AL': [20.2, 41.2], 'DZ': [1.7, 28.0], 'AO': [17.9, -11.2], 'AR': [-63.6, -38.4],
|
||||
'AM': [45.0, 40.1], 'AU': [133.8, -25.3], 'AZ': [47.6, 40.1], 'BD': [90.4, 23.7], 'BY': [27.9, 53.7],
|
||||
'BR': [-51.9, -14.2], 'MM': [96.0, 21.9], 'KH': [105.0, 12.6], 'CM': [12.4, 7.4], 'CA': [-106.3, 56.1],
|
||||
'CF': [20.9, 6.6], 'TD': [18.7, 15.5], 'CL': [-71.5, -35.7], 'CN': [104.2, 35.9], 'CO': [-74.3, 4.6],
|
||||
'CD': [21.8, -4.0], 'CU': [-77.8, 21.5], 'EG': [30.8, 26.8], 'ET': [40.5, 9.1], 'FR': [2.2, 46.2],
|
||||
'DE': [10.5, 51.2], 'GH': [-1.0, 7.9], 'GR': [21.8, 39.1], 'HT': [-72.3, 19.1], 'IN': [78.9, 20.6],
|
||||
'ID': [113.9, -0.8], 'IR': [53.7, 32.4], 'IQ': [43.7, 33.2], 'IL': [34.9, 31.0], 'IT': [12.6, 41.9],
|
||||
'JP': [138.3, 36.2], 'JO': [36.2, 30.6], 'KZ': [67.0, 48.0], 'KE': [37.9, -0.0], 'KP': [127.5, 40.3],
|
||||
'KR': [128.0, 35.9], 'KW': [47.5, 29.3], 'LB': [35.9, 33.9], 'LY': [17.2, 26.3], 'MX': [-102.6, 23.6],
|
||||
'MA': [-7.1, 31.8], 'MZ': [35.5, -18.7], 'NG': [8.7, 9.1], 'PK': [69.3, 30.4], 'PS': [35.2, 31.9],
|
||||
'PH': [122.0, 12.9], 'PL': [19.1, 51.9], 'RU': [105.3, 61.5], 'SA': [45.1, 23.9], 'SD': [30.2, 12.9],
|
||||
'SO': [46.2, 5.2], 'ZA': [22.9, -30.6], 'SS': [31.3, 6.9], 'SY': [38.0, 35.0], 'TW': [121.0, 23.7],
|
||||
'TZ': [34.9, -6.4], 'TH': [100.5, 15.9], 'TR': [35.2, 38.9], 'UA': [31.2, 48.4], 'AE': [53.8, 23.4],
|
||||
'GB': [-3.4, 55.4], 'US': [-98.5, 39.8], 'UZ': [64.6, 41.4], 'VE': [-66.6, 6.4], 'VN': [108.3, 14.1],
|
||||
'YE': [48.5, 15.6], 'ZW': [29.2, -19.0],
|
||||
};
|
||||
// Internet outages — region-level with backend-geocoded coordinates
|
||||
const internetOutagesGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.internet_outages || !data?.internet_outages?.length) return null;
|
||||
return {
|
||||
type: 'FeatureCollection' as const,
|
||||
features: data.internet_outages.map((o: any) => {
|
||||
const coords = COUNTRY_CENTROIDS[o.country_code];
|
||||
if (!coords) return null;
|
||||
const lat = o.lat;
|
||||
const lng = o.lng;
|
||||
if (lat == null || lng == null) return null;
|
||||
const severity = o.severity || 0;
|
||||
const region = o.region_name || o.region_code || '?';
|
||||
const country = o.country_name || o.country_code || '';
|
||||
const label = `${region}, ${country}`;
|
||||
const detail = `${label}\n${severity}% drop · ${o.datasource || 'IODA'}`;
|
||||
return {
|
||||
type: 'Feature' as const,
|
||||
properties: { country: o.country_name, level: o.level, score: o.score || 0 },
|
||||
geometry: { type: 'Point' as const, coordinates: coords }
|
||||
properties: {
|
||||
id: o.region_code || region,
|
||||
type: 'internet_outage',
|
||||
name: label,
|
||||
country,
|
||||
region,
|
||||
level: o.level,
|
||||
severity,
|
||||
datasource: o.datasource || '',
|
||||
detail,
|
||||
},
|
||||
geometry: { type: 'Point' as const, coordinates: [lng, lat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
};
|
||||
@@ -561,6 +601,15 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
loadImg('icon-threat', svgThreat);
|
||||
loadImg('icon-liveua-yellow', svgTriangleYellow);
|
||||
loadImg('icon-liveua-red', svgTriangleRed);
|
||||
// FIRMS fire icons
|
||||
loadImg('fire-yellow', svgFireYellow);
|
||||
loadImg('fire-orange', svgFireOrange);
|
||||
loadImg('fire-red', svgFireRed);
|
||||
loadImg('fire-darkred', svgFireDarkRed);
|
||||
loadImg('fire-cluster-sm', svgFireClusterSmall);
|
||||
loadImg('fire-cluster-md', svgFireClusterMed);
|
||||
loadImg('fire-cluster-lg', svgFireClusterLarge);
|
||||
loadImg('fire-cluster-xl', svgFireClusterXL);
|
||||
|
||||
// Satellite mission-type icons
|
||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||
@@ -1193,8 +1242,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
satellitesGeoJSON && 'satellites-layer',
|
||||
cctvGeoJSON && 'cctv-layer',
|
||||
kiwisdrGeoJSON && 'kiwisdr-layer',
|
||||
radiationGeoJSON && 'radiation-layer',
|
||||
internetOutagesGeoJSON && 'internet-outages-layer'
|
||||
internetOutagesGeoJSON && 'internet-outages-layer',
|
||||
firmsGeoJSON && 'firms-viirs-layer'
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
|
||||
@@ -1298,22 +1347,47 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* NASA FIRMS VIIRS — thermal anomalies / wildfires overlay */}
|
||||
{activeLayers.firms && gibsDate && (
|
||||
<Source
|
||||
key={`firms-${gibsDate}`}
|
||||
id="firms-viirs"
|
||||
type="raster"
|
||||
tiles={[`https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/VIIRS_NOAA20_Thermal_Anomalies_375m_All/default/${gibsDate}/GoogleMapsCompatible_Level9/{z}/{y}/{x}.png`]}
|
||||
tileSize={256}
|
||||
maxzoom={9}
|
||||
>
|
||||
{/* NASA FIRMS VIIRS — fire hotspot icons from FIRMS CSV feed */}
|
||||
{firmsGeoJSON && (
|
||||
<Source id="firms-fires" type="geojson" data={firmsGeoJSON as any} cluster={true} clusterRadius={40} clusterMaxZoom={10}>
|
||||
{/* Cluster fire icons — flame shape to differentiate from Global Incidents circles */}
|
||||
<Layer
|
||||
id="firms-clusters"
|
||||
type="symbol"
|
||||
filter={['has', 'point_count']}
|
||||
layout={{
|
||||
'icon-image': ['step', ['get', 'point_count'],
|
||||
'fire-cluster-sm', 10, 'fire-cluster-md', 50, 'fire-cluster-lg', 200, 'fire-cluster-xl'],
|
||||
'icon-size': ['step', ['get', 'point_count'], 1.0, 10, 1.1, 50, 1.2, 200, 1.3],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
'text-field': '{point_count_abbreviated}',
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-size': ['step', ['get', 'point_count'], 9, 10, 10, 50, 11, 200, 12],
|
||||
'text-offset': [0, 0.15],
|
||||
'text-allow-overlap': true,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1.2,
|
||||
}}
|
||||
/>
|
||||
{/* Individual fire icons — flame shape sized by FRP */}
|
||||
<Layer
|
||||
id="firms-viirs-layer"
|
||||
type="raster"
|
||||
paint={{
|
||||
'raster-opacity': 0.9,
|
||||
'raster-fade-duration': 300
|
||||
type="symbol"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
layout={{
|
||||
'icon-image': ['get', 'iconId'],
|
||||
'icon-size': ['interpolate', ['linear'], ['zoom'],
|
||||
2, 0.4,
|
||||
5, 0.6,
|
||||
8, 0.8,
|
||||
12, 1.0
|
||||
],
|
||||
'icon-allow-overlap': true,
|
||||
'icon-ignore-placement': true,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
@@ -1976,58 +2050,65 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Radiation Monitors — green/red clustered dots */}
|
||||
{radiationGeoJSON && (
|
||||
<Source id="radiation" type="geojson" data={radiationGeoJSON as any} cluster={true} clusterRadius={50} clusterMaxZoom={12}>
|
||||
<Layer
|
||||
id="radiation-clusters"
|
||||
type="circle"
|
||||
filter={['has', 'point_count']}
|
||||
paint={{
|
||||
'circle-color': 'rgba(0, 255, 100, 0.7)',
|
||||
'circle-radius': ['step', ['get', 'point_count'], 10, 5, 14, 10, 18],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': 'rgba(0, 255, 100, 1.0)'
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="radiation-cluster-count"
|
||||
type="symbol"
|
||||
filter={['has', 'point_count']}
|
||||
layout={{ 'text-field': '{point_count_abbreviated}', 'text-size': 10, 'text-allow-overlap': true }}
|
||||
paint={{ 'text-color': '#ffffff', 'text-halo-color': '#000000', 'text-halo-width': 1 }}
|
||||
/>
|
||||
<Layer
|
||||
id="radiation-layer"
|
||||
type="circle"
|
||||
filter={['!', ['has', 'point_count']]}
|
||||
paint={{
|
||||
'circle-radius': 5,
|
||||
'circle-color': ['case', ['>', ['get', 'cpm'], 100], '#ff2222', '#00ff66'],
|
||||
'circle-stroke-width': 1,
|
||||
'circle-stroke-color': ['case', ['>', ['get', 'cpm'], 100], '#ff4444', '#00cc55'],
|
||||
'circle-opacity': 0.8
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
)}
|
||||
|
||||
{/* Internet Outages — country-level markers */}
|
||||
{/* Internet Outages — region-level grey markers with % and labels */}
|
||||
{internetOutagesGeoJSON && (
|
||||
<Source id="internet-outages" type="geojson" data={internetOutagesGeoJSON as any}>
|
||||
{/* Outer ring */}
|
||||
<Layer
|
||||
id="internet-outages-pulse"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': ['interpolate', ['linear'], ['get', 'severity'], 0, 14, 50, 18, 80, 22],
|
||||
'circle-color': 'rgba(180, 180, 180, 0.1)',
|
||||
'circle-stroke-width': 1.5,
|
||||
'circle-stroke-color': 'rgba(180, 180, 180, 0.35)',
|
||||
}}
|
||||
/>
|
||||
{/* Inner solid circle — all grey, size conveys severity */}
|
||||
<Layer
|
||||
id="internet-outages-layer"
|
||||
type="circle"
|
||||
paint={{
|
||||
'circle-radius': 12,
|
||||
'circle-color': ['case',
|
||||
['>=', ['get', 'score'], 80], '#ff0040',
|
||||
['>=', ['get', 'score'], 50], '#ff6600',
|
||||
'#888888'
|
||||
],
|
||||
'circle-radius': ['interpolate', ['linear'], ['get', 'severity'], 0, 6, 50, 9, 80, 12],
|
||||
'circle-color': '#888888',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-opacity': 0.8
|
||||
'circle-stroke-color': 'rgba(0, 0, 0, 0.6)',
|
||||
'circle-opacity': 0.9
|
||||
}}
|
||||
/>
|
||||
{/* Severity % inside circle */}
|
||||
<Layer
|
||||
id="internet-outages-pct"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': ['case', ['>', ['get', 'severity'], 0], ['concat', ['to-string', ['get', 'severity']], '%'], '!'],
|
||||
'text-size': 9,
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-allow-overlap': true,
|
||||
'text-ignore-placement': true,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#ffffff',
|
||||
'text-halo-color': 'rgba(0,0,0,0.8)',
|
||||
'text-halo-width': 1,
|
||||
}}
|
||||
/>
|
||||
{/* Region name label below — grey */}
|
||||
<Layer
|
||||
id="internet-outages-label"
|
||||
type="symbol"
|
||||
layout={{
|
||||
'text-field': ['get', 'region'],
|
||||
'text-size': 10,
|
||||
'text-font': ['Noto Sans Bold'],
|
||||
'text-offset': [0, 1.8],
|
||||
'text-anchor': 'top',
|
||||
'text-allow-overlap': false,
|
||||
}}
|
||||
paint={{
|
||||
'text-color': '#aaaaaa',
|
||||
'text-halo-color': 'rgba(0,0,0,0.9)',
|
||||
'text-halo-width': 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
@@ -58,8 +58,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
{ id: "gibs_imagery", name: "MODIS Terra (Daily)", source: "NASA GIBS", count: null, icon: Globe },
|
||||
{ id: "highres_satellite", name: "High-Res Satellite", source: "Esri World Imagery", count: null, icon: Satellite },
|
||||
{ id: "kiwisdr", name: "KiwiSDR Receivers", source: "KiwiSDR.com", count: data?.kiwisdr?.length || 0, icon: Radio },
|
||||
{ id: "firms", name: "Thermal Anomalies", source: "NASA FIRMS VIIRS", count: null, icon: Flame },
|
||||
{ id: "radiation", name: "Radiation Monitors", source: "Safecast CC0", count: data?.radiation?.length || 0, icon: Activity },
|
||||
{ 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: "day_night", name: "Day / Night Cycle", source: "Solar Calc", count: null, icon: Sun },
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user