From 4ded3fbf152453c1615aa9424ab24be08360a474 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Thu, 15 Oct 2026 20:56:16 +0400 Subject: [PATCH] feat(app): run gev on Navara behind ?engine=navara The page's engine is ?engine=cesium|navara, else localStorage gev.engine, else Cesium (src/app/engineChoice.js). Cesium mode is unchanged. In Navara mode (src/app/navaraMode.js, loaded with import() only then, and loading the Navara globe the same way) the scene builds the Navara globe on the app container with no viewer; map stacks go through engine.maps (Google 3D from the browser key, else Esri imagery); only the shell's own data credits are registered. The layer catalog is empty and the controls get empty layer stand-ins. The shell does not create its viewer-bound parts (cockpit view, celestial ring, detection, tracked readout, post-process stages); the tools install the render governor on the engine, skip the director, annotations, drawing, imagery box, scope mask, voice and embedded views, skip the first-run welcome, and show "Experimental Navara engine: layers, cockpit, director and voice are unavailable". window.__godsEyeView.viewer is null. Navara's attribution card is kept clear of the location tray. A few shell paths became null-safe for the missing viewer, layers or stages without changing Cesium behaviour. scripts/qa-engine-shell.mjs checks both modes headless: boot, viewer, console errors, map drawn, search and fly, north/tilt/zoom/reset, orbit, HUD, share link restore and map stacks. --- scripts/package-boundaries.json | 18 +++- src/app/catalog.js | 11 ++ src/app/catalog.test.mjs | 68 ++++++++++++- src/app/controls.js | 18 +++- src/app/data.js | 3 +- src/app/engineChoice.js | 30 ++++++ src/app/engineChoice.test.mjs | 55 ++++++++++ src/app/navaraMode.js | 156 ++++++++++++++++++++++++++++ src/app/navaraScene.test.mjs | 169 +++++++++++++++++++++++++++++++ src/app/scene.js | 24 ++++- src/app/tools.js | 21 +++- src/app/viewerlessTools.test.mjs | 99 ++++++++++++++++++ src/data/dataCredits.test.mjs | 14 +++ src/standalone/application.js | 21 ++++ src/ui/applicationShell.js | 58 ++++++----- src/ui/cockpitCoordinator.js | 14 ++- src/ui/layerBindings.js | 12 ++- src/ui/radioControls.js | 3 +- src/ui/styles/status.css | 14 +++ src/ui/visualEffects.js | 7 +- src/ui/visualSettings.js | 10 +- 21 files changed, 776 insertions(+), 49 deletions(-) create mode 100644 src/app/engineChoice.js create mode 100644 src/app/engineChoice.test.mjs create mode 100644 src/app/navaraMode.js create mode 100644 src/app/navaraScene.test.mjs create mode 100644 src/app/viewerlessTools.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d65fbc4da..4088d9477 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1760,6 +1760,18 @@ "src/globe/cesium/render.js", "src/globe/cesium/surface.js", "src/globe/cesium/engineFromViewer.js", + "src/globe/navara/cameraMath.js", + "src/globe/navara/cameraModel.js", + "src/globe/navara/createNavaraEngine.js", + "src/globe/navara/credits.js", + "src/globe/navara/index.js", + "src/globe/navara/input.js", + "src/globe/navara/internals.js", + "src/globe/navara/maps.js", + "src/globe/navara/surface.js", + "src/globe/navara/viewOptions.js", + "src/app/engineChoice.js", + "src/app/navaraMode.js", "src/sources/sourceSlot.js", "src/app/sources.js", "src/app/startupChrome.js", @@ -2525,7 +2537,11 @@ "pbf", "satellite.js", "mapillary-js", - "@jtarrio/webrtlsdr" + "@jtarrio/webrtlsdr", + "@navaramap/three", + "@navaramap/three-default-plugin", + "three", + "postprocessing" ] }, "application-catalog": { diff --git a/src/app/catalog.js b/src/app/catalog.js index 9dc641fef..aaead0026 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -59,3 +59,14 @@ export function catalogControlServices(catalog) { }), ); } + +/** + * Layer roles for controls on a page without data layers (the experimental + * Navara engine): one empty object per role, so the shell's optional calls + * (`layer.method?.()`) do nothing. + */ +export function absentLayerControlServices() { + return Object.fromEntries( + Object.keys(CONTROL_LAYER_IDS).map((role) => [role, {}]), + ); +} diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index ff6d73db5..34b1b1e51 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -1,6 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { createLayerCatalog, catalogControlServices } from './catalog.js'; +import { + absentLayerControlServices, + createLayerCatalog, + catalogControlServices, +} from './catalog.js'; import { createApplicationData } from './data.js'; const metadata = (layers) => @@ -124,3 +128,65 @@ test('controls bind catalog instances rather than similarly named defaults', () /Control layer missing/, ); }); + +test('without data layers the controls get one empty stand-in per layer role', () => { + const ids = [ + 'traffic', + 'flights', + 'military', + 'satellites', + 'cctv', + 'street-level', + 'radio', + 'bikeshare', + 'transit', + 'ais-live-vessels', + 'military-awareness', + 'military-installations', + 'rocket-launches', + 'local-adsb', + ]; + const layers = ids.map((id) => ({ id })); + const roles = Object.keys( + catalogControlServices(createLayerCatalog(layers, metadata(layers))), + ); + const absent = absentLayerControlServices(); + assert.deepEqual(Object.keys(absent), roles); + for (const role of roles) { + assert.deepEqual(Object.keys(absent[role]), [], role); + assert.equal(absent[role].subscribe, undefined); + } + assert.notEqual(absent.flightsLayer, absent.trafficLayer); + assert.notEqual( + absentLayerControlServices().flightsLayer, + absent.flightsLayer, + ); +}); + +test('data setup without a viewer registers an empty catalog and no sonar scene', async (t) => { + const previous = globalThis.document; + globalThis.document = { getElementById: () => null }; + t.after(() => { + if (previous === undefined) delete globalThis.document; + else globalThis.document = previous; + }); + const releases = []; + let attached = null; + const { dataManager } = createApplicationData({ + scene: { viewer: null, mapStackController: null }, + controls: { + styleManager: { + attachDataManager(manager) { + attached = manager; + }, + }, + }, + catalog: createLayerCatalog([], []), + defer: (release) => releases.push(release), + }); + assert.equal(attached, dataManager); + assert.equal(dataManager.viewer, null); + assert.equal(dataManager.layers.size, 0); + assert.equal(dataManager.registrationsFinalized, true); + for (const release of releases.reverse()) await release(); +}); diff --git a/src/app/controls.js b/src/app/controls.js index 9c10715dd..1d74bcfa5 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -1,4 +1,7 @@ -import { catalogControlServices } from './catalog.js'; +import { + absentLayerControlServices, + catalogControlServices, +} from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; @@ -21,7 +24,10 @@ export function createApplicationControls({ searchAndFlyTo: operations.searchAndFlyTo, fetchRegionalBrief: (...args) => operations.requests.regional.getBrief(...args), - ...catalogControlServices(catalog), + // No viewer (the Navara engine): no data layers, empty stand-ins. + ...(viewer + ? catalogControlServices(catalog) + : absentLayerControlServices()), }, requestServices: operations.requests, // The page's globe engine: the shell's overlay host runs on it. @@ -36,9 +42,11 @@ export function createApplicationControls({ // 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, { - weatherService: operations.requests.weather, - }); + const cockpitCloudEffects = viewer + ? initCockpitCloudEffects(viewer, { + weatherService: operations.requests.weather, + }) + : null; defer(() => cockpitCloudEffects?.destroy()); // If no share link state, do default fly-to Austin diff --git a/src/app/data.js b/src/app/data.js index ce3ad0731..fb3f59f1b 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -57,7 +57,8 @@ export function createApplicationData({ } presentation.mount(document.getElementById('data-toggles')); styleManager.attachDataManager(dataManager); - defer(createCyberSonarScene(viewer, dataManager)); + // The sonar scene draws on the viewer; the Navara engine has none. + if (viewer) defer(createCyberSonarScene(viewer, dataManager)); return { dataManager, catalog, presentation }; } diff --git a/src/app/engineChoice.js b/src/app/engineChoice.js new file mode 100644 index 000000000..26608a31e --- /dev/null +++ b/src/app/engineChoice.js @@ -0,0 +1,30 @@ +/** The localStorage key that remembers an engine choice between visits. */ +export const ENGINE_STORAGE_KEY = 'gev.engine'; + +const KINDS = new Set(['cesium', 'navara']); + +const known = (value) => { + const kind = String(value ?? '') + .trim() + .toLowerCase(); + return KINDS.has(kind) ? kind : null; +}; + +/** + * The globe engine for this page: `?engine=cesium|navara`, else the stored + * `gev.engine`, else Cesium. Navara is experimental; an unknown or unreadable + * value never selects it. + * @param {{ search?: string, storage?: { getItem(key: string): string | null } | null }} [where] + * @returns {'cesium' | 'navara'} + */ +export function resolveEngineKind({ search = '', storage = null } = {}) { + const fromQuery = known(new URLSearchParams(search).get('engine')); + if (fromQuery) return fromQuery; + let stored = null; + try { + stored = storage?.getItem(ENGINE_STORAGE_KEY); + } catch { + // Storage can be blocked (private mode, sandboxed frames). + } + return known(stored) ?? 'cesium'; +} diff --git a/src/app/engineChoice.test.mjs b/src/app/engineChoice.test.mjs new file mode 100644 index 000000000..45bf3b4b1 --- /dev/null +++ b/src/app/engineChoice.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ENGINE_STORAGE_KEY, resolveEngineKind } from './engineChoice.js'; + +const storage = (value) => ({ + getItem: (key) => (key === ENGINE_STORAGE_KEY ? value : null), +}); + +test('Cesium is the default engine', () => { + assert.equal(resolveEngineKind(), 'cesium'); + assert.equal( + resolveEngineKind({ search: '', storage: storage(null) }), + 'cesium', + ); + assert.equal(ENGINE_STORAGE_KEY, 'gev.engine'); +}); + +test('?engine= chooses the engine and wins over storage', () => { + assert.equal(resolveEngineKind({ search: '?engine=navara' }), 'navara'); + assert.equal( + resolveEngineKind({ + search: '?x=1&engine=cesium', + storage: storage('navara'), + }), + 'cesium', + ); + assert.equal(resolveEngineKind({ search: '?engine=NAVARA' }), 'navara'); +}); + +test('the stored choice applies without a query', () => { + assert.equal( + resolveEngineKind({ search: '', storage: storage('navara') }), + 'navara', + ); + assert.equal( + resolveEngineKind({ search: '?lat=1', storage: storage(' navara ') }), + 'navara', + ); +}); + +test('unknown values and unreadable storage fall back to Cesium', () => { + assert.equal(resolveEngineKind({ search: '?engine=unreal' }), 'cesium'); + assert.equal( + resolveEngineKind({ search: '?engine=unreal', storage: storage('navara') }), + 'navara', + 'an unknown query value does not hide a valid stored choice', + ); + assert.equal(resolveEngineKind({ storage: storage('unreal') }), 'cesium'); + const throwing = { + getItem() { + throw new Error('SecurityError'); + }, + }; + assert.equal(resolveEngineKind({ search: '', storage: throwing }), 'cesium'); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js new file mode 100644 index 000000000..106249f6c --- /dev/null +++ b/src/app/navaraMode.js @@ -0,0 +1,156 @@ +// The experimental Navara mode (?engine=navara) of the application: its +// scene and its viewerless tools. src/app/scene.js and src/app/tools.js load +// this module with import() only in that mode, and it loads the Navara globe +// the same way, so the Cesium-mode bundle carries none of it. + +import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js'; +import { isEmbeddedInline } from './embed.js'; +import { + getRenderGovernorDiagnostics, + governorRequestRender, + installRenderGovernor, + uninstallRenderGovernor, +} from '../renderGovernor.js'; + +/** The Navara globe module, loaded on demand. */ +const loadNavaraGlobeModule = async () => + (await import('../globe/navara/index.js')).createNavaraGlobe; + +/** The shell's notice in the experimental Navara mode (label and detail). */ +export const NAVARA_MODE_NOTICE = Object.freeze({ + label: 'Experimental Navara engine', + detail: 'Layers, cockpit, director and voice are unavailable', +}); + +/** + * Data credits for what the shell uses without data layers: place search, + * its OpenStreetMap data and the keyless terrain. Navara lists every credit + * in one attribution card, so layer credits would only crowd it. + */ +const SHELL_CREDIT_KEYS = new Set([ + 'photon-geocoder', + 'openstreetmap', + 'reearth-terrain', +]); + +/** + * The scene on the experimental Navara engine: no viewer and no Cesium + * objects. Navara builds the map sources from the controller's descriptions + * (Google 3D tiles from the browser key, else base imagery) and shows their + * credits in its own attribution UI. + */ +export async function createNavaraScene({ + operations, + cesiumToken, + credits, + MapController, + mapOptions, + loaderStatus, + loadNavaraGlobe = loadNavaraGlobeModule, + signal, + defer, +}) { + loaderStatus.textContent = 'Loading the experimental Navara engine...'; + const createNavaraGlobe = await loadNavaraGlobe(); + signal.throwIfAborted(); + const globe = await createNavaraGlobe({ + container: document.getElementById('cesiumContainer'), + }); + defer(() => globe.destroy()); + const { engine } = globe; + // The tools install the governor on this engine; it goes before the engine. + defer(() => uninstallRenderGovernor(engine)); + signal.throwIfAborted(); + registerDataCredits( + engine, + (credits ?? DATA_CREDITS).filter(({ key }) => SHELL_CREDIT_KEYS.has(key)), + ); + loaderStatus.textContent = 'Initializing systems...'; + const mapStackController = new MapController(null, { + requestRender: governorRequestRender, + engine, + ...mapOptions, + cesiumToken, + onChange: (state) => { + window.dispatchEvent( + new CustomEvent('gev:map-stack-changed', { detail: state }), + ); + }, + onError: (message) => console.warn('[MapStack]', message), + }); + defer(() => mapStackController.destroy()); + await mapStackController.setStack(mapStackController.getActiveId(), { + silent: true, + }); + signal.throwIfAborted(); + return { + viewer: null, + engine, + tileset: null, + mapStackController, + operations, + }; +} + +/** + * The tools without a viewer (the experimental Navara engine): the startup + * chrome, the render governor on the engine, hidden-tab handling and the + * debug handle. The shell shows which features are unavailable. + */ +export function createViewerlessTools({ + scene: { engine, mapStackController, operations }, + controls: { styleManager }, + data, + loadingScreen, + startChrome, + signal, + defer, +}) { + const { dataManager } = data; + // No first-run welcome: its choices turn on data layers. + if (startChrome) + defer( + startChrome({ + loadingScreen, + styleManager, + dataManager, + signal, + initializeWelcome: null, + }), + ); + // Holds drive engine.render.setContinuous; requests go to the engine. + installRenderGovernor(engine); + const syncVisibility = () => { + if (document.hidden && !isEmbeddedInline()) return; + data.presentation.flushVisible(); + governorRequestRender('visibility-restore'); + }; + document.addEventListener('visibilitychange', syncVisibility); + defer(() => document.removeEventListener('visibilitychange', syncVisibility)); + styleManager._showGlobalStatusNotice?.(NAVARA_MODE_NOTICE.label, { + state: 'retry', + detail: NAVARA_MODE_NOTICE.detail, + persistent: true, + }); + window.__godsEyeView = { + viewer: null, + engine, + styleManager, + tileset: null, + dataManager, + sceneDirector: null, + mapStackController, + annotations: null, + weatherEffects: null, + cockpitCloudEffects: null, + voiceCommands: null, + getRenderGovernorDiagnostics, + surfaceServices: operations.surface, + requestRender: governorRequestRender, + }; + const debug = window.__godsEyeView; + defer(() => { + if (window.__godsEyeView === debug) delete window.__godsEyeView; + }); + return { sceneDirector: null, annotations: null, voiceCommands: null }; +} diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs new file mode 100644 index 000000000..cac0abb3c --- /dev/null +++ b/src/app/navaraScene.test.mjs @@ -0,0 +1,169 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationScene } from './scene.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { + installRenderGovernor, + isRenderGovernorInstalled, +} from '../renderGovernor.js'; + +// The scene's Navara branch in Node: the globe loader is a seam, so the fake +// engine stands in for Navara and nothing Cesium is built. + +function installDom() { + const saved = { + window: globalThis.window, + document: globalThis.document, + CustomEvent: globalThis.CustomEvent, + }; + const container = { id: 'cesiumContainer' }; + const appended = []; + globalThis.document = { + getElementById: (id) => (id === 'cesiumContainer' ? container : null), + querySelector: () => null, + querySelectorAll: () => [], + createElement: () => { + throw new Error('the Navara scene creates no Cesium credit container'); + }, + body: { appendChild: (node) => appended.push(node) }, + }; + const events = []; + globalThis.window = { + dispatchEvent: (event) => events.push(event), + addEventListener() {}, + removeEventListener() {}, + }; + globalThis.CustomEvent ??= class { + constructor(type, { detail } = {}) { + this.type = type; + this.detail = detail; + } + }; + const restore = () => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + }; + return { container, appended, events, restore }; +} + +const requestServices = { + terrain: { getHeights: async () => [] }, + regional: { getBrief: async () => null }, + weather: { getConditions: async () => null }, + summary: { summarize: async () => null }, + features: Object.fromEntries( + [ + 'getAdministrativeAreas', + 'getAreaGeometry', + 'getEnclosingAreas', + 'getFocusFootprints', + 'getFootprints', + 'getMonuments', + 'getNeighborhoodAreas', + 'getStreetAreas', + 'getStreetLines', + ].map((name) => [name, async () => []]), + ), +}; + +// Tests end with run.teardown (the scene's cleanups, then the DOM stand-ins). +async function startScene(options = {}) { + const dom = installDom(); + const cleanups = []; + const loads = []; + const engine = createFakeEngine({ kind: 'navara' }); + let globeDestroyed = 0; + const loaderStatus = { textContent: '' }; + const scene = await createApplicationScene({ + engineKind: 'navara', + requestServices, + googleApiKey: 'google-test', + credits: [ + { key: 'opensky', html: 'Flights credit' }, + { key: 'photon-geocoder', html: 'Search credit' }, + { key: 'openstreetmap', html: 'OSM credit' }, + { key: 'reearth-terrain', html: 'Terrain credit' }, + ], + loaderStatus, + signal: new AbortController().signal, + defer: (dispose) => cleanups.push(dispose), + loadNavaraGlobe: async () => async (globeOptions) => { + loads.push(globeOptions); + return { + engine, + destroy() { + globeDestroyed += 1; + engine.destroy(); + }, + }; + }, + ...options, + }); + const teardown = async () => { + while (cleanups.length) await cleanups.pop()(); + dom.restore(); + }; + return { + scene, + engine, + loads, + dom, + loaderStatus, + teardown, + globeDestroyed: () => globeDestroyed, + }; +} + +test('the Navara scene builds the engine on the app container and no viewer', async (t) => { + const run = await startScene(); + t.after(run.teardown); + assert.equal(run.scene.viewer, null); + assert.equal(run.scene.tileset, null); + assert.equal(run.scene.engine, run.engine); + assert.equal(run.scene.engine.kind, 'navara'); + assert.equal(run.loads.length, 1); + assert.equal(run.loads[0].container, run.dom.container); + assert.ok(run.scene.operations.searchAndFlyTo); + assert.equal(globalThis.window.__GOOGLE_MAPS_API_KEY__, 'google-test'); + // Data layers are off: only the credits of what the shell itself uses + // (place search, its OSM data, keyless terrain) go to the engine's credits. + assert.deepEqual( + run.engine.fake.credits.map(({ html }) => html), + ['Search credit', 'OSM credit', 'Terrain credit'], + ); +}); + +test('the Navara scene opens Google 3D through the engine', async (t) => { + const run = await startScene(); + t.after(run.teardown); + const { mapStackController } = run.scene; + assert.equal(mapStackController.getActiveId(), 'photoreal'); + assert.equal(mapStackController.isStackAvailable('bing-aerial'), false); + const [tiles] = run.engine.fake.callsOf('maps.setPhotorealTiles')[0].args; + assert.equal(tiles.kind, 'photoreal'); +}); + +test('without a browser key the Navara scene opens on base imagery', async (t) => { + const run = await startScene({ googleApiKey: '' }); + t.after(run.teardown); + assert.equal(run.scene.mapStackController.getActiveId(), 'esri-imagery'); + assert.equal( + run.engine.fake.callsOf('maps.setBaseImagery')[0].args[0].kind, + 'esri-imagery', + ); +}); + +test('the Navara scene tears down the controller, then the globe', async (t) => { + const run = await startScene(); + // The tools install the governor on the engine (src/app/tools.js); the + // scene removes it before the engine goes. + installRenderGovernor(run.engine); + const view = globalThis.window; + await run.teardown(); + assert.equal(run.globeDestroyed(), 1); + assert.equal(run.engine.isDestroyed(), true); + assert.equal(isRenderGovernorInstalled(run.engine), false); + assert.equal(view.__GOOGLE_MAPS_API_KEY__, undefined); +}); diff --git a/src/app/scene.js b/src/app/scene.js index 9de1ccf6c..579bf48ec 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -16,7 +16,11 @@ import { import { describeError } from './errors.js'; import { createSceneEngine } from './globeEngine.js'; -/** Construct the application globe using the caller's local configuration. */ +/** + * Construct the application globe using the caller's local configuration. + * `engineKind` 'navara' builds the experimental Navara engine instead of the + * Cesium viewer: the scene then has no viewer (`viewer: null`). + */ export async function createApplicationScene({ requestServices, googleApiKey, @@ -26,6 +30,8 @@ export async function createApplicationScene({ MapController = MapStackController, mapOptions = {}, loaderStatus, + engineKind = 'cesium', + loadNavaraGlobe, signal, defer, }) { @@ -43,6 +49,22 @@ export async function createApplicationScene({ else window.__GOOGLE_MAPS_API_KEY__ = previousKey; }); } + // The Navara mode's own code loads only in that mode, with Navara itself, + // so the Cesium-mode bundle carries none of it. + if (engineKind === 'navara') + return (await import('./navaraMode.js')).createNavaraScene({ + operations, + cesiumToken, + credits, + MapController, + mapOptions, + loaderStatus, + loadNavaraGlobe, + signal, + defer, + }); + if (engineKind !== 'cesium') + throw new TypeError(`Unknown globe engine: ${engineKind}`); loaderStatus.textContent = 'Configuring viewer...'; // Provider attribution stays visible, including clean-view and recording. const creditContainer = document.createElement('div'); diff --git a/src/app/tools.js b/src/app/tools.js index b299ade1f..0342793ad 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -14,7 +14,12 @@ import { releaseContinuousRender, } from '../renderGovernor.js'; -/** Attach scene tools, rendering listeners and the application debug handle. */ +/** + * Attach scene tools, rendering listeners and the application debug handle. + * Without a viewer (the experimental Navara engine) the viewer-bound tools + * (director, annotations, drawing, imagery box, scope mask, tracking, voice, + * embedded views) are not created; their handles are null. + */ export function createApplicationTools({ scene, controls, @@ -31,6 +36,20 @@ export function createApplicationTools({ const { viewer, engine, tileset, mapStackController, operations } = scene; const { styleManager, weatherEffects, cockpitCloudEffects } = controls; const { dataManager } = data; + // Without a viewer (the Navara mode) the tools load with that mode's code. + if (!viewer) { + return import('./navaraMode.js').then(({ createViewerlessTools }) => + createViewerlessTools({ + scene, + controls, + data, + loadingScreen, + startChrome, + signal, + defer, + }), + ); + } const sceneDirector = new SceneDirector(viewer, styleManager, dataManager, { engine, dataPacks: sceneDataPacks, diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs new file mode 100644 index 000000000..38413e3d8 --- /dev/null +++ b/src/app/viewerlessTools.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationTools } from './tools.js'; +import { NAVARA_MODE_NOTICE } from './navaraMode.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { + _resetRenderGovernorForTest, + holdContinuousRender, + isRenderGovernorInstalled, + releaseContinuousRender, +} from '../renderGovernor.js'; + +// The tools without a viewer (the experimental Navara engine); they load +// from src/app/navaraMode.js. +test('without a viewer the tools run the governor on the engine, show the notice and create no viewer tools', async (t) => { + const saved = { window: globalThis.window, document: globalThis.document }; + const listeners = new Map(); + globalThis.window = {}; + globalThis.document = { + hidden: false, + addEventListener: (type, fn) => listeners.set(type, fn), + removeEventListener: (type, fn) => { + if (listeners.get(type) === fn) listeners.delete(type); + }, + }; + t.after(() => { + _resetRenderGovernorForTest(); + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + } + }); + const engine = createFakeEngine({ kind: 'navara' }); + const notices = []; + const chrome = []; + const cleanups = []; + let flushed = 0; + const styleManager = { + _showGlobalStatusNotice: (label, options) => + notices.push({ label, options }), + }; + const dataManager = { layers: new Map() }; + const tools = await createApplicationTools({ + scene: { + viewer: null, + engine, + tileset: null, + mapStackController: { kind: 'maps' }, + operations: { surface: { kind: 'surface' } }, + }, + controls: { styleManager, weatherEffects: null, cockpitCloudEffects: null }, + data: { dataManager, presentation: { flushVisible: () => flushed++ } }, + loadingScreen: { kind: 'loading' }, + startChrome: (options) => { + chrome.push(options); + return () => {}; + }, + signal: new AbortController().signal, + defer: (dispose) => cleanups.push(dispose), + }); + assert.deepEqual(tools, { + sceneDirector: null, + annotations: null, + voiceCommands: null, + }); + assert.equal(chrome.length, 1); + assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); + assert.equal(chrome[0].styleManager, styleManager); + assert.deepEqual(notices, [ + { + label: NAVARA_MODE_NOTICE.label, + options: { + state: 'retry', + detail: NAVARA_MODE_NOTICE.detail, + persistent: true, + }, + }, + ]); + assert.match( + `${NAVARA_MODE_NOTICE.label}: ${NAVARA_MODE_NOTICE.detail}`, + /^Experimental Navara engine: layers, cockpit, director and voice are unavailable$/i, + ); + // The governor drives the engine's continuous rendering. + assert.equal(isRenderGovernorInstalled(engine), true); + holdContinuousRender('qa'); + assert.equal(engine.render.isContinuous(), true); + releaseContinuousRender('qa'); + assert.equal(engine.render.isContinuous(), false); + const debug = window.__godsEyeView; + assert.equal(debug.viewer, null); + assert.equal(debug.engine, engine); + assert.equal(debug.sceneDirector, null); + assert.equal(debug.dataManager, dataManager); + listeners.get('visibilitychange')(); + assert.equal(flushed, 1); + while (cleanups.length) await cleanups.pop()(); + assert.equal(window.__godsEyeView, undefined); + assert.equal(listeners.size, 0); +}); diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index fb76a351b..8528e5285 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -159,3 +159,17 @@ test('data credits go through the globe engine credits', async () => { 0, ); }); + +test('without a viewer or engine the OSM and dynamic credits skip cleanly', async () => { + const { + registerDataCredits, + registerDynamicCredit, + showOsmCredit, + hideOsmCredit, + } = await import('./dataCredits.js'); + // The Navara mode has no viewer; a layer-side call with it must not throw. + assert.doesNotThrow(() => registerDataCredits(null, [{ html: 'A' }])); + assert.equal(showOsmCredit(null, 'owner'), false); + assert.equal(hideOsmCredit(null, 'owner'), false); + assert.equal(registerDynamicCredit(null, { key: 'k', html: 'K' }), false); +}); diff --git a/src/standalone/application.js b/src/standalone/application.js index 5c5b048d9..ac6d60958 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -7,10 +7,23 @@ import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; +import { createLayerCatalog } from '../app/catalog.js'; +import { resolveEngineKind } from '../app/engineChoice.js'; // The existing controls and layer catalog contain page-scoped state. let constructed = false; +/** The page's engine choice: ?engine=, else the stored gev.engine, else Cesium. */ +function pageEngineKind() { + let storage = null; + try { + storage = globalThis.localStorage ?? null; + } catch { + // Blocked storage reads as no stored choice. + } + return resolveEngineKind({ search: globalThis.location?.search, storage }); +} + /** Compose the standalone application once per page. Reload to start again. */ export function createStandaloneApplication({ googleApiKey, @@ -20,6 +33,7 @@ export function createStandaloneApplication({ sources = {}, streetLevelProviders, allowQaRegistration = false, + engineKind = pageEngineKind(), }) { if (constructed) throw new Error('The standalone application already owns this page'); @@ -47,7 +61,14 @@ export function createStandaloneApplication({ googleTokens: googleApiKey ? null : createGoogleTokenSource(), cesiumToken, loaderStatus, + engineKind, }); + // The experimental Navara engine runs without data layers: an empty + // catalog with the same interface. + if (engineKind === 'navara') { + catalog = createLayerCatalog([], []); + return scene; + } catalog = createStandaloneCatalog({ sources, streetLevelProviders, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 134d52b2f..c648c6b71 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -414,14 +414,18 @@ export class StyleManager extends ShellFacade { // Full-globe sun/moon ring. It is a crisp screen-space overlay above the // Cesium canvas but below the HUD/detection/readout z ladder. - this.celestialRing = new CelestialRing(viewer, { - enabled: false, - onAutoDisable: () => - this.setCelestialRingEnabled(false, { - syncShare: !!this.shareLinkManager, - focus: false, - }), - }); + // Viewer-bound modules (the ring, detection, tracked readout, cockpit) + // are not created without a viewer (the experimental Navara engine). + this.celestialRing = viewer + ? new CelestialRing(viewer, { + enabled: false, + onAutoDisable: () => + this.setCelestialRingEnabled(false, { + syncShare: !!this.shareLinkManager, + focus: false, + }), + }) + : null; // Share Link Manager this.shareLinkManager = new ShareLinkManager(engine, { @@ -478,24 +482,26 @@ export class StyleManager extends ShellFacade { // Initialize detection overlay BEFORE style stages so the composite // stage is first in the post-process pipeline - initDetection( - viewer, - [ - trafficLayer, - flightsLayer, - militaryFlightsLayer, - localAdsbLayer, - satellitesLayer, - cctvLayer, - bikeshareLayer, - transitLayer, - aisLiveVesselsLayer, - ].filter(Boolean), - (modeLabel) => { - this._updateDetectionButton(modeLabel); - }, - ); - initTrackedReadout(viewer); + if (viewer) { + initDetection( + viewer, + [ + trafficLayer, + flightsLayer, + militaryFlightsLayer, + localAdsbLayer, + satellitesLayer, + cctvLayer, + bikeshareLayer, + transitLayer, + aisLiveVesselsLayer, + ].filter(Boolean), + (modeLabel) => { + this._updateDetectionButton(modeLabel); + }, + ); + initTrackedReadout(viewer); + } setDetectionStyle(this.activeStyle); this._applyDetectionDensityFromUi(); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 218ec816d..5042b0953 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -31,6 +31,15 @@ export class CockpitCoordinator { elements, operations, ); + this._cockpitDisplayPortal = null; + // The cockpit flies the Cesium camera after tracked aircraft; without a + // viewer (the Navara engine) there is no cockpit and controlCockpit + // reports it unavailable. + this.cockpitView = viewer + ? this._createCockpitView(viewer, services) + : null; + } + _createCockpitView(viewer, services) { const { flightsLayer, militaryFlightsLayer, @@ -49,8 +58,7 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, } = services; - this._cockpitDisplayPortal = null; - this.cockpitView = new CockpitViewController(viewer, { + return new CockpitViewController(viewer, { services: { flightsLayer, militaryFlightsLayer, @@ -112,7 +120,7 @@ export class CockpitCoordinator { } getCockpitState() { const { militaryAwarenessLayer } = this.services; - const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); + const snapshot = militaryAwarenessLayer?.getContextSnapshot?.(); const info = this.cockpitView?.readAircraftInfo?.(); const active = Boolean(this.cockpitView?.active); const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); diff --git a/src/ui/layerBindings.js b/src/ui/layerBindings.js index 10ceb3c82..6100e7420 100644 --- a/src/ui/layerBindings.js +++ b/src/ui/layerBindings.js @@ -77,11 +77,13 @@ export class LayerBindings { window, this._worldRequestFocusHandler, ); - this._navigationOwnerChangedRemover = - this.viewer.trackedEntityChanged.addEventListener((entity) => { - if (entity && !this._disposed) - this._stampNavigation({ cancelPendingSelection: false }); - }); + // Entity tracking is the viewer's (no viewer on the Navara engine). + if (this.viewer) + this._navigationOwnerChangedRemover = + this.viewer.trackedEntityChanged.addEventListener((entity) => { + if (entity && !this._disposed) + this._stampNavigation({ cancelPendingSelection: false }); + }); // Vessel/installation focus flies without ever assigning a tracked entity, // so it cannot reach the listener above. It announces instead. this._removeNavigationAuthorityListener = diff --git a/src/ui/radioControls.js b/src/ui/radioControls.js index b4a3669ba..f04c9215a 100644 --- a/src/ui/radioControls.js +++ b/src/ui/radioControls.js @@ -230,6 +230,7 @@ export class RadioControls { document .getElementById('title-bar') ?.classList.remove('radio-broadcasting'); - this.radio.endTuning(); + // The Navara engine's stand-in radio layer has no tuner. + this.radio.endTuning?.(); } } diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index b03dfe602..77fe37bba 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -415,3 +415,17 @@ visibility: visible; transform: translateX(-50%) translateY(0); } + +/* The experimental Navara engine (?engine=navara) shows its attributions in + its own card, fixed bottom-right over the location tray. Keep it on the + left, above the HUD's coordinate lines and below the left panel rail. */ +body .navara-attr-dock { + right: auto; + left: 16px; + bottom: 176px; + align-items: flex-start; +} +body .navara-attr-card { + max-height: 290px; + overflow: auto; +} diff --git a/src/ui/visualEffects.js b/src/ui/visualEffects.js index 6533bb748..8a3670f84 100644 --- a/src/ui/visualEffects.js +++ b/src/ui/visualEffects.js @@ -52,8 +52,10 @@ export class VisualEffects { this.previousBloom = null; } + // Post-process stages are Cesium's: without a viewer (the Navara engine) + // there are none, and the style, bloom and sharpen controls change nothing. initStyles() { - if (this.stopped || this.stageEntries.length) return; + if (!this.viewer || this.stopped || this.stageEntries.length) return; for (const [name, shader] of Object.entries(STYLES)) { const uniforms = { intensity: 0.0 }; if (shader.fragmentShader.includes('uniform float time')) @@ -73,7 +75,7 @@ export class VisualEffects { } initPostProcess(sharpenIntensity = this.sharpenIntensity) { - if (this.stopped || this.sharpenStage) return; + if (!this.viewer || this.stopped || this.sharpenStage) return; this.bloomStage = this.viewer.scene.postProcessStages.bloom; const names = [ 'glowOnly', @@ -224,6 +226,7 @@ export class VisualEffects { if (this.destroyed) return; this.stop(); this.destroyed = true; + if (!this.viewer) return; const stages = this.viewer.scene.postProcessStages; for (const [, stage] of this.stageEntries) stages.remove(stage); if (this.sharpenStage) stages.remove(this.sharpenStage); diff --git a/src/ui/visualSettings.js b/src/ui/visualSettings.js index 17788a68d..117266c43 100644 --- a/src/ui/visualSettings.js +++ b/src/ui/visualSettings.js @@ -544,7 +544,7 @@ export class VisualSettings { fadeRatio: fadePct / 100, outsideOpacity: outsideOpacityPct / 100, }); - this.viewer.scene.requestRender?.(); + this.viewer?.scene.requestRender?.(); } _setDetectionAllocation(strategy, { syncShare = true, persist = true } = {}) { @@ -1486,7 +1486,13 @@ export class VisualSettings { this._styleParameters.clear(); const shader = STYLES[styleName]; - if (!shader || !shader.uniforms || styleName === 'normal') { + // No stage without a viewer (the Navara engine): nothing to adjust. + if ( + !shader || + !shader.uniforms || + styleName === 'normal' || + !this.stages[styleName] + ) { this._sliderPanel.classList.remove('active'); this._scheduleRightPanelLayout(); return;