feat(map): Sentinel-1 SAR layer toggle (Leaflet) #23
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.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)
|
||||
|
|
|
|||
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