osint-dashboard/app/live_layers.py
Sirius DevOps fdd59052c5 perf: cache IEM SBW globally, clip alerts to viewport, slim properties
National storm-based warnings are fetched once (45s TTL) and clipped to
the quantized bbox. Popup fields only — NWS descriptions stay off the wire.
2026-08-27 21:16:16 -04:00

730 lines
25 KiB
Python

"""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
import math
import time
from datetime import datetime, timezone
from typing import Any, Awaitable, Callable
import httpx
from config import OSINT_USER_AGENT
MARKER_FIELDS = ("id", "lat", "lon", "heading", "speed", "label", "extra")
ADSB_LOL_BASE = "https://api.adsb.lol"
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"
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
_HTTP_TIMEOUT = 25.0
_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]] = {}
_key_locks: dict[str, asyncio.Lock] = {}
_key_locks_guard = asyncio.Lock()
_QUANT = 0.25 # degrees — pan jitter inside a cell reuses the TTL entry
# Last-known AIS positions (MMSI -> marker). Filled by ais_stream worker.
vessel_last_known: dict[str, dict] = {}
vessel_lock = asyncio.Lock()
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",
},
"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"},
}
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
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)))
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
_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}
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
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,
extra={
"hex": hex_id,
"reg": ac.get("r"),
"type": ac.get("t"),
"alt_baro": ac.get("alt_baro"),
"squawk": ac.get("squawk"),
"emergency": ac.get("emergency"),
"category": ac.get("category"),
"seen_pos": ac.get("seen_pos"),
"src": "adsb.lol",
},
))
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}
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()
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
extra["cog"] = pos.get("Cog")
extra["dest"] = extra.get("dest")
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"}
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
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]
lock = await _lock_for(key)
async with lock:
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
async def _get_json(url: str, params: dict | None = None) -> Any:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT, follow_redirects=True,
headers=_headers()) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
return resp.json()
async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
qminlon, qminlat, qmaxlon, qmaxlat = quantize_bbox(minlon, minlat, maxlon, maxlat)
lat, lon, radius = bbox_center_radius_nm(qminlon, qminlat, qmaxlon, qmaxlat)
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)
return filter_points_bbox(rows, minlon, minlat, maxlon, maxlat, limit)
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]
async def fetch_vessels(bbox: str | None, limit: int = DEFAULT_LIMIT) -> list[dict]:
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]
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
vessel_last_known[vid] = {
**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(),
}
def _wfigs_params(bbox: str | None) -> dict:
params = {
"where": "1=1",
"outSR": "4326",
"f": "geojson",
"resultRecordCount": 2000,
}
if bbox:
minlon, minlat, maxlon, maxlat = quantize_bbox(*parse_bbox(bbox))
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))
rows = await _ttl_get(f"wfigs:inc:{bbox_cell_key(bbox)}", 600.0, _load)
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)
fc = await _ttl_get(f"wfigs:per:{bbox_cell_key(bbox)}", 600.0, _load)
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."""
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": []}
async def _load():
nws_params: dict[str, str] = {"status": "actual"}
clip_box = None
if area:
nws_params["area"] = area.upper()
elif bbox:
clip_box = quantize_bbox(*parse_bbox(bbox))
nws_params["bbox"] = f"{clip_box[0]},{clip_box[1]},{clip_box[2]},{clip_box[3]}"
nws_fc: dict = {"features": []}
try:
nws_fc = await _get_json(NWS_ALERTS, nws_params)
except Exception:
nws_fc = {"features": []}
sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
features = []
for feat in nws_fc.get("features") or []:
if not isinstance(feat, dict):
continue
props = dict(feat.get("properties") or {})
props["source"] = "nws"
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 {})
props["source"] = "iem-sbw"
if "event" not in props:
props["event"] = props.get("ps") or "Storm-based warning"
features.append({**feat, "properties": slim_alert_properties(props)})
merged = {"type": "FeatureCollection", "features": features}
if clip_box:
merged = clip_fc_to_bbox(merged, *clip_box)
elif bbox:
merged = clip_fc_to_bbox(merged, *parse_bbox(bbox))
return merged
key = f"alerts:{area or ''}:{bbox_cell_key(bbox) if bbox else ''}"
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)