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.
This commit is contained in:
Sirius DevOps 2026-08-29 11:15:34 -04:00
parent 4b9760d6e1
commit fa06c1dc42
2 changed files with 117 additions and 1 deletions

View file

@ -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 @@
<span class="lp-count" id="lp-thermal-count">VIIRS</span>
</div>
</div>
<div class="lp-layer" id="sentinel-1-sar">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-sentinel-on" onchange="toggleSentinel1()"> <span class="lp-dot sar"></span> Sentinel-1 SAR (Cloud-Penetrating)</label>
<span class="lp-count" id="lp-sentinel-count">7d</span>
</div>
<div class="lp-opacity">Opacity <input type="range" id="lp-sentinel-opacity" min="5" max="100" value="80" oninput="setSentinelOpacity(this.value)"><b id="lp-sentinel-val">80%</b></div>
<div class="lp-note">Cloud-penetrating C-band. Last 7 days, viewport bbox. Tiles from same-origin TiTiler — not fetched on load.</div>
</div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-perim-on" checked onchange="togglePerimeters()"> <span class="lp-dot wfigs"></span> WFIGS Perimeters</label>
@ -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)

View file

@ -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