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).
This commit is contained in:
parent
c48788d4b6
commit
f61d587523
2 changed files with 364 additions and 16 deletions
|
|
@ -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 @@
|
|||
<span class="clock"><span id="utc-clock">--:--:--</span> UTC</span>
|
||||
<span class="sep hide-sm">|</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 class="nav">
|
||||
<button id="nav-btn" class="nav-btn" aria-haspopup="true" aria-expanded="false">
|
||||
|
|
@ -790,6 +825,13 @@
|
|||
</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 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-row">
|
||||
<label class="lp-name"><input type="checkbox" id="lp-fires-on" checked onchange="toggleFires()"> <span class="lp-dot fires"></span> FIRMS Fires</label>
|
||||
|
|
@ -939,6 +981,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-news"><span class="c news"></span>NEWS <b id="hud-news-n">—</b></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">
|
||||
<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">
|
||||
|
|
@ -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 '<div class="thumb placeholder">no preview available</div>';
|
||||
if (!camThumbsAllowed()) {
|
||||
return '<div class="thumb placeholder">zoom in for preview</div>';
|
||||
}
|
||||
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(`<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);
|
||||
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('<a href="https://www.adsb.lol/docs/open-data/api">ADSB.lol</a> ODbL');
|
||||
addExtraAttrib('<a href="https://www.planespotters.net/photo/api">Photo © planespotters.net</a>');
|
||||
} 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('<a href="https://amtraker.com/about">Amtraker</a>');
|
||||
} 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)}` : ''; }
|
||||
|
|
|
|||
90
tests/test_hud_osiris.py
Normal file
90
tests/test_hud_osiris.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue