341 lines
15 KiB
JavaScript
341 lines
15 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { StyleManager } from './ui.js';
|
|
import { flyToAustin } from './camera.js';
|
|
import { DataLayerManager } from './data/manager.js';
|
|
import flightsLayer from './data/flights.js';
|
|
import militaryFlightsLayer from './data/militaryFlights.js';
|
|
import earthquakesLayer from './data/earthquakes.js';
|
|
import warscopeEventsLayer from './data/warscopeEvents.js';
|
|
import satellitesLayer from './data/satellites.js';
|
|
import rocketLaunchesLayer from './data/rocketLaunches.js';
|
|
import trafficLayer from './data/traffic.js';
|
|
import cctvLayer from './data/cctv.js';
|
|
import radioLayer from './data/radio.js';
|
|
import bikeshareLayer from './data/bikeshare.js';
|
|
import aisLiveVesselsLayer from './data/aisLiveVessels.js';
|
|
import militaryInstallationsLayer from './data/militaryInstallations.js';
|
|
import militaryAwarenessLayer from './data/militaryAwareness.js';
|
|
import localDataLayers from './data/localLayers.js';
|
|
import { LAYER_STATE_REGISTRY } from './data/layerState.js';
|
|
import { registerDataCredits } from './data/dataCredits.js';
|
|
import { SceneDirector } from './scenes/director.js';
|
|
import { initGevVoiceCommands } from './voice/gevRealtime.js';
|
|
import { MapStackController } from './mapStackController.js';
|
|
import { initAnnotations } from './annotations/index.js';
|
|
import { initLogoGaze } from './logoGaze.js';
|
|
import { initCockpitCloudEffects } from './cockpitCloudEffects.js';
|
|
import {
|
|
installRenderGovernor,
|
|
getRenderGovernorDiagnostics,
|
|
governorRequestRender,
|
|
holdContinuousRender,
|
|
releaseContinuousRender,
|
|
} from './renderGovernor.js';
|
|
import { installScopeMask } from './scopeMask.js';
|
|
import { initFirstRunExperience } from './firstRunExperience.js';
|
|
import { initKeySetup } from './keySetup.js';
|
|
import { loadPhotorealisticTileset } from './mapStartup.js';
|
|
|
|
initLogoGaze();
|
|
|
|
/**
|
|
* Extract a human-readable error message from any thrown value.
|
|
* Handles Error objects, strings, and plain objects with message/error fields.
|
|
* @param {*} error — caught exception value
|
|
* @returns {string} best-effort error description
|
|
*/
|
|
function describeError(error) {
|
|
if (!error) return 'Unknown initialization error';
|
|
if (error instanceof Error) {
|
|
if (error.message && error.message.trim()) return error.message.trim();
|
|
return error.name || 'Initialization error';
|
|
}
|
|
if (typeof error === 'string' && error.trim()) return error.trim();
|
|
if (typeof error === 'object') {
|
|
const maybeMessage = String(error.message || error.error || '').trim();
|
|
if (maybeMessage) return maybeMessage;
|
|
try {
|
|
const serialized = JSON.stringify(error);
|
|
if (serialized && serialized !== '{}') return serialized;
|
|
} catch {
|
|
// ignore serialization error
|
|
}
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
/**
|
|
* GOD'S EYE VIEW — Main Entry Point
|
|
* Initializes CesiumJS with Google Photorealistic 3D Tiles,
|
|
* style system, intelligence HUD, location presets, and share links.
|
|
*/
|
|
async function init() {
|
|
const loadingScreen = document.getElementById('loading-screen');
|
|
const loaderStatus = loadingScreen.querySelector('.loader-status');
|
|
|
|
try {
|
|
loaderStatus.textContent = 'Configuring viewer...';
|
|
|
|
// A direct Google key provides Google 3D plus GEV place search. Cesium ion
|
|
// can host the same 3D tiles and also powers Bing/world-terrain stacks.
|
|
const cesiumToken = import.meta.env.CESIUM_ION_TOKEN;
|
|
const googleApiKey = import.meta.env.GOOGLE_MAPS_API_KEY;
|
|
if (googleApiKey) window.__GOOGLE_MAPS_API_KEY__ = googleApiKey;
|
|
|
|
// Create the Cesium viewer with minimal chrome
|
|
const viewer = new Cesium.Viewer('cesiumContainer', {
|
|
timeline: false,
|
|
animation: false,
|
|
baseLayerPicker: false,
|
|
geocoder: false,
|
|
homeButton: false,
|
|
sceneModePicker: false,
|
|
navigationHelpButton: false,
|
|
fullscreenButton: false,
|
|
vrButton: false,
|
|
selectionIndicator: false,
|
|
infoBox: false,
|
|
baseLayer: false,
|
|
// Visible attribution container — Google Maps / 3D Tiles credits are
|
|
// required by Google's Terms of Service, so they must be shown (styled
|
|
// subtly via #cesium-credits). The credit line stays visible in
|
|
// clean-view AND recording modes too (ToS requires attribution while the
|
|
// content is displayed — those are the exact modes used to record
|
|
// demos), including the "Data attribution" link that opens the per-layer
|
|
// license popover.
|
|
creditContainer: (() => {
|
|
const el = document.createElement('div');
|
|
el.id = 'cesium-credits';
|
|
document.body.appendChild(el);
|
|
return el;
|
|
})(),
|
|
msaaSamples: 4,
|
|
contextOptions: {
|
|
webgl: {
|
|
preserveDrawingBuffer: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Cap the default render loop at 60 fps. Cesium's loop otherwise runs at
|
|
// the display's refresh rate — 120 Hz on ProMotion panels — doubling GPU
|
|
// and CPU burn for zero visual benefit in a map app whose animation
|
|
// cadences (poll interpolation, trail fades, style crossfades) are all
|
|
// designed against wall-clock time, not frame count. Measured on the
|
|
// 2026-08-05 perf investigation as a strict halving of idle burn on
|
|
// 120 Hz hardware; a no-op on 60 Hz displays. (perf item 2)
|
|
viewer.targetFrameRate = 60;
|
|
|
|
// Register per-layer data attribution into the "Data attribution" popover.
|
|
// Required by each source's license (ODbL, CC BY-NC-SA, NASA FIRMS, etc.);
|
|
// strings are verbatim from DATA_SOURCES.md. Static + always-present in the
|
|
// expandable bottom-left credit lightbox (showOnScreen=false), so they never
|
|
// clutter the on-globe line. See docs/pre-ship-audit-2026-07-01.md H11.
|
|
registerDataCredits(viewer);
|
|
|
|
// Hide Cesium's default globe — Google Photorealistic 3D Tiles provide their own
|
|
// globe at all LODs (street level → orbital). The default globe's 2D imagery
|
|
// clips through 3D tile buildings at close range.
|
|
viewer.scene.globe.show = false;
|
|
|
|
// Keep a sky behind Google 3D Tiles, but soften Cesium's high-intensity
|
|
// default atmosphere. With the globe hidden its bright limb otherwise
|
|
// reads as a hard cyan seam where distant photoreal tiles meet the sky.
|
|
viewer.scene.skyAtmosphere.show = true;
|
|
viewer.scene.skyAtmosphere.atmosphereLightIntensity = 18;
|
|
viewer.scene.skyAtmosphere.saturationShift = -0.12;
|
|
viewer.scene.skyAtmosphere.brightnessShift = -0.08;
|
|
|
|
loaderStatus.textContent = googleApiKey || cesiumToken
|
|
? 'Loading Google 3D Tiles...'
|
|
: 'Loading the keyless globe...';
|
|
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
|
googleApiKey,
|
|
cesiumToken,
|
|
});
|
|
const tileset = photoreal.tileset;
|
|
if (tileset) {
|
|
viewer.scene.primitives.add(tileset);
|
|
// NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom.
|
|
// Google Photorealistic 3D Tiles provide their own terrain/elevation.
|
|
viewer.scene.globe.show = false;
|
|
console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`);
|
|
} else {
|
|
if (photoreal.errors.length) {
|
|
const tileError = photoreal.errors.at(-1);
|
|
console.warn('[Init] Google 3D Tiles unavailable, using the keyless globe:', tileError);
|
|
const tileErrorDetail = describeError(tileError);
|
|
loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`;
|
|
}
|
|
viewer.scene.globe.show = true;
|
|
}
|
|
|
|
loaderStatus.textContent = 'Initializing systems...';
|
|
|
|
const mapStackController = new MapStackController(viewer, {
|
|
googleTileset: tileset,
|
|
cesiumToken,
|
|
initialStack: tileset ? 'photoreal' : 'esri-imagery',
|
|
// Task 5 (height-datum fix): rebroadcast stack changes as a window
|
|
// CustomEvent so data layers (CCTV per-regime ground resolution) can
|
|
// react without coupling MapStackController to layer modules. Fires on
|
|
// 'switching'/'ready'/'error'; listeners derive the surface regime from
|
|
// live scene state, so intermediate emissions are harmless.
|
|
onChange: (state) => {
|
|
window.dispatchEvent(new CustomEvent('gev:map-stack-changed', { detail: state }));
|
|
},
|
|
onError: (message) => console.warn('[MapStack]', message),
|
|
});
|
|
await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { silent: true });
|
|
|
|
// Initialize the style manager (post-processing, HUD, locations, share links)
|
|
const styleManager = new StyleManager(viewer, { mapStackController });
|
|
// The previous multi-canvas weather compositor remains disabled. Cockpit
|
|
// clouds use a separate, capped low-resolution GPU pass that never attaches
|
|
// Cesium fog or post-process stages and is fully stopped in map mode.
|
|
const weatherEffects = null;
|
|
const cockpitCloudEffects = initCockpitCloudEffects(viewer);
|
|
|
|
// If no share link state, do default fly-to Austin
|
|
if (!styleManager.hasShareState) {
|
|
loaderStatus.textContent = 'Flying to Austin, TX...';
|
|
flyToAustin(viewer);
|
|
} else {
|
|
loaderStatus.textContent = 'Restoring shared view...';
|
|
}
|
|
|
|
// Initialize data layer manager
|
|
const dataManager = new DataLayerManager(viewer, {
|
|
allowQaRegistration: import.meta.env.DEV,
|
|
});
|
|
dataManager.register(flightsLayer);
|
|
dataManager.register(militaryFlightsLayer);
|
|
dataManager.register(earthquakesLayer);
|
|
dataManager.register(warscopeEventsLayer);
|
|
dataManager.register(satellitesLayer);
|
|
dataManager.register(rocketLaunchesLayer);
|
|
rocketLaunchesLayer.attachDataManager(dataManager);
|
|
dataManager.register(trafficLayer);
|
|
dataManager.register(cctvLayer);
|
|
dataManager.register(radioLayer);
|
|
dataManager.register(bikeshareLayer);
|
|
dataManager.register(aisLiveVesselsLayer);
|
|
dataManager.register(militaryInstallationsLayer);
|
|
dataManager.register(militaryAwarenessLayer);
|
|
militaryAwarenessLayer.attachDataManager(dataManager);
|
|
for (const layer of localDataLayers) {
|
|
dataManager.register(layer);
|
|
}
|
|
// Restoration starts only after the complete production registry is sealed.
|
|
dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY);
|
|
if (import.meta.env.DEV) {
|
|
window.__gevQaRegisterLayer = (targetManager, layerModule) => {
|
|
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
|
|
return dataManager.registerForQa(layerModule);
|
|
};
|
|
window.__gevQaUnregisterLayer = (targetManager, layerId) => {
|
|
if (targetManager !== dataManager) throw new Error('QA layer manager mismatch');
|
|
return dataManager.unregisterForQa(layerId);
|
|
};
|
|
}
|
|
dataManager.buildTogglePanel(document.getElementById('data-toggles'));
|
|
styleManager.attachDataManager(dataManager);
|
|
|
|
// Initialize deterministic scene playback for social clip capture
|
|
const sceneDirector = new SceneDirector(viewer, styleManager, dataManager);
|
|
|
|
// Initialize the voice "whiteboard" annotation engine (world-space renderer)
|
|
const annotations = initAnnotations({ viewer, tileset });
|
|
|
|
// Keep startup chrome truthful: a share is not restored until camera,
|
|
// visual/map/panel lanes, and every requested layer have terminated.
|
|
void Promise.all([
|
|
styleManager.initialRestorePromise,
|
|
new Promise((resolve) => setTimeout(resolve, 1000)),
|
|
]).finally(() => {
|
|
loadingScreen.classList.add('hidden');
|
|
// Reveal only after the loading cover has yielded. transitionend can be
|
|
// absent under reduced motion, so a bounded fallback makes this reliable.
|
|
let firstRunRevealed = false;
|
|
const revealFirstRun = () => {
|
|
if (firstRunRevealed) return;
|
|
firstRunRevealed = true;
|
|
// dataManager is passed explicitly: the globe missions enable bundled
|
|
// keyless layers through it, and reaching for styleManager._dataManager
|
|
// would make a private field part of this feature's contract.
|
|
initFirstRunExperience({ styleManager, dataManager });
|
|
};
|
|
loadingScreen.addEventListener('transitionend', revealFirstRun, { once: true });
|
|
setTimeout(revealFirstRun, 900);
|
|
});
|
|
|
|
// Provider Settings (the POWER UP chip + dialog). Fire-and-forget: the
|
|
// module removes its own surface when the dev-server endpoint is absent
|
|
// (prod builds, non-local visitors), so this costs prod exactly nothing.
|
|
void initKeySetup();
|
|
|
|
// Expose for debugging
|
|
// Idle render governor: flips the scene into requestRenderMode whenever
|
|
// nothing animates per frame. Installed AFTER every module above has had
|
|
// its chance to register pre-install holds. (perf wave 2)
|
|
installRenderGovernor(viewer);
|
|
|
|
// The explicit scope mask replaces the emergent six-pass artifact —
|
|
// see src/scopeMask.js. Installed before the UI so the DISPLAY-rail
|
|
// toggle finds it live.
|
|
installScopeMask(viewer);
|
|
|
|
// The follow camera recomputes the tracked target's dead-reckon position
|
|
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
|
viewer.trackedEntityChanged.addEventListener(() => {
|
|
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
|
else releaseContinuousRender('tracked-entity');
|
|
});
|
|
|
|
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
|
// stop the default render loop outright — a hidden canvas repaints for
|
|
// nobody, and browser rAF throttling still lets throttled frames burn
|
|
// GPU. Holder/data state is untouched, so return is seamless: restore
|
|
// the loop, refresh the one DOM surface we gated, render a frame.
|
|
const syncVisibilitySuspension = () => {
|
|
const hidden = document.hidden;
|
|
viewer.useDefaultRenderLoop = !hidden;
|
|
cockpitCloudEffects?.setSuspended?.(hidden);
|
|
if (!hidden) {
|
|
if (dataManager._panelRefreshPendingOnVisible) {
|
|
dataManager._panelRefreshPendingOnVisible = false;
|
|
dataManager._refreshTogglePanel();
|
|
}
|
|
governorRequestRender('visibility-restore');
|
|
}
|
|
};
|
|
document.addEventListener('visibilitychange', syncVisibilitySuspension);
|
|
// Apply the CURRENT state too — bootstrap can complete while the tab is
|
|
// already hidden, and waiting for the next transition would leave the
|
|
// loop burning behind a hidden tab. (perf wave 2 fix)
|
|
syncVisibilitySuspension();
|
|
|
|
window.__godsEyeView = {
|
|
viewer,
|
|
styleManager,
|
|
tileset,
|
|
dataManager,
|
|
sceneDirector,
|
|
mapStackController,
|
|
annotations,
|
|
weatherEffects,
|
|
cockpitCloudEffects,
|
|
getRenderGovernorDiagnostics,
|
|
requestRender: governorRequestRender,
|
|
};
|
|
window.__godsEyeView.voiceCommands = initGevVoiceCommands({ viewer, styleManager, dataManager, sceneDirector, annotations });
|
|
|
|
} catch (error) {
|
|
console.error("God's Eye View initialization failed:", error);
|
|
loaderStatus.textContent = `Error: ${describeError(error)}`;
|
|
loaderStatus.style.color = '#ff4444';
|
|
}
|
|
}
|
|
|
|
init();
|