mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 03:44:00 +00:00
Fly the remaining flights through the camera port, on any viewer
The embed view, the celestial-ring focus, the ground-guard lift, the Bhote Koshi approach and orbit, launch selection and replay flights, and the cockpit's view and takeover now drive the renderer's camera port. The contract gains a sineInOut easing, pinned by a conformance trace. A viewer with no renderer bound, such as one a consumer of a public export brings, now gets a camera port too, as it already got an input port: cameraOf builds one over the viewer's own Cesium camera and keeps it for the viewer's life, until a renderer is bound. The Cesium adapter provides the factory as it loads; code that reaches Cesium takes cameraOf from the adapter so the factory comes with it, and findCamera answers null where a missing camera is not an error (HUD, share link, world focus, Recent Imagery's ZOOM IN). A test walks every package export and fails one that can reach the bridge without loading the factory. A destroyed viewer gets no camera and lets go of a built one, and the application renderer stays bound until its viewer is destroyed.
This commit is contained in:
1 parent
e3baeec223
commit
0b1c5d0f23
44 files changed
+1167
-344
No files matched your search
@@ -848,11 +848,22 @@
|
||||
"src/data/distanceFormat.js",
|
||||
"src/ui/cockpit.js",
|
||||
"src/ui/cockpitBriefing.js",
|
||||
"src/geo/index.js",
|
||||
"src/ui/cockpitBriefPages.js",
|
||||
"src/ui/cockpitContextState.js",
|
||||
"src/ui/cockpitInstrumentState.js",
|
||||
"src/ui/cockpitRollingValue.js",
|
||||
"src/ui/cockpitCamera.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/ui/cockpitContext.js",
|
||||
"src/ui/cockpitController.js",
|
||||
"src/ui/cockpitDisplayPortal.js",
|
||||
@@ -968,7 +979,11 @@
|
||||
"src/styles/surveillance.js",
|
||||
"src/styles/thermal.js",
|
||||
"src/ui/applicationShell.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/ui/localePicker.js",
|
||||
"src/ui/cyberSonarControls.js",
|
||||
"src/ui/locationNavigation.js",
|
||||
@@ -1711,10 +1726,12 @@
|
||||
"src/layers/launches/camera.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/index.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/layers/launches/ingestion.js",
|
||||
"src/layers/launches/launchPad.js",
|
||||
"src/layers/launches/lifecycle.js",
|
||||
"src/layers/launches/model.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/launches/orbitRendering.js",
|
||||
"src/layers/launches/overlays.js",
|
||||
"src/layers/launches/panel.js",
|
||||
@@ -1722,6 +1739,14 @@
|
||||
"src/layers/launches/policy.js",
|
||||
"src/layers/launches/policyHelpers.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/launches/rendering.js",
|
||||
"src/layers/launches/replay.js",
|
||||
"src/layers/launches/selection.js",
|
||||
@@ -1761,7 +1786,17 @@
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/geo/index.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
@@ -1825,11 +1860,21 @@
|
||||
"src/cockpitMath.js",
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/militaryAwarenessEngine.js",
|
||||
"src/geo/index.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/awareness/history.js",
|
||||
"src/layers/awareness/index.js",
|
||||
"src/layers/awareness/ingestion.js",
|
||||
@@ -1987,16 +2032,26 @@
|
||||
"src/layers/cctv/geometryQueue.js",
|
||||
"src/layers/cctv/ground.js",
|
||||
"src/layers/cctv/headingConfidence.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/cctv/health.js",
|
||||
"src/layers/cctv/hover.js",
|
||||
"src/layers/cctv/index.js",
|
||||
"src/geo/frames.js",
|
||||
"src/layers/cctv/ingestion.js",
|
||||
"src/layers/cctv/lifecycle.js",
|
||||
"src/layers/cctv/model.js",
|
||||
"src/geoDistance.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
@@ -2052,11 +2107,21 @@
|
||||
],
|
||||
"modules": [
|
||||
"src/layers/streetLevel/index.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/streetLevel/credits.js",
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
@@ -2129,10 +2194,20 @@
|
||||
"modules": [
|
||||
"src/data/headingDegrees.js",
|
||||
"src/layers/alpr/index.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/markerState.js",
|
||||
@@ -2183,6 +2258,7 @@
|
||||
"src/data/radioCountry.js",
|
||||
"src/layers/radio/catalogModel.js",
|
||||
"src/layers/radio/categories.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/radio/clustering.js",
|
||||
"src/layers/radio/controls.js",
|
||||
"src/layers/radio/index.js",
|
||||
@@ -2192,7 +2268,15 @@
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
@@ -2237,8 +2321,18 @@
|
||||
"src/layers/submarineCables/geometry.js",
|
||||
"src/layers/submarineCables/index.js",
|
||||
"src/layers/submarineCables/ingestion.js",
|
||||
"src/geo/index.js",
|
||||
"src/layers/submarineCables/interaction.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/internals.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/layers/submarineCables/pickState.js",
|
||||
"src/data/entityRecords.js",
|
||||
"src/layers/submarineCables/lifecycle.js",
|
||||
@@ -3481,6 +3575,7 @@
|
||||
"src/maps/imageryComparison.js",
|
||||
"src/data/bhoteKoshiEmbeddedMedia.js",
|
||||
"src/data/bhoteKoshiLocator.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/data/bhoteKoshiIncidentPlaces.js",
|
||||
"src/data/bhoteKoshiFloodPath.js",
|
||||
"src/data/bhoteKoshiShotPaths.js",
|
||||
@@ -3819,6 +3914,8 @@
|
||||
"src/layers/vessels/tracking.js",
|
||||
"src/locations.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/cityPois.js",
|
||||
"src/navigationPolicy.js",
|
||||
"src/overlays/worldOverlay.js",
|
||||
@@ -4121,7 +4218,16 @@
|
||||
"src/keylessGeocoder.js",
|
||||
"src/layers/firms/anchors.js",
|
||||
"src/locations.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/index.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/cityPois.js",
|
||||
"src/cameraGroundGuard.js",
|
||||
"src/search/alpsGeocoder.js",
|
||||
@@ -4713,6 +4819,15 @@
|
||||
"src/data/headingDegrees.js",
|
||||
"src/cyberSonar.js",
|
||||
"src/celestialRing.js",
|
||||
"src/render/cesium/camera.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/render/camera/events.js",
|
||||
"src/geo/celestial.js",
|
||||
"src/geo/quaternion.js",
|
||||
"src/geo/frames.js",
|
||||
"src/geo/ellipsoid.js",
|
||||
"src/geo/vector.js",
|
||||
"src/render/cesium/common.js",
|
||||
"src/data/approxDistance.js",
|
||||
"src/data/cameraSensitivity.js",
|
||||
"src/data/contactPlayback.js",
|
||||
@@ -4733,6 +4848,7 @@
|
||||
"src/data/spriteOrder.js",
|
||||
"src/data/terrainHeights.js",
|
||||
"src/data/transit.js",
|
||||
"src/geo/index.js",
|
||||
"src/data/transitFeeds.js",
|
||||
"src/data/transitIcons.js",
|
||||
"src/data/transitPresetStyle.js",
|
||||
|
||||
+13
-14
@@ -13,6 +13,7 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
|
||||
const EMBED_VIEW_MESSAGE = 'gev:view';
|
||||
@@ -147,21 +148,19 @@ const delay = (ms, signal) =>
|
||||
function flyTo(viewer, camera) {
|
||||
announceNavigationAuthority('embed-view');
|
||||
return new Promise((resolve) => {
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.altitude_m,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
cameraOf(viewer).flyTo(
|
||||
{ lon: camera.lon, lat: camera.lat, height: camera.altitude_m },
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(camera.heading_deg),
|
||||
pitch: Cesium.Math.toRadians(camera.pitch_deg),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: FLIGHT_SECONDS,
|
||||
onComplete: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
},
|
||||
duration: FLIGHT_SECONDS,
|
||||
complete: () => resolve(true),
|
||||
cancel: () => resolve(false),
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
@@ -12,7 +13,7 @@ import { createView } from '../view/index.js';
|
||||
|
||||
const fakeViewer = () => {
|
||||
const flights = [];
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
flights,
|
||||
camera: {
|
||||
flyTo(options) {
|
||||
@@ -20,7 +21,7 @@ const fakeViewer = () => {
|
||||
options.complete();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
const fakeLayers = (enabled) => ({
|
||||
getAll: () =>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { ownCameraArrival } from './data/cameraArrival.js';
|
||||
import { heightsFor } from './queries/heights.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
import { cameraFlightActive } from './render/cesium/internals.js';
|
||||
|
||||
/** Minimum eye height above the rendered surface for a usable view. */
|
||||
@@ -196,20 +197,22 @@ export function guardCameraAboveGround(viewer, at, options = {}) {
|
||||
);
|
||||
if (deficit > 0) {
|
||||
stop();
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cameraOf(viewer).flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
carto.height + deficit,
|
||||
),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
},
|
||||
durationS: 0.9,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: 0.9,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
try {
|
||||
onLift?.(deficit);
|
||||
} catch {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Arrival framing is computed before the destination's tiles exist, so it
|
||||
// works from a predicted ground height. The guard measures the real surface
|
||||
// after arrival and lifts the eye; these tests pin when it acts.
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
@@ -49,8 +50,12 @@ test('the clearance frames a subject without turning into an overflight', () =>
|
||||
|
||||
function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
const flights = [];
|
||||
const position = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, cameraHeightM);
|
||||
return {
|
||||
const position = Cesium.Cartographic.fromDegrees(
|
||||
-97.7603,
|
||||
30.3141,
|
||||
cameraHeightM,
|
||||
);
|
||||
return bindFakeCesiumCamera({
|
||||
flights,
|
||||
scene: {
|
||||
canvas: null,
|
||||
@@ -71,7 +76,7 @@ function fakeViewer({ cameraHeightM, surfaceM }) {
|
||||
this._currentFlight = {};
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test('the guard lifts a buried arrival once the surface answers', async () => {
|
||||
@@ -111,7 +116,11 @@ test('the guard waits for streaming tiles and yields to a newer arrival', async
|
||||
|
||||
test('the guard leaves a clear view untouched', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 400, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 1 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 1 },
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.equal(viewer.flights.length, 0);
|
||||
});
|
||||
@@ -142,27 +151,58 @@ function handoffFixture(t) {
|
||||
const originalFly = viewer.camera.flyTo;
|
||||
const navigation = new NavigationController({
|
||||
viewer,
|
||||
tracking: Object.fromEntries(['flightsLayer', 'militaryFlightsLayer',
|
||||
'satellitesLayer', 'aisLiveVesselsLayer', 'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer'].map((key) => [key, {}])),
|
||||
cancelOrientation() {}, clearLocation() {}, cancelShareSelection() {},
|
||||
getDataManager() {}, interruptCameraMotion() {}, stopOrbit() {},
|
||||
isCockpitActive: () => false, showToast() {},
|
||||
tracking: Object.fromEntries(
|
||||
[
|
||||
'flightsLayer',
|
||||
'militaryFlightsLayer',
|
||||
'satellitesLayer',
|
||||
'aisLiveVesselsLayer',
|
||||
'militaryAwarenessLayer',
|
||||
'rocketLaunchesLayer',
|
||||
].map((key) => [key, {}]),
|
||||
),
|
||||
cancelOrientation() {},
|
||||
clearLocation() {},
|
||||
cancelShareSelection() {},
|
||||
getDataManager() {},
|
||||
interruptCameraMotion() {},
|
||||
stopOrbit() {},
|
||||
isCockpitActive: () => false,
|
||||
showToast() {},
|
||||
});
|
||||
guardCameraAboveGround(viewer, { lat: 30.3, lon: -97.7 });
|
||||
return { viewer, navigation, originalFly };
|
||||
}
|
||||
|
||||
for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
'flyTo', 'flyToBoundingSphere', 'setView', 'layer-disable', 'layer-destroy', 'app-stop']) {
|
||||
for (const handoff of [
|
||||
'Director',
|
||||
'tracking',
|
||||
'follow',
|
||||
'keyboard',
|
||||
'UI',
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'layer-disable',
|
||||
'layer-destroy',
|
||||
'app-stop',
|
||||
]) {
|
||||
test(`arrival guard relinquishes and removes hooks on ${handoff}`, async (t) => {
|
||||
const { viewer, navigation, originalFly } = handoffFixture(t);
|
||||
const methods = ['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight']
|
||||
.map((name) => viewer.camera[name]);
|
||||
const methods = [
|
||||
'flyTo',
|
||||
'flyToBoundingSphere',
|
||||
'setView',
|
||||
'lookAt',
|
||||
'cancelFlight',
|
||||
].map((name) => viewer.camera[name]);
|
||||
if (handoff === 'Director') {
|
||||
SceneDirector.prototype._claimCameraOwnership.call({
|
||||
viewer, styleManager: { runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn) },
|
||||
viewer,
|
||||
styleManager: {
|
||||
runImmediateNavigation: (noun, fn) =>
|
||||
navigation._runExplicitNavigation(noun, fn),
|
||||
},
|
||||
});
|
||||
} else if (handoff === 'tracking') {
|
||||
viewer.trackedEntityChanged.raiseEvent({ id: 'contact' });
|
||||
@@ -173,19 +213,31 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
} else if (handoff === 'app-stop') navigation.stop();
|
||||
else if (handoff.startsWith('layer-')) {
|
||||
const lifecycle = new LayerLifecycle(viewer);
|
||||
lifecycle.register({ id: 'test', init() {}, enable() {}, disable() {}, destroy() {} });
|
||||
lifecycle.register({
|
||||
id: 'test',
|
||||
init() {},
|
||||
enable() {},
|
||||
disable() {},
|
||||
destroy() {},
|
||||
});
|
||||
await lifecycle.setEnabled('test', true);
|
||||
if (handoff === 'layer-disable') await lifecycle.setEnabled('test', false);
|
||||
if (handoff === 'layer-disable')
|
||||
await lifecycle.setEnabled('test', false);
|
||||
else await lifecycle.destroyLayer('test');
|
||||
} else viewer.camera[handoff]({});
|
||||
// A direct flyTo is itself one recorded flight; the guard adds none.
|
||||
const priorFlights = viewer.flights.length;
|
||||
t.mock.timers.tick(10000);
|
||||
assert.equal(viewer.flights.length, priorFlights, 'the old probe cannot fly');
|
||||
assert.equal(
|
||||
viewer.flights.length,
|
||||
priorFlights,
|
||||
'the old probe cannot fly',
|
||||
);
|
||||
assert.equal(viewer.camera.flyTo, originalFly);
|
||||
assert.deepEqual(
|
||||
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight']
|
||||
.map((name) => viewer.camera[name]),
|
||||
['flyTo', 'flyToBoundingSphere', 'setView', 'lookAt', 'cancelFlight'].map(
|
||||
(name) => viewer.camera[name],
|
||||
),
|
||||
methods,
|
||||
'the guard never replaces camera methods',
|
||||
);
|
||||
@@ -196,7 +248,11 @@ for (const handoff of ['Director', 'tracking', 'follow', 'keyboard', 'UI',
|
||||
|
||||
test('the guard yields to a camera moved without any camera method', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
// A cockpit or follow frame writes the pose directly.
|
||||
viewer.camera.directionWC = new Cesium.Cartesian3(1, 0, 0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
@@ -205,20 +261,30 @@ test('the guard yields to a camera moved without any camera method', async () =>
|
||||
|
||||
test('Cesium lifting a buried eye straight up is not another owner', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 104, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
// Terrain collision raises the eye to just above the loaded ground.
|
||||
const raised = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 172);
|
||||
viewer.camera.positionCartographic = raised;
|
||||
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(raised);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.equal(viewer.flights.length, 1, 'the guard still lifts the eye');
|
||||
const lifted = Cesium.Cartographic.fromCartesian(viewer.flights[0].destination);
|
||||
const lifted = Cesium.Cartographic.fromCartesian(
|
||||
viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(Math.abs(lifted.height - (171 + MIN_EYE_CLEARANCE_M)) < 0.5);
|
||||
});
|
||||
|
||||
test('another owner lowering the eye is not corrected', async () => {
|
||||
const viewer = fakeViewer({ cameraHeightM: 172, surfaceM: 171 });
|
||||
guardCameraAboveGround(viewer, { lat: 30.3125, lon: -97.765 }, { intervalMs: 5 });
|
||||
guardCameraAboveGround(
|
||||
viewer,
|
||||
{ lat: 30.3125, lon: -97.765 },
|
||||
{ intervalMs: 5 },
|
||||
);
|
||||
const lowered = Cesium.Cartographic.fromDegrees(-97.7603, 30.3141, 150);
|
||||
viewer.camera.positionCartographic = lowered;
|
||||
viewer.camera.positionWC = Cesium.Cartographic.toCartesian(lowered);
|
||||
|
||||
+320
-146
@@ -1,7 +1,13 @@
|
||||
import { LayerBindings } from './ui/layerBindings.js';
|
||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
||||
import {
|
||||
readShellSource,
|
||||
shellMethod,
|
||||
} from './testSupport/readShellSource.mjs';
|
||||
import { readLayerSource } from './testSupport/readLayerSource.mjs';
|
||||
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
|
||||
import {
|
||||
enter as cockpitEnter,
|
||||
navigateContext,
|
||||
} from './ui/cockpitTrackingController.js';
|
||||
import { CockpitViewController } from './ui/cockpitController.js';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
@@ -11,11 +17,24 @@ import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ui = readShellSource();
|
||||
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
||||
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
||||
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
|
||||
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
|
||||
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
|
||||
const firms = readLayerSource(
|
||||
path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'),
|
||||
);
|
||||
const vessels = readLayerSource(
|
||||
path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'),
|
||||
);
|
||||
const voice = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'voice', 'gevActions.js'),
|
||||
'utf8',
|
||||
);
|
||||
const cameraVerbs = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'cameraVerbs.js'),
|
||||
'utf8',
|
||||
);
|
||||
const cockpitTracking = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'cockpitTracking.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function body(source, pattern, label) {
|
||||
const name = pattern.source.match(/^(\w+)/)?.[1];
|
||||
@@ -31,8 +50,14 @@ function body(source, pattern, label) {
|
||||
function ordered(source, needles, label) {
|
||||
let previous = -1;
|
||||
for (const needle of needles) {
|
||||
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
|
||||
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
|
||||
const match =
|
||||
needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
|
||||
const index =
|
||||
needle instanceof RegExp
|
||||
? match
|
||||
? previous + 1 + match.index
|
||||
: -1
|
||||
: source.indexOf(needle, previous + 1);
|
||||
assert.ok(index >= 0, `${label}: missing ${needle}`);
|
||||
assert.ok(index > previous, `${label}: ${needle} is out of order`);
|
||||
previous = index;
|
||||
@@ -41,12 +66,16 @@ function ordered(source, needles, label) {
|
||||
|
||||
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
||||
const enter = cockpitEnter.toString();
|
||||
ordered(enter, [
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
], 'Cockpit takeover');
|
||||
ordered(
|
||||
enter,
|
||||
[
|
||||
'if (!info || !entity?.position) return false;',
|
||||
'this.onCameraTakeover?.();',
|
||||
'cameraOf(this.viewer).cancelFlight();',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
],
|
||||
'Cockpit takeover',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
|
||||
@@ -80,11 +109,23 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
|
||||
);
|
||||
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
|
||||
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(
|
||||
stamp,
|
||||
/militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/,
|
||||
);
|
||||
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
|
||||
assert.match(
|
||||
stamp,
|
||||
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/,
|
||||
);
|
||||
assert.match(
|
||||
stamp,
|
||||
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/,
|
||||
);
|
||||
assert.match(
|
||||
stamp,
|
||||
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/,
|
||||
);
|
||||
});
|
||||
|
||||
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
|
||||
@@ -104,10 +145,14 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
|
||||
/export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/,
|
||||
'Cockpit entry transaction',
|
||||
);
|
||||
ordered(transaction, [
|
||||
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
|
||||
'if (!entryError) entered = Boolean(cockpitView.enter());',
|
||||
], 'Cockpit entry transaction');
|
||||
ordered(
|
||||
transaction,
|
||||
[
|
||||
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
|
||||
'if (!entryError) entered = Boolean(cockpitView.enter());',
|
||||
],
|
||||
'Cockpit entry transaction',
|
||||
);
|
||||
// No direct camera control: every mutation goes through a layer tracker or
|
||||
// the cockpit controller, both of which stamp.
|
||||
assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/);
|
||||
@@ -146,27 +191,41 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
|
||||
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
|
||||
);
|
||||
const funnel = navigateContext.toString();
|
||||
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
|
||||
ordered(funnel, [
|
||||
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
|
||||
'const navigationOptions = wasActive',
|
||||
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
|
||||
'this._adoptTrackedEntity(performance.now());',
|
||||
], 'Cockpit Context navigation funnel');
|
||||
assert.match(
|
||||
funnel,
|
||||
/const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/,
|
||||
);
|
||||
ordered(
|
||||
funnel,
|
||||
[
|
||||
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
|
||||
'const navigationOptions = wasActive',
|
||||
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
|
||||
'this._adoptTrackedEntity(performance.now());',
|
||||
],
|
||||
'Cockpit Context navigation funnel',
|
||||
);
|
||||
const navigate = body(
|
||||
ui,
|
||||
/if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/,
|
||||
'controlCockpit navigation branch',
|
||||
);
|
||||
ordered(navigate, [
|
||||
'this.cockpitView.navigateContext(',
|
||||
"normalized === 'next' ? 1 : -1,",
|
||||
], 'controlCockpit navigation branch');
|
||||
ordered(
|
||||
navigate,
|
||||
['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"],
|
||||
'controlCockpit navigation branch',
|
||||
);
|
||||
// No private camera route around the awareness layer.
|
||||
assert.doesNotMatch(navigate, /flyTo|camera|trackById|_runExplicitNavigation/);
|
||||
assert.doesNotMatch(
|
||||
navigate,
|
||||
/flyTo|camera|trackById|_runExplicitNavigation/,
|
||||
);
|
||||
// An exhausted cohort is an honest failure, not a silent success.
|
||||
assert.match(navigate, /ok: changed,/);
|
||||
assert.match(navigate, /error: changed \? null : 'No further context target was available',/);
|
||||
assert.match(
|
||||
navigate,
|
||||
/error: changed \? null : 'No further context target was available',/,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepted navigation releases through PR15-aware ownership before flight', () => {
|
||||
@@ -175,37 +234,69 @@ test('accepted navigation releases through PR15-aware ownership before flight',
|
||||
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
|
||||
'explicit navigation',
|
||||
);
|
||||
ordered(run, [
|
||||
'cockpitActive: this.isCockpitActive()',
|
||||
'stamp: () => this._stampNavigation()',
|
||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||
'navigate,',
|
||||
], 'explicit navigation');
|
||||
ordered(
|
||||
run,
|
||||
[
|
||||
'cockpitActive: this.isCockpitActive()',
|
||||
'stamp: () => this._stampNavigation()',
|
||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||
'navigate,',
|
||||
],
|
||||
'explicit navigation',
|
||||
);
|
||||
const release = body(
|
||||
ui,
|
||||
/_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||
'follow release',
|
||||
);
|
||||
ordered(release, [
|
||||
'origin: trackingOrigin',
|
||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
"interruptCameraMotion('explicit-navigation')",
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
||||
], 'follow release');
|
||||
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
||||
ordered(
|
||||
release,
|
||||
[
|
||||
'origin: trackingOrigin',
|
||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||
'this.viewer.trackedEntity = undefined;',
|
||||
"interruptCameraMotion('explicit-navigation')",
|
||||
'this.viewer.camera.cancelFlight();',
|
||||
'this.viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);',
|
||||
],
|
||||
'follow release',
|
||||
);
|
||||
assert.match(
|
||||
release,
|
||||
/flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
|
||||
);
|
||||
assert.match(
|
||||
release,
|
||||
/militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test('validated voice camera destinations share the UI navigation authority facade', () => {
|
||||
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
|
||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
|
||||
assert.match(
|
||||
ui,
|
||||
/runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/,
|
||||
);
|
||||
assert.match(
|
||||
voice,
|
||||
/runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/,
|
||||
);
|
||||
assert.match(
|
||||
voice,
|
||||
/runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/,
|
||||
);
|
||||
assert.match(
|
||||
voice,
|
||||
/runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/,
|
||||
);
|
||||
assert.match(
|
||||
voice,
|
||||
/runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/,
|
||||
);
|
||||
assert.match(
|
||||
voice,
|
||||
/runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/,
|
||||
);
|
||||
const trackedVoice = voice.slice(
|
||||
voice.indexOf('async function trackEntity'),
|
||||
voice.indexOf('async function frameOverhead'),
|
||||
@@ -222,47 +313,80 @@ test('validated voice camera destinations share the UI navigation authority faca
|
||||
/export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/,
|
||||
'move camera',
|
||||
);
|
||||
ordered(move, [
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
], 'move validation before handoff');
|
||||
ordered(
|
||||
move,
|
||||
[
|
||||
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
|
||||
'const start = () => {',
|
||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||
'runNavigation(start, { preserveCameraFlight })',
|
||||
],
|
||||
'move validation before handoff',
|
||||
);
|
||||
|
||||
const route = body(
|
||||
cameraVerbs,
|
||||
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,?\s*\) \{([\s\S]*?)\n\}/,
|
||||
'fly route',
|
||||
);
|
||||
ordered(route, [
|
||||
'if (!routes.length)',
|
||||
'const pts = route.path.map',
|
||||
'const start = () => {',
|
||||
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
|
||||
], 'route validation before handoff');
|
||||
ordered(
|
||||
route,
|
||||
[
|
||||
'if (!routes.length)',
|
||||
'const pts = route.path.map',
|
||||
'const start = () => {',
|
||||
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
|
||||
],
|
||||
'route validation before handoff',
|
||||
);
|
||||
// The corridor warm is injected the same way the floor READ is — the dolly
|
||||
// never reaches into the data layer itself, and the voice dispatch is the one
|
||||
// place that binds both.
|
||||
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
|
||||
assert.match(
|
||||
voice,
|
||||
/\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/,
|
||||
);
|
||||
});
|
||||
|
||||
test('deferred search releases only after its final authority check', () => {
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'const authority = this.begin();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
'beforeFly: () => current() && this.beforeFly(authority)',
|
||||
'if (!current() || controller.signal.aborted) return;',
|
||||
'destination?.cancelled',
|
||||
'finally',
|
||||
'this.onSettled?.(authority)',
|
||||
], 'deferred search component');
|
||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
|
||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
||||
const search = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
|
||||
'utf8',
|
||||
);
|
||||
ordered(
|
||||
search,
|
||||
[
|
||||
'const authority = this.begin();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
'beforeFly: () => current() && this.beforeFly(authority)',
|
||||
'if (!current() || controller.signal.aborted) return;',
|
||||
'destination?.cancelled',
|
||||
'finally',
|
||||
'this.onSettled?.(authority)',
|
||||
],
|
||||
'deferred search component',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/begin: \(\) => this\._beginDeferredNavigation\('location'\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/,
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
search.slice(0, search.indexOf('await this.search')),
|
||||
/_releaseFollowCamera/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a direct globe gesture retires delayed camera and selection restore only', () => {
|
||||
@@ -287,77 +411,115 @@ test('a direct globe gesture retires delayed camera and selection restore only',
|
||||
/_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||
'navigation stamp',
|
||||
);
|
||||
assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/);
|
||||
assert.match(
|
||||
stamp,
|
||||
/if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/,
|
||||
);
|
||||
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
|
||||
});
|
||||
|
||||
test('newer navigation, reset, Cockpit, and teardown share one generation', () => {
|
||||
assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1);
|
||||
const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset');
|
||||
ordered(reset, [
|
||||
'if (this._globeResetPromise) return this._globeResetPromise;',
|
||||
'this._stampNavigation();',
|
||||
"interruptCameraMotion('reset-globe')",
|
||||
], 'reset supersession');
|
||||
ordered(
|
||||
reset,
|
||||
[
|
||||
'if (this._globeResetPromise) return this._globeResetPromise;',
|
||||
'this._stampNavigation();',
|
||||
"interruptCameraMotion('reset-globe')",
|
||||
],
|
||||
'reset supersession',
|
||||
);
|
||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
ordered(dispose, [
|
||||
'this._disposed = true;',
|
||||
'this._layerBindings.stop();',
|
||||
'this._navigation.destroy();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'dispose invalidation');
|
||||
ordered(
|
||||
dispose,
|
||||
[
|
||||
'this._disposed = true;',
|
||||
'this._layerBindings.stop();',
|
||||
'this._navigation.destroy();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
],
|
||||
'dispose invalidation',
|
||||
);
|
||||
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\)/);
|
||||
});
|
||||
|
||||
test('teardown synchronously closes immediate camera entry points', () => {
|
||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||
ordered(dispose, [
|
||||
'this._disposed = true;',
|
||||
'this._layerBindings.stop();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
], 'synchronous teardown barrier');
|
||||
ordered(LayerBindings.prototype.stop.toString(), [
|
||||
'this._disposed = true;',
|
||||
'this._removeCctvRequestFocusListener?.();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'this._navigationOwnerChangedRemover?.();',
|
||||
], 'camera bindings close synchronously');
|
||||
ordered(
|
||||
dispose,
|
||||
[
|
||||
'this._disposed = true;',
|
||||
'this._layerBindings.stop();',
|
||||
'await this._contextControls.restoreForDisposal();',
|
||||
],
|
||||
'synchronous teardown barrier',
|
||||
);
|
||||
ordered(
|
||||
LayerBindings.prototype.stop.toString(),
|
||||
[
|
||||
'this._disposed = true;',
|
||||
'this._removeCctvRequestFocusListener?.();',
|
||||
'this._removeWorldRequestFocusListener?.();',
|
||||
'this._navigationOwnerChangedRemover?.();',
|
||||
],
|
||||
'camera bindings close synchronously',
|
||||
);
|
||||
|
||||
const navigation = body(
|
||||
ui,
|
||||
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
|
||||
'explicit navigation',
|
||||
);
|
||||
ordered(navigation, [
|
||||
'disposed: this._disposed',
|
||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||
'navigate,',
|
||||
], 'disposed navigation guard');
|
||||
ordered(
|
||||
navigation,
|
||||
[
|
||||
'disposed: this._disposed',
|
||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||
'navigate,',
|
||||
],
|
||||
'disposed navigation guard',
|
||||
);
|
||||
|
||||
const cctvFocus = body(
|
||||
ui,
|
||||
/_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/,
|
||||
'explicit CCTV focus',
|
||||
);
|
||||
ordered(cctvFocus, [
|
||||
'if (this._disposed) return false;',
|
||||
'const cameraId = activate();',
|
||||
], 'disposed CCTV activation guard');
|
||||
ordered(
|
||||
cctvFocus,
|
||||
['if (this._disposed) return false;', 'const cameraId = activate();'],
|
||||
'disposed CCTV activation guard',
|
||||
);
|
||||
});
|
||||
|
||||
test('teardown refuses deferred location work before geocoding begins', () => {
|
||||
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
|
||||
const deferred = body(
|
||||
ui,
|
||||
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||
'deferred navigation',
|
||||
);
|
||||
assert.match(deferred, /disposed: this\._disposed/);
|
||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
||||
ordered(search, [
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input?.blur();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
], 'disposed search refusal');
|
||||
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
|
||||
const search = fs.readFileSync(
|
||||
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
|
||||
'utf8',
|
||||
);
|
||||
ordered(
|
||||
search,
|
||||
[
|
||||
'if (!query || this.destroyed) return;',
|
||||
'const authority = this.begin();',
|
||||
'if (authority === false)',
|
||||
'this.input?.blur();',
|
||||
'this.onStart?.(authority);',
|
||||
'await this.search(query',
|
||||
],
|
||||
'disposed search refusal',
|
||||
);
|
||||
assert.match(
|
||||
search,
|
||||
/if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/,
|
||||
);
|
||||
});
|
||||
|
||||
test('refused canned destinations commit no location or POI state', () => {
|
||||
@@ -366,11 +528,15 @@ test('refused canned destinations commit no location or POI state', () => {
|
||||
['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/],
|
||||
]) {
|
||||
const handler = body(ui, pattern, name);
|
||||
ordered(handler, [
|
||||
'this._flyWithTransition(',
|
||||
'if (result === false) return;',
|
||||
'this._setActiveLocation(cityId);',
|
||||
], name);
|
||||
ordered(
|
||||
handler,
|
||||
[
|
||||
'this._flyWithTransition(',
|
||||
'if (result === false) return;',
|
||||
'this._setActiveLocation(cityId);',
|
||||
],
|
||||
name,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -392,11 +558,15 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
/inputFor\(viewer\)\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
'vessel click',
|
||||
);
|
||||
ordered(vesselClick, [
|
||||
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
|
||||
'_vesselOverlayHost.hitTest?.(',
|
||||
'selectAndFocusVessel(record)',
|
||||
], 'vessel sibling ownership');
|
||||
ordered(
|
||||
vesselClick,
|
||||
[
|
||||
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
|
||||
'_vesselOverlayHost.hitTest?.(',
|
||||
'selectAndFocusVessel(record)',
|
||||
],
|
||||
'vessel sibling ownership',
|
||||
);
|
||||
const vesselFocus = body(
|
||||
vessels,
|
||||
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
|
||||
@@ -408,9 +578,13 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
|
||||
/input\.onClick\(\(event\) => \{([\s\S]*?)\n \}\);/,
|
||||
'fire click',
|
||||
);
|
||||
ordered(fireClick, [
|
||||
'isOwnedByOtherLayer(id, pickedId)',
|
||||
'overlayHost.hitTest?.(',
|
||||
'selectAndFocusFire(carded)',
|
||||
], 'fire sibling ownership');
|
||||
ordered(
|
||||
fireClick,
|
||||
[
|
||||
'isOwnedByOtherLayer(id, pickedId)',
|
||||
'overlayHost.hitTest?.(',
|
||||
'selectAndFocusFire(carded)',
|
||||
],
|
||||
'fire sibling ownership',
|
||||
);
|
||||
});
|
||||
+18
-14
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
|
||||
/** Outer edge of the existing NVG/FLIR keyhole in normalized shader space. */
|
||||
export const KEYHOLE_OUTER_RADIUS = 1.05;
|
||||
@@ -576,30 +577,33 @@ export class CelestialRing {
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
|
||||
const camera = cameraOf(this.viewer);
|
||||
this._focusInProgress = true;
|
||||
const finishFocus = () => {
|
||||
this._focusInProgress = false;
|
||||
this.viewer.scene.requestRender?.();
|
||||
};
|
||||
this.viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
camera.flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: this.viewer.camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: finishFocus,
|
||||
onCancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: finishFocus,
|
||||
cancel: () => {
|
||||
finishFocus();
|
||||
if (!this.isGlobeFullyVisible()) this._autoDisable();
|
||||
},
|
||||
});
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
setOverlayEntries,
|
||||
} from '../overlays/worldOverlay.js';
|
||||
import { governorRequestRender } from '../renderGovernor.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import {
|
||||
BHOTE_KOSHI_LOCATOR_CREDIT,
|
||||
registerDynamicCredit,
|
||||
@@ -620,6 +621,7 @@ export function createBhoteKoshiLocatorLayer({
|
||||
requestRender('bhote-koshi-trigger-record-reveal');
|
||||
const camera = _viewer?.camera;
|
||||
if (!camera?.flyTo) return;
|
||||
const port = cameraOf(_viewer);
|
||||
const generation = _generation;
|
||||
_triggerApproachInFlight = true;
|
||||
const finish = () => {
|
||||
@@ -627,50 +629,54 @@ export function createBhoteKoshiLocatorLayer({
|
||||
attempt.phase = 'finished';
|
||||
_triggerApproachInFlight = false;
|
||||
};
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
TRIGGER_APPROACH_CAMERA.lon,
|
||||
TRIGGER_APPROACH_CAMERA.lat,
|
||||
TRIGGER_APPROACH_CAMERA.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
||||
port.flyTo(
|
||||
{
|
||||
lon: TRIGGER_APPROACH_CAMERA.lon,
|
||||
lat: TRIGGER_APPROACH_CAMERA.lat,
|
||||
height: TRIGGER_APPROACH_CAMERA.alt,
|
||||
},
|
||||
duration: TRIGGER_APPROACH_DURATION_SEC,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
||||
return;
|
||||
if (
|
||||
!_enabled ||
|
||||
generation !== _generation ||
|
||||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
||||
) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
attempt.phase = 'orbiting';
|
||||
camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
TRIGGER_ORBIT_CAMERA.lon,
|
||||
TRIGGER_ORBIT_CAMERA.lat,
|
||||
TRIGGER_ORBIT_CAMERA.alt,
|
||||
),
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
||||
},
|
||||
duration: TRIGGER_ORBIT_DURATION_SEC,
|
||||
easingFunction: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
|
||||
complete: finish,
|
||||
cancel: finish,
|
||||
});
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_APPROACH_CAMERA.roll),
|
||||
},
|
||||
durationS: TRIGGER_APPROACH_DURATION_SEC,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: () => {
|
||||
if (_triggerApproachAttempt !== attempt || attempt.phase !== 'flying')
|
||||
return;
|
||||
if (
|
||||
!_enabled ||
|
||||
generation !== _generation ||
|
||||
_presentation !== BHOTE_KOSHI_TRIGGER_RECORD_PRESENTATION
|
||||
) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
attempt.phase = 'orbiting';
|
||||
port.flyTo(
|
||||
{
|
||||
lon: TRIGGER_ORBIT_CAMERA.lon,
|
||||
lat: TRIGGER_ORBIT_CAMERA.lat,
|
||||
height: TRIGGER_ORBIT_CAMERA.alt,
|
||||
},
|
||||
{
|
||||
orientation: {
|
||||
heading: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.heading),
|
||||
pitch: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.pitch),
|
||||
roll: Cesium.Math.toRadians(TRIGGER_ORBIT_CAMERA.roll),
|
||||
},
|
||||
durationS: TRIGGER_ORBIT_DURATION_SEC,
|
||||
easing: 'sineInOut',
|
||||
onComplete: finish,
|
||||
onCancel: finish,
|
||||
},
|
||||
);
|
||||
},
|
||||
onCancel: finish,
|
||||
},
|
||||
cancel: finish,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function armTriggerApproach() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
@@ -647,6 +648,7 @@ test('Bhote Koshi trigger-record waits for the scene camera, zooms, then orbits'
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
requestRender() {},
|
||||
@@ -703,6 +705,9 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
},
|
||||
cancelFlight() {},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
// The renderer's camera port relays these; the layer's own come on top.
|
||||
const portListeners = [moveStart, moveEnd].map((e) => e.numberOfListeners);
|
||||
const layer = createBhoteKoshiLocatorLayer({
|
||||
boundaryResolver: async () => null,
|
||||
overlayHost: overlayHostFixture(),
|
||||
@@ -745,8 +750,10 @@ for (const phase of ['timer', 'move-end', 'approach', 'orbit']) {
|
||||
const oldFlights = [...flights];
|
||||
SceneDirector.prototype.stopScene.call(director);
|
||||
assert.equal(timers.size, 0, 'Stop cancels the actual delayed work');
|
||||
assert.equal(moveStart.numberOfListeners, 0);
|
||||
assert.equal(moveEnd.numberOfListeners, 0);
|
||||
assert.deepEqual(
|
||||
[moveStart, moveEnd].map((e) => e.numberOfListeners),
|
||||
portListeners,
|
||||
);
|
||||
callbacks[0]();
|
||||
moveEnd.raiseEvent();
|
||||
for (const flight of oldFlights) flight.complete();
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ import { applicationServices } from './services/application.js';
|
||||
*/
|
||||
|
||||
import { toDegrees, toRadians } from './geo/index.js';
|
||||
import { rendererOf } from './render/rendererOf.js';
|
||||
import { findCamera } from './render/rendererOf.js';
|
||||
import { greatCircleKm } from './geoDistance.js';
|
||||
import { forward as toMGRS } from 'mgrs';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
@@ -241,7 +241,7 @@ export class IntelHUD {
|
||||
basemapContext = {},
|
||||
summaryService = applicationServices.summary,
|
||||
publish = () => {},
|
||||
camera = rendererOf(viewer)?.camera,
|
||||
camera = findCamera(viewer),
|
||||
} = {},
|
||||
) {
|
||||
if (!camera) throw new Error('IntelHUD needs a camera port');
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
|
||||
import { alprMarkers } from './markerState.js';
|
||||
import { heightsFor } from '../../queries/heights.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createAlprPresentation({ state, services, source }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createFocus({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_FOCUS_RESULT } from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
translateKnown,
|
||||
} from '../../i18n/index.js';
|
||||
import { formatQuantity } from '../../ui/unitLabels.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
|
||||
Vendored
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.js';
|
||||
import { formatNumber, t, tn } from '../../i18n/index.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
|
||||
// hands back msg()-marked English plus values; getStats translates them here.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
import {
|
||||
SATELLITE_STANDALONE_DEFAULTS,
|
||||
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
import {
|
||||
REPLAY_ASCENT_FALLBACK_SEC,
|
||||
REPLAY_ASCENT_MIN_SEC,
|
||||
@@ -353,7 +354,7 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
|
||||
layerState._viewer.trackedEntity = undefined;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
cameraOf(layerState._viewer).cancelFlight();
|
||||
layerState._animationStarts.set(
|
||||
launchId,
|
||||
Date.now() +
|
||||
@@ -485,20 +486,20 @@ export function createReplay({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
});
|
||||
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(track.ascentPath[0], 0),
|
||||
cameraOf(layerState._viewer).flyToBounds(
|
||||
{ center: track.ascentPath[0], radius: 0 },
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
initialHeading,
|
||||
Cesium.Math.toRadians(-20),
|
||||
REPLAY_INITIAL_RANGE_M,
|
||||
),
|
||||
duration: 1.4,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
offset: {
|
||||
heading: initialHeading,
|
||||
pitch: Cesium.Math.toRadians(-20),
|
||||
range: REPLAY_INITIAL_RANGE_M,
|
||||
},
|
||||
durationS: 1.4,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: () => {
|
||||
cameraReady = true;
|
||||
},
|
||||
cancel: () => {
|
||||
onCancel: () => {
|
||||
if (
|
||||
token === layerState._replayCameraToken &&
|
||||
layerState._replayCameraLaunchId === launchId
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { MISSION_GLOBE_VIEW_RANGE_M, MISSION_FOCUS_RANGE_M } from './policy.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
@@ -32,20 +33,23 @@ export function createSelection({
|
||||
);
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
const camera = cameraOf(viewer);
|
||||
camera.flyTo(
|
||||
Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: viewer.camera.heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.pose().heading,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
durationS: duration,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
function entityLaunchId(entity) {
|
||||
@@ -187,11 +191,10 @@ export function createSelection({
|
||||
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination,
|
||||
cameraOf(layerState._viewer).flyTo(destination, {
|
||||
orientation: { direction, up },
|
||||
duration: 1.1,
|
||||
complete: () => {
|
||||
durationS: 1.1,
|
||||
onComplete: () => {
|
||||
if (layerState._selectedLaunchId === launch.id)
|
||||
startMissionZoomAnchor(launch, range);
|
||||
},
|
||||
@@ -202,24 +205,20 @@ export function createSelection({
|
||||
if (!layerState._viewer || !launch) return;
|
||||
parts.replay.stopMissionReplay();
|
||||
stopMissionZoomAnchor();
|
||||
const launchPosition = Cesium.Cartesian3.fromDegrees(
|
||||
launch.lon,
|
||||
launch.lat,
|
||||
);
|
||||
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
|
||||
`rocket-launch:${launch.id}`,
|
||||
);
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(launchPosition, 0),
|
||||
cameraOf(layerState._viewer).flyToBounds(
|
||||
{ center: { lon: launch.lon, lat: launch.lat }, radius: 0 },
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
0,
|
||||
Cesium.Math.toRadians(-42),
|
||||
MISSION_FOCUS_RANGE_M,
|
||||
),
|
||||
duration: 1.4,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
complete: () => {
|
||||
offset: {
|
||||
heading: 0,
|
||||
pitch: Cesium.Math.toRadians(-42),
|
||||
range: MISSION_FOCUS_RANGE_M,
|
||||
},
|
||||
durationS: 1.4,
|
||||
easing: 'cubicInOut',
|
||||
onComplete: () => {
|
||||
if (layerState._selectedLaunchId === launch.id) {
|
||||
startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
} from './model.js';
|
||||
import { msg, t } from '../../i18n/index.js';
|
||||
import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { findCamera } from '../../render/rendererOf.js';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -980,15 +980,17 @@ export function createRecentImageryLayer({
|
||||
const target = _boxError ? _refusalTarget : null;
|
||||
const camera = _viewer?.camera;
|
||||
const ellipsoid = ellipsoidOf(_viewer);
|
||||
if (!target || !camera?.flyTo || !ellipsoid?.cartographicToCartesian)
|
||||
return null;
|
||||
// No camera port: a viewer of the caller's own whose renderer did not
|
||||
// bring the camera factory (GEV's own imagery renderer does).
|
||||
const port = findCamera(_viewer);
|
||||
if (!target || !port || !ellipsoid?.cartographicToCartesian) return null;
|
||||
const canvas = _viewer.scene?.canvas;
|
||||
const height = fitViewHeightM({
|
||||
width: canvas?.clientWidth,
|
||||
height: canvas?.clientHeight,
|
||||
fovy: camera.frustum?.fovy,
|
||||
fovy: camera?.frustum?.fovy,
|
||||
});
|
||||
cameraOf(_viewer).flyTo(
|
||||
port.flyTo(
|
||||
ellipsoid.cartographicToCartesian({
|
||||
longitude: toRadians(target.lon),
|
||||
latitude: toRadians(target.lat),
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { withTileRequestHook } from '../../render/cesium/tileRequests.js';
|
||||
// The layer is Cesium-free and flies through `cameraOf`; its Cesium renderer
|
||||
// brings the factory a viewer with no renderer bound gets a camera from.
|
||||
import '../../render/cesium/camera.js';
|
||||
import { PRODUCTS, gibsTemplate } from './model.js';
|
||||
|
||||
const SLOT_IDS = ['a', 'b'];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
|
||||
import { heightsFor } from '../../queries/heights.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
/** Fly the globe camera once to frame a photo after it opens. */
|
||||
export function createCameraFraming({ state }) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cablePicks } from './pickState.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
export function createInteraction({ state, inputFor }) {
|
||||
function registerPickEntity(entity, info) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from './clock.js';
|
||||
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
|
||||
import { formatScaleLabel } from '../../ui/unitLabels.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import { cameraOf } from '../../render/cesium/camera.js';
|
||||
|
||||
/**
|
||||
* A stored (English) failure in the language. HTTP failures from source.js
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ import { cancelCameraArrival } from './data/cameraArrival.js';
|
||||
import { guardCameraAboveGround } from './cameraGroundGuard.js';
|
||||
import { CITY_POIS } from './cityPois.js';
|
||||
import { heightsFor } from './queries/heights.js';
|
||||
import { cameraOf } from './render/rendererOf.js';
|
||||
import { cameraOf } from './render/cesium/camera.js';
|
||||
|
||||
/** Newest landmark arrival; an older post-arrival ground guard yields to it. */
|
||||
let arrivalGeneration = 0;
|
||||
|
||||
@@ -121,11 +121,13 @@ export function createCesiumRendererPlugin({
|
||||
},
|
||||
destroy() {
|
||||
try {
|
||||
unbind();
|
||||
input.dispose();
|
||||
ports.dispose();
|
||||
if (!viewer.isDestroyed()) viewer.destroy();
|
||||
} finally {
|
||||
// Bound until the viewer is gone: a late `cameraOf` must not
|
||||
// build a camera of its own over the application's viewer.
|
||||
unbind();
|
||||
creditContainer.remove();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,8 +6,15 @@
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { createCameraEvents } from '../camera/events.js';
|
||||
import { listenerList, toCartesian } from './common.js';
|
||||
import { listenerList, toCartesian, uncaught } from './common.js';
|
||||
import { cameraFlightActive } from './internals.js';
|
||||
import { cameraOf, provideUnboundCamera } from '../rendererOf.js';
|
||||
|
||||
/**
|
||||
* `cameraOf`, for code that already reaches Cesium: importing it from here
|
||||
* loads the factory a viewer with no renderer bound gets its camera from.
|
||||
*/
|
||||
export { cameraOf };
|
||||
|
||||
/** The contract's easings as Cesium's. */
|
||||
export const EASINGS = Object.freeze({
|
||||
@@ -16,6 +23,7 @@ export const EASINGS = Object.freeze({
|
||||
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
|
||||
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||
sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
|
||||
});
|
||||
|
||||
/** A contract destination as Cesium's: a Cartesian3, or a Rectangle. */
|
||||
@@ -265,3 +273,19 @@ export function createCesiumCamera({ scene, camera, report }) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// A Cesium viewer of a consumer's own, with no renderer bound to it, gets a
|
||||
// camera port over its camera when code first asks `cameraOf` for one.
|
||||
provideUnboundCamera((viewer) =>
|
||||
// By shape, not `instanceof`: a consumer's viewer may come from another
|
||||
// copy of Cesium.
|
||||
typeof viewer.camera?.flyTo === 'function' &&
|
||||
typeof viewer.camera.moveStart?.addEventListener === 'function' &&
|
||||
typeof viewer.scene?.postUpdate?.addEventListener === 'function'
|
||||
? createCesiumCamera({
|
||||
scene: viewer.scene,
|
||||
camera: viewer.camera,
|
||||
report: uncaught,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
@@ -29,6 +29,7 @@ const EASINGS = {
|
||||
cubicInOut: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
cubicOut: Cesium.EasingFunction.CUBIC_OUT,
|
||||
quinticInOut: Cesium.EasingFunction.QUINTIC_IN_OUT,
|
||||
sineInOut: Cesium.EasingFunction.SINUSOIDAL_IN_OUT,
|
||||
};
|
||||
|
||||
/** One frame of the virtual clock, ms. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { FRAME_MS, headlessCamera, withVirtualClock } from './cameraGoldens.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { createCesiumPorts } from './ports.js';
|
||||
import { cameraOf } from '../rendererOf.js';
|
||||
|
||||
/** Records the headless scene cannot answer: they need the query port. */
|
||||
const QUERY_RECORDS = new Set([
|
||||
@@ -208,3 +209,26 @@ test('a flight Cesium does not start ends cancelled rather than never', async ()
|
||||
assert.equal(await flight.done, 'cancelled');
|
||||
assert.equal(port.flying, false);
|
||||
});
|
||||
|
||||
test('a Cesium viewer with no renderer bound gets a camera port over its own camera', async () => {
|
||||
await withVirtualClock(async () => {
|
||||
const { scene, camera } = headlessCamera();
|
||||
scene.postUpdate = new Cesium.Event();
|
||||
scene.requestRender = () => {};
|
||||
const viewer = { scene, camera };
|
||||
const port = cameraOf(viewer);
|
||||
assert.equal(cameraOf(viewer), port, 'one port per viewer');
|
||||
let outcome = null;
|
||||
port.flyTo(
|
||||
{ lon: 2.35, lat: 48.86, height: 1500 },
|
||||
{ durationS: 0, onComplete: () => (outcome = 'complete') },
|
||||
);
|
||||
assert.equal(outcome, 'complete');
|
||||
const pose = port.pose();
|
||||
assert.ok(
|
||||
Math.abs(pose.lon - 2.35) < 1e-9 && Math.abs(pose.lat - 48.86) < 1e-9,
|
||||
);
|
||||
assert.ok(Math.abs(pose.height - 1500) < 1e-6);
|
||||
assert.throws(() => cameraOf({ scene, camera: {} }), /no renderer camera/);
|
||||
});
|
||||
});
|
||||
@@ -50,3 +50,18 @@ export function toCartesian(pos) {
|
||||
if ('x' in pos) return new Cesium.Cartesian3(pos.x, pos.y, pos.z);
|
||||
return Cesium.Cartesian3.fromDegrees(pos.lon, pos.lat, pos.height ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a listener's throw goes with no `error` listener: the platform's
|
||||
* `reportError` (or a rethrow on a microtask), as a browser reports an event
|
||||
* listener's exception, loud, while the frame and the other listeners carry
|
||||
* on.
|
||||
*/
|
||||
export function uncaught(error) {
|
||||
if (typeof globalThis.reportError === 'function')
|
||||
globalThis.reportError(error);
|
||||
else
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { heightsFor } from '../../queries/heights.js';
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { listenerList, toCartesian } from './common.js';
|
||||
import { listenerList, toCartesian, uncaught } from './common.js';
|
||||
import {
|
||||
atEndOfTick,
|
||||
billboardLoadError,
|
||||
@@ -25,21 +25,6 @@ import {
|
||||
|
||||
export { listenerList, toCartesian };
|
||||
|
||||
/**
|
||||
* Where a listener's throw goes with no `error` listener: the platform's
|
||||
* `reportError` (or a rethrow on a microtask), as a browser reports an event
|
||||
* listener's exception, loud, while the frame and the other listeners carry
|
||||
* on.
|
||||
*/
|
||||
function uncaught(error) {
|
||||
if (typeof globalThis.reportError === 'function')
|
||||
globalThis.reportError(error);
|
||||
else
|
||||
queueMicrotask(() => {
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/** The contract's colour as Cesium's. */
|
||||
export function toColor(color, alpha = 1) {
|
||||
if (color === undefined || color === null)
|
||||
|
||||
@@ -392,6 +392,29 @@ export const CAMERA_SCRIPTS = Object.freeze([
|
||||
{ record: 'pose' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'flyTo sineInOut',
|
||||
steps: [
|
||||
START,
|
||||
{
|
||||
do: 'flyTo',
|
||||
args: [
|
||||
{ ...AUSTIN, height: 4000 },
|
||||
{
|
||||
durationS: 1,
|
||||
easing: 'sineInOut',
|
||||
orientation: { heading: 0.3, pitch: -0.5, roll: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ do: 'frames', count: 15 },
|
||||
{ record: 'pose' },
|
||||
{ do: 'frames', count: 30 },
|
||||
{ record: 'pose' },
|
||||
{ do: 'frames', count: 30 },
|
||||
{ record: 'pose' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'a cancelled flight stops where it was',
|
||||
steps: [
|
||||
|
||||
@@ -2144,6 +2144,110 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"flyTo sineInOut": {
|
||||
"records": [
|
||||
{
|
||||
"step": 3,
|
||||
"kind": "pose",
|
||||
"value": {
|
||||
"position": {
|
||||
"x": -2264202.3786493177,
|
||||
"y": -16652052.298267456,
|
||||
"z": 9785772.494992554
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.1297004555930398,
|
||||
"y": 0.9146885584650551,
|
||||
"z": -0.38278274886948277
|
||||
},
|
||||
"up": {
|
||||
"x": 0.08931821646822154,
|
||||
"y": 0.3736965407891975,
|
||||
"z": 0.9232405708205872
|
||||
},
|
||||
"right": {
|
||||
"x": 0.9875221759666432,
|
||||
"y": -0.1539341950812336,
|
||||
"z": -0.0332297390720854
|
||||
},
|
||||
"heading": 0.03852827617838983,
|
||||
"pitch": -1.433276538096362,
|
||||
"roll": 6.283185307179586,
|
||||
"height": 13074099.901429506,
|
||||
"frameIsIdentity": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"kind": "pose",
|
||||
"value": {
|
||||
"position": {
|
||||
"x": -1031979.3952742261,
|
||||
"y": -7589681.480280083,
|
||||
"z": 4448442.5808664905
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.31556045134531424,
|
||||
"y": 0.8875905643040791,
|
||||
"z": 0.33556607651118137
|
||||
},
|
||||
"up": {
|
||||
"x": 0.10445908107755925,
|
||||
"y": -0.38397902960887687,
|
||||
"z": 0.9174139770033249
|
||||
},
|
||||
"right": {
|
||||
"x": 0.9431383259772522,
|
||||
"y": -0.254446644660509,
|
||||
"z": -0.21388549061082934
|
||||
},
|
||||
"heading": 0.25036959095382905,
|
||||
"pitch": -0.6771468656798949,
|
||||
"roll": 6.283185307179586,
|
||||
"height": 2484858.7135209497,
|
||||
"frameIsIdentity": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"kind": "pose",
|
||||
"value": {
|
||||
"position": {
|
||||
"x": -743292.8811925538,
|
||||
"y": -5466539.584651379,
|
||||
"z": 3198007.239048282
|
||||
},
|
||||
"direction": {
|
||||
"x": 0.36970163659367444,
|
||||
"y": 0.7940777354746522,
|
||||
"z": 0.48245336554262613
|
||||
},
|
||||
"up": {
|
||||
"x": 0.0693712174939781,
|
||||
"y": -0.5413817815775576,
|
||||
"z": 0.8379101388331048
|
||||
},
|
||||
"right": {
|
||||
"x": 0.9265572481413991,
|
||||
"y": -0.2763083722932726,
|
||||
"z": -0.2552358699659599
|
||||
},
|
||||
"heading": 0.2999999999999998,
|
||||
"pitch": -0.5,
|
||||
"roll": 6.283185307179586,
|
||||
"height": 3999.9999999988663,
|
||||
"frameIsIdentity": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"flights": [
|
||||
{
|
||||
"step": 1,
|
||||
"outcome": "complete",
|
||||
"frames": 62
|
||||
}
|
||||
]
|
||||
},
|
||||
"a cancelled flight stops where it was": {
|
||||
"records": [
|
||||
{
|
||||
|
||||
Vendored
+6
-1
@@ -78,7 +78,12 @@ export interface ViewSpec {
|
||||
}
|
||||
|
||||
export type Easing =
|
||||
'linear' | 'quadraticInOut' | 'cubicInOut' | 'cubicOut' | 'quinticInOut';
|
||||
| 'linear'
|
||||
| 'quadraticInOut'
|
||||
| 'cubicInOut'
|
||||
| 'cubicOut'
|
||||
| 'quinticInOut'
|
||||
| 'sineInOut';
|
||||
|
||||
/**
|
||||
* A flight's omitted orientation is the same as `setView`'s. Its omitted
|
||||
|
||||
@@ -9,13 +9,36 @@
|
||||
const renderers = new WeakMap();
|
||||
|
||||
/**
|
||||
* Bind a renderer to its scene object.
|
||||
* Camera ports built for scene objects no renderer is bound to (a viewer of
|
||||
* a consumer's own, say), each `{port, dispose}` and kept for as long as its
|
||||
* scene object lives, or until a renderer is bound to it.
|
||||
*/
|
||||
const unboundCameras = new WeakMap();
|
||||
let buildUnboundCamera = null;
|
||||
|
||||
/**
|
||||
* Provide the factory `cameraOf` builds a camera port with for a scene
|
||||
* object no renderer is bound to. The renderer adapter provides it as it
|
||||
* loads; a later call replaces it.
|
||||
* @param {(host: object) => ({port: object, dispose: () => void} | null)} build
|
||||
*/
|
||||
export function provideUnboundCamera(build) {
|
||||
buildUnboundCamera = build;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a renderer to its scene object. A camera `cameraOf` built for it
|
||||
* while it had no renderer is disposed: its listeners hear nothing more,
|
||||
* and `cameraOf` answers with the renderer's camera from then on.
|
||||
* @param {object} host The scene object callers hold.
|
||||
* @param {object} renderer
|
||||
* @returns {() => void} Unbinds, if the binding is still this one.
|
||||
*/
|
||||
export function bindRenderer(host, renderer) {
|
||||
if (renderers.has(host)) throw new Error('This scene already has a renderer');
|
||||
// The renderer's camera supersedes one built while there was none.
|
||||
unboundCameras.get(host)?.dispose();
|
||||
unboundCameras.delete(host);
|
||||
renderers.set(host, renderer);
|
||||
return () => {
|
||||
if (renderers.get(host) === renderer) renderers.delete(host);
|
||||
@@ -29,13 +52,43 @@ export function bindRenderer(host, renderer) {
|
||||
export const rendererOf = (host) => (host && renderers.get(host)) || null;
|
||||
|
||||
/**
|
||||
* The camera port of the renderer bound to `host`.
|
||||
* The camera port of the renderer bound to `host`; with no renderer bound,
|
||||
* one built over `host` by the provided factory and kept with it while
|
||||
* `host` lives.
|
||||
* @param {object|null|undefined} host
|
||||
* @returns {import('./contract/camera').CameraPort | null} Null when `host`
|
||||
* has no renderer and no camera can be built for it, or is destroyed.
|
||||
*/
|
||||
export function findCamera(host) {
|
||||
const camera = rendererOf(host)?.camera;
|
||||
if (camera) return camera;
|
||||
if (!host || typeof host !== 'object') return null;
|
||||
if (host.isDestroyed?.()) {
|
||||
// Gone with its scene: let the built camera go with it.
|
||||
unboundCameras.get(host)?.dispose();
|
||||
unboundCameras.delete(host);
|
||||
return null;
|
||||
}
|
||||
let built = unboundCameras.get(host);
|
||||
if (!built && buildUnboundCamera) {
|
||||
built = buildUnboundCamera(host);
|
||||
if (built) unboundCameras.set(host, built);
|
||||
}
|
||||
return built?.port ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `findCamera`, for code that needs the camera.
|
||||
* @param {object} host
|
||||
* @returns {import('./contract/camera').CameraPort}
|
||||
* @throws When no renderer is bound to `host`.
|
||||
* @throws When there is no camera for `host`.
|
||||
*/
|
||||
export function cameraOf(host) {
|
||||
const camera = rendererOf(host)?.camera;
|
||||
if (!camera) throw new Error('This scene has no renderer camera');
|
||||
return camera;
|
||||
const camera = findCamera(host);
|
||||
if (camera) return camera;
|
||||
throw new Error(
|
||||
host?.isDestroyed?.()
|
||||
? 'This scene has been destroyed'
|
||||
: 'This scene has no renderer camera',
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { bindRenderer, cameraOf, rendererOf } from './rendererOf.js';
|
||||
import {
|
||||
bindRenderer,
|
||||
cameraOf,
|
||||
findCamera,
|
||||
provideUnboundCamera,
|
||||
rendererOf,
|
||||
} from './rendererOf.js';
|
||||
|
||||
test('a scene finds its renderer until it is unbound', () => {
|
||||
const scene = {};
|
||||
@@ -32,3 +38,52 @@ test('cameraOf names the missing renderer instead of returning nothing', () => {
|
||||
assert.equal(cameraOf(scene), camera);
|
||||
unbind();
|
||||
});
|
||||
|
||||
test('with no renderer bound, cameraOf builds one camera per scene until a renderer is bound', (t) => {
|
||||
t.after(() => provideUnboundCamera(null));
|
||||
const built = [];
|
||||
provideUnboundCamera((host) => {
|
||||
if (!host.camera) return null;
|
||||
const entry = { port: { id: built.length }, disposed: 0 };
|
||||
entry.dispose = () => (entry.disposed += 1);
|
||||
built.push(entry);
|
||||
return entry;
|
||||
});
|
||||
const viewer = { camera: {} };
|
||||
assert.equal(cameraOf(viewer), built[0].port);
|
||||
assert.equal(cameraOf(viewer), built[0].port, 'kept, not rebuilt');
|
||||
assert.equal(built.length, 1);
|
||||
assert.throws(
|
||||
() => cameraOf({}),
|
||||
/no renderer camera/,
|
||||
'nothing to build over',
|
||||
);
|
||||
assert.equal(findCamera({}), null);
|
||||
assert.equal(findCamera(null), null);
|
||||
|
||||
const camera = { pose() {} };
|
||||
const unbind = bindRenderer(viewer, { camera });
|
||||
assert.equal(
|
||||
built[0].disposed,
|
||||
1,
|
||||
'the renderer supersedes the built camera',
|
||||
);
|
||||
assert.equal(cameraOf(viewer), camera);
|
||||
unbind();
|
||||
assert.equal(cameraOf(viewer), built[1].port, 'unbound again: a fresh one');
|
||||
});
|
||||
|
||||
test('a destroyed scene gets no built camera, and lets go of the one it had', (t) => {
|
||||
t.after(() => provideUnboundCamera(null));
|
||||
let disposed = 0;
|
||||
provideUnboundCamera(() => ({ port: {}, dispose: () => (disposed += 1) }));
|
||||
let destroyed = false;
|
||||
const viewer = { isDestroyed: () => destroyed };
|
||||
cameraOf(viewer);
|
||||
destroyed = true;
|
||||
assert.throws(() => cameraOf(viewer), /destroyed/);
|
||||
assert.equal(disposed, 1);
|
||||
assert.equal(findCamera(viewer), null, 'findCamera answers null instead');
|
||||
assert.throws(() => cameraOf(viewer), /destroyed/);
|
||||
assert.equal(disposed, 1, 'once');
|
||||
});
|
||||
@@ -61,6 +61,7 @@ const EASINGS = {
|
||||
cubicInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2),
|
||||
quinticInOut: (t) => (t < 0.5 ? 16 * t ** 5 : 1 - (-2 * t + 2) ** 5 / 2),
|
||||
cubicOut: (t) => (t - 1) ** 3 + 1,
|
||||
sineInOut: (t) => 0.5 * (1 - Math.cos(Math.PI * t)),
|
||||
};
|
||||
|
||||
/** Above this height, a descending flight eases out rather than in and out. */
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* A public entry that reaches the renderer bridge (`rendererOf.js`) may hand
|
||||
* `cameraOf` a viewer of the consumer's own, with no renderer bound. The
|
||||
* bridge builds a camera port for it with the factory the Cesium adapter
|
||||
* provides as `render/cesium/camera.js` loads, so every such entry must load
|
||||
* that module too.
|
||||
*
|
||||
* The graph is every static import, re-export and literal dynamic import of
|
||||
* the entry's module and those it reaches, read by the parsers the Cesium
|
||||
* ratchet uses; bare specifiers (packages) end the walk.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parsers } from 'prettier/plugins/babel.mjs';
|
||||
import { parse as parseSfc } from 'vue/compiler-sfc';
|
||||
|
||||
const root = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const BRIDGE = 'src/render/rendererOf.js';
|
||||
const FACTORY = 'src/render/cesium/camera.js';
|
||||
|
||||
/**
|
||||
* Entries that take their Cesium code injected: the module named here is
|
||||
* GEV's Cesium side for them, and must load the factory. Given another, the
|
||||
* entry's flight finds no camera (`findCamera`) and does nothing.
|
||||
*/
|
||||
const INJECTED = {
|
||||
'./layers/recent-imagery': 'src/layers/recentImagery/rendering.js',
|
||||
};
|
||||
|
||||
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
|
||||
/** The browser module an export resolves to, or null for Node-only ones. */
|
||||
function browserTarget(declaration) {
|
||||
if (typeof declaration === 'string') return declaration;
|
||||
if (!declaration || typeof declaration !== 'object') return null;
|
||||
for (const condition of ['browser', 'import', 'default']) {
|
||||
const target = browserTarget(declaration[condition]);
|
||||
if (target) return target;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function specifiersOf(file) {
|
||||
let code = readFileSync(path.join(root, file), 'utf8');
|
||||
if (file.endsWith('.vue')) {
|
||||
const { descriptor } = parseSfc(code, { filename: file });
|
||||
code = [descriptor.script, descriptor.scriptSetup]
|
||||
.filter(Boolean)
|
||||
.map((block) => block.content)
|
||||
.join('\n');
|
||||
}
|
||||
const found = [];
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node)) return node.forEach(walk);
|
||||
if (
|
||||
(node.type === 'ImportDeclaration' ||
|
||||
node.type === 'ExportNamedDeclaration' ||
|
||||
node.type === 'ExportAllDeclaration') &&
|
||||
node.source
|
||||
)
|
||||
found.push({ specifier: node.source.value, lazy: false });
|
||||
if (
|
||||
node.type === 'ImportExpression' &&
|
||||
node.source?.type === 'StringLiteral'
|
||||
)
|
||||
found.push({ specifier: node.source.value, lazy: true });
|
||||
if (
|
||||
node.type === 'CallExpression' &&
|
||||
node.callee?.type === 'Import' &&
|
||||
node.arguments[0]?.type === 'StringLiteral'
|
||||
)
|
||||
found.push({ specifier: node.arguments[0].value, lazy: true });
|
||||
for (const key of Object.keys(node))
|
||||
if (key !== 'loc' && key !== 'start' && key !== 'end') walk(node[key]);
|
||||
};
|
||||
walk(parsers.babel.parse(code));
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A relative specifier as a repository file, or null when it is a package. */
|
||||
function resolve(from, specifier) {
|
||||
const bare = specifier.split('?')[0];
|
||||
if (!bare.startsWith('.')) return null;
|
||||
const base = path.join(path.dirname(from), bare);
|
||||
const candidates = ['', '.js', '.mjs', '.vue', '/index.js', '/index.mjs'];
|
||||
for (const suffix of candidates) {
|
||||
const full = path.join(root, base + suffix);
|
||||
if (existsSync(full) && statSync(full).isFile())
|
||||
return path.normalize(base + suffix);
|
||||
}
|
||||
// A subgraph dropped here would hide a missing factory.
|
||||
throw new Error(`${from}: cannot resolve ${specifier}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modules `entry` reaches. With `eager`, only through static imports:
|
||||
* what has loaded once the entry has, before any lazy import resolves.
|
||||
*/
|
||||
function reachable(entry, { eager = false } = {}) {
|
||||
const seen = new Set([entry]);
|
||||
const queue = [entry];
|
||||
while (queue.length) {
|
||||
const file = queue.pop();
|
||||
if (!/\.(m?js|vue)$/.test(file)) continue;
|
||||
for (const { specifier, lazy } of specifiersOf(file)) {
|
||||
if (eager && lazy) continue;
|
||||
const next = resolve(file, specifier);
|
||||
if (next && !seen.has(next)) {
|
||||
seen.add(next);
|
||||
queue.push(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}
|
||||
|
||||
test('every public entry that reaches the renderer bridge loads the unbound camera factory', () => {
|
||||
const missing = [];
|
||||
let bridged = 0;
|
||||
for (const [key, declaration] of Object.entries(pkg.exports)) {
|
||||
const target = browserTarget(declaration);
|
||||
if (typeof target !== 'string' || !target.startsWith('./src/')) continue;
|
||||
const entry = path.normalize(target.slice(2));
|
||||
const graph = reachable(entry);
|
||||
// Any path to the bridge counts, lazy or not; only an eager one loads
|
||||
// the factory in time for the first `cameraOf`.
|
||||
if (!graph.has(BRIDGE)) continue;
|
||||
bridged += 1;
|
||||
const carrier = INJECTED[key];
|
||||
const loads = (file) => reachable(file, { eager: true }).has(FACTORY);
|
||||
if (!loads(entry) && !(carrier && loads(carrier))) missing.push(key);
|
||||
}
|
||||
assert.ok(bridged > 0, 'no entry reaches the bridge: the walk is broken');
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`These entries can reach cameraOf without ${FACTORY}; import it where the entry first reaches the bridge.`,
|
||||
);
|
||||
});
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
import { toDegrees, toRadians } from './geo/index.js';
|
||||
import { rendererOf } from './render/rendererOf.js';
|
||||
import { findCamera } from './render/rendererOf.js';
|
||||
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
|
||||
import {
|
||||
migrateDetectionState,
|
||||
@@ -69,7 +69,7 @@ export class ShareLinkManager {
|
||||
isNavigationCurrent,
|
||||
cancelOwnedNavigation,
|
||||
initialShareCode = null,
|
||||
camera = rendererOf(viewer)?.camera,
|
||||
camera = findCamera(viewer),
|
||||
} = {},
|
||||
) {
|
||||
if (!camera) throw new Error('ShareLinkManager needs a camera port');
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from './cyberSonarControls.js';
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraOf } from '../render/rendererOf.js';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Camera placement, ground acquisition and motion correction for the Cockpit controller. */
|
||||
import * as Cesium from 'cesium';
|
||||
import { cameraOf } from '../render/cesium/camera.js';
|
||||
import {
|
||||
cockpitAnchorCorrectionStep,
|
||||
cockpitGroundSafeHeight,
|
||||
@@ -349,7 +350,7 @@ export function update() {
|
||||
}
|
||||
}
|
||||
|
||||
this.viewer.camera.setView({
|
||||
cameraOf(this.viewer).setView({
|
||||
destination: this.scratchCamera,
|
||||
orientation: { direction: this.scratchForward, up: this.scratchUp },
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CockpitViewController } from './cockpitController.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
const INFO = Object.freeze({
|
||||
icao24: 'abc123',
|
||||
@@ -51,6 +52,7 @@ function fixture(t) {
|
||||
},
|
||||
trackedEntityChanged: listeners(),
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const cockpit = new CockpitViewController(viewer, {
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: () => INFO, refreshTr3b() {} },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Tracking identity, entry, exit and contact navigation for the Cockpit controller. */
|
||||
import { t } from '../i18n/index.js';
|
||||
import { cameraOf } from '../render/rendererOf.js';
|
||||
import {
|
||||
formatHeadingReadout,
|
||||
normalizeHeading,
|
||||
@@ -150,7 +151,7 @@ export function enter() {
|
||||
: null;
|
||||
// Retire deferred navigation before cancelFlight can run its callbacks.
|
||||
this.onCameraTakeover?.();
|
||||
this.viewer.camera.cancelFlight();
|
||||
cameraOf(this.viewer).cancelFlight();
|
||||
this.trackedEntity = entity;
|
||||
this.trackedEntityWasShown = entity.show;
|
||||
entity.show = false;
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
* One-click camera transfer for layer-owned world targets.
|
||||
*/
|
||||
import { WGS84, toRadians } from './geo/index.js';
|
||||
import { rendererOf } from './render/rendererOf.js';
|
||||
import { findCamera } from './render/rendererOf.js';
|
||||
|
||||
export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus';
|
||||
export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9;
|
||||
@@ -69,7 +69,7 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
|
||||
|
||||
/** Fly to a world target after ownership has been released. */
|
||||
export function flyToWorldTarget(viewer, target = {}) {
|
||||
const camera = rendererOf(viewer)?.camera;
|
||||
const camera = findCamera(viewer);
|
||||
const framing = WORLD_FOCUS_FRAMING[target.kind];
|
||||
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
|
||||
const poseHeading = camera.pose().heading;
|
||||
|
||||
Reference in new issue
Block a user