feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
"""Live map overlays: parsers, TTL cache, and upstream fetchers.
|
|
|
|
|
|
|
|
|
|
|
|
Moving objects (aircraft, vessels, trains) and alerts are vectors. Radar /
|
|
|
|
|
|
GIBS fire tiles are rasters served directly to the browser — this module
|
|
|
|
|
|
only returns tile *templates* and GeoJSON/JSON for vectors.
|
|
|
|
|
|
|
|
|
|
|
|
Third-party APIs that leak keys, lack CORS, or rate-limit by IP are fetched
|
|
|
|
|
|
here (FastAPI), never from Leaflet. Viewport bbox only; never a global ADS-B
|
|
|
|
|
|
or AIS poll.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import asyncio
|
2026-08-28 21:49:05 -04:00
|
|
|
|
import logging
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
import math
|
|
|
|
|
|
import time
|
2026-08-29 01:01:09 -04:00
|
|
|
|
from datetime import datetime, timedelta, timezone
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
from typing import Any, Awaitable, Callable
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
2026-08-29 01:01:09 -04:00
|
|
|
|
from urllib.parse import quote, urlencode
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
2026-08-29 01:01:09 -04:00
|
|
|
|
from config import OSINT_USER_AGENT, TITILER_PUBLIC_BASE
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
logger = logging.getLogger("osint.live_layers")
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra")
|
|
|
|
|
|
|
|
|
|
|
|
ADSB_LOL_BASE = "https://api.adsb.lol"
|
2026-08-28 22:58:04 -04:00
|
|
|
|
PLANESPOTTERS_PHOTO = "https://api.planespotters.net/pub/photos"
|
|
|
|
|
|
# Planespotters ToS cap server-side JSON caching at 24 hours.
|
|
|
|
|
|
PLANESPOTTERS_CACHE_TTL = 24 * 3600
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
AMTRAKER_TRAINS = "https://api.amtraker.com/v3/trains"
|
|
|
|
|
|
RAINVIEWER_MAPS = "https://api.rainviewer.com/public/weather-maps.json"
|
|
|
|
|
|
NWS_ALERTS = "https://api.weather.gov/alerts/active"
|
|
|
|
|
|
IEM_SBW = "https://mesonet.agron.iastate.edu/geojson/sbw.geojson"
|
|
|
|
|
|
WFIGS_INCIDENTS = (
|
|
|
|
|
|
"https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
|
|
|
|
|
|
"WFIGS_Incident_Locations_Current/FeatureServer/0/query"
|
|
|
|
|
|
)
|
|
|
|
|
|
WFIGS_PERIMETERS = (
|
|
|
|
|
|
"https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
|
|
|
|
|
|
"WFIGS_Interagency_Perimeters_Current/FeatureServer/0/query"
|
|
|
|
|
|
)
|
|
|
|
|
|
NHC_STORMS = "https://www.nhc.noaa.gov/CurrentStorms.json"
|
|
|
|
|
|
|
2026-08-29 01:01:09 -04:00
|
|
|
|
PC_STAC_SEARCH = "https://planetarycomputer.microsoft.com/api/stac/v1/search"
|
|
|
|
|
|
PC_SAS_TOKEN = "https://planetarycomputer.microsoft.com/api/sas/v1/token/sentinel-1-grd"
|
|
|
|
|
|
# Self-hosted TiTiler on the Pi, exposed same-origin through the osint.rpi.local
|
|
|
|
|
|
# nginx vhost. Relative template — Leaflet resolves it against the page origin,
|
|
|
|
|
|
# so the browser never touches a raw loopback port or titiler.xyz.
|
2026-08-29 11:42:03 -04:00
|
|
|
|
TITILER_COG_TILES = f"{TITILER_PUBLIC_BASE}/cog/tiles/WebMercatorQuad/{{z}}/{{x}}/{{y}}"
|
2026-08-29 20:40:27 -04:00
|
|
|
|
SENTINEL1_TTL = 6 * 3600 # S-1 revisit is 6–12 days; cache the COG all afternoon
|
|
|
|
|
|
SENTINEL1_CELL = 2.0 # degrees — pan/zoom inside a cell reuses the same scene
|
|
|
|
|
|
SENTINEL1_STAC_LIMIT = 8
|
2026-08-29 01:01:09 -04:00
|
|
|
|
SENTINEL1_ATTRIBUTION = "Copernicus Sentinel-1 / Microsoft Planetary Computer"
|
|
|
|
|
|
|
2026-08-29 14:14:08 -04:00
|
|
|
|
# GPSJAM (John Wiseman / ADS-B Exchange): daily H3 hexes of aircraft nav
|
|
|
|
|
|
# accuracy. Hexes are published as a gzip CSV at a stable per-date URL, soon
|
|
|
|
|
|
# after midnight UTC. Red/yellow != proven jamming.
|
|
|
|
|
|
GPSJAM_BASE = "https://gpsjam.org"
|
|
|
|
|
|
GPSJAM_RES = 4
|
|
|
|
|
|
GPSJAM_TTL = 3600.0 # 1h — whole-world layer, fetched once per day effectively
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
IEM_NEXRAD = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q/{z}/{x}/{y}.png"
|
|
|
|
|
|
GIBS_THERMAL = (
|
|
|
|
|
|
"https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/"
|
|
|
|
|
|
"VIIRS_SNPP_Thermal_Anomalies_375m_All/default/{time}/"
|
|
|
|
|
|
"GoogleMapsCompatible_Level9/{z}/{y}/{x}.png"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
CONUS = (-125.0, 24.0, -66.0, 50.0)
|
|
|
|
|
|
MAX_RADIUS_NM = 150
|
|
|
|
|
|
DEFAULT_LIMIT = 2000
|
|
|
|
|
|
|
2026-08-27 21:21:19 -04:00
|
|
|
|
_HTTP_TIMEOUT = httpx.Timeout(8.0, connect=3.0)
|
|
|
|
|
|
_http: httpx.AsyncClient | None = None
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
_COMPASS = {
|
|
|
|
|
|
"N": 0, "NE": 45, "E": 90, "SE": 135,
|
|
|
|
|
|
"S": 180, "SW": 225, "W": 270, "NW": 315,
|
|
|
|
|
|
"NNE": 22, "ENE": 67, "ESE": 112, "SSE": 157,
|
|
|
|
|
|
"SSW": 202, "WSW": 247, "WNW": 292, "NNW": 337,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_cache: dict[str, tuple[float, Any]] = {}
|
2026-08-27 21:14:49 -04:00
|
|
|
|
_key_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
_key_locks_guard = asyncio.Lock()
|
|
|
|
|
|
_QUANT = 0.25 # degrees — pan jitter inside a cell reuses the TTL entry
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
# Last-known AIS positions (MMSI -> marker). Filled by ais_stream worker.
|
|
|
|
|
|
vessel_last_known: dict[str, dict] = {}
|
|
|
|
|
|
vessel_lock = asyncio.Lock()
|
2026-08-27 21:46:37 -04:00
|
|
|
|
# Viewport-following accumulates vessels across every region visited in a
|
|
|
|
|
|
# session — keep the in-memory store bounded (oldest entries evicted).
|
|
|
|
|
|
_MAX_VESSELS = 6000
|
2026-08-28 09:33:19 -04:00
|
|
|
|
# Last ADS-B snapshot + WFIGS points for fire↔tanker correlation.
|
|
|
|
|
|
aircraft_last_known: dict[str, dict] = {}
|
|
|
|
|
|
fire_last_known: list[dict] = []
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def overlay_catalog() -> dict:
|
|
|
|
|
|
"""Tile templates + vector endpoint map for the layer panel. No secrets."""
|
|
|
|
|
|
today = datetime.now(timezone.utc).date().isoformat()
|
|
|
|
|
|
return {
|
|
|
|
|
|
"radar_iem": {
|
|
|
|
|
|
"id": "radar_iem",
|
|
|
|
|
|
"title": "IEM NEXRAD (CONUS)",
|
|
|
|
|
|
"kind": "raster",
|
|
|
|
|
|
"tileUrl": IEM_NEXRAD,
|
|
|
|
|
|
"maxZoom": 18,
|
|
|
|
|
|
"attribution": "Iowa Environmental Mesonet",
|
|
|
|
|
|
},
|
|
|
|
|
|
"radar_rainviewer": {
|
|
|
|
|
|
"id": "radar_rainviewer",
|
|
|
|
|
|
"title": "RainViewer (global)",
|
|
|
|
|
|
"kind": "raster",
|
|
|
|
|
|
"tileUrl": None, # filled from /api/map/radar frames
|
|
|
|
|
|
"maxZoom": 7,
|
|
|
|
|
|
"attribution": '<a href="https://www.rainviewer.com/">Weather data by RainViewer</a>',
|
|
|
|
|
|
},
|
|
|
|
|
|
"gibs_thermal": {
|
|
|
|
|
|
"id": "gibs_thermal",
|
|
|
|
|
|
"title": "GIBS VIIRS thermal anomalies",
|
|
|
|
|
|
"kind": "raster",
|
|
|
|
|
|
"tileUrl": GIBS_THERMAL.replace("{time}", today),
|
|
|
|
|
|
"timeTemplate": GIBS_THERMAL,
|
|
|
|
|
|
"maxZoom": 9,
|
|
|
|
|
|
"attribution": "NASA GIBS / EOSDIS",
|
|
|
|
|
|
},
|
2026-08-29 01:01:09 -04:00
|
|
|
|
"sentinel1_sar": {
|
|
|
|
|
|
"id": "sentinel1_sar",
|
|
|
|
|
|
"title": "Sentinel-1 SAR (Cloud-Penetrating)",
|
|
|
|
|
|
"kind": "raster",
|
|
|
|
|
|
"tileUrl": None, # filled from /api/map/sentinel1 (signed COG)
|
|
|
|
|
|
"endpoint": "/api/map/sentinel1",
|
|
|
|
|
|
"maxZoom": 14,
|
|
|
|
|
|
"attribution": SENTINEL1_ATTRIBUTION,
|
|
|
|
|
|
},
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
"nws_alerts": {"id": "nws_alerts", "kind": "geojson", "endpoint": "/api/weather-alerts"},
|
|
|
|
|
|
"wfigs_incidents": {"id": "wfigs_incidents", "kind": "points", "endpoint": "/api/fire-incidents"},
|
|
|
|
|
|
"wfigs_perimeters": {"id": "wfigs_perimeters", "kind": "geojson", "endpoint": "/api/fire-perimeters"},
|
|
|
|
|
|
"aircraft": {"id": "aircraft", "kind": "points", "endpoint": "/api/aircraft"},
|
|
|
|
|
|
"vessels": {"id": "vessels", "kind": "points", "endpoint": "/api/vessels"},
|
|
|
|
|
|
"trains": {"id": "trains", "kind": "points", "endpoint": "/api/trains"},
|
|
|
|
|
|
"storms": {"id": "storms", "kind": "points", "endpoint": "/api/storms"},
|
2026-08-29 14:14:08 -04:00
|
|
|
|
"gpsjam": {
|
|
|
|
|
|
"id": "gpsjam",
|
|
|
|
|
|
"kind": "geojson",
|
|
|
|
|
|
"endpoint": "/api/map/gpsjam",
|
|
|
|
|
|
"attribution": "GPSJAM / John Wiseman / ADS-B Exchange",
|
|
|
|
|
|
},
|
feat(conflicts): GET /api/conflicts catalog + news/GDELT event counts
Curated static catalogue of 13 conflict theatres (Ukraine, Gaza, Sudan,
Myanmar, DRC, Yemen, Syria, Lebanon, Sahel, Somalia, Red Sea, Taiwan
Strait, Korean DMZ) with war|high|elevated severity and short factual
descriptions. GET /api/conflicts returns { zones: [{id,label,severity,
lat,lon,description,eventCount,lastUpdated}], timestamp } where
eventCount rolls up pre-existing geocoded news/GDELT//api/news/map rows
inside each zone bbox (0 when the tables are empty). Adds the conflicts
entry to overlay_catalog(). No upstream scraping, no jittered coords.
2026-08-31 21:30:23 -04:00
|
|
|
|
"conflicts": {
|
|
|
|
|
|
"id": "conflicts",
|
|
|
|
|
|
"kind": "points",
|
|
|
|
|
|
"endpoint": "/api/conflicts",
|
|
|
|
|
|
"attribution": "Curated OSINT conflict catalog",
|
|
|
|
|
|
},
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_bbox(bbox: str) -> tuple[float, float, float, float]:
|
|
|
|
|
|
"""Parse 'minlon,minlat,maxlon,maxlat' into four floats."""
|
|
|
|
|
|
parts = [p.strip() for p in (bbox or "").split(",")]
|
|
|
|
|
|
if len(parts) != 4:
|
|
|
|
|
|
raise ValueError("bbox must be 'minlon,minlat,maxlon,maxlat'")
|
|
|
|
|
|
try:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = (float(p) for p in parts)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError("bbox values must be floats") from exc
|
|
|
|
|
|
return minlon, minlat, maxlon, maxlat
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:14:49 -04:00
|
|
|
|
def quantize_bbox(
|
|
|
|
|
|
minlon: float, minlat: float, maxlon: float, maxlat: float,
|
|
|
|
|
|
step: float = _QUANT,
|
|
|
|
|
|
) -> tuple[float, float, float, float]:
|
|
|
|
|
|
"""Snap a viewport to a coarse cell so nearby pans share a cache key.
|
|
|
|
|
|
|
|
|
|
|
|
The returned envelope is expanded to cover the original box.
|
|
|
|
|
|
"""
|
|
|
|
|
|
def q_down(v: float, lo: float, hi: float) -> float:
|
|
|
|
|
|
v = max(lo, min(hi, v))
|
|
|
|
|
|
return math.floor(v / step) * step
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
round(q_down(minlon, -180.0, 180.0), 4),
|
|
|
|
|
|
round(q_down(minlat, -90.0, 90.0), 4),
|
|
|
|
|
|
round(q_down(maxlon, -180.0, 180.0) + step, 4),
|
|
|
|
|
|
round(q_down(maxlat, -90.0, 90.0) + step, 4),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bbox_cell_key(bbox: str | None) -> str:
|
|
|
|
|
|
"""Stable cache-key fragment for a viewport (or 'all')."""
|
|
|
|
|
|
if not bbox:
|
|
|
|
|
|
return "all"
|
|
|
|
|
|
return ",".join(f"{v:.4f}" for v in quantize_bbox(*parse_bbox(bbox)))
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
def bbox_center_radius_nm(
|
|
|
|
|
|
minlon: float, minlat: float, maxlon: float, maxlat: float,
|
|
|
|
|
|
) -> tuple[float, float, int]:
|
|
|
|
|
|
"""Viewport center + half-diagonal radius in nautical miles, clamped ≤ 150."""
|
|
|
|
|
|
lat = (minlat + maxlat) / 2.0
|
|
|
|
|
|
lon = (minlon + maxlon) / 2.0
|
|
|
|
|
|
# Half the diagonal of the box, in nm (1 deg lat ≈ 60 nm).
|
|
|
|
|
|
dlat = abs(maxlat - minlat) / 2.0
|
|
|
|
|
|
dlon = abs(maxlon - minlon) / 2.0
|
|
|
|
|
|
km = _haversine_km(lat, lon, lat + dlat, lon + dlon)
|
|
|
|
|
|
nm = km / 1.852
|
|
|
|
|
|
radius = max(1, min(MAX_RADIUS_NM, int(math.ceil(nm))))
|
|
|
|
|
|
return lat, lon, radius
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
|
|
|
|
r = 6371.0
|
|
|
|
|
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
|
|
|
|
|
dphi = math.radians(lat2 - lat1)
|
|
|
|
|
|
dlmb = math.radians(lon2 - lon1)
|
|
|
|
|
|
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2
|
|
|
|
|
|
return 2 * r * math.asin(min(1.0, math.sqrt(a)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_marker(
|
|
|
|
|
|
id_: str,
|
|
|
|
|
|
lat: float | None,
|
|
|
|
|
|
lon: float | None,
|
|
|
|
|
|
heading: float | None = None,
|
|
|
|
|
|
speed: float | None = None,
|
|
|
|
|
|
label: str | None = None,
|
|
|
|
|
|
extra: dict | None = None,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(id_),
|
|
|
|
|
|
"lat": lat,
|
|
|
|
|
|
"lon": lon,
|
|
|
|
|
|
"heading": heading,
|
|
|
|
|
|
"speed": speed,
|
|
|
|
|
|
"label": label or str(id_),
|
|
|
|
|
|
"extra": extra or {},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def filter_points_bbox(
|
|
|
|
|
|
points: list[dict],
|
|
|
|
|
|
minlon: float, minlat: float, maxlon: float, maxlat: float,
|
|
|
|
|
|
limit: int = DEFAULT_LIMIT,
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for p in points:
|
|
|
|
|
|
lat, lon = p.get("lat"), p.get("lon")
|
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if minlat <= lat <= maxlat and minlon <= lon <= maxlon:
|
|
|
|
|
|
out.append(p)
|
|
|
|
|
|
if len(out) >= limit:
|
|
|
|
|
|
break
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:16:16 -04:00
|
|
|
|
_ALERT_KEEP = ("event", "severity", "headline", "areaDesc", "wfo", "source")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def slim_alert_properties(props: dict | None) -> dict:
|
|
|
|
|
|
"""Keep only the fields the map popup reads."""
|
|
|
|
|
|
src = props or {}
|
|
|
|
|
|
return {k: src.get(k) for k in _ALERT_KEEP}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _walk_coords(coords: Any, acc: list[float]) -> None:
|
|
|
|
|
|
if not coords:
|
|
|
|
|
|
return
|
|
|
|
|
|
first = coords[0]
|
|
|
|
|
|
if isinstance(first, (int, float)):
|
|
|
|
|
|
lon, lat = float(coords[0]), float(coords[1])
|
|
|
|
|
|
acc[0] = min(acc[0], lon)
|
|
|
|
|
|
acc[1] = min(acc[1], lat)
|
|
|
|
|
|
acc[2] = max(acc[2], lon)
|
|
|
|
|
|
acc[3] = max(acc[3], lat)
|
|
|
|
|
|
return
|
|
|
|
|
|
for child in coords:
|
|
|
|
|
|
_walk_coords(child, acc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _geom_envelope(geom: dict | None) -> tuple[float, float, float, float] | None:
|
|
|
|
|
|
if not geom or not isinstance(geom, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if geom.get("type") == "GeometryCollection":
|
|
|
|
|
|
env: list[float] | None = None
|
|
|
|
|
|
for g in geom.get("geometries") or []:
|
|
|
|
|
|
e = _geom_envelope(g)
|
|
|
|
|
|
if e is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if env is None:
|
|
|
|
|
|
env = list(e)
|
|
|
|
|
|
else:
|
|
|
|
|
|
env[0] = min(env[0], e[0])
|
|
|
|
|
|
env[1] = min(env[1], e[1])
|
|
|
|
|
|
env[2] = max(env[2], e[2])
|
|
|
|
|
|
env[3] = max(env[3], e[3])
|
|
|
|
|
|
return tuple(env) if env else None # type: ignore[return-value]
|
|
|
|
|
|
coords = geom.get("coordinates")
|
|
|
|
|
|
if coords is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
acc = [180.0, 90.0, -180.0, -90.0]
|
|
|
|
|
|
try:
|
|
|
|
|
|
_walk_coords(coords, acc)
|
|
|
|
|
|
except (TypeError, ValueError, IndexError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
if acc[0] > acc[2]:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return acc[0], acc[1], acc[2], acc[3]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def clip_fc_to_bbox(
|
|
|
|
|
|
fc: dict | None,
|
|
|
|
|
|
minlon: float, minlat: float, maxlon: float, maxlat: float,
|
|
|
|
|
|
) -> dict:
|
|
|
|
|
|
"""Drop features whose geometry envelope misses the viewport. No shapely."""
|
|
|
|
|
|
box = (minlon, minlat, maxlon, maxlat)
|
|
|
|
|
|
out = []
|
|
|
|
|
|
for feat in (fc or {}).get("features") or []:
|
|
|
|
|
|
if not isinstance(feat, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
env = _geom_envelope(feat.get("geometry"))
|
|
|
|
|
|
if env is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if env[0] <= box[2] and env[2] >= box[0] and env[1] <= box[3] and env[3] >= box[1]:
|
|
|
|
|
|
out.append(feat)
|
|
|
|
|
|
return {"type": "FeatureCollection", "features": out}
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
def _f(value: object) -> float | None:
|
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _heading(value: object) -> float | None:
|
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
|
key = value.strip().upper()
|
|
|
|
|
|
if key in _COMPASS:
|
|
|
|
|
|
return float(_COMPASS[key])
|
|
|
|
|
|
num = _f(value)
|
|
|
|
|
|
if num is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if num < 0 or num > 360:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return num
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
def _s(value: object) -> str | None:
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
|
return text or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ADS-B emitter category (DO-260B). A3 airliners, A5 heavies, A7 helicopters.
|
|
|
|
|
|
_EMITTER = {
|
|
|
|
|
|
"A0": "unknown", "A1": "light", "A2": "small", "A3": "large",
|
|
|
|
|
|
"A4": "high vortex", "A5": "heavy", "A6": "high performance", "A7": "rotorcraft",
|
|
|
|
|
|
"B0": "unknown", "B1": "glider", "B2": "airship", "B3": "parachute",
|
|
|
|
|
|
"B4": "ultralight", "B6": "UAV", "B7": "space",
|
|
|
|
|
|
"C0": "ground unknown", "C1": "emergency vehicle", "C2": "service vehicle",
|
|
|
|
|
|
"D0": "unknown", "D1": "emergency",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Combat / dedicated-military ICAO types. C-130/C-17 omitted — those also fly
|
|
|
|
|
|
# as fire tankers and civil contractors; dbFlags/hex catch the real mil ones.
|
|
|
|
|
|
_MIL_ICAO = frozenset({
|
|
|
|
|
|
"F15", "F16", "F18", "FA18", "F22", "F35", "F117", "A10", "A10A",
|
|
|
|
|
|
"B1", "B1B", "B2", "B52", "AV8B", "F4", "F5", "F14",
|
|
|
|
|
|
"SU27", "SU30", "SU34", "SU35", "SU57",
|
|
|
|
|
|
"MG29", "MIG29", "MG31", "MIG31", "J10", "J11", "J15", "J16", "J20",
|
|
|
|
|
|
"EUFI", "RFAL", "TOR", "E3TF", "E3CF", "E6", "E8", "P8",
|
|
|
|
|
|
"MQ9", "MQ1", "RQ4", "V22", "AH64", "H64",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
# US DoD Mode-S block AE0000–AEFFFF.
|
|
|
|
|
|
_US_DOD_HEX_LO, _US_DOD_HEX_HI = 0xAE0000, 0xAEFFFF
|
|
|
|
|
|
|
|
|
|
|
|
_MIL_CS_PREFIX = ("RCH", "NAVY", "ARMY", "MARINE", "GOTOF", "REACH")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def classify_adsb(ac: dict) -> tuple[str, str]:
|
|
|
|
|
|
"""Return (role, role_src). Prefer readsb dbFlags bit0, then type/hex/cs."""
|
|
|
|
|
|
flags = ac.get("dbFlags")
|
|
|
|
|
|
try:
|
|
|
|
|
|
flags_i = int(flags) if flags is not None else 0
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
flags_i = 0
|
|
|
|
|
|
if flags_i & 1:
|
|
|
|
|
|
return "military", "dbFlags"
|
|
|
|
|
|
icao = str(ac.get("t") or "").strip().upper()
|
|
|
|
|
|
if icao in _MIL_ICAO:
|
|
|
|
|
|
return "military", "type"
|
|
|
|
|
|
try:
|
|
|
|
|
|
hx = int(str(ac.get("hex") or "").strip(), 16)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
hx = -1
|
|
|
|
|
|
if _US_DOD_HEX_LO <= hx <= _US_DOD_HEX_HI:
|
|
|
|
|
|
return "military", "hex"
|
|
|
|
|
|
cs = str(ac.get("flight") or "").strip().upper()
|
|
|
|
|
|
if cs.startswith(_MIL_CS_PREFIX):
|
|
|
|
|
|
return "military", "callsign"
|
|
|
|
|
|
return "civilian", "default"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _adsb_extra(ac: dict, hex_id: str) -> dict[str, Any]:
|
|
|
|
|
|
role, src = classify_adsb(ac)
|
|
|
|
|
|
cat = str(ac.get("category") or "").strip().upper()
|
|
|
|
|
|
extra: dict[str, Any] = {
|
|
|
|
|
|
"hex": hex_id,
|
|
|
|
|
|
"reg": _s(ac.get("r")),
|
|
|
|
|
|
"type": _s(ac.get("t")),
|
|
|
|
|
|
"alt_baro": ac.get("alt_baro"),
|
|
|
|
|
|
"squawk": _s(ac.get("squawk")),
|
|
|
|
|
|
"emergency": _s(ac.get("emergency")),
|
|
|
|
|
|
"category": cat or None,
|
|
|
|
|
|
"emitter": _EMITTER.get(cat),
|
|
|
|
|
|
"seen_pos": ac.get("seen_pos"),
|
|
|
|
|
|
"role": role,
|
|
|
|
|
|
"role_src": src,
|
|
|
|
|
|
"src": "adsb.lol",
|
|
|
|
|
|
}
|
|
|
|
|
|
desc = _s(ac.get("desc"))
|
|
|
|
|
|
if desc:
|
|
|
|
|
|
extra["desc"] = desc
|
|
|
|
|
|
own = _s(ac.get("ownOp") or ac.get("ownOpName") or ac.get("ownop"))
|
|
|
|
|
|
if own:
|
|
|
|
|
|
extra["ownOp"] = own
|
|
|
|
|
|
if ac.get("alt_geom") is not None:
|
|
|
|
|
|
extra["alt_geom"] = ac.get("alt_geom")
|
|
|
|
|
|
vs = ac.get("baro_rate")
|
|
|
|
|
|
if vs is None:
|
|
|
|
|
|
vs = ac.get("geom_rate")
|
|
|
|
|
|
if vs is not None:
|
|
|
|
|
|
extra["vs"] = vs
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_flags = ac.get("dbFlags")
|
|
|
|
|
|
flags_i = int(raw_flags) if raw_flags is not None else 0
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
flags_i = 0
|
|
|
|
|
|
if flags_i:
|
|
|
|
|
|
extra["dbFlags"] = flags_i
|
|
|
|
|
|
extra["interesting"] = bool(flags_i & 2)
|
|
|
|
|
|
return extra
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_NAVSTAT = {
|
|
|
|
|
|
0: "underway",
|
|
|
|
|
|
1: "at anchor",
|
|
|
|
|
|
2: "not under command",
|
|
|
|
|
|
3: "restricted manoeuvre",
|
|
|
|
|
|
4: "constrained by draught",
|
|
|
|
|
|
5: "moored",
|
|
|
|
|
|
6: "aground",
|
|
|
|
|
|
7: "fishing",
|
|
|
|
|
|
8: "sailing",
|
|
|
|
|
|
14: "AIS-SART",
|
|
|
|
|
|
15: "undefined",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Compact MID → country for the flags that actually show up on AIS.
|
|
|
|
|
|
_MID_COUNTRY = {
|
|
|
|
|
|
211: "Germany", 218: "Germany",
|
|
|
|
|
|
219: "Denmark", 220: "Denmark",
|
|
|
|
|
|
224: "Spain", 225: "Spain",
|
|
|
|
|
|
226: "France", 227: "France", 228: "France",
|
|
|
|
|
|
232: "United Kingdom", 233: "United Kingdom", 234: "United Kingdom", 235: "United Kingdom",
|
|
|
|
|
|
236: "Gibraltar", 237: "Greece", 239: "Greece", 240: "Greece", 241: "Greece",
|
|
|
|
|
|
244: "Netherlands", 245: "Netherlands", 246: "Netherlands",
|
|
|
|
|
|
247: "Italy", 249: "Malta", 250: "Ireland", 251: "Iceland",
|
|
|
|
|
|
255: "Portugal", 256: "Malta",
|
|
|
|
|
|
257: "Norway", 258: "Norway", 259: "Norway",
|
|
|
|
|
|
261: "Poland", 263: "Portugal", 265: "Sweden", 266: "Sweden",
|
|
|
|
|
|
271: "Turkey", 273: "Russia", 276: "Estonia", 277: "Lithuania",
|
|
|
|
|
|
301: "Anguilla", 303: "United States", 310: "Bermuda", 316: "Canada",
|
|
|
|
|
|
319: "Cayman Islands", 338: "United States", 339: "Jamaica",
|
|
|
|
|
|
345: "Mexico", 352: "Panama", 353: "Panama", 354: "Panama",
|
|
|
|
|
|
355: "Panama", 356: "Panama", 357: "Panama",
|
|
|
|
|
|
366: "United States", 367: "United States", 368: "United States", 369: "United States",
|
|
|
|
|
|
370: "Panama", 371: "Panama", 372: "Panama", 373: "Panama", 374: "Panama",
|
|
|
|
|
|
375: "St Vincent", 376: "St Vincent", 377: "St Vincent",
|
|
|
|
|
|
412: "China", 413: "China", 414: "China", 416: "Taiwan",
|
|
|
|
|
|
419: "India", 431: "Japan", 432: "Japan", 440: "South Korea", 441: "South Korea",
|
|
|
|
|
|
477: "Hong Kong", 503: "Australia", 525: "Indonesia", 533: "Malaysia",
|
|
|
|
|
|
538: "Marshall Islands", 548: "Philippines", 563: "Singapore",
|
|
|
|
|
|
564: "Singapore", 565: "Singapore", 566: "Singapore", 567: "Thailand",
|
|
|
|
|
|
574: "Vietnam", 636: "Liberia", 637: "Liberia",
|
|
|
|
|
|
710: "Brazil", 725: "Chile", 730: "Colombia", 760: "Peru",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mmsi_country(mmsi: object) -> str | None:
|
|
|
|
|
|
digits = "".join(ch for ch in str(mmsi or "") if ch.isdigit())
|
|
|
|
|
|
if len(digits) < 3:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
mid = int(digits[:3])
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return _MID_COUNTRY.get(mid)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def classify_ais_type(type_code: int | None) -> tuple[str, str]:
|
|
|
|
|
|
"""Return (role, kind) from ITU-R M.1371 ship-and-cargo type."""
|
|
|
|
|
|
if type_code is None:
|
|
|
|
|
|
return "civilian", "unknown"
|
|
|
|
|
|
t = int(type_code)
|
|
|
|
|
|
tens = t // 10
|
|
|
|
|
|
if t == 35:
|
|
|
|
|
|
return "military", "military"
|
|
|
|
|
|
if t == 30:
|
|
|
|
|
|
return "civilian", "fishing"
|
|
|
|
|
|
if t in (31, 32, 52):
|
|
|
|
|
|
return "civilian", "tug"
|
|
|
|
|
|
if t == 33:
|
|
|
|
|
|
return "civilian", "dredger"
|
|
|
|
|
|
if t == 34:
|
|
|
|
|
|
return "civilian", "diving"
|
|
|
|
|
|
if t == 36:
|
|
|
|
|
|
return "civilian", "sailing"
|
|
|
|
|
|
if t == 37:
|
|
|
|
|
|
return "civilian", "pleasure"
|
|
|
|
|
|
if t == 50:
|
|
|
|
|
|
return "government", "pilot"
|
|
|
|
|
|
if t == 51:
|
|
|
|
|
|
return "government", "SAR"
|
|
|
|
|
|
if t == 55:
|
|
|
|
|
|
return "government", "law"
|
|
|
|
|
|
if t == 54:
|
|
|
|
|
|
return "government", "anti-pollution"
|
|
|
|
|
|
if t == 58:
|
|
|
|
|
|
return "government", "medical"
|
|
|
|
|
|
if tens == 4:
|
|
|
|
|
|
return "civilian", "HSC"
|
|
|
|
|
|
if tens == 6:
|
|
|
|
|
|
return "civilian", "passenger"
|
|
|
|
|
|
if tens == 7:
|
|
|
|
|
|
return "civilian", "cargo"
|
|
|
|
|
|
if tens == 8:
|
|
|
|
|
|
return "civilian", "tanker"
|
|
|
|
|
|
if tens in (5, 9) or t in (53, 56, 57, 59):
|
|
|
|
|
|
return "civilian", "special"
|
|
|
|
|
|
return "civilian", "other"
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
def transform_adsb_lol(payload: dict | list | None) -> list[dict]:
|
|
|
|
|
|
"""Map ADSB.lol v2 aircraft list to shared markers. Dedup on hex."""
|
|
|
|
|
|
if payload is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
if isinstance(payload, list):
|
|
|
|
|
|
aircraft = payload
|
|
|
|
|
|
else:
|
|
|
|
|
|
aircraft = payload.get("ac") or payload.get("aircraft") or []
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for ac in aircraft:
|
|
|
|
|
|
hex_id = str(ac.get("hex") or "").strip().lower()
|
|
|
|
|
|
lat, lon = _f(ac.get("lat")), _f(ac.get("lon"))
|
|
|
|
|
|
if not hex_id or lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if hex_id in seen:
|
|
|
|
|
|
continue
|
|
|
|
|
|
seen.add(hex_id)
|
|
|
|
|
|
flight = str(ac.get("flight") or "").strip() or hex_id
|
|
|
|
|
|
out.append(to_marker(
|
|
|
|
|
|
hex_id, lat, lon,
|
|
|
|
|
|
heading=_heading(ac.get("track")),
|
|
|
|
|
|
speed=_f(ac.get("gs")),
|
|
|
|
|
|
label=flight,
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
extra=_adsb_extra(ac, hex_id),
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transform_amtraker(payload: dict | None) -> list[dict]:
|
|
|
|
|
|
"""Flatten Amtraker `{trainNum: [Train, ...]}` to one marker per trainID."""
|
|
|
|
|
|
if not payload or not isinstance(payload, dict):
|
|
|
|
|
|
return []
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for _num, trains in payload.items():
|
|
|
|
|
|
if not isinstance(trains, list):
|
|
|
|
|
|
continue
|
|
|
|
|
|
for tr in trains:
|
|
|
|
|
|
if not isinstance(tr, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
lat, lon = _f(tr.get("lat")), _f(tr.get("lon"))
|
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
tid = str(tr.get("trainID") or tr.get("trainId") or "").strip()
|
|
|
|
|
|
tnum = str(tr.get("trainNum") or _num)
|
|
|
|
|
|
route = str(tr.get("routeName") or "").strip()
|
|
|
|
|
|
label = f"{route} #{tnum}".strip() if route else f"Train {tnum}"
|
|
|
|
|
|
late = tr.get("late")
|
|
|
|
|
|
if late is None:
|
|
|
|
|
|
late = tr.get("lateMin")
|
|
|
|
|
|
out.append(to_marker(
|
|
|
|
|
|
tid or tnum, lat, lon,
|
|
|
|
|
|
heading=_heading(tr.get("heading")),
|
|
|
|
|
|
speed=_f(tr.get("velocity") or tr.get("speed")),
|
|
|
|
|
|
label=label,
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"route": route,
|
|
|
|
|
|
"trainNum": tnum,
|
|
|
|
|
|
"late_min": late,
|
|
|
|
|
|
"iconColor": tr.get("iconColor"),
|
|
|
|
|
|
"stations": tr.get("stations") or [],
|
|
|
|
|
|
"src": "amtraker",
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transform_ais_frame(frame: dict | None) -> dict | None:
|
|
|
|
|
|
"""Decode one AISStream JSON envelope to a marker (or None if unusable)."""
|
|
|
|
|
|
if not frame or not isinstance(frame, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
meta = frame.get("MetaData") or {}
|
|
|
|
|
|
mmsi = meta.get("MMSI") or meta.get("mmsi")
|
|
|
|
|
|
if mmsi is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
name = str(meta.get("ShipName") or meta.get("shipName") or "").strip()
|
|
|
|
|
|
lat = _f(meta.get("Latitude") if "Latitude" in meta else meta.get("latitude"))
|
|
|
|
|
|
lon = _f(meta.get("Longitude") if "Longitude" in meta else meta.get("longitude"))
|
|
|
|
|
|
msg = frame.get("Message") or {}
|
|
|
|
|
|
pos = (
|
|
|
|
|
|
msg.get("PositionReport")
|
|
|
|
|
|
or msg.get("StandardClassBPositionReport")
|
|
|
|
|
|
or msg.get("ExtendedClassBPositionReport")
|
|
|
|
|
|
or {}
|
|
|
|
|
|
)
|
|
|
|
|
|
extra: dict[str, Any] = {"src": "aisstream", "mmsi": mmsi}
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
country = _mmsi_country(mmsi)
|
|
|
|
|
|
if country:
|
|
|
|
|
|
extra["country"] = country
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if frame.get("MessageType") == "ShipStaticData":
|
|
|
|
|
|
static = msg.get("ShipStaticData") or {}
|
|
|
|
|
|
dest = str(static.get("Destination") or static.get("destination") or "").strip()
|
|
|
|
|
|
extra["dest"] = dest
|
|
|
|
|
|
extra["static"] = True
|
|
|
|
|
|
if not name:
|
|
|
|
|
|
name = str(static.get("Name") or static.get("name") or "").strip()
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
cs = _s(static.get("CallSign") or static.get("callSign"))
|
|
|
|
|
|
if cs:
|
|
|
|
|
|
extra["callsign"] = cs
|
|
|
|
|
|
try:
|
|
|
|
|
|
imo = int(static.get("ImoNumber") or static.get("imoNumber") or 0)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
imo = 0
|
|
|
|
|
|
if imo:
|
|
|
|
|
|
extra["imo"] = imo
|
|
|
|
|
|
type_code = static.get("Type") if "Type" in static else static.get("type")
|
|
|
|
|
|
try:
|
|
|
|
|
|
type_i = int(type_code) if type_code is not None else None
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
type_i = None
|
|
|
|
|
|
if type_i is not None:
|
|
|
|
|
|
extra["type_code"] = type_i
|
|
|
|
|
|
role, kind = classify_ais_type(type_i)
|
|
|
|
|
|
extra["role"] = role
|
|
|
|
|
|
extra["kind"] = kind
|
|
|
|
|
|
dim = static.get("Dimension") or static.get("dimension") or {}
|
|
|
|
|
|
if isinstance(dim, dict):
|
|
|
|
|
|
a, b = _f(dim.get("A")), _f(dim.get("B"))
|
|
|
|
|
|
c, d = _f(dim.get("C")), _f(dim.get("D"))
|
|
|
|
|
|
if a is not None and b is not None:
|
|
|
|
|
|
extra["length"] = int(round(a + b))
|
|
|
|
|
|
if c is not None and d is not None:
|
|
|
|
|
|
extra["beam"] = int(round(c + d))
|
|
|
|
|
|
draught = _f(static.get("MaximumStaticDraught") or static.get("maximumStaticDraught"))
|
|
|
|
|
|
if draught is not None:
|
|
|
|
|
|
extra["draught"] = draught
|
|
|
|
|
|
eta = static.get("Eta") or static.get("eta") or {}
|
|
|
|
|
|
if isinstance(eta, dict) and eta.get("Month"):
|
|
|
|
|
|
extra["eta"] = (
|
|
|
|
|
|
f"{int(eta.get('Month') or 0):02d}-{int(eta.get('Day') or 0):02d} "
|
|
|
|
|
|
f"{int(eta.get('Hour') or 0):02d}:{int(eta.get('Minute') or 0):02d}"
|
|
|
|
|
|
)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
# Static-only update: caller merges onto last-known by MMSI.
|
|
|
|
|
|
return to_marker(str(mmsi), None, None, label=name or str(mmsi), extra=extra)
|
|
|
|
|
|
heading = pos.get("TrueHeading")
|
|
|
|
|
|
if heading in (511, 511.0, None):
|
|
|
|
|
|
heading = pos.get("Cog")
|
|
|
|
|
|
sog = pos.get("Sog")
|
|
|
|
|
|
navstat = pos.get("NavigationalStatus")
|
|
|
|
|
|
extra["navstat"] = navstat
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
try:
|
|
|
|
|
|
extra["nav"] = _NAVSTAT.get(int(navstat)) if navstat is not None else None
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
extra["nav"] = None
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
extra["cog"] = pos.get("Cog")
|
feat: classify aircraft/vessels as military vs civilian in popups
Aircraft: readsb dbFlags bit0, then ICAO combat types / US-DoD hex /
callsign. Popup shows type, operator, alt, vs, squawk, emitter class.
Vessels: ITU ship type 35 = military, 50/51/55 = government; cargo/
tanker/passenger/etc otherwise. Popup shows kind, flag, dest, nav,
IMO, size. Magenta glyphs = military, gold = law/gov.
2026-08-28 21:00:20 -04:00
|
|
|
|
if not extra.get("dest"):
|
|
|
|
|
|
extra.pop("dest", None)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return to_marker(
|
|
|
|
|
|
str(mmsi), lat, lon,
|
|
|
|
|
|
heading=_heading(heading),
|
|
|
|
|
|
speed=_f(sog),
|
|
|
|
|
|
label=name or str(mmsi),
|
|
|
|
|
|
extra=extra,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transform_wfigs_incidents(fc: dict | None) -> list[dict]:
|
|
|
|
|
|
if not fc:
|
|
|
|
|
|
return []
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for feat in fc.get("features") or []:
|
|
|
|
|
|
geom = feat.get("geometry") or {}
|
|
|
|
|
|
coords = geom.get("coordinates") or []
|
|
|
|
|
|
if len(coords) < 2:
|
|
|
|
|
|
continue
|
|
|
|
|
|
lon, lat = _f(coords[0]), _f(coords[1])
|
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
props = feat.get("properties") or {}
|
|
|
|
|
|
name = str(props.get("IncidentName") or "Incident").strip()
|
|
|
|
|
|
out.append(to_marker(
|
|
|
|
|
|
name, lat, lon,
|
|
|
|
|
|
label=name,
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"acres": props.get("IncidentSize"),
|
|
|
|
|
|
"contained": props.get("PercentContained"),
|
|
|
|
|
|
"state": props.get("POOState"),
|
|
|
|
|
|
"category": props.get("IncidentTypeCategory"),
|
|
|
|
|
|
"cause": props.get("FireCause"),
|
|
|
|
|
|
"discovered": props.get("FireDiscoveryDateTime"),
|
|
|
|
|
|
"src": "wfigs",
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def transform_nhc_storms(payload: dict | None) -> list[dict]:
|
|
|
|
|
|
if not payload:
|
|
|
|
|
|
return []
|
|
|
|
|
|
storms = payload.get("activeStorms") or []
|
|
|
|
|
|
class_label = {
|
|
|
|
|
|
"TD": "Tropical Depression",
|
|
|
|
|
|
"TS": "Tropical Storm",
|
|
|
|
|
|
"HU": "Hurricane",
|
|
|
|
|
|
"STD": "Subtropical Depression",
|
|
|
|
|
|
"STS": "Subtropical Storm",
|
|
|
|
|
|
"PTC": "Potential Tropical Cyclone",
|
|
|
|
|
|
"PC": "Post-Tropical Cyclone",
|
|
|
|
|
|
}
|
|
|
|
|
|
out: list[dict] = []
|
|
|
|
|
|
for s in storms:
|
|
|
|
|
|
lat = _f(s.get("latitudeNumeric") or s.get("lat"))
|
|
|
|
|
|
lon = _f(s.get("longitudeNumeric") or s.get("lon"))
|
|
|
|
|
|
if lat is None or lon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
sid = str(s.get("id") or s.get("binNumber") or "").strip()
|
|
|
|
|
|
name = str(s.get("name") or "Storm").strip()
|
|
|
|
|
|
klass = str(s.get("classification") or "").upper()
|
|
|
|
|
|
kind = class_label.get(klass, klass or "Storm")
|
|
|
|
|
|
out.append(to_marker(
|
|
|
|
|
|
sid or name, lat, lon,
|
|
|
|
|
|
heading=_heading(s.get("movementDir")),
|
|
|
|
|
|
speed=_f(s.get("movementSpeed")),
|
|
|
|
|
|
label=f"{kind} {name}".strip(),
|
|
|
|
|
|
extra={
|
|
|
|
|
|
"classification": klass,
|
|
|
|
|
|
"intensity_kt": s.get("intensity"),
|
|
|
|
|
|
"src": "nhc",
|
|
|
|
|
|
},
|
|
|
|
|
|
))
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def rainviewer_tile_url(host: str, path: str, size: int = 256, color: int = 2,
|
|
|
|
|
|
smooth: int = 1, snow: int = 1) -> str:
|
|
|
|
|
|
host = host.rstrip("/")
|
|
|
|
|
|
path = path if path.startswith("/") else f"/{path}"
|
|
|
|
|
|
return f"{host}{path}/{size}/{{z}}/{{x}}/{{y}}/{color}/{smooth}_{snow}.png"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _headers() -> dict[str, str]:
|
|
|
|
|
|
return {"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 23:28:28 -04:00
|
|
|
|
def _planespotters_headers() -> dict[str, str]:
|
|
|
|
|
|
"""Planespotters 403s server clients whose UA has no contact URL/email."""
|
|
|
|
|
|
ua = (OSINT_USER_AGENT or "").strip()
|
|
|
|
|
|
if ua and ("@" in ua or "http://" in ua.lower() or "https://" in ua.lower()):
|
|
|
|
|
|
return {"User-Agent": ua, "Accept": "application/json"}
|
|
|
|
|
|
base = ua or "osint-dashboard/1.0"
|
|
|
|
|
|
return {
|
|
|
|
|
|
"User-Agent": f"{base} (lancewalters94@gmail.com)",
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:14:49 -04:00
|
|
|
|
async def _lock_for(key: str) -> asyncio.Lock:
|
|
|
|
|
|
async with _key_locks_guard:
|
|
|
|
|
|
lock = _key_locks.get(key)
|
|
|
|
|
|
if lock is None:
|
|
|
|
|
|
lock = asyncio.Lock()
|
|
|
|
|
|
_key_locks[key] = lock
|
|
|
|
|
|
return lock
|
|
|
|
|
|
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
async def _ttl_get(key: str, ttl: float, factory: Callable[[], Awaitable[Any]]) -> Any:
|
|
|
|
|
|
now = time.monotonic()
|
|
|
|
|
|
hit = _cache.get(key)
|
|
|
|
|
|
if hit and now - hit[0] < ttl:
|
|
|
|
|
|
return hit[1]
|
2026-08-27 21:14:49 -04:00
|
|
|
|
lock = await _lock_for(key)
|
|
|
|
|
|
async with lock:
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
hit = _cache.get(key)
|
|
|
|
|
|
if hit and time.monotonic() - hit[0] < ttl:
|
|
|
|
|
|
return hit[1]
|
|
|
|
|
|
value = await factory()
|
|
|
|
|
|
_cache[key] = (time.monotonic(), value)
|
|
|
|
|
|
return value
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:21:19 -04:00
|
|
|
|
async def init_http() -> None:
|
|
|
|
|
|
"""Shared outbound client — one TLS pool for all overlay upstreams."""
|
|
|
|
|
|
global _http
|
|
|
|
|
|
if _http is None:
|
|
|
|
|
|
_http = httpx.AsyncClient(
|
|
|
|
|
|
timeout=_HTTP_TIMEOUT,
|
|
|
|
|
|
follow_redirects=True,
|
|
|
|
|
|
headers=_headers(),
|
|
|
|
|
|
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def close_http() -> None:
|
|
|
|
|
|
global _http
|
|
|
|
|
|
if _http is not None:
|
|
|
|
|
|
await _http.aclose()
|
|
|
|
|
|
_http = None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 23:28:28 -04:00
|
|
|
|
async def _get_json(
|
|
|
|
|
|
url: str, params: dict | None = None, headers: dict | None = None,
|
|
|
|
|
|
) -> Any:
|
2026-08-27 21:21:19 -04:00
|
|
|
|
if _http is None:
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
|
|
|
|
|
|
) as client:
|
2026-08-28 23:28:28 -04:00
|
|
|
|
resp = await client.get(url, params=params, headers=headers)
|
2026-08-27 21:21:19 -04:00
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return resp.json()
|
2026-08-28 23:28:28 -04:00
|
|
|
|
resp = await _http.get(url, params=params, headers=headers)
|
2026-08-27 21:21:19 -04:00
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return resp.json()
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 22:58:04 -04:00
|
|
|
|
def _normalize_planespotter_photo(photo: dict) -> dict | None:
|
|
|
|
|
|
"""Slim a planespotters.net photo object for the aircraft popup.
|
|
|
|
|
|
|
|
|
|
|
|
Image binaries are never proxied/re-hosted: we return the CDN ``src``
|
|
|
|
|
|
and the browser loads it directly (per ToS).
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(photo, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
large = photo.get("thumbnail_large") or {}
|
|
|
|
|
|
small = photo.get("thumbnail") or {}
|
|
|
|
|
|
src = large.get("src") or small.get("src")
|
|
|
|
|
|
if not src:
|
|
|
|
|
|
return None
|
|
|
|
|
|
size = large.get("size") or small.get("size") or {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": str(photo.get("id") or ""),
|
|
|
|
|
|
"src": src,
|
|
|
|
|
|
"width": size.get("width"),
|
|
|
|
|
|
"height": size.get("height"),
|
|
|
|
|
|
"link": photo.get("link") or "",
|
|
|
|
|
|
"photographer": photo.get("photographer") or "",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_planespotters_photo(
|
|
|
|
|
|
hex_code: str | None = None, reg: str | None = None,
|
|
|
|
|
|
) -> dict | None:
|
|
|
|
|
|
"""Latest photo for an aircraft from planespotters.net (hex preferred).
|
|
|
|
|
|
|
|
|
|
|
|
Server-side only: planespotters 403s any request carrying an ``Origin``
|
|
|
|
|
|
header (browser fetch() always sends one), so the browser can never reach
|
|
|
|
|
|
it directly. We fetch here with the identifying UA, cache the JSON ≤24h,
|
|
|
|
|
|
and hand the CDN image URL back for the browser to load.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if hex_code:
|
|
|
|
|
|
key = f"psp-hex:{hex_code.strip().lower()}"
|
|
|
|
|
|
url = f"{PLANESPOTTERS_PHOTO}/hex/{quote(hex_code.strip())}"
|
|
|
|
|
|
elif reg:
|
|
|
|
|
|
key = f"psp-reg:{reg.strip().lower()}"
|
|
|
|
|
|
url = f"{PLANESPOTTERS_PHOTO}/reg/{quote(reg.strip())}"
|
|
|
|
|
|
else:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def _load() -> dict | None:
|
2026-08-28 23:28:28 -04:00
|
|
|
|
data = await _get_json(url, headers=_planespotters_headers())
|
2026-08-28 22:58:04 -04:00
|
|
|
|
photos = data.get("photos") or []
|
|
|
|
|
|
return _normalize_planespotter_photo(photos[0]) if photos else None
|
|
|
|
|
|
|
|
|
|
|
|
return await _ttl_get(key, PLANESPOTTERS_CACHE_TTL, _load)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-28 21:49:05 -04:00
|
|
|
|
async def fetch_aircraft(
|
|
|
|
|
|
bbox: str, limit: int = DEFAULT_LIMIT, persist: bool = False,
|
|
|
|
|
|
) -> list[dict]:
|
|
|
|
|
|
"""Viewport ADS-B.
|
|
|
|
|
|
|
|
|
|
|
|
GET path (persist=False) serves in-memory last-known and never writes
|
|
|
|
|
|
tracks/geofences. Background refresh (persist=True) hits ADSB.lol and
|
|
|
|
|
|
then persist_aircraft_snapshot.
|
|
|
|
|
|
"""
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
2026-08-28 21:49:05 -04:00
|
|
|
|
if not persist:
|
|
|
|
|
|
cached = [
|
|
|
|
|
|
dict(v) for v in aircraft_last_known.values()
|
|
|
|
|
|
if v.get("lat") is not None and v.get("lon") is not None
|
|
|
|
|
|
]
|
|
|
|
|
|
filtered = filter_points_bbox(cached, minlon, minlat, maxlon, maxlat, limit)
|
|
|
|
|
|
if filtered:
|
|
|
|
|
|
return filtered
|
2026-08-27 21:14:49 -04:00
|
|
|
|
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(minlon, minlat, maxlon, maxlat)
|
|
|
|
|
|
lat, lon, radius = bbox_center_radius_nm(qminlon, qminlat, qmaxlon, qmaxlat)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
cache_key = f"adsb:{lat:.2f}:{lon:.2f}:{radius}"
|
|
|
|
|
|
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
url = f"{ADSB_LOL_BASE}/v2/lat/{lat:.4f}/lon/{lon:.4f}/dist/{radius}"
|
|
|
|
|
|
return transform_adsb_lol(await _get_json(url))
|
|
|
|
|
|
|
|
|
|
|
|
rows = await _ttl_get(cache_key, 8.0, _load)
|
2026-08-28 21:49:05 -04:00
|
|
|
|
for m in rows:
|
|
|
|
|
|
aircraft_last_known[str(m.get("id"))] = m
|
|
|
|
|
|
if persist:
|
|
|
|
|
|
await persist_aircraft_snapshot(rows)
|
|
|
|
|
|
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def persist_aircraft_snapshot(rows: list[dict]) -> None:
|
|
|
|
|
|
"""Track / geofence / WS / fire correlation — never on the GET path."""
|
2026-08-28 09:33:19 -04:00
|
|
|
|
from ws_manager import manager
|
|
|
|
|
|
from tracks import record_position
|
|
|
|
|
|
from geofence import record_and_notify
|
2026-08-28 21:49:05 -04:00
|
|
|
|
|
2026-08-28 09:33:19 -04:00
|
|
|
|
for m in rows:
|
|
|
|
|
|
mlat, mlon = m.get("lat"), m.get("lon")
|
|
|
|
|
|
if mlat is None or mlon is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
await record_position("aircraft", m)
|
|
|
|
|
|
if manager.has_clients():
|
|
|
|
|
|
await manager.publish_point("adsb", m, lat=mlat, lon=mlon)
|
|
|
|
|
|
await record_and_notify(
|
|
|
|
|
|
source_kind="adsb", entity_id=str(m.get("id")),
|
|
|
|
|
|
lat=mlat, lon=mlon, payload=m,
|
|
|
|
|
|
)
|
|
|
|
|
|
if fire_last_known:
|
|
|
|
|
|
from fire_aircraft import correlate_and_notify
|
|
|
|
|
|
await correlate_and_notify(fire_last_known, rows)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_trains(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
return transform_amtraker(await _get_json(AMTRAKER_TRAINS))
|
|
|
|
|
|
|
|
|
|
|
|
rows = await _ttl_get("amtraker:trains", 20.0, _load)
|
|
|
|
|
|
if bbox:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
|
|
|
|
|
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
|
|
|
|
|
return rows[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 14:03:15 -04:00
|
|
|
|
async def fetch_vessels(
|
|
|
|
|
|
bbox: str | None,
|
|
|
|
|
|
limit: int = DEFAULT_LIMIT,
|
|
|
|
|
|
src: str | None = None,
|
|
|
|
|
|
) -> list[dict]:
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
async with vessel_lock:
|
|
|
|
|
|
rows = [dict(v) for v in vessel_last_known.values()
|
|
|
|
|
|
if v.get("lat") is not None and v.get("lon") is not None]
|
2026-08-29 14:03:15 -04:00
|
|
|
|
if src and src != "all":
|
|
|
|
|
|
rows = [r for r in rows if (r.get("extra") or {}).get("src") == src]
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if bbox:
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
|
|
|
|
|
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
|
|
|
|
|
|
return rows[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def upsert_vessel(marker: dict) -> None:
|
|
|
|
|
|
"""Merge an AIS marker into last-known by MMSI. Static-only updates names."""
|
|
|
|
|
|
vid = str(marker.get("id") or "")
|
|
|
|
|
|
if not vid:
|
|
|
|
|
|
return
|
|
|
|
|
|
async with vessel_lock:
|
|
|
|
|
|
prev = vessel_last_known.get(vid, {})
|
|
|
|
|
|
extra = {**(prev.get("extra") or {}), **(marker.get("extra") or {})}
|
|
|
|
|
|
lat = marker.get("lat") if marker.get("lat") is not None else prev.get("lat")
|
|
|
|
|
|
lon = marker.get("lon") if marker.get("lon") is not None else prev.get("lon")
|
|
|
|
|
|
label = marker.get("label")
|
|
|
|
|
|
if not label or label == vid:
|
|
|
|
|
|
label = prev.get("label") or vid
|
2026-08-28 09:33:19 -04:00
|
|
|
|
stored = {
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
**to_marker(
|
|
|
|
|
|
vid, lat, lon,
|
|
|
|
|
|
heading=marker.get("heading") if marker.get("heading") is not None else prev.get("heading"),
|
|
|
|
|
|
speed=marker.get("speed") if marker.get("speed") is not None else prev.get("speed"),
|
|
|
|
|
|
label=label,
|
|
|
|
|
|
extra=extra,
|
|
|
|
|
|
),
|
|
|
|
|
|
"seen_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
}
|
2026-08-28 09:33:19 -04:00
|
|
|
|
vessel_last_known[vid] = stored
|
2026-08-27 21:46:37 -04:00
|
|
|
|
if len(vessel_last_known) > _MAX_VESSELS:
|
|
|
|
|
|
excess = len(vessel_last_known) - int(_MAX_VESSELS * 0.9)
|
2026-08-29 13:05:57 -04:00
|
|
|
|
# AISStream's live US-coast feed can crowd the shared store past
|
|
|
|
|
|
# _MAX_VESSELS and evict the Hormuz VesselAPI rows (refreshed only
|
|
|
|
|
|
# every VESSELAPI_INTERVAL) as "oldest". Keep src=vesselapi rows
|
|
|
|
|
|
# resident so the blind-spot box stays visible between polls.
|
|
|
|
|
|
evictable = [
|
|
|
|
|
|
k for k, v in vessel_last_known.items()
|
|
|
|
|
|
if (v.get("extra") or {}).get("src") != "vesselapi"
|
|
|
|
|
|
]
|
|
|
|
|
|
if len(evictable) < excess:
|
|
|
|
|
|
excess = len(evictable)
|
2026-08-27 21:46:37 -04:00
|
|
|
|
oldest = sorted(
|
2026-08-29 13:05:57 -04:00
|
|
|
|
evictable,
|
2026-08-27 21:46:37 -04:00
|
|
|
|
key=lambda k: vessel_last_known[k].get("seen_at", ""),
|
|
|
|
|
|
)[:excess]
|
|
|
|
|
|
for k in oldest:
|
|
|
|
|
|
vessel_last_known.pop(k, None)
|
2026-08-28 09:33:19 -04:00
|
|
|
|
if lat is not None and lon is not None:
|
|
|
|
|
|
from ws_manager import manager
|
|
|
|
|
|
from tracks import record_position
|
|
|
|
|
|
from geofence import record_and_notify
|
|
|
|
|
|
await manager.publish_point("ais", stored, lat=lat, lon=lon)
|
|
|
|
|
|
await record_position("vessel", stored)
|
|
|
|
|
|
await record_and_notify(
|
|
|
|
|
|
source_kind="ais", entity_id=vid, lat=lat, lon=lon, payload=stored,
|
|
|
|
|
|
)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
|
|
|
|
|
|
2026-08-27 21:16:53 -04:00
|
|
|
|
def _wfigs_params(bbox: str | None, *, offset_m: float = 250.0) -> dict:
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
params = {
|
|
|
|
|
|
"where": "1=1",
|
|
|
|
|
|
"outSR": "4326",
|
|
|
|
|
|
"f": "geojson",
|
2026-08-27 21:16:53 -04:00
|
|
|
|
"resultRecordCount": 500,
|
|
|
|
|
|
"maxAllowableOffset": offset_m / 111_320.0, # metres → degrees
|
|
|
|
|
|
"geometryPrecision": 5,
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
}
|
|
|
|
|
|
if bbox:
|
2026-08-27 21:14:49 -04:00
|
|
|
|
minlon, minlat, maxlon, maxlat = quantize_bbox(*parse_bbox(bbox))
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
params["geometry"] = f"{minlon},{minlat},{maxlon},{maxlat}"
|
|
|
|
|
|
params["geometryType"] = "esriGeometryEnvelope"
|
|
|
|
|
|
params["inSR"] = "4326"
|
|
|
|
|
|
params["spatialRel"] = "esriSpatialRelIntersects"
|
|
|
|
|
|
return params
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_fire_incidents(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dict]:
|
|
|
|
|
|
params = _wfigs_params(bbox)
|
|
|
|
|
|
params["outFields"] = (
|
|
|
|
|
|
"IncidentName,IncidentSize,FireDiscoveryDateTime,POOState,"
|
|
|
|
|
|
"PercentContained,IncidentTypeCategory,FireCause"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
return transform_wfigs_incidents(await _get_json(WFIGS_INCIDENTS, params))
|
|
|
|
|
|
|
2026-08-27 21:14:49 -04:00
|
|
|
|
rows = await _ttl_get(f"wfigs:inc:{bbox_cell_key(bbox)}", 600.0, _load)
|
2026-08-28 09:33:19 -04:00
|
|
|
|
fire_last_known[:] = list(rows)
|
|
|
|
|
|
if rows and aircraft_last_known:
|
|
|
|
|
|
from fire_aircraft import correlate_and_notify
|
|
|
|
|
|
await correlate_and_notify(rows, list(aircraft_last_known.values()))
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
return rows[:limit]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_fire_perimeters(bbox: str | None) -> dict:
|
|
|
|
|
|
params = _wfigs_params(bbox)
|
|
|
|
|
|
params["outFields"] = (
|
|
|
|
|
|
"poly_IncidentName,poly_GISAcres,attr_IncidentSize,"
|
|
|
|
|
|
"attr_PercentContained,attr_FireDiscoveryDateTime"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
return await _get_json(WFIGS_PERIMETERS, params)
|
|
|
|
|
|
|
2026-08-27 21:14:49 -04:00
|
|
|
|
fc = await _ttl_get(f"wfigs:per:{bbox_cell_key(bbox)}", 600.0, _load)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if not isinstance(fc, dict):
|
|
|
|
|
|
return {"type": "FeatureCollection", "features": []}
|
|
|
|
|
|
return fc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict:
|
|
|
|
|
|
"""Cached NWS active alerts + IEM storm-based warning polygons."""
|
|
|
|
|
|
|
2026-08-27 21:16:16 -04:00
|
|
|
|
async def _load_iem():
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = await _get_json(IEM_SBW)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {"type": "FeatureCollection", "features": []}
|
|
|
|
|
|
return data if isinstance(data, dict) else {"type": "FeatureCollection", "features": []}
|
|
|
|
|
|
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
async def _load():
|
|
|
|
|
|
nws_params: dict[str, str] = {"status": "actual"}
|
2026-08-27 21:16:16 -04:00
|
|
|
|
clip_box = None
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
if area:
|
|
|
|
|
|
nws_params["area"] = area.upper()
|
2026-08-28 21:49:05 -04:00
|
|
|
|
if bbox:
|
2026-08-27 21:16:16 -04:00
|
|
|
|
clip_box = quantize_bbox(*parse_bbox(bbox))
|
2026-08-28 21:49:05 -04:00
|
|
|
|
# NWS /alerts/active 400s on bbox= — never send it; clip locally.
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
nws_fc: dict = {"features": []}
|
2026-08-28 21:49:05 -04:00
|
|
|
|
nws_ok = True
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
try:
|
|
|
|
|
|
nws_fc = await _get_json(NWS_ALERTS, nws_params)
|
2026-08-28 21:49:05 -04:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("NWS alerts fetch failed: %s", exc)
|
|
|
|
|
|
nws_ok = False
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
nws_fc = {"features": []}
|
2026-08-27 21:16:16 -04:00
|
|
|
|
sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
features = []
|
|
|
|
|
|
for feat in nws_fc.get("features") or []:
|
2026-08-27 21:16:16 -04:00
|
|
|
|
if not isinstance(feat, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
props = dict(feat.get("properties") or {})
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
props["source"] = "nws"
|
2026-08-27 21:16:16 -04:00
|
|
|
|
features.append({**feat, "properties": slim_alert_properties(props)})
|
|
|
|
|
|
for feat in (sbw_fc or {}).get("features") or []:
|
|
|
|
|
|
if not isinstance(feat, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
props = dict(feat.get("properties") or {})
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
props["source"] = "iem-sbw"
|
|
|
|
|
|
if "event" not in props:
|
|
|
|
|
|
props["event"] = props.get("ps") or "Storm-based warning"
|
2026-08-27 21:16:16 -04:00
|
|
|
|
features.append({**feat, "properties": slim_alert_properties(props)})
|
2026-08-28 21:49:05 -04:00
|
|
|
|
merged = {"type": "FeatureCollection", "features": features, "nws_ok": nws_ok}
|
2026-08-27 21:16:16 -04:00
|
|
|
|
if clip_box:
|
2026-08-28 21:49:05 -04:00
|
|
|
|
clipped = clip_fc_to_bbox(merged, *clip_box)
|
|
|
|
|
|
clipped["nws_ok"] = nws_ok
|
|
|
|
|
|
merged = clipped
|
2026-08-27 21:16:16 -04:00
|
|
|
|
elif bbox:
|
2026-08-28 21:49:05 -04:00
|
|
|
|
clipped = clip_fc_to_bbox(merged, *parse_bbox(bbox))
|
|
|
|
|
|
clipped["nws_ok"] = nws_ok
|
|
|
|
|
|
merged = clipped
|
2026-08-27 21:16:16 -04:00
|
|
|
|
return merged
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
|
2026-08-27 21:14:49 -04:00
|
|
|
|
key = f"alerts:{area or ''}:{bbox_cell_key(bbox) if bbox else ''}"
|
feat: toggleable live map feeds (ADS-B, trains, AIS, radar, WFIGS, NWS)
Wire the free data streams from docs/free-data-streams.md into the
dashboard as layer-panel toggles. Third-party APIs are proxied/cached
in FastAPI; raster tiles (IEM, RainViewer, GIBS) stay in the browser.
- Aircraft via ADSB.lol viewport poll (bbox required, radius ≤ 150 nm)
- Amtraker trains, NHC storms, WFIGS incidents/perimeters
- NWS + IEM SBW as /api/weather-alerts (does not collide with /api/alerts)
- AISStream worker is server-side only and idles without AISSTREAM_API_KEY
- Caltrans CWWP2 D1–D12 camera parser; FIRMS dual-write NOAA-20/21
2026-08-27 19:08:30 -04:00
|
|
|
|
return await _ttl_get(key, 30.0, _load)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_radar_meta() -> dict:
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
data = await _get_json(RAINVIEWER_MAPS)
|
|
|
|
|
|
host = data.get("host") or "https://tilecache.rainviewer.com"
|
|
|
|
|
|
radar = (data.get("radar") or {})
|
|
|
|
|
|
past = radar.get("past") or []
|
|
|
|
|
|
nowcast = radar.get("nowcast") or []
|
|
|
|
|
|
frames = []
|
|
|
|
|
|
for fr in past + nowcast:
|
|
|
|
|
|
path = fr.get("path")
|
|
|
|
|
|
if not path:
|
|
|
|
|
|
continue
|
|
|
|
|
|
frames.append({
|
|
|
|
|
|
"time": fr.get("time"),
|
|
|
|
|
|
"path": path,
|
|
|
|
|
|
"tileUrl": rainviewer_tile_url(host, path),
|
|
|
|
|
|
})
|
|
|
|
|
|
latest = frames[-1]["tileUrl"] if frames else None
|
|
|
|
|
|
return {
|
|
|
|
|
|
"provider": "rainviewer",
|
|
|
|
|
|
"host": host,
|
|
|
|
|
|
"tileUrl": latest,
|
|
|
|
|
|
"frames": frames,
|
|
|
|
|
|
"attribution": "Weather data by RainViewer",
|
|
|
|
|
|
"iemTileUrl": IEM_NEXRAD,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await _ttl_get("radar:rv", 300.0, _load)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_storms() -> list[dict]:
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
return transform_nhc_storms(await _get_json(NHC_STORMS))
|
|
|
|
|
|
|
|
|
|
|
|
return await _ttl_get("nhc:storms", 300.0, _load)
|
2026-08-29 01:01:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Sentinel-1 SAR (Planetary Computer STAC → signed COG tile template) ────
|
|
|
|
|
|
|
|
|
|
|
|
class UpstreamRateLimited(Exception):
|
|
|
|
|
|
"""Planetary Computer returned 429. Carries Retry-After for the client."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, retry_after: str | None = None):
|
|
|
|
|
|
self.retry_after = retry_after
|
|
|
|
|
|
super().__init__("planetary computer rate limited")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _post_json(
|
|
|
|
|
|
url: str, json: dict | None = None, headers: dict | None = None,
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
if _http is None:
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
resp = await client.post(url, json=json, headers=headers)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return resp.json()
|
|
|
|
|
|
resp = await _http.post(url, json=json, headers=headers)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return resp.json()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _pc_call(coro: Awaitable[Any]) -> Any:
|
|
|
|
|
|
"""Run a Planetary Computer call, mapping 429 → UpstreamRateLimited."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return await coro
|
|
|
|
|
|
except httpx.HTTPStatusError as exc:
|
|
|
|
|
|
if exc.response.status_code == 429:
|
|
|
|
|
|
raise UpstreamRateLimited(
|
|
|
|
|
|
exc.response.headers.get("Retry-After")
|
|
|
|
|
|
) from exc
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sign_cog_url(href: str, token: str) -> str:
|
2026-08-29 11:42:03 -04:00
|
|
|
|
"""Append a SAS token to a PC blob URL (respect existing query string).
|
|
|
|
|
|
|
|
|
|
|
|
PC's SAS endpoint returns the token as an already-percent-encoded query
|
|
|
|
|
|
string (``st=…&se=…&sp=rl&…&sig=…``). Azure only honours those parameters
|
|
|
|
|
|
when they sit top-level on the blob URL — wrapping them under a single
|
|
|
|
|
|
``token=`` param yields 403/409, so we append the token verbatim.
|
|
|
|
|
|
"""
|
2026-08-29 01:01:09 -04:00
|
|
|
|
sep = "&" if "?" in href else "?"
|
2026-08-29 11:42:03 -04:00
|
|
|
|
return f"{href}{sep}{token}"
|
2026-08-29 01:01:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sentinel1_tile_url(signed_cog: str) -> str:
|
|
|
|
|
|
"""TiTiler XYZ template for a signed COG (Leaflet substitutes {z}/{x}/{y})."""
|
|
|
|
|
|
params = urlencode({
|
|
|
|
|
|
"url": signed_cog,
|
|
|
|
|
|
"rescale": "0,500",
|
|
|
|
|
|
"colormap_name": "cfastie",
|
|
|
|
|
|
})
|
|
|
|
|
|
return f"{TITILER_COG_TILES}?{params}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 20:40:27 -04:00
|
|
|
|
def feature_bbox(feat: dict) -> list[float] | None:
|
|
|
|
|
|
"""STAC Feature bbox as [minlon, minlat, maxlon, maxlat], or None."""
|
|
|
|
|
|
raw = feat.get("bbox") if isinstance(feat, dict) else None
|
|
|
|
|
|
if isinstance(raw, (list, tuple)) and len(raw) >= 4:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return [float(raw[0]), float(raw[1]), float(raw[2]), float(raw[3])]
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
pass
|
|
|
|
|
|
geom = (feat or {}).get("geometry") or {}
|
|
|
|
|
|
coords = geom.get("coordinates") if isinstance(geom, dict) else None
|
|
|
|
|
|
if not coords:
|
|
|
|
|
|
return None
|
|
|
|
|
|
lons: list[float] = []
|
|
|
|
|
|
lats: list[float] = []
|
|
|
|
|
|
|
|
|
|
|
|
def _walk(node: Any) -> None:
|
|
|
|
|
|
if isinstance(node, (list, tuple)) and node and isinstance(node[0], (int, float)):
|
|
|
|
|
|
lons.append(float(node[0]))
|
|
|
|
|
|
lats.append(float(node[1]))
|
|
|
|
|
|
elif isinstance(node, (list, tuple)):
|
|
|
|
|
|
for child in node:
|
|
|
|
|
|
_walk(child)
|
|
|
|
|
|
|
|
|
|
|
|
_walk(coords)
|
|
|
|
|
|
if not lons:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return [min(lons), min(lats), max(lons), max(lats)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def pick_sentinel_feature(features: list, lon: float, lat: float) -> dict | None:
|
|
|
|
|
|
"""Prefer the scene whose bbox covers the viewport center; else first."""
|
|
|
|
|
|
if not features:
|
|
|
|
|
|
return None
|
|
|
|
|
|
for feat in features:
|
|
|
|
|
|
bb = feature_bbox(feat)
|
|
|
|
|
|
if bb and bb[0] <= lon <= bb[2] and bb[1] <= lat <= bb[3]:
|
|
|
|
|
|
return feat
|
|
|
|
|
|
return features[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-29 01:01:09 -04:00
|
|
|
|
async def fetch_sentinel1(bbox: str) -> dict | None:
|
|
|
|
|
|
"""Most recent Sentinel-1 GRD COG for a viewport, signed and TiTiler-ready.
|
|
|
|
|
|
|
|
|
|
|
|
Returns the overlay tile-template dict, or ``None`` when no GRD imagery
|
|
|
|
|
|
covers the bbox in the last 7 days (caller maps to 404). Queries Planetary
|
2026-08-29 20:40:27 -04:00
|
|
|
|
Computer only when called; cached per 2° cell + UTC day so pan/zoom inside
|
|
|
|
|
|
the same region reuses the COG instead of picking a neighbouring swath.
|
2026-08-29 01:01:09 -04:00
|
|
|
|
"""
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
|
2026-08-29 20:40:27 -04:00
|
|
|
|
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(
|
|
|
|
|
|
minlon, minlat, maxlon, maxlat, step=SENTINEL1_CELL,
|
|
|
|
|
|
)
|
2026-08-29 01:01:09 -04:00
|
|
|
|
day = datetime.now(timezone.utc).date().isoformat()
|
2026-08-29 20:40:27 -04:00
|
|
|
|
key = f"sentinel1:{day}:{qminlon:.4f},{qminlat:.4f},{qmaxlon:.4f},{qmaxlat:.4f}"
|
|
|
|
|
|
clon = (qminlon + qmaxlon) / 2.0
|
|
|
|
|
|
clat = (qminlat + qmaxlat) / 2.0
|
2026-08-29 01:01:09 -04:00
|
|
|
|
|
|
|
|
|
|
async def _load() -> dict | None:
|
|
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
|
|
week_ago = now - timedelta(days=7)
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"collections": ["sentinel-1-grd"],
|
2026-08-29 20:40:27 -04:00
|
|
|
|
"bbox": [qminlon, qminlat, qmaxlon, qmaxlat],
|
2026-08-29 01:01:09 -04:00
|
|
|
|
"datetime": f"{week_ago.isoformat()}/{now.isoformat()}",
|
2026-08-29 20:40:27 -04:00
|
|
|
|
"limit": SENTINEL1_STAC_LIMIT,
|
2026-08-29 01:01:09 -04:00
|
|
|
|
"sortby": [{"field": "datetime", "direction": "desc"}],
|
|
|
|
|
|
}
|
|
|
|
|
|
data = await _pc_call(_post_json(PC_STAC_SEARCH, json=payload))
|
|
|
|
|
|
features = data.get("features") or []
|
|
|
|
|
|
if not features:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-08-29 20:40:27 -04:00
|
|
|
|
feat = pick_sentinel_feature(features, clon, clat)
|
|
|
|
|
|
if not feat:
|
|
|
|
|
|
return None
|
2026-08-29 01:01:09 -04:00
|
|
|
|
assets = feat.get("assets") or {}
|
|
|
|
|
|
chosen_href: str | None = None
|
|
|
|
|
|
polarization: str | None = None
|
|
|
|
|
|
for pol in ("vv", "hh"):
|
|
|
|
|
|
href = (assets.get(pol) or {}).get("href")
|
|
|
|
|
|
if href:
|
|
|
|
|
|
chosen_href = href
|
|
|
|
|
|
polarization = pol
|
|
|
|
|
|
break
|
|
|
|
|
|
if not chosen_href:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
sas = await _pc_call(_get_json(PC_SAS_TOKEN))
|
|
|
|
|
|
token = (sas or {}).get("token")
|
|
|
|
|
|
if not token:
|
|
|
|
|
|
raise RuntimeError("planetarycomputer SAS token missing")
|
|
|
|
|
|
signed = sign_cog_url(chosen_href, token)
|
|
|
|
|
|
|
|
|
|
|
|
props = feat.get("properties") or {}
|
|
|
|
|
|
return {
|
|
|
|
|
|
"id": "sentinel-1-sar",
|
|
|
|
|
|
"kind": "raster",
|
|
|
|
|
|
"tileUrl": sentinel1_tile_url(signed),
|
|
|
|
|
|
"opacity": 0.8,
|
|
|
|
|
|
"itemId": feat.get("id"),
|
|
|
|
|
|
"datetime": props.get("datetime") or feat.get("datetime"),
|
|
|
|
|
|
"polarization": polarization,
|
|
|
|
|
|
"attribution": SENTINEL1_ATTRIBUTION,
|
2026-08-29 20:40:27 -04:00
|
|
|
|
"bbox": feature_bbox(feat),
|
2026-08-29 01:01:09 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return await _ttl_get(key, float(SENTINEL1_TTL), _load)
|
2026-08-29 14:14:08 -04:00
|
|
|
|
# ── GPSJAM ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gpsjam_level(percent_bad: float) -> str:
|
|
|
|
|
|
"""Map a denoised bad-aircraft percentage to GPSJAM's three tiers."""
|
|
|
|
|
|
if percent_bad > 10.0:
|
|
|
|
|
|
return "high"
|
|
|
|
|
|
if percent_bad > 2.0:
|
|
|
|
|
|
return "medium"
|
|
|
|
|
|
return "low"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def gpsjam_csv_to_geojson(text: str) -> dict:
|
|
|
|
|
|
"""Convert a GPSJAM daily CSV to a world FeatureCollection of hex polygons.
|
|
|
|
|
|
|
|
|
|
|
|
Rows are ``hex,count_good_aircraft,count_bad_aircraft``. The interference
|
|
|
|
|
|
percentage uses GPSJAM's published denoise formula
|
|
|
|
|
|
``100 * (bad - 1) / (good + bad)``; hexes with zero bad aircraft are the
|
|
|
|
|
|
"normal" background and are dropped (the base map already shows nothing).
|
|
|
|
|
|
"""
|
|
|
|
|
|
import csv
|
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
|
|
import h3
|
|
|
|
|
|
|
|
|
|
|
|
features = []
|
|
|
|
|
|
for row in csv.DictReader(io.StringIO(text)):
|
|
|
|
|
|
hex_id = (row.get("hex") or "").strip()
|
|
|
|
|
|
if not hex_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
good = int(row.get("count_good_aircraft") or 0)
|
|
|
|
|
|
bad = int(row.get("count_bad_aircraft") or 0)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if bad < 1:
|
|
|
|
|
|
continue
|
|
|
|
|
|
denom = good + bad
|
|
|
|
|
|
percent = 100.0 * (bad - 1) / denom if denom > 0 else 0.0
|
|
|
|
|
|
try:
|
|
|
|
|
|
# h3 returns (lat, lng); GeoJSON needs (lng, lat) closed rings.
|
|
|
|
|
|
ring = [[lng, lat] for lat, lng in h3.cell_to_boundary(hex_id)]
|
|
|
|
|
|
except Exception: # malformed/unknown cell id — skip
|
|
|
|
|
|
continue
|
|
|
|
|
|
ring.append(ring[0])
|
|
|
|
|
|
features.append({
|
|
|
|
|
|
"type": "Feature",
|
|
|
|
|
|
"geometry": {"type": "Polygon", "coordinates": [ring]},
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"level": gpsjam_level(percent),
|
|
|
|
|
|
"percent_bad": round(percent, 2),
|
|
|
|
|
|
"good": good,
|
|
|
|
|
|
"bad": bad,
|
|
|
|
|
|
"hex": hex_id,
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
return {"type": "FeatureCollection", "features": features}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def fetch_gpsjam(date: str) -> dict:
|
|
|
|
|
|
"""Fetch + convert one GPSJAM daily hex layer (whole world, 1h TTL)."""
|
|
|
|
|
|
url = f"{GPSJAM_BASE}/data/{date}-h3_{GPSJAM_RES}.csv"
|
|
|
|
|
|
|
|
|
|
|
|
async def _load():
|
|
|
|
|
|
if _http is None:
|
|
|
|
|
|
async with httpx.AsyncClient(
|
|
|
|
|
|
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
|
|
|
|
|
|
) as client:
|
|
|
|
|
|
resp = await client.get(url)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return gpsjam_csv_to_geojson(resp.text)
|
|
|
|
|
|
resp = await _http.get(url)
|
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
|
return gpsjam_csv_to_geojson(resp.text)
|
|
|
|
|
|
|
|
|
|
|
|
return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load)
|