From ac03442998c55777164b2cbd9df2660da2ca7544 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Sat, 29 Aug 2026 14:12:02 -0400 Subject: [PATCH] feat(map): chokepoint preset buttons on the Leaflet toolbar Fetch GET /api/map/chokepoints once. Desktop buttons / phone + +
@@ -1902,6 +1918,8 @@ let moveDebounce = null; let overlayAbort = null; let lastCell = ''; let lastVesselSubBox = ''; // last viewport box sent to the AIS stream +let chokepointCatalog = []; // GET /api/map/chokepoints, fetched once +let vesselSrcPref = ''; // 'vesselapi' only on Hormuz preset — no extra polls let vesselRefollowTimer = null; // one follow-up fetch after a retune function bboxCell() { if (!map) return ''; @@ -2023,6 +2041,7 @@ async function initMap() { maxZoom: 12, attributionControl: true, }); + window.map = map; L.control.zoom({ position: 'topright' }).addTo(map); map.attributionControl.setPrefix(''); let activeHls = null; @@ -2131,6 +2150,7 @@ async function initMap() { vesselsOn = document.getElementById('lp-vessels-on').checked; stormsOn = document.getElementById('lp-storms-on').checked; connectLiveWs(); + loadChokepoints(); requestAnimationFrame(() => { if (firesOn) loadFires(); setTimeout(() => { @@ -2258,7 +2278,93 @@ function mapGoDays(n) { document.getElementById('map-date').value = iso; mapDateChanged(); } -function mapResetView() { if (map) map.setView([25, 10], 2); } +function mapResetView() { + vesselSrcPref = ''; + document.querySelectorAll('#chokepoint-btns .btn').forEach(b => { + b.classList.remove('is-on'); + b.setAttribute('aria-pressed', 'false'); + }); + const sel = document.getElementById('chokepoint-select'); + if (sel) sel.value = ''; + if (map) map.setView([25, 10], 2); +} + +const CHOKEPOINT_SHORT = { + hormuz: 'Hormuz', + bab_el_mandeb: 'Bab el-Mandeb', + suez: 'Suez', + malacca: 'Malacca', + taiwan: 'Taiwan', +}; +async function loadChokepoints() { + try { + const r = await fetch(`${API}/api/map/chokepoints`); + const d = await r.json(); + if (r.ok && Array.isArray(d.chokepoints)) chokepointCatalog = d.chokepoints; + } catch (_) { /* catalog is optional until the backend PR lands */ } + renderChokepointControls(); +} +function renderChokepointControls() { + const btns = document.getElementById('chokepoint-btns'); + const sel = document.getElementById('chokepoint-select'); + if (!btns || !sel) return; + btns.replaceChildren(); + sel.querySelectorAll('option:not([value=""])').forEach(o => o.remove()); + for (const p of chokepointCatalog) { + const label = CHOKEPOINT_SHORT[p.id] || p.title || p.id; + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'btn'; + b.dataset.id = p.id; + b.textContent = label; + b.setAttribute('aria-pressed', 'false'); + b.addEventListener('click', () => applyChokepoint(p.id)); + btns.appendChild(b); + const opt = document.createElement('option'); + opt.value = p.id; + opt.textContent = label; + sel.appendChild(opt); + } +} +function chokepointLeafletBounds(bbox) { + // Catalog bbox is minlat,minlon,maxlat,maxlon — Leaflet wants [[lat,lon],[lat,lon]]. + const [minlat, minlon, maxlat, maxlon] = String(bbox || '').split(',').map(Number); + if (![minlat, minlon, maxlat, maxlon].every(Number.isFinite)) return null; + return [[minlat, minlon], [maxlat, maxlon]]; +} +function applyChokepoint(id) { + if (!id || !map) return; + const p = chokepointCatalog.find(x => x.id === id); + if (!p) return; + document.querySelectorAll('#chokepoint-btns .btn').forEach(b => { + const on = b.dataset.id === id; + b.classList.toggle('is-on', on); + b.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + const sel = document.getElementById('chokepoint-select'); + if (sel && sel.value !== id) sel.value = id; + vesselSrcPref = p.vesselapi ? 'vesselapi' : ''; + if (Array.isArray(p.center) && p.center.length === 2 && p.zoom) { + map.setView(p.center, p.zoom); + } else { + const bounds = chokepointLeafletBounds(p.bbox); + if (bounds) map.fitBounds(bounds, { padding: [24, 24] }); + } + const vChk = document.getElementById('lp-vessels-on'); + if (vChk) vChk.checked = true; + vesselsOn = true; + // Hormuz especially: turn SAR on so AIS dots sit on the radar raster. User can untick. + if (p.id === 'hormuz' || p.vesselapi) { + const sChk = document.getElementById('lp-sentinel-on'); + if (sChk && !sChk.checked) { + sChk.checked = true; + sentinelOn = true; + } + } + lastCell = ''; + if (vesselsOn) loadVessels(); + if (sentinelOn) loadSentinel1(); +} function currentBBox() { const b = map.getBounds(); @@ -3208,7 +3314,9 @@ async function loadVessels() { const isWorld = (be - bw) >= 300 || bb.startsWith('-180.0000,-85.0000,180.0000,85.0000'); const subKey = isWorld ? '' : quantizeBBox(bb); let retuned = false; - if (subKey && subKey !== lastVesselSubBox) { + // AISStream subscribe retunes the US feed. Never fire it for Gulf/strait + // boxes or the free stream leaves CONUS. VesselAPI is a cached poll. + if (subKey && subKey !== lastVesselSubBox && intersectsConus()) { lastVesselSubBox = subKey; retuned = true; fetch(`${API}/api/vessels/subscribe`, { @@ -3218,7 +3326,8 @@ async function loadVessels() { }).catch(() => {}); } try { - const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${dvrQs()}`); + const srcQs = vesselSrcPref ? `&src=${encodeURIComponent(vesselSrcPref)}` : ''; + const r = await overlayFetch(`${API}/api/vessels?bbox=${bb}${srcQs}${dvrQs()}`); const pts = await r.json(); if (req !== overlayReq.vessels) return; vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => { diff --git a/tests/test_frontend_reliability.py b/tests/test_frontend_reliability.py index 908f779..d734f49 100644 --- a/tests/test_frontend_reliability.py +++ b/tests/test_frontend_reliability.py @@ -47,3 +47,32 @@ def test_ws_reconnect_uses_backoff(): def test_check_health_treats_degraded_status(): fn = HTML.split("async function checkHealth")[1].split("/* ═══════════════ NAV")[0] assert "degraded" in fn.lower() or "d.status" in fn + + +def test_chokepoint_presets_in_toolbar(): + assert 'id="chokepoint-btns"' in HTML + assert 'id="chokepoint-select"' in HTML + assert "loadChokepoints()" in HTML + assert "/api/map/chokepoints" in HTML + assert "function applyChokepoint" in HTML + for name in ("Hormuz", "Bab el-Mandeb", "Suez", "Malacca", "Taiwan"): + assert name in HTML + + +def test_chokepoint_skips_aisstream_subscribe_outside_conus(): + load = HTML.split("async function loadVessels")[1].split("async function toggleStorms")[0] + assert "intersectsConus()" in load + assert "api/vessels/subscribe" in load + assert "src=${encodeURIComponent(vesselSrcPref)}" in load or "&src=" in load + apply = HTML.split("function applyChokepoint")[1].split("function currentBBox")[0] + assert "vesselapi" in apply + assert "lp-vessels-on" in apply + assert "lp-sentinel-on" in apply + assert "map.setView" in apply + assert "minlat,minlon,maxlat,maxlon" in HTML.split("function chokepointLeafletBounds")[1][:400] + + +def test_phone_chokepoints_use_select_not_buttons(): + mobile = HTML.split("@media (max-width: 820px)")[1].split("@media (prefers-reduced-motion")[0] + assert "#chokepoint-select { display: block; }" in mobile + assert ".chokepoint-btns { display: none; }" in mobile or "#chokepoint-label, .chokepoint-btns { display: none; }" in mobile