GODSIGH/js/ui.js
monsterrobotparty d06ebf07cb 🪐 portal: the orrery gets a door — 🪐 SOLARGOD button deep-links at GODSIGH's scrubbed moment
Reciprocal to SOLARGOD's Earth-card link into GODSIGH. A subtle HUD pill
(above the footer, styled to the mode-button family) opens the orrery in a
new tab. The href is rebuilt fresh at click time from viewer.clock.currentTime
via the shared `#t=@<unixMs>` convention, so scrubbing GODSIGH's clock carries
straight through to SOLARGOD's sim time.

- index.html: <a#solargod-link.portal-link> between #layers and footer
- css: .portal-link (matches .modes button; accent on hover)
- ui.js: wireSolargodLink(getMs) — rebuilds href on pointerdown+click
  (covers ⌘/middle-click new-tab); Cesium stays out of the DOM module
- main.js: wire it with a getMs closure over viewer.clock

Verified in-browser: button renders, zero console errors, all layers boot;
live click → href tracks the clock; scrub -3h → href exactly 3h behind now +
chip flips SCRUBBED + live layers pause; opening the link launches SOLARGOD
in SCRUBBED state at the passed epoch (its hash echoes the exact ms).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 09:45:48 +10:00

150 lines
5.9 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'];
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']);
const CATEGORY_BY_ID = {
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space',
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
ships: 'Sea',
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
infra: 'Context', events: 'Context',
rfs: 'Regional — Australia',
};
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;
}