diff --git a/app/camera_config.py b/app/camera_config.py index 5eaa1fb..2ccff69 100644 --- a/app/camera_config.py +++ b/app/camera_config.py @@ -61,3 +61,15 @@ SNAPSHOT_TIMEOUT = float(os.getenv("SNAPSHOT_TIMEOUT", "8.0")) # NATS subject cameras are published on (consumed by the shared ingester). CAMERA_NATS_SUBJECT = os.getenv("CAMERA_NATS_SUBJECT", "events.camera") + + +# ── UDOT IBI 511 traffic cameras ────────────────────────────────────────── +# DataTables endpoint (POST form-encoded; server caps at 100 rows/page no +# matter what `length` is sent). No API key. Snapshot stills live at a stable +# /map/Cctv/{id} URL — same URL always serves the latest frame, so we store +# the URL and never scrape every frame ourselves. +UDOT_IBI_URL = "https://prod-ut.ibi511.com/List/GetData/Cameras" +UDOT_IBI_BASE = "https://prod-ut.ibi511.com" +UDOT_IBI_PAGE_SIZE = 100 +# Safety cap on pages per cycle so a runaway recordsTotal cannot fan out. +UDOT_IBI_MAX_PAGES = int(os.getenv("UDOT_IBI_MAX_PAGES", "40")) diff --git a/app/camera_scraper.py b/app/camera_scraper.py index e968567..10dde21 100644 --- a/app/camera_scraper.py +++ b/app/camera_scraper.py @@ -38,6 +38,7 @@ from camera_config import ( CAMERA_SOURCE_URLS, CAMERA_REQUEST_DELAY, CAMERA_MAX_PER_SOURCE, NOMINATIM_URL, NOMINATIM_MIN_INTERVAL, USER_AGENT, SNAPSHOT_CACHE_DIR, SNAPSHOT_TTL_SECONDS, SNAPSHOT_TIMEOUT, + UDOT_IBI_URL, UDOT_IBI_BASE, UDOT_IBI_PAGE_SIZE, UDOT_IBI_MAX_PAGES, ) from camera_models import cameras from database import async_session @@ -115,6 +116,15 @@ class RateLimitedClient: self._last[host] = time.monotonic() return await self.client.get(url, **kw) + async def post(self, url: str, **kw) -> httpx.Response: + host = urlparse(url).netloc + now = time.monotonic() + wait = self._last.get(host, 0.0) + self._delay - now + if wait > 0: + await asyncio.sleep(wait) + self._last[host] = time.monotonic() + return await self.client.post(url, **kw) + async def aclose(self): await self.client.aclose() @@ -380,6 +390,83 @@ def parse_caltrans_json(text: str, source_name: str) -> list[dict]: return out +# ── UDOT IBI 511 ────────────────────────────────────────────────────────── +# Utah bbox (lat 36.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). ODOT_BBOX = (41.9, 46.3, -124.6, -116.4) # lat_min, lat_max, lon_min, lon_max @@ -672,6 +759,54 @@ async def scrape_source(client: RateLimitedClient, geo: Geocoder, return out +# ── UDOT IBI 511 paginated fetcher ──────────────────────────────────────── + +async def scrape_udot_ibi(client: RateLimitedClient) -> list[dict]: + """Page through the UDOT IBI 511 DataTables endpoint and normalize. + + POSTs `start`/`length` form fields (server caps at 100 rows/page), walking + pages until `recordsTotal` is exhausted or UDOT_IBI_MAX_PAGES is hit. + """ + out: list[dict] = [] + seen: set[str] = set() + start = 0 + for _ in range(UDOT_IBI_MAX_PAGES): + try: + resp = await client.post( + UDOT_IBI_URL, + data={ + "start": str(start), + "length": str(UDOT_IBI_PAGE_SIZE), + "lang": "en-US", + }, + headers={"X-Requested-With": "XMLHttpRequest"}, + ) + resp.raise_for_status() + body = resp.text + except Exception: # noqa: BLE001 + logger.exception("failed to fetch UDOT IBI page start=%d", start) + break + try: + payload = json.loads(body) + except ValueError: + logger.warning("UDOT IBI non-JSON response at start=%d", start) + break + total = int(payload.get("recordsTotal") or 0) + rows = payload.get("data") or [] + if not isinstance(rows, list) or not rows: + break + for cam in parse_udot_ibi_page(body, "udot"): + if cam["source_url"] in seen: + continue + seen.add(cam["source_url"]) + out.append(cam) + if start + len(rows) >= total: + break + start += len(rows) + logger.info("UDOT IBI yielded %d cameras", len(out)) + return out + + # ── Persistence ──────────────────────────────────────────────────────────── async def upsert_cameras(cams: list[dict]) -> int: @@ -723,6 +858,7 @@ async def run_cycle() -> int: try: results = await asyncio.gather( *(scrape_source(client, geo, s) for s in CAMERA_SOURCE_URLS), + scrape_udot_ibi(client), return_exceptions=True, ) all_cams: list[dict] = [] diff --git a/app/config.py b/app/config.py index d93f492..b2bdab8 100644 --- a/app/config.py +++ b/app/config.py @@ -71,6 +71,9 @@ FIRMS_DATASETS = [d.strip() for d in _FIRMS_DATASETS_RAW.split(",") if d.strip() OSINT_USER_AGENT = os.getenv( "OSINT_USER_AGENT", "osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)" ) +# Nominatim reverse (GET /api/place). Camera scraper has its own copy in camera_config. +NOMINATIM_URL = os.getenv("NOMINATIM_URL", "https://nominatim.openstreetmap.org") +NOMINATIM_MIN_INTERVAL = float(os.getenv("NOMINATIM_MIN_INTERVAL", "1.0")) # Self-hosted TiTiler (warps Sentinel-1 signed COGs into XYZ tiles on the Pi). # TITILER_PUBLIC_BASE is the same-origin path prefix the browser hits through diff --git a/app/live_layers.py b/app/live_layers.py index 1a0e819..bbc9d71 100644 --- a/app/live_layers.py +++ b/app/live_layers.py @@ -155,6 +155,12 @@ def overlay_catalog() -> dict: "endpoint": "/api/map/gpsjam", "attribution": "GPSJAM / John Wiseman / ADS-B Exchange", }, + "infra_nuclear": { + "id": "infra_nuclear", + "kind": "points", + "endpoint": "/api/infrastructure?types=nuclear", + "attribution": "OpenStreetMap contributors / Overpass API", + }, "conflicts": { "id": "conflicts", "kind": "points", @@ -1444,3 +1450,104 @@ async def fetch_gpsjam(date: str) -> dict: return gpsjam_csv_to_geojson(resp.text) return await _ttl_get(f"gpsjam:{date}", GPSJAM_TTL, _load) +# ── Infrastructure (Overpass) ─────────────────────────────────────────────── + + +OVERPASS_INTERPRETER = "https://overpass-api.de/api/interpreter" +# One in-flight query per quantized bbox (the per-key lock in _ttl_get). Overpass +# asks for a 25s server timeout in-band; the client gives it 30s of headroom. +OVERPASS_TIMEOUT = httpx.Timeout(30.0, connect=5.0) +INFRA_TTL = 24 * 3600 # 24h per quantized bbox — static infrastructure + +# `types=` enum. Nuclear ships first; military/hospital slot in behind the same +# query template without touching the transport. Overpass bbox is +# (south, west, north, east), i.e. (minlat, minlon, maxlat, maxlon). +_INFRA_QUERIES: dict[str, str] = { + "nuclear": ( + '[out:json][timeout:25];\n' + 'nwr["power"="plant"]["plant:source"="nuclear"]({bbox});\n' + 'out center;' + ), +} + + +def infra_query(type_: str, minlon: float, minlat: float, maxlon: float, maxlat: float) -> str: + """Render one Overpass query with the bbox substituted in south,west,north,east.""" + bbox = f"{minlat},{minlon},{maxlat},{maxlon}" + return _INFRA_QUERIES[type_].replace("{bbox}", bbox) + + +def normalize_infra_element(elem: dict, type_: str) -> dict | None: + """Map one Overpass element to ``{id, name, lat, lon, type, extra}``. + + ``out center`` gives nodes their own ``lat``/``lon`` and ways/relations a + ``center``. Elements with no usable coordinate are dropped. + """ + etype = elem.get("type") + eid = elem.get("id") + if eid is None: + return None + if etype == "node": + lat, lon = elem.get("lat"), elem.get("lon") + else: + center = elem.get("center") or {} + lat, lon = center.get("lat"), center.get("lon") + if lat is None or lon is None: + return None + tags = elem.get("tags") or {} + name = tags.get("name") or tags.get("ref") or f"{etype}/{eid}" + extra = {k: v for k, v in tags.items() if k != "name"} + return { + "id": f"{etype}/{eid}", + "name": name, + "lat": lat, + "lon": lon, + "type": type_, + "extra": extra, + } + + +def overpass_nuclear_to_markers(data: dict) -> list[dict]: + """Convert an Overpass JSON response to normalized nuclear markers.""" + markers = [] + for elem in data.get("elements") or []: + marker = normalize_infra_element(elem, "nuclear") + if marker is not None: + markers.append(marker) + return markers + + +async def fetch_infrastructure(types: str, bbox: str) -> list[dict]: + """Fetch Overpass infrastructure markers, cached 24h per quantized bbox. + + ``types`` is a single supported enum value (``nuclear`` for now). ``bbox`` + is ``minlon,minlat,maxlon,maxlat``. + """ + requested = [t.strip() for t in types.split(",") if t.strip()] + minlon, minlat, maxlon, maxlat = parse_bbox(bbox) + key = f"infra:{','.join(requested)}:{bbox_cell_key(bbox)}" + + async def _load() -> list[dict]: + # One query per requested type, concatenated. Nuclear is the only type + # today; the loop keeps the shape ready for military/hospital. + out: list[dict] = [] + for type_ in requested: + query = infra_query(type_, minlon, minlat, maxlon, maxlat) + if _http is None: + async with httpx.AsyncClient( + timeout=OVERPASS_TIMEOUT, follow_redirects=True, + headers=_headers(), + ) as client: + resp = await client.post(OVERPASS_INTERPRETER, data={"data": query}) + resp.raise_for_status() + out.extend(overpass_nuclear_to_markers(resp.json())) + else: + resp = await _http.post( + OVERPASS_INTERPRETER, data={"data": query}, + timeout=OVERPASS_TIMEOUT, + ) + resp.raise_for_status() + out.extend(overpass_nuclear_to_markers(resp.json())) + return out + + return await _ttl_get(key, float(INFRA_TTL), _load) diff --git a/app/main.py b/app/main.py index 1621a07..e53bd9b 100644 --- a/app/main.py +++ b/app/main.py @@ -59,8 +59,9 @@ from live_layers import ( fetch_aircraft, fetch_fire_incidents, fetch_fire_perimeters, fetch_gpsjam, fetch_planespotters_photo, fetch_radar_meta, fetch_sentinel1, fetch_storms, fetch_trains, fetch_vessels, fetch_weather_alerts, - overlay_catalog, parse_bbox, UpstreamRateLimited, + fetch_infrastructure, overlay_catalog, parse_bbox, UpstreamRateLimited, ) +from place import reverse_geocode logging.basicConfig(level=logging.INFO) logger = structlog.get_logger("osint.dashboard") @@ -1844,6 +1845,25 @@ async def list_storms(): _upstream_or_502(exc, "storms") +@app.get("/api/place") +async def get_place( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), +): + """Nominatim reverse geocode for the map \"What's here?\" dossier. + + Identifying ``OSINT_USER_AGENT``, 1 req/s, 60s cache, 500 keys. The HUD + lists already-loaded overlay entities client-side — this route does not + refetch aircraft/vessels/cameras/fires. + """ + try: + return overlay_json(await reverse_geocode(lat, lon), 60) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + except Exception as exc: + _upstream_or_502(exc, "nominatim") + + _GPSJAM_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") @@ -1879,6 +1899,39 @@ async def map_gpsjam( return overlay_json(fc, 3600) +_INFRA_TYPES = frozenset({"nuclear"}) + + +@app.get("/api/infrastructure") +async def api_infrastructure( + types: str = Query(..., description="comma-separated enum (nuclear)"), + bbox: str | None = Query(None, description="minlon,minlat,maxlon,maxlat"), +): + """Overpass-derived static infrastructure markers (nuclear power plants). + + ``bbox`` is required; ``types`` is a comma-separated subset of ``nuclear``. + Fetched from Overpass (identifying UA, 25s query) and cached 24h per + quantized bbox. Markers are ``{id, name, lat, lon, type, extra}``. + """ + if not bbox: + raise HTTPException(400, "bbox required (minlon,minlat,maxlon,maxlat)") + requested = [t.strip() for t in (types or "").split(",") if t.strip()] + if not requested: + raise HTTPException(422, "types required (e.g. nuclear)") + unknown = [t for t in requested if t not in _INFRA_TYPES] + if unknown: + raise HTTPException( + 422, f"unsupported types: {', '.join(unknown)} (supported: nuclear)" + ) + try: + markers = await fetch_infrastructure(",".join(requested), bbox) + except ValueError as exc: + raise HTTPException(422, str(exc)) from exc + except Exception as exc: + _upstream_or_502(exc, "infrastructure") + return overlay_json(markers, 86400) + + @app.get("/api/map/times") async def map_layer_times( layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"), diff --git a/app/place.py b/app/place.py new file mode 100644 index 0000000..e1729ee --- /dev/null +++ b/app/place.py @@ -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) diff --git a/app/static/index.html b/app/static/index.html index 90f7470..e75f9bb 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -408,6 +408,54 @@ .cheat-sheet dd { margin: 0; font-size: 0.78rem; color: var(--muted); } #swpc-chip { color: var(--text); letter-spacing: 0.08em; } #swpc-chip.swpc-storm { color: var(--amber); text-shadow: 0 0 8px rgba(255,180,84,0.45); } + /* ── Place dossier (“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 ── */ .cam-pop { min-width: 210px; max-width: 260px; } @@ -744,6 +792,14 @@ margin-top: 8px; margin-right: 8px; } + #place-dossier { + top: auto; + right: 8px; + left: 8px; + bottom: 56px; + width: auto; + max-height: 36vh; + } } @media (prefers-reduced-motion: reduce) { .tick-track { animation: none; } @@ -1002,6 +1058,21 @@ +
Initializing…
FIRMS
@@ -2158,6 +2229,8 @@ let lastVesselSubBox = ''; // last viewport box sent to the AIS stream let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls let vesselRefollowTimer = null; // one follow-up fetch after a retune +let lastCams = [], lastFires = [], lastAircraft = [], lastVessels = [], lastAlerts = []; +let placeMarker = null, placeReq = 0, placeLongPress = null; function bboxCell() { if (!map) return ''; return currentBBox().split(',').map(n => Number(n).toFixed(2)).join(',') + '@' + map.getZoom(); @@ -2381,6 +2454,17 @@ async function initMap() { map.on('popupclose', () => { camPopupOpen = false; }); map.on('zoomstart', () => { if (camPopupOpen) map.closePopup(); }); map.on('dragstart', () => { if (camPopupOpen) map.closePopup(); }); + map.on('contextmenu', (e) => { + if (e.originalEvent) e.originalEvent.preventDefault(); + if (typeof gfDrawOn !== 'undefined' && gfDrawOn) return; + openPlaceDossier(e.latlng); + }); + bindPlaceLongPress(map); + const pdClose = document.getElementById('pd-close'); + if (pdClose) pdClose.addEventListener('click', closePlaceDossier); + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') closePlaceDossier(); + }); map.on('moveend', () => { if (camPopupOpen) return; // only the popup's own autopan now if (moveDebounce) clearTimeout(moveDebounce); @@ -2777,7 +2861,7 @@ function sinceToISO(sel) { async function toggleFires() { firesOn = document.getElementById('lp-fires-on').checked; if (firesOn) await loadFires(); - else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; } + else if (firesHeat) { map.removeLayer(firesHeat); firesHeat = null; lastFires = []; } hudFiresCount = null; syncHud(); } @@ -2807,6 +2891,7 @@ async function loadFires() { if (req !== fireReq) return; // superseded by a newer pan/zoom if (firesHeat) map.removeLayer(firesHeat); const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)]); + lastFires = Array.isArray(fires) ? fires : []; firesHeat = L.heatLayer(pts, { radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2, gradient: firesGradient(), @@ -2829,7 +2914,7 @@ async function loadFires() { async function toggleCams() { camsOn = document.getElementById('lp-cams-on').checked; if (camsOn) await loadCams(); - else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; } + else if (camsGroup) { map.removeLayer(camsGroup); camsGroup = null; lastCams = []; } hudCamsCount = null; syncHud(); } @@ -2907,6 +2992,7 @@ async function loadCams() { if (!map) return; if (tooZoomedOut()) { camsGroup = dropLayer(camsGroup); + lastCams = []; markZoom('lp-cams-count'); hudCamsCount = null; syncHud(); @@ -2917,6 +3003,7 @@ async function loadCams() { const r = await overlayFetch(`${API}/api/cameras?bbox=${currentBBox()}&limit=2000`); const cams = await r.json(); if (req !== camReq) return; // superseded by a newer pan/zoom + lastCams = Array.isArray(cams) ? cams : []; if (camsGroup) map.removeLayer(camsGroup); // Clustered markers: camera coverage stays visible as numbered // clusters at every zoom instead of vanishing into a sparse/empty @@ -3505,12 +3592,13 @@ function loadThermal() { async function toggleWxAlerts() { wxAlertsOn = document.getElementById('lp-alerts-on').checked; if (wxAlertsOn) await loadWxAlerts(); - else wxAlertsGroup = dropLayer(wxAlertsGroup); + else { wxAlertsGroup = dropLayer(wxAlertsGroup); lastAlerts = []; } } async function loadWxAlerts() { if (!map) return; if (tooZoomedOut()) { wxAlertsGroup = dropLayer(wxAlertsGroup); + lastAlerts = []; markZoom('lp-alerts-count'); return; } @@ -3521,6 +3609,7 @@ async function loadWxAlerts() { if (req !== overlayReq.alerts) return; wxAlertsGroup = dropLayer(wxAlertsGroup); const feats = fc.features || []; + lastAlerts = feats; wxAlertsGroup = L.geoJSON(fc, { renderer: L.canvas({ padding: 0.5 }), style: (f) => ({ @@ -3609,7 +3698,7 @@ async function loadIncidents() { async function toggleAircraft() { acOn = document.getElementById('lp-ac-on').checked; if (acOn) await loadAircraft(); - else acGroup = dropLayer(acGroup); + else { acGroup = dropLayer(acGroup); lastAircraft = []; } } function toggleAircraftMil() { acMilOn = document.getElementById('lp-ac-mil-on').checked; @@ -3629,6 +3718,7 @@ async function loadAircraft() { const all = Array.isArray(pts) ? pts : []; noteMilSupport(all); const shown = acMilOn ? all.filter(acVisible) : all; + lastAircraft = shown; acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac'); setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString()); const milEl = document.getElementById('lp-ac-mil-count'); @@ -3673,6 +3763,7 @@ async function toggleVessels() { await loadVessels(); } else { vesselsGroup = dropLayer(vesselsGroup); + lastVessels = []; clearTimeout(vesselRefollowTimer); } } @@ -3707,7 +3798,8 @@ async function loadVessels() { const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`); const pts = await r.json(); if (req !== overlayReq.vessels) return; - vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => { + lastVessels = Array.isArray(pts) ? pts : []; + vesselsGroup = renderPoints(vesselsGroup, lastVessels, p => { const extra = p.extra || {}; if (extra.role === 'military') return '#f472b6'; if (extra.role === 'government') return '#facc15'; @@ -3749,6 +3841,163 @@ async function loadStorms() { } } +/* ── Place dossier (“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 = '
No loaded cameras, aircraft, vessels, fires, or alerts within 5 km.
'; + 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 ``; + }).join('') + (more > 0 ? `
+ ${more} more
` : ''); + box.querySelectorAll('.pd-item').forEach((btn) => { + btn.addEventListener('click', () => { + const lat = Number(btn.dataset.lat), lon = Number(btn.dataset.lon); + if (!map || Number.isNaN(lat) || Number.isNaN(lon)) return; + map.setView([lat, lon], Math.max(map.getZoom(), 10)); + }); + }); +} +function closePlaceDossier() { + const panel = document.getElementById('place-dossier'); + if (panel) { + panel.classList.remove('open'); + panel.setAttribute('aria-hidden', 'true'); + } + if (placeMarker && map) { map.removeLayer(placeMarker); placeMarker = null; } +} +function bindPlaceLongPress(m) { + const el = m.getContainer(); + const clear = () => { + if (placeLongPress) { clearTimeout(placeLongPress.timer); placeLongPress = null; } + }; + el.addEventListener('touchstart', (ev) => { + if (ev.touches.length !== 1) { clear(); return; } + const t = ev.touches[0]; + placeLongPress = { + x: t.clientX, y: t.clientY, + timer: setTimeout(() => { + const start = placeLongPress; + placeLongPress = null; + if (!start || gfDrawOn) return; + const latlng = m.mouseEventToLatLng({ clientX: start.x, clientY: start.y }); + openPlaceDossier(latlng); + }, 550), + }; + }, { passive: true }); + el.addEventListener('touchmove', (ev) => { + if (!placeLongPress) return; + const t = ev.touches[0]; + if (Math.hypot(t.clientX - placeLongPress.x, t.clientY - placeLongPress.y) > 14) clear(); + }, { passive: true }); + el.addEventListener('touchend', clear); + el.addEventListener('touchcancel', clear); + el.addEventListener('contextmenu', (ev) => ev.preventDefault()); +} +async function openPlaceDossier(latlng) { + if (!latlng || !map) return; + const panel = document.getElementById('place-dossier'); + const status = document.getElementById('pd-status'); + const coords = document.getElementById('pd-coords'); + const nameEl = document.getElementById('pd-name'); + const addrEl = document.getElementById('pd-addr'); + if (!panel) return; + panel.classList.add('open'); + panel.setAttribute('aria-hidden', 'false'); + const lat = latlng.lat, lon = latlng.lng; + if (coords) coords.textContent = `${lat.toFixed(5)}, ${lon.toFixed(5)}`; + if (nameEl) nameEl.textContent = ''; + if (addrEl) addrEl.textContent = ''; + if (status) { status.classList.remove('err'); status.textContent = 'Looking up place…'; } + if (placeMarker) map.removeLayer(placeMarker); + placeMarker = L.circleMarker([lat, lon], { + radius: 7, color: '#35e0ff', weight: 2, fillColor: '#35e0ff', fillOpacity: 0.25, + pane: 'markerPane', + }).addTo(map); + renderNearby(latlng); + const req = ++placeReq; + try { + const r = await fetch(`${API}/api/place?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}`); + if (req !== placeReq) return; + if (!r.ok) throw new Error(`place ${r.status}`); + const d = await r.json(); + if (req !== placeReq) return; + if (nameEl) nameEl.textContent = d.name || d.display_name || 'Unknown place'; + if (addrEl) addrEl.textContent = d.display_name && d.name && d.display_name !== d.name + ? d.display_name : ''; + if (status) status.textContent = d.display_name ? 'Nominatim · OSM' : 'No reverse geocode for this point'; + const closeBtn = document.getElementById('pd-close'); + if (closeBtn) closeBtn.focus(); + } catch (e) { + if (req !== placeReq) return; + if (status) { status.classList.add('err'); status.textContent = 'Place lookup failed — nearby list is still from loaded overlays.'; } + } +} + function conflictSeverityColor(sev) { const s = String(sev || '').toLowerCase(); if (s === 'war') return '#ff2a6d'; diff --git a/docker-compose.yml b/docker-compose.yml index 76d0431..01abd37 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -138,6 +138,8 @@ services: FIRMS_DATASETS: ${FIRMS_DATASETS:-VIIRS_NOAA20_NRT,VIIRS_NOAA21_NRT} FIRMS_BBOX: ${FIRMS_BBOX:--180,-60,180,75} OSINT_USER_AGENT: ${OSINT_USER_AGENT:-osint-dashboard/1.0 (self-hosted; lancewalters94@gmail.com)} + NOMINATIM_URL: ${NOMINATIM_URL:-https://nominatim.openstreetmap.org} + NOMINATIM_MIN_INTERVAL: ${NOMINATIM_MIN_INTERVAL:-1.0} AISSTREAM_API_KEY: ${AISSTREAM_API_KEY:-} AISSTREAM_BBOX: ${AISSTREAM_BBOX:-24,-125,50,-66} AISSTREAM_IN_APP: ${AISSTREAM_IN_APP:-1} diff --git a/tests/test_api_place.py b/tests/test_api_place.py new file mode 100644 index 0000000..52e15ad --- /dev/null +++ b/tests/test_api_place.py @@ -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 diff --git a/tests/test_infrastructure.py b/tests/test_infrastructure.py new file mode 100644 index 0000000..27dd80f --- /dev/null +++ b/tests/test_infrastructure.py @@ -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() diff --git a/tests/test_live_layers.py b/tests/test_live_layers.py index 32b57ad..9b31f71 100644 --- a/tests/test_live_layers.py +++ b/tests/test_live_layers.py @@ -27,7 +27,7 @@ from live_layers import ( _wfigs_params, ) -from camera_scraper import parse_caltrans_json, parse_odot_json, parse_mdot_json +from camera_scraper import parse_caltrans_json, parse_udot_ibi_page, parse_odot_json, parse_mdot_json def test_parse_bbox_and_radius_clamps_to_150_nm(): @@ -259,6 +259,83 @@ def test_parse_caltrans_skips_oos_and_maps_jpeg_hls(): assert "rtsp://" not in cam["snapshot_url"].lower() +# ── UDOT IBI 511 parser ────────────────────────────────────────────────── + +def _udot_row(cam_id, lng, lat, **img_overrides): + img = { + "id": cam_id, "cameraSiteId": cam_id, + "imageUrl": f"/map/Cctv/{cam_id}", "disabled": False, "blocked": False, + } + img.update(img_overrides) + return { + "id": cam_id, "sourceId": "102771", "source": "ADX", + "roadway": "Unknown", "direction": "Unknown", + "location": "Freedom Blvd / 200 W @ 1100 N, PVO", + "latLng": {"geography": { + "coordinateSystemId": 4326, + "wellKnownText": f"POINT ({lng} {lat})"}}, + "images": [img], + } + + +def _udot_page(rows): + import json + return json.dumps({"draw": 0, "recordsTotal": len(rows), + "recordsFiltered": len(rows), "data": rows}) + + +def test_parse_udot_wkt_maps_lng_lat(): + cams = parse_udot_ibi_page(_udot_page([_udot_row(112731, -111.66204, 40.24863)])) + assert len(cams) == 1 + cam = cams[0] + # WKT is `POINT (lng lat)` — order must not be swapped. + assert cam["location_lat"] == 40.24863 + assert cam["location_lon"] == -111.66204 + assert cam["discovery_source"] == "udot" + assert cam["vendor"] == "UDOT" + assert cam["source_url"] == "https://prod-ut.ibi511.com/map/Cctv/112731" + assert cam["snapshot_url"] == cam["source_url"] + assert "rtsp://" not in cam["source_url"].lower() + assert cam["raw"]["udot_id"] == 112731 + + +def test_parse_udot_skips_blocked_and_disabled(): + rows = [ + _udot_row(1, -111.0, 40.0), + _udot_row(2, -111.1, 40.1, blocked=True), + _udot_row(3, -111.2, 40.2, disabled=True), + ] + rows.append(_udot_row(4, -111.3, 40.3)) + rows[3]["images"] = [] # no images → drop + cams = parse_udot_ibi_page(_udot_page(rows)) + assert [c["raw"]["udot_id"] for c in cams] == [1] + + +def test_parse_udot_drops_out_of_bbox(): + rows = [ + _udot_row(1, -111.0, 40.0), # inside Utah + _udot_row(2, -100.0, 40.0), # east of -108.9 + _udot_row(3, -120.0, 40.0), # west of -114.2 + _udot_row(4, -111.0, 44.0), # north of 42.1 + _udot_row(5, -111.0, 30.0), # south of 36.9 + ] + cams = parse_udot_ibi_page(_udot_page(rows)) + assert [c["raw"]["udot_id"] for c in cams] == [1] + + +def test_parse_udot_bad_payload_returns_empty(): + import json + assert parse_udot_ibi_page("not json") == [] + assert parse_udot_ibi_page(json.dumps({"data": None})) == [] + assert parse_udot_ibi_page(json.dumps({"data": "nope"})) == [] + + +def test_parse_udot_missing_wkt_skipped(): + row = _udot_row(1, -111.0, 40.0) + row["latLng"] = {} + assert parse_udot_ibi_page(_udot_page([row])) == [] + + def test_parse_odot_tripcheck_keeps_valid_skips_missing_and_oob(): payload = """ {"features":[ diff --git a/tests/test_place_dossier_frontend.py b/tests/test_place_dossier_frontend.py new file mode 100644 index 0000000..6fc1fd8 --- /dev/null +++ b/tests/test_place_dossier_frontend.py @@ -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