GODSIGH/js/main.js

332 lines
13 KiB
JavaScript

// GODSIGH bootstrap: viewer, basemaps, clock, live/scrub gating, picking, and
// dynamic layer loading.
//
// LAYER CONTRACT — each module in js/layers/*.js default-exports a factory:
//
// export default function create(ctx) {
// const { viewer, CONFIG, lib, ui, Cesium, start, stop, now } = ctx;
// // register HUD row(s) with ui.addLayer(id, name, defaultOn, onToggle)
// // report progress/failure with ui.setStatus(id, text, 'ok'|'warn'|'err')
// return {
// id: 'x', // string
// onClockTick(currentTime, isLive) {}, // optional; called ~1/s
// handlePick(picked) { return false; }, // optional; true if it consumed the click
// clearPick() {}, // optional; hide any custom overlay
// };
// }
import { CONFIG } from './config.js';
import * as lib from './lib.js';
import * as ui from './ui.js';
import { REGISTRY } from './registry.js';
import { createGeoJsonLayer } from './layers/geojson-layer.js';
import { ADSB_LAYERS } from './adsb-registry.js';
import { createAdsbLayer } from './layers/adsb-layer.js';
import initSolarSystem from './solarsystem.js';
import * as styles from './styles.js';
import * as keyed from './keyed.js';
import { initVoice } from './voice.js';
const Cesium = window.Cesium;
// ---- clock window: now-6h .. now+6h ----
const now = Cesium.JulianDate.now();
const start = Cesium.JulianDate.addHours(now, -CONFIG.windowHours, new Cesium.JulianDate());
const stop = Cesium.JulianDate.addHours(now, CONFIG.windowHours, new Cesium.JulianDate());
const viewer = new Cesium.Viewer('cesiumContainer', {
baseLayer: false, // we manage imagery ourselves — no Cesium ion token
baseLayerPicker: false,
geocoder: false,
sceneModePicker: false,
navigationHelpButton: false,
homeButton: false,
fullscreenButton: false,
timeline: true,
animation: true,
infoBox: true,
selectionIndicator: true,
shouldAnimate: true,
});
// ---- basemaps ----
const imagery = viewer.imageryLayers;
const darkLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: CONFIG.basemaps.dark.url,
subdomains: CONFIG.basemaps.dark.subdomains,
credit: CONFIG.basemaps.dark.credit,
maximumLevel: CONFIG.basemaps.dark.maximumLevel,
}));
const photoLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
url: CONFIG.basemaps.photo.url,
credit: CONFIG.basemaps.photo.credit,
maximumLevel: CONFIG.basemaps.photo.maximumLevel,
}));
photoLayer.show = false;
// Four basemap modes; bing/3d exist only once keyed.js activates them (Wave 4.1).
let currentMode = 'data';
let bingLayer = null; // Cesium ion Bing imagery layer, set by keyed.js
let google3d = false; // Google 3D tileset ready (keyed.js owns its .show)
function setMode(mode) {
if (mode === 'bing' && !bingLayer) mode = 'data'; // keyless → graceful fallback
if (mode === '3d' && !google3d) mode = 'data';
currentMode = mode;
const photo = mode === 'photo';
const bing = mode === 'bing';
photoLayer.show = photo;
darkLayer.show = mode === 'data';
if (bingLayer) bingLayer.show = bing;
// Data Mode = flat high-contrast asset map (always readable).
// Photo/Bing = realistic imagery with a live day/night terminator.
// 3D = Google Photorealistic tiles replace the globe entirely.
viewer.scene.globe.show = mode !== '3d';
viewer.scene.globe.enableLighting = photo || bing;
viewer.scene.globe.showGroundAtmosphere = photo || bing;
for (const [id, m] of [['mode-data', 'data'], ['mode-photo', 'photo'], ['mode-bing', 'bing'], ['mode-3d', '3d']]) {
document.getElementById(id)?.classList.toggle('active', mode === m);
}
}
document.getElementById('mode-data').addEventListener('click', () => setMode('data'));
document.getElementById('mode-photo').addEventListener('click', () => setMode('photo'));
// ---- HUD hide/unhide + snap-to-live chip ----
const hudToggle = document.getElementById('hud-toggle');
hudToggle.addEventListener('click', () => {
const hidden = document.body.classList.toggle('hud-hidden');
hudToggle.textContent = hidden ? '▶' : '◀';
hudToggle.title = hidden ? 'Show panel' : 'Hide panel';
});
// Clicking LIVE/SCRUBBED snaps the clock back to now — the panel can cover the
// Cesium clock dial, so this is the always-visible escape hatch from scrub mode.
document.getElementById('live-chip').addEventListener('click', () => {
viewer.clock.currentTime = clampToWindow(Cesium.JulianDate.now());
});
// ---- sensor styles (NVG/FLIR/CRT post-process; must init before hash parse) ----
styles.initStyles(viewer);
// ---- portal to SOLARGOD (sibling orrery) ----
// Deep-links to the solar-system app at GODSIGH's current sim moment via its
// shared `#t=@<unixMs>` convention; the href is rebuilt fresh at click time.
ui.wireSolargodLink(() => Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime());
// ---- clock configuration ----
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = now.clone();
viewer.clock.clockRange = Cesium.ClockRange.CLAMPED;
viewer.clock.multiplier = 1;
viewer.timeline.zoomTo(start, stop);
// ---- shareable URL state (#c=lon,lat,height,heading,pitch&m=d|p&L=on,ids&t=offsetSec) ----
function clampToWindow(jd) {
if (Cesium.JulianDate.lessThan(jd, start)) return start.clone();
if (Cesium.JulianDate.greaterThan(jd, stop)) return stop.clone();
return jd;
}
let pendingLayerList = null; // ON-layer ids from the hash, applied after layers load
function applyHashState() {
const state = { cameraApplied: false, modeApplied: false };
const raw = location.hash.replace(/^#/, '');
if (!raw) return state;
const params = new URLSearchParams(raw);
const c = params.get('c');
if (c) {
const parts = c.split(',');
const p = parts.map(Number);
// Reject blank tokens too — Number('') is 0, which would silently apply a
// bogus camera from a truncated hash instead of falling back to the default.
if (parts.length === 5 && parts.every((s) => s.trim() !== '') && p.every(Number.isFinite)) {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(p[0], p[1], p[2]),
orientation: { heading: Cesium.Math.toRadians(p[3]), pitch: Cesium.Math.toRadians(p[4]), roll: 0 },
});
state.cameraApplied = true;
}
}
const m = params.get('m');
if (m === 'p') { setMode('photo'); state.modeApplied = true; }
else if (m === 'd') { setMode('data'); state.modeApplied = true; }
// Keyed basemaps aren't loaded yet at boot — remember the wish, applied by
// keyed.js's onBasemapReady (keyless viewers just stay on the default).
else if (m === 'b') state.keyedMode = 'bing';
else if (m === 'g') state.keyedMode = '3d';
const s = params.get('s');
if (s) styles.setStyle(s); // unknown ids are ignored by setStyle
const t = params.get('t');
if (t !== null && t !== '' && Number.isFinite(Number(t))) {
viewer.clock.currentTime = clampToWindow(
Cesium.JulianDate.addSeconds(now, Number(t), new Cesium.JulianDate()));
}
if (params.has('L')) pendingLayerList = params.get('L');
return state;
}
// Parse once at boot; malformed hashes are ignored silently.
let hashState = { cameraApplied: false, modeApplied: false };
try { hashState = applyHashState(); } catch (err) { /* ignore malformed hash */ }
// Default camera opens on the Gulf — only when the hash didn't provide one.
if (!hashState.cameraApplied) {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(CONFIG.camera.lon, CONFIG.camera.lat, CONFIG.camera.height),
});
}
function serializeState() {
const cam = viewer.camera;
const carto = cam.positionCartographic;
const lon = Cesium.Math.toDegrees(carto.longitude).toFixed(3);
const lat = Cesium.Math.toDegrees(carto.latitude).toFixed(3);
const height = Math.round(carto.height);
const heading = (((Cesium.Math.toDegrees(cam.heading) % 360) + 360) % 360).toFixed(3);
const pitch = Cesium.Math.toDegrees(cam.pitch).toFixed(3);
const mode = { data: 'd', photo: 'p', bing: 'b', '3d': 'g' }[currentMode] || 'd';
const onLayers = ui.getLayerIds().filter((id) => ui.getLayerChecked(id));
const curMs = Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime();
const offset = Math.round((curMs - Date.now()) / 1000);
const t = Math.abs(offset) <= CONFIG.liveThresholdSec ? 0 : offset; // 0 = live
const style = styles.getStyle();
const s = style !== 'none' ? `&s=${style}` : '';
return `#c=${lon},${lat},${height},${heading},${pitch}&m=${mode}${s}&L=${onLayers.join(',')}&t=${t}`;
}
function startHashSync() {
// Poll once a second; rewrite only when the serialized state actually changed
// (replaceState keeps it out of history). Covers camera, mode, layers, scrub.
setInterval(() => {
const hash = serializeState();
if (hash !== location.hash) history.replaceState(null, '', hash);
}, 1000);
}
// ---- context handed to every layer factory ----
const ctx = { viewer, CONFIG, lib, ui, Cesium, start, stop, now };
// ---- dynamic layer loading (order = draw/registration order in the HUD) ----
const LAYER_MODULES = [
'./layers/satellites.js',
'./layers/infra.js',
'./layers/cables.js',
'./layers/events.js',
'./layers/ships.js',
'./layers/ais.js',
'./layers/aircraft.js',
'./layers/military.js',
'./layers/radius.js',
'./layers/celestial.js',
'./layers/radiogarden.js',
'./layers/firms.js',
'./layers/roadcams.js',
];
const activeLayers = [];
async function loadLayers() {
const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p)));
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.error(`[godsigh] layer ${LAYER_MODULES[i]} failed to load:`, r.reason);
return;
}
const factory = r.value.default;
if (typeof factory !== 'function') {
console.warn(`[godsigh] ${LAYER_MODULES[i]} has no default export factory yet`);
return;
}
try {
const layer = factory(ctx);
if (layer) activeLayers.push(layer);
} catch (err) {
console.error(`[godsigh] layer ${LAYER_MODULES[i]} threw during init:`, err);
}
});
// Registry-driven layers (Wave 3): data-defined GeoJSON feeds.
for (const spec of REGISTRY) {
try {
const layer = createGeoJsonLayer(ctx, spec);
if (layer) activeLayers.push(layer);
} catch (err) {
console.error(`[godsigh] registry layer ${spec.id} threw during init:`, err);
}
}
// ADS-B OSINT layers (Wave 3.1): emergency squawks, rare-type hunter, shadow.
for (const spec of ADSB_LAYERS) {
try {
const layer = createAdsbLayer(ctx, spec);
if (layer) activeLayers.push(layer);
} catch (err) {
console.error(`[godsigh] adsb layer ${spec.id} threw during init:`, err);
}
}
}
// ---- live/scrub gating (throttled ~1/s) ----
let lastTickMs = 0;
viewer.clock.onTick.addEventListener((clock) => {
const curMs = Cesium.JulianDate.toDate(clock.currentTime).getTime();
if (lastTickMs && Math.abs(curMs - lastTickMs) < 1000) return;
lastTickMs = curMs;
const isLive = Math.abs(curMs - Date.now()) <= CONFIG.liveThresholdSec * 1000;
ui.setLiveChip(isLive);
for (const l of activeLayers) l.onClockTick?.(clock.currentTime, isLive);
});
// ---- picking: aircraft (primitives) get a custom overlay; entities use InfoBox ----
const clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
clickHandler.setInputAction((movement) => {
const picked = viewer.scene.pick(movement.position);
let consumed = false;
for (const l of activeLayers) {
if (l.handlePick?.(picked)) { consumed = true; break; }
}
if (consumed) {
viewer.selectedEntity = undefined; // suppress InfoBox when a layer handled it
} else {
for (const l of activeLayers) l.clearPick?.();
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
if (!hashState.modeApplied) setMode('data');
ui.setLiveChip(true);
loadLayers().then(() => {
// Restore layer on/off from the hash once every layer has registered its row.
if (pendingLayerList !== null) {
const wanted = new Set(pendingLayerList.split(',').filter(Boolean));
for (const id of ui.getLayerIds()) ui.setLayerChecked(id, wanted.has(id));
}
startHashSync();
});
// Solar System mode ("Travel to…") — a separate mode, not a layer.
const solarSystem = initSolarSystem(ctx);
// ---- key-gated extras (Wave 4.1): Google 3D, Bing aerial, TomTom, voice ----
keyed.initKeyed(ctx, {
onBasemapReady: (kind, resource) => {
if (kind === 'bing') bingLayer = resource;
if (kind === '3d') google3d = true;
const btn = document.getElementById(kind === 'bing' ? 'mode-bing' : 'mode-3d');
if (btn) {
btn.hidden = false;
btn.addEventListener('click', () => setMode(kind));
}
if (hashState.keyedMode === kind) setMode(kind); // honor a shared link's mode
},
mode3dActive: () => currentMode === '3d',
solarActive: () => solarSystem.isActive(),
}).catch((err) => console.warn('[godsigh] keyed init failed:', err));
initVoice({ viewer, ui, Cesium, CONFIG, styles, setMode, getMode: () => currentMode });
window.__godsigh = { viewer, ctx, activeLayers, solarSystem }; // debug handle