Compare commits

...

2 commits

Author SHA1 Message Date
0becc8a716 Merge pull request 'feat(map): chokepoint preset buttons (Leaflet)' (#28) from feat/chokepoint-preset-buttons into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 3m42s
Reviewed-on: #28
2026-08-29 17:03:15 -04:00
Sirius DevOps
ac03442998 feat(map): chokepoint preset buttons on the Leaflet toolbar
Fetch GET /api/map/chokepoints once. Desktop buttons / phone <select>
fly to Hormuz, Bab el-Mandeb, Suez, Malacca, Taiwan. Turns Vessels on,
uses ?src=vesselapi on Hormuz, enables Sentinel-1 there, and never
POSTs /api/vessels/subscribe outside CONUS.
2026-08-29 14:12:02 -04:00
2 changed files with 141 additions and 3 deletions

View file

@ -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 @@
<button class="btn" id="map-7d-btn" onclick="mapGoDays(7)" style="display:none">-7d</button>
<button class="btn" id="map-30d-btn" onclick="mapGoDays(30)" style="display:none">-30d</button>
<button class="btn" id="map-reset" onclick="mapResetView()">Reset</button>
<span class="mt-label" id="chokepoint-label">Strait</span>
<div class="chokepoint-btns" id="chokepoint-btns" role="group" aria-label="Chokepoint presets"></div>
<select id="chokepoint-select" aria-label="Chokepoint preset" onchange="applyChokepoint(this.value)">
<option value="">Strait…</option>
</select>
</div>
<div class="map-stage" id="map-wrap">
<div id="map"></div>
@ -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 => {

View file

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