mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
306e3e9467
commit
4ded3fbf15
21 files changed
+776
-49
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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, {}]),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Vendored
+13
-5
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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?.());
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user