From 195c6b64b94ed61e044d0ce290cf183f89461f07 Mon Sep 17 00:00:00 2001 From: anoracleofra-code Date: Tue, 10 Mar 2026 10:23:38 -0600 Subject: [PATCH] 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 --- README.md | 14 + backend/main.py | 4 +- backend/services/data_fetcher.py | 161 ++++++++--- frontend/package.json | 2 +- frontend/src/app/page.tsx | 1 - frontend/src/components/ChangelogModal.tsx | 53 ++-- frontend/src/components/MaplibreViewer.tsx | 265 ++++++++++++------ .../src/components/WorldviewLeftPanel.tsx | 3 +- 8 files changed, 327 insertions(+), 176 deletions(-) diff --git a/README.md b/README.md index d2ca2ed..477014c 100644 --- a/README.md +++ b/README.md @@ -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 | --- diff --git a/backend/main.py b/backend/main.py index 847c6a3..5df50b1 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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", "") diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 6e15bfb..8d952ed 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -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] diff --git a/frontend/package.json b/frontend/package.json index c5bb0b4..dc0bbba 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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\"", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8bda64a..e08e295 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -147,7 +147,6 @@ export default function Dashboard() { highres_satellite: false, kiwisdr: false, firms: false, - radiation: false, internet_outages: false, }); diff --git a/frontend/src/components/ChangelogModal.tsx b/frontend/src/components/ChangelogModal.tsx index 89c2a21..c29c13c 100644 --- a/frontend/src/components/ChangelogModal.tsx +++ b/frontend/src/components/ChangelogModal.tsx @@ -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: , - 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: , + 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: , - 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: , + 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: , - 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: , + 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: , - 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: , - 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: , - title: "SATELLITE Style Preset", - desc: "STYLE button now cycles: DEFAULT β†’ SATELLITE. SATELLITE auto-enables high-res imagery.", + icon: , + 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() { diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index cdd7762..fd32e67 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -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( + `` + + // Main flame body (wide base, pointed top) + `` + + // Left tongue (forks out left from top) + `` + + // Right tongue (forks out right from top) + `` + + // Inner bright core + `` + + `` + )}`; +} +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 = { 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 = { - '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 )} - {/* NASA FIRMS VIIRS β€” thermal anomalies / wildfires overlay */} - {activeLayers.firms && gibsDate && ( - + {/* NASA FIRMS VIIRS β€” fire hotspot icons from FIRMS CSV feed */} + {firmsGeoJSON && ( + + {/* Cluster fire icons β€” flame shape to differentiate from Global Incidents circles */} + + {/* Individual fire icons β€” flame shape sized by FRP */} @@ -1976,58 +2050,65 @@ 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 */} + {/* Internet Outages β€” region-level grey markers with % and labels */} {internetOutagesGeoJSON && ( + {/* Outer ring */} + + {/* Inner solid circle β€” all grey, size conveys severity */} =', ['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 */} + ', ['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 */} + diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index d328aa9..0d0b696 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -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 }, ];