From 7cb926e22746bc5381eedb26befefbe5fbaff3d2 Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Tue, 10 Mar 2026 09:01:35 -0600 Subject: [PATCH] feat: add FIRMS thermal, space weather, radiation, and internet outage layers Add 4 new intelligence layers for v0.5: - NASA FIRMS VIIRS thermal anomaly tiles (frontend-only WMTS) - NOAA Space Weather Kp index badge in bottom bar - Safecast radiation monitoring with clustered markers - IODA internet outage alerts at country centroids All use free keyless APIs. All layers default to off. Co-Authored-By: Claude Opus 4.6 --- backend/main.py | 5 +- backend/services/data_fetcher.py | 113 ++++++++++++++- frontend/src/app/page.tsx | 18 +++ frontend/src/components/MaplibreViewer.tsx | 129 +++++++++++++++++- .../src/components/WorldviewLeftPanel.tsx | 5 +- 5 files changed, 266 insertions(+), 4 deletions(-) diff --git a/backend/main.py b/backend/main.py index e830df9..847c6a3 100644 --- a/backend/main.py +++ b/backend/main.py @@ -93,7 +93,10 @@ async def live_data_slow(request: Request): "gdelt": d.get("gdelt", []), "airports": d.get("airports", []), "satellites": d.get("satellites", []), - "kiwisdr": d.get("kiwisdr", []) + "kiwisdr": d.get("kiwisdr", []), + "space_weather": d.get("space_weather"), + "radiation": d.get("radiation", []), + "internet_outages": d.get("internet_outages", []) } # ETag based on last_updated + item counts last_updated = d.get("last_updated", "") diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 1871683..6e15bfb 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -101,7 +101,10 @@ latest_data = { "frontlines": None, "gdelt": [], "liveuamap": [], - "kiwisdr": [] + "kiwisdr": [], + "space_weather": None, + "radiation": [], + "internet_outages": [] } # Thread lock for safe reads/writes to latest_data @@ -1269,6 +1272,111 @@ def fetch_kiwisdr(): logger.error(f"Error fetching KiwiSDR nodes: {e}") latest_data["kiwisdr"] = [] +def fetch_space_weather(): + """Fetch NOAA SWPC Kp index and recent solar events.""" + try: + kp_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/planetary_k_index_1m.json", timeout=10) + kp_value = None + kp_text = "QUIET" + if kp_resp.status_code == 200: + kp_data = kp_resp.json() + if kp_data: + latest_kp = kp_data[-1] + kp_value = float(latest_kp.get("kp_index", 0)) + if kp_value >= 7: + kp_text = f"STORM G{min(int(kp_value) - 4, 5)}" + elif kp_value >= 5: + kp_text = f"STORM G{min(int(kp_value) - 4, 5)}" + elif kp_value >= 4: + kp_text = "ACTIVE" + elif kp_value >= 3: + kp_text = "UNSETTLED" + + events = [] + ev_resp = fetch_with_curl("https://services.swpc.noaa.gov/json/edited_events.json", timeout=10) + if ev_resp.status_code == 200: + all_events = ev_resp.json() + for ev in all_events[-10:]: + events.append({ + "type": ev.get("type", ""), + "begin": ev.get("begin", ""), + "end": ev.get("end", ""), + "classtype": ev.get("classtype", ""), + }) + + latest_data["space_weather"] = { + "kp_index": kp_value, + "kp_text": kp_text, + "events": events, + } + logger.info(f"Space weather: Kp={kp_value} ({kp_text}), {len(events)} events") + 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 = [] + try: + url = "https://api.safecast.org/en-US/measurements.json?distance=10000&latitude=0&longitude=0" + response = fetch_with_curl(url, timeout=15) + 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 + +def fetch_internet_outages(): + """Fetch internet outage alerts from IODA (Georgia Tech).""" + 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}" + response = fetch_with_curl(url, timeout=15) + if response.status_code == 200: + data = response.json() + alerts = data.get("data", []) + for alert in alerts: + entity = alert.get("entity", {}) + if entity.get("type") != "country": + continue + 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") + except Exception as e: + logger.error(f"Error fetching internet outages: {e}") + latest_data["internet_outages"] = outages + def fetch_bikeshare(): bikes = [] try: @@ -1825,6 +1933,9 @@ def update_slow_data(): fetch_earthquakes, fetch_geopolitics, fetch_kiwisdr, + fetch_space_weather, + fetch_radiation, + fetch_internet_outages, ] with concurrent.futures.ThreadPoolExecutor(max_workers=len(slow_funcs)) as executor: futures = [executor.submit(func) for func in slow_funcs] diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 851db6c..8bda64a 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -146,6 +146,9 @@ export default function Dashboard() { gibs_imagery: false, highres_satellite: false, kiwisdr: false, + firms: false, + radiation: false, + internet_outages: false, }); // NASA GIBS satellite imagery state @@ -511,6 +514,21 @@ export default function Dashboard() {
STYLE
{activeStyle}
+ + {/* Divider */} +
+ + {/* Space Weather */} +
+
SOLAR
+
= 5 ? 'text-red-400' : + (data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' : + 'text-green-400' + }`}> + {data?.space_weather?.kp_text || 'N/A'} +
+
diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index c4e7cf1..cdd7762 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -411,6 +411,53 @@ 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; + 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] } + })) + }; + }, [activeLayers.radiation, data?.radiation]); + + // Internet outages — country centroids + const COUNTRY_CENTROIDS: Record = { + '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], + }; + 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; + return { + type: 'Feature' as const, + properties: { country: o.country_name, level: o.level, score: o.score || 0 }, + geometry: { type: 'Point' as const, coordinates: coords } + }; + }).filter(Boolean) + }; + }, [activeLayers.internet_outages, data?.internet_outages]); + // Load Images into the Map Style once loaded const onMapLoad = useCallback((e: any) => { const map = e.target; @@ -1145,7 +1192,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele earthquakesGeoJSON && 'earthquakes-layer', satellitesGeoJSON && 'satellites-layer', cctvGeoJSON && 'cctv-layer', - kiwisdrGeoJSON && 'kiwisdr-layer' + kiwisdrGeoJSON && 'kiwisdr-layer', + radiationGeoJSON && 'radiation-layer', + internetOutagesGeoJSON && 'internet-outages-layer' ].filter(Boolean) as string[]; @@ -1249,6 +1298,27 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele )} + {/* NASA FIRMS VIIRS — thermal anomalies / wildfires overlay */} + {activeLayers.firms && gibsDate && ( + + + + )} + {/* SOLAR TERMINATOR — night overlay */} {activeLayers.day_night && nightGeoJSON && ( @@ -1906,6 +1976,63 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele )} + {/* Radiation Monitors — green/red clustered dots */} + {radiationGeoJSON && ( + + + + ', ['get', 'cpm'], 100], '#ff2222', '#00ff66'], + 'circle-stroke-width': 1, + 'circle-stroke-color': ['case', ['>', ['get', 'cpm'], 100], '#ff4444', '#00cc55'], + 'circle-opacity': 0.8 + }} + /> + + )} + + {/* Internet Outages — country-level markers */} + {internetOutagesGeoJSON && ( + + =', ['get', 'score'], 80], '#ff0040', + ['>=', ['get', 'score'], 50], '#ff6600', + '#888888' + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.8 + }} + /> + + )} + {/* Satellite positions — mission-type icons */} {satellitesGeoJSON && ( diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 50838cf..d328aa9 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } 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 } from "lucide-react"; +import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi } from "lucide-react"; import { useTheme } from "@/lib/ThemeContext"; const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void }) { @@ -58,6 +58,9 @@ 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: "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 }, ];