GODSIGH/js/ui.js
type-two 30b1381a8b feat: Wave 6.2 — swap paywalled Windy for free 511-network road cams
Windy moved its Webcams API behind a paid tier (only the in-site Plugins
API is free), so the windycams layer is gone before ever going live. In
its place: roadcams — official public traffic cameras from keyless 511
APIs (a North-America-standard platform, one adapter covers many regions).
Launching with Alberta (367) + Ontario (946); more regions are one entry
in REGIONS + serve.py CAMS_REGIONS + an nginx location each (several 511s
just need free keys). InfoBox descriptions are CallbackProperties, so the
snapshot cache-buster is computed at click time — always-fresh images with
zero entity rebuilds. Verified: 1,302 cams, live Toronto snapshot in-app.

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

157 lines
6.5 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', roadcams: '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;
// SOLARGOD is a localhost dev server for now — showing that link on the live
// site would be a dead button. Hide it unless GODSIGH itself is on localhost;
// when SOLARGOD gets a public home, point this at it and drop the guard.
const local = /^(127\.0\.0\.1|localhost)$/.test(location.hostname);
if (!local) { a.style.display = 'none'; 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;
}