From 567c833582deb14678db764adbb7a2d5d52cf9a8 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 22:16:42 +0100 Subject: [PATCH 01/10] feat(cockpit): keep the orbital camera on a ground target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/package-boundaries.json | 15 + src/cockpitOrbitalFocus.js | 281 +++++++++ src/cockpitOrbitalFocus.test.mjs | 291 +++++++++ src/i18n/locales/ar.json | 12 + src/i18n/locales/de.json | 12 + src/i18n/locales/es.json | 12 + src/i18n/locales/fr.json | 12 + src/i18n/locales/it.json | 12 + src/i18n/locales/ja.json | 12 + src/i18n/locales/pt.json | 12 + src/i18n/locales/ru.json | 12 + src/i18n/locales/uk.json | 12 + src/i18n/locales/zh-CN.json | 12 + src/i18n/locales/zh-Hant.json | 12 + src/ui/applicationShell.js | 2 + src/ui/cockpitController.js | 30 + src/ui/cockpitOrbitalFocus.js | 388 ++++++++++++ src/ui/cockpitOrbitalFocus.test.mjs | 644 ++++++++++++++++++++ src/ui/cockpitOrbitalView.js | 71 ++- src/ui/stores/cockpit.js | 25 +- src/ui/styles/cockpitOrbitalReach.css | 150 +++++ src/ui/views/panels/CockpitOrbital.vue | 12 +- src/ui/views/panels/CockpitOrbitalReach.vue | 109 ++++ style.css | 1 + 24 files changed, 2132 insertions(+), 19 deletions(-) create mode 100644 src/cockpitOrbitalFocus.js create mode 100644 src/cockpitOrbitalFocus.test.mjs create mode 100644 src/ui/cockpitOrbitalFocus.js create mode 100644 src/ui/cockpitOrbitalFocus.test.mjs create mode 100644 src/ui/styles/cockpitOrbitalReach.css create mode 100644 src/ui/views/panels/CockpitOrbitalReach.vue diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ee1e5a9b6..28ebb17d2 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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", diff --git a/src/cockpitOrbitalFocus.js b/src/cockpitOrbitalFocus.js new file mode 100644 index 000000000..db7f0989c --- /dev/null +++ b/src/cockpitOrbitalFocus.js @@ -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; +} diff --git a/src/cockpitOrbitalFocus.test.mjs b/src/cockpitOrbitalFocus.test.mjs new file mode 100644 index 000000000..67a0cdef8 --- /dev/null +++ b/src/cockpitOrbitalFocus.test.mjs @@ -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, + ); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 97f6b4eb7..4df6ddef0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -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} كم", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 94baca906..1e9020465 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 329ef3f4f..e4e1834a9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 5d76194aa..782c5b724 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 04578c035..9553c8b92 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 4e79c1a8e..1ba00a474 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index a8a0c1cbd..189404f79 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6f02fb0bd..6eb44c31f 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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} КМ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index b2f8e8d5e..328f0fe72 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -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} КМ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 90d2afb62..4d1b7b55c 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a37bb7a9d..cbead8578 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 5010a5338..9952206a5 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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), }, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 6c0843c47..caeb3d667 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -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 diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js new file mode 100644 index 000000000..8ece2e28b --- /dev/null +++ b/src/ui/cockpitOrbitalFocus.js @@ -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); +} diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs new file mode 100644 index 000000000..97370fb3f --- /dev/null +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -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); +}); diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index 2482920f0..50e90d007 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -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); diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index 1e4accde5..a71a78336 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -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, }; diff --git a/src/ui/styles/cockpitOrbitalReach.css b/src/ui/styles/cockpitOrbitalReach.css new file mode 100644 index 000000000..fede7254e --- /dev/null +++ b/src/ui/styles/cockpitOrbitalReach.css @@ -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; +} diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 767fc9cc7..51f14e1ed 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -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(() => {

{{ trackUp ? t('TRACK UP') : t('NORTH UP') }}

- {{ look.readout }} + {{ look.readout }}
+

{{ focusText }}

+ +
diff --git a/src/ui/views/panels/CockpitOrbitalReach.vue b/src/ui/views/panels/CockpitOrbitalReach.vue new file mode 100644 index 000000000..852799da9 --- /dev/null +++ b/src/ui/views/panels/CockpitOrbitalReach.vue @@ -0,0 +1,109 @@ + + + diff --git a/style.css b/style.css index 0537c30e1..4d678058c 100644 --- a/style.css +++ b/style.css @@ -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'; From dd063b0e836d818d6dd88423f30e19da45985fd2 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 22:16:43 +0100 Subject: [PATCH 02/10] docs(cockpit): describe the orbital view's ground focus --- docs/CURRENT-STATE.md | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 85bb0badf..cb3d128ec 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 +point 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,40 @@ 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. +A single click on a mapped ground point (a ground point of a layer that is +on, through the shared context store: Mapped Installations, fires, local +data packs; aircraft, vessels and satellites are not targets) keeps the +camera on it (`src/ui/cockpitOrbitalFocus.js`, geometry in +`src/cockpitOrbitalFocus.js`). The look is aimed each frame from the +satellite's position to the point's Earth-fixed position in the same +track-up nadir frame the arrows tilt, so the point stays at the centre as +the satellite moves; the turn onto it (and between points) 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 point exactly, and cuts under +`prefers-reduced-motion`. A click anywhere else, FOCUS on the focused point, +NADIR, Home or `0` ends the focus and the view eases back to nadir on the +same clock; an arrow ends it and tilts on from where the view is. The focus +also ends by itself, easing back, when the point leaves the reach (either +tilt axis past ±45°, or the point below the satellite's horizon), when its +layer is turned off or its record is dropped, and with the orbital view. A +mapped point beyond the reach cannot be focused. While focused the readout +gives the single off-nadir angle to the point, 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 point. 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. Under the readout, IN REACH · *n* (a disclosure) lists the mapped +points facing the satellite that are within the reach or in the frame now, +nearest to nadir first, at most 12 (with NEAREST 12 OF *n* beyond that), +rebuilt once a second. A row opens the point's details: position, source and +its OpenStreetMap tags (or its layer's properties); FOCUS on a row focuses +it, switches to it from another point, or ends the focus held on it, and is +marked unavailable (`aria-disabled`) for a point in frame but beyond the +reach. A row that leaves the list hands keyboard focus to the disclosure. +`src/ui/cockpitOrbitalFocus.test.mjs` covers the easing in and out, the +reach release, list switching, click handling and the readout. + 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 From b18336d3d45f7c19f46d44d5dd315834739de66a Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 22:40:57 +0100 Subject: [PATCH 03/10] fix(cockpit): keep the orbital view on a double-click at a ground point In the orbital view a double-click on a mapped ground point went on to Cesium's own double-click, which tracks the picked entity and so ended the view. The Cockpit's double-click routing now takes it in orbit: it keeps or starts the focus on that point (a point beyond the reach is left unfocused but still not tracked). A double-click anywhere else, one a tool owns, and every one outside orbit go on to Cesium's as before, and stopping the Cockpit still hands Cesium's handler back. --- docs/CURRENT-STATE.md | 5 ++- src/ui/cockpitController.js | 10 +++++ src/ui/cockpitInput.js | 6 ++- src/ui/cockpitOrbitalFocus.js | 39 +++++++++++++---- src/ui/cockpitOrbitalFocus.test.mjs | 65 +++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cb3d128ec..cddbff85f 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1723,7 +1723,10 @@ in a polite live region (`FOCUS · Camp Alpha`), and the swath reads approximate as for a tilt, scaled by the tilt onto the point. 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. Under the readout, IN REACH · *n* (a disclosure) lists the mapped +it draws. A double-click on a mapped ground point keeps or starts the focus +on it and never reaches Cesium's double-click, which would track the point +and end the view (the Cockpit's double-click routing in +`src/ui/cockpitInput.js`); any other double-click goes on as before. Under the readout, IN REACH · *n* (a disclosure) lists the mapped points facing the satellite that are within the reach or in the frame now, nearest to nadir first, at most 12 (with NEAREST 12 OF *n* beyond that), rebuilt once a second. A row opens the point's details: position, source and diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index caeb3d667..ed66e8199 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -29,6 +29,7 @@ import { } from './cockpitOrbitalView.js'; import { onOrbitalClick, + onOrbitalDoubleClick, routeSceneClick, toggleOrbitalFocus, } from './cockpitOrbitalFocus.js'; @@ -445,6 +446,15 @@ export class CockpitViewController { 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); + } /** * FOCUS on the reach list: focus that point, switch to it, or end the * focus held on it. diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index c5987e97a..89228c393 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -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); diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js index 8ece2e28b..dc2cfa738 100644 --- a/src/ui/cockpitOrbitalFocus.js +++ b/src/ui/cockpitOrbitalFocus.js @@ -168,6 +168,37 @@ export function toggleOrbitalFocus(key) { return record ? startFocus.call(this, record) : false; } +/** The mapped ground point drawn at a canvas position, or null. */ +function groundRecordAt(controller, position) { + let picked = null; + try { + picked = controller.viewer.scene?.pick?.(position) ?? null; + } catch { + picked = null; + } + return pickedGroundRecord(controller, picked); +} + +/** + * 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 record = groundRecordAt(this, position); + if (!record) return false; + // A point beyond the reach is not focused, but still never tracked. + if (this.orbital.focus?.key !== String(record.id)) + startFocus.call(this, record); + 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) @@ -179,13 +210,7 @@ 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 record = groundRecordAt(this, position); const state = this.orbital; if (record) { if (state.focus?.key === String(record.id)) return true; diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs index 97370fb3f..1e1a232dd 100644 --- a/src/ui/cockpitOrbitalFocus.test.mjs +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -109,6 +109,18 @@ function fixture(t, { reducedMotion = false } = {}) { }; handler.stock = stockClick; actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, stockClick); + // Its double-click tracks what it picks (pickAndTrackObject): a mapped + // point, tracked, would end the orbital view. + handler.stockDoubleClicks = 0; + const stockDoubleClick = ({ position }) => { + handler.stockDoubleClicks += 1; + const picked = viewer.scene.pick(position); + viewer.trackedEntity = picked?.id + ? { ...picked.id, position: { getValue: () => null } } + : undefined; + }; + handler.stockDouble = stockDoubleClick; + actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, stockDoubleClick); viewer.screenSpaceEventHandler = handler; let subject = { ...ISS }; @@ -229,6 +241,12 @@ function fixture(t, { reducedMotion = false } = {}) { frame, click: (position) => actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }), + /** A double-click as Cesium fires it: two clicks, then the double. */ + doubleClick(position) { + actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); + actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); + actions.get(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)?.({ position }); + }, /** Move the satellite along its track by `deg` of latitude and longitude. */ move(dLat, dLon) { subject = { @@ -642,3 +660,50 @@ test('stopping the Cockpit hands the single click back to Cesium', async (t) => assert.equal(scene.handler.stockClicks, 1); assert.equal(scene.cockpit.orbital, null); }); + +test('a double-click on a mapped point 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 point'); + assert.equal(viewer.trackedEntity, undefined); + scene.frame(); + assert.equal(cockpit.active, true, 'still in orbit'); + assert.equal(cockpit.orbital.focus.key, 'site-alpha'); + run(scene, LAND_FRAMES); + assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5); + + // On the focused point it keeps the focus; on another it switches. + scene.doubleClick(SITES.alpha.at); + assert.equal(cockpit.orbital.focus.key, 'site-alpha'); + scene.doubleClick(SITES.bravo.at); + assert.equal(cockpit.orbital.focus.key, 'site-bravo'); + // A mapped point beyond the reach is not focused, but not tracked either. + scene.doubleClick(SITES.far.at); + assert.equal(handler.stockDoubleClicks, 0); + scene.frame(); + assert.equal(cockpit.active, true); + + // Anywhere else the double-click goes on to Cesium's, as before. + scene.doubleClick(EMPTY_GROUND); + assert.equal(handler.stockDoubleClicks, 1); + assert.equal(cockpit.orbital.focus, null, 'the clicks ended the focus'); +}); + +test('a double-click a tool owns, or one outside orbit, goes on to Cesium', async (t) => { + const scene = fixture(t); + const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; + scene.doubleClick(SITES.alpha.at); + assert.equal(scene.handler.stockDoubleClicks, 1, 'outside orbit'); + scene.viewer.trackedEntity = undefined; + assert.equal(scene.cockpit.enter(), true); + await settle(); + scene.frame(); + const lease = claimPointer('draw'); + t.after(() => releasePointer(lease)); + scene.doubleClick(SITES.alpha.at); + assert.equal(scene.handler.stockDoubleClicks, 2, 'a tool owns it'); + releasePointer(lease); + scene.cockpit.stop(); + assert.equal(scene.handler.getInputAction(type), scene.handler.stockDouble); +}); From 569c7cfb1b61b734f47eb5301515789abbc8d09a Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:59:17 +0100 Subject: [PATCH 04/10] feat(installations): look a mapped site up by id or scene pick getById returns a loaded installation with its drawn position, as getNearby does, without selecting it; findByPick resolves a scene pick (a named marker's installationId or a site entity) the way the layer's own click selection reads it. Both return null while the layer is off. --- src/layers/installations/controls.js | 35 ++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/layers/installations/controls.js b/src/layers/installations/controls.js index 2fa31e59e..5cbe0ca29 100644 --- a/src/layers/installations/controls.js +++ b/src/layers/installations/controls.js @@ -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. From fad62a4ba8c5fae36211701dbe72575747bbe19a Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 5 Nov 2026 01:16:28 +0100 Subject: [PATCH 05/10] feat(satellites): find the satellites nearest a position getNearestPositions makes one bounded pass over the shown points, keeps the nearest few in a short sorted list and reads latitude and longitude for those alone, leaving out dense extras, asked-for ids and classes such as debris. A caller that wants the nearest handful no longer builds and sorts an entry for every satellite. --- src/layers/satellites/controls.js | 62 +++++++++++++++++ .../satellites/nearestPositions.test.mjs | 67 +++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 src/layers/satellites/nearestPositions.test.mjs diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index dee92342a..6b390871a 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -371,6 +371,68 @@ 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, + * excludeClasses?: Array}} [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 = []; + if (!center || !layerState._points || layerState._points.size === 0) + return nearest; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 8; + const excluded = new Set(excludeIds.map(String)); + const classes = new Set(excludeClasses); + const dense = layerState._denseIds.length > 0; + 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. diff --git a/src/layers/satellites/nearestPositions.test.mjs b/src/layers/satellites/nearestPositions.test.mjs new file mode 100644 index 000000000..ab06d705c --- /dev/null +++ b/src/layers/satellites/nearestPositions.test.mjs @@ -0,0 +1,67 @@ +// 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), []); +}); From dd02068ce914ef0201d5537c161cdd5cc198591d Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:59:53 +0100 Subject: [PATCH 06/10] feat(cockpit): list orbit's sites and satellites in Live Signals The orbital Context card is gone and the Contact card stays hidden in orbit again. Live Signals page 1 had nothing in orbit but TRACK ACQUIRED, because orbit feeds no Contacts snapshot; it now lists what is around the satellite, built the way the aircraft rows are, five rows with stable keys rebuilt once a second. Rows already shown keep their places while they still qualify, so a site passing under a low orbit is not pulled from under the pointer. Site rows come from the Mapped Installations layer (getNearby, asked only as far as the reach extends) and focus their site, or release it on the focused site's row, which reads as the subject does. A site the layer drops during its frequent reloads stays listed for 10 s, since sites do not move. With the layer off nothing is listed and a SITES STANDBY row says why. Satellite rows come from the satellites layer's bounded nearest query, measured from the satellite itself, and ride the satellite; RETURN goes back to the one before, skipping one that has left the catalogue. While the camera hops to a new satellite only TRACK ACQUIRED and RETURN show, so nothing is measured from the old position. An arrow pressed while a focus eases back takes the arrows' own clock. Site and satellite rows name their own actions. --- scripts/package-boundaries.json | 22 +- src/cockpitOrbitalFocus.js | 70 +- src/cockpitOrbitalFocus.test.mjs | 50 +- src/cockpitOrbitalSatellites.js | 55 ++ src/cockpitOrbitalSatellites.test.mjs | 28 + src/i18n/locales/ar.json | 18 +- src/i18n/locales/de.json | 18 +- src/i18n/locales/es.json | 18 +- src/i18n/locales/fr.json | 18 +- src/i18n/locales/it.json | 18 +- src/i18n/locales/ja.json | 18 +- src/i18n/locales/pt.json | 18 +- src/i18n/locales/ru.json | 18 +- src/i18n/locales/uk.json | 18 +- src/i18n/locales/zh-CN.json | 18 +- src/i18n/locales/zh-Hant.json | 18 +- src/i18n/same-as-english.json | 6 +- src/ui/applicationShell.js | 4 +- src/ui/cockpitController.js | 40 +- src/ui/cockpitCoordinator.js | 2 + src/ui/cockpitOrbitalFocus.js | 410 +++++---- src/ui/cockpitOrbitalFocus.test.mjs | 968 ++++++++++++-------- src/ui/cockpitOrbitalSatellites.js | 188 ++++ src/ui/cockpitOrbitalSignals.js | 260 ++++++ src/ui/cockpitOrbitalView.js | 11 +- src/ui/cockpitSignals.js | 13 +- src/ui/stores/cockpit.js | 18 - src/ui/styles/cockpitOrbitalFocus.css | 11 + src/ui/styles/cockpitOrbitalReach.css | 150 --- src/ui/views/panels/CockpitOrbital.vue | 3 - src/ui/views/panels/CockpitOrbitalReach.vue | 109 --- style.css | 2 +- 32 files changed, 1562 insertions(+), 1056 deletions(-) create mode 100644 src/cockpitOrbitalSatellites.js create mode 100644 src/cockpitOrbitalSatellites.test.mjs create mode 100644 src/ui/cockpitOrbitalSatellites.js create mode 100644 src/ui/cockpitOrbitalSignals.js create mode 100644 src/ui/styles/cockpitOrbitalFocus.css delete mode 100644 src/ui/styles/cockpitOrbitalReach.css delete mode 100644 src/ui/views/panels/CockpitOrbitalReach.vue diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 28ebb17d2..829d0713c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -41,7 +41,6 @@ "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", @@ -905,10 +904,12 @@ "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/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", @@ -1062,9 +1063,10 @@ "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/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", @@ -2907,6 +2909,9 @@ "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", @@ -4252,6 +4257,7 @@ "src/cockpitLook.js", "src/cockpitOrbital.js", "src/cockpitOrbitalFocus.js", + "src/cockpitOrbitalSatellites.js", "src/geoDistance.js", "src/data/motionModel.js", "src/contactsDetectionPolicy.js", diff --git a/src/cockpitOrbitalFocus.js b/src/cockpitOrbitalFocus.js index db7f0989c..623507fc0 100644 --- a/src/cockpitOrbitalFocus.js +++ b/src/cockpitOrbitalFocus.js @@ -21,22 +21,15 @@ import { */ export const ORBITAL_FOCUS_EASE_TAU_MS = 250; -/** How often the list of mapped points in reach is rebuilt, ms. */ +/** How often the orbital Live Signals rows are 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; +/** The most mapped sites offered for the rows, nearest to nadir first. */ +export const ORBITAL_REACH_LIST_MAX = 6; /** 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) { @@ -52,29 +45,6 @@ function finiteVector(v) { ); } -/** - * 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 @@ -246,36 +216,8 @@ export function pointsInReach( a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key), ); const limit = - Number.isInteger(max) && max >= 0 ? max : ORBITAL_REACH_LIST_MAX; + max === Infinity || (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; -} diff --git a/src/cockpitOrbitalFocus.test.mjs b/src/cockpitOrbitalFocus.test.mjs index 67a0cdef8..62b11463b 100644 --- a/src/cockpitOrbitalFocus.test.mjs +++ b/src/cockpitOrbitalFocus.test.mjs @@ -7,12 +7,10 @@ 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'; @@ -53,25 +51,14 @@ const onSphere = (latDeg, lonDeg, radiusM) => { }; }; +/** 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('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]) { @@ -260,32 +247,3 @@ test('in frame follows the view, its height and its width', () => { 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, - ); -}); diff --git a/src/cockpitOrbitalSatellites.js b/src/cockpitOrbitalSatellites.js new file mode 100644 index 000000000..21d12dd91 --- /dev/null +++ b/src/cockpitOrbitalSatellites.js @@ -0,0 +1,55 @@ +/** + * 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. Both ends may move during the hop; pass + * their positions now. + * @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 = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z)); + const omega = Math.acos(cosine); + let ka = 1 - p; + let kb = p; + if (omega > 1e-9) { + const sine = Math.sin(omega); + ka = Math.sin((1 - p) * omega) / sine; + kb = Math.sin(p * omega) / sine; + } + const r = ra + (rb - ra) * p; + return { + x: (a.x * ka + b.x * kb) * r, + y: (a.y * ka + b.y * kb) * r, + z: (a.z * ka + b.z * kb) * r, + }; +} diff --git a/src/cockpitOrbitalSatellites.test.mjs b/src/cockpitOrbitalSatellites.test.mjs new file mode 100644 index 000000000..57d84c3c7 --- /dev/null +++ b/src/cockpitOrbitalSatellites.test.mjs @@ -0,0 +1,28 @@ +// 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); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 4df6ddef0..880eed4e0 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -806,18 +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}", - "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} كم", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 1e9020465..09f9c98a7 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -193,18 +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}", - "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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index e4e1834a9..fa59166fa 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -664,18 +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}", - "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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 782c5b724..e597c118f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -829,18 +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}", - "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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9553c8b92..687ac971c 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -829,18 +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}", - "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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 1ba00a474..f3f86ea2d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -803,18 +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}", - "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", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 189404f79..054bfc32e 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -827,18 +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}", - "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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6eb44c31f..993edb912 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -189,18 +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}", - "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} КМ", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 328f0fe72..1fe699b69 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -841,18 +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}", - "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} КМ", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 4d1b7b55c..537325aef 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -780,18 +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}", - "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", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index cbead8578..f358a5128 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -775,18 +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}", - "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", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index e64b2307b..8c2a2bd75 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -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", diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 9952206a5..a11228dfa 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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, @@ -413,8 +415,6 @@ 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), }, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index ed66e8199..81b39fabe 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -33,6 +33,12 @@ import { routeSceneClick, toggleOrbitalFocus, } from './cockpitOrbitalFocus.js'; +import { + handleOrbitalSignalClick, + refreshOrbitalSignals, + updateOrbitalSignals, +} from './cockpitOrbitalSignals.js'; +import { rideOrbitalSatellite } from './cockpitOrbitalSatellites.js'; import { publishInstrumentsView, publishRouteView, @@ -233,7 +239,6 @@ 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 = { @@ -359,10 +364,6 @@ 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 }), @@ -456,14 +457,37 @@ export class CockpitViewController { return onOrbitalDoubleClick.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. + * 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 diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 60a3259f9..ec85d5888 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -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, diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js index dc2cfa738..810fb1c03 100644 --- a/src/ui/cockpitOrbitalFocus.js +++ b/src/ui/cockpitOrbitalFocus.js @@ -1,37 +1,59 @@ /** - * 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 + * 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 { offNadirOrientation } from '../cockpitOrbital.js'; import { - ORBITAL_REACH_LIST_MAX, - ORBITAL_REACH_REFRESH_MS, - detailRows, + ORBITAL_EARTH_RADIUS_M, + offNadirOrientation, +} from '../cockpitOrbital.js'; +import { 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']); +/** 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() { @@ -49,80 +71,103 @@ function inOrbit(controller) { ); } -/** The layers' on/off state, as `isContextRecordActive` reads it. */ -function layerGate(controller) { +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' - ? { 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)); + ? isLayerEnabled(SITES_LAYER_ID) !== false + : false; } /** - * 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. + * 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 startFocus(record) { +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, record); + 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(record.id), - label: String(record.label || record.layerName || record.id), + 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); - publishOrbitalReach.call(this); + this.refreshOrbitalSignals?.(); return true; } @@ -135,7 +180,11 @@ function startFocus(record) { */ export function releaseOrbitalFocus({ handOver = false } = {}) { const state = this.orbital; - if (!state?.focus) return false; + if (!state?.focus) { + // An arrow during the ease back takes the arrows' own clock again. + if (handOver && state) state.focusReleasing = false; + return false; + } state.focus = null; if (handOver) { state.lookTarget = clampOrbitalLook(state.lookView); @@ -147,36 +196,22 @@ export function releaseOrbitalFocus({ handOver = false } = {}) { state.focusReleasing = false; } else state.focusReleasing = true; } - publishOrbitalReach.call(this); + this.refreshOrbitalSignals?.(); return true; } /** - * FOCUS on the reach list: focus that point, switch to it from another, or + * 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 point's context id. + * @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 record = groundRecord( - this, - getContextStore().entities.get(String(key)), - ); - return record ? startFocus.call(this, record) : false; -} - -/** The mapped ground point drawn at a canvas position, or null. */ -function groundRecordAt(controller, position) { - let picked = null; - try { - picked = controller.viewer.scene?.pick?.(position) ?? null; - } catch { - picked = null; - } - return pickedGroundRecord(controller, picked); + const site = siteById(this, String(key)); + return site ? startFocus.call(this, site) : false; } /** @@ -191,11 +226,10 @@ export function onOrbitalDoubleClick(position) { if (!inOrbit(this) || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - const record = groundRecordAt(this, position); - if (!record) return false; - // A point beyond the reach is not focused, but still never tracked. - if (this.orbital.focus?.key !== String(record.id)) - startFocus.call(this, record); + 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; } @@ -210,12 +244,12 @@ export function onOrbitalClick(position) { if (!inOrbit(this) || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - const record = groundRecordAt(this, position); + const site = siteAt(this, position); 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. + if (site) { + if (state.focus?.key === String(site.id)) return true; + if (startFocus.call(this, site)) return true; + // A mapped site beyond the reach leaves the view as it is. return false; } releaseOrbitalFocus.call(this); @@ -249,9 +283,9 @@ export function routeSceneClick() { /** * The orbital view's look for one frame, while a focus holds or is ending. * A held focus aims at its target: the live look plus whatever is left of - * the turn onto it. The focus ends, easing back, when its point is gone (its - * layer off, the record dropped) or beyond the reach. Also rebuilds the - * reach list on its own clock. + * the turn onto it. The focus ends, easing back, when its site is gone (its + * layer off, the site no longer loaded) or beyond the reach. Also rebuilds + * the 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. @@ -264,16 +298,19 @@ export function updateOrbitalFocus(state, nadir, position, nowMs) { 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 record = groundRecord( - this, - getContextStore().entities.get(focus.key), - ); - const look = record - ? focusLook(nadir, state.focusFrame.position, recordPosition(record)) + const site = siteById(this, focus.key); + const look = site + ? focusLook(nadir, state.focusFrame.position, sitePosition(site)) : null; if (!focusInReach(look)) { releaseOrbitalFocus.call(this); @@ -296,8 +333,7 @@ export function updateOrbitalFocus(state, nadir, position, nowMs) { aimed = true; } } - if (nowMs - (state.reachMs ?? -Infinity) >= ORBITAL_REACH_REFRESH_MS) - publishOrbitalReach.call(this, nowMs); + this.updateOrbitalSignals?.(nowMs); return aimed; } @@ -345,69 +381,95 @@ export function orbitalFocusReadout() { } /** - * 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] + * 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 {{count: number, entries: Array<{site: object, + * offNadirDeg: number, focused: boolean}>}} `count` also counts sites in + * the frame but beyond the reach. */ -export function publishOrbitalReach(nowMs = performance.now()) { +export function orbitalSitesInReach(info) { 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 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 { count: 0, entries: [] }; + if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) + return { count: 0, 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 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), - }; + const reach = pointsInReach(candidates, { + nadir: frame.nadir, + view: offNadirOrientation(frame.nadir, { + northDeg: state.lookView.pitchDeg, + eastDeg: state.lookView.yawDeg, }), + satellite: frame.position, + verticalFovRad: state.verticalFov, + aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1, + max: Infinity, }); -} - -/** Clear the reach list as the orbital session ends. */ -export function endOrbitalFocus() { - this.publishOrbitalReach(null); + const focusKey = state.focus?.key ?? null; + return { + count: reach.count, + entries: reach.entries + .filter((entry) => entry.reachable) + .map(({ key, offNadirDeg }) => ({ + site: byId.get(key), + offNadirDeg, + focused: key === focusKey, + })), + }; } diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs index 1e1a232dd..eba4d348e 100644 --- a/src/ui/cockpitOrbitalFocus.test.mjs +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -1,8 +1,11 @@ -// 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. +// 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'; @@ -15,52 +18,213 @@ 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'; +import { ORBITAL_HOP_MS } from '../cockpitOrbitalSatellites.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, +/** 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' }, }); -const LAYER = 'military-installations'; - -/** Mapped points: two in reach, one only beyond it. */ +/** Mapped sites as the installations layer keeps them. */ 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 } }, + 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), - ]), + defineComponent(() => () => [h(ContextView), h(IntelHudView), h(CockpitView)]), ).use(pinia); app.mount(host); useHudStore(pinia); @@ -95,64 +259,51 @@ function fixture(t, { reducedMotion = false } = {}) { trackedEntityChanged: listeners(), }; // The Viewer's own input handler: its single click picks into - // selectedEntity, which the Cockpit must still hand it. + // 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, }; - const stockClick = () => { + handler.stock = () => { handler.stockClicks += 1; }; - handler.stock = stockClick; - actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, stockClick); - // Its double-click tracks what it picks (pickAndTrackObject): a mapped - // point, tracked, would end the orbital view. - handler.stockDoubleClicks = 0; - const stockDoubleClick = ({ position }) => { + handler.stockDouble = ({ position }) => { handler.stockDoubleClicks += 1; const picked = viewer.scene.pick(position); - viewer.trackedEntity = picked?.id - ? { ...picked.id, position: { getValue: () => null } } + viewer.trackedEntity = picked + ? { position: { getValue: () => null } } : undefined; }; - handler.stockDouble = stockDoubleClick; - actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, stockDoubleClick); + actions.set(Cesium.ScreenSpaceEventType.LEFT_CLICK, handler.stock); + actions.set(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK, handler.stockDouble); 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 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, - isLayerEnabled: (layerId) => enabled.has(layerId), }, isEntryAllowed: () => false, isBridgeEntryAllowed: () => false, @@ -163,7 +314,7 @@ function fixture(t, { reducedMotion = false } = {}) { route: (view) => store.publishRoute(view), radar: (view) => store.publishRadar(view), orbital: (view) => store.publishOrbital(view), - orbitalReach: (view) => store.publishOrbitalReach(view), + signals: (rows) => store.publishSignals(rows), }, }); Object.assign(cockpit, { @@ -171,42 +322,18 @@ function fixture(t, { reducedMotion = false } = {}) { 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' }, + // 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: {}, }); - 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; @@ -215,12 +342,12 @@ function fixture(t, { reducedMotion = false } = {}) { 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; - getContextStore().entities.clear(); document.body.classList.remove('cockpit-mode', 'cockpit-bridge', 'cockpit-orbit'); }); @@ -229,6 +356,7 @@ function fixture(t, { reducedMotion = false } = {}) { cockpit.update(); return shots.at(-1); }; + const fire = (type, position) => actions.get(type)?.({ position }); return { $: (selector) => document.querySelector(selector), cockpit, @@ -236,27 +364,21 @@ function fixture(t, { reducedMotion = false } = {}) { store, shots, handler, - carriers, - enabled, + sites, + satellites, + modeEvents, frame, - click: (position) => - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }), + click: (position) => fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position), /** A double-click as Cesium fires it: two clicks, then the double. */ doubleClick(position) { - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); - actions.get(Cesium.ScreenSpaceEventType.LEFT_CLICK)?.({ position }); - actions.get(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK)?.({ position }); + fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position); + fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, position); + fire(Cesium.ScreenSpaceEventType.LEFT_DOUBLE_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; + /** Rebuild the Context card on the next frame, past its clock. */ + refreshCard() { + nowMs += 1000; + cockpit.update(); }, }; } @@ -271,6 +393,7 @@ async function inOrbit(t, options) { assert.equal(scene.cockpit.enter(), true); await settle(); scene.frame(); + scene.refreshCard(); await settle(); return scene; } @@ -316,7 +439,7 @@ 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); + scene.satellites.move(drift, drift); const shot = scene.frame(); largest = Math.max(largest, angleDeg(previous.direction, shot.direction)); previous = shot; @@ -334,93 +457,125 @@ const LAND_FRAMES = Math.ceil((12 * ORBITAL_FOCUS_EASE_TAU_MS) / FRAME_MS); */ 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) => { +/** 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 { $, cockpit, store, handler } = scene; - const before = scene.shots.at(-1); - assert.ok(missDeg(before, 'alpha') > 10, 'not centred at nadir'); + 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); +}); - 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'); +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`); - - // 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); + scene.satellites.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-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( - $('.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)}°`); + // 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('a click elsewhere eases the camera back to nadir', async (t) => { +test('another site row switches the focus; the held site row ends it', async (t) => { const scene = await inOrbit(t); - const { $, cockpit } = scene; - scene.click(SITES.alpha.at); + const { cockpit } = scene; + siteRow(scene, 'alpha').click(); run(scene, LAND_FRAMES); - const focused = offNadir(scene.shots.at(-1)); - assert.ok(focused > 10); + 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); - const first = scene.frame(); - assert.ok(offNadir(first) > focused * 0.8, 'no jump back'); + 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, `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'); + 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. + scene.click(SITES.far.at); + assert.equal(cockpit.orbital.focus ?? null, null); }); test('a click a tool owns is left alone', async (t) => { @@ -430,183 +585,236 @@ test('a click a tool owns is left alone', async (t) => { 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'); + assert.equal(scene.cockpit.orbital.focus.key, SITES.alpha.id, 'kept while drawing'); }); -test('the focus ends by itself, easing back, when the point leaves the reach', async (t) => { +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; - scene.click(SITES.bravo.at); + siteRow(scene, 'bravo').click(); 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); + 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'); - const atRelease = offNadir(scene.shots.at(-1)); - assert.ok(atRelease > 40, `held to the reach (${atRelease}°)`); + 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('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) => { +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.enabled.delete(LAYER); + scene.sites.enabled = false; 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); + 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, 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'); + 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('leaving orbit while focused clears the focus and the list', async (t) => { +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); + + satelliteRow(scene, 49260).click(); + 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); + cockpit.onKeyDown({ + key: 'ArrowUp', + target: document.body, + preventDefault() {}, + stopImmediatePropagation() {}, + }); + assert.equal(cockpit.orbital.focusReleasing, false); +}); + +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.drop(); + scene.satellites.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. + assert.equal(scene.store.presence.context, false); scene.click(SITES.bravo.at); - assert.equal(scene.cockpit.toggleOrbitalFocus('site-bravo'), false); + assert.equal(scene.cockpit.toggleOrbitalFocus(SITES.bravo.id), false); assert.equal(scene.cockpit.onOrbitalClick(SITES.bravo.at), false); - assert.equal(scene.handler.stockClicks, 2, 'Cesium kept its clicks'); + 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) => { @@ -625,85 +833,113 @@ test('NADIR and Home end a focus; an arrow takes the view over where it is', asy $('#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); + assert.equal( + cockpit.orbital.lookTarget.pitchDeg, + Math.floor(held.pitchDeg / 5 + 1e-9) * 5 + 5, + ); }); -test('reduced motion cuts onto the point and back', async (t) => { +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 the single click back to Cesium', async (t) => { +test('stopping the Cockpit hands both clicks back to Cesium', async (t) => { const scene = fixture(t); - const type = Cesium.ScreenSpaceEventType.LEFT_CLICK; - assert.notEqual(scene.handler.getInputAction(type), scene.handler.stock); + 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(type), scene.handler.stock); - scene.click(EMPTY_GROUND); - assert.equal(scene.handler.stockClicks, 1); - assert.equal(scene.cockpit.orbital, null); + assert.equal(scene.handler.getInputAction(click), scene.handler.stock); + assert.equal(scene.handler.getInputAction(double), scene.handler.stockDouble); }); -test('a double-click on a mapped point focuses it and keeps the orbital view', async (t) => { +test('the focused site keeps its row after nearer sites push it down', 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 point'); - assert.equal(viewer.trackedEntity, undefined); - scene.frame(); - assert.equal(cockpit.active, true, 'still in orbit'); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); + siteRow(scene, 'alpha').click(); run(scene, LAND_FRAMES); - assert.ok(missDeg(scene.shots.at(-1), 'alpha') < 1e-5); - - // On the focused point it keeps the focus; on another it switches. - scene.doubleClick(SITES.alpha.at); - assert.equal(cockpit.orbital.focus.key, 'site-alpha'); - scene.doubleClick(SITES.bravo.at); - assert.equal(cockpit.orbital.focus.key, 'site-bravo'); - // A mapped point beyond the reach is not focused, but not tracked either. - scene.doubleClick(SITES.far.at); - assert.equal(handler.stockDoubleClicks, 0); - scene.frame(); - assert.equal(cockpit.active, true); - - // Anywhere else the double-click goes on to Cesium's, as before. - scene.doubleClick(EMPTY_GROUND); - assert.equal(handler.stockDoubleClicks, 1); - assert.equal(cockpit.orbital.focus, null, 'the clicks ended the focus'); + // 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 double-click a tool owns, or one outside orbit, goes on to Cesium', async (t) => { - const scene = fixture(t); - const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK; - scene.doubleClick(SITES.alpha.at); - assert.equal(scene.handler.stockDoubleClicks, 1, 'outside orbit'); - scene.viewer.trackedEntity = undefined; - assert.equal(scene.cockpit.enter(), true); +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(); - scene.frame(); - const lease = claimPointer('draw'); - t.after(() => releasePointer(lease)); - scene.doubleClick(SITES.alpha.at); - assert.equal(scene.handler.stockDoubleClicks, 2, 'a tool owns it'); - releasePointer(lease); - scene.cockpit.stop(); - assert.equal(scene.handler.getInputAction(type), scene.handler.stockDouble); + 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', + ); }); diff --git a/src/ui/cockpitOrbitalSatellites.js b/src/ui/cockpitOrbitalSatellites.js new file mode 100644 index 000000000..c58c50d7f --- /dev/null +++ b/src/ui/cockpitOrbitalSatellites.js @@ -0,0 +1,188 @@ +/** + * 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} 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); + 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); +} diff --git a/src/ui/cockpitOrbitalSignals.js b/src/ui/cockpitOrbitalSignals.js new file mode 100644 index 000000000..c01bd56a2 --- /dev/null +++ b/src/ui/cockpitOrbitalSignals.js @@ -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} entries Nearest first. + * @param {(entry: object) => string} keyOf + * @param {Array} 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; +} diff --git a/src/ui/cockpitOrbitalView.js b/src/ui/cockpitOrbitalView.js index 50e90d007..8e6f20b06 100644 --- a/src/ui/cockpitOrbitalView.js +++ b/src/ui/cockpitOrbitalView.js @@ -44,11 +44,11 @@ import { isPointerFree } from '../data/inputOwnership.js'; import { zoomableSwathKm } from '../layers/satellites/sensors.js'; import { easeOrbitalLook, - endOrbitalFocus, orbitalFocusReadout, releaseOrbitalFocus, updateOrbitalFocus, } from './cockpitOrbitalFocus.js'; +import { orbitalEyePosition } from './cockpitOrbitalSatellites.js'; import { COCKPIT_CONTEXT_UPDATE_MS, COCKPIT_HUD_UPDATE_MS, @@ -137,7 +137,6 @@ 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); } /** @@ -306,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 @@ -326,7 +327,7 @@ export function updateOrbital(nowMs) { goToZoom.call(this, maxZoom); } // A ground focus aims the look itself (src/ui/cockpitOrbitalFocus.js). - if (!updateOrbitalFocus.call(this, state, nadir, position, nowMs)) + if (!updateOrbitalFocus.call(this, state, nadir, eye, nowMs)) state.lookView = easeOrbitalLook( state, nowMs - (state.lookLastMs || nowMs), @@ -340,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), diff --git a/src/ui/cockpitSignals.js b/src/ui/cockpitSignals.js index 32f25bdc8..54889a82d 100644 --- a/src/ui/cockpitSignals.js +++ b/src/ui/cockpitSignals.js @@ -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, diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index a71a78336..4a54742e1 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -132,22 +132,6 @@ 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 @@ -187,8 +171,6 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { publishRadar, orbital, publishOrbital, - orbitalReach, - publishOrbitalReach, utilityLayout, publishUtilityLayout, }; diff --git a/src/ui/styles/cockpitOrbitalFocus.css b/src/ui/styles/cockpitOrbitalFocus.css new file mode 100644 index 000000000..0815c3159 --- /dev/null +++ b/src/ui/styles/cockpitOrbitalFocus.css @@ -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; +} diff --git a/src/ui/styles/cockpitOrbitalReach.css b/src/ui/styles/cockpitOrbitalReach.css deleted file mode 100644 index fede7254e..000000000 --- a/src/ui/styles/cockpitOrbitalReach.css +++ /dev/null @@ -1,150 +0,0 @@ -/* 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; -} diff --git a/src/ui/views/panels/CockpitOrbital.vue b/src/ui/views/panels/CockpitOrbital.vue index 51f14e1ed..47f19cbef 100644 --- a/src/ui/views/panels/CockpitOrbital.vue +++ b/src/ui/views/panels/CockpitOrbital.vue @@ -14,7 +14,6 @@ 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 @@ -328,8 +327,6 @@ const lensStyle = computed(() => {

{{ focusText }}

- -
diff --git a/src/ui/views/panels/CockpitOrbitalReach.vue b/src/ui/views/panels/CockpitOrbitalReach.vue deleted file mode 100644 index 852799da9..000000000 --- a/src/ui/views/panels/CockpitOrbitalReach.vue +++ /dev/null @@ -1,109 +0,0 @@ - - - diff --git a/style.css b/style.css index 4d678058c..930cc2346 100644 --- a/style.css +++ b/style.css @@ -11,7 +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/cockpitOrbitalFocus.css'; @import './src/ui/styles/overlays.css'; @import './src/ui/styles/layers.css'; @import './src/ui/styles/radio.css'; From 7b844792be32dc840012263626cc043e69026062 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 23:59:55 +0100 Subject: [PATCH 07/10] docs(cockpit): describe orbit's Live Signals rows and site focus --- docs/CURRENT-STATE.md | 98 ++++++++++++++++++++++++++++--------------- 1 file changed, 64 insertions(+), 34 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index cddbff85f..6b2afdd76 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1643,7 +1643,7 @@ 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 by the satellites layer while it is ridden (a click on a mapped -point focuses it; see below). The optical zoom steps 1×, 2×, 4× and 8× for +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 @@ -1700,42 +1700,72 @@ 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. -A single click on a mapped ground point (a ground point of a layer that is -on, through the shared context store: Mapped Installations, fires, local -data packs; aircraft, vessels and satellites are not targets) keeps the -camera on it (`src/ui/cockpitOrbitalFocus.js`, geometry in -`src/cockpitOrbitalFocus.js`). The look is aimed each frame from the -satellite's position to the point's Earth-fixed position in the same -track-up nadir frame the arrows tilt, so the point stays at the centre as -the satellite moves; the turn onto it (and between points) 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 point exactly, and cuts under -`prefers-reduced-motion`. A click anywhere else, FOCUS on the focused point, -NADIR, Home or `0` ends the focus and the view eases back to nadir on the -same clock; an arrow ends it and tilts on from where the view is. The focus -also ends by itself, easing back, when the point leaves the reach (either -tilt axis past ±45°, or the point below the satellite's horizon), when its -layer is turned off or its record is dropped, and with the orbital view. A -mapped point beyond the reach cannot be focused. While focused the readout -gives the single off-nadir angle to the point, as image metadata does +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) 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, then rebuilds at once. + +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, 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 point. Clicks still +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 mapped ground point keeps or starts the focus -on it and never reaches Cesium's double-click, which would track the point -and end the view (the Cockpit's double-click routing in -`src/ui/cockpitInput.js`); any other double-click goes on as before. Under the readout, IN REACH · *n* (a disclosure) lists the mapped -points facing the satellite that are within the reach or in the frame now, -nearest to nadir first, at most 12 (with NEAREST 12 OF *n* beyond that), -rebuilt once a second. A row opens the point's details: position, source and -its OpenStreetMap tags (or its layer's properties); FOCUS on a row focuses -it, switches to it from another point, or ends the focus held on it, and is -marked unavailable (`aria-disabled`) for a point in frame but beyond the -reach. A row that leaves the list hands keyboard focus to the disclosure. -`src/ui/cockpitOrbitalFocus.test.mjs` covers the easing in and out, the -reach release, list switching, click handling and the readout. +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, @@ -1765,7 +1795,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 From 7d6314b981f751a7dfd6be7497f1228f387c30a4 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Thu, 5 Nov 2026 10:53:41 +0100 Subject: [PATCH 08/10] docs(changelog): add the #1580 fragment --- changelog.d/1580-orbital-ground-focus.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog.d/1580-orbital-ground-focus.md diff --git a/changelog.d/1580-orbital-ground-focus.md b/changelog.d/1580-orbital-ground-focus.md new file mode 100644 index 000000000..0df7cb384 --- /dev/null +++ b/changelog.d/1580-orbital-ground-focus.md @@ -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). From 6be8af17f1778c58f7fcd3d9ad36377fe588aa43 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 17:03:41 -0800 Subject: [PATCH 09/10] fix(cockpit): a new ride's TRACK ACQUIRED keeps its own time; an arrow during the ease back tilts on from the view; a site beyond the reach ends a focus; the horizon uses the WGS84 surface normal; a hop to the opposite side goes round the Earth; drop the unused in-frame listing --- docs/CURRENT-STATE.md | 11 +- src/cockpitOrbitalFocus.js | 100 ++++-------------- src/cockpitOrbitalFocus.test.mjs | 68 ++++-------- src/cockpitOrbitalSatellites.js | 26 ++++- src/cockpitOrbitalSatellites.test.mjs | 13 +++ src/layers/satellites/controls.js | 10 +- .../satellites/nearestPositions.test.mjs | 3 + src/ui/cockpitOrbitalFocus.js | 58 ++++------ src/ui/cockpitOrbitalFocus.test.mjs | 20 +++- src/ui/cockpitOrbitalSatellites.js | 3 + 10 files changed, 142 insertions(+), 170 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6b2afdd76..bcb1de592 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1708,7 +1708,8 @@ time. TRACK ACQUIRED stays first. Then: - **Sites**: the Mapped Installations layer's loaded records around the sub-satellite point (`getNearby`, asked only as far as the reach extends - over the ground) that face the satellite and lie within the off-nadir + over the ground, and never past 3,000 km, so from a high orbit only the + sites within 3,000 km of the point below are listed) that face the satellite and lie within the off-nadir reach, nearest to nadir first, the focused site always among them. A row reads the site's name over its class and off-nadir angle; its button (`Keep the camera on …`) focuses the site, and on the focused site's row, @@ -1727,7 +1728,10 @@ time. TRACK ACQUIRED stays first. Then: back to nadir, and the camera hops to the new satellite along the great circle over 1.2 s (cut under reduced motion), keeping the zoom. Until the camera is on the new satellite the page shows only TRACK ACQUIRED and - RETURN, then rebuilds at once. + RETURN (and SITES STANDBY with Mapped Installations off), then rebuilds + at once. The new satellite's TRACK ACQUIRED carries the time it was + ridden. The hop starts from where the camera was, which does not follow + the old satellite on. Sites take two rows while satellites fill the rest, more when there are fewer satellites. The Contact card stays hidden in orbit. @@ -1741,7 +1745,8 @@ with the arrows' easing on a slower clock (250 ms time constant, about 0.75 s to 95%), then holds the site exactly, and cuts under `prefers-reduced-motion`. A single click on a site's marker focuses it too: the pick is resolved through the layer's own pick ids (`findByPick`: a named -marker's `{ installationId }` or a site entity). A click anywhere else, the +marker's `{ installationId }` or a site entity). A click anywhere else +(a site beyond the reach included), the focused site's row, NADIR, Home or `0` ends the focus and the view eases back to nadir on the same clock; an arrow ends it (or the ease back) and tilts on from where the view is. The layer reloads its thinned view as the diff --git a/src/cockpitOrbitalFocus.js b/src/cockpitOrbitalFocus.js index 623507fc0..ab9d081fd 100644 --- a/src/cockpitOrbitalFocus.js +++ b/src/cockpitOrbitalFocus.js @@ -24,8 +24,9 @@ export const ORBITAL_FOCUS_EASE_TAU_MS = 250; /** How often the orbital Live Signals rows are rebuilt, ms. */ export const ORBITAL_REACH_REFRESH_MS = 1000; -/** The most mapped sites offered for the rows, nearest to nadir first. */ -export const ORBITAL_REACH_LIST_MAX = 6; +/** WGS84 semi-axes squared, m²: the surface normal for the horizon test. */ +const WGS84_A2 = 6378137 ** 2; +const WGS84_B2 = 6356752.314245179 ** 2; /** Slack on the reach limit so a target held exactly at 45° stays. */ const REACH_EPSILON_DEG = 1e-6; @@ -71,14 +72,15 @@ export function focusLook(nadir, satellite, target) { const along = dot(v, nadir.direction); const up = dot(v, nadir.up); const right = Math.min(1, Math.max(-1, dot(v, nadir.right))); - // The target's own zenith: the satellite is above its horizon when the - // line from the target to it points up. - const targetRadius = Math.hypot(target.x, target.y, target.z); + // The target's own zenith, the WGS84 surface normal there: the satellite + // is above its horizon when the line from the target to it points up. + const normal = { + x: target.x / WGS84_A2, + y: target.y / WGS84_A2, + z: target.z / WGS84_B2, + }; const visible = - targetRadius > 0 && - -(line.x * target.x + line.y * target.y + line.z * target.z) / - targetRadius > - 0; + Math.hypot(normal.x, normal.y, normal.z) > 0 && -dot(line, normal) > 0; const yawDeg = toDegrees(Math.asin(right)); const pitchDeg = toDegrees(Math.atan2(up, along)); return { @@ -106,38 +108,6 @@ export function focusInReach(look) { ); } -/** - * Whether `target` is inside the picture as it is now framed. - * @param {{direction: object, up: object, right: object}} view The camera's - * orientation (the nadir frame tilted by the eased look). - * @param {{x: number, y: number, z: number}} satellite ECEF, metres. - * @param {{x: number, y: number, z: number}} target ECEF, metres. - * @param {{verticalFovRad: number, aspect?: number}} frame - * @returns {boolean} - */ -export function inFrame( - view, - satellite, - target, - { verticalFovRad, aspect = 1 }, -) { - if (!view || !finiteVector(satellite) || !finiteVector(target)) return false; - if (!(verticalFovRad > 0)) return false; - const line = { - x: target.x - satellite.x, - y: target.y - satellite.y, - z: target.z - satellite.z, - }; - const along = dot(line, view.direction); - if (!(along > 0)) return false; - const halfTan = Math.tan(verticalFovRad / 2); - const ratio = Number.isFinite(aspect) && aspect > 0 ? aspect : 1; - return ( - Math.abs(dot(line, view.up)) / along <= halfTan && - Math.abs(dot(line, view.right)) / along <= halfTan * ratio - ); -} - /** * Ease what is left of the turn onto a focus target toward nothing: the * look's easing (`easeLook`) on the focus's slower clock. The view is the @@ -171,53 +141,23 @@ export function focusEaseDtMs(dtMs) { } /** - * The mapped points the view shows now or can be turned to: those facing - * the satellite and either within the off-nadir reach (`reachable`) or in - * the frame as it is. Nearest to nadir first, at most `max`. + * The mapped points the view can be turned to: those facing the satellite + * and within the off-nadir reach, nearest to nadir first. * @param {Iterable<{key: string, position: {x: number, y: number, * z: number}}>} candidates - * @param {{nadir: object, view: object, satellite: object, - * verticalFovRad: number, aspect?: number, max?: number}} frame - * @returns {{count: number, entries: Array<{key: string, - * offNadirDeg: number, reachable: boolean, look: object}>}} `count` is - * every point in reach, `entries` the nearest. + * @param {{nadir: object, satellite: object}} frame + * @returns {Array<{key: string, offNadirDeg: number, look: object}>} */ -export function pointsInReach( - candidates, - { - nadir, - view, - satellite, - verticalFovRad, - aspect = 1, - max = ORBITAL_REACH_LIST_MAX, - }, -) { +export function pointsInReach(candidates, { nadir, satellite }) { const entries = []; - if (!nadir || !finiteVector(satellite)) return { count: 0, entries }; + if (!nadir || !finiteVector(satellite)) return entries; for (const candidate of candidates) { const look = focusLook(nadir, satellite, candidate?.position); - if (!look?.visible) continue; - const reachable = focusInReach(look); - if ( - !reachable && - !inFrame(view, satellite, candidate.position, { verticalFovRad, aspect }) - ) - continue; - entries.push({ - key: candidate.key, - offNadirDeg: look.offNadirDeg, - reachable, - look, - }); + if (!focusInReach(look)) continue; + entries.push({ key: candidate.key, offNadirDeg: look.offNadirDeg, look }); } - entries.sort( + return entries.sort( (a, b) => a.offNadirDeg - b.offNadirDeg || String(a.key).localeCompare(b.key), ); - const limit = - max === Infinity || (Number.isInteger(max) && max >= 0) - ? max - : ORBITAL_REACH_LIST_MAX; - return { count: entries.length, entries: entries.slice(0, limit) }; } diff --git a/src/cockpitOrbitalFocus.test.mjs b/src/cockpitOrbitalFocus.test.mjs index 62b11463b..78701cec4 100644 --- a/src/cockpitOrbitalFocus.test.mjs +++ b/src/cockpitOrbitalFocus.test.mjs @@ -6,12 +6,10 @@ import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; import { ORBITAL_FOCUS_EASE_TAU_MS, - ORBITAL_REACH_LIST_MAX, easeFocusRemainder, focusEaseDtMs, focusInReach, focusLook, - inFrame, pointsInReach, } from './cockpitOrbitalFocus.js'; import { @@ -200,50 +198,30 @@ test('the reach list keeps points in reach or in frame, nearest first, bounded', for (let i = 0; i < 80; i += 1) candidates.push({ key: `east-${i}`, position: geodeticToEcef(0, i * 0.1) }); candidates.push({ key: 'far-side', position: geodeticToEcef(0, 180) }); - const frame = { - nadir, - view: nadir, - satellite, - verticalFovRad: (60 * Math.PI) / 180, - aspect: 16 / 9, - }; - const all = pointsInReach(candidates, { ...frame, max: 100 }); - assert.ok(all.entries.every((entry) => entry.key !== 'far-side')); - const angles = all.entries.map((entry) => entry.offNadirDeg); + const entries = pointsInReach(candidates, { nadir, satellite }); + assert.ok(entries.every((entry) => entry.key !== 'far-side')); + const angles = entries.map((entry) => entry.offNadirDeg); assert.deepEqual(angles, [...angles].sort((a, b) => a - b), 'nearest first'); - assert.equal(all.entries[0].key, 'east-0'); - assert.equal(all.entries[0].offNadirDeg, 0); - // The widest frame (16:9 at 60° tall) reaches past 45° across: those - // points are listed, but cannot be focused. - const beyond = all.entries.filter((entry) => !entry.reachable); - assert.ok(beyond.length > 0, 'some only in frame'); - for (const entry of beyond) assert.ok(Math.abs(entry.look.yawDeg) > 45); - for (const entry of all.entries.filter((e) => e.reachable)) - assert.ok(Math.abs(entry.look.yawDeg) <= 45); - // Out of frame and out of reach: not listed. - assert.ok(all.count < 80); - - const bounded = pointsInReach(candidates, frame); - assert.equal(bounded.entries.length, ORBITAL_REACH_LIST_MAX); - assert.equal(bounded.count, all.count, 'the count is all of them'); - assert.deepEqual( - bounded.entries.map((entry) => entry.key), - all.entries.slice(0, ORBITAL_REACH_LIST_MAX).map((entry) => entry.key), - ); - assert.deepEqual(pointsInReach([], frame), { count: 0, entries: [] }); + assert.equal(entries[0].key, 'east-0'); + assert.equal(entries[0].offNadirDeg, 0); + // Only those within the 45° tilt are listed. + assert.ok(entries.length > 0 && entries.length < 80); + for (const entry of entries) assert.ok(Math.abs(entry.look.yawDeg) <= 45); + const beyond = focusLook(nadir, satellite, candidates[entries.length].position); + assert.ok(Math.abs(beyond.yawDeg) > 45, 'the next one east is past the reach'); + assert.deepEqual(pointsInReach([], { nadir, satellite }), []); }); -test('in frame follows the view, its height and its width', () => { - const satellite = satelliteAt(0, 0); - const nadir = nadirOrientation(satellite); - const target = geodeticToEcef(0, 3); - const narrow = { verticalFovRad: (5 * Math.PI) / 180, aspect: 1 }; - assert.equal(inFrame(nadir, satellite, target, narrow), false); - const look = focusLook(nadir, satellite, target); - const tilted = offNadirOrientation(nadir, { - northDeg: look.pitchDeg, - eastDeg: look.yawDeg, - }); - assert.equal(inFrame(tilted, satellite, target, narrow), true); - assert.equal(inFrame(tilted, satellite, target, { verticalFovRad: 0 }), false); +test('the horizon is the ground’s own: the WGS84 surface normal, not the centre line', () => { + const target = geodeticToEcef(45, 0); + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(target); + const satelliteAtElevation = (elevationDeg) => { + const e = Cesium.Math.toRadians(elevationDeg); + const local = new Cesium.Cartesian3(0, -Math.cos(e) * 4e7, Math.sin(e) * 4e7); + return Cesium.Matrix4.multiplyByPoint(enu, local, new Cesium.Cartesian3()); + }; + const below = satelliteAtElevation(-0.1); + assert.equal(focusLook(nadirOrientation(below), below, target).visible, false); + const above = satelliteAtElevation(0.1); + assert.equal(focusLook(nadirOrientation(above), above, target).visible, true); }); diff --git a/src/cockpitOrbitalSatellites.js b/src/cockpitOrbitalSatellites.js index 21d12dd91..b52d4db5c 100644 --- a/src/cockpitOrbitalSatellites.js +++ b/src/cockpitOrbitalSatellites.js @@ -22,8 +22,8 @@ function finiteVector(v) { /** * Where the camera is part-way through a hop: along the great circle * between the two positions (never through the Earth), its distance from the - * centre interpolated linearly. Both ends may move during the hop; pass - * their positions now. + * centre interpolated linearly. Opposite ends turn about a fixed axis + * square to them, so even then the path stays off the Earth. * @param {{x: number, y: number, z: number}} from * @param {{x: number, y: number, z: number}} to * @param {number} progress Eased, 0..1. @@ -39,6 +39,27 @@ export function hopPosition(from, to, progress) { const b = { x: to.x / rb, y: to.y / rb, z: to.z / rb }; const cosine = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z)); const omega = Math.acos(cosine); + const r = ra + (rb - ra) * p; + if (Math.PI - omega < 1e-6) { + // No single great circle joins opposite points: turn about the axis + // square to `a` that is least aligned with the Earth's. + const pole = + Math.abs(a.z) < 0.9 ? { x: 0, y: 0, z: 1 } : { x: 1, y: 0, z: 0 }; + const along = pole.x * a.x + pole.y * a.y + pole.z * a.z; + const c = { + x: pole.x - along * a.x, + y: pole.y - along * a.y, + z: pole.z - along * a.z, + }; + const rc = Math.hypot(c.x, c.y, c.z); + const cos = Math.cos(p * Math.PI); + const sin = Math.sin(p * Math.PI); + return { + x: (a.x * cos + (c.x / rc) * sin) * r, + y: (a.y * cos + (c.y / rc) * sin) * r, + z: (a.z * cos + (c.z / rc) * sin) * r, + }; + } let ka = 1 - p; let kb = p; if (omega > 1e-9) { @@ -46,7 +67,6 @@ export function hopPosition(from, to, progress) { ka = Math.sin((1 - p) * omega) / sine; kb = Math.sin(p * omega) / sine; } - const r = ra + (rb - ra) * p; return { x: (a.x * ka + b.x * kb) * r, y: (a.y * ka + b.y * kb) * r, diff --git a/src/cockpitOrbitalSatellites.test.mjs b/src/cockpitOrbitalSatellites.test.mjs index 57d84c3c7..3a4902b11 100644 --- a/src/cockpitOrbitalSatellites.test.mjs +++ b/src/cockpitOrbitalSatellites.test.mjs @@ -26,3 +26,16 @@ test('the hop follows the great circle and lands on the satellite', () => { assert.ok(Math.abs(Math.atan2(mid.y, mid.x) - Math.PI / 4) < 1e-9); assert.equal(hopPosition(null, to, 0.5), null); }); + +test('a hop to the opposite side goes round the Earth, not through it', () => { + const from = at(0, 0, 400_000); + const to = at(0, 180, 400_000); + const radius = Cesium.Cartesian3.magnitude(from); + for (const p of [0.25, 0.5, 0.75]) { + const point = hopPosition(from, to, p); + assert.ok(Math.abs(Math.hypot(point.x, point.y, point.z) - radius) < 1e-3, `p=${p}`); + } + const end = hopPosition(from, to, 1); + for (const axis of ['x', 'y', 'z']) + assert.ok(Math.abs(end[axis] - to[axis]) < 1e-3, axis); +}); diff --git a/src/layers/satellites/controls.js b/src/layers/satellites/controls.js index 6b390871a..ab0cfe6ea 100644 --- a/src/layers/satellites/controls.js +++ b/src/layers/satellites/controls.js @@ -389,10 +389,14 @@ export function createControls({ state: layerState, services, parts, source }) { { excludeIds = [], excludeClasses = [] } = {}, ) { const nearest = []; - if (!center || !layerState._points || layerState._points.size === 0) + const cap = Number.isFinite(maxCount) ? Math.floor(maxCount) : 8; + if ( + cap < 1 || + !center || + !layerState._points || + layerState._points.size === 0 + ) return nearest; - const cap = - Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 8; const excluded = new Set(excludeIds.map(String)); const classes = new Set(excludeClasses); const dense = layerState._denseIds.length > 0; diff --git a/src/layers/satellites/nearestPositions.test.mjs b/src/layers/satellites/nearestPositions.test.mjs index ab06d705c..8ceb306ee 100644 --- a/src/layers/satellites/nearestPositions.test.mjs +++ b/src/layers/satellites/nearestPositions.test.mjs @@ -64,4 +64,7 @@ test('the nearest shown satellites come first, bounded, with their distance', () assert.equal(nearest[0].satelliteClass, 'imaging'); assert.equal(methods.getNearestPositions(CENTRE, 1, { excludeIds: [6] })[0].label, 'DEB'); assert.deepEqual(methods.getNearestPositions(null, 3), []); + assert.deepEqual(methods.getNearestPositions(CENTRE, 0), []); + assert.deepEqual(methods.getNearestPositions(CENTRE, 0.5), []); + assert.equal(methods.getNearestPositions(CENTRE, 2.5).length, 2); }); diff --git a/src/ui/cockpitOrbitalFocus.js b/src/ui/cockpitOrbitalFocus.js index 810fb1c03..1a1a151f6 100644 --- a/src/ui/cockpitOrbitalFocus.js +++ b/src/ui/cockpitOrbitalFocus.js @@ -10,10 +10,7 @@ */ import * as Cesium from 'cesium'; import { clampOrbitalLook, easeLook } from '../cockpitLook.js'; -import { - ORBITAL_EARTH_RADIUS_M, - offNadirOrientation, -} from '../cockpitOrbital.js'; +import { ORBITAL_EARTH_RADIUS_M } from '../cockpitOrbital.js'; import { easeFocusRemainder, focusEaseDtMs, @@ -181,8 +178,12 @@ function startFocus(site) { export function releaseOrbitalFocus({ handOver = false } = {}) { const state = this.orbital; if (!state?.focus) { - // An arrow during the ease back takes the arrows' own clock again. - if (handOver && state) state.focusReleasing = false; + // An arrow during the ease back takes the arrows' own clock again and + // tilts on from where the view is, not from the nadir it was easing to. + if (handOver && state?.focusReleasing) { + state.lookTarget = clampOrbitalLook(state.lookView); + state.focusReleasing = false; + } return false; } state.focus = null; @@ -249,8 +250,7 @@ export function onOrbitalClick(position) { if (site) { if (state.focus?.key === String(site.id)) return true; if (startFocus.call(this, site)) return true; - // A mapped site beyond the reach leaves the view as it is. - return false; + // A mapped site beyond the reach is a click elsewhere: the focus ends. } releaseOrbitalFocus.call(this); return false; @@ -283,8 +283,9 @@ export function routeSceneClick() { /** * The orbital view's look for one frame, while a focus holds or is ending. * A held focus aims at its target: the live look plus whatever is left of - * the turn onto it. The focus ends, easing back, when its site is gone (its - * layer off, the site no longer loaded) or beyond the reach. Also rebuilds + * the turn onto it. The focus ends, easing back, when its layer is turned + * off or its site is beyond the reach (a site the layer stops loading is + * held from its last record). Also rebuilds * the orbital Live Signals rows on their own clock. * @param {object} state The orbital session. * @param {{direction: object, up: object, right: object}} nadir This @@ -389,9 +390,8 @@ export function orbitalFocusReadout() { * with the layer off. * @param {{latitude: number, longitude: number, altitudeM: number}} info * The ridden satellite. - * @returns {{count: number, entries: Array<{site: object, - * offNadirDeg: number, focused: boolean}>}} `count` also counts sites in - * the frame but beyond the reach. + * @returns {{entries: Array<{site: object, offNadirDeg: number, + * focused: boolean}>}} */ export function orbitalSitesInReach(info) { const state = this.orbital; @@ -401,9 +401,9 @@ export function orbitalSitesInReach(info) { // then, and no row is left to focus from. if (state && !sitesLayerOn(this)) state.listedSites = new Map(); if (!frame || typeof layer?.getNearby !== 'function' || !sitesLayerOn(this)) - return { count: 0, entries: [] }; + return { entries: [] }; if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) - return { count: 0, entries: [] }; + return { entries: [] }; const rangeM = reachGroundRangeM(info.altitudeM); let sites = []; try { @@ -449,27 +449,15 @@ export function orbitalSitesInReach(info) { seenMs: nowMs, }); state.listedSites = remembered; - const frustum = this.viewer.camera?.frustum; - const reach = pointsInReach(candidates, { - nadir: frame.nadir, - view: offNadirOrientation(frame.nadir, { - northDeg: state.lookView.pitchDeg, - eastDeg: state.lookView.yawDeg, - }), - satellite: frame.position, - verticalFovRad: state.verticalFov, - aspect: Number.isFinite(frustum?.aspectRatio) ? frustum.aspectRatio : 1, - max: Infinity, - }); const focusKey = state.focus?.key ?? null; return { - count: reach.count, - entries: reach.entries - .filter((entry) => entry.reachable) - .map(({ key, offNadirDeg }) => ({ - site: byId.get(key), - offNadirDeg, - focused: key === focusKey, - })), + entries: pointsInReach(candidates, { + nadir: frame.nadir, + satellite: frame.position, + }).map(({ key, offNadirDeg }) => ({ + site: byId.get(key), + offNadirDeg, + focused: key === focusKey, + })), }; } diff --git a/src/ui/cockpitOrbitalFocus.test.mjs b/src/ui/cockpitOrbitalFocus.test.mjs index eba4d348e..f7b18e22b 100644 --- a/src/ui/cockpitOrbitalFocus.test.mjs +++ b/src/ui/cockpitOrbitalFocus.test.mjs @@ -573,9 +573,14 @@ test('a click on a site marker focuses it through the layer; a click elsewhere e const largest = run(scene, LAND_FRAMES, { drift: 0 }); assert.ok(largest < MAX_TURN_PER_FRAME_DEG); assert.ok(offNadir(scene.shots.at(-1)) < 1e-6); - // A site beyond the reach is not focused. + // A site beyond the reach is not focused, and ends a focus as a click + // elsewhere does. scene.click(SITES.far.at); assert.equal(cockpit.orbital.focus ?? null, null); + scene.click(SITES.alpha.at); + assert.equal(cockpit.orbital.focus.key, SITES.alpha.id); + scene.click(SITES.far.at); + assert.equal(cockpit.orbital.focus, null); }); test('a click a tool owns is left alone', async (t) => { @@ -676,7 +681,12 @@ test('a satellite row rides it, staying in orbit; RETURN goes back', async (t) = run(scene, 5); const before = Cesium.Cartesian3.clone(scene.shots.at(-1).destination); + const firstTrack = cockpit.signalItems.find((item) => item.key === 'track'); + firstTrack.timestamp -= 60_000; satelliteRow(scene, 49260).click(); + const newTrack = cockpit.signalItems.filter((item) => item.key === 'track'); + assert.equal(newTrack.length, 1); + assert.ok(newTrack[0].timestamp > firstTrack.timestamp, 'the new ride’s own time'); assert.deepEqual(satellites.calls.slice(-2), [['track', 49260], ['subject', 49260]]); assert.equal(viewer.trackedEntity, undefined, 'the Cockpit keeps the camera'); assert.equal(cockpit.subjectId, 49260); @@ -793,6 +803,8 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock', scene.click(EMPTY_GROUND); scene.frame(); assert.equal(cockpit.orbital.focusReleasing, true); + const view = { ...cockpit.orbital.lookView }; + assert.ok(Math.abs(view.pitchDeg) > 1 || Math.abs(view.yawDeg) > 1, 'still turned'); cockpit.onKeyDown({ key: 'ArrowUp', target: document.body, @@ -800,6 +812,12 @@ test('an arrow during the ease back from a focus turns on the arrows’ clock', stopImmediatePropagation() {}, }); assert.equal(cockpit.orbital.focusReleasing, false); + // The arrow tilts on from where the view is, not from nadir. + assert.ok( + Math.abs(cockpit.orbital.lookTarget.yawDeg - view.yawDeg) < 1e-6, + 'across kept', + ); + assert.ok(cockpit.orbital.lookTarget.pitchDeg > view.pitchDeg, 'tilted on up'); }); test('leaving orbit leaves no orbital rows to act on', async (t) => { diff --git a/src/ui/cockpitOrbitalSatellites.js b/src/ui/cockpitOrbitalSatellites.js index c58c50d7f..d8735dd65 100644 --- a/src/ui/cockpitOrbitalSatellites.js +++ b/src/ui/cockpitOrbitalSatellites.js @@ -150,6 +150,9 @@ export function switchOrbitalSatellite(noradId) { this.lastHudUpdateMs = 0; this.lastContextUpdateMs = 0; this.dispatchCockpitModeChanged(true, info); + // The new satellite's TRACK ACQUIRED replaces the old one, time and all. + this.signalItems = this.signalItems.filter((item) => item.key !== 'track'); + this.signalSignatures.delete('track'); this.pushCockpitSignal( 'track', 'track', From 577170ee8e971d7933ba8d76fe3ef2173d732160 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 17:09:56 -0800 Subject: [PATCH 10/10] fix(cockpit): the hop's circle runs through both ends, so it moves smoothly as a nearly opposite destination does and lands on it --- docs/CURRENT-STATE.md | 4 +-- src/cockpitOrbitalSatellites.js | 51 +++++++++++++-------------- src/cockpitOrbitalSatellites.test.mjs | 34 +++++++++++++++++- 3 files changed, 60 insertions(+), 29 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bcb1de592..63041c29a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1709,8 +1709,8 @@ time. TRACK ACQUIRED stays first. Then: - **Sites**: the Mapped Installations layer's loaded records around the sub-satellite point (`getNearby`, asked only as far as the reach extends over the ground, 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 + 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 diff --git a/src/cockpitOrbitalSatellites.js b/src/cockpitOrbitalSatellites.js index b52d4db5c..343530f42 100644 --- a/src/cockpitOrbitalSatellites.js +++ b/src/cockpitOrbitalSatellites.js @@ -22,8 +22,10 @@ function finiteVector(v) { /** * Where the camera is part-way through a hop: along the great circle * between the two positions (never through the Earth), its distance from the - * centre interpolated linearly. Opposite ends turn about a fixed axis - * square to them, so even then the path stays off the Earth. + * 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. @@ -37,39 +39,36 @@ export function hopPosition(from, to, progress) { 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 = Math.min(1, Math.max(-1, a.x * b.x + a.y * b.y + a.z * b.z)); - const omega = Math.acos(cosine); + 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; - if (Math.PI - omega < 1e-6) { - // No single great circle joins opposite points: turn about the axis - // square to `a` that is least aligned with the Earth's. + const 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; - const c = { + c = { x: pole.x - along * a.x, y: pole.y - along * a.y, z: pole.z - along * a.z, }; - const rc = Math.hypot(c.x, c.y, c.z); - const cos = Math.cos(p * Math.PI); - const sin = Math.sin(p * Math.PI); - return { - x: (a.x * cos + (c.x / rc) * sin) * r, - y: (a.y * cos + (c.y / rc) * sin) * r, - z: (a.z * cos + (c.z / rc) * sin) * r, - }; - } - let ka = 1 - p; - let kb = p; - if (omega > 1e-9) { - const sine = Math.sin(omega); - ka = Math.sin((1 - p) * omega) / sine; - kb = Math.sin(p * omega) / sine; + 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 * ka + b.x * kb) * r, - y: (a.y * ka + b.y * kb) * r, - z: (a.z * ka + b.z * kb) * r, + x: (a.x * cos + c.x * sin) * r, + y: (a.y * cos + c.y * sin) * r, + z: (a.z * cos + c.z * sin) * r, }; } diff --git a/src/cockpitOrbitalSatellites.test.mjs b/src/cockpitOrbitalSatellites.test.mjs index 3a4902b11..60156b89e 100644 --- a/src/cockpitOrbitalSatellites.test.mjs +++ b/src/cockpitOrbitalSatellites.test.mjs @@ -33,9 +33,41 @@ test('a hop to the opposite side goes round the Earth, not through it', () => { 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}`); + 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}`, + ); + } +});