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.
-
+
+ +
-
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
+
@@ -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}` + - `
`; + const on = f.active === false ? '' : ' checked'; + const sel = f.id === gfSelectedId ? ' selected' : ''; + return `
` + + `` + + `` + + `` + + `` + + `` + + `` + + `
`; }).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}
` + + `
` + `
` ); }, }).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(); diff --git a/tests/test_geofence_frontend.py b/tests/test_geofence_frontend.py index 50ee03e..994c02d 100644 --- a/tests/test_geofence_frontend.py +++ b/tests/test_geofence_frontend.py @@ -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 @@ -24,3 +24,33 @@ def test_load_geofences_renders_delete_controls(): assert "deleteGeofence" in js assert "onEachFeature" 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