Merge pull request #1580 from ZloyS/denis/gev-orbital-focus

feat(cockpit): keep the orbital camera on a ground target
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-08 17:21:27 -08:00
commit 4ee617cf42
36 files changed
+2989 -27

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Hold the orbital camera on a mapped site as the satellite passes over it,
from a click on its marker or its Live Signals row, and list nearby sites
and satellites in orbit's Live Signals, where a row focuses a site or rides
a satellite (Denis Groshev, @ZloyS, #1580).
+75 -2
View File
@@ -1642,7 +1642,8 @@ and the saved cloud preference stands for the next aircraft or bridge. The layer
companions, its own orbit ring and its detection bracket, and draws every
other orbit ring (the ISS ring included) at 30% of its alpha; other
satellites' points and labels stay. Scene clicks and the layer's Escape are
ignored while it is ridden. The optical zoom steps 1×, 2×, 4× and 8× for
ignored by the satellites layer while it is ridden (a click on a mapped
site focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for
every satellite with `+` (or `=`) and `-` (or `_`), the panel's buttons and
the mouse wheel (one step per notch; trackpad scrolling is summed to a step):
the 1× frame is 60° vertical in low orbit and fits the Earth's disk with
@@ -1699,6 +1700,78 @@ imagery under the view has loaded (at most 2.5 s, `zoomSettleHolding`), then
the reticle locks. The look keys and zoom keys keep the bridge's focus
guards.
In orbit the briefing's Live Signals page (OBSERVED / MAPPED PINGS) lists
what is around the satellite, as the aircraft Cockpit lists the contacts
of its Contacts snapshot (`src/ui/cockpitOrbitalSignals.js`): five rows at
most, rebuilt once a second with stable keys, so a row keeps its place and
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, 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,
marked as the subject is (`FOCUS · …`, `Release the camera from …`), ends
the focus. With Mapped Installations off the layer keeps its records, so
nothing is listed then: a SITES STANDBY row says to turn the layer on.
- **Satellites**: the nearest shown satellites to the ridden one, debris
left out (the satellites layer's bounded `getNearestPositions`, measured
from the satellite itself), with their bearing from the top of the frame
and distance. A row (`Ride …`) rides that satellite. While there is a
satellite ridden before this one, a RETURN · *name* row goes back to it; if
that satellite has left the catalogue it is dropped and RETURN offers the
one before. Riding stays in orbit: the satellites layer tracks the new
satellite (`trackById`), the Cockpit takes the camera back at once and
marks it as the ridden subject, any ground focus ends with the view easing
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 (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.
The focus (`src/ui/cockpitOrbitalFocus.js`, geometry in
`src/cockpitOrbitalFocus.js`) aims the look each frame from the satellite's
position to the site's Earth-fixed position in the same track-up nadir
frame the arrows tilt, so the site stays at the centre as the satellite
moves. The turn onto it (and between sites) eases what is left of the swing
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
(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
camera moves (about once a second in orbit), so a site in view can leave
its records for a moment: while the layer is on, a site seen in the last
10 s stays a candidate (sites do not move), so the rows hold still and a
row's site can be focused, and a focused site keeps its last position. The focus ends by
itself, easing back, when the site leaves the reach (either tilt axis past
±45°, or the site below the satellite's horizon), when the layer is turned
off, when another satellite is ridden, and with the orbital view. A site
beyond the reach cannot be focused. While focused the readout gives the
single off-nadir angle to the site, as image metadata does
(`OFF-NADIR 23.4°`, updated on the HUD's beat and not announced), names it
in a polite live region (`FOCUS · Camp Alpha`), and the swath reads
approximate as for a tilt, scaled by the tilt onto the site. Clicks still
reach Cesium's own handler and never untrack the satellite; a click a tool
owns (drawing) is ignored, and the draw tool still borrows the click while
it draws. A double-click on a site keeps or starts the focus on it and
never reaches Cesium's double-click, which would track the site and end the
view (the Cockpit's double-click routing in `src/ui/cockpitInput.js`); any
other double-click goes on as before. `src/ui/cockpitOrbitalFocus.test.mjs`
covers the rows, the easing in and out, the reach release, switching sites
and satellites, the hop, the layer turned off, click handling and the
readout, against the layers' method shapes.
The HUD (`#cockpit-hud[data-subject='satellite']`, body class
`cockpit-orbit`) mounts `CockpitOrbital` over the picture: name, class,
altitude, orbital speed, sub-satellite point, TRACK UP (NORTH UP without a
@@ -1727,7 +1800,7 @@ unfolds the two element lines; each row is left out when the satellites layer do
know it. The
aircraft and bridge instruments, tapes, compass, radar, look controls,
position readout, route card and Contact card are hidden; the vision-style
switcher, briefing card (Local and Regional for the sub-satellite point; the
switcher, briefing card (Live Signals as above, Local and Regional for the sub-satellite point; the
regional brief refreshes at most once a minute) and the reset and EXIT ORBIT
buttons stay, square-cornered like the bridge. Exit (`Escape`, EXIT
ORBIT) restores the camera's own field of view exactly and returns to the
+21
View File
@@ -903,6 +903,13 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/layers/installations/classLabel.js",
"src/data/installationNames.js",
"src/i18n/marker.js",
"src/data/placeBelow.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
@@ -926,6 +933,7 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/ui/panelRailGeometry.js",
@@ -1000,6 +1008,7 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
@@ -1053,6 +1062,11 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/layers/installations/classLabel.js",
"src/data/placeBelow.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
@@ -2448,6 +2462,7 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
@@ -2893,6 +2908,10 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/ui/cockpitOrbitalSignals.js",
"src/ui/cockpitOrbitalSatellites.js",
"src/cockpitOrbitalSatellites.js",
"src/data/placeBelow.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -4237,6 +4256,8 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/cockpitOrbitalSatellites.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
+163
View File
@@ -0,0 +1,163 @@
/**
* Ground focus for the orbital view: the look that keeps a point on the
* ground at the centre of the picture as the satellite moves, whether that
* look is within the off-nadir reach, and which mapped points the view can
* show or be turned to. The look is the orbital view's own
* `{yawDeg, pitchDeg}` over its nadir frame (see `offNadirOrientation` in
* `src/cockpitOrbital.js`), so a focused view is a tilt like any other.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
import {
LOOK_EASE_TAU_MS,
ORBITAL_LOOK_LIMIT_DEG,
easeLook,
} from './cockpitLook.js';
/**
* How long a focus takes to swing the view onto its target, or back to
* nadir when it ends: the look's own easing curve, on a slower clock. Three
* of them (about 0.75 s) close 95% of the turn.
*/
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;
/** 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;
const toDegrees = (rad) => (rad * 180) / Math.PI;
function dot(a, b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* The look that centres `target` in the picture, in the nadir frame's tilt
* (north first, then east; see `offNadirOrientation`): east is the angle off
* the frame's top-bottom plane, north the angle along it. Also the single
* angle between nadir and the line of sight, the image's off-nadir angle,
* and whether the target faces the satellite (above its horizon).
* @param {{direction: object, up: object, right: object}} nadir From
* `nadirOrientation`.
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @returns {{yawDeg: number, pitchDeg: number, offNadirDeg: number,
* visible: boolean}|null} Null without usable positions.
*/
export function focusLook(nadir, satellite, target) {
if (!nadir || !finiteVector(satellite) || !finiteVector(target)) return null;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const range = Math.hypot(line.x, line.y, line.z);
if (!(range > 1)) return null;
const v = { x: line.x / range, y: line.y / range, z: line.z / range };
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 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 =
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 {
yawDeg: yawDeg === 0 ? 0 : yawDeg,
pitchDeg: pitchDeg === 0 ? 0 : pitchDeg,
offNadirDeg: toDegrees(Math.acos(Math.min(1, Math.max(-1, along)))),
visible,
};
}
/**
* Whether a focus look is within the off-nadir reach: the target faces the
* satellite and each axis is within the arrows' ±45° clamp.
* @param {{yawDeg: number, pitchDeg: number, visible?: boolean}|null} look
* @returns {boolean}
*/
export function focusInReach(look) {
if (!look || look.visible === false) return false;
const limit = ORBITAL_LOOK_LIMIT_DEG + REACH_EPSILON_DEG;
return (
Number.isFinite(look.yawDeg) &&
Number.isFinite(look.pitchDeg) &&
Math.abs(look.yawDeg) <= limit &&
Math.abs(look.pitchDeg) <= limit
);
}
/**
* 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
* live target's look plus this remainder, so once it lands the view holds
* the target exactly, without the lag of chasing it. Reduced motion cuts.
* @param {{yawDeg: number, pitchDeg: number}} remainder View minus target.
* @param {number} dtMs Milliseconds since the previous frame.
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeFocusRemainder(
remainder,
dtMs,
{ immediate = false } = {},
) {
return easeLook(remainder, { yawDeg: 0, pitchDeg: 0 }, focusEaseDtMs(dtMs), {
immediate,
});
}
/**
* A frame's time on the focus's clock, for `easeLook`: its fixed time
* constant scaled to ORBITAL_FOCUS_EASE_TAU_MS.
* @param {number} dtMs
* @returns {number}
*/
export function focusEaseDtMs(dtMs) {
return Number.isFinite(dtMs) && dtMs > 0
? (dtMs * LOOK_EASE_TAU_MS) / ORBITAL_FOCUS_EASE_TAU_MS
: 0;
}
/**
* 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, satellite: object}} frame
* @returns {Array<{key: string, offNadirDeg: number, look: object}>}
*/
export function pointsInReach(candidates, { nadir, satellite }) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return entries;
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!focusInReach(look)) continue;
entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look });
}
return entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
}
+227
View File
@@ -0,0 +1,227 @@
// src/cockpitOrbitalFocus.test.mjs — the geometry of a ground focus in the
// orbital view: the look that centres a target, its off-nadir angle, the
// reach, the easing onto and off a target, and the reach list.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
ORBITAL_FOCUS_EASE_TAU_MS,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
pointsInReach,
} from './cockpitOrbitalFocus.js';
import {
ORBITAL_EARTH_RADIUS_M,
nadirOrientation,
offNadirAngleDeg,
offNadirOrientation,
} from './cockpitOrbital.js';
import { LOOK_EASE_TAU_MS } from './cockpitLook.js';
const near = (actual, expected, label, tolerance = 1e-9) =>
assert.ok(
Math.abs(actual - expected) <= tolerance,
`${label}: ${actual} not within ${tolerance} of ${expected}`,
);
const unit = (v) => {
const n = Math.hypot(v.x, v.y, v.z);
return { x: v.x / n, y: v.y / n, z: v.z / n };
};
const sub = (a, b) => ({ x: a.x - b.x, y: a.y - b.y, z: a.z - b.z });
const angleBetweenDeg = (a, b) => {
const u = unit(a);
const v = unit(b);
const cosine = Math.min(1, Math.max(-1, u.x * v.x + u.y * v.y + u.z * v.z));
return (Math.acos(cosine) * 180) / Math.PI;
};
/** A point on a sphere of radius `radiusM`, ECEF. */
const onSphere = (latDeg, lonDeg, radiusM) => {
const lat = (latDeg * Math.PI) / 180;
const lon = (lonDeg * Math.PI) / 180;
return {
x: radiusM * Math.cos(lat) * Math.cos(lon),
y: radiusM * Math.cos(lat) * Math.sin(lon),
z: radiusM * Math.sin(lat),
};
};
/** A point on the WGS84 ellipsoid, as the globe draws it. */
const geodeticToEcef = (latDeg, lonDeg, heightM = 0) =>
Cesium.Cartesian3.fromDegrees(lonDeg, latDeg, heightM);
const ISS_ALT_M = 420_000;
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
geodeticToEcef(latDeg, lonDeg, altitudeM);
test('the focus look turns the nadir frame onto the target exactly', () => {
const satellite = satelliteAt(30, -97);
for (const heading of [null, 0, 51.6, 200]) {
const nadir = nadirOrientation(satellite, { headingDeg: heading });
for (const [lat, lon] of [
[31.5, -96],
[28.2, -99.4],
[30, -97],
[32.9, -97.1],
]) {
const target = geodeticToEcef(lat, lon, 300);
const look = focusLook(nadir, satellite, target);
const view = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
near(
angleBetweenDeg(view.direction, sub(target, satellite)),
0,
`centred at ${lat},${lon} heading ${heading}`,
1e-5,
);
// The single angle the readout gives is the tilt's total angle.
near(
look.offNadirDeg,
offNadirAngleDeg({ northDeg: look.pitchDeg, eastDeg: look.yawDeg }),
'one off-nadir angle',
1e-9,
);
assert.equal(look.visible, true);
}
}
});
test('the off-nadir angle is the angle between nadir and the line of sight', () => {
// On a sphere: a target `central` radians from the sub-satellite point is
// seen atan(R sin γ / (R + h − R cos γ)) off nadir.
const R = ORBITAL_EARTH_RADIUS_M;
const satellite = onSphere(10, 20, R + ISS_ALT_M);
const nadir = nadirOrientation(satellite);
for (const centralDeg of [0, 0.5, 2, 5, 12]) {
const target = onSphere(10 + centralDeg, 20, R);
const γ = (centralDeg * Math.PI) / 180;
const expected =
(Math.atan2(R * Math.sin(γ), R + ISS_ALT_M - R * Math.cos(γ)) * 180) /
Math.PI;
const look = focusLook(nadir, satellite, target);
near(look.offNadirDeg, expected, `${centralDeg}° away`, 1e-9);
}
// Straight below reads zero, a real angle and not a missing one.
const below = focusLook(nadir, satellite, onSphere(10, 20, R));
near(below.offNadirDeg, 0, 'straight below', 1e-9);
near(below.yawDeg, 0, 'no east', 1e-9);
near(below.pitchDeg, 0, 'no north', 1e-9);
assert.equal(focusInReach(below), true);
});
test('the reach is the arrows’ ±45° on each axis, facing the satellite', () => {
assert.equal(focusInReach({ yawDeg: 45, pitchDeg: -45, visible: true }), true);
assert.equal(focusInReach({ yawDeg: 45.01, pitchDeg: 0, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: -45.01, visible: true }), false);
assert.equal(focusInReach({ yawDeg: 0, pitchDeg: 0, visible: false }), false);
assert.equal(focusInReach(null), false);
assert.equal(focusInReach({ yawDeg: Number.NaN, pitchDeg: 0 }), false);
// From geostationary height the far side of the Earth lies within a few
// degrees of nadir, but it faces away.
const satellite = satelliteAt(0, 0, 35_786_000);
const nadir = nadirOrientation(satellite);
const farSide = geodeticToEcef(0, 100, 0);
const look = focusLook(nadir, satellite, farSide);
assert.ok(Math.abs(look.yawDeg) < 45 && Math.abs(look.pitchDeg) < 45);
assert.equal(look.visible, false);
assert.equal(focusInReach(look), false);
// A point near the sub-satellite point faces it.
assert.equal(focusInReach(focusLook(nadir, satellite, geodeticToEcef(5, 5))), true);
});
test('a target slides out of reach as the satellite passes it', () => {
const target = geodeticToEcef(0, 10, 0);
const inReach = [];
for (let lon = 10; lon >= -5; lon -= 1) {
const satellite = satelliteAt(0, lon);
inReach.push(focusInReach(focusLook(nadirOrientation(satellite), satellite, target)));
}
assert.equal(inReach[0], true, 'overhead');
assert.equal(inReach.at(-1), false, '15° of longitude behind');
// Once out, it stays out: the reach ends once, not on and off.
const firstOut = inReach.indexOf(false);
assert.ok(firstOut > 0);
assert.ok(inReach.slice(firstOut).every((value) => value === false));
});
test('the turn onto a target eases on the slower focus clock and lands', () => {
assert.equal(
focusEaseDtMs(ORBITAL_FOCUS_EASE_TAU_MS),
LOOK_EASE_TAU_MS,
'one focus time constant is one look time constant',
);
assert.equal(focusEaseDtMs(Number.NaN), 0);
assert.equal(focusEaseDtMs(-5), 0);
let remainder = { yawDeg: -30, pitchDeg: 20 };
let previous = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
let frames = 0;
while ((remainder.yawDeg || remainder.pitchDeg) && frames < 500) {
const before = remainder;
remainder = easeFocusRemainder(remainder, 16);
const size = Math.hypot(remainder.yawDeg, remainder.pitchDeg);
assert.ok(size < previous, 'always closer');
// No axis moves more than a tenth of the way in a frame, but for the
// last few hundredths of a degree it lands on: a turn, not a cut.
for (const axis of ['yawDeg', 'pitchDeg']) {
const step = Math.abs(before[axis] - remainder[axis]);
assert.ok(
step <= Math.max(Math.abs(before[axis]) * 0.1, 0.06),
`frame ${frames} ${axis}`,
);
}
previous = size;
frames += 1;
}
assert.deepEqual(remainder, { yawDeg: 0, pitchDeg: 0 }, 'lands exactly');
// About three time constants close 95%; landing takes a little longer.
assert.ok(frames * 16 > 2 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.ok(frames * 16 < 12 * ORBITAL_FOCUS_EASE_TAU_MS, `${frames} frames`);
assert.deepEqual(
easeFocusRemainder({ yawDeg: 10, pitchDeg: -4 }, 16, { immediate: true }),
{ yawDeg: 0, pitchDeg: 0 },
'reduced motion cuts',
);
});
test('the reach list keeps points in reach or in frame, nearest first, bounded', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const candidates = [];
// A row of points eastward, 0.1° apart: those up to ~3.7° are within the
// 45° tilt, the rest beyond it.
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 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(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('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);
});
+74
View File
@@ -0,0 +1,74 @@
/**
* The camera's hop from the satellite the orbital view rides to another.
* No DOM, Cesium or i18n dependencies: vectors are plain `{x, y, z}` objects
* in Earth-fixed (ECEF) metres.
*/
/** The most nearby satellites asked for the orbital rows, nearest first. */
export const ORBITAL_SATELLITES_MAX = 8;
/** How long the camera takes to hop to another satellite, ms. */
export const ORBITAL_HOP_MS = 1200;
function finiteVector(v) {
return (
Boolean(v) &&
Number.isFinite(v.x) &&
Number.isFinite(v.y) &&
Number.isFinite(v.z)
);
}
/**
* 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. The circle is the one through both ends, so
* the path moves smoothly as the destination does, however far round it
* lies; only exactly opposite ends, which no single circle joins, turn about
* a fixed axis square to them.
* @param {{x: number, y: number, z: number}} from
* @param {{x: number, y: number, z: number}} to
* @param {number} progress Eased, 0..1.
* @returns {{x: number, y: number, z: number}|null}
*/
export function hopPosition(from, to, progress) {
if (!finiteVector(from) || !finiteVector(to)) return null;
const p = Math.min(1, Math.max(0, Number.isFinite(progress) ? progress : 1));
const ra = Math.hypot(from.x, from.y, from.z);
const rb = Math.hypot(to.x, to.y, to.z);
if (!(ra > 0) || !(rb > 0)) return null;
const a = { x: from.x / ra, y: from.y / ra, z: from.z / ra };
const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb };
const cosine = a.x * b.x + a.y * b.y + a.z * b.z;
// The direction the circle leaves `a` in: `b` less its part along `a`.
let c = {
x: b.x - cosine * a.x,
y: b.y - cosine * a.y,
z: b.z - cosine * a.z,
};
let rc = Math.hypot(c.x, c.y, c.z);
const r = ra + (rb - ra) * p;
const omega = Math.atan2(rc, cosine);
if (!(rc > 1e-12)) {
if (cosine > 0) return { x: a.x * r, y: a.y * r, z: a.z * r };
// Opposite ends: set off square to `a`, northward (or, near a pole,
// toward the prime meridian).
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;
c = {
x: pole.x - along * a.x,
y: pole.y - along * a.y,
z: pole.z - along * a.z,
};
rc = Math.hypot(c.x, c.y, c.z);
}
const angle = p * omega;
const cos = Math.cos(angle);
const sin = Math.sin(angle) / rc;
return {
x: (a.x * cos + c.x * sin) * r,
y: (a.y * cos + c.y * sin) * r,
z: (a.z * cos + c.z * sin) * r,
};
}
+73
View File
@@ -0,0 +1,73 @@
// src/cockpitOrbitalSatellites.test.mjs — the camera's hop from one ridden
// satellite to another.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { hopPosition } from './cockpitOrbitalSatellites.js';
const at = (lat, lon, alt) => Cesium.Cartesian3.fromDegrees(lon, lat, alt);
test('the hop follows the great circle and lands on the satellite', () => {
const from = at(0, 0, 400_000);
const to = at(0, 90, 800_000);
const start = hopPosition(from, to, 0);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(start[axis] - from[axis]) < 1e-6, axis);
const end = hopPosition(from, to, 1);
for (const axis of ['x', 'y', 'z'])
assert.ok(Math.abs(end[axis] - to[axis]) < 1e-6, axis);
// Half-way it is above the Earth, between the two heights, not on the
// chord through it.
const mid = hopPosition(from, to, 0.5);
const radius = Math.hypot(mid.x, mid.y, mid.z);
const ra = Cesium.Cartesian3.magnitude(from);
const rb = Cesium.Cartesian3.magnitude(to);
assert.ok(Math.abs(radius - (ra + rb) / 2) < 1e-6);
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);
});
test('a hop to nearly the opposite side moves smoothly as the destination does', () => {
const r = 6_778_137;
const from = { x: r, y: 0, z: 0 };
const near = (delta) => ({
x: -r * Math.cos(delta),
y: r * Math.sin(delta),
z: 0,
});
const mid = (delta) => hopPosition(from, near(delta), 0.5);
const [a, b] = [mid(0.999e-6), mid(1.001e-6)];
assert.ok(
Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z) < 1,
'no jump between neighbours',
);
for (const delta of [1e-3, 1e-6, 1e-9]) {
const to = near(delta);
const end = hopPosition(from, to, 1);
assert.ok(
Math.hypot(end.x - to.x, end.y - to.y, end.z - to.z) < 1e-3,
`lands at ${delta}`,
);
const half = mid(delta);
assert.ok(
Math.abs(Math.hypot(half.x, half.y, half.z) - r) < 1e-3,
`round at ${delta}`,
);
}
});
+12
View File
@@ -806,6 +806,18 @@
"{value} KM/S": "{value} كم/ث",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}",
"SITES STANDBY": "المواقع في وضع الاستعداد",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "فعّل المنشآت المرسومة لإشارات المواقع",
"Release the camera from {name}": "تحرير الكاميرا من {name}",
"RETURN · {name}": "عودة · {name}",
"PREVIOUS SATELLITE": "القمر الصناعي السابق",
"Return to {name}": "العودة إلى {name}",
"SATELLITE": "قمر صناعي",
"Ride {name}": "الانتقال إلى {name}",
"Unnamed site": "موقع بلا اسم",
"OFF-NADIR {angle}°": "انحراف عن النظير {angle}°",
"FOCUS · {name}": "تركيز · {name}",
"Keep the camera on {name}": "إبقاء الكاميرا على {name}",
"ZOOM {zoom}×": "تكبير {zoom}×",
"SWATH {value} KM": "عرض نطاق التصوير {value} كم",
"SWATH ≈ {value} KM": "عرض نطاق التصوير ≈ {value} كم",
+12
View File
@@ -193,6 +193,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}",
"SITES STANDBY": "ORTE STANDBY",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN",
"Release the camera from {name}": "Kamera von {name} lösen",
"RETURN · {name}": "ZURÜCK · {name}",
"PREVIOUS SATELLITE": "VORHERIGER SATELLIT",
"Return to {name}": "Zurück zu {name}",
"SATELLITE": "SATELLIT",
"Ride {name}": "Mit {name} fliegen",
"Unnamed site": "Unbenannter Ort",
"OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°",
"FOCUS · {name}": "FOKUS · {name}",
"Keep the camera on {name}": "Kamera auf {name} halten",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STREIFENBREITE {value} KM",
"SWATH ≈ {value} KM": "STREIFENBREITE ≈ {value} KM",
+12
View File
@@ -664,6 +664,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}",
"SITES STANDBY": "SITIOS EN ESPERA",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS",
"Release the camera from {name}": "Soltar la cámara de {name}",
"RETURN · {name}": "VOLVER · {name}",
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
"Return to {name}": "Volver a {name}",
"SATELLITE": "SATÉLITE",
"Ride {name}": "Viajar en {name}",
"Unnamed site": "Sitio sin nombre",
"OFF-NADIR {angle}°": "FUERA DEL NADIR {angle}°",
"FOCUS · {name}": "ENFOQUE · {name}",
"Keep the camera on {name}": "Mantener la cámara sobre {name}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "BARRIDO {value} KM",
"SWATH ≈ {value} KM": "BARRIDO ≈ {value} KM",
+12
View File
@@ -829,6 +829,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "HORS NADIR {angle}° · {offset}",
"SITES STANDBY": "SITES EN VEILLE",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVER LES INSTALLATIONS CARTOGRAPHIÉES POUR LES PINGS DE SITES",
"Release the camera from {name}": "Libérer la caméra de {name}",
"RETURN · {name}": "RETOUR · {name}",
"PREVIOUS SATELLITE": "SATELLITE PRÉCÉDENT",
"Return to {name}": "Revenir à {name}",
"SATELLITE": "SATELLITE",
"Ride {name}": "Suivre {name}",
"Unnamed site": "Site sans nom",
"OFF-NADIR {angle}°": "HORS NADIR {angle}°",
"FOCUS · {name}": "CENTRÉ · {name}",
"Keep the camera on {name}": "Garder la caméra sur {name}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAUCHÉE {value} KM",
"SWATH ≈ {value} KM": "FAUCHÉE ≈ {value} KM",
+12
View File
@@ -829,6 +829,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUORI NADIR {angle}° · {offset}",
"SITES STANDBY": "SITI IN ATTESA",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATTIVA LE INSTALLAZIONI MAPPATE PER I PING DEI SITI",
"Release the camera from {name}": "Sblocca la camera da {name}",
"RETURN · {name}": "TORNA · {name}",
"PREVIOUS SATELLITE": "SATELLITE PRECEDENTE",
"Return to {name}": "Torna a {name}",
"SATELLITE": "SATELLITE",
"Ride {name}": "Segui {name}",
"Unnamed site": "Sito senza nome",
"OFF-NADIR {angle}°": "FUORI NADIR {angle}°",
"FOCUS · {name}": "PUNTAMENTO · {name}",
"Keep the camera on {name}": "Mantieni la camera su {name}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STRISCIATA {value} KM",
"SWATH ≈ {value} KM": "STRISCIATA ≈ {value} KM",
+12
View File
@@ -803,6 +803,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
"SITES STANDBY": "地点待機",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示",
"Release the camera from {name}": "{name} からカメラを解放",
"RETURN · {name}": "戻る · {name}",
"PREVIOUS SATELLITE": "前の衛星",
"Return to {name}": "{name} に戻る",
"SATELLITE": "衛星",
"Ride {name}": "{name} に乗る",
"Unnamed site": "名称のない地点",
"OFF-NADIR {angle}°": "オフナディア {angle}°",
"FOCUS · {name}": "フォーカス · {name}",
"Keep the camera on {name}": "カメラを {name} に向け続ける",
"ZOOM {zoom}×": "ズーム {zoom}×",
"SWATH {value} KM": "観測幅 {value} km",
"SWATH ≈ {value} KM": "観測幅 ≈ {value} km",
+12
View File
@@ -827,6 +827,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FORA DO NADIR {angle}° · {offset}",
"SITES STANDBY": "LOCAIS EM ESPERA",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ATIVE AS INSTALAÇÕES MAPEADAS PARA PINGS DE LOCAIS",
"Release the camera from {name}": "Soltar a câmera de {name}",
"RETURN · {name}": "VOLTAR · {name}",
"PREVIOUS SATELLITE": "SATÉLITE ANTERIOR",
"Return to {name}": "Voltar para {name}",
"SATELLITE": "SATÉLITE",
"Ride {name}": "Acompanhar {name}",
"Unnamed site": "Local sem nome",
"OFF-NADIR {angle}°": "FORA DO NADIR {angle}°",
"FOCUS · {name}": "FOCO · {name}",
"Keep the camera on {name}": "Manter a câmera em {name}",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAIXA IMAGEADA {value} KM",
"SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM",
+12
View File
@@ -189,6 +189,18 @@
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
"SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ",
"Release the camera from {name}": "Освободить камеру от {name}",
"RETURN · {name}": "НАЗАД · {name}",
"PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК",
"Return to {name}": "Вернуться к {name}",
"SATELLITE": "СПУТНИК",
"Ride {name}": "Перейти на {name}",
"Unnamed site": "Безымянный объект",
"OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°",
"FOCUS · {name}": "ФОКУС · {name}",
"Keep the camera on {name}": "Удерживать камеру на {name}",
"ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×",
"SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ",
"SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ",
+12
View File
@@ -841,6 +841,18 @@
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
"SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ",
"Release the camera from {name}": "Звільнити камеру від {name}",
"RETURN · {name}": "НАЗАД · {name}",
"PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК",
"Return to {name}": "Повернутися до {name}",
"SATELLITE": "СУПУТНИК",
"Ride {name}": "Перейти на {name}",
"Unnamed site": "Безіменний об'єкт",
"OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°",
"FOCUS · {name}": "ФОКУС · {name}",
"Keep the camera on {name}": "Утримувати камеру на {name}",
"ZOOM {zoom}×": "НАБЛИЖЕННЯ {zoom}×",
"SWATH {value} KM": "СМУГА ОГЛЯДУ {value} КМ",
"SWATH ≈ {value} KM": "СМУГА ОГЛЯДУ ≈ {value} КМ",
+12
View File
@@ -780,6 +780,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
"SITES STANDBY": "地点待机",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒",
"Release the camera from {name}": "让镜头离开{name}",
"RETURN · {name}": "返回 · {name}",
"PREVIOUS SATELLITE": "上一颗卫星",
"Return to {name}": "返回{name}",
"SATELLITE": "卫星",
"Ride {name}": "乘坐{name}",
"Unnamed site": "未命名地点",
"OFF-NADIR {angle}°": "偏离天底 {angle}°",
"FOCUS · {name}": "聚焦 · {name}",
"Keep the camera on {name}": "让镜头持续对准{name}",
"ZOOM {zoom}×": "变焦 {zoom}×",
"SWATH {value} KM": "幅宽 {value} km",
"SWATH ≈ {value} KM": "幅宽 ≈ {value} km",
+12
View File
@@ -775,6 +775,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
"SITES STANDBY": "地點待機",
"ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒",
"Release the camera from {name}": "讓鏡頭離開{name}",
"RETURN · {name}": "返回 · {name}",
"PREVIOUS SATELLITE": "上一顆衛星",
"Return to {name}": "返回{name}",
"SATELLITE": "衛星",
"Ride {name}": "乘坐{name}",
"Unnamed site": "未命名地點",
"OFF-NADIR {angle}°": "偏離天底 {angle}°",
"FOCUS · {name}": "聚焦 · {name}",
"Keep the camera on {name}": "讓鏡頭持續對準{name}",
"ZOOM {zoom}×": "變焦 {zoom}×",
"SWATH {value} KM": "幅寬 {value} km",
"SWATH ≈ {value} KM": "幅寬 ≈ {value} km",
+4 -2
View File
@@ -585,7 +585,8 @@
"ΔN",
"Ⅱ PAUSE",
"≥ {value} MW",
"▲ {frp} MW"
"▲ {frp} MW",
"SATELLITE"
],
"it": [
"1 min/s",
@@ -776,7 +777,8 @@
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
"iNaturalist",
"SATELLITE"
],
"ja": [
"1090 MHz",
+35
View File
@@ -164,6 +164,41 @@ export function createControls({ state: layerState, services, parts, source }) {
);
},
/**
* A loaded mapped installation by id, as `getNearby` returns it (with its
* drawn `position`), or null while the layer is off or the id is not
* loaded. Read-only: nothing is selected.
* @param {string} id Source-backed installation id.
* @returns {object|null}
*/
getById(id) {
if (!layerState.enabled || id === null || id === undefined) return null;
const record = layerState.recordById.get(String(id));
if (!record || record.kind !== 'installation') return null;
return {
...record,
position: Cesium.Cartesian3.fromDegrees(
record.longitude,
record.latitude,
parts.rendering.installationSurfaceHeightM(record),
),
};
},
/**
* The mapped installation a `scene.pick()` result landed on: a named
* marker point (`{ installationId }`) or a site entity, as this layer's
* own click selection reads them. Null for anything else.
* @param {object|null|undefined} picked
* @returns {object|null} As {@link getById}.
*/
findByPick(picked) {
const id =
picked?.id?.installationId ||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
return id ? methods.getById(id) : null;
},
/**
* Select and frame a mapped installation from another contextual UI.
* @param {string} id Source-backed installation id.
+66
View File
@@ -371,6 +371,72 @@ export function createControls({ state: layerState, services, parts, source }) {
return result;
},
/**
* The shown satellites nearest a position, nearest first, from per-point
* state (no SGP4 re-propagation): a bounded pass that keeps only the
* nearest `maxCount`, and reads latitude and longitude for those alone.
* Dense extras are left out, as in `getAllPositions`.
* @param {Cesium.Cartesian3} center Earth-fixed position.
* @param {number} [maxCount=8] Most entries to return.
* @param {{excludeIds?: Array<number|string>,
* excludeClasses?: Array<string>}} [options] Satellites, and classes
* (e.g. 'debris'), to leave out.
* @returns {Array<{ id: number, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number, altitudeM: number, satelliteClass: string|null, distanceM: number }>}
*/
getNearestPositions(
center,
maxCount = 8,
{ excludeIds = [], excludeClasses = [] } = {},
) {
const nearest = [];
const cap = Number.isFinite(maxCount) ? Math.floor(maxCount) : 8;
if (
cap < 1 ||
!center ||
!layerState._points ||
layerState._points.size === 0
)
return nearest;
const excluded = new Set(excludeIds.map(String));
const classes = new Set(excludeClasses);
const dense = layerState._denseIds.length > 0;
for (const [noradId, point] of layerState._points) {
const p = point.position;
if (!p || excluded.has(String(noradId))) continue;
if (!_classVisible(noradId)) continue;
const sat = layerState._catalog.get(noradId);
if (dense && sat?.group === 'dense') continue;
if (classes.size && classes.has(sat?.satelliteClass)) continue;
const dx = p.x - center.x;
const dy = p.y - center.y;
const dz = p.z - center.z;
const distanceSq = dx * dx + dy * dy + dz * dz;
if (nearest.length === cap && distanceSq >= nearest[cap - 1].distanceSq)
continue;
// Insertion into a short sorted list: no copy or sort of the rest.
let at = nearest.length;
while (at > 0 && nearest[at - 1].distanceSq > distanceSq) at -= 1;
nearest.splice(at, 0, { noradId, point, sat, distanceSq });
if (nearest.length > cap) nearest.pop();
}
const result = [];
for (const { noradId, point, sat, distanceSq } of nearest) {
const carto = Cesium.Cartographic.fromCartesian(point.position);
if (!carto) continue;
result.push({
id: noradId,
label: sat ? sat.name.trim() : String(noradId),
position: point.position,
latitude: Cesium.Math.toDegrees(carto.latitude),
longitude: Cesium.Math.toDegrees(carto.longitude),
altitudeM: carto.height,
satelliteClass: sat?.satelliteClass ?? null,
distanceM: Math.sqrt(distanceSq),
});
}
return result;
},
/**
* Track a satellite by NORAD id (camera follow + orbit path + highlight),
* same path as clicking its point.
@@ -0,0 +1,70 @@
// src/layers/satellites/nearestPositions.test.mjs — the satellites nearest a
// position: bounded to the nearest few, nearest first, hidden classes,
// dense extras and asked-for exclusions left out.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createControls } from './controls.js';
function controlsWith(satellites, { classes, dense = [] } = {}) {
const state = {
_points: new Map(),
_catalog: new Map(),
_denseIds: dense,
_params: classes ? { classes } : {},
};
for (const sat of satellites) {
state._points.set(sat.id, {
position: Cesium.Cartesian3.fromDegrees(sat.lon, sat.lat, sat.alt),
});
state._catalog.set(sat.id, {
name: `${sat.name} `,
satelliteClass: sat.cls,
group: sat.group,
});
}
return createControls({
state,
services: { layerState: { isExplicitLayerStateOrigin: () => false } },
parts: {},
}).methods;
}
const SATELLITES = [
{ id: 1, name: 'NEAR', lat: 30.5, lon: -97, alt: 500_000, cls: 'imaging' },
{ id: 2, name: 'MID', lat: 33, lon: -97, alt: 500_000, cls: 'stations' },
{ id: 3, name: 'FAR', lat: -40, lon: 80, alt: 500_000, cls: 'imaging' },
{ id: 4, name: 'DEB', lat: 30.1, lon: -97, alt: 500_000, cls: 'debris' },
{ id: 5, name: 'TELECOM', lat: 30.2, lon: -97, alt: 500_000, cls: 'telecom' },
{ id: 6, name: 'SELF', lat: 30, lon: -97, alt: 420_000, cls: 'stations' },
{ id: 7, name: 'EXTRA', lat: 30.3, lon: -97, alt: 500_000, cls: 'imaging', group: 'dense' },
];
const CENTRE = Cesium.Cartesian3.fromDegrees(-97, 30, 420_000);
test('the nearest shown satellites come first, bounded, with their distance', () => {
const methods = controlsWith(SATELLITES, {
classes: { stations: true, imaging: true, debris: true, telecom: false },
dense: [7],
});
const nearest = methods.getNearestPositions(CENTRE, 3, {
excludeIds: [6],
excludeClasses: ['debris'],
});
assert.deepEqual(
nearest.map((entry) => entry.label),
['NEAR', 'MID', 'FAR'],
'no self, debris, hidden telecom or dense extra',
);
for (let i = 1; i < nearest.length; i += 1)
assert.ok(nearest[i].distanceM >= nearest[i - 1].distanceM);
const exact = Cesium.Cartesian3.distance(CENTRE, nearest[0].position);
assert.ok(Math.abs(nearest[0].distanceM - exact) < 1e-6);
assert.ok(Math.abs(nearest[0].latitude - 30.5) < 1e-6);
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);
});
+2
View File
@@ -363,6 +363,8 @@ export class StyleManager extends ShellFacade {
militaryFlightsLayer: services.militaryFlightsLayer,
aisLiveVesselsLayer: services.aisLiveVesselsLayer,
satellitesLayer: services.satellitesLayer,
// Orbit's Live Signals rows list and focus its mapped sites.
militaryInstallationsLayer: services.militaryInstallationsLayer,
isTr3b: services.isTr3b,
toggleTr3b: services.toggleTr3b,
militaryAwarenessLayer: services.militaryAwarenessLayer,
+64
View File
@@ -27,6 +27,18 @@ import {
updateOrbital,
updateOrbitalHud,
} from './cockpitOrbitalView.js';
import {
onOrbitalClick,
onOrbitalDoubleClick,
routeSceneClick,
toggleOrbitalFocus,
} from './cockpitOrbitalFocus.js';
import {
handleOrbitalSignalClick,
refreshOrbitalSignals,
updateOrbitalSignals,
} from './cockpitOrbitalSignals.js';
import { rideOrbitalSatellite } from './cockpitOrbitalSatellites.js';
import {
publishInstrumentsView,
publishRouteView,
@@ -292,6 +304,8 @@ export class CockpitViewController {
true,
);
this._listenerRemovers.push(routeSceneDoubleClick.call(this));
// In orbit a click on a mapped point keeps the camera on it.
this._listenerRemovers.push(routeSceneClick.call(this));
// In orbit the wheel steps the optical zoom (Cesium's own wheel zoom is
// off while the Cockpit holds the camera).
this._listen(
@@ -424,6 +438,56 @@ export class CockpitViewController {
onOrbitalWheel(event) {
return onOrbitalWheel.call(this, event);
}
/**
* A click on the orbital picture: on a mapped point in reach it keeps the
* camera on that point; anywhere else it ends such a focus.
* @param {{x: number, y: number}|null|undefined} position
* @returns {boolean} True when the click focused a point.
*/
onOrbitalClick(position) {
return onOrbitalClick.call(this, position);
}
/**
* A double-click on the orbital picture: on a mapped ground point it keeps
* or starts the focus there instead of letting Cesium track the point.
* @param {{x: number, y: number}|null|undefined} position
* @returns {boolean} True when it was taken.
*/
onOrbitalDoubleClick(position) {
return onOrbitalDoubleClick.call(this, position);
}
/**
* A site's orbital Live Signals row: focus that site, switch to it, or end
* the focus held on it.
* @param {string} key The site's id in the Mapped Installations layer.
* @returns {boolean} True when the focus changed.
*/
toggleOrbitalFocus(key) {
return toggleOrbitalFocus.call(this, key);
}
/** Rebuild the orbital Live Signals rows now. */
refreshOrbitalSignals() {
return refreshOrbitalSignals.call(this);
}
/** Rebuild the orbital Live Signals rows on their clock. */
updateOrbitalSignals(nowMs) {
return updateOrbitalSignals.call(this, nowMs);
}
/**
* Ride the satellite an orbital Live Signals row names (RETURN goes back).
* @param {number|string} noradId
* @returns {boolean} True when the ridden satellite changed.
*/
rideOrbitalSatellite(noradId) {
return rideOrbitalSatellite.call(this, noradId);
}
/**
* A click on an orbital Live Signals row.
* @returns {boolean} True in orbit.
*/
handleOrbitalSignalClick(layerId, id) {
return handleOrbitalSignalClick.call(this, layerId, id);
}
/**
* Tilt the orbital view one arrow step off nadir.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step
+2
View File
@@ -44,6 +44,7 @@ export class CockpitCoordinator {
militaryFlightsLayer,
aisLiveVesselsLayer,
satellitesLayer,
militaryInstallationsLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
@@ -67,6 +68,7 @@ export class CockpitCoordinator {
militaryFlightsLayer,
aisLiveVesselsLayer,
satellitesLayer,
militaryInstallationsLayer,
isTr3b,
toggleTr3b,
militaryAwarenessLayer,
+5 -1
View File
@@ -123,7 +123,8 @@ export function onSceneDoubleClick(position, nowMs = performance.now()) {
* installs its own (pickAndTrackObject), which untracks whenever the pick is
* not an Entity: on a followed aircraft's model, a primitive, it would drop
* the very track the double-click means to ride. A double-click on the
* followed track is the Cockpit's; any other goes on to Cesium's.
* followed track is the Cockpit's, as is one on a mapped ground point in
* orbit (a focus); any other goes on to Cesium's.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
@@ -134,6 +135,9 @@ export function routeSceneDoubleClick() {
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
if (this.onSceneDoubleClick(event?.position)) return;
// In orbit, a double-click on a mapped ground point is a focus, never a
// retarget that would end the view.
if (this.onOrbitalDoubleClick?.(event?.position)) return;
stock?.(event);
};
handler.setInputAction(routed, type);
+463
View File
@@ -0,0 +1,463 @@
/**
* Ground focus in the Cockpit's orbital view: a site's Live Signals row, or
* a click on a mapped site's marker, keeps the camera on that site as the
* satellite moves; clicking elsewhere, the site's row again, NADIR or the
* site leaving the off-nadir reach eases the view back. Sites come
* from the Mapped Installations layer's own records and pick ids. Bound to
* the Cockpit controller like the orbital view itself
* (src/ui/cockpitOrbitalView.js); its state lives on the orbital session, so
* it ends with the session.
*/
import * as Cesium from 'cesium';
import { clampOrbitalLook, easeLook } from '../cockpitLook.js';
import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js';
import {
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
pointsInReach,
} from '../cockpitOrbitalFocus.js';
import { isPointerFree } from '../data/inputOwnership.js';
const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 });
/** The Mapped Installations layer's id. */
const SITES_LAYER_ID = 'military-installations';
/** The most sites asked of the layer around the sub-satellite point. */
const SITE_QUERY_MAX = 500;
/** How long a site the layer dropped stays listed, ms (it may come back). */
const SITE_MEMORY_MS = 10_000;
/** The farthest from the sub-satellite point a listed site may lie, m. */
const SITE_QUERY_MAX_RANGE_M = 3_000_000;
/** The greatest angle off nadir the reach allows: 45° on both axes. */
const REACH_CORNER_RAD = Math.acos(Math.cos(Math.PI / 4) ** 2);
/**
* How far over the ground the reach extends from the sub-satellite point,
* m: the central angle at the reach's corner off nadir (or the horizon, if
* nearer), with a little margin, capped. Sites farther out are never asked
* of the layer.
*/
function reachGroundRangeM(altitudeM) {
const R = ORBITAL_EARTH_RADIUS_M;
const h = Math.max(0, Number.isFinite(altitudeM) ? altitudeM : 0);
const sine = ((R + h) / R) * Math.sin(REACH_CORNER_RAD);
const central =
sine >= 1 ? Math.acos(R / (R + h)) : Math.asin(sine) - REACH_CORNER_RAD;
return Math.min(SITE_QUERY_MAX_RANGE_M, R * central * 1.05);
}
/** Whether the user asked for reduced motion: focus turns then cut. */
function prefersReducedMotion() {
return Boolean(
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches,
);
}
function inOrbit(controller) {
return (
controller.active &&
!controller.destroyed &&
controller.subjectKind === 'satellite' &&
Boolean(controller.orbital)
);
}
function sitesLayer(controller) {
return controller.services?.militaryInstallationsLayer ?? null;
}
/** Whether the Mapped Installations layer is on (unknown reads as off). */
function sitesLayerOn(controller) {
const isLayerEnabled = controller.services?.isLayerEnabled;
return typeof isLayerEnabled === 'function'
? isLayerEnabled(SITES_LAYER_ID) !== false
: false;
}
/**
* A mapped site by id: as the layer has it loaded now, else, while the
* layer is on, as the focus or the Live Signals rows last saw it. The layer
* reloads its thinned view as the camera moves, so a site in view can leave
* its records for a moment; a site does not move, so its last position
* stands. Null with the layer off or the site never seen.
*/
function siteById(controller, id) {
let live = null;
try {
live = sitesLayer(controller)?.getById?.(id) ?? null;
} catch {
live = null;
}
if (live) return live;
if (!sitesLayerOn(controller)) return null;
const state = controller.orbital;
const key = String(id);
if (state?.focus?.key === key && state.focus.site) return state.focus.site;
return state?.listedSites?.get(key)?.site ?? null;
}
/** Where a site is drawn, ECEF, or null. */
function sitePosition(site) {
const p = site?.position;
return p &&
Number.isFinite(p.x) &&
Number.isFinite(p.y) &&
Number.isFinite(p.z)
? { x: p.x, y: p.y, z: p.z }
: null;
}
/** The mapped site a canvas position shows, through the layer's pick ids. */
function siteAt(controller, position) {
let picked = null;
try {
picked = controller.viewer.scene?.pick?.(position) ?? null;
} catch {
picked = null;
}
if (!picked) return null;
try {
return sitesLayer(controller)?.findByPick?.(picked) ?? null;
} catch {
return null;
}
}
/** The focus look at a site from the last frame's satellite, or null. */
function lookAt(state, site) {
const frame = state.focusFrame;
if (!frame) return null;
return focusLook(frame.nadir, frame.position, sitePosition(site));
}
/**
* Point the view at a mapped site and keep it there: from the view as it is
* now, so switching targets turns rather than jumps. A site beyond the
* reach is refused.
* @returns {boolean} True when the focus is on that site.
*/
function startFocus(site) {
const state = this.orbital;
const look = lookAt(state, site);
// Before the first frame there is nothing to measure against; the frame
// that follows checks the reach.
if (state.focusFrame && !focusInReach(look)) return false;
state.focus = {
key: String(site.id),
label: String(site.name || site.id),
// Where the site is, kept while the layer reloads around it.
site: {
id: site.id,
name: site.name,
class: site.class,
position: sitePosition(site),
},
look,
// The view minus the target's look, eased away; set on the next frame.
remainder: null,
};
state.focusReleasing = false;
if (look) state.lookTarget = clampOrbitalLook(look);
this.refreshOrbitalSignals?.();
return true;
}
/**
* End the focus. The view eases back to nadir on the focus's clock (cut
* under reduced motion), or with `handOver` stays where it is for an arrow
* key to tilt on from.
* @param {{handOver?: boolean}} [options]
* @returns {boolean} True when a focus was ended.
*/
export function releaseOrbitalFocus({ handOver = false } = {}) {
const state = this.orbital;
if (!state?.focus) {
// 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;
if (handOver) {
state.lookTarget = clampOrbitalLook(state.lookView);
state.focusReleasing = false;
} else {
state.lookTarget = { ...NADIR_LOOK };
if (prefersReducedMotion()) {
state.lookView = { ...NADIR_LOOK };
state.focusReleasing = false;
} else state.focusReleasing = true;
}
this.refreshOrbitalSignals?.();
return true;
}
/**
* A site's Live Signals row: focus that site, switch to it from another, or
* end the focus when it is the one held.
* @param {string} key The site's id in the Mapped Installations layer.
* @returns {boolean} True when the focus changed.
*/
export function toggleOrbitalFocus(key) {
if (!inOrbit(this) || key === null || key === undefined) return false;
const state = this.orbital;
if (state.focus?.key === String(key)) return releaseOrbitalFocus.call(this);
const site = siteById(this, String(key));
return site ? startFocus.call(this, site) : false;
}
/**
* A double-click on the orbital picture. On a mapped ground point it keeps
* or starts the focus on it (as a single click does) and is taken: Cesium's
* own double-click would track that point, which ends the view. Any other
* double-click, one a tool owns, and every one outside orbit go on.
* @param {{x: number, y: number}|null|undefined} position Canvas position.
* @returns {boolean} True when it landed on a ground point and was taken.
*/
export function onOrbitalDoubleClick(position) {
if (!inOrbit(this) || !isPointerFree()) return false;
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
return false;
const site = siteAt(this, position);
if (!site) return false;
// A site beyond the reach is not focused, but still never tracked.
if (this.orbital.focus?.key !== String(site.id)) startFocus.call(this, site);
return true;
}
/**
* A click on the orbital picture. One on a mapped ground point in reach
* focuses it; any other click ends a focus. A click a tool owns (drawing)
* is left alone, as is every click outside orbit.
* @param {{x: number, y: number}|null|undefined} position Canvas position.
* @returns {boolean} True when the click focused a point.
*/
export function onOrbitalClick(position) {
if (!inOrbit(this) || !isPointerFree()) return false;
if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y))
return false;
const site = siteAt(this, position);
const state = this.orbital;
if (site) {
if (state.focus?.key === String(site.id)) return true;
if (startFocus.call(this, site)) return true;
// A mapped site beyond the reach is a click elsewhere: the focus ends.
}
releaseOrbitalFocus.call(this);
return false;
}
/**
* Take the scene's single click through the orbital focus first, then hand
* it on to Cesium's own (it picks into `selectedEntity`), as before.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
export function routeSceneClick() {
const handler = this.viewer.screenSpaceEventHandler;
if (typeof handler?.setInputAction !== 'function') return () => {};
const type = Cesium.ScreenSpaceEventType.LEFT_CLICK;
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
this.onOrbitalClick(event?.position);
stock?.(event);
};
handler.setInputAction(routed, type);
return () => {
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
return;
if (stock) handler.setInputAction(stock, type);
else handler.removeInputAction(type);
};
}
/**
* 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 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
* frame's nadir frame.
* @param {{x: number, y: number, z: number}} position The satellite, ECEF.
* @param {number} nowMs
* @returns {boolean} True when it placed `state.lookView` itself.
*/
export function updateOrbitalFocus(state, nadir, position, nowMs) {
state.focusFrame = {
nadir,
position: { x: position.x, y: position.y, z: position.z },
};
// After a satellite switch the first frame on the new satellite (the hop
// landed, or none) is the one the rows can be measured from: rebuild now.
if (state.awaitingFrame && !state.hop) {
state.awaitingFrame = false;
state.signalsMs = -Infinity;
}
const dtMs = nowMs - (state.lookLastMs || nowMs);
let aimed = false;
const focus = state.focus;
if (focus) {
const site = siteById(this, focus.key);
const look = site
? focusLook(nadir, state.focusFrame.position, sitePosition(site))
: null;
if (!focusInReach(look)) {
releaseOrbitalFocus.call(this);
} else {
focus.look = look;
const reduced = prefersReducedMotion();
focus.remainder = focus.remainder
? easeFocusRemainder(focus.remainder, dtMs, { immediate: reduced })
: reduced
? { ...NADIR_LOOK }
: {
yawDeg: state.lookView.yawDeg - look.yawDeg,
pitchDeg: state.lookView.pitchDeg - look.pitchDeg,
};
state.lookTarget = clampOrbitalLook(look);
state.lookView = {
yawDeg: look.yawDeg + focus.remainder.yawDeg,
pitchDeg: look.pitchDeg + focus.remainder.pitchDeg,
};
aimed = true;
}
}
this.updateOrbitalSignals?.(nowMs);
return aimed;
}
/**
* Ease the orbital look toward its target for one frame: the look's own
* easing, on the focus's slower clock while a focus is easing back.
* @param {object} state The orbital session.
* @param {number} dtMs
* @param {{immediate?: boolean}} [options]
* @returns {{yawDeg: number, pitchDeg: number}}
*/
export function easeOrbitalLook(state, dtMs, { immediate = false } = {}) {
const view = easeLook(
state.lookView,
state.lookTarget,
state.focusReleasing ? focusEaseDtMs(dtMs) : dtMs,
{ immediate },
);
if (
state.focusReleasing &&
view.yawDeg === state.lookTarget.yawDeg &&
view.pitchDeg === state.lookTarget.pitchDeg
)
state.focusReleasing = false;
return view;
}
/**
* The focused target for the HUD: its name and the live look at it, whose
* single off-nadir angle the readout shows. Null without a focus, or before
* its first frame.
* @returns {{key: string, label: string, offNadirDeg: number,
* yawDeg: number, pitchDeg: number}|null}
*/
export function orbitalFocusReadout() {
const focus = this.orbital?.focus;
if (!focus?.look) return null;
return {
key: focus.key,
label: focus.label,
offNadirDeg: focus.look.offNadirDeg,
yawDeg: focus.look.yawDeg,
pitchDeg: focus.look.pitchDeg,
};
}
/**
* The mapped sites the orbital view can be turned to, for the Live Signals
* rows: the Mapped Installations layer's loaded sites around the
* sub-satellite point (`getNearby`), with those seen in the last
* SITE_MEMORY_MS and the focused one, facing the satellite and within the
* off-nadir reach, nearest to nadir first. Empty before the first frame or
* with the layer off.
* @param {{latitude: number, longitude: number, altitudeM: number}} info
* The ridden satellite.
* @returns {{entries: Array<{site: object, offNadirDeg: number,
* focused: boolean}>}}
*/
export function orbitalSitesInReach(info) {
const state = this.orbital;
const frame = state?.focusFrame;
const layer = sitesLayer(this);
// The layer keeps its records when it is turned off: nothing is listed
// 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 { entries: [] };
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return { entries: [] };
const rangeM = reachGroundRangeM(info.altitudeM);
let sites = [];
try {
sites = layer.getNearby(
Cesium.Cartesian3.fromDegrees(info.longitude, info.latitude, 0),
rangeM,
SITE_QUERY_MAX,
);
} catch {
sites = [];
}
const byId = new Map();
const candidates = [];
for (const site of sites || []) {
const position = sitePosition(site);
if (!position || site.id === null || site.id === undefined) continue;
const key = String(site.id);
byId.set(key, site);
candidates.push({ key, position });
}
// In orbit the layer reloads its thinned view every second or so, and a
// site in view can drop out of its records and come back. Sites do not
// move: one seen within SITE_MEMORY_MS stays a candidate, so the rows hold
// still and a row's site can be focused from what the rows show.
const nowMs = performance.now();
const remembered = new Map();
for (const [key, entry] of state.listedSites ?? []) {
if (nowMs - entry.seenMs > SITE_MEMORY_MS || byId.has(key)) continue;
remembered.set(key, entry);
byId.set(key, entry.site);
candidates.push({ key, position: entry.site.position });
}
// The focused site stays a candidate while the layer reloads around it.
const held = state.focus;
if (held?.site?.position && !byId.has(held.key)) {
byId.set(held.key, held.site);
candidates.push({ key: held.key, position: held.site.position });
}
for (const [key, site] of byId)
if (!remembered.has(key))
remembered.set(key, {
site: { ...site, position: sitePosition(site) },
seenMs: nowMs,
});
state.listedSites = remembered;
const focusKey = state.focus?.key ?? null;
return {
entries: pointsInReach(candidates, {
nadir: frame.nadir,
satellite: frame.position,
}).map(({ key, offNadirDeg }) => ({
site: byId.get(key),
offNadirDeg,
focused: key === focusKey,
})),
};
}
+963
View File
@@ -0,0 +1,963 @@
// src/ui/cockpitOrbitalFocus.test.mjs — the orbital view's Live Signals
// rows and its ground focus: the rows list the Mapped Installations layer's
// sites around the satellite (getNearby) and the nearest satellites; a site
// row, or a click on the site's marker (the layer's pick ids), turns the
// camera onto it and holds it as the satellite moves; a click elsewhere, the
// row again, NADIR or the site leaving the reach eases the view back, never
// in one jump; a satellite row rides that satellite and stays in orbit.
// The layers are stubs with the real methods' shapes. Over happy-dom.
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPinia } from 'pinia';
import { createApp, defineComponent, h, nextTick } from 'vue';
import * as Cesium from 'cesium';
import { CockpitViewController } from './cockpitController.js';
import { useCockpitStore } from './stores/cockpit.js';
import { useHudStore } from './stores/hud.js';
import CockpitView from './views/CockpitView.vue';
import ContextView from './views/ContextView.vue';
import IntelHudView from './views/IntelHudView.vue';
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
import { ORBITAL_FOCUS_EASE_TAU_MS } from '../cockpitOrbitalFocus.js';
import { ORBITAL_HOP_MS } from '../cockpitOrbitalSatellites.js';
const START_MS = 1_800_000_000_000;
const ASPECT = 16 / 9;
const FRAME_MS = 16;
/** Satellites the layer knows, by NORAD id: the ridden one and its neighbours. */
const SATELLITES = Object.freeze({
25544: { name: 'ISS (ZARYA)', lat: 30, lon: -97, alt: 420_000, cls: 'stations' },
49260: { name: 'LANDSAT 9', lat: 31.5, lon: -95, alt: 705_000, cls: 'imaging' },
40697: { name: 'SENTINEL-2A', lat: 26, lon: -99, alt: 786_000, cls: 'imaging' },
99001: { name: 'DEB FRAGMENT', lat: 30.2, lon: -97.1, alt: 430_000, cls: 'debris' },
});
/** Mapped sites as the installations layer keeps them. */
const SITES = Object.freeze({
alpha: {
id: 'osm:military:way/101',
osmKey: 'way/101',
name: 'Camp Alpha',
class: 'base',
lat: 31,
lon: -96.5,
memberNames: ['North Field'],
at: { x: 100, y: 100 },
},
bravo: {
id: 'osm:military:way/202',
osmKey: 'way/202',
name: 'Bravo Field',
class: 'airfield',
lat: 29.2,
lon: -97.8,
at: { x: 300, y: 300 },
},
// Beyond the ±45° reach of the ISS at its start.
far: {
id: 'osm:military:way/303',
osmKey: 'way/303',
name: 'Far Range',
class: 'range',
lat: 33.5,
lon: -101,
at: { x: 500, y: 50 },
},
});
const EMPTY_GROUND = Object.freeze({ x: 640, y: 360 });
/** The Mapped Installations layer: its records, getNearby, getById, findByPick. */
function installationsLayer() {
const records = Object.values(SITES).map((site) => ({
id: site.id,
osmKey: site.osmKey,
name: site.name,
class: site.class,
kind: 'installation',
latitude: site.lat,
longitude: site.lon,
memberNames: site.memberNames || [],
sources: [{ name: 'OpenStreetMap', id: site.osmKey }],
}));
const layer = {
enabled: true,
records,
calls: { getNearby: 0 },
withPosition(record) {
return {
...record,
position: Cesium.Cartesian3.fromDegrees(record.longitude, record.latitude, 0),
};
},
// As the layer's own: loaded installations within `rangeM` of
// `center`, nearest first, each with its drawn position and distance.
getNearby(center, rangeM, maxCount = 50) {
layer.calls.getNearby += 1;
if (!center) return [];
const from = Cesium.Cartographic.fromCartesian(center);
const geodesic = new Cesium.EllipsoidGeodesic();
return layer.records
.filter((record) => record.kind === 'installation')
.map((record) => {
geodesic.setEndPoints(
from,
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude),
);
return { ...layer.withPosition(record), distanceM: geodesic.surfaceDistance };
})
.filter((record) => record.distanceM <= rangeM)
.sort((a, b) => a.distanceM - b.distanceM)
.slice(0, maxCount);
},
getById(id) {
if (!layer.enabled) return null;
const record = layer.records.find((r) => r.id === String(id));
return record ? layer.withPosition(record) : null;
},
findByPick(picked) {
const id =
picked?.id?.installationId ||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
return id ? layer.getById(id) : null;
},
};
return layer;
}
/** The satellites layer: tracking, the Cockpit subject and every position. */
function satellitesLayer(viewer) {
const positions = new Map(
Object.entries(SATELLITES).map(([id, sat]) => [Number(id), { ...sat }]),
);
const layer = {
tracked: 25544,
calls: [],
position(id) {
const sat = positions.get(id);
return Cesium.Cartesian3.fromDegrees(sat.lon, sat.lat, sat.alt);
},
move(dLat, dLon) {
const sat = positions.get(layer.tracked);
sat.lat += dLat;
sat.lon += dLon;
},
getCockpitSubject() {
if (layer.tracked === null) return null;
const sat = positions.get(layer.tracked);
return {
kind: 'satellite',
layerId: 'satellites',
id: String(layer.tracked),
noradId: layer.tracked,
name: sat.name,
classLabel: sat.cls.toUpperCase(),
latitude: sat.lat,
longitude: sat.lon,
altitudeM: sat.alt,
velocityMps: 7_600,
groundTrackDeg: 51.6,
satelliteClass: sat.cls,
stale: false,
};
},
getCockpitPosition(result) {
if (layer.tracked === null) return null;
return Cesium.Cartesian3.clone(layer.position(layer.tracked), result);
},
getAllPositions(maxCount = 300) {
return [...positions.entries()].slice(0, maxCount).map(([id, sat]) => ({
id,
label: sat.name,
position: layer.position(id),
latitude: sat.lat,
longitude: sat.lon,
altitudeM: sat.alt,
satelliteClass: sat.cls,
}));
},
// As the layer's: the nearest shown satellites, bounded, with the
// asked-for ids and classes left out.
getNearestPositions(center, maxCount = 8, { excludeIds = [], excludeClasses = [] } = {}) {
layer.calls.push(['nearest']);
const skip = new Set(excludeIds.map(String));
return layer
.getAllPositions()
.filter((entry) => !skip.has(String(entry.id)))
.filter((entry) => !excludeClasses.includes(entry.satelliteClass))
.map((entry) => ({
...entry,
distanceM: Cesium.Cartesian3.distance(center, entry.position),
}))
.sort((a, b) => a.distanceM - b.distanceM)
.slice(0, maxCount);
},
/** The satellite leaves the catalogue. */
remove(id) {
positions.delete(Number(id));
},
// As the layer's: tracking hands the camera to Cesium's follow.
trackById(noradId) {
const id = Number(noradId);
if (!positions.has(id)) return false;
layer.calls.push(['track', id]);
layer.tracked = id;
viewer.trackedEntity = { position: { getValue: () => layer.position(id) } };
return true;
},
setCockpitSubject(noradId) {
layer.calls.push(['subject', noradId]);
return true;
},
refocusTrackedById: () => true,
drop() {
layer.tracked = null;
},
};
return layer;
}
function fixture(t, { reducedMotion = false } = {}) {
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(
defineComponent(() => () => [h(ContextView), h(IntelHudView), h(CockpitView)]),
).use(pinia);
app.mount(host);
useHudStore(pinia);
const store = useCockpitStore(pinia);
const previousMatchMedia = globalThis.matchMedia;
globalThis.matchMedia = (query) => ({
matches: query.includes('reduce') && reducedMotion,
});
const listeners = () => ({ addEventListener: () => () => {} });
const shots = [];
const picks = new Map();
const viewer = {
trackedEntity: undefined,
clock: { currentTime: null },
camera: {
frustum: { fov: 1, aspectRatio: ASPECT },
cancelFlight() {},
setView({ destination, orientation }) {
shots.push({
destination: Cesium.Cartesian3.clone(destination),
direction: Cesium.Cartesian3.clone(orientation.direction),
up: Cesium.Cartesian3.clone(orientation.up),
});
},
},
entities: { contains: () => false },
scene: {
preUpdate: listeners(),
screenSpaceCameraController: { enableInputs: true },
pick: (position) => picks.get(`${position.x},${position.y}`),
},
trackedEntityChanged: listeners(),
};
// The Viewer's own input handler: its single click picks into
// selectedEntity; its double-click tracks what it picks, which would end
// the orbital view.
const actions = new Map();
const handler = {
stockClicks: 0,
stockDoubleClicks: 0,
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
isDestroyed: () => false,
};
handler.stock = () => {
handler.stockClicks += 1;
};
handler.stockDouble = ({ position }) => {
handler.stockDoubleClicks += 1;
const picked = viewer.scene.pick(position);
viewer.trackedEntity = picked
? { position: { getValue: () => null } }
: undefined;
};
actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, handler.stock);
actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, handler.stockDouble);
viewer.screenSpaceEventHandler = handler;
const sites = installationsLayer();
const satellites = satellitesLayer(viewer);
const modeEvents = [];
const onMode = (event) => modeEvents.push(event.detail);
window.addEventListener('gev:cockpit-mode-changed', onMode);
const cockpit = new CockpitViewController(viewer, {
services: {
flightsLayer: { getTrackedInfo: () => null },
militaryFlightsLayer: { getTrackedInfo: () => null },
aisLiveVesselsLayer: { getCockpitSubject: () => null },
satellitesLayer: satellites,
militaryInstallationsLayer: sites,
militaryAwarenessLayer: {},
isLayerEnabled: (layerId) =>
layerId === 'military-installations' ? sites.enabled : true,
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
cachedGroundFloor: () => null,
GROUND_FLOOR_LIFT_M: 0,
},
isEntryAllowed: () => false,
isBridgeEntryAllowed: () => false,
isOrbitEntryAllowed: () => true,
publish: {
presence: (view) => store.publishPresence(view),
instruments: (view) => store.publishInstruments(view),
route: (view) => store.publishRoute(view),
radar: (view) => store.publishRadar(view),
orbital: (view) => store.publishOrbital(view),
signals: (rows) => store.publishSignals(rows),
},
});
Object.assign(cockpit, {
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
scheduleContextLayout() {},
updateLocalPosition() {},
maybeRefreshRegionalBrief() {},
updateContext() {},
updateRoute() {},
});
// Named markers pick as `{ installationId }`, as the layer draws them.
for (const site of Object.values(SITES))
picks.set(`${site.at.x},${site.at.y}`, {
id: { installationId: site.id },
primitive: {},
});
const originalNow = performance.now;
const originalDateNow = Date.now;
let nowMs = START_MS;
performance.now = () => nowMs;
Date.now = () => nowMs;
t.after(() => {
window.removeEventListener('gev:cockpit-mode-changed', onMode);
cockpit.stop();
app.unmount();
globalThis.matchMedia = previousMatchMedia;
performance.now = originalNow;
Date.now = originalDateNow;
document.body.classList.remove('cockpit-mode', 'cockpit-bridge', 'cockpit-orbit');
});
const frame = () => {
nowMs += FRAME_MS;
cockpit.update();
return shots.at(-1);
};
const fire = (type, position) => actions.get(type)?.({ position });
return {
$: (selector) => document.querySelector(selector),
cockpit,
viewer,
store,
shots,
handler,
sites,
satellites,
modeEvents,
frame,
click: (position) => fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position),
/** A double-click as Cesium fires it: two clicks, then the double. */
doubleClick(position) {
fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position);
fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position);
fire(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, position);
},
/** Rebuild the Context card on the next frame, past its clock. */
refreshCard() {
nowMs += 1000;
cockpit.update();
},
};
}
async function settle() {
await nextTick();
await Promise.resolve();
}
async function inOrbit(t, options) {
const scene = fixture(t, options);
assert.equal(scene.cockpit.enter(), true);
await settle();
scene.frame();
scene.refreshCard();
await settle();
return scene;
}
const angleDeg = (a, b) =>
Cesium.Math.toDegrees(Cesium.Cartesian3.angleBetween(a, b));
const sitePosition = (key) =>
Cesium.Cartesian3.fromDegrees(SITES[key].lon, SITES[key].lat, 0);
/** How far the camera looks from a site, degrees. */
function missDeg(shot, key) {
const line = Cesium.Cartesian3.subtract(
sitePosition(key),
shot.destination,
new Cesium.Cartesian3(),
);
return angleDeg(shot.direction, line);
}
/** The camera's angle from nadir, degrees. */
function offNadir(shot) {
const toCentre = Cesium.Cartesian3.negate(shot.destination, new Cesium.Cartesian3());
return angleDeg(shot.direction, toCentre);
}
/** The off-nadir angle to a site from the camera's position, degrees. */
function siteOffNadir(shot, key) {
const line = Cesium.Cartesian3.subtract(
sitePosition(key),
shot.destination,
new Cesium.Cartesian3(),
);
const toCentre = Cesium.Cartesian3.negate(shot.destination, new Cesium.Cartesian3());
return angleDeg(line, toCentre);
}
/**
* Run frames, the satellite drifting a little each one, and return the
* largest turn of the camera between two frames.
*/
function run(scene, frames, { drift = 0.0005 } = {}) {
let largest = 0;
let previous = scene.shots.at(-1);
for (let i = 0; i < frames; i += 1) {
scene.satellites.move(drift, drift);
const shot = scene.frame();
largest = Math.max(largest, angleDeg(previous.direction, shot.direction));
previous = shot;
}
return largest;
}
/** Frames enough for the focus easing to land (about 12 time constants). */
const LAND_FRAMES = Math.ceil((12 * ORBITAL_FOCUS_EASE_TAU_MS) / FRAME_MS);
/**
* No frame turns the camera further than this: a smooth turn, no jump. The
* focus clock moves about 6% of the way in a 16 ms frame, so even a swing
* back from the 45° reach stays under it.
*/
const MAX_TURN_PER_FRAME_DEG = 3;
/** A Live Signals row's button, by layer and id. */
const rowButton = (scene, layerId, id) =>
scene.$(
`#cockpit-signal-list button[data-signal-layer="${layerId}"][data-signal-id="${id}"]`,
);
const siteRow = (scene, key) =>
rowButton(scene, 'military-installations', SITES[key].id);
const satelliteRow = (scene, id) => rowButton(scene, 'satellites', String(id));
/** The rows as published: title, detail, tone and target. */
const rows = (scene) => scene.store.signals ?? [];
/** Rebuild the rows and let the view render them. */
async function refreshRows(scene) {
scene.refreshCard();
await settle();
}
test('in orbit Live Signals lists the sites getNearby finds and the nearest satellites', async (t) => {
const scene = await inOrbit(t);
const { $, sites } = scene;
assert.ok(sites.calls.getNearby > 0, 'asked the installations layer');
assert.equal($('#cockpit-context').hidden, true, 'no Contact card in orbit');
const shown = rows(scene);
assert.ok(shown.length <= 5, 'five rows at most');
assert.equal(shown[0].title, 'TRACK ACQUIRED', 'the track row stays first');
// Two site rows, nearest to nadir first, then the satellites (debris out).
const sitesShown = shown.filter((row) => row.target?.layerId === 'military-installations');
assert.deepEqual(
sitesShown.map((row) => row.target.id).sort(),
[SITES.alpha.id, SITES.bravo.id].sort(),
);
for (const row of sitesShown) {
assert.match(row.detail, /^(BASE|AIRFIELD) · OFF-NADIR \d+\.\d°$/);
assert.match(row.className, /nearby actionable/);
}
const satellitesShown = shown.filter((row) => row.target?.layerId === 'satellites');
assert.deepEqual(satellitesShown.map((row) => row.title), ['LANDSAT 9', 'SENTINEL-2A']);
assert.match(satellitesShown[0].detail, /^SATELLITE · (AHEAD|[LR] \d{3}°) · [\d,.]+\s+KM$/);
// Each row names its own action.
assert.equal(siteRow(scene, 'alpha').getAttribute('aria-label'), 'Keep the camera on Camp Alpha');
assert.equal(satelliteRow(scene, 49260).getAttribute('aria-label'), 'Ride LANDSAT 9');
// Rebuilt on the clock, rows keep their keys and times.
const before = shown.map((row) => [row.key, row.time]);
await refreshRows(scene);
assert.deepEqual(rows(scene).map((row) => [row.key, row.time]), before);
});
test('a site row turns the camera onto it, holds it, and reads its off-nadir angle', async (t) => {
const scene = await inOrbit(t);
const { $, cockpit, store } = scene;
assert.ok(missDeg(scene.shots.at(-1), 'alpha') > 10, 'not centred at nadir');
siteRow(scene, 'alpha').click();
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id);
// The first frames turn part of the way: no jump onto the site.
assert.ok(missDeg(scene.frame(), 'alpha') > 5, 'still turning');
const largest = run(scene, LAND_FRAMES);
assert.ok(largest > 0.1 && largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`);
for (let i = 0; i < 20; i += 1) {
scene.satellites.move(0.002, 0.002);
const shot = scene.frame();
assert.ok(missDeg(shot, 'alpha') < 1e-5, `frame ${i}: ${missDeg(shot, 'alpha')}°`);
}
for (let i = 0; i < 8; i += 1) scene.frame();
await settle();
const shot = scene.shots.at(-1);
assert.equal(
$('#cockpit-orbital-look-readout').textContent,
`OFF-NADIR ${siteOffNadir(shot, 'alpha').toFixed(1)}°`,
);
assert.equal($('#cockpit-orbital-focus').textContent, 'FOCUS · Camp Alpha');
assert.equal($('#cockpit-orbital-look-readout').getAttribute('aria-live'), 'off');
assert.equal(store.orbital.footprintKm, null);
assert.match($('.cockpit-orbital-swath').textContent, /^SWATH ≈ [\d.]+ KM$/);
// The held site's row reads as the subject does, first among the sites.
const held = rows(scene).find((row) => row.target?.id === SITES.alpha.id);
assert.match(held.className, /^track actionable$/);
assert.match(held.detail, /^FOCUS · BASE · OFF-NADIR/);
assert.equal(siteRow(scene, 'alpha').getAttribute('aria-label'), 'Release the camera from Camp Alpha');
});
test('another site row switches the focus; the held site row ends it', async (t) => {
const scene = await inOrbit(t);
const { cockpit } = scene;
siteRow(scene, 'alpha').click();
run(scene, LAND_FRAMES);
await settle();
siteRow(scene, 'bravo').click();
assert.equal(cockpit.orbital.focus.key, SITES.bravo.id);
const largest = run(scene, LAND_FRAMES);
assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`);
assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5);
await settle();
assert.match(rows(scene).find((row) => row.target?.id === SITES.alpha.id).className, /^nearby/);
siteRow(scene, 'bravo').click();
assert.equal(cockpit.orbital.focus, null);
const back = run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(back < MAX_TURN_PER_FRAME_DEG);
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'back at nadir');
});
test('a click on a site marker focuses it through the layer; a click elsewhere eases back', async (t) => {
const scene = await inOrbit(t);
const { cockpit, handler } = scene;
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id);
assert.equal(handler.stockClicks, 1, 'Cesium still gets the click');
run(scene, LAND_FRAMES);
assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5);
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id, 'kept on a second click');
const focused = offNadir(scene.shots.at(-1));
scene.click(EMPTY_GROUND);
assert.equal(cockpit.orbital.focus, null);
assert.ok(offNadir(scene.frame()) > focused * 0.8, 'no jump back');
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, 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) => {
const scene = await inOrbit(t);
const lease = claimPointer('draw');
t.after(() => releasePointer(lease));
scene.click(SITES.alpha.at);
assert.equal(scene.cockpit.orbital.focus ?? null, null, 'no focus while drawing');
releasePointer(lease);
scene.click(SITES.alpha.at);
const again = claimPointer('draw');
t.after(() => releasePointer(again));
scene.click(EMPTY_GROUND);
assert.equal(scene.cockpit.orbital.focus.key, SITES.alpha.id, 'kept while drawing');
});
test('a double-click on a site focuses it and keeps the orbital view', async (t) => {
const scene = await inOrbit(t);
const { cockpit, viewer, handler } = scene;
scene.doubleClick(SITES.alpha.at);
assert.equal(handler.stockDoubleClicks, 0, 'Cesium does not track the site');
assert.equal(viewer.trackedEntity, undefined);
scene.frame();
assert.equal(cockpit.active, true, 'still in orbit');
assert.equal(cockpit.orbital.focus.key, SITES.alpha.id);
scene.doubleClick(SITES.bravo.at);
assert.equal(cockpit.orbital.focus.key, SITES.bravo.id);
scene.doubleClick(SITES.far.at);
assert.equal(handler.stockDoubleClicks, 0, 'beyond reach: not focused, not tracked');
scene.frame();
assert.equal(cockpit.active, true);
scene.doubleClick(EMPTY_GROUND);
assert.equal(handler.stockDoubleClicks, 1, 'anywhere else goes on to Cesium');
});
test('the focus ends by itself, easing back, when the site leaves the reach', async (t) => {
const scene = await inOrbit(t);
const { cockpit } = scene;
siteRow(scene, 'bravo').click();
run(scene, LAND_FRAMES, { drift: 0 });
let released = null;
let previous = scene.shots.at(-1);
let largest = 0;
for (let i = 0; i < 2000 && released === null; i += 1) {
scene.satellites.move(0.004, 0.004);
const shot = scene.frame();
largest = Math.max(largest, angleDeg(previous.direction, shot.direction));
previous = shot;
if (!cockpit.orbital.focus) released = i;
}
assert.ok(released > 0, 'the focus ended');
assert.ok(offNadir(scene.shots.at(-1)) > 40, 'held to the reach');
largest = Math.max(largest, run(scene, LAND_FRAMES, { drift: 0 }));
assert.ok(largest < MAX_TURN_PER_FRAME_DEG, `turned ${largest}° in a frame`);
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'back at nadir');
});
test('turning the layer off ends the focus and says why; a reload around the site keeps it', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
run(scene, 5);
scene.sites.enabled = false;
scene.frame();
assert.equal(scene.cockpit.orbital.focus, null, 'layer off');
assert.equal(scene.cockpit.orbital.focusReleasing, true, 'easing back');
await refreshRows(scene);
const status = rows(scene).find((row) => row.title === 'SITES STANDBY');
assert.ok(status, 'a status row says why no site is listed');
assert.equal(status.detail, 'ENABLE MAPPED INSTALLATIONS FOR SITE PINGS');
assert.equal(status.className, 'warning');
assert.ok(!rows(scene).some((row) => row.target?.layerId === 'military-installations'));
assert.equal(rows(scene).filter((row) => row.target?.layerId === 'satellites').length, 2);
scene.sites.enabled = true;
await refreshRows(scene);
assert.ok(!rows(scene).some((row) => row.title === 'SITES STANDBY'));
scene.click(SITES.bravo.at);
run(scene, LAND_FRAMES);
// The layer reloads its thinned view: the site leaves its records for a
// while. It has not moved, so the focus stays on it.
const loaded = scene.sites.records;
scene.sites.records = loaded.filter((r) => r.id !== SITES.bravo.id);
run(scene, 10);
assert.equal(scene.cockpit.orbital.focus.key, SITES.bravo.id, 'kept through a reload');
assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5, 'still centred');
scene.sites.records = loaded;
// A row the list shows works even if the layer just dropped its site.
await refreshRows(scene);
scene.sites.records = loaded.filter((r) => r.id !== SITES.alpha.id);
siteRow(scene, 'alpha').click();
assert.equal(scene.cockpit.orbital.focus.key, SITES.alpha.id);
});
test('a satellite row rides it, staying in orbit; RETURN goes back', async (t) => {
const scene = await inOrbit(t);
const { cockpit, satellites, viewer, modeEvents } = scene;
assert.ok(!rows(scene).some((row) => row.title.startsWith('RETURN')), 'no history yet');
siteRow(scene, 'alpha').click();
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);
assert.equal(cockpit.orbital.focus, null, 'the focus is reset');
assert.equal(modeEvents.at(-1)?.active, true);
await settle();
const shown = rows(scene);
assert.deepEqual([shown[0].title, shown[0].detail], ['TRACK ACQUIRED', 'LANDSAT 9 · NADIR VIEW']);
assert.equal(shown.filter((row) => row.title === 'TRACK ACQUIRED').length, 1);
const back = shown.find((row) => row.title === 'RETURN · ISS (ZARYA)');
assert.ok(back, 'a RETURN row while there is history');
assert.equal(back.detail, 'PREVIOUS SATELLITE');
assert.equal(satelliteRow(scene, 25544).getAttribute('aria-label'), 'Return to ISS (ZARYA)');
assert.ok(!shown.some((row) => row.title === 'ISS (ZARYA)'), 'listed once, as RETURN');
// The camera hops along the way, never in one jump.
const target = satellites.position(49260);
const hopM = Cesium.Cartesian3.distance(before, target);
let previous = before;
let largestStep = 0;
for (let i = 0; i < Math.ceil(ORBITAL_HOP_MS / FRAME_MS) + 2; i += 1) {
const shot = scene.frame();
assert.equal(cockpit.active, true, `still in orbit at frame ${i}`);
largestStep = Math.max(largestStep, Cesium.Cartesian3.distance(previous, shot.destination));
previous = shot.destination;
}
assert.ok(largestStep < hopM * 0.1, `largest step ${largestStep} m of ${hopM} m`);
assert.ok(Cesium.Cartesian3.distance(previous, target) < 1, 'landed on the satellite');
run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6, 'looking at nadir');
// On to another, then RETURN twice back to the start.
await refreshRows(scene);
satelliteRow(scene, 40697).click();
assert.equal(cockpit.subjectId, 40697);
await settle();
assert.ok(rows(scene).some((row) => row.title === 'RETURN · LANDSAT 9'));
satelliteRow(scene, 49260).click();
assert.equal(cockpit.subjectId, 49260);
await settle();
satelliteRow(scene, 25544).click();
assert.equal(cockpit.subjectId, 25544);
await settle();
assert.ok(!rows(scene).some((row) => row.title.startsWith('RETURN')), 'back at the start');
scene.frame();
assert.equal(cockpit.active, true);
assert.ok(!satellites.calls.some(([, id]) => id === 99001), 'debris never offered');
});
test('while the camera hops, no stale rows; on landing, rows measured from the new satellite', async (t) => {
const scene = await inOrbit(t);
const { cockpit } = scene;
satelliteRow(scene, 49260).click();
await settle();
// Mid-hop: TRACK ACQUIRED and RETURN only, nothing measured from the old
// satellite's frame (which would list LANDSAT 9 itself at 0 km).
let shown = rows(scene);
assert.deepEqual(
shown.map((row) => row.title),
['TRACK ACQUIRED', 'RETURN · ISS (ZARYA)'],
);
scene.frame();
scene.refreshCard();
await settle();
assert.ok(cockpit.orbital.hop, 'still hopping');
assert.deepEqual(rows(scene).map((row) => row.title), ['TRACK ACQUIRED', 'RETURN · ISS (ZARYA)']);
// Landed: the first frame on the satellite rebuilds them at once.
for (let i = 0; i < Math.ceil(ORBITAL_HOP_MS / FRAME_MS) + 2; i += 1) scene.frame();
await settle();
shown = rows(scene);
assert.ok(shown.some((row) => row.target?.layerId === 'military-installations'), 'sites back');
const satellitesShown = shown.filter((row) => row.title !== 'TRACK ACQUIRED' && row.target?.layerId === 'satellites');
assert.ok(!satellitesShown.some((row) => row.title === 'LANDSAT 9'), 'never the ridden one');
assert.equal(satellitesShown.filter((row) => row.target.id === '25544').length, 1, 'ISS only as RETURN');
});
test('RETURN to a satellite that left the catalogue offers the one before it', async (t) => {
const scene = await inOrbit(t);
const { cockpit, satellites } = scene;
satelliteRow(scene, 49260).click();
for (let i = 0; i < 80; i += 1) scene.frame();
await settle();
satelliteRow(scene, 40697).click();
for (let i = 0; i < 80; i += 1) scene.frame();
await settle();
assert.ok(rows(scene).some((row) => row.title === 'RETURN · LANDSAT 9'));
satellites.remove(49260);
satelliteRow(scene, 49260).click();
assert.equal(cockpit.subjectId, 40697, 'still on the same satellite');
await settle();
assert.ok(rows(scene).some((row) => row.title === 'RETURN · ISS (ZARYA)'), 'the one before');
satelliteRow(scene, 25544).click();
assert.equal(cockpit.subjectId, 25544);
});
test('with Installations off no site is offered, by row, list or click', async (t) => {
const scene = await inOrbit(t);
const { cockpit, sites } = scene;
// The real layer keeps its records and getNearby answers while off.
sites.enabled = false;
await refreshRows(scene);
assert.ok(rows(scene).some((row) => row.title === 'SITES STANDBY'));
assert.equal(cockpit.orbital.listedSites.size, 0, 'nothing kept to focus from');
assert.equal(cockpit.toggleOrbitalFocus(SITES.alpha.id), false);
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus ?? null, null);
});
test('an arrow during the ease back from a focus turns on the arrows’ clock', async (t) => {
const scene = await inOrbit(t);
const { cockpit } = scene;
siteRow(scene, 'alpha').click();
run(scene, LAND_FRAMES);
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,
preventDefault() {},
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) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
run(scene, 5);
scene.satellites.drop();
scene.frame();
assert.equal(scene.cockpit.active, false);
assert.equal(scene.store.presence.context, false);
scene.click(SITES.bravo.at);
assert.equal(scene.cockpit.toggleOrbitalFocus(SITES.bravo.id), false);
assert.equal(scene.cockpit.onOrbitalClick(SITES.bravo.at), false);
assert.equal(scene.cockpit.rideOrbitalSatellite(49260), false);
assert.equal(scene.cockpit.handleOrbitalSignalClick('satellites', '49260'), false);
});
test('NADIR and Home end a focus; an arrow takes the view over where it is', async (t) => {
const scene = await inOrbit(t);
const { $, cockpit } = scene;
const press = (key) =>
cockpit.onKeyDown({
key,
target: document.body,
preventDefault() {},
stopImmediatePropagation() {},
});
scene.click(SITES.alpha.at);
run(scene, LAND_FRAMES);
await settle();
$('#cockpit-orbital-nadir').click();
assert.equal(cockpit.orbital.focus, null);
assert.ok(offNadir(scene.frame()) > 5, 'eases back');
scene.click(SITES.alpha.at);
run(scene, LAND_FRAMES);
press('Home');
assert.equal(cockpit.orbital.focus, null);
scene.click(SITES.alpha.at);
run(scene, LAND_FRAMES);
const held = { ...cockpit.orbital.lookView };
press('ArrowUp');
assert.equal(cockpit.orbital.focus, null);
assert.equal(
cockpit.orbital.lookTarget.pitchDeg,
Math.floor(held.pitchDeg / 5 + 1e-9) * 5 + 5,
);
});
test('reduced motion cuts onto the site, back, and between satellites', async (t) => {
const scene = await inOrbit(t, { reducedMotion: true });
await settle();
scene.click(SITES.alpha.at);
assert.ok(missDeg(scene.frame(), 'alpha') < 1e-5, 'on it at once');
scene.click(EMPTY_GROUND);
assert.ok(offNadir(scene.frame()) < 1e-6, 'back at once');
satelliteRow(scene, 49260).click();
const shot = scene.frame();
assert.ok(Cesium.Cartesian3.distance(shot.destination, scene.satellites.position(49260)) < 1);
});
test('stopping the Cockpit hands both clicks back to Cesium', async (t) => {
const scene = fixture(t);
const click = Cesium.ScreenSpaceEventType.LEFT_CLICK;
const double = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
assert.notEqual(scene.handler.getInputAction(click), scene.handler.stock);
scene.cockpit.stop();
assert.equal(scene.handler.getInputAction(click), scene.handler.stock);
assert.equal(scene.handler.getInputAction(double), scene.handler.stockDouble);
});
test('the focused site keeps its row after nearer sites push it down', async (t) => {
const scene = await inOrbit(t);
siteRow(scene, 'alpha').click();
run(scene, LAND_FRAMES);
// Eight more sites appear right below the satellite, all nearer to nadir.
for (let i = 0; i < 8; i += 1)
scene.sites.records.push({
id: `osm:military:way/9${i}`,
osmKey: `way/9${i}`,
name: `Near ${i}`,
class: 'base',
kind: 'installation',
latitude: 30.4 + i * 0.01,
longitude: -96.6,
memberNames: [],
sources: [],
});
await refreshRows(scene);
const sitesShown = rows(scene).filter((row) => row.target?.layerId === 'military-installations');
assert.equal(sitesShown.length, 2);
assert.equal(sitesShown[0].target.id, SITES.alpha.id, 'the focused site is kept, first');
siteRow(scene, 'alpha').click();
assert.equal(scene.cockpit.orbital.focus, null, 'its row still ends the focus');
});
test('a site the layer drops in a reload keeps its row, and its row still focuses it', async (t) => {
const scene = await inOrbit(t);
await settle();
const before = rows(scene).map((row) => row.key);
const loaded = scene.sites.records;
// The thinned view reloads without Camp Alpha for a moment.
scene.sites.records = loaded.filter((r) => r.id !== SITES.alpha.id);
await refreshRows(scene);
assert.deepEqual(rows(scene).map((row) => row.key), before, 'the rows hold still');
siteRow(scene, 'alpha').click();
assert.equal(scene.cockpit.orbital.focus?.key, SITES.alpha.id, 'focused from its row');
siteRow(scene, 'alpha').click();
// Gone for good: after a while it leaves the rows.
for (let i = 0; i < 12; i += 1) await refreshRows(scene);
assert.ok(!rows(scene).some((row) => row.target?.id === SITES.alpha.id), 'forgotten later');
});
test('shown rows keep their places while they still qualify', async (t) => {
const scene = await inOrbit(t);
await settle();
const sitesBefore = rows(scene)
.filter((row) => row.target?.layerId === 'military-installations')
.map((row) => row.target.id);
// A site appears right below the satellite, nearer to nadir than both.
scene.sites.records.push({
id: 'osm:military:way/999',
osmKey: 'way/999',
name: 'Directly Below',
class: 'base',
kind: 'installation',
latitude: 30.01,
longitude: -97.01,
memberNames: [],
sources: [],
});
await refreshRows(scene);
const sitesAfter = rows(scene)
.filter((row) => row.target?.layerId === 'military-installations')
.map((row) => row.target.id);
assert.deepEqual(sitesAfter, sitesBefore, 'no row moves while its site is in reach');
// Once a shown site leaves the list, the nearer one takes its slot.
scene.sites.records = scene.sites.records.filter((r) => r.id !== SITES.bravo.id);
for (let i = 0; i < 12; i += 1) await refreshRows(scene);
assert.ok(
rows(scene).some((row) => row.target?.id === 'osm:military:way/999'),
'the nearer site fills the freed slot',
);
});
+191
View File
@@ -0,0 +1,191 @@
/**
* The satellites around the one the Cockpit's orbital view rides: the
* nearest ones for the Live Signals rows, and riding one of them (or going
* back to the one before) from its row. The view stays in orbit: the
* satellites layer tracks the
* new satellite, the Cockpit takes the camera straight back, any ground
* focus ends, and the camera hops along the great circle to the new
* satellite. Bound to the Cockpit controller like the orbital view itself.
*/
import * as Cesium from 'cesium';
import { t } from '../i18n/index.js';
import { zoomTransitionProgress } from '../cockpitOrbital.js';
import {
ORBITAL_HOP_MS,
ORBITAL_SATELLITES_MAX,
hopPosition,
} from '../cockpitOrbitalSatellites.js';
/** Whether the user asked for reduced motion: hops then cut. */
function prefersReducedMotion() {
return Boolean(
globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches,
);
}
function inOrbit(controller) {
return (
controller.active &&
!controller.destroyed &&
controller.subjectKind === 'satellite' &&
Boolean(controller.orbital)
);
}
/** The satellites ridden in this orbital visit, oldest first. */
function visits(controller) {
const state = controller.orbital;
if (!Array.isArray(state.visits)) state.visits = [];
const current = controller.subjectId;
if (current !== null && current !== undefined && !state.visits.length)
state.visits.push(current);
return state.visits;
}
/**
* The satellites nearest the ridden one, nearest first (debris left out):
* the layer's bounded nearest query, measured from the satellite itself
* (not the camera, which may still be hopping to it).
* @returns {Array<object>} As the layer's `getNearestPositions`.
*/
export function orbitalNearbySatellites() {
const layer = this.services?.satellitesLayer;
if (typeof layer?.getNearestPositions !== 'function') return [];
try {
const from = layer.getCockpitPosition?.();
if (!from) return [];
return (
layer.getNearestPositions(from, ORBITAL_SATELLITES_MAX, {
excludeIds: [this.subjectId],
excludeClasses: ['debris'],
}) || []
);
} catch {
return [];
}
}
/**
* The satellite ridden before this one in this orbital visit, or null.
* @returns {number|string|null}
*/
export function previousOrbitalSatellite() {
if (!inOrbit(this)) return null;
const ridden = visits(this);
return ridden.length > 1 ? ridden[ridden.length - 2] : null;
}
/**
* Ride the satellite a Live Signals row names: back to the one ridden before
* (its RETURN row), or on to another, remembered so RETURN can come back.
* @param {number|string} noradId
* @returns {boolean} True when the ridden satellite changed.
*/
export function rideOrbitalSatellite(noradId) {
if (!inOrbit(this)) return false;
const ridden = visits(this);
const back = ridden.length > 1 ? ridden[ridden.length - 2] : null;
if (back !== null && String(back) === String(noradId)) {
if (!switchOrbitalSatellite.call(this, back)) {
// It left the catalogue: forget it, so RETURN offers the one before.
ridden.splice(ridden.length - 2, 1);
this.refreshOrbitalSignals?.();
return false;
}
ridden.pop();
} else {
if (!switchOrbitalSatellite.call(this, noradId)) return false;
ridden.push(this.subjectId);
}
// The RETURN row follows the history just changed.
this.refreshOrbitalSignals?.();
return true;
}
/**
* Ride another satellite without leaving orbit. The satellites layer tracks
* it (as a click on its point would); the Cockpit takes the camera back at
* once, ends any ground focus with the view back at nadir, and starts the
* hop from where the camera is.
* @param {number|string} noradId
* @returns {boolean} True when the ridden satellite changed.
*/
export function switchOrbitalSatellite(noradId) {
if (!inOrbit(this)) return false;
if (String(noradId) === String(this.subjectId)) return false;
const layer = this.services?.satellitesLayer;
const state = this.orbital;
const from = state.focusFrame?.position ?? null;
let tracked = false;
try {
tracked = Boolean(layer?.trackById?.(noradId, { origin: 'user' }));
} catch {
tracked = false;
}
// Tracking hands the camera to Cesium's follow; the orbital view keeps it.
this.viewer.trackedEntity = undefined;
const info = tracked ? this.readSatelliteInfo() : null;
if (!info || String(info.noradId) !== String(noradId)) {
// Nothing changed hands, or the layer could not take it: the view ends
// on the next frame rather than ride something it did not ask for.
return false;
}
layer.setCockpitSubject?.(info.noradId);
this.subjectId = info.noradId;
state.focus = null;
state.focusReleasing = !prefersReducedMotion();
state.lookTarget = { yawDeg: 0, pitchDeg: 0 };
if (!state.focusReleasing) state.lookView = { yawDeg: 0, pitchDeg: 0 };
state.hop =
from && !prefersReducedMotion()
? { from: { ...from }, startMs: performance.now() }
: null;
// The rows measure from the camera's frame, still the old satellite's:
// until the camera is on the new one, they list no sites or satellites.
state.awaitingFrame = true;
state.previousUp = null;
state.place = undefined;
state.placeTracker?.reset?.();
state.prefetched?.clear?.();
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',
t('TRACK ACQUIRED'),
t('{label} · NADIR VIEW', { label: info.name }),
);
this.updateOrbitalHud(info);
this.refreshOrbitalSignals?.();
return true;
}
/**
* Where the camera is this frame: on the satellite, or part-way along a hop
* to it, eased like a zoom step. The hop ends when it lands.
* @param {object} state The orbital session.
* @param {Cesium.Cartesian3} position The ridden satellite this frame.
* @param {number} nowMs
* @returns {Cesium.Cartesian3}
*/
export function orbitalEyePosition(state, position, nowMs) {
const hop = state.hop;
if (!hop) return position;
const progress = zoomTransitionProgress(nowMs - hop.startMs, {
durationMs: ORBITAL_HOP_MS,
});
if (progress >= 1) {
state.hop = null;
return position;
}
const eye = hopPosition(hop.from, position, progress);
if (!eye) {
state.hop = null;
return position;
}
return new Cesium.Cartesian3(eye.x, eye.y, eye.z);
}
+260
View File
@@ -0,0 +1,260 @@
/**
* The Live Signals rows in the orbital view. Orbit feeds no Contacts
* snapshot, so the rows are built here, the way the aircraft rows are built
* from one (src/ui/cockpitSignals.js): the TRACK ACQUIRED row, the nearest
* mapped sites in reach (a row focuses its site, or releases the one held),
* a RETURN row to the satellite ridden before, and the nearest satellites (a
* row rides it). With Mapped Installations off a status row says why no
* site is listed. Five rows at most, with stable keys, rebuilt on their own
* clock. Bound to the Cockpit controller.
*/
import { formatNumber, t } from '../i18n/index.js';
import { ORBITAL_REACH_REFRESH_MS } from '../cockpitOrbitalFocus.js';
import { bearingBetweenCoordinates, relativeBearing } from '../cockpitMath.js';
import {
installationClassLabel,
installationTitle,
} from '../layers/installations/classLabel.js';
import { formatCockpitDistance } from './cockpitPresentation.js';
import { orbitalSitesInReach } from './cockpitOrbitalFocus.js';
import {
orbitalNearbySatellites,
previousOrbitalSatellite,
} from './cockpitOrbitalSatellites.js';
/** The most rows Live Signals shows, as for aircraft. */
const SIGNAL_ROWS = 5;
/** Site rows when there are satellites to list too. */
const SITE_ROWS = 2;
const SITES_LAYER_ID = 'military-installations';
function inOrbit(controller) {
return (
controller.active &&
!controller.destroyed &&
controller.subjectKind === 'satellite' &&
Boolean(controller.orbital)
);
}
function sitesLayerOn(controller) {
const isLayerEnabled = controller.services?.isLayerEnabled;
return typeof isLayerEnabled === 'function'
? isLayerEnabled(SITES_LAYER_ID) !== false
: false;
}
/** Where another satellite is, from the top of the frame, worded. */
function bearingWords(info, satellite) {
const bearing = bearingBetweenCoordinates(
info.latitude,
info.longitude,
satellite.latitude,
satellite.longitude,
);
// Relative to the top of the frame: the ground track, or north.
const relative = relativeBearing(
bearing,
Number.isFinite(info.groundTrackDeg) ? info.groundTrackDeg : 0,
);
if (relative === null) return t('BRG —');
if (Math.abs(relative) < 8) return t('AHEAD');
return t('{side} {angle}', {
side: relative < 0 ? t('L') : t('R'),
angle: `${String(Math.round(Math.abs(relative))).padStart(3, '0')}°`,
});
}
/** The ids the rows of one layer show now, in their order. */
function shownIds(controller, layerId, keyPrefix) {
return controller.signalItems
.filter((item) => item.key.startsWith(keyPrefix))
.filter((item) => item.target?.layerId === layerId)
.map((item) => String(item.target.id));
}
/**
* Order rows so those already shown keep their places while they still
* qualify; new ones fill after them, nearest first. Sites pass under a low
* orbit in seconds: re-sorting by angle every beat would move a row away
* from the pointer reaching for it.
* @param {Array<object>} entries Nearest first.
* @param {(entry: object) => string} keyOf
* @param {Array<string>} shown Ids shown now, in order.
* @param {string|null} [first] An id that goes first (the focused site).
*/
function stickyOrder(entries, keyOf, shown, first = null) {
const byKey = new Map(entries.map((entry) => [keyOf(entry), entry]));
const ordered = [];
const take = (key) => {
const entry = byKey.get(key);
if (!entry) return;
ordered.push(entry);
byKey.delete(key);
};
if (first !== null) take(first);
for (const key of shown) take(key);
for (const entry of entries) take(keyOf(entry));
return ordered;
}
/** The site rows: the focused site first, then those shown, then the nearest. */
function siteRows(info, { settled = true } = {}) {
if (!sitesLayerOn(this)) {
// Also forgets the sites last listed, so none is focused from a row.
orbitalSitesInReach.call(this, info);
return [
{
key: 'site-status',
tone: 'warning',
title: t('SITES STANDBY'),
detail: t('ENABLE MAPPED INSTALLATIONS FOR SITE PINGS'),
target: null,
},
];
}
if (!settled) return [];
const { entries } = orbitalSitesInReach.call(this, info);
const focused = entries.find((entry) => entry.focused);
return stickyOrder(
entries,
(entry) => String(entry.site.id),
shownIds(this, SITES_LAYER_ID, 'site:'),
focused ? String(focused.site.id) : null,
).map(({ site, offNadirDeg, focused }) => {
const title = installationTitle(site.name) || t('Unnamed site');
const angle = t('OFF-NADIR {angle}°', {
angle: formatNumber(offNadirDeg, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
});
const words = [installationClassLabel(site.class), angle];
if (focused) words.unshift(t('FOCUS'));
return {
key: `site:${site.id}`,
// The held site reads as the subject does.
tone: focused ? 'track' : 'nearby',
title,
detail: words.join(' · '),
target: {
layerId: SITES_LAYER_ID,
id: String(site.id),
label: focused
? t('Release the camera from {name}', { name: title })
: t('Keep the camera on {name}', { name: title }),
},
};
});
}
/** The satellite rows: RETURN to the one before, then the nearest. */
function satelliteRows(info) {
const state = this.orbital;
state.satelliteNames ??= new Map();
state.satelliteNames.set(String(info.noradId), info.name);
const rows = [];
const back = previousOrbitalSatellite.call(this);
if (back !== null) {
const name = state.satelliteNames.get(String(back)) ?? String(back);
rows.push({
key: `satellite-return:${back}`,
tone: 'nearby',
title: t('RETURN · {name}', { name }),
detail: t('PREVIOUS SATELLITE'),
target: {
layerId: 'satellites',
id: String(back),
label: t('Return to {name}', { name }),
},
});
}
// Measured only once the camera is on the ridden satellite.
const measured = !state.hop && !state.awaitingFrame;
const nearby = measured
? stickyOrder(
orbitalNearbySatellites.call(this),
(satellite) => String(satellite.id),
shownIds(this, 'satellites', 'satellite:'),
)
: [];
for (const satellite of nearby) {
if (back !== null && String(satellite.id) === String(back)) continue;
const name = String(satellite.label ?? satellite.id);
state.satelliteNames.set(String(satellite.id), name);
rows.push({
key: `satellite:${satellite.id}`,
tone: 'nearby',
title: name,
detail: [
t('SATELLITE'),
bearingWords(info, satellite),
formatCockpitDistance(satellite.distanceM),
].join(' · '),
target: {
layerId: 'satellites',
id: String(satellite.id),
label: t('Ride {name}', { name }),
},
});
}
return rows;
}
/**
* Rebuild the orbital Live Signals rows now: after a focus change, a
* satellite switch, or on their clock. TRACK ACQUIRED stays first; sites
* take two rows when satellites fill the rest, more when they do not.
*/
export function refreshOrbitalSignals() {
if (!inOrbit(this)) return;
const info = this.lastSubjectInfo;
if (info?.kind !== 'satellite') return;
this.orbital.signalsMs = performance.now();
const previous = new Map(this.signalItems.map((item) => [item.key, item]));
const rows = [];
const track = this.signalItems.find((item) => item.key === 'track');
if (track) rows.push(track);
// While the camera hops to a new satellite its frame is not the
// satellite's yet: the site rows wait for it (the status row does not).
const settled = !this.orbital.hop && !this.orbital.awaitingFrame;
const sites = siteRows.call(this, info, { settled });
const satellites = satelliteRows.call(this, info);
const free = SIGNAL_ROWS - rows.length;
rows.push(...sites.slice(0, Math.max(SITE_ROWS, free - satellites.length)));
rows.push(...satellites.slice(0, SIGNAL_ROWS - rows.length));
const now = Date.now();
this.signalItems = rows.map((row) => ({
...row,
timestamp: previous.get(row.key)?.timestamp ?? row.timestamp ?? now,
}));
this.signalSignatures.clear();
this.publishCockpitSignals();
}
/**
* The rows' clock: rebuild once every ORBITAL_REACH_REFRESH_MS.
* @param {number} nowMs
*/
export function updateOrbitalSignals(nowMs) {
const state = this.orbital;
if (!state) return;
if (nowMs - (state.signalsMs ?? -Infinity) < ORBITAL_REACH_REFRESH_MS) return;
refreshOrbitalSignals.call(this);
}
/**
* A click on an orbital row: a site row focuses its site or releases it, a
* satellite row rides that satellite (RETURN goes back).
* @param {string} layerId
* @param {string} id
* @returns {boolean} True in orbit, where the row is this module's.
*/
export function handleOrbitalSignalClick(layerId, id) {
if (!inOrbit(this)) return false;
if (layerId === SITES_LAYER_ID) this.toggleOrbitalFocus(id);
else if (layerId === 'satellites') this.rideOrbitalSatellite(id);
return true;
}
+58 -18
View File
@@ -34,7 +34,6 @@ import {
} from '../cockpitOrbital.js';
import {
clampOrbitalLook,
easeLook,
lookIsOffset,
orbitalLookReadoutParts,
stepOrbitalLook,
@@ -43,6 +42,13 @@ import { cockpitUiUpdateDue } from '../cockpitMath.js';
import { createPlaceTracker, nearPlaceRadiusKm } from '../data/placeBelow.js';
import { isPointerFree } from '../data/inputOwnership.js';
import { zoomableSwathKm } from '../layers/satellites/sensors.js';
import {
easeOrbitalLook,
orbitalFocusReadout,
releaseOrbitalFocus,
updateOrbitalFocus,
} from './cockpitOrbitalFocus.js';
import { orbitalEyePosition } from './cockpitOrbitalSatellites.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
COCKPIT_HUD_UPDATE_MS,
@@ -299,9 +305,11 @@ export function updateOrbital(nowMs) {
);
if (!position) return;
const state = this.orbital;
// On the satellite, or on the way to it after Previous/Next.
const eye = orbitalEyePosition(state, position, nowMs);
// Track-up: the ground track at the top, so the picture scrolls along it.
// A geostationary satellite has no track and stays north-up.
const nadir = nadirOrientation(position, {
const nadir = nadirOrientation(eye, {
previousUp: state.previousUp,
headingDeg: Number.isFinite(info.groundTrackDeg)
? info.groundTrackDeg
@@ -318,12 +326,13 @@ export function updateOrbital(nowMs) {
this.lastSubjectInfo = info;
goToZoom.call(this, maxZoom);
}
state.lookView = easeLook(
state.lookView,
state.lookTarget,
nowMs - (state.lookLastMs || nowMs),
{ immediate: prefersReducedMotion() },
);
// A ground focus aims the look itself (src/ui/cockpitOrbitalFocus.js).
if (!updateOrbitalFocus.call(this, state, nadir, eye, nowMs))
state.lookView = easeOrbitalLook(
state,
nowMs - (state.lookLastMs || nowMs),
{ immediate: prefersReducedMotion() },
);
state.lookLastMs = nowMs;
const view = offNadirOrientation(nadir, {
northDeg: state.lookView.pitchDeg,
@@ -332,7 +341,7 @@ export function updateOrbital(nowMs) {
const zoomStepping = Boolean(state.transition);
const reading = applyFov.call(this, state, info.altitudeM, nowMs);
this.viewer.camera.setView({
destination: position,
destination: eye,
orientation: {
direction: intoCartesian(view.direction, this.scratchForward),
up: intoCartesian(view.up, this.scratchUp),
@@ -592,16 +601,31 @@ function lookTilt(parts) {
};
}
/**
* Word a ground focus's off-nadir angle, as image metadata gives it:
* `OFF-NADIR 23.4°`.
*/
function wordFocusOffNadir(focus) {
return t('OFF-NADIR {angle}°', {
angle: formatNumber(focus.offNadirDeg, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}),
});
}
/**
* Publish the orbital HUD. The swath is the nadir footprint, so it is left
* out while the view is tilted off nadir.
* out while the view is tilted off nadir. A ground focus reads the single
* off-nadir angle to its target and scales the swath by the tilt onto it.
* @param {object} info Satellite subject (`readSatelliteInfo`).
* @param {{active: boolean, blurPx: number, lockTick: boolean}} transition
*/
export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
const state = this.orbital;
if (!state || !info) return;
const offset = lookIsOffset(state.lookTarget, 0);
const focus = orbitalFocusReadout.call(this);
const offset = Boolean(focus) || lookIsOffset(state.lookTarget, 0);
const aspect = Number.isFinite(this.viewer.camera?.frustum?.aspectRatio)
? this.viewer.camera.frustum.aspectRatio
: canvasAspect(this.viewer);
@@ -610,15 +634,21 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
const maxZoom = orbitalMaxZoomFor.call(this, info);
const swathKm = sensorSwathKm(info);
const trackUp = Number.isFinite(info.groundTrackDeg);
const tilt = offset
? lookTilt(orbitalLookReadoutParts(state.lookTarget))
: null;
const swathScale = offset ? offNadirSwathScale(tilt) : 1;
// A ground focus sets the tilt; otherwise the look target does.
const tilt = focus
? { northDeg: focus.pitchDeg, eastDeg: focus.yawDeg }
: offset
? lookTilt(orbitalLookReadoutParts(state.lookTarget))
: null;
const swathScale = tilt ? offNadirSwathScale(tilt) : 1;
// Tilted past the limb, the view's centre looks into space: no swath. The
// camera eases toward the target, so both must meet the Earth; the HUD
// republishes on its cadence, so the swath appears once the view arrives.
// Exact angles here, not the whole degrees the readout rounds to.
const target = clampOrbitalLook(state.lookTarget);
// Exact angles here, not the whole degrees the readout rounds to; a ground
// focus aims the look itself, so its angles are the target.
const target = focus
? { pitchDeg: focus.pitchDeg, yawDeg: focus.yawDeg }
: clampOrbitalLook(state.lookTarget);
// The eased view is tested even with the target at nadir: on the way back
// from past the limb it still looks into space.
const seesEarth =
@@ -664,7 +694,13 @@ export function publishOrbitalView(info, transition = IDLE_TRANSITION) {
transition: { ...transition },
look: {
offset,
readout: offset ? wordOffNadir(state.lookTarget, { trackUp }) : '',
readout: focus
? wordFocusOffNadir(focus)
: offset
? wordOffNadir(state.lookTarget, { trackUp })
: '',
// The mapped point the view is held on, or null.
focus: focus ? { key: focus.key, label: focus.label } : null,
// Off nadir, the ground width at the view's centre, approximated from
// the nadir footprint (`offNadirSwathScale`); 1 and null at nadir.
swathScale,
@@ -844,6 +880,8 @@ export function turnOrbitalLook(step) {
return false;
if (!step) return false;
const state = this.orbital;
// An arrow takes the view over from a ground focus where it is.
releaseOrbitalFocus.call(this, { handOver: true });
state.lookTarget = stepOrbitalLook(state.lookTarget, step);
if (prefersReducedMotion()) state.lookView = { ...state.lookTarget };
presentNow.call(this);
@@ -859,6 +897,8 @@ export function resetOrbitalLook({ immediate = false } = {}) {
if (!this.active || this.subjectKind !== 'satellite' || !this.orbital)
return false;
const state = this.orbital;
// Ends a ground focus too, easing back on its clock.
releaseOrbitalFocus.call(this);
state.lookTarget = { ...NADIR_LOOK };
if (immediate || prefersReducedMotion()) state.lookView = { ...NADIR_LOOK };
presentNow.call(this);
+12 -1
View File
@@ -28,7 +28,10 @@ export function publishCockpitSignals() {
target: item.target && {
layerId: item.target.layerId,
id: item.target.id,
label: t('Select flight {title}', { title: item.title }),
// Orbit's site and satellite rows name their own action.
label:
item.target.label ??
t('Select flight {title}', { title: item.title }),
},
};
}),
@@ -212,6 +215,14 @@ export function handleSignalClick(event) {
);
if (!target) return;
event.preventDefault();
// In orbit the rows are sites and satellites (cockpitOrbitalSignals.js).
if (
this.handleOrbitalSignalClick?.(
target.dataset.signalLayer,
target.dataset.signalId,
)
)
return;
this.services.militaryAwarenessLayer.focusTarget?.(
target.dataset.signalLayer,
target.dataset.signalId,
+5 -2
View File
@@ -93,7 +93,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
* zoom, zoomSteps: number[], maxZoom, footprintKm|null,
* sensor: { label|null, swathKm, stripFraction|null, zoom|null }|null,
* transition: { active, blurPx, lockTick },
* look: { offset, readout, swathScale, swathKm|null }, trackUp, northDeg,
* look: { offset, readout, swathScale, swathKm|null,
* focus: { key, label }|null }, trackUp, northDeg,
* place: { kind: 'country'|'sea'|'ocean', name, countryCode,
* division?|null, near?|null }|null|undefined,
* noradId|null, cosparId|null, tle: { line1, line2, epochMs }|null,
@@ -117,7 +118,9 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
* src/cockpitOrbital.js), `look` says whether the view is tilted off
* nadir and words the tilt (`readout`, '' at nadir), with the approximate
* ground width at the tilted view's centre (`swathKm`, null at nadir) and
* its ratio to the nadir footprint (`swathScale`, 1 at nadir), `trackUp`
* its ratio to the nadir footprint (`swathScale`, 1 at nadir) and the
* mapped point a ground focus holds the view on (`focus`; the readout
* then gives the single off-nadir angle to it), `trackUp`
* whether the view is turned to the ground track (false for a satellite with none,
* which stays north-up) and `northDeg` where north sits, in degrees
* clockwise from the top (0 when north-up).
+11
View File
@@ -0,0 +1,11 @@
/* The site a ground focus holds, named under the off-nadir readout
(src/ui/views/panels/CockpitOrbital.vue). Empty, it takes no room. */
.cockpit-orbital-focus:not(:empty) {
margin-top: 4px;
overflow: hidden;
color: #f4fbff;
font-weight: 700;
letter-spacing: 0.1em;
text-overflow: ellipsis;
white-space: nowrap;
}
+8 -1
View File
@@ -254,6 +254,12 @@ watch(
// The off-nadir tilt; an untilted view shows no readout or reset.
const look = computed(() => orbital.value?.look || { offset: false });
// A ground focus names its target in a polite live region; the angle,
// which changes every update, is left out of it.
const focusText = computed(() => {
const label = look.value.focus?.label;
return label ? t('FOCUS · {name}', { name: label }) : '';
});
const transition = computed(() => orbital.value?.transition || {});
const lensStyle = computed(() => {
@@ -317,9 +323,10 @@ const lensStyle = computed(() => {
<p class="cockpit-orbital-north">{{ trackUp ? t('TRACK UP') : t('NORTH UP') }}</p>
<!-- Shown only while the view is tilted off nadir: where it looks and the way back. -->
<div v-if="look.offset" class="cockpit-orbital-look">
<span id="cockpit-orbital-look-readout" class="cockpit-orbital-look-readout" aria-live="polite">{{ look.readout }}</span>
<span id="cockpit-orbital-look-readout" class="cockpit-orbital-look-readout" :aria-live="look.focus ? 'off' : 'polite'">{{ look.readout }}</span>
<button id="cockpit-orbital-nadir" ref="nadirButton" type="button" class="cockpit-orbital-nadir" :aria-label="t('Return the view to nadir')" :title="t('Return to nadir (Home or 0)')" aria-keyshortcuts="Home 0">{{ t('NADIR') }}</button>
</div>
<p id="cockpit-orbital-focus" class="cockpit-orbital-focus" aria-live="polite">{{ focusText }}</p>
</div>
<div class="cockpit-orbital-optics">
<button id="cockpit-orbital-zoom-out" ref="zoomOutButton" type="button" class="cockpit-orbital-zoom-button" :disabled="atWidest" :aria-label="t('Zoom out')" :title="t('Zoom out (−)')" aria-keyshortcuts="- _">−</button>
+1
View File
@@ -11,6 +11,7 @@
@import './src/ui/styles/cockpitBridge.css';
@import './src/ui/styles/cockpitRadar.css';
@import './src/ui/styles/cockpitOrbital.css';
@import './src/ui/styles/cockpitOrbitalFocus.css';
@import './src/ui/styles/overlays.css';
@import './src/ui/styles/layers.css';
@import './src/ui/styles/radio.css';