feat(hud): layer counts, shortcuts, terminator, zoom-gated cams, SWPC #41

Merged
sirius merged 4 commits from osint-dashboard/t_45362857-osint-hud-counts-shortcuts-terminator-zo into master 2026-08-31 23:13:53 -04:00
2 changed files with 363 additions and 16 deletions

View file

@ -352,6 +352,10 @@
.lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; } .lp-dot.wfigs { background: #ef4444; box-shadow: 0 0 7px #ef4444; }
.lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; } .lp-dot.trains { background: #c084fc; box-shadow: 0 0 7px #c084fc; }
.lp-dot.storms { background: #f472b6; box-shadow: 0 0 7px #f472b6; } .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-dot.conflicts { background: #ff2a6d; box-shadow: 0 0 7px #ff2a6d; } .lp-dot.conflicts { background: #ff2a6d; box-shadow: 0 0 7px #ff2a6d; }
.lp-count { font-family: 'Share Tech Mono', monospace; font-size: 0.7rem; color: var(--cyan); } .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-sub { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; }
@ -376,6 +380,34 @@
.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 { 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); } .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); }
/* ── Place dossier (“Whats here?”) ── */ /* ── Place dossier (“Whats here?”) ── */
#place-dossier { #place-dossier {
position: absolute; top: 58px; right: 54px; z-index: 650; position: absolute; top: 58px; right: 54px; z-index: 650;
@ -806,6 +838,8 @@
<span class="clock"><span id="utc-clock">--:--:--</span> UTC</span> <span class="clock"><span id="utc-clock">--:--:--</span> UTC</span>
<span class="sep hide-sm">|</span> <span class="sep hide-sm">|</span>
<span class="hide-sm" id="last-update"></span> <span class="hide-sm" id="last-update"></span>
<span class="sep hide-sm" id="swpc-sep" hidden>|</span>
<span class="hide-sm" id="swpc-chip" hidden title="NOAA SWPC planetary K-index">Kp —</span>
</div> </div>
<div class="nav"> <div class="nav">
<button id="nav-btn" class="nav-btn" aria-haspopup="true" aria-expanded="false"> <button id="nav-btn" class="nav-btn" aria-haspopup="true" aria-expanded="false">
@ -864,6 +898,13 @@
</div> </div>
<div class="lp-opacity">Opacity <input type="range" id="lp-base-opacity" min="5" max="100" value="100" oninput="setBaseOpacity(this.value)"><b id="lp-base-val">100%</b></div> <div class="lp-opacity">Opacity <input type="range" id="lp-base-opacity" min="5" max="100" value="100" oninput="setBaseOpacity(this.value)"><b id="lp-base-val">100%</b></div>
</div> </div>
<div class="lp-layer">
<div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-terminator-on" onchange="toggleTerminator()"> <span class="lp-dot night"></span> Day/Night</label>
<span class="lp-count" id="lp-terminator-count">off</span>
</div>
<div class="lp-note">Sun terminator overlay. Default off. No API.</div>
</div>
<div class="lp-layer"> <div class="lp-layer">
<div class="lp-row"> <div class="lp-row">
<label class="lp-name"><input type="checkbox" id="lp-fires-on" checked onchange="toggleFires()"> <span class="lp-dot fires"></span> FIRMS Fires</label> <label class="lp-name"><input type="checkbox" id="lp-fires-on" checked onchange="toggleFires()"> <span class="lp-dot fires"></span> FIRMS Fires</label>
@ -1039,6 +1080,16 @@
<div class="hud-chip" id="hud-blips"><span class="c blips"></span>BLIPS <b id="hud-blips-n"></b></div> <div class="hud-chip" id="hud-blips"><span class="c blips"></span>BLIPS <b id="hud-blips-n"></b></div>
<div class="hud-chip" id="hud-news"><span class="c news"></span>NEWS <b id="hud-news-n"></b></div> <div class="hud-chip" id="hud-news"><span class="c news"></span>NEWS <b id="hud-news-n"></b></div>
</div> </div>
<div id="cheat-sheet" class="cheat-sheet" hidden role="dialog" aria-label="Keyboard shortcuts">
<h3>Shortcuts</h3>
<dl>
<dt><kbd>?</kbd></dt><dd>This cheat sheet</dd>
<dt><kbd>L</kbd></dt><dd>Toggle layer rail</dd>
<dt><kbd>R</kbd></dt><dd>Reset map view</dd>
<dt><kbd>Esc</kbd></dt><dd>Close panels</dd>
</dl>
<p class="lp-note" style="margin-top:0.7rem">Camera / HLS thumbs at zoom 12+. F and S are left unbound.</p>
</div>
<div id="dvr-bar" class="live"> <div id="dvr-bar" class="live">
<button class="btn" id="dvr-live" type="button" onclick="dvrGoLive()">LIVE</button> <button class="btn" id="dvr-live" type="button" onclick="dvrGoLive()">LIVE</button>
<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">
@ -1380,6 +1431,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) ═══════════════ */ /* ═══════════════ NAV (dropdown) ═══════════════ */
const VIEWS = ['map', 'news', 'events', 'alerts', 'entities', 'ingest', 'keys', 'settings']; const VIEWS = ['map', 'news', 'events', 'alerts', 'entities', 'ingest', 'keys', 'settings'];
@ -2228,7 +2425,7 @@ async function initMap() {
.then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); }) .then(r => { if (!r.ok) throw new Error('camera detail'); return r.json(); })
.then(c => { .then(c => {
box.outerHTML = camPopupHtml(c); box.outerHTML = camPopupHtml(c);
startHlsFrom(e.popup.getElement()); if (camThumbsAllowed()) startHlsFrom(e.popup.getElement());
}) })
.catch(() => { .catch(() => {
const ph = box.querySelector('.thumb.placeholder'); const ph = box.querySelector('.thumb.placeholder');
@ -2236,7 +2433,7 @@ async function initMap() {
}); });
return; return;
} }
startHlsFrom(root); if (camThumbsAllowed()) startHlsFrom(root);
}); });
map.on('popupopen', (e) => { loadPlanePhoto(e.popup); }); map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });
map.on('popupclose', () => { map.on('popupclose', () => {
@ -2581,6 +2778,56 @@ function setBaseOpacity(v) {
if (mapLayer && document.getElementById('lp-base-on').checked) mapLayer.setOpacity(baseOpacity); 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 ── */ /* ── FIRMS fire heatmap ── */
function firesIntensity(f) { function firesIntensity(f) {
const confidence = f.confidence || f.c; const confidence = f.confidence || f.c;
@ -2650,7 +2897,7 @@ async function loadFires() {
gradient: firesGradient(), gradient: firesGradient(),
}).addTo(map); }).addTo(map);
firesHeat._canvas.style.opacity = firesOpacity; firesHeat._canvas.style.opacity = firesOpacity;
document.getElementById('lp-fires-count').textContent = fires.length.toLocaleString(); setLayerCount('lp-fires-count', fires.length.toLocaleString());
hudFiresCount = fires.length; hudFiresCount = fires.length;
syncHud(); syncHud();
document.getElementById('map-hint').textContent = document.getElementById('map-hint').textContent =
@ -2701,8 +2948,14 @@ function camKind(c) {
if (c.device_type === 'rtsp' || u.startsWith('rtsp://')) return 'rtsp'; if (c.device_type === 'rtsp' || u.startsWith('rtsp://')) return 'rtsp';
return 'http'; return 'http';
} }
function camThumbsAllowed() {
return map && map.getZoom() >= CAM_THUMB_MIN_ZOOM;
}
function camThumb(c) { function camThumb(c) {
if (!c.id) return '<div class="thumb placeholder">no preview available</div>'; if (!c.id) return '<div class="thumb placeholder">no preview available</div>';
if (!camThumbsAllowed()) {
return '<div class="thumb placeholder">zoom in for preview</div>';
}
const kind = camKind(c); const kind = camKind(c);
if (kind === 'youtube') { if (kind === 'youtube') {
const vid = youtubeId(c.snapshot_url || c.source_url); const vid = youtubeId(c.snapshot_url || c.source_url);
@ -2778,7 +3031,7 @@ async function loadCams() {
}); });
camsGroup.addTo(map); camsGroup.addTo(map);
camsGroup.eachLayer(l => l.setOpacity && l.setOpacity(camsOpacity)); 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; hudCamsCount = cams.length;
syncHud(); syncHud();
document.getElementById('map-hint').textContent = document.getElementById('map-hint').textContent =
@ -2937,8 +3190,7 @@ function dropLayer(ref) {
const HEAVY_MIN_ZOOM = 4; const HEAVY_MIN_ZOOM = 4;
function tooZoomedOut() { return !map || map.getZoom() <= HEAVY_MIN_ZOOM; } function tooZoomedOut() { return !map || map.getZoom() <= HEAVY_MIN_ZOOM; }
function markZoom(id) { function markZoom(id) {
const el = document.getElementById(id); setLayerCount(id, 'zoom');
if (el) el.textContent = 'zoom';
} }
function intersectsConus() { function intersectsConus() {
if (!map) return false; if (!map) return false;
@ -3369,12 +3621,12 @@ async function loadWxAlerts() {
layer.bindPopup(`<div class="blip-pop"><div class="blip-src">${esc(p.source || 'alert')}</div><b>${esc(p.event || p.headline || 'Alert')}</b><div class="blip-time">${esc(p.severity || '')} · ${esc(p.areaDesc || p.wfo || '')}</div></div>`); layer.bindPopup(`<div class="blip-pop"><div class="blip-src">${esc(p.source || 'alert')}</div><b>${esc(p.event || p.headline || 'Alert')}</b><div class="blip-time">${esc(p.severity || '')} · ${esc(p.areaDesc || p.wfo || '')}</div></div>`);
}, },
}).addTo(map); }).addTo(map);
document.getElementById('lp-alerts-count').textContent = feats.length.toLocaleString(); setLayerCount('lp-alerts-count', feats.length.toLocaleString());
addExtraAttrib('NWS / IEM storm-based warnings'); addExtraAttrib('NWS / IEM storm-based warnings');
} catch (e) { } catch (e) {
if (isAbort(e)) return; if (isAbort(e)) return;
console.error('Alerts load failed', e); console.error('Alerts load failed', e);
document.getElementById('lp-alerts-count').textContent = 'err'; setLayerCount('lp-alerts-count', 'err');
} }
} }
async function togglePerimeters() { async function togglePerimeters() {
@ -3455,7 +3707,7 @@ function toggleAircraftMil() {
async function loadAircraft() { async function loadAircraft() {
if (!map) return; if (!map) return;
if (map.getZoom() <= 3) { if (map.getZoom() <= 3) {
document.getElementById('lp-ac-count').textContent = 'zoom'; setLayerCount('lp-ac-count', 'zoom');
return; return;
} }
const req = ++overlayReq.ac; const req = ++overlayReq.ac;
@ -3468,7 +3720,7 @@ async function loadAircraft() {
const shown = acMilOn ? all.filter(acVisible) : all; const shown = acMilOn ? all.filter(acVisible) : all;
lastAircraft = shown; lastAircraft = shown;
acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac'); acGroup = renderPoints(acGroup, shown, p => acColor(p), true, 'ac');
document.getElementById('lp-ac-count').textContent = shown.length.toLocaleString(); setLayerCount('lp-ac-count', (shown.length || 0).toLocaleString());
const milEl = document.getElementById('lp-ac-mil-count'); const milEl = document.getElementById('lp-ac-mil-count');
if (milEl) { if (milEl) {
milEl.textContent = acMilOn milEl.textContent = acMilOn
@ -3480,7 +3732,7 @@ async function loadAircraft() {
} catch (e) { } catch (e) {
if (isAbort(e)) return; if (isAbort(e)) return;
console.error('Aircraft load failed', e); console.error('Aircraft load failed', e);
document.getElementById('lp-ac-count').textContent = 'err'; setLayerCount('lp-ac-count', 'err');
} }
} }
async function toggleTrains() { async function toggleTrains() {
@ -3496,12 +3748,12 @@ async function loadTrains() {
const pts = await r.json(); const pts = await r.json();
if (req !== overlayReq.trains) return; if (req !== overlayReq.trains) return;
trainsGroup = renderPoints(trainsGroup, Array.isArray(pts) ? pts : [], p => (p.extra || {}).iconColor || '#c084fc', false, 'train'); 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('<a href="https://amtraker.com/about">Amtraker</a>'); addExtraAttrib('<a href="https://amtraker.com/about">Amtraker</a>');
} catch (e) { } catch (e) {
if (isAbort(e)) return; if (isAbort(e)) return;
console.error('Trains load failed', e); console.error('Trains load failed', e);
document.getElementById('lp-trains-count').textContent = 'err'; setLayerCount('lp-trains-count', 'err');
} }
} }
async function toggleVessels() { async function toggleVessels() {
@ -3518,7 +3770,7 @@ async function toggleVessels() {
async function loadVessels() { async function loadVessels() {
if (!map) return; if (!map) return;
if (map.getZoom() <= 3) { if (map.getZoom() <= 3) {
document.getElementById('lp-vessels-count').textContent = 'zoom'; setLayerCount('lp-vessels-count', 'zoom');
return; return;
} }
const req = ++overlayReq.vessels; const req = ++overlayReq.vessels;
@ -3554,7 +3806,7 @@ async function loadVessels() {
const sog = Number((p.speed) || 0); const sog = Number((p.speed) || 0);
return sog > 0.5 ? '#2dd4bf' : '#64748b'; return sog > 0.5 ? '#2dd4bf' : '#64748b';
}, true, 'vessel'); }, true, 'vessel');
document.getElementById('lp-vessels-count').textContent = (pts.length || 0).toLocaleString(); setLayerCount('lp-vessels-count', (pts.length || 0).toLocaleString());
addExtraAttrib('AISStream'); addExtraAttrib('AISStream');
// 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) {
@ -3564,7 +3816,7 @@ async function loadVessels() {
} catch (e) { } catch (e) {
if (isAbort(e)) return; if (isAbort(e)) return;
console.error('Vessels load failed', e); console.error('Vessels load failed', e);
document.getElementById('lp-vessels-count').textContent = 'err'; setLayerCount('lp-vessels-count', 'err');
} }
} }
async function toggleStorms() { async function toggleStorms() {
@ -3838,6 +4090,7 @@ async function loadConflicts(paint) {
/* ═══════════════ INITIAL LOAD ═══════════════ */ /* ═══════════════ INITIAL LOAD ═══════════════ */
initNav(); initNav();
initHudKeys();
initSettings(); initSettings();
initMarketTicker(); initMarketTicker();
checkHealth(); checkHealth();
@ -3850,6 +4103,10 @@ initMap();
// contend with the first bbox burst. Payload is slim (no article bodies). // contend with the first bbox burst. Payload is slim (no article bodies).
setTimeout(() => loadNews(true), 400); setTimeout(() => loadNews(true), 400);
setInterval(checkHealth, 30000); 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 */ /* 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)}` : ''; }

90
tests/test_hud_osiris.py Normal file
View file

@ -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