diff --git a/backend/data/plane_alert_db.json b/backend/data/plane_alert_db.json deleted file mode 100644 index 9e26dfe..0000000 --- a/backend/data/plane_alert_db.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/backend/data/plane_alert_db.json.REMOVED.git-id b/backend/data/plane_alert_db.json.REMOVED.git-id new file mode 100644 index 0000000..a71d364 --- /dev/null +++ b/backend/data/plane_alert_db.json.REMOVED.git-id @@ -0,0 +1 @@ +eb32e2f229fad1d5a14fe81e071e71591f875673 \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index bfb6fba..9c975bf 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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. @@ -187,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): @@ -201,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 {} diff --git a/backend/services/data_fetcher.py b/backend/services/data_fetcher.py index 4c2a6a9..c61960c 100644 --- a/backend/services/data_fetcher.py +++ b/backend/services/data_fetcher.py @@ -142,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() @@ -204,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"] @@ -245,18 +322,22 @@ _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). + # Let Excel take precedence as it has cleaner individual names (e.g. Elon Musk instead of FALCON LANDING LLC). flight["alert_operator"] = match["name"] flight["alert_category"] = match["category"] if "alert_color" not in flight: diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2d55e86..e48a034 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -298,23 +298,32 @@ export default function Dashboard() { const slowEtag = useRef(null); useEffect(() => { + // Track whether we've received substantial data yet (backend may still be starting up) + let hasData = false; + let fastTimerId: ReturnType | null = null; + let slowTimerId: ReturnType | null = null; + const fetchFastData = async () => { try { const headers: Record = {}; 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 = {}; 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 */}
{/* LEFT PANEL - DATA LAYERS */} - setSettingsOpen(true)} onLegendClick={() => setLegendOpen(true)} gibsDate={gibsDate} setGibsDate={setGibsDate} gibsOpacity={gibsOpacity} setGibsOpacity={setGibsOpacity} /> + 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 */} diff --git a/frontend/src/components/MaplibreViewer.tsx b/frontend/src/components/MaplibreViewer.tsx index b73b4b3..3ba9b59 100644 --- a/frontend/src/components/MaplibreViewer.tsx +++ b/frontend/src/components/MaplibreViewer.tsx @@ -26,10 +26,12 @@ const svgPlanePink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgPlaneDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgPlaneWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgHeliPink = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgHeliAlertRed = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgHeliDarkBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; -const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgHeliBlue = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgHeliLime = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgHeliWhiteAlert = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgPlaneBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgHeliBlack = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; const svgDrone = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; @@ -88,6 +90,13 @@ function makeAircraftSvg(type: 'airliner' | 'turboprop' | 'bizjet' | 'generic', return `data:image/svg+xml;utf8,${encodeURIComponent(`${extras}`)}`; } +// POTUS fleet — oversized hot pink with yellow halo ring +const svgPotusPlane = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; +const svgPotusHeli = `data:image/svg+xml;utf8,${encodeURIComponent(``)}`; + +// 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'); @@ -334,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); @@ -595,6 +618,8 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele loadImg('svgHeliCyan', svgHeliCyan); loadImg('svgHeliOrange', svgHeliOrange); loadImg('svgHeliPurple', svgHeliPurple); + loadImg('svgHeliBlue', svgHeliBlue); + loadImg('svgHeliLime', svgHeliLime); loadImg('svgFighter', svgFighter); loadImg('svgTanker', svgTanker); loadImg('svgRecon', svgRecon); @@ -636,17 +661,28 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele 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); @@ -976,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; @@ -987,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 = []; @@ -1150,10 +1190,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele // Tracked icon maps by aircraft shape and alert color const trackedIconMap: Record> = { - 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 { @@ -1164,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"; @@ -1696,16 +1740,46 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele ))} - {/* 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 = { + '#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 ( -
+
{String(displayName)}
diff --git a/frontend/src/components/NewsFeed.tsx b/frontend/src/components/NewsFeed.tsx index 858d651..e423c45 100644 --- a/frontend/src/components/NewsFeed.tsx +++ b/frontend/src/components/NewsFeed.tsx @@ -260,28 +260,40 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi if (flight) { const callsign = flight.callsign || "UNKNOWN"; const alertColorMap: Record = { - '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 = { - '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 = { - '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 (

@@ -293,31 +305,39 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
OPERATOR - {flight.alert_operator && flight.alert_operator !== "UNKNOWN" ? ( - - {flight.alert_operator} - - ) : ( + {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 ( + + {flight.alert_operator} + + ); + })() : ( UNKNOWN )}
{/* Owner/Operator Wikipedia photo */} - {flight.alert_operator && flight.alert_operator !== "UNKNOWN" && ( -
- -
- )} + {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 ( +
+ +
+ ); + })()} {/* Aircraft model Wikipedia photo */} {aircraftImgUrl && (
@@ -348,22 +368,10 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi REGISTRATION {flight.registration || "N/A"}
- {flight.alert_tag1 && ( + {flight.alert_tags && (
- INTEL TAG - {flight.alert_tag1} -
- )} - {flight.alert_tag2 && ( -
- SECONDARY - {flight.alert_tag2} -
- )} - {flight.alert_tag3 && ( -
- DETAIL - {flight.alert_tag3} + INTEL TAGS + {flight.alert_tags}
)}
diff --git a/frontend/src/components/WorldviewLeftPanel.tsx b/frontend/src/components/WorldviewLeftPanel.tsx index 373ea70..2fdfaee 100644 --- a/frontend/src/components/WorldviewLeftPanel.tsx +++ b/frontend/src/components/WorldviewLeftPanel.tsx @@ -2,7 +2,7 @@ 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 = { 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 = { + '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); @@ -84,6 +102,21 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active 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 }, { id: "private", name: "Private Flights", source: "adsb.lol", count: data?.private_flights?.length || 0, icon: Plane }, @@ -260,6 +293,58 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
) })} + + {/* POTUS Fleet Tracker */} +
+
+ + POTUS FLEET + {potusFlights.length > 0 && ( + + {potusFlights.length} ACTIVE + + )} +
+ {potusFlights.length === 0 ? ( +
+ No POTUS fleet aircraft currently airborne +
+ ) : ( +
+ {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 ( +
{ + 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 }); + } + }} + > +
+ {pf.meta.label} + + {alt > 0 ? `${Math.round(alt).toLocaleString()} ft` : 'GND'} · {speed > 0 ? `${Math.round(speed)} kts` : 'STATIC'} + +
+
+
+ TRACK +
+
+ ); + })} +
+ )} +
)} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7eb166c..015f350 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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