mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-09-23 09:20:51 +02:00
v0.7.0: performance hardening — parallel fetches, deferred icons, AIS stability
Optimizations:
- Parallelized yfinance stock/oil fetches via ThreadPoolExecutor (~2s vs ~8s)
- AIS backoff reset after 200 successes; removed hot-loop pruning (lock contention)
- Single-pass ETag serialization (was double-serializing JSON)
- Deferred ~50 non-critical map icons via setTimeout(0)
- News feed animation capped at 15 items (was 100+ simultaneous)
- heapq.nlargest() for FIRMS fires (60K→5K) and internet outages
- Removed satellite duplication from fast endpoint
- Geopolitics interval 5min → 30min
- Ship counts single-pass memoized; color maps module-level constants
- Improved GDELT URL-to-headline extraction (skip gibberish slugs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Former-commit-id: 4a14a2f078
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
10f376d4d7
commit
cf68f1978d
@@ -1,12 +1,44 @@
|
|||||||
{
|
{
|
||||||
"feeds": [
|
"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": "NPR",
|
||||||
{ "name": "AlJazeera", "url": "https://www.aljazeera.com/xml/rss/all.xml", "weight": 2 },
|
"url": "https://feeds.npr.org/1004/rss.xml",
|
||||||
{ "name": "NYT", "url": "https://rss.nytimes.com/services/xml/rss/nyt/World.xml", "weight": 1 },
|
"weight": 4
|
||||||
{ "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": "BBC",
|
||||||
{ "name": "Mercopress", "url": "https://en.mercopress.com/rss/", "weight": 3 }
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+11
-24
@@ -77,6 +77,15 @@ async def force_refresh():
|
|||||||
async def live_data():
|
async def live_data():
|
||||||
return get_latest_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")
|
@app.get("/api/live-data/fast")
|
||||||
async def live_data_fast(request: Request):
|
async def live_data_fast(request: Request):
|
||||||
d = get_latest_data()
|
d = get_latest_data()
|
||||||
@@ -87,25 +96,13 @@ async def live_data_fast(request: Request):
|
|||||||
"private_jets": d.get("private_jets", []),
|
"private_jets": d.get("private_jets", []),
|
||||||
"tracked_flights": d.get("tracked_flights", []),
|
"tracked_flights": d.get("tracked_flights", []),
|
||||||
"ships": d.get("ships", []),
|
"ships": d.get("ships", []),
|
||||||
"satellites": d.get("satellites", []),
|
|
||||||
"cctv": d.get("cctv", []),
|
"cctv": d.get("cctv", []),
|
||||||
"uavs": d.get("uavs", []),
|
"uavs": d.get("uavs", []),
|
||||||
"liveuamap": d.get("liveuamap", []),
|
"liveuamap": d.get("liveuamap", []),
|
||||||
"gps_jamming": d.get("gps_jamming", []),
|
"gps_jamming": d.get("gps_jamming", []),
|
||||||
"freshness": dict(source_timestamps),
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag includes last_updated timestamp so it changes on every data refresh,
|
return _etag_response(request, payload, prefix="fast|")
|
||||||
# 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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/api/live-data/slow")
|
@app.get("/api/live-data/slow")
|
||||||
async def live_data_slow(request: Request):
|
async def live_data_slow(request: Request):
|
||||||
@@ -129,17 +126,7 @@ async def live_data_slow(request: Request):
|
|||||||
"datacenters": d.get("datacenters", []),
|
"datacenters": d.get("datacenters", []),
|
||||||
"freshness": dict(source_timestamps),
|
"freshness": dict(source_timestamps),
|
||||||
}
|
}
|
||||||
# ETag based on last_updated + item counts
|
return _etag_response(request, payload, prefix="slow|", default=str)
|
||||||
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"}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.get("/api/debug-latest")
|
@app.get("/api/debug-latest")
|
||||||
async def debug_latest_data():
|
async def debug_latest_data():
|
||||||
|
|||||||
@@ -238,49 +238,51 @@ def _ais_stream_loop():
|
|||||||
logger.info("AIS Stream proxy started — receiving vessel data")
|
logger.info("AIS Stream proxy started — receiving vessel data")
|
||||||
|
|
||||||
msg_count = 0
|
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, ''):
|
for raw_msg in iter(process.stdout.readline, ''):
|
||||||
if not _ws_running:
|
if not _ws_running:
|
||||||
process.terminate()
|
process.terminate()
|
||||||
break
|
break
|
||||||
|
|
||||||
raw_msg = raw_msg.strip()
|
raw_msg = raw_msg.strip()
|
||||||
if not raw_msg:
|
if not raw_msg:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(raw_msg)
|
data = json.loads(raw_msg)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if "error" in data:
|
if "error" in data:
|
||||||
logger.error(f"AIS Stream error: {data['error']}")
|
logger.error(f"AIS Stream error: {data['error']}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
msg_type = data.get("MessageType", "")
|
msg_type = data.get("MessageType", "")
|
||||||
metadata = data.get("MetaData", {})
|
metadata = data.get("MetaData", {})
|
||||||
message = data.get("Message", {})
|
message = data.get("Message", {})
|
||||||
|
|
||||||
mmsi = metadata.get("MMSI", 0)
|
mmsi = metadata.get("MMSI", 0)
|
||||||
if not mmsi:
|
if not mmsi:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with _vessels_lock:
|
with _vessels_lock:
|
||||||
if mmsi not in _vessels:
|
if mmsi not in _vessels:
|
||||||
_vessels[mmsi] = {"_updated": time.time()}
|
_vessels[mmsi] = {"_updated": time.time()}
|
||||||
vessel = _vessels[mmsi]
|
vessel = _vessels[mmsi]
|
||||||
|
|
||||||
# Update position from PositionReport or StandardClassBPositionReport
|
# Update position from PositionReport or StandardClassBPositionReport
|
||||||
if msg_type in ("PositionReport", "StandardClassBPositionReport"):
|
if msg_type in ("PositionReport", "StandardClassBPositionReport"):
|
||||||
report = message.get(msg_type, {})
|
report = message.get(msg_type, {})
|
||||||
lat = report.get("Latitude", metadata.get("latitude", 0))
|
lat = report.get("Latitude", metadata.get("latitude", 0))
|
||||||
lng = report.get("Longitude", metadata.get("longitude", 0))
|
lng = report.get("Longitude", metadata.get("longitude", 0))
|
||||||
|
|
||||||
# Skip invalid positions
|
# Skip invalid positions
|
||||||
if lat == 0 and lng == 0:
|
if lat == 0 and lng == 0:
|
||||||
continue
|
continue
|
||||||
if abs(lat) > 90 or abs(lng) > 180:
|
if abs(lat) > 90 or abs(lng) > 180:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
with _vessels_lock:
|
with _vessels_lock:
|
||||||
vessel["lat"] = lat
|
vessel["lat"] = lat
|
||||||
vessel["lng"] = lng
|
vessel["lng"] = lng
|
||||||
@@ -292,12 +294,12 @@ def _ais_stream_loop():
|
|||||||
# Use metadata name if we don't have one yet
|
# Use metadata name if we don't have one yet
|
||||||
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
if not vessel.get("name") or vessel["name"] == "UNKNOWN":
|
||||||
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
vessel["name"] = metadata.get("ShipName", "UNKNOWN").strip() or "UNKNOWN"
|
||||||
|
|
||||||
# Update static data from ShipStaticData
|
# Update static data from ShipStaticData
|
||||||
elif msg_type == "ShipStaticData":
|
elif msg_type == "ShipStaticData":
|
||||||
static = message.get("ShipStaticData", {})
|
static = message.get("ShipStaticData", {})
|
||||||
ais_type = static.get("Type", 0)
|
ais_type = static.get("Type", 0)
|
||||||
|
|
||||||
with _vessels_lock:
|
with _vessels_lock:
|
||||||
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
vessel["name"] = (static.get("Name", "") or metadata.get("ShipName", "UNKNOWN")).strip() or "UNKNOWN"
|
||||||
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
vessel["callsign"] = (static.get("CallSign", "") or "").strip()
|
||||||
@@ -306,21 +308,24 @@ def _ais_stream_loop():
|
|||||||
vessel["ais_type_code"] = ais_type
|
vessel["ais_type_code"] = ais_type
|
||||||
vessel["type"] = classify_vessel(ais_type, mmsi)
|
vessel["type"] = classify_vessel(ais_type, mmsi)
|
||||||
vessel["_updated"] = time.time()
|
vessel["_updated"] = time.time()
|
||||||
|
|
||||||
msg_count += 1
|
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:
|
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)
|
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")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"AIS proxy connection error: {e}")
|
logger.error(f"AIS proxy connection error: {e}")
|
||||||
if _ws_running:
|
if _ws_running:
|
||||||
@@ -328,8 +333,6 @@ def _ais_stream_loop():
|
|||||||
time.sleep(backoff)
|
time.sleep(backoff)
|
||||||
backoff = min(backoff * 2, 60) # Double up to 60s max
|
backoff = min(backoff * 2, 60) # Double up to 60s max
|
||||||
continue
|
continue
|
||||||
# Reset backoff on successful connection (got at least some messages)
|
|
||||||
backoff = 1
|
|
||||||
|
|
||||||
|
|
||||||
def _run_ais_loop():
|
def _run_ais_loop():
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import threading
|
|||||||
import io
|
import io
|
||||||
from apscheduler.schedulers.background import BackgroundScheduler
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
import concurrent.futures
|
import concurrent.futures
|
||||||
|
import heapq
|
||||||
from sgp4.api import Satrec, WGS72
|
from sgp4.api import Satrec, WGS72
|
||||||
from sgp4.api import jday
|
from sgp4.api import jday
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -81,6 +82,25 @@ opensky_client = OpenSkyClient(
|
|||||||
last_opensky_fetch = 0
|
last_opensky_fetch = 0
|
||||||
cached_opensky_flights = []
|
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
|
# In-memory store
|
||||||
@@ -480,27 +500,31 @@ def fetch_news():
|
|||||||
latest_data['news'] = news_items
|
latest_data['news'] = news_items
|
||||||
_mark_fresh("news")
|
_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():
|
def fetch_defense_stocks():
|
||||||
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
tickers = ["RTX", "LMT", "NOC", "GD", "BA", "PLTR"]
|
||||||
stocks_data = {}
|
|
||||||
try:
|
try:
|
||||||
for t in tickers:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
try:
|
results = pool.map(lambda t: _fetch_single_ticker(t, "2d"), tickers)
|
||||||
ticker = yf.Ticker(t)
|
stocks_data = {sym: data for sym, data in results if data}
|
||||||
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}")
|
|
||||||
|
|
||||||
latest_data['stocks'] = stocks_data
|
latest_data['stocks'] = stocks_data
|
||||||
_mark_fresh("stocks")
|
_mark_fresh("stocks")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -509,25 +533,10 @@ def fetch_defense_stocks():
|
|||||||
def fetch_oil_prices():
|
def fetch_oil_prices():
|
||||||
# CL=F is Crude Oil, BZ=F is Brent Crude
|
# CL=F is Crude Oil, BZ=F is Brent Crude
|
||||||
tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
|
tickers = {"WTI Crude": "CL=F", "Brent Crude": "BZ=F"}
|
||||||
oil_data = {}
|
|
||||||
try:
|
try:
|
||||||
for name, symbol in tickers.items():
|
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
try:
|
results = pool.map(lambda item: (_fetch_single_ticker(item[1], "5d")[1], item[0]), tickers.items())
|
||||||
ticker = yf.Ticker(symbol)
|
oil_data = {name: data for data, name in results if data}
|
||||||
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}")
|
|
||||||
|
|
||||||
latest_data['oil'] = oil_data
|
latest_data['oil'] = oil_data
|
||||||
_mark_fresh("oil")
|
_mark_fresh("oil")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -612,6 +621,87 @@ _HELI_TYPES_BACKEND = {
|
|||||||
"B47G", "HUEY", "GAMA", "CABR", "EXE",
|
"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():
|
def fetch_flights():
|
||||||
# OpenSky Network public API for flights. We want to demonstrate global coverage.
|
# OpenSky Network public API for flights. We want to demonstrate global coverage.
|
||||||
flights = []
|
flights = []
|
||||||
@@ -712,7 +802,22 @@ def fetch_flights():
|
|||||||
all_adsb_flights.append(osf)
|
all_adsb_flights.append(osf)
|
||||||
seen_hex.add(h.lower().strip())
|
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:
|
if all_adsb_flights:
|
||||||
|
|
||||||
# The user requested maximum flight density. Rendering all available aircraft.
|
# The user requested maximum flight density. Rendering all available aircraft.
|
||||||
@@ -1333,9 +1438,8 @@ def fetch_firms_fires():
|
|||||||
})
|
})
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
# Sort by FRP descending, keep top 5000 (most intense fires first)
|
# Keep top 5000 by FRP (most intense fires first) — heapq is O(n) vs O(n log n) sort
|
||||||
all_rows.sort(key=lambda x: x["frp"], reverse=True)
|
fires = heapq.nlargest(5000, all_rows, key=lambda x: x["frp"])
|
||||||
fires = all_rows[:5000]
|
|
||||||
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
logger.info(f"FIRMS fires: {len(fires)} hotspots (from {response.status_code})")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching FIRMS fires: {e}")
|
logger.error(f"Error fetching FIRMS fires: {e}")
|
||||||
@@ -1471,9 +1575,8 @@ def fetch_internet_outages():
|
|||||||
r["lat"] = coords[0]
|
r["lat"] = coords[0]
|
||||||
r["lng"] = coords[1]
|
r["lng"] = coords[1]
|
||||||
geocoded.append(r)
|
geocoded.append(r)
|
||||||
# Sort by severity descending, cap at 100
|
# Keep top 100 by severity
|
||||||
geocoded.sort(key=lambda x: x["severity"], reverse=True)
|
outages = heapq.nlargest(100, geocoded, key=lambda x: x["severity"])
|
||||||
outages = geocoded[:100]
|
|
||||||
logger.info(f"Internet outages: {len(outages)} regions affected")
|
logger.info(f"Internet outages: {len(outages)} regions affected")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error fetching internet outages: {e}")
|
logger.error(f"Error fetching internet outages: {e}")
|
||||||
@@ -2219,8 +2322,8 @@ def start_scheduler():
|
|||||||
scheduler.add_job(update_liveuamap, 'date', run_date=datetime.now())
|
scheduler.add_job(update_liveuamap, 'date', run_date=datetime.now())
|
||||||
scheduler.add_job(update_liveuamap, 'interval', hours=12)
|
scheduler.add_job(update_liveuamap, 'interval', hours=12)
|
||||||
|
|
||||||
# Geopolitics (frontlines) more frequently than other slow data
|
# Geopolitics (frontlines) aligned with slow-data tier
|
||||||
scheduler.add_job(fetch_geopolitics, 'interval', minutes=5)
|
scheduler.add_job(fetch_geopolitics, 'interval', minutes=30)
|
||||||
|
|
||||||
scheduler.start()
|
scheduler.start()
|
||||||
|
|
||||||
|
|||||||
+153
-27
@@ -86,8 +86,10 @@ def _extract_domain(url):
|
|||||||
|
|
||||||
def _url_to_headline(url):
|
def _url_to_headline(url):
|
||||||
"""Extract a human-readable headline from a URL path.
|
"""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:
|
try:
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -100,43 +102,151 @@ def _url_to_headline(url):
|
|||||||
if not path:
|
if not path:
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
# Take the last path segment (usually the slug)
|
# Try the last path segment first, then walk backwards
|
||||||
slug = path.split('/')[-1]
|
segments = [s for s in path.split('/') if s]
|
||||||
# Remove file extensions
|
slug = ''
|
||||||
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
for seg in reversed(segments):
|
||||||
if slug.lower().endswith(ext):
|
# Remove file extensions
|
||||||
slug = slug[:-len(ext)]
|
for ext in ['.html', '.htm', '.php', '.asp', '.aspx', '.shtml']:
|
||||||
# If slug is purely numeric or a short ID, try the second-to-last segment
|
if seg.lower().endswith(ext):
|
||||||
import re
|
seg = seg[:-len(ext)]
|
||||||
if re.match(r'^[a-z]?\d{5,}$', slug, re.IGNORECASE):
|
# Skip segments that are clearly not headlines
|
||||||
segments = path.split('/')
|
if _is_gibberish(seg):
|
||||||
if len(segments) >= 2:
|
continue
|
||||||
slug = segments[-2]
|
slug = seg
|
||||||
for ext in ['.html', '.htm', '.php']:
|
break
|
||||||
if slug.lower().endswith(ext):
|
|
||||||
slug = slug[:-len(ext)]
|
if not slug:
|
||||||
|
return domain
|
||||||
|
|
||||||
# Remove common ID patterns at start/end
|
# Remove common ID patterns at start/end
|
||||||
slug = re.sub(r'^[\d]+-', '', slug) # leading numbers like "13847569-"
|
slug = re.sub(r'^[\d]+-', '', slug) # leading "13847569-"
|
||||||
slug = re.sub(r'-[\da-f]{6,}$', '', slug) # trailing hex IDs
|
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'[-_]c-\d+$', '', slug) # trailing "-c-21803431"
|
||||||
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
slug = re.sub(r'^p=\d+$', '', slug) # WordPress ?p=1234
|
||||||
# Convert slug separators to spaces
|
# Convert slug separators to spaces
|
||||||
slug = slug.replace('-', ' ').replace('_', ' ')
|
slug = slug.replace('-', ' ').replace('_', ' ')
|
||||||
# Clean up multiple spaces
|
|
||||||
slug = re.sub(r'\s+', ' ', slug).strip()
|
slug = re.sub(r'\s+', ' ', slug).strip()
|
||||||
|
|
||||||
# If slug is still just a number or too short, fall back to domain
|
# Final gibberish check after cleanup
|
||||||
if len(slug) < 5 or re.match(r'^\d+$', slug):
|
if len(slug) < 8 or _is_gibberish(slug.replace(' ', '-')):
|
||||||
return domain
|
return domain
|
||||||
|
|
||||||
# Title case and truncate
|
# Title case and truncate
|
||||||
headline = slug.title()
|
headline = slug.title()
|
||||||
if len(headline) > 80:
|
if len(headline) > 90:
|
||||||
headline = headline[:77] + '...'
|
headline = headline[:87] + '...'
|
||||||
return f"{headline} ({domain})"
|
return headline
|
||||||
except Exception:
|
except Exception:
|
||||||
return url[:60]
|
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):
|
def _parse_gdelt_export_zip(zip_bytes, conflict_codes, seen_locs, features, loc_index):
|
||||||
"""Parse a single GDELT export ZIP and append conflict features.
|
"""Parse a single GDELT export ZIP and append conflict features.
|
||||||
loc_index maps loc_key -> index in features list for fast duplicate merging.
|
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:
|
if zip_bytes:
|
||||||
_parse_gdelt_export_zip(zip_bytes, CONFLICT_CODES, seen_locs, features, loc_index)
|
_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
|
# Build URL + headline arrays for frontend rendering
|
||||||
for f in features:
|
for f in features:
|
||||||
urls = f["properties"].pop("_urls", [])
|
urls = f["properties"].pop("_urls", [])
|
||||||
f["properties"].pop("_domains", None)
|
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"]["_urls_list"] = urls
|
||||||
f["properties"]["_headlines_list"] = headlines
|
f["properties"]["_headlines_list"] = headlines
|
||||||
import html
|
import html
|
||||||
|
|||||||
@@ -125,6 +125,13 @@ const svgHeliGrey = `data:image/svg+xml;utf8,${encodeURIComponent(`<svg xmlns="h
|
|||||||
// Grey icon map for grounded aircraft
|
// Grey icon map for grounded aircraft
|
||||||
const GROUNDED_ICON_MAP: Record<string, string> = { heli: 'svgHeliGrey', turboprop: 'svgTurbopropGrey', bizjet: 'svgBizjetGrey', airliner: 'svgAirlinerGrey' };
|
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
|
// 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 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']);
|
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']);
|
||||||
@@ -579,96 +586,93 @@ 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('svgPlaneCyan', svgPlaneCyan);
|
||||||
loadImg('svgPlaneYellow', svgPlaneYellow);
|
loadImg('svgPlaneYellow', svgPlaneYellow);
|
||||||
loadImg('svgPlaneOrange', svgPlaneOrange);
|
loadImg('svgPlaneOrange', svgPlaneOrange);
|
||||||
loadImg('svgPlanePurple', svgPlanePurple);
|
loadImg('svgPlanePurple', svgPlanePurple);
|
||||||
loadImg('svgPlanePink', svgPlanePink);
|
|
||||||
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
|
||||||
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
|
||||||
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
|
||||||
loadImg('svgPlaneBlack', svgPlaneBlack);
|
|
||||||
// Heli icons
|
|
||||||
loadImg('svgHeli', svgHeli);
|
loadImg('svgHeli', svgHeli);
|
||||||
loadImg('svgHeliCyan', svgHeliCyan);
|
loadImg('svgHeliCyan', svgHeliCyan);
|
||||||
loadImg('svgHeliOrange', svgHeliOrange);
|
loadImg('svgHeliOrange', svgHeliOrange);
|
||||||
loadImg('svgHeliPurple', svgHeliPurple);
|
loadImg('svgHeliPurple', svgHeliPurple);
|
||||||
loadImg('svgHeliPink', svgHeliPink);
|
|
||||||
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
|
||||||
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
|
||||||
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
|
||||||
loadImg('svgHeliBlack', svgHeliBlack);
|
|
||||||
// Military special
|
|
||||||
loadImg('svgFighter', svgFighter);
|
loadImg('svgFighter', svgFighter);
|
||||||
loadImg('svgTanker', svgTanker);
|
loadImg('svgTanker', svgTanker);
|
||||||
loadImg('svgRecon', svgRecon);
|
loadImg('svgRecon', svgRecon);
|
||||||
// Airliner icons (swept wings + engine pods)
|
|
||||||
loadImg('svgAirlinerCyan', svgAirlinerCyan);
|
loadImg('svgAirlinerCyan', svgAirlinerCyan);
|
||||||
loadImg('svgAirlinerOrange', svgAirlinerOrange);
|
loadImg('svgAirlinerOrange', svgAirlinerOrange);
|
||||||
loadImg('svgAirlinerPurple', svgAirlinerPurple);
|
loadImg('svgAirlinerPurple', svgAirlinerPurple);
|
||||||
loadImg('svgAirlinerYellow', svgAirlinerYellow);
|
loadImg('svgAirlinerYellow', svgAirlinerYellow);
|
||||||
loadImg('svgAirlinerPink', svgAirlinerPink);
|
|
||||||
loadImg('svgAirlinerRed', svgAirlinerRed);
|
|
||||||
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
|
||||||
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
|
||||||
// Turboprop icons (straight wings)
|
|
||||||
loadImg('svgTurbopropCyan', svgTurbopropCyan);
|
loadImg('svgTurbopropCyan', svgTurbopropCyan);
|
||||||
loadImg('svgTurbopropOrange', svgTurbopropOrange);
|
loadImg('svgTurbopropOrange', svgTurbopropOrange);
|
||||||
loadImg('svgTurbopropPurple', svgTurbopropPurple);
|
loadImg('svgTurbopropPurple', svgTurbopropPurple);
|
||||||
loadImg('svgTurbopropYellow', svgTurbopropYellow);
|
loadImg('svgTurbopropYellow', svgTurbopropYellow);
|
||||||
loadImg('svgTurbopropPink', svgTurbopropPink);
|
|
||||||
loadImg('svgTurbopropRed', svgTurbopropRed);
|
|
||||||
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
|
||||||
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
|
||||||
// Bizjet icons (sleek, T-tail)
|
|
||||||
loadImg('svgBizjetCyan', svgBizjetCyan);
|
loadImg('svgBizjetCyan', svgBizjetCyan);
|
||||||
loadImg('svgBizjetOrange', svgBizjetOrange);
|
loadImg('svgBizjetOrange', svgBizjetOrange);
|
||||||
loadImg('svgBizjetPurple', svgBizjetPurple);
|
loadImg('svgBizjetPurple', svgBizjetPurple);
|
||||||
loadImg('svgBizjetYellow', svgBizjetYellow);
|
loadImg('svgBizjetYellow', svgBizjetYellow);
|
||||||
loadImg('svgBizjetPink', svgBizjetPink);
|
|
||||||
loadImg('svgBizjetRed', svgBizjetRed);
|
|
||||||
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
|
||||||
loadImg('svgBizjetWhite', svgBizjetWhite);
|
|
||||||
// Grey grounded icons
|
|
||||||
loadImg('svgAirlinerGrey', svgAirlinerGrey);
|
loadImg('svgAirlinerGrey', svgAirlinerGrey);
|
||||||
loadImg('svgTurbopropGrey', svgTurbopropGrey);
|
loadImg('svgTurbopropGrey', svgTurbopropGrey);
|
||||||
loadImg('svgBizjetGrey', svgBizjetGrey);
|
loadImg('svgBizjetGrey', svgBizjetGrey);
|
||||||
loadImg('svgHeliGrey', svgHeliGrey);
|
loadImg('svgHeliGrey', svgHeliGrey);
|
||||||
loadImg('svgDrone', svgDrone);
|
|
||||||
loadImg('svgShipGray', svgShipGray);
|
loadImg('svgShipGray', svgShipGray);
|
||||||
loadImg('svgShipRed', svgShipRed);
|
loadImg('svgShipRed', svgShipRed);
|
||||||
loadImg('svgShipYellow', svgShipYellow);
|
loadImg('svgShipYellow', svgShipYellow);
|
||||||
loadImg('svgShipBlue', svgShipBlue);
|
loadImg('svgShipBlue', svgShipBlue);
|
||||||
loadImg('svgShipWhite', svgShipWhite);
|
loadImg('svgShipWhite', svgShipWhite);
|
||||||
loadImg('svgCarrier', svgCarrier);
|
loadImg('svgCarrier', svgCarrier);
|
||||||
loadImg('svgCctv', svgCctv);
|
|
||||||
loadImg('svgWarning', svgWarning);
|
loadImg('svgWarning', svgWarning);
|
||||||
loadImg('icon-threat', svgThreat);
|
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
|
// Deferred icons — for off-by-default layers and rare variants
|
||||||
loadImg('datacenter', svgDataCenter);
|
// Loaded in next frame to avoid blocking initial map render
|
||||||
|
setTimeout(() => {
|
||||||
// Satellite mission-type icons
|
loadImg('svgPlanePink', svgPlanePink);
|
||||||
loadImg('sat-mil', makeSatSvg('#ff3333'));
|
loadImg('svgPlaneAlertRed', svgPlaneAlertRed);
|
||||||
loadImg('sat-sar', makeSatSvg('#00e5ff'));
|
loadImg('svgPlaneDarkBlue', svgPlaneDarkBlue);
|
||||||
loadImg('sat-sigint', makeSatSvg('#ffffff'));
|
loadImg('svgPlaneWhiteAlert', svgPlaneWhiteAlert);
|
||||||
loadImg('sat-nav', makeSatSvg('#4488ff'));
|
loadImg('svgPlaneBlack', svgPlaneBlack);
|
||||||
loadImg('sat-ew', makeSatSvg('#ff00ff'));
|
loadImg('svgHeliPink', svgHeliPink);
|
||||||
loadImg('sat-com', makeSatSvg('#44ff44'));
|
loadImg('svgHeliAlertRed', svgHeliAlertRed);
|
||||||
loadImg('sat-station', makeSatSvg('#ffdd00'));
|
loadImg('svgHeliDarkBlue', svgHeliDarkBlue);
|
||||||
loadImg('sat-gen', makeSatSvg('#aaaaaa'));
|
loadImg('svgHeliWhiteAlert', svgHeliWhiteAlert);
|
||||||
|
loadImg('svgHeliBlack', svgHeliBlack);
|
||||||
|
loadImg('svgAirlinerPink', svgAirlinerPink);
|
||||||
|
loadImg('svgAirlinerRed', svgAirlinerRed);
|
||||||
|
loadImg('svgAirlinerDarkBlue', svgAirlinerDarkBlue);
|
||||||
|
loadImg('svgAirlinerWhite', svgAirlinerWhite);
|
||||||
|
loadImg('svgTurbopropPink', svgTurbopropPink);
|
||||||
|
loadImg('svgTurbopropRed', svgTurbopropRed);
|
||||||
|
loadImg('svgTurbopropDarkBlue', svgTurbopropDarkBlue);
|
||||||
|
loadImg('svgTurbopropWhite', svgTurbopropWhite);
|
||||||
|
loadImg('svgBizjetPink', svgBizjetPink);
|
||||||
|
loadImg('svgBizjetRed', svgBizjetRed);
|
||||||
|
loadImg('svgBizjetDarkBlue', svgBizjetDarkBlue);
|
||||||
|
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);
|
setMapReady(true);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -748,7 +752,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
|
// Create GeoJSON collections dynamically (this runs ultra fast in pure JS)
|
||||||
const commFlightsGeoJSON = useMemo(() => {
|
const commFlightsGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.flights || !data?.commercial_flights) return null;
|
if (!activeLayers.flights || !data?.commercial_flights) return null;
|
||||||
const colorMap: Record<string, string> = { heli: 'svgHeliCyan', turboprop: 'svgTurbopropCyan', bizjet: 'svgBizjetCyan', airliner: 'svgAirlinerCyan' };
|
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.commercial_flights.map((f: any, i: number) => {
|
features: data.commercial_flights.map((f: any, i: number) => {
|
||||||
@@ -760,7 +763,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
const [iLng, iLat] = interpFlight(f);
|
const [iLng, iLat] = interpFlight(f);
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
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] }
|
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
@@ -769,7 +772,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
|
|
||||||
const privFlightsGeoJSON = useMemo(() => {
|
const privFlightsGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.private || !data?.private_flights) return null;
|
if (!activeLayers.private || !data?.private_flights) return null;
|
||||||
const colorMap: Record<string, string> = { heli: 'svgHeliOrange', turboprop: 'svgTurbopropOrange', bizjet: 'svgBizjetOrange', airliner: 'svgAirlinerOrange' };
|
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.private_flights.map((f: any, i: number) => {
|
features: data.private_flights.map((f: any, i: number) => {
|
||||||
@@ -781,7 +783,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
const [iLng, iLat] = interpFlight(f);
|
const [iLng, iLat] = interpFlight(f);
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
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] }
|
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
@@ -790,7 +792,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
|
|
||||||
const privJetsGeoJSON = useMemo(() => {
|
const privJetsGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.jets || !data?.private_jets) return null;
|
if (!activeLayers.jets || !data?.private_jets) return null;
|
||||||
const colorMap: Record<string, string> = { heli: 'svgHeliPurple', turboprop: 'svgTurbopropPurple', bizjet: 'svgBizjetPurple', airliner: 'svgAirlinerPurple' };
|
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.private_jets.map((f: any, i: number) => {
|
features: data.private_jets.map((f: any, i: number) => {
|
||||||
@@ -802,7 +803,7 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
const [iLng, iLat] = interpFlight(f);
|
const [iLng, iLat] = interpFlight(f);
|
||||||
return {
|
return {
|
||||||
type: 'Feature',
|
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] }
|
geometry: { type: 'Point', coordinates: [iLng, iLat] }
|
||||||
};
|
};
|
||||||
}).filter(Boolean)
|
}).filter(Boolean)
|
||||||
@@ -812,11 +813,6 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
const milFlightsGeoJSON = useMemo(() => {
|
const milFlightsGeoJSON = useMemo(() => {
|
||||||
if (!activeLayers.military || !data?.military_flights) return null;
|
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 {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
features: data.military_flights.map((f: any, i: number) => {
|
features: data.military_flights.map((f: any, i: number) => {
|
||||||
@@ -825,10 +821,10 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
if (f.icao24 && trackedIcaoSet.has(f.icao24.toLowerCase())) return null;
|
||||||
const milType = f.military_type || 'default';
|
const milType = f.military_type || 'default';
|
||||||
const grounded = f.alt != null && f.alt <= 100;
|
const grounded = f.alt != null && f.alt <= 100;
|
||||||
let iconId = milSpecialMap[milType];
|
let iconId = MIL_SPECIAL_MAP[milType];
|
||||||
if (!iconId) {
|
if (!iconId) {
|
||||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
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) {
|
} else if (grounded) {
|
||||||
const acType = classifyAircraft(f.model, f.aircraft_category);
|
const acType = classifyAircraft(f.model, f.aircraft_category);
|
||||||
iconId = GROUNDED_ICON_MAP[acType];
|
iconId = GROUNDED_ICON_MAP[acType];
|
||||||
@@ -2487,20 +2483,30 @@ const MaplibreViewer = ({ data, activeLayers, onEntityClick, flyToLocation, sele
|
|||||||
{(() => {
|
{(() => {
|
||||||
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
|
const urls: string[] = data.gdelt[selectedEntity.id as number].properties?._urls_list || [];
|
||||||
const headlines: string[] = data.gdelt[selectedEntity.id as number].properties?._headlines_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>;
|
if (urls.length === 0) return <span className="text-[var(--text-muted)] text-[10px]">No articles available.</span>;
|
||||||
return urls.map((url: string, idx: number) => (
|
return urls.map((url: string, idx: number) => {
|
||||||
<a
|
const headline = headlines[idx] || '';
|
||||||
key={idx}
|
let domain = '';
|
||||||
href={url}
|
try { domain = new URL(url).hostname.replace('www.', ''); } catch { domain = ''; }
|
||||||
target="_blank"
|
return (
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
onClick={(e) => e.stopPropagation()}
|
key={idx}
|
||||||
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"
|
href={url}
|
||||||
style={{ pointerEvents: 'all' }}
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
{headlines[idx] || url}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</a>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -667,10 +667,34 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 mt-2">
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
<span className="text-[var(--text-muted)] text-[10px]">LATEST REPORTS:</span>
|
||||||
<div
|
<div className="flex flex-col gap-1 max-h-[250px] overflow-y-auto styled-scrollbar">
|
||||||
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.' }}
|
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>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -966,9 +990,9 @@ function NewsFeedInner({ data, selectedEntity, regionDossier, regionDossierLoadi
|
|||||||
<motion.div
|
<motion.div
|
||||||
key={idx}
|
key={idx}
|
||||||
ref={(el) => { itemRefs.current[idx] = el; }}
|
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 }}
|
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`}
|
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">
|
<div className="flex items-center justify-between text-[8px] text-[var(--text-secondary)] uppercase tracking-widest">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
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 } from "lucide-react";
|
||||||
import { useTheme } from "@/lib/ThemeContext";
|
import { useTheme } from "@/lib/ThemeContext";
|
||||||
@@ -70,10 +70,19 @@ const WorldviewLeftPanel = React.memo(function WorldviewLeftPanel({ data, active
|
|||||||
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
|
return () => { if (gibsIntervalRef.current) clearInterval(gibsIntervalRef.current); };
|
||||||
}, [gibsPlaying, gibsDate, setGibsDate]);
|
}, [gibsPlaying, gibsDate, setGibsDate]);
|
||||||
|
|
||||||
// Compute ship category counts
|
// Compute ship category counts (memoized — ships array can be 1000+ items)
|
||||||
const importantShipCount = data?.ships?.filter((s: any) => ['carrier', 'military_vessel', 'tanker', 'cargo'].includes(s.type))?.length || 0;
|
const { importantShipCount, passengerShipCount, civilianShipCount } = useMemo(() => {
|
||||||
const passengerShipCount = data?.ships?.filter((s: any) => s.type === 'passenger')?.length || 0;
|
const ships = data?.ships;
|
||||||
const civilianShipCount = data?.ships?.filter((s: any) => !['carrier', 'military_vessel', 'tanker', 'cargo', 'passenger'].includes(s.type))?.length || 0;
|
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]);
|
||||||
|
|
||||||
const layers = [
|
const layers = [
|
||||||
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
{ id: "flights", name: "Commercial Flights", source: "adsb.lol", count: data?.commercial_flights?.length || 0, icon: Plane },
|
||||||
|
|||||||
Reference in New Issue
Block a user