Three new layers from the OSINT4ALL directory triage: - jamcams (registry): ~800 live London traffic cameras via TfL open API (CORS-open, keyless). Click a cam dot -> live snapshot in the InfoBox, 5-min cache-busted refresh. New collapsed 'Regional - London' category. - inciweb (registry): named US wildfire incidents from InciWeb RSS. New spec.parse hook in the geojson-layer factory lets XML feeds supply their own parser; coordinates regex'd out of DMS text in <description>. - radio (bespoke): ~12k live radio stations from Radio Garden pinned to their cities (PointPrimitiveCollection). Click -> station list + live audio player (audio streams direct; JSON via new allowlisted serve.py proxy/rg/ with 12h/1h caches, SSRF-tested). Keeps playing while you pan. Deploy note: prod nginx needs a location for /godsigh/proxy/rg/ before the radio layer works live (mirror of serve.py RG_PATH_RE allowlist). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
6.1 KiB
JavaScript
152 lines
6.1 KiB
JavaScript
// HUD controller: categorized collapsible layer list, per-feed status lines,
|
||
// LIVE/SCRUBBED chip.
|
||
|
||
const rows = new Map();
|
||
|
||
// Category scheme is owned here so HUD order is stable regardless of layer
|
||
// registration order. Categories collapsed-by-default are marked below.
|
||
const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia', 'Regional — London'];
|
||
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia', 'Regional — London']);
|
||
const CATEGORY_BY_ID = {
|
||
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', celestial: 'Space',
|
||
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
||
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
||
ships: 'Sea',
|
||
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
||
infra: 'Context', events: 'Context', radio: 'Context',
|
||
rfs: 'Regional — Australia',
|
||
jamcams: 'Regional — London', inciweb: 'Earth & hazards',
|
||
};
|
||
|
||
const sections = new Map(); // category → { body }
|
||
|
||
// Lazily create a category section, inserted in CATEGORY_ORDER position.
|
||
function sectionFor(category) {
|
||
if (sections.has(category)) return sections.get(category);
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'cat';
|
||
if (COLLAPSED_BY_DEFAULT.has(category)) wrap.classList.add('collapsed');
|
||
const header = document.createElement('div');
|
||
header.className = 'cat-header';
|
||
header.innerHTML = `<span class="cat-chevron">▾</span><span class="cat-name"></span>`;
|
||
header.querySelector('.cat-name').textContent = category;
|
||
header.addEventListener('click', () => wrap.classList.toggle('collapsed'));
|
||
const body = document.createElement('div');
|
||
body.className = 'cat-body';
|
||
wrap.appendChild(header);
|
||
wrap.appendChild(body);
|
||
|
||
// Insert before the existing section that is this one's immediate successor in
|
||
// CATEGORY_ORDER (smallest index greater than ours), so DOM order stays stable
|
||
// regardless of registration order. Unknown categories sort last.
|
||
const container = document.getElementById('layers');
|
||
const ord = (c) => { const i = CATEGORY_ORDER.indexOf(c); return i < 0 ? Infinity : i; };
|
||
const myOrder = ord(category);
|
||
let before = null, bestOrder = Infinity;
|
||
for (const [cat, s] of sections) {
|
||
const idx = ord(cat);
|
||
if (idx > myOrder && idx < bestOrder) { bestOrder = idx; before = s.wrap; }
|
||
}
|
||
container.insertBefore(wrap, before);
|
||
const rec = { wrap, body };
|
||
sections.set(category, rec);
|
||
return rec;
|
||
}
|
||
|
||
// Register a layer row. Returns a handle so callers can flip the checkbox.
|
||
// `category` is optional; a known id maps to a fixed category regardless.
|
||
export function addLayer(id, name, defaultOn, onToggle, category) {
|
||
const cat = CATEGORY_BY_ID[id] || category || 'Other';
|
||
const row = document.createElement('div');
|
||
row.className = 'layer-row';
|
||
row.innerHTML = `
|
||
<label>
|
||
<input type="checkbox" ${defaultOn ? 'checked' : ''} />
|
||
<span class="dot" data-state="${defaultOn ? 'ok' : 'off'}"></span>
|
||
<span class="lname"></span>
|
||
</label>
|
||
<div class="lstatus"></div>`;
|
||
row.querySelector('.lname').textContent = name;
|
||
const cb = row.querySelector('input');
|
||
cb.addEventListener('change', () => onToggle(cb.checked));
|
||
sectionFor(cat).body.appendChild(row);
|
||
rows.set(id, row);
|
||
return { setChecked: (v) => { cb.checked = v; } };
|
||
}
|
||
|
||
// Registered layer ids, in registration order (for URL-state serialization).
|
||
export function getLayerIds() {
|
||
return [...rows.keys()];
|
||
}
|
||
|
||
// Is a layer's checkbox currently checked?
|
||
export function getLayerChecked(id) {
|
||
const row = rows.get(id);
|
||
return row ? row.querySelector('input').checked : false;
|
||
}
|
||
|
||
// Set a layer's checkbox AND fire its onToggle (replays a real user toggle),
|
||
// so restoring URL state actually shows/hides the layer. No-op if unchanged.
|
||
export function setLayerChecked(id, on) {
|
||
const row = rows.get(id);
|
||
if (!row) return;
|
||
const cb = row.querySelector('input');
|
||
if (cb.checked !== on) {
|
||
cb.checked = on;
|
||
cb.dispatchEvent(new Event('change'));
|
||
}
|
||
}
|
||
|
||
// state ∈ 'ok' | 'warn' | 'err' | 'off'
|
||
export function setStatus(id, text, state = 'ok') {
|
||
const row = rows.get(id);
|
||
if (!row) return;
|
||
row.querySelector('.lstatus').textContent = text;
|
||
row.querySelector('.dot').dataset.state = state;
|
||
}
|
||
|
||
export function setLiveChip(isLive) {
|
||
const chip = document.getElementById('live-chip');
|
||
if (!chip) return;
|
||
chip.textContent = isLive ? 'LIVE' : 'SCRUBBED';
|
||
chip.title = isLive ? 'Clock is at wall-clock now' : 'Live layers paused — scrubbed off now';
|
||
chip.dataset.state = isLive ? 'live' : 'scrub';
|
||
}
|
||
|
||
// Portal to SOLARGOD (sibling orrery). `getMs` returns GODSIGH's current sim
|
||
// time as unix ms; we rebuild the deep-link href fresh at click time so the
|
||
// orrery opens at whatever moment the clock is scrubbed to, via the shared
|
||
// `#t=@<unixMs>` hash convention. pointerdown fires before navigation, so this
|
||
// also covers ⌘/ctrl-click and middle-click new-tab opens.
|
||
export function wireSolargodLink(getMs) {
|
||
const a = document.getElementById('solargod-link');
|
||
if (!a) return;
|
||
const sync = () => { a.href = `http://127.0.0.1:8147/#t=@${getMs()}`; };
|
||
a.addEventListener('pointerdown', sync);
|
||
a.addEventListener('click', sync);
|
||
}
|
||
|
||
// Small fixed overlay for primitive picks (aircraft), which have no Cesium InfoBox.
|
||
export function showPickOverlay(title, rowPairs) {
|
||
const el = document.getElementById('pick-overlay');
|
||
if (!el) return;
|
||
el.innerHTML =
|
||
`<div class="po-close" role="button" aria-label="close">×</div>` +
|
||
`<div class="po-title"></div>` +
|
||
rowPairs.map(() => `<div class="po-row"><span></span><span></span></div>`).join('');
|
||
el.querySelector('.po-title').textContent = title;
|
||
const rowEls = el.querySelectorAll('.po-row');
|
||
rowPairs.forEach(([k, v], i) => {
|
||
const spans = rowEls[i].querySelectorAll('span');
|
||
spans[0].textContent = k;
|
||
spans[1].textContent = v;
|
||
});
|
||
el.querySelector('.po-close').addEventListener('click', hidePickOverlay);
|
||
el.hidden = false;
|
||
}
|
||
|
||
export function hidePickOverlay() {
|
||
const el = document.getElementById('pick-overlay');
|
||
if (el) el.hidden = true;
|
||
}
|