From fa06c1dc42e9f5352870384ab4284939dde99fcb Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Sat, 29 Aug 2026 11:15:34 -0400 Subject: [PATCH] feat(map): Sentinel-1 SAR layer toggle (Leaflet) Add a default-off Layers row that fetches GET /api/map/sentinel1?bbox= only when enabled, then L.tileLayer(tileUrl) at opacity 0.8. HUD hints for 404/429/502; moveend refetch while on. Same-origin tileUrl as-is. --- app/static/index.html | 77 +++++++++++++++++++++++++++++++- tests/test_sentinel1_frontend.py | 41 +++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 tests/test_sentinel1_frontend.py diff --git a/app/static/index.html b/app/static/index.html index 38d1849..937e03f 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -328,6 +328,7 @@ .lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; } .lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; } .lp-dot.thermal { background: #f97316; box-shadow: 0 0 7px #f97316; } + .lp-dot.sar { background: #86efac; box-shadow: 0 0 7px #86efac; } .lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; } .lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; } .lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; } @@ -844,6 +845,14 @@ VIIRS +
+
+ + 7d +
+
Opacity 80%
+
Cloud-penetrating C-band. Last 7 days, viewport bbox. Tiles from same-origin TiTiler — not fetched on load.
+
@@ -1880,6 +1889,7 @@ let mapAttributionAdded = null; let extraAttribs = new Set(); let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null; let thermalLayer = null, thermalOn = false; +let sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8; let wxAlertsGroup = null, wxAlertsOn = true; let perimGroup = null, perimOn = true; let incidentsGroup = null, incidentsOn = false; @@ -1887,7 +1897,7 @@ let acGroup = null, acOn = true; let trainsGroup = null, trainsOn = true; let vesselsGroup = null, vesselsOn = false; let stormsGroup = null, stormsOn = true; -let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0}; +let overlayReq = {ac:0, trains:0, vessels:0, alerts:0, perim:0, incidents:0, storms:0, sar:0}; let moveDebounce = null; let overlayAbort = null; let lastCell = ''; @@ -2113,6 +2123,7 @@ async function initMap() { radarOn = document.getElementById('lp-radar-on').checked; wxAlertsOn = document.getElementById('lp-alerts-on').checked; thermalOn = document.getElementById('lp-thermal-on').checked; + sentinelOn = document.getElementById('lp-sentinel-on').checked; perimOn = document.getElementById('lp-perim-on').checked; incidentsOn = document.getElementById('lp-incidents-on').checked; acOn = document.getElementById('lp-ac-on').checked; @@ -2634,6 +2645,7 @@ function refreshLiveOverlays() { if (!map) return; if (radarOn) loadRadar(); if (thermalOn) loadThermal(); + if (sentinelOn) loadSentinel1(); if (wxAlertsOn) loadWxAlerts(); if (perimOn) loadPerimeters(); if (incidentsOn) loadIncidents(); @@ -2948,6 +2960,69 @@ async function toggleThermal() { if (thermalOn) loadThermal(); else thermalLayer = dropLayer(thermalLayer); } +async function toggleSentinel1() { + sentinelOn = document.getElementById('lp-sentinel-on').checked; + if (sentinelOn) await loadSentinel1(); + else { + sentinelLayer = dropLayer(sentinelLayer); + const n = document.getElementById('lp-sentinel-count'); + if (n) n.textContent = '7d'; + } +} +function setSentinelOpacity(v) { + sentinelOpacity = v / 100; + document.getElementById('lp-sentinel-val').textContent = `${Math.round(v)}%`; + if (sentinelLayer) sentinelLayer.setOpacity(sentinelOpacity); +} +async function loadSentinel1() { + if (!map || !sentinelOn) return; + const req = ++overlayReq.sar; + const countEl = document.getElementById('lp-sentinel-count'); + const hint = document.getElementById('map-hint'); + try { + if (countEl) countEl.textContent = '…'; + const r = await overlayFetch(`${API}/api/map/sentinel1?bbox=${currentBBox()}`); + let body = null; + try { body = await r.json(); } catch (_) { body = null; } + if (req !== overlayReq.sar) return; + if (r.status === 404 && body && body.error === 'no_imagery') { + sentinelLayer = dropLayer(sentinelLayer); + if (hint) hint.textContent = 'No Sentinel-1 imagery for this view in the last 7 days.'; + if (countEl) countEl.textContent = 'none'; + return; + } + if (r.status === 429) { + sentinelLayer = dropLayer(sentinelLayer); + if (hint) hint.textContent = 'Sentinel-1 rate limited — retry later.'; + if (countEl) countEl.textContent = '429'; + return; + } + if (!r.ok || !body || !body.tileUrl) { + sentinelLayer = dropLayer(sentinelLayer); + if (hint) hint.textContent = 'Sentinel-1 unavailable — retry later.'; + if (countEl) countEl.textContent = 'err'; + return; + } + const attrib = body.attribution || ''; + sentinelLayer = dropLayer(sentinelLayer); + sentinelLayer = L.tileLayer(body.tileUrl, { + opacity: sentinelOpacity, + maxZoom: 18, + attribution: attrib, + }).addTo(map); + if (countEl) countEl.textContent = String(body.polarization || 'SAR').toUpperCase(); + if (hint) { + const when = body.datetime ? ` · ${body.datetime}` : ''; + hint.textContent = `Sentinel-1 SAR${when} · cloud-penetrating C-band`; + } + } catch (e) { + if (isAbort(e)) return; + if (req !== overlayReq.sar) return; + sentinelLayer = dropLayer(sentinelLayer); + if (hint) hint.textContent = 'Sentinel-1 unavailable — retry later.'; + if (countEl) countEl.textContent = 'err'; + } +} function loadThermal() { if (!map) return; const time = (document.getElementById('map-date') && document.getElementById('map-date').value) diff --git a/tests/test_sentinel1_frontend.py b/tests/test_sentinel1_frontend.py new file mode 100644 index 0000000..94a30a8 --- /dev/null +++ b/tests/test_sentinel1_frontend.py @@ -0,0 +1,41 @@ +"""Sentinel-1 SAR layer: Leaflet toggle contract (no browser STAC/TiTiler).""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HTML = (ROOT / "app/static/index.html").read_text() + + +def test_sentinel1_toggle_default_off(): + assert 'id="lp-sentinel-on"' in HTML + assert 'id="sentinel-1-sar"' in HTML + assert "Sentinel-1 SAR (Cloud-Penetrating)" in HTML + on = HTML.split('id="lp-sentinel-on"', 1)[1].split(">", 1)[0] + assert "checked" not in on + + +def test_sentinel1_fetches_backend_not_planetary_computer(): + assert "/api/map/sentinel1?bbox=" in HTML + assert "titiler.xyz" not in HTML + js = HTML.split("async function loadSentinel1", 1)[1].split("function loadThermal", 1)[0] + assert "planetarycomputer" not in js.lower() + assert "stac" not in js.lower() + + +def test_sentinel1_hud_errors_not_alert(): + js = HTML.split("async function loadSentinel1", 1)[1].split("function loadThermal", 1)[0] + assert "alert(" not in js + assert "No Sentinel-1 imagery for this view in the last 7 days." in js + assert "retry later" in js + assert "opacity: sentinelOpacity" in js + assert "maxZoom: 18" in js + assert "L.tileLayer(body.tileUrl" in js + + +def test_sentinel1_not_fetched_on_init_unless_on(): + init = HTML.split("function initMap", 1)[1].split("function readMapPrefs", 1)[0] + assert "loadSentinel1()" not in init + refresh = HTML.split("function refreshLiveOverlays", 1)[1].split("function addExtraAttrib", 1)[0] + assert "if (sentinelOn) loadSentinel1();" in refresh