From 6be8af17f1778c58f7fcd3d9ad36377fe588aa43 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 8 Nov 2026 17:03:41 -0800 Subject: [PATCH] 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',