Fly layer focus flights through the camera port

flyToBounds takes an easing, as flyTo does: in the contract, the Cesium
port and the reference recorder, pinned by a new conformance trace.

The flights of radio, awareness, CCTV, submarine cables, cyclones, the
launch preview, installations, ALPR, street-level framing, recent
imagery, weather and world focus now go through the renderer's camera
port. Street-level framing cancels its own flight handle rather than
whatever flight is current. worldFocus.js no longer reaches Cesium and
leaves the allow-list.

Layer pose reads stay for each layer's own slice. The local GeoJSON
flight stays on Cesium: gods-eye-view/infrastructure is used with plain
Cesium viewers that have no renderer bound.
This commit is contained in:
Sameh Khamis committed 2026-11-05 20:46:58 -08:00
1 parent f95324acb1
commit e3baeec223
31 files changed
+2349 -871

No files matched your search

+21
View File
@@ -1064,6 +1064,12 @@
"src/ui/visualPresets.js", "src/ui/visualPresets.js",
"src/ui/visualSettings.js", "src/ui/visualSettings.js",
"src/worldFocus.js", "src/worldFocus.js",
"src/geo/index.js",
"src/geo/vector.js",
"src/geo/ellipsoid.js",
"src/geo/frames.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/data/feedState.js", "src/data/feedState.js",
"src/data/layerSnapshot.js", "src/data/layerSnapshot.js",
"src/ui/aircraftDisplay.js", "src/ui/aircraftDisplay.js",
@@ -1574,6 +1580,7 @@
"modules": [ "modules": [
"src/i18n/marker.js", "src/i18n/marker.js",
"src/layers/recentImagery/index.js", "src/layers/recentImagery/index.js",
"src/render/rendererOf.js",
"src/layers/recentImagery/source.js", "src/layers/recentImagery/source.js",
"src/layers/recentImagery/model.js", "src/layers/recentImagery/model.js",
"src/layers/recentImagery/catalog.js", "src/layers/recentImagery/catalog.js",
@@ -1714,6 +1721,7 @@
"src/layers/launches/paths.js", "src/layers/launches/paths.js",
"src/layers/launches/policy.js", "src/layers/launches/policy.js",
"src/layers/launches/policyHelpers.js", "src/layers/launches/policyHelpers.js",
"src/render/rendererOf.js",
"src/layers/launches/rendering.js", "src/layers/launches/rendering.js",
"src/layers/launches/replay.js", "src/layers/launches/replay.js",
"src/layers/launches/selection.js", "src/layers/launches/selection.js",
@@ -1753,6 +1761,7 @@
"src/data/militaryInstallationData.js", "src/data/militaryInstallationData.js",
"src/data/installationNames.js", "src/data/installationNames.js",
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
"src/render/rendererOf.js",
"src/layers/installations/index.js", "src/layers/installations/index.js",
"src/layers/installations/ingestion.js", "src/layers/installations/ingestion.js",
"src/layers/installations/lifecycle.js", "src/layers/installations/lifecycle.js",
@@ -1820,6 +1829,7 @@
"src/layers/awareness/controls.js", "src/layers/awareness/controls.js",
"src/layers/awareness/dependencies.js", "src/layers/awareness/dependencies.js",
"src/layers/awareness/focus.js", "src/layers/awareness/focus.js",
"src/render/rendererOf.js",
"src/layers/awareness/history.js", "src/layers/awareness/history.js",
"src/layers/awareness/index.js", "src/layers/awareness/index.js",
"src/layers/awareness/ingestion.js", "src/layers/awareness/ingestion.js",
@@ -1986,6 +1996,7 @@
"src/geoDistance.js", "src/geoDistance.js",
"src/layers/cctv/modelSpecs.js", "src/layers/cctv/modelSpecs.js",
"src/layers/cctv/navigation.js", "src/layers/cctv/navigation.js",
"src/render/rendererOf.js",
"src/layers/cctv/policy.js", "src/layers/cctv/policy.js",
"src/layers/cctv/presentation.js", "src/layers/cctv/presentation.js",
"src/layers/cctv/projection.js", "src/layers/cctv/projection.js",
@@ -2045,6 +2056,7 @@
"src/layers/streetLevel/providers/mapillary/index.js", "src/layers/streetLevel/providers/mapillary/index.js",
"src/layers/streetLevel/viewerHost.js", "src/layers/streetLevel/viewerHost.js",
"src/layers/streetLevel/cameraFraming.js", "src/layers/streetLevel/cameraFraming.js",
"src/render/rendererOf.js",
"src/layers/streetLevel/marker.js", "src/layers/streetLevel/marker.js",
"src/layers/streetLevel/view.js", "src/layers/streetLevel/view.js",
"src/layers/streetLevel/uiState.js", "src/layers/streetLevel/uiState.js",
@@ -2120,6 +2132,7 @@
"src/layers/alpr/model.js", "src/layers/alpr/model.js",
"src/layers/alpr/policy.js", "src/layers/alpr/policy.js",
"src/layers/alpr/presentation.js", "src/layers/alpr/presentation.js",
"src/render/rendererOf.js",
"src/layers/alpr/visuals.js", "src/layers/alpr/visuals.js",
"src/layers/alpr/overlay.js", "src/layers/alpr/overlay.js",
"src/layers/alpr/markerState.js", "src/layers/alpr/markerState.js",
@@ -2179,6 +2192,7 @@
"src/layers/radio/lifecycle.js", "src/layers/radio/lifecycle.js",
"src/layers/radio/model.js", "src/layers/radio/model.js",
"src/layers/radio/navigation.js", "src/layers/radio/navigation.js",
"src/render/rendererOf.js",
"src/layers/radio/playback.js", "src/layers/radio/playback.js",
"src/layers/radio/policy.js", "src/layers/radio/policy.js",
"src/layers/radio/presentation.js", "src/layers/radio/presentation.js",
@@ -2224,6 +2238,7 @@
"src/layers/submarineCables/index.js", "src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js", "src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js", "src/layers/submarineCables/interaction.js",
"src/render/rendererOf.js",
"src/layers/submarineCables/pickState.js", "src/layers/submarineCables/pickState.js",
"src/data/entityRecords.js", "src/data/entityRecords.js",
"src/layers/submarineCables/lifecycle.js", "src/layers/submarineCables/lifecycle.js",
@@ -3821,6 +3836,12 @@
"src/ui/panelRailGeometry.js", "src/ui/panelRailGeometry.js",
"src/ui/radioTunerModel.js", "src/ui/radioTunerModel.js",
"src/worldFocus.js", "src/worldFocus.js",
"src/geo/index.js",
"src/geo/vector.js",
"src/geo/ellipsoid.js",
"src/geo/frames.js",
"src/geo/celestial.js",
"src/geo/quaternion.js",
"src/layers/flights/records.js", "src/layers/flights/records.js",
"src/layers/flights/recordPolicy.js", "src/layers/flights/recordPolicy.js",
"src/layers/flights/snapshotRenderer.js", "src/layers/flights/snapshotRenderer.js",
+9 -1
View File
@@ -3,6 +3,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import alprCamerasLayer, { import alprCamerasLayer, {
createAlprCamerasLayer, createAlprCamerasLayer,
configureAlprSource, configureAlprSource,
@@ -342,6 +343,9 @@ function cameraHarness(layer = alprCamerasLayer) {
remove() {}, remove() {},
}, },
}; };
bindFakeCesiumCamera(viewer);
// The renderer's camera port relays moveEnd; the layer's own come on top.
const portMoveEndListeners = viewer.camera.moveEnd.numberOfListeners;
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction; const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function ( Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
fn, fn,
@@ -364,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) {
layer.enable(); layer.enable();
return { return {
viewer, viewer,
portMoveEndListeners,
requests, requests,
credits, credits,
get source() { get source() {
@@ -932,7 +937,10 @@ test('two factories keep their requests, records and destruction independent', a
b.source.entities.values[0], b.source.entities.values[0],
)?.labelModel.details.includes('Source: Test directory'), )?.labelModel.details.includes('Source: Test directory'),
); );
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0); assert.equal(
a.viewer.camera.moveEnd.numberOfListeners,
a.portMoveEndListeners,
);
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0); assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
} finally { } finally {
b.restore(); b.restore();
+14 -13
View File
@@ -13,6 +13,7 @@
// //
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under // computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
// plain node:test. // plain node:test.
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
import { test } from 'node:test'; import { test } from 'node:test';
// Preserve structural pins across component qualification and formatter wrapping. // Preserve structural pins across component qualification and formatter wrapping.
function componentFunctionSource(fn) { function componentFunctionSource(fn) {
@@ -868,6 +869,7 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera',
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -899,6 +901,7 @@ test('CCTV focus reports when the camera flight starts', () => {
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -919,6 +922,7 @@ test('CCTV focus refuses camera flights while cockpit owns the view', () => {
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
const record = { const record = {
camera: { rangeM: 210, headingDeg: 41 }, camera: { rangeM: 210, headingDeg: 41 },
position: { x: 1, y: 2, z: 3 }, position: { x: 1, y: 2, z: 3 },
@@ -1397,19 +1401,16 @@ test('CCTV drag-then-release over a camera is inert, while a clean tap activates
target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) => target.addEventListener(CCTV_FOCUS_REQUEST_EVENT, (event) =>
requests.push(event.detail), requests.push(event.detail),
); );
bindCctvWorldClickGesture( bindCctvWorldClickGesture(input, () => {
input, activateCctvCameraFromWorldClick(
() => { 'atx-cam-3',
activateCctvCameraFromWorldClick( () => {
'atx-cam-3', activationCalls += 1;
() => { return CCTV_ACTIVATION_RESULT.ACTIVATED;
activationCalls += 1; },
return CCTV_ACTIVATION_RESULT.ACTIVATED; target,
}, );
target, });
);
},
);
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } }); handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
timeMs = 20; timeMs = 20;
+35 -9
View File
@@ -48,6 +48,7 @@ import {
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js'; import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
import { withEntityRecord } from './entityRecords.js'; import { withEntityRecord } from './entityRecords.js';
import { framePortFor } from '../testSupport/fakeFramePort.mjs'; import { framePortFor } from '../testSupport/fakeFramePort.mjs';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const militaryAwarenessSource = readLayerSource( const militaryAwarenessSource = readLayerSource(
new URL('./militaryAwareness.js', import.meta.url), new URL('./militaryAwareness.js', import.meta.url),
@@ -183,6 +184,7 @@ function installAwarenessRuntime({
}, },
container: new FakeElement(), container: new FakeElement(),
}; };
bindFakeCesiumCamera(viewer);
const layers = new Map( const layers = new Map(
AWARENESS_DEPENDENCIES.map((layerId) => [ AWARENESS_DEPENDENCIES.map((layerId) => [
layerId, layerId,
@@ -825,7 +827,10 @@ for (const fixture of [
let retrackCalls = 0; let retrackCalls = 0;
try { try {
const trackedEntity = withEntityRecord({}, { trackedId: `${subject.layerId}:${subject.id}` }); const trackedEntity = withEntityRecord(
{},
{ trackedId: `${subject.layerId}:${subject.id}` },
);
runtime.viewer.trackedEntity = trackedEntity; runtime.viewer.trackedEntity = trackedEntity;
replaceMethod( replaceMethod(
fixture.layer, fixture.layer,
@@ -927,7 +932,10 @@ test('Contacts activation adopts the production military tracked-subject descrip
), ),
'mutating one accessor result must not alter the tracked source position', 'mutating one accessor result must not alter the tracked source position',
); );
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: `military:${id}` },
);
replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores);
replaceMethod( replaceMethod(
flightsLayer, flightsLayer,
@@ -1032,7 +1040,10 @@ test('production military tracked-subject label falls back to registration befor
'getAllPositions id stays the identity hex', 'getAllPositions id stays the identity hex',
); );
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: `military:${id}` },
);
replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores); replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores);
replaceMethod(flightsLayer, 'trackById', () => true, restores); replaceMethod(flightsLayer, 'trackById', () => true, restores);
replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores); replaceMethod(militaryFlightsLayer, 'trackById', () => true, restores);
@@ -1104,7 +1115,10 @@ test('a cached Context subject re-reads its label when enrichment lands after se
meta, meta,
}); });
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: `flights:${id}` },
);
replaceMethod( replaceMethod(
militaryFlightsLayer, militaryFlightsLayer,
'getTrackedSubject', 'getTrackedSubject',
@@ -1247,7 +1261,10 @@ test('Contacts activation adopts the production civilian tracked-subject descrip
), ),
'mutating one accessor result must not alter the tracked source position', 'mutating one accessor result must not alter the tracked source position',
); );
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: `flights:${id}` },
);
replaceMethod( replaceMethod(
militaryFlightsLayer, militaryFlightsLayer,
'getTrackedSubject', 'getTrackedSubject',
@@ -1334,7 +1351,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after
const restores = []; const restores = [];
try { try {
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:first' }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: 'flights:first' },
);
replaceMethod( replaceMethod(
flightsLayer, flightsLayer,
'getTrackedSubject', 'getTrackedSubject',
@@ -1357,7 +1377,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after
civilianTracked = null; civilianTracked = null;
militaryTracked = awarenessSubject('military', 'latest', positions.latest); militaryTracked = awarenessSubject('military', 'latest', positions.latest);
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'military:latest' }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: 'military:latest' },
);
releaseDependencies(); releaseDependencies();
await nextTurn(); await nextTurn();
await nextTurn(); await nextTurn();
@@ -1401,7 +1424,10 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto
const focused = []; const focused = [];
try { try {
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:selected' }); runtime.viewer.trackedEntity = withEntityRecord(
{},
{ trackedId: 'flights:selected' },
);
replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores); replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores);
replaceMethod( replaceMethod(
militaryFlightsLayer, militaryFlightsLayer,
@@ -1999,7 +2025,7 @@ test('vessel entry and selection framing both use the 3 km focus radius', () =>
); );
const focusRadiusUses = const focusRadiusUses =
militaryAwarenessSource.match( militaryAwarenessSource.match(
/new\s*Cesium\.BoundingSphere\(\s*vessel\.position,\s*VESSEL_FOCUS_RADIUS_M,?\s*\)/g, /\{\s*center:\s*vessel\.position,\s*radius:\s*VESSEL_FOCUS_RADIUS_M\s*\}/g,
) || []; ) || [];
assert.equal(focusRadiusUses.length, 2); assert.equal(focusRadiusUses.length, 2);
}); });
File diff suppressed because it is too large. Load diff
+1179 -476
View File
File diff suppressed because it is too large. Load diff
+13 -8
View File
@@ -26,6 +26,7 @@ import {
import { entityRecord, readEntityRecord } from '../../data/entityRecords.js'; import { entityRecord, readEntityRecord } from '../../data/entityRecords.js';
import { alprMarkers } from './markerState.js'; import { alprMarkers } from './markerState.js';
import { heightsFor } from '../../queries/heights.js'; import { heightsFor } from '../../queries/heights.js';
import { cameraOf } from '../../render/rendererOf.js';
export function createAlprPresentation({ state, services, source }) { export function createAlprPresentation({ state, services, source }) {
const { governorRequestRender } = services.render; const { governorRequestRender } = services.render;
@@ -534,14 +535,18 @@ export function createAlprPresentation({ state, services, source }) {
validAlprGroundHeight(height) ? height : 0, validAlprGroundHeight(height) ? height : 0,
); );
if (!selectRecord(nearest.id)) return false; if (!selectRecord(nearest.id)) return false;
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), { const port = cameraOf(state.viewer);
duration: 1.2, port.flyToBounds(
offset: new Cesium.HeadingPitchRange( { center, radius: 30 },
camera.heading || 0, {
-Math.PI / 4, durationS: 1.2,
800, offset: {
), heading: port.pose().heading || 0,
}); pitch: -Math.PI / 4,
range: 800,
},
},
);
governorRequestRender('alpr-focus'); governorRequestRender('alpr-focus');
return true; return true;
} }
+7 -6
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { VESSEL_FOCUS_RADIUS_M } from './policy.js'; import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
export function createFocus({ state: layerState, services, parts, source }) { export function createFocus({ state: layerState, services, parts, source }) {
const flightsLayer = services.flights; const flightsLayer = services.flights;
@@ -45,9 +46,9 @@ export function createFocus({ state: layerState, services, parts, source }) {
) )
return true; return true;
announceNavigationAuthority('context-vessel-focus'); announceNavigationAuthority('context-vessel-focus');
layerState.viewer.camera.flyToBoundingSphere( cameraOf(layerState.viewer).flyToBounds(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), { center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M },
{ duration: 1.4 }, { durationS: 1.4 },
); );
return true; return true;
} }
@@ -180,9 +181,9 @@ export function createFocus({ state: layerState, services, parts, source }) {
announceNavigationAuthority('context-vessel-autofocus', { announceNavigationAuthority('context-vessel-autofocus', {
cancelPendingSelection: false, cancelPendingSelection: false,
}); });
layerState.viewer.camera.flyToBoundingSphere( cameraOf(layerState.viewer).flyToBounds(
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M), { center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M },
{ duration: 1.6 }, { durationS: 1.6 },
); );
return true; return true;
} }
+9 -11
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { CCTV_FOCUS_RESULT } from './policy.js'; import { CCTV_FOCUS_RESULT } from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
export function createNavigation({ export function createNavigation({
state: layerState, state: layerState,
@@ -59,19 +60,16 @@ export function createNavigation({
} }
const { camera } = record; const { camera } = record;
const range = Math.max(280, camera.rangeM * 1.18); const range = Math.max(280, camera.rangeM * 1.18);
viewer.camera.flyToBoundingSphere( cameraOf(viewer).flyToBounds(
new Cesium.BoundingSphere( { center: record.position, radius: Math.max(40, camera.rangeM * 0.36) },
record.position,
Math.max(40, camera.rangeM * 0.36),
),
{ {
offset: new Cesium.HeadingPitchRange( offset: {
parts.model.toRad(camera.headingDeg), heading: parts.model.toRad(camera.headingDeg),
parts.model.toRad(-22), pitch: parts.model.toRad(-22),
range, range,
), },
duration: Math.max(0.2, duration || 0), durationS: Math.max(0.2, duration || 0),
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}, },
); );
return CCTV_FOCUS_RESULT.FOCUSED; return CCTV_FOCUS_RESULT.FOCUSED;
+10 -6
View File
@@ -23,6 +23,7 @@ import {
translateKnown, translateKnown,
} from '../../i18n/index.js'; } from '../../i18n/index.js';
import { formatQuantity } from '../../ui/unitLabels.js'; import { formatQuantity } from '../../ui/unitLabels.js';
import { cameraOf } from '../../render/rendererOf.js';
// "10-12 15:00 UTC" never breaks across lines: no-break spaces, and a word // "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). // joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
@@ -319,12 +320,15 @@ export function createCyclonesLayer({
selectedId !== storm.id selectedId !== storm.id
) )
return; return;
return viewer.camera.flyToBoundingSphere(sphere, { cameraOf(viewer).flyToBounds(
duration: matchMedia?.('(prefers-reduced-motion: reduce)') { center: sphere.center, radius: sphere.radius },
?.matches {
? 0 durationS: matchMedia?.('(prefers-reduced-motion: reduce)')
: 1.4, ?.matches
}); ? 0
: 1.4,
},
);
}); });
} }
} }
+7 -1
View File
@@ -8,6 +8,7 @@ import {
} from '../../data/inputOwnership.js'; } from '../../data/inputOwnership.js';
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
import { fakeInput } from '../../input/testDoubles.mjs'; import { fakeInput } from '../../input/testDoubles.mjs';
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
/** Record each click subscription the layer makes, as the old handler fakes did. */ /** Record each click subscription the layer makes, as the old handler fakes did. */
function recordHandlers(viewer, handlers) { function recordHandlers(viewer, handlers) {
@@ -120,6 +121,7 @@ test('map selection yields to pointer owners and owns only its enabled handler',
}, },
}, },
}; };
bindFakeCesiumCamera(viewer);
recordHandlers(viewer, handlers); recordHandlers(viewer, handlers);
layer.init(viewer); layer.init(viewer);
layer.setRowControlsListener(() => notices++); layer.setRowControlsListener(() => notices++);
@@ -360,7 +362,10 @@ function harness(
setSelection(id) { setSelection(id) {
selection = id; selection = id;
}, },
getFocusSphere: () => ({ radius: 500000 }), getFocusSphere: () => ({
center: { x: 6378137, y: 0, z: 0 },
radius: 500000,
}),
clear() { clear() {
cleared++; cleared++;
}, },
@@ -384,6 +389,7 @@ function harness(
navigation.push({ sphere, options }), navigation.push({ sphere, options }),
}, },
}; };
bindFakeCesiumCamera(viewer);
layer.init(viewer); layer.init(viewer);
layer.attachShellServices({ layer.attachShellServices({
runNavigation: (fn) => { runNavigation: (fn) => {
+11 -10
View File
@@ -6,6 +6,7 @@ import {
ANCHOR_REFRESH_M, ANCHOR_REFRESH_M,
} from './policy.js'; } from './policy.js';
import { formatNumber, t, tn } from '../../i18n/index.js'; import { formatNumber, t, tn } from '../../i18n/index.js';
import { cameraOf } from '../../render/rendererOf.js';
// installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and // installationFeedback.js is shared with the HUD (src/loadingFeedback.js) and
// hands back msg()-marked English plus values; getStats translates them here. // hands back msg()-marked English plus values; getStats translates them here.
@@ -175,16 +176,16 @@ export function createControls({ state: layerState, services, parts, source }) {
// No camera flight without a real selection: a flight plus a stale subject // No camera flight without a real selection: a flight plus a stale subject
// reads as success to Context navigation and strands NEXT on this item. // reads as success to Context navigation and strands NEXT on this item.
if (!parts.selection.selectRecord(record.id)) return false; if (!parts.selection.selectRecord(record.id)) return false;
layerState.viewer.camera.flyToBoundingSphere( cameraOf(layerState.viewer).flyToBounds(
new Cesium.BoundingSphere( {
Cesium.Cartesian3.fromDegrees( center: {
record.longitude, lon: record.longitude,
record.latitude, lat: record.latitude,
parts.rendering.installationSurfaceHeightM(record), height: parts.rendering.installationSurfaceHeightM(record),
), },
18000, radius: 18000,
), },
{ duration: 1.4 }, { durationS: 1.4 },
); );
return true; return true;
}, },
+6 -10
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js'; import { t } from '../../i18n/index.js';
import { cameraOf } from '../../render/rendererOf.js';
import { import {
SATELLITE_STANDALONE_DEFAULTS, SATELLITE_STANDALONE_DEFAULTS,
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M, LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
@@ -228,17 +229,12 @@ export function createPolicyHelpers({
const range = parts.panel.missionHoverPreviewRange( const range = parts.panel.missionHoverPreviewRange(
layerState._viewer.camera.positionCartographic?.height, layerState._viewer.camera.positionCartographic?.height,
); );
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat); cameraOf(layerState._viewer).flyToBounds(
layerState._viewer.camera.flyToBoundingSphere( { center: { lon: launch.lon, lat: launch.lat }, radius: 0 },
new Cesium.BoundingSphere(position, 0),
{ {
offset: new Cesium.HeadingPitchRange( offset: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, range },
0, durationS: 0.8,
-Cesium.Math.PI_OVER_TWO, easing: 'cubicInOut',
range,
),
duration: 0.8,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
}, },
); );
} }
+27 -30
View File
@@ -4,6 +4,7 @@ import {
GLOBAL_RADIO_ALTITUDE_M, GLOBAL_RADIO_ALTITUDE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
} from './policy.js'; } from './policy.js';
import { cameraOf } from '../../render/rendererOf.js';
export function createNavigation({ export function createNavigation({
state: layerState, state: layerState,
@@ -229,7 +230,7 @@ export function createNavigation({
) )
return; return;
layerState._activeRadioCameraFlight = null; layerState._activeRadioCameraFlight = null;
layerState._viewer.camera.cancelFlight(); cameraOf(layerState._viewer).cancelFlight();
} }
function invalidateRadioCameraNavigation() { function invalidateRadioCameraNavigation() {
@@ -247,7 +248,7 @@ export function createNavigation({
); );
} }
function startRadioCameraFlight(navigation, options, onComplete) { function startRadioCameraFlight(navigation, flight, onComplete) {
if (!radioCameraNavigationIsCurrent(navigation)) return false; if (!radioCameraNavigationIsCurrent(navigation)) return false;
const token = ++layerState._radioCameraFlightSequence; const token = ++layerState._radioCameraFlightSequence;
layerState._activeRadioCameraFlight = { layerState._activeRadioCameraFlight = {
@@ -260,10 +261,10 @@ export function createNavigation({
if (completed && radioCameraNavigationIsCurrent(navigation)) if (completed && radioCameraNavigationIsCurrent(navigation))
onComplete?.(); onComplete?.();
}; };
layerState._viewer.camera.flyTo({ cameraOf(layerState._viewer).flyTo(flight.destination, {
...options, ...flight.options,
complete: () => finish(true), onComplete: () => finish(true),
cancel: () => finish(false), onCancel: () => finish(false),
}); });
return true; return true;
} }
@@ -280,17 +281,15 @@ export function createNavigation({
return startRadioCameraFlight( return startRadioCameraFlight(
navigation, navigation,
{ {
destination: Cesium.Cartesian3.fromDegrees( destination: { lon: plan.lon, lat: plan.lat, height: plan.height },
plan.lon, options: {
plan.lat, orientation: {
plan.height, heading: plan.heading,
), pitch: plan.pitch,
orientation: { roll: plan.roll,
heading: plan.heading, },
pitch: plan.pitch, durationS: navigation.duration,
roll: plan.roll,
}, },
duration: navigation.duration,
}, },
() => { () => {
navigation.phase = 'settled'; navigation.phase = 'settled';
@@ -340,13 +339,15 @@ export function createNavigation({
activeNavigation, activeNavigation,
{ {
destination: activeNavigation.recenterPlan.destination, destination: activeNavigation.recenterPlan.destination,
orientation: { options: {
heading: activeNavigation.cameraState.heading, orientation: {
pitch: activeNavigation.cameraState.pitch, heading: activeNavigation.cameraState.heading,
roll: activeNavigation.cameraState.roll, pitch: activeNavigation.cameraState.pitch,
roll: activeNavigation.cameraState.roll,
},
durationS: recenterDuration,
easing: 'cubicInOut',
}, },
duration: recenterDuration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
}, },
() => { () => {
activeNavigation.recentered = true; activeNavigation.recentered = true;
@@ -363,14 +364,10 @@ export function createNavigation({
if (!station || !radioCameraNavigationAllowed(layerState._viewer)) if (!station || !radioCameraNavigationAllowed(layerState._viewer))
return false; return false;
cancelActiveRadioCameraFlight(); cancelActiveRadioCameraFlight();
layerState._viewer.camera.flyTo({ cameraOf(layerState._viewer).flyTo(
destination: Cesium.Cartesian3.fromDegrees( { lon: station.lon, lat: station.lat, height: 85_000 },
station.lon, { durationS: 1.1 },
station.lat, );
85_000,
),
duration: 1.1,
});
return true; return true;
} }
return { return {
+8 -5
View File
@@ -34,6 +34,7 @@ import {
} from './model.js'; } from './model.js';
import { msg, t } from '../../i18n/index.js'; import { msg, t } from '../../i18n/index.js';
import { READOUT_WORDS, displayDay, displayMetres } from './display.js'; import { READOUT_WORDS, displayDay, displayMetres } from './display.js';
import { cameraOf } from '../../render/rendererOf.js';
/** /**
* Quiet time after the last arrow key before the focused day previews, so a * Quiet time after the last arrow key before the focused day previews, so a
@@ -987,15 +988,17 @@ export function createRecentImageryLayer({
height: canvas?.clientHeight, height: canvas?.clientHeight,
fovy: camera.frustum?.fovy, fovy: camera.frustum?.fovy,
}); });
camera.flyTo({ cameraOf(_viewer).flyTo(
destination: ellipsoid.cartographicToCartesian({ ellipsoid.cartographicToCartesian({
longitude: toRadians(target.lon), longitude: toRadians(target.lon),
latitude: toRadians(target.lat), latitude: toRadians(target.lat),
height, height,
}), }),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 }, {
duration: ZOOM_FIT_SECONDS, orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
}); durationS: ZOOM_FIT_SECONDS,
},
);
return height; return height;
}, },
+14 -8
View File
@@ -1,4 +1,5 @@
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { NO_IMAGERY_HOST } from '../../maps/imageryHost.js'; import { NO_IMAGERY_HOST } from '../../maps/imageryHost.js';
import { acquireImageryComparison } from '../../maps/imageryComparison.js'; import { acquireImageryComparison } from '../../maps/imageryComparison.js';
@@ -24,6 +25,7 @@ import {
manualTimers, manualTimers,
settle, settle,
} from './testDoubles.mjs'; } from './testDoubles.mjs';
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
const NOW = new Date('2026-09-21T15:00:00Z'); const NOW = new Date('2026-09-21T15:00:00Z');
const LINK_BOX = { const LINK_BOX = {
@@ -133,6 +135,7 @@ function fixture({
}); });
if (viirs) layer.setSources({ viirs: true }); if (viirs) layer.setSources({ viirs: true });
const viewer = { camera: {} }; const viewer = { camera: {} };
bindFakeCesiumCamera(viewer);
const reasons = []; const reasons = [];
layer.subscribe((snapshot, reason) => reasons.push(reason)); layer.subscribe((snapshot, reason) => reasons.push(reason));
layer.init(viewer); layer.init(viewer);
@@ -966,6 +969,8 @@ test('USE VIEW takes the camera rectangle and refuses a view over the cap instea
test('ZOOM IN: an oversized box or view flies top-down to its centre at the height whose view is 400 km wide', () => { test('ZOOM IN: an oversized box or view flies top-down to its centre at the height whose view is 400 km wide', () => {
const f = fixture(); const f = fixture();
const flights = []; const flights = [];
const landing = (i) =>
Cesium.Ellipsoid.WGS84.cartesianToCartographic(flights[i].destination);
const deg = (rad) => (rad * 180) / Math.PI; const deg = (rad) => (rad * 180) / Math.PI;
const rad = (value) => (value * Math.PI) / 180; const rad = (value) => (value * Math.PI) / 180;
// A 1000 × 500 canvas with a 60° horizontal FOV. // A 1000 × 500 canvas with a 60° horizontal FOV.
@@ -976,7 +981,8 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig
scene: { scene: {
canvas: { clientWidth: 1000, clientHeight: 500 }, canvas: { clientWidth: 1000, clientHeight: 500 },
ellipsoid: { ellipsoid: {
cartographicToCartesian: (c) => ({ ...c, cartesian: true }), cartographicToCartesian: (c) =>
Cesium.Ellipsoid.WGS84.cartographicToCartesian(c),
cartesianToCartographic: (c) => c && { ...c }, cartesianToCartographic: (c) => c && { ...c },
}, },
}, },
@@ -999,9 +1005,9 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig
assert.ok(Math.abs(height - expected) < 1e-6, String(height)); assert.ok(Math.abs(height - expected) < 1e-6, String(height));
assert.equal(flights.length, 1); assert.equal(flights.length, 1);
const [flight] = flights; const [flight] = flights;
assert.ok(Math.abs(deg(flight.destination.longitude) - 10) < 1e-9); assert.ok(Math.abs(deg(landing(0).longitude) - 10) < 1e-9);
assert.ok(Math.abs(deg(flight.destination.latitude) - 10) < 1e-9); assert.ok(Math.abs(deg(landing(0).latitude) - 10) < 1e-9);
assert.equal(flight.destination.height, height); assert.ok(Math.abs(landing(0).height - height) < 1e-6);
assert.deepEqual(flight.orientation, { assert.deepEqual(flight.orientation, {
heading: 0, heading: 0,
pitch: -Math.PI / 2, pitch: -Math.PI / 2,
@@ -1019,14 +1025,14 @@ test('ZOOM IN: an oversized box or view flies top-down to its centre at the heig
assert.equal(f.layer.useCurrentView(), false); assert.equal(f.layer.useCurrentView(), false);
assert.equal(f.snap().zoomToFit, true); assert.equal(f.snap().zoomToFit, true);
f.layer.zoomToFit(); f.layer.zoomToFit();
assert.ok(Math.abs(deg(flights[1].destination.longitude) + 97) < 1e-9); assert.ok(Math.abs(deg(landing(1).longitude) + 97) < 1e-9);
assert.ok(Math.abs(deg(flights[1].destination.latitude) - 31) < 1e-9); assert.ok(Math.abs(deg(landing(1).latitude) - 31) < 1e-9);
// Sky at the centre: the view rectangle's centre instead. // Sky at the centre: the view rectangle's centre instead.
centreHit = null; centreHit = null;
f.layer.useCurrentView(); f.layer.useCurrentView();
f.layer.zoomToFit(); f.layer.zoomToFit();
assert.ok(Math.abs(deg(flights[2].destination.longitude) + 98) < 1e-9); assert.ok(Math.abs(deg(landing(2).longitude) + 98) < 1e-9);
assert.ok(Math.abs(deg(flights[2].destination.latitude) - 30) < 1e-9); assert.ok(Math.abs(deg(landing(2).latitude) - 30) < 1e-9);
// The box tool's own oversized drag carries its box. // The box tool's own oversized drag carries its box.
f.layer.reportBoxRefusal('Box is 2,226 km wide · limit 1,000 km', { f.layer.reportBoxRefusal('Box is 2,226 km wide · limit 1,000 km', {
west: 0, west: 0,
+18 -16
View File
@@ -1,6 +1,7 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js'; import { plausibleSurfaceHeight, sampledSurfaceHeight } from './view.js';
import { heightsFor } from '../../queries/heights.js'; import { heightsFor } from '../../queries/heights.js';
import { cameraOf } from '../../render/rendererOf.js';
/** Fly the globe camera once to frame a photo after it opens. */ /** Fly the globe camera once to frame a photo after it opens. */
export function createCameraFraming({ state }) { export function createCameraFraming({ state }) {
@@ -49,36 +50,37 @@ export function createCameraFraming({ state }) {
return; return;
const ground = groundHeightAt(position.lon, position.lat, altitude); const ground = groundHeightAt(position.lon, position.lat, altitude);
// Owned before the call: starting it cancels the previous flight (ours // Owned before the call: starting it cancels the previous flight (ours
// included) synchronously, and a zero-length one completes at once. // included), and a zero-length one completes inside the call.
const current = {}; const current = {};
flight = current; flight = current;
const release = () => { const release = () => {
if (flight === current) flight = null; if (flight === current) flight = null;
}; };
state.viewer.camera.flyToBoundingSphere( const started = cameraOf(state.viewer).flyToBounds(
new Cesium.BoundingSphere(
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
4,
),
{ {
offset: new Cesium.HeadingPitchRange( center: { lon: position.lon, lat: position.lat, height: ground + 2 },
Cesium.Math.toRadians(bearing || 0), radius: 4,
Cesium.Math.toRadians(-32), },
140, {
), offset: {
duration: 1.6, heading: Cesium.Math.toRadians(bearing || 0),
complete: release, pitch: Cesium.Math.toRadians(-32),
cancel: release, range: 140,
},
durationS: 1.6,
onComplete: release,
onCancel: release,
}, },
); );
if (flight === current) current.handle = started;
} }
/** Stop our framing flight if it is still in the air; another feature's flight is left alone. */ /** Stop our framing flight if it is still in the air; another feature's flight is left alone. */
function cancel() { function cancel() {
if (!flight) return; if (!flight) return;
// Give up ownership first: cancelFlight delivers `cancel` synchronously. const { handle } = flight;
flight = null; flight = null;
state.viewer?.camera?.cancelFlight?.(); handle?.cancel();
} }
return { attachNavigation, begin, frame, cancel }; return { attachNavigation, begin, frame, cancel };
+3 -2
View File
@@ -1,6 +1,7 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; import test from 'node:test';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
import { createStreetLevelLayer } from './index.js'; import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
import { createMapillaryProvider } from './providers/mapillary/index.js'; import { createMapillaryProvider } from './providers/mapillary/index.js';
@@ -326,7 +327,7 @@ function fakeViewer() {
disableRootEvents: true, disableRootEvents: true,
onwheel: null, onwheel: null,
}); });
return { return bindFakeCesiumCamera({
credits, credits,
flights, flights,
scene: { scene: {
@@ -358,7 +359,7 @@ function fakeViewer() {
if (at !== -1) credits.splice(at, 1); if (at !== -1) credits.splice(at, 1);
}, },
}, },
}; });
} }
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */ /** A layer over `providers`, initialised and enabled on a stand-in viewer. */
+14 -15
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { cablePicks } from './pickState.js'; import { cablePicks } from './pickState.js';
import { cameraOf } from '../../render/rendererOf.js';
export function createInteraction({ state, inputFor }) { export function createInteraction({ state, inputFor }) {
function registerPickEntity(entity, info) { function registerPickEntity(entity, info) {
@@ -45,22 +46,20 @@ export function createInteraction({ state, inputFor }) {
function flyToReference(viewer, reference) { function flyToReference(viewer, reference) {
if (!viewer || !reference) return; if (!viewer || !reference) return;
const destination = Cesium.Cartesian3.fromDegrees( const camera = cameraOf(viewer);
reference.lon, camera.cancelFlight();
reference.lat, camera.flyTo(
6500, { lon: reference.lon, lat: reference.lat, height: 6500 },
); {
viewer.camera.cancelFlight(); orientation: {
viewer.camera.flyTo({ heading: camera.pose().heading || 0,
destination, pitch: Cesium.Math.toRadians(-52),
orientation: { roll: 0,
heading: viewer.camera.heading || 0, },
pitch: Cesium.Math.toRadians(-52), durationS: 1.35,
roll: 0, easing: 'cubicInOut',
}, },
duration: 1.35, );
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
} }
return { return {
registerPickEntity, registerPickEntity,
+18 -12
View File
@@ -7,6 +7,7 @@ import { createWeatherRendering } from './rendering.js';
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js'; import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
import { createShellCesium, createShellScene } from './shellFixture.mjs'; import { createShellCesium, createShellScene } from './shellFixture.mjs';
import { formatScaleLabel } from '../../ui/unitLabels.js'; import { formatScaleLabel } from '../../ui/unitLabels.js';
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z']; const times = ['2026-09-15T20:00:00.000Z', '2026-09-15T20:05:00.000Z'];
const base = { const base = {
@@ -58,18 +59,22 @@ async function shownLayer(id, manifest, params, view = {}) {
getDiagnostics: () => ({ time, loading: false, error: null }), getDiagnostics: () => ({ time, loading: false, error: null }),
}), }),
}); });
layer.init({ layer.init(
camera: { bindFakeCesiumCamera({
moveEnd: { addEventListener: () => () => {} }, camera: {
flyTo: ({ destination }) => flights.push(destination), moveEnd: { addEventListener: () => () => {} },
pickEllipsoid: () => flyTo: ({ destination }) => flights.push(destination),
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined, pickEllipsoid: () =>
}, view.center
scene: { ? Cesium.Cartesian3.fromDegrees(...view.center)
canvas: { clientWidth: 800, clientHeight: 600 }, : undefined,
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, },
}, scene: {
}); canvas: { clientWidth: 800, clientHeight: 600 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
}),
);
if (params) layer.setParams(params); if (params) layer.setParams(params);
layer.enable(); layer.enable();
await layer.update(); await layer.update();
@@ -638,6 +643,7 @@ function globeViewer() {
requestRender() {}, requestRender() {},
}, },
}; };
bindFakeCesiumCamera(viewer);
class Provider { class Provider {
constructor(options) { constructor(options) {
this.options = options; this.options = options;
+14 -11
View File
@@ -10,6 +10,7 @@ import {
} from './clock.js'; } from './clock.js';
import { formatUtcDayMonth, t, msg } from '../../i18n/index.js'; import { formatUtcDayMonth, t, msg } from '../../i18n/index.js';
import { formatScaleLabel } from '../../ui/unitLabels.js'; import { formatScaleLabel } from '../../ui/unitLabels.js';
import { cameraOf } from '../../render/rendererOf.js';
/** /**
* A stored (English) failure in the language. HTTP failures from source.js * A stored (English) failure in the language. HTTP failures from source.js
@@ -475,17 +476,19 @@ export function createWeatherLayer({
(lightning (lightning
? { west: 110, south: -25, east: 0, north: 80 } ? { west: 110, south: -25, east: 0, north: 80 }
: manifest.bounds); : manifest.bounds);
runNavigation(() => runNavigation(() => {
viewer.camera.flyTo({ cameraOf(viewer).flyTo(
destination: cesium.Rectangle.fromDegrees( {
b.west, rect: {
b.south, west: b.west,
b.east, south: b.south,
b.north, east: b.east,
), north: b.north,
duration: motion?.matches ? 0 : 1.4, },
}), },
); { durationS: motion?.matches ? 0 : 1.4 },
);
});
} }
if (clock && enabled) { if (clock && enabled) {
if (params.play === true) void clock.togglePlay(); if (params.play === true) void clock.togglePlay();
+1
View File
@@ -182,6 +182,7 @@ export function createCesiumCamera({ scene, camera, report }) {
o.range ?? 0, o.range ?? 0,
), ),
duration: options.durationS, duration: options.durationS,
easingFunction: options.easing && EASINGS[options.easing],
...callbacks, ...callbacks,
}, },
), ),
+1
View File
@@ -198,6 +198,7 @@ function cesiumDriver(clock, threshold) {
) )
: undefined, : undefined,
duration: options.durationS, duration: options.durationS,
easingFunction: EASINGS[options.easing],
complete, complete,
cancel, cancel,
}, },
+1 -2
View File
@@ -230,6 +230,5 @@
"src/ui/shareFollow.js", "src/ui/shareFollow.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
"src/voice/gevActions.js", "src/voice/gevActions.js",
"src/voice/pointerPick.js", "src/voice/pointerPick.js"
"src/worldFocus.js"
] ]
+21
View File
@@ -371,6 +371,27 @@ export const CAMERA_SCRIPTS = Object.freeze([
{ record: 'pose' }, { record: 'pose' },
], ],
}, },
{
name: 'flyToBounds easing',
steps: [
START,
{
do: 'flyToBounds',
args: [
{ center: { ...AUSTIN, height: 0 }, radius: 500 },
{
offset: { heading: 0.5, pitch: -0.4, range: 2500 },
durationS: 1,
easing: 'cubicInOut',
},
],
},
{ do: 'frames', count: 20 },
{ record: 'pose' },
{ do: 'frames', count: 70 },
{ record: 'pose' },
],
},
{ {
name: 'a cancelled flight stops where it was', name: 'a cancelled flight stops where it was',
steps: [ steps: [
@@ -2071,6 +2071,79 @@
} }
] ]
}, },
"flyToBounds easing": {
"records": [
{
"step": 3,
"kind": "pose",
"value": {
"position": {
"x": -2267093.6902229865,
"y": -16670141.730590489,
"z": 9795550.536889307
},
"direction": {
"x": 0.13436019637409768,
"y": 0.918216318410333,
"z": -0.37259378716678554
},
"up": {
"x": 0.11222044188344235,
"y": 0.3594849211517312,
"z": 0.9263785208477249
},
"right": {
"x": 0.9845577230684848,
"y": -0.1662810394167284,
"z": -0.05474217639712155
},
"heading": 0.06349647744048603,
"pitch": -1.4221294499846318,
"roll": 6.283185231543937,
"height": 13094845.933521682,
"frameIsIdentity": true
}
},
{
"step": 5,
"kind": "pose",
"value": {
"position": {
"x": -744171.8196286416,
"y": -5464810.027402491,
"z": 3194736.500278188
},
"direction": {
"x": 0.5377602708229207,
"y": 0.6774719395626166,
"z": 0.5018422682773198
},
"up": {
"x": 0.1010227917440539,
"y": -0.6427253771880552,
"z": 0.7594066664618568
},
"right": {
"x": 0.8370234684121447,
"y": -0.35768122766471616,
"z": -0.41407227956428677
},
"heading": 0.4998993704309962,
"pitch": -0.4003616646350623,
"roll": 6.283184711710125,
"height": 973.9627016434621,
"frameIsIdentity": true
}
}
],
"flights": [
{
"step": 1,
"outcome": "complete",
"frames": 62
}
]
},
"a cancelled flight stops where it was": { "a cancelled flight stops where it was": {
"records": [ "records": [
{ {
+2 -2
View File
@@ -210,9 +210,9 @@ export interface CameraPort {
*/ */
flyToBounds( flyToBounds(
sphere: Sphere, sphere: Sphere,
options?: { offset?: HeadingPitchRange; durationS?: number } & Pick< options?: { offset?: HeadingPitchRange } & Pick<
FlightOptions, FlightOptions,
'onComplete' | 'onCancel' 'durationS' | 'easing' | 'onComplete' | 'onCancel'
>, >,
): Flight; ): Flight;
cancelFlight(): void; cancelFlight(): void;
+3 -2
View File
@@ -1,5 +1,6 @@
/** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */ /** Shared Street Level test stand-ins: provider snapshot and provider, Mapillary source, photo viewer, Cesium viewer, ray-casting camera. */
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from './fakeCameraPort.mjs';
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js'; import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
import { import {
latToTileY, latToTileY,
@@ -190,7 +191,7 @@ export function fakeCesiumViewer({ view = false } = {}) {
}, },
}, },
); );
return { return bindFakeCesiumCamera({
credits, credits,
flights, flights,
scene: { scene: {
@@ -211,7 +212,7 @@ export function fakeCesiumViewer({ view = false } = {}) {
if (at !== -1) credits.splice(at, 1); if (at !== -1) credits.splice(at, 1);
}, },
}, },
}; });
} }
/** /**
+7 -4
View File
@@ -1,3 +1,4 @@
import * as Cesium from 'cesium';
import '../testSupport/registerDom.mjs'; import '../testSupport/registerDom.mjs';
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
@@ -15,6 +16,7 @@ import {
response, response,
settle, settle,
} from '../layers/recentImagery/testDoubles.mjs'; } from '../layers/recentImagery/testDoubles.mjs';
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
const S18 = 'S30:2026-09-18'; const S18 = 'S30:2026-09-18';
const L16 = 'L30:2026-09-16'; const L16 = 'L30:2026-09-16';
@@ -128,6 +130,7 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) {
camera: {}, camera: {},
scene: { canvas: { clientWidth: 1000 }, requestRender() {} }, scene: { canvas: { clientWidth: 1000 }, requestRender() {} },
}; };
bindFakeCesiumCamera(viewer);
layer.init(viewer); layer.init(viewer);
layer.attachMapStackController(controller); layer.attachMapStackController(controller);
let active = false; let active = false;
@@ -801,9 +804,7 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
const f = fixture(); const f = fixture();
const flights = []; const flights = [];
f.viewer.scene.canvas.clientHeight = 500; f.viewer.scene.canvas.clientHeight = 500;
f.viewer.scene.ellipsoid = { f.viewer.scene.ellipsoid = Cesium.Ellipsoid.WGS84;
cartographicToCartesian: (c) => ({ ...c }),
};
Object.assign(f.viewer.camera, { Object.assign(f.viewer.camera, {
frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) }, frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) },
flyTo: (options) => flights.push(options), flyTo: (options) => flights.push(options),
@@ -835,7 +836,9 @@ test('an oversized box offers ZOOM IN in a fixed slot at the end of the notice l
assert.equal(flights.length, 1); assert.equal(flights.length, 1);
assert.ok( assert.ok(
Math.abs( Math.abs(
flights[0].destination.height - 400_000 / (2 * Math.tan(Math.PI / 6)), Cesium.Ellipsoid.WGS84.cartesianToCartographic(flights[0].destination)
.height -
400_000 / (2 * Math.tan(Math.PI / 6)),
) < 1e-6, ) < 1e-6,
); );
// Another refusal has nothing to fit; a box that succeeds hides it again. // Another refusal has nothing to fit; a box that succeeds hides it again.
+16 -17
View File
@@ -1,7 +1,8 @@
/** /**
* One-click camera transfer for layer-owned world targets. * One-click camera transfer for layer-owned world targets.
*/ */
import * as Cesium from 'cesium'; import { WGS84, toRadians } from './geo/index.js';
import { rendererOf } from './render/rendererOf.js';
export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus'; export const WORLD_FOCUS_REQUEST_EVENT = 'gev:world-request-focus';
export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9; export const WORLD_CLICK_FOCUS_DURATION_SEC = 1.9;
@@ -26,11 +27,8 @@ export function isValidWorldFocusTarget(detail) {
// Vessel and fire targets are surface-anchored Earth positions. Merely // Vessel and fire targets are surface-anchored Earth positions. Merely
// finite coordinates near the ECEF origin cannot be flown to, and must be // finite coordinates near the ECEF origin cannot be flown to, and must be
// rejected before the camera policy releases a current follow owner. // rejected before the camera policy releases a current follow owner.
const magnitude = Cesium.Cartesian3.magnitude(position); const magnitude = Math.hypot(position.x, position.y, position.z);
return ( return Number.isFinite(magnitude) && magnitude >= WGS84.radii.z * 0.95;
Number.isFinite(magnitude) &&
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
);
} }
/** Announce a valid user-click focus request. */ /** Announce a valid user-click focus request. */
@@ -71,25 +69,26 @@ export function routeWorldFocusRequest(event, runExplicitFocus, fly) {
/** Fly to a world target after ownership has been released. */ /** Fly to a world target after ownership has been released. */
export function flyToWorldTarget(viewer, target = {}) { export function flyToWorldTarget(viewer, target = {}) {
const camera = viewer?.camera; const camera = rendererOf(viewer)?.camera;
const framing = WORLD_FOCUS_FRAMING[target.kind]; const framing = WORLD_FOCUS_FRAMING[target.kind];
if (!camera || !framing || !isValidWorldFocusTarget(target)) return false; if (!camera || !framing || !isValidWorldFocusTarget(target)) return false;
const heading = Number.isFinite(camera.heading) ? camera.heading : 0; const poseHeading = camera.pose().heading;
const heading = Number.isFinite(poseHeading) ? poseHeading : 0;
const duration = const duration =
target.durationSec > 0 target.durationSec > 0
? target.durationSec ? target.durationSec
: WORLD_CLICK_FOCUS_DURATION_SEC; : WORLD_CLICK_FOCUS_DURATION_SEC;
camera.cancelFlight?.(); camera.cancelFlight();
camera.flyToBoundingSphere( camera.flyToBounds(
new Cesium.BoundingSphere(target.position, framing.radiusM), { center: target.position, radius: framing.radiusM },
{ {
offset: new Cesium.HeadingPitchRange( offset: {
heading, heading,
Cesium.Math.toRadians(framing.pitchDeg), pitch: toRadians(framing.pitchDeg),
framing.rangeM, range: framing.rangeM,
), },
duration, durationS: duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, easing: 'cubicInOut',
}, },
); );
return true; return true;
+124 -30
View File
@@ -3,6 +3,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
import { import {
WORLD_CLICK_FOCUS_DURATION_SEC, WORLD_CLICK_FOCUS_DURATION_SEC,
WORLD_FOCUS_FRAMING, WORLD_FOCUS_FRAMING,
@@ -21,16 +22,28 @@ function stubCamera() {
return { return {
calls, calls,
heading: Cesium.Math.toRadians(45), heading: Cesium.Math.toRadians(45),
cancelFlight() { calls.cancelFlight += 1; }, cancelFlight() {
flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); }, calls.cancelFlight += 1;
},
flyToBoundingSphere(sphere, options) {
calls.flights.push({ sphere, options });
},
}; };
} }
test('a focus request carries the clicked target to any registered listener', () => { test('a focus request carries the clicked target to any registered listener', () => {
const target = new EventTarget(); const target = new EventTarget();
const seen = []; const seen = [];
const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail)); const dispose = registerWorldFocusRequestListener(target, (event) =>
assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true); seen.push(event.detail),
);
assert.equal(
requestWorldFocus(
{ kind: 'vessel', position: POSITION, id: '123' },
target,
),
true,
);
assert.equal(seen.length, 1); assert.equal(seen.length, 1);
assert.equal(seen[0].kind, 'vessel'); assert.equal(seen[0].kind, 'vessel');
assert.equal(seen[0].id, '123'); assert.equal(seen[0].id, '123');
@@ -44,7 +57,9 @@ test('a focus request carries the clicked target to any registered listener', ()
test('requests without a kind or a position are dropped, never dispatched', () => { test('requests without a kind or a position are dropped, never dispatched', () => {
const target = new EventTarget(); const target = new EventTarget();
let count = 0; let count = 0;
target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => { count += 1; }); target.addEventListener(WORLD_FOCUS_REQUEST_EVENT, () => {
count += 1;
});
assert.equal(requestWorldFocus({ position: POSITION }, target), false); assert.equal(requestWorldFocus({ position: POSITION }, target), false);
assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false); assert.equal(requestWorldFocus({ kind: 'vessel' }, target), false);
assert.equal(requestWorldFocus(null, target), false); assert.equal(requestWorldFocus(null, target), false);
@@ -74,51 +89,95 @@ test('a refusing policy (cockpit owns the camera) never reaches the flight', ()
const result = routeWorldFocusRequest( const result = routeWorldFocusRequest(
{ detail: { kind: 'vessel', id: '123', position: POSITION } }, { detail: { kind: 'vessel', id: '123', position: POSITION } },
() => false, () => false,
() => { flights += 1; }, () => {
flights += 1;
},
); );
assert.equal(result, false); assert.equal(result, false);
assert.equal(flights, 0); assert.equal(flights, 0);
}); });
test('malformed requests are inert', () => { test('malformed requests are inert', () => {
assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false); assert.equal(
assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false); routeWorldFocusRequest(
null,
() => 'x',
() => 'y',
),
false,
);
assert.equal(
routeWorldFocusRequest(
{ detail: { kind: 'vessel' } },
() => 'x',
() => 'y',
),
false,
);
}); });
// A request that cannot fly must never cost the user their tracking: the UI // A request that cannot fly must never cost the user their tracking: the UI
// policy releases the follow camera, so validation happens BEFORE it runs. // policy releases the follow camera, so validation happens BEFORE it runs.
test('an unflyable request never reaches the release policy', () => { test('an unflyable request never reaches the release policy', () => {
const unflyable = [ const unflyable = [
{ kind: 'plane', position: POSITION }, // no framing for this kind { kind: 'plane', position: POSITION }, // no framing for this kind
{ kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position { kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position
{ kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete { kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete
{ kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin { kind: 'vessel', id: '123', position: { x: 0, y: 0, z: 0 } }, // ECEF origin
{ kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth { kind: 'fire', id: 'fire-1', position: { x: 1, y: 1, z: 1 } }, // finite but inside Earth
{ kind: 'vessel', position: 'somewhere' }, { kind: 'vessel', position: 'somewhere' },
{ kind: 'vessel', position: POSITION }, // missing owner id { kind: 'vessel', position: POSITION }, // missing owner id
{ position: POSITION }, { position: POSITION },
]; ];
for (const detail of unflyable) { for (const detail of unflyable) {
assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail)); assert.equal(
isValidWorldFocusTarget(detail),
false,
JSON.stringify(detail),
);
let policyRuns = 0; let policyRuns = 0;
const result = routeWorldFocusRequest( const result = routeWorldFocusRequest(
{ detail }, { detail },
() => { policyRuns += 1; return 'released'; }, () => {
policyRuns += 1;
return 'released';
},
() => 'flew', () => 'flew',
); );
assert.equal(result, false, JSON.stringify(detail)); assert.equal(result, false, JSON.stringify(detail));
assert.equal(policyRuns, 0, `the policy must not run for ${JSON.stringify(detail)}`); assert.equal(
policyRuns,
0,
`the policy must not run for ${JSON.stringify(detail)}`,
);
// Nor may such a request be dispatched in the first place. // Nor may such a request be dispatched in the first place.
assert.equal(requestWorldFocus(detail, new EventTarget()), false); assert.equal(requestWorldFocus(detail, new EventTarget()), false);
} }
assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true); assert.equal(
assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true); isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }),
true,
);
assert.equal(
isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }),
true,
);
}); });
test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => { test('the transfer flight supersedes any flight in progress and keeps the operator heading', () => {
const camera = stubCamera(); const camera = stubCamera();
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true); assert.equal(
assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued'); flyToWorldTarget(bindFakeCesiumCamera({ camera }), {
kind: 'vessel',
id: '123',
position: POSITION,
}),
true,
);
assert.equal(
camera.calls.cancelFlight,
1,
'a prior flight must be cancelled, not queued',
);
assert.equal(camera.calls.flights.length, 1); assert.equal(camera.calls.flights.length, 1);
const { sphere, options } = camera.calls.flights[0]; const { sphere, options } = camera.calls.flights[0];
assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM); assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.vessel.radiusM);
@@ -131,12 +190,20 @@ test('the transfer flight supersedes any flight in progress and keeps the operat
test('fires frame wider than vessels — a fire is read by its surroundings', () => { test('fires frame wider than vessels — a fire is read by its surroundings', () => {
const camera = stubCamera(); const camera = stubCamera();
flyToWorldTarget({ camera }, { kind: 'fire', id: 'fire-1', position: POSITION }); flyToWorldTarget(bindFakeCesiumCamera({ camera }), {
kind: 'fire',
id: 'fire-1',
position: POSITION,
});
const { sphere, options } = camera.calls.flights[0]; const { sphere, options } = camera.calls.flights[0];
assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM); assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM);
assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM); assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM);
assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM); assert.ok(
assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM); WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM,
);
assert.ok(
WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM,
);
}); });
test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => { test('every framing is a real oblique standoff, not a nadir or an inside-out sphere', () => {
@@ -145,25 +212,52 @@ test('every framing is a real oblique standoff, not a nadir or an inside-out sph
for (const kind of kinds) { for (const kind of kinds) {
const framing = WORLD_FOCUS_FRAMING[kind]; const framing = WORLD_FOCUS_FRAMING[kind];
// Looking DOWN at the target, but obliquely — a nadir drop reads as a map. // Looking DOWN at the target, but obliquely — a nadir drop reads as a map.
assert.ok(framing.pitchDeg < 0, `${kind}: pitch must look down, got ${framing.pitchDeg}`); assert.ok(
assert.ok(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`); framing.pitchDeg < 0,
`${kind}: pitch must look down, got ${framing.pitchDeg}`,
);
assert.ok(
framing.pitchDeg > -80,
`${kind}: pitch must stay oblique, got ${framing.pitchDeg}`,
);
// Standing off outside the framed sphere, at a human-readable distance. // Standing off outside the framed sphere, at a human-readable distance.
assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`); assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`);
assert.ok( assert.ok(
framing.rangeM > framing.radiusM, framing.rangeM > framing.radiusM,
`${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`, `${kind}: range ${framing.rangeM} must stand off outside radius ${framing.radiusM}`,
); );
assert.ok(framing.rangeM <= 10000, `${kind}: range must stay close-in, got ${framing.rangeM}`); assert.ok(
framing.rangeM <= 10000,
`${kind}: range must stay close-in, got ${framing.rangeM}`,
);
} }
// Exact shipped values — a silent retune must show up as a failing test. // Exact shipped values — a silent retune must show up as a failing test.
assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 }); assert.deepEqual(
assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 }); { ...WORLD_FOCUS_FRAMING.vessel },
{ radiusM: 150, rangeM: 1200, pitchDeg: -30 },
);
assert.deepEqual(
{ ...WORLD_FOCUS_FRAMING.fire },
{ radiusM: 400, rangeM: 3000, pitchDeg: -35 },
);
}); });
test('unknown kinds and missing viewers issue no flight', () => { test('unknown kinds and missing viewers issue no flight', () => {
const camera = stubCamera(); const camera = stubCamera();
assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false); assert.equal(
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false); flyToWorldTarget(bindFakeCesiumCamera({ camera }), {
assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false); kind: 'plane',
position: POSITION,
}),
false,
);
assert.equal(
flyToWorldTarget(bindFakeCesiumCamera({ camera }), { kind: 'vessel' }),
false,
);
assert.equal(
flyToWorldTarget(null, { kind: 'vessel', position: POSITION }),
false,
);
assert.equal(camera.calls.flights.length, 0); assert.equal(camera.calls.flights.length, 0);
}); });