Compare commits
13 commits
f7853a354c
...
7d344ce636
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d344ce636 | ||
| 6caa98e0a4 | |||
| 7a627d116b | |||
|
|
848ace15d1 | ||
| 5815ccb4d4 | |||
| f8dbfef6c2 | |||
|
|
640fea31f4 | ||
|
|
3f18b40da7 | ||
|
|
6c41019d6c | ||
|
|
e69d19c521 | ||
|
|
a9ca98e791 | ||
|
|
5cef596a3c | ||
|
|
150cc5fdc4 |
12 changed files with 1076 additions and 7 deletions
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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.9–42.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] = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
55
app/main.py
55
app/main.py
|
|
@ -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
99
app/place.py
Normal 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)
|
||||||
|
|
@ -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 (“What’s 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">What’s 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 (“What’s 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';
|
||||||
|
|
|
||||||
|
|
@ -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
127
tests/test_api_place.py
Normal 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
|
||||||
145
tests/test_infrastructure.py
Normal file
145
tests/test_infrastructure.py
Normal 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()
|
||||||
|
|
@ -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":[
|
||||||
|
|
|
||||||
59
tests/test_place_dossier_frontend.py
Normal file
59
tests/test_place_dossier_frontend.py
Normal 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 "What’s 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
|
||||||
Loading…
Add table
Reference in a new issue