mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 04:44:01 +00:00
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:
36 files changed
+2989
-27
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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} كم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} КМ",
|
||||
|
||||
@@ -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} КМ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+35
@@ -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.
|
||||
|
||||
Vendored
+66
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user