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