Merge origin/master into HUD polish

Keep cheat-sheet/SWPC CSS (this PR) and place-dossier CSS from #37.
This commit is contained in:
Sirius DevOps 2026-08-31 23:15:08 -04:00
commit 7d344ce636
No known key found for this signature in database
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). # NATS subject cameras are published on (consumed by the shared ingester).
CAMERA_NATS_SUBJECT = os.getenv("CAMERA_NATS_SUBJECT", "events.camera") 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, CAMERA_SOURCE_URLS, CAMERA_REQUEST_DELAY, CAMERA_MAX_PER_SOURCE,
NOMINATIM_URL, NOMINATIM_MIN_INTERVAL, USER_AGENT, NOMINATIM_URL, NOMINATIM_MIN_INTERVAL, USER_AGENT,
SNAPSHOT_CACHE_DIR, SNAPSHOT_TTL_SECONDS, SNAPSHOT_TIMEOUT, 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 camera_models import cameras
from database import async_session from database import async_session
@ -115,6 +116,15 @@ class RateLimitedClient:
self._last[host] = time.monotonic() self._last[host] = time.monotonic()
return await self.client.get(url, **kw) 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): async def aclose(self):
await self.client.aclose() await self.client.aclose()
@ -380,6 +390,83 @@ def parse_caltrans_json(text: str, source_name: str) -> list[dict]:
return out 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). # 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 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 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 ──────────────────────────────────────────────────────────── # ── Persistence ────────────────────────────────────────────────────────────
async def upsert_cameras(cams: list[dict]) -> int: async def upsert_cameras(cams: list[dict]) -> int:
@ -723,6 +858,7 @@ async def run_cycle() -> int:
try: try:
results = await asyncio.gather( results = await asyncio.gather(
*(scrape_source(client, geo, s) for s in CAMERA_SOURCE_URLS), *(scrape_source(client, geo, s) for s in CAMERA_SOURCE_URLS),
scrape_udot_ibi(client),
return_exceptions=True, return_exceptions=True,
) )
all_cams: list[dict] = [] 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 = os.getenv(
"OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)" "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). # 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 # 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", "endpoint": "/api/map/gpsjam",
"attribution": "GPSJAM / John Wiseman / ADS-B Exchange", "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": { "conflicts": {
"id": "conflicts", "id": "conflicts",
"kind": "points", "kind": "points",
@ -1444,3 +1450,104 @@ async def fetch_gpsjam(date: str) -> dict:
return gpsjam_csv_to_geojson(resp.text) return gpsjam_csv_to_geojson(resp.text)
return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load) 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_aircraft, fetch_fire_incidents, fetch_fire_perimeters,
fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1, fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1,
fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts, 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) logging.basicConfig(level=logging.INFO)
logger = structlog.get_logger("osint.dashboard") logger = structlog.get_logger("osint.dashboard")
@ -1844,6 +1845,25 @@ async def list_storms():
_upstream_or_502(exc, "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}$") _GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
@ -1879,6 +1899,39 @@ async def map_gpsjam(
return overlay_json(fc, 3600) 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") @app.get("/api/map/times")
async def map_layer_times( async def map_layer_times(
layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"), 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); } .cheat-sheet dd { margin: 0; font-size: 0.78rem; color: var(--muted); }
#swpc-chip { color: var(--text); letter-spacing: 0.08em; } #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); } #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 ── */ /* ── Camera / blip popup thumbnails ── */
.cam-pop { min-width: 210px; max-width: 260px; } .cam-pop { min-width: 210px; max-width: 260px; }
@ -744,6 +792,14 @@
margin-top: 8px; margin-top: 8px;
margin-right: 8px; margin-right: 8px;
} }
#place-dossier {
top: auto;
right: 8px;
left: 8px;
bottom: 56px;
width: auto;
max-height: 36vh;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.tick-track { animation: none; } .tick-track { animation: none; }
@ -1002,6 +1058,21 @@
</div> </div>
</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="map-hint" id="map-hint">Initializing…</div>
<div class="hud-chips" id="hud-chips"> <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> <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 chokepointCatalog = []; // GET /api/map/chokepoints, fetched once
let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls
let vesselRefollowTimer = null; // one follow-up fetch after a retune 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() { function bboxCell() {
if (!map) return ''; if (!map) return '';
return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom(); 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('popupclose', () => { camPopupOpen = false; });
map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); }); map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); });
map.on('dragstart', () => { 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', () => { map.on('moveend', () => {
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);
@ -2777,7 +2861,7 @@ function sinceToISO(sel) {
async function toggleFires() { async function toggleFires() {
firesOn = document.getElementById('lp-fires-on').checked; firesOn = document.getElementById('lp-fires-on').checked;
if (firesOn) await loadFires(); if (firesOn) await loadFires();
else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; } else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; lastFires = []; }
hudFiresCount = null; hudFiresCount = null;
syncHud(); syncHud();
} }
@ -2807,6 +2891,7 @@ async function loadFires() {
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.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]); const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]);
lastFires = Array.isArray(fires) ? fires : [];
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(),
@ -2829,7 +2914,7 @@ async function loadFires() {
async function toggleCams() { async function toggleCams() {
camsOn = document.getElementById('lp-cams-on').checked; camsOn = document.getElementById('lp-cams-on').checked;
if (camsOn) await loadCams(); if (camsOn) await loadCams();
else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; } else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; lastCams = []; }
hudCamsCount = null; hudCamsCount = null;
syncHud(); syncHud();
} }
@ -2907,6 +2992,7 @@ async function loadCams() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) { if (tooZoomedOut()) {
camsGroup = dropLayer(camsGroup); camsGroup = dropLayer(camsGroup);
lastCams = [];
markZoom('lp-cams-count'); markZoom('lp-cams-count');
hudCamsCount = null; hudCamsCount = null;
syncHud(); syncHud();
@ -2917,6 +3003,7 @@ async function loadCams() {
const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`); 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
lastCams = Array.isArray(cams) ? cams : [];
if (camsGroup) map.removeLayer(camsGroup); if (camsGroup) map.removeLayer(camsGroup);
// Clustered markers: camera coverage stays visible as numbered // Clustered markers: camera coverage stays visible as numbered
// clusters at every zoom instead of vanishing into a sparse/empty // clusters at every zoom instead of vanishing into a sparse/empty
@ -3505,12 +3592,13 @@ function loadThermal() {
async function toggleWxAlerts() { async function toggleWxAlerts() {
wxAlertsOn = document.getElementById('lp-alerts-on').checked; wxAlertsOn = document.getElementById('lp-alerts-on').checked;
if (wxAlertsOn) await loadWxAlerts(); if (wxAlertsOn) await loadWxAlerts();
else wxAlertsGroup = dropLayer(wxAlertsGroup); else { wxAlertsGroup = dropLayer(wxAlertsGroup); lastAlerts = []; }
} }
async function loadWxAlerts() { async function loadWxAlerts() {
if (!map) return; if (!map) return;
if (tooZoomedOut()) { if (tooZoomedOut()) {
wxAlertsGroup = dropLayer(wxAlertsGroup); wxAlertsGroup = dropLayer(wxAlertsGroup);
lastAlerts = [];
markZoom('lp-alerts-count'); markZoom('lp-alerts-count');
return; return;
} }
@ -3521,6 +3609,7 @@ async function loadWxAlerts() {
if (req !== overlayReq.alerts) return; if (req !== overlayReq.alerts) return;
wxAlertsGroup = dropLayer(wxAlertsGroup); wxAlertsGroup = dropLayer(wxAlertsGroup);
const feats = fc.features || []; const feats = fc.features || [];
lastAlerts = feats;
wxAlertsGroup = L.geoJSON(fc, { wxAlertsGroup = L.geoJSON(fc, {
renderer: L.canvas({ padding: 0.5 }), renderer: L.canvas({ padding: 0.5 }),
style: (f) => ({ style: (f) => ({
@ -3609,7 +3698,7 @@ async function loadIncidents() {
async function toggleAircraft() { async function toggleAircraft() {
acOn = document.getElementById('lp-ac-on').checked; acOn = document.getElementById('lp-ac-on').checked;
if (acOn) await loadAircraft(); if (acOn) await loadAircraft();
else acGroup = dropLayer(acGroup); else { acGroup = dropLayer(acGroup); lastAircraft = []; }
} }
function toggleAircraftMil() { function toggleAircraftMil() {
acMilOn = document.getElementById('lp-ac-mil-on').checked; acMilOn = document.getElementById('lp-ac-mil-on').checked;
@ -3629,6 +3718,7 @@ async function loadAircraft() {
const all = Array.isArray(pts) ? pts : []; const all = Array.isArray(pts) ? pts : [];
noteMilSupport(all); noteMilSupport(all);
const shown = acMilOn ? all.filter(acVisible) : all; const shown = acMilOn ? all.filter(acVisible) : all;
lastAircraft = shown;
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac'); acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString()); setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString());
const milEl = document.getElementById('lp-ac-mil-count'); const milEl = document.getElementById('lp-ac-mil-count');
@ -3673,6 +3763,7 @@ async function toggleVessels() {
await loadVessels(); await loadVessels();
} else { } else {
vesselsGroup = dropLayer(vesselsGroup); vesselsGroup = dropLayer(vesselsGroup);
lastVessels = [];
clearTimeout(vesselRefollowTimer); clearTimeout(vesselRefollowTimer);
} }
} }
@ -3707,7 +3798,8 @@ async function loadVessels() {
const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`); const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`);
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 => { lastVessels = Array.isArray(pts) ? pts : [];
vesselsGroup = renderPoints(vesselsGroup, lastVessels, p => {
const extra = p.extra || {}; const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6'; if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15'; 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) { function conflictSeverityColor(sev) {
const s = String(sev || '').toLowerCase(); const s = String(sev || '').toLowerCase();
if (s === 'war') return '#ff2a6d'; if (s === 'war') return '#ff2a6d';

View file

@ -138,6 +138,8 @@ services:
FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT} FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT}
FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75} FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75}
OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)} 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_API_KEY: ${AISSTREAM_API_KEY:-}
AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66} AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66}
AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1} 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, _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(): 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() 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(): def test_parse_odot_tripcheck_keeps_valid_skips_missing_and_oob():
payload = """ payload = """
{"features":[ {"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