diff --git a/app/static/index.html b/app/static/index.html
index 937e03f..4100be3 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -174,7 +174,16 @@
border: 1px solid var(--line-hi);
box-shadow: 0 0 16px rgba(53,224,255,0.10);
clip-path: polygon(0 6px, 6px 0, calc(100% - 6px) 0, 100% 6px, 100% calc(100% - 6px), calc(100% - 6px) 100%, 6px 100%, 0 calc(100% - 6px));
+ max-width: calc(100% - 300px); /* keep off LAYERS (left) and zoom (right) */
+ justify-content: center;
}
+ .chokepoint-btns { display: flex; flex-wrap: wrap; align-items: center; gap: 0.25rem; }
+ .chokepoint-btns .btn { padding: 0.22rem 0.42rem; font-size: 0.66rem; letter-spacing: 0.04em; }
+ .chokepoint-btns .btn.is-on {
+ border-color: var(--cyan); color: var(--cyan);
+ box-shadow: 0 0 8px var(--cyan-glow); background: rgba(53,224,255,0.10);
+ }
+ #chokepoint-select { display: none; }
.map-toolbar select, .map-toolbar input[type="date"], .map-toolbar input[type="datetime-local"] {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: 4px; padding: 0.3rem 0.45rem; font-size: 0.76rem;
@@ -641,6 +650,8 @@
flex: 1 1 8rem;
min-width: 0;
}
+ #chokepoint-label, .chokepoint-btns { display: none; }
+ #chokepoint-select { display: block; }
.map-stage {
flex: 1 1 auto;
height: auto;
@@ -749,6 +760,11 @@
+ Strait
+
@@ -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