mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-04-28 22:18:21 +02:00
Merge branch 'BigBodyCobain:main' into main
Former-commit-id: 5c49568921
This commit is contained in:
@@ -9,7 +9,11 @@
|
||||
---
|
||||
|
||||
|
||||

|
||||
|
||||
|
||||
https://github.com/user-attachments/assets/248208ec-62f7-49d1-831d-4bd0a1fa6852
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +25,7 @@ Built with **Next.js**, **MapLibre GL**, **FastAPI**, and **Python**, it's desig
|
||||
|
||||
## Interesting Use Cases
|
||||
|
||||
* Track private jets of billionaires
|
||||
* Track everything from Air Force One to the private jets of billionaires, dictators, and corporations
|
||||
* Monitor satellites passing overhead and see high-resolution satellite imagery
|
||||
* Nose around local emergency scanners
|
||||
* Watch naval traffic worldwide
|
||||
|
||||
@@ -1,12 +1,44 @@
|
||||
{
|
||||
"feeds": [
|
||||
{ "name": "NPR", "url": "https://feeds.npr.org/1004/rss.xml", "weight": 4 },
|
||||
{ "name": "BBC", "url": "http://feeds.bbci.co.uk/news/world/rss.xml", "weight": 3 },
|
||||
{ "name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2 },
|
||||
{ "name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1 },
|
||||
{ "name": "GDACS", "url": "https://www.gdacs.org/xml/rss.xml", "weight": 5 },
|
||||
{ "name": "NHK", "url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml", "weight": 3 },
|
||||
{ "name": "CNA", "url": "https://www.channelnewsasia.com/rssfeed/8395986", "weight": 3 },
|
||||
{ "name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3 }
|
||||
{
|
||||
"name": "NPR",
|
||||
"url": "https://feeds.npr.org/1004/rss.xml",
|
||||
"weight": 4
|
||||
},
|
||||
{
|
||||
"name": "BBC",
|
||||
"url": "http://feeds.bbci.co.uk/news/world/rss.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "AlJazeera",
|
||||
"url": "https://www.aljazeera.com/xml/rss/all.xml",
|
||||
"weight": 2
|
||||
},
|
||||
{
|
||||
"name": "NYT",
|
||||
"url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml",
|
||||
"weight": 1
|
||||
},
|
||||
{
|
||||
"name": "GDACS",
|
||||
"url": "https://www.gdacs.org/xml/rss.xml",
|
||||
"weight": 5
|
||||
},
|
||||
{
|
||||
"name": "NHK",
|
||||
"url": "https://www3.nhk.or.jp/nhkworld/rss/world.xml",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "CNA",
|
||||
"url": "https://www.channelnewsasia.com/rssfeed/8395986",
|
||||
"weight": 3
|
||||
},
|
||||
{
|
||||
"name": "Mercopress",
|
||||
"url": "https://en.mercopress.com/rss/",
|
||||
"weight": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
38a18cbbf1acbec5eb9266b809c28d31e2941c53
|
||||
+57
-33
@@ -1,3 +1,40 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker Swarm Secrets support
|
||||
# For each VAR below, if VAR_FILE is set (e.g. AIS_API_KEY_FILE=/run/secrets/AIS_API_KEY),
|
||||
# the file is read and its trimmed content is placed into VAR.
|
||||
# This MUST run before service imports — modules read os.environ at import time.
|
||||
# ---------------------------------------------------------------------------
|
||||
_SECRET_VARS = [
|
||||
"AIS_API_KEY",
|
||||
"OPENSKY_CLIENT_ID",
|
||||
"OPENSKY_CLIENT_SECRET",
|
||||
"LTA_ACCOUNT_KEY",
|
||||
"CORS_ORIGINS",
|
||||
]
|
||||
|
||||
for _var in _SECRET_VARS:
|
||||
_file_var = f"{_var}_FILE"
|
||||
_file_path = os.environ.get(_file_var)
|
||||
if _file_path:
|
||||
try:
|
||||
with open(_file_path, "r") as _f:
|
||||
_value = _f.read().strip()
|
||||
if _value:
|
||||
os.environ[_var] = _value
|
||||
logger.info(f"Loaded secret {_var} from {_file_path}")
|
||||
else:
|
||||
logger.warning(f"Secret file {_file_path} for {_var} is empty")
|
||||
except FileNotFoundError:
|
||||
logger.error(f"Secret file {_file_path} for {_var} not found")
|
||||
except Exception as _e:
|
||||
logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}")
|
||||
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from contextlib import asynccontextmanager
|
||||
@@ -5,14 +42,10 @@ from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_da
|
||||
from services.ais_stream import start_ais_stream, stop_ais_stream
|
||||
from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
|
||||
import uvicorn
|
||||
import logging
|
||||
import hashlib
|
||||
import json as json_mod
|
||||
import os
|
||||
import socket
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def _build_cors_origins():
|
||||
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
|
||||
@@ -77,6 +110,15 @@ async def force_refresh():
|
||||
async def live_data():
|
||||
return get_latest_data()
|
||||
|
||||
def _etag_response(request: Request, payload: dict, prefix: str = "", default=None):
|
||||
"""Serialize once, hash the bytes for ETag, return 304 or full response."""
|
||||
content = json_mod.dumps(payload, default=default)
|
||||
etag = hashlib.md5(f"{prefix}{content[:256]}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(content=content, media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
|
||||
@app.get("/api/live-data/fast")
|
||||
async def live_data_fast(request: Request):
|
||||
d = get_latest_data()
|
||||
@@ -87,25 +129,13 @@ async def live_data_fast(request: Request):
|
||||
"private_jets": d.get("private_jets", []),
|
||||
"tracked_flights": d.get("tracked_flights", []),
|
||||
"ships": d.get("ships", []),
|
||||
"satellites": d.get("satellites", []),
|
||||
"cctv": d.get("cctv", []),
|
||||
"uavs": d.get("uavs", []),
|
||||
"liveuamap": d.get("liveuamap", []),
|
||||
"gps_jamming": d.get("gps_jamming", []),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
||||
# not just when item counts change (old bug: positions went stale)
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||
etag = hashlib.md5(f"{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="fast|")
|
||||
|
||||
@app.get("/api/live-data/slow")
|
||||
async def live_data_slow(request: Request):
|
||||
@@ -129,17 +159,7 @@ async def live_data_slow(request: Request):
|
||||
"datacenters": d.get("datacenters", []),
|
||||
"freshness": dict(source_timestamps),
|
||||
}
|
||||
# ETag based on last_updated + item counts
|
||||
last_updated = d.get("last_updated", "")
|
||||
counts = "|".join(f"{k}:{len(v) if isinstance(v, list) else 0}" for k, v in payload.items() if k != "freshness")
|
||||
etag = hashlib.md5(f"slow|{last_updated}|{counts}".encode()).hexdigest()[:16]
|
||||
if request.headers.get("if-none-match") == etag:
|
||||
return Response(status_code=304, headers={"ETag": etag, "Cache-Control": "no-cache"})
|
||||
return Response(
|
||||
content=json_mod.dumps(payload, default=str),
|
||||
media_type="application/json",
|
||||
headers={"ETag": etag, "Cache-Control": "no-cache"}
|
||||
)
|
||||
return _etag_response(request, payload, prefix="slow|", default=str)
|
||||
|
||||
@app.get("/api/debug-latest")
|
||||
async def debug_latest_data():
|
||||
@@ -200,9 +220,9 @@ async def api_get_nearest_radios_list(lat: float, lng: float, limit: int = 5):
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
@app.get("/api/route/{callsign}")
|
||||
async def get_flight_route(callsign: str):
|
||||
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign}]}, timeout=10)
|
||||
if r.status_code == 200:
|
||||
async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0):
|
||||
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10)
|
||||
if r and r.status_code == 200:
|
||||
data = r.json()
|
||||
route_list = []
|
||||
if isinstance(data, dict):
|
||||
@@ -214,9 +234,13 @@ async def get_flight_route(callsign: str):
|
||||
route = route_list[0]
|
||||
airports = route.get("_airports", [])
|
||||
if len(airports) >= 2:
|
||||
orig = airports[0]
|
||||
dest = airports[-1]
|
||||
return {
|
||||
"orig_loc": [airports[0].get("lon", 0), airports[0].get("lat", 0)],
|
||||
"dest_loc": [airports[-1].get("lon", 0), airports[-1].get("lat", 0)]
|
||||
"orig_loc": [orig.get("lon", 0), orig.get("lat", 0)],
|
||||
"dest_loc": [dest.get("lon", 0), dest.get("lat", 0)],
|
||||
"origin_name": f"{orig.get('iata', '') or orig.get('icao', '')}: {orig.get('name', 'Unknown')}",
|
||||
"dest_name": f"{dest.get('iata', '') or dest.get('icao', '')}: {dest.get('name', 'Unknown')}",
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
@@ -238,49 +238,51 @@ def _ais_stream_loop():
|
||||
logger.info("AIS Stream proxy started — receiving vessel data")
|
||||
|
||||
msg_count = 0
|
||||
ok_streak = 0 # Track consecutive successful messages for backoff reset
|
||||
last_log_time = time.time()
|
||||
for raw_msg in iter(process.stdout.readline, ''):
|
||||
if not _ws_running:
|
||||
process.terminate()
|
||||
break
|
||||
|
||||
|
||||
raw_msg = raw_msg.strip()
|
||||
if not raw_msg:
|
||||
continue
|
||||
|
||||
|
||||
try:
|
||||
data = json.loads(raw_msg)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
if "error" in data:
|
||||
logger.error(f"AIS Stream error: {data['error']}")
|
||||
continue
|
||||
|
||||
|
||||
msg_type = data.get("MessageType", "")
|
||||
metadata = data.get("MetaData", {})
|
||||
message = data.get("Message", {})
|
||||
|
||||
|
||||
mmsi = metadata.get("MMSI", 0)
|
||||
if not mmsi:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
if mmsi not in _vessels:
|
||||
_vessels[mmsi] = {"_updated": time.time()}
|
||||
vessel = _vessels[mmsi]
|
||||
|
||||
|
||||
# Update position from PositionReport or StandardClassBPositionReport
|
||||
if msg_type in ("PositionReport", "StandardClassBPositionReport"):
|
||||
report = message.get(msg_type, {})
|
||||
lat = report.get("Latitude", metadata.get("latitude", 0))
|
||||
lng = report.get("Longitude", metadata.get("longitude", 0))
|
||||
|
||||
|
||||
# Skip invalid positions
|
||||
if lat == 0 and lng == 0:
|
||||
continue
|
||||
if abs(lat) > 90 or abs(lng) > 180:
|
||||
continue
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["lat"] = lat
|
||||
vessel["lng"] = lng
|
||||
@@ -292,12 +294,12 @@ def _ais_stream_loop():
|
||||
# Use metadata name if we don't have one yet
|
||||
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
||||
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||
|
||||
|
||||
# Update static data from ShipStaticData
|
||||
elif msg_type == "ShipStaticData":
|
||||
static = message.get("ShipStaticData", {})
|
||||
ais_type = static.get("Type", 0)
|
||||
|
||||
|
||||
with _vessels_lock:
|
||||
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
||||
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
||||
@@ -306,21 +308,24 @@ def _ais_stream_loop():
|
||||
vessel["ais_type_code"] = ais_type
|
||||
vessel["type"] = classify_vessel(ais_type, mmsi)
|
||||
vessel["_updated"] = time.time()
|
||||
|
||||
|
||||
msg_count += 1
|
||||
if msg_count % 5000 == 0:
|
||||
ok_streak += 1
|
||||
|
||||
# Reset backoff after 200 consecutive successful messages
|
||||
if ok_streak >= 200 and backoff > 1:
|
||||
backoff = 1
|
||||
ok_streak = 0
|
||||
|
||||
# Periodic logging + cache save (time-based instead of count-based to avoid lock in hot loop)
|
||||
now = time.time()
|
||||
if now - last_log_time >= 60:
|
||||
with _vessels_lock:
|
||||
# Inline pruning: remove vessels not updated in 15 minutes
|
||||
prune_cutoff = time.time() - 900
|
||||
stale = [k for k, v in _vessels.items() if v.get("_updated", 0) < prune_cutoff]
|
||||
for k in stale:
|
||||
del _vessels[k]
|
||||
count = len(_vessels)
|
||||
if stale:
|
||||
logger.info(f"AIS pruned {len(stale)} stale vessels")
|
||||
logger.info(f"AIS Stream: processed {msg_count} messages, tracking {count} vessels")
|
||||
_save_cache() # Auto-save every 5000 messages (~60 seconds)
|
||||
|
||||
_save_cache()
|
||||
last_log_time = now
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AIS proxy connection error: {e}")
|
||||
if _ws_running:
|
||||
@@ -328,8 +333,6 @@ def _ais_stream_loop():
|
||||
time.sleep(backoff)
|
||||
backoff = min(backoff * 2, 60) # Double up to 60s max
|
||||
continue
|
||||
# Reset backoff on successful connection (got at least some messages)
|
||||
backoff = 1
|
||||
|
||||
|
||||
def _run_ais_loop():
|
||||
|
||||
@@ -15,6 +15,7 @@ import threading
|
||||
import io
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
import concurrent.futures
|
||||
import heapq
|
||||
from sgp4.api import Satrec, WGS72
|
||||
from sgp4.api import jday
|
||||
from datetime import datetime
|
||||
@@ -81,6 +82,25 @@ opensky_client = OpenSkyClient(
|
||||
last_opensky_fetch = 0
|
||||
cached_opensky_flights = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Supplemental ADS-B sources for blind-spot gap-filling (Russia/China/Africa)
|
||||
# These aggregators have different feeder pools than adsb.lol and can surface
|
||||
# aircraft invisible to our primary source. Only gap-fill planes are kept.
|
||||
# ---------------------------------------------------------------------------
|
||||
_BLIND_SPOT_REGIONS = [
|
||||
{"name": "Yekaterinburg", "lat": 56.8, "lon": 60.6, "radius_nm": 250},
|
||||
{"name": "Novosibirsk", "lat": 55.0, "lon": 82.9, "radius_nm": 250},
|
||||
{"name": "Krasnoyarsk", "lat": 56.0, "lon": 92.9, "radius_nm": 250},
|
||||
{"name": "Vladivostok", "lat": 43.1, "lon": 131.9, "radius_nm": 250},
|
||||
{"name": "Urumqi", "lat": 43.8, "lon": 87.6, "radius_nm": 250},
|
||||
{"name": "Chengdu", "lat": 30.6, "lon": 104.1, "radius_nm": 250},
|
||||
{"name": "Lagos-Accra", "lat": 6.5, "lon": 3.4, "radius_nm": 250},
|
||||
{"name": "Addis Ababa", "lat": 9.0, "lon": 38.7, "radius_nm": 250},
|
||||
]
|
||||
_SUPPLEMENTAL_FETCH_INTERVAL = 120 # seconds — only query every 2 min
|
||||
last_supplemental_fetch = 0
|
||||
cached_supplemental_flights = []
|
||||
|
||||
|
||||
|
||||
# In-memory store
|
||||
@@ -122,56 +142,132 @@ def _mark_fresh(*keys):
|
||||
_data_lock = threading.Lock()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plane-Alert DB — load tracked aircraft from CSV on startup
|
||||
# Plane-Alert DB — load tracked aircraft from JSON on startup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Category → color mapping
|
||||
_PINK_CATEGORIES = {
|
||||
"Dictator Alert", "Head of State", "Da Comrade", "Oligarch",
|
||||
"Governments", "Royal Aircraft", "Quango",
|
||||
}
|
||||
_RED_CATEGORIES = {
|
||||
"Don't you know who I am?", "As Seen on TV", "Joe Cool",
|
||||
"Vanity Plate", "Football", "Bizjets",
|
||||
}
|
||||
_DARKBLUE_CATEGORIES = {
|
||||
"USAF", "United States Navy", "United States Marine Corps",
|
||||
"Special Forces", "Hired Gun", "Oxcart", "Gunship", "Nuclear",
|
||||
"CAP", "Zoomies",
|
||||
# Exact category → color mapping for all 53 known categories.
|
||||
# O(1) dict lookup — no keyword scanning, no false positives.
|
||||
_CATEGORY_COLOR: dict[str, str] = {
|
||||
# YELLOW — Military / Intelligence / Defense
|
||||
"USAF": "yellow",
|
||||
"Other Air Forces": "yellow",
|
||||
"Toy Soldiers": "yellow",
|
||||
"Oxcart": "yellow",
|
||||
"United States Navy": "yellow",
|
||||
"GAF": "yellow",
|
||||
"Hired Gun": "yellow",
|
||||
"United States Marine Corps": "yellow",
|
||||
"Gunship": "yellow",
|
||||
"RAF": "yellow",
|
||||
"Other Navies": "yellow",
|
||||
"Special Forces": "yellow",
|
||||
"Zoomies": "yellow",
|
||||
"Royal Navy Fleet Air Arm": "yellow",
|
||||
"Army Air Corps": "yellow",
|
||||
"Aerobatic Teams": "yellow",
|
||||
"UAV": "yellow",
|
||||
"Ukraine": "yellow",
|
||||
"Nuclear": "yellow",
|
||||
# LIME — Emergency / Medical / Rescue / Fire
|
||||
"Flying Doctors": "#32cd32",
|
||||
"Aerial Firefighter": "#32cd32",
|
||||
"Coastguard": "#32cd32",
|
||||
# BLUE — Government / Law Enforcement / Civil
|
||||
"Police Forces": "blue",
|
||||
"Governments": "blue",
|
||||
"Quango": "blue",
|
||||
"UK National Police Air Service": "blue",
|
||||
"CAP": "blue",
|
||||
# BLACK — Privacy / PIA
|
||||
"PIA": "black",
|
||||
# RED — Dictator / Oligarch
|
||||
"Dictator Alert": "red",
|
||||
"Da Comrade": "red",
|
||||
"Oligarch": "red",
|
||||
# HOT PINK — High Value Assets / VIP / Celebrity
|
||||
"Head of State": "#ff1493",
|
||||
"Royal Aircraft": "#ff1493",
|
||||
"Don't you know who I am?": "#ff1493",
|
||||
"As Seen on TV": "#ff1493",
|
||||
"Bizjets": "#ff1493",
|
||||
"Vanity Plate": "#ff1493",
|
||||
"Football": "#ff1493",
|
||||
# ORANGE — Joe Cool
|
||||
"Joe Cool": "orange",
|
||||
# WHITE — Climate Crisis
|
||||
"Climate Crisis": "white",
|
||||
# PURPLE — General Tracked / Other Notable
|
||||
"Historic": "purple",
|
||||
"Jump Johnny Jump": "purple",
|
||||
"Ptolemy would be proud": "purple",
|
||||
"Distinctive": "purple",
|
||||
"Dogs with Jobs": "purple",
|
||||
"You came here in that thing?": "purple",
|
||||
"Big Hello": "purple",
|
||||
"Watch Me Fly": "purple",
|
||||
"Perfectly Serviceable Aircraft": "purple",
|
||||
"Jesus he Knows me": "purple",
|
||||
"Gas Bags": "purple",
|
||||
"Radiohead": "purple",
|
||||
}
|
||||
|
||||
def _category_to_color(cat: str) -> str:
|
||||
if cat in _PINK_CATEGORIES:
|
||||
return "pink"
|
||||
if cat in _RED_CATEGORIES:
|
||||
return "red"
|
||||
if cat in _DARKBLUE_CATEGORIES:
|
||||
return "darkblue"
|
||||
return "white"
|
||||
"""O(1) exact lookup. Unknown categories default to purple."""
|
||||
return _CATEGORY_COLOR.get(cat, "purple")
|
||||
|
||||
# Load once on module import
|
||||
_PLANE_ALERT_DB: dict = {} # uppercase ICAO hex → dict of aircraft info
|
||||
_PLANE_ALERT_DB: dict = {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POTUS Fleet — override colors and operator names for presidential aircraft.
|
||||
# These are hardcoded ICAO hexes verified against FAA registry + plane-alert.
|
||||
# ---------------------------------------------------------------------------
|
||||
_POTUS_FLEET: dict[str, dict] = {
|
||||
# Air Force One — Boeing VC-25A (747-200B)
|
||||
"ADFDF8": {"color": "#ff1493", "operator": "Air Force One (82-8000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"},
|
||||
"ADFDF9": {"color": "#ff1493", "operator": "Air Force One (92-9000)", "category": "Head of State", "wiki": "Air_Force_One", "fleet": "AF1"},
|
||||
# Air Force Two — Boeing C-32A (757-200)
|
||||
"ADFEB7": {"color": "blue", "operator": "Air Force Two (98-0001)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEB8": {"color": "blue", "operator": "Air Force Two (98-0002)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEB9": {"color": "blue", "operator": "Air Force Two (99-0003)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"ADFEBA": {"color": "blue", "operator": "Air Force Two (99-0004)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AE6": {"color": "blue", "operator": "Air Force Two (09-0015)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AE8": {"color": "blue", "operator": "Air Force Two (09-0016)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AEA": {"color": "blue", "operator": "Air Force Two (09-0017)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
"AE4AEC": {"color": "blue", "operator": "Air Force Two (19-0018)", "category": "Governments", "wiki": "Air_Force_Two", "fleet": "AF2"},
|
||||
# Marine One — VH-3D Sea King / VH-92A Patriot
|
||||
"AE0865": {"color": "#ff1493", "operator": "Marine One (VH-3D)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E76": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E77": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
"AE5E79": {"color": "#ff1493", "operator": "Marine One (VH-92A)", "category": "Head of State", "wiki": "Marine_One", "fleet": "M1"},
|
||||
}
|
||||
|
||||
def _load_plane_alert_db():
|
||||
"""Parse plane_alert_db.json into a dict keyed by uppercase ICAO hex."""
|
||||
"""Load plane_alert_db.json (exported from SQLite) into memory."""
|
||||
global _PLANE_ALERT_DB
|
||||
import json
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"data", "plane_alert_db.json"
|
||||
)
|
||||
if not os.path.exists(json_path):
|
||||
logger.warning(f"Plane-Alert JSON DB not found at {json_path}")
|
||||
logger.warning(f"Plane-Alert DB not found at {json_path}")
|
||||
return
|
||||
try:
|
||||
with open(json_path, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
for icao_hex, info in data.items():
|
||||
info["color"] = _category_to_color(info.get("category", ""))
|
||||
_PLANE_ALERT_DB[icao_hex] = info
|
||||
logger.info(f"Plane-Alert JSON DB loaded: {len(_PLANE_ALERT_DB)} aircraft")
|
||||
raw = json.load(fh)
|
||||
for icao_hex, info in raw.items():
|
||||
info["color"] = _category_to_color(info.get("category", ""))
|
||||
# Apply POTUS fleet overrides (correct colors + clean operator names)
|
||||
override = _POTUS_FLEET.get(icao_hex)
|
||||
if override:
|
||||
info["color"] = override["color"]
|
||||
info["operator"] = override["operator"]
|
||||
info["category"] = override["category"]
|
||||
info["wiki"] = override.get("wiki", "")
|
||||
info["potus_fleet"] = override.get("fleet", "")
|
||||
_PLANE_ALERT_DB[icao_hex] = info
|
||||
logger.info(f"Plane-Alert DB loaded: {len(_PLANE_ALERT_DB)} aircraft")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load Plane-Alert JSON DB: {e}")
|
||||
logger.error(f"Failed to load Plane-Alert DB: {e}")
|
||||
|
||||
_load_plane_alert_db()
|
||||
|
||||
@@ -184,11 +280,12 @@ def enrich_with_plane_alert(flight: dict) -> dict:
|
||||
flight["alert_color"] = info["color"]
|
||||
flight["alert_operator"] = info["operator"]
|
||||
flight["alert_type"] = info["ac_type"]
|
||||
flight["alert_tag1"] = info["tag1"]
|
||||
flight["alert_tag2"] = info["tag2"]
|
||||
flight["alert_tag3"] = info["tag3"]
|
||||
flight["alert_tags"] = info["tags"]
|
||||
flight["alert_link"] = info["link"]
|
||||
# Override registration if DB has a better one
|
||||
if info.get("wiki"):
|
||||
flight["alert_wiki"] = info["wiki"]
|
||||
if info.get("potus_fleet"):
|
||||
flight["potus_fleet"] = info["potus_fleet"]
|
||||
if info["registration"]:
|
||||
flight["registration"] = info["registration"]
|
||||
|
||||
@@ -225,21 +322,37 @@ _load_tracked_names()
|
||||
|
||||
def enrich_with_tracked_names(flight: dict) -> dict:
|
||||
"""If flight's registration matches our Excel extraction, tag it as tracked."""
|
||||
# POTUS fleet overrides are authoritative — never let Excel overwrite them
|
||||
icao = flight.get("icao24", "").strip().upper()
|
||||
if icao in _POTUS_FLEET:
|
||||
return flight
|
||||
|
||||
reg = flight.get("registration", "").strip().upper()
|
||||
callsign = flight.get("callsign", "").strip().upper()
|
||||
|
||||
|
||||
match = None
|
||||
if reg and reg in _TRACKED_NAMES_DB:
|
||||
match = _TRACKED_NAMES_DB[reg]
|
||||
elif callsign and callsign in _TRACKED_NAMES_DB:
|
||||
match = _TRACKED_NAMES_DB[callsign]
|
||||
|
||||
|
||||
if match:
|
||||
# Don't overwrite Plane-Alert DB operator if it exists unless we want Excel to take precedence.
|
||||
# Let's let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC).
|
||||
flight["alert_operator"] = match["name"]
|
||||
name = match["name"]
|
||||
# Let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC).
|
||||
flight["alert_operator"] = name
|
||||
flight["alert_category"] = match["category"]
|
||||
if "alert_color" not in flight:
|
||||
|
||||
# Override pink default if the name implies a specific function
|
||||
name_lower = name.lower()
|
||||
is_gov = any(w in name_lower for w in ['state of ', 'government', 'republic', 'ministry', 'department', 'federal', 'cia'])
|
||||
is_law = any(w in name_lower for w in ['police', 'marshal', 'sheriff', 'douane', 'customs', 'patrol', 'gendarmerie', 'guardia', 'law enforcement'])
|
||||
is_med = any(w in name_lower for w in ['fire', 'bomberos', 'ambulance', 'paramedic', 'medevac', 'rescue', 'hospital', 'medical', 'lifeflight'])
|
||||
|
||||
if is_gov or is_law:
|
||||
flight["alert_color"] = "blue"
|
||||
elif is_med:
|
||||
flight["alert_color"] = "#32cd32" # lime
|
||||
elif "alert_color" not in flight:
|
||||
flight["alert_color"] = "pink"
|
||||
|
||||
return flight
|
||||
@@ -480,27 +593,31 @@ def fetch_news():
|
||||
latest_data['news'] = news_items
|
||||
_mark_fresh("news")
|
||||
|
||||
def _fetch_single_ticker(symbol: str, period: str = "2d"):
|
||||
"""Fetch a single yfinance ticker. Returns (symbol, data_dict) or (symbol, None)."""
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period=period)
|
||||
if len(hist) >= 1:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[0] if len(hist) > 1 else current_price
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
return symbol, {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||
return symbol, None
|
||||
|
||||
|
||||
def fetch_defense_stocks():
|
||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||
stocks_data = {}
|
||||
try:
|
||||
for t in tickers:
|
||||
try:
|
||||
ticker = yf.Ticker(t)
|
||||
hist = ticker.history(period="2d")
|
||||
if len(hist) >= 1:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[0] if len(hist) > 1 else current_price
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
|
||||
stocks_data[t] = {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {t}: {e}")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = pool.map(lambda t: _fetch_single_ticker(t, "2d"), tickers)
|
||||
stocks_data = {sym: data for sym, data in results if data}
|
||||
latest_data['stocks'] = stocks_data
|
||||
_mark_fresh("stocks")
|
||||
except Exception as e:
|
||||
@@ -509,25 +626,10 @@ def fetch_defense_stocks():
|
||||
def fetch_oil_prices():
|
||||
# CL=F is Crude Oil, BZ=F is Brent Crude
|
||||
tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
|
||||
oil_data = {}
|
||||
try:
|
||||
for name, symbol in tickers.items():
|
||||
try:
|
||||
ticker = yf.Ticker(symbol)
|
||||
hist = ticker.history(period="5d")
|
||||
if len(hist) >= 2:
|
||||
current_price = hist['Close'].iloc[-1]
|
||||
prev_close = hist['Close'].iloc[-2]
|
||||
change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close else 0
|
||||
|
||||
oil_data[name] = {
|
||||
"price": round(float(current_price), 2),
|
||||
"change_percent": round(float(change_percent), 2),
|
||||
"up": bool(change_percent >= 0)
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not fetch data for {symbol}: {e}")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = pool.map(lambda item: (_fetch_single_ticker(item[1], "5d")[1], item[0]), tickers.items())
|
||||
oil_data = {name: data for data, name in results if data}
|
||||
latest_data['oil'] = oil_data
|
||||
_mark_fresh("oil")
|
||||
except Exception as e:
|
||||
@@ -612,6 +714,87 @@ _HELI_TYPES_BACKEND = {
|
||||
"B47G", "HUEY", "GAMA", "CABR", "EXE",
|
||||
}
|
||||
|
||||
|
||||
def _fetch_supplemental_sources(seen_hex: set) -> list:
|
||||
"""Fetch from airplanes.live and adsb.fi to fill blind-spot gaps.
|
||||
|
||||
Only returns aircraft whose ICAO hex is NOT already in seen_hex.
|
||||
Throttled to run every _SUPPLEMENTAL_FETCH_INTERVAL seconds.
|
||||
Fully wrapped in try/except — returns [] on any failure.
|
||||
"""
|
||||
global last_supplemental_fetch, cached_supplemental_flights
|
||||
|
||||
now = time.time()
|
||||
if now - last_supplemental_fetch < _SUPPLEMENTAL_FETCH_INTERVAL:
|
||||
# Return cached results, but still filter against current seen_hex
|
||||
return [f for f in cached_supplemental_flights
|
||||
if f.get("hex", "").lower().strip() not in seen_hex]
|
||||
|
||||
new_supplemental = []
|
||||
supplemental_hex = set() # track hex within supplemental to avoid internal dupes
|
||||
|
||||
# --- airplanes.live (parallel, all hotspots) ---
|
||||
def _fetch_airplaneslive(region):
|
||||
try:
|
||||
url = (f"https://api.airplanes.live/v2/point/"
|
||||
f"{region['lat']}/{region['lon']}/{region['radius_nm']}")
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
return data.get("ac", [])
|
||||
except Exception as e:
|
||||
logger.debug(f"airplanes.live {region['name']} failed: {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(_fetch_airplaneslive, _BLIND_SPOT_REGIONS))
|
||||
for region_flights in results:
|
||||
for f in region_flights:
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h and h not in seen_hex and h not in supplemental_hex:
|
||||
f["supplemental_source"] = "airplanes.live"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except Exception as e:
|
||||
logger.warning(f"airplanes.live supplemental fetch failed: {e}")
|
||||
|
||||
ap_count = len(new_supplemental)
|
||||
|
||||
# --- adsb.fi (sequential, 1.1s between requests to respect 1 req/sec limit) ---
|
||||
try:
|
||||
for region in _BLIND_SPOT_REGIONS:
|
||||
try:
|
||||
url = (f"https://opendata.adsb.fi/api/v3/lat/"
|
||||
f"{region['lat']}/lon/{region['lon']}/dist/{region['radius_nm']}")
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
for f in data.get("ac", []):
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h and h not in seen_hex and h not in supplemental_hex:
|
||||
f["supplemental_source"] = "adsb.fi"
|
||||
new_supplemental.append(f)
|
||||
supplemental_hex.add(h)
|
||||
except Exception as e:
|
||||
logger.debug(f"adsb.fi {region['name']} failed: {e}")
|
||||
time.sleep(1.1) # Rate limit: 1 req/sec
|
||||
except Exception as e:
|
||||
logger.warning(f"adsb.fi supplemental fetch failed: {e}")
|
||||
|
||||
fi_count = len(new_supplemental) - ap_count
|
||||
|
||||
cached_supplemental_flights = new_supplemental
|
||||
last_supplemental_fetch = now
|
||||
if new_supplemental:
|
||||
_mark_fresh("supplemental_flights")
|
||||
|
||||
logger.info(f"Supplemental: +{len(new_supplemental)} new aircraft from blind-spot "
|
||||
f"hotspots (airplanes.live: {ap_count}, adsb.fi: {fi_count})")
|
||||
|
||||
return new_supplemental
|
||||
|
||||
|
||||
def fetch_flights():
|
||||
# OpenSky Network public API for flights. We want to demonstrate global coverage.
|
||||
flights = []
|
||||
@@ -712,7 +895,22 @@ def fetch_flights():
|
||||
all_adsb_flights.append(osf)
|
||||
seen_hex.add(h.lower().strip())
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Supplemental Sources: airplanes.live + adsb.fi (blind-spot gap-fill)
|
||||
# Only adds aircraft whose ICAO hex is NOT already in seen_hex.
|
||||
# -------------------------------------------------------------------
|
||||
try:
|
||||
gap_fill = _fetch_supplemental_sources(seen_hex)
|
||||
for f in gap_fill:
|
||||
all_adsb_flights.append(f)
|
||||
h = f.get("hex", "").lower().strip()
|
||||
if h:
|
||||
seen_hex.add(h)
|
||||
if gap_fill:
|
||||
logger.info(f"Gap-fill: added {len(gap_fill)} aircraft to pipeline")
|
||||
except Exception as e:
|
||||
logger.warning(f"Supplemental source fetch failed (non-fatal): {e}")
|
||||
|
||||
if all_adsb_flights:
|
||||
|
||||
# The user requested maximum flight density. Rendering all available aircraft.
|
||||
@@ -1333,9 +1531,8 @@ def fetch_firms_fires():
|
||||
})
|
||||
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]
|
||||
# Keep top 5000 by FRP (most intense fires first) — heapq is O(n) vs O(n log n) sort
|
||||
fires = heapq.nlargest(5000, all_rows, key=lambda x: x["frp"])
|
||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||
@@ -1471,9 +1668,8 @@ def fetch_internet_outages():
|
||||
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]
|
||||
# Keep top 100 by severity
|
||||
outages = heapq.nlargest(100, geocoded, key=lambda x: x["severity"])
|
||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching internet outages: {e}")
|
||||
@@ -2219,8 +2415,8 @@ def start_scheduler():
|
||||
scheduler.add_job(update_liveuamap, 'date', run_date=datetime.now())
|
||||
scheduler.add_job(update_liveuamap, 'interval', hours=12)
|
||||
|
||||
# Geopolitics (frontlines) more frequently than other slow data
|
||||
scheduler.add_job(fetch_geopolitics, 'interval', minutes=5)
|
||||
# Geopolitics (frontlines) aligned with slow-data tier
|
||||
scheduler.add_job(fetch_geopolitics, 'interval', minutes=30)
|
||||
|
||||
scheduler.start()
|
||||
|
||||
|
||||
+153
-27
@@ -86,8 +86,10 @@ def _extract_domain(url):
|
||||
|
||||
def _url_to_headline(url):
|
||||
"""Extract a human-readable headline from a URL path.
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites (nytimes.com)'
|
||||
e.g. 'https://nytimes.com/2026/03/us-strikes-iran-nuclear-sites.html' -> 'Us Strikes Iran Nuclear Sites'
|
||||
Falls back to domain name if the URL slug is gibberish (hex IDs, UUIDs, etc.).
|
||||
"""
|
||||
import re
|
||||
try:
|
||||
from urllib.parse import urlparse, unquote
|
||||
parsed = urlparse(url)
|
||||
@@ -100,43 +102,151 @@ def _url_to_headline(url):
|
||||
if not path:
|
||||
return domain
|
||||
|
||||
# Take the last path segment (usually the slug)
|
||||
slug = path.split('/')[-1]
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# If slug is purely numeric or a short ID, try the second-to-last segment
|
||||
import re
|
||||
if re.match(r'^[a-z]?\d{5,}$', slug, re.IGNORECASE):
|
||||
segments = path.split('/')
|
||||
if len(segments) >= 2:
|
||||
slug = segments[-2]
|
||||
for ext in ['.html', '.htm', '.php']:
|
||||
if slug.lower().endswith(ext):
|
||||
slug = slug[:-len(ext)]
|
||||
# Try the last path segment first, then walk backwards
|
||||
segments = [s for s in path.split('/') if s]
|
||||
slug = ''
|
||||
for seg in reversed(segments):
|
||||
# Remove file extensions
|
||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||
if seg.lower().endswith(ext):
|
||||
seg = seg[:-len(ext)]
|
||||
# Skip segments that are clearly not headlines
|
||||
if _is_gibberish(seg):
|
||||
continue
|
||||
slug = seg
|
||||
break
|
||||
|
||||
if not slug:
|
||||
return domain
|
||||
|
||||
# Remove common ID patterns at start/end
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading numbers like "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
|
||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
||||
slug = re.sub(r'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||
# Convert slug separators to spaces
|
||||
slug = slug.replace('-', ' ').replace('_', ' ')
|
||||
# Clean up multiple spaces
|
||||
slug = re.sub(r'\s+', ' ', slug).strip()
|
||||
|
||||
# If slug is still just a number or too short, fall back to domain
|
||||
if len(slug) < 5 or re.match(r'^\d+$', slug):
|
||||
# Final gibberish check after cleanup
|
||||
if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
|
||||
return domain
|
||||
|
||||
# Title case and truncate
|
||||
headline = slug.title()
|
||||
if len(headline) > 80:
|
||||
headline = headline[:77] + '...'
|
||||
return f"{headline} ({domain})"
|
||||
if len(headline) > 90:
|
||||
headline = headline[:87] + '...'
|
||||
return headline
|
||||
except Exception:
|
||||
return url[:60]
|
||||
|
||||
|
||||
def _is_gibberish(text):
|
||||
"""Detect if a URL segment is gibberish (hex IDs, UUIDs, numeric IDs, etc.)
|
||||
rather than a real human-readable slug like 'us-strikes-iran'."""
|
||||
import re
|
||||
t = text.strip()
|
||||
if not t:
|
||||
return True
|
||||
# Pure numbers
|
||||
if re.match(r'^\d+$', t):
|
||||
return True
|
||||
# UUID pattern (with or without dashes)
|
||||
if re.match(r'^[0-9a-f]{8}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{4}[_-]?[0-9a-f]{12}$', t, re.I):
|
||||
return True
|
||||
# Hex-heavy string: more than 40% hex digits among alphanumeric chars
|
||||
alnum = re.sub(r'[^a-zA-Z0-9]', '', t)
|
||||
if alnum:
|
||||
hex_chars = sum(1 for c in alnum if c in '0123456789abcdefABCDEF')
|
||||
if hex_chars / len(alnum) > 0.4 and len(alnum) > 6:
|
||||
return True
|
||||
# Mostly digits with a few alpha (like "article8efa6c53")
|
||||
digits = sum(1 for c in alnum if c.isdigit())
|
||||
if alnum and digits / len(alnum) > 0.5:
|
||||
return True
|
||||
# Too short to be a headline slug
|
||||
if len(t) < 5:
|
||||
return True
|
||||
# Query-param style segments
|
||||
if '=' in t:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Persistent cache for article titles — survives across GDELT cache refreshes
|
||||
_article_title_cache = {}
|
||||
|
||||
def _fetch_article_title(url):
|
||||
"""Fetch the real headline from an article's HTML <title> or og:title tag.
|
||||
Returns the title string, or None if it can't be fetched.
|
||||
Uses a persistent cache to avoid refetching."""
|
||||
if url in _article_title_cache:
|
||||
return _article_title_cache[url]
|
||||
|
||||
import re
|
||||
try:
|
||||
# Only read the first 32KB — the <title> is always in <head>
|
||||
resp = requests.get(url, timeout=4, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; OSINT Dashboard/1.0)'
|
||||
}, stream=True)
|
||||
if resp.status_code != 200:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
chunk = resp.raw.read(32768).decode('utf-8', errors='replace')
|
||||
resp.close()
|
||||
|
||||
title = None
|
||||
|
||||
# Try og:title first (usually the cleanest)
|
||||
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\'>]+)["\']', chunk, re.I)
|
||||
if not og_match:
|
||||
og_match = re.search(r'<meta[^>]+content=["\']([^"\'>]+)["\'][^>]+property=["\']og:title["\']', chunk, re.I)
|
||||
if og_match:
|
||||
title = og_match.group(1).strip()
|
||||
|
||||
# Fall back to <title> tag
|
||||
if not title:
|
||||
title_match = re.search(r'<title[^>]*>([^<]+)</title>', chunk, re.I)
|
||||
if title_match:
|
||||
title = title_match.group(1).strip()
|
||||
|
||||
if title:
|
||||
# Clean up HTML entities
|
||||
import html as html_mod
|
||||
title = html_mod.unescape(title)
|
||||
# Remove site name suffixes like " | CNN" or " - BBC News"
|
||||
title = re.sub(r'\s*[|\-–—]\s*[^|\-–—]{2,30}$', '', title).strip()
|
||||
# Truncate very long titles
|
||||
if len(title) > 120:
|
||||
title = title[:117] + '...'
|
||||
if len(title) > 10:
|
||||
_article_title_cache[url] = title
|
||||
return title
|
||||
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
except Exception:
|
||||
_article_title_cache[url] = None
|
||||
return None
|
||||
|
||||
|
||||
def _batch_fetch_titles(urls):
|
||||
"""Fetch real article titles for a list of URLs in parallel.
|
||||
Returns a dict of url -> title (or None if fetch failed)."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
results = {}
|
||||
with ThreadPoolExecutor(max_workers=16) as executor:
|
||||
futures = {executor.submit(_fetch_article_title, u): u for u in urls}
|
||||
for future in futures:
|
||||
url = futures[future]
|
||||
try:
|
||||
results[url] = future.result()
|
||||
except Exception:
|
||||
results[url] = None
|
||||
return results
|
||||
|
||||
|
||||
def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index):
|
||||
"""Parse a single GDELT export ZIP and append conflict features.
|
||||
loc_index maps loc_key -> index in features list for fast duplicate merging.
|
||||
@@ -278,11 +388,27 @@ def fetch_global_military_incidents():
|
||||
if zip_bytes:
|
||||
_parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index)
|
||||
|
||||
# Collect all unique article URLs for batch title fetching
|
||||
all_article_urls = set()
|
||||
for f in features:
|
||||
for u in f["properties"].get("_urls", []):
|
||||
if u:
|
||||
all_article_urls.add(u)
|
||||
|
||||
logger.info(f"Fetching real article titles for {len(all_article_urls)} unique URLs...")
|
||||
fetched_titles = _batch_fetch_titles(all_article_urls)
|
||||
fetched_count = sum(1 for v in fetched_titles.values() if v)
|
||||
logger.info(f"Resolved {fetched_count}/{len(all_article_urls)} article titles from HTML")
|
||||
|
||||
# Build URL + headline arrays for frontend rendering
|
||||
for f in features:
|
||||
urls = f["properties"].pop("_urls", [])
|
||||
f["properties"].pop("_domains", None)
|
||||
headlines = [_url_to_headline(u) for u in urls]
|
||||
headlines = []
|
||||
for u in urls:
|
||||
# Try the real fetched title first, then fall back to URL slug parsing
|
||||
real_title = fetched_titles.get(u)
|
||||
headlines.append(real_title if real_title else _url_to_headline(u))
|
||||
f["properties"]["_urls_list"] = urls
|
||||
f["properties"]["_headlines_list"] = headlines
|
||||
import html
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import time
|
||||
import concurrent.futures
|
||||
from urllib.parse import quote
|
||||
import requests as _requests
|
||||
from cachetools import TTLCache
|
||||
from services.network_utils import fetch_with_curl
|
||||
|
||||
@@ -10,26 +12,46 @@ logger = logging.getLogger(__name__)
|
||||
# Key: rounded lat/lng grid (0.1 degree ≈ 11km)
|
||||
dossier_cache = TTLCache(maxsize=500, ttl=86400)
|
||||
|
||||
# Nominatim requires max 1 req/sec — track last call time
|
||||
_nominatim_last_call = 0.0
|
||||
|
||||
|
||||
def _reverse_geocode(lat: float, lng: float) -> dict:
|
||||
global _nominatim_last_call
|
||||
url = (
|
||||
f"https://nominatim.openstreetmap.org/reverse?"
|
||||
f"lat={lat}&lon={lng}&format=json&zoom=10&addressdetails=1&accept-language=en"
|
||||
)
|
||||
try:
|
||||
res = fetch_with_curl(url, timeout=10)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
headers = {"User-Agent": "ShadowBroker-OSINT/1.0 (live-risk-dashboard; contact@shadowbroker.app)"}
|
||||
|
||||
for attempt in range(2):
|
||||
# Enforce Nominatim's 1 req/sec policy
|
||||
elapsed = time.time() - _nominatim_last_call
|
||||
if elapsed < 1.1:
|
||||
time.sleep(1.1 - elapsed)
|
||||
_nominatim_last_call = time.time()
|
||||
|
||||
try:
|
||||
# Use requests directly — fetch_with_curl raises on non-200 which breaks 429 handling
|
||||
res = _requests.get(url, timeout=10, headers=headers)
|
||||
if res.status_code == 200:
|
||||
data = res.json()
|
||||
addr = data.get("address", {})
|
||||
return {
|
||||
"city": addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county") or "",
|
||||
"state": addr.get("state") or addr.get("region") or "",
|
||||
"country": addr.get("country") or "",
|
||||
"country_code": (addr.get("country_code") or "").upper(),
|
||||
"display_name": data.get("display_name", ""),
|
||||
}
|
||||
elif res.status_code == 429:
|
||||
logger.warning(f"Nominatim 429 rate-limited, retrying after 2s (attempt {attempt+1})")
|
||||
time.sleep(2)
|
||||
continue
|
||||
else:
|
||||
logger.warning(f"Nominatim returned {res.status_code}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Reverse geocode failed: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
+26
-10
@@ -298,23 +298,32 @@ export default function Dashboard() {
|
||||
const slowEtag = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Track whether we've received substantial data yet (backend may still be starting up)
|
||||
let hasData = false;
|
||||
let fastTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
let slowTimerId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const fetchFastData = async () => {
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (fastEtag.current) headers['If-None-Match'] = fastEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/fast`, { headers });
|
||||
if (res.status === 304) { setBackendStatus('connected'); return; }
|
||||
if (res.status === 304) { setBackendStatus('connected'); scheduleNext('fast'); return; }
|
||||
if (res.ok) {
|
||||
setBackendStatus('connected');
|
||||
fastEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
dataRef.current = { ...dataRef.current, ...json };
|
||||
setDataVersion(v => v + 1);
|
||||
// Check if we got real data (backend finished loading)
|
||||
const flights = json.commercial_flights?.length || 0;
|
||||
if (flights > 100) hasData = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed fetching fast live data", e);
|
||||
setBackendStatus('disconnected');
|
||||
}
|
||||
scheduleNext('fast');
|
||||
};
|
||||
|
||||
const fetchSlowData = async () => {
|
||||
@@ -322,7 +331,7 @@ export default function Dashboard() {
|
||||
const headers: Record<string, string> = {};
|
||||
if (slowEtag.current) headers['If-None-Match'] = slowEtag.current;
|
||||
const res = await fetch(`${API_BASE}/api/live-data/slow`, { headers });
|
||||
if (res.status === 304) return;
|
||||
if (res.status === 304) { scheduleNext('slow'); return; }
|
||||
if (res.ok) {
|
||||
slowEtag.current = res.headers.get('etag') || null;
|
||||
const json = await res.json();
|
||||
@@ -332,19 +341,26 @@ export default function Dashboard() {
|
||||
} catch (e) {
|
||||
console.error("Failed fetching slow live data", e);
|
||||
}
|
||||
scheduleNext('slow');
|
||||
};
|
||||
|
||||
// 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
|
||||
fastTimerId = setTimeout(fetchFastData, delay);
|
||||
} else {
|
||||
const delay = hasData ? 120000 : 5000; // 5s startup retry → 120s steady state
|
||||
slowTimerId = setTimeout(fetchSlowData, delay);
|
||||
}
|
||||
};
|
||||
|
||||
fetchFastData();
|
||||
fetchSlowData();
|
||||
|
||||
// Fast polling: 60s (matches backend update cadence — was 15s, wasting 75% on 304s)
|
||||
// Slow polling: 120s (backend updates every 30min)
|
||||
const fastInterval = setInterval(fetchFastData, 60000);
|
||||
const slowInterval = setInterval(fetchSlowData, 120000);
|
||||
|
||||
return () => {
|
||||
clearInterval(fastInterval);
|
||||
clearInterval(slowInterval);
|
||||
if (fastTimerId) clearTimeout(fastTimerId);
|
||||
if (slowTimerId) clearTimeout(slowTimerId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -418,7 +434,7 @@ export default function Dashboard() {
|
||||
{/* LEFT HUD CONTAINER */}
|
||||
<div className="absolute left-6 top-24 bottom-6 w-80 flex flex-col gap-6 z-[200] pointer-events-none">
|
||||
{/* LEFT PANEL - DATA LAYERS */}
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} />
|
||||
<WorldviewLeftPanel data={data} activeLayers={activeLayers} setActiveLayers={setActiveLayers} onSettingsClick={() => setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} onEntityClick={setSelectedEntity} onFlyTo={(lat, lng) => setFlyToLocation({ lat, lng, ts: Date.now() })} />
|
||||
|
||||
{/* LEFT BOTTOM - DISPLAY CONFIG */}
|
||||
<WorldviewRightPanel effects={effects} setEffects={setEffects} setUiVisible={setUiVisible} />
|
||||
|
||||
@@ -656,13 +656,11 @@ export default function CesiumViewer({ data, activeLayers, activeFilters, effect
|
||||
}
|
||||
if (filters.tracked_owner?.length) {
|
||||
const op = (f.alert_operator || '').toLowerCase();
|
||||
const t1 = (f.alert_tag1 || '').toLowerCase();
|
||||
const t2 = (f.alert_tag2 || '').toLowerCase();
|
||||
const t3 = (f.alert_tag3 || '').toLowerCase();
|
||||
const tags = (f.alert_tags || '').toLowerCase();
|
||||
const cs = (f.callsign || '').toLowerCase();
|
||||
if (!filters.tracked_owner.some(sv => {
|
||||
const q = sv.toLowerCase();
|
||||
return op.includes(q) || t1.includes(q) || t2.includes(q) || t3.includes(q) || cs.includes(q);
|
||||
return op.includes(q) || tags.includes(q) || cs.includes(q);
|
||||
})) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -2,45 +2,45 @@
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { X, Rss, Server, Zap, Shield, Bug } from "lucide-react";
|
||||
import { X, Zap, Gauge, Anchor, Layers, Bug } from "lucide-react";
|
||||
|
||||
const CURRENT_VERSION = "0.6";
|
||||
const CURRENT_VERSION = "0.7";
|
||||
const STORAGE_KEY = `shadowbroker_changelog_v${CURRENT_VERSION}`;
|
||||
|
||||
const NEW_FEATURES = [
|
||||
{
|
||||
icon: <Rss size={14} className="text-orange-400" />,
|
||||
title: "Custom News Feed Manager",
|
||||
desc: "Add, remove, and prioritize up to 20 RSS intelligence sources directly from the Settings panel. Assign weight levels (1-5) to control feed importance. No more editing Python files — your custom feeds persist across restarts.",
|
||||
color: "orange",
|
||||
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: <Server size={14} className="text-purple-400" />,
|
||||
title: "Global Data Center Map Layer",
|
||||
desc: "2,000+ data centers plotted worldwide from a curated dataset. Click any DC for operator details — and if an internet outage is detected in the same country, the popup flags it automatically.",
|
||||
color: "purple",
|
||||
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: "Imperative Map Rendering",
|
||||
desc: "High-volume layers (flights, satellites, fire hotspots) now bypass React reconciliation and update the map directly via setData(). Debounced updates on dense layers. Smoother panning and zooming under load.",
|
||||
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.",
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
icon: <Shield size={14} className="text-cyan-400" />,
|
||||
title: "Enhanced Health Observability",
|
||||
desc: "The /api/health endpoint now reports per-source freshness timestamps and counts for all data layers — UAVs, FIRMS fires, LiveUAMap, GDELT, and more. Better uptime monitoring for self-hosters.",
|
||||
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.",
|
||||
color: "cyan",
|
||||
},
|
||||
];
|
||||
|
||||
const BUG_FIXES = [
|
||||
"Settings panel now has tabbed UI — API Keys and News Feeds on separate tabs",
|
||||
"Data center coordinates fixed for 187 Southern Hemisphere entries (were mirrored north of equator)",
|
||||
"Docker networking: CORS_ORIGINS env var properly passed through docker-compose",
|
||||
"Start scripts warn on Python 3.13+ compatibility issues before install",
|
||||
"Satellite and fire hotspot layers debounced (2s) to prevent render thrashing",
|
||||
"Entries with invalid geocoded coordinates automatically filtered out",
|
||||
"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",
|
||||
];
|
||||
|
||||
export function useChangelog() {
|
||||
|
||||
@@ -106,8 +106,7 @@ export default function FilterPanel({ data, activeFilters, setActiveFilters }: F
|
||||
const ops = new Set<string>(trackedOperators);
|
||||
for (const f of data?.tracked_flights || []) {
|
||||
if (f.alert_operator) ops.add(f.alert_operator);
|
||||
if (f.alert_tag1) ops.add(f.alert_tag1);
|
||||
if (f.alert_tag2) ops.add(f.alert_tag2);
|
||||
if (f.alert_tags) ops.add(f.alert_tags);
|
||||
}
|
||||
return Array.from(ops).sort();
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
@@ -101,10 +101,23 @@ const LEGEND: LegendCategory[] = [
|
||||
name: "TRACKED AIRCRAFT (ALERT)",
|
||||
color: "text-pink-400 border-pink-500/30",
|
||||
items: [
|
||||
{ svg: airliner("#FF1493"), label: "Alert — Low Priority (pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Alert — High Priority (red)" },
|
||||
{ svg: airliner("#1A3A8A"), label: "Alert — Government (navy)" },
|
||||
{ svg: airliner("white"), label: "Alert — General (white)" },
|
||||
{ svg: airliner("#FF1493"), label: "VIP / Celebrity / Bizjet (hot pink)" },
|
||||
{ svg: airliner("#FF2020"), label: "Dictator / Oligarch (red)" },
|
||||
{ svg: airliner("#3b82f6"), label: "Government / Police / Customs (blue)" },
|
||||
{ svg: heli("#32CD32"), label: "Medical / Fire / Rescue (lime)" },
|
||||
{ svg: airliner("yellow"), label: "Military / Intelligence (yellow)" },
|
||||
{ svg: airliner("#222"), label: "PIA — Privacy / Stealth (black)" },
|
||||
{ svg: airliner("#FF8C00"), label: "Private Flights / Joe Cool (orange)" },
|
||||
{ svg: airliner("white"), label: "Climate Crisis (white)" },
|
||||
{ svg: airliner("#9B59B6"), label: "Private Jets / Historic / Other (purple)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "POTUS FLEET",
|
||||
color: "text-yellow-400 border-yellow-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(6,6)"><path d="M12 2C11.2 2 10.5 2.8 10.5 3.5V8.5L3 13V15L10.5 12.5V18L8 19.5V21L12 19.5L16 21V19.5L13.5 18V12.5L21 15V13L13.5 8.5V3.5C13.5 2.8 12.8 2 12 2Z" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`, label: "Air Force One / Two (gold ring)" },
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 32 32"><circle cx="16" cy="16" r="14" fill="none" stroke="gold" stroke-width="2" stroke-dasharray="4 2"/><g transform="translate(8,6)"><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" stroke-width="0.5"/></g></svg>`, label: "Marine One (gold ring + heli)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -138,7 +151,15 @@ const LEGEND: LegendCategory[] = [
|
||||
name: "GEOPHYSICAL",
|
||||
color: "text-orange-400 border-orange-500/30",
|
||||
items: [
|
||||
{ svg: circle("#ff6600"), label: "Earthquake (size = magnitude)" },
|
||||
{ svg: circle("#ffcc00"), label: "Earthquake (yellow blob, size = magnitude)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "WILDFIRES",
|
||||
color: "text-red-400 border-red-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path d="M12 1C8 7 5 10 5 14a7 7 0 0 0 14 0c0-4-3-7-7-13z" fill="#ff6600" stroke="#ffcc00" stroke-width="1"/></svg>`, label: "Active wildfire / hotspot" },
|
||||
{ svg: clusterCircle("#cc0000", "#ff3300"), label: "Fire cluster (grouped hotspots)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -166,6 +187,14 @@ const LEGEND: LegendCategory[] = [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" fill="#ff0040" stroke="#000" stroke-width="1" opacity="0.2" rx="2"/></svg>`, label: "Low severity (25-50% degraded)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "INFRASTRUCTURE",
|
||||
color: "text-purple-400 border-purple-500/30",
|
||||
items: [
|
||||
{ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a78bfa" stroke-width="1.5"><rect x="3" y="3" width="18" height="6" rx="1" fill="#2e1065"/><rect x="3" y="11" width="18" height="6" rx="1" fill="#2e1065"/><circle cx="7" cy="6" r="1" fill="#a78bfa"/><circle cx="7" cy="14" r="1" fill="#a78bfa"/></svg>`, label: "Data Center" },
|
||||
{ svg: circle("#888"), label: "Internet Outage Zone (grey)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "SURVEILLANCE / CCTV",
|
||||
color: "text-green-400 border-green-500/30",
|
||||
|
||||
@@ -26,10 +26,12 @@ const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="
|
||||
const svgPlaneAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF2020" stroke="black"><path d="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" /></svg>`)}`;
|
||||
const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#1A3A8A" stroke="#4A80D0"><path d="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" /></svg>`)}`;
|
||||
const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000" stroke-width="2"><path d="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" /></svg>`)}`;
|
||||
const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff66b2" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff66b2" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#FF1493" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#FF1493" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#ff0000" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#000080" stroke="#4A80D0"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#4A80D0" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#ff0000"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#ff0000" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#3b82f6" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#3b82f6" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="#32CD32" stroke="black"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#32CD32" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="white" stroke="#666"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#999" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="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" /></svg>`)}`;
|
||||
const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="#222" stroke="#444"><path d="M10 6L10 14L8 16L8 18L10 17L12 22L14 17L16 18L16 16L14 14L14 6C14 4 13 2 12 2C11 2 10 4 10 6Z"/><circle cx="12" cy="12" r="8" fill="none" stroke="#444" stroke-dasharray="2 2" stroke-width="1"/></svg>`)}`;
|
||||
const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="orange" stroke="black"><path d="M12 2L15 8H9L12 2Z" /><rect x="8" y="8" width="8" height="2" /><path d="M4 10L10 14H14L20 10V12L14 16H10L4 12V10Z" /><circle cx="12" cy="14" r="2" fill="red"/></svg>`)}`;
|
||||
@@ -88,6 +90,13 @@ function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic',
|
||||
return `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="${fill}" stroke="${stroke}"><path d="${p}"/>${extras}</svg>`)}`;
|
||||
}
|
||||
|
||||
// POTUS fleet — oversized hot pink with yellow halo ring
|
||||
const svgPotusPlane = `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(4,4)"><path d="${AIRLINER_PATH}" fill="#FF1493" stroke="black"/><circle cx="7" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/><circle cx="17" cy="12.5" r="1.2" fill="#FF1493" stroke="black" stroke-width="0.5"/></g></svg>`)}`;
|
||||
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']);
|
||||
|
||||
// Pre-built aircraft SVGs by type & color
|
||||
const svgAirlinerCyan = makeAircraftSvg('airliner', 'cyan');
|
||||
const svgAirlinerOrange = makeAircraftSvg('airliner', '#FF8C00');
|
||||
@@ -96,7 +105,10 @@ const svgAirlinerYellow = makeAircraftSvg('airliner', 'yellow');
|
||||
const svgAirlinerPink = makeAircraftSvg('airliner', '#FF1493', 'black', 22);
|
||||
const svgAirlinerRed = makeAircraftSvg('airliner', '#FF2020', 'black', 22);
|
||||
const svgAirlinerDarkBlue = makeAircraftSvg('airliner', '#1A3A8A', '#4A80D0', 22);
|
||||
const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#ff0000', 22);
|
||||
const svgAirlinerBlue = makeAircraftSvg('airliner', '#3b82f6', 'black', 22);
|
||||
const svgAirlinerLime = makeAircraftSvg('airliner', '#32CD32', 'black', 22);
|
||||
const svgAirlinerBlack = makeAircraftSvg('airliner', '#222', '#555', 22);
|
||||
const svgAirlinerWhite = makeAircraftSvg('airliner', 'white', '#666', 22);
|
||||
|
||||
const svgTurbopropCyan = makeAircraftSvg('turboprop', 'cyan');
|
||||
const svgTurbopropOrange = makeAircraftSvg('turboprop', '#FF8C00');
|
||||
@@ -105,7 +117,10 @@ const svgTurbopropYellow = makeAircraftSvg('turboprop', 'yellow');
|
||||
const svgTurbopropPink = makeAircraftSvg('turboprop', '#FF1493', 'black', 22);
|
||||
const svgTurbopropRed = makeAircraftSvg('turboprop', '#FF2020', 'black', 22);
|
||||
const svgTurbopropDarkBlue = makeAircraftSvg('turboprop', '#1A3A8A', '#4A80D0', 22);
|
||||
const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#ff0000', 22);
|
||||
const svgTurbopropBlue = makeAircraftSvg('turboprop', '#3b82f6', 'black', 22);
|
||||
const svgTurbopropLime = makeAircraftSvg('turboprop', '#32CD32', 'black', 22);
|
||||
const svgTurbopropBlack = makeAircraftSvg('turboprop', '#222', '#555', 22);
|
||||
const svgTurbopropWhite = makeAircraftSvg('turboprop', 'white', '#666', 22);
|
||||
|
||||
const svgBizjetCyan = makeAircraftSvg('bizjet', 'cyan');
|
||||
const svgBizjetOrange = makeAircraftSvg('bizjet', '#FF8C00');
|
||||
@@ -114,7 +129,10 @@ const svgBizjetYellow = makeAircraftSvg('bizjet', 'yellow');
|
||||
const svgBizjetPink = makeAircraftSvg('bizjet', '#FF1493', 'black', 22);
|
||||
const svgBizjetRed = makeAircraftSvg('bizjet', '#FF2020', 'black', 22);
|
||||
const svgBizjetDarkBlue = makeAircraftSvg('bizjet', '#1A3A8A', '#4A80D0', 22);
|
||||
const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#ff0000', 22);
|
||||
const svgBizjetBlue = makeAircraftSvg('bizjet', '#3b82f6', 'black', 22);
|
||||
const svgBizjetLime = makeAircraftSvg('bizjet', '#32CD32', 'black', 22);
|
||||
const svgBizjetBlack = makeAircraftSvg('bizjet', '#222', '#555', 22);
|
||||
const svgBizjetWhite = makeAircraftSvg('bizjet', 'white', '#666', 22);
|
||||
|
||||
// Grey variants for grounded/parked aircraft (altitude 0)
|
||||
const svgAirlinerGrey = makeAircraftSvg('airliner', '#555', '#333');
|
||||
@@ -125,6 +143,13 @@ const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="h
|
||||
// Grey icon map for grounded aircraft
|
||||
const GROUNDED_ICON_MAP: Record<string, string> = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' };
|
||||
|
||||
// Per-layer color maps (module-level to avoid re-allocation every render tick)
|
||||
const COLOR_MAP_COMMERCIAL: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
||||
const COLOR_MAP_PRIVATE: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
||||
const COLOR_MAP_JETS: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
||||
const COLOR_MAP_MILITARY: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
|
||||
const MIL_SPECIAL_MAP: Record<string, string> = { fighter: 'svgFighter', tanker: 'svgTanker', recon: 'svgRecon' };
|
||||
|
||||
// ICAO type code -> aircraft shape classification
|
||||
const HELI_TYPES = new Set(['R22', 'R44', 'R66', 'B06', 'B05', 'B47G', 'B105', 'B212', 'B222', 'B230', 'B407', 'B412', 'B429', 'B430', 'B505', 'BK17', 'S55', 'S58', 'S61', 'S64', 'S70', 'S76', 'S92', 'A109', 'A119', 'A139', 'A169', 'A189', 'AW09', 'EC20', 'EC25', 'EC30', 'EC35', 'EC45', 'EC55', 'EC75', 'H125', 'H130', 'H135', 'H145', 'H155', 'H160', 'H175', 'H215', 'H225', 'AS32', 'AS35', 'AS50', 'AS55', 'AS65', 'MD52', 'MD60', 'MDHI', 'MD90', 'NOTR', 'HUEY', 'GAMA', 'CABR', 'EXE', 'R300', 'R480', 'LAMA', 'ALLI', 'PUMA', 'NH90', 'CH47', 'UH1', 'UH60', 'AH64', 'MI8', 'MI24', 'MI26', 'MI28', 'KA52', 'K32', 'LYNX', 'WILD', 'MRLX', 'A149', 'A119']);
|
||||
const TURBOPROP_TYPES = new Set(['AT43', 'AT45', 'AT72', 'AT73', 'AT75', 'AT76', 'B190', 'B350', 'BE20', 'BE30', 'BE40', 'BE9L', 'BE99', 'C130', 'C160', 'C208', 'C212', 'C295', 'CN35', 'D228', 'D328', 'DHC2', 'DHC3', 'DHC4', 'DHC5', 'DHC6', 'DHC7', 'DHC8', 'DO28', 'DH8A', 'DH8B', 'DH8C', 'DH8D', 'E110', 'E120', 'F27', 'F406', 'F50', 'G159', 'G73T', 'J328', 'JS31', 'JS32', 'JS41', 'L188', 'MA60', 'M28', 'N262', 'P68', 'P180', 'PA31', 'PA42', 'PC12', 'PC21', 'PC24', 'S2', 'S340', 'SF34', 'SF50', 'SW4', 'TRIS', 'TBM7', 'TBM8', 'TBM9', 'C30J', 'C5M', 'AN12', 'AN24', 'AN26', 'AN30', 'AN32', 'IL18', 'L410', 'Y12', 'BALL', 'AEST', 'AC68', 'AC80', 'AC90', 'AC95', 'AC11', 'C172', 'C182', 'C206', 'C210', 'C310', 'C337', 'C402', 'C414', 'C421', 'C425', 'C441', 'M20P', 'M20T', 'PA28', 'PA32', 'PA34', 'PA44', 'PA46', 'PA60', 'P28A', 'P28B', 'P28R', 'P32R', 'P46T', 'SR20', 'SR22', 'DA40', 'DA42', 'DA62', 'RV10', 'BE33', 'BE35', 'BE36', 'BE55', 'BE58', 'DR40', 'TB20', 'AA5']);
|
||||
@@ -327,21 +352,26 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
let isMounted = true;
|
||||
|
||||
let callsign = null;
|
||||
let entityLat = 0;
|
||||
let entityLng = 0;
|
||||
if (selectedEntity && data) {
|
||||
let entity = null;
|
||||
if (selectedEntity.type === 'flight') entity = data?.commercial_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
|
||||
|
||||
if (entity && entity.callsign) {
|
||||
callsign = entity.callsign;
|
||||
entityLat = entity.lat ?? 0;
|
||||
entityLng = entity.lng ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (callsign && callsign !== prevCallsign.current) {
|
||||
prevCallsign.current = callsign;
|
||||
fetch(`${API_BASE}/api/route/${callsign}`)
|
||||
fetch(`${API_BASE}/api/route/${callsign}?lat=${entityLat}&lng=${entityLng}`)
|
||||
.then(res => res.json())
|
||||
.then(routeData => {
|
||||
if (isMounted) setDynamicRoute(routeData);
|
||||
@@ -579,96 +609,106 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy generic plane icons (still used as fallbacks)
|
||||
// Critical icons — needed immediately for default-on layers
|
||||
loadImg('svgPlaneCyan', svgPlaneCyan);
|
||||
loadImg('svgPlaneYellow', svgPlaneYellow);
|
||||
loadImg('svgPlaneOrange', svgPlaneOrange);
|
||||
loadImg('svgPlanePurple', svgPlanePurple);
|
||||
loadImg('svgPlanePink', svgPlanePink);
|
||||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||||
// Heli icons
|
||||
loadImg('svgHeli', svgHeli);
|
||||
loadImg('svgHeliCyan', svgHeliCyan);
|
||||
loadImg('svgHeliOrange', svgHeliOrange);
|
||||
loadImg('svgHeliPurple', svgHeliPurple);
|
||||
loadImg('svgHeliPink', svgHeliPink);
|
||||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||||
loadImg('svgHeliBlack', svgHeliBlack);
|
||||
// Military special
|
||||
loadImg('svgHeliBlue', svgHeliBlue);
|
||||
loadImg('svgHeliLime', svgHeliLime);
|
||||
loadImg('svgFighter', svgFighter);
|
||||
loadImg('svgTanker', svgTanker);
|
||||
loadImg('svgRecon', svgRecon);
|
||||
// Airliner icons (swept wings + engine pods)
|
||||
loadImg('svgAirlinerCyan', svgAirlinerCyan);
|
||||
loadImg('svgAirlinerOrange', svgAirlinerOrange);
|
||||
loadImg('svgAirlinerPurple', svgAirlinerPurple);
|
||||
loadImg('svgAirlinerYellow', svgAirlinerYellow);
|
||||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||||
// Turboprop icons (straight wings)
|
||||
loadImg('svgTurbopropCyan', svgTurbopropCyan);
|
||||
loadImg('svgTurbopropOrange', svgTurbopropOrange);
|
||||
loadImg('svgTurbopropPurple', svgTurbopropPurple);
|
||||
loadImg('svgTurbopropYellow', svgTurbopropYellow);
|
||||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||||
// Bizjet icons (sleek, T-tail)
|
||||
loadImg('svgBizjetCyan', svgBizjetCyan);
|
||||
loadImg('svgBizjetOrange', svgBizjetOrange);
|
||||
loadImg('svgBizjetPurple', svgBizjetPurple);
|
||||
loadImg('svgBizjetYellow', svgBizjetYellow);
|
||||
loadImg('svgBizjetPink', svgBizjetPink);
|
||||
loadImg('svgBizjetRed', svgBizjetRed);
|
||||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
||||
// Grey grounded icons
|
||||
loadImg('svgAirlinerGrey', svgAirlinerGrey);
|
||||
loadImg('svgTurbopropGrey', svgTurbopropGrey);
|
||||
loadImg('svgBizjetGrey', svgBizjetGrey);
|
||||
loadImg('svgHeliGrey', svgHeliGrey);
|
||||
loadImg('svgDrone', svgDrone);
|
||||
loadImg('svgShipGray', svgShipGray);
|
||||
loadImg('svgShipRed', svgShipRed);
|
||||
loadImg('svgShipYellow', svgShipYellow);
|
||||
loadImg('svgShipBlue', svgShipBlue);
|
||||
loadImg('svgShipWhite', svgShipWhite);
|
||||
loadImg('svgCarrier', svgCarrier);
|
||||
loadImg('svgCctv', svgCctv);
|
||||
loadImg('svgWarning', svgWarning);
|
||||
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);
|
||||
|
||||
// Data center icon
|
||||
loadImg('datacenter', svgDataCenter);
|
||||
|
||||
// Satellite mission-type icons
|
||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
||||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
||||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||
// Deferred icons — for off-by-default layers and rare variants
|
||||
// Loaded in next frame to avoid blocking initial map render
|
||||
setTimeout(() => {
|
||||
loadImg('svgPlanePink', svgPlanePink);
|
||||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||||
loadImg('svgHeliPink', svgHeliPink);
|
||||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||||
loadImg('svgHeliBlack', svgHeliBlack);
|
||||
loadImg('svgPotusPlane', svgPotusPlane);
|
||||
loadImg('svgPotusHeli', svgPotusHeli);
|
||||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||||
loadImg('svgAirlinerBlue', svgAirlinerBlue);
|
||||
loadImg('svgAirlinerLime', svgAirlinerLime);
|
||||
loadImg('svgAirlinerBlack', svgAirlinerBlack);
|
||||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||||
loadImg('svgTurbopropBlue', svgTurbopropBlue);
|
||||
loadImg('svgTurbopropLime', svgTurbopropLime);
|
||||
loadImg('svgTurbopropBlack', svgTurbopropBlack);
|
||||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||||
loadImg('svgBizjetPink', svgBizjetPink);
|
||||
loadImg('svgBizjetRed', svgBizjetRed);
|
||||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||||
loadImg('svgBizjetBlue', svgBizjetBlue);
|
||||
loadImg('svgBizjetLime', svgBizjetLime);
|
||||
loadImg('svgBizjetBlack', svgBizjetBlack);
|
||||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
||||
loadImg('svgDrone', svgDrone);
|
||||
loadImg('svgCctv', svgCctv);
|
||||
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);
|
||||
// Data center icon
|
||||
loadImg('datacenter', svgDataCenter);
|
||||
// Satellite mission-type icons
|
||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
||||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
||||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
||||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
||||
}, 0);
|
||||
|
||||
setMapReady(true);
|
||||
}, []);
|
||||
@@ -748,7 +788,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
|
||||
const commFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.flights || !data?.commercial_flights) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.commercial_flights.map((f: any, i: number) => {
|
||||
@@ -760,7 +799,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'flight', callsign: f.callsign || f.icao24, rotation: f.true_track || f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_COMMERCIAL[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -769,7 +808,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
|
||||
const privFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.private || !data?.private_flights) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.private_flights.map((f: any, i: number) => {
|
||||
@@ -781,7 +819,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'private_flight', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_PRIVATE[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -790,7 +828,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
|
||||
const privJetsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.jets || !data?.private_jets) return null;
|
||||
const colorMap: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.private_jets.map((f: any, i: number) => {
|
||||
@@ -802,7 +839,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
return {
|
||||
type: 'Feature',
|
||||
properties: { id: i, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : colorMap[acType] },
|
||||
properties: { id: i, type: 'private_jet', callsign: f.callsign || f.icao24, rotation: f.heading || 0, iconId: grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_JETS[acType] },
|
||||
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||
};
|
||||
}).filter(Boolean)
|
||||
@@ -812,11 +849,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const milFlightsGeoJSON = useMemo(() => {
|
||||
if (!activeLayers.military || !data?.military_flights) return null;
|
||||
|
||||
// Special military types keep their unique icons
|
||||
const milSpecialMap: any = { 'fighter': 'svgFighter', 'tanker': 'svgTanker', 'recon': 'svgRecon' };
|
||||
// Fallback by aircraft shape for cargo/default
|
||||
const milColorMap: Record<string, string> = { heli: 'svgHeli', turboprop: 'svgTurbopropYellow', bizjet: 'svgBizjetYellow', airliner: 'svgAirlinerYellow' };
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.military_flights.map((f: any, i: number) => {
|
||||
@@ -825,10 +857,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
||||
const milType = f.military_type || 'default';
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
let iconId = milSpecialMap[milType];
|
||||
let iconId = MIL_SPECIAL_MAP[milType];
|
||||
if (!iconId) {
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
iconId = grounded ? GROUNDED_ICON_MAP[acType] : milColorMap[acType];
|
||||
iconId = grounded ? GROUNDED_ICON_MAP[acType] : COLOR_MAP_MILITARY[acType];
|
||||
} else if (grounded) {
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
iconId = GROUNDED_ICON_MAP[acType];
|
||||
@@ -980,6 +1012,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
else if (selectedEntity.type === 'private_flight') entity = data?.private_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'military_flight') entity = data?.military_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'private_jet') entity = data?.private_jets?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'tracked_flight') entity = data?.tracked_flights?.[selectedEntity.id as number];
|
||||
else if (selectedEntity.type === 'ship') entity = data?.ships?.[selectedEntity.id as number];
|
||||
|
||||
if (!entity) return null;
|
||||
@@ -991,6 +1024,9 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
if (dynamicRoute && dynamicRoute.orig_loc && dynamicRoute.dest_loc) {
|
||||
originLoc = dynamicRoute.orig_loc;
|
||||
destLoc = dynamicRoute.dest_loc;
|
||||
// Also override display names so NewsFeed shows the resolved airport info
|
||||
if (dynamicRoute.origin_name) entity.origin_name = dynamicRoute.origin_name;
|
||||
if (dynamicRoute.dest_name) entity.dest_name = dynamicRoute.dest_name;
|
||||
}
|
||||
|
||||
const features = [];
|
||||
@@ -1154,10 +1190,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
|
||||
// Tracked icon maps by aircraft shape and alert color
|
||||
const trackedIconMap: Record<string, Record<string, string>> = {
|
||||
heli: { pink: 'svgHeliPink', red: 'svgHeliAlertRed', darkblue: 'svgHeliDarkBlue', white: 'svgHeliWhiteAlert' },
|
||||
airliner: { pink: 'svgAirlinerPink', red: 'svgAirlinerRed', darkblue: 'svgAirlinerDarkBlue', white: 'svgAirlinerWhite' },
|
||||
turboprop: { pink: 'svgTurbopropPink', red: 'svgTurbopropRed', darkblue: 'svgTurbopropDarkBlue', white: 'svgTurbopropWhite' },
|
||||
bizjet: { pink: 'svgBizjetPink', red: 'svgBizjetRed', darkblue: 'svgBizjetDarkBlue', white: 'svgBizjetWhite' },
|
||||
heli: { '#ff1493': 'svgHeliPink', pink: 'svgHeliPink', red: 'svgHeliAlertRed', blue: 'svgHeliBlue', darkblue: 'svgHeliDarkBlue', yellow: 'svgHeli', orange: 'svgHeliOrange', purple: 'svgHeliPurple', '#32cd32': 'svgHeliLime', black: 'svgHeliBlack', white: 'svgHeliWhiteAlert' },
|
||||
airliner: { '#ff1493': 'svgAirlinerPink', pink: 'svgAirlinerPink', red: 'svgAirlinerRed', blue: 'svgAirlinerBlue', darkblue: 'svgAirlinerDarkBlue', yellow: 'svgAirlinerYellow', orange: 'svgAirlinerOrange', purple: 'svgAirlinerPurple', '#32cd32': 'svgAirlinerLime', black: 'svgAirlinerBlack', white: 'svgAirlinerWhite' },
|
||||
turboprop: { '#ff1493': 'svgTurbopropPink', pink: 'svgTurbopropPink', red: 'svgTurbopropRed', blue: 'svgTurbopropBlue', darkblue: 'svgTurbopropDarkBlue', yellow: 'svgTurbopropYellow', orange: 'svgTurbopropOrange', purple: 'svgTurbopropPurple', '#32cd32': 'svgTurbopropLime', black: 'svgTurbopropBlack', white: 'svgTurbopropWhite' },
|
||||
bizjet: { '#ff1493': 'svgBizjetPink', pink: 'svgBizjetPink', red: 'svgBizjetRed', blue: 'svgBizjetBlue', darkblue: 'svgBizjetDarkBlue', yellow: 'svgBizjetYellow', orange: 'svgBizjetOrange', purple: 'svgBizjetPurple', '#32cd32': 'svgBizjetLime', black: 'svgBizjetBlack', white: 'svgBizjetWhite' },
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1168,7 +1204,11 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
const alertColor = f.alert_color || 'white';
|
||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
const iconId = grounded ? GROUNDED_ICON_MAP[acType] : (trackedIconMap[acType]?.[alertColor] || trackedIconMap.airliner[alertColor] || 'svgAirlinerWhite');
|
||||
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 displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
|
||||
|
||||
@@ -1700,16 +1740,46 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Marker>
|
||||
))}
|
||||
|
||||
{/* HTML labels for tracked flights (pink names, grey when grounded) */}
|
||||
{/* HTML labels for tracked flights — color-matched, zoom-gated for non-HVA */}
|
||||
{trackedFlightsGeoJSON && !selectedEntity && data?.tracked_flights?.map((f: any, i: number) => {
|
||||
if (f.lat == null || f.lng == null) return null;
|
||||
if (!inView(f.lat, f.lng)) return null;
|
||||
const displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
|
||||
|
||||
const alertColor = f.alert_color || '#ff1493';
|
||||
// Always hide military labels (yellow) — too many, clutters map
|
||||
if (alertColor === 'yellow') return null;
|
||||
// Hide black (PIA) labels — they want to stay hidden
|
||||
if (alertColor === 'black') return null;
|
||||
|
||||
// Only show non-HVA/non-red labels when zoomed in (~2000mi or closer = zoom >= 5)
|
||||
const isHighPriority = alertColor === '#ff1493' || alertColor === 'pink' || alertColor === 'red';
|
||||
if (!isHighPriority && viewState.zoom < 5) return null;
|
||||
|
||||
let displayName = f.alert_operator || f.operator || f.owner || f.name || f.callsign || f.icao24 || "UNKNOWN";
|
||||
// Strip redundant "Private" labels — tells you nothing
|
||||
if (displayName === 'Private' || displayName === 'private') return null;
|
||||
|
||||
// Map alert_color to a visible label color (some hex colors render near-white)
|
||||
const labelColorMap: Record<string, string> = {
|
||||
'#ff1493': '#ff1493', pink: '#ff1493', red: '#ff4444',
|
||||
blue: '#3b82f6', orange: '#FF8C00', '#32cd32': '#32cd32',
|
||||
purple: '#b266ff', white: '#cccccc',
|
||||
};
|
||||
const grounded = f.alt != null && f.alt <= 100;
|
||||
const labelColor = grounded ? '#888' : (labelColorMap[alertColor] || alertColor);
|
||||
const [iLng, iLat] = interpFlight(f);
|
||||
|
||||
return (
|
||||
<Marker key={`tf-label-${i}`} longitude={iLng} latitude={iLat} anchor="top" offset={[0, 10]} style={{ zIndex: 2 }}>
|
||||
<div style={{ color: grounded ? '#888' : '#ff1493', fontSize: '10px', fontFamily: 'monospace', fontWeight: 'bold', textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000', whiteSpace: 'nowrap', pointerEvents: 'none' }}>
|
||||
<div style={{
|
||||
color: labelColor,
|
||||
fontSize: '10px',
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'bold',
|
||||
textShadow: '0 0 3px #000, 0 0 3px #000, 1px 1px 2px #000',
|
||||
whiteSpace: 'nowrap',
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
{String(displayName)}
|
||||
</div>
|
||||
</Marker>
|
||||
@@ -2487,20 +2557,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
{(() => {
|
||||
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
|
||||
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_list || [];
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[9px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-orange-400 text-[9px] underline hover:text-orange-300 block py-1 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
{headlines[idx] || url}
|
||||
</a>
|
||||
));
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => {
|
||||
const headline = headlines[idx] || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||||
style={{ pointerEvents: 'all' }}
|
||||
>
|
||||
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2643,64 +2723,209 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{/* SENTINEL-2 IMAGERY — floating intel card on map near right-click */}
|
||||
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (
|
||||
<Popup
|
||||
longitude={selectedEntity.extra.lng}
|
||||
latitude={selectedEntity.extra.lat}
|
||||
anchor="top-left"
|
||||
offset={[20, -10]}
|
||||
closeButton={false}
|
||||
closeOnClick={false}
|
||||
className="sentinel-popup"
|
||||
maxWidth="320px"
|
||||
>
|
||||
<div className="bg-black/90 backdrop-blur-md border border-blue-500/50 rounded-lg overflow-hidden shadow-[0_0_25px_rgba(59,130,246,0.3)] pointer-events-auto" style={{ width: 300 }}>
|
||||
{/* Header bar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-blue-950/60 border-b border-blue-500/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
|
||||
<span className="text-[9px] text-blue-400 font-mono tracking-[0.2em] font-bold">SENTINEL-2 IMAGERY</span>
|
||||
{/* SENTINEL-2 IMAGERY — fullscreen overlay modal */}
|
||||
{selectedEntity?.type === 'region_dossier' && selectedEntity.extra && regionDossier?.sentinel2 && !regionDossierLoading && (() => {
|
||||
const s2 = regionDossier.sentinel2;
|
||||
const imgUrl = s2.fullres_url || s2.thumbnail_url;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 9999,
|
||||
background: 'rgba(0,0,0,0.85)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '60px 20px 20px 20px',
|
||||
}}
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onEntityClick(null); }}
|
||||
onKeyDown={(e: any) => { if (e.key === 'Escape') onEntityClick(null); }}
|
||||
tabIndex={-1}
|
||||
ref={(el) => el?.focus()}
|
||||
>
|
||||
<div style={{
|
||||
background: 'rgba(0,0,0,0.95)',
|
||||
border: '1px solid rgba(59,130,246,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)',
|
||||
}}>
|
||||
{/* Header bar */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(30,58,138,0.4)',
|
||||
borderBottom: '1px solid rgba(59,130,246,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' }}>
|
||||
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' }}>
|
||||
{selectedEntity.extra.lat.toFixed(4)}, {selectedEntity.extra.lng.toFixed(4)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onEntityClick(null)}
|
||||
style={{
|
||||
background: 'rgba(239,68,68,0.2)',
|
||||
border: '1px solid rgba(239,68,68,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#ef4444',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '4px 10px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.1em',
|
||||
}}
|
||||
>
|
||||
✕ CLOSE
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[8px] text-blue-300/60 font-mono">{selectedEntity.extra.lat.toFixed(3)}, {selectedEntity.extra.lng.toFixed(3)}</span>
|
||||
|
||||
{s2.found ? (
|
||||
<>
|
||||
{/* Metadata row */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '8px 16px',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
borderBottom: '1px solid rgba(30,58,138,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>
|
||||
</div>
|
||||
|
||||
{/* Image */}
|
||||
{imgUrl ? (
|
||||
<div style={{ flex: 1, overflow: 'auto', display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: 400 }}>
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt="Sentinel-2 scene"
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
objectFit: 'contain',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
|
||||
Scene found — no preview available
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{imgUrl && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(30,58,138,0.3)',
|
||||
borderTop: '1px solid rgba(59,130,246,0.2)',
|
||||
}}>
|
||||
<a
|
||||
href={imgUrl}
|
||||
download={`sentinel2_${selectedEntity.extra.lat.toFixed(4)}_${selectedEntity.extra.lng.toFixed(4)}.jpg`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'rgba(59,130,246,0.2)',
|
||||
border: '1px solid rgba(59,130,246,0.5)',
|
||||
borderRadius: 6,
|
||||
color: '#60a5fa',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
⬇ DOWNLOAD
|
||||
</a>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const resp = await fetch(imgUrl);
|
||||
const blob = await resp.blob();
|
||||
await navigator.clipboard.write([
|
||||
new ClipboardItem({ [blob.type]: blob })
|
||||
]);
|
||||
} catch {
|
||||
// fallback: copy URL
|
||||
await navigator.clipboard.writeText(imgUrl);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(34,211,238,0.15)',
|
||||
border: '1px solid rgba(34,211,238,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#22d3ee',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
📋 COPY
|
||||
</button>
|
||||
<a
|
||||
href={imgUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
background: 'rgba(16,185,129,0.15)',
|
||||
border: '1px solid rgba(16,185,129,0.4)',
|
||||
borderRadius: 6,
|
||||
color: '#10b981',
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
padding: '6px 16px',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '0.15em',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
↗ OPEN FULL RES
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ padding: '40px 16px', fontSize: 11, color: 'rgba(147,197,253,0.5)', fontFamily: 'monospace', textAlign: 'center' }}>
|
||||
No clear imagery in last 30 days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{regionDossier.sentinel2.found ? (
|
||||
<>
|
||||
{/* Metadata row */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[9px] font-mono border-b border-blue-900/40">
|
||||
<span className="text-blue-300">{regionDossier.sentinel2.platform}</span>
|
||||
<span className="text-cyan-400 font-bold">{regionDossier.sentinel2.datetime?.slice(0, 10)}</span>
|
||||
<span className="text-blue-300">{regionDossier.sentinel2.cloud_cover?.toFixed(0)}% cloud</span>
|
||||
</div>
|
||||
|
||||
{/* Thumbnail */}
|
||||
{regionDossier.sentinel2.thumbnail_url ? (
|
||||
<a href={regionDossier.sentinel2.fullres_url || regionDossier.sentinel2.thumbnail_url} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={regionDossier.sentinel2.thumbnail_url}
|
||||
alt="Sentinel-2 scene"
|
||||
className="w-full block hover:brightness-110 transition-all cursor-pointer"
|
||||
style={{ maxHeight: 220, objectFit: 'cover' }}
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">Scene found — no preview available</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-1 bg-blue-950/40 text-[7px] text-blue-400/50 font-mono tracking-widest text-center">
|
||||
CLICK IMAGE TO OPEN FULL RESOLUTION
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="px-3 py-4 text-[9px] text-blue-300/50 font-mono text-center">
|
||||
No clear imagery in last 30 days
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* MEASUREMENT LINES */}
|
||||
{measurePoints && measurePoints.length >= 2 && (
|
||||
|
||||
@@ -260,28 +260,40 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
if (flight) {
|
||||
const callsign = flight.callsign || "UNKNOWN";
|
||||
const alertColorMap: Record<string, string> = {
|
||||
'pink': 'text-pink-400', 'red': 'text-red-400',
|
||||
'darkblue': 'text-blue-400', 'white': 'text-white'
|
||||
'#ff1493': 'text-[#ff1493]', pink: 'text-[#ff1493]', red: 'text-red-400', yellow: 'text-yellow-400',
|
||||
blue: 'text-blue-400', orange: 'text-orange-400', '#32cd32': 'text-[#32cd32]', purple: 'text-purple-400',
|
||||
black: 'text-gray-400', white: 'text-white'
|
||||
};
|
||||
const alertBorderMap: Record<string, string> = {
|
||||
'pink': 'border-pink-500/30', 'red': 'border-red-500/30',
|
||||
'darkblue': 'border-blue-500/30', 'white': 'border-[var(--border-primary)]/30'
|
||||
'#ff1493': 'border-[#ff1493]/30', pink: 'border-[#ff1493]/30', red: 'border-red-500/30', yellow: 'border-yellow-500/30',
|
||||
blue: 'border-blue-500/30', orange: 'border-orange-500/30', '#32cd32': 'border-[#32cd32]/30', purple: 'border-purple-500/30',
|
||||
black: 'border-gray-500/30', white: 'border-[var(--border-primary)]/30'
|
||||
};
|
||||
const alertBgMap: Record<string, string> = {
|
||||
'pink': 'bg-pink-950/40', 'red': 'bg-red-950/40',
|
||||
'darkblue': 'bg-blue-950/40', 'white': 'bg-[var(--bg-panel)]'
|
||||
'#ff1493': 'bg-[#ff1493]/10', pink: 'bg-[#ff1493]/10', red: 'bg-red-950/40', yellow: 'bg-yellow-950/40',
|
||||
blue: 'bg-blue-950/40', orange: 'bg-orange-950/40', '#32cd32': 'bg-lime-950/40', purple: 'bg-purple-950/40',
|
||||
black: 'bg-gray-900/40', white: 'bg-[var(--bg-panel)]'
|
||||
};
|
||||
const ac = flight.alert_color || 'white';
|
||||
const headerColor = alertColorMap[ac] || 'text-white';
|
||||
const borderColor = alertBorderMap[ac] || 'border-[var(--border-primary)]/30';
|
||||
const bgColor = alertBgMap[ac] || 'bg-[var(--bg-panel)]';
|
||||
|
||||
const shadowColor = (ac === 'pink' || ac === '#ff1493') ? 'rgba(255,20,147,0.4)'
|
||||
: ac === 'red' ? 'rgba(255,32,32,0.2)'
|
||||
: ac === 'yellow' ? 'rgba(255,255,0,0.2)'
|
||||
: ac === 'blue' ? 'rgba(59,130,246,0.2)'
|
||||
: ac === 'orange' ? 'rgba(255,140,0,0.3)'
|
||||
: ac === '#32cd32' ? 'rgba(50,205,50,0.2)'
|
||||
: ac === 'purple' ? 'rgba(155,89,182,0.2)'
|
||||
: 'rgba(255,255,255,0.1)';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className={`w-full bg-black/60 backdrop-blur-md border ${ac === 'pink' ? 'border-pink-800' : ac === 'red' ? 'border-red-800' : ac === 'darkblue' ? 'border-blue-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_rgba(255,20,147,0.2)] pointer-events-auto overflow-hidden flex-shrink-0`}
|
||||
className={`w-full bg-black/60 backdrop-blur-md border ${(ac === 'pink' || ac === '#ff1493') ? 'border-[#ff1493]' : ac === 'red' ? 'border-red-800' : ac === 'yellow' ? 'border-yellow-800' : ac === 'blue' ? 'border-blue-800' : ac === 'orange' ? 'border-orange-800' : ac === '#32cd32' ? 'border-lime-800' : ac === 'purple' ? 'border-purple-800' : 'border-[var(--border-secondary)]'} rounded-xl flex flex-col z-10 font-mono shadow-[0_4px_30px_${shadowColor}] pointer-events-auto overflow-hidden flex-shrink-0`}
|
||||
>
|
||||
<div className={`p-3 border-b ${borderColor} ${bgColor} flex justify-between items-center`}>
|
||||
<h2 className={`text-xs tracking-widest font-bold ${headerColor} flex items-center gap-2`}>
|
||||
@@ -293,31 +305,39 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<div className="p-4 flex flex-col gap-3">
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">OPERATOR</span>
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (
|
||||
<a
|
||||
href={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
||||
title={`Search Wikipedia for ${flight.alert_operator}`}
|
||||
>
|
||||
{flight.alert_operator}
|
||||
</a>
|
||||
) : (
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? (() => {
|
||||
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
||||
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
||||
return (
|
||||
<a
|
||||
href={wikiHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={`text-xs font-bold underline ${headerColor} hover:opacity-80 transition-opacity`}
|
||||
title={`Search Wikipedia for ${flight.alert_operator}`}
|
||||
>
|
||||
{flight.alert_operator}
|
||||
</a>
|
||||
);
|
||||
})() : (
|
||||
<span className={`text-xs font-bold ${headerColor}`}>UNKNOWN</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Owner/Operator Wikipedia photo */}
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={`https://en.wikipedia.org/wiki/${encodeURIComponent(flight.alert_operator.replace(/ /g, '_'))}`}
|
||||
label={flight.alert_operator}
|
||||
maxH="max-h-36"
|
||||
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_operator && flight.alert_operator !== "UNKNOWN" && (() => {
|
||||
const wikiSlug = flight.alert_wiki || flight.alert_operator.replace(/\s*\(.*?\)\s*/g, '').trim().replace(/ /g, '_');
|
||||
const wikiHref = `https://en.wikipedia.org/wiki/${encodeURIComponent(wikiSlug)}`;
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
<WikiImage
|
||||
wikiUrl={wikiHref}
|
||||
label={flight.alert_operator}
|
||||
maxH="max-h-36"
|
||||
accent={ac === 'pink' ? 'hover:border-pink-500/50' : ac === 'red' ? 'hover:border-red-500/50' : 'hover:border-cyan-500/50'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Aircraft model Wikipedia photo */}
|
||||
{aircraftImgUrl && (
|
||||
<div className="border-b border-[var(--border-primary)] pb-2">
|
||||
@@ -348,22 +368,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<span className="text-[var(--text-muted)] text-[10px]">REGISTRATION</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.registration || "N/A"}</span>
|
||||
</div>
|
||||
{flight.alert_tag1 && (
|
||||
{flight.alert_tags && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAG</span>
|
||||
<span className={`text-xs font-bold ${headerColor}`}>{flight.alert_tag1}</span>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_tag2 && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">SECONDARY</span>
|
||||
<span className="text-[var(--text-primary)] text-xs font-bold">{flight.alert_tag2}</span>
|
||||
</div>
|
||||
)}
|
||||
{flight.alert_tag3 && (
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">DETAIL</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs">{flight.alert_tag3}</span>
|
||||
<span className="text-[var(--text-muted)] text-[10px]">INTEL TAGS</span>
|
||||
<span className={`text-xs font-bold text-right max-w-[200px] ${headerColor}`}>{flight.alert_tags}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between items-center border-b border-[var(--border-primary)] pb-2">
|
||||
@@ -667,10 +675,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
||||
<div
|
||||
className="text-[var(--text-primary)] text-xs whitespace-normal [&_a]:text-orange-400 [&_a]:underline hover:[&_a]:text-orange-300 [&_br]:mb-2"
|
||||
dangerouslySetInnerHTML={{ __html: props.html || 'No articles available.' }}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
|
||||
{(() => {
|
||||
const urls: string[] = props._urls_list || [];
|
||||
const headlines: string[] = props._headlines_list || [];
|
||||
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||
return urls.map((url: string, idx: number) => {
|
||||
const headline = headlines[idx] || '';
|
||||
let domain = '';
|
||||
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block py-1.5 border-b border-[var(--border-primary)]/50 last:border-0 cursor-pointer group"
|
||||
>
|
||||
<span className="text-orange-400 text-[11px] font-bold leading-tight group-hover:text-orange-300 block">
|
||||
{headline || domain || 'View Article'}
|
||||
</span>
|
||||
{headline && domain && (
|
||||
<span className="text-[var(--text-muted)] text-[9px] block mt-0.5">{domain}</span>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -966,9 +998,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
||||
<motion.div
|
||||
key={idx}
|
||||
ref={(el) => { itemRefs.current[idx] = el; }}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
initial={idx < 15 ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + (idx * 0.05) }}
|
||||
transition={idx < 15 ? { delay: 0.1 + (idx * 0.05) } : { duration: 0 }}
|
||||
className={`p-2 rounded-sm border-l-[2px] border-r border-t border-b ${bgClass} flex flex-col gap-1 relative group shrink-0`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
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 } 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 } from "lucide-react";
|
||||
import { useTheme } from "@/lib/ThemeContext";
|
||||
|
||||
function relativeTime(iso: string | undefined): string {
|
||||
@@ -40,7 +40,25 @@ const FRESHNESS_MAP: Record<string, string> = {
|
||||
datacenters: "datacenters",
|
||||
};
|
||||
|
||||
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 }) {
|
||||
// POTUS fleet ICAO hex codes for client-side filtering
|
||||
const POTUS_ICAOS: Record<string, { label: string; type: string }> = {
|
||||
'ADFDF8': { label: 'Air Force One (82-8000)', type: 'AF1' },
|
||||
'ADFDF9': { label: 'Air Force One (92-9000)', type: 'AF1' },
|
||||
'ADFEB7': { label: 'Air Force Two (98-0001)', type: 'AF2' },
|
||||
'ADFEB8': { label: 'Air Force Two (98-0002)', type: 'AF2' },
|
||||
'ADFEB9': { label: 'Air Force Two (99-0003)', type: 'AF2' },
|
||||
'ADFEBA': { label: 'Air Force Two (99-0004)', type: 'AF2' },
|
||||
'AE4AE6': { label: 'Air Force Two (09-0015)', type: 'AF2' },
|
||||
'AE4AE8': { label: 'Air Force Two (09-0016)', type: 'AF2' },
|
||||
'AE4AEA': { label: 'Air Force Two (09-0017)', type: 'AF2' },
|
||||
'AE4AEC': { label: 'Air Force Two (19-0018)', type: 'AF2' },
|
||||
'AE0865': { label: 'Marine One (VH-3D)', type: 'M1' },
|
||||
'AE5E76': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E77': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
'AE5E79': { label: 'Marine One (VH-92A)', type: 'M1' },
|
||||
};
|
||||
|
||||
const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, activeLayers, setActiveLayers, onSettingsClick, onLegendClick, gibsDate, setGibsDate, gibsOpacity, setGibsOpacity, onEntityClick, onFlyTo }: { data: any; activeLayers: any; setActiveLayers: any; onSettingsClick?: () => void; onLegendClick?: () => void; gibsDate?: string; setGibsDate?: (d: string) => void; gibsOpacity?: number; setGibsOpacity?: (o: number) => void; onEntityClick?: (entity: { type: string; id: number; extra?: any }) => void; onFlyTo?: (lat: number, lng: number) => void }) {
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const [gibsPlaying, setGibsPlaying] = useState(false);
|
||||
@@ -70,10 +88,34 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
|
||||
}, [gibsPlaying, gibsDate, setGibsDate]);
|
||||
|
||||
// Compute ship category counts
|
||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
||||
// Compute ship category counts (memoized — ships array can be 1000+ items)
|
||||
const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
|
||||
const ships = data?.ships;
|
||||
if (!ships || !ships.length) return { importantShipCount: 0, passengerShipCount: 0, civilianShipCount: 0 };
|
||||
let important = 0, passenger = 0, civilian = 0;
|
||||
for (const s of ships) {
|
||||
const t = s.type;
|
||||
if (t === 'carrier' || t === 'military_vessel' || t === 'tanker' || t === 'cargo') important++;
|
||||
else if (t === 'passenger') passenger++;
|
||||
else civilian++;
|
||||
}
|
||||
return { importantShipCount: important, passengerShipCount: passenger, civilianShipCount: civilian };
|
||||
}, [data?.ships]);
|
||||
|
||||
// Find POTUS fleet planes currently airborne from tracked flights
|
||||
const potusFlights = useMemo(() => {
|
||||
const tracked = data?.tracked_flights;
|
||||
if (!tracked) return [];
|
||||
const results: { index: number; flight: any; meta: { label: string; type: string } }[] = [];
|
||||
for (let i = 0; i < tracked.length; i++) {
|
||||
const f = tracked[i];
|
||||
const icao = (f.icao24 || '').toUpperCase();
|
||||
if (POTUS_ICAOS[icao]) {
|
||||
results.push({ index: i, flight: f, meta: POTUS_ICAOS[icao] });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}, [data?.tracked_flights]);
|
||||
|
||||
const layers = [
|
||||
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
||||
@@ -251,6 +293,58 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// All API calls use relative paths (e.g. /api/flights).
|
||||
// Next.js rewrites them at the server level to BACKEND_URL (set in docker-compose
|
||||
// or .env.local for dev). This means:
|
||||
// The catch-all route handler at src/app/api/[...path]/route.ts proxies them
|
||||
// to BACKEND_URL at runtime (set in docker-compose or .env.local for dev).
|
||||
// This means:
|
||||
// - No build-time baking of the backend URL into the client bundle
|
||||
// - BACKEND_URL=http://backend:8000 works via Docker internal networking
|
||||
// - Only port 3000 needs to be exposed externally
|
||||
|
||||
Reference in New Issue
Block a user