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';