Compare commits

..

2 commits

Author SHA1 Message Date
cb20473119 Merge pull request 'feat(map): geofence watch HUD — finish/cancel, inbox, fence DVR' (#48) from feat/geofence-watch-ui into master
All checks were successful
build-and-deploy / build-push-deploy (push) Successful in 21s
Reviewed-on: #48
2026-09-01 07:44:36 -04:00
Sirius DevOps
8c97ce50d2 feat(map): geofence watch HUD — finish/cancel, inbox, fence DVR
Phone can finish a polygon without double-click. List is mute/go/rename/delete.
Inbox seeds from /api/geofence-alerts and appends WS hits. Watch all fence
ids on /ws/live. Selected-fence DVR uses GET /api/geofences/{id}/at.
2026-09-01 01:36:21 -04:00
2 changed files with 328 additions and 19 deletions

View file

@ -374,11 +374,34 @@
.lp-future .lp-name { cursor: not-allowed; } .lp-future .lp-name { cursor: not-allowed; }
.lp-error { color: var(--red); font-size: 0.66rem; } .lp-error { color: var(--red); font-size: 0.66rem; }
.lp-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; } .lp-note { font-size: 0.6rem; color: var(--muted); opacity: 0.85; line-height: 1.35; }
.gf-list { display: flex; flex-direction: column; gap: 0.22rem; max-height: 8rem; overflow-y: auto; } .gf-list { display: flex; flex-direction: column; gap: 0.22rem; max-height: 9.5rem; overflow-y: auto; }
.gf-item { display: flex; justify-content: space-between; align-items: center; gap: 0.4rem; font-size: 0.64rem; } .gf-item { display: flex; justify-content: space-between; align-items: center; gap: 0.35rem; font-size: 0.64rem; flex-wrap: wrap; }
.gf-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); } .gf-item.selected { outline: 1px solid var(--cyan); background: rgba(53,224,255,0.08); border-radius: 3px; }
.gf-del { background: transparent; border: 1px solid var(--magenta); color: var(--magenta); font-family: 'Share Tech Mono', monospace; font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.12rem 0.35rem; border-radius: 3px; cursor: pointer; } .gf-item .gf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); background: none; border: 0; font: inherit; cursor: pointer; text-align: left; flex: 1 1 6rem; min-width: 0; padding: 0.12rem 0; }
.gf-item .gf-name:hover { color: var(--cyan); }
.gf-item .gf-actions { display: flex; align-items: center; gap: 0.28rem; flex: 0 0 auto; }
.gf-item input.gf-active { accent-color: var(--cyan); cursor: pointer; width: 14px; height: 14px; }
.gf-del, .gf-go, .gf-ren { background: transparent; border: 1px solid var(--magenta); color: var(--magenta); font-family: 'Share Tech Mono', monospace; font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; padding: 0.12rem 0.35rem; border-radius: 3px; cursor: pointer; }
.gf-go, .gf-ren { border-color: var(--cyan); color: var(--cyan); }
.gf-del:hover { background: rgba(255,46,151,0.16); } .gf-del:hover { background: rgba(255,46,151,0.16); }
.gf-go:hover, .gf-ren:hover { background: rgba(53,224,255,0.14); }
.gf-draw-row { flex-wrap: wrap; gap: 0.35rem; }
#gf-finish, #gf-cancel { min-height: 32px; }
#gf-draw-bar {
position: absolute; left: 12px; bottom: 52px; z-index: 530;
display: flex; gap: 0.4rem; align-items: center;
background: rgba(6,11,20,0.86); border: 1px solid var(--line-hi);
border-radius: 4px; padding: 0.22rem 0.4rem;
}
#gf-draw-bar[hidden] { display: none !important; }
.gf-inbox { display: flex; flex-direction: column; gap: 0.18rem; max-height: 8rem; overflow-y: auto; margin-top: 0.28rem; }
.gf-hit {
cursor: pointer; font-size: 0.58rem; line-height: 1.35; color: var(--text);
padding: 0.22rem 0.28rem; border: 1px solid var(--line); border-radius: 3px;
font-family: 'Share Tech Mono', monospace;
}
.gf-hit:hover, .gf-hit:focus { border-color: var(--cyan); outline: none; background: rgba(53,224,255,0.08); }
.gf-hit .gf-hit-meta { color: var(--muted); }
/* ── Keyboard cheat sheet (centered; keep off LAYERS / zoom / dock) ── */ /* ── Keyboard cheat sheet (centered; keep off LAYERS / zoom / dock) ── */
.cheat-sheet { .cheat-sheet {
@ -787,6 +810,13 @@
min-width: 0; min-width: 0;
min-height: 32px; min-height: 32px;
} }
#gf-draw-bar {
left: 8px;
bottom: 56px;
}
#gf-finish, #gf-cancel, #gf-draw-bar .btn, .gf-go, .gf-del, .gf-ren {
min-height: 32px;
}
.leaflet-control-attribution { display: none !important; } .leaflet-control-attribution { display: none !important; }
.leaflet-top.leaflet-right .leaflet-control-zoom { .leaflet-top.leaflet-right .leaflet-control-zoom {
margin-top: 8px; margin-top: 8px;
@ -1029,11 +1059,14 @@
<div class="lp-note">Two feeds: AISStream (US live) and VesselAPI (Hormuz, 5×/day). Paste both keys on the Keys page. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div> <div class="lp-note">Two feeds: AISStream (US live) and VesselAPI (Hormuz, 5×/day). Paste both keys on the Keys page. Magenta = military · gold = law/gov · teal = underway. Click for type / dest / IMO.</div>
</div> </div>
<div class="lp-layer"> <div class="lp-layer">
<div class="lp-row"> <div class="lp-row gf-draw-row">
<button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button> <button class="btn" id="gf-draw" type="button" onclick="toggleGeofenceDraw()">Draw geofence</button>
<button class="btn" id="gf-finish" type="button" hidden onclick="onGfClose(event)">Finish</button>
<button class="btn" id="gf-cancel" type="button" hidden onclick="cancelGeofenceDraw()">Cancel</button>
</div> </div>
<div id="gf-list" class="gf-list"></div> <div id="gf-list" class="gf-list"></div>
<div class="lp-note">Click vertices, double-click to close. Delete a saved fence from the list or its popup.</div> <div id="gf-inbox" class="gf-inbox" aria-label="Geofence alerts"></div>
<div class="lp-note">Tap vertices, then Finish (or double-click). Inbox keeps hits after the toast fades.</div>
</div> </div>
<div class="lp-layer"> <div class="lp-layer">
<div class="lp-row"> <div class="lp-row">
@ -1095,6 +1128,10 @@
<input type="range" id="dvr-slider" min="0" max="360" value="360" oninput="dvrScrub(this.value)" aria-label="DVR time"> <input type="range" id="dvr-slider" min="0" max="360" value="360" oninput="dvrScrub(this.value)" aria-label="DVR time">
<span id="dvr-label">live</span> <span id="dvr-label">live</span>
</div> </div>
<div id="gf-draw-bar" hidden>
<button class="btn" type="button" onclick="onGfClose(event)">Finish</button>
<button class="btn" type="button" onclick="cancelGeofenceDraw()">Cancel</button>
</div>
<div id="dvr-toast"></div> <div id="dvr-toast"></div>
</div> </div>
</section> </section>
@ -2242,6 +2279,8 @@ let firefighterHex = new Set();
let dvrTs = null; let dvrTs = null;
let dvrMinMs = 0, dvrMaxMs = 0; let dvrMinMs = 0, dvrMaxMs = 0;
let gfDrawOn = false, gfVerts = [], gfLayer = null, gfSaved = null; let gfDrawOn = false, gfVerts = [], gfLayer = null, gfSaved = null;
let gfWatchIds = [], gfSelectedId = null, gfLayerById = {}, gfNameById = {};
let gfAtReq = 0, dvrRangeFromTracks = false, gfAtOk = true;
function liveWsUrl() { function liveWsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws/live'; return proto + '//' + location.host + '/ws/live';
@ -2249,6 +2288,11 @@ function liveWsUrl() {
function sendLiveViewport() { function sendLiveViewport() {
if (!liveWs || liveWs.readyState !== 1 || !map) return; if (!liveWs || liveWs.readyState !== 1 || !map) return;
liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() })); liveWs.send(JSON.stringify({ type: 'viewport', bbox: currentBBox() }));
sendWatchGeofences();
}
function sendWatchGeofences() {
if (!liveWs || liveWs.readyState !== 1) return;
liveWs.send(JSON.stringify({ type: 'watch_geofences', ids: gfWatchIds }));
} }
function dropLivePoint(group, id) { function dropLivePoint(group, id) {
if (!group || !group._osintById || id == null) return; if (!group || !group._osintById || id == null) return;
@ -2291,6 +2335,7 @@ function applyLiveMarker(kind, p) {
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac'); acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
} else if (kind === 'geofence_alert') { } else if (kind === 'geofence_alert') {
showGeofenceToast(p); showGeofenceToast(p);
pushGfInbox(p);
} else if (kind === 'fire_aircraft') { } else if (kind === 'fire_aircraft') {
firefighterHex.add(String(p.aircraft_hex || '')); firefighterHex.add(String(p.aircraft_hex || ''));
if (acOn && p.aircraft_lat != null) { if (acOn && p.aircraft_lat != null) {
@ -2338,7 +2383,7 @@ function connectLiveWs() {
setTimeout(connectLiveWs, delay); setTimeout(connectLiveWs, delay);
return; return;
} }
liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); }; liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); sendWatchGeofences(); };
liveWs.onmessage = (ev) => { liveWs.onmessage = (ev) => {
try { try {
const msg = JSON.parse(ev.data); const msg = JSON.parse(ev.data);
@ -2881,6 +2926,7 @@ function setFiresSince() {
} }
async function loadFires() { async function loadFires() {
if (!map) return; if (!map) return;
if (fenceDvrOn()) return;
const req = ++fireReq; const req = ++fireReq;
try { try {
let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000&format=heat`; let url = `${API}/api/fires?bbox=${currentBBox()}&limit=2000&format=heat`;
@ -3704,8 +3750,9 @@ function toggleAircraftMil() {
acMilOn = document.getElementById('lp-ac-mil-on').checked; acMilOn = document.getElementById('lp-ac-mil-on').checked;
if (acOn) loadAircraft(); if (acOn) loadAircraft();
} }
async function loadAircraft() { async function loadAircraft(force) {
if (!map) return; if (!map) return;
if (!force && fenceDvrOn()) return;
if (map.getZoom() <= 3) { if (map.getZoom() <= 3) {
setLayerCount('lp-ac-count', 'zoom'); setLayerCount('lp-ac-count', 'zoom');
return; return;
@ -3729,6 +3776,7 @@ async function loadAircraft() {
} }
addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL'); addExtraAttrib('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>'); addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
refreshDvrRangeOnce();
} catch (e) { } catch (e) {
if (isAbort(e)) return; if (isAbort(e)) return;
console.error('Aircraft load failed', e); console.error('Aircraft load failed', e);
@ -3767,8 +3815,9 @@ async function toggleVessels() {
clearTimeout(vesselRefollowTimer); clearTimeout(vesselRefollowTimer);
} }
} }
async function loadVessels() { async function loadVessels(force) {
if (!map) return; if (!map) return;
if (!force && fenceDvrOn()) return;
if (map.getZoom() <= 3) { if (map.getZoom() <= 3) {
setLayerCount('lp-vessels-count', 'zoom'); setLayerCount('lp-vessels-count', 'zoom');
return; return;
@ -3808,6 +3857,7 @@ async function loadVessels() {
}, true, 'vessel'); }, true, 'vessel');
setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString()); setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString());
addExtraAttrib('AISStream'); addExtraAttrib('AISStream');
refreshDvrRangeOnce();
// First positions for a new zone arrive ~1-3s after the retune. // First positions for a new zone arrive ~1-3s after the retune.
if (retuned) { if (retuned) {
clearTimeout(vesselRefollowTimer); clearTimeout(vesselRefollowTimer);
@ -4110,6 +4160,12 @@ setInterval(pollSwpc, SWPC_POLL_MS);
/* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */ /* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */
function dvrQs() { return dvrTs ? `&timestamp=${encodeURIComponent(dvrTs)}` : ''; } function dvrQs() { return dvrTs ? `&timestamp=${encodeURIComponent(dvrTs)}` : ''; }
function fenceDvrOn() { return !!(gfSelectedId && dvrTs && gfAtOk); }
function refreshDvrRangeOnce() {
if (dvrRangeFromTracks) return;
dvrRangeFromTracks = true;
initDvrRange();
}
function dvrGoLive() { function dvrGoLive() {
dvrTs = null; dvrTs = null;
const bar = document.getElementById('dvr-bar'); const bar = document.getElementById('dvr-bar');
@ -4130,6 +4186,15 @@ function dvrScrub(v) {
if (Number(v) >= 359) { dvrGoLive(); return; } if (Number(v) >= 359) { dvrGoLive(); return; }
const lab = document.getElementById('dvr-label'); const lab = document.getElementById('dvr-label');
if (lab) lab.textContent = dvrTs.slice(11, 16) + 'Z'; if (lab) lab.textContent = dvrTs.slice(11, 16) + 'Z';
if (gfSelectedId) {
dvrScrubFence().then(ok => {
if (!ok) {
if (acOn) loadAircraft(true);
if (vesselsOn) loadVessels(true);
}
});
return;
}
if (acOn) loadAircraft(); if (acOn) loadAircraft();
if (vesselsOn) loadVessels(); if (vesselsOn) loadVessels();
} }
@ -4148,6 +4213,59 @@ async function initDvrRange() {
dvrMinMs = dvrMaxMs - 6 * 3600 * 1000; dvrMinMs = dvrMaxMs - 6 * 3600 * 1000;
} }
} }
function applyFenceSnapshot(body) {
const ac = Array.isArray(body && body.aircraft) ? body.aircraft : [];
const vs = Array.isArray(body && body.vessels) ? body.vessels : [];
const fires = Array.isArray(body && body.fires) ? body.fires : [];
if (acOn) {
lastAircraft = ac;
acGroup = renderPoints(acGroup, ac, p => acColor(p), true, 'ac');
setLayerCount('lp-ac-count', ac.length.toLocaleString());
}
if (vesselsOn) {
lastVessels = vs;
vesselsGroup = renderPoints(vesselsGroup, vs, p => {
const extra = p.extra || {};
if (extra.role === 'military') return '#f472b6';
if (extra.role === 'government') return '#facc15';
const sog = Number((p.speed) || 0);
return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, true, 'vessel');
setLayerCount('lp-vessels-count', vs.length.toLocaleString());
}
if (typeof firesOn !== 'undefined' && firesOn && map) {
if (firesHeat) map.removeLayer(firesHeat);
const pts = fires.map(f => [f.lat ?? f.latitude, f.lon ?? f.longitude, firesIntensity(f)])
.filter(p => p[0] != null && p[1] != null);
lastFires = fires;
firesHeat = L.heatLayer(pts, {
radius: 22, blur: 20, maxZoom: 9, max: 1.0, minOpacity: 0.2,
gradient: firesGradient(),
}).addTo(map);
if (firesHeat._canvas) firesHeat._canvas.style.opacity = firesOpacity;
setLayerCount('lp-fires-count', fires.length.toLocaleString());
}
}
async function dvrScrubFence() {
if (!gfSelectedId || !dvrTs) return false;
const req = ++gfAtReq;
try {
const r = await overlayFetch(
`${API}/api/geofences/${encodeURIComponent(gfSelectedId)}/at?timestamp=${encodeURIComponent(dvrTs)}`
);
if (req !== gfAtReq) return true;
if (r.status === 404) { gfAtOk = false; return false; }
if (!r.ok) return false;
const body = await r.json();
if (req !== gfAtReq) return true;
gfAtOk = true;
applyFenceSnapshot(body);
return true;
} catch (e) {
if (isAbort(e) || req !== gfAtReq) return true;
return false;
}
}
function showGeofenceToast(p) { function showGeofenceToast(p) {
const box = document.getElementById('dvr-toast'); const box = document.getElementById('dvr-toast');
if (!box) return; if (!box) return;
@ -4157,12 +4275,70 @@ function showGeofenceToast(p) {
box.prepend(el); box.prepend(el);
setTimeout(() => el.remove(), 8000); setTimeout(() => el.remove(), 8000);
} }
function gfHitTime(p) {
const raw = p && (p.created_at || (p.payload && p.payload.created_at));
if (!raw) return 'now';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return String(raw).slice(11, 19) || '—';
return d.toISOString().slice(11, 19) + 'Z';
}
function pushGfInbox(p) {
if (!p) return;
const box = document.getElementById('gf-inbox');
if (!box) return;
const gid = p.geofence_id || '';
const name = p.geofence_name || gfNameById[gid] || 'Fence';
const el = document.createElement('button');
el.type = 'button';
el.className = 'gf-hit';
el.dataset.gid = gid;
if (p.lat != null) el.dataset.lat = String(p.lat);
if (p.lon != null) el.dataset.lon = String(p.lon);
el.innerHTML = `<span class="gf-hit-meta">${newsEsc(gfHitTime(p))}</span> · ${newsEsc(name)}` +
` · ${newsEsc(p.source_kind || '')} · ${newsEsc(p.entity_id || '')}`;
el.onclick = () => onGfHitClick(el);
box.prepend(el);
while (box.children.length > 50) box.removeChild(box.lastChild);
}
function onGfHitClick(el) {
const lat = parseFloat(el.dataset.lat);
const lon = parseFloat(el.dataset.lon);
const gid = el.dataset.gid;
if (map && Number.isFinite(lat) && Number.isFinite(lon)) {
map.setView([lat, lon], Math.max(map.getZoom() || 0, 10));
}
if (gid) selectGeofence(gid, false);
}
async function loadGfInbox() {
try {
const r = await fetch(`${API}/api/geofence-alerts?limit=50`);
if (!r.ok) return;
const rows = await r.json();
const box = document.getElementById('gf-inbox');
if (!box) return;
box.innerHTML = '';
(Array.isArray(rows) ? rows : []).slice().reverse().forEach(pushGfInbox);
} catch (e) { /* route may not be on this backend yet */ }
}
function syncGfDrawUi() {
const fin = document.getElementById('gf-finish');
const can = document.getElementById('gf-cancel');
if (fin) fin.hidden = !gfDrawOn;
if (can) can.hidden = !gfDrawOn;
const btn = document.getElementById('gf-draw');
if (btn) btn.textContent = gfDrawOn ? 'Drawing…' : 'Draw geofence';
const bar = document.getElementById('gf-draw-bar');
if (bar) bar.hidden = !gfDrawOn;
}
function cancelGeofenceDraw() {
if (!gfDrawOn) return;
toggleGeofenceDraw();
}
function toggleGeofenceDraw() { function toggleGeofenceDraw() {
gfDrawOn = !gfDrawOn; gfDrawOn = !gfDrawOn;
gfVerts = []; gfVerts = [];
if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; } if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; }
const btn = document.getElementById('gf-draw'); syncGfDrawUi();
if (btn) btn.textContent = gfDrawOn ? 'Click map… (dbl-click close)' : 'Draw geofence';
if (!map) return; if (!map) return;
if (gfDrawOn) { if (gfDrawOn) {
map.getContainer().style.cursor = 'crosshair'; map.getContainer().style.cursor = 'crosshair';
@ -4185,21 +4361,99 @@ function onGfClick(e) {
} }
async function onGfClose(e) { async function onGfClose(e) {
if (!gfDrawOn) return; if (!gfDrawOn) return;
L.DomEvent.stop(e); if (e) L.DomEvent.stop(e);
if (gfVerts.length < 3) return; if (gfVerts.length < 3) return;
const ring = gfVerts.concat([gfVerts[0]]); const ring = gfVerts.concat([gfVerts[0]]);
const geojson = { type: 'Polygon', coordinates: [ring] }; const geojson = { type: 'Polygon', coordinates: [ring] };
const fallback = 'Fence ' + new Date().toISOString().slice(11, 19);
let created = null;
try { try {
await fetch(`${API}/api/geofences`, { const r = await fetch(`${API}/api/geofences`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Fence ' + new Date().toISOString().slice(11, 19), geojson }), body: JSON.stringify({ name: fallback, geojson }),
}); });
if (r.ok) created = await r.json();
} catch (err) { console.error('geofence save failed', err); } } catch (err) { console.error('geofence save failed', err); }
toggleGeofenceDraw(); toggleGeofenceDraw();
if (created && created.id) {
const typed = window.prompt('Name this watch', created.name || fallback);
const name = (typed == null ? '' : String(typed).trim()) || (created.name || fallback);
if (name && name !== created.name) {
try {
await fetch(`${API}/api/geofences/${encodeURIComponent(created.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
} catch (err) { console.error('geofence rename failed', err); }
}
}
loadGeofences(); loadGeofences();
} }
async function patchGeofence(id, body) {
if (!id) return;
try {
await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
} catch (err) { console.error('geofence patch failed', err); }
loadGeofences();
}
function patchGfActive(id, on) {
patchGeofence(id, { active: !!on });
}
async function renameGeofence(id) {
const cur = gfNameById[id] || 'Fence';
const typed = window.prompt('Rename watch', cur);
if (typed == null) return;
const name = String(typed).trim() || cur;
await patchGeofence(id, { name });
}
function goGeofence(id) {
selectGeofence(id, true);
}
function onGfNameClick(id) {
if (gfSelectedId === id) { deselectGeofence(); return; }
selectGeofence(id, true);
}
function deselectGeofence() {
gfSelectedId = null;
document.querySelectorAll('.gf-item.selected').forEach(el => el.classList.remove('selected'));
if (gfSaved && gfSaved.setStyle) gfSaved.setStyle({ weight: 2, fillOpacity: 0.08 });
if (dvrTs) {
if (acOn) loadAircraft();
if (vesselsOn) loadVessels();
}
}
function selectGeofence(id, fly) {
if (!id) return;
gfSelectedId = id;
gfAtOk = true;
document.querySelectorAll('.gf-item').forEach(el => {
el.classList.toggle('selected', el.dataset.id === id);
});
const layer = gfLayerById[id];
if (gfSaved && gfSaved.eachLayer) {
gfSaved.eachLayer(ly => {
const fid = ly.feature && ly.feature.properties && ly.feature.properties.id;
ly.setStyle({
color: '#ff2e97',
weight: fid === id ? 4 : 2,
fillOpacity: fid === id ? 0.18 : 0.08,
});
});
}
if (layer && map) {
if (fly !== false && layer.getBounds) {
try { map.fitBounds(layer.getBounds(), { padding: [28, 28], maxZoom: 12 }); } catch (e) { /* empty */ }
}
if (layer.openPopup) layer.openPopup();
}
if (fenceDvrOn()) dvrScrubFence();
}
async function deleteGeofence(id) { async function deleteGeofence(id) {
if (!id) return; if (!id) return;
if (gfSelectedId === id) gfSelectedId = null;
try { try {
const r = await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, { method: 'DELETE' }); const r = await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!r.ok && r.status !== 204) throw new Error('delete failed'); if (!r.ok && r.status !== 204) throw new Error('delete failed');
@ -4212,31 +4466,55 @@ async function loadGeofences() {
const r = await overlayFetch(`${API}/api/geofences`); const r = await overlayFetch(`${API}/api/geofences`);
const rows = await r.json(); const rows = await r.json();
const list = Array.isArray(rows) ? rows : []; const list = Array.isArray(rows) ? rows : [];
gfWatchIds = list.map(f => f.id).filter(Boolean);
gfNameById = {};
list.forEach(f => { if (f.id) gfNameById[f.id] = f.name || 'Fence'; });
if (gfSelectedId && !gfNameById[gfSelectedId]) gfSelectedId = null;
const box = document.getElementById('gf-list'); const box = document.getElementById('gf-list');
if (box) { if (box) {
box.innerHTML = list.map(f => { box.innerHTML = list.map(f => {
const id = newsEsc(f.id || ''); const id = newsEsc(f.id || '');
const name = newsEsc(f.name || 'Fence'); const name = newsEsc(f.name || 'Fence');
return `<div class="gf-item"><span title="${name}">${name}</span>` + const on = f.active === false ? '' : ' checked';
`<button class="gf-del" type="button" onclick="deleteGeofence('${id}')">Delete</button></div>`; const sel = f.id === gfSelectedId ? ' selected' : '';
return `<div class="gf-item${sel}" data-id="${id}">` +
`<input class="gf-active" type="checkbox"${on} aria-label="Active" ` +
`onchange="patchGfActive('${id}', this.checked)">` +
`<button type="button" class="gf-name" title="${name}" onclick="onGfNameClick('${id}')">${name}</button>` +
`<span class="gf-actions">` +
`<button class="gf-go" type="button" onclick="goGeofence('${id}')">Go</button>` +
`<button class="gf-ren" type="button" aria-label="Rename" onclick="renameGeofence('${id}')">Aa</button>` +
`<button class="gf-del" type="button" onclick="deleteGeofence('${id}')">Delete</button>` +
`</span></div>`;
}).join(''); }).join('');
} }
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved); if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
gfLayerById = {};
const feats = list.map(f => ({ const feats = list.map(f => ({
type: 'Feature', properties: { name: f.name, id: f.id }, geometry: f.geojson, type: 'Feature',
properties: { name: f.name, id: f.id, active: f.active !== false },
geometry: f.geojson,
})); }));
gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, { gfSaved = L.geoJSON({ type: 'FeatureCollection', features: feats }, {
style: { color: '#ff2e97', weight: 2, fillOpacity: 0.08 }, style: feat => {
const on = feat.properties && feat.properties.id === gfSelectedId;
return { color: '#ff2e97', weight: on ? 4 : 2, fillOpacity: on ? 0.18 : 0.08 };
},
onEachFeature: (feat, layer) => { onEachFeature: (feat, layer) => {
const id = feat.properties && feat.properties.id; const id = feat.properties && feat.properties.id;
const name = newsEsc((feat.properties && feat.properties.name) || 'Fence'); const name = newsEsc((feat.properties && feat.properties.name) || 'Fence');
const active = feat.properties && feat.properties.active;
if (!id) return; if (!id) return;
gfLayerById[id] = layer;
const chk = active ? ' checked' : '';
layer.bindPopup( layer.bindPopup(
`<div class="gf-pop"><b>${name}</b><br>` + `<div class="gf-pop"><b>${name}</b><br>` +
`<label><input type="checkbox"${chk} onchange="patchGfActive('${newsEsc(id)}', this.checked)"> active</label><br>` +
`<button class="gf-del" type="button" onclick="deleteGeofence('${newsEsc(id)}')">Delete</button></div>` `<button class="gf-del" type="button" onclick="deleteGeofence('${newsEsc(id)}')">Delete</button></div>`
); );
}, },
}).addTo(map); }).addTo(map);
sendWatchGeofences();
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); } } catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
} }
async function loadFireAircraftHits() { async function loadFireAircraftHits() {
@ -4249,6 +4527,7 @@ async function loadFireAircraftHits() {
} catch (e) { /* empty without table */ } } catch (e) { /* empty without table */ }
} }
initDvrRange(); initDvrRange();
loadGfInbox();
</script> </script>
</body> </body>

View file

@ -1,4 +1,4 @@
"""Geofence layer panel: draw + delete (DELETE /api/geofences/{id}).""" """Geofence layer panel: draw, watch, inbox, delete (HTML contract)."""
from __future__ import annotations from __future__ import annotations
@ -24,3 +24,33 @@ def test_load_geofences_renders_delete_controls():
assert "deleteGeofence" in js assert "deleteGeofence" in js
assert "onEachFeature" in js assert "onEachFeature" in js
assert "bindPopup" in js assert "bindPopup" in js
def test_finish_cancel_draw_controls():
assert 'id="gf-finish"' in HTML
assert 'id="gf-cancel"' in HTML
assert "function cancelGeofenceDraw" in HTML
assert "function onGfClose" in HTML
def test_watch_geofences_ws_payload():
assert "watch_geofences" in HTML
assert "function sendWatchGeofences" in HTML
def test_geofence_alert_inbox():
assert 'id="gf-inbox"' in HTML
assert "/api/geofence-alerts" in HTML
assert "function loadGfInbox" in HTML
assert "function pushGfInbox" in HTML
def test_delete_geofence_still_present():
assert "function deleteGeofence" in HTML
assert "method: 'DELETE'" in HTML or 'method: "DELETE"' in HTML
def test_fence_dvr_at_endpoint():
assert "/at?timestamp=" in HTML or "/at?timestamp=${" in HTML
assert "function dvrScrubFence" in HTML
assert "gfSelectedId" in HTML