feat(cockpit): keep the orbital camera on a ground target

In the satellite orbital view a single click on a mapped ground point (a
ground point of a layer that is on, such as a Mapped Installations site, a
fire or a local data pack feature) keeps the camera on it. Each frame the
look is aimed from the satellite's position to the point's Earth-fixed
position in the track-up nadir frame the arrows tilt, so the point stays
at the centre as the satellite moves. The swing onto the point, or between
points, uses the arrows' easing on a slower clock and then holds the point
exactly; reduced motion cuts.

A click elsewhere, FOCUS on the focused point, NADIR, Home or 0 ends the
focus and the view eases back to nadir; an arrow ends it and tilts on from
where the view is. The focus also ends, easing back, when the point leaves
the ±45° reach or the satellite's horizon, when its layer is turned off or
its record is dropped, and with the view. While focused the readout gives
the single off-nadir angle to the point (OFF-NADIR 23.4°), names it in a
polite live region, and the swath reads approximate as for a tilt. Clicks
still reach Cesium's own handler and never untrack the satellite; a click
a tool owns is ignored.

Under the readout, IN REACH lists the mapped points facing the satellite
that are within the reach or in the frame, nearest to nadir first and at
most 12, rebuilt once a second. A row opens the point's position, source
and OpenStreetMap tags (or its layer's properties); FOCUS focuses it,
switches to it, or ends the focus held on it, and is unavailable for a
point in frame but beyond the reach.
This commit is contained in:
Denis Groshev committed 2026-11-09 01:56:29 +01:00
1 parent 745c11969b
commit 567c833582
24 files changed
+2132 -19

No files matched your search

+15
View File
@@ -41,6 +41,7 @@
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/views/panels/CockpitRadar.vue",
"src/ui/views/panels/CockpitOrbital.vue",
"src/ui/views/panels/CockpitOrbitalReach.vue",
"src/ui/ownerFlag.js",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
@@ -903,6 +904,11 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/data/contextStore.js",
"src/data/pickedContext.js",
"src/data/pickRegistry.js",
"src/data/scenePick.js",
"src/data/placeBelow.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
@@ -926,6 +932,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 +1007,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 +1061,10 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/data/contextStore.js",
"src/data/pickedContext.js",
"src/data/pickRegistry.js",
"src/data/placeBelow.js",
"src/layers/satellites/sensors.js",
"src/data/adminBoundaries.js",
@@ -2448,6 +2460,7 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
@@ -2893,6 +2906,7 @@
"src/ui/cockpitRollingValue.js",
"src/ui/cockpitCamera.js",
"src/ui/cockpitOrbitalView.js",
"src/ui/cockpitOrbitalFocus.js",
"src/data/placeBelow.js",
"src/ui/cockpitContext.js",
"src/ui/cockpitController.js",
@@ -4237,6 +4251,7 @@
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/cockpitOrbital.js",
"src/cockpitOrbitalFocus.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
+281
View File
@@ -0,0 +1,281 @@
/**
* 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 list of mapped points in reach is rebuilt, ms. */
export const ORBITAL_REACH_REFRESH_MS = 1000;
/** The most mapped points the reach list shows, nearest to nadir first. */
export const ORBITAL_REACH_LIST_MAX = 12;
/** The most detail rows (OpenStreetMap tags or properties) shown per point. */
export const ORBITAL_REACH_DETAIL_MAX = 16;
/** Slack on the reach limit so a target held exactly at 45° stays. */
const REACH_EPSILON_DEG = 1e-6;
const WGS84_A = 6378137;
const WGS84_E2 = 6.69437999014e-3;
const toRadians = (deg) => (deg * Math.PI) / 180;
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)
);
}
/**
* Earth-fixed position of a geodetic point on the WGS84 ellipsoid, as
* Cesium's `Cartesian3.fromDegrees` places it.
* @param {number} latDeg
* @param {number} lonDeg
* @param {number} [heightM] Height above the ellipsoid.
* @returns {{x: number, y: number, z: number}|null}
*/
export function geodeticToEcef(latDeg, lonDeg, heightM = 0) {
if (!Number.isFinite(latDeg) || !Number.isFinite(lonDeg)) return null;
const h = Number.isFinite(heightM) ? heightM : 0;
const lat = toRadians(latDeg);
const lon = toRadians(lonDeg);
const sinLat = Math.sin(lat);
const cosLat = Math.cos(lat);
const n = WGS84_A / Math.sqrt(1 - WGS84_E2 * sinLat * sinLat);
return {
x: (n + h) * cosLat * Math.cos(lon),
y: (n + h) * cosLat * Math.sin(lon),
z: (n * (1 - WGS84_E2) + h) * sinLat,
};
}
/**
* 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 satellite is above its horizon when the
// line from the target to it points up.
const targetRadius = Math.hypot(target.x, target.y, target.z);
const visible =
targetRadius > 0 &&
-(line.x * target.x + line.y * target.y + line.z * target.z) /
targetRadius >
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
);
}
/**
* Whether `target` is inside the picture as it is now framed.
* @param {{direction: object, up: object, right: object}} view The camera's
* orientation (the nadir frame tilted by the eased look).
* @param {{x: number, y: number, z: number}} satellite ECEF, metres.
* @param {{x: number, y: number, z: number}} target ECEF, metres.
* @param {{verticalFovRad: number, aspect?: number}} frame
* @returns {boolean}
*/
export function inFrame(
view,
satellite,
target,
{ verticalFovRad, aspect = 1 },
) {
if (!view || !finiteVector(satellite) || !finiteVector(target)) return false;
if (!(verticalFovRad > 0)) return false;
const line = {
x: target.x - satellite.x,
y: target.y - satellite.y,
z: target.z - satellite.z,
};
const along = dot(line, view.direction);
if (!(along > 0)) return false;
const halfTan = Math.tan(verticalFovRad / 2);
const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
return (
Math.abs(dot(line, view.up)) / along <= halfTan &&
Math.abs(dot(line, view.right)) / along <= halfTan * ratio
);
}
/**
* Ease what is left of the turn onto a focus target toward nothing: the
* look's easing (`easeLook`) on the focus's slower clock. The view is the
* 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 shows now or can be turned to: those facing
* the satellite and either within the off-nadir reach (`reachable`) or in
* the frame as it is. Nearest to nadir first, at most `max`.
* @param {Iterable<{key: string, position: {x: number, y: number,
* z: number}}>} candidates
* @param {{nadir: object, view: object, satellite: object,
* verticalFovRad: number, aspect?: number, max?: number}} frame
* @returns {{count: number, entries: Array<{key: string,
* offNadirDeg: number, reachable: boolean, look: object}>}} `count` is
* every point in reach, `entries` the nearest.
*/
export function pointsInReach(
candidates,
{
nadir,
view,
satellite,
verticalFovRad,
aspect = 1,
max = ORBITAL_REACH_LIST_MAX,
},
) {
const entries = [];
if (!nadir || !finiteVector(satellite)) return { count: 0, entries };
for (const candidate of candidates) {
const look = focusLook(nadir, satellite, candidate?.position);
if (!look?.visible) continue;
const reachable = focusInReach(look);
if (
!reachable &&
!inFrame(view, satellite, candidate.position, { verticalFovRad, aspect })
)
continue;
entries.push({
key: candidate.key,
offNadirDeg: look.offNadirDeg,
reachable,
look,
});
}
entries.sort(
(a, b) =>
a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key),
);
const limit =
Number.isInteger(max) && max >= 0 ? max : ORBITAL_REACH_LIST_MAX;
return { count: entries.length, entries: entries.slice(0, limit) };
}
/**
* A mapped point's details as key/value rows: its OpenStreetMap tags, or the
* properties its layer keeps. Text, numbers and lists of text only; keys
* starting with `_` are internal. At most `max` rows, in the layer's order.
* @param {object|null|undefined} properties
* @param {number} [max]
* @returns {Array<{key: string, value: string}>}
*/
export function detailRows(properties, max = ORBITAL_REACH_DETAIL_MAX) {
const rows = [];
if (!properties || typeof properties !== 'object') return rows;
for (const [key, raw] of Object.entries(properties)) {
if (rows.length >= max) break;
if (!key || key.startsWith('_')) continue;
let value = null;
if (typeof raw === 'string') value = raw.trim();
else if (typeof raw === 'number' && Number.isFinite(raw))
value = String(raw);
else if (typeof raw === 'boolean') value = raw ? 'yes' : 'no';
else if (Array.isArray(raw)) {
const items = raw
.filter((item) => typeof item === 'string' && item.trim())
.map((item) => item.trim());
value = items.join(', ');
}
if (value) rows.push({ key, value });
}
return rows;
}
+291
View File
@@ -0,0 +1,291 @@
// 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,
ORBITAL_REACH_LIST_MAX,
detailRows,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
geodeticToEcef,
inFrame,
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),
};
};
const ISS_ALT_M = 420_000;
const satelliteAt = (latDeg, lonDeg, altitudeM = ISS_ALT_M) =>
geodeticToEcef(latDeg, lonDeg, altitudeM);
test('geodetic positions match Cesium on the WGS84 ellipsoid', () => {
for (const [lat, lon, h] of [
[0, 0, 0],
[52.37, 4.9, 12],
[-33.9, 151.2, 420_000],
[89.9, -120, 0],
]) {
const mine = geodeticToEcef(lat, lon, h);
const cesium = Cesium.Cartesian3.fromDegrees(lon, lat, h);
for (const axis of ['x', 'y', 'z'])
near(mine[axis], cesium[axis], `${lat},${lon} ${axis}`, 1e-6);
}
assert.equal(geodeticToEcef(Number.NaN, 0), null);
});
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 frame = {
nadir,
view: nadir,
satellite,
verticalFovRad: (60 * Math.PI) / 180,
aspect: 16 / 9,
};
const all = pointsInReach(candidates, { ...frame, max: 100 });
assert.ok(all.entries.every((entry) => entry.key !== 'far-side'));
const angles = all.entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first');
assert.equal(all.entries[0].key, 'east-0');
assert.equal(all.entries[0].offNadirDeg, 0);
// The widest frame (16:9 at 60° tall) reaches past 45° across: those
// points are listed, but cannot be focused.
const beyond = all.entries.filter((entry) => !entry.reachable);
assert.ok(beyond.length > 0, 'some only in frame');
for (const entry of beyond) assert.ok(Math.abs(entry.look.yawDeg) > 45);
for (const entry of all.entries.filter((e) => e.reachable))
assert.ok(Math.abs(entry.look.yawDeg) <= 45);
// Out of frame and out of reach: not listed.
assert.ok(all.count < 80);
const bounded = pointsInReach(candidates, frame);
assert.equal(bounded.entries.length, ORBITAL_REACH_LIST_MAX);
assert.equal(bounded.count, all.count, 'the count is all of them');
assert.deepEqual(
bounded.entries.map((entry) => entry.key),
all.entries.slice(0, ORBITAL_REACH_LIST_MAX).map((entry) => entry.key),
);
assert.deepEqual(pointsInReach([], frame), { count: 0, entries: [] });
});
test('in frame follows the view, its height and its width', () => {
const satellite = satelliteAt(0, 0);
const nadir = nadirOrientation(satellite);
const target = geodeticToEcef(0, 3);
const narrow = { verticalFovRad: (5 * Math.PI) / 180, aspect: 1 };
assert.equal(inFrame(nadir, satellite, target, narrow), false);
const look = focusLook(nadir, satellite, target);
const tilted = offNadirOrientation(nadir, {
northDeg: look.pitchDeg,
eastDeg: look.yawDeg,
});
assert.equal(inFrame(tilted, satellite, target, narrow), true);
assert.equal(inFrame(tilted, satellite, target, { verticalFovRad: 0 }), false);
});
test('details keep text, numbers and lists of text, and drop internal keys', () => {
assert.deepEqual(
detailRows({
name: ' Camp Example ',
military: 'base',
height: 12,
bad: Number.NaN,
empty: '',
_internal: 'x',
memberNames: ['North field', '', 4, 'South field'],
validation: { ok: true },
fixme: false,
}),
[
{ key: 'name', value: 'Camp Example' },
{ key: 'military', value: 'base' },
{ key: 'height', value: '12' },
{ key: 'memberNames', value: 'North field, South field' },
{ key: 'fixme', value: 'no' },
],
);
assert.deepEqual(detailRows(null), []);
assert.equal(
detailRows(Object.fromEntries([...Array(40)].map((_, i) => [`k${i}`, 'v'])))
.length,
16,
);
});
+12
View File
@@ -806,6 +806,18 @@
"{value} KM/S": "{value} كم/ث",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "انحراف عن النظير {angle}° · {offset}",
"OFF-NADIR {angle}°": "انحراف عن النظير {angle}°",
"FOCUS · {name}": "تركيز · {name}",
"IN REACH · {count}": "في المتناول · {count}",
"NEAREST {shown} OF {count}": "أقرب {shown} من {count}",
"Mapped points in reach": "النقاط المرسومة في المتناول",
"Hide the mapped points in reach": "إخفاء النقاط المرسومة في المتناول",
"Show the mapped points in reach": "إظهار النقاط المرسومة في المتناول",
"NOTHING MAPPED IN REACH": "لا شيء مرسوم في المتناول",
"Keep the camera on {name}": "إبقاء الكاميرا على {name}",
"Beyond the off-nadir reach": "خارج مدى الانحراف عن النظير",
"OPENSTREETMAP TAGS": "وسوم OpenStreetMap",
"DETAILS": "التفاصيل",
"ZOOM {zoom}×": "تكبير {zoom}×",
"SWATH {value} KM": "عرض نطاق التصوير {value} كم",
"SWATH ≈ {value} KM": "عرض نطاق التصوير ≈ {value} كم",
+12
View File
@@ -193,6 +193,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}",
"OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°",
"FOCUS · {name}": "FOKUS · {name}",
"IN REACH · {count}": "IN REICHWEITE · {count}",
"NEAREST {shown} OF {count}": "NÄCHSTE {shown} VON {count}",
"Mapped points in reach": "Kartierte Punkte in Reichweite",
"Hide the mapped points in reach": "Kartierte Punkte in Reichweite ausblenden",
"Show the mapped points in reach": "Kartierte Punkte in Reichweite anzeigen",
"NOTHING MAPPED IN REACH": "NICHTS KARTIERTES IN REICHWEITE",
"Keep the camera on {name}": "Kamera auf {name} halten",
"Beyond the off-nadir reach": "Außerhalb der Reichweite der Nadirabweichung",
"OPENSTREETMAP TAGS": "OPENSTREETMAP-TAGS",
"DETAILS": "EINZELHEITEN",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STREIFENBREITE {value} KM",
"SWATH ≈ {value} KM": "STREIFENBREITE ≈ {value} KM",
+12
View File
@@ -664,6 +664,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}",
"OFF-NADIR {angle}°": "FUERA DEL NADIR {angle}°",
"FOCUS · {name}": "ENFOQUE · {name}",
"IN REACH · {count}": "AL ALCANCE · {count}",
"NEAREST {shown} OF {count}": "{shown} MÁS CERCANOS DE {count}",
"Mapped points in reach": "Puntos cartografiados al alcance",
"Hide the mapped points in reach": "Ocultar los puntos cartografiados al alcance",
"Show the mapped points in reach": "Mostrar los puntos cartografiados al alcance",
"NOTHING MAPPED IN REACH": "NADA CARTOGRAFIADO AL ALCANCE",
"Keep the camera on {name}": "Mantener la cámara sobre {name}",
"Beyond the off-nadir reach": "Más allá del alcance fuera del nadir",
"OPENSTREETMAP TAGS": "ETIQUETAS DE OPENSTREETMAP",
"DETAILS": "DETALLES",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "BARRIDO {value} KM",
"SWATH ≈ {value} KM": "BARRIDO ≈ {value} KM",
+12
View File
@@ -829,6 +829,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "HORS NADIR {angle}° · {offset}",
"OFF-NADIR {angle}°": "HORS NADIR {angle}°",
"FOCUS · {name}": "CENTRÉ · {name}",
"IN REACH · {count}": "À PORTÉE · {count}",
"NEAREST {shown} OF {count}": "LES {shown} PLUS PROCHES SUR {count}",
"Mapped points in reach": "Points cartographiés à portée",
"Hide the mapped points in reach": "Masquer les points cartographiés à portée",
"Show the mapped points in reach": "Afficher les points cartographiés à portée",
"NOTHING MAPPED IN REACH": "RIEN DE CARTOGRAPHIÉ À PORTÉE",
"Keep the camera on {name}": "Garder la caméra sur {name}",
"Beyond the off-nadir reach": "Au-delà de la portée hors nadir",
"OPENSTREETMAP TAGS": "BALISES OPENSTREETMAP",
"DETAILS": "DÉTAILS",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAUCHÉE {value} KM",
"SWATH ≈ {value} KM": "FAUCHÉE ≈ {value} KM",
+12
View File
@@ -829,6 +829,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FUORI NADIR {angle}° · {offset}",
"OFF-NADIR {angle}°": "FUORI NADIR {angle}°",
"FOCUS · {name}": "PUNTAMENTO · {name}",
"IN REACH · {count}": "A PORTATA · {count}",
"NEAREST {shown} OF {count}": "I {shown} PIÙ VICINI SU {count}",
"Mapped points in reach": "Punti mappati a portata",
"Hide the mapped points in reach": "Nascondi i punti mappati a portata",
"Show the mapped points in reach": "Mostra i punti mappati a portata",
"NOTHING MAPPED IN REACH": "NIENTE DI MAPPATO A PORTATA",
"Keep the camera on {name}": "Mantieni la camera su {name}",
"Beyond the off-nadir reach": "Oltre la portata fuori nadir",
"OPENSTREETMAP TAGS": "TAG OPENSTREETMAP",
"DETAILS": "DETTAGLI",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "STRISCIATA {value} KM",
"SWATH ≈ {value} KM": "STRISCIATA ≈ {value} KM",
+12
View File
@@ -803,6 +803,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}",
"OFF-NADIR {angle}°": "オフナディア {angle}°",
"FOCUS · {name}": "フォーカス · {name}",
"IN REACH · {count}": "到達範囲内 · {count}",
"NEAREST {shown} OF {count}": "{count} 件中 近い {shown} 件",
"Mapped points in reach": "到達範囲内の地図上の地点",
"Hide the mapped points in reach": "到達範囲内の地図上の地点を隠す",
"Show the mapped points in reach": "到達範囲内の地図上の地点を表示",
"NOTHING MAPPED IN REACH": "到達範囲内に地図上の地点なし",
"Keep the camera on {name}": "カメラを {name} に向け続ける",
"Beyond the off-nadir reach": "オフナディアの到達範囲外",
"OPENSTREETMAP TAGS": "OpenStreetMap タグ",
"DETAILS": "詳細",
"ZOOM {zoom}×": "ズーム {zoom}×",
"SWATH {value} KM": "観測幅 {value} km",
"SWATH ≈ {value} KM": "観測幅 ≈ {value} km",
+12
View File
@@ -827,6 +827,18 @@
"{value} KM/S": "{value} KM/S",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "FORA DO NADIR {angle}° · {offset}",
"OFF-NADIR {angle}°": "FORA DO NADIR {angle}°",
"FOCUS · {name}": "FOCO · {name}",
"IN REACH · {count}": "AO ALCANCE · {count}",
"NEAREST {shown} OF {count}": "{shown} MAIS PRÓXIMOS DE {count}",
"Mapped points in reach": "Pontos mapeados ao alcance",
"Hide the mapped points in reach": "Ocultar os pontos mapeados ao alcance",
"Show the mapped points in reach": "Mostrar os pontos mapeados ao alcance",
"NOTHING MAPPED IN REACH": "NADA MAPEADO AO ALCANCE",
"Keep the camera on {name}": "Manter a câmera em {name}",
"Beyond the off-nadir reach": "Além do alcance fora do nadir",
"OPENSTREETMAP TAGS": "ETIQUETAS DO OPENSTREETMAP",
"DETAILS": "DETALHES",
"ZOOM {zoom}×": "ZOOM {zoom}×",
"SWATH {value} KM": "FAIXA IMAGEADA {value} KM",
"SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM",
+12
View File
@@ -189,6 +189,18 @@
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}",
"OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°",
"FOCUS · {name}": "ФОКУС · {name}",
"IN REACH · {count}": "В ЗОНЕ ДОСЯГАЕМОСТИ · {count}",
"NEAREST {shown} OF {count}": "БЛИЖАЙШИЕ {shown} ИЗ {count}",
"Mapped points in reach": "Отмеченные на карте точки в зоне досягаемости",
"Hide the mapped points in reach": "Скрыть отмеченные точки в зоне досягаемости",
"Show the mapped points in reach": "Показать отмеченные точки в зоне досягаемости",
"NOTHING MAPPED IN REACH": "НЕТ ОТМЕЧЕННЫХ ТОЧЕК В ЗОНЕ ДОСЯГАЕМОСТИ",
"Keep the camera on {name}": "Удерживать камеру на {name}",
"Beyond the off-nadir reach": "За пределами допустимого отклонения от надира",
"OPENSTREETMAP TAGS": "ТЕГИ OPENSTREETMAP",
"DETAILS": "ПОДРОБНОСТИ",
"ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×",
"SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ",
"SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ",
+12
View File
@@ -841,6 +841,18 @@
"{value} KM/S": "{value} КМ/С",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}",
"OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°",
"FOCUS · {name}": "ФОКУС · {name}",
"IN REACH · {count}": "У ЗОНІ ДОСЯЖНОСТІ · {count}",
"NEAREST {shown} OF {count}": "НАЙБЛИЖЧІ {shown} З {count}",
"Mapped points in reach": "Позначені на мапі точки в зоні досяжності",
"Hide the mapped points in reach": "Сховати позначені точки в зоні досяжності",
"Show the mapped points in reach": "Показати позначені точки в зоні досяжності",
"NOTHING MAPPED IN REACH": "НЕМАЄ ПОЗНАЧЕНИХ ТОЧОК У ЗОНІ ДОСЯЖНОСТІ",
"Keep the camera on {name}": "Утримувати камеру на {name}",
"Beyond the off-nadir reach": "Поза межами допустимого відхилення від надира",
"OPENSTREETMAP TAGS": "ТЕГИ OPENSTREETMAP",
"DETAILS": "ПОДРОБИЦІ",
"ZOOM {zoom}×": "НАБЛИЖЕННЯ {zoom}×",
"SWATH {value} KM": "СМУГА ОГЛЯДУ {value} КМ",
"SWATH ≈ {value} KM": "СМУГА ОГЛЯДУ ≈ {value} КМ",
+12
View File
@@ -780,6 +780,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}",
"OFF-NADIR {angle}°": "偏离天底 {angle}°",
"FOCUS · {name}": "聚焦 · {name}",
"IN REACH · {count}": "可达范围内 · {count}",
"NEAREST {shown} OF {count}": "{count} 个中最近的 {shown} 个",
"Mapped points in reach": "可达范围内的地图点位",
"Hide the mapped points in reach": "隐藏可达范围内的地图点位",
"Show the mapped points in reach": "显示可达范围内的地图点位",
"NOTHING MAPPED IN REACH": "可达范围内没有地图点位",
"Keep the camera on {name}": "让镜头持续对准{name}",
"Beyond the off-nadir reach": "超出偏离天底的可达范围",
"OPENSTREETMAP TAGS": "OpenStreetMap 标签",
"DETAILS": "详情",
"ZOOM {zoom}×": "变焦 {zoom}×",
"SWATH {value} KM": "幅宽 {value} km",
"SWATH ≈ {value} KM": "幅宽 ≈ {value} km",
+12
View File
@@ -775,6 +775,18 @@
"{value} KM/S": "{value} km/s",
"{direction} {angle}°": "{direction} {angle}°",
"OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}",
"OFF-NADIR {angle}°": "偏離天底 {angle}°",
"FOCUS · {name}": "聚焦 · {name}",
"IN REACH · {count}": "可達範圍內 · {count}",
"NEAREST {shown} OF {count}": "{count} 個中最近的 {shown} 個",
"Mapped points in reach": "可達範圍內的地圖點位",
"Hide the mapped points in reach": "隱藏可達範圍內的地圖點位",
"Show the mapped points in reach": "顯示可達範圍內的地圖點位",
"NOTHING MAPPED IN REACH": "可達範圍內沒有地圖點位",
"Keep the camera on {name}": "讓鏡頭持續對準{name}",
"Beyond the off-nadir reach": "超出偏離天底的可達範圍",
"OPENSTREETMAP TAGS": "OpenStreetMap 標籤",
"DETAILS": "詳情",
"ZOOM {zoom}×": "變焦 {zoom}×",
"SWATH {value} KM": "幅寬 {value} km",
"SWATH ≈ {value} KM": "幅寬 ≈ {value} km",
+2
View File
@@ -413,6 +413,8 @@ export class StyleManager extends ShellFacade {
useCockpitStore(applicationPinia()).publishRadar(view),
orbital: (view) =>
useCockpitStore(applicationPinia()).publishOrbital(view),
orbitalReach: (view) =>
useCockpitStore(applicationPinia()).publishOrbitalReach(view),
utilityLayout: (layout) =>
useCockpitStore(applicationPinia()).publishUtilityLayout(layout),
},
+30
View File
@@ -27,6 +27,11 @@ import {
updateOrbital,
updateOrbitalHud,
} from './cockpitOrbitalView.js';
import {
onOrbitalClick,
routeSceneClick,
toggleOrbitalFocus,
} from './cockpitOrbitalFocus.js';
import {
publishInstrumentsView,
publishRouteView,
@@ -227,6 +232,7 @@ export class CockpitViewController {
this.publishUtilityLayout = publish.utilityLayout ?? (() => {});
this.publishRadar = publish.radar ?? (() => {});
this.publishOrbital = publish.orbital ?? (() => {});
this.publishOrbitalReach = publish.orbitalReach ?? (() => {});
/** The bridge radar's range in nautical miles; kept across bridges. */
this.radarRangeNm = DEFAULT_RADAR_RANGE_NM;
this.presence = {
@@ -292,6 +298,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(
@@ -350,6 +358,10 @@ export class CockpitViewController {
else if (target?.closest?.('#cockpit-orbital-nadir')) this.orbitalNadir();
else if (target?.closest?.('#cockpit-orbital-sensor'))
this.jumpToSensorSwath();
else {
const focus = target?.closest?.('[data-orbital-focus]');
if (focus) this.toggleOrbitalFocus(focus.dataset.orbitalFocus);
}
});
this._listen(this.signalToggle, 'click', () =>
this.setSignalCollapsed(!this.signalCollapsed, { user: true }),
@@ -424,6 +436,24 @@ 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);
}
/**
* FOCUS on the reach list: focus that point, switch to it, or end the
* focus held on it.
* @param {string} key The point's context id.
* @returns {boolean} True when the focus changed.
*/
toggleOrbitalFocus(key) {
return toggleOrbitalFocus.call(this, key);
}
/**
* Tilt the orbital view one arrow step off nadir.
* @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step
+388
View File
@@ -0,0 +1,388 @@
/**
* Ground focus in the Cockpit's orbital view: a click on a mapped point, or
* FOCUS on the reach list, keeps the camera on that point as the satellite
* moves; clicking elsewhere, FOCUS again, NADIR or the target leaving the
* off-nadir reach eases the view back. Also builds the list of mapped points
* in reach. 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 { offNadirOrientation } from '../cockpitOrbital.js';
import {
ORBITAL_REACH_LIST_MAX,
ORBITAL_REACH_REFRESH_MS,
detailRows,
easeFocusRemainder,
focusEaseDtMs,
focusInReach,
focusLook,
geodeticToEcef,
pointsInReach,
} from '../cockpitOrbitalFocus.js';
import {
getContextStore,
isContextRecordActive,
} from '../data/contextStore.js';
import { isPointerFree } from '../data/inputOwnership.js';
import { pickedKindForLayer } from '../data/pickedContext.js';
const NADIR_LOOK = Object.freeze({ yawDeg: 0, pitchDeg: 0 });
/** Only fixed points on the ground are focus targets: no aircraft, ships or satellites. */
const GROUND_KINDS = new Set(['feature', 'fire']);
/** 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)
);
}
/** The layers' on/off state, as `isContextRecordActive` reads it. */
function layerGate(controller) {
const isLayerEnabled = controller.services?.isLayerEnabled;
return typeof isLayerEnabled === 'function'
? { isEnabled: (layerId) => isLayerEnabled(layerId) !== false }
: null;
}
/** A context record that is a fixed ground point of a layer that is on. */
function groundRecord(controller, record) {
if (!record || !isContextRecordActive(record, layerGate(controller)))
return null;
if (!GROUND_KINDS.has(pickedKindForLayer(record.layerId))) return null;
if (!Number.isFinite(record.latitude) || !Number.isFinite(record.longitude))
return null;
return record;
}
/** Where a mapped point is drawn: its rendered anchor, else on the ellipsoid. */
function recordPosition(record) {
const anchor = record.entity?.__localBaseCartesian;
if (
anchor &&
Number.isFinite(anchor.x) &&
Number.isFinite(anchor.y) &&
Number.isFinite(anchor.z)
)
return { x: anchor.x, y: anchor.y, z: anchor.z };
return geodeticToEcef(record.latitude, record.longitude, 0);
}
/** The context record a scene pick landed on, when it is a ground point. */
function pickedGroundRecord(controller, picked) {
if (!picked) return null;
const store = getContextStore();
for (const carrier of [picked.id, picked.primitive?.id, picked.primitive]) {
const contextId =
carrier && typeof carrier === 'object' ? carrier.__gevContextId : null;
if (!contextId) continue;
const record = groundRecord(controller, store.entities.get(contextId));
if (record) return record;
}
return null;
}
/** The focus look at a record from the last frame's satellite, or null. */
function lookAt(state, record) {
const frame = state.focusFrame;
if (!frame) return null;
return focusLook(frame.nadir, frame.position, recordPosition(record));
}
/**
* Point the view at a mapped point and keep it there: from the view as it is
* now, so switching targets turns rather than jumps. A point beyond the
* reach is refused.
* @returns {boolean} True when the focus is on that point.
*/
function startFocus(record) {
const state = this.orbital;
const look = lookAt(state, record);
// 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(record.id),
label: String(record.label || record.layerName || record.id),
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);
publishOrbitalReach.call(this);
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) 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;
}
publishOrbitalReach.call(this);
return true;
}
/**
* FOCUS on the reach list: focus that point, switch to it from another, or
* end the focus when it is the one held.
* @param {string} key The point's context id.
* @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 record = groundRecord(
this,
getContextStore().entities.get(String(key)),
);
return record ? startFocus.call(this, record) : false;
}
/**
* 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;
let picked = null;
try {
picked = this.viewer.scene?.pick?.(position) ?? null;
} catch {
picked = null;
}
const record = pickedGroundRecord(this, picked);
const state = this.orbital;
if (record) {
if (state.focus?.key === String(record.id)) return true;
if (startFocus.call(this, record)) return true;
// A mapped point beyond the reach leaves the view as it is.
return false;
}
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 point is gone (its
* layer off, the record dropped) or beyond the reach. Also rebuilds the
* reach list on its 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 },
};
const dtMs = nowMs - (state.lookLastMs || nowMs);
let aimed = false;
const focus = state.focus;
if (focus) {
const record = groundRecord(
this,
getContextStore().entities.get(focus.key),
);
const look = record
? focusLook(nadir, state.focusFrame.position, recordPosition(record))
: 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;
}
}
if (nowMs - (state.reachMs ?? -Infinity) >= ORBITAL_REACH_REFRESH_MS)
publishOrbitalReach.call(this, 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,
};
}
/**
* Rebuild and publish the list of mapped points in reach: ground points of
* the layers that are on, facing the satellite and within the off-nadir
* reach or in the frame now, nearest to nadir first, at most
* ORBITAL_REACH_LIST_MAX.
* @param {number} [nowMs]
*/
export function publishOrbitalReach(nowMs = performance.now()) {
const state = this.orbital;
if (!inOrbit(this) || !state) return;
state.reachMs = nowMs;
const frame = state.focusFrame;
const records = new Map();
const candidates = [];
if (frame) {
for (const entry of getContextStore().entities.values()) {
const record = groundRecord(this, entry);
if (!record) continue;
const position = recordPosition(record);
if (!position) continue;
const key = String(record.id);
records.set(key, record);
candidates.push({ key, position });
}
}
const frustum = this.viewer.camera?.frustum;
const reach = frame
? pointsInReach(candidates, {
nadir: frame.nadir,
view: offNadirOrientation(frame.nadir, {
northDeg: state.lookView.pitchDeg,
eastDeg: state.lookView.yawDeg,
}),
satellite: frame.position,
verticalFovRad: state.verticalFov,
aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1,
max: ORBITAL_REACH_LIST_MAX,
})
: { count: 0, entries: [] };
const focusKey = state.focus?.key ?? null;
this.publishOrbitalReach({
count: reach.count,
focusKey,
entries: reach.entries.map(({ key, offNadirDeg, reachable }) => {
const record = records.get(key);
const source = String(record.source || '');
return {
key,
label: String(record.label || record.layerName || key),
layerName: String(record.layerName || ''),
source,
latitude: record.latitude,
longitude: record.longitude,
offNadirDeg,
reachable,
focused: key === focusKey,
osm: /openstreetmap|\bosm\b/i.test(source),
details: detailRows(record.properties),
};
}),
});
}
/** Clear the reach list as the orbital session ends. */
export function endOrbitalFocus() {
this.publishOrbitalReach(null);
}
+644
View File
@@ -0,0 +1,644 @@
// src/ui/cockpitOrbitalFocus.test.mjs — keeping the orbital camera on a
// mapped ground point: a click on the point or FOCUS on the reach list turns
// the view onto it and holds it as the satellite moves; a click elsewhere,
// FOCUS again, NADIR or the point leaving the reach eases the view back,
// never in one jump. Over happy-dom, with the shared context store.
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 { getContextStore, registerEntityContext } from '../data/contextStore.js';
import { claimPointer, releasePointer } from '../data/inputOwnership.js';
import { ORBITAL_FOCUS_EASE_TAU_MS } from '../cockpitOrbitalFocus.js';
const START_MS = 1_800_000_000_000;
const ASPECT = 16 / 9;
const FRAME_MS = 16;
const ISS = Object.freeze({
kind: 'satellite',
layerId: 'satellites',
id: '25544',
noradId: 25544,
name: 'ISS (ZARYA)',
classKey: 'station',
classLabel: 'STATION · ISS',
latitude: 30,
longitude: -97,
altitudeM: 420_000,
velocityMps: 7_660,
// Track-up: the focus is built on the turned frame.
groundTrackDeg: 51.6,
stale: false,
});
const LAYER = 'military-installations';
/** Mapped points: two in reach, one only beyond it. */
const SITES = Object.freeze({
alpha: { lat: 31, lon: -96.5, label: 'Camp Alpha', at: { x: 100, y: 100 } },
bravo: { lat: 29.2, lon: -97.8, label: 'Bravo Field', at: { x: 300, y: 300 } },
far: { lat: 33.5, lon: -101, label: 'Far Range', at: { x: 500, y: 50 } },
});
const EMPTY_GROUND = Object.freeze({ x: 640, y: 360 });
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, which the Cockpit must still hand it.
const actions = new Map();
const handler = {
stockClicks: 0,
getInputAction: (type) => actions.get(type),
setInputAction: (action, type) => actions.set(type, action),
removeInputAction: (type) => actions.delete(type),
isDestroyed: () => false,
};
const stockClick = () => {
handler.stockClicks += 1;
};
handler.stock = stockClick;
actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, stockClick);
viewer.screenSpaceEventHandler = handler;
let subject = { ...ISS };
const satellites = {
getCockpitSubject: () => (subject ? { ...subject } : null),
getCockpitPosition(result) {
if (!subject) return null;
return Cesium.Cartesian3.fromDegrees(
subject.longitude,
subject.latitude,
subject.altitudeM,
Cesium.Ellipsoid.WGS84,
result,
);
},
setCockpitSubject: () => true,
refocusTrackedById: () => true,
};
const enabled = new Set(['satellites', LAYER]);
const cockpit = new CockpitViewController(viewer, {
services: {
flightsLayer: { getTrackedInfo: () => null },
militaryFlightsLayer: { getTrackedInfo: () => null },
aisLiveVesselsLayer: { getCockpitSubject: () => null },
satellitesLayer: satellites,
militaryAwarenessLayer: {},
isTr3b: () => false,
holdContinuousRender() {},
releaseContinuousRender() {},
cachedGroundFloor: () => null,
GROUND_FLOOR_LIFT_M: 0,
isLayerEnabled: (layerId) => enabled.has(layerId),
},
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),
orbitalReach: (view) => store.publishOrbitalReach(view),
},
});
Object.assign(cockpit, {
setVisionMode() {},
showBriefPage() {},
startBriefRotation() {},
stopBriefRotation() {},
pushCockpitSignal() {},
scheduleContextLayout() {},
updateLocalPosition() {},
maybeRefreshRegionalBrief() {},
updateContext() {},
updateRoute() {},
});
// The mapped points, registered as the installations layer does.
const carriers = {};
for (const [key, site] of Object.entries(SITES)) {
const carrier = { show: true };
carriers[key] = carrier;
registerEntityContext(carrier, {
id: `site-${key}`,
layerId: LAYER,
layerName: 'Mapped Military Installations',
source: 'OpenStreetMap',
label: site.label,
latitude: site.lat,
longitude: site.lon,
properties: { military: 'base', name: site.label, _internal: 'x' },
});
picks.set(`${site.at.x},${site.at.y}`, { id: carrier });
}
// A moving contact is never a focus target.
const aircraftCarrier = { show: true };
registerEntityContext(aircraftCarrier, {
id: 'flight-abc',
layerId: 'flights',
label: 'ABC123',
latitude: 30.2,
longitude: -97.1,
});
enabled.add('flights');
picks.set('700,700', { id: aircraftCarrier });
const originalNow = performance.now;
const originalDateNow = Date.now;
let nowMs = START_MS;
performance.now = () => nowMs;
Date.now = () => nowMs;
t.after(() => {
cockpit.stop();
app.unmount();
globalThis.matchMedia = previousMatchMedia;
performance.now = originalNow;
Date.now = originalDateNow;
getContextStore().entities.clear();
document.body.classList.remove('cockpit-mode', 'cockpit-bridge', 'cockpit-orbit');
});
const frame = () => {
nowMs += FRAME_MS;
cockpit.update();
return shots.at(-1);
};
return {
$: (selector) => document.querySelector(selector),
cockpit,
viewer,
store,
shots,
handler,
carriers,
enabled,
frame,
click: (position) =>
actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }),
/** Move the satellite along its track by `deg` of latitude and longitude. */
move(dLat, dLon) {
subject = {
...subject,
latitude: subject.latitude + dLat,
longitude: subject.longitude + dLon,
};
},
drop() {
subject = null;
},
};
}
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();
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.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;
test('a click on a mapped point turns the camera onto it and holds it there', async (t) => {
const scene = await inOrbit(t);
const { $, cockpit, store, handler } = scene;
const before = scene.shots.at(-1);
assert.ok(missDeg(before, 'alpha') > 10, 'not centred at nadir');
scene.click(SITES.alpha.at);
assert.equal(cockpit.orbital.focus.key, 'site-alpha');
assert.equal(handler.stockClicks, 1, 'Cesium still gets the click');
// The first frames turn part of the way: no jump onto the target.
const first = scene.frame();
assert.ok(missDeg(first, '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`);
// Landed, it holds the target dead centre while the satellite moves.
for (let i = 0; i < 20; i += 1) {
scene.move(0.002, 0.002);
const shot = scene.frame();
assert.ok(missDeg(shot, 'alpha') < 1e-5, `frame ${i}: ${missDeg(shot, 'alpha')}°`);
}
// Held still past a HUD beat, the readout matches the last frame.
for (let i = 0; i < 8; i += 1) scene.frame();
// The readout gives the single off-nadir angle to it, as image metadata does.
await settle();
const shot = scene.shots.at(-1);
const readout = $('#cockpit-orbital-look-readout').textContent;
assert.equal(readout, `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(
$('.cockpit-orbital-kicker').textContent.trim(),
'ORBITAL VIEW · OFF-NADIR',
);
// The swath stays approximate, scaled by the tilt onto the target.
assert.equal(store.orbital.footprintKm, null);
assert.ok(store.orbital.look.swathScale > 1);
assert.match($('.cockpit-orbital-swath').textContent, /^SWATH ≈ [\d.]+ KM$/);
assert.deepEqual(store.orbital.look.focus, { key: 'site-alpha', label: 'Camp Alpha' });
// It keeps up with the satellite: the angle changes as it moves.
scene.move(-0.3, -0.3);
for (let i = 0; i < 8; i += 1) scene.frame();
await settle();
const later = $('#cockpit-orbital-look-readout').textContent;
assert.notEqual(later, readout);
assert.equal(later, `OFF-NADIR ${siteOffNadir(scene.shots.at(-1), 'alpha').toFixed(1)}°`);
});
test('a click elsewhere eases the camera back to nadir', async (t) => {
const scene = await inOrbit(t);
const { $, cockpit } = scene;
scene.click(SITES.alpha.at);
run(scene, LAND_FRAMES);
const focused = offNadir(scene.shots.at(-1));
assert.ok(focused > 10);
scene.click(EMPTY_GROUND);
assert.equal(cockpit.orbital.focus, null);
const first = scene.frame();
assert.ok(offNadir(first) > focused * 0.8, 'no jump back');
const 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');
await settle();
assert.equal($('.cockpit-orbital-look'), null);
assert.equal($('#cockpit-orbital-focus').textContent, '');
});
test('a click on a moving contact is not a focus and ends one', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
scene.frame();
scene.click({ x: 700, y: 700 });
assert.equal(scene.cockpit.orbital.focus, null);
scene.click({ x: 700, y: 700 });
assert.equal(scene.cockpit.orbital.focus, null);
});
test('a click on the focused point again keeps the focus', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
run(scene, 5);
scene.click(SITES.alpha.at);
assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha');
});
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);
assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha');
const again = claimPointer('draw');
t.after(() => releasePointer(again));
scene.click(EMPTY_GROUND);
assert.equal(scene.cockpit.orbital.focus.key, 'site-alpha', 'kept while drawing');
});
test('the focus ends by itself, easing back, when the point leaves the reach', async (t) => {
const scene = await inOrbit(t);
const { cockpit } = scene;
scene.click(SITES.bravo.at);
run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(missDeg(scene.shots.at(-1), 'bravo') < 1e-5);
// The satellite runs on north-east, away from the point, until the tilt
// onto it would pass 45°.
let released = null;
let previous = scene.shots.at(-1);
let largest = 0;
for (let i = 0; i < 2000 && released === null; i += 1) {
scene.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');
const atRelease = offNadir(scene.shots.at(-1));
assert.ok(atRelease > 40, `held to the reach (${atRelease}°)`);
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('a mapped point beyond the reach cannot be focused', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.far.at);
assert.equal(scene.cockpit.orbital.focus ?? null, null);
assert.equal(scene.cockpit.toggleOrbitalFocus('site-far'), false);
});
test('the reach list offers the points in reach with details and FOCUS', async (t) => {
const scene = await inOrbit(t);
const { $, store, cockpit } = scene;
await settle();
const reach = store.orbitalReach;
assert.ok(reach, 'published in orbit');
// Nearest to nadir first; moving contacts and points beyond reach and
// frame are left out.
const keys = reach.entries.map((entry) => entry.key);
assert.ok(keys.includes('site-alpha') && keys.includes('site-bravo'));
assert.ok(!keys.includes('flight-abc'));
assert.ok(!keys.includes('site-far'));
const angles = reach.entries.map((entry) => entry.offNadirDeg);
assert.deepEqual(angles, [...angles].sort((a, b) => a - b));
assert.equal(reach.count, reach.entries.length);
const toggle = $('#cockpit-orbital-reach-toggle');
assert.equal(toggle.textContent, `IN REACH · ${reach.count}`);
assert.equal(toggle.getAttribute('aria-expanded'), 'false');
assert.equal($('#cockpit-orbital-reach-body').style.display, 'none');
toggle.click();
await settle();
assert.equal(toggle.getAttribute('aria-expanded'), 'true');
assert.notEqual($('#cockpit-orbital-reach-body').style.display, 'none');
const row = (key) => $(`[data-orbital-reach-key="${key}"]`);
const name = row('site-alpha').querySelector('.cockpit-orbital-reach-name');
assert.match(name.textContent, /Camp Alpha/);
name.click();
await settle();
assert.equal(name.getAttribute('aria-expanded'), 'true');
const details = $('#cockpit-orbital-reach-details');
assert.match(details.textContent, /OPENSTREETMAP TAGS/);
assert.match(details.textContent, /military\s*base/);
assert.doesNotMatch(details.textContent, /_internal/);
assert.equal(name.getAttribute('aria-controls'), 'cockpit-orbital-reach-details');
// FOCUS through the delegated listener, as a person presses it.
const focusButton = (key) => row(key).querySelector('[data-orbital-focus]');
assert.equal(focusButton('site-alpha').getAttribute('aria-label'), 'Keep the camera on Camp Alpha');
focusButton('site-alpha').click();
assert.equal(cockpit.orbital.focus.key, 'site-alpha');
await settle();
assert.equal(focusButton('site-alpha').getAttribute('aria-pressed'), 'true');
assert.equal(focusButton('site-bravo').getAttribute('aria-pressed'), 'false');
// The list takes its own clicks: none reaches the picture to end the focus.
run(scene, LAND_FRAMES);
assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5);
// FOCUS on another point switches to it, turning rather than jumping.
focusButton('site-bravo').click();
assert.equal(cockpit.orbital.focus.key, 'site-bravo');
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.equal(focusButton('site-alpha').getAttribute('aria-pressed'), 'false');
assert.equal(focusButton('site-bravo').getAttribute('aria-pressed'), 'true');
// FOCUS on the focused point ends the focus.
focusButton('site-bravo').click();
assert.equal(cockpit.orbital.focus, null);
run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(offNadir(scene.shots.at(-1)) < 1e-6);
});
test('a point in frame but beyond the reach is listed without FOCUS', async (t) => {
const scene = await inOrbit(t);
// Tilt the view toward the far point with the arrows: it comes into frame
// but a focus on it would pass 45°.
const press = (key) =>
scene.cockpit.onKeyDown({
key,
target: document.body,
preventDefault() {},
stopImmediatePropagation() {},
});
for (let i = 0; i < 9; i += 1) press('ArrowLeft');
run(scene, 2 * LAND_FRAMES, { drift: 0 });
// Rebuild the list on the next frame.
scene.cockpit.orbital.reachMs = -Infinity;
scene.frame();
await settle();
const far = scene.store.orbitalReach.entries.find((entry) => entry.key === 'site-far');
assert.ok(far, 'listed while in frame');
assert.equal(far.reachable, false);
scene.$('#cockpit-orbital-reach-toggle').click();
await settle();
const button = scene.$('[data-orbital-reach-key="site-far"] [data-orbital-focus]');
assert.equal(button.getAttribute('aria-disabled'), 'true');
assert.equal(button.title, 'Beyond the off-nadir reach');
button.click();
assert.equal(scene.cockpit.orbital.focus ?? null, null);
});
test('turning the layer off or dropping the point ends the focus', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
run(scene, 5);
scene.enabled.delete(LAYER);
scene.frame();
assert.equal(scene.cockpit.orbital.focus, null, 'layer off');
assert.equal(scene.cockpit.orbital.focusReleasing, true, 'easing back');
scene.cockpit.orbital.reachMs = -Infinity;
scene.frame();
assert.equal(scene.store.orbitalReach.count, 0, 'nothing listed from a layer that is off');
scene.enabled.add(LAYER);
scene.click(SITES.bravo.at);
run(scene, 5);
getContextStore().entities.delete('site-bravo');
scene.frame();
assert.equal(scene.cockpit.orbital.focus, null, 'point dropped');
scene.click(SITES.alpha.at);
run(scene, 5);
scene.carriers.alpha.show = false;
scene.frame();
assert.equal(scene.cockpit.orbital.focus, null, 'point hidden');
});
test('leaving orbit while focused clears the focus and the list', async (t) => {
const scene = await inOrbit(t);
scene.click(SITES.alpha.at);
run(scene, 5);
scene.drop();
scene.frame();
assert.equal(scene.cockpit.active, false);
assert.equal(scene.store.orbitalReach, null);
assert.equal(scene.store.orbital, null);
// Late clicks and presses after the exit do nothing.
scene.click(SITES.bravo.at);
assert.equal(scene.cockpit.toggleOrbitalFocus('site-bravo'), false);
assert.equal(scene.cockpit.onOrbitalClick(SITES.bravo.at), false);
assert.equal(scene.handler.stockClicks, 2, 'Cesium kept its clicks');
});
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);
// The step continues from the focused look, on the 5° grid.
assert.equal(cockpit.orbital.lookTarget.pitchDeg, Math.floor(held.pitchDeg / 5 + 1e-9) * 5 + 5);
const largest = run(scene, LAND_FRAMES, { drift: 0 });
assert.ok(largest < MAX_TURN_PER_FRAME_DEG);
});
test('reduced motion cuts onto the point and back', async (t) => {
const scene = await inOrbit(t, { reducedMotion: true });
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');
});
test('stopping the Cockpit hands the single click back to Cesium', async (t) => {
const scene = fixture(t);
const type = Cesium.ScreenSpaceEventType.LEFT_CLICK;
assert.notEqual(scene.handler.getInputAction(type), scene.handler.stock);
scene.cockpit.stop();
assert.equal(scene.handler.getInputAction(type), scene.handler.stock);
scene.click(EMPTY_GROUND);
assert.equal(scene.handler.stockClicks, 1);
assert.equal(scene.cockpit.orbital, null);
});
+55 -16
View File
@@ -34,7 +34,6 @@ import {
} from '../cockpitOrbital.js';
import {
clampOrbitalLook,
easeLook,
lookIsOffset,
orbitalLookReadoutParts,
stepOrbitalLook,
@@ -43,6 +42,13 @@ import { cockpitUiUpdateDue } from '../cockpitMath.js';
import { createPlaceTracker, nearPlaceRadiusKm } from '../data/placeBelow.js';
import { isPointerFree } from '../data/inputOwnership.js';
import { zoomableSwathKm } from '../layers/satellites/sensors.js';
import {
easeOrbitalLook,
endOrbitalFocus,
orbitalFocusReadout,
releaseOrbitalFocus,
updateOrbitalFocus,
} from './cockpitOrbitalFocus.js';
import {
COCKPIT_CONTEXT_UPDATE_MS,
COCKPIT_HUD_UPDATE_MS,
@@ -131,6 +137,7 @@ export function endOrbital() {
// Back to the map the operator had, unless they picked another meanwhile.
state.imagery?.release?.()?.catch?.(() => {});
this.publishOrbital(null);
endOrbitalFocus.call(this);
}
/**
@@ -318,12 +325,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, position, nowMs))
state.lookView = easeOrbitalLook(
state,
nowMs - (state.lookLastMs || nowMs),
{ immediate: prefersReducedMotion() },
);
state.lookLastMs = nowMs;
const view = offNadirOrientation(nadir, {
northDeg: state.lookView.pitchDeg,
@@ -592,16 +600,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 +633,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 +693,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 +879,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 +896,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);
+23 -2
View File
@@ -93,7 +93,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
* zoom, zoomSteps: number[], maxZoom, footprintKm|null,
* sensor: { label|null, swathKm, stripFraction|null, zoom|null }|null,
* transition: { active, blurPx, lockTick },
* look: { offset, readout, swathScale, swathKm|null }, trackUp, northDeg,
* look: { offset, readout, swathScale, swathKm|null,
* focus: { key, label }|null }, trackUp, northDeg,
* place: { kind: 'country'|'sea'|'ocean', name, countryCode,
* division?|null, near?|null }|null|undefined,
* noradId|null, cosparId|null, tle: { line1, line2, epochMs }|null,
@@ -117,7 +118,9 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
* src/cockpitOrbital.js), `look` says whether the view is tilted off
* nadir and words the tilt (`readout`, '' at nadir), with the approximate
* ground width at the tilted view's centre (`swathKm`, null at nadir) and
* its ratio to the nadir footprint (`swathScale`, 1 at nadir), `trackUp`
* its ratio to the nadir footprint (`swathScale`, 1 at nadir) and the
* mapped point a ground focus holds the view on (`focus`; the readout
* then gives the single off-nadir angle to it), `trackUp`
* whether the view is turned to the ground track (false for a satellite with none,
* which stays north-up) and `northDeg` where north sits, in degrees
* clockwise from the top (0 when north-up).
@@ -129,6 +132,22 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
orbital.value = next && markRaw(next);
}
/**
* The mapped points the orbital view shows or can be turned to: null
* outside orbit. Shape: `{ count, focusKey|null, entries: [{ key, label,
* layerName, source, latitude, longitude, offNadirDeg, reachable,
* focused, osm, details: [{ key, value }] }] }`, nearest to nadir first
* and at most 12 (`count` is all of them); `reachable` is false for a
* point in the frame but beyond the off-nadir reach, `osm` marks details
* that are OpenStreetMap tags (src/ui/cockpitOrbitalFocus.js).
*/
const orbitalReach = shallowRef(null);
/** @param {?object} next A fresh list of the mapped points in reach. */
function publishOrbitalReach(next) {
orbitalReach.value = next && markRaw(next);
}
/**
* The utility strip's measured layout: its top and height limits as CSS
* lengths (null leaves the stylesheet's), whether only the expanded control
@@ -168,6 +187,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => {
publishRadar,
orbital,
publishOrbital,
orbitalReach,
publishOrbitalReach,
utilityLayout,
publishUtilityLayout,
};
+150
View File
@@ -0,0 +1,150 @@
/* The orbital view's ground focus (src/ui/views/panels/CockpitOrbital.vue,
CockpitOrbitalReach.vue): the focused target under the off-nadir readout
and, at the foot of the readout card, the mapped points in reach. The HUD
passes clicks through to the picture; the list takes its own, so a click
on it never ends a focus. */
.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;
}
.cockpit-orbital-reach {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--orbital-faint);
pointer-events: auto;
}
.cockpit-orbital-reach-toggle,
.cockpit-orbital-reach-focus {
padding: 2px 7px;
border: 1px solid var(--orbital-line);
border-radius: 2px;
background: rgba(0, 7, 12, 0.6);
color: #f4fbff;
font: inherit;
font-weight: 700;
letter-spacing: 0.12em;
cursor: pointer;
}
.cockpit-orbital-reach-toggle[aria-expanded='true'],
.cockpit-orbital-reach-focus[aria-pressed='true'],
.cockpit-orbital-reach-toggle:hover,
.cockpit-orbital-reach-toggle:focus-visible,
.cockpit-orbital-reach-focus:hover,
.cockpit-orbital-reach-focus:focus-visible {
border-color: var(--cockpit-accent, #00dcff);
color: #fff;
outline: none;
}
.cockpit-orbital-reach-focus[aria-pressed='true'] {
background: color-mix(
in srgb,
var(--cockpit-accent, #00dcff) 22%,
rgba(0, 7, 12, 0.8)
);
}
.cockpit-orbital-reach-focus[aria-disabled='true'] {
border-color: rgba(244, 251, 255, 0.12);
color: rgba(244, 251, 255, 0.32);
cursor: default;
}
.cockpit-orbital-reach-body {
max-height: min(30vh, 260px);
margin-top: 6px;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.cockpit-orbital-reach-list {
display: grid;
gap: 3px;
margin: 0;
padding: 0;
list-style: none;
}
.cockpit-orbital-reach-row {
display: flex;
align-items: center;
gap: 6px;
}
.cockpit-orbital-reach-name {
display: grid;
flex: 1;
min-width: 0;
padding: 3px 6px;
border: 1px solid transparent;
border-inline-start-color: var(--orbital-faint);
background: rgba(0, 7, 12, 0.34);
color: #f4fbff;
font: inherit;
text-align: start;
cursor: pointer;
}
.cockpit-orbital-reach-name strong,
.cockpit-orbital-reach-name small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cockpit-orbital-reach-name small {
color: rgba(244, 251, 255, 0.56);
font-size: 9px;
font-variant-numeric: tabular-nums;
}
.cockpit-orbital-reach-name[aria-expanded='true'],
.cockpit-orbital-reach-name:hover,
.cockpit-orbital-reach-name:focus-visible {
border-color: var(--orbital-line);
outline: none;
}
.cockpit-orbital-reach-focused .cockpit-orbital-reach-name {
border-inline-start-color: var(--cockpit-accent, #00dcff);
}
.cockpit-orbital-reach-details {
margin: 3px 0 4px;
padding: 4px 6px;
border: 1px solid var(--orbital-faint);
background: rgba(0, 4, 8, 0.7);
user-select: text;
}
.cockpit-orbital-reach-where {
color: rgba(244, 251, 255, 0.66);
font-variant-numeric: tabular-nums;
}
.cockpit-orbital-readout .cockpit-orbital-reach-kicker {
margin-top: 4px;
color: var(--cockpit-accent, #00dcff);
font-weight: 700;
letter-spacing: 0.14em;
}
.cockpit-orbital-reach-tags {
display: grid;
gap: 2px;
margin: 3px 0 0;
}
.cockpit-orbital-reach-tag {
display: flex;
gap: 8px;
min-width: 0;
}
.cockpit-orbital-reach-tag dt {
flex: none;
color: rgba(244, 251, 255, 0.56);
}
.cockpit-orbital-reach-tag dd {
min-width: 0;
margin: 0;
color: #f4fbff;
overflow-wrap: anywhere;
}
.cockpit-orbital-reach-empty,
.cockpit-orbital-reach-more {
color: rgba(244, 251, 255, 0.56);
}
.cockpit-orbital-readout .cockpit-orbital-reach-more {
margin-top: 4px;
}
+11 -1
View File
@@ -14,6 +14,7 @@ import {
shownOwnerMark,
} from '../../ownerFlag.js';
import { useCockpitStore } from '../../stores/cockpit.js';
import CockpitOrbitalReach from './CockpitOrbitalReach.vue';
// The orbital view's HUD: a nadir reticle over the picture, the satellite's
// identity and orbit, and the optical zoom. The Cockpit controller presents
@@ -254,6 +255,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 +324,12 @@ 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>
<!-- The mapped points the view shows or can be turned to, each with FOCUS. -->
<CockpitOrbitalReach />
</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>
+109
View File
@@ -0,0 +1,109 @@
<script setup>
import { storeToRefs } from 'pinia';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import { formatNumber, t } from '../../../i18n/index.js';
import { formatCockpitCoordinate } from '../../cockpitPresentation.js';
import { useCockpitStore } from '../../stores/cockpit.js';
// The mapped points the orbital view shows or can be turned to, under its
// readout: ground points of the layers that are on, nearest to nadir first.
// A row opens the point's details (its OpenStreetMap tags where the layer
// keeps them); FOCUS keeps the camera on it, switches to it from another, or
// ends the focus held on it. The Cockpit controller builds the list
// (src/ui/cockpitOrbitalFocus.js) and keeps FOCUS's delegated listener;
// which rows are open is this view's own.
const { orbitalReach } = storeToRefs(useCockpitStore());
const finite = (value) => typeof value === 'number' && Number.isFinite(value);
const entries = computed(() => orbitalReach.value?.entries || []);
const count = computed(() =>
finite(orbitalReach.value?.count) ? orbitalReach.value.count : 0,
);
const open = ref(false);
const selectedKey = ref(null);
const root = useTemplateRef('root');
const toggleText = computed(() =>
t('IN REACH · {count}', { count: formatNumber(count.value) }),
);
const moreText = computed(() =>
count.value > entries.value.length
? t('NEAREST {shown} OF {count}', {
shown: formatNumber(entries.value.length),
count: formatNumber(count.value),
})
: '',
);
const angleText = (deg) =>
finite(deg)
? `${formatNumber(deg, { minimumFractionDigits: 1, maximumFractionDigits: 1 })}°`
: '—';
const positionText = (entry) =>
finite(entry.latitude) && finite(entry.longitude)
? `${formatCockpitCoordinate(entry.latitude, 'N', 'S')} ${formatCockpitCoordinate(entry.longitude, 'E', 'W')}`
: '—';
function toggleDetails(key) {
selectedKey.value = selectedKey.value === key ? null : key;
}
// A point that leaves the list closes its details. Keyboard focus inside
// its row moves to the list's toggle, which stays, instead of the page.
watch(
entries,
(list) => {
const key = selectedKey.value;
const active = document.activeElement;
const row = active?.closest?.('[data-orbital-reach-key]');
const leaving =
row &&
root.value?.contains(row) &&
!list.some((entry) => entry.key === row.dataset.orbitalReachKey);
if (key !== null && !list.some((entry) => entry.key === key))
selectedKey.value = null;
if (leaving)
nextTick(() =>
root.value
?.querySelector('#cockpit-orbital-reach-toggle')
?.focus({ preventScroll: true }),
);
},
{ flush: 'pre' },
);
</script>
<template>
<section ref="root" class="cockpit-orbital-reach" :aria-label="t('Mapped points in reach')">
<button id="cockpit-orbital-reach-toggle" type="button" class="cockpit-orbital-reach-toggle" :aria-expanded="String(open)" aria-controls="cockpit-orbital-reach-body" :aria-label="open ? t('Hide the mapped points in reach') : t('Show the mapped points in reach')" :title="open ? t('Hide the mapped points in reach') : t('Show the mapped points in reach')" @click="open = !open">{{ toggleText }}</button>
<div v-show="open" id="cockpit-orbital-reach-body" class="cockpit-orbital-reach-body">
<p v-if="!entries.length" class="cockpit-orbital-reach-empty">{{ t('NOTHING MAPPED IN REACH') }}</p>
<ul v-else class="cockpit-orbital-reach-list">
<li v-for="entry in entries" :key="entry.key" class="cockpit-orbital-reach-item" :class="{ 'cockpit-orbital-reach-focused': entry.focused }" :data-orbital-reach-key="entry.key">
<div class="cockpit-orbital-reach-row">
<button type="button" class="cockpit-orbital-reach-name" :aria-expanded="String(selectedKey === entry.key)" :aria-controls="selectedKey === entry.key ? 'cockpit-orbital-reach-details' : undefined" @click="toggleDetails(entry.key)">
<strong>{{ entry.label }}</strong>
<small>{{ entry.layerName }}<template v-if="entry.layerName"> · </template>{{ angleText(entry.offNadirDeg) }}</small>
</button>
<button type="button" class="cockpit-orbital-reach-focus" :data-orbital-focus="entry.key" :aria-pressed="String(entry.focused)" :aria-disabled="entry.reachable ? undefined : 'true'" :aria-label="t('Keep the camera on {name}', { name: entry.label })" :title="entry.reachable ? t('Keep the camera on {name}', { name: entry.label }) : t('Beyond the off-nadir reach')">{{ t('FOCUS') }}</button>
</div>
<div v-if="selectedKey === entry.key" id="cockpit-orbital-reach-details" class="cockpit-orbital-reach-details">
<p class="cockpit-orbital-reach-where">{{ positionText(entry) }}<template v-if="entry.source"> · {{ entry.source }}</template></p>
<template v-if="entry.details.length">
<p class="cockpit-orbital-reach-kicker">{{ entry.osm ? t('OPENSTREETMAP TAGS') : t('DETAILS') }}</p>
<dl class="cockpit-orbital-reach-tags" translate="no">
<div v-for="row in entry.details" :key="row.key" class="cockpit-orbital-reach-tag">
<dt>{{ row.key }}</dt>
<dd>{{ row.value }}</dd>
</div>
</dl>
</template>
</div>
</li>
</ul>
<p v-if="moreText" class="cockpit-orbital-reach-more">{{ moreText }}</p>
</div>
</section>
</template>
+1
View File
@@ -11,6 +11,7 @@
@import './src/ui/styles/cockpitBridge.css';
@import './src/ui/styles/cockpitRadar.css';
@import './src/ui/styles/cockpitOrbital.css';
@import './src/ui/styles/cockpitOrbitalReach.css';
@import './src/ui/styles/overlays.css';
@import './src/ui/styles/layers.css';
@import './src/ui/styles/radio.css';