mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
refactor(ui): navigation, orientation, share links and HUD use the globe engine
The application shell keeps the page's globe engine and hands it to the navigation controller, location navigation (search, city and POI flights, orbit, globe reset), the tilt and north-up controls, share-link save and restore, share restoration's first-gesture listeners and the HUD. The viewer is passed only where entity tracking still lives on it (phase 1c) and to the HUD's basemap label context (voice); every such use is null-safe. Share links and captured shots write heading and roll as Cesium's camera reports them (src/cameraAngles.js): the contract's [0, 2π) pose maps an exact 0 back to Cesium's 2π, except the roll of a vertical view, so links stay byte-identical (heading=360, roll=360) on every engine. Source-reading tests follow the moved code with the same checks.
This commit is contained in:
1 parent
16e63f4c50
commit
ec38e6ef16
19 files changed
+594
-226
No files matched your search
@@ -551,6 +551,7 @@
|
||||
"exports": ["./ui/shell"],
|
||||
"modules": [
|
||||
"src/globe/cesium/engineFromViewer.js",
|
||||
"src/cameraAngles.js",
|
||||
"src/globe/contract.js",
|
||||
"src/hudLayouts.js",
|
||||
"src/hudLayoutPolicy.js",
|
||||
@@ -1696,6 +1697,7 @@
|
||||
"./standalone/catalog"
|
||||
],
|
||||
"modules": [
|
||||
"src/cameraAngles.js",
|
||||
"src/cyberSonarScene.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/hudLayouts.js",
|
||||
|
||||
@@ -17,7 +17,10 @@ test('the controls pass the scene engine to the shell', () => {
|
||||
|
||||
test('the shell initializes the world overlay on its engine', () => {
|
||||
const shell = read('../ui/applicationShell.js');
|
||||
assert.match(shell, /\{\n\s+engine = null,\n\s+mapStackController = null,/);
|
||||
assert.match(
|
||||
shell,
|
||||
/\{\n\s+engine = engineFromViewer\(viewer\),\n\s+mapStackController = null,/,
|
||||
);
|
||||
assert.match(shell, /initWorldOverlay\(engine\);/);
|
||||
assert.doesNotMatch(shell, /initWorldOverlay\(viewer\)/);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Camera heading and roll as Cesium's camera reports them, from an engine pose.
|
||||
//
|
||||
// The globe contract keeps pose angles in [0, 2π) (src/globe/contract.js).
|
||||
// Cesium's own Camera.heading and .roll lie in (0, 2π]: a view facing exactly
|
||||
// north reports heading 2π, a level view reports roll 2π, and roll is exactly
|
||||
// 0 only in Cesium's vertical branch (looking within 1e-3 of straight up or
|
||||
// down). Share links and captured scene shots have always carried Cesium's
|
||||
// numbers (`heading=360`, `roll=360`), so they are written from these, and a
|
||||
// link or shot made on any engine reads as one made on Cesium. The contract
|
||||
// folds Cesium's values within 1e-12 of 0 and 2π onto 0; a reading that was a
|
||||
// hair above 0 therefore comes back as 2π here, the only case that differs.
|
||||
|
||||
import * as geo from './geo/index.js';
|
||||
|
||||
const { TWO_PI } = geo.math;
|
||||
|
||||
/** Whether a pose looks (nearly, within 1e-3) straight up or down, as Cesium tests it. */
|
||||
export function poseIsVertical({ position, direction }) {
|
||||
const up = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
position,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
return Math.abs(1 - Math.abs(geo.cartesian3.dot(direction, up))) <= 1e-3;
|
||||
}
|
||||
|
||||
/** The pose heading as Cesium reports it, in (0, 2π]. */
|
||||
export const cesiumHeading = (pose) =>
|
||||
pose.heading === 0 ? TWO_PI : pose.heading;
|
||||
|
||||
/** The pose roll as Cesium reports it: (0, 2π], or exactly 0 for a vertical view. */
|
||||
export const cesiumRoll = (pose) =>
|
||||
pose.roll === 0 && !poseIsVertical(pose) ? TWO_PI : pose.roll;
|
||||
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from './geo/index.js';
|
||||
import { cesiumHeading, cesiumRoll, poseIsVertical } from './cameraAngles.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
|
||||
const poseAt = (heading, pitch, roll = 0) => {
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.setPose({
|
||||
position: geo.cartesian3.fromDegrees(10, 45, 2000),
|
||||
heading,
|
||||
pitch,
|
||||
roll,
|
||||
});
|
||||
return engine.camera.pose();
|
||||
};
|
||||
|
||||
test('a level north-facing pose reports Cesium heading and roll 2π', () => {
|
||||
const pose = poseAt(0, -0.5);
|
||||
assert.equal(pose.heading, 0);
|
||||
assert.equal(cesiumHeading(pose), 2 * Math.PI);
|
||||
assert.equal(cesiumRoll({ ...pose, roll: 0 }), 2 * Math.PI);
|
||||
assert.equal(poseIsVertical(pose), false);
|
||||
});
|
||||
|
||||
test('a straight-down pose keeps roll 0; other angles pass through', () => {
|
||||
const down = poseAt(0, -Math.PI / 2);
|
||||
assert.equal(poseIsVertical(down), true);
|
||||
assert.equal(cesiumRoll(down), 0);
|
||||
const oblique = { ...poseAt(1, -0.5), heading: 1.25, roll: 0.5 };
|
||||
assert.equal(cesiumHeading(oblique), 1.25);
|
||||
assert.equal(cesiumRoll(oblique), 0.5);
|
||||
});
|
||||
@@ -191,10 +191,10 @@ test('accepted navigation releases through PR15-aware ownership before flight',
|
||||
'origin: trackingOrigin',
|
||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
'if (this.viewer) this.viewer.trackedEntity = undefined;',
|
||||
"interruptCameraMotion('explicit-navigation')",
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
||||
'camera?.cancelFlight();',
|
||||
'camera?.release();',
|
||||
], 'follow release');
|
||||
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
@@ -273,11 +273,11 @@ test('a direct globe gesture retires delayed camera and selection restore only',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
|
||||
/this\.canvas\?\.addEventListener\(\s*'pointerdown',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/viewer\?\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
|
||||
/this\.canvas\?\.addEventListener\(\s*'wheel',\s*this\._initialShareGestureHandler/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
|
||||
@@ -533,7 +533,7 @@ test('Reset releases Contact camera ownership through its selection-preserving r
|
||||
test('Location navigation releases immediate routes before flight and deferred routes after resolution', () => {
|
||||
const releaseStart = ui.indexOf(' _releaseFollowCamera(');
|
||||
const locationFlight = ui.indexOf('_flyWithTransition(cityChanged, flyAction)');
|
||||
const search = ui.indexOf('searchAndFlyTo(this.viewer, query, {');
|
||||
const search = ui.indexOf('searchAndFlyTo(this.engine, query, {');
|
||||
const voiceStart = voiceActions.indexOf('beginDeferredLocationNavigation');
|
||||
|
||||
assert.ok(releaseStart >= 0, 'shared Location camera handoff is missing');
|
||||
|
||||
+29
-15
@@ -15,6 +15,8 @@ import { applicationServices } from './services/application.js';
|
||||
*/
|
||||
|
||||
import * as geo from './geo/index.js';
|
||||
import { isGlobeEngine } from './globe/contract.js';
|
||||
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './locations.js';
|
||||
import { composeLocalityTag } from './hudLocality.js';
|
||||
@@ -92,12 +94,17 @@ const NEARBY_POINTS = Object.values(CITY_POIS).flatMap((city) =>
|
||||
*/
|
||||
export class IntelHUD {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for
|
||||
* camera telemetry and coordinate derivation.
|
||||
* @param {import('./globe/contract.js').GlobeEngine | object} engine The
|
||||
* globe engine whose camera drives the telemetry, or a viewer registered
|
||||
* for one.
|
||||
* @param {object} [options] `viewer`: the Cesium viewer the basemap label
|
||||
* context (voice, until phase 1c) reads; without one the summary has no
|
||||
* place labels. Defaults to the first argument when that is a viewer.
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
engine,
|
||||
{
|
||||
viewer = isGlobeEngine(engine) ? null : engine,
|
||||
placeSearch,
|
||||
summaryPolicy = {},
|
||||
basemapContext = {},
|
||||
@@ -108,6 +115,7 @@ export class IntelHUD {
|
||||
this.summaryPolicy = summaryPolicy;
|
||||
this.basemapContext = basemapContext;
|
||||
this.placeSearch = placeSearch;
|
||||
this.engine = engineFromViewer(engine);
|
||||
this.viewer = viewer;
|
||||
this._visible = false;
|
||||
this._autoMode = true; // auto show/hide based on style
|
||||
@@ -172,7 +180,9 @@ export class IntelHUD {
|
||||
this._passNum = 100 + Math.floor(Math.random() * 200);
|
||||
|
||||
this._buildDOM();
|
||||
this.viewer.camera.moveEnd.addEventListener(this._onCameraMoveEnd);
|
||||
this._removeCameraMoveEnd = this.engine.camera.moveEnd.addEventListener(
|
||||
this._onCameraMoveEnd,
|
||||
);
|
||||
this._startTimers();
|
||||
}
|
||||
|
||||
@@ -332,13 +342,13 @@ export class IntelHUD {
|
||||
|
||||
/**
|
||||
* Derive all camera-based telemetry and push values to the DOM.
|
||||
* Reads the viewer camera's cartographic position and computes MGRS,
|
||||
* Reads the camera's cartographic position and computes MGRS,
|
||||
* lat/lon DMS, GSD, NIIRS, sun elevation, off-nadir angle, and
|
||||
* collection timestamp. Stores results in {@link _latestMetrics}.
|
||||
*/
|
||||
_updateCameraData() {
|
||||
const camera = this.viewer.camera;
|
||||
const cartographic = camera.positionCartographic;
|
||||
const pose = this.engine.camera.pose();
|
||||
const cartographic = pose.cartographic;
|
||||
if (!cartographic) return;
|
||||
|
||||
const lonDeg = geo.math.toDegrees(cartographic.longitude);
|
||||
@@ -407,7 +417,7 @@ export class IntelHUD {
|
||||
|
||||
// Off-nadir angle (ONA): camera pitch of -90 deg is nadir (straight down),
|
||||
// so ONA = 90 + pitch gives 0 at nadir and increases toward the horizon.
|
||||
const pitchDeg = geo.math.toDegrees(camera.pitch);
|
||||
const pitchDeg = geo.math.toDegrees(pose.pitch);
|
||||
const ona = Math.max(0, 90 + pitchDeg);
|
||||
const onaEl = document.getElementById('hud-ona');
|
||||
if (onaEl) onaEl.textContent = `ONA: ${ona.toFixed(1)}°`;
|
||||
@@ -569,7 +579,7 @@ export class IntelHUD {
|
||||
* dimensions, or null if the view rectangle cannot be computed.
|
||||
*/
|
||||
_viewWindowKm(latDeg) {
|
||||
const rect = this.viewer.camera.computeViewRectangle();
|
||||
const rect = this.engine.camera.viewRectangle();
|
||||
if (!rect) return null;
|
||||
const north = geo.math.toDegrees(rect.north);
|
||||
const south = geo.math.toDegrees(rect.south);
|
||||
@@ -779,11 +789,14 @@ export class IntelHUD {
|
||||
}
|
||||
|
||||
async _summaryContext() {
|
||||
const labels = await getBasemapLabelContext(
|
||||
this.viewer,
|
||||
this.placeSearch,
|
||||
this.basemapContext,
|
||||
);
|
||||
// Basemap labels still read the Cesium viewer (voice, phase 1c).
|
||||
const labels = this.viewer
|
||||
? await getBasemapLabelContext(
|
||||
this.viewer,
|
||||
this.placeSearch,
|
||||
this.basemapContext,
|
||||
)
|
||||
: { placeLabels: [], streetLabels: [], nearbyPlaceLabels: [] };
|
||||
const enabledLayers =
|
||||
this._dataManager
|
||||
?.getAll?.()
|
||||
@@ -943,7 +956,8 @@ export class IntelHUD {
|
||||
clearInterval(this._timestampInterval);
|
||||
clearInterval(this._summaryInterval);
|
||||
clearInterval(this._summaryTypingInterval);
|
||||
this.viewer.camera.moveEnd.removeEventListener(this._onCameraMoveEnd);
|
||||
this._removeCameraMoveEnd?.();
|
||||
this._removeCameraMoveEnd = null;
|
||||
this._dataManagerUnsubscribe?.();
|
||||
this._summaryRequest?.abort();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { ensureGeoidReady } from './data/geoid.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
|
||||
const MGRS_STUB_URL = 'gev-test-stub:mgrs';
|
||||
registerHooks({
|
||||
@@ -57,6 +58,18 @@ const SFO_ELLIPSOIDAL_M = -15;
|
||||
* is deliberately absent so `_buildDOM` bails and the readouts stay the plain
|
||||
* text sinks this test reads.
|
||||
*/
|
||||
/**
|
||||
* The HUD reads its camera through a globe engine: one standing where the
|
||||
* viewer double's camera stands (cartographic, pitch, no view rectangle).
|
||||
*/
|
||||
function engineFor(viewer) {
|
||||
const engine = createFakeEngine();
|
||||
const { longitude, latitude, height } = viewer.camera.positionCartographic;
|
||||
engine.fake.setPose({ longitude, latitude, height, heading: 0, pitch: viewer.camera.pitch, roll: 0 });
|
||||
engine.fake.script('viewRectangle', undefined);
|
||||
return engine;
|
||||
}
|
||||
|
||||
function installHudEnvironment() {
|
||||
const elements = new Map(
|
||||
['hud-alt', 'hud-summary', 'hud-mgrs', 'hud-latlon', 'hud-bottom-line', 'hud-gsd', 'hud-coll', 'hud-ona', 'hud-mode']
|
||||
@@ -181,7 +194,7 @@ test('a cold tick paints both readouts uncorrected, and resolving flips both in
|
||||
const env = installHudEnvironment();
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer);
|
||||
hud = new IntelHUD(engineFor(env.viewer));
|
||||
const alt = () => env.elements.get('hud-alt').textContent;
|
||||
const summary = () => env.elements.get('hud-summary').textContent;
|
||||
|
||||
@@ -231,7 +244,7 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
|
||||
env.viewer.camera.positionCartographic.height = 100;
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(env.viewer);
|
||||
hud = new IntelHUD(engineFor(env.viewer));
|
||||
hud._updateCameraData(); // cold: requests the grid, paints uncorrected
|
||||
assert.match(env.elements.get('hud-alt').textContent, /^ALT: 100m/);
|
||||
await ensureGeoidReady();
|
||||
@@ -248,3 +261,31 @@ test('the corrected readouts are the MSL datum, not a coincidence of the SFO sig
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the HUD follows the engine camera: moveEnd repaints, the view window reads the engine, destroy unsubscribes', () => {
|
||||
const env = installHudEnvironment();
|
||||
const engine = engineFor(env.viewer);
|
||||
let hud;
|
||||
try {
|
||||
hud = new IntelHUD(engine);
|
||||
assert.equal(engine.camera.moveEnd.numberOfListeners, 1);
|
||||
engine.fake.script('viewRectangle', {
|
||||
west: (-122.5 * Math.PI) / 180,
|
||||
south: (37.5 * Math.PI) / 180,
|
||||
east: (-122.4 * Math.PI) / 180,
|
||||
north: (37.6 * Math.PI) / 180,
|
||||
});
|
||||
const window = hud._viewWindowKm(37.55);
|
||||
assert.ok(Math.abs(window.heightKm - 11.1) < 1e-9);
|
||||
hud._visible = true;
|
||||
hud._firstSummaryKicked = true; // no AI summary request from this test
|
||||
engine.fake.emit('camera.moveEnd');
|
||||
assert.match(env.elements.get('hud-mgrs').textContent, /^MGRS: 10S/);
|
||||
hud.destroy();
|
||||
hud = null;
|
||||
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
|
||||
} finally {
|
||||
hud?.destroy();
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -42,6 +42,30 @@ import {
|
||||
setScopeMaskFeather,
|
||||
} from './scopeMask.js';
|
||||
import { ShareLinkManager } from './sharelink.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
/**
|
||||
* A globe engine standing in for the viewer double this test used to build:
|
||||
* the camera pose comes from `camera.positionCartographic`/heading/pitch/roll,
|
||||
* and `camera.flyTo`/`setView` and `scene.requestRender` replace the engine's.
|
||||
*/
|
||||
function engineViewer({ camera = {}, scene = {} } = {}) {
|
||||
const engine = createFakeEngine();
|
||||
const carto = camera.positionCartographic;
|
||||
if (carto)
|
||||
engine.fake.setPose({
|
||||
longitude: carto.longitude,
|
||||
latitude: carto.latitude,
|
||||
height: carto.height,
|
||||
heading: camera.heading ?? 0,
|
||||
pitch: camera.pitch ?? -Math.PI / 2,
|
||||
roll: camera.roll ?? 0,
|
||||
});
|
||||
if (camera.flyTo) engine.camera.flyTo = camera.flyTo;
|
||||
if (camera.setView) engine.camera.setView = camera.setView;
|
||||
if (scene.requestRender) engine.render.request = () => scene.requestRender();
|
||||
return engine;
|
||||
}
|
||||
|
||||
|
||||
// Follow the UI wiring and its extracted preset definitions.
|
||||
const uiSource = readShellSource()
|
||||
@@ -71,7 +95,7 @@ function managerForHash(hash) {
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
return new ShareLinkManager(viewer);
|
||||
return new ShareLinkManager(engineViewer(viewer));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -10,6 +10,30 @@ import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
||||
import { createDefaultLayerState } from './data/layerState.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
/**
|
||||
* A globe engine standing in for the viewer double this test used to build:
|
||||
* the camera pose comes from `camera.positionCartographic`/heading/pitch/roll,
|
||||
* and `camera.flyTo`/`setView` and `scene.requestRender` replace the engine's.
|
||||
*/
|
||||
function engineViewer({ camera = {}, scene = {} } = {}) {
|
||||
const engine = createFakeEngine();
|
||||
const carto = camera.positionCartographic;
|
||||
if (carto)
|
||||
engine.fake.setPose({
|
||||
longitude: carto.longitude,
|
||||
latitude: carto.latitude,
|
||||
height: carto.height,
|
||||
heading: camera.heading ?? 0,
|
||||
pitch: camera.pitch ?? -Math.PI / 2,
|
||||
roll: camera.roll ?? 0,
|
||||
});
|
||||
if (camera.flyTo) engine.camera.flyTo = camera.flyTo;
|
||||
if (camera.setView) engine.camera.setView = camera.setView;
|
||||
if (scene.requestRender) engine.render.request = () => scene.requestRender();
|
||||
return engine;
|
||||
}
|
||||
|
||||
|
||||
const uiSource = readShellSource();
|
||||
|
||||
@@ -47,7 +71,7 @@ function makeManager(hash = '') {
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
return new ShareLinkManager(viewer);
|
||||
return new ShareLinkManager(engineViewer(viewer));
|
||||
}
|
||||
|
||||
function installClipboard(writeText) {
|
||||
@@ -424,7 +448,7 @@ test('share-link restore forces a final stationary render for Google 3D Tiles',
|
||||
},
|
||||
scene: { requestRender() { calls.renders += 1; } },
|
||||
};
|
||||
const manager = new ShareLinkManager(viewer);
|
||||
const manager = new ShareLinkManager(engineViewer(viewer));
|
||||
manager.applyState({
|
||||
lat: 40.7669,
|
||||
lon: -73.9909,
|
||||
@@ -453,7 +477,7 @@ test('newer navigation suppresses delayed share camera while non-camera state st
|
||||
flyTo() { flights += 1; },
|
||||
},
|
||||
};
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
const manager = new ShareLinkManager(engineViewer(viewer), {
|
||||
onRestore: (state) => { restored = state; },
|
||||
isNavigationCurrent: () => false,
|
||||
});
|
||||
@@ -683,7 +707,7 @@ test('share apply completion waits for both callback work and camera settlement'
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
const manager = new ShareLinkManager(viewer, { onRestore: () => restoreGate });
|
||||
const manager = new ShareLinkManager(engineViewer(viewer), { onRestore: () => restoreGate });
|
||||
let settled = false;
|
||||
const applying = manager.applyState({
|
||||
lat: 40, lon: -74, alt: 500, heading: 0, pitch: -30, roll: 0,
|
||||
@@ -712,7 +736,7 @@ test('a later navigation prevents share completion from resetting the final pose
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
const manager = new ShareLinkManager(engineViewer(viewer), {
|
||||
isNavigationCurrent: (token) => token === generation,
|
||||
});
|
||||
manager.applyState({
|
||||
@@ -736,7 +760,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
|
||||
},
|
||||
scene: { requestRender() {} },
|
||||
};
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
const manager = new ShareLinkManager(engineViewer(viewer), {
|
||||
isNavigationCurrent: (token) => token === generation,
|
||||
cancelOwnedNavigation: () => { cancellations += 1; flight?.cancel?.(); },
|
||||
});
|
||||
@@ -748,7 +772,7 @@ test('destroy cancels only a still-owned share flight and ignores delayed comple
|
||||
assert.equal(cancellations, 1);
|
||||
assert.equal(setViews, 0);
|
||||
|
||||
const newerManager = new ShareLinkManager(viewer, {
|
||||
const newerManager = new ShareLinkManager(engineViewer(viewer), {
|
||||
isNavigationCurrent: (token) => token === generation,
|
||||
cancelOwnedNavigation: () => { cancellations += 1; },
|
||||
});
|
||||
|
||||
+22
-20
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from './geo/index.js';
|
||||
import { engineFromViewer } from './globe/cesium/engineFromViewer.js';
|
||||
import { cesiumHeading, cesiumRoll } from './cameraAngles.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
|
||||
import {
|
||||
migrateDetectionState,
|
||||
@@ -86,11 +87,15 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
||||
});
|
||||
|
||||
export class ShareLinkManager {
|
||||
/**
|
||||
* @param {import('./globe/contract.js').GlobeEngine | object} engine The
|
||||
* globe engine, or a viewer registered for one.
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
engine,
|
||||
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this.engine = engineFromViewer(engine);
|
||||
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
|
||||
this._debounceTimer = null;
|
||||
this._currentStyle = 'normal';
|
||||
@@ -143,7 +148,7 @@ export class ShareLinkManager {
|
||||
: null;
|
||||
|
||||
// Listen for camera changes
|
||||
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
|
||||
this._removeCameraChanged = this.engine.camera.changed.addEventListener(
|
||||
() => {
|
||||
this._scheduleUpdate();
|
||||
},
|
||||
@@ -258,7 +263,7 @@ export class ShareLinkManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a parsed state to the viewer + style manager.
|
||||
* Apply a parsed state to the camera + style manager.
|
||||
*/
|
||||
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
|
||||
if (this._destroyed || !state)
|
||||
@@ -293,10 +298,10 @@ export class ShareLinkManager {
|
||||
};
|
||||
// Re-apply the final pose only while this share restoration still owns
|
||||
// navigation. A later user or voice command wins over delayed restore.
|
||||
this.viewer.camera.flyTo({
|
||||
this.engine.camera.flyTo({
|
||||
...view,
|
||||
duration: 3.0,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
easing: 'cubicInOut',
|
||||
complete: () => {
|
||||
if (
|
||||
this._destroyed ||
|
||||
@@ -306,8 +311,8 @@ export class ShareLinkManager {
|
||||
releaseOwnedFlight('superseded');
|
||||
return;
|
||||
}
|
||||
this.viewer.camera.setView(view);
|
||||
this.viewer.scene?.requestRender?.();
|
||||
this.engine.camera.setView(view);
|
||||
this.engine.render.request('share-restore');
|
||||
releaseOwnedFlight('applied');
|
||||
},
|
||||
cancel: () => releaseOwnedFlight('cancelled'),
|
||||
@@ -539,8 +544,8 @@ export class ShareLinkManager {
|
||||
/** Build a deterministic snapshot without mutating history. */
|
||||
_buildHashParams() {
|
||||
if (this._destroyed) return null;
|
||||
const camera = this.viewer.camera;
|
||||
const carto = camera.positionCartographic;
|
||||
const pose = this.engine.camera.pose();
|
||||
const carto = pose.cartographic;
|
||||
if (!carto) return null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
@@ -548,15 +553,12 @@ export class ShareLinkManager {
|
||||
params.set('lat', geo.math.toDegrees(carto.latitude).toFixed(4));
|
||||
params.set('lon', geo.math.toDegrees(carto.longitude).toFixed(4));
|
||||
params.set('alt', Math.round(carto.height).toString());
|
||||
params.set(
|
||||
'heading',
|
||||
Math.round(geo.math.toDegrees(camera.heading)).toString(),
|
||||
);
|
||||
params.set(
|
||||
'pitch',
|
||||
Math.round(geo.math.toDegrees(camera.pitch)).toString(),
|
||||
);
|
||||
params.set('roll', Math.round(geo.math.toDegrees(camera.roll)).toString());
|
||||
// Heading and roll as Cesium reports them, so `heading=360` and
|
||||
// `roll=360` read the same on every engine (src/cameraAngles.js).
|
||||
const degrees = (angle) => Math.round(geo.math.toDegrees(angle)).toString();
|
||||
params.set('heading', degrees(cesiumHeading(pose)));
|
||||
params.set('pitch', degrees(pose.pitch));
|
||||
params.set('roll', degrees(cesiumRoll(pose)));
|
||||
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
|
||||
params.set('bloom', this._bloomEnabled ? '1' : '0');
|
||||
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// Share links read the camera through the globe engine and write heading and
|
||||
// roll the way Cesium's camera reports them (in (0, 2π]), so a link made on
|
||||
// any engine reads as one made on Cesium.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from './geo/index.js';
|
||||
import { createFakeEngine } from './globe/testing/fakeEngine.js';
|
||||
import { ShareLinkManager } from './sharelink.js';
|
||||
|
||||
function linkFor(pose) {
|
||||
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.setPose(pose);
|
||||
const manager = new ShareLinkManager(engine);
|
||||
const params = new URLSearchParams(manager._buildHashParams().toString());
|
||||
manager.destroy();
|
||||
return Object.fromEntries(
|
||||
['lat', 'lon', 'alt', 'heading', 'pitch', 'roll'].map((k) => [k, params.get(k)]),
|
||||
);
|
||||
}
|
||||
|
||||
const at = (heading, pitch, roll = 0) => ({
|
||||
position: geo.cartesian3.fromDegrees(-73.9857, 40.7484, 1800),
|
||||
heading: geo.math.toRadians(heading),
|
||||
pitch: geo.math.toRadians(pitch),
|
||||
roll: geo.math.toRadians(roll),
|
||||
});
|
||||
|
||||
test('a level north-facing view writes heading and roll 360, as Cesium reports them', () => {
|
||||
assert.deepEqual(linkFor(at(0, -30)), {
|
||||
lat: '40.7484',
|
||||
lon: '-73.9857',
|
||||
alt: '1800',
|
||||
heading: '360',
|
||||
pitch: '-30',
|
||||
roll: '360',
|
||||
});
|
||||
});
|
||||
|
||||
test('a straight-down view writes roll 0; other angles round as before', () => {
|
||||
const down = linkFor(at(0, -90));
|
||||
assert.equal(down.heading, '360');
|
||||
assert.equal(down.roll, '0');
|
||||
assert.equal(down.pitch, '-90');
|
||||
const oblique = linkFor(at(45.4, -29.6, 2));
|
||||
assert.deepEqual([oblique.heading, oblique.pitch, oblique.roll], ['45', '-30', '2']);
|
||||
assert.equal(linkFor(at(359.7, -30)).heading, '360');
|
||||
});
|
||||
|
||||
test('the camera changed event schedules a hash update and destroy unsubscribes', () => {
|
||||
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||
const engine = createFakeEngine();
|
||||
const manager = new ShareLinkManager(engine);
|
||||
assert.equal(engine.camera.changed.numberOfListeners, 1);
|
||||
manager.destroy();
|
||||
assert.equal(engine.camera.changed.numberOfListeners, 0);
|
||||
});
|
||||
+22
-12
@@ -29,8 +29,9 @@ import {
|
||||
setCyberSonarControls,
|
||||
} from './cyberSonarControls.js';
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import { cesiumHeading, cesiumRoll } from '../cameraAngles.js';
|
||||
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
@@ -62,12 +63,13 @@ export class StyleManager extends ShellFacade {
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
|
||||
* @param {object} [options] `engine`: the page's globe engine (the world
|
||||
* overlay host runs on it).
|
||||
* overlay host, camera navigation, HUD and share links run on it; defaults
|
||||
* to the one registered for the viewer).
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
engine = null,
|
||||
engine = engineFromViewer(viewer),
|
||||
mapStackController = null,
|
||||
placeSearch,
|
||||
services,
|
||||
@@ -129,11 +131,13 @@ export class StyleManager extends ShellFacade {
|
||||
readLayers: () => this._dataManager?.getAll?.() || [],
|
||||
});
|
||||
this.viewer = viewer;
|
||||
this.engine = engine;
|
||||
this.mapStackController = mapStackController;
|
||||
this.placeSearch = placeSearch;
|
||||
|
||||
this._navigation = new NavigationController({
|
||||
viewer,
|
||||
engine,
|
||||
tracking: {
|
||||
flightsLayer,
|
||||
militaryFlightsLayer,
|
||||
@@ -154,6 +158,7 @@ export class StyleManager extends ShellFacade {
|
||||
});
|
||||
this._shareRestoration = new ShareRestoration({
|
||||
viewer,
|
||||
canvas: engine?.host.canvas ?? viewer?.canvas,
|
||||
navigation: this._navigation,
|
||||
syncShareState: () => this._syncShareState(),
|
||||
syncModels3d: (state) => this._syncModels3dFromLayerState(state),
|
||||
@@ -302,6 +307,7 @@ export class StyleManager extends ShellFacade {
|
||||
|
||||
this._locationNavigation = new LocationNavigation({
|
||||
viewer,
|
||||
engine,
|
||||
placeSearch,
|
||||
navigation: this._navigation,
|
||||
readCockpit: () => this.cockpitView,
|
||||
@@ -354,7 +360,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._lastTrafficChipUpdateAt = 0;
|
||||
|
||||
// Intel HUD
|
||||
this.hud = new IntelHUD(viewer, {
|
||||
this.hud = new IntelHUD(engine, {
|
||||
viewer,
|
||||
placeSearch,
|
||||
summaryService: requestServices?.summary,
|
||||
});
|
||||
@@ -417,13 +424,13 @@ export class StyleManager extends ShellFacade {
|
||||
});
|
||||
|
||||
// Share Link Manager
|
||||
this.shareLinkManager = new ShareLinkManager(viewer, {
|
||||
this.shareLinkManager = new ShareLinkManager(engine, {
|
||||
onRestore: async (state) => {
|
||||
return this._visualSettings.restoreShareState(state);
|
||||
},
|
||||
isNavigationCurrent: (generation) =>
|
||||
generation === this._navigationGeneration,
|
||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||
cancelOwnedNavigation: () => this.engine.camera.cancelFlight(),
|
||||
});
|
||||
this.shareLinkManager.setPanelStateProvider(() =>
|
||||
this._buildSharePanelState(),
|
||||
@@ -1322,15 +1329,17 @@ export class StyleManager extends ShellFacade {
|
||||
* @returns {{lat: number, lon: number, alt: number, heading: number, pitch: number, roll: number}|null}
|
||||
*/
|
||||
getCameraState() {
|
||||
const carto = this.viewer.camera.positionCartographic;
|
||||
const pose = this.engine?.camera.pose();
|
||||
const carto = pose?.cartographic;
|
||||
if (!carto) return null;
|
||||
// Heading and roll as Cesium reports them, as captured shots always held.
|
||||
return {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
alt: carto.height,
|
||||
heading: geo.math.toDegrees(this.viewer.camera.heading),
|
||||
pitch: geo.math.toDegrees(this.viewer.camera.pitch),
|
||||
roll: geo.math.toDegrees(this.viewer.camera.roll),
|
||||
heading: geo.math.toDegrees(cesiumHeading(pose)),
|
||||
pitch: geo.math.toDegrees(pose.pitch),
|
||||
roll: geo.math.toDegrees(cesiumRoll(pose)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1342,7 +1351,7 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
applyCameraState(cameraState, duration = 2.8) {
|
||||
if (!cameraState) return;
|
||||
this.viewer.camera.flyTo({
|
||||
this.engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(
|
||||
cameraState.lon,
|
||||
cameraState.lat,
|
||||
@@ -1354,7 +1363,7 @@ export class StyleManager extends ShellFacade {
|
||||
roll: geo.math.toRadians(cameraState.roll || 0),
|
||||
},
|
||||
duration: Math.max(0.2, duration || 0),
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
easing: 'cubicInOut',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1466,6 +1475,7 @@ export class StyleManager extends ShellFacade {
|
||||
this._cameraOrientationControls?.destroy();
|
||||
this._cameraOrientationControls = bindCameraOrientationControls({
|
||||
viewer: this.viewer,
|
||||
engine: this.engine,
|
||||
elements: {
|
||||
tiltButton: this._tiltMapBtn,
|
||||
northButton: this._northUpBtn,
|
||||
|
||||
+151
-108
@@ -1,6 +1,21 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { isPickedWorldPosition } from '../data/scenePick.js';
|
||||
import { isGlobeEngine } from '../globe/contract.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// The camera is the globe engine's. Entity tracking stays on the Cesium viewer
|
||||
// until phase 1c, so the viewer, when there is one, is only read for its
|
||||
// tracked entity: callers pass an engine and that viewer, or a viewer whose
|
||||
// engine is registered (engineFromViewer).
|
||||
const engineOf = (target) => engineFromViewer(target);
|
||||
const trackerOf = (target, tracker) =>
|
||||
tracker ?? (target && !isGlobeEngine(target) ? target : null);
|
||||
|
||||
// Cesium's EasingFunction.CUBIC_IN_OUT (tween.js Cubic.InOut).
|
||||
function cubicInOut(amount) {
|
||||
if ((amount *= 2) < 1) return 0.5 * amount * amount * amount;
|
||||
return 0.5 * ((amount -= 2) * amount * amount + 2);
|
||||
}
|
||||
|
||||
function trackedTarget(viewer) {
|
||||
const entity = viewer.trackedEntity;
|
||||
@@ -21,69 +36,66 @@ function normalizedHeading(heading) {
|
||||
return Math.abs(wrapped - geo.math.TWO_PI) < geo.math.EPSILON10 ? 0 : wrapped;
|
||||
}
|
||||
|
||||
/** Return the world position under the center of the canvas, when available. */
|
||||
export function pickViewTarget(viewer) {
|
||||
const scene = viewer?.scene;
|
||||
const camera = viewer?.camera;
|
||||
const canvas = scene?.canvas;
|
||||
if (!scene || !camera || !canvas) return null;
|
||||
const width = canvas.clientWidth || canvas.width || 0;
|
||||
const height = canvas.clientHeight || canvas.height || 0;
|
||||
/**
|
||||
* Return the world position under the center of the canvas, when available.
|
||||
* @param {import('../globe/contract.js').GlobeEngine | object} target The
|
||||
* globe engine, or a viewer registered for one.
|
||||
*/
|
||||
export function pickViewTarget(target) {
|
||||
const engine = engineOf(target);
|
||||
if (!engine) return null;
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
const center = new Cesium.Cartesian2(width / 2, height / 2);
|
||||
let target = null;
|
||||
|
||||
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') {
|
||||
const center = { x: width / 2, y: height / 2 };
|
||||
const { picker, camera } = engine;
|
||||
// Depth first, then terrain before the ellipsoid: the globe pick
|
||||
// intersects the rendered surface, so over keyless elevated terrain it
|
||||
// returns the ground the operator is looking at. pickEllipsoid answers with
|
||||
// sea level, which on a plateau sits far below the view and would swing the
|
||||
// camera on every tilt.
|
||||
const stages = [
|
||||
() =>
|
||||
picker.pickPositionSupported ? picker.pickPosition(center) : undefined,
|
||||
() => picker.pickGlobe(center),
|
||||
() => camera.pickEllipsoid(center),
|
||||
];
|
||||
for (const stage of stages) {
|
||||
let picked = null;
|
||||
try {
|
||||
target = scene.pickPosition(center);
|
||||
picked = stage();
|
||||
} catch {
|
||||
target = null;
|
||||
picked = null;
|
||||
}
|
||||
if (isPickedWorldPosition(picked)) return picked;
|
||||
}
|
||||
// Terrain before the ellipsoid: globe.pick intersects the rendered surface,
|
||||
// so over keyless elevated terrain it returns the ground the operator is
|
||||
// looking at. pickEllipsoid answers with sea level, which on a plateau sits
|
||||
// far below the view and would swing the camera on every tilt.
|
||||
if (
|
||||
!isPickedWorldPosition(target) &&
|
||||
typeof camera.getPickRay === 'function'
|
||||
) {
|
||||
try {
|
||||
target = scene.globe?.pick(camera.getPickRay(center), scene) || null;
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!isPickedWorldPosition(target) &&
|
||||
typeof camera.pickEllipsoid === 'function'
|
||||
) {
|
||||
try {
|
||||
target = camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
return isPickedWorldPosition(target) ? target : null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Describe the camera as an orbit around the current viewport center. */
|
||||
export function readCameraTargetFrame(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const target = viewer?.trackedEntity
|
||||
/**
|
||||
* Describe the camera as an orbit around the current viewport center.
|
||||
* @param {import('../globe/contract.js').GlobeEngine | object} target The
|
||||
* globe engine, or a viewer registered for one.
|
||||
* @param {object} [tracker] The viewer whose tracked entity is the orbit
|
||||
* target (defaults to `target` when that is a viewer).
|
||||
*/
|
||||
export function readCameraTargetFrame(target, tracker) {
|
||||
const engine = engineOf(target);
|
||||
if (!engine) return null;
|
||||
const viewer = trackerOf(target, tracker);
|
||||
const center = viewer?.trackedEntity
|
||||
? trackedTarget(viewer)
|
||||
: pickViewTarget(viewer);
|
||||
if (!camera || !target || !isPickedWorldPosition(camera.positionWC))
|
||||
return null;
|
||||
: pickViewTarget(engine);
|
||||
const pose = engine.camera.pose();
|
||||
if (!center || !isPickedWorldPosition(pose.position)) return null;
|
||||
|
||||
const transform = geo.transforms.eastNorthUpToFixedFrame(target);
|
||||
const transform = geo.transforms.eastNorthUpToFixedFrame(center);
|
||||
const inverse = geo.matrix4.inverseTransformation(
|
||||
transform,
|
||||
geo.matrix4.create(),
|
||||
);
|
||||
const localOffset = geo.matrix4.multiplyByPoint(
|
||||
inverse,
|
||||
camera.positionWC,
|
||||
pose.position,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
const range = geo.cartesian3.magnitude(localOffset);
|
||||
@@ -97,42 +109,43 @@ export function readCameraTargetFrame(viewer) {
|
||||
// make north-up rotate the wrong way from three quarters of the compass.
|
||||
const targetHeading = Math.atan2(-localOffset.x, -localOffset.y);
|
||||
const heading = normalizedHeading(
|
||||
pitch < geo.math.toRadians(-88.5) ? camera.heading : targetHeading,
|
||||
pitch < geo.math.toRadians(-88.5) ? pose.heading : targetHeading,
|
||||
);
|
||||
return { target, range, heading, pitch };
|
||||
return { target: center, range, heading, pitch };
|
||||
}
|
||||
|
||||
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
|
||||
export function setCameraTargetFrame(viewer, frame) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
|
||||
/**
|
||||
* Apply an orbit frame, then return the camera to the fixed-world frame.
|
||||
* @param {import('../globe/contract.js').GlobeEngine | object} target The
|
||||
* globe engine, or a viewer registered for one.
|
||||
* @param {object} frame From {@link readCameraTargetFrame}.
|
||||
* @param {object} [tracker] As in readCameraTargetFrame.
|
||||
*/
|
||||
export function setCameraTargetFrame(target, frame, tracker) {
|
||||
const engine = engineOf(target);
|
||||
if (!engine || !frame?.target || !Number.isFinite(frame.range)) return false;
|
||||
const viewer = trackerOf(target, tracker);
|
||||
const { camera } = engine;
|
||||
try {
|
||||
const trackedTransform = viewer.trackedEntity
|
||||
? geo.matrix4.clone(camera.transform)
|
||||
: null;
|
||||
camera.lookAt(
|
||||
frame.target,
|
||||
new Cesium.HeadingPitchRange(
|
||||
normalizedHeading(frame.heading),
|
||||
frame.pitch,
|
||||
frame.range,
|
||||
),
|
||||
);
|
||||
const trackedTransform = viewer?.trackedEntity ? camera.transform() : null;
|
||||
camera.lookAt(frame.target, {
|
||||
heading: normalizedHeading(frame.heading),
|
||||
pitch: frame.pitch,
|
||||
range: frame.range,
|
||||
});
|
||||
if (trackedTransform) {
|
||||
// Preserve EntityView's reference frame (including satellite frames).
|
||||
// Its next update carries this new local offset along with the target.
|
||||
camera.lookAtTransform(trackedTransform);
|
||||
viewer.scene?.requestRender?.();
|
||||
engine.render.request('camera-orientation');
|
||||
return true;
|
||||
}
|
||||
const destination = geo.cartesian3.clone(camera.positionWC);
|
||||
const direction = geo.cartesian3.clone(camera.directionWC);
|
||||
const up = geo.cartesian3.clone(camera.upWC);
|
||||
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
const { position: destination, direction, up } = camera.pose();
|
||||
camera.release();
|
||||
if (destination && direction && up) {
|
||||
camera.setView({ destination, orientation: { direction, up } });
|
||||
}
|
||||
viewer.scene?.requestRender?.();
|
||||
engine.render.request('camera-orientation');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -158,32 +171,40 @@ export function frameIsTilted(frame) {
|
||||
}
|
||||
|
||||
/** Toggle between a straight-down map and a useful oblique map angle. */
|
||||
export function toggleCameraTilt(viewer) {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
export function toggleCameraTilt(target, tracker) {
|
||||
const frame = readCameraTargetFrame(target, tracker);
|
||||
if (!frame) return false;
|
||||
const tilted = frameIsTilted(frame);
|
||||
const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH;
|
||||
return setCameraTargetFrame(viewer, { ...frame, pitch })
|
||||
return setCameraTargetFrame(target, { ...frame, pitch }, tracker)
|
||||
? { tilted: !tilted, pitch }
|
||||
: false;
|
||||
}
|
||||
|
||||
/** Rotate around the viewport center until north is at the top. */
|
||||
export function resetCameraNorth(viewer) {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
export function resetCameraNorth(target, tracker) {
|
||||
const frame = readCameraTargetFrame(target, tracker);
|
||||
if (!frame) return false;
|
||||
return setCameraTargetFrame(viewer, { ...frame, heading: 0 });
|
||||
return setCameraTargetFrame(target, { ...frame, heading: 0 }, tracker);
|
||||
}
|
||||
|
||||
function headingDegrees(camera) {
|
||||
return geo.math.toDegrees(normalizedHeading(camera?.heading));
|
||||
function headingDegrees(engine) {
|
||||
return geo.math.toDegrees(normalizedHeading(engine?.camera.pose().heading));
|
||||
}
|
||||
|
||||
/** Ease an orbit without handing off the entity that owns the follow camera. */
|
||||
/**
|
||||
* Ease an orbit without handing off the entity that owns the follow camera.
|
||||
* @param {import('../globe/contract.js').GlobeEngine | object} target The
|
||||
* globe engine, or a viewer registered for one.
|
||||
* @param {{ now?: () => number, duration?: number, tracker?: object }} [options]
|
||||
* `tracker` as in readCameraTargetFrame.
|
||||
*/
|
||||
export function createCameraOrientationAnimator(
|
||||
viewer,
|
||||
{ now = () => performance.now(), duration = 650 } = {},
|
||||
target,
|
||||
{ now = () => performance.now(), duration = 650, tracker } = {},
|
||||
) {
|
||||
const engine = engineOf(target);
|
||||
const viewer = trackerOf(target, tracker);
|
||||
let remove = null;
|
||||
let pending = null;
|
||||
const cancel = () => {
|
||||
@@ -193,37 +214,42 @@ export function createCameraOrientationAnimator(
|
||||
};
|
||||
function animate(frame, destination) {
|
||||
cancel();
|
||||
if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0)
|
||||
return setCameraTargetFrame(viewer, { ...frame, ...destination });
|
||||
const entity = viewer.trackedEntity;
|
||||
if (!engine) return false;
|
||||
if (duration <= 0)
|
||||
return setCameraTargetFrame(engine, { ...frame, ...destination }, viewer);
|
||||
const entity = viewer?.trackedEntity;
|
||||
pending = destination;
|
||||
const start = now();
|
||||
const headingDelta = geo.math.negativePiToPi(
|
||||
destination.heading - frame.heading,
|
||||
);
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
remove = engine.frames.preUpdate.addEventListener(() => {
|
||||
if (engine.isDestroyed() || viewer?.trackedEntity !== entity) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
const progress = geo.math.clamp((now() - start) / duration, 0, 1);
|
||||
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress);
|
||||
const target = entity ? trackedTarget(viewer) : frame.target;
|
||||
const eased = cubicInOut(progress);
|
||||
const center = entity ? trackedTarget(viewer) : frame.target;
|
||||
if (
|
||||
!isPickedWorldPosition(target) ||
|
||||
!setCameraTargetFrame(viewer, {
|
||||
...frame,
|
||||
target,
|
||||
heading: frame.heading + headingDelta * eased,
|
||||
pitch: geo.math.lerp(frame.pitch, destination.pitch, eased),
|
||||
})
|
||||
!isPickedWorldPosition(center) ||
|
||||
!setCameraTargetFrame(
|
||||
engine,
|
||||
{
|
||||
...frame,
|
||||
target: center,
|
||||
heading: frame.heading + headingDelta * eased,
|
||||
pitch: geo.math.lerp(frame.pitch, destination.pitch, eased),
|
||||
},
|
||||
viewer,
|
||||
)
|
||||
) {
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (progress === 1) cancel();
|
||||
});
|
||||
viewer.scene.requestRender?.();
|
||||
engine.render.request('camera-orientation');
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
@@ -235,12 +261,25 @@ export function createCameraOrientationAnimator(
|
||||
};
|
||||
}
|
||||
|
||||
/** Bind the two map-orientation actions and keep their accessible state current. */
|
||||
/**
|
||||
* Bind the two map-orientation actions and keep their accessible state current.
|
||||
* @param {{
|
||||
* engine?: import('../globe/contract.js').GlobeEngine,
|
||||
* viewer?: object,
|
||||
* elements: object,
|
||||
* runNavigation: Function,
|
||||
* showToast?: Function,
|
||||
* animationMs?: number,
|
||||
* }} options `engine` defaults to the one registered for `viewer`; the viewer
|
||||
* is otherwise only read for its tracked entity (phase 1c).
|
||||
*/
|
||||
export function bindCameraOrientationControls({
|
||||
viewer,
|
||||
viewer = null,
|
||||
engine = engineOf(viewer),
|
||||
elements,
|
||||
runNavigation,
|
||||
showToast,
|
||||
animationMs,
|
||||
}) {
|
||||
const tiltButton = elements?.tiltButton;
|
||||
const northButton = elements?.northButton;
|
||||
@@ -251,12 +290,16 @@ export function bindCameraOrientationControls({
|
||||
// of the screen: 4 ms median and up to 12 ms, measured on this machine. It is
|
||||
// therefore held between refreshes rather than recomputed per frame.
|
||||
let tilted = false;
|
||||
const animator = createCameraOrientationAnimator(viewer);
|
||||
const animator = createCameraOrientationAnimator(engine, {
|
||||
tracker: viewer,
|
||||
...(animationMs === undefined ? {} : { duration: animationMs }),
|
||||
});
|
||||
const readFrame = () => readCameraTargetFrame(engine, viewer);
|
||||
|
||||
/** Recompute the tilt state exactly, paying for one pick. */
|
||||
const refreshTilt = () => {
|
||||
if (destroyed) return;
|
||||
tilted = frameIsTilted(readCameraTargetFrame(viewer));
|
||||
tilted = frameIsTilted(readFrame());
|
||||
sync();
|
||||
};
|
||||
|
||||
@@ -272,7 +315,7 @@ export function bindCameraOrientationControls({
|
||||
if (destroyed) return;
|
||||
const next = {
|
||||
tilted,
|
||||
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
|
||||
heading: Math.round(headingDegrees(engine)) % 360,
|
||||
};
|
||||
if (
|
||||
applied &&
|
||||
@@ -308,7 +351,7 @@ export function bindCameraOrientationControls({
|
||||
listen(tiltButton, () => {
|
||||
const pending = animator.destination;
|
||||
const result = runNavigation('camera', () => {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
const frame = readFrame();
|
||||
if (!frame) return false;
|
||||
const nextTilted = !frameIsTilted(pending || frame);
|
||||
const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH;
|
||||
@@ -330,7 +373,7 @@ export function bindCameraOrientationControls({
|
||||
listen(northButton, () => {
|
||||
const pending = animator.destination;
|
||||
const result = runNavigation('camera', () => {
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
const frame = readFrame();
|
||||
return (
|
||||
frame &&
|
||||
animator.animate(frame, {
|
||||
@@ -350,13 +393,13 @@ export function bindCameraOrientationControls({
|
||||
// The needle follows every frame the scene draws. `camera.changed` is NOT
|
||||
// enough: it only fires past `camera.percentageChanged`, so a rotation of a
|
||||
// few degrees leaves the needle pointing at the old bearing.
|
||||
subscribe(viewer?.scene?.preRender, sync);
|
||||
subscribe(engine?.frames.preRender, sync);
|
||||
// The exact tilt state is refreshed once per gesture, not once per frame —
|
||||
// moveEnd fires when the camera stops, which is where the pick is affordable.
|
||||
subscribe(viewer?.camera?.moveEnd, refreshTilt);
|
||||
subscribe(engine?.camera.moveEnd, refreshTilt);
|
||||
subscribe(viewer?.trackedEntityChanged, animator.cancel);
|
||||
for (const type of ['pointerdown', 'wheel']) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const canvas = engine?.host.canvas;
|
||||
canvas?.addEventListener?.(type, animator.cancel, { passive: true });
|
||||
removers.push(() => canvas?.removeEventListener?.(type, animator.cancel));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,24 @@ import {
|
||||
toggleCameraTilt,
|
||||
} from './cameraOrientationControls.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createCesiumEngine } from '../globe/cesium/createCesiumEngine.js';
|
||||
import { registerViewerEngine } from '../globe/cesium/engineFromViewer.js';
|
||||
|
||||
// The controls run on the globe engine; these viewer doubles get the Cesium
|
||||
// engine registered for them, as the app's scene engine is.
|
||||
function withEngine(viewer) {
|
||||
registerViewerEngine(viewer, createCesiumEngine(viewer));
|
||||
return viewer;
|
||||
}
|
||||
|
||||
/** Same Cartesian values (the engine hands out copies, never Cesium's own). */
|
||||
const sameXYZ = (actual, expected, message) =>
|
||||
assert.deepEqual(
|
||||
[actual?.x, actual?.y, actual?.z],
|
||||
[expected.x, expected.y, expected.z],
|
||||
message,
|
||||
);
|
||||
|
||||
function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
||||
const calls = [];
|
||||
@@ -23,7 +41,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
||||
directionWC: new Cesium.Cartesian3(-1, 0, 0),
|
||||
upWC: new Cesium.Cartesian3(0, 0, 1),
|
||||
heading: Cesium.Math.toRadians(90),
|
||||
pitch: Cesium.Math.toRadians(-90),
|
||||
roll: 0,
|
||||
frustum: { fovy: 1, aspectRatio: 1.5 },
|
||||
changed,
|
||||
moveStart: new Cesium.Event(),
|
||||
moveEnd: new Cesium.Event(),
|
||||
pickEllipsoid: () => surface,
|
||||
getPickRay: () => ({}),
|
||||
lookAt(targetValue, offset) {
|
||||
@@ -44,9 +67,12 @@ function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
|
||||
pickPosition: pickPosition || (() => null),
|
||||
globe: { pick: () => surface },
|
||||
requestRender: () => calls.push({ type: 'requestRender' }),
|
||||
preUpdate: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
postRender: new Cesium.Event(),
|
||||
},
|
||||
};
|
||||
return { viewer, calls, target: surface };
|
||||
return { viewer: withEngine(viewer), calls, target: surface };
|
||||
}
|
||||
|
||||
class FakeButton extends EventTarget {
|
||||
@@ -104,8 +130,10 @@ function createRealViewer(target) {
|
||||
// The binding drives the needle from the scene's per-frame signal, not from
|
||||
// `camera.changed`, so the fixture has to carry one.
|
||||
scene.preRender = new Cesium.Event();
|
||||
scene.preUpdate = new Cesium.Event();
|
||||
scene.postRender = new Cesium.Event();
|
||||
camera.getPickRay = () => ({});
|
||||
return { viewer: { camera, scene }, target };
|
||||
return { viewer: withEngine({ camera, scene }), target };
|
||||
}
|
||||
|
||||
/** Place a real camera in an orbit around `target` and release the frame. */
|
||||
@@ -282,6 +310,8 @@ test('the button shows the state the next click will produce, at every range', (
|
||||
viewer,
|
||||
elements: { tiltButton, northButton: new FakeButton() },
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
// No frame loop drives this fixture: apply each change at once.
|
||||
animationMs: 0,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
@@ -425,7 +455,7 @@ test('a sky-facing camera yields no frame and both actions decline', () => {
|
||||
scene.requestRender = () => {};
|
||||
camera.getPickRay = () => ({});
|
||||
camera.pickEllipsoid = () => undefined;
|
||||
const viewer = { camera, scene };
|
||||
const viewer = withEngine({ camera, scene });
|
||||
|
||||
assert.equal(readCameraTargetFrame(viewer), null);
|
||||
assert.equal(toggleCameraTilt(viewer), false);
|
||||
@@ -439,14 +469,14 @@ test('center picking prefers terrain over the ellipsoid and falls through an inv
|
||||
const { viewer } = createViewer({ pickPosition: () => invalid });
|
||||
viewer.scene.globe.pick = () => terrain;
|
||||
viewer.camera.pickEllipsoid = () => sea;
|
||||
assert.equal(
|
||||
sameXYZ(
|
||||
pickViewTarget(viewer),
|
||||
terrain,
|
||||
'the rendered ground wins over sea level',
|
||||
);
|
||||
|
||||
viewer.scene.globe.pick = () => null;
|
||||
assert.equal(
|
||||
sameXYZ(
|
||||
pickViewTarget(viewer),
|
||||
sea,
|
||||
'the ellipsoid still answers when nothing is under the cursor',
|
||||
@@ -468,7 +498,7 @@ test('tilt preserves the picked target and range while choosing oblique pitch',
|
||||
const result = toggleCameraTilt(viewer);
|
||||
const lookAt = calls.find((call) => call.type === 'lookAt');
|
||||
assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH });
|
||||
assert.equal(lookAt.target, target);
|
||||
sameXYZ(lookAt.target, target);
|
||||
assert.equal(lookAt.offset.range, before.range);
|
||||
assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH);
|
||||
assert.ok(calls.some((call) => call.type === 'lookAtTransform'));
|
||||
@@ -533,7 +563,6 @@ for (const [label, altitude, range] of [
|
||||
);
|
||||
let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude);
|
||||
const { viewer } = createRealViewer(target);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
viewer.clock = { currentTime: Cesium.JulianDate.now() };
|
||||
const entity = new Cesium.Entity({
|
||||
position: new Cesium.CallbackPositionProperty(() => target, false),
|
||||
@@ -597,7 +626,6 @@ for (const [label, altitude, range] of [
|
||||
test('orientation animation cancels on replacement and explicit cancellation', () => {
|
||||
const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0));
|
||||
orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
const animator = createCameraOrientationAnimator(viewer);
|
||||
const frame = readCameraTargetFrame(viewer);
|
||||
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
|
||||
@@ -615,13 +643,17 @@ test('orientation animation cancels on replacement and explicit cancellation', (
|
||||
test('orientation navigation preserves follow and selection; new navigation cancels animation', () => {
|
||||
const calls = [];
|
||||
const entity = {};
|
||||
const viewer = {
|
||||
trackedEntity: entity,
|
||||
camera: { cancelFlight: () => calls.push('cancel-flight') },
|
||||
const viewer = { trackedEntity: entity };
|
||||
const engine = createFakeEngine();
|
||||
const cancelFlight = engine.camera.cancelFlight;
|
||||
engine.camera.cancelFlight = () => {
|
||||
calls.push('cancel-flight');
|
||||
cancelFlight();
|
||||
};
|
||||
let cockpit = false;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
engine,
|
||||
tracking: {},
|
||||
isCockpitActive: () => cockpit,
|
||||
cancelOrientation: () => calls.push('cancel-orientation'),
|
||||
@@ -662,7 +694,6 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt
|
||||
const target = Cesium.Cartesian3.fromDegrees(0, 0);
|
||||
const { viewer } = createRealViewer(target);
|
||||
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
|
||||
viewer.scene.preUpdate = new Cesium.Event();
|
||||
const tiltButton = new FakeButton();
|
||||
const northButton = new FakeButton();
|
||||
let controls;
|
||||
@@ -688,3 +719,45 @@ test('rapid tilt presses reverse direction and north-up keeps the requested tilt
|
||||
controls.destroy();
|
||||
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('the controls run on a globe engine alone', (t) => {
|
||||
let time = 0;
|
||||
t.mock.method(performance, 'now', () => time);
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.setPose({
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 3000),
|
||||
heading: Cesium.Math.toRadians(70),
|
||||
pitch: STRAIGHT_DOWN_PITCH,
|
||||
roll: 0,
|
||||
});
|
||||
const tiltButton = new FakeButton();
|
||||
const northButton = new FakeButton();
|
||||
const controls = bindCameraOrientationControls({
|
||||
engine,
|
||||
elements: { tiltButton, northButton },
|
||||
runNavigation: (_noun, navigate) => navigate(),
|
||||
});
|
||||
assert.equal(tiltButton.getAttribute('aria-pressed'), 'false');
|
||||
assert.equal(northButton.getAttribute('--camera-heading'), '70deg');
|
||||
tiltButton.click();
|
||||
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
|
||||
// The animation runs on the engine's preUpdate frames.
|
||||
time = 700;
|
||||
engine.fake.frame(16);
|
||||
const tilted = readCameraTargetFrame(engine);
|
||||
assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6);
|
||||
northButton.click();
|
||||
time = 1400;
|
||||
engine.fake.frame(16);
|
||||
const north = readCameraTargetFrame(engine);
|
||||
assert.ok(
|
||||
Math.abs(north.heading) < 1e-6 ||
|
||||
Math.abs(north.heading - 2 * Math.PI) < 1e-6,
|
||||
);
|
||||
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
|
||||
assert.ok(engine.fake.renderRequests > 0);
|
||||
controls.destroy();
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, 0);
|
||||
assert.equal(engine.frames.preUpdate.numberOfListeners, 0);
|
||||
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
|
||||
});
|
||||
@@ -1,12 +1,17 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import { LocationControls } from './location.js';
|
||||
|
||||
/** Own destination selection, lookup, orbit and world-jump lifetime. */
|
||||
/**
|
||||
* Own destination selection, lookup, orbit and world-jump lifetime. The camera
|
||||
* is the globe `engine`'s; `viewer` is optional and only clears Cesium's
|
||||
* tracked entity (entity tracking moves to the engine in phase 1c).
|
||||
*/
|
||||
export class LocationNavigation {
|
||||
constructor({
|
||||
viewer,
|
||||
viewer = null,
|
||||
engine = engineFromViewer(viewer),
|
||||
placeSearch,
|
||||
services,
|
||||
elements,
|
||||
@@ -16,7 +21,7 @@ export class LocationNavigation {
|
||||
}) {
|
||||
Object.assign(
|
||||
this,
|
||||
{ viewer, placeSearch, services, navigation, readCockpit },
|
||||
{ viewer, engine, placeSearch, services, navigation, readCockpit },
|
||||
elements,
|
||||
operations,
|
||||
);
|
||||
@@ -31,7 +36,7 @@ export class LocationNavigation {
|
||||
this._globeResetPromise = null;
|
||||
this._cancelGlobeReset = null;
|
||||
this._worldJumpActive = false;
|
||||
this.orbitController = new services.OrbitController(viewer);
|
||||
this.orbitController = new services.OrbitController(engine);
|
||||
this._orbitIndicator = null;
|
||||
this._locationState = createStateChannel(
|
||||
() => this._locationLookup?.getState() || null,
|
||||
@@ -112,7 +117,7 @@ export class LocationNavigation {
|
||||
!this._disposed && generation === this._navigationGeneration,
|
||||
beforeFly: (generation) => this._reassertNavigationHandoff(generation),
|
||||
search: (query, options) =>
|
||||
searchAndFlyTo(this.viewer, query, {
|
||||
searchAndFlyTo(this.engine, query, {
|
||||
placeSearch: this.placeSearch,
|
||||
...options,
|
||||
}),
|
||||
@@ -194,7 +199,7 @@ export class LocationNavigation {
|
||||
const isCityChanged =
|
||||
this._activeLocationId && this._activeLocationId !== cityId;
|
||||
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
|
||||
flyToPresetLocation(this.viewer, cityId, hooks),
|
||||
flyToPresetLocation(this.engine, cityId, hooks),
|
||||
);
|
||||
if (result === false) return;
|
||||
this._expandPOIRow(cityId);
|
||||
@@ -215,7 +220,7 @@ export class LocationNavigation {
|
||||
const isCityChanged =
|
||||
this._activeLocationId && this._activeLocationId !== cityId;
|
||||
const result = this._flyWithTransition(!!isCityChanged, (hooks) =>
|
||||
flyToPOI(this.viewer, cityId, poiIndex, hooks),
|
||||
flyToPOI(this.engine, cityId, poiIndex, hooks),
|
||||
);
|
||||
if (result === false) return;
|
||||
this._setActiveLocation(cityId);
|
||||
@@ -350,9 +355,9 @@ export class LocationNavigation {
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
this.viewer.trackedEntity = undefined;
|
||||
this.viewer.camera.cancelFlight();
|
||||
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
if (this.viewer) this.viewer.trackedEntity = undefined;
|
||||
this.engine.camera.cancelFlight();
|
||||
this.engine.camera.release();
|
||||
this._beginWorldJumpTransition();
|
||||
|
||||
let resolveReset;
|
||||
@@ -367,7 +372,7 @@ export class LocationNavigation {
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
this._endWorldJumpTransition();
|
||||
const carto = this.viewer.camera.positionCartographic;
|
||||
const carto = this.engine.camera.pose().cartographic;
|
||||
const result = {
|
||||
ok: !cancelled,
|
||||
action: 'zoom_to_globe',
|
||||
@@ -392,7 +397,7 @@ export class LocationNavigation {
|
||||
};
|
||||
this._cancelGlobeReset = () => finish(true);
|
||||
timer = window.setTimeout(() => {
|
||||
const height = this.viewer.camera.positionCartographic?.height;
|
||||
const height = this.engine.camera.height();
|
||||
finish(
|
||||
!Number.isFinite(height) ||
|
||||
Math.abs(height - GLOBE_VIEW.heightM) > 1000,
|
||||
@@ -406,7 +411,7 @@ export class LocationNavigation {
|
||||
'aria-label',
|
||||
'Resetting cockpit to full globe view',
|
||||
);
|
||||
const target = flyToGlobeView(this.viewer, {
|
||||
const target = flyToGlobeView(this.engine, {
|
||||
onComplete: () => finish(false),
|
||||
onCancel: () => finish(true),
|
||||
});
|
||||
@@ -414,7 +419,7 @@ export class LocationNavigation {
|
||||
return resetPromise;
|
||||
}
|
||||
|
||||
/** Revoke callbacks and settle owned camera work before the viewer is released. */
|
||||
/** Revoke callbacks and settle owned camera work before the engine is released. */
|
||||
destroy() {
|
||||
if (this._disposed) return;
|
||||
this._disposed = true;
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { cancelCameraArrival } from '../data/cameraArrival.js';
|
||||
import { createStateChannel } from '../app/stateChannel.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { engineFromViewer } from '../globe/cesium/engineFromViewer.js';
|
||||
import {
|
||||
beginDeferredNavigation,
|
||||
reassertNavigationHandoff,
|
||||
runExplicitNavigation,
|
||||
} from '../navigationPolicy.js';
|
||||
|
||||
/** Own camera authority generations, pending search UI and tracking handoff. */
|
||||
/**
|
||||
* Own camera authority generations, pending search UI and tracking handoff.
|
||||
* The camera is the globe `engine`'s. `viewer` is optional and only clears
|
||||
* Cesium's tracked entity (entity tracking moves to the engine in phase 1c).
|
||||
*/
|
||||
export class NavigationController {
|
||||
constructor({
|
||||
viewer,
|
||||
viewer = null,
|
||||
engine = engineFromViewer(viewer),
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
@@ -24,6 +29,7 @@ export class NavigationController {
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
engine,
|
||||
tracking,
|
||||
searchInput,
|
||||
interruptCameraMotion,
|
||||
@@ -46,7 +52,7 @@ export class NavigationController {
|
||||
cancelPendingSelection = true,
|
||||
clearSearchedLocation = true,
|
||||
} = {}) {
|
||||
cancelCameraArrival(this.viewer);
|
||||
cancelCameraArrival(this.engine);
|
||||
this.cancelOrientation();
|
||||
const { flightsLayer, militaryFlightsLayer, satellitesLayer } =
|
||||
this.tracking;
|
||||
@@ -174,12 +180,13 @@ export class NavigationController {
|
||||
} catch {
|
||||
/* best-effort release */
|
||||
}
|
||||
this.viewer.trackedEntity = undefined;
|
||||
if (this.viewer) this.viewer.trackedEntity = undefined;
|
||||
this.interruptCameraMotion('explicit-navigation');
|
||||
this.stopOrbit();
|
||||
if (!preserveCameraFlight) this.viewer.camera.cancelFlight();
|
||||
const camera = this.engine?.camera;
|
||||
if (!preserveCameraFlight) camera?.cancelFlight();
|
||||
try {
|
||||
this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
camera?.release();
|
||||
} catch {
|
||||
/* teardown race */
|
||||
}
|
||||
@@ -213,7 +220,7 @@ export class NavigationController {
|
||||
release: () => {
|
||||
this.interruptCameraMotion('camera-orientation');
|
||||
this.stopOrbit();
|
||||
this.viewer.camera.cancelFlight();
|
||||
this.engine?.camera.cancelFlight();
|
||||
},
|
||||
navigate,
|
||||
});
|
||||
@@ -259,7 +266,7 @@ export class NavigationController {
|
||||
return this._cameraHandoffs.subscribe(listener, { emitCurrent: false });
|
||||
}
|
||||
stop() {
|
||||
cancelCameraArrival(this.viewer);
|
||||
cancelCameraArrival(this.engine);
|
||||
this._disposed = true;
|
||||
this._cameraHandoffs?.publish();
|
||||
this._cameraHandoffs?.destroy();
|
||||
|
||||
+13
-14
@@ -3,10 +3,15 @@ import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
/**
|
||||
* Own initial share restoration, durable layer state and restoration notices.
|
||||
* `canvas` (the globe engine's) receives the first-gesture listeners; it
|
||||
* defaults to the viewer's.
|
||||
*/
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
viewer,
|
||||
canvas = viewer?.canvas,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -16,6 +21,7 @@ export class ShareRestoration {
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
canvas,
|
||||
navigation,
|
||||
syncShareState,
|
||||
syncModels3d,
|
||||
@@ -114,20 +120,16 @@ export class ShareRestoration {
|
||||
this.navigation._stampNavigation(options),
|
||||
);
|
||||
};
|
||||
this.viewer?.canvas?.addEventListener(
|
||||
this.canvas?.addEventListener(
|
||||
'pointerdown',
|
||||
this._initialShareGestureHandler,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
this.viewer?.canvas?.addEventListener(
|
||||
'wheel',
|
||||
this._initialShareGestureHandler,
|
||||
{
|
||||
passive: true,
|
||||
},
|
||||
);
|
||||
this.canvas?.addEventListener('wheel', this._initialShareGestureHandler, {
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
connect(dataManager) {
|
||||
this._dataManager = dataManager;
|
||||
@@ -292,14 +294,11 @@ export class ShareRestoration {
|
||||
share: null,
|
||||
layers: [],
|
||||
});
|
||||
this.viewer?.canvas?.removeEventListener(
|
||||
this.canvas?.removeEventListener(
|
||||
'pointerdown',
|
||||
this._initialShareGestureHandler,
|
||||
);
|
||||
this.viewer?.canvas?.removeEventListener(
|
||||
'wheel',
|
||||
this._initialShareGestureHandler,
|
||||
);
|
||||
this.canvas?.removeEventListener('wheel', this._initialShareGestureHandler);
|
||||
this._initialShareGestureHandler = null;
|
||||
this._lifetime.destroy();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { LocationNavigation } from './locationNavigation.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
|
||||
function replaceWindow(t, value) {
|
||||
@@ -17,11 +18,9 @@ function location(t) {
|
||||
replaceWindow(t, { setTimeout });
|
||||
const calls = [];
|
||||
let hooks;
|
||||
const engine = createFakeEngine({ altitude: 20000000 });
|
||||
const owner = new LocationNavigation({
|
||||
viewer: { camera: {
|
||||
cancelFlight() {}, lookAtTransform() {},
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 20000000 },
|
||||
} },
|
||||
engine,
|
||||
services: {
|
||||
OrbitController: class { stop() { calls.push('orbit-stop'); } },
|
||||
GLOBE_VIEW: { heightM: 20000000 },
|
||||
|
||||
Reference in new issue
Block a user