feat(ui): tilt and north-up compass controls (#571)

Supersedes #442 by @yashveeeeeeer, ported onto the current component layout with fixes from review.

Verified before merge: format, package boundaries, unit suite, build, and the browser tracking suite; a browser harness for the change where one applies; adversarial review applied.

Co-authored-by: Yashveer Singh <[email protected]>
This commit is contained in:
Bilawal SidhuandYashveer Singh authored and GitHub committed 2026-09-14 21:15:29 -05:00
1 parent fb5ee5aa36
commit d9fac6d88e
14 files changed
+895 -13

No files matched your search

+9
View File
@@ -163,6 +163,15 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
### Added
- Two map-orientation controls sit beside Share in the top-center globe
actions. Tilt Map swings between a straight-down map and a 35-degree oblique
around the point under the centre of the view, keeping that point and the
distance to it. North Up rotates around the same point until north is at the
top, keeping the pitch, and its needle shows the current bearing. Both decline
without moving the camera when nothing is under the centre of the view, and
both follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit
(#442 — thanks @yashveeeeeeer).
- Add Open Calgary traffic cameras as a keyless CCTV source pack (thanks
@rileygramlich): the public City of Calgary catalog, frames pinned to the
city's own host and upgraded to HTTPS, with the Open Government Licence –
+15 -6
View File
@@ -1450,7 +1450,15 @@ This is the current runtime/source-of-truth snapshot for the project.
> user intent.
> Display orders 3D immediately above Celestial and Clean UI immediately
> below it. The top-center action group places Clear Layers to the left of
> Share and Reset Globe to the right. Clear Layers turns off the currently
> Share, then Tilt Map and North Up, and Reset Globe to the right. Tilt Map
> swings the camera between a straight-down map and a 35-degree oblique around
> the point under the centre of the view, keeping that point and the distance
> to it; North Up rotates around the same point until north is at the top,
> keeping the pitch. Its needle shows the current bearing. Both decline
> without moving the camera when nothing is under the centre of the view (a
> camera facing the sky), and both are explicit camera actions, so either one
> releases a followed aircraft the way the other camera controls do. They
> follow Reset Globe out of Clean UI, recording, Scene playback and Cockpit. Clear Layers turns off the currently
> selected manager-owned data layers, including an active Context choice,
> while retaining visual, HUD, map, and panel settings. A disabled layer may
> still release camera work that it owns through its normal teardown.
@@ -1496,8 +1504,9 @@ This is the current runtime/source-of-truth snapshot for the project.
> the Cockpit HUD at 145), scrolling internally when the layer list is longer than
> the corridor. Cesium's credit line is never passable and still bounds the
> corridor. Layer toggles stay live from there, and collapsing returns the
> plain launcher. The map-only Clear, Share, and Reset Globe actions are hidden
> for the duration of Cockpit, both as a group and as individual controls.
> plain launcher. The map-only Clear, Share, Tilt Map, North Up, and Reset
> Globe actions are hidden for the duration of Cockpit, both as a group and as
> individual controls.
> It uses the `radar` symbol; both tabs are reachable with Tab and Left/Right arrows switch between them. Its action row
> places the single Cockpit entry before Search Nearby Sites. Cockpit removes
> the duplicate floating map entry and topline exit; the bottom-center
@@ -1607,9 +1616,9 @@ This is the current runtime/source-of-truth snapshot for the project.
> tray inside the screen.
> Adaptive remeasurement preserves the user's
> scroll position across Tactical, Minimal, and HUD Off layouts.
> During an active Scene run, Clear Selected Layers and Reset Globe remain
> hidden until playback stops or completes because the Scene transport owns
> layer and camera sequencing for that interval.
> During an active Scene run, Clear Selected Layers, Tilt Map, North Up and
> Reset Globe remain hidden until playback stops or completes because the
> Scene transport owns layer and camera sequencing for that interval.
> The Cockpit Contact summary exposes Previous and Next contact navigation
> plus its collapse control; it does not offer a Focus action because the
> first-person Cockpit camera remains owned by the tracked aircraft.
+2
View File
@@ -150,6 +150,8 @@
"scripts/qa-visual-input.mjs",
"src/ui/displayControls.js",
"src/ui/displayControls.test.mjs",
"src/ui/cameraOrientationControls.js",
"src/ui/cameraOrientationControls.test.mjs",
"src/ui/effects.js",
"src/ui/visualEffects.js",
"src/ui/visualPresets.js",
+3
View File
@@ -458,6 +458,7 @@
"src/data/installationFeedback.js",
"src/data/layerState.js",
"src/data/militaryAwarenessEngine.js",
"src/data/scenePick.js",
"src/keySetupCore.mjs",
"src/loadingFeedback.js",
"src/locationStatus.js",
@@ -473,6 +474,7 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/cameraOrientationControls.js",
"src/ui/applicationShortcuts.js",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
@@ -1490,6 +1492,7 @@
"src/styles/surveillance.js",
"src/styles/thermal.js",
"src/ui/applicationShell.js",
"src/ui/cameraOrientationControls.js",
"src/ui/applicationShortcuts.js",
"src/ui/cctv.js",
"src/ui/cctvBindings.js",
+14 -5
View File
@@ -45,7 +45,7 @@ test('Cockpit has one reset action beside its bottom exit path', () => {
);
const topCenterActions = html.match(/<nav id="top-center-actions"[\s\S]*?<\/nav>/);
assert.ok(topCenterActions, 'Top-center globe actions are missing');
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="reset-globe-view"/);
assert.match(topCenterActions[0], /id="clear-selected-layers"[\s\S]*?id="share-btn"[\s\S]*?id="tilt-map-view"[\s\S]*?id="north-up-view"[\s\S]*?id="reset-globe-view"/);
assert.equal((html.match(/id="clear-selected-layers"/g) || []).length, 1, 'Clear Layers must have one DOM owner');
assert.equal((html.match(/id="reset-globe-view"/g) || []).length, 1, 'Reset Globe must have one DOM owner');
assert.equal((html.match(/id="cockpit-reset-globe"/g) || []).length, 1, 'Cockpit Reset must have one DOM owner');
@@ -227,7 +227,7 @@ test('share startup isolates panel defaults from recipient-local collapse prefer
});
test('Cockpit owns a focused shared Display portal and compact Radio controls', () => {
const hiddenRule = css.match(/body\.cockpit-mode :is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
const hiddenRule = css.match(/body\.cockpit-mode\s*:is\(([^)]*)\)\s*\{\s*display:\s*none\s*!important;/);
assert.ok(hiddenRule, 'Cockpit hidden-chrome rule is missing');
assert.match(css, /body\.cockpit-mode #right-context-rail\s*\{\s*display:\s*none\s*!important;/);
assert.match(css, /body\.cockpit-mode #left-panel-stack > #scene-panel\s*\{\s*display:\s*none\s*!important;/);
@@ -240,11 +240,20 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
assert.match(html, /id="clear-selected-layers"[^>]*aria-label="Clear selected data layers"/);
assert.match(html, /id="reset-globe-view"[^>]*aria-label="Reset to full globe view"/);
assert.match(css, /#top-center-actions\s*\{[\s\S]*?left:\s*50%;[\s\S]*?display:\s*flex;[\s\S]*?transform:\s*translateX\(-50%\)/);
assert.match(
css,
/@media \(max-width: 720px\)\s*\{[\s\S]*?#top-center-actions\s*\{[\s\S]*?right:\s*16px;[\s\S]*?left:\s*auto;[\s\S]*?transform:\s*none;/,
);
assert.match(css, /@media \(max-width: 720px\)\s*\{[\s\S]*?#style-indicator\s*\{\s*display:\s*none;/);
assert.match(
css,
/@media \(max-width: 520px\)\s*\{[\s\S]*?#title-bar h1 > span:last-child,[\s\S]*?#title-bar \.subtitle\s*\{\s*display:\s*none;/,
);
assert.match(css, /body\.ui-clean-view #top-center-actions/);
assert.match(css, /body\.recording-mode #top-center-actions/);
assert.match(
css,
/body\.scene-playback-mode :is\(#clear-selected-layers, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.scene-playback-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
);
assert.match(sceneDirector, /this\._running = true;\s*this\._setPlaybackActive\(true\);/);
assert.match(sceneDirector, /styleManager\.setRecordingMode\(false\);\s*this\._setPlaybackActive\(false\);/);
@@ -468,11 +477,11 @@ test('real disclosure changes reconsider only their own temporary panel lane', (
test('Cockpit hides the complete top-center globe action group', () => {
assert.match(
css,
/body\.cockpit-mode :is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
/body\.cockpit-mode\s*:is\([\s\S]*?#top-center-actions[\s\S]*?\)\s*\{\s*display: none !important;\s*\}/,
);
assert.match(
css,
/body\.cockpit-mode :is\(#clear-selected-layers, #share-btn, #reset-globe-view\)\s*\{\s*display:\s*none !important;/,
/body\.cockpit-mode\s*:is\([\s\S]*?#clear-selected-layers,[\s\S]*?#share-btn,[\s\S]*?#tilt-map-view,[\s\S]*?#north-up-view,[\s\S]*?#reset-globe-view[\s\S]*?\)\s*\{\s*display:\s*none !important;/,
'Cockpit must hide each map-only globe action even if its group layout is disturbed',
);
});
+18
View File
@@ -15,6 +15,7 @@ import { CctvControls } from './cctv.js';
import { RadioControls } from './radio.js';
import { LocationControls } from './location.js';
import { bindClearLayersControl } from './layers.js';
import { bindCameraOrientationControls } from './cameraOrientationControls.js';
import { createMapSourceControls } from './mapSource.js';
import { STYLES } from './effects.js';
import { bindDisplayControls } from './displayControls.js';
@@ -590,6 +591,7 @@ export class StyleManager {
this._initGlobalContextPanel();
this._initLocationBar();
this._initShareButton();
this._initCameraOrientationControls();
this._initClearSelectedLayersButton();
this._initHUDToggle();
this._initModels3dToggle();
@@ -3269,6 +3271,21 @@ export class StyleManager {
);
}
/** Wire Google Maps-style tilt and north-up camera actions. */
_initCameraOrientationControls() {
this._cameraOrientationControls?.destroy();
this._cameraOrientationControls = bindCameraOrientationControls({
viewer: this.viewer,
elements: {
tiltButton: this._tiltMapBtn,
northButton: this._northUpBtn,
},
runNavigation: (noun, navigate) =>
this._runExplicitNavigation(noun, navigate),
showToast: (message) => this._showToast(message),
});
}
/**
* Clear every selected data layer without resetting visual, map, HUD, or
* camera state. A layer may still release camera work it owns as part of its
@@ -3659,6 +3676,7 @@ export class StyleManager {
this._displayControls?.destroy();
this._frameRateMonitor?.destroy();
this._mapSourceControls?.destroy();
this._cameraOrientationControls?.destroy();
this._clearLayersControl?.destroy();
this._locationControls?.destroy();
this._cctvControls?.destroy();
+269
View File
@@ -0,0 +1,269 @@
import * as Cesium from 'cesium';
import { isPickedWorldPosition } from '../data/scenePick.js';
export const OBLIQUE_PITCH = Cesium.Math.toRadians(-35);
export const STRAIGHT_DOWN_PITCH = Cesium.Math.toRadians(-89);
const OBLIQUE_THRESHOLD = Cesium.Math.toRadians(-60);
function normalizedHeading(heading) {
const wrapped = Cesium.Math.zeroToTwoPi(
Number.isFinite(heading) ? heading : 0,
);
return Math.abs(wrapped - Cesium.Math.TWO_PI) < Cesium.Math.EPSILON10
? 0
: wrapped;
}
/** Return the world position under the center of the canvas, when available. */
export function pickViewTarget(viewer) {
const scene = viewer?.scene;
const camera = viewer?.camera;
const canvas = scene?.canvas;
if (!scene || !camera || !canvas) return null;
const width = canvas.clientWidth || canvas.width || 0;
const height = canvas.clientHeight || canvas.height || 0;
if (!width || !height) return null;
const center = new Cesium.Cartesian2(width / 2, height / 2);
let target = null;
if (scene.pickPositionSupported && typeof scene.pickPosition === 'function') {
try {
target = scene.pickPosition(center);
} catch {
target = null;
}
}
// Terrain before the ellipsoid: globe.pick intersects the rendered surface,
// so over keyless elevated terrain it returns the ground the operator is
// looking at. pickEllipsoid answers with sea level, which on a plateau sits
// far below the view and would swing the camera on every tilt.
if (
!isPickedWorldPosition(target) &&
typeof camera.getPickRay === 'function'
) {
try {
target = scene.globe?.pick(camera.getPickRay(center), scene) || null;
} catch {
target = null;
}
}
if (
!isPickedWorldPosition(target) &&
typeof camera.pickEllipsoid === 'function'
) {
try {
target = camera.pickEllipsoid(center, Cesium.Ellipsoid.WGS84);
} catch {
target = null;
}
}
return isPickedWorldPosition(target) ? target : null;
}
/** Describe the camera as an orbit around the current viewport center. */
export function readCameraTargetFrame(viewer) {
const camera = viewer?.camera;
const target = pickViewTarget(viewer);
if (!camera || !target || !isPickedWorldPosition(camera.positionWC))
return null;
const transform = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const inverse = Cesium.Matrix4.inverseTransformation(
transform,
new Cesium.Matrix4(),
);
const localOffset = Cesium.Matrix4.multiplyByPoint(
inverse,
camera.positionWC,
new Cesium.Cartesian3(),
);
const range = Cesium.Cartesian3.magnitude(localOffset);
if (!Number.isFinite(range) || range < 1) return null;
const pitch = -Math.asin(Cesium.Math.clamp(localOffset.z / range, -1, 1));
// Cesium places a lookAt camera at range * (-sin h cos p, -cos h cos p,
// -sin p) in the target's east-north-up frame, so recovering h means
// negating BOTH horizontal components. Negating only the north one reads
// every heading as its mirror image (30 deg comes back as 330), which would
// make north-up rotate the wrong way from three quarters of the compass.
const targetHeading = Math.atan2(-localOffset.x, -localOffset.y);
const heading = normalizedHeading(
pitch < Cesium.Math.toRadians(-88.5) ? camera.heading : targetHeading,
);
return { target, range, heading, pitch };
}
/** Apply an orbit frame, then return the camera to Cesium's fixed-world frame. */
export function setCameraTargetFrame(viewer, frame) {
const camera = viewer?.camera;
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
try {
camera.lookAt(
frame.target,
new Cesium.HeadingPitchRange(
normalizedHeading(frame.heading),
frame.pitch,
frame.range,
),
);
const destination = Cesium.Cartesian3.clone(camera.positionWC);
const direction = Cesium.Cartesian3.clone(camera.directionWC);
const up = Cesium.Cartesian3.clone(camera.upWC);
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
if (destination && direction && up) {
camera.setView({ destination, orientation: { direction, up } });
}
viewer.scene?.requestRender?.();
return true;
} catch {
return false;
}
}
/**
* Whether the map reads as tilted, from the orbit frame.
*
* This is the quantity the toggle commands, so it is the only one the button
* may show. The camera's OWN pitch is a different angle: at a few kilometres
* the two agree to 0.02 degrees, but from the full-globe view they are tens of
* degrees apart, because the point under the middle of the screen is most of a
* hemisphere away. Reading the camera's pitch there made the toggle choose
* oblique twice in a row — commanded oblique, camera pitch came back near -77,
* still "not tilted", tilt again.
*
* @param {{pitch: number}|null} frame From {@link readCameraTargetFrame}.
*/
export function frameIsTilted(frame) {
const pitch = Number(frame?.pitch);
return Number.isFinite(pitch) ? pitch > OBLIQUE_THRESHOLD : false;
}
/** Toggle between a straight-down map and a useful oblique map angle. */
export function toggleCameraTilt(viewer) {
const frame = readCameraTargetFrame(viewer);
if (!frame) return false;
const tilted = frameIsTilted(frame);
const pitch = tilted ? STRAIGHT_DOWN_PITCH : OBLIQUE_PITCH;
return setCameraTargetFrame(viewer, { ...frame, pitch })
? { tilted: !tilted, pitch }
: false;
}
/** Rotate around the viewport center until north is at the top. */
export function resetCameraNorth(viewer) {
const frame = readCameraTargetFrame(viewer);
if (!frame) return false;
return setCameraTargetFrame(viewer, { ...frame, heading: 0 });
}
function headingDegrees(camera) {
return Cesium.Math.toDegrees(normalizedHeading(camera?.heading));
}
/** Bind the two map-orientation actions and keep their accessible state current. */
export function bindCameraOrientationControls({
viewer,
elements,
runNavigation,
showToast,
}) {
const tiltButton = elements?.tiltButton;
const northButton = elements?.northButton;
const removers = [];
let destroyed = false;
let applied = null;
// The tilt state is the one thing here that costs a depth read of the middle
// of the screen: 4 ms median and up to 12 ms, measured on this machine. It is
// therefore held between refreshes rather than recomputed per frame.
let tilted = false;
/** Recompute the tilt state exactly, paying for one pick. */
const refreshTilt = () => {
if (destroyed) return;
tilted = frameIsTilted(readCameraTargetFrame(viewer));
sync();
};
/**
* Write the two controls' state, but only what actually changed.
*
* The compass needle reads the camera's own bearing, which costs nothing, so
* this can run on every rendered frame: measured at 1.4 ms across 120 frames
* of a continuously dragged map (0.012 ms per frame, 0.1 ms worst case),
* against the 4 ms per frame the depth read it replaced would have cost.
*/
function sync() {
if (destroyed) return;
const next = {
tilted,
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
};
if (
applied &&
applied.tilted === next.tilted &&
applied.heading === next.heading
)
return;
if (!applied || applied.tilted !== next.tilted) {
tiltButton?.setAttribute('aria-pressed', String(next.tilted));
tiltButton?.setAttribute(
'aria-label',
next.tilted
? 'Return map to straight-down view'
: 'Tilt map to oblique view',
);
}
if (!applied || applied.heading !== next.heading) {
northButton?.style?.setProperty('--camera-heading', `${next.heading}deg`);
northButton?.setAttribute(
'aria-label',
`Reset map to north up. Current heading ${next.heading} degrees`,
);
}
applied = next;
}
const listen = (element, handler) => {
if (!element) return;
element.addEventListener('click', handler);
removers.push(() => element.removeEventListener('click', handler));
};
listen(tiltButton, () => {
const result = runNavigation('camera', () => toggleCameraTilt(viewer));
if (result) {
showToast?.(result.tilted ? 'Tilted view' : 'Straight-down view');
// The action reports the state it just commanded, so the button can
// follow it without a second pick.
tilted = result.tilted;
}
sync();
});
listen(northButton, () => {
const result = runNavigation('camera', () => resetCameraNorth(viewer));
if (result) showToast?.('North up');
sync();
});
const subscribe = (event, handler) => {
const remove = event?.addEventListener?.(handler);
if (typeof remove === 'function') removers.push(remove);
};
// The needle follows every frame the scene draws. `camera.changed` is NOT
// enough: it only fires past `camera.percentageChanged`, so a rotation of a
// few degrees leaves the needle pointing at the old bearing.
subscribe(viewer?.scene?.preRender, sync);
// The exact tilt state is refreshed once per gesture, not once per frame —
// moveEnd fires when the camera stops, which is where the pick is affordable.
subscribe(viewer?.camera?.moveEnd, refreshTilt);
refreshTilt();
return {
sync,
refreshTilt,
destroy() {
if (destroyed) return;
destroyed = true;
for (const remove of removers.splice(0)) remove();
},
};
}
+491
View File
@@ -0,0 +1,491 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
OBLIQUE_PITCH,
STRAIGHT_DOWN_PITCH,
bindCameraOrientationControls,
pickViewTarget,
readCameraTargetFrame,
resetCameraNorth,
toggleCameraTilt,
} from './cameraOrientationControls.js';
function createViewer({ cameraPosition, target, pickPosition = null } = {}) {
const calls = [];
const changed = new Cesium.Event();
const surface = target || Cesium.Cartesian3.fromDegrees(0, 0, 0);
const position = cameraPosition || Cesium.Cartesian3.fromDegrees(0, 0, 1_000);
const camera = {
positionWC: position,
directionWC: new Cesium.Cartesian3(-1, 0, 0),
upWC: new Cesium.Cartesian3(0, 0, 1),
heading: Cesium.Math.toRadians(90),
changed,
pickEllipsoid: () => surface,
getPickRay: () => ({}),
lookAt(targetValue, offset) {
calls.push({ type: 'lookAt', target: targetValue, offset });
},
lookAtTransform(transform) {
calls.push({ type: 'lookAtTransform', transform });
},
setView(view) {
calls.push({ type: 'setView', view });
},
};
const viewer = {
camera,
scene: {
canvas: { clientWidth: 1_200, clientHeight: 800 },
pickPositionSupported: Boolean(pickPosition),
pickPosition: pickPosition || (() => null),
globe: { pick: () => surface },
requestRender: () => calls.push({ type: 'requestRender' }),
},
};
return { viewer, calls, target: surface };
}
class FakeButton extends EventTarget {
constructor() {
super();
this.attributes = new Map();
this.style = {
setProperty: (name, value) => this.attributes.set(name, value),
};
}
setAttribute(name, value) {
this.attributes.set(name, String(value));
}
getAttribute(name) {
return this.attributes.get(name);
}
click() {
this.dispatchEvent(new Event('click'));
}
}
/**
* A real Cesium camera attached to a minimal offscreen scene.
*
* The fake above records what lookAt was asked for but never moves, so it
* cannot show whether a frame written into the camera reads back as the same
* frame. These cases need the production math on both sides.
*/
function createRealCamera() {
const scene = {
canvas: { clientWidth: 1200, clientHeight: 800, width: 1200, height: 800 },
drawingBufferWidth: 1200,
drawingBufferHeight: 800,
mapProjection: new Cesium.GeographicProjection(Cesium.Ellipsoid.WGS84),
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
// setView converts a world-space direction/up pair into the local frame
// only in 3D, and the camera derives heading/pitch the same way, so the
// scene mode is load-bearing here rather than decoration.
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
return { camera, scene };
}
/** A viewer whose center pick is a fixed ground point and whose camera is real. */
function createRealViewer(target) {
const { camera, scene } = createRealCamera();
scene.pickPositionSupported = false;
scene.pickPosition = () => null;
scene.globe.pick = () => target;
scene.requestRender = () => {};
// The binding drives the needle from the scene's per-frame signal, not from
// `camera.changed`, so the fixture has to carry one.
scene.preRender = new Cesium.Event();
camera.getPickRay = () => ({});
return { viewer: { camera, scene }, target };
}
/** Place a real camera in an orbit around `target` and release the frame. */
function orbit(viewer, target, headingDeg, pitchRad, range) {
viewer.camera.lookAt(
target,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(headingDeg),
pitchRad,
range,
),
);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
const AUSTIN_GROUND = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 0);
test('a heading written into a real camera reads back as the same heading', () => {
// One oblique heading per quadrant, plus the cardinals, at pitches a user
// reaches with the tilt control.
for (const headingDeg of [0, 30, 90, 135, 180, 225, 270, 315, 350]) {
for (const pitchDeg of [-25, -40, -65]) {
const { viewer } = createRealViewer(AUSTIN_GROUND);
viewer.camera.lookAt(
AUSTIN_GROUND,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(headingDeg),
Cesium.Math.toRadians(pitchDeg),
4000,
),
);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
const frame = readCameraTargetFrame(viewer);
assert.ok(frame, `no frame at ${headingDeg}/${pitchDeg}`);
const readDeg = Cesium.Math.toDegrees(frame.heading);
assert.ok(
Math.abs(((readDeg - headingDeg + 540) % 360) - 180) < 0.01,
`heading ${headingDeg} deg at pitch ${pitchDeg} read back as ${readDeg.toFixed(2)} deg`,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(frame.pitch) - pitchDeg) < 0.01,
`pitch ${pitchDeg} read back as ${Cesium.Math.toDegrees(frame.pitch).toFixed(2)}`,
);
assert.ok(Math.abs(frame.range - 4000) < 1, `range ${frame.range}`);
}
}
});
test('north-up really points a real camera north from every quadrant', () => {
for (const headingDeg of [30, 135, 225, 315]) {
const { viewer } = createRealViewer(AUSTIN_GROUND);
viewer.camera.lookAt(
AUSTIN_GROUND,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(headingDeg),
Cesium.Math.toRadians(-40),
4000,
),
);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
assert.equal(
resetCameraNorth(viewer),
true,
`reset failed at ${headingDeg}`,
);
const after = readCameraTargetFrame(viewer);
assert.ok(after, `no frame after reset at ${headingDeg}`);
const readDeg = Cesium.Math.toDegrees(after.heading);
assert.ok(
Math.min(readDeg, 360 - readDeg) < 0.01,
`north-up from ${headingDeg} deg left the camera at ${readDeg.toFixed(2)} deg`,
);
assert.ok(
Math.abs(Cesium.Math.toDegrees(after.pitch) + 40) < 0.01,
'north-up must not change the pitch',
);
assert.ok(
Math.abs(after.range - 4000) < 1,
'north-up must not change the range',
);
}
});
test('tilt moves a real camera between the two pitches and keeps target and range', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
viewer.camera.lookAt(
AUSTIN_GROUND,
new Cesium.HeadingPitchRange(
Cesium.Math.toRadians(215),
STRAIGHT_DOWN_PITCH,
3000,
),
);
viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
const first = toggleCameraTilt(viewer);
assert.deepEqual(first, { tilted: true, pitch: OBLIQUE_PITCH });
const tilted = readCameraTargetFrame(viewer);
assert.ok(Math.abs(tilted.pitch - OBLIQUE_PITCH) < 1e-6);
assert.ok(Math.abs(tilted.range - 3000) < 1, `range ${tilted.range}`);
assert.ok(
Math.abs(Cesium.Math.toDegrees(tilted.heading) - 215) < 0.01,
'tilt must not rotate the map',
);
// Releasing the orbit frame must leave the camera pointing at the same
// ground, not merely standing in the right place.
const toTarget = Cesium.Cartesian3.normalize(
Cesium.Cartesian3.subtract(
AUSTIN_GROUND,
viewer.camera.positionWC,
new Cesium.Cartesian3(),
),
new Cesium.Cartesian3(),
);
const offBy = Cesium.Math.toDegrees(
Math.acos(
Cesium.Math.clamp(
Cesium.Cartesian3.dot(viewer.camera.directionWC, toTarget),
-1,
1,
),
),
);
assert.ok(
offBy < 0.05,
`camera points ${offBy.toFixed(2)} deg away from its target`,
);
const second = toggleCameraTilt(viewer);
assert.deepEqual(second, { tilted: false, pitch: STRAIGHT_DOWN_PITCH });
const down = readCameraTargetFrame(viewer);
assert.ok(Math.abs(down.pitch - STRAIGHT_DOWN_PITCH) < 1e-6);
assert.ok(Math.abs(down.range - 3000) < 1);
});
test('tilt alternates at globe range, not only at map range', () => {
// The canonical full-globe view. The camera's own pitch here is tens of
// degrees away from the orbit pitch, so a control reading the camera would
// command oblique twice in a row and the map would never come back level.
for (const range of [3_000, 250_000, 18_000_000]) {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
const first = toggleCameraTilt(viewer);
assert.deepEqual(
first,
{ tilted: true, pitch: OBLIQUE_PITCH },
`first click at ${range} m must tilt`,
);
const second = toggleCameraTilt(viewer);
assert.deepEqual(
second,
{ tilted: false, pitch: STRAIGHT_DOWN_PITCH },
`second click at ${range} m must level (camera pitch reads ${Cesium.Math.toDegrees(viewer.camera.pitch).toFixed(1)} deg)`,
);
const third = toggleCameraTilt(viewer);
assert.deepEqual(
third,
{ tilted: true, pitch: OBLIQUE_PITCH },
`third click at ${range} m must tilt again`,
);
}
});
test('the button shows the state the next click will produce, at every range', () => {
for (const range of [3_000, 18_000_000]) {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, STRAIGHT_DOWN_PITCH, range);
const tiltButton = new FakeButton();
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton: new FakeButton() },
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(
tiltButton.getAttribute('aria-pressed'),
'false',
`straight down at ${range} m must not read as tilted`,
);
tiltButton.click();
assert.equal(
tiltButton.getAttribute('aria-pressed'),
'true',
`after one click at ${range} m the button must read tilted`,
);
// And the exact refresh that runs when the camera settles must agree.
controls.refreshTilt();
assert.equal(
tiltButton.getAttribute('aria-pressed'),
'true',
`the settled state at ${range} m must agree with the commanded one`,
);
controls.destroy();
}
});
test('the needle follows a rotation too small for the camera-changed threshold', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
const northButton = new FakeButton();
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton: new FakeButton(), northButton },
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
// Ten degrees is far below Cesium's default percentageChanged, so
// `camera.changed` may never fire for it; the per-frame signal must.
let changedFired = 0;
viewer.camera.changed.addEventListener(() => {
changedFired += 1;
});
orbit(viewer, AUSTIN_GROUND, 10, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(
northButton.getAttribute('--camera-heading'),
'10deg',
`needle must follow a 10 degree rotation (camera.changed fired ${changedFired} time(s))`,
);
orbit(viewer, AUSTIN_GROUND, 13, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.equal(northButton.getAttribute('--camera-heading'), '13deg');
controls.destroy();
});
test('a settled camera writes nothing, and teardown releases every signal', () => {
const { viewer } = createRealViewer(AUSTIN_GROUND);
orbit(viewer, AUSTIN_GROUND, 0, Cesium.Math.toRadians(-80), 5_000);
const tiltButton = new FakeButton();
const northButton = new FakeButton();
let writes = 0;
const count = (element) => {
const setAttribute = element.setAttribute.bind(element);
const setProperty = element.style.setProperty.bind(element.style);
element.setAttribute = (name, value) => {
writes += 1;
setAttribute(name, value);
};
element.style.setProperty = (name, value) => {
writes += 1;
setProperty(name, value);
};
};
count(tiltButton);
count(northButton);
const preRenderBefore = viewer.scene.preRender.numberOfListeners;
const moveEndBefore = viewer.camera.moveEnd.numberOfListeners;
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
runNavigation: (_noun, navigate) => navigate(),
});
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore + 1);
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore + 1);
writes = 0;
for (let i = 0; i < 60; i += 1) viewer.scene.preRender.raiseEvent();
assert.equal(writes, 0, '60 frames of a settled camera write nothing');
orbit(viewer, AUSTIN_GROUND, 47, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
assert.ok(writes > 0, 'a rotated camera updates the needle');
writes = 0;
for (let i = 0; i < 20; i += 1) viewer.scene.preRender.raiseEvent();
assert.equal(writes, 0, 'and then goes quiet again');
controls.destroy();
assert.equal(viewer.scene.preRender.numberOfListeners, preRenderBefore);
assert.equal(viewer.camera.moveEnd.numberOfListeners, moveEndBefore);
writes = 0;
orbit(viewer, AUSTIN_GROUND, 120, Cesium.Math.toRadians(-80), 5_000);
viewer.scene.preRender.raiseEvent();
viewer.camera.moveEnd.raiseEvent();
tiltButton.click();
assert.equal(writes, 0, 'a destroyed binding writes nothing');
});
test('a sky-facing camera yields no frame and both actions decline', () => {
const { camera, scene } = createRealCamera();
scene.pickPositionSupported = false;
scene.pickPosition = () => null;
scene.globe.pick = () => undefined;
scene.requestRender = () => {};
camera.getPickRay = () => ({});
camera.pickEllipsoid = () => undefined;
const viewer = { camera, scene };
assert.equal(readCameraTargetFrame(viewer), null);
assert.equal(toggleCameraTilt(viewer), false);
assert.equal(resetCameraNorth(viewer), false);
});
test('center picking prefers terrain over the ellipsoid and falls through an invalid depth hit', () => {
const invalid = new Cesium.Cartesian3(Number.NaN, 0, 0);
const terrain = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 400);
const sea = Cesium.Cartesian3.fromDegrees(-97.74, 30.27, 0);
const { viewer } = createViewer({ pickPosition: () => invalid });
viewer.scene.globe.pick = () => terrain;
viewer.camera.pickEllipsoid = () => sea;
assert.equal(
pickViewTarget(viewer),
terrain,
'the rendered ground wins over sea level',
);
viewer.scene.globe.pick = () => null;
assert.equal(
pickViewTarget(viewer),
sea,
'the ellipsoid still answers when nothing is under the cursor',
);
});
test('target frame identifies a straight-down camera in local ENU space', () => {
const { viewer } = createViewer();
const frame = readCameraTargetFrame(viewer);
assert.ok(frame);
assert.ok(Math.abs(frame.pitch - Cesium.Math.toRadians(-90)) < 0.001);
assert.ok(frame.range > 999 && frame.range < 1_001);
assert.equal(frame.heading, Cesium.Math.toRadians(90));
});
test('tilt preserves the picked target and range while choosing oblique pitch', () => {
const { viewer, calls, target } = createViewer();
const before = readCameraTargetFrame(viewer);
const result = toggleCameraTilt(viewer);
const lookAt = calls.find((call) => call.type === 'lookAt');
assert.deepEqual(result, { tilted: true, pitch: OBLIQUE_PITCH });
assert.equal(lookAt.target, target);
assert.equal(lookAt.offset.range, before.range);
assert.equal(lookAt.offset.pitch, OBLIQUE_PITCH);
assert.ok(calls.some((call) => call.type === 'lookAtTransform'));
assert.ok(calls.some((call) => call.type === 'setView'));
});
test('north-up preserves target pitch and range', () => {
const { viewer, calls } = createViewer();
const before = readCameraTargetFrame(viewer);
assert.equal(resetCameraNorth(viewer), true);
const lookAt = calls.find((call) => call.type === 'lookAt');
assert.equal(lookAt.offset.heading, 0);
assert.equal(lookAt.offset.pitch, before.pitch);
assert.equal(lookAt.offset.range, before.range);
});
test('bindings route both controls and release every listener on destroy', () => {
const { viewer } = createViewer();
const tiltButton = new FakeButton();
const northButton = new FakeButton();
const navigations = [];
const toasts = [];
const controls = bindCameraOrientationControls({
viewer,
elements: { tiltButton, northButton },
runNavigation(noun, navigate) {
navigations.push(noun);
return navigate();
},
showToast: (message) => toasts.push(message),
});
tiltButton.click();
northButton.click();
assert.deepEqual(navigations, ['camera', 'camera']);
assert.deepEqual(toasts, ['Tilted view', 'North up']);
// The button shows the state the click just commanded, not a re-measurement.
assert.equal(tiltButton.getAttribute('aria-pressed'), 'true');
assert.equal(northButton.getAttribute('--camera-heading'), '90deg');
viewer.camera.heading = -1e-12;
controls.sync();
assert.equal(northButton.getAttribute('--camera-heading'), '0deg');
controls.destroy();
tiltButton.click();
assert.deepEqual(navigations, ['camera', 'camera']);
assert.equal(STRAIGHT_DOWN_PITCH, Cesium.Math.toRadians(-89));
});
+2
View File
@@ -146,6 +146,8 @@ export function readShellElements(document = globalThis.document) {
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
_shareBtn: document.getElementById('share-btn'),
_tiltMapBtn: document.getElementById('tilt-map-view'),
_northUpBtn: document.getElementById('north-up-view'),
_clearSelectedLayersBtn: document.getElementById('clear-selected-layers'),
_globalLoadingStatus: document.getElementById('global-loading-status'),
_globalLoadingLabel: document.getElementById('global-loading-label'),
+8 -1
View File
@@ -1946,7 +1946,14 @@ body.cockpit-mode
) {
display: none !important;
}
body.cockpit-mode :is(#clear-selected-layers, #share-btn, #reset-globe-view) {
body.cockpit-mode
:is(
#clear-selected-layers,
#share-btn,
#tilt-map-view,
#north-up-view,
#reset-globe-view
) {
display: none !important;
}
body.cockpit-mode #left-panel-stack {
+7 -1
View File
@@ -27,6 +27,12 @@ body.recording-mode #scene-runtime.active {
}
/* Scene playback owns layer and camera sequencing until Stop or completion. */
body.scene-playback-mode :is(#clear-selected-layers, #reset-globe-view) {
body.scene-playback-mode
:is(
#clear-selected-layers,
#tilt-map-view,
#north-up-view,
#reset-globe-view
) {
display: none !important;
}
+23
View File
@@ -28,6 +28,17 @@
left: 20px;
}
#top-center-actions {
top: max(12px, env(safe-area-inset-top));
right: 16px;
left: auto;
transform: none;
}
#style-indicator {
display: none;
}
#title-bar h1 {
font-size: 16px;
letter-spacing: 4px;
@@ -226,3 +237,15 @@
text-align: center;
}
}
@media (max-width: 520px) {
#title-bar {
top: 14px;
left: 16px;
}
#title-bar h1 > span:last-child,
#title-bar .subtitle {
display: none;
}
}
+27
View File
@@ -53,6 +53,33 @@
font-size: 18px;
}
#tilt-map-view[aria-pressed='true'] {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 14px rgba(0, 212, 255, 0.18);
}
#north-up-view {
--camera-heading: 0deg;
position: relative;
}
.camera-compass-n {
position: absolute;
top: 2px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 8px;
font-weight: 700;
line-height: 1;
}
#north-up-view .camera-compass-needle {
font-size: 17px;
transform: rotate(calc(-1 * var(--camera-heading)));
transition: transform var(--transition-fast);
}
#global-loading-status {
position: fixed;
top: 74px;
+7
View File
@@ -28,6 +28,13 @@
<button id="share-btn" type="button" aria-label="Copy share link" title="Copy share link">
<span class="share-icon" aria-hidden="true">&#x1F517;</span>
</button>
<button id="tilt-map-view" type="button" aria-label="Tilt map to oblique view" aria-pressed="false" title="Toggle straight-down and tilted map views">
<span class="material-symbols-outlined" aria-hidden="true">view_in_ar</span>
</button>
<button id="north-up-view" type="button" aria-label="Reset map to north up" title="Reset map bearing to north">
<span class="camera-compass-n" aria-hidden="true">N</span>
<span class="camera-compass-needle material-symbols-outlined" aria-hidden="true">navigation</span>
</button>
<button id="reset-globe-view" type="button" aria-label="Reset to full globe view" title="Reset camera and return to full globe view">
<span class="material-symbols-outlined" aria-hidden="true">public</span>
</button>