Compare commits

...

7 commits

Author SHA1 Message Date
Sirius DevOps
734c310d2c perf: abort stale overlay fetches, skip same-cell rebuilds, update markers in place
moveend aborts in-flight overlay JSON and skips work when the 0.01° cell
is unchanged. Live aircraft/trains/vessels diff-update by id. Camera popup
HTML is built on open. Alert/perimeter polygons paint on canvas.
2026-08-27 21:23:25 -04:00
Sirius DevOps
435f63e473 perf: shared httpx client, gzip, overlay cache-control
Reuse one TLS pool for overlay upstreams (8s/3s timeouts). Gzip JSON
over 1 KB. Aircraft/vessels Cache-Control max-age=5, alerts/perimeters 30.
Lifespan replaces deprecated on_event startup.
2026-08-27 21:21:19 -04:00
Sirius DevOps
8f89994201 perf: slim fires/cameras/events/news map payloads
Heatmap uses {lat,lon,i,c}. Camera list drops URLs (detail via GET
/api/cameras/{id}). Event blips skip body. News omits content unless
include_content=true.
2026-08-27 21:19:42 -04:00
Sirius DevOps
9c14b899ee perf: simplify WFIGS perimeter geometry at query time
Ask ArcGIS for 500 features, 5-decimal precision, and ~250m offset so
fire rings are outlines instead of tens of thousands of vertices.
2026-08-27 21:16:53 -04:00
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
Sirius DevOps
84532d505a perf: per-key overlay cache locks and quantized bbox keys
Nearby pans share a 0.25° cache cell. Slow NWS/WFIGS factories no longer
hold a process-wide lock that stalls trains/aircraft/radar fills.
2026-08-27 21:14:49 -04:00
Sirius DevOps
07638288a9 perf: faster map boot — zoom-gate heavy layers, static basemap, defer HUD, lazy HLS
World view no longer fetches cameras/NWS/WFIGS. Default Blue Marble so init
does not wait on GIBS times. News/summary/events load with their views.
hls.min.js loads only on the first HLS camera popup.
2026-08-27 21:12:58 -04:00
6 changed files with 629 additions and 131 deletions

View file

@ -49,7 +49,8 @@ CONUS = (-125.0, 24.0, -66.0, 50.0)
MAX_RADIUS_NM = 150 MAX_RADIUS_NM = 150
DEFAULT_LIMIT = 2000 DEFAULT_LIMIT = 2000
_HTTP_TIMEOUT = 25.0 _HTTP_TIMEOUT = httpx.Timeout(8.0, connect=3.0)
_http: httpx.AsyncClient | None = None
_COMPASS = { _COMPASS = {
"N": 0, "NE": 45, "E": 90, "SE": 135, "N": 0, "NE": 45, "E": 90, "SE": 135,
"S": 180, "SW": 225, "W": 270, "NW": 315, "S": 180, "SW": 225, "W": 270, "NW": 315,
@ -58,7 +59,9 @@ _COMPASS = {
} }
_cache: dict[str, tuple[float, Any]] = {} _cache: dict[str, tuple[float, Any]] = {}
_cache_lock = asyncio.Lock() _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. # Last-known AIS positions (MMSI -> marker). Filled by ais_stream worker.
vessel_last_known: dict[str, dict] = {} vessel_last_known: dict[str, dict] = {}
@ -116,6 +119,33 @@ def parse_bbox(bbox: str) -> tuple[float, float, float, float]:
return minlon, minlat, maxlon, maxlat 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( def bbox_center_radius_nm(
minlon: float, minlat: float, maxlon: float, maxlat: float, minlon: float, minlat: float, maxlon: float, maxlat: float,
) -> tuple[float, float, int]: ) -> tuple[float, float, int]:
@ -177,6 +207,78 @@ def filter_points_bbox(
return out 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: def _f(value: object) -> float | None:
if value is None or value == "": if value is None or value == "":
return None return None
@ -404,12 +506,22 @@ def _headers() -> dict[str, str]:
return {"User-Agent": OSINT_USER_AGENT, "Accept": "application/json"} 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: async def _ttl_get(key: str, ttl: float, factory: Callable[[], Awaitable[Any]]) -> Any:
now = time.monotonic() now = time.monotonic()
hit = _cache.get(key) hit = _cache.get(key)
if hit and now - hit[0] < ttl: if hit and now - hit[0] < ttl:
return hit[1] return hit[1]
async with _cache_lock: lock = await _lock_for(key)
async with lock:
hit = _cache.get(key) hit = _cache.get(key)
if hit and time.monotonic() - hit[0] < ttl: if hit and time.monotonic() - hit[0] < ttl:
return hit[1] return hit[1]
@ -418,17 +530,42 @@ async def _ttl_get(key: str, ttl: float, factory: Callable[[], Awaitable[Any]])
return value return value
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
async def _get_json(url: str, params: dict | None = None) -> Any: async def _get_json(url: str, params: dict | None = None) -> Any:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT, follow_redirects=True, if _http is None:
headers=_headers()) as client: async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT, follow_redirects=True, headers=_headers(),
) as client:
resp = await client.get(url, params=params) resp = await client.get(url, params=params)
resp.raise_for_status() resp.raise_for_status()
return resp.json() return resp.json()
resp = await _http.get(url, params=params)
resp.raise_for_status()
return resp.json()
async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]: async def fetch_aircraft(bbox: str, limit: int = DEFAULT_LIMIT) -> list[dict]:
minlon, minlat, maxlon, maxlat = parse_bbox(bbox) minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
lat, lon, radius = bbox_center_radius_nm(minlon, minlat, maxlon, maxlat) 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}" cache_key = f"adsb:{lat:.2f}:{lon:.2f}:{radius}"
async def _load(): async def _load():
@ -485,15 +622,17 @@ async def upsert_vessel(marker: dict) -> None:
} }
def _wfigs_params(bbox: str | None) -> dict: def _wfigs_params(bbox: str | None, *, offset_m: float = 250.0) -> dict:
params = { params = {
"where": "1=1", "where": "1=1",
"outSR": "4326", "outSR": "4326",
"f": "geojson", "f": "geojson",
"resultRecordCount": 2000, "resultRecordCount": 500,
"maxAllowableOffset": offset_m / 111_320.0, # metres → degrees
"geometryPrecision": 5,
} }
if bbox: if bbox:
minlon, minlat, maxlon, maxlat = parse_bbox(bbox) minlon, minlat, maxlon, maxlat = quantize_bbox(*parse_bbox(bbox))
params["geometry"] = f"{minlon},{minlat},{maxlon},{maxlat}" params["geometry"] = f"{minlon},{minlat},{maxlon},{maxlat}"
params["geometryType"] = "esriGeometryEnvelope" params["geometryType"] = "esriGeometryEnvelope"
params["inSR"] = "4326" params["inSR"] = "4326"
@ -511,7 +650,7 @@ async def fetch_fire_incidents(bbox: str | None, limit: int = DEFAULT_LIMIT) ->
async def _load(): async def _load():
return transform_wfigs_incidents(await _get_json(WFIGS_INCIDENTS, params)) return transform_wfigs_incidents(await _get_json(WFIGS_INCIDENTS, params))
rows = await _ttl_get(f"wfigs:inc:{bbox or 'all'}", 600.0, _load) rows = await _ttl_get(f"wfigs:inc:{bbox_cell_key(bbox)}", 600.0, _load)
return rows[:limit] return rows[:limit]
@ -525,7 +664,7 @@ async def fetch_fire_perimeters(bbox: str | None) -> dict:
async def _load(): async def _load():
return await _get_json(WFIGS_PERIMETERS, params) return await _get_json(WFIGS_PERIMETERS, params)
fc = await _ttl_get(f"wfigs:per:{bbox or 'all'}", 600.0, _load) fc = await _ttl_get(f"wfigs:per:{bbox_cell_key(bbox)}", 600.0, _load)
if not isinstance(fc, dict): if not isinstance(fc, dict):
return {"type": "FeatureCollection", "features": []} return {"type": "FeatureCollection", "features": []}
return fc return fc
@ -534,44 +673,50 @@ async def fetch_fire_perimeters(bbox: str | None) -> dict:
async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict: async def fetch_weather_alerts(area: str | None, bbox: str | None) -> dict:
"""Cached NWS active alerts + IEM storm-based warning polygons.""" """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(): async def _load():
nws_params: dict[str, str] = {"status": "actual"} nws_params: dict[str, str] = {"status": "actual"}
clip_box = None
if area: if area:
nws_params["area"] = area.upper() nws_params["area"] = area.upper()
elif bbox: elif bbox:
minlon, minlat, maxlon, maxlat = parse_bbox(bbox) clip_box = quantize_bbox(*parse_bbox(bbox))
nws_params["bbox"] = f"{minlon},{minlat},{maxlon},{maxlat}" nws_params["bbox"] = f"{clip_box[0]},{clip_box[1]},{clip_box[2]},{clip_box[3]}"
nws_fc: dict = {"features": []} nws_fc: dict = {"features": []}
sbw_fc: dict = {"features": []}
try: try:
nws_fc = await _get_json(NWS_ALERTS, nws_params) nws_fc = await _get_json(NWS_ALERTS, nws_params)
except Exception: except Exception:
nws_fc = {"features": []} nws_fc = {"features": []}
try: sbw_fc = await _ttl_get("iem:sbw", 45.0, _load_iem)
sbw_fc = await _get_json(IEM_SBW)
except Exception:
sbw_fc = {"features": []}
features = [] features = []
for feat in nws_fc.get("features") or []: for feat in nws_fc.get("features") or []:
props = feat.get("properties") or {} if not isinstance(feat, dict):
continue
props = dict(feat.get("properties") or {})
props["source"] = "nws" props["source"] = "nws"
feat["properties"] = props features.append({**feat, "properties": slim_alert_properties(props)})
features.append(feat) for feat in (sbw_fc or {}).get("features") or []:
for feat in sbw_fc.get("features") or []: if not isinstance(feat, dict):
props = feat.get("properties") or {} continue
props = dict(feat.get("properties") or {})
props["source"] = "iem-sbw" props["source"] = "iem-sbw"
# IEM uses `ps` (phenomenon) / `wfo`; map a display event.
if "event" not in props: if "event" not in props:
props["event"] = props.get("ps") or "Storm-based warning" props["event"] = props.get("ps") or "Storm-based warning"
feat["properties"] = props features.append({**feat, "properties": slim_alert_properties(props)})
if bbox: merged = {"type": "FeatureCollection", "features": features}
# Cheap reject: skip if no geometry; keep otherwise (polygons). if clip_box:
if not feat.get("geometry"): merged = clip_fc_to_bbox(merged, *clip_box)
continue elif bbox:
features.append(feat) merged = clip_fc_to_bbox(merged, *parse_bbox(bbox))
return {"type": "FeatureCollection", "features": features} return merged
key = f"alerts:{area or ''}:{bbox or ''}" key = f"alerts:{area or ''}:{bbox_cell_key(bbox) if bbox else ''}"
return await _ttl_get(key, 30.0, _load) return await _ttl_get(key, 30.0, _load)

View file

@ -14,6 +14,7 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
from contextlib import asynccontextmanager
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
@ -21,7 +22,8 @@ from uuid import UUID
import structlog import structlog
from fastapi import FastAPI, HTTPException, Query from fastapi import FastAPI, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import and_, func, or_, select, text from sqlalchemy import and_, func, or_, select, text
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -53,11 +55,30 @@ from live_layers import (
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = structlog.get_logger("osint.dashboard") logger = structlog.get_logger("osint.dashboard")
@asynccontextmanager
async def _lifespan(app: FastAPI):
await init_extensions()
from live_layers import close_http, init_http
await init_http()
from config import AISSTREAM_IN_APP
ais_task = None
if AISSTREAM_IN_APP:
from ais_stream import run_ais_worker
ais_task = asyncio.create_task(run_ais_worker())
yield
if ais_task is not None:
ais_task.cancel()
await close_http()
app = FastAPI( app = FastAPI(
title="OSINT Dashboard", title="OSINT Dashboard",
description="Real-time geospatial OSINT intelligence dashboard", description="Real-time geospatial OSINT intelligence dashboard",
version="0.1.0", version="0.1.0",
lifespan=_lifespan,
) )
app.add_middleware(GZipMiddleware, minimum_size=1024)
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
@ -69,17 +90,17 @@ def event_to_out(row: dict) -> EventOut:
return EventOut( return EventOut(
id=row["id"], id=row["id"],
source_type=row["source_type"], source_type=row["source_type"],
source_id=row["source_id"], source_id=row.get("source_id"),
title=row["title"], title=row.get("title"),
body=row["body"], body=row.get("body"),
url=row["url"], url=row.get("url"),
sentiment_score=row["sentiment_score"], sentiment_score=row.get("sentiment_score"),
sentiment_label=row["sentiment_label"], sentiment_label=row.get("sentiment_label"),
location_lat=row["location_lat"], location_lat=row.get("location_lat"),
location_lon=row["location_lon"], location_lon=row.get("location_lon"),
location_name=row["location_name"], location_name=row.get("location_name"),
entities=row["entities"], entities=row.get("entities"),
tags=row["tags"], tags=row.get("tags"),
ingested_at=row["ingested_at"], ingested_at=row["ingested_at"],
source_timestamp=row["source_timestamp"], source_timestamp=row["source_timestamp"],
) )
@ -131,20 +152,11 @@ async def health():
return {"status": "ok", "db_time": db_time.isoformat() if db_time else None} return {"status": "ok", "db_time": db_time.isoformat() if db_time else None}
# ── Startup ─────────────────────────────────────────────────────────────── def overlay_json(data, max_age: int) -> JSONResponse:
"""JSON overlay payload with a short browser/proxy TTL."""
@app.on_event("startup") resp = JSONResponse(content=data)
async def startup(): resp.headers["Cache-Control"] = f"public, max-age={max_age}"
"""Initialize PostGIS/TimescaleDB extensions on first connection. return resp
Schema migrations are applied by the container entrypoint (alembic upgrade
head) before uvicorn starts, so they don't run nested inside the event loop.
"""
await init_extensions()
from config import AISSTREAM_IN_APP
if AISSTREAM_IN_APP:
from ais_stream import run_ais_worker
asyncio.create_task(run_ais_worker())
# ── Feed Sources ────────────────────────────────────────────────────────── # ── Feed Sources ──────────────────────────────────────────────────────────
@ -218,6 +230,14 @@ async def list_events(
): ):
"""List recent ingested events.""" """List recent ingested events."""
async with async_session() as session: async with async_session() as session:
if has_coords:
stmt = select(
events.c.id, events.c.source_type, events.c.source_id,
events.c.title, events.c.url,
events.c.location_lat, events.c.location_lon, events.c.location_name,
events.c.ingested_at, events.c.source_timestamp,
).order_by(events.c.ingested_at.desc())
else:
stmt = select(events).order_by(events.c.ingested_at.desc()) stmt = select(events).order_by(events.c.ingested_at.desc())
if source_type: if source_type:
stmt = stmt.where(events.c.source_type == source_type.value) stmt = stmt.where(events.c.source_type == source_type.value)
@ -272,7 +292,29 @@ async def create_event(payload: EventCreate):
# ── Active Fires / Hotspots (NASA FIRMS) ───────────────────────────────── # ── Active Fires / Hotspots (NASA FIRMS) ─────────────────────────────────
@app.get("/api/fires", response_model=list[FireOut]) def fire_heat_row(r) -> dict:
"""Minimal FIRMS point for the heatmap overlay."""
return {
"lat": r["latitude"],
"lon": r["longitude"],
"i": r["brightness"],
"c": r["confidence"],
}
def camera_map_row(r) -> dict:
"""Minimal camera pin — URLs stay off the list payload."""
return {
"id": str(r["id"]),
"lat": r["location_lat"],
"lon": r["location_lon"],
"device_type": r["device_type"],
"discovery_source": r["discovery_source"],
"location_name": r["location_name"],
}
@app.get("/api/fires", response_model=None)
async def list_fires( async def list_fires(
bbox: str | None = Query( bbox: str | None = Query(
None, None,
@ -286,12 +328,16 @@ async def list_fires(
"(ISO 8601, e.g. '2026-08-24T12:00:00Z').", "(ISO 8601, e.g. '2026-08-24T12:00:00Z').",
), ),
limit: int = Query(2000, ge=1, le=10000), limit: int = Query(2000, ge=1, le=10000),
format: str = Query("full", description="'full' FireOut rows or 'heat' {lat,lon,i,c}"),
): ):
"""List stored FIRMS active fire/hotspot detections as JSON. """List stored FIRMS active fire/hotspot detections as JSON.
This is the data contract for the map's fire heatmap overlay: the frontend This is the data contract for the map's fire heatmap overlay: the frontend
calls `GET /api/fires?bbox=...&since=...` and renders the returned points. calls `GET /api/fires?bbox=...&since=...&format=heat` and renders the points.
""" """
fmt = (format or "full").lower()
if fmt not in ("full", "heat"):
raise HTTPException(422, "format must be 'full' or 'heat'")
async with async_session() as session: async with async_session() as session:
stmt = select(fires).order_by(fires.c.acq_time.desc()) stmt = select(fires).order_by(fires.c.acq_time.desc())
if since: if since:
@ -316,6 +362,8 @@ async def list_fires(
) )
stmt = stmt.limit(limit) stmt = stmt.limit(limit)
rows = (await session.execute(stmt)).mappings().all() rows = (await session.execute(stmt)).mappings().all()
if fmt == "heat":
return [fire_heat_row(r) for r in rows]
return [ return [
FireOut( FireOut(
latitude=r["latitude"], longitude=r["longitude"], latitude=r["latitude"], longitude=r["longitude"],
@ -798,7 +846,11 @@ async def list_cameras(
stmt = stmt.where(cam_table.c.discovery_source == source) stmt = stmt.where(cam_table.c.discovery_source == source)
rows = (await session.execute(stmt.limit(limit))).mappings().all() rows = (await session.execute(stmt.limit(limit))).mappings().all()
return [{ return [camera_map_row(r) for r in rows]
def camera_detail_row(r) -> dict:
return {
"id": str(r["id"]), "id": str(r["id"]),
"source_url": r["source_url"], "source_url": r["source_url"],
"snapshot_url": r["snapshot_url"], "snapshot_url": r["snapshot_url"],
@ -808,9 +860,23 @@ async def list_cameras(
"location_name": r["location_name"], "location_name": r["location_name"],
"vendor": r["vendor"], "vendor": r["vendor"],
"device_type": r["device_type"], "device_type": r["device_type"],
"first_seen": r["first_seen"].isoformat(), "first_seen": r["first_seen"].isoformat() if r["first_seen"] else None,
"last_seen": r["last_seen"].isoformat(), "last_seen": r["last_seen"].isoformat() if r["last_seen"] else None,
} for r in rows] }
@app.get("/api/cameras/{camera_id}")
async def get_camera(camera_id: UUID):
"""Full camera row for a map popup. List endpoint stays slim."""
from camera_models import cameras as cam_table
async with async_session() as session:
row = (await session.execute(
select(cam_table).where(cam_table.c.id == camera_id)
)).mappings().one_or_none()
if not row:
raise HTTPException(404, "Camera not found")
return camera_detail_row(row)
@app.get("/api/cameras/{camera_id}/snapshot") @app.get("/api/cameras/{camera_id}/snapshot")
@ -959,6 +1025,10 @@ async def list_news(
), ),
limit: int = Query(50, ge=1, le=500), limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
include_content: bool = Query(
False,
description="Include full article body. Default false — ticker/list only need title/url.",
),
): ):
"""Most recent scraped news articles (newest first).""" """Most recent scraped news articles (newest first)."""
async with async_session() as session: async with async_session() as session:
@ -974,7 +1044,7 @@ async def list_news(
return [ return [
NewsArticleOut( NewsArticleOut(
id=r["id"], title=r["title"], url=r["url"], id=r["id"], title=r["title"], url=r["url"],
content=r["content"], domain=r["domain"], content=r["content"] if include_content else None, domain=r["domain"],
timestamp=r["timestamp"], timestamp=r["timestamp"],
) )
for r in rows for r in rows
@ -1056,7 +1126,7 @@ def _upstream_or_502(exc: Exception, name: str):
async def map_radar(): async def map_radar():
"""RainViewer frame list + IEM NEXRAD tile template. Browser fetches tiles.""" """RainViewer frame list + IEM NEXRAD tile template. Browser fetches tiles."""
try: try:
return await fetch_radar_meta() return overlay_json(await fetch_radar_meta(), 60)
except Exception as exc: except Exception as exc:
_upstream_or_502(exc, "radar") _upstream_or_502(exc, "radar")
@ -1069,7 +1139,7 @@ async def list_aircraft(
"""Viewport ADS-B last-known (ADSB.lol). Requires bbox; radius clamped ≤ 150 nm.""" """Viewport ADS-B last-known (ADSB.lol). Requires bbox; radius clamped ≤ 150 nm."""
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_aircraft(bbox, limit) return overlay_json(await fetch_aircraft(bbox, limit), 5)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) from exc raise HTTPException(422, str(exc)) from exc
except Exception as exc: except Exception as exc:
@ -1085,7 +1155,7 @@ async def list_trains(
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_trains(bbox, limit) return overlay_json(await fetch_trains(bbox, limit), 20)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) from exc raise HTTPException(422, str(exc)) from exc
except Exception as exc: except Exception as exc:
@ -1101,7 +1171,7 @@ async def list_vessels(
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_vessels(bbox, limit) return overlay_json(await fetch_vessels(bbox, limit), 5)
except ValueError as exc: except ValueError as exc:
raise HTTPException(422, str(exc)) from exc raise HTTPException(422, str(exc)) from exc
@ -1115,7 +1185,7 @@ async def list_fire_incidents(
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_fire_incidents(bbox, limit) return overlay_json(await fetch_fire_incidents(bbox, limit), 30)
except Exception as exc: except Exception as exc:
_upstream_or_502(exc, "fire-incidents") _upstream_or_502(exc, "fire-incidents")
@ -1126,7 +1196,7 @@ async def list_fire_perimeters(bbox: str | None = Query(None)):
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_fire_perimeters(bbox) return overlay_json(await fetch_fire_perimeters(bbox), 30)
except Exception as exc: except Exception as exc:
_upstream_or_502(exc, "fire-perimeters") _upstream_or_502(exc, "fire-perimeters")
@ -1144,7 +1214,7 @@ async def list_weather_alerts(
if bbox: if bbox:
_parse_bbox_query(bbox) _parse_bbox_query(bbox)
try: try:
return await fetch_weather_alerts(area, bbox) return overlay_json(await fetch_weather_alerts(area, bbox), 30)
except Exception as exc: except Exception as exc:
_upstream_or_502(exc, "weather-alerts") _upstream_or_502(exc, "weather-alerts")
@ -1153,7 +1223,7 @@ async def list_weather_alerts(
async def list_storms(): async def list_storms():
"""NHC active tropical cyclones.""" """NHC active tropical cyclones."""
try: try:
return await fetch_storms() return overlay_json(await fetch_storms(), 20)
except Exception as exc: except Exception as exc:
_upstream_or_502(exc, "storms") _upstream_or_502(exc, "storms")

View file

@ -1026,10 +1026,22 @@
<script src="/static/vendor/leaflet/leaflet.js"></script> <script src="/static/vendor/leaflet/leaflet.js"></script>
<script src="/static/vendor/leaflet/leaflet.markercluster.js"></script> <script src="/static/vendor/leaflet/leaflet.markercluster.js"></script>
<script src="/static/vendor/leaflet/leaflet.heat.js"></script> <script src="/static/vendor/leaflet/leaflet.heat.js"></script>
<script src="/static/vendor/hls/hls.min.js"></script>
<script> <script>
const API = ''; const API = '';
function loadHls() {
if (window.Hls) return Promise.resolve();
if (window._hlsLoading) return window._hlsLoading;
window._hlsLoading = new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/static/vendor/hls/hls.min.js';
s.onload = res;
s.onerror = rej;
document.head.appendChild(s);
});
return window._hlsLoading;
}
/* ═══════════════ BOOT SPLASH ═══════════════ */ /* ═══════════════ BOOT SPLASH ═══════════════ */
(function () { (function () {
const boot = document.getElementById('boot'); const boot = document.getElementById('boot');
@ -1136,7 +1148,8 @@ function showView(name) {
} }
} }
if (name === 'news') loadNews(false); if (name === 'news') loadNews(false);
if (name === 'settings') renderSysInfo(); if (name === 'events') { loadSummary(); loadEvents(); }
if (name === 'settings') { loadSummary(); renderSysInfo(); }
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
@ -1637,6 +1650,16 @@ let vesselsGroup = null, vesselsOn = false;
let stormsGroup = null, stormsOn = true; let stormsGroup = null, stormsOn = true;
let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0}; let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0};
let moveDebounce = null; let moveDebounce = null;
let overlayAbort = null;
let lastCell = '';
function bboxCell() {
if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
}
function overlayFetch(url) {
return fetch(url, overlayAbort ? { signal: overlayAbort.signal } : {});
}
function isAbort(e) { return e && e.name === 'AbortError'; }
const pointCanvas = () => L.canvas({ padding: 0.5 }); const pointCanvas = () => L.canvas({ padding: 0.5 });
async function initMap() { async function initMap() {
@ -1651,7 +1674,10 @@ async function initMap() {
const sel = document.getElementById('map-layer'); const sel = document.getElementById('map-layer');
sel.innerHTML = mapLayers.map(l => sel.innerHTML = mapLayers.map(l =>
`<option value="${l.id}">${l.title}</option>`).join(''); `<option value="${l.id}">${l.title}</option>`).join('');
const preferred = mapLayers.find(l => l.has_time) || mapLayers[0]; const preferred =
mapLayers.find(l => l.id === 'BlueMarble_ShadedRelief_Bathymetry')
|| mapLayers.find(l => !l.has_time)
|| mapLayers[0];
if (preferred) sel.value = preferred.id; if (preferred) sel.value = preferred.id;
map = L.map('map', { map = L.map('map', {
center: [25, 10], zoom: 2, center: [25, 10], zoom: 2,
@ -1676,6 +1702,7 @@ async function initMap() {
img.alt = 'camera preview'; img.alt = 'camera preview';
v.replaceWith(img); v.replaceWith(img);
}; };
const startHls = () => {
if (window.Hls && Hls.isSupported()) { if (window.Hls && Hls.isSupported()) {
activeHls = new Hls({ enableWorker: true, lowLatencyMode: true }); activeHls = new Hls({ enableWorker: true, lowLatencyMode: true });
activeHls.loadSource(v.dataset.hls); activeHls.loadSource(v.dataset.hls);
@ -1690,6 +1717,9 @@ async function initMap() {
} else { } else {
fallback(); fallback();
} }
};
if (window.Hls) startHls();
else loadHls().then(startHls).catch(fallback);
}); });
map.on('popupclose', () => { map.on('popupclose', () => {
if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; } if (activeHls) { try { activeHls.destroy(); } catch (_) {} activeHls = null; }
@ -1713,13 +1743,19 @@ async function initMap() {
if (camPopupOpen) return; // only the popup's own autopan now if (camPopupOpen) return; // only the popup's own autopan now
if (moveDebounce) clearTimeout(moveDebounce); if (moveDebounce) clearTimeout(moveDebounce);
moveDebounce = setTimeout(() => { moveDebounce = setTimeout(() => {
const cell = bboxCell();
if (cell === lastCell) return;
lastCell = cell;
if (overlayAbort) overlayAbort.abort();
overlayAbort = new AbortController();
if (firesOn) loadFires(); if (firesOn) loadFires();
if (camsOn) loadCams(); if (camsOn) loadCams();
if (blipsOn) loadBlips(); if (blipsOn) loadBlips();
refreshLiveOverlays(); refreshLiveOverlays();
}, 300); }, 300);
}); });
await mapLayerChanged(); // Static Blue Marble has no time-domain fetch — don't block overlays on GIBS.
mapLayerChanged();
// Overlays default ON (checkboxes in the layer panel + saved settings) // Overlays default ON (checkboxes in the layer panel + saved settings)
applyMapSettings(readMapPrefs(), false); applyMapSettings(readMapPrefs(), false);
firesOn = document.getElementById('lp-fires-on').checked; firesOn = document.getElementById('lp-fires-on').checked;
@ -1900,10 +1936,12 @@ function setBaseOpacity(v) {
/* ── FIRMS fire heatmap ── */ /* ── FIRMS fire heatmap ── */
function firesIntensity(f) { function firesIntensity(f) {
const confidence = f.confidence || f.c;
const brightness = f.brightness != null ? f.brightness : f.i;
if (firesColor === 'confidence') { if (firesColor === 'confidence') {
return f.confidence === 'h' ? 1.0 : f.confidence === 'l' ? 0.35 : 0.6; return confidence === 'h' ? 1.0 : confidence === 'l' ? 0.35 : 0.6;
} }
const b = f.brightness || 300; const b = brightness || 300;
return Math.max(0.05, Math.min(1, (b - 290) / 110)); return Math.max(0.05, Math.min(1, (b - 290) / 110));
} }
function firesGradient() { function firesGradient() {
@ -1951,14 +1989,14 @@ async function loadFires() {
if (!map) return; if (!map) return;
const req = ++fireReq; const req = ++fireReq;
try { try {
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000`; let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000&format=heat`;
const since = sinceToISO(firesSince); const since = sinceToISO(firesSince);
if (since) url += `&since=${encodeURIComponent(since)}`; if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await fetch(url); const r = await overlayFetch(url);
const fires = await r.json(); const fires = await r.json();
if (req !== fireReq) return; // superseded by a newer pan/zoom if (req !== fireReq) return; // superseded by a newer pan/zoom
if (firesHeat) map.removeLayer(firesHeat); if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.latitude, f.longitude, firesIntensity(f)]); const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]);
firesHeat = L.heatLayer(pts, { firesHeat = L.heatLayer(pts, {
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2, radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
gradient: firesGradient(), gradient: firesGradient(),
@ -1971,6 +2009,7 @@ async function loadFires() {
`${fires.length.toLocaleString()} fire hotspots in view` + `${fires.length.toLocaleString()} fire hotspots in view` +
(since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : ''); (since ? ` · since ${since.slice(0,16).replace('T',' ')}Z` : '');
} catch(e) { } catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`; document.getElementById('map-hint').textContent = `Fires load failed: ${e.message || e}`;
console.error('Fires load failed', e); console.error('Fires load failed', e);
} }
@ -2036,9 +2075,16 @@ function camThumb(c) {
} }
async function loadCams() { async function loadCams() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup);
markZoom('lp-cams-count');
hudCamsCount = null;
syncHud();
return;
}
const req = ++camReq; const req = ++camReq;
try { try {
const r = await fetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=5000`); const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
const cams = await r.json(); const cams = await r.json();
if (req !== camReq) return; // superseded by a newer pan/zoom if (req !== camReq) return; // superseded by a newer pan/zoom
if (camsGroup) map.removeLayer(camsGroup); if (camsGroup) map.removeLayer(camsGroup);
@ -2062,7 +2108,7 @@ async function loadCams() {
iconSize: [12, 12], iconAnchor: [6, 6], iconSize: [12, 12], iconAnchor: [6, 6],
}); });
camsGroup.addLayer(L.marker([c.lat, c.lon], { icon }) camsGroup.addLayer(L.marker([c.lat, c.lon], { icon })
.bindPopup(`<div class="cam-pop">` + .bindPopup(() => `<div class="cam-pop">` +
`<b>${esc(c.location_name || 'Open camera')}</b>` + `<b>${esc(c.location_name || 'Open camera')}</b>` +
`${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` + `${c.id ? camThumb(c) : '<div class="thumb placeholder">no snapshot</div>'}` +
`<table>` + `<table>` +
@ -2083,6 +2129,7 @@ async function loadCams() {
document.getElementById('map-hint').textContent = document.getElementById('map-hint').textContent =
`${cams.length.toLocaleString()} open cameras in view`; `${cams.length.toLocaleString()} open cameras in view`;
} catch(e) { } catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`; document.getElementById('map-hint').textContent = `Cameras load failed: ${e.message || e}`;
console.error('Cameras load failed', e); console.error('Cameras load failed', e);
} }
@ -2118,7 +2165,7 @@ async function loadBlips() {
let url = `${API}/api/events?bbox=${currentBBox()}&has_coords=true&limit=500`; let url = `${API}/api/events?bbox=${currentBBox()}&has_coords=true&limit=500`;
const since = sinceToISO(blipsSince); const since = sinceToISO(blipsSince);
if (since) url += `&since=${encodeURIComponent(since)}`; if (since) url += `&since=${encodeURIComponent(since)}`;
const r = await fetch(url); const r = await overlayFetch(url);
const evs = (await r.json()).filter(ev => ev.source_type !== 'camera'); const evs = (await r.json()).filter(ev => ev.source_type !== 'camera');
if (req !== blipReq) return; // superseded by a newer pan/zoom if (req !== blipReq) return; // superseded by a newer pan/zoom
if (blipsGroup) map.removeLayer(blipsGroup); if (blipsGroup) map.removeLayer(blipsGroup);
@ -2148,6 +2195,7 @@ async function loadBlips() {
`${evs.length.toLocaleString()} event blips in view`; `${evs.length.toLocaleString()} event blips in view`;
} }
} catch(e) { } catch(e) {
if (isAbort(e)) return;
document.getElementById('map-hint').textContent = `Blips load failed: ${e.message || e}`; document.getElementById('map-hint').textContent = `Blips load failed: ${e.message || e}`;
console.error('Blips load failed', e); console.error('Blips load failed', e);
} }
@ -2175,6 +2223,14 @@ function dropLayer(ref) {
if (ref && map && map.hasLayer(ref)) map.removeLayer(ref); if (ref && map && map.hasLayer(ref)) map.removeLayer(ref);
return null; return null;
} }
/* Polygon/camera layers hitch at world scale. Same idea as aircraft's zoom<=3
skip: do not fetch or render these until the user is zoomed in. */
const HEAVY_MIN_ZOOM = 4;
function tooZoomedOut() { return !map || map.getZoom() <= HEAVY_MIN_ZOOM; }
function markZoom(id) {
const el = document.getElementById(id);
if (el) el.textContent = 'zoom';
}
function intersectsConus() { function intersectsConus() {
if (!map) return false; if (!map) return false;
const b = map.getBounds(); const b = map.getBounds();
@ -2251,34 +2307,70 @@ function feedIcon(feed, color, heading) {
} }
return ic; return ic;
} }
function renderPoints(existing, points, colorFn, cluster, feed) { function makePointMarker(p, colorFn, feed, renderer) {
if (existing) map.removeLayer(existing); if (p.lat == null || p.lon == null) return null;
const zoom = map.getZoom();
const useCluster = cluster && (zoom < 7 || points.length > 200);
const group = useCluster
? L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, spiderfyOnMaxZoom: true, chunkedLoading: true })
: L.layerGroup();
const renderer = pointCanvas();
points.forEach(p => {
if (p.lat == null || p.lon == null) return;
const col = sanitizeColor(colorFn(p), '#35e0ff'); const col = sanitizeColor(colorFn(p), '#35e0ff');
if (feed) { if (feed) {
const heading = Number(p.heading); const heading = Number(p.heading);
const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading); const icon = feedIcon(feed, col, Number.isNaN(heading) ? null : heading);
const m = L.marker([p.lat, p.lon], { icon }).bindPopup(pointPopup(p)); return L.marker([p.lat, p.lon], { icon }).bindPopup(() => pointPopup(p));
group.addLayer(m); }
} else { return L.circleMarker([p.lat, p.lon], {
const heading = Number(p.heading);
const m = L.circleMarker([p.lat, p.lon], {
radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1, radius: 5, color: col, fillColor: col, fillOpacity: 0.9, weight: 1,
renderer, renderer,
}).bindPopup(pointPopup(p)); }).bindPopup(() => pointPopup(p));
if (!Number.isNaN(heading)) m.setStyle({ className: 'hdg' }); }
function renderPoints(existing, points, colorFn, cluster, feed) {
const zoom = map.getZoom();
const useCluster = cluster && (zoom < 7 || points.length > 200);
const canReuse = existing && map.hasLayer(existing)
&& !!existing._osintCluster === !!useCluster
&& existing._osintById;
if (!canReuse) {
if (existing) map.removeLayer(existing);
const group = useCluster
? L.markerClusterGroup({ maxClusterRadius: 48, showCoverageOnHover: false, spiderfyOnMaxZoom: true, chunkedLoading: true })
: L.layerGroup();
group._osintCluster = !!useCluster;
group._osintById = new Map();
const renderer = pointCanvas();
points.forEach(p => {
const m = makePointMarker(p, colorFn, feed, renderer);
if (!m) return;
group.addLayer(m); group.addLayer(m);
} if (p.id != null) group._osintById.set(String(p.id), m);
}); });
group.addTo(map); group.addTo(map);
return group; return group;
}
const group = existing;
const byId = group._osintById;
const next = new Set();
const renderer = pointCanvas();
points.forEach(p => {
if (p.lat == null || p.lon == null || p.id == null) return;
const id = String(p.id);
next.add(id);
const col = sanitizeColor(colorFn(p), '#35e0ff');
const m = byId.get(id);
if (m) {
m.setLatLng([p.lat, p.lon]);
if (feed) m.setIcon(feedIcon(feed, col, p.heading));
else if (m.setStyle) m.setStyle({ color: col, fillColor: col });
} else {
const nm = makePointMarker(p, colorFn, feed, renderer);
if (!nm) return;
group.addLayer(nm);
byId.set(id, nm);
}
});
byId.forEach((m, id) => {
if (!next.has(id)) {
group.removeLayer(m);
byId.delete(id);
}
});
return group;
} }
async function toggleRadar() { async function toggleRadar() {
radarOn = document.getElementById('lp-radar-on').checked; radarOn = document.getElementById('lp-radar-on').checked;
@ -2294,7 +2386,7 @@ async function loadRadar() {
if (!map) return; if (!map) return;
try { try {
if (!radarMeta) { if (!radarMeta) {
const r = await fetch(`${API}/api/map/radar`); const r = await overlayFetch(`${API}/api/map/radar`);
radarMeta = await r.json(); radarMeta = await r.json();
addExtraAttrib('<a href="https://www.rainviewer.com/api.html">Weather data by RainViewer</a>'); addExtraAttrib('<a href="https://www.rainviewer.com/api.html">Weather data by RainViewer</a>');
addExtraAttrib('Iowa Environmental Mesonet'); addExtraAttrib('Iowa Environmental Mesonet');
@ -2313,6 +2405,7 @@ async function loadRadar() {
radarLayer = L.tileLayer(url, { opacity: radarOpacity, maxZoom: 12, maxNativeZoom: useIem ? 18 : 7, attribution: '' }).addTo(map); radarLayer = L.tileLayer(url, { opacity: radarOpacity, maxZoom: 12, maxNativeZoom: useIem ? 18 : 7, attribution: '' }).addTo(map);
} }
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Radar load failed', e); console.error('Radar load failed', e);
document.getElementById('lp-radar-count').textContent = 'err'; document.getElementById('lp-radar-count').textContent = 'err';
} }
@ -2338,14 +2431,20 @@ async function toggleWxAlerts() {
} }
async function loadWxAlerts() { async function loadWxAlerts() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup);
markZoom('lp-alerts-count');
return;
}
const req = ++overlayReq.alerts; const req = ++overlayReq.alerts;
try { try {
const r = await fetch(`${API}/api/weather-alerts?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/weather-alerts?bbox=${currentBBox()}`);
const fc = await r.json(); const fc = await r.json();
if (req !== overlayReq.alerts) return; if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup); wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || []; const feats = fc.features || [];
wxAlertsGroup = L.geoJSON(fc, { wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({ style: (f) => ({
color: severityColor((f.properties || {}).severity), color: severityColor((f.properties || {}).severity),
weight: 2, fillOpacity: 0.18, weight: 2, fillOpacity: 0.18,
@ -2358,6 +2457,7 @@ async function loadWxAlerts() {
document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString(); document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString();
addExtraAttrib('NWS / IEM storm-based warnings'); addExtraAttrib('NWS / IEM storm-based warnings');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Alerts load failed', e); console.error('Alerts load failed', e);
document.getElementById('lp-alerts-count').textContent = 'err'; document.getElementById('lp-alerts-count').textContent = 'err';
} }
@ -2369,14 +2469,20 @@ async function togglePerimeters() {
} }
async function loadPerimeters() { async function loadPerimeters() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) {
perimGroup = dropLayer(perimGroup);
markZoom('lp-perim-count');
return;
}
const req = ++overlayReq.perim; const req = ++overlayReq.perim;
try { try {
const r = await fetch(`${API}/api/fire-perimeters?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/fire-perimeters?bbox=${currentBBox()}`);
const fc = await r.json(); const fc = await r.json();
if (req !== overlayReq.perim) return; if (req !== overlayReq.perim) return;
perimGroup = dropLayer(perimGroup); perimGroup = dropLayer(perimGroup);
const feats = fc.features || []; const feats = fc.features || [];
perimGroup = L.geoJSON(fc, { perimGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => { style: (f) => {
const acres = Number((f.properties || {}).poly_GISAcres || (f.properties || {}).attr_IncidentSize || 0); const acres = Number((f.properties || {}).poly_GISAcres || (f.properties || {}).attr_IncidentSize || 0);
return { color: acres > 10000 ? '#ef4444' : '#fb923c', weight: 2, fillOpacity: 0.25, fillColor: '#fb923c' }; return { color: acres > 10000 ? '#ef4444' : '#fb923c', weight: 2, fillOpacity: 0.25, fillColor: '#fb923c' };
@ -2391,6 +2497,7 @@ async function loadPerimeters() {
document.getElementById('lp-perim-count').textContent = feats.length.toLocaleString(); document.getElementById('lp-perim-count').textContent = feats.length.toLocaleString();
addExtraAttrib('NIFC WFIGS'); addExtraAttrib('NIFC WFIGS');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Perimeters load failed', e); console.error('Perimeters load failed', e);
document.getElementById('lp-perim-count').textContent = 'err'; document.getElementById('lp-perim-count').textContent = 'err';
} }
@ -2402,15 +2509,21 @@ async function toggleIncidents() {
} }
async function loadIncidents() { async function loadIncidents() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) {
incidentsGroup = dropLayer(incidentsGroup);
markZoom('lp-incidents-count');
return;
}
const req = ++overlayReq.incidents; const req = ++overlayReq.incidents;
try { try {
const r = await fetch(`${API}/api/fire-incidents?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/fire-incidents?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.incidents) return; if (req !== overlayReq.incidents) return;
incidentsGroup = renderPoints(incidentsGroup, pts, () => '#ef4444', false); incidentsGroup = renderPoints(incidentsGroup, pts, () => '#ef4444', false);
document.getElementById('lp-incidents-count').textContent = pts.length.toLocaleString(); document.getElementById('lp-incidents-count').textContent = pts.length.toLocaleString();
addExtraAttrib('NIFC WFIGS'); addExtraAttrib('NIFC WFIGS');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Incidents load failed', e); console.error('Incidents load failed', e);
document.getElementById('lp-incidents-count').textContent = 'err'; document.getElementById('lp-incidents-count').textContent = 'err';
} }
@ -2428,13 +2541,14 @@ async function loadAircraft() {
} }
const req = ++overlayReq.ac; const req = ++overlayReq.ac;
try { try {
const r = await fetch(`${API}/api/aircraft?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/aircraft?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.ac) return; if (req !== overlayReq.ac) return;
acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true, 'ac'); acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => altColor((p.extra || {}).alt_baro), true, 'ac');
document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL'); addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Aircraft load failed', e); console.error('Aircraft load failed', e);
document.getElementById('lp-ac-count').textContent = 'err'; document.getElementById('lp-ac-count').textContent = 'err';
} }
@ -2448,13 +2562,14 @@ async function loadTrains() {
if (!map) return; if (!map) return;
const req = ++overlayReq.trains; const req = ++overlayReq.trains;
try { try {
const r = await fetch(`${API}/api/trains?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/trains?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.trains) return; if (req !== overlayReq.trains) return;
trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train'); trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train');
document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>'); addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Trains load failed', e); console.error('Trains load failed', e);
document.getElementById('lp-trains-count').textContent = 'err'; document.getElementById('lp-trains-count').textContent = 'err';
} }
@ -2472,7 +2587,7 @@ async function loadVessels() {
} }
const req = ++overlayReq.vessels; const req = ++overlayReq.vessels;
try { try {
const r = await fetch(`${API}/api/vessels?bbox=${currentBBox()}`); const r = await overlayFetch(`${API}/api/vessels?bbox=${currentBBox()}`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.vessels) return; if (req !== overlayReq.vessels) return;
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => { vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
@ -2482,6 +2597,7 @@ async function loadVessels() {
document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('AISStream'); addExtraAttrib('AISStream');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Vessels load failed', e); console.error('Vessels load failed', e);
document.getElementById('lp-vessels-count').textContent = 'err'; document.getElementById('lp-vessels-count').textContent = 'err';
} }
@ -2495,13 +2611,14 @@ async function loadStorms() {
if (!map) return; if (!map) return;
const req = ++overlayReq.storms; const req = ++overlayReq.storms;
try { try {
const r = await fetch(`${API}/api/storms`); const r = await overlayFetch(`${API}/api/storms`);
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.storms) return; if (req !== overlayReq.storms) return;
stormsGroup = renderPoints(stormsGroup, Array.isArray(pts) ? pts : [], () => '#f472b6', false); stormsGroup = renderPoints(stormsGroup, Array.isArray(pts) ? pts : [], () => '#f472b6', false);
document.getElementById('lp-storms-count').textContent = (pts.length || 0).toLocaleString(); document.getElementById('lp-storms-count').textContent = (pts.length || 0).toLocaleString();
addExtraAttrib('NHC'); addExtraAttrib('NHC');
} catch (e) { } catch (e) {
if (isAbort(e)) return;
console.error('Storms load failed', e); console.error('Storms load failed', e);
document.getElementById('lp-storms-count').textContent = 'err'; document.getElementById('lp-storms-count').textContent = 'err';
} }
@ -2511,9 +2628,9 @@ async function loadStorms() {
initNav(); initNav();
initSettings(); initSettings();
initMarketTicker(); initMarketTicker();
loadSummary(); loadEvents(); loadNews(true); checkHealth(); checkHealth();
setInterval(() => { loadSummary(); loadEvents(); checkHealth(); }, 30000);
initMap(); initMap();
setInterval(checkHealth, 30000);
</script> </script>
</body> </body>
</html> </html>

View file

@ -36,3 +36,4 @@ def test_vessels_empty_without_ais_key():
resp = asyncio.run(_get("/api/vessels")) resp = asyncio.run(_get("/api/vessels"))
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json() == [] assert resp.json() == []
assert "max-age" in (resp.headers.get("cache-control") or "").lower()

View file

@ -3,15 +3,21 @@
from live_layers import ( from live_layers import (
MARKER_FIELDS, MARKER_FIELDS,
bbox_center_radius_nm, bbox_center_radius_nm,
clip_fc_to_bbox,
filter_points_bbox, filter_points_bbox,
parse_bbox, parse_bbox,
quantize_bbox,
rainviewer_tile_url, rainviewer_tile_url,
slim_alert_properties,
to_marker, to_marker,
transform_adsb_lol, transform_adsb_lol,
transform_ais_frame, transform_ais_frame,
transform_amtraker, transform_amtraker,
transform_nhc_storms, transform_nhc_storms,
transform_wfigs_incidents, transform_wfigs_incidents,
_cache,
_ttl_get,
_wfigs_params,
) )
from camera_scraper import parse_caltrans_json from camera_scraper import parse_caltrans_json
@ -244,3 +250,115 @@ def test_parse_caltrans_skips_oos_and_maps_jpeg_hls():
assert "I-80" in cam["location_name"] assert "I-80" in cam["location_name"]
assert "rtsp://" not in cam["source_url"].lower() assert "rtsp://" not in cam["source_url"].lower()
assert "rtsp://" not in cam["snapshot_url"].lower() assert "rtsp://" not in cam["snapshot_url"].lower()
def test_quantize_bbox_stable_under_jitter():
a = quantize_bbox(*parse_bbox("-78.7912,35.7711,-78.6101,35.9102"))
b = quantize_bbox(*parse_bbox("-78.7900,35.7700,-78.6110,35.9090"))
assert a == b
minlon, minlat, maxlon, maxlat = a
assert minlon <= -78.7912
assert minlat <= 35.7700
assert maxlon >= -78.6101
assert maxlat >= 35.9102
def test_ttl_get_does_not_block_other_keys():
import asyncio
_cache.clear()
order = []
async def slow():
order.append("slow-start")
await asyncio.sleep(0.2)
order.append("slow-end")
return "S"
async def fast():
order.append("fast")
return "F"
async def run():
t1 = asyncio.create_task(_ttl_get("slow", 5, slow))
await asyncio.sleep(0.01)
t2 = asyncio.create_task(_ttl_get("fast", 5, fast))
await asyncio.gather(t1, t2)
asyncio.run(run())
assert order.index("fast") < order.index("slow-end")
assert _cache["slow"][1] == "S"
assert _cache["fast"][1] == "F"
_cache.clear()
def test_clip_fc_to_bbox_drops_far_features_and_empty_geometry():
fc = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"event": "near"},
"geometry": {"type": "Point", "coordinates": [-78.7, 35.8]},
},
{
"type": "Feature",
"properties": {"event": "far"},
"geometry": {"type": "Point", "coordinates": [-120.0, 45.0]},
},
{
"type": "Feature",
"properties": {"event": "nogeom"},
"geometry": None,
},
{
"type": "Feature",
"properties": {"event": "poly-overlap"},
"geometry": {
"type": "Polygon",
"coordinates": [[
[-79.0, 35.0], [-78.0, 35.0], [-78.0, 36.0],
[-79.0, 36.0], [-79.0, 35.0],
]],
},
},
],
}
clipped = clip_fc_to_bbox(fc, -79.0, 35.5, -78.0, 36.0)
events = [f["properties"]["event"] for f in clipped["features"]]
assert events == ["near", "poly-overlap"]
def test_slim_alert_properties_keeps_popup_fields_only():
fat = {
"event": "Tornado Warning",
"severity": "Extreme",
"headline": "TORNADO WARNING",
"areaDesc": "Wake",
"wfo": "RAH",
"source": "nws",
"parameters": {"WIND": ["70"]},
"description": "A long narrative " * 40,
"instruction": "Take shelter.",
"geocode": {"SAME": ["037183"]},
}
slim = slim_alert_properties(fat)
assert slim == {
"event": "Tornado Warning",
"severity": "Extreme",
"headline": "TORNADO WARNING",
"areaDesc": "Wake",
"wfo": "RAH",
"source": "nws",
}
def test_wfigs_params_requests_simplified_geometry():
params = _wfigs_params("-84.5,33.8,-75.4,36.6")
assert "maxAllowableOffset" in params
assert float(params["maxAllowableOffset"]) > 0
assert params["geometryPrecision"] == 5
assert int(params["resultRecordCount"]) <= 500
# Envelope is the quantized cell, not the raw pan box.
geom = params["geometry"]
assert geom != "-84.5,33.8,-75.4,36.6"

View file

@ -0,0 +1,47 @@
"""Slim map-payload helpers (no DB)."""
from uuid import uuid4
from main import camera_map_row, fire_heat_row
def test_fire_heat_row_is_tiny():
row = {
"latitude": 35.0,
"longitude": -78.0,
"brightness": 340.1,
"confidence": "h",
"satellite": "N21",
"acq_time": "2026-08-27T00:00:00Z",
"instrument": "VIIRS",
"frp": 12.4,
}
out = fire_heat_row(row)
assert out == {"lat": 35.0, "lon": -78.0, "i": 340.1, "c": "h"}
assert "satellite" not in out
assert "frp" not in out
def test_camera_map_row_omits_urls():
cid = uuid4()
row = {
"id": cid,
"location_lat": 37.8,
"location_lon": -122.4,
"device_type": "hls",
"discovery_source": "caltrans",
"location_name": "I-80 WB",
"source_url": "https://example.invalid/playlist.m3u8",
"snapshot_url": "https://example.invalid/cam.jpg",
"vendor": "Caltrans",
"first_seen": None,
"last_seen": None,
}
out = camera_map_row(row)
assert out["id"] == str(cid)
assert out["lat"] == 37.8
assert out["lon"] == -122.4
assert out["device_type"] == "hls"
assert "source_url" not in out
assert "snapshot_url" not in out
assert "vendor" not in out