GODSIGH/js/ui.js
type-two 092fe5254a feat: HUD hide/unhide tab + click-to-live chip
The side panel covered Cesium's clock dial, so a scrubbed clock was easy
to get stuck in with no visible way back — live-only layers (aircraft,
AIS ships) then look broken. Two escape hatches: a collapse tab riding
the panel edge (docks top-left when hidden, revealing the dial), and the
LIVE/SCRUBBED chip is now a button that snaps the clock straight back to
now from anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:31:00 +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', ais: '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' : 'Scrubbed off now — click to snap back to live';
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;
}