Keep your target in view: smooth camera controls and organized data layers (#583)

## Explore the map without losing your target
Tilt and north-up used to stop tracking an aircraft or satellite. Both controls now keep the selected target centered while easing into the new angle over 650 ms, preserving camera range. Tilt switches between top-down and oblique views; north-up takes the shortest rotation. Rapid presses reverse a pending tilt, and pressing north-up during a tilt completes both changes.

New navigation, target selection, map gestures, and teardown cancel the transition. Cockpit keeps its existing camera guard.

## A data panel that follows how you explore
The panel now has five clearly labeled sections:

| Section | Layer order |
| --- | --- |
| Movement | Satellites → Live Flights → Military Flights → Live Vessels → Street Traffic → Bike Share |
| Cameras | Cameras → Mapped ALPR Cameras |
| Infrastructure | Mapped Installations → Data Centers → Submarine Cables → Dams |
| Events | Space Missions → Earthquakes → Active Fires |
| Utilities | Directions → Radio |

Readable panel labels replace CCTV, Bikeshare, Datacenters, and provider-heavy names. Source attribution remains in the subtitles, and toggle accessibility labels match the visible names. Presentation order is independent of layer registration; hidden layers stay hidden and unlisted layers remain available under Other layers.

## Validation
- Camera changes: full Node 24 suite, including allocation gates — 3,699 passing and one skipped.
- Final panel changes: 42 focused tests passed; browser inspection confirmed all sections, ordering, and labels.
- Real Cesium Camera/EntityView tests cover moving aircraft, low/high satellites, cancellation, navigation ownership, and rapid button presses.
- Live browser: UITMSAT-2 remained centered and selected through tilt and north-up, including combined commands.
- Final formatting, package-boundary checks, and production build pass.
- GitHub CI verifies the final head on Node 24, Node 26, and Windows onboarding before merge.
This commit is contained in:
Bilawal Sidhu authored and GitHub committed 2026-09-15 02:24:54 -05:00
1 parent c9f9896b48
commit 1ad565c8af
6 files changed
+402 -8

No files matched your search

+172
View File
@@ -5,11 +5,13 @@ import {
OBLIQUE_PITCH,
STRAIGHT_DOWN_PITCH,
bindCameraOrientationControls,
createCameraOrientationAnimator,
pickViewTarget,
readCameraTargetFrame,
resetCameraNorth,
toggleCameraTilt,
} from './cameraOrientationControls.js';
import { NavigationController } from './navigationController.js';
function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
const calls = [];
@@ -489,3 +491,173 @@ test('bindings route both controls and release every listener on destroy', () =>
assert.deepEqual(navigations, ['camera', 'camera']);
assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89));
});
for (const [label, altitude, range] of [
['aircraft', 10000, 1500],
['satellite', 400000, 1500000],
['high satellite', 35786000, 3000000],
]) {
test(`${label}: animated tilt and north retain a moving EntityView`, (t) => {
// Use Cesium's built-in TEME fallback without fetching Earth-orientation data.
t.mock.method(
Cesium.Transforms,
'computeFixedToIcrfMatrix',
() => undefined,
);
let target = Cesium.Cartesian3.fromDegrees(-97, 30, altitude);
const { viewer } = createRealViewer(target);
viewer.scene.preUpdate = new Cesium.Event();
viewer.clock = { currentTime: Cesium.JulianDate.now() };
const entity = new Cesium.Entity({
position: new Cesium.CallbackPositionProperty(() => target, false),
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
});
entity.gevDisplayPosition = () => target;
viewer.trackedEntity = entity;
const follow = new Cesium.EntityView(entity, viewer.scene);
follow.update(viewer.clock.currentTime);
viewer.camera.lookAt(
target,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(350),
OBLIQUE_PITCH,
range,
),
);
let time = 0;
const animator = createCameraOrientationAnimator(viewer, {
now: () => time,
duration: 1000,
});
for (const pitch of [STRAIGHT_DOWN_PITCH, OBLIQUE_PITCH]) {
const frame = readCameraTargetFrame(viewer);
animator.animate(frame, { heading: 0, pitch });
for (const step of [0, 100, 500, 900, 1000]) {
time = (pitch === STRAIGHT_DOWN_PITCH ? 0 : 1000) + step;
target = Cesium.Cartesian3.fromDegrees(
-97 + time / 100000,
30,
altitude,
);
follow.update(viewer.clock.currentTime);
const transform = Cesium.Matrix4.clone(viewer.camera.transform);
viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.trackedEntity, entity);
assert.ok(
Cesium.Matrix4.equalsEpsilon(
viewer.camera.transform,
transform,
1e-8,
),
);
const actual = readCameraTargetFrame(viewer);
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(step / 1000);
assert.ok(
Math.abs(actual.pitch - Cesium.Math.lerp(frame.pitch, pitch, eased)) <
1e-6,
);
assert.ok(Math.abs(actual.range - range) < 1e-5);
}
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
}
target = Cesium.Cartesian3.fromDegrees(-96, 31, altitude);
follow.update(viewer.clock.currentTime);
assert.equal(viewer.trackedEntity, entity);
assert.ok(Math.abs(readCameraTargetFrame(viewer).range - range) < 1e-5);
});
}
test('orientation animation cancels on replacement and explicit cancellation', () => {
const { viewer } = createRealViewer(Cesium.Cartesian3.fromDegrees(0, 0));
orbit(viewer, Cesium.Cartesian3.fromDegrees(0, 0), 90, OBLIQUE_PITCH, 1000);
viewer.scene.preUpdate = new Cesium.Event();
const animator = createCameraOrientationAnimator(viewer);
const frame = readCameraTargetFrame(viewer);
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
animator.animate(frame, { heading: 0, pitch: OBLIQUE_PITCH });
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
viewer.trackedEntity = {};
viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
viewer.trackedEntity = undefined;
animator.animate(frame, { heading: 0, pitch: STRAIGHT_DOWN_PITCH });
animator.cancel();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
});
test('orientation navigation preserves follow and selection; new navigation cancels animation', () => {
const calls = [];
const entity = {};
const viewer = {
trackedEntity: entity,
camera: { cancelFlight: () => calls.push('cancel-flight') },
};
let cockpit = false;
const navigation = new NavigationController({
viewer,
tracking: {},
isCockpitActive: () => cockpit,
cancelOrientation: () => calls.push('cancel-orientation'),
clearLocation: () => calls.push('clear-location'),
cancelShareSelection: () => calls.push('clear-selection'),
interruptCameraMotion: () => calls.push('interrupt-motion'),
stopOrbit: () => calls.push('stop-orbit'),
showToast: () => calls.push('toast'),
});
assert.equal(
navigation.runOrientation('camera', () => 'animated'),
'animated',
);
assert.equal(viewer.trackedEntity, entity);
assert.deepEqual(calls, [
'cancel-orientation',
'interrupt-motion',
'stop-orbit',
'cancel-flight',
]);
calls.length = 0;
navigation._stampNavigation({ cancelPendingSelection: false });
assert.deepEqual(calls, ['cancel-orientation', 'clear-location']);
calls.length = 0;
cockpit = true;
assert.equal(
navigation.runOrientation('camera', () =>
assert.fail('cockpit should refuse'),
),
false,
);
assert.deepEqual(calls, ['toast']);
});
test('rapid tilt presses reverse direction and north-up keeps the requested tilt', (t) => {
let time = 0;
t.mock.method(performance, 'now', () => time);
const target = Cesium.Cartesian3.fromDegrees(0, 0);
const { viewer } = createRealViewer(target);
orbit(viewer, target, 90, OBLIQUE_PITCH, 1000);
viewer.scene.preUpdate = new Cesium.Event();
const tiltButton = new FakeButton();
const northButton = new FakeButton();
let controls;
controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
runNavigation: (_noun, navigate) => {
controls.cancel();
return navigate();
},
});
tiltButton.click();
tiltButton.click();
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
tiltButton.click();
northButton.click();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 1);
time = 650;
viewer.scene.preUpdate.raiseEvent();
const frame = readCameraTargetFrame(viewer);
assert.ok(Math.abs(frame.pitch - STRAIGHT_DOWN_PITCH) < 1e-6);
assert.ok(Math.abs(frame.heading) < 1e-6);
controls.destroy();
assert.equal(viewer.scene.preUpdate.numberOfListeners, 0);
});