mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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:
1 parent
fb5ee5aa36
commit
d9fac6d88e
14 files changed
+895
-13
No files matched your search
@@ -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
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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">🔗</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>
|
||||
|
||||
Reference in new issue
Block a user