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:
parent
4b9760d6e1
commit
fa06c1dc42
2 changed files with 117 additions and 1 deletions
|
|
@ -328,6 +328,7 @@
|
||||||
.lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; }
|
.lp-dot.vessels { background: #2dd4bf; box-shadow: 0 0 7px #2dd4bf; }
|
||||||
.lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
|
.lp-dot.radar { background: #38bdf8; box-shadow: 0 0 7px #38bdf8; }
|
||||||
.lp-dot.thermal { background: #f97316; box-shadow: 0 0 7px #f97316; }
|
.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.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
|
||||||
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
|
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
|
||||||
.lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; }
|
.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>
|
<span class="lp-count" id="lp-thermal-count">VIIRS</span>
|
||||||
</div>
|
</div>
|
||||||
</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-layer">
|
||||||
<div class="lp-row">
|
<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>
|
<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 extraAttribs = new Set();
|
||||||
let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
|
let radarLayer = null, radarOn = true, radarOpacity = 0.7, radarMeta = null, radarTimer = null;
|
||||||
let thermalLayer = null, thermalOn = false;
|
let thermalLayer = null, thermalOn = false;
|
||||||
|
let sentinelLayer = null, sentinelOn = false, sentinelOpacity = 0.8;
|
||||||
let wxAlertsGroup = null, wxAlertsOn = true;
|
let wxAlertsGroup = null, wxAlertsOn = true;
|
||||||
let perimGroup = null, perimOn = true;
|
let perimGroup = null, perimOn = true;
|
||||||
let incidentsGroup = null, incidentsOn = false;
|
let incidentsGroup = null, incidentsOn = false;
|
||||||
|
|
@ -1887,7 +1897,7 @@ let acGroup = null, acOn = true;
|
||||||
let trainsGroup = null, trainsOn = true;
|
let trainsGroup = null, trainsOn = true;
|
||||||
let vesselsGroup = null, vesselsOn = false;
|
let vesselsGroup = null, vesselsOn = false;
|
||||||
let stormsGroup = null, stormsOn = true;
|
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 moveDebounce = null;
|
||||||
let overlayAbort = null;
|
let overlayAbort = null;
|
||||||
let lastCell = '';
|
let lastCell = '';
|
||||||
|
|
@ -2113,6 +2123,7 @@ async function initMap() {
|
||||||
radarOn = document.getElementById('lp-radar-on').checked;
|
radarOn = document.getElementById('lp-radar-on').checked;
|
||||||
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
|
wxAlertsOn = document.getElementById('lp-alerts-on').checked;
|
||||||
thermalOn = document.getElementById('lp-thermal-on').checked;
|
thermalOn = document.getElementById('lp-thermal-on').checked;
|
||||||
|
sentinelOn = document.getElementById('lp-sentinel-on').checked;
|
||||||
perimOn = document.getElementById('lp-perim-on').checked;
|
perimOn = document.getElementById('lp-perim-on').checked;
|
||||||
incidentsOn = document.getElementById('lp-incidents-on').checked;
|
incidentsOn = document.getElementById('lp-incidents-on').checked;
|
||||||
acOn = document.getElementById('lp-ac-on').checked;
|
acOn = document.getElementById('lp-ac-on').checked;
|
||||||
|
|
@ -2634,6 +2645,7 @@ function refreshLiveOverlays() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (radarOn) loadRadar();
|
if (radarOn) loadRadar();
|
||||||
if (thermalOn) loadThermal();
|
if (thermalOn) loadThermal();
|
||||||
|
if (sentinelOn) loadSentinel1();
|
||||||
if (wxAlertsOn) loadWxAlerts();
|
if (wxAlertsOn) loadWxAlerts();
|
||||||
if (perimOn) loadPerimeters();
|
if (perimOn) loadPerimeters();
|
||||||
if (incidentsOn) loadIncidents();
|
if (incidentsOn) loadIncidents();
|
||||||
|
|
@ -2948,6 +2960,69 @@ async function toggleThermal() {
|
||||||
if (thermalOn) loadThermal();
|
if (thermalOn) loadThermal();
|
||||||
else thermalLayer = dropLayer(thermalLayer);
|
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() {
|
function loadThermal() {
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
const time = (document.getElementById('map-date') && document.getElementById('map-date').value)
|
const time = (document.getElementById('map-date') && document.getElementById('map-date').value)
|
||||||
|
|
|
||||||
41
tests/test_sentinel1_frontend.py
Normal file
41
tests/test_sentinel1_frontend.py
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue