diff --git a/app/static/index.html b/app/static/index.html
index e75f9bb..71619a5 100644
--- a/app/static/index.html
+++ b/app/static/index.html
@@ -374,11 +374,34 @@
.lp-future .lp-name { cursor: not-allowed; }
.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; }
- .gf-list { display: flex; flex-direction: column; gap: 0.22rem; max-height: 8rem; overflow-y: auto; }
- .gf-item { display: flex; justify-content: space-between; align-items: center; gap: 0.4rem; font-size: 0.64rem; }
- .gf-item span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
- .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-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.35rem; font-size: 0.64rem; flex-wrap: wrap; }
+ .gf-item.selected { outline: 1px solid var(--cyan); background: rgba(53,224,255,0.08); border-radius: 3px; }
+ .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-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) ── */
.cheat-sheet {
@@ -787,6 +810,13 @@
min-width: 0;
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-top.leaflet-right .leaflet-control-zoom {
margin-top: 8px;
@@ -1029,11 +1059,14 @@
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.
-
+
Draw geofence
+ Finish
+ Cancel
-
Click vertices, double-click to close. Delete a saved fence from the list or its popup.
+
+
Tap vertices, then Finish (or double-click). Inbox keeps hits after the toast fades.
@@ -1095,6 +1128,10 @@
live
+
+ Finish
+ Cancel
+
@@ -2242,6 +2279,8 @@ let firefighterHex = new Set();
let dvrTs = null;
let dvrMinMs = 0, dvrMaxMs = 0;
let gfDrawOn = false, gfVerts = [], gfLayer = null, gfSaved = null;
+let gfWatchIds = [], gfSelectedId = null, gfLayerById = {}, gfNameById = {};
+let gfAtReq = 0, dvrRangeFromTracks = false, gfAtOk = true;
function liveWsUrl() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws/live';
@@ -2249,6 +2288,11 @@ function liveWsUrl() {
function sendLiveViewport() {
if (!liveWs || liveWs.readyState !== 1 || !map) return;
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) {
if (!group || !group._osintById || id == null) return;
@@ -2291,6 +2335,7 @@ function applyLiveMarker(kind, p) {
acGroup = upsertLivePoint(acGroup, p, q => acColor(q), 'ac');
} else if (kind === 'geofence_alert') {
showGeofenceToast(p);
+ pushGfInbox(p);
} else if (kind === 'fire_aircraft') {
firefighterHex.add(String(p.aircraft_hex || ''));
if (acOn && p.aircraft_lat != null) {
@@ -2338,7 +2383,7 @@ function connectLiveWs() {
setTimeout(connectLiveWs, delay);
return;
}
- liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); };
+ liveWs.onopen = () => { wsRetryMs = 1000; sendLiveViewport(); sendWatchGeofences(); };
liveWs.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
@@ -2881,6 +2926,7 @@ function setFiresSince() {
}
async function loadFires() {
if (!map) return;
+ if (fenceDvrOn()) return;
const req = ++fireReq;
try {
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;
if (acOn) loadAircraft();
}
-async function loadAircraft() {
+async function loadAircraft(force) {
if (!map) return;
+ if (!force && fenceDvrOn()) return;
if (map.getZoom() <= 3) {
setLayerCount('lp-ac-count', 'zoom');
return;
@@ -3729,6 +3776,7 @@ async function loadAircraft() {
}
addExtraAttrib('
ADSB.lol ODbL');
addExtraAttrib('
Photo © planespotters.net ');
+ refreshDvrRangeOnce();
} catch (e) {
if (isAbort(e)) return;
console.error('Aircraft load failed', e);
@@ -3767,8 +3815,9 @@ async function toggleVessels() {
clearTimeout(vesselRefollowTimer);
}
}
-async function loadVessels() {
+async function loadVessels(force) {
if (!map) return;
+ if (!force && fenceDvrOn()) return;
if (map.getZoom() <= 3) {
setLayerCount('lp-vessels-count', 'zoom');
return;
@@ -3808,6 +3857,7 @@ async function loadVessels() {
}, true, 'vessel');
setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString());
addExtraAttrib('AISStream');
+ refreshDvrRangeOnce();
// First positions for a new zone arrive ~1-3s after the retune.
if (retuned) {
clearTimeout(vesselRefollowTimer);
@@ -4110,6 +4160,12 @@ setInterval(pollSwpc, SWPC_POLL_MS);
/* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */
function dvrQs() { return dvrTs ? `×tamp=${encodeURIComponent(dvrTs)}` : ''; }
+function fenceDvrOn() { return !!(gfSelectedId && dvrTs && gfAtOk); }
+function refreshDvrRangeOnce() {
+ if (dvrRangeFromTracks) return;
+ dvrRangeFromTracks = true;
+ initDvrRange();
+}
function dvrGoLive() {
dvrTs = null;
const bar = document.getElementById('dvr-bar');
@@ -4130,6 +4186,15 @@ function dvrScrub(v) {
if (Number(v) >= 359) { dvrGoLive(); return; }
const lab = document.getElementById('dvr-label');
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 (vesselsOn) loadVessels();
}
@@ -4148,6 +4213,59 @@ async function initDvrRange() {
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) {
const box = document.getElementById('dvr-toast');
if (!box) return;
@@ -4157,12 +4275,70 @@ function showGeofenceToast(p) {
box.prepend(el);
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 = `
${newsEsc(gfHitTime(p))} · ${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() {
gfDrawOn = !gfDrawOn;
gfVerts = [];
if (gfLayer && map) { map.removeLayer(gfLayer); gfLayer = null; }
- const btn = document.getElementById('gf-draw');
- if (btn) btn.textContent = gfDrawOn ? 'Click map… (dbl-click close)' : 'Draw geofence';
+ syncGfDrawUi();
if (!map) return;
if (gfDrawOn) {
map.getContainer().style.cursor = 'crosshair';
@@ -4185,21 +4361,99 @@ function onGfClick(e) {
}
async function onGfClose(e) {
if (!gfDrawOn) return;
- L.DomEvent.stop(e);
+ if (e) L.DomEvent.stop(e);
if (gfVerts.length < 3) return;
const ring = gfVerts.concat([gfVerts[0]]);
const geojson = { type: 'Polygon', coordinates: [ring] };
+ const fallback = 'Fence ' + new Date().toISOString().slice(11, 19);
+ let created = null;
try {
- await fetch(`${API}/api/geofences`, {
+ const r = await fetch(`${API}/api/geofences`, {
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); }
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();
}
+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) {
if (!id) return;
+ if (gfSelectedId === id) gfSelectedId = null;
try {
const r = await fetch(`${API}/api/geofences/${encodeURIComponent(id)}`, { method: 'DELETE' });
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 rows = await r.json();
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');
if (box) {
box.innerHTML = list.map(f => {
const id = newsEsc(f.id || '');
const name = newsEsc(f.name || 'Fence');
- return `
${name} ` +
- `Delete
`;
+ const on = f.active === false ? '' : ' checked';
+ const sel = f.id === gfSelectedId ? ' selected' : '';
+ return `
` +
+ ` ` +
+ `${name} ` +
+ `` +
+ `Go ` +
+ `Aa ` +
+ `Delete ` +
+ `
`;
}).join('');
}
if (gfSaved && map.hasLayer(gfSaved)) map.removeLayer(gfSaved);
+ gfLayerById = {};
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 }, {
- 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) => {
const id = feat.properties && feat.properties.id;
const name = newsEsc((feat.properties && feat.properties.name) || 'Fence');
+ const active = feat.properties && feat.properties.active;
if (!id) return;
+ gfLayerById[id] = layer;
+ const chk = active ? ' checked' : '';
layer.bindPopup(
`
${name} ` +
+ ` active ` +
`Delete
`
);
},
}).addTo(map);
+ sendWatchGeofences();
} catch (e) { if (!isAbort(e)) console.error('geofences load failed', e); }
}
async function loadFireAircraftHits() {
@@ -4249,6 +4527,7 @@ async function loadFireAircraftHits() {
} catch (e) { /* empty without table */ }
}
initDvrRange();
+loadGfInbox();