fix(cockpit): a new ride's TRACK ACQUIRED keeps its own time; an arrow during the ease back tilts on from the view; a site beyond the reach ends a focus; the horizon uses the WGS84 surface normal; a hop to the opposite side goes round the Earth; drop the unused in-frame listing

This commit is contained in:
Sameh Khamis committed 2026-11-08 17:12:04 -08:00
1 parent 7d6314b981
commit 6be8af17f1
10 files changed
+142 -170

No files matched your search

+8 -3
View File
@@ -1708,7 +1708,8 @@ time. TRACK ACQUIRED stays first. Then:
- **Sites**: the Mapped Installations layer's loaded records around the
sub-satellite point (`getNearby`, asked only as far as the reach extends
over the ground) that face the satellite and lie within the off-nadir
over the ground, and never past 3,000 km, so from a high orbit only the
sites within 3,000 km of the point below are listed) that face the satellite and lie within the off-nadir
reach, nearest to nadir first, the focused site always among them. A row
reads the site's name over its class and off-nadir angle; its button
(`Keep the camera on …`) focuses the site, and on the focused site's row,
@@ -1727,7 +1728,10 @@ time. TRACK ACQUIRED stays first. Then:
back to nadir, and the camera hops to the new satellite along the great
circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the
camera is on the new satellite the page shows only TRACK ACQUIRED and
RETURN, then rebuilds at once.
RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds
at once. The new satellite's TRACK ACQUIRED carries the time it was
ridden. The hop starts from where the camera was, which does not follow
the old satellite on.
Sites take two rows while satellites fill the rest, more when there are
fewer satellites. The Contact card stays hidden in orbit.
@@ -1741,7 +1745,8 @@ with the arrows' easing on a slower clock (250 ms time constant, about
0.75 s to 95%), then holds the site exactly, and cuts under
`prefers-reduced-motion`. A single click on a site's marker focuses it too:
the pick is resolved through the layer's own pick ids (`findByPick`: a named
marker's `{ installationId }` or a site entity). A click anywhere else, the
marker's `{ installationId }` or a site entity). A click anywhere else
(a site beyond the reach included), the
focused site's row, NADIR, Home or `0` ends the focus and the view eases
back to nadir on the same clock; an arrow ends it (or the ease back) and
tilts on from where the view is. The layer reloads its thinned view as the
+20 -80
View File
@@ -24,8 +24,9 @@ export const ORBITAL_FOCUS_EASE_TAU_MS = 250;
/** How often the orbital Live Signals rows are rebuilt, ms. */
export const ORBITAL_REACH_REFRESH_MS = 1000;
/** The most mapped sites offered for the rows, nearest to nadir first. */
export const ORBITAL_REACH_LIST_MAX = 6;
/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */
const WGS84_A2 = 6378137 ** 2;
const WGS84_B2 = 6356752.314245179 ** 2;
/** Slack on the reach limit so a target held exactly at 45° stays. */
const REACH_EPSILON_DEG = 1e-6;
@@ -71,14 +72,15 @@ export function focusLook(nadir, satellite, target) {
const along = dot(v, nadir.direction);
const up = dot(v, nadir.up);
const right = Math.min(1, Math.max(-1, dot(v, nadir.right)));
// The target's own zenith: the satellite is above its horizon when the
// line from the target to it points up.
const targetRadius = Math.hypot(target.x, target.y, target.z);
// The target's own zenith, the WGS84 surface normal there: the satellite
// is above its horizon when the line from the target to it points up.
const normal = {
x: target.x / WGS84_A2,
y: target.y / WGS84_A2,
z: target.z / WGS84_B2,
};
const visible =
targetRadius > 0 &&
-(line.x * target.x + line.y * target.y + line.z * target.z) /
targetRadius >
0;
Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0;
const yawDeg = toDegrees(Math.asin(right));
const pitchDeg = toDegrees(Math.atan2(up, along));
return {
@@ -106,38 +108,6 @@ export function focusInReach(look) {
);
}
/**
* Whether `target` is inside the picture as it is now framed.
* @param {{direction: object, up: object, right: object}} view The camera's
* orientation (the nadir frame tilted by the eased look).
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @param {{verticalFovRad: number, aspect?: number}} frame
* @returns {boolean}
*/
export function inFrame(
view,
satellite,
target,
{ verticalFovRad, aspect = 1 },
) {
if (!view || !finiteVector(satellite) || !finiteVector(target)) return false;
if (!(verticalFovRad > 0)) return false;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const along = dot(line, view.direction);
if (!(along > 0)) return false;
const halfTan = Math.tan(verticalFovRad / 2);
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
return (
Math.abs(dot(line, view.up)) / along <= halfTan &&
Math.abs(dot(line, view.right)) / along <= halfTan * ratio
);
}
/**
* Ease what is left of the turn onto a focus target toward nothing: the
* look's easing (`easeLook`) on the focus's slower clock. The view is the
@@ -171,53 +141,23 @@ export function focusEaseDtMs(dtMs) {
}
/**
* The mapped points the view shows now or can be turned to: those facing
* the satellite and either within the off-nadir reach (`reachable`) or in
* the frame as it is. Nearest to nadir first, at most `max`.
* The mapped points the view can be turned to: those facing the satellite
* and within the off-nadir reach, nearest to nadir first.
* @param {Iterable<{key: string, position: {x: number, y: number,
* z: number}}>} candidates
* @param {{nadir: object, view: object, satellite: object,
* verticalFovRad: number, aspect?: number, max?: number}} frame
* @returns {{count: number, entries: Array<{key: string,
* offNadirDeg: number, reachable: boolean, look: object}>}} `count` is
* every point in reach, `entries` the nearest.
* @param {{nadir: object, satellite: object}} frame
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
*/
export function pointsInReach(
candidates,
{
nadir,
view,
satellite,
verticalFovRad,
aspect = 1,
max = ORBITAL_REACH_LIST_MAX,
},
) {
export function pointsInReach(candidates, { nadir, satellite }) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return { count: 0, entries };
if (!nadir || !finiteVector(satellite)) return entries;
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!look?.visible) continue;
const reachable = focusInReach(look);
if (
!reachable &&
!inFrame(view, satellite, candidate.position, { verticalFovRad, aspect })
)
continue;
entries.push({
key: candidate.key,
offNadirDeg: look.offNadirDeg,
reachable,
look,
});
if (!focusInReach(look)) continue;
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
}
entries.sort(
return entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
const limit =
max === Infinity || (Number.isInteger(max) && max >= 0)
? max
: ORBITAL_REACH_LIST_MAX;
return { count: entries.length, entries: entries.slice(0, limit) };
}
+23 -45
View File
@@ -6,12 +6,10 @@ import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
ORBITAL_FOCUS_EASE_TAU_MS,
ORBITAL_REACH_LIST_MAX,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
inFrame,
pointsInReach,
} from './cockpitOrbitalFocus.js';
import {
@@ -200,50 +198,30 @@ test('the reach list keeps points in reach or in frame, nearest first, bounded',
for (let i = 0; i < 80; i += 1)
candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) });
candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) });
const frame = {
nadir,
view: nadir,
satellite,
verticalFovRad: (60 * Math.PI) / 180,
aspect: 16 / 9,
};
const all = pointsInReach(candidates, { ...frame, max: 100 });
assert.ok(all.entries.every((entry) => entry.key !== 'far-side'));
const angles = all.entries.map((entry) => entry.offNadirDeg);
const entries = pointsInReach(candidates, { nadir, satellite });
assert.ok(entries.every((entry) => entry.key !== 'far-side'));
const angles = entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
assert.equal(all.entries[0].key, 'east-0');
assert.equal(all.entries[0].offNadirDeg, 0);
// The widest frame (16:9 at 60° tall) reaches past 45° across: those
// points are listed, but cannot be focused.
const beyond = all.entries.filter((entry) => !entry.reachable);
assert.ok(beyond.length > 0, 'some only in frame');
for (const entry of beyond) assert.ok(Math.abs(entry.look.yawDeg) > 45);
for (const entry of all.entries.filter((e) => e.reachable))
assert.ok(Math.abs(entry.look.yawDeg) <= 45);
// Out of frame and out of reach: not listed.
assert.ok(all.count < 80);
const bounded = pointsInReach(candidates, frame);
assert.equal(bounded.entries.length, ORBITAL_REACH_LIST_MAX);
assert.equal(bounded.count, all.count, 'the count is all of them');
assert.deepEqual(
bounded.entries.map((entry) => entry.key),
all.entries.slice(0, ORBITAL_REACH_LIST_MAX).map((entry) => entry.key),
);
assert.deepEqual(pointsInReach([], frame), { count: 0, entries: [] });
assert.equal(entries[0].key, 'east-0');
assert.equal(entries[0].offNadirDeg, 0);
// Only those within the 45° tilt are listed.
assert.ok(entries.length > 0 && entries.length < 80);
for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45);
const beyond = focusLook(nadir, satellite, candidates[entries.length].position);
assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach');
assert.deepEqual(pointsInReach([], { nadir, satellite }), []);
});
test('in frame follows the view, its height and its width', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const target = geodeticToEcef(0, 3);
const narrow = { verticalFovRad: (5 * Math.PI) / 180, aspect: 1 };
assert.equal(inFrame(nadir, satellite, target, narrow), false);
const look = focusLook(nadir, satellite, target);
const tilted = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
assert.equal(inFrame(tilted, satellite, target, narrow), true);
assert.equal(inFrame(tilted, satellite, target, { verticalFovRad: 0 }), false);
test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => {
const target = geodeticToEcef(45, 0);
const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target);
const satelliteAtElevation = (elevationDeg) => {
const e = Cesium.Math.toRadians(elevationDeg);
const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7);
return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3());
};
const below = satelliteAtElevation(-0.1);
assert.equal(focusLook(nadirOrientation(below), below, target).visible, false);
const above = satelliteAtElevation(0.1);
assert.equal(focusLook(nadirOrientation(above), above, target).visible, true);
});
+23 -3
View File
@@ -22,8 +22,8 @@ function finiteVector(v) {
/**
* Where the camera is part-way through a hop: along the great circle
* between the two positions (never through the Earth), its distance from the
* centre interpolated linearly. Both ends may move during the hop; pass
* their positions now.
* centre interpolated linearly. Opposite ends turn about a fixed axis
* square to them, so even then the path stays off the Earth.
* @param {{x: number, y: number, z: number}} from
* @param {{x: number, y: number, z: number}} to
* @param {number} progress Eased, 0..1.
@@ -39,6 +39,27 @@ export function hopPosition(from, to, progress) {
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
const cosine = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z));
const omega = Math.acos(cosine);
const r = ra + (rb - ra) * p;
if (Math.PI - omega < 1e-6) {
// No single great circle joins opposite points: turn about the axis
// square to `a` that is least aligned with the Earth's.
const pole =
Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 };
const along = pole.x * a.x + pole.y * a.y + pole.z * a.z;
const c = {
x: pole.x - along * a.x,
y: pole.y - along * a.y,
z: pole.z - along * a.z,
};
const rc = Math.hypot(c.x, c.y, c.z);
const cos = Math.cos(p * Math.PI);
const sin = Math.sin(p * Math.PI);
return {
x: (a.x * cos + (c.x / rc) * sin) * r,
y: (a.y * cos + (c.y / rc) * sin) * r,
z: (a.z * cos + (c.z / rc) * sin) * r,
};
}
let ka = 1 - p;
let kb = p;
if (omega > 1e-9) {
@@ -46,7 +67,6 @@ export function hopPosition(from, to, progress) {
ka = Math.sin((1 - p) * omega) / sine;
kb = Math.sin(p * omega) / sine;
}
const r = ra + (rb - ra) * p;
return {
x: (a.x * ka + b.x * kb) * r,
y: (a.y * ka + b.y * kb) * r,
+13
View File
@@ -26,3 +26,16 @@ test('the hop follows the great circle and lands on the satellite', () => {
assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9);
assert.equal(hopPosition(null, to, 0.5), null);
});
test('a hop to the opposite side goes round the Earth, not through it', () => {
const from = at(0, 0, 400_000);
const to = at(0, 180, 400_000);
const radius = Cesium.Cartesian3.magnitude(from);
for (const p of [0.25, 0.5, 0.75]) {
const point = hopPosition(from, to, p);
assert.ok(Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3, `p=${p}`);
}
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis);
});
+7 -3
View File
@@ -389,10 +389,14 @@ export function createControls({ state: layerState, services, parts, source }) {
{ excludeIds = [], excludeClasses = [] } = {},
) {
const nearest = [];
if (!center || !layerState._points || layerState._points.size === 0)
const cap = Number.isFinite(maxCount) ? Math.floor(maxCount) : 8;
if (
cap < 1 ||
!center ||
!layerState._points ||
layerState._points.size === 0
)
return nearest;
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 8;
const excluded = new Set(excludeIds.map(String));
const classes = new Set(excludeClasses);
const dense = layerState._denseIds.length > 0;
@@ -64,4 +64,7 @@ test('the nearest shown satellites come first, bounded, with their distance', ()
assert.equal(nearest[0].satelliteClass, 'imaging');
assert.equal(methods.getNearestPositions(CENTRE, 1, { excludeIds: [6] })[0].label, 'DEB');
assert.deepEqual(methods.getNearestPositions(null, 3), []);
assert.deepEqual(methods.getNearestPositions(CENTRE, 0), []);
assert.deepEqual(methods.getNearestPositions(CENTRE, 0.5), []);
assert.equal(methods.getNearestPositions(CENTRE, 2.5).length, 2);
});
+23 -35
View File
@@ -10,10 +10,7 @@
*/
import * as Cesium from 'cesium';
import { clampOrbitalLook, easeLook } from '../cockpitLook.js';
import {
ORBITAL_EARTH_RADIUS_M,
offNadirOrientation,
} from '../cockpitOrbital.js';
import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js';
import {
easeFocusRemainder,
focusEaseDtMs,
@@ -181,8 +178,12 @@ function startFocus(site) {
export function releaseOrbitalFocus({ handOver = false } = {}) {
const state = this.orbital;
if (!state?.focus) {
// An arrow during the ease back takes the arrows' own clock again.
if (handOver && state) state.focusReleasing = false;
// An arrow during the ease back takes the arrows' own clock again and
// tilts on from where the view is, not from the nadir it was easing to.
if (handOver && state?.focusReleasing) {
state.lookTarget = clampOrbitalLook(state.lookView);
state.focusReleasing = false;
}
return false;
}
state.focus = null;
@@ -249,8 +250,7 @@ export function onOrbitalClick(position) {
if (site) {
if (state.focus?.key === String(site.id)) return true;
if (startFocus.call(this, site)) return true;
// A mapped site beyond the reach leaves the view as it is.
return false;
// A mapped site beyond the reach is a click elsewhere: the focus ends.
}
releaseOrbitalFocus.call(this);
return false;
@@ -283,8 +283,9 @@ export function routeSceneClick() {
/**
* The orbital view's look for one frame, while a focus holds or is ending.
* A held focus aims at its target: the live look plus whatever is left of
* the turn onto it. The focus ends, easing back, when its site is gone (its
* layer off, the site no longer loaded) or beyond the reach. Also rebuilds
* the turn onto it. The focus ends, easing back, when its layer is turned
* off or its site is beyond the reach (a site the layer stops loading is
* held from its last record). Also rebuilds
* the orbital Live Signals rows on their own clock.
* @param {object} state The orbital session.
* @param {{direction: object, up: object, right: object}} nadir This
@@ -389,9 +390,8 @@ export function orbitalFocusReadout() {
* with the layer off.
* @param {{latitude: number, longitude: number, altitudeM: number}} info
* The ridden satellite.
* @returns {{count: number, entries: Array<{site: object,
* offNadirDeg: number, focused: boolean}>}} `count` also counts sites in
* the frame but beyond the reach.
* @returns {{entries: Array<{site: object, offNadirDeg: number,
* focused: boolean}>}}
*/
export function orbitalSitesInReach(info) {
const state = this.orbital;
@@ -401,9 +401,9 @@ export function orbitalSitesInReach(info) {
// then, and no row is left to focus from.
if (state && !sitesLayerOn(this)) state.listedSites = new Map();
if (!frame || typeof layer?.getNearby !== 'function' || !sitesLayerOn(this))
return { count: 0, entries: [] };
return { entries: [] };
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return { count: 0, entries: [] };
return { entries: [] };
const rangeM = reachGroundRangeM(info.altitudeM);
let sites = [];
try {
@@ -449,27 +449,15 @@ export function orbitalSitesInReach(info) {
seenMs: nowMs,
});
state.listedSites = remembered;
const frustum = this.viewer.camera?.frustum;
const reach = pointsInReach(candidates, {
nadir: frame.nadir,
view: offNadirOrientation(frame.nadir, {
northDeg: state.lookView.pitchDeg,
eastDeg: state.lookView.yawDeg,
}),
satellite: frame.position,
verticalFovRad: state.verticalFov,
aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1,
max: Infinity,
});
const focusKey = state.focus?.key ?? null;
return {
count: reach.count,
entries: reach.entries
.filter((entry) => entry.reachable)
.map(({ key, offNadirDeg }) => ({
site: byId.get(key),
offNadirDeg,
focused: key === focusKey,
})),
entries: pointsInReach(candidates, {
nadir: frame.nadir,
satellite: frame.position,
}).map(({ key, offNadirDeg }) => ({
site: byId.get(key),
offNadirDeg,
focused: key === focusKey,
})),
};
}
+19 -1
View File
@@ -573,9 +573,14 @@ test('a click on a site marker focuses it through the layer; a click elsewhere e
const largest = run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(largest < MAX_TURN_PER_FRAME_DEG);
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6);
// A site beyond the reach is not focused.
// A site beyond the reach is not focused, and ends a focus as a click
// elsewhere does.
scene.click(SITES.far.at);
assert.equal(cockpit.orbital.focus ?? null, null);
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id);
scene.click(SITES.far.at);
assert.equal(cockpit.orbital.focus, null);
});
test('a click a tool owns is left alone', async (t) => {
@@ -676,7 +681,12 @@ test('a satellite row rides it, staying in orbit; RETURN goes back', async (t) =
run(scene, 5);
const before = Cesium.Cartesian3.clone(scene.shots.at(-1).destination);
const firstTrack = cockpit.signalItems.find((item) => item.key === 'track');
firstTrack.timestamp -= 60_000;
satelliteRow(scene, 49260).click();
const newTrack = cockpit.signalItems.filter((item) => item.key === 'track');
assert.equal(newTrack.length, 1);
assert.ok(newTrack[0].timestamp > firstTrack.timestamp, 'the new ride’s own time');
assert.deepEqual(satellites.calls.slice(-2), [['track', 49260], ['subject', 49260]]);
assert.equal(viewer.trackedEntity, undefined, 'the Cockpit keeps the camera');
assert.equal(cockpit.subjectId, 49260);
@@ -793,6 +803,8 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock',
scene.click(EMPTY_GROUND);
scene.frame();
assert.equal(cockpit.orbital.focusReleasing, true);
const view = { ...cockpit.orbital.lookView };
assert.ok(Math.abs(view.pitchDeg) > 1 || Math.abs(view.yawDeg) > 1, 'still turned');
cockpit.onKeyDown({
key: 'ArrowUp',
target: document.body,
@@ -800,6 +812,12 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock',
stopImmediatePropagation() {},
});
assert.equal(cockpit.orbital.focusReleasing, false);
// The arrow tilts on from where the view is, not from nadir.
assert.ok(
Math.abs(cockpit.orbital.lookTarget.yawDeg - view.yawDeg) < 1e-6,
'across kept',
);
assert.ok(cockpit.orbital.lookTarget.pitchDeg > view.pitchDeg, 'tilted on up');
});
test('leaving orbit leaves no orbital rows to act on', async (t) => {
+3
View File
@@ -150,6 +150,9 @@ export function switchOrbitalSatellite(noradId) {
this.lastHudUpdateMs = 0;
this.lastContextUpdateMs = 0;
this.dispatchCockpitModeChanged(true, info);
// The new satellite's TRACK ACQUIRED replaces the old one, time and all.
this.signalItems = this.signalItems.filter((item) => item.key !== 'track');
this.signalSignatures.delete('track');
this.pushCockpitSignal(
'track',
'track',