@@ -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) ═══════════════ */
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(c => {
box.outerHTML = camPopupHtml(c);
- startHlsFrom(e.popup.getElement());
+ if (camThumbsAllowed()) startHlsFrom(e.popup.getElement());
})
.catch(() => {
const ph = box.querySelector('.thumb.placeholder');
@@ -2236,7 +2433,7 @@ async function initMap() {
});
return;
}
- startHlsFrom(root);
+ if (camThumbsAllowed()) startHlsFrom(root);
});
map.on('popupopen', (e) => { loadPlanePhoto(e.popup); });
map.on('popupclose', () => {
@@ -2581,6 +2778,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;
@@ -2650,7 +2897,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 =
@@ -2701,8 +2948,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);
@@ -2778,7 +3031,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 =
@@ -2937,8 +3190,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;
@@ -3369,12 +3621,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() {
@@ -3455,7 +3707,7 @@ function toggleAircraftMil() {
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;
@@ -3468,7 +3720,7 @@ async function loadAircraft() {
const shown = acMilOn ? all.filter(acVisible) : all;
lastAircraft = shown;
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');
if (milEl) {
milEl.textContent = acMilOn
@@ -3480,7 +3732,7 @@ async function loadAircraft() {
} 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() {
@@ -3496,12 +3748,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() {
@@ -3518,7 +3770,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;
@@ -3554,7 +3806,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) {
@@ -3564,7 +3816,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() {
@@ -3838,6 +4090,7 @@ async function loadConflicts(paint) {
/* ═══════════════ INITIAL LOAD ═══════════════ */
initNav();
+initHudKeys();
initSettings();
initMarketTicker();
checkHealth();
@@ -3850,6 +4103,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