Compare commits
2 commits
75e065f8c2
...
cb20473119
| Author | SHA1 | Date | |
|---|---|---|---|
| cb20473119 | |||
|
|
8c97ce50d2 |
2 changed files with 328 additions and 19 deletions
|
|
@ -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 ? `×tamp=${encodeURIComponent(dvrTs)}` : ''; }
|
function dvrQs() { return dvrTs ? `×tamp=${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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue