Compare commits

..

13 commits

Author SHA1 Message Date
Sirius DevOps
7d344ce636
Merge origin/master into HUD polish
Keep cheat-sheet/SWPC CSS (this PR) and place-dossier CSS from #37.
2026-08-31 23:15:08 -04:00
6caa98e0a4 Merge pull request 'feat(map): right-click place dossier (Nominatim + nearby overlays)' (#37) from osint-dashboard/t_715a3b7e-osint-map-right-click-place-dossier into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 16s
Reviewed-on: #37
2026-08-31 22:28:05 -04:00
7a627d116b Merge branch 'master' into osint-dashboard/t_715a3b7e-osint-map-right-click-place-dossier 2026-08-31 22:27:52 -04:00
Sirius DevOps
848ace15d1
Merge origin/master into place dossier
Keep place-dossier JS (this PR) and conflicts overlay loaders from #39.
Disjoint HUD features in index.html.
2026-08-31 22:23:04 -04:00
5815ccb4d4 Merge pull request 'feat(infra): GET /api/infrastructure Overpass nuclear markers (bbox)' (#36) from osint-dashboard/t_5955729a-osint-get-api-infrastructure-overpass-nu into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 17s
Reviewed-on: #36
2026-08-31 22:22:47 -04:00
f8dbfef6c2 Merge pull request 'feat(cameras): UDOT IBI 511 traffic cameras -> cameras table' (#38) from osint-dashboard/t_5bfc52e8-osint-udot-ibi-511-cameras-cameras-table into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 16s
Reviewed-on: #38
2026-08-31 22:22:11 -04:00
Sirius DevOps
640fea31f4
Merge origin/master into UDOT IBI 511 cameras
Keep both UDOT IBI 511 (this PR) and ODOT TripCheck (merged #40)
plus existing MDOT parsers. Disjoint camera sources.
2026-08-31 22:20:04 -04:00
Sirius DevOps
3f18b40da7
Merge origin/master into place dossier
Keep lastAircraft cache for nearby-dossier and MIL filter from #35.
2026-08-31 22:12:51 -04:00
Sirius DevOps
6c41019d6c
Merge origin/master into Overpass nuclear infrastructure
Keep both overlay_catalog entries: infra_nuclear (this PR) and
conflicts (already on master). Disjoint layers.
2026-08-31 22:11:29 -04:00
Sirius DevOps
e69d19c521
Merge origin/master into UDOT IBI 511 cameras
Keep both UDOT IBI 511 and already-merged MDOT MiDrive parsers
(disjoint camera sources on camera_scraper.py / tests).
2026-08-31 22:11:09 -04:00
Sirius DevOps
a9ca98e791 feat(cameras): UDOT IBI 511 parser -> cameras table
Add parse_udot_ibi_page + scrape_udot_ibi for the UDOT IBI 511 traffic
camera feed (prod-ut.ibi511.com, no key). DataTables endpoint is POST
form-encoded and caps at 100 rows/page regardless of `length`; page walk
uses recordsTotal with a UDOT_IBI_MAX_PAGES (default 40) runaway cap.

Skip images[0].blocked/disabled, parse WKT POINT(lng lat) from
latLng.geography.wellKnownText, drop outside the Utah bbox
(lat 36.9-42.1, lon -114.2--108.9). discovery_source=udot, stable
source_url == snapshot_url == /map/Cctv/{id} (never scrape frames),
url_hash dedupe, OSINT_USER_AGENT + X-Requested-With header.

Unit tests: WKT lng/lat order, blocked/disabled skip, bbox drop,
malformed payload, missing WKT.
2026-08-31 21:35:07 -04:00
Sirius DevOps
5cef596a3c feat(map): right-click place dossier (Nominatim + nearby overlays)
Right-click (long-press on touch) opens a What's here? HUD panel: reverse
geocode via GET /api/place (honest UA, 1 req/s, 60s/500-key cache) and lists
already-loaded cameras, aircraft, vessels, fires, and alerts within 5 km.
2026-08-31 21:35:00 -04:00
Sirius DevOps
150cc5fdc4 feat(infra): GET /api/infrastructure Overpass nuclear markers (bbox)
Overpass nwr power=plant + plant:source=nuclear, bbox-required, one
in-flight per quantized bbox, 25s server query with identifying UA,
24h TTL cache. Normalized to {id,name,lat,lon,type,extra}. overlay_catalog
entry infra_nuclear. Missing bbox 400; unknown type 422.
2026-08-31 21:31:31 -04:00
12 changed files with 1076 additions and 7 deletions

View file

@ -61,3 +61,15 @@ SNAPSHOT_TIMEOUT = float(os.getenv("SNAPSHOT_TIMEOUT", "8.0"))
# NATS subject cameras are published on (consumed by the shared ingester).
CAMERA_NATS_SUBJECT = os.getenv("CAMERA_NATS_SUBJECT", "events.camera")
# ── UDOT IBI 511 traffic cameras ──────────────────────────────────────────
# DataTables endpoint (POST form-encoded; server caps at 100 rows/page no
# matter what `length` is sent). No API key. Snapshot stills live at a stable
# /map/Cctv/{id} URL — same URL always serves the latest frame, so we store
# the URL and never scrape every frame ourselves.
UDOT_IBI_URL = "https://prod-ut.ibi511.com/List/GetData/Cameras"
UDOT_IBI_BASE = "https://prod-ut.ibi511.com"
UDOT_IBI_PAGE_SIZE = 100
# Safety cap on pages per cycle so a runaway recordsTotal cannot fan out.
UDOT_IBI_MAX_PAGES = int(os.getenv("UDOT_IBI_MAX_PAGES", "40"))

View file

@ -38,6 +38,7 @@ from camera_config import (
CAMERA_SOURCE_URLS, CAMERA_REQUEST_DELAY, CAMERA_MAX_PER_SOURCE,
NOMINATIM_URL, NOMINATIM_MIN_INTERVAL, USER_AGENT,
SNAPSHOT_CACHE_DIR, SNAPSHOT_TTL_SECONDS, SNAPSHOT_TIMEOUT,
UDOT_IBI_URL, UDOT_IBI_BASE, UDOT_IBI_PAGE_SIZE, UDOT_IBI_MAX_PAGES,
)
from camera_models import cameras
from database import async_session
@ -115,6 +116,15 @@ class RateLimitedClient:
self._last[host] = time.monotonic()
return await self.client.get(url, **kw)
async def post(self, url: str, **kw) -> httpx.Response:
host = urlparse(url).netloc
now = time.monotonic()
wait = self._last.get(host, 0.0) + self._delay - now
if wait > 0:
await asyncio.sleep(wait)
self._last[host] = time.monotonic()
return await self.client.post(url, **kw)
async def aclose(self):
await self.client.aclose()
@ -380,6 +390,83 @@ def parse_caltrans_json(text: str, source_name: str) -> list[dict]:
return out
# ── UDOT IBI 511 ──────────────────────────────────────────────────────────
# Utah bbox (lat 36.942.1, lon -114.2-108.9). WKT is `POINT (lng lat)`.
_UDOT_IBI_MIN_LAT, _UDOT_IBI_MAX_LAT = 36.9, 42.1
_UDOT_IBI_MIN_LON, _UDOT_IBI_MAX_LON = -114.2, -108.9
_UDOT_WKT_POINT_RE = re.compile(
r"POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)", re.I,
)
def parse_udot_ibi_page(text: str, source_name: str = "udot") -> list[dict]:
"""Parse one UDOT IBI 511 DataTables camera page (`{"data": [...]}`).
Skips rows whose first image is `blocked` or `disabled`, and drops any
point outside the Utah bbox. The `/map/Cctv/{id}` URL is a stable identity
(always serves the latest frame), so it is stored as both source_url and
snapshot_url we never scrape frames ourselves.
"""
try:
payload = json.loads(text)
except (json.JSONDecodeError, ValueError):
return []
rows = payload.get("data") if isinstance(payload, dict) else None
if not isinstance(rows, list):
return []
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
cam_id = row.get("id")
images = row.get("images") or []
if cam_id is None or not images:
continue
img = images[0] or {}
if img.get("blocked") or img.get("disabled"):
continue
lon = lat = None
try:
wkt = (row.get("latLng") or {}).get("geography") or {}
wkt = wkt.get("wellKnownText") or ""
m = _UDOT_WKT_POINT_RE.match(str(wkt).strip())
if m:
lon, lat = float(m.group(1)), float(m.group(2))
except (AttributeError, TypeError, ValueError):
lon = lat = None
if lat is None or lon is None:
continue
if not (_UDOT_IBI_MIN_LAT <= lat <= _UDOT_IBI_MAX_LAT
and _UDOT_IBI_MIN_LON <= lon <= _UDOT_IBI_MAX_LON):
continue
snap = f"{UDOT_IBI_BASE}/map/Cctv/{cam_id}"
roadway, direction, location = (
row.get("roadway"), row.get("direction"), row.get("location"),
)
name = ", ".join(
str(b) for b in (roadway, direction, location)
if b and str(b).strip() and str(b).strip().lower() != "unknown"
) or None
out.append({
"source_url": snap,
"snapshot_url": snap,
"discovery_source": source_name,
"location_lat": lat,
"location_lon": lon,
"location_name": name,
"vendor": "UDOT",
"device_type": "http",
"raw": {
"udot_id": cam_id,
"agency": row.get("source"),
"source_id": row.get("sourceId"),
"roadway": roadway,
"direction": direction,
},
})
return out
# Oregon DOT TripCheck inventory bounding box (approx state extent).
ODOT_BBOX = (41.9, 46.3, -124.6, -116.4) # lat_min, lat_max, lon_min, lon_max
@ -672,6 +759,54 @@ async def scrape_source(client: RateLimitedClient, geo: Geocoder,
return out
# ── UDOT IBI 511 paginated fetcher ────────────────────────────────────────
async def scrape_udot_ibi(client: RateLimitedClient) -> list[dict]:
"""Page through the UDOT IBI 511 DataTables endpoint and normalize.
POSTs `start`/`length` form fields (server caps at 100 rows/page), walking
pages until `recordsTotal` is exhausted or UDOT_IBI_MAX_PAGES is hit.
"""
out: list[dict] = []
seen: set[str] = set()
start = 0
for _ in range(UDOT_IBI_MAX_PAGES):
try:
resp = await client.post(
UDOT_IBI_URL,
data={
"start": str(start),
"length": str(UDOT_IBI_PAGE_SIZE),
"lang": "en-US",
},
headers={"X-Requested-With": "XMLHttpRequest"},
)
resp.raise_for_status()
body = resp.text
except Exception: # noqa: BLE001
logger.exception("failed to fetch UDOT IBI page start=%d", start)
break
try:
payload = json.loads(body)
except ValueError:
logger.warning("UDOT IBI non-JSON response at start=%d", start)
break
total = int(payload.get("recordsTotal") or 0)
rows = payload.get("data") or []
if not isinstance(rows, list) or not rows:
break
for cam in parse_udot_ibi_page(body, "udot"):
if cam["source_url"] in seen:
continue
seen.add(cam["source_url"])
out.append(cam)
if start + len(rows) >= total:
break
start += len(rows)
logger.info("UDOT IBI yielded %d cameras", len(out))
return out
# ── Persistence ────────────────────────────────────────────────────────────
async def upsert_cameras(cams: list[dict]) -> int:
@ -723,6 +858,7 @@ async def run_cycle() -> int:
try:
results = await asyncio.gather(
*(scrape_source(client, geo, s) for s in CAMERA_SOURCE_URLS),
scrape_udot_ibi(client),
return_exceptions=True,
)
all_cams: list[dict] = []

View file

@ -71,6 +71,9 @@ FIRMS_DATASETS = [d.strip() for d in _FIRMS_DATASETS_RAW.split(",") if d.strip()
OSINT_USER_AGENT = os.getenv(
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)"
)
# Nominatim reverse (GET /api/place). Camera scraper has its own copy in camera_config.
NOMINATIM_URL = os.getenv("NOMINATIM_URL", "https://nominatim.openstreetmap.org")
NOMINATIM_MIN_INTERVAL = float(os.getenv("NOMINATIM_MIN_INTERVAL", "1.0"))
# Self-hosted TiTiler (warps Sentinel-1 signed COGs into XYZ tiles on the Pi).
# TITILER_PUBLIC_BASE is the same-origin path prefix the browser hits through

View file

@ -155,6 +155,12 @@ def overlay_catalog() -> dict:
"endpoint": "/api/map/gpsjam",
"attribution": "GPSJAM / John Wiseman / ADS-B Exchange",
},
"infra_nuclear": {
"id": "infra_nuclear",
"kind": "points",
"endpoint": "/api/infrastructure?types=nuclear",
"attribution": "OpenStreetMap contributors / Overpass API",
},
"conflicts": {
"id": "conflicts",
"kind": "points",
@ -1444,3 +1450,104 @@ async def fetch_gpsjam(date: str) -> dict:
return gpsjam_csv_to_geojson(resp.text)
return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load)
# ── Infrastructure (Overpass) ───────────────────────────────────────────────
OVERPASS_INTERPRETER = "https://overpass-api.de/api/interpreter"
# One in-flight query per quantized bbox (the per-key lock in _ttl_get). Overpass
# asks for a 25s server timeout in-band; the client gives it 30s of headroom.
OVERPASS_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
INFRA_TTL = 24 * 3600 # 24h per quantized bbox — static infrastructure
# `types=` enum. Nuclear ships first; military/hospital slot in behind the same
# query template without touching the transport. Overpass bbox is
# (south, west, north, east), i.e. (minlat, minlon, maxlat, maxlon).
_INFRA_QUERIES: dict[str, str] = {
"nuclear": (
'[out:json][timeout:25];\n'
'nwr["power"="plant"]["plant:source"="nuclear"]({bbox});\n'
'out center;'
),
}
def infra_query(type_: str, minlon: float, minlat: float, maxlon: float, maxlat: float) -> str:
"""Render one Overpass query with the bbox substituted in south,west,north,east."""
bbox = f"{minlat},{minlon},{maxlat},{maxlon}"
return _INFRA_QUERIES[type_].replace("{bbox}", bbox)
def normalize_infra_element(elem: dict, type_: str) -> dict | None:
"""Map one Overpass element to ``{id, name, lat, lon, type, extra}``.
``out center`` gives nodes their own ``lat``/``lon`` and ways/relations a
``center``. Elements with no usable coordinate are dropped.
"""
etype = elem.get("type")
eid = elem.get("id")
if eid is None:
return None
if etype == "node":
lat, lon = elem.get("lat"), elem.get("lon")
else:
center = elem.get("center") or {}
lat, lon = center.get("lat"), center.get("lon")
if lat is None or lon is None:
return None
tags = elem.get("tags") or {}
name = tags.get("name") or tags.get("ref") or f"{etype}/{eid}"
extra = {k: v for k, v in tags.items() if k != "name"}
return {
"id": f"{etype}/{eid}",
"name": name,
"lat": lat,
"lon": lon,
"type": type_,
"extra": extra,
}
def overpass_nuclear_to_markers(data: dict) -> list[dict]:
"""Convert an Overpass JSON response to normalized nuclear markers."""
markers = []
for elem in data.get("elements") or []:
marker = normalize_infra_element(elem, "nuclear")
if marker is not None:
markers.append(marker)
return markers
async def fetch_infrastructure(types: str, bbox: str) -> list[dict]:
"""Fetch Overpass infrastructure markers, cached 24h per quantized bbox.
``types`` is a single supported enum value (``nuclear`` for now). ``bbox``
is ``minlon,minlat,maxlon,maxlat``.
"""
requested = [t.strip() for t in types.split(",") if t.strip()]
minlon, minlat, maxlon, maxlat = parse_bbox(bbox)
key = f"infra:{','.join(requested)}:{bbox_cell_key(bbox)}"
async def _load() -> list[dict]:
# One query per requested type, concatenated. Nuclear is the only type
# today; the loop keeps the shape ready for military/hospital.
out: list[dict] = []
for type_ in requested:
query = infra_query(type_, minlon, minlat, maxlon, maxlat)
if _http is None:
async with httpx.AsyncClient(
timeout=OVERPASS_TIMEOUT, follow_redirects=True,
headers=_headers(),
) as client:
resp = await client.post(OVERPASS_INTERPRETER, data={"data": query})
resp.raise_for_status()
out.extend(overpass_nuclear_to_markers(resp.json()))
else:
resp = await _http.post(
OVERPASS_INTERPRETER, data={"data": query},
timeout=OVERPASS_TIMEOUT,
)
resp.raise_for_status()
out.extend(overpass_nuclear_to_markers(resp.json()))
return out
return await _ttl_get(key, float(INFRA_TTL), _load)

View file

@ -59,8 +59,9 @@ from live_layers import (
fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1,
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts,
overlay_catalog, parse_bbox, UpstreamRateLimited,
fetch_infrastructure, overlay_catalog, parse_bbox, UpstreamRateLimited,
)
from place import reverse_geocode
logging.basicConfig(level=logging.INFO)
logger = structlog.get_logger("osint.dashboard")
@ -1844,6 +1845,25 @@ async def list_storms():
_upstream_or_502(exc, "storms")
@app.get("/api/place")
async def get_place(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Nominatim reverse geocode for the map \"What's here?\" dossier.
Identifying ``OSINT_USER_AGENT``, 1 req/s, 60s cache, 500 keys. The HUD
lists already-loaded overlay entities client-side this route does not
refetch aircraft/vessels/cameras/fires.
"""
try:
return overlay_json(await reverse_geocode(lat, lon), 60)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
except Exception as exc:
_upstream_or_502(exc, "nominatim")
_GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@ -1879,6 +1899,39 @@ async def map_gpsjam(
return overlay_json(fc, 3600)
_INFRA_TYPES = frozenset({"nuclear"})
@app.get("/api/infrastructure")
async def api_infrastructure(
types: str = Query(..., description="comma-separated enum (nuclear)"),
bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"),
):
"""Overpass-derived static infrastructure markers (nuclear power plants).
``bbox`` is required; ``types`` is a comma-separated subset of ``nuclear``.
Fetched from Overpass (identifying UA, 25s query) and cached 24h per
quantized bbox. Markers are ``{id, name, lat, lon, type, extra}``.
"""
if not bbox:
raise HTTPException(400, "bbox required (minlon,minlat,maxlon,maxlat)")
requested = [t.strip() for t in (types or "").split(",") if t.strip()]
if not requested:
raise HTTPException(422, "types required (e.g. nuclear)")
unknown = [t for t in requested if t not in _INFRA_TYPES]
if unknown:
raise HTTPException(
422, f"unsupported types: {', '.join(unknown)} (supported: nuclear)"
)
try:
markers = await fetch_infrastructure(",".join(requested), bbox)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc
except Exception as exc:
_upstream_or_502(exc, "infrastructure")
return overlay_json(markers, 86400)
@app.get("/api/map/times")
async def map_layer_times(
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"),

99
app/place.py Normal file
View file

@ -0,0 +1,99 @@
"""Nominatim reverse-geocode proxy for the map place dossier.
Browser clients cannot set an identifying User-Agent, and Nominatim typically
blocks CORS so the HUD calls GET /api/place instead of talking to OSM
directly. Cache 60s / 500 keys; never exceed 1 req/s upstream.
"""
from __future__ import annotations
import asyncio
import time
import httpx
from cachetools import TTLCache
from config import NOMINATIM_MIN_INTERVAL, NOMINATIM_URL, OSINT_USER_AGENT
_NOMINATIM = NOMINATIM_URL.rstrip("/")
place_cache: TTLCache = TTLCache(maxsize=500, ttl=60)
_lock = asyncio.Lock()
_last_req = 0.0
_ADDR_KEEP = (
"house_number", "road", "neighbourhood", "suburb", "city", "town",
"village", "hamlet", "county", "state", "postcode", "country", "country_code",
)
def cache_key(lat: float, lon: float) -> str:
return f"{lat:.4f},{lon:.4f}"
def slim_place(lat: float, lon: float, data: dict | None) -> dict:
data = data or {}
raw_addr = data.get("address")
addr_in: dict = raw_addr if isinstance(raw_addr, dict) else {}
address = {k: addr_in[k] for k in _ADDR_KEEP if addr_in.get(k)}
err = data.get("error")
display = None if err else (data.get("display_name") or None)
name = None if err else (data.get("name") or address.get("city")
or address.get("town") or address.get("village") or None)
return {
"lat": lat,
"lon": lon,
"display_name": display,
"name": name,
"address": address,
"osm_type": None if err else data.get("osm_type"),
"osm_id": None if err else data.get("osm_id"),
"attribution": "© OpenStreetMap contributors",
}
async def reverse_geocode(lat: float, lon: float) -> dict:
"""Reverse-geocode a point. Cache hits skip Nominatim entirely."""
if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0):
raise ValueError("lat/lon out of range")
key = cache_key(lat, lon)
qlat, qlon = (float(p) for p in key.split(","))
async with _lock:
hit = place_cache.get(key)
if hit is not None:
return hit
global _last_req
wait = _last_req + NOMINATIM_MIN_INTERVAL - time.monotonic()
if wait > 0:
await asyncio.sleep(wait)
body = await _fetch_nominatim(qlat, qlon)
_last_req = time.monotonic()
place_cache[key] = body
return body
async def _fetch_nominatim(lat: float, lon: float) -> dict:
headers = {
"User-Agent": OSINT_USER_AGENT,
"Accept": "application/json",
}
url = f"{_NOMINATIM}/reverse"
params = {
"lat": f"{lat:.6f}",
"lon": f"{lon:.6f}",
"format": "jsonv2",
"addressdetails": "1",
"zoom": "18",
}
async with _http_client(timeout=10.0, follow_redirects=True) as client:
r = await client.get(url, params=params, headers=headers)
r.raise_for_status()
data = r.json()
if not isinstance(data, dict):
data = {}
return slim_place(lat, lon, data)
def _http_client(**kwargs):
return httpx.AsyncClient(**kwargs)

View file

@ -408,6 +408,54 @@
.cheat-sheet dd { margin: 0; font-size: 0.78rem; color: var(--muted); }
#swpc-chip { color: var(--text); letter-spacing: 0.08em; }
#swpc-chip.swpc-storm { color: var(--amber); text-shadow: 0 0 8px rgba(255,180,84,0.45); }
/* ── Place dossier (“Whats here?”) ── */
#place-dossier {
position: absolute; top: 58px; right: 54px; z-index: 650;
width: 280px; max-height: calc(100% - 90px); overflow: hidden;
background: rgba(6,11,20,0.90); backdrop-filter: blur(8px);
border: 1px solid var(--line-hi); border-radius: 6px;
box-shadow: 0 0 22px rgba(53,224,255,0.14), 0 0 2px rgba(53,224,255,0.4);
color: var(--text); font-size: 0.78rem; display: none; flex-direction: column;
clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px));
}
#place-dossier.open { display: flex; }
.pd-head {
display: flex; justify-content: space-between; align-items: center;
padding: 0.55rem 0.95rem 0.55rem 0.85rem; border-bottom: 1px solid var(--line);
font-family: 'Orbitron', sans-serif; font-size: 0.68rem; font-weight: 700;
letter-spacing: 0.14em; text-transform: uppercase; color: var(--cyan);
text-shadow: 0 0 10px rgba(53,224,255,0.55);
background: linear-gradient(180deg, rgba(53,224,255,0.07), transparent);
}
.pd-close {
background: transparent; border: 1px solid var(--line); color: var(--muted);
font-family: 'Share Tech Mono', monospace; font-size: 0.78rem;
width: 28px; height: 28px; border-radius: 4px; cursor: pointer; line-height: 1;
flex-shrink: 0; margin-right: 2px;
}
.pd-close:hover { border-color: var(--cyan); color: var(--cyan); }
.pd-body { padding: 0.55rem 0.75rem 0.7rem; overflow-y: auto; display: flex; flex-direction: column; gap: 0.55rem; min-height: 0; flex: 1; }
.pd-status { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--muted); }
.pd-status.err { color: var(--red); }
.pd-coords { font-family: 'Share Tech Mono', monospace; font-size: 0.66rem; color: var(--cyan); letter-spacing: 0.04em; }
.pd-name { font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 0.92rem; color: var(--text); line-height: 1.25; }
.pd-addr { font-size: 0.72rem; color: var(--muted); line-height: 1.4; }
.pd-sec {
font-family: 'Orbitron', sans-serif; font-size: 0.58rem; letter-spacing: 0.12em;
text-transform: uppercase; color: var(--muted); margin-top: 0.15rem;
}
.pd-list { display: flex; flex-direction: column; gap: 0.18rem; }
.pd-item {
display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem;
background: transparent; border: 0; border-left: 2px solid var(--line);
color: var(--text); text-align: left; cursor: pointer; padding: 0.22rem 0.35rem;
font-family: 'Rajdhani', sans-serif; font-size: 0.78rem; font-weight: 600;
}
.pd-item:hover, .pd-item:focus-visible { border-left-color: var(--cyan); color: var(--cyan); background: rgba(53,224,255,0.06); }
.pd-item .k { text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.58rem; color: var(--muted); font-family: 'Share Tech Mono', monospace; flex-shrink: 0; }
.pd-item .d { font-family: 'Share Tech Mono', monospace; font-size: 0.62rem; color: var(--cyan); white-space: nowrap; }
.pd-empty { font-size: 0.72rem; color: var(--muted); font-style: italic; }
.pd-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
/* ── Camera / blip popup thumbnails ── */
.cam-pop { min-width: 210px; max-width: 260px; }
@ -744,6 +792,14 @@
margin-top: 8px;
margin-right: 8px;
}
#place-dossier {
top: auto;
right: 8px;
left: 8px;
bottom: 56px;
width: auto;
max-height: 36vh;
}
}
@media (prefers-reduced-motion: reduce) {
.tick-track { animation: none; }
@ -1002,6 +1058,21 @@
</div>
</div>
<aside id="place-dossier" role="dialog" aria-labelledby="pd-title" aria-hidden="true">
<div class="pd-head">
<span id="pd-title">Whats here?</span>
<button type="button" class="pd-close" id="pd-close" aria-label="Close place dossier"></button>
</div>
<div class="pd-body">
<div class="pd-status" id="pd-status">Right-click the map (long-press on touch).</div>
<div class="pd-coords" id="pd-coords"></div>
<div class="pd-name" id="pd-name"></div>
<div class="pd-addr" id="pd-addr"></div>
<div class="pd-sec">Nearby · 5 km</div>
<div class="pd-list" id="pd-nearby"></div>
<div class="pd-note">Loaded overlays only — no world refetch. Nominatim reverse via GET /api/place.</div>
</div>
</aside>
<div class="map-hint" id="map-hint">Initializing…</div>
<div class="hud-chips" id="hud-chips">
<div class="hud-chip" id="hud-fires"><span class="c fires"></span>FIRMS <b id="hud-fires-n"></b></div>
@ -2158,6 +2229,8 @@ let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once
let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls
let vesselRefollowTimer = null; // one follow-up fetch after a retune
let lastCams = [], lastFires = [], lastAircraft = [], lastVessels = [], lastAlerts = [];
let placeMarker = null, placeReq = 0, placeLongPress = null;
function bboxCell() {
if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom();
@ -2381,6 +2454,17 @@ async function initMap() {
map.on('popupclose', () => { camPopupOpen = false; });
map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('dragstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('contextmenu', (e) => {
if (e.originalEvent) e.originalEvent.preventDefault();
if (typeof gfDrawOn !== 'undefined' && gfDrawOn) return;
openPlaceDossier(e.latlng);
});
bindPlaceLongPress(map);
const pdClose = document.getElementById('pd-close');
if (pdClose) pdClose.addEventListener('click', closePlaceDossier);
document.addEventListener('keydown', (ev) => {
if (ev.key === 'Escape') closePlaceDossier();
});
map.on('moveend', () => {
if (camPopupOpen) return; // only the popup's own autopan now
if (moveDebounce) clearTimeout(moveDebounce);
@ -2777,7 +2861,7 @@ function sinceToISO(sel) {
async function toggleFires() {
firesOn = document.getElementById('lp-fires-on').checked;
if (firesOn) await loadFires();
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; }
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; lastFires = []; }
hudFiresCount = null;
syncHud();
}
@ -2807,6 +2891,7 @@ async function loadFires() {
if (req !== fireReq) return; // superseded by a newer pan/zoom
if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]);
lastFires = Array.isArray(fires) ? fires : [];
firesHeat = L.heatLayer(pts, {
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
gradient: firesGradient(),
@ -2829,7 +2914,7 @@ async function loadFires() {
async function toggleCams() {
camsOn = document.getElementById('lp-cams-on').checked;
if (camsOn) await loadCams();
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; }
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; lastCams = []; }
hudCamsCount = null;
syncHud();
}
@ -2907,6 +2992,7 @@ async function loadCams() {
if (!map) return;
if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup);
lastCams = [];
markZoom('lp-cams-count');
hudCamsCount = null;
syncHud();
@ -2917,6 +3003,7 @@ async function loadCams() {
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`);
const cams = await r.json();
if (req !== camReq) return; // superseded by a newer pan/zoom
lastCams = Array.isArray(cams) ? cams : [];
if (camsGroup) map.removeLayer(camsGroup);
// Clustered markers: camera coverage stays visible as numbered
// clusters at every zoom instead of vanishing into a sparse/empty
@ -3505,12 +3592,13 @@ function loadThermal() {
async function toggleWxAlerts() {
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
if (wxAlertsOn) await loadWxAlerts();
else wxAlertsGroup = dropLayer(wxAlertsGroup);
else { wxAlertsGroup = dropLayer(wxAlertsGroup); lastAlerts = []; }
}
async function loadWxAlerts() {
if (!map) return;
if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup);
lastAlerts = [];
markZoom('lp-alerts-count');
return;
}
@ -3521,6 +3609,7 @@ async function loadWxAlerts() {
if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || [];
lastAlerts = feats;
wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({
@ -3609,7 +3698,7 @@ async function loadIncidents() {
async function toggleAircraft() {
acOn = document.getElementById('lp-ac-on').checked;
if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup);
else { acGroup = dropLayer(acGroup); lastAircraft = []; }
}
function toggleAircraftMil() {
acMilOn = document.getElementById('lp-ac-mil-on').checked;
@ -3629,6 +3718,7 @@ async function loadAircraft() {
const all = Array.isArray(pts) ? pts : [];
noteMilSupport(all);
const shown = acMilOn ? all.filter(acVisible) : all;
lastAircraft = shown;
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString());
const milEl = document.getElementById('lp-ac-mil-count');
@ -3673,6 +3763,7 @@ async function toggleVessels() {
await loadVessels();
} else {
vesselsGroup = dropLayer(vesselsGroup);
lastVessels = [];
clearTimeout(vesselRefollowTimer);
}
}
@ -3707,7 +3798,8 @@ async function loadVessels() {
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`);
const pts = await r.json();
if (req !== overlayReq.vessels) return;
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
lastVessels = Array.isArray(pts) ? pts : [];
vesselsGroup = renderPoints(vesselsGroup, lastVessels, p => {
const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15';
@ -3749,6 +3841,163 @@ async function loadStorms() {
}
}
/* ── Place dossier (“Whats here?”) — Nominatim + already-loaded overlays ── */
const PLACE_PAD_KM = 5;
function haversineKm(aLat, aLon, bLat, bLon) {
const toRad = (d) => d * Math.PI / 180;
const dLat = toRad(bLat - aLat), dLon = toRad(bLon - aLon);
const a = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(aLat)) * Math.cos(toRad(bLat)) * Math.sin(dLon / 2) ** 2;
return 2 * 6371 * Math.asin(Math.min(1, Math.sqrt(a)));
}
function placePt(p) {
const lat = p.lat ?? p.latitude;
const lon = p.lon ?? p.longitude;
if (lat == null || lon == null) return null;
return { lat: Number(lat), lon: Number(lon) };
}
function nearbyFromPoints(rows, origin, kind, labelFn) {
const out = [];
(rows || []).forEach((p) => {
const pt = placePt(p);
if (!pt) return;
const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
if (km <= PLACE_PAD_KM) out.push({ kind, km, label: labelFn(p), lat: pt.lat, lon: pt.lon });
});
return out;
}
function alertCentroid(f) {
const g = f && f.geometry;
if (!g) return null;
let c = g.coordinates;
if (g.type === 'Point' && Array.isArray(c)) return { lat: c[1], lon: c[0] };
while (Array.isArray(c) && Array.isArray(c[0])) c = c[0];
if (Array.isArray(c) && typeof c[0] === 'number') return { lat: c[1], lon: c[0] };
return null;
}
function nearbyFromAlerts(feats, origin) {
const out = [];
(feats || []).forEach((f) => {
const p = f.properties || {};
const label = p.event || p.headline || 'Alert';
const pt = alertCentroid(f);
if (!pt) return;
const km = haversineKm(origin.lat, origin.lng, pt.lat, pt.lon);
if (km <= PLACE_PAD_KM) out.push({ kind: 'alert', km, label, lat: pt.lat, lon: pt.lon });
});
return out;
}
function collectNearby(origin) {
const items = [
...nearbyFromPoints(lastCams, origin, 'camera', (p) => p.location_name || 'Camera'),
...nearbyFromPoints(lastAircraft, origin, 'aircraft', (p) => p.label || (p.extra || {}).hex || 'Aircraft'),
...nearbyFromPoints(lastVessels, origin, 'vessel', (p) => p.label || 'Vessel'),
...nearbyFromPoints(lastFires, origin, 'fire', () => 'FIRMS hotspot'),
...nearbyFromAlerts(lastAlerts, origin),
];
items.sort((a, b) => a.km - b.km);
return items;
}
function renderNearby(origin) {
const box = document.getElementById('pd-nearby');
if (!box) return;
const items = collectNearby(origin);
if (!items.length) {
box.innerHTML = '<div class="pd-empty">No loaded cameras, aircraft, vessels, fires, or alerts within 5 km.</div>';
return;
}
const shown = items.slice(0, 24);
const more = items.length - shown.length;
box.innerHTML = shown.map((it) => {
const km = it.km < 1 ? `${Math.round(it.km * 1000)} m` : `${it.km.toFixed(1)} km`;
return `<button type="button" class="pd-item" data-lat="${it.lat}" data-lon="${it.lon}">`
+ `<span><span class="k">${esc(it.kind)}</span> ${esc(it.label)}</span>`
+ `<span class="d">${esc(km)}</span></button>`;
}).join('') + (more > 0 ? `<div class="pd-empty">+ ${more} more</div>` : '');
box.querySelectorAll('.pd-item').forEach((btn) => {
btn.addEventListener('click', () => {
const lat = Number(btn.dataset.lat), lon = Number(btn.dataset.lon);
if (!map || Number.isNaN(lat) || Number.isNaN(lon)) return;
map.setView([lat, lon], Math.max(map.getZoom(), 10));
});
});
}
function closePlaceDossier() {
const panel = document.getElementById('place-dossier');
if (panel) {
panel.classList.remove('open');
panel.setAttribute('aria-hidden', 'true');
}
if (placeMarker && map) { map.removeLayer(placeMarker); placeMarker = null; }
}
function bindPlaceLongPress(m) {
const el = m.getContainer();
const clear = () => {
if (placeLongPress) { clearTimeout(placeLongPress.timer); placeLongPress = null; }
};
el.addEventListener('touchstart', (ev) => {
if (ev.touches.length !== 1) { clear(); return; }
const t = ev.touches[0];
placeLongPress = {
x: t.clientX, y: t.clientY,
timer: setTimeout(() => {
const start = placeLongPress;
placeLongPress = null;
if (!start || gfDrawOn) return;
const latlng = m.mouseEventToLatLng({ clientX: start.x, clientY: start.y });
openPlaceDossier(latlng);
}, 550),
};
}, { passive: true });
el.addEventListener('touchmove', (ev) => {
if (!placeLongPress) return;
const t = ev.touches[0];
if (Math.hypot(t.clientX - placeLongPress.x, t.clientY - placeLongPress.y) > 14) clear();
}, { passive: true });
el.addEventListener('touchend', clear);
el.addEventListener('touchcancel', clear);
el.addEventListener('contextmenu', (ev) => ev.preventDefault());
}
async function openPlaceDossier(latlng) {
if (!latlng || !map) return;
const panel = document.getElementById('place-dossier');
const status = document.getElementById('pd-status');
const coords = document.getElementById('pd-coords');
const nameEl = document.getElementById('pd-name');
const addrEl = document.getElementById('pd-addr');
if (!panel) return;
panel.classList.add('open');
panel.setAttribute('aria-hidden', 'false');
const lat = latlng.lat, lon = latlng.lng;
if (coords) coords.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`;
if (nameEl) nameEl.textContent = '';
if (addrEl) addrEl.textContent = '';
if (status) { status.classList.remove('err'); status.textContent = 'Looking up place…'; }
if (placeMarker) map.removeLayer(placeMarker);
placeMarker = L.circleMarker([lat, lon], {
radius: 7, color: '#35e0ff', weight: 2, fillColor: '#35e0ff', fillOpacity: 0.25,
pane: 'markerPane',
}).addTo(map);
renderNearby(latlng);
const req = ++placeReq;
try {
const r = await fetch(`${API}/api/place?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`);
if (req !== placeReq) return;
if (!r.ok) throw new Error(`place ${r.status}`);
const d = await r.json();
if (req !== placeReq) return;
if (nameEl) nameEl.textContent = d.name || d.display_name || 'Unknown place';
if (addrEl) addrEl.textContent = d.display_name && d.name && d.display_name !== d.name
? d.display_name : '';
if (status) status.textContent = d.display_name ? 'Nominatim · OSM' : 'No reverse geocode for this point';
const closeBtn = document.getElementById('pd-close');
if (closeBtn) closeBtn.focus();
} catch (e) {
if (req !== placeReq) return;
if (status) { status.classList.add('err'); status.textContent = 'Place lookup failed — nearby list is still from loaded overlays.'; }
}
}
function conflictSeverityColor(sev) {
const s = String(sev || '').toLowerCase();
if (s === 'war') return '#ff2a6d';

View file

@ -138,6 +138,8 @@ services:
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)}
NOMINATIM_URL: ${NOMINATIM_URL:-https://nominatim.openstreetmap.org}
NOMINATIM_MIN_INTERVAL: ${NOMINATIM_MIN_INTERVAL:-1.0}
AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1}

127
tests/test_api_place.py Normal file
View file

@ -0,0 +1,127 @@
"""GET /api/place — Nominatim reverse proxy (60s cache, 500 keys, 1 req/s)."""
from __future__ import annotations
import asyncio
import httpx
import pytest
from main import app
from place import cache_key, place_cache, slim_place
BASE = "http://test"
SAMPLE = {
"display_name": "Raleigh, Wake County, North Carolina, United States",
"name": "Raleigh",
"osm_type": "relation",
"osm_id": 123,
"address": {
"city": "Raleigh",
"state": "North Carolina",
"country": "United States",
"country_code": "us",
"tourism": "ignore-me",
},
}
class _FakeResp:
def __init__(self, payload, status=200):
self._payload = payload
self.status_code = status
def raise_for_status(self):
if self.status_code >= 400:
req = httpx.Request("GET", "https://nominatim.openstreetmap.org/reverse")
raise httpx.HTTPStatusError(
"upstream", request=req,
response=httpx.Response(self.status_code, request=req),
)
def json(self):
return self._payload
class _FakeNominatim:
calls: list[dict] = []
def __init__(self, *args, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
async def get(self, url, params=None, headers=None):
_FakeNominatim.calls.append({"url": url, "params": params, "headers": headers})
return _FakeResp(SAMPLE)
def _nominatim_client(**kwargs):
return _FakeNominatim()
async def _get(path: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
return await client.get(path)
@pytest.fixture(autouse=True)
def _reset_place(monkeypatch):
place_cache.clear()
_FakeNominatim.calls = []
monkeypatch.setattr("place._http_client", _nominatim_client)
monkeypatch.setattr("place.NOMINATIM_MIN_INTERVAL", 0.0)
monkeypatch.setattr("place._last_req", 0.0)
yield
place_cache.clear()
def test_slim_place_keeps_address_subset():
body = slim_place(35.78, -78.64, SAMPLE)
assert body["display_name"].startswith("Raleigh")
assert body["name"] == "Raleigh"
assert body["address"]["city"] == "Raleigh"
assert "tourism" not in body["address"]
assert body["attribution"].startswith("© OpenStreetMap")
def test_cache_key_quantizes_to_4_decimals():
assert cache_key(35.77961, -78.63821) == cache_key(35.77964, -78.63819)
def test_place_requires_lat_lon():
resp = asyncio.run(_get("/api/place"))
assert resp.status_code == 422
def test_place_rejects_out_of_range():
assert asyncio.run(_get("/api/place?lat=99&lon=0")).status_code == 422
assert asyncio.run(_get("/api/place?lat=0&lon=200")).status_code == 422
def test_place_reverse_and_cache():
r1 = asyncio.run(_get("/api/place?lat=35.7796&lon=-78.6382"))
assert r1.status_code == 200
body = r1.json()
assert body["display_name"].startswith("Raleigh")
assert body["lat"] == pytest.approx(35.7796, abs=0.001)
assert "max-age=60" in (r1.headers.get("cache-control") or "").lower()
assert len(_FakeNominatim.calls) == 1
ua = _FakeNominatim.calls[0]["headers"]["User-Agent"]
assert "osint-dashboard" in ua.lower() or "@" in ua
r2 = asyncio.run(_get("/api/place?lat=35.77961&lon=-78.63821"))
assert r2.status_code == 200
assert len(_FakeNominatim.calls) == 1 # cache hit, same 4-decimal key
def test_place_cache_cap_500():
from cachetools import TTLCache
assert isinstance(place_cache, TTLCache)
assert place_cache.maxsize == 500
assert place_cache.ttl == 60

View file

@ -0,0 +1,145 @@
"""GET /api/infrastructure — Overpass nuclear markers."""
import asyncio
import httpx
from live_layers import (
normalize_infra_element,
overlay_catalog,
overpass_nuclear_to_markers,
_cache,
)
from main import app
BASE = "http://test"
OVERPASS = {
"version": 0.6,
"generator": "Overpass API",
"elements": [
{
"type": "node",
"id": 12345,
"lat": 44.0,
"lon": -1.5,
"tags": {"name": "Test NPP", "operator": "EDF", "plant:source": "nuclear"},
},
{
"type": "way",
"id": 67890,
"center": {"lat": 43.5, "lon": -1.25},
"tags": {"name": "Test Plant Way", "plant:source": "nuclear"},
},
{
"type": "relation",
"id": 999,
"center": {"lat": 43.0, "lon": -1.0},
"tags": {},
},
],
}
async def _get(path: str) -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url=BASE) as client:
return await client.get(path)
def test_normalize_node_to_marker():
m = normalize_infra_element(OVERPASS["elements"][0], "nuclear")
assert m["id"] == "node/12345"
assert m["name"] == "Test NPP"
assert m["lat"] == 44.0
assert m["lon"] == -1.5
assert m["type"] == "nuclear"
assert m["extra"]["operator"] == "EDF"
assert "name" not in m["extra"]
def test_way_center_and_unnamed_fallback():
way = normalize_infra_element(OVERPASS["elements"][1], "nuclear")
assert way["lat"] == 43.5
assert way["lon"] == -1.25
rel = normalize_infra_element(OVERPASS["elements"][2], "nuclear")
assert rel["name"] == "relation/999"
def test_overpass_json_to_markers():
markers = overpass_nuclear_to_markers(OVERPASS)
assert len(markers) == 3
assert markers[0]["id"] == "node/12345"
def test_missing_bbox_400():
resp = asyncio.run(_get("/api/infrastructure?types=nuclear"))
assert resp.status_code == 400
def test_unknown_type_422():
resp = asyncio.run(_get("/api/infrastructure?types=military&bbox=-2,43,-1,44"))
assert resp.status_code == 422
def test_map_infrastructure_returns_markers(monkeypatch):
async def fake_fetch(types, bbox):
return [
{"id": "node/1", "name": "X", "lat": 1.0, "lon": 2.0,
"type": "nuclear", "extra": {}}
]
monkeypatch.setattr("main.fetch_infrastructure", fake_fetch)
resp = asyncio.run(_get("/api/infrastructure?types=nuclear&bbox=-2,43,-1,44"))
assert resp.status_code == 200
body = resp.json()
assert body[0]["name"] == "X"
assert body[0]["type"] == "nuclear"
assert "max-age" in (resp.headers.get("cache-control") or "").lower()
def test_overlay_catalog_has_infra_nuclear():
entry = overlay_catalog()["infra_nuclear"]
assert entry["kind"] == "points"
assert "nuclear" in entry["endpoint"]
def test_fetch_infrastructure_cache_hit_no_refetch(monkeypatch):
_cache.clear()
hits = {"n": 0}
class FakeResp:
def raise_for_status(self):
pass
def json(self):
return OVERPASS
class FakeClient:
def __init__(self, **kw):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def post(self, url, data=None, timeout=None):
hits["n"] += 1
assert "overpass-api.de" in url
assert "plant:source" in data["data"]
assert "nuclear" in data["data"]
return FakeResp()
monkeypatch.setattr("live_layers.httpx.AsyncClient", FakeClient)
monkeypatch.setattr("live_layers._http", None)
from live_layers import fetch_infrastructure
m1 = asyncio.run(fetch_infrastructure("nuclear", "-2,43,-1,44"))
m2 = asyncio.run(fetch_infrastructure("nuclear", "-2,43,-1,44"))
assert len(m1) == 3
assert m2 == m1
assert hits["n"] == 1
_cache.clear()

View file

@ -27,7 +27,7 @@ from live_layers import (
_wfigs_params,
)
from camera_scraper import parse_caltrans_json, parse_odot_json, parse_mdot_json
from camera_scraper import parse_caltrans_json, parse_udot_ibi_page, parse_odot_json, parse_mdot_json
def test_parse_bbox_and_radius_clamps_to_150_nm():
@ -259,6 +259,83 @@ def test_parse_caltrans_skips_oos_and_maps_jpeg_hls():
assert "rtsp://" not in cam["snapshot_url"].lower()
# ── UDOT IBI 511 parser ──────────────────────────────────────────────────
def _udot_row(cam_id, lng, lat, **img_overrides):
img = {
"id": cam_id, "cameraSiteId": cam_id,
"imageUrl": f"/map/Cctv/{cam_id}", "disabled": False, "blocked": False,
}
img.update(img_overrides)
return {
"id": cam_id, "sourceId": "102771", "source": "ADX",
"roadway": "Unknown", "direction": "Unknown",
"location": "Freedom Blvd / 200 W @ 1100 N, PVO",
"latLng": {"geography": {
"coordinateSystemId": 4326,
"wellKnownText": f"POINT ({lng} {lat})"}},
"images": [img],
}
def _udot_page(rows):
import json
return json.dumps({"draw": 0, "recordsTotal": len(rows),
"recordsFiltered": len(rows), "data": rows})
def test_parse_udot_wkt_maps_lng_lat():
cams = parse_udot_ibi_page(_udot_page([_udot_row(112731, -111.66204, 40.24863)]))
assert len(cams) == 1
cam = cams[0]
# WKT is `POINT (lng lat)` — order must not be swapped.
assert cam["location_lat"] == 40.24863
assert cam["location_lon"] == -111.66204
assert cam["discovery_source"] == "udot"
assert cam["vendor"] == "UDOT"
assert cam["source_url"] == "https://prod-ut.ibi511.com/map/Cctv/112731"
assert cam["snapshot_url"] == cam["source_url"]
assert "rtsp://" not in cam["source_url"].lower()
assert cam["raw"]["udot_id"] == 112731
def test_parse_udot_skips_blocked_and_disabled():
rows = [
_udot_row(1, -111.0, 40.0),
_udot_row(2, -111.1, 40.1, blocked=True),
_udot_row(3, -111.2, 40.2, disabled=True),
]
rows.append(_udot_row(4, -111.3, 40.3))
rows[3]["images"] = [] # no images → drop
cams = parse_udot_ibi_page(_udot_page(rows))
assert [c["raw"]["udot_id"] for c in cams] == [1]
def test_parse_udot_drops_out_of_bbox():
rows = [
_udot_row(1, -111.0, 40.0), # inside Utah
_udot_row(2, -100.0, 40.0), # east of -108.9
_udot_row(3, -120.0, 40.0), # west of -114.2
_udot_row(4, -111.0, 44.0), # north of 42.1
_udot_row(5, -111.0, 30.0), # south of 36.9
]
cams = parse_udot_ibi_page(_udot_page(rows))
assert [c["raw"]["udot_id"] for c in cams] == [1]
def test_parse_udot_bad_payload_returns_empty():
import json
assert parse_udot_ibi_page("not json") == []
assert parse_udot_ibi_page(json.dumps({"data": None})) == []
assert parse_udot_ibi_page(json.dumps({"data": "nope"})) == []
def test_parse_udot_missing_wkt_skipped():
row = _udot_row(1, -111.0, 40.0)
row["latLng"] = {}
assert parse_udot_ibi_page(_udot_page([row])) == []
def test_parse_odot_tripcheck_keeps_valid_skips_missing_and_oob():
payload = """
{"features":[

View file

@ -0,0 +1,59 @@
"""Right-click place dossier HUD contract."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
HTML = (ROOT / "app/static/index.html").read_text()
def test_place_dossier_panel_markup():
assert 'id="place-dossier"' in HTML
assert "Whats here?" in HTML or "What's here?" in HTML
assert 'id="pd-nearby"' in HTML
assert 'id="pd-close"' in HTML
assert 'role="dialog"' in HTML
def test_place_dossier_uses_backend_nominatim_proxy():
js = HTML.split("async function openPlaceDossier", 1)[1].split(
"/* ═══════════════ INITIAL LOAD", 1
)[0]
assert "/api/place?lat=" in js
assert "nominatim.openstreetmap.org" not in js
assert "/api/aircraft" not in js
assert "/api/vessels" not in js
assert "/api/cameras" not in js
assert "/api/fires" not in js
assert "/api/weather-alerts" not in js
assert "/api/infrastructure" not in js
def test_place_dossier_scans_loaded_overlays_5km():
assert "const PLACE_PAD_KM = 5" in HTML
assert "function collectNearby" in HTML
assert "lastCams" in HTML
assert "lastAircraft" in HTML
assert "lastVessels" in HTML
assert "lastFires" in HTML
assert "lastAlerts" in HTML
assert "function haversineKm" in HTML
def test_place_dossier_right_click_and_long_press():
assert "map.on('contextmenu'" in HTML
assert "function bindPlaceLongPress" in HTML
assert "function closePlaceDossier" in HTML
assert "Escape" in HTML.split("function initMap", 1)[1][:8000] or "Escape" in HTML.split(
"bindPlaceLongPress(map)", 1
)[0][-500:]
def test_place_dossier_mobile_is_bottom_sheet():
mobile = HTML.split("@media (max-width: 820px)")[1].split(
"@media (prefers-reduced-motion"
)[0]
assert "#place-dossier" in mobile
assert "bottom: 56px" in mobile
assert "max-height: 36vh" in mobile