From f61d5875231e06ab30aab2a4a3a691b12dcc4cf1 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Mon, 31 Aug 2026 21:41:59 -0400 Subject: [PATCH] feat(hud): layer counts, shortcuts, terminator, zoom-gated cams, SWPC Poll GET /api/stats ~30s for rail integers (404 falls back to overlay array lengths). Keyboard: ? cheat sheet, L layers, R reset, Esc closes panels; F/S left unbound. Cheap sun-position day/night overlay default off. Camera/HLS thumbs only at zoom >= 12. NOAA SWPC Kp chip in the status strip (browser-direct; hide on failure). --- app/static/index.html | 290 ++++++++++++++++++++++++++++++++++++--- tests/test_hud_osiris.py | 90 ++++++++++++ 2 files changed, 364 insertions(+), 16 deletions(-) create mode 100644 tests/test_hud_osiris.py diff --git a/app/static/index.html b/app/static/index.html index 02122d1..4fe4812 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -341,6 +341,10 @@ .lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; } .lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; } .lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; } + .lp-dot.night { + background: linear-gradient(90deg, #0b1c33 50%, #ffb454 50%); + box-shadow: 0 0 7px #64748b; + } .lp-count { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; color: var(--cyan); } .lp-sub { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; } .lp-opacity { display: flex; align-items: center; gap: 0.4rem; font-size: 0.62rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; } @@ -364,6 +368,35 @@ .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-del:hover { background: rgba(255,46,151,0.16); } + /* ── Keyboard cheat sheet (centered; keep off LAYERS / zoom / dock) ── */ + .cheat-sheet { + position: absolute; top: 50%; left: 50%; + transform: translate(-50%, -50%); + z-index: 640; + width: min(420px, calc(100% - 96px)); + max-height: calc(100% - 88px); + overflow: auto; + background: rgba(6,11,20,0.94); backdrop-filter: blur(10px); + border: 1px solid var(--line-hi); border-radius: 6px; + box-shadow: 0 0 28px rgba(53,224,255,0.18); + padding: 0.85rem 1rem 1rem; + clip-path: polygon(0 8px, 8px 0, calc(100% - 8px) 0, 100% 8px, 100% calc(100% - 8px), calc(100% - 8px) 100%, 8px 100%, 0 calc(100% - 8px)); + } + .cheat-sheet[hidden] { display: none !important; } + .cheat-sheet h3 { + font-family: 'Orbitron', sans-serif; font-size: 0.72rem; letter-spacing: 0.16em; + color: var(--cyan); margin: 0 0 0.65rem; text-transform: uppercase; + } + .cheat-sheet dl { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem 0.85rem; margin: 0; } + .cheat-sheet dt kbd { + display: inline-block; min-width: 1.4em; text-align: center; + border: 1px solid var(--line-hi); border-radius: 3px; padding: 0.12rem 0.4rem; + color: var(--cyan); font-size: 0.72rem; background: var(--bg-1); + } + .cheat-sheet dd { margin: 0; font-size: 0.78rem; color: var(--muted); } + #swpc-chip { color: var(--text); letter-spacing: 0.08em; } + #swpc-chip.swpc-storm { color: var(--amber); text-shadow: 0 0 8px rgba(255,180,84,0.45); } + /* ── Camera / blip popup thumbnails ── */ .cam-pop { min-width: 210px; max-width: 260px; } .cam-pop .thumb { width: 100%; height: 160px; object-fit: cover; border-radius: 4px; border: 1px solid var(--line); margin: 0.3rem 0; box-shadow: 0 0 10px rgba(53,224,255,0.2); background: var(--bg-0); } @@ -732,6 +765,8 @@ --:--:-- UTC | + +
Opacity 100%
+
+
+ + off +
+
Sun terminator overlay. Default off. No API.
+
@@ -939,6 +981,16 @@
BLIPS
NEWS
+
@@ -1280,6 +1332,152 @@ async function checkHealth() { } } +/* ═══════════════ HUD keys + SWPC + /api/stats rail ═══════════════ */ +const STATS_POLL_MS = 30000; +const SWPC_POLL_MS = 90000; +const CAM_THUMB_MIN_ZOOM = 12; +let statsRailActive = false; +let statsPollDisabled = false; +const STATS_COUNT_IDS = { + aircraft: 'lp-ac-count', + vessels: 'lp-vessels-count', + trains: 'lp-trains-count', + cameras: 'lp-cams-count', + fires: 'lp-fires-count', + alerts: 'lp-alerts-count', +}; + +function setLayerCount(id, value) { + if (statsRailActive && Object.values(STATS_COUNT_IDS).indexOf(id) !== -1) return; + const el = document.getElementById(id); + if (el) el.textContent = value; +} + +async function pollLayerStats() { + if (statsPollDisabled) return; + try { + const r = await fetch(`${API}/api/stats`); + if (r.status === 404) { statsPollDisabled = true; statsRailActive = false; return; } + if (!r.ok) return; + const s = await r.json(); + if (!s || typeof s !== 'object') return; + statsRailActive = true; + Object.keys(STATS_COUNT_IDS).forEach((k) => { + const n = s[k]; + if (typeof n !== 'number' || !isFinite(n)) return; + const el = document.getElementById(STATS_COUNT_IDS[k]); + if (el) el.textContent = n.toLocaleString(); + }); + } catch (e) { + // Never block the map on stats failure — keep last viewport lengths. + } +} + +function hideSwpcChip() { + const chip = document.getElementById('swpc-chip'); + const sep = document.getElementById('swpc-sep'); + if (chip) { chip.hidden = true; chip.classList.remove('swpc-storm'); } + if (sep) sep.hidden = true; +} +async function pollSwpc() { + const chip = document.getElementById('swpc-chip'); + const sep = document.getElementById('swpc-sep'); + if (!chip) return; + try { + const r = await fetch('https://services.swpc.noaa.gov/json/planetary_k_index_1m.json'); + if (!r.ok) { hideSwpcChip(); return; } + const rows = await r.json(); + const last = Array.isArray(rows) && rows.length ? rows[rows.length - 1] : null; + const kp = last && last.kp_index; + if (kp == null || !isFinite(Number(kp))) { hideSwpcChip(); return; } + let label = 'Kp ' + Number(kp); + try { + const fr = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xray-flares-latest.json'); + if (fr.ok) { + const flares = await fr.json(); + const f = Array.isArray(flares) ? flares[0] : flares; + const cls = f && (f.max_class || f.current_class || f['class']); + if (cls && /^[MX]/i.test(String(cls))) label += ' · ' + cls; + } + } catch (e) { /* flares optional */ } + try { + const ar = await fetch('https://services.swpc.noaa.gov/products/alerts.json'); + if (ar.ok) { + const alerts = await ar.json(); + const n = Array.isArray(alerts) ? alerts.length : 0; + if (n) chip.title = 'NOAA SWPC · ' + n + ' alert(s)'; + } + } catch (e) { /* alerts optional */ } + chip.textContent = label; + chip.hidden = false; + if (sep) sep.hidden = false; + chip.classList.toggle('swpc-storm', Number(kp) >= 5); + } catch (e) { + hideSwpcChip(); + } +} + +function hudTypingTarget(el) { + if (!el) return false; + const tag = (el.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; + return !!el.isContentEditable; +} +function toggleCheatSheet(force) { + const el = document.getElementById('cheat-sheet'); + if (!el) return; + const open = force === undefined ? el.hidden : !!force; + el.hidden = !open; +} +function closeLayerPanel() { + const panel = document.getElementById('layer-panel'); + if (!panel) return; + const head = panel.querySelector('.lp-head'); + if (window.matchMedia('(max-width: 820px)').matches) { + if (panel.classList.contains('is-open')) { + panel.classList.remove('is-open'); + if (head) head.setAttribute('aria-expanded', 'false'); + } + return; + } + if (!panel.classList.contains('collapsed')) { + panel.classList.add('collapsed'); + if (head) head.setAttribute('aria-expanded', 'false'); + } +} +function closeHudPanels() { + toggleCheatSheet(false); + closeNav(); + closeLayerPanel(); + if (map) map.closePopup(); +} +function initHudKeys() { + document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + if (e.key === 'Escape') { + closeHudPanels(); + return; + } + if (hudTypingTarget(e.target)) return; + if (e.key === '?' || (e.key === '/' && e.shiftKey)) { + e.preventDefault(); + const sheet = document.getElementById('cheat-sheet'); + toggleCheatSheet(sheet ? sheet.hidden : true); + return; + } + if (e.key === 'l' || e.key === 'L') { + e.preventDefault(); + toggleLayerPanel(); + return; + } + if (e.key === 'r' || e.key === 'R') { + e.preventDefault(); + mapResetView(); + } + // F / S intentionally unbound — Osiris conflicts (flights vs fullscreen / search). + }); +} + /* ═══════════════ NAV (dropdown) ═══════════════ */ const VIEWS = ['map', 'news', 'events', 'alerts', 'entities', 'ingest', 'keys', 'settings']; @@ -2093,7 +2291,7 @@ async function initMap() { .then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); }) .then(c => { box.outerHTML = camPopupHtml(c); - startHlsFrom(e.popup.getElement()); + if (camThumbsAllowed()) startHlsFrom(e.popup.getElement()); }) .catch(() => { const ph = box.querySelector('.thumb.placeholder'); @@ -2101,7 +2299,7 @@ async function initMap() { }); return; } - startHlsFrom(root); + if (camThumbsAllowed()) startHlsFrom(root); }); map.on('popupopen', (e) => { loadPlanePhoto(e.popup); }); map.on('popupclose', () => { @@ -2433,6 +2631,56 @@ function setBaseOpacity(v) { if (mapLayer && document.getElementById('lp-base-on').checked) mapLayer.setOpacity(baseOpacity); } +/* ── Day/night terminator (sun position, no API) ── */ +let terminatorGroup = null, terminatorOn = false, terminatorTimer = null; +function subsolarPoint(date) { + const rad = Math.PI / 180; + const jd = date.getTime() / 86400000 + 2440587.5; + const n = jd - 2451545.0; + const L = (280.460 + 0.9856474 * n) % 360; + const g = (357.528 + 0.9856003 * n) % 360; + const lambda = (L + 1.915 * Math.sin(g * rad) + 0.020 * Math.sin(2 * g * rad)) * rad; + const eps = (23.439 - 0.0000004 * n) * rad; + const delta = Math.asin(Math.sin(eps) * Math.sin(lambda)); + const alpha = Math.atan2(Math.cos(eps) * Math.sin(lambda), Math.cos(lambda)); + const gmst = (280.46061837 + 360.98564736629 * n) % 360; + let lon = (alpha * 180 / Math.PI) - gmst; + lon = ((lon + 180) % 360 + 360) % 360 - 180; + return [delta * 180 / Math.PI, lon]; +} +function paintTerminator() { + if (!map || !terminatorOn) return; + const [lat, lon] = subsolarPoint(new Date()); + const antiLat = -lat; + const antiLon = lon >= 0 ? lon - 180 : lon + 180; + const radius = 6378137 * Math.PI / 2; + const opts = { + radius, color: '#1e3a5f', weight: 1, fillColor: '#020814', + fillOpacity: 0.38, interactive: false, pane: 'overlayPane', + }; + if (terminatorGroup) { map.removeLayer(terminatorGroup); terminatorGroup = null; } + terminatorGroup = L.layerGroup([ + L.circle([antiLat, antiLon], opts), + L.circle([antiLat, antiLon + 360], opts), + L.circle([antiLat, antiLon - 360], opts), + ]).addTo(map); + const n = document.getElementById('lp-terminator-count'); + if (n) n.textContent = 'on'; +} +function toggleTerminator() { + terminatorOn = !!(document.getElementById('lp-terminator-on') && document.getElementById('lp-terminator-on').checked); + if (terminatorTimer) { clearInterval(terminatorTimer); terminatorTimer = null; } + if (!terminatorOn) { + if (terminatorGroup && map) map.removeLayer(terminatorGroup); + terminatorGroup = null; + const n = document.getElementById('lp-terminator-count'); + if (n) n.textContent = 'off'; + return; + } + paintTerminator(); + terminatorTimer = setInterval(paintTerminator, 60000); +} + /* ── FIRMS fire heatmap ── */ function firesIntensity(f) { const confidence = f.confidence || f.c; @@ -2501,7 +2749,7 @@ async function loadFires() { gradient: firesGradient(), }).addTo(map); firesHeat._canvas.style.opacity = firesOpacity; - document.getElementById('lp-fires-count').textContent = fires.length.toLocaleString(); + setLayerCount('lp-fires-count', fires.length.toLocaleString()); hudFiresCount = fires.length; syncHud(); document.getElementById('map-hint').textContent = @@ -2552,8 +2800,14 @@ function camKind(c) { if (c.device_type === 'rtsp' || u.startsWith('rtsp://')) return 'rtsp'; return 'http'; } +function camThumbsAllowed() { + return map && map.getZoom() >= CAM_THUMB_MIN_ZOOM; +} function camThumb(c) { if (!c.id) return '
no preview available
'; + if (!camThumbsAllowed()) { + return '
zoom in for preview
'; + } const kind = camKind(c); if (kind === 'youtube') { const vid = youtubeId(c.snapshot_url || c.source_url); @@ -2627,7 +2881,7 @@ async function loadCams() { }); camsGroup.addTo(map); camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity)); - document.getElementById('lp-cams-count').textContent = cams.length.toLocaleString(); + setLayerCount('lp-cams-count', cams.length.toLocaleString()); hudCamsCount = cams.length; syncHud(); document.getElementById('map-hint').textContent = @@ -2786,8 +3040,7 @@ function dropLayer(ref) { const HEAVY_MIN_ZOOM = 4; function tooZoomedOut() { return !map || map.getZoom() <= HEAVY_MIN_ZOOM; } function markZoom(id) { - const el = document.getElementById(id); - if (el) el.textContent = 'zoom'; + setLayerCount(id, 'zoom'); } function intersectsConus() { if (!map) return false; @@ -3207,12 +3460,12 @@ async function loadWxAlerts() { layer.bindPopup(`
${esc(p.source || 'alert')}
${esc(p.event || p.headline || 'Alert')}
${esc(p.severity || '')} · ${esc(p.areaDesc || p.wfo || '')}
`); }, }).addTo(map); - document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString(); + setLayerCount('lp-alerts-count', feats.length.toLocaleString()); addExtraAttrib('NWS / IEM storm-based warnings'); } catch (e) { if (isAbort(e)) return; console.error('Alerts load failed', e); - document.getElementById('lp-alerts-count').textContent = 'err'; + setLayerCount('lp-alerts-count', 'err'); } } async function togglePerimeters() { @@ -3289,7 +3542,7 @@ async function toggleAircraft() { async function loadAircraft() { if (!map) return; if (map.getZoom() <= 3) { - document.getElementById('lp-ac-count').textContent = 'zoom'; + setLayerCount('lp-ac-count', 'zoom'); return; } const req = ++overlayReq.ac; @@ -3298,13 +3551,13 @@ async function loadAircraft() { const pts = await r.json(); if (req !== overlayReq.ac) return; acGroup = renderPoints(acGroup, Array.isArray(pts) ? pts : [], p => acColor(p), true, 'ac'); - document.getElementById('lp-ac-count').textContent = (pts.length || 0).toLocaleString(); + setLayerCount('lp-ac-count', (pts.length || 0).toLocaleString()); addExtraAttrib('ADSB.lol ODbL'); addExtraAttrib('Photo © planespotters.net'); } catch (e) { if (isAbort(e)) return; console.error('Aircraft load failed', e); - document.getElementById('lp-ac-count').textContent = 'err'; + setLayerCount('lp-ac-count', 'err'); } } async function toggleTrains() { @@ -3320,12 +3573,12 @@ async function loadTrains() { const pts = await r.json(); if (req !== overlayReq.trains) return; trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train'); - document.getElementById('lp-trains-count').textContent = (pts.length || 0).toLocaleString(); + setLayerCount('lp-trains-count', (pts.length || 0).toLocaleString()); addExtraAttrib('Amtraker'); } catch (e) { if (isAbort(e)) return; console.error('Trains load failed', e); - document.getElementById('lp-trains-count').textContent = 'err'; + setLayerCount('lp-trains-count', 'err'); } } async function toggleVessels() { @@ -3341,7 +3594,7 @@ async function toggleVessels() { async function loadVessels() { if (!map) return; if (map.getZoom() <= 3) { - document.getElementById('lp-vessels-count').textContent = 'zoom'; + setLayerCount('lp-vessels-count', 'zoom'); return; } const req = ++overlayReq.vessels; @@ -3376,7 +3629,7 @@ async function loadVessels() { const sog = Number((p.speed) || 0); return sog > 0.5 ? '#2dd4bf' : '#64748b'; }, true, 'vessel'); - document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString(); + setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString()); addExtraAttrib('AISStream'); // First positions for a new zone arrive ~1-3s after the retune. if (retuned) { @@ -3386,7 +3639,7 @@ async function loadVessels() { } catch (e) { if (isAbort(e)) return; console.error('Vessels load failed', e); - document.getElementById('lp-vessels-count').textContent = 'err'; + setLayerCount('lp-vessels-count', 'err'); } } async function toggleStorms() { @@ -3413,6 +3666,7 @@ async function loadStorms() { /* ═══════════════ INITIAL LOAD ═══════════════ */ initNav(); +initHudKeys(); initSettings(); initMarketTicker(); checkHealth(); @@ -3425,6 +3679,10 @@ initMap(); // contend with the first bbox burst. Payload is slim (no article bodies). setTimeout(() => loadNews(true), 400); setInterval(checkHealth, 30000); +setTimeout(pollLayerStats, 1800); +setInterval(() => { if (!statsPollDisabled) pollLayerStats(); }, STATS_POLL_MS); +setTimeout(pollSwpc, 2200); +setInterval(pollSwpc, SWPC_POLL_MS); /* Phase 2: DVR slider + geofence draw (no Leaflet.Draw) + fire/aircraft hits */ function dvrQs() { return dvrTs ? `×tamp=${encodeURIComponent(dvrTs)}` : ''; } diff --git a/tests/test_hud_osiris.py b/tests/test_hud_osiris.py new file mode 100644 index 0000000..a4035de --- /dev/null +++ b/tests/test_hud_osiris.py @@ -0,0 +1,90 @@ +"""HUD: layer-rail stats, shortcuts, terminator, zoom-gated cams, SWPC chip.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +HTML = (ROOT / "app/static/index.html").read_text() + + +def _fn(name: str, nxt: str | None = None) -> str: + start = HTML.index(f"function {name}") + if nxt: + return HTML[start : HTML.index(f"function {nxt}", start + 1)] + return HTML[start : start + 4000] + + +def test_stats_poll_uses_api_then_falls_back(): + assert "/api/stats" in HTML + assert "30000" in HTML.split("pollLayerStats")[1][:2500] or "STATS_POLL_MS" in HTML + poll = HTML.split("async function pollLayerStats")[1].split("async function ")[0] + assert "404" in poll + assert "catch" in poll + ids = HTML.split("STATS_COUNT_IDS")[1].split("};")[0] + assert "aircraft" in ids and "cameras" in ids and "fires" in ids and "vessels" in ids + # Overlay loaders still write array lengths when stats is down. + assert "setLayerCount('lp-fires-count'" in HTML or 'setLayerCount("lp-fires-count"' in HTML + assert "setLayerCount('lp-cams-count'" in HTML or 'setLayerCount("lp-cams-count"' in HTML + assert "setLayerCount('lp-ac-count'" in HTML or 'setLayerCount("lp-ac-count"' in HTML + assert "setLayerCount('lp-vessels-count'" in HTML or 'setLayerCount("lp-vessels-count"' in HTML + + +def test_keyboard_shortcuts_do_not_steal_osiris_fs(): + keys = HTML.split("function initHudKeys")[1].split("function ")[0] + assert "Escape" in keys + assert "cheat-sheet" in keys or "toggleCheatSheet" in keys + assert "mapResetView" in keys + assert "toggleLayerPanel" in keys or "closeLayerPanel" in keys + # Do not bind Osiris's conflicting F/S (flights vs fullscreen / search). + assert "e.key === 'f'" not in keys.lower() + assert "e.key === 's'" not in keys.lower() + assert "case 'f'" not in keys.lower() + assert "case 's'" not in keys.lower() + assert 'id="cheat-sheet"' in HTML + assert "?" in keys or "Shift" in keys + + +def test_terminator_toggle_defaults_off(): + assert 'id="lp-terminator-on"' in HTML + row = HTML.split('id="lp-terminator-on"')[0][-120:] + HTML.split('id="lp-terminator-on"')[1][:80] + assert "checked" not in row.split(">")[0] + assert "function toggleTerminator" in HTML + assert "subsolarPoint" in HTML or "terminator" in HTML.lower() + + +def test_camera_thumbs_gated_at_zoom_12(): + assert "CAM_THUMB_MIN_ZOOM" in HTML + assert "CAM_THUMB_MIN_ZOOM = 12" in HTML + thumb = _fn("camThumb", "camPopupHtml") + assert "camThumbsAllowed" in thumb or "CAM_THUMB_MIN_ZOOM" in thumb + assert "zoom in for preview" in HTML or "zoom for preview" in HTML + assert "preview unavailable" in HTML + # Masscan / RTSP still proxy through snapshot; never emit rtsp hrefs. + src = _fn("camSourceLink", "youtubeId") + assert "rtsp://" in src + assert "href=" not in src.split("rtsp://")[1].split("return")[0] or "Never emit" in src + assert 'href="${esc(url)}"' in src or "href=\"${esc(url)}\"" in src + assert src.index("rtsp://") < src.index("href=") + + +def test_swpc_chip_browser_direct_correct_urls(): + assert 'id="swpc-chip"' in HTML + assert "services.swpc.noaa.gov/json/planetary_k_index_1m.json" in HTML + assert "services.swpc.noaa.gov/json/goes/primary/xray-flares-latest.json" in HTML + assert "services.swpc.noaa.gov/products/alerts.json" in HTML + assert "services.swpc.noaa.gov/json/alerts.json" not in HTML + sw = HTML.split("async function pollSwpc")[1].split("async function ")[0] + assert "hidden" in sw + assert "kp_index" in sw + assert "90000" in HTML or "SWPC_POLL_MS" in HTML + + +def test_new_chrome_does_not_cover_mobile_layers_zoom(): + mobile = HTML.split("@media (max-width: 820px)")[1].split("@media (prefers-reduced-motion")[0] + assert "#layer-panel" in mobile + assert ".leaflet-top.leaflet-right .leaflet-control-zoom" in mobile + assert 'id="cheat-sheet"' in HTML + cheat = HTML.split(".cheat-sheet")[1][:500] + assert "z-index" in cheat + assert "calc(100% - 96px)" in cheat or "96px" in cheat -- 2.45.3