mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
The camera orientation controls publish whether the map is tilted and the camera's whole-degree heading instead of writing the buttons' attributes, and answer tilt and north-up through the globe actions store. The new GlobeCompass renders the heading, so a rotating camera re-renders only the compass. The store drops an unchanged camera state and keeps each clear owner's busy state, so the buttons show the shell that answers them: a shell replaced by a newer one, or disposed mid-clear, leaves the live shell's state alone, and an older shell that answers again shows its own. createOwners gains owns() and ownerOf().
362 lines
12 KiB
JavaScript
362 lines
12 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
import { isPickedWorldPosition } from '../data/scenePick.js';
|
|
import { t } from '../i18n/index.js';
|
|
|
|
function trackedTarget(viewer) {
|
|
const entity = viewer.trackedEntity;
|
|
// Read the visual's sample first; sampling a flight CallbackProperty twice
|
|
// can advance dead reckoning ahead of the displayed aircraft.
|
|
return (
|
|
entity?.gevDisplayPosition?.() ||
|
|
entity?.position?.getValue(viewer.clock?.currentTime)
|
|
);
|
|
}
|
|
|
|
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 = viewer?.trackedEntity
|
|
? trackedTarget(viewer)
|
|
: 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. */
|
|
function setCameraTargetFrame(viewer, frame) {
|
|
const camera = viewer?.camera;
|
|
if (!camera || !frame?.target || !Number.isFinite(frame.range)) return false;
|
|
try {
|
|
const trackedTransform = viewer.trackedEntity
|
|
? Cesium.Matrix4.clone(camera.transform)
|
|
: null;
|
|
camera.lookAt(
|
|
frame.target,
|
|
new Cesium.HeadingPitchRange(
|
|
normalizedHeading(frame.heading),
|
|
frame.pitch,
|
|
frame.range,
|
|
),
|
|
);
|
|
if (trackedTransform) {
|
|
// Preserve EntityView's reference frame (including satellite frames).
|
|
// Its next update carries this new local offset along with the target.
|
|
camera.lookAtTransform(trackedTransform);
|
|
viewer.scene?.requestRender?.();
|
|
return true;
|
|
}
|
|
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}.
|
|
*/
|
|
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));
|
|
}
|
|
|
|
/** Ease an orbit without handing off the entity that owns the follow camera. */
|
|
export function createCameraOrientationAnimator(
|
|
viewer,
|
|
{ now = () => performance.now(), duration = 650 } = {},
|
|
) {
|
|
let remove = null;
|
|
let pending = null;
|
|
const cancel = () => {
|
|
remove?.();
|
|
remove = null;
|
|
pending = null;
|
|
};
|
|
function animate(frame, destination) {
|
|
cancel();
|
|
if (!viewer.scene?.preUpdate?.addEventListener || duration <= 0)
|
|
return setCameraTargetFrame(viewer, { ...frame, ...destination });
|
|
const entity = viewer.trackedEntity;
|
|
pending = destination;
|
|
const start = now();
|
|
const headingDelta = Cesium.Math.negativePiToPi(
|
|
destination.heading - frame.heading,
|
|
);
|
|
remove = viewer.scene.preUpdate.addEventListener(() => {
|
|
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
|
cancel();
|
|
return;
|
|
}
|
|
const progress = Cesium.Math.clamp((now() - start) / duration, 0, 1);
|
|
const eased = Cesium.EasingFunction.CUBIC_IN_OUT(progress);
|
|
const target = entity ? trackedTarget(viewer) : frame.target;
|
|
if (
|
|
!isPickedWorldPosition(target) ||
|
|
!setCameraTargetFrame(viewer, {
|
|
...frame,
|
|
target,
|
|
heading: frame.heading + headingDelta * eased,
|
|
pitch: Cesium.Math.lerp(frame.pitch, destination.pitch, eased),
|
|
})
|
|
) {
|
|
cancel();
|
|
return;
|
|
}
|
|
if (progress === 1) cancel();
|
|
});
|
|
viewer.scene.requestRender?.();
|
|
return true;
|
|
}
|
|
return {
|
|
animate,
|
|
cancel,
|
|
get destination() {
|
|
return pending;
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Answer the two map-orientation actions and publish the state their buttons
|
|
* show: whether the map is tilted and the camera's whole-degree heading.
|
|
* The state is published on every rendered frame; its store keeps only a
|
|
* change, so an owner that takes the buttons back shows its state at once.
|
|
*/
|
|
export function bindCameraOrientationControls({
|
|
viewer,
|
|
publish,
|
|
runNavigation,
|
|
showToast,
|
|
}) {
|
|
const removers = [];
|
|
let destroyed = false;
|
|
// 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;
|
|
const animator = createCameraOrientationAnimator(viewer);
|
|
|
|
/** Recompute the tilt state exactly, paying for one pick. */
|
|
const refreshTilt = () => {
|
|
if (destroyed) return;
|
|
tilted = frameIsTilted(readCameraTargetFrame(viewer));
|
|
sync();
|
|
};
|
|
|
|
/**
|
|
* Publish the two controls' state.
|
|
*
|
|
* 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.
|
|
* That measured the controls' own work; what a publish renders is the
|
|
* view's (the globe actions compass alone renders the heading).
|
|
*/
|
|
function sync() {
|
|
if (destroyed) return;
|
|
const next = {
|
|
tilted,
|
|
heading: Math.round(headingDegrees(viewer?.camera)) % 360,
|
|
};
|
|
publish?.(next);
|
|
}
|
|
|
|
function tilt() {
|
|
if (destroyed) return;
|
|
const pending = animator.destination;
|
|
const result = runNavigation('camera', () => {
|
|
const frame = readCameraTargetFrame(viewer);
|
|
if (!frame) return false;
|
|
const nextTilted = !frameIsTilted(pending || frame);
|
|
const pitch = nextTilted ? OBLIQUE_PITCH : STRAIGHT_DOWN_PITCH;
|
|
return animator.animate(frame, {
|
|
heading: pending?.heading ?? frame.heading,
|
|
pitch,
|
|
})
|
|
? { tilted: nextTilted, pitch }
|
|
: false;
|
|
});
|
|
if (result) {
|
|
showToast?.(result.tilted ? t('Tilted view') : t('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();
|
|
}
|
|
|
|
function northUp() {
|
|
if (destroyed) return;
|
|
const pending = animator.destination;
|
|
const result = runNavigation('camera', () => {
|
|
const frame = readCameraTargetFrame(viewer);
|
|
return (
|
|
frame &&
|
|
animator.animate(frame, {
|
|
heading: 0,
|
|
pitch: pending?.pitch ?? frame.pitch,
|
|
})
|
|
);
|
|
});
|
|
if (result) showToast?.(t('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);
|
|
subscribe(viewer?.trackedEntityChanged, animator.cancel);
|
|
for (const type of ['pointerdown', 'wheel']) {
|
|
const canvas = viewer?.scene?.canvas;
|
|
canvas?.addEventListener?.(type, animator.cancel, { passive: true });
|
|
removers.push(() => canvas?.removeEventListener?.(type, animator.cancel));
|
|
}
|
|
refreshTilt();
|
|
|
|
return {
|
|
tilt,
|
|
northUp,
|
|
sync,
|
|
refreshTilt,
|
|
cancel: animator.cancel,
|
|
destroy() {
|
|
if (destroyed) return;
|
|
destroyed = true;
|
|
animator.cancel();
|
|
for (const remove of removers.splice(0)) remove();
|
|
},
|
|
};
|
|
}
|