GODSIGH/js/ui.js
type-two f55d3202e0 feat: Wave 6.1 keyed layers — NASA FIRMS hotspots + Windy webcams
- firms (bespoke): raw VIIRS S-NPP fire detections, world, 24h. CSV via
  new serve.py proxy/firms (key from gitignored firmscredentials.json,
  injected server-side, 1h cache + stale-on-error). Points colored/sized
  by fire radiative power; click -> FRP/brightness/time/satellite overlay.
  Verified live: 1412 detections, hottest 88 MW seen 00:35 UTC today.
- windycams (bespoke): viewport-driven Windy Webcams v3 — nearest ~50 cams
  wherever the camera stops, entities with live preview img in InfoBox.
  proxy/windy validates nearby=lat,lon,km, injects x-windy-api-key header,
  10min coarsened cache. Degrades cleanly: 'key rejected' status until a
  real WEBCAMS-product key lands in windycredentials.json (re-read per
  request, no restart needed).
- key loader tolerant of JSON, brace-less fragments, or raw pasted tokens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 14:14:32 +10:00

152 lines
6.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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', windycams: 'Context',
rfs: 'Regional — Australia',
jamcams: 'Regional — London', inciweb: 'Earth & hazards', firms: '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;
}