mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
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:
1 parent
f95324acb1
commit
e3baeec223
31 files changed
+2349
-871
No files matched your search
@@ -1064,6 +1064,12 @@
|
||||
"src/ui/visualPresets.js",
|
||||
"src/ui/visualSettings.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/layerSnapshot.js",
|
||||
"src/ui/aircraftDisplay.js",
|
||||
@@ -1574,6 +1580,7 @@
|
||||
"modules": [
|
||||
"src/i18n/marker.js",
|
||||
"src/layers/recentImagery/index.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/recentImagery/source.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
@@ -1714,6 +1721,7 @@
|
||||
"src/layers/launches/paths.js",
|
||||
"src/layers/launches/policy.js",
|
||||
"src/layers/launches/policyHelpers.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/launches/rendering.js",
|
||||
"src/layers/launches/replay.js",
|
||||
"src/layers/launches/selection.js",
|
||||
@@ -1753,6 +1761,7 @@
|
||||
"src/data/militaryInstallationData.js",
|
||||
"src/data/installationNames.js",
|
||||
"src/layers/installations/controls.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/installations/index.js",
|
||||
"src/layers/installations/ingestion.js",
|
||||
"src/layers/installations/lifecycle.js",
|
||||
@@ -1820,6 +1829,7 @@
|
||||
"src/layers/awareness/controls.js",
|
||||
"src/layers/awareness/dependencies.js",
|
||||
"src/layers/awareness/focus.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/awareness/history.js",
|
||||
"src/layers/awareness/index.js",
|
||||
"src/layers/awareness/ingestion.js",
|
||||
@@ -1986,6 +1996,7 @@
|
||||
"src/geoDistance.js",
|
||||
"src/layers/cctv/modelSpecs.js",
|
||||
"src/layers/cctv/navigation.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/cctv/policy.js",
|
||||
"src/layers/cctv/presentation.js",
|
||||
"src/layers/cctv/projection.js",
|
||||
@@ -2045,6 +2056,7 @@
|
||||
"src/layers/streetLevel/providers/mapillary/index.js",
|
||||
"src/layers/streetLevel/viewerHost.js",
|
||||
"src/layers/streetLevel/cameraFraming.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/streetLevel/marker.js",
|
||||
"src/layers/streetLevel/view.js",
|
||||
"src/layers/streetLevel/uiState.js",
|
||||
@@ -2120,6 +2132,7 @@
|
||||
"src/layers/alpr/model.js",
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/alpr/visuals.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/markerState.js",
|
||||
@@ -2179,6 +2192,7 @@
|
||||
"src/layers/radio/lifecycle.js",
|
||||
"src/layers/radio/model.js",
|
||||
"src/layers/radio/navigation.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/radio/playback.js",
|
||||
"src/layers/radio/policy.js",
|
||||
"src/layers/radio/presentation.js",
|
||||
@@ -2224,6 +2238,7 @@
|
||||
"src/layers/submarineCables/index.js",
|
||||
"src/layers/submarineCables/ingestion.js",
|
||||
"src/layers/submarineCables/interaction.js",
|
||||
"src/render/rendererOf.js",
|
||||
"src/layers/submarineCables/pickState.js",
|
||||
"src/data/entityRecords.js",
|
||||
"src/layers/submarineCables/lifecycle.js",
|
||||
@@ -3821,6 +3836,12 @@
|
||||
"src/ui/panelRailGeometry.js",
|
||||
"src/ui/radioTunerModel.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/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
@@ -342,6 +343,9 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
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;
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
||||
fn,
|
||||
@@ -364,6 +368,7 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
layer.enable();
|
||||
return {
|
||||
viewer,
|
||||
portMoveEndListeners,
|
||||
requests,
|
||||
credits,
|
||||
get source() {
|
||||
@@ -932,7 +937,10 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
b.source.entities.values[0],
|
||||
)?.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);
|
||||
} finally {
|
||||
b.restore();
|
||||
|
||||
+14
-13
@@ -13,6 +13,7 @@
|
||||
//
|
||||
// computeFrustumGeometry is PURE (no viewer, no scene queries) so it runs under
|
||||
// plain node:test.
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
import { test } from 'node:test';
|
||||
// Preserve structural pins across component qualification and formatter wrapping.
|
||||
function componentFunctionSource(fn) {
|
||||
@@ -868,6 +869,7 @@ test('CCTV focus distinguishes tracking ownership from a missing active camera',
|
||||
},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const record = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
@@ -899,6 +901,7 @@ test('CCTV focus reports when the camera flight starts', () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const record = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
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 = {
|
||||
camera: { rangeM: 210, headingDeg: 41 },
|
||||
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) =>
|
||||
requests.push(event.detail),
|
||||
);
|
||||
bindCctvWorldClickGesture(
|
||||
input,
|
||||
() => {
|
||||
activateCctvCameraFromWorldClick(
|
||||
'atx-cam-3',
|
||||
() => {
|
||||
activationCalls += 1;
|
||||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||||
},
|
||||
target,
|
||||
);
|
||||
},
|
||||
);
|
||||
bindCctvWorldClickGesture(input, () => {
|
||||
activateCctvCameraFromWorldClick(
|
||||
'atx-cam-3',
|
||||
() => {
|
||||
activationCalls += 1;
|
||||
return CCTV_ACTIVATION_RESULT.ACTIVATED;
|
||||
},
|
||||
target,
|
||||
);
|
||||
});
|
||||
|
||||
handler.fire(GESTURE_TYPES.LEFT_DOWN, { position: { x: 10, y: 10 } });
|
||||
timeMs = 20;
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
import { NAVIGATION_AUTHORITY_EVENT } from '../navigationPolicy.js';
|
||||
import { withEntityRecord } from './entityRecords.js';
|
||||
import { framePortFor } from '../testSupport/fakeFramePort.mjs';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
const militaryAwarenessSource = readLayerSource(
|
||||
new URL('./militaryAwareness.js', import.meta.url),
|
||||
@@ -183,6 +184,7 @@ function installAwarenessRuntime({
|
||||
},
|
||||
container: new FakeElement(),
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const layers = new Map(
|
||||
AWARENESS_DEPENDENCIES.map((layerId) => [
|
||||
layerId,
|
||||
@@ -825,7 +827,10 @@ for (const fixture of [
|
||||
let retrackCalls = 0;
|
||||
|
||||
try {
|
||||
const trackedEntity = withEntityRecord({}, { trackedId: `${subject.layerId}:${subject.id}` });
|
||||
const trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: `${subject.layerId}:${subject.id}` },
|
||||
);
|
||||
runtime.viewer.trackedEntity = trackedEntity;
|
||||
replaceMethod(
|
||||
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',
|
||||
);
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `military:${id}` });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: `military:${id}` },
|
||||
);
|
||||
replaceMethod(flightsLayer, 'getTrackedSubject', () => null, restores);
|
||||
replaceMethod(
|
||||
flightsLayer,
|
||||
@@ -1032,7 +1040,10 @@ test('production military tracked-subject label falls back to registration befor
|
||||
'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, '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,
|
||||
});
|
||||
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: `flights:${id}` },
|
||||
);
|
||||
replaceMethod(
|
||||
militaryFlightsLayer,
|
||||
'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',
|
||||
);
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: `flights:${id}` });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: `flights:${id}` },
|
||||
);
|
||||
replaceMethod(
|
||||
militaryFlightsLayer,
|
||||
'getTrackedSubject',
|
||||
@@ -1334,7 +1351,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after
|
||||
const restores = [];
|
||||
|
||||
try {
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:first' });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: 'flights:first' },
|
||||
);
|
||||
replaceMethod(
|
||||
flightsLayer,
|
||||
'getTrackedSubject',
|
||||
@@ -1357,7 +1377,10 @@ test('Contacts activation reconciles to a newer cross-layer tracked flight after
|
||||
|
||||
civilianTracked = null;
|
||||
militaryTracked = awarenessSubject('military', 'latest', positions.latest);
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'military:latest' });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: 'military:latest' },
|
||||
);
|
||||
releaseDependencies();
|
||||
await nextTurn();
|
||||
await nextTurn();
|
||||
@@ -1401,7 +1424,10 @@ test('tracked flight cleared during Contacts activation suppresses fallback auto
|
||||
const focused = [];
|
||||
|
||||
try {
|
||||
runtime.viewer.trackedEntity = withEntityRecord({}, { trackedId: 'flights:selected' });
|
||||
runtime.viewer.trackedEntity = withEntityRecord(
|
||||
{},
|
||||
{ trackedId: 'flights:selected' },
|
||||
);
|
||||
replaceMethod(flightsLayer, 'getTrackedSubject', () => tracked, restores);
|
||||
replaceMethod(
|
||||
militaryFlightsLayer,
|
||||
@@ -1999,7 +2025,7 @@ test('vessel entry and selection framing both use the 3 km focus radius', () =>
|
||||
);
|
||||
const focusRadiusUses =
|
||||
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);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+1179
-476
File diff suppressed because it is too large.
Load diff
@@ -26,6 +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';
|
||||
|
||||
export function createAlprPresentation({ state, services, source }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
@@ -534,14 +535,18 @@ export function createAlprPresentation({ state, services, source }) {
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
|
||||
duration: 1.2,
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
camera.heading || 0,
|
||||
-Math.PI / 4,
|
||||
800,
|
||||
),
|
||||
});
|
||||
const port = cameraOf(state.viewer);
|
||||
port.flyToBounds(
|
||||
{ center, radius: 30 },
|
||||
{
|
||||
durationS: 1.2,
|
||||
offset: {
|
||||
heading: port.pose().heading || 0,
|
||||
pitch: -Math.PI / 4,
|
||||
range: 800,
|
||||
},
|
||||
},
|
||||
);
|
||||
governorRequestRender('alpr-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { VESSEL_FOCUS_RADIUS_M } from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
|
||||
export function createFocus({ state: layerState, services, parts, source }) {
|
||||
const flightsLayer = services.flights;
|
||||
@@ -45,9 +46,9 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
)
|
||||
return true;
|
||||
announceNavigationAuthority('context-vessel-focus');
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.4 },
|
||||
cameraOf(layerState.viewer).flyToBounds(
|
||||
{ center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M },
|
||||
{ durationS: 1.4 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
@@ -180,9 +181,9 @@ export function createFocus({ state: layerState, services, parts, source }) {
|
||||
announceNavigationAuthority('context-vessel-autofocus', {
|
||||
cancelPendingSelection: false,
|
||||
});
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(vessel.position, VESSEL_FOCUS_RADIUS_M),
|
||||
{ duration: 1.6 },
|
||||
cameraOf(layerState.viewer).flyToBounds(
|
||||
{ center: vessel.position, radius: VESSEL_FOCUS_RADIUS_M },
|
||||
{ durationS: 1.6 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CCTV_FOCUS_RESULT } from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
@@ -59,19 +60,16 @@ export function createNavigation({
|
||||
}
|
||||
const { camera } = record;
|
||||
const range = Math.max(280, camera.rangeM * 1.18);
|
||||
viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
record.position,
|
||||
Math.max(40, camera.rangeM * 0.36),
|
||||
),
|
||||
cameraOf(viewer).flyToBounds(
|
||||
{ center: record.position, radius: Math.max(40, camera.rangeM * 0.36) },
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
parts.model.toRad(camera.headingDeg),
|
||||
parts.model.toRad(-22),
|
||||
offset: {
|
||||
heading: parts.model.toRad(camera.headingDeg),
|
||||
pitch: parts.model.toRad(-22),
|
||||
range,
|
||||
),
|
||||
duration: Math.max(0.2, duration || 0),
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
},
|
||||
durationS: Math.max(0.2, duration || 0),
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
);
|
||||
return CCTV_FOCUS_RESULT.FOCUSED;
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
translateKnown,
|
||||
} from '../../i18n/index.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
|
||||
// joiner after the hyphen (JetBrains Mono has no U+2011 non-breaking hyphen).
|
||||
@@ -319,12 +320,15 @@ export function createCyclonesLayer({
|
||||
selectedId !== storm.id
|
||||
)
|
||||
return;
|
||||
return viewer.camera.flyToBoundingSphere(sphere, {
|
||||
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
?.matches
|
||||
? 0
|
||||
: 1.4,
|
||||
});
|
||||
cameraOf(viewer).flyToBounds(
|
||||
{ center: sphere.center, radius: sphere.radius },
|
||||
{
|
||||
durationS: matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||
?.matches
|
||||
? 0
|
||||
: 1.4,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
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. */
|
||||
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);
|
||||
layer.init(viewer);
|
||||
layer.setRowControlsListener(() => notices++);
|
||||
@@ -360,7 +362,10 @@ function harness(
|
||||
setSelection(id) {
|
||||
selection = id;
|
||||
},
|
||||
getFocusSphere: () => ({ radius: 500000 }),
|
||||
getFocusSphere: () => ({
|
||||
center: { x: 6378137, y: 0, z: 0 },
|
||||
radius: 500000,
|
||||
}),
|
||||
clear() {
|
||||
cleared++;
|
||||
},
|
||||
@@ -384,6 +389,7 @@ function harness(
|
||||
navigation.push({ sphere, options }),
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
layer.init(viewer);
|
||||
layer.attachShellServices({
|
||||
runNavigation: (fn) => {
|
||||
|
||||
Vendored
+11
-10
@@ -6,6 +6,7 @@ import {
|
||||
ANCHOR_REFRESH_M,
|
||||
} from './policy.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
|
||||
// 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
|
||||
// reads as success to Context navigation and strands NEXT on this item.
|
||||
if (!parts.selection.selectRecord(record.id)) return false;
|
||||
layerState.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
parts.rendering.installationSurfaceHeightM(record),
|
||||
),
|
||||
18000,
|
||||
),
|
||||
{ duration: 1.4 },
|
||||
cameraOf(layerState.viewer).flyToBounds(
|
||||
{
|
||||
center: {
|
||||
lon: record.longitude,
|
||||
lat: record.latitude,
|
||||
height: parts.rendering.installationSurfaceHeightM(record),
|
||||
},
|
||||
radius: 18000,
|
||||
},
|
||||
{ durationS: 1.4 },
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
import {
|
||||
SATELLITE_STANDALONE_DEFAULTS,
|
||||
LAUNCH_PAD_ZONE_MAX_CAMERA_HEIGHT_M,
|
||||
@@ -228,17 +229,12 @@ export function createPolicyHelpers({
|
||||
const range = parts.panel.missionHoverPreviewRange(
|
||||
layerState._viewer.camera.positionCartographic?.height,
|
||||
);
|
||||
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(position, 0),
|
||||
cameraOf(layerState._viewer).flyToBounds(
|
||||
{ center: { lon: launch.lon, lat: launch.lat }, radius: 0 },
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
0,
|
||||
-Cesium.Math.PI_OVER_TWO,
|
||||
range,
|
||||
),
|
||||
duration: 0.8,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
offset: { heading: 0, pitch: -Cesium.Math.PI_OVER_TWO, range },
|
||||
durationS: 0.8,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
@@ -229,7 +230,7 @@ export function createNavigation({
|
||||
)
|
||||
return;
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
cameraOf(layerState._viewer).cancelFlight();
|
||||
}
|
||||
|
||||
function invalidateRadioCameraNavigation() {
|
||||
@@ -247,7 +248,7 @@ export function createNavigation({
|
||||
);
|
||||
}
|
||||
|
||||
function startRadioCameraFlight(navigation, options, onComplete) {
|
||||
function startRadioCameraFlight(navigation, flight, onComplete) {
|
||||
if (!radioCameraNavigationIsCurrent(navigation)) return false;
|
||||
const token = ++layerState._radioCameraFlightSequence;
|
||||
layerState._activeRadioCameraFlight = {
|
||||
@@ -260,10 +261,10 @@ export function createNavigation({
|
||||
if (completed && radioCameraNavigationIsCurrent(navigation))
|
||||
onComplete?.();
|
||||
};
|
||||
layerState._viewer.camera.flyTo({
|
||||
...options,
|
||||
complete: () => finish(true),
|
||||
cancel: () => finish(false),
|
||||
cameraOf(layerState._viewer).flyTo(flight.destination, {
|
||||
...flight.options,
|
||||
onComplete: () => finish(true),
|
||||
onCancel: () => finish(false),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -280,17 +281,15 @@ export function createNavigation({
|
||||
return startRadioCameraFlight(
|
||||
navigation,
|
||||
{
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
plan.lon,
|
||||
plan.lat,
|
||||
plan.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: plan.heading,
|
||||
pitch: plan.pitch,
|
||||
roll: plan.roll,
|
||||
destination: { lon: plan.lon, lat: plan.lat, height: plan.height },
|
||||
options: {
|
||||
orientation: {
|
||||
heading: plan.heading,
|
||||
pitch: plan.pitch,
|
||||
roll: plan.roll,
|
||||
},
|
||||
durationS: navigation.duration,
|
||||
},
|
||||
duration: navigation.duration,
|
||||
},
|
||||
() => {
|
||||
navigation.phase = 'settled';
|
||||
@@ -340,13 +339,15 @@ export function createNavigation({
|
||||
activeNavigation,
|
||||
{
|
||||
destination: activeNavigation.recenterPlan.destination,
|
||||
orientation: {
|
||||
heading: activeNavigation.cameraState.heading,
|
||||
pitch: activeNavigation.cameraState.pitch,
|
||||
roll: activeNavigation.cameraState.roll,
|
||||
options: {
|
||||
orientation: {
|
||||
heading: activeNavigation.cameraState.heading,
|
||||
pitch: activeNavigation.cameraState.pitch,
|
||||
roll: activeNavigation.cameraState.roll,
|
||||
},
|
||||
durationS: recenterDuration,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: recenterDuration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
},
|
||||
() => {
|
||||
activeNavigation.recentered = true;
|
||||
@@ -363,14 +364,10 @@ export function createNavigation({
|
||||
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
|
||||
return false;
|
||||
cancelActiveRadioCameraFlight();
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
station.lon,
|
||||
station.lat,
|
||||
85_000,
|
||||
),
|
||||
duration: 1.1,
|
||||
});
|
||||
cameraOf(layerState._viewer).flyTo(
|
||||
{ lon: station.lon, lat: station.lat, height: 85_000 },
|
||||
{ durationS: 1.1 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -34,6 +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';
|
||||
|
||||
/**
|
||||
* Quiet time after the last arrow key before the focused day previews, so a
|
||||
@@ -987,15 +988,17 @@ export function createRecentImageryLayer({
|
||||
height: canvas?.clientHeight,
|
||||
fovy: camera.frustum?.fovy,
|
||||
});
|
||||
camera.flyTo({
|
||||
destination: ellipsoid.cartographicToCartesian({
|
||||
cameraOf(_viewer).flyTo(
|
||||
ellipsoid.cartographicToCartesian({
|
||||
longitude: toRadians(target.lon),
|
||||
latitude: toRadians(target.lat),
|
||||
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;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import assert from 'node:assert/strict';
|
||||
import { NO_IMAGERY_HOST } from '../../maps/imageryHost.js';
|
||||
import { acquireImageryComparison } from '../../maps/imageryComparison.js';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
manualTimers,
|
||||
settle,
|
||||
} from './testDoubles.mjs';
|
||||
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
const NOW = new Date('2026-09-21T15:00:00Z');
|
||||
const LINK_BOX = {
|
||||
@@ -133,6 +135,7 @@ function fixture({
|
||||
});
|
||||
if (viirs) layer.setSources({ viirs: true });
|
||||
const viewer = { camera: {} };
|
||||
bindFakeCesiumCamera(viewer);
|
||||
const reasons = [];
|
||||
layer.subscribe((snapshot, reason) => reasons.push(reason));
|
||||
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', () => {
|
||||
const f = fixture();
|
||||
const flights = [];
|
||||
const landing = (i) =>
|
||||
Cesium.Ellipsoid.WGS84.cartesianToCartographic(flights[i].destination);
|
||||
const deg = (rad) => (rad * 180) / Math.PI;
|
||||
const rad = (value) => (value * Math.PI) / 180;
|
||||
// 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: {
|
||||
canvas: { clientWidth: 1000, clientHeight: 500 },
|
||||
ellipsoid: {
|
||||
cartographicToCartesian: (c) => ({ ...c, cartesian: true }),
|
||||
cartographicToCartesian: (c) =>
|
||||
Cesium.Ellipsoid.WGS84.cartographicToCartesian(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.equal(flights.length, 1);
|
||||
const [flight] = flights;
|
||||
assert.ok(Math.abs(deg(flight.destination.longitude) - 10) < 1e-9);
|
||||
assert.ok(Math.abs(deg(flight.destination.latitude) - 10) < 1e-9);
|
||||
assert.equal(flight.destination.height, height);
|
||||
assert.ok(Math.abs(deg(landing(0).longitude) - 10) < 1e-9);
|
||||
assert.ok(Math.abs(deg(landing(0).latitude) - 10) < 1e-9);
|
||||
assert.ok(Math.abs(landing(0).height - height) < 1e-6);
|
||||
assert.deepEqual(flight.orientation, {
|
||||
heading: 0,
|
||||
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.snap().zoomToFit, true);
|
||||
f.layer.zoomToFit();
|
||||
assert.ok(Math.abs(deg(flights[1].destination.longitude) + 97) < 1e-9);
|
||||
assert.ok(Math.abs(deg(flights[1].destination.latitude) - 31) < 1e-9);
|
||||
assert.ok(Math.abs(deg(landing(1).longitude) + 97) < 1e-9);
|
||||
assert.ok(Math.abs(deg(landing(1).latitude) - 31) < 1e-9);
|
||||
// Sky at the centre: the view rectangle's centre instead.
|
||||
centreHit = null;
|
||||
f.layer.useCurrentView();
|
||||
f.layer.zoomToFit();
|
||||
assert.ok(Math.abs(deg(flights[2].destination.longitude) + 98) < 1e-9);
|
||||
assert.ok(Math.abs(deg(flights[2].destination.latitude) - 30) < 1e-9);
|
||||
assert.ok(Math.abs(deg(landing(2).longitude) + 98) < 1e-9);
|
||||
assert.ok(Math.abs(deg(landing(2).latitude) - 30) < 1e-9);
|
||||
// The box tool's own oversized drag carries its box.
|
||||
f.layer.reportBoxRefusal('Box is 2,226 km wide · limit 1,000 km', {
|
||||
west: 0,
|
||||
|
||||
@@ -1,6 +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';
|
||||
|
||||
/** Fly the globe camera once to frame a photo after it opens. */
|
||||
export function createCameraFraming({ state }) {
|
||||
@@ -49,36 +50,37 @@ export function createCameraFraming({ state }) {
|
||||
return;
|
||||
const ground = groundHeightAt(position.lon, position.lat, altitude);
|
||||
// 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 = {};
|
||||
flight = current;
|
||||
const release = () => {
|
||||
if (flight === current) flight = null;
|
||||
};
|
||||
state.viewer.camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(
|
||||
Cesium.Cartesian3.fromDegrees(position.lon, position.lat, ground + 2),
|
||||
4,
|
||||
),
|
||||
const started = cameraOf(state.viewer).flyToBounds(
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
Cesium.Math.toRadians(bearing || 0),
|
||||
Cesium.Math.toRadians(-32),
|
||||
140,
|
||||
),
|
||||
duration: 1.6,
|
||||
complete: release,
|
||||
cancel: release,
|
||||
center: { lon: position.lon, lat: position.lat, height: ground + 2 },
|
||||
radius: 4,
|
||||
},
|
||||
{
|
||||
offset: {
|
||||
heading: Cesium.Math.toRadians(bearing || 0),
|
||||
pitch: Cesium.Math.toRadians(-32),
|
||||
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. */
|
||||
function cancel() {
|
||||
if (!flight) return;
|
||||
// Give up ownership first: cancelFlight delivers `cancel` synchronously.
|
||||
const { handle } = flight;
|
||||
flight = null;
|
||||
state.viewer?.camera?.cancelFlight?.();
|
||||
handle?.cancel();
|
||||
}
|
||||
|
||||
return { attachNavigation, begin, frame, cancel };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { bindFakeCesiumCamera } from '../../testSupport/fakeCameraPort.mjs';
|
||||
import { createStreetLevelLayer } from './index.js';
|
||||
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
|
||||
import { createMapillaryProvider } from './providers/mapillary/index.js';
|
||||
@@ -326,7 +327,7 @@ function fakeViewer() {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
});
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
credits,
|
||||
flights,
|
||||
scene: {
|
||||
@@ -358,7 +359,7 @@ function fakeViewer() {
|
||||
if (at !== -1) credits.splice(at, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { cablePicks } from './pickState.js';
|
||||
import { cameraOf } from '../../render/rendererOf.js';
|
||||
|
||||
export function createInteraction({ state, inputFor }) {
|
||||
function registerPickEntity(entity, info) {
|
||||
@@ -45,22 +46,20 @@ export function createInteraction({ state, inputFor }) {
|
||||
|
||||
function flyToReference(viewer, reference) {
|
||||
if (!viewer || !reference) return;
|
||||
const destination = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
6500,
|
||||
);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: viewer.camera.heading || 0,
|
||||
pitch: Cesium.Math.toRadians(-52),
|
||||
roll: 0,
|
||||
const camera = cameraOf(viewer);
|
||||
camera.cancelFlight();
|
||||
camera.flyTo(
|
||||
{ lon: reference.lon, lat: reference.lat, height: 6500 },
|
||||
{
|
||||
orientation: {
|
||||
heading: camera.pose().heading || 0,
|
||||
pitch: Cesium.Math.toRadians(-52),
|
||||
roll: 0,
|
||||
},
|
||||
durationS: 1.35,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
duration: 1.35,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
);
|
||||
}
|
||||
return {
|
||||
registerPickEntity,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { createWeatherRendering } from './rendering.js';
|
||||
import { weatherDisplay, insideExtent, escapeCreditText } from './display.js';
|
||||
import { createShellCesium, createShellScene } from './shellFixture.mjs';
|
||||
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 base = {
|
||||
@@ -58,18 +59,22 @@ async function shownLayer(id, manifest, params, view = {}) {
|
||||
getDiagnostics: () => ({ time, loading: false, error: null }),
|
||||
}),
|
||||
});
|
||||
layer.init({
|
||||
camera: {
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
flyTo: ({ destination }) => flights.push(destination),
|
||||
pickEllipsoid: () =>
|
||||
view.center ? Cesium.Cartesian3.fromDegrees(...view.center) : undefined,
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
});
|
||||
layer.init(
|
||||
bindFakeCesiumCamera({
|
||||
camera: {
|
||||
moveEnd: { addEventListener: () => () => {} },
|
||||
flyTo: ({ destination }) => flights.push(destination),
|
||||
pickEllipsoid: () =>
|
||||
view.center
|
||||
? Cesium.Cartesian3.fromDegrees(...view.center)
|
||||
: undefined,
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 800, clientHeight: 600 },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (params) layer.setParams(params);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
@@ -638,6 +643,7 @@ function globeViewer() {
|
||||
requestRender() {},
|
||||
},
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
class Provider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
|
||||
+14
-11
@@ -10,6 +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';
|
||||
|
||||
/**
|
||||
* A stored (English) failure in the language. HTTP failures from source.js
|
||||
@@ -475,17 +476,19 @@ export function createWeatherLayer({
|
||||
(lightning
|
||||
? { west: 110, south: -25, east: 0, north: 80 }
|
||||
: manifest.bounds);
|
||||
runNavigation(() =>
|
||||
viewer.camera.flyTo({
|
||||
destination: cesium.Rectangle.fromDegrees(
|
||||
b.west,
|
||||
b.south,
|
||||
b.east,
|
||||
b.north,
|
||||
),
|
||||
duration: motion?.matches ? 0 : 1.4,
|
||||
}),
|
||||
);
|
||||
runNavigation(() => {
|
||||
cameraOf(viewer).flyTo(
|
||||
{
|
||||
rect: {
|
||||
west: b.west,
|
||||
south: b.south,
|
||||
east: b.east,
|
||||
north: b.north,
|
||||
},
|
||||
},
|
||||
{ durationS: motion?.matches ? 0 : 1.4 },
|
||||
);
|
||||
});
|
||||
}
|
||||
if (clock && enabled) {
|
||||
if (params.play === true) void clock.togglePlay();
|
||||
|
||||
@@ -182,6 +182,7 @@ export function createCesiumCamera({ scene, camera, report }) {
|
||||
o.range ?? 0,
|
||||
),
|
||||
duration: options.durationS,
|
||||
easingFunction: options.easing && EASINGS[options.easing],
|
||||
...callbacks,
|
||||
},
|
||||
),
|
||||
|
||||
@@ -198,6 +198,7 @@ function cesiumDriver(clock, threshold) {
|
||||
)
|
||||
: undefined,
|
||||
duration: options.durationS,
|
||||
easingFunction: EASINGS[options.easing],
|
||||
complete,
|
||||
cancel,
|
||||
},
|
||||
|
||||
@@ -230,6 +230,5 @@
|
||||
"src/ui/shareFollow.js",
|
||||
"src/ui/visualEffects.js",
|
||||
"src/voice/gevActions.js",
|
||||
"src/voice/pointerPick.js",
|
||||
"src/worldFocus.js"
|
||||
"src/voice/pointerPick.js"
|
||||
]
|
||||
@@ -371,6 +371,27 @@ export const CAMERA_SCRIPTS = Object.freeze([
|
||||
{ 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',
|
||||
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": {
|
||||
"records": [
|
||||
{
|
||||
|
||||
Vendored
+2
-2
@@ -210,9 +210,9 @@ export interface CameraPort {
|
||||
*/
|
||||
flyToBounds(
|
||||
sphere: Sphere,
|
||||
options?: { offset?: HeadingPitchRange; durationS?: number } & Pick<
|
||||
options?: { offset?: HeadingPitchRange } & Pick<
|
||||
FlightOptions,
|
||||
'onComplete' | 'onCancel'
|
||||
'durationS' | 'easing' | 'onComplete' | 'onCancel'
|
||||
>,
|
||||
): Flight;
|
||||
cancelFlight(): void;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 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 { bindFakeCesiumCamera } from './fakeCameraPort.mjs';
|
||||
import { MAPILLARY_CREDIT_HTML } from '../layers/streetLevel/providers/mapillary/policy.js';
|
||||
import {
|
||||
latToTileY,
|
||||
@@ -190,7 +191,7 @@ export function fakeCesiumViewer({ view = false } = {}) {
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
return bindFakeCesiumCamera({
|
||||
credits,
|
||||
flights,
|
||||
scene: {
|
||||
@@ -211,7 +212,7 @@ export function fakeCesiumViewer({ view = false } = {}) {
|
||||
if (at !== -1) credits.splice(at, 1);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
response,
|
||||
settle,
|
||||
} from '../layers/recentImagery/testDoubles.mjs';
|
||||
import { bindFakeCesiumCamera } from '../testSupport/fakeCameraPort.mjs';
|
||||
|
||||
const S18 = 'S30:2026-09-18';
|
||||
const L16 = 'L30:2026-09-16';
|
||||
@@ -128,6 +130,7 @@ function fixture({ fetchImpl, controller = fakeController() } = {}) {
|
||||
camera: {},
|
||||
scene: { canvas: { clientWidth: 1000 }, requestRender() {} },
|
||||
};
|
||||
bindFakeCesiumCamera(viewer);
|
||||
layer.init(viewer);
|
||||
layer.attachMapStackController(controller);
|
||||
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 flights = [];
|
||||
f.viewer.scene.canvas.clientHeight = 500;
|
||||
f.viewer.scene.ellipsoid = {
|
||||
cartographicToCartesian: (c) => ({ ...c }),
|
||||
};
|
||||
f.viewer.scene.ellipsoid = Cesium.Ellipsoid.WGS84;
|
||||
Object.assign(f.viewer.camera, {
|
||||
frustum: { fovy: 2 * Math.atan(Math.tan(Math.PI / 6) / 2) },
|
||||
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.ok(
|
||||
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,
|
||||
);
|
||||
// Another refusal has nothing to fit; a box that succeeds hides it again.
|
||||
|
||||
+16
-17
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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_CLICK_FOCUS_DURATION_SEC = 1.9;
|
||||
@@ -26,11 +27,8 @@ export function isValidWorldFocusTarget(detail) {
|
||||
// Vessel and fire targets are surface-anchored Earth positions. Merely
|
||||
// finite coordinates near the ECEF origin cannot be flown to, and must be
|
||||
// rejected before the camera policy releases a current follow owner.
|
||||
const magnitude = Cesium.Cartesian3.magnitude(position);
|
||||
return (
|
||||
Number.isFinite(magnitude) &&
|
||||
magnitude >= Cesium.Ellipsoid.WGS84.minimumRadius * 0.95
|
||||
);
|
||||
const magnitude = Math.hypot(position.x, position.y, position.z);
|
||||
return Number.isFinite(magnitude) && magnitude >= WGS84.radii.z * 0.95;
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
export function flyToWorldTarget(viewer, target = {}) {
|
||||
const camera = viewer?.camera;
|
||||
const camera = rendererOf(viewer)?.camera;
|
||||
const framing = WORLD_FOCUS_FRAMING[target.kind];
|
||||
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 =
|
||||
target.durationSec > 0
|
||||
? target.durationSec
|
||||
: WORLD_CLICK_FOCUS_DURATION_SEC;
|
||||
camera.cancelFlight?.();
|
||||
camera.flyToBoundingSphere(
|
||||
new Cesium.BoundingSphere(target.position, framing.radiusM),
|
||||
camera.cancelFlight();
|
||||
camera.flyToBounds(
|
||||
{ center: target.position, radius: framing.radiusM },
|
||||
{
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
offset: {
|
||||
heading,
|
||||
Cesium.Math.toRadians(framing.pitchDeg),
|
||||
framing.rangeM,
|
||||
),
|
||||
duration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
pitch: toRadians(framing.pitchDeg),
|
||||
range: framing.rangeM,
|
||||
},
|
||||
durationS: duration,
|
||||
easing: 'cubicInOut',
|
||||
},
|
||||
);
|
||||
return true;
|
||||
|
||||
+124
-30
@@ -3,6 +3,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { bindFakeCesiumCamera } from './testSupport/fakeCameraPort.mjs';
|
||||
import {
|
||||
WORLD_CLICK_FOCUS_DURATION_SEC,
|
||||
WORLD_FOCUS_FRAMING,
|
||||
@@ -21,16 +22,28 @@ function stubCamera() {
|
||||
return {
|
||||
calls,
|
||||
heading: Cesium.Math.toRadians(45),
|
||||
cancelFlight() { calls.cancelFlight += 1; },
|
||||
flyToBoundingSphere(sphere, options) { calls.flights.push({ sphere, options }); },
|
||||
cancelFlight() {
|
||||
calls.cancelFlight += 1;
|
||||
},
|
||||
flyToBoundingSphere(sphere, options) {
|
||||
calls.flights.push({ sphere, options });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a focus request carries the clicked target to any registered listener', () => {
|
||||
const target = new EventTarget();
|
||||
const seen = [];
|
||||
const dispose = registerWorldFocusRequestListener(target, (event) => seen.push(event.detail));
|
||||
assert.equal(requestWorldFocus({ kind: 'vessel', position: POSITION, id: '123' }, target), true);
|
||||
const dispose = registerWorldFocusRequestListener(target, (event) =>
|
||||
seen.push(event.detail),
|
||||
);
|
||||
assert.equal(
|
||||
requestWorldFocus(
|
||||
{ kind: 'vessel', position: POSITION, id: '123' },
|
||||
target,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(seen.length, 1);
|
||||
assert.equal(seen[0].kind, 'vessel');
|
||||
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', () => {
|
||||
const target = new EventTarget();
|
||||
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({ kind: 'vessel' }, 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(
|
||||
{ detail: { kind: 'vessel', id: '123', position: POSITION } },
|
||||
() => false,
|
||||
() => { flights += 1; },
|
||||
() => {
|
||||
flights += 1;
|
||||
},
|
||||
);
|
||||
assert.equal(result, false);
|
||||
assert.equal(flights, 0);
|
||||
});
|
||||
|
||||
test('malformed requests are inert', () => {
|
||||
assert.equal(routeWorldFocusRequest(null, () => 'x', () => 'y'), false);
|
||||
assert.equal(routeWorldFocusRequest({ detail: { kind: 'vessel' } }, () => 'x', () => 'y'), false);
|
||||
assert.equal(
|
||||
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
|
||||
// policy releases the follow camera, so validation happens BEFORE it runs.
|
||||
test('an unflyable request never reaches the release policy', () => {
|
||||
const unflyable = [
|
||||
{ kind: 'plane', position: POSITION }, // no framing for this kind
|
||||
{ kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position
|
||||
{ kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete
|
||||
{ kind: 'plane', position: POSITION }, // no framing for this kind
|
||||
{ kind: 'vessel', position: { x: NaN, y: 0, z: 0 } }, // malformed position
|
||||
{ kind: 'vessel', position: { x: 0, y: 0 } }, // truthy but incomplete
|
||||
{ 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: 'vessel', position: 'somewhere' },
|
||||
{ kind: 'vessel', position: POSITION }, // missing owner id
|
||||
{ kind: 'vessel', position: POSITION }, // missing owner id
|
||||
{ position: POSITION },
|
||||
];
|
||||
for (const detail of unflyable) {
|
||||
assert.equal(isValidWorldFocusTarget(detail), false, JSON.stringify(detail));
|
||||
assert.equal(
|
||||
isValidWorldFocusTarget(detail),
|
||||
false,
|
||||
JSON.stringify(detail),
|
||||
);
|
||||
let policyRuns = 0;
|
||||
const result = routeWorldFocusRequest(
|
||||
{ detail },
|
||||
() => { policyRuns += 1; return 'released'; },
|
||||
() => {
|
||||
policyRuns += 1;
|
||||
return 'released';
|
||||
},
|
||||
() => 'flew',
|
||||
);
|
||||
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.
|
||||
assert.equal(requestWorldFocus(detail, new EventTarget()), false);
|
||||
}
|
||||
assert.equal(isValidWorldFocusTarget({ kind: 'vessel', id: '123', position: POSITION }), true);
|
||||
assert.equal(isValidWorldFocusTarget({ kind: 'fire', id: 'fire-1', position: POSITION }), true);
|
||||
assert.equal(
|
||||
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', () => {
|
||||
const camera = stubCamera();
|
||||
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel', id: '123', position: POSITION }), true);
|
||||
assert.equal(camera.calls.cancelFlight, 1, 'a prior flight must be cancelled, not queued');
|
||||
assert.equal(
|
||||
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);
|
||||
const { sphere, options } = camera.calls.flights[0];
|
||||
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', () => {
|
||||
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];
|
||||
assert.equal(sphere.radius, WORLD_FOCUS_FRAMING.fire.radiusM);
|
||||
assert.equal(options.offset.range, WORLD_FOCUS_FRAMING.fire.rangeM);
|
||||
assert.ok(WORLD_FOCUS_FRAMING.fire.rangeM > WORLD_FOCUS_FRAMING.vessel.rangeM);
|
||||
assert.ok(WORLD_FOCUS_FRAMING.fire.radiusM > WORLD_FOCUS_FRAMING.vessel.radiusM);
|
||||
assert.ok(
|
||||
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', () => {
|
||||
@@ -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) {
|
||||
const framing = WORLD_FOCUS_FRAMING[kind];
|
||||
// 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(framing.pitchDeg > -80, `${kind}: pitch must stay oblique, got ${framing.pitchDeg}`);
|
||||
assert.ok(
|
||||
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.
|
||||
assert.ok(framing.radiusM > 0, `${kind}: radius must be positive`);
|
||||
assert.ok(
|
||||
framing.rangeM > 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.
|
||||
assert.deepEqual({ ...WORLD_FOCUS_FRAMING.vessel }, { radiusM: 150, rangeM: 1200, pitchDeg: -30 });
|
||||
assert.deepEqual({ ...WORLD_FOCUS_FRAMING.fire }, { radiusM: 400, rangeM: 3000, pitchDeg: -35 });
|
||||
assert.deepEqual(
|
||||
{ ...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', () => {
|
||||
const camera = stubCamera();
|
||||
assert.equal(flyToWorldTarget({ camera }, { kind: 'plane', position: POSITION }), false);
|
||||
assert.equal(flyToWorldTarget({ camera }, { kind: 'vessel' }), false);
|
||||
assert.equal(flyToWorldTarget(null, { kind: 'vessel', position: POSITION }), false);
|
||||
assert.equal(
|
||||
flyToWorldTarget(bindFakeCesiumCamera({ camera }), {
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user