v0.8.0: POTUS fleet tracking, full aircraft color-coding, carrier fidelity, UI overhaul

New features:
- POTUS fleet (AF1, AF2, Marine One) with hot-pink icons + gold halo ring
- 9-color aircraft system: military, medical, police, VIP, privacy, dictators
- Sentinel-2 fullscreen overlay with download/copy/open buttons (green themed)
- Carrier homeport deconfliction — distinct pier positions instead of stacking
- Toggle all data layers button (cyan when active, excludes MODIS Terra)
- Version badge + update checker + Discussions shortcut in UI
- Overhauled MapLegend with POTUS fleet, wildfires, infrastructure sections
- Data center map layer with ~700 global DCs from curated dataset

Fixes:
- All Air Force Two ICAO hex codes now correctly identified
- POTUS icon priority over grounded state
- Sentinel-2 no longer overlaps bottom coordinate bar
- Region dossier Nominatim 429 rate-limit retry/backoff
- Docker ENV legacy format warnings resolved
- UI buttons cyan in dark mode, grey in light mode
- Circuit breaker for flaky upstream APIs

Community: @suranyami — parallel multi-arch Docker builds + runtime BACKEND_URL fix (PR #35, #44)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Former-commit-id: 7c523df70a2d26f675603166e3513d29230592cd
This commit is contained in:
anoracleofra-code
2026-03-12 09:30:51 -06:00
parent a0d0a449eb
commit 34db99deaf
20 changed files with 907 additions and 553 deletions
+16
View File
@@ -0,0 +1,16 @@
# ShadowBroker — Docker Compose Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required for backend container ─────────────────────────────
OPENSKY_CLIENT_ID=
OPENSKY_CLIENT_SECRET=
AIS_API_KEY=
# ── Optional ───────────────────────────────────────────────────
# LTA (Singapore traffic cameras) — leave blank to skip
# LTA_ACCOUNT_KEY=
# Override the backend URL the frontend uses (leave blank for auto-detect)
# NEXT_PUBLIC_API_URL=http://192.168.1.50:8000
+15
View File
@@ -0,0 +1,15 @@
# ShadowBroker Backend — Environment Variables
# Copy this file to .env and fill in your keys:
# cp .env.example .env
# ── Required Keys ──────────────────────────────────────────────
# Without these, the corresponding data layers will be empty.
OPENSKY_CLIENT_ID= # https://opensky-network.org/ — free account, OAuth2 client ID
OPENSKY_CLIENT_SECRET= # OAuth2 client secret from your OpenSky dashboard
AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# ── Optional ───────────────────────────────────────────────────
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
@@ -0,0 +1 @@
430ac93c4f7c4fb5a3e596ec38e3b7794c731cc1
@@ -0,0 +1 @@
476b691be156eb4fe6a6ad80f882c1dbaded8c33
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+166
View File
@@ -0,0 +1,166 @@
"""
Geocode data center street addresses via Nominatim (OpenStreetMap).
Rate limit: 1 request/second (Nominatim policy).
Resumable: caches results in geocode_cache.json so interrupted runs can continue.
"""
import json
import time
import urllib.request
import urllib.parse
import os
import sys
# Fix Windows console encoding + force unbuffered output
if sys.platform == "win32":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
# Force line-buffered stdout for detached processes
class Unbuffered:
def __init__(self, stream):
self.stream = stream
def write(self, data):
self.stream.write(data)
self.stream.flush()
def writelines(self, datas):
self.stream.writelines(datas)
self.stream.flush()
def __getattr__(self, attr):
return getattr(self.stream, attr)
sys.stdout = Unbuffered(sys.stdout)
DATA_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters.json")
CACHE_FILE = os.path.join(os.path.dirname(__file__), "data", "geocode_cache.json")
OUTPUT_FILE = os.path.join(os.path.dirname(__file__), "data", "datacenters_geocoded.json")
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
USER_AGENT = "ShadowBroker-DataCenterGeocoder/1.0"
def geocode_address(address: str, retries: int = 3) -> tuple[float, float] | None:
"""Geocode a single address via Nominatim. Returns (lat, lng) or None."""
params = urllib.parse.urlencode({"q": address, "format": "json", "limit": 1})
url = f"{NOMINATIM_URL}?{params}"
for attempt in range(retries):
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
try:
resp = urllib.request.urlopen(req, timeout=15)
data = json.loads(resp.read())
if data:
return float(data[0]["lat"]), float(data[0]["lon"])
return None # Valid response but no results
except Exception as e:
if attempt < retries - 1:
wait = 2 ** (attempt + 1)
print(f" RETRY ({attempt+1}/{retries}): {e} — waiting {wait}s")
time.sleep(wait)
else:
print(f" ERROR (gave up after {retries} attempts): {e}")
return None
def main():
with open(DATA_FILE, "r", encoding="utf-8") as f:
dcs = json.load(f)
# Load cache
cache = {}
if os.path.exists(CACHE_FILE):
with open(CACHE_FILE, "r", encoding="utf-8") as f:
cache = json.load(f)
print(f"Loaded {len(cache)} cached geocode results")
# Filter to DCs with real street addresses
to_geocode = []
skipped = 0
for i, dc in enumerate(dcs):
street = (dc.get("street") or "").strip()
if not street or len(street) <= 3 or street.lower() in ("tbc", "n/a", "na", "-"):
skipped += 1
continue
to_geocode.append((i, dc))
print(f"Total DCs: {len(dcs)}")
print(f"Skipped (no real address): {skipped}")
print(f"To geocode: {len(to_geocode)}")
# Count how many already cached
already_cached = sum(1 for _, dc in to_geocode if dc.get("address", "") in cache)
need_api = len(to_geocode) - already_cached
print(f"Already cached: {already_cached}")
print(f"Need API calls: {need_api}")
if need_api > 0:
print(f"Estimated time: {need_api // 60}m {need_api % 60}s")
print()
geocoded = 0
failed = 0
api_calls = 0
save_interval = 50 # Save cache every 50 API calls
for idx, (i, dc) in enumerate(to_geocode):
address = dc.get("address", "").strip()
if not address:
# Build address from parts
parts = [dc.get("street", ""), dc.get("zip", ""), dc.get("city", ""), dc.get("country", "")]
address = " ".join(p.strip() for p in parts if p and p.strip())
if not address:
failed += 1
continue
# Check cache first
if address in cache:
result = cache[address]
if result:
dcs[i]["lat"] = result[0]
dcs[i]["lng"] = result[1]
dcs[i]["geocode_source"] = "nominatim"
geocoded += 1
else:
failed += 1
continue
# API call — Nominatim requires 1 req/s, use 1.5s to avoid 429s after heavy use
time.sleep(1.5)
coords = geocode_address(address)
api_calls += 1
if coords:
cache[address] = coords
dcs[i]["lat"] = coords[0]
dcs[i]["lng"] = coords[1]
dcs[i]["geocode_source"] = "nominatim"
geocoded += 1
print(f"[{api_calls}/{need_api}] OK: {dc.get('name', '?')} -> ({coords[0]:.4f}, {coords[1]:.4f})")
else:
cache[address] = None
failed += 1
print(f"[{api_calls}/{need_api}] FAIL: {dc.get('name', '?')} | {address}")
# Periodic cache save
if api_calls % save_interval == 0:
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f)
print(f" -- Cache saved ({len(cache)} entries) --")
# Final save
with open(CACHE_FILE, "w", encoding="utf-8") as f:
json.dump(cache, f)
# Write output - only DCs with real coordinates
output = [dc for dc in dcs if dc.get("lat") is not None and dc.get("lng") is not None]
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2)
print(f"\nDone!")
print(f"Geocoded: {geocoded}")
print(f"Failed: {failed}")
print(f"API calls made: {api_calls}")
print(f"Output: {len(output)} DCs with coordinates -> {OUTPUT_FILE}")
if __name__ == "__main__":
main()
+2
View File
@@ -133,6 +133,8 @@ async def live_data_fast(request: Request):
"uavs": d.get("uavs", []),
"liveuamap": d.get("liveuamap", []),
"gps_jamming": d.get("gps_jamming", []),
"satellites": d.get("satellites", []),
"satellite_source": d.get("satellite_source", "none"),
"freshness": dict(source_timestamps),
}
return _etag_response(request, payload, prefix="fast|")
+150 -86
View File
@@ -26,104 +26,116 @@ logger = logging.getLogger(__name__)
# Carrier registry: hull number → metadata + fallback position
# -----------------------------------------------------------------
CARRIER_REGISTRY: Dict[str, dict] = {
# Fallback positions sourced from USNI News Fleet & Marine Tracker (Mar 9, 2026)
# https://news.usni.org/2026/03/09/usni-news-fleet-and-marine-tracker-march-9-2026
# --- Bremerton, WA (Naval Base Kitsap) ---
# Distinct pier positions along Sinclair Inlet so carriers don't stack
"CVN-68": {
"name": "USS Nimitz (CVN-68)",
"wiki": "https://en.wikipedia.org/wiki/USS_Nimitz",
"homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63,
"fallback_lat": 21.35, "fallback_lng": -157.95,
"fallback_heading": 270,
"fallback_desc": "Pacific Fleet / Pearl Harbor"
},
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 120,
"fallback_desc": "Red Sea / CENTCOM AOR"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 34.0, "fallback_lng": 25.0,
"homeport_lat": 47.5535, "homeport_lng": -122.6400,
"fallback_lat": 47.5535, "fallback_lng": -122.6400,
"fallback_heading": 90,
"fallback_desc": "Eastern Mediterranean deterrence"
},
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 15.0, "fallback_lng": 115.0,
"fallback_heading": 45,
"fallback_desc": "South China Sea patrol"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 22.0, "fallback_lng": 122.0,
"fallback_heading": 300,
"fallback_desc": "Philippine Sea / Taiwan Strait"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.68, "homeport_lng": -117.15,
"fallback_lat": 21.0, "fallback_lng": -158.0,
"fallback_heading": 270,
"fallback_desc": "Pacific deployment"
},
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.28, "homeport_lng": 139.67,
"fallback_lat": 35.0, "fallback_lng": 139.0,
"fallback_heading": 0,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.95, "fallback_lng": -76.33,
"fallback_heading": 0,
"fallback_desc": "RCOH / Norfolk (maintenance)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 90,
"fallback_desc": "Mediterranean deployment"
"fallback_desc": "Bremerton, WA (Maintenance)"
},
"CVN-76": {
"name": "USS Ronald Reagan (CVN-76)",
"wiki": "https://en.wikipedia.org/wiki/USS_Ronald_Reagan",
"homeport": "Bremerton, WA",
"homeport_lat": 47.56, "homeport_lng": -122.63,
"fallback_lat": 47.56, "fallback_lng": -122.63,
"homeport_lat": 47.5580, "homeport_lng": -122.6360,
"fallback_lat": 47.5580, "fallback_lng": -122.6360,
"fallback_heading": 90,
"fallback_desc": "Bremerton, WA (Decommissioning)"
},
# --- Norfolk, VA (Naval Station Norfolk) ---
# Piers run N-S along Willoughby Bay; each carrier gets a distinct berth
"CVN-69": {
"name": "USS Dwight D. Eisenhower (CVN-69)",
"wiki": "https://en.wikipedia.org/wiki/USS_Dwight_D._Eisenhower",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9465, "homeport_lng": -76.3265,
"fallback_lat": 36.9465, "fallback_lng": -76.3265,
"fallback_heading": 0,
"fallback_desc": "Bremerton, WA (Homeport)"
"fallback_desc": "Norfolk, VA (Post-deployment maintenance)"
},
"CVN-78": {
"name": "USS Gerald R. Ford (CVN-78)",
"wiki": "https://en.wikipedia.org/wiki/USS_Gerald_R._Ford",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9505, "homeport_lng": -76.3250,
"fallback_lat": 18.0, "fallback_lng": 39.5,
"fallback_heading": 0,
"fallback_desc": "Red Sea — Operation Epic Fury (USNI Mar 9)"
},
"CVN-74": {
"name": "USS John C. Stennis (CVN-74)",
"wiki": "https://en.wikipedia.org/wiki/USS_John_C._Stennis",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9540, "homeport_lng": -76.3235,
"fallback_lat": 36.98, "fallback_lng": -76.43,
"fallback_heading": 0,
"fallback_desc": "Newport News, VA (RCOH refueling overhaul)"
},
"CVN-75": {
"name": "USS Harry S. Truman (CVN-75)",
"wiki": "https://en.wikipedia.org/wiki/USS_Harry_S._Truman",
"homeport": "Norfolk, VA",
"homeport_lat": 36.9580, "homeport_lng": -76.3220,
"fallback_lat": 36.0, "fallback_lng": 15.0,
"fallback_heading": 0,
"fallback_desc": "Mediterranean Sea deployment (USNI Mar 9)"
},
"CVN-77": {
"name": "USS George H.W. Bush (CVN-77)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_H.W._Bush",
"homeport": "Norfolk, VA",
"homeport_lat": 36.95, "homeport_lng": -76.33,
"fallback_lat": 36.95, "fallback_lng": -76.33,
"homeport_lat": 36.9620, "homeport_lng": -76.3210,
"fallback_lat": 36.5, "fallback_lng": -74.0,
"fallback_heading": 0,
"fallback_desc": "Norfolk, VA (Homeport)"
"fallback_desc": "Atlantic — Pre-deployment workups (USNI Mar 9)"
},
# --- San Diego, CA (Naval Base San Diego) ---
# Carrier piers along the east shore of San Diego Bay, spread N-S
"CVN-70": {
"name": "USS Carl Vinson (CVN-70)",
"wiki": "https://en.wikipedia.org/wiki/USS_Carl_Vinson",
"homeport": "San Diego, CA",
"homeport_lat": 32.6840, "homeport_lng": -117.1290,
"fallback_lat": 32.6840, "fallback_lng": -117.1290,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Homeport)"
},
"CVN-71": {
"name": "USS Theodore Roosevelt (CVN-71)",
"wiki": "https://en.wikipedia.org/wiki/USS_Theodore_Roosevelt_(CVN-71)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6885, "homeport_lng": -117.1280,
"fallback_lat": 32.6885, "fallback_lng": -117.1280,
"fallback_heading": 180,
"fallback_desc": "San Diego, CA (Maintenance)"
},
"CVN-72": {
"name": "USS Abraham Lincoln (CVN-72)",
"wiki": "https://en.wikipedia.org/wiki/USS_Abraham_Lincoln_(CVN-72)",
"homeport": "San Diego, CA",
"homeport_lat": 32.6925, "homeport_lng": -117.1275,
"fallback_lat": 20.0, "fallback_lng": 64.0,
"fallback_heading": 0,
"fallback_desc": "Arabian Sea — Operation Epic Fury (USNI Mar 9)"
},
# --- Yokosuka, Japan (CFAY) ---
"CVN-73": {
"name": "USS George Washington (CVN-73)",
"wiki": "https://en.wikipedia.org/wiki/USS_George_Washington_(CVN-73)",
"homeport": "Yokosuka, Japan",
"homeport_lat": 35.2830, "homeport_lng": 139.6700,
"fallback_lat": 35.2830, "fallback_lng": 139.6700,
"fallback_heading": 180,
"fallback_desc": "Yokosuka, Japan (Forward deployed)"
},
}
@@ -302,7 +314,8 @@ def _parse_carrier_positions_from_news(articles: List[dict]) -> Dict[str, dict]:
"lat": coords[0],
"lng": coords[1],
"desc": title[:100],
"source": "GDELT OSINT",
"source": "GDELT News API",
"source_url": article.get("url", "https://api.gdeltproject.org"),
"updated": datetime.now(timezone.utc).isoformat()
}
logger.info(f"Carrier update: {CARRIER_REGISTRY[hull]['name']}{coords} (from: {title[:80]})")
@@ -316,7 +329,7 @@ def update_carrier_positions():
logger.info("Carrier tracker: updating positions from OSINT sources...")
# Start with fallback positions
# Start with fallback positions (sourced from USNI News Fleet Tracker)
positions: Dict[str, dict] = {}
for hull, info in CARRIER_REGISTRY.items():
positions[hull] = {
@@ -326,7 +339,8 @@ def update_carrier_positions():
"heading": info["fallback_heading"],
"desc": info["fallback_desc"],
"wiki": info["wiki"],
"source": "Static OSINT estimate",
"source": "USNI News Fleet & Marine Tracker",
"source_url": "https://news.usni.org/category/fleet-tracker",
"updated": datetime.now(timezone.utc).isoformat()
}
@@ -370,6 +384,55 @@ def update_carrier_positions():
logger.info(f"Carrier tracker: {len(positions)} carriers updated. Sources: {sources}")
def _deconflict_positions(result: List[dict]) -> List[dict]:
"""Offset carriers that share identical coordinates so they don't stack.
At port: offset along the pier axis (~500m / 0.004° apart).
At sea: offset perpendicular to each other (~0.08° / ~9km apart)
so they're visibly separate but clearly operating together.
"""
# Group by rounded lat/lng (within ~0.01° ≈ 1km = same spot)
from collections import defaultdict
groups: dict[str, list[int]] = defaultdict(list)
for i, c in enumerate(result):
key = f"{round(c['lat'], 2)},{round(c['lng'], 2)}"
groups[key].append(i)
for indices in groups.values():
if len(indices) < 2:
continue
n = len(indices)
# Determine if this is a port (near a homeport) or at sea
sample = result[indices[0]]
at_port = any(
abs(sample["lat"] - info.get("homeport_lat", 0)) < 0.05
and abs(sample["lng"] - info.get("homeport_lng", 0)) < 0.05
for info in CARRIER_REGISTRY.values()
)
if at_port:
# Use each carrier's distinct homeport pier coordinates
for idx in indices:
carrier = result[idx]
hull = None
for h, info in CARRIER_REGISTRY.items():
if info["name"] == carrier["name"]:
hull = h
break
if hull:
info = CARRIER_REGISTRY[hull]
carrier["lat"] = info["homeport_lat"]
carrier["lng"] = info["homeport_lng"]
else:
# At sea: spread in a line perpendicular to travel (~0.08° apart)
spacing = 0.08 # ~9km — close enough to see they're together
start_offset = -(n - 1) * spacing / 2
for j, idx in enumerate(indices):
result[idx]["lng"] += start_offset + j * spacing
return result
def get_carrier_positions() -> List[dict]:
"""Return current carrier positions for the data pipeline."""
with _positions_lock:
@@ -381,7 +444,7 @@ def get_carrier_positions() -> List[dict]:
"type": "carrier",
"lat": pos["lat"],
"lng": pos["lng"],
"heading": pos.get("heading", 0),
"heading": None, # Heading unknown for carriers — OSINT cannot determine true heading
"sog": 0,
"cog": 0,
"country": "United States",
@@ -389,9 +452,10 @@ def get_carrier_positions() -> List[dict]:
"wiki": pos.get("wiki", info.get("wiki", "")),
"estimated": True,
"source": pos.get("source", "OSINT estimated position"),
"source_url": pos.get("source_url", "https://news.usni.org/category/fleet-tracker"),
"last_osint_update": pos.get("updated", "")
})
return result
return _deconflict_positions(result)
# -----------------------------------------------------------------
+162 -174
View File
@@ -467,7 +467,8 @@ def fetch_news():
source_weights = {f["name"]: f["weight"] for f in feed_config}
clusters = {}
_cluster_grid = {} # spatial hash grid: (cell_x, cell_y) → [cluster_keys]
# Fetch all feeds in parallel for speed (each has a 10s timeout)
def _fetch_feed(item):
source_name, url = item
@@ -540,20 +541,25 @@ def fetch_news():
break
# If mapped, check if there is an existing cluster within ~400km (4 degrees) to merge them
# Uses spatial hash grid (4° cells) for O(1) lookup instead of O(n) scan
if lat is not None:
key = None
for existing_key in clusters.keys():
if "," in existing_key:
parts = existing_key.split(",")
try:
cell_x, cell_y = int(lng // 4), int(lat // 4)
for dx in range(-1, 2):
for dy in range(-1, 2):
for ckey in _cluster_grid.get((cell_x + dx, cell_y + dy), []):
parts = ckey.split(",")
elat, elng = float(parts[0]), float(parts[1])
if ((lat - elat)**2 + (lng - elng)**2)**0.5 < 4.0:
key = existing_key
key = ckey
break
except ValueError:
pass
if key:
break
if key:
break
if key is None:
key = f"{lat},{lng}"
_cluster_grid.setdefault((cell_x, cell_y), []).append(key)
else:
key = title
@@ -1193,11 +1199,11 @@ def fetch_flights():
if hex_id:
seen_hexes.add(hex_id)
# Prune stale trails (10 min for non-tracked, 30 min for tracked)
# Prune stale trails (5 min for non-tracked, 30 min for tracked)
tracked_hexes = {t.get('icao24', '').lower() for t in latest_data.get('tracked_flights', [])}
stale_keys = []
for k, v in flight_trails.items():
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 600
cutoff = now_ts - 1800 if k in tracked_hexes else now_ts - 300
if v['last_seen'] < cutoff:
stale_keys.append(k)
for k in stale_keys:
@@ -1308,17 +1314,25 @@ def fetch_ships():
"""Fetch real-time AIS vessel data and combine with OSINT carrier positions."""
from services.ais_stream import get_ais_vessels
from services.carrier_tracker import get_carrier_positions
ships = []
# Dynamic OSINT carrier positions (updated from GDELT + cache)
carriers = get_carrier_positions()
ships.extend(carriers)
try:
carriers = get_carrier_positions()
ships.extend(carriers)
except Exception as e:
logger.error(f"Carrier tracker error (non-fatal): {e}")
carriers = []
# Real AIS vessel data from aisstream.io
ais_vessels = get_ais_vessels()
ships.extend(ais_vessels)
try:
ais_vessels = get_ais_vessels()
ships.extend(ais_vessels)
except Exception as e:
logger.error(f"AIS stream error (non-fatal): {e}")
ais_vessels = []
logger.info(f"Ships: {len(carriers)} carriers + {len(ais_vessels)} AIS vessels")
latest_data['ships'] = ships
_mark_fresh("ships")
@@ -1677,123 +1691,37 @@ def fetch_internet_outages():
if outages:
_mark_fresh("internet_outages")
_DC_CACHE_PATH = Path(__file__).parent.parent / "data" / "datacenters.json"
_DC_URL = "https://raw.githubusercontent.com/Ringmast4r/Data-Center-Map---Global/1f290297c6a11454dc7a47bf95aef7cf0fe1d34c/datacenters_cleaned.json"
# Country bounding boxes (lat_min, lat_max, lng_min, lng_max) for coordinate validation.
# The source dataset has abs(lat) for all Southern Hemisphere entries, so we fix the sign
# and then validate the result falls within the country's bounding box.
_COUNTRY_BBOX: dict[str, tuple[float, float, float, float]] = {
"Argentina": (-55, -21, -74, -53), "Australia": (-44, -10, 112, 154),
"Bolivia": (-23, -9, -70, -57), "Brazil": (-34, 6, -74, -34),
"Chile": (-56, -17, -76, -66), "Colombia": (-5, 13, -82, -66),
"Ecuador": (-5, 2, -81, -75), "Indonesia": (-11, 6, 95, 141),
"Kenya": (-5, 5, 34, 42), "Madagascar": (-26, -12, 43, 51),
"Mozambique": (-27, -10, 30, 41), "New Zealand": (-47, -34, 166, 179),
"Paraguay": (-28, -19, -63, -54), "Peru": (-18, 0, -82, -68),
"South Africa": (-35, -22, 16, 33), "Tanzania": (-12, -1, 29, 41),
"Uruguay": (-35, -30, -59, -53), "Zimbabwe": (-23, -15, 25, 34),
# Northern-hemisphere countries for validation only
"United States": (24, 72, -180, -65), "Canada": (41, 84, -141, -52),
"United Kingdom": (49, 61, -9, 2), "Germany": (47, 55, 5, 16),
"France": (41, 51, -5, 10), "Japan": (24, 46, 123, 146),
"India": (6, 36, 68, 98), "China": (18, 54, 73, 135),
"Singapore": (1, 2, 103, 105), "Spain": (36, 44, -10, 5),
"Netherlands": (50, 54, 3, 8), "Sweden": (55, 70, 11, 25),
"Italy": (36, 47, 6, 19), "Russia": (41, 82, 19, 180),
"Mexico": (14, 33, -118, -86), "Nigeria": (4, 14, 2, 15),
"Thailand": (5, 21, 97, 106), "Malaysia": (0, 8, 99, 120),
"Philippines": (4, 21, 116, 127), "South Korea": (33, 39, 124, 132),
"Taiwan": (21, 26, 119, 123), "Hong Kong": (22, 23, 113, 115),
"Vietnam": (8, 24, 102, 110), "Poland": (49, 55, 14, 25),
"Switzerland": (45, 48, 5, 11), "Austria": (46, 49, 9, 17),
"Belgium": (49, 52, 2, 7), "Denmark": (54, 58, 8, 16),
"Finland": (59, 70, 20, 32), "Norway": (57, 72, 4, 32),
"Ireland": (51, 56, -11, -5), "Portugal": (36, 42, -10, -6),
"Turkey": (35, 42, 25, 45), "Israel": (29, 34, 34, 36),
"UAE": (22, 27, 51, 56), "Saudi Arabia": (16, 33, 34, 56),
}
# Countries whose DCs always sit south of the equator
_SOUTHERN_COUNTRIES = {
"Argentina", "Australia", "Bolivia", "Brazil", "Chile", "Madagascar",
"Mozambique", "New Zealand", "Paraguay", "Peru", "South Africa",
"Tanzania", "Uruguay", "Zimbabwe",
}
def _fix_dc_coords(lat: float, lng: float, country: str) -> tuple[float, float] | None:
"""Fix and validate data-center coordinates against the stated country.
The source dataset stores abs(lat) for Southern-Hemisphere entries.
We negate lat when the country is in the Southern Hemisphere, then
validate the result falls within the country bounding box (if known).
Returns corrected (lat, lng) or None if the coords are clearly wrong.
"""
# Fix Southern Hemisphere sign
if country in _SOUTHERN_COUNTRIES and lat > 0:
lat = -lat
bbox = _COUNTRY_BBOX.get(country)
if bbox:
lat_min, lat_max, lng_min, lng_max = bbox
if lat_min <= lat <= lat_max and lng_min <= lng <= lng_max:
return lat, lng
# Try swapping sign as last resort (some entries are just wrong sign)
if lat_min <= -lat <= lat_max and lng_min <= lng <= lng_max:
return -lat, lng
# Coords don't match country at all — drop the entry
return None
# No bbox for this country — basic sanity only
return lat, lng
_DC_GEOCODED_PATH = Path(__file__).parent.parent / "data" / "datacenters_geocoded.json"
def fetch_datacenters():
"""Load data center locations (static dataset, cached locally after first fetch)."""
"""Load geocoded data centers (5K+ street-level precise locations)."""
dcs = []
try:
raw = None
# Use local cache if it exists and is less than 7 days old
if _DC_CACHE_PATH.exists():
age_days = (time.time() - _DC_CACHE_PATH.stat().st_mtime) / 86400
if age_days < 7:
raw = json.loads(_DC_CACHE_PATH.read_text(encoding="utf-8"))
# Otherwise fetch from GitHub
if raw is None:
resp = fetch_with_curl(_DC_URL, timeout=20)
if resp.status_code == 200:
raw = resp.json()
_DC_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
_DC_CACHE_PATH.write_text(json.dumps(raw), encoding="utf-8")
if raw:
dropped = 0
for entry in raw:
coords = entry.get("city_coords")
if not coords or not isinstance(coords, list) or len(coords) < 2:
continue
lat, lng = coords[0], coords[1]
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
continue
country = entry.get("country", "")
fixed = _fix_dc_coords(lat, lng, country)
if fixed is None:
dropped += 1
continue
lat, lng = fixed
dcs.append({
"name": entry.get("name", "Unknown"),
"company": entry.get("company", ""),
"city": entry.get("city", ""),
"country": country,
"lat": lat,
"lng": lng,
})
if dropped:
logger.info(f"Data centers: dropped {dropped} entries with mismatched coordinates")
logger.info(f"Data centers: {len(dcs)} with valid coordinates (from {'cache' if _DC_CACHE_PATH.exists() else 'GitHub'})")
if not _DC_GEOCODED_PATH.exists():
logger.warning(f"Geocoded DC file not found: {_DC_GEOCODED_PATH}")
return
raw = json.loads(_DC_GEOCODED_PATH.read_text(encoding="utf-8"))
for entry in raw:
lat = entry.get("lat")
lng = entry.get("lng")
if lat is None or lng is None:
continue
if not (-90 <= lat <= 90 and -180 <= lng <= 180):
continue
dcs.append({
"name": entry.get("name", "Unknown"),
"company": entry.get("company", ""),
"street": entry.get("street", ""),
"city": entry.get("city", ""),
"country": entry.get("country", ""),
"zip": entry.get("zip", ""),
"lat": lat,
"lng": lng,
})
logger.info(f"Data centers: {len(dcs)} geocoded locations loaded")
except Exception as e:
logger.error(f"Error fetching data centers: {e}")
logger.error(f"Error loading data centers: {e}")
latest_data["datacenters"] = dcs
if dcs:
_mark_fresh("datacenters")
@@ -1859,7 +1787,37 @@ def fetch_earthquakes():
_mark_fresh("earthquakes")
# Satellite GP data cache — re-download from CelesTrak only every 30 minutes
_sat_gp_cache = {"data": None, "last_fetch": 0}
_sat_gp_cache = {"data": None, "last_fetch": 0, "source": "none"}
_sat_classified_cache = {"data": None, "gp_fetch_ts": 0} # Cache classified sat list (skip re-classification when TLEs unchanged)
_SAT_CACHE_PATH = Path(__file__).parent.parent / "data" / "sat_gp_cache.json"
def _load_sat_cache():
"""Load satellite GP data from local disk cache."""
try:
if _SAT_CACHE_PATH.exists():
import os
age_hours = (time.time() - os.path.getmtime(str(_SAT_CACHE_PATH))) / 3600
if age_hours < 48: # Use cache if less than 48 hours old
with open(_SAT_CACHE_PATH, "r") as f:
data = json.load(f)
if isinstance(data, list) and len(data) > 10:
logger.info(f"Satellites: Loaded {len(data)} records from disk cache ({age_hours:.1f}h old)")
return data
else:
logger.info(f"Satellites: Disk cache is {age_hours:.0f}h old, will try fresh fetch")
except Exception as e:
logger.warning(f"Satellites: Failed to load disk cache: {e}")
return None
def _save_sat_cache(data):
"""Save satellite GP data to local disk cache."""
try:
_SAT_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
with open(_SAT_CACHE_PATH, "w") as f:
json.dump(data, f)
logger.info(f"Satellites: Saved {len(data)} records to disk cache")
except Exception as e:
logger.warning(f"Satellites: Failed to save disk cache: {e}")
# Satellite intelligence classification database — module-level constant.
# Key: substring to match in OBJECT_NAME → {country, mission, sat_type, wiki}
@@ -1961,31 +1919,39 @@ def _fetch_satellites_from_tle_api():
term = key.split()[0] if len(key.split()) > 1 and key.split()[0] in ("USA", "NROL") else key
search_terms.add(term)
all_results = []
seen_ids = set()
for term in search_terms:
def _fetch_term(term):
"""Fetch a single search term from TLE API."""
results = []
try:
url = f"https://tle.ivanstanojevic.me/api/tle/?search={term}&page_size=100&format=json"
response = fetch_with_curl(url, timeout=10)
response = fetch_with_curl(url, timeout=8)
if response.status_code != 200:
continue
return results
data = response.json()
for member in data.get("member", []):
sat_id = member.get("satelliteId")
if sat_id in seen_ids:
continue
seen_ids.add(sat_id)
gp = _parse_tle_to_gp(
member.get("name", "UNKNOWN"),
sat_id,
member.get("satelliteId"),
member.get("line1", ""),
member.get("line2", ""),
)
if gp:
all_results.append(gp)
results.append(gp)
except Exception as e:
logger.debug(f"TLE fallback search '{term}' failed: {e}")
continue
return results
# Fetch ALL search terms in parallel (was sequential — 35+ requests taking forever)
all_results = []
seen_ids = set()
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
future_map = {executor.submit(_fetch_term, term): term for term in search_terms}
for future in concurrent.futures.as_completed(future_map):
for gp in future.result():
sat_id = gp.get("NORAD_CAT_ID")
if sat_id not in seen_ids:
seen_ids.add(sat_id)
all_results.append(gp)
return all_results
@@ -1998,25 +1964,28 @@ def fetch_satellites():
now_ts = time.time()
if _sat_gp_cache["data"] is None or (now_ts - _sat_gp_cache["last_fetch"]) > 1800:
# Try multiple CelesTrak mirrors — .org is often blocked/banned by some networks
# Short timeout (5s) so we fail fast and hit the TLE fallback quickly
gp_urls = [
"https://celestrak.org/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
"https://celestrak.com/NORAD/elements/gp.php?GROUP=active&FORMAT=json",
]
for url in gp_urls:
try:
response = fetch_with_curl(url, timeout=8)
response = fetch_with_curl(url, timeout=5)
if response.status_code == 200:
gp_data = response.json()
if isinstance(gp_data, list) and len(gp_data) > 100:
_sat_gp_cache["data"] = gp_data
_sat_gp_cache["last_fetch"] = now_ts
_sat_gp_cache["source"] = "celestrak"
_save_sat_cache(gp_data)
logger.info(f"Satellites: Downloaded {len(gp_data)} GP records from {url}")
break
except Exception as e:
logger.warning(f"Satellites: Failed to fetch from {url}: {e}")
continue
# Fallback: if CelesTrak is blocked, use tle.ivanstanojevic.me TLE API
# Fallback 1: TLE API (parallel fetch)
if _sat_gp_cache["data"] is None:
logger.info("Satellites: CelesTrak unreachable, trying TLE fallback API...")
try:
@@ -2024,10 +1993,20 @@ def fetch_satellites():
if fallback_data and len(fallback_data) > 10:
_sat_gp_cache["data"] = fallback_data
_sat_gp_cache["last_fetch"] = now_ts
_sat_gp_cache["source"] = "tle_api"
_save_sat_cache(fallback_data)
logger.info(f"Satellites: Got {len(fallback_data)} records from TLE fallback API")
except Exception as e:
logger.error(f"Satellites: TLE fallback also failed: {e}")
# Fallback 2: local disk cache (survives API outages / rate limits)
if _sat_gp_cache["data"] is None:
disk_data = _load_sat_cache()
if disk_data:
_sat_gp_cache["data"] = disk_data
_sat_gp_cache["last_fetch"] = now_ts - 1500 # Mark as slightly stale so we retry sooner
_sat_gp_cache["source"] = "disk_cache"
data = _sat_gp_cache["data"]
if not data:
logger.warning("No satellite GP data available from any source")
@@ -2035,33 +2014,40 @@ def fetch_satellites():
return
# Only keep satellites matching the intel classification DB
classified = []
for sat in data:
name = sat.get("OBJECT_NAME", "UNKNOWN").upper()
intel = None
for key, meta in _SAT_INTEL_DB:
if key.upper() in name:
intel = dict(meta)
break
if not intel:
continue # Skip junk, debris, CubeSats, bulk constellations
entry = {
"id": sat.get("NORAD_CAT_ID"),
"name": sat.get("OBJECT_NAME", "UNKNOWN"),
"MEAN_MOTION": sat.get("MEAN_MOTION"),
"ECCENTRICITY": sat.get("ECCENTRICITY"),
"INCLINATION": sat.get("INCLINATION"),
"RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
"ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"),
"MEAN_ANOMALY": sat.get("MEAN_ANOMALY"),
"BSTAR": sat.get("BSTAR"),
"EPOCH": sat.get("EPOCH"),
}
entry.update(intel)
classified.append(entry)
# Skip re-classification if TLEs haven't changed (saves O(n*m) scan)
if _sat_classified_cache["gp_fetch_ts"] == _sat_gp_cache["last_fetch"] and _sat_classified_cache["data"]:
classified = _sat_classified_cache["data"]
logger.info(f"Satellites: Using cached classification ({len(classified)} sats, TLEs unchanged)")
else:
classified = []
for sat in data:
name = sat.get("OBJECT_NAME", "UNKNOWN").upper()
intel = None
for key, meta in _SAT_INTEL_DB:
if key.upper() in name:
intel = dict(meta)
break
if not intel:
continue # Skip junk, debris, CubeSats, bulk constellations
entry = {
"id": sat.get("NORAD_CAT_ID"),
"name": sat.get("OBJECT_NAME", "UNKNOWN"),
"MEAN_MOTION": sat.get("MEAN_MOTION"),
"ECCENTRICITY": sat.get("ECCENTRICITY"),
"INCLINATION": sat.get("INCLINATION"),
"RA_OF_ASC_NODE": sat.get("RA_OF_ASC_NODE"),
"ARG_OF_PERICENTER": sat.get("ARG_OF_PERICENTER"),
"MEAN_ANOMALY": sat.get("MEAN_ANOMALY"),
"BSTAR": sat.get("BSTAR"),
"EPOCH": sat.get("EPOCH"),
}
entry.update(intel)
classified.append(entry)
_sat_classified_cache["data"] = classified
_sat_classified_cache["gp_fetch_ts"] = _sat_gp_cache["last_fetch"]
logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog")
all_sats = classified
logger.info(f"Satellites: {len(classified)} intel-classified out of {len(data)} total in catalog")
# Propagate orbital elements to get current lat/lng/alt using SGP4
now = datetime.utcnow()
@@ -2156,9 +2142,11 @@ def fetch_satellites():
# Only overwrite if we got data — don't wipe the map on API timeout
if sats:
latest_data["satellites"] = sats
latest_data["satellite_source"] = _sat_gp_cache.get("source", "none")
_mark_fresh("satellites")
elif not latest_data.get("satellites"):
latest_data["satellites"] = []
latest_data["satellite_source"] = "none"
# ---------------------------------------------------------------------------
# Real UAV detection from ADS-B data — filters military drone transponders
@@ -2368,14 +2356,14 @@ def update_slow_data():
logger.info("Slow-tier update complete.")
def update_all_data():
"""Full update — runs on startup. Fast and slow tiers run IN PARALLEL for fastest startup."""
"""Full update — runs on startup. All tiers run IN PARALLEL for fastest startup."""
logger.info("Full data update starting (parallel)...")
fetch_airports() # Cached after first download
# Run fast + slow in parallel so the user sees data ASAP
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
# Run airports, fast, and slow ALL in parallel so the user sees data ASAP
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as pool:
f0 = pool.submit(fetch_airports) # Cached after first download
f1 = pool.submit(update_fast_data)
f2 = pool.submit(update_slow_data)
concurrent.futures.wait([f1, f2])
concurrent.futures.wait([f0, f1, f2])
logger.info("Full data update complete.")
scheduler = BackgroundScheduler()
+15 -1
View File
@@ -24,6 +24,11 @@ _BASH_PATH = shutil.which("bash") or "bash"
_domain_fail_cache: dict[str, float] = {}
_DOMAIN_FAIL_TTL = 300 # 5 minutes
# Circuit breaker: track domains where BOTH requests AND curl fail
# If a domain failed completely within the last 2 minutes, skip it entirely
_circuit_breaker: dict[str, float] = {}
_CIRCUIT_BREAKER_TTL = 120 # 2 minutes
class _DummyResponse:
"""Minimal response object matching requests.Response interface."""
def __init__(self, status_code, text):
@@ -54,6 +59,10 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
domain = urlparse(url).netloc
# Circuit breaker: if domain failed completely <2min ago, fail fast
if domain in _circuit_breaker and (time.time() - _circuit_breaker[domain]) < _CIRCUIT_BREAKER_TTL:
raise Exception(f"Circuit breaker open for {domain} (failed <{_CIRCUIT_BREAKER_TTL}s ago)")
# Check if this domain recently failed with requests — skip straight to curl
if domain in _domain_fail_cache and (time.time() - _domain_fail_cache[domain]) < _DOMAIN_FAIL_TTL:
pass # Fall through to curl below
@@ -64,8 +73,9 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
else:
res = _session.get(url, timeout=timeout, headers=default_headers)
res.raise_for_status()
# Clear failure cache on success
# Clear failure caches on success
_domain_fail_cache.pop(domain, None)
_circuit_breaker.pop(domain, None)
return res
except Exception as e:
logger.warning(f"Python requests failed for {url} ({e}), falling back to bash curl...")
@@ -92,10 +102,14 @@ def fetch_with_curl(url, method="GET", json_data=None, timeout=15, headers=None)
lines = res.stdout.rstrip().rsplit("\n", 1)
body = lines[0] if len(lines) > 1 else res.stdout
http_code = int(lines[-1]) if len(lines) > 1 and lines[-1].strip().isdigit() else 200
if http_code < 400:
_circuit_breaker.pop(domain, None) # Clear circuit breaker on success
return _DummyResponse(http_code, body)
else:
logger.error(f"bash curl fallback failed: exit={res.returncode} stderr={res.stderr[:200]}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "")
except Exception as curl_e:
logger.error(f"bash curl fallback exception: {curl_e}")
_circuit_breaker[domain] = time.time()
return _DummyResponse(500, "")
+9 -5
View File
@@ -10,13 +10,17 @@ FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
ENV NEXT_TELEMETRY_DISABLED=1
# NEXT_PUBLIC_* vars must exist at build time for Next.js to inline them.
# Default empty = auto-detect from browser hostname at runtime.
ARG NEXT_PUBLIC_API_URL=""
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
@@ -32,7 +36,7 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "frontend",
"version": "0.7.0",
"version": "0.8.0",
"private": true,
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
-95
View File
@@ -1,95 +0,0 @@
/**
* Catch-all proxy route — forwards /api/* requests from the browser to the
* backend server. BACKEND_URL is a plain server-side env var (not NEXT_PUBLIC_),
* so it is read at request time from the runtime environment, never baked into
* the client bundle or the build manifest.
*
* Set BACKEND_URL in docker-compose `environment:` (e.g. http://backend:8000)
* to use Docker internal networking. Defaults to http://localhost:8000 for
* local development where both services run on the same host.
*/
import { NextRequest, NextResponse } from "next/server";
// Headers that must not be forwarded to the backend.
const STRIP_REQUEST = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade", "host",
]);
// Headers that must not be forwarded back to the browser.
// content-encoding and content-length are stripped because Node.js fetch()
// automatically decompresses gzip/br responses — forwarding these headers
// would cause ERR_CONTENT_DECODING_FAILED in the browser.
const STRIP_RESPONSE = new Set([
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
"content-encoding", "content-length",
]);
async function proxy(req: NextRequest, path: string[]): Promise<NextResponse> {
const backendUrl = process.env.BACKEND_URL ?? "http://localhost:8000";
const targetUrl = new URL(`/api/${path.join("/")}`, backendUrl);
targetUrl.search = req.nextUrl.search;
// Forward relevant request headers
const forwardHeaders = new Headers();
req.headers.forEach((value, key) => {
if (!STRIP_REQUEST.has(key.toLowerCase())) {
forwardHeaders.set(key, value);
}
});
const isBodyless = req.method === "GET" || req.method === "HEAD";
let upstream: Response;
try {
upstream = await fetch(targetUrl.toString(), {
method: req.method,
headers: forwardHeaders,
body: isBodyless ? undefined : req.body,
// Required for streaming request bodies in Node.js fetch
// @ts-ignore
duplex: "half",
});
} catch (err) {
// Backend unreachable — return a clean 502 so the UI can handle it gracefully
return new NextResponse(JSON.stringify({ error: "Backend unavailable" }), {
status: 502,
headers: { "Content-Type": "application/json" },
});
}
// Forward response headers
const responseHeaders = new Headers();
upstream.headers.forEach((value, key) => {
if (!STRIP_RESPONSE.has(key.toLowerCase())) {
responseHeaders.set(key, value);
}
});
// 304 responses must have no body
if (upstream.status === 304) {
return new NextResponse(null, { status: 304, headers: responseHeaders });
}
return new NextResponse(upstream.body, {
status: upstream.status,
headers: responseHeaders,
});
}
export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
return proxy(req, (await params).path);
}
+9 -6
View File
@@ -10,6 +10,7 @@ import NewsFeed from "@/components/NewsFeed";
import MarketsPanel from "@/components/MarketsPanel";
import FilterPanel from "@/components/FilterPanel";
import FindLocateBar from "@/components/FindLocateBar";
import TopRightControls from "@/components/TopRightControls";
import RadioInterceptPanel from "@/components/RadioInterceptPanel";
import SettingsPanel from "@/components/SettingsPanel";
import MapLegend from "@/components/MapLegend";
@@ -28,7 +29,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
const [results, setResults] = useState<{ label: string; lat: number; lng: number }[]>([]);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout>>();
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => { if (open) inputRef.current?.focus(); }, [open]);
@@ -50,7 +51,7 @@ function LocateBar({ onLocate }: { onLocate: (lat: number, lng: number) => void
return;
}
// Geocode with Nominatim (debounced)
clearTimeout(timerRef.current);
if (timerRef.current) clearTimeout(timerRef.current);
if (q.trim().length < 2) { setResults([]); return; }
timerRef.current = setTimeout(async () => {
setLoading(true);
@@ -347,7 +348,7 @@ export default function Dashboard() {
// Adaptive polling: retry every 3s during startup, back off to normal cadence once data arrives
const scheduleNext = (tier: 'fast' | 'slow') => {
if (tier === 'fast') {
const delay = hasData ? 60000 : 3000; // 3s startup retry → 60s steady state
const delay = hasData ? 15000 : 3000; // 3s startup retry → 15s steady state
fastTimerId = setTimeout(fetchFastData, delay);
} else {
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
@@ -442,6 +443,8 @@ export default function Dashboard() {
{/* RIGHT HUD CONTAINER */}
<div className="absolute right-6 top-24 bottom-6 w-80 flex flex-col gap-4 z-[200] pointer-events-auto overflow-y-auto styled-scrollbar pr-2">
<TopRightControls />
{/* FIND / LOCATE */}
<div className="flex-shrink-0">
<FindLocateBar
@@ -489,8 +492,8 @@ export default function Dashboard() {
</div>
</div>
{/* BOTTOM CENTER COORDINATE / LOCATION BAR */}
<motion.div
{/* BOTTOM CENTER COORDINATE / LOCATION BAR — hidden when Sentinel-2 imagery overlay is open */}
{!(selectedEntity?.type === 'region_dossier' && regionDossier?.sentinel2) && <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 1, duration: 1 }}
@@ -546,7 +549,7 @@ export default function Dashboard() {
</div>
</div>
</div>
</motion.div>
</motion.div>}
</>
)}
+59 -24
View File
@@ -2,45 +2,60 @@
import React, { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Zap, Gauge, Anchor, Layers, Bug } from "lucide-react";
import { X, Zap, Shield, Satellite, MapPin, Palette, ToggleRight, Bug, Heart } from "lucide-react";
const CURRENT_VERSION = "0.7";
const CURRENT_VERSION = "0.8";
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
const NEW_FEATURES = [
{
icon: <Gauge size={14} className="text-green-400" />,
title: "Parallelized Data Fetches",
desc: "Stock and oil ticker fetches now run in parallel via ThreadPoolExecutor — backend data updates ~4x faster (~2s vs ~8s serial).",
color: "green",
icon: <Shield size={14} className="text-pink-400" />,
title: "POTUS Fleet Tracking",
desc: "Air Force One, Air Force Two, and Marine One aircraft now display with oversized hot-pink icons and a gold dashed halo ring — instantly recognizable on the map.",
color: "pink",
},
{
icon: <Anchor size={14} className="text-blue-400" />,
title: "AIS WebSocket Stability",
desc: "Exponential backoff now properly resets after 200 consecutive successes. Removed lock-contention vessel pruning — replaced with time-based logging every 60s.",
color: "blue",
},
{
icon: <Zap size={14} className="text-yellow-400" />,
title: "Deferred Icon Loading",
desc: "~35 critical map icons load immediately on startup. ~50 non-critical icons (fire markers, satellites, color variants) are deferred — faster initial map render.",
icon: <Palette size={14} className="text-yellow-400" />,
title: "Full Aircraft Color-Coding",
desc: "9-color system: military (yellow), medical/rescue (lime), police/government (blue), privacy (black), VIPs (hot pink), dictators/oligarchs (red), and more — all enriched from plane_alert_db.",
color: "yellow",
},
{
icon: <Layers size={14} className="text-cyan-400" />,
title: "Smarter Data Tiering",
desc: "Satellites removed from fast endpoint (was duplicated). Geopolitics polling reduced from 5min to 30min. Single-pass ETag serialization — clients get 304 Not Modified most of the time.",
icon: <Satellite size={14} className="text-green-400" />,
title: "Sentinel-2 Satellite Overhaul",
desc: "Replaced the tiny satellite popup with a fullscreen image overlay. Added Download, Copy to Clipboard, and Open Full Res buttons. Green dossier-themed UI.",
color: "green",
},
{
icon: <MapPin size={14} className="text-blue-400" />,
title: "Region Dossier & Carrier Fidelity",
desc: "Fixed Nominatim 429 rate-limit errors with retry/backoff. Carriers at shared homeports now dock at distinct pier positions instead of stacking.",
color: "blue",
},
{
icon: <Zap size={14} className="text-cyan-400" />,
title: "Overhauled Map Legend & Controls",
desc: "Full 9-color aircraft legend with POTUS fleet, wildfires, and infrastructure sections. New version badge, update checker, and Discussions shortcut in the UI.",
color: "cyan",
},
{
icon: <ToggleRight size={14} className="text-purple-400" />,
title: "Toggle All Data Layers",
desc: "One-click button to enable/disable all data layers at once. Turns cyan when active. MODIS Terra excluded from bulk toggle to prevent accidental imagery load.",
color: "purple",
},
];
const BUG_FIXES = [
"News feed entrance animations capped at 15 items — no more 100+ simultaneous Framer Motion instances",
"FIRMS fire hotspots and internet outages use heapq.nlargest() instead of full sort — faster processing of 60K+ records",
"Ship counts in left panel memoized with single-pass loop instead of 3 separate filter calls",
"Color map objects extracted to module-level constants — no allocation on every 2s tick",
"GDELT headline extraction improved — skips gibberish URL slugs and hex IDs",
"Multi-arch Docker images now available (amd64 + arm64) — runs on Raspberry Pi and Apple Silicon",
"POTUS fleet ICAO codes expanded — all Air Force Two (C-32A/B) airframes now correctly identified with gold halo",
"POTUS icon priority fixed — presidential aircraft always show the POTUS icon even when grounded",
"Sentinel-2 imagery no longer overlaps the bottom coordinate bar",
"Docker ENV format warnings resolved (legacy syntax → key=value)",
"Settings/Key/Version buttons now cyan in dark mode, grey only in light mode",
];
const CONTRIBUTORS = [
{ name: "@suranyami", desc: "Parallel multi-arch Docker builds (11min → 3min) + runtime BACKEND_URL fix", pr: "#35, #44" },
];
export function useChangelog() {
@@ -145,6 +160,26 @@ const ChangelogModal = React.memo(function ChangelogModal({ onClose }: Changelog
))}
</div>
</div>
{/* Contributors */}
<div>
<div className="text-[9px] font-mono tracking-[0.2em] text-pink-400 font-bold mb-3 flex items-center gap-2">
<Heart size={10} className="text-pink-400" />
COMMUNITY CONTRIBUTORS
</div>
<div className="space-y-1.5">
{CONTRIBUTORS.map((c, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded-lg border border-pink-500/20 bg-pink-500/5">
<span className="text-pink-400 text-[10px] mt-0.5 flex-shrink-0">&hearts;</span>
<div>
<span className="text-[10px] font-mono text-pink-300 font-bold">{c.name}</span>
<span className="text-[9px] font-mono text-[var(--text-muted)]"> {c.desc}</span>
<span className="text-[8px] font-mono text-[var(--text-muted)]"> (PR {c.pr})</span>
</div>
</div>
))}
</div>
</div>
</div>
{/* Footer */}
+6 -4
View File
@@ -89,12 +89,13 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
});
}
// Tracked flights
// Tracked flights — include tags/owner/name for broad search (first name, last name, etc.)
for (const f of data?.tracked_flights || []) {
const uid = f.icao24 || f.registration || f.callsign || '';
const operator = f.alert_operator || 'Unknown Operator';
const category = f.alert_category || 'Tracked';
const type = f.alert_type || f.model || 'Unknown';
const extras = [f.alert_tags, f.owner, f.name, f.callsign].filter(Boolean).join(' ');
results.push({
id: `tracked-${uid}`,
label: operator,
@@ -104,7 +105,8 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
lat: f.lat,
lng: f.lng,
entityType: "tracked_flight",
});
_extra: extras,
} as any);
}
// Ships
@@ -144,7 +146,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
const q = query.toLowerCase();
return allEntities
.filter(e => {
const searchable = `${e.label} ${e.sublabel} ${e.id}`.toLowerCase();
const searchable = `${e.label} ${e.sublabel} ${e.id} ${(e as any)._extra || ''}`.toLowerCase();
return searchable.includes(q);
})
.slice(0, 12);
@@ -177,7 +179,7 @@ export default function FindLocateBar({ data, onLocate, onFilter }: FindLocateBa
ref={inputRef}
type="text"
value={query}
placeholder="Find aircraft or vessel..."
placeholder="Find aircraft, person or vessel..."
className="flex-1 bg-transparent text-[10px] text-[var(--text-secondary)] font-mono tracking-wider outline-none placeholder:text-[var(--text-muted)]"
onChange={(e) => {
setQuery(e.target.value);
+183 -95
View File
@@ -95,7 +95,12 @@ const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns=
const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><circle cx="16" cy="16" r="15" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,4)"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z" fill="#FF1493" stroke="black"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></g></svg>`)}`;
// POTUS fleet ICAO hex codes (verified FAA registry)
const POTUS_ICAOS = new Set(['ADFDF8','ADFDF9','AE0865','AE5E76','AE5E77','AE5E79']);
const POTUS_ICAOS = new Set([
'ADFDF8','ADFDF9', // Air Force One (VC-25A)
'ADFEB7','ADFEB8','ADFEB9','ADFEBA', // Air Force Two (C-32A)
'AE4AE6','AE4AE8','AE4AEA','AE4AEC', // Air Force Two (C-32B)
'AE0865','AE5E76','AE5E77','AE5E79', // Marine One (VH-3D / VH-92A)
]);
// Pre-built aircraft SVGs by type & color
const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
@@ -334,10 +339,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
dataTimestamp.current = Date.now();
}, [data?.commercial_flights, data?.ships, data?.satellites]);
// Tick every 2s between data refreshes to animate positions
// Tick every 1s between data refreshes to animate positions
// Satellites move ~7km/s so need frequent updates for smooth motion
useEffect(() => {
const timer = setInterval(() => setInterpTick(t => t + 1), 2000);
const timer = setInterval(() => setInterpTick(t => t + 1), 1000);
return () => clearInterval(timer);
}, []);
@@ -566,8 +571,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
type: 'datacenter',
name: dc.name || 'Unknown',
company: dc.company || '',
street: dc.street || '',
city: dc.city || '',
country: dc.country || '',
zip: dc.zip || '',
},
geometry: { type: 'Point' as const, coordinates: [dc.lng, dc.lat] }
}))
@@ -733,10 +740,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
// Helper: interpolate a flight's position if airborne and has speed+heading
const interpFlight = (f: any): [number, number] => {
// Fast path: skip trig for stationary/grounded/no-speed aircraft
if (!f.speed_knots || f.speed_knots <= 0 || dtSeconds <= 0) return [f.lng, f.lat];
if (f.alt != null && f.alt <= 100) return [f.lng, f.lat];
// Only interpolate if enough time has passed to matter (>1s)
if (dtSeconds < 1) return [f.lng, f.lat];
const heading = f.true_track || f.heading || 0;
const [newLat, newLng] = interpolatePosition(f.lat, f.lng, heading, f.speed_knots, dtSeconds);
@@ -752,8 +757,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
// Helper: interpolate a satellite's position between API updates
// Satellites have deterministic orbits so linear interpolation over 60s is accurate
// maxDt=65 allows full interval coverage (60s update + 5s buffer)
const interpSat = (s: any): [number, number] => {
if (!s.speed_knots || s.speed_knots <= 0 || dtSeconds < 1) return [s.lng, s.lat];
const [newLat, newLng] = interpolatePosition(s.lat, s.lng, s.heading || 0, s.speed_knots, dtSeconds, 0, 65);
@@ -768,15 +771,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
features: data.satellites.filter((s: any) => s.lat != null && s.lng != null && inView(s.lat, s.lng)).map((s: any, i: number) => ({
type: 'Feature' as const,
properties: {
id: s.id || i,
type: 'satellite',
name: s.name,
mission: s.mission || 'general',
sat_type: s.sat_type || 'Satellite',
country: s.country || '',
alt_km: s.alt_km || 0,
wiki: s.wiki || '',
color: MISSION_COLORS[s.mission] || '#aaaaaa',
id: s.id || i, type: 'satellite', name: s.name, mission: s.mission || 'general',
sat_type: s.sat_type || 'Satellite', country: s.country || '', alt_km: s.alt_km || 0,
wiki: s.wiki || '', color: MISSION_COLORS[s.mission] || '#aaaaaa',
iconId: MISSION_ICON_MAP[s.mission] || 'sat-gen'
},
geometry: { type: 'Point' as const, coordinates: interpSat(s) }
@@ -784,8 +781,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
};
}, [activeLayers.satellites, data?.satellites, dtSeconds, inView]);
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
const commFlightsGeoJSON = useMemo(() => {
if (!activeLayers.flights || !data?.commercial_flights) return null;
return {
@@ -848,7 +843,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const milFlightsGeoJSON = useMemo(() => {
if (!activeLayers.military || !data?.military_flights) return null;
return {
type: 'FeatureCollection',
features: data.military_flights.map((f: any, i: number) => {
@@ -877,34 +871,21 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
const shipsGeoJSON = useMemo(() => {
if (!(activeLayers.ships_important || activeLayers.ships_civilian || activeLayers.ships_passenger) || !data?.ships) return null;
return {
type: 'FeatureCollection',
features: data.ships.map((s: any, i: number) => {
if (s.lat == null || s.lng == null) return null;
if (!inView(s.lat, s.lng)) return null;
const isImportant = s.type === 'carrier' || s.type === 'military_vessel' || s.type === 'tanker' || s.type === 'cargo';
const isPassenger = s.type === 'passenger';
// Carriers are now handled by a dedicated unclustered source
if (s.type === 'carrier') return null;
if (isImportant && activeLayers?.ships_important === false) return null;
if (isPassenger && activeLayers?.ships_passenger === false) return null;
if (!isImportant && !isPassenger && activeLayers?.ships_civilian === false) return null;
let iconId = 'svgShipBlue';
if (s.type === 'carrier') {
iconId = 'svgCarrier';
} else if (s.type === 'tanker' || s.type === 'cargo') {
iconId = 'svgShipRed';
} else if (s.type === 'yacht' || s.type === 'passenger') {
iconId = 'svgShipWhite';
} else if (s.type === 'military_vessel') {
iconId = 'svgShipYellow';
}
if (s.type === 'tanker' || s.type === 'cargo') iconId = 'svgShipRed';
else if (s.type === 'yacht' || s.type === 'passenger') iconId = 'svgShipWhite';
else if (s.type === 'military_vessel') iconId = 'svgShipYellow';
const [iLng, iLat] = interpShip(s);
return {
type: 'Feature',
@@ -994,11 +975,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
type: 'FeatureCollection',
features: data.ships.map((s: any, i: number) => {
if (s.type !== 'carrier' || s.lat == null || s.lng == null) return null;
const [iLng, iLat] = interpShip(s);
return {
type: 'Feature',
properties: { id: i, type: 'ship', name: s.name, rotation: s.heading || 0, iconId: 'svgCarrier' },
geometry: { type: 'Point', coordinates: [iLng, iLat] }
geometry: { type: 'Point', coordinates: [s.lng, s.lat] }
};
}).filter(Boolean)
};
@@ -1030,12 +1010,22 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}
const features = [];
// Extract IATA codes from "IATA: Airport Name" format
const originCode = (entity.origin_name || '').split(':')[0]?.trim() || '';
const destCode = (entity.dest_name || '').split(':')[0]?.trim() || '';
if (originLoc) {
features.push({
type: 'Feature',
properties: { type: 'route-origin' },
geometry: { type: 'LineString', coordinates: [currentLoc, originLoc] }
});
// Airport dot at origin
features.push({
type: 'Feature',
properties: { type: 'airport', code: originCode, role: 'DEP' },
geometry: { type: 'Point', coordinates: originLoc }
});
}
if (destLoc) {
features.push({
@@ -1043,6 +1033,12 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
properties: { type: 'route-dest' },
geometry: { type: 'LineString', coordinates: [currentLoc, destLoc] }
});
// Airport dot at destination
features.push({
type: 'Feature',
properties: { type: 'airport', code: destCode, role: 'ARR' },
geometry: { type: 'Point', coordinates: destLoc }
});
}
if (features.length === 0) return null;
@@ -1185,6 +1181,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}));
}, [data?.news, Math.round(viewState.zoom)]);
// Tracked flights GeoJSON with interpolation
const trackedFlightsGeoJSON = useMemo(() => {
if (!activeLayers.tracked || !data?.tracked_flights) return null;
@@ -1196,30 +1193,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
bizjet: { '#ff1493': 'svgBizjetPink', pink: 'svgBizjetPink', red: 'svgBizjetRed', blue: 'svgBizjetBlue', darkblue: 'svgBizjetDarkBlue', yellow: 'svgBizjetYellow', orange: 'svgBizjetOrange', purple: 'svgBizjetPurple', '#32cd32': 'svgBizjetLime', black: 'svgBizjetBlack', white: 'svgBizjetWhite' },
};
return {
type: 'FeatureCollection',
features: data.tracked_flights.map((f: any, i: number) => {
if (f.lat == null || f.lng == null) return null;
const features: any[] = [];
for (let i = 0; i < data.tracked_flights.length; i++) {
const f = data.tracked_flights[i];
if (f.lat == null || f.lng == null) continue;
const alertColor = f.alert_color || 'white';
const acType = classifyAircraft(f.model, f.aircraft_category);
const grounded = f.alt != null && f.alt <= 100;
const icaoHex = (f.icao24 || '').toUpperCase();
// POTUS fleet gets oversized gold-ringed icon
const isPotus = POTUS_ICAOS.has(icaoHex);
const potusIcon = acType === 'heli' ? 'svgPotusHeli' : 'svgPotusPlane';
const iconId = grounded ? GROUNDED_ICON_MAP[acType] : isPotus ? potusIcon : (trackedIconMap[acType]?.[alertColor] || trackedIconMap.airliner[alertColor] || 'svgAirlinerWhite');
const [lng, lat] = interpFlight(f);
const alertColor = f.alert_color || 'white';
const acType = classifyAircraft(f.model, f.aircraft_category);
const grounded = f.alt != null && f.alt <= 100;
const icaoHex = (f.icao24 || '').toUpperCase();
const isPotus = POTUS_ICAOS.has(icaoHex);
const potusIcon = acType === 'heli' ? 'svgPotusHeli' : 'svgPotusPlane';
const iconId = isPotus ? potusIcon : grounded ? GROUNDED_ICON_MAP[acType] : (trackedIconMap[acType]?.[alertColor] || trackedIconMap.airliner[alertColor] || 'svgAirlinerWhite');
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
const [iLng, iLat] = interpFlight(f);
return {
type: 'Feature',
properties: { id: i, type: 'tracked_flight', callsign: String(displayName), rotation: f.heading || 0, iconId },
geometry: { type: 'Point', coordinates: [iLng, iLat] }
};
}).filter(Boolean)
};
features.push({
type: 'Feature',
properties: { id: i, type: 'tracked_flight', callsign: String(displayName), rotation: f.heading || 0, iconId },
geometry: { type: 'Point', coordinates: [lng, lat] }
});
}
return { type: 'FeatureCollection', features };
}, [activeLayers.tracked, data?.tracked_flights, dtSeconds]);
const uavGeoJSON = useMemo(() => {
@@ -1294,6 +1289,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
// Interactive layer IDs for click handling
const activeInteractiveLayerIds = [
commFlightsGeoJSON && 'commercial-flights-layer',
privFlightsGeoJSON && 'private-flights-layer',
@@ -1317,19 +1313,15 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
].filter(Boolean) as string[];
// --- Imperative source updates for high-volume layers ---
// Bypasses React reconciliation of huge GeoJSON FeatureCollections.
// The <Source data={EMPTY_FC}> mounts the source; the hook pushes real data.
// --- Imperative source updates: bypass React reconciliation for GeoJSON layers ---
const mapForHook = mapReady ? mapRef.current : null;
// Flights & UAVs: immediate (they move fast, stale = visually wrong)
useImperativeSource(mapForHook, 'commercial-flights', commFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-flights', privFlightsGeoJSON);
useImperativeSource(mapForHook, 'private-jets', privJetsGeoJSON);
useImperativeSource(mapForHook, 'military-flights', milFlightsGeoJSON);
useImperativeSource(mapForHook, 'tracked-flights', trackedFlightsGeoJSON);
useImperativeSource(mapForHook, 'uavs', uavGeoJSON);
// Satellites & fires: 2s debounce (slow-changing, high feature count)
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON, 2000);
useImperativeSource(mapForHook, 'satellites', satellitesGeoJSON);
useImperativeSource(mapForHook, 'firms-fires', firmsGeoJSON, 2000);
const handleMouseMove = useCallback((evt: any) => {
@@ -1353,7 +1345,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
onViewStateChange?.({ zoom: evt.viewState.zoom, latitude: evt.viewState.latitude });
// Debounce bounds update to avoid thrashing during drag
if (boundsTimerRef.current) clearTimeout(boundsTimerRef.current);
boundsTimerRef.current = setTimeout(updateBounds, 300);
boundsTimerRef.current = setTimeout(updateBounds, 500);
}}
onMouseMove={handleMouseMove}
onContextMenu={(evt) => {
@@ -1409,6 +1401,25 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
/>
</Source>
)}
{/* Esri Reference Overlay — borders, labels, cities on top of satellite imagery */}
{activeLayers.highres_satellite && (
<Source
id="esri-reference-overlay"
type="raster"
tiles={['https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}']}
tileSize={256}
maxzoom={18}
>
<Layer
id="esri-reference-overlay-layer"
type="raster"
paint={{
'raster-opacity': 0.9,
'raster-fade-duration': 300
}}
/>
</Source>
)}
{/* NASA GIBS MODIS Terra — daily satellite imagery overlay */}
{activeLayers.gibs_imagery && gibsDate && (
@@ -1635,12 +1646,13 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
<Layer
id="active-route-layer"
type="line"
filter={['in', ['get', 'type'], ['literal', ['route-origin', 'route-dest']]]}
paint={{
'line-color': [
'match',
['get', 'type'],
'route-origin', '#38bdf8', // light blue
'route-dest', '#fcd34d', // yellow
'route-origin', '#38bdf8',
'route-dest', '#fcd34d',
'#ffffff'
],
'line-width': 2,
@@ -1648,6 +1660,38 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
'line-opacity': 0.8
}}
/>
{/* Airport dots at origin/destination */}
<Layer
id="airport-dots"
type="circle"
filter={['==', ['get', 'type'], 'airport']}
paint={{
'circle-radius': 5,
'circle-color': ['match', ['get', 'role'], 'DEP', '#38bdf8', 'ARR', '#fcd34d', '#ffffff'],
'circle-stroke-color': '#000',
'circle-stroke-width': 1.5,
'circle-opacity': 0.9
}}
/>
{/* IATA code labels at airports */}
<Layer
id="airport-labels"
type="symbol"
filter={['==', ['get', 'type'], 'airport']}
layout={{
'text-field': ['get', 'code'],
'text-font': ['Noto Sans Bold'],
'text-size': 11,
'text-offset': [0, -1.4],
'text-anchor': 'bottom',
'text-allow-overlap': true,
}}
paint={{
'text-color': ['match', ['get', 'role'], 'DEP', '#38bdf8', 'ARR', '#fcd34d', '#ffffff'],
'text-halo-color': '#000',
'text-halo-width': 1.5,
}}
/>
</Source>
)}
@@ -1668,12 +1712,33 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
{/* tracked-flights & UAVs: data pushed imperatively */}
<Source id="tracked-flights" type="geojson" data={EMPTY_FC as any}>
{/* Gold halo ring — POTUS aircraft only (Air Force One/Two, Marine One) */}
<Layer
id="tracked-flights-halo"
type="circle"
filter={['any',
['==', ['get', 'iconId'], 'svgPotusPlane'],
['==', ['get', 'iconId'], 'svgPotusHeli'],
]}
paint={{
'circle-radius': 18,
'circle-color': 'transparent',
'circle-stroke-width': 2,
'circle-stroke-color': 'gold',
'circle-stroke-opacity': opacityFilter,
'circle-opacity': 0,
}}
/>
<Layer
id="tracked-flights-layer"
type="symbol"
layout={{
'icon-image': ['get', 'iconId'],
'icon-size': 0.8,
'icon-size': ['case',
['==', ['get', 'iconId'], 'svgPotusPlane'], 1.3,
['==', ['get', 'iconId'], 'svgPotusHeli'], 1.3,
0.8
],
'icon-allow-overlap': true,
'icon-rotate': ['get', 'rotation'],
'icon-rotation-alignment': 'map'
@@ -2463,12 +2528,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Speed: <span style={{ color: '#00e5ff' }}>{ship.sog.toFixed(1)} kn</span>
</div>
)}
{ship.heading != null && (
<div style={{ marginBottom: 4 }}>
Heading: <span style={{ color: '#888' }}>{Math.round(ship.heading)}°</span>
<div style={{ marginBottom: 4 }}>
Heading: <span style={{ color: ship.heading != null ? '#888' : '#ff6644' }}>
{ship.heading != null ? `${Math.round(ship.heading)}°` : 'UNKNOWN'}
</span>
</div>
{ship.type === 'carrier' && ship.source && (
<div style={{ marginTop: 6, padding: '5px 7px', background: 'rgba(255,170,0,0.08)', border: '1px solid rgba(255,170,0,0.3)', borderRadius: 4, fontSize: 9, letterSpacing: 1 }}>
<div style={{ color: '#ffaa00', marginBottom: 3 }}>
SOURCE: {ship.source_url ? (
<a href={ship.source_url} target="_blank" rel="noopener noreferrer"
style={{ color: '#00e5ff', textDecoration: 'underline' }}>{ship.source}</a>
) : (
<span style={{ color: '#fff' }}>{ship.source}</span>
)}
</div>
{ship.last_osint_update && (
<div style={{ color: '#888' }}>LAST OSINT UPDATE: {new Date(ship.last_osint_update).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</div>
)}
{ship.desc && (
<div style={{ color: '#aaa', marginTop: 3, fontSize: 8, lineHeight: 1.3 }}>{ship.desc}</div>
)}
</div>
)}
{ship.last_osint_update && (
{ship.type !== 'carrier' && ship.last_osint_update && (
<div style={{ marginBottom: 4 }}>
Last OSINT Update: <span style={{ color: '#888' }}>{new Date(ship.last_osint_update).toLocaleDateString()}</span>
</div>
@@ -2505,6 +2588,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
Operator: <span style={{ color: '#c4b5fd' }}>{dc.company}</span>
</div>
)}
{dc.street && (
<div style={{ marginBottom: 4 }}>
Address: <span style={{ color: '#fff' }}>{dc.street}{dc.zip ? ` ${dc.zip}` : ''}</span>
</div>
)}
{dc.city && (
<div style={{ marginBottom: 4 }}>
Location: <span style={{ color: '#fff' }}>{dc.city}{dc.country ? `, ${dc.country}` : ''}</span>
@@ -2741,7 +2829,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '60px 20px 20px 20px',
padding: '60px 20px 80px 20px',
}}
onClick={(e) => { if (e.target === e.currentTarget) onEntityClick(null); }}
onKeyDown={(e: any) => { if (e.key === 'Escape') onEntityClick(null); }}
@@ -2750,14 +2838,14 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
>
<div style={{
background: 'rgba(0,0,0,0.95)',
border: '1px solid rgba(59,130,246,0.5)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 12,
overflow: 'hidden',
maxWidth: 'calc(100vw - 40px)',
maxHeight: 'calc(100vh - 80px)',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 0 60px rgba(59,130,246,0.3)',
boxShadow: '0 0 60px rgba(34,197,94,0.3)',
}}>
{/* Header bar */}
<div style={{
@@ -2765,17 +2853,17 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
alignItems: 'center',
justifyContent: 'space-between',
padding: '10px 16px',
background: 'rgba(30,58,138,0.4)',
borderBottom: '1px solid rgba(59,130,246,0.3)',
background: 'rgba(20,83,45,0.4)',
borderBottom: '1px solid rgba(34,197,94,0.3)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#60a5fa', animation: 'pulse 2s infinite' }} />
<span style={{ fontSize: 11, color: '#60a5fa', fontFamily: 'monospace', letterSpacing: '0.2em', fontWeight: 'bold' }}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#4ade80', animation: 'pulse 2s infinite' }} />
<span style={{ fontSize: 11, color: '#4ade80', fontFamily: 'monospace', letterSpacing: '0.2em', fontWeight: 'bold' }}>
SENTINEL-2 IMAGERY
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 10, color: 'rgba(147,197,253,0.6)', fontFamily: 'monospace' }}>
<span style={{ fontSize: 10, color: 'rgba(134,239,172,0.6)', fontFamily: 'monospace' }}>
{selectedEntity.extra.lat.toFixed(4)}, {selectedEntity.extra.lng.toFixed(4)}
</span>
<button
@@ -2807,11 +2895,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
padding: '8px 16px',
fontSize: 11,
fontFamily: 'monospace',
borderBottom: '1px solid rgba(30,58,138,0.4)',
borderBottom: '1px solid rgba(20,83,45,0.4)',
}}>
<span style={{ color: '#93c5fd' }}>{s2.platform}</span>
<span style={{ color: '#22d3ee', fontWeight: 'bold' }}>{s2.datetime?.slice(0, 10)}</span>
<span style={{ color: '#93c5fd' }}>{s2.cloud_cover?.toFixed(0)}% cloud</span>
<span style={{ color: '#86efac' }}>{s2.platform}</span>
<span style={{ color: '#4ade80', fontWeight: 'bold' }}>{s2.datetime?.slice(0, 10)}</span>
<span style={{ color: '#86efac' }}>{s2.cloud_cover?.toFixed(0)}% cloud</span>
</div>
{/* Image */}
@@ -2829,7 +2917,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
/>
</div>
) : (
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(134,239,172,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
Scene found no preview available
</div>
)}
@@ -2842,8 +2930,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
justifyContent: 'center',
gap: 12,
padding: '10px 16px',
background: 'rgba(30,58,138,0.3)',
borderTop: '1px solid rgba(59,130,246,0.2)',
background: 'rgba(20,83,45,0.3)',
borderTop: '1px solid rgba(34,197,94,0.2)',
}}>
<a
href={imgUrl}
@@ -2851,10 +2939,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
target="_blank"
rel="noopener noreferrer"
style={{
background: 'rgba(59,130,246,0.2)',
border: '1px solid rgba(59,130,246,0.5)',
background: 'rgba(34,197,94,0.2)',
border: '1px solid rgba(34,197,94,0.5)',
borderRadius: 6,
color: '#60a5fa',
color: '#4ade80',
fontSize: 10,
fontFamily: 'monospace',
padding: '6px 16px',
@@ -2880,10 +2968,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
}
}}
style={{
background: 'rgba(34,211,238,0.15)',
border: '1px solid rgba(34,211,238,0.4)',
background: 'rgba(34,197,94,0.15)',
border: '1px solid rgba(34,197,94,0.4)',
borderRadius: 6,
color: '#22d3ee',
color: '#4ade80',
fontSize: 10,
fontFamily: 'monospace',
padding: '6px 16px',
@@ -2918,7 +3006,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
)}
</>
) : (
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(134,239,172,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
No clear imagery in last 30 days
</div>
)}
+2 -2
View File
@@ -14,7 +14,7 @@ const _cache: Record<string, { url: string | null; done: boolean }> = {};
* maxH: Max height class (default "max-h-32")
* accent: Border hover color class (default "hover:border-cyan-500/50")
*/
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent = 'hover:border-cyan-500/50' }: {
export default function WikiImage({ wikiUrl, label, maxH = 'max-h-52', accent = 'hover:border-cyan-500/50' }: {
wikiUrl: string;
label?: string;
maxH?: string;
@@ -56,7 +56,7 @@ export default function WikiImage({ wikiUrl, label, maxH = 'max-h-32', accent =
<img
src={imgUrl}
alt={label || title.replace(/_/g, ' ')}
className={`w-full h-auto ${maxH} object-cover rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
className={`w-full h-auto ${maxH} object-contain rounded border border-[var(--border-primary)]/50 ${accent} transition-colors`}
/>
</a>
)}
+108 -60
View File
@@ -2,7 +2,8 @@
import React, { useState, useEffect, useRef, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield } from "lucide-react";
import { Plane, AlertTriangle, Activity, Satellite, Cctv, ChevronDown, ChevronUp, Ship, Eye, Anchor, Settings, Sun, Moon, BookOpen, Radio, Play, Pause, Globe, Flame, Wifi, Server, Shield, ToggleLeft, ToggleRight } from "lucide-react";
import packageJson from "../../package.json";
import { useTheme } from "@/lib/ThemeContext";
function relativeTime(iso: string | undefined): string {
@@ -62,6 +63,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
const [isMinimized, setIsMinimized] = useState(false);
const { theme, toggleTheme } = useTheme();
const [gibsPlaying, setGibsPlaying] = useState(false);
const [potusEnabled, setPotusEnabled] = useState(true);
const gibsIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
// GIBS time slider play/pause animation
@@ -124,7 +126,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{ id: "military", name: "Military Flights", source: "adsb.lol", count: data?.military_flights?.length || 0, icon: AlertTriangle },
{ id: "tracked", name: "Tracked Aircraft", source: "Plane-Alert DB", count: data?.tracked_flights?.length || 0, icon: Eye },
{ id: "earthquakes", name: "Earthquakes (24h)", source: "USGS", count: data?.earthquakes?.length || 0, icon: Activity },
{ id: "satellites", name: "Satellites", source: "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
{ id: "satellites", name: "Satellites", source: data?.satellite_source === "celestrak" ? "CelesTrak SGP4" : data?.satellite_source === "tle_api" ? "TLE API · SGP4" : data?.satellite_source === "disk_cache" ? "Cached · SGP4 (est.)" : "CelesTrak SGP4", count: data?.satellites?.length || 0, icon: Satellite },
{ id: "ships_important", name: "Carriers / Mil / Cargo", source: "AIS Stream", count: importantShipCount, icon: Ship },
{ id: "ships_civilian", name: "Civilian Vessels", source: "AIS Stream", count: civilianShipCount, icon: Anchor },
{ id: "ships_passenger", name: "Cruise / Passenger", source: "AIS Stream", count: passengerShipCount, icon: Anchor },
@@ -158,7 +160,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
<h1 className="text-2xl font-bold tracking-[0.2em] text-[var(--text-heading)]">FLIR</h1>
<button
onClick={toggleTheme}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun size={14} /> : <Moon size={14} />}
@@ -166,7 +168,7 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onSettingsClick && (
<button
onClick={onSettingsClick}
className="w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)] group"
className={`w-7 h-7 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)] group`}
title="System Settings"
>
<Settings size={14} className="group-hover:rotate-90 transition-transform duration-300" />
@@ -175,13 +177,16 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{onLegendClick && (
<button
onClick={onLegendClick}
className="h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 text-[var(--text-muted)] hover:text-cyan-400 transition-all hover:bg-[var(--hover-accent)]"
className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] hover:border-cyan-500/50 flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-300 transition-all hover:bg-[var(--hover-accent)]`}
title="Map Legend / Icon Key"
>
<BookOpen size={12} />
<span className="text-[8px] font-mono tracking-widest font-bold">KEY</span>
</button>
)}
<span className={`h-7 px-2 rounded-lg border border-[var(--border-primary)] flex items-center justify-center text-[8px] ${theme === 'dark' ? 'text-cyan-400' : 'text-[var(--text-muted)]'} font-mono tracking-widest select-none`}>
v{packageJson.version}
</span>
</div>
</div>
@@ -191,12 +196,30 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
{/* Header / Toggle */}
<div
className="flex justify-between items-center p-4 cursor-pointer hover:bg-[var(--bg-secondary)]/50 transition-colors border-b border-[var(--border-primary)]/50"
onClick={() => setIsMinimized(!isMinimized)}
>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">DATA LAYERS</span>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors">
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest" onClick={() => setIsMinimized(!isMinimized)}>DATA LAYERS</span>
<div className="flex items-center gap-2">
<button
title={Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? "Disable all layers" : "Enable all layers"}
className={`${Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? 'text-cyan-400' : 'text-[var(--text-muted)]'} hover:text-cyan-400 transition-colors`}
onClick={(e) => {
e.stopPropagation();
const allOn = Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v);
setActiveLayers((prev: any) => {
const next: any = {};
for (const k of Object.keys(prev)) {
next[k] = k === 'gibs_imagery' ? false : !allOn;
}
return next;
});
}}
>
{Object.entries(activeLayers).filter(([k]) => k !== 'gibs_imagery').every(([, v]) => v) ? <ToggleRight size={16} /> : <ToggleLeft size={16} />}
</button>
<button className="text-[var(--text-muted)] hover:text-[var(--text-primary)] transition-colors" onClick={() => setIsMinimized(!isMinimized)}>
{isMinimized ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</button>
</div>
</div>
<AnimatePresence>
@@ -208,6 +231,61 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
className="overflow-y-auto styled-scrollbar"
>
<div className="flex flex-col gap-6 p-4 pt-2 pb-6">
{/* POTUS Fleet — pinned to TOP when aircraft are active */}
{potusEnabled && potusFlights.length > 0 && (
<div className="bg-[#ff1493]/5 border border-[#ff1493]/30 rounded-lg p-3 -mt-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[#ff1493]" />
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(false); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
title="Hide POTUS Fleet tracker"
>
HIDE
</button>
</div>
<div className="flex flex-col gap-2">
{potusFlights.map((pf) => {
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
const speed = pf.flight.gs || pf.flight.speed || 0;
return (
<div
key={pf.flight.icao24}
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
style={{ borderColor: `${color}40`, background: `${color}10` }}
onClick={() => {
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
onFlyTo(pf.flight.lat, pf.flight.lng);
}
if (onEntityClick) {
onEntityClick({ type: 'tracked_flight', id: pf.index });
}
}}
>
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
</div>
</div>
);
})}
</div>
</div>
)}
{layers.map((layer, idx) => {
const Icon = layer.icon;
const active = activeLayers[layer.id as keyof typeof activeLayers] || false;
@@ -294,57 +372,27 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
)
})}
{/* POTUS Fleet Tracker */}
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
<div className="flex items-center gap-2 mb-3">
<Shield size={14} className="text-[#ff1493]" />
<span className="text-[10px] text-[#ff1493] font-mono tracking-widest font-bold">POTUS FLEET</span>
{potusFlights.length > 0 && (
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded-full bg-[#ff1493]/20 border border-[#ff1493]/40 text-[#ff1493] animate-pulse">
{potusFlights.length} ACTIVE
</span>
)}
{/* POTUS Fleet — bottom section when inactive or hidden */}
{(potusFlights.length === 0 || !potusEnabled) && (
<div className="border-t border-[var(--border-primary)]/50 pt-4 mt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield size={14} className="text-[var(--text-muted)]" />
<span className="text-[10px] text-[var(--text-muted)] font-mono tracking-widest">POTUS FLEET</span>
</div>
{!potusEnabled ? (
<button
onClick={(e) => { e.stopPropagation(); setPotusEnabled(true); }}
className="text-[8px] font-mono text-[var(--text-muted)] hover:text-[#ff1493] border border-[var(--border-primary)] hover:border-[#ff1493]/40 rounded px-1.5 py-0.5 transition-colors"
>
SHOW
</button>
) : (
<span className="text-[8px] font-mono text-[var(--text-muted)]">NO ACTIVE AIRCRAFT</span>
)}
</div>
</div>
{potusFlights.length === 0 ? (
<div className="ml-5 text-[9px] text-[var(--text-muted)] font-mono">
No POTUS fleet aircraft currently airborne
</div>
) : (
<div className="flex flex-col gap-2 ml-1">
{potusFlights.map((pf) => {
const color = pf.meta.type === 'AF1' ? '#ff1493' : pf.meta.type === 'M1' ? '#ff1493' : '#3b82f6';
const alt = pf.flight.alt_baro || pf.flight.alt || 0;
const speed = pf.flight.gs || pf.flight.speed || 0;
return (
<div
key={pf.flight.icao24}
className="flex items-center justify-between p-2 rounded-lg border cursor-pointer transition-all hover:bg-[var(--bg-secondary)]/60"
style={{ borderColor: `${color}40`, background: `${color}10` }}
onClick={() => {
if (onFlyTo && pf.flight.lat != null && pf.flight.lng != null) {
onFlyTo(pf.flight.lat, pf.flight.lng);
}
if (onEntityClick) {
onEntityClick({ type: 'tracked_flight', id: pf.index });
}
}}
>
<div className="flex flex-col">
<span className="text-[10px] font-bold font-mono" style={{ color }}>{pf.meta.label}</span>
<span className="text-[8px] text-[var(--text-muted)] font-mono mt-0.5">
{alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'}
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ backgroundColor: color }} />
<span className="text-[8px] font-mono" style={{ color }}>TRACK</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</motion.div>
)}