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
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 3m42s
Reviewed-on: #28
This commit is contained in:
commit
0becc8a716
2 changed files with 141 additions and 3 deletions
|
|
@ -174,7 +174,16 @@
|
||||||
border: 1px solid var(--line-hi);
|
border: 1px solid var(--line-hi);
|
||||||
box-shadow: 0 0 16px rgba(53,224,255,0.10);
|
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));
|
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"] {
|
.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);
|
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
||||||
border-radius: 4px; padding: 0.3rem 0.45rem; font-size: 0.76rem;
|
border-radius: 4px; padding: 0.3rem 0.45rem; font-size: 0.76rem;
|
||||||
|
|
@ -641,6 +650,8 @@
|
||||||
flex: 1 1 8rem;
|
flex: 1 1 8rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
#chokepoint-label, .chokepoint-btns { display: none; }
|
||||||
|
#chokepoint-select { display: block; }
|
||||||
.map-stage {
|
.map-stage {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
height: 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-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-30d-btn" onclick="mapGoDays(30)" style="display:none">-30d</button>
|
||||||
<button class="btn" id="map-reset" onclick="mapResetView()">Reset</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>
|
||||||
<div class="map-stage" id="map-wrap">
|
<div class="map-stage" id="map-wrap">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
|
|
@ -1902,6 +1918,8 @@ let moveDebounce = null;
|
||||||
let overlayAbort = null;
|
let overlayAbort = null;
|
||||||
let lastCell = '';
|
let lastCell = '';
|
||||||
let lastVesselSubBox = ''; // last viewport box sent to the AIS stream
|
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
|
let vesselRefollowTimer = null; // one follow-up fetch after a retune
|
||||||
function bboxCell() {
|
function bboxCell() {
|
||||||
if (!map) return '';
|
if (!map) return '';
|
||||||
|
|
@ -2023,6 +2041,7 @@ async function initMap() {
|
||||||
maxZoom: 12,
|
maxZoom: 12,
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
});
|
});
|
||||||
|
window.map = map;
|
||||||
L.control.zoom({ position: 'topright' }).addTo(map);
|
L.control.zoom({ position: 'topright' }).addTo(map);
|
||||||
map.attributionControl.setPrefix('');
|
map.attributionControl.setPrefix('');
|
||||||
let activeHls = null;
|
let activeHls = null;
|
||||||
|
|
@ -2131,6 +2150,7 @@ async function initMap() {
|
||||||
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
vesselsOn = document.getElementById('lp-vessels-on').checked;
|
||||||
stormsOn = document.getElementById('lp-storms-on').checked;
|
stormsOn = document.getElementById('lp-storms-on').checked;
|
||||||
connectLiveWs();
|
connectLiveWs();
|
||||||
|
loadChokepoints();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (firesOn) loadFires();
|
if (firesOn) loadFires();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -2258,7 +2278,93 @@ function mapGoDays(n) {
|
||||||
document.getElementById('map-date').value = iso;
|
document.getElementById('map-date').value = iso;
|
||||||
mapDateChanged();
|
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() {
|
function currentBBox() {
|
||||||
const b = map.getBounds();
|
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 isWorld = (be - bw) >= 300 || bb.startsWith('-180.0000,-85.0000,180.0000,85.0000');
|
||||||
const subKey = isWorld ? '' : quantizeBBox(bb);
|
const subKey = isWorld ? '' : quantizeBBox(bb);
|
||||||
let retuned = false;
|
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;
|
lastVesselSubBox = subKey;
|
||||||
retuned = true;
|
retuned = true;
|
||||||
fetch(`${API}/api/vessels/subscribe`, {
|
fetch(`${API}/api/vessels/subscribe`, {
|
||||||
|
|
@ -3218,7 +3326,8 @@ async function loadVessels() {
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
try {
|
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();
|
const pts = await r.json();
|
||||||
if (req !== overlayReq.vessels) return;
|
if (req !== overlayReq.vessels) return;
|
||||||
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
|
vesselsGroup = renderPoints(vesselsGroup, Array.isArray(pts) ? pts : [], p => {
|
||||||
|
|
|
||||||
|
|
@ -47,3 +47,32 @@ def test_ws_reconnect_uses_backoff():
|
||||||
def test_check_health_treats_degraded_status():
|
def test_check_health_treats_degraded_status():
|
||||||
fn = HTML.split("async function checkHealth")[1].split("/* ═══════════════ NAV")[0]
|
fn = HTML.split("async function checkHealth")[1].split("/* ═══════════════ NAV")[0]
|
||||||
assert "degraded" in fn.lower() or "d.status" in fn
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue