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.
This commit is contained in:
German Khokhlov committed 2026-10-15 20:56:16 +04:00
1 parent 306e3e9467
commit 4ded3fbf15
21 files changed
+776 -49

No files matched your search

+17 -1
View File
@@ -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": {
+11
View File
@@ -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, {}]),
);
}
+67 -1
View File
@@ -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();
});
+13 -5
View File
@@ -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
+2 -1
View File
@@ -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 };
}
+30
View File
@@ -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';
}
+55
View File
@@ -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');
});
+156
View File
@@ -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 };
}
+169
View File
@@ -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);
});
+23 -1
View File
@@ -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');
+20 -1
View File
@@ -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,
+99
View File
@@ -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);
});
+14
View File
@@ -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);
});
+21
View File
@@ -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,
+32 -26
View File
@@ -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();
+11 -3
View File
@@ -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?.());
+7 -5
View File
@@ -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 =
+2 -1
View File
@@ -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?.();
}
}
+14
View File
@@ -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;
}
+5 -2
View File
@@ -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);
+8 -2
View File
@@ -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;