From 3aa6f265bb725b60c9afc5e67eb4b55a6686e85a Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Mon, 24 Aug 2026 17:35:44 -0400 Subject: [PATCH] Add NASA GIBS satellite basemap world map tab (Leaflet) Interactive world map panel for the dashboard: - Vendored Leaflet 1.9.4 under app/static/vendor/leaflet/ (served via new /static mount on the FastAPI app) - New Map tab: GIBS WMTS raster basemap (BlueMarble_ShadedRelief_Bathymetry, VIIRS/MODIS/Aqua CorrectedReflectance_TrueColor, VIIRS_DayNightBand), layer picker + UTC date selector (Latest/-7d/-30d quick picks) - /api/map/layers: curated GIBS catalog (tms, format, has_time, max_zoom) - /api/map/times: per-layer date windows from GIBS Domains XML (6h cache, graceful 502 on GIBS hiccup; static layers 422) - Fire hotspots (existing /api/fires) + open cameras (/api/cameras) as toggleable bbox-scoped overlays; dark UI matching dashboard theme --- app/gibs_map.py | 157 +++++ app/main.py | 47 ++ app/static/index.html | 273 +++++++- .../vendor/leaflet/images/layers-2x.png | Bin 0 -> 1259 bytes app/static/vendor/leaflet/images/layers.png | Bin 0 -> 696 bytes .../vendor/leaflet/images/marker-icon-2x.png | Bin 0 -> 2464 bytes .../vendor/leaflet/images/marker-icon.png | Bin 0 -> 1466 bytes .../vendor/leaflet/images/marker-shadow.png | Bin 0 -> 618 bytes app/static/vendor/leaflet/leaflet.css | 661 ++++++++++++++++++ app/static/vendor/leaflet/leaflet.js | 6 + 10 files changed, 1142 insertions(+), 2 deletions(-) create mode 100644 app/gibs_map.py create mode 100644 app/static/vendor/leaflet/images/layers-2x.png create mode 100644 app/static/vendor/leaflet/images/layers.png create mode 100644 app/static/vendor/leaflet/images/marker-icon-2x.png create mode 100644 app/static/vendor/leaflet/images/marker-icon.png create mode 100644 app/static/vendor/leaflet/images/marker-shadow.png create mode 100644 app/static/vendor/leaflet/leaflet.css create mode 100644 app/static/vendor/leaflet/leaflet.js diff --git a/app/gibs_map.py b/app/gibs_map.py new file mode 100644 index 0000000..48d0edb --- /dev/null +++ b/app/gibs_map.py @@ -0,0 +1,157 @@ +"""NASA GIBS basemap catalog + time-domain helper for the OSINT map tab. + +GIBS (Global Imagery Browse Services) is what worldview.earthdata.nasa.gov +renders from. Tiles are plain XYZ/WebMercator WMTS rasters, CORS-open +(access-control-allow-origin: *), so the browser can pull them directly — +no tile proxy needed. + +Layer metadata is curated here (identifier, display title, tile matrix set, +format, whether it has a Time dimension). Available time windows per layer +come from GIBS' per-layer Domains XML endpoint (tiny), fetched on demand and +cached for a few hours. + +Tile URL template (RESTful WMTS): + https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/{layer}/default/{time}/{tms}/{z}/{y}/{x}.{ext} +The {time} path segment is omitted entirely for static layers (no Time dim). +""" + +from __future__ import annotations + +import asyncio +import re +import time +from datetime import date, datetime, timedelta + +import httpx + +GIBS_BASE = "https://gibs.earthdata.nasa.gov/wmts/epsg3857/best" +GIBS_TIMEOUT = 30.0 + +# ── Curated basemap catalog ──────────────────────────────────────────────── +# has_time: True → tile URL includes /default/{time}/; the /api/map/times +# endpoint returns the valid date windows. False → static basemap. +MAP_LAYERS = [ + { + "id": "BlueMarble_ShadedRelief_Bathymetry", + "title": "Blue Marble (shaded relief + bathymetry)", + "subtitle": "Static MODIS composite basemap", + "tms": "GoogleMapsCompatible_Level8", + "format": "jpeg", + "has_time": False, + "max_zoom": 8, + }, + { + "id": "VIIRS_SNPP_CorrectedReflectance_TrueColor", + "title": "VIIRS S-NPP True Color (daily)", + "subtitle": "Suomi NPP corrected reflectance", + "tms": "GoogleMapsCompatible_Level9", + "format": "jpeg", + "has_time": True, + "max_zoom": 9, + }, + { + "id": "MODIS_Terra_CorrectedReflectance_TrueColor", + "title": "MODIS Terra True Color (daily)", + "subtitle": "Terra corrected reflectance", + "tms": "GoogleMapsCompatible_Level9", + "format": "jpeg", + "has_time": True, + "max_zoom": 9, + }, + { + "id": "MODIS_Aqua_CorrectedReflectance_TrueColor", + "title": "MODIS Aqua True Color (daily)", + "subtitle": "Aqua corrected reflectance", + "tms": "GoogleMapsCompatible_Level9", + "format": "jpeg", + "has_time": True, + "max_zoom": 9, + }, + { + "id": "VIIRS_SNPP_DayNightBand_ENCC", + "title": "VIIRS Night Lights (DNB)", + "subtitle": "Earth at night, enhanced near-constant contrast", + "tms": "GoogleMapsCompatible_Level8", + "format": "png", + "has_time": True, + "max_zoom": 8, + }, +] + +_LAYER_BY_ID = {l["id"]: l for l in MAP_LAYERS} + +# ── Time-domain cache (per layer) ────────────────────────────────────────── +# GIBS Domains XML gives a comma-joined list of ISO ranges: START/END/PERIOD +_DOMAIN_CACHE: dict[str, tuple[float, dict]] = {} +_DOMAIN_TTL = 6 * 3600 # 6h +_HTTPS_LOCK = asyncio.Lock() + +# e.g. 2026-07-16/2026-08-24/P1D +_TIME_RANGE_RE = re.compile( + r"^(\d{4}-\d{2}-\d{2})/(\d{4}-\d{2}-\d{2})/(P\d+D|P\d+M|PT\d+H)$" +) + + +def _parse_time_domain(domain: str) -> dict: + """Expand a Domains XML string into usable date metadata. + + Returns {min, max, latest, ranges:[{start,end}]} where ranges keep the + raw GIBS windows so the frontend can warn when a picked date is outside + every window (GIBS still serves nearest-time tiles, but this is honest). + """ + windows = [] + min_d, max_d = None, None + for part in domain.split(","): + part = part.strip() + m = _TIME_RANGE_RE.match(part) + if not m: + continue + start = date.fromisoformat(m.group(1)) + end = date.fromisoformat(m.group(2)) + windows.append({"start": start.isoformat(), "end": end.isoformat()}) + if min_d is None or start < min_d: + min_d = start + if max_d is None or end > max_d: + max_d = end + return { + "min": min_d.isoformat() if min_d else None, + "max": max_d.isoformat() if max_d else None, + "latest": max_d.isoformat() if max_d else None, + "ranges": windows, + } + + +async def fetch_layer_domain(layer_id: str, tms: str) -> dict: + """Return parsed time domain for a layer, cached for _DOMAIN_TTL.""" + now = time.time() + hit = _DOMAIN_CACHE.get(layer_id) + if hit and now - hit[0] < _DOMAIN_TTL: + return hit[1] + + url = ( + f"{GIBS_BASE}/1.0.0/{layer_id}/default/{tms}/all/all.xml" + ) + # One in-flight request per layer; serialize so a burst of layer switches + # doesn't fan out parallel GIBS hits. + async with _HTTPS_LOCK: + hit = _DOMAIN_CACHE.get(layer_id) + if hit and time.time() - hit[0] < _DOMAIN_TTL: + return hit[1] + async with httpx.AsyncClient(timeout=GIBS_TIMEOUT) as client: + resp = await client.get(url) + resp.raise_for_status() + m = re.search(r"([^<]+)", resp.text) + if not m: + raise ValueError(f"No time in GIBS response for {layer_id}") + parsed = _parse_time_domain(m.group(1)) + _DOMAIN_CACHE[layer_id] = (time.time(), parsed) + return parsed + + +def default_time_for(layer_id: str) -> str | None: + """Most recent data date for a time-aware layer (best effort, from cache).""" + hit = _DOMAIN_CACHE.get(layer_id) + if not hit: + return None + latest = hit[1].get("latest") + return latest or None diff --git a/app/main.py b/app/main.py index 9d02941..d87ec63 100644 --- a/app/main.py +++ b/app/main.py @@ -21,6 +21,7 @@ from uuid import UUID import structlog from fastapi import FastAPI, HTTPException, Query from fastapi.responses import FileResponse, HTMLResponse +from fastapi.staticfiles import StaticFiles from sqlalchemy import and_, func, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -841,6 +842,52 @@ async def index(): return FileResponse(str(STATIC_DIR / "index.html")) +# ── NASA GIBS basemap map tab ───────────────────────────────────────────── + +@app.get("/api/map/layers") +async def map_layers(): + """Curated NASA GIBS raster basemap layers for the map tab. + + Each entry has everything the browser needs to render the WMTS tiles: + id — GIBS layer identifier (used in the tile URL path) + title — human-readable display name + tms — GIBS tile matrix set (GoogleMapsCompatible_LevelN) + format — tile image extension (jpeg|png) + has_time — whether the layer has a Time dimension (=> date selector) + max_zoom — highest native zoom served by that tile matrix set + """ + from gibs_map import MAP_LAYERS + return {"layers": MAP_LAYERS} + + +@app.get("/api/map/times") +async def map_layer_times( + layer: str = Query(..., description="GIBS layer identifier, e.g. VIIRS_SNPP_CorrectedReflectance_TrueColor"), +): + """Available date windows for a time-aware GIBS layer. + + Parsed from the layer's Domains XML (GIBS serves nearest-time tiles even + for dates slightly outside a window, but we surface the real ranges so + the UI can clamp the picker and flag out-of-range picks). + """ + from gibs_map import _LAYER_BY_ID, fetch_layer_domain + + meta = _LAYER_BY_ID.get(layer) + if not meta: + raise HTTPException(404, f"Unknown GIBS layer: {layer}") + if not meta["has_time"]: + raise HTTPException(422, f"Layer '{layer}' is static (no Time dimension)") + try: + domain = await fetch_layer_domain(meta["id"], meta["tms"]) + except Exception as exc: # network / GIBS hiccup → degrade gracefully + logger.warning("gibs_domain_fetch_failed", layer=layer, error=str(exc)) + raise HTTPException(502, f"GIBS time domain unavailable: {exc}") + return {"layer": layer, **domain} + + +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/app/static/index.html b/app/static/index.html index 0f7a0ca..bffb987 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -4,6 +4,7 @@ OSINT Dashboard + @@ -106,6 +131,7 @@ + @@ -172,6 +198,28 @@
+ + + +