7 Commits

Author SHA1 Message Date
anoracleofra-code 12857a4b83 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>

Former-commit-id: 195c6b64b9
2026-03-10 10:23:38 -06:00
anoracleofra-code c343084def 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 <noreply@anthropic.com>

Former-commit-id: 7cb926e227
2026-03-10 09:01:35 -06:00
anoracleofra-code c085475110 fix: remove defunct FLIR/NVG/CRT style presets, keep only DEFAULT and SATELLITE
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: c4de39bb02
2026-03-10 04:53:17 -06:00
anoracleofra-code e0257d2419 chore: remove debug/sample files from tracking, update .gitignore
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: e7f3378b5a
2026-03-10 04:31:21 -06:00
anoracleofra-code 5d221c3dc7 fix: install backend Node.js deps (ws) in start scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 41a7811360
2026-03-10 04:25:53 -06:00
anoracleofra-code dd8485d1b6 fix: filter out TWR (tower/platform) ADS-B transponders from flight data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 791ec971d9
2026-03-09 21:41:57 -06:00
anoracleofra-code f6aa5ccbc1 chore: bump frontend version to 0.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: d05bef8de5
2026-03-09 21:02:03 -06:00
21 changed files with 473 additions and 11134 deletions
+6
View File
@@ -68,6 +68,12 @@ TheAirTraffic Database.xlsx
# Debug dumps & release artifacts
backend/dump.json
backend/debug_fast.json
backend/nyc_sample.json
backend/nyc_full.json
backend/liveua_test.html
backend/out_liveua.json
frontend/server_logs*.txt
frontend/cctv.db
*.zip
.git_backup/
+14
View File
@@ -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 G1G5). 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 |
---
-1
View File
@@ -1 +0,0 @@
5c3b1c768973ca54e9a1befee8dc075f38e8cc56
-1
View File
@@ -1 +0,0 @@
2b64633521ffb6f06da36e19f5c8eb86979e2187
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -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"),
"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", "")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+191 -1
View File
@@ -101,7 +101,10 @@ latest_data = {
"frontlines": None,
"gdelt": [],
"liveuamap": [],
"kiwisdr": []
"kiwisdr": [],
"space_weather": None,
"internet_outages": [],
"firms_fires": []
}
# Thread lock for safe reads/writes to latest_data
@@ -759,6 +762,11 @@ def fetch_flights():
speed_knots = round(gs_knots, 1) if isinstance(gs_knots, (int, float)) else None
model_upper = f.get("t", "").upper()
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
if model_upper == "TWR":
continue
ac_category = "heli" if model_upper in _HELI_TYPES_BACKEND else "plane"
flights.append({
@@ -1129,6 +1137,11 @@ def fetch_military_flights():
continue
model = str(f.get("t", "UNKNOWN")).upper()
# Skip fixed structures (towers, oil platforms) that broadcast ADS-B
if model == "TWR":
continue
mil_cat = "default"
if "H" in model and any(c.isdigit() for c in model):
mil_cat = "heli"
@@ -1259,6 +1272,180 @@ 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:
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}")
# 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:
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:
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 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}&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", {})
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", "")
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
def fetch_bikeshare():
bikes = []
try:
@@ -1815,6 +2002,9 @@ def update_slow_data():
fetch_earthquakes,
fetch_geopolitics,
fetch_kiwisdr,
fetch_space_weather,
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]
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.3.0",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+18 -1
View File
@@ -146,6 +146,8 @@ export default function Dashboard() {
gibs_imagery: false,
highres_satellite: false,
kiwisdr: false,
firms: false,
internet_outages: false,
});
// NASA GIBS satellite imagery state
@@ -161,7 +163,7 @@ export default function Dashboard() {
});
const [activeStyle, setActiveStyle] = useState('DEFAULT');
const stylesList = ['DEFAULT', 'SATELLITE', 'FLIR', 'NVG', 'CRT'];
const stylesList = ['DEFAULT', 'SATELLITE'];
const cycleStyle = () => {
setActiveStyle((prev) => {
@@ -511,6 +513,21 @@ export default function Dashboard() {
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">STYLE</div>
<div className="text-[11px] text-cyan-400 font-mono font-bold">{activeStyle}</div>
</div>
{/* Divider */}
<div className="w-px h-8 bg-[var(--border-primary)]" />
{/* Space Weather */}
<div className="flex flex-col items-center" title={`Kp Index: ${data?.space_weather?.kp_index ?? 'N/A'}`}>
<div className="text-[8px] text-[var(--text-muted)] font-mono tracking-[0.2em]">SOLAR</div>
<div className={`text-[11px] font-mono font-bold ${
(data?.space_weather?.kp_index ?? 0) >= 5 ? 'text-red-400' :
(data?.space_weather?.kp_index ?? 0) >= 4 ? 'text-yellow-400' :
'text-green-400'
}`}>
{data?.space_weather?.kp_text || 'N/A'}
</div>
</div>
</div>
</motion.div>
</>
+21 -32
View File
@@ -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 → FLIR → NVG → CRT. 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() {
+209 -1
View File
@@ -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,6 +437,67 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [activeLayers.kiwisdr, data?.kiwisdr, inView]);
// 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.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.firms, data?.firms_fires]);
// 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 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: {
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)
};
}, [activeLayers.internet_outages, data?.internet_outages]);
// Load Images into the Map Style once loaded
const onMapLoad = useCallback((e: any) => {
const map = e.target;
@@ -514,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'));
@@ -1145,7 +1241,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
earthquakesGeoJSON && 'earthquakes-layer',
satellitesGeoJSON && 'satellites-layer',
cctvGeoJSON && 'cctv-layer',
kiwisdrGeoJSON && 'kiwisdr-layer'
kiwisdrGeoJSON && 'kiwisdr-layer',
internetOutagesGeoJSON && 'internet-outages-layer',
firmsGeoJSON && 'firms-viirs-layer'
].filter(Boolean) as string[];
@@ -1249,6 +1347,52 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{/* 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="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>
)}
{/* SOLAR TERMINATOR — night overlay */}
{activeLayers.day_night && nightGeoJSON && (
<Source id="night-overlay" type="geojson" data={nightGeoJSON as any}>
@@ -1906,6 +2050,70 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
</Source>
)}
{/* 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': ['interpolate', ['linear'], ['get', 'severity'], 0, 6, 50, 9, 80, 12],
'circle-color': '#888888',
'circle-stroke-width': 2,
'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>
)}
{/* Satellite positions — mission-type icons */}
{satellitesGeoJSON && (
<Source id="satellites" type="geojson" data={satellitesGeoJSON as any}>
@@ -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,8 @@ 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: "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 },
];
+3
View File
@@ -58,6 +58,9 @@ if %errorlevel% neq 0 (
exit /b 1
)
echo [*] Backend dependencies OK.
echo [*] Installing backend Node.js dependencies...
call npm install --silent
echo [*] Backend Node.js dependencies OK.
cd ..
echo.
+3
View File
@@ -52,6 +52,9 @@ if [ $? -ne 0 ]; then
fi
echo "[*] Backend dependencies OK."
deactivate
echo "[*] Installing backend Node.js dependencies..."
npm install --silent
echo "[*] Backend Node.js dependencies OK."
cd "$SCRIPT_DIR"