diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index af2d4c94c..5ab894d48 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -306,3 +306,18 @@ for a provider placing a train along its line. The frame, HUD view and candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail and Transit read APIs in `src/layers/rail/cockpitSubject.js` and `src/layers/transit/cockpitSubject.js`. + +A candidate's `selectedAt` is a token that changes each time the user selects +the subject, even the same one again, so entry takes it first. The Rail and +Transit snapshots give one, `selectedAtMs`: when the user last selected the +train or vehicle by a click or `focusTrain`/`focusVehicle`. It is null after +a deselection and for one selected only by a hold, since a hold is not the +user's selection. + +A frame with `heightM: null` stands on the shared floor, or where it last +stood while that floor is cold. If no height has been known since entry, the +view waits, showing ACQUIRING SURFACE, for at most five seconds +(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. A +`hold` that throws or returns a promise refuses entry and is followed by +`release(id, { restore: false })`, again once a returned promise settles, so +`release` must be harmless when called more than once. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0fbf6119c..88f968ff8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2011,7 +2011,9 @@ older subject's. A frame places the eye `eye.upM` above `heightM` (the shared floor where the frame gives none, or where it last stood while that floor is cold; with -neither the camera waits where it is), slews toward `headingDeg` at the provider's rate, and may +neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for +at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the +subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may turn off the floor clamps (`groundClamp: false`, for a subject inside a structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a layer that already moves it smoothly); `cameraIntervalMs: 0` updates the @@ -2034,7 +2036,8 @@ The Rail layer offers a read API for such a view: `getSelectedTrain()` and `getCockpitSubject()` (a snapshot of the train: where it is along its line, which way it runs (`pathBinding.js` learns it), its rail height, speed as reported or derived, position source, feed credit and stops, and -`selectedAtMs`, when the user last selected it, even again), +`selectedAtMs`, when the user last selected it by a click or `focusTrain`, +even again; null after a deselection and for a train only a hold selected), `setCockpitSubject(key)` (its marker and card hidden, the selection kept, its feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js index 46b588ebe..aba0a2b75 100644 --- a/src/cockpitSubjectProviders.js +++ b/src/cockpitSubjectProviders.js @@ -170,8 +170,10 @@ function refusal(provider, kinds) { * - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`, * `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all * synchronous (createCockpitSubjectCaller). `isEntryAllowed` allows entry - * only by returning `true`. A `hold` that throws is followed by - * `release(id, {restore: false})`. + * only by returning `true`. A `hold` that throws or returns a promise + * refuses entry and is followed by `release(id, {restore: false})`, and by + * another once that promise settles; `release` must therefore be harmless + * when called again for a subject it already gave back. * * A provider that is not so is left out with one warning naming it. * @param {unknown} list @@ -501,7 +503,9 @@ export function cockpitSubjectHudView(raw) { * Provider methods are synchronous: the Cockpit calls them on its render * cadence and uses the answer at once. One that returns a promise (an async * function, say) breaks that contract: the promise is refused as a throw - * would be (its rejection, if any, is swallowed), with one warning. + * would be (its rejection, if any, is swallowed), with one warning, and + * `onSettled`, when given, runs once the promise has settled, so a caller + * can undo work that lands late. * @param {{warn?: (message: string, error: unknown) => void}} [options] */ export function createCockpitSubjectCaller({ @@ -517,7 +521,13 @@ export function createCockpitSubjectCaller({ error, ); }; - return function call(provider, method, args = [], fallback = null) { + return function call( + provider, + method, + args = [], + fallback = null, + onSettled = null, + ) { let result; try { result = provider[method](...args); @@ -530,7 +540,11 @@ export function createCockpitSubjectCaller({ (typeof result === 'object' || typeof result === 'function') && typeof result.then === 'function' ) { - Promise.resolve(result).catch(() => {}); + // Its work may still land: `onSettled` runs once it has, either way. + const settled = () => { + if (typeof onSettled === 'function') onSettled(); + }; + Promise.resolve(result).then(settled, settled); warnOnce( provider, method, diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index 56078b37b..fe85d496e 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -17,8 +17,9 @@ import { displayMotion } from '../transit/movement.js'; * view that waits for it holds it again if the feed lists it again. * `focusTrain(key)` selects a train and hands it the camera, as a click does. * A snapshot's `selectedAtMs` is when the user last selected the selected - * train, even again (null for any other), for a view's entry to tell a - * re-selection. + * train (a click, or `focusTrain`), even again, for a view's entry to tell a + * re-selection. It is null for any other train, after a deselection, and for + * a train selected only by a hold: a hold is not a user's selection. * * @param {object} context The layer's state, parts, services and timetable. * @returns {{methods: object}} @@ -181,7 +182,11 @@ export function createRailCockpitSubject({ } const entry = state._trains.get(key); if (!entry) return false; - if (state._selectedKey !== key) parts.selection.selectTrain(key); + if (state._selectedKey !== key) { + parts.selection.selectTrain(key); + // A hold is not a selection the user made (selectedAtMs). + state._selectedAtMs = null; + } state._cockpitSubjectKey = key; state._cockpitLostFeedId = null; if (previous && previous !== entry) parts.rendering.paint(previous); diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs index d04c62277..ad4af5061 100644 --- a/src/layers/rail/cockpitSubject.test.mjs +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -196,6 +196,36 @@ test('a held train its feed drops leaves the feed polled until the view lets go' assert.equal(app.state()._trains.size, 0); }); +test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.focusTrain(T1), true); + const first = app.layer.getSelectedTrain().selectedAtMs; + assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it'); + // Selected again: a later stamp. + app.run(1000); + app.layer.focusTrain(T1); + assert.ok(app.layer.getSelectedTrain().selectedAtMs > first); + // A hold of another train is not the user's selection. + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().key, T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + // Nor after a deselection (Escape), then a hold. + app.layer.focusTrain(T1); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null, 'deselection clears it'); + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + // Releasing twice is harmless: the selection stays. + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.state()._selectedKey, T2); + assert.equal(app.layer.getCockpitSubject(), null); +}); + test('turning the layer off ends the hold', async (t) => { const app = await onTheLine(t); app.layer.setCockpitSubject(T1); diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 8818ae195..c1499f0a8 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -127,6 +127,7 @@ export function createSelection({ state, services, parts }) { const hadSelection = state._selectedKey !== null; parts.follow.release({ untrack }); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; if (hadSelection) { parts.rendering.paintAll(); diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js index 93927c63f..3061ef216 100644 --- a/src/layers/transit/cockpitSubject.js +++ b/src/layers/transit/cockpitSubject.js @@ -17,8 +17,10 @@ import { displayMotion } from './movement.js'; * change the selection. The hold ends with `setCockpitSubject(null)`, and on * its own when the vehicle is removed or the layer is turned off. * `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's - * `selectedAtMs` is when the user last selected the selected vehicle, even - * again (null for any other). + * `selectedAtMs` is when the user last selected the selected vehicle (a + * click, or `focusVehicle`), even again. It is null for any other vehicle, + * after a deselection, and for a vehicle selected only by a hold: a hold is + * not a user's selection. * * @param {object} context The layer's state, parts and services. * @returns {{methods: object}} @@ -165,7 +167,7 @@ export function createTransitCockpitSubject({ state, parts, services }) { */ focusVehicle(key) { if (!state._vehicles.get(key)?.marker) return false; - parts.selection.selectVehicle(key); + parts.selection.selectVehicle(key, { byUser: true }); return true; }, }; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index 7467f65d7..b17ab026b 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -4196,6 +4196,32 @@ test('the selected vehicle reads as a snapshot of its place, motion and source', assert.ok(Number.isFinite(view.position.latitude)); }); +test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + // A vehicle only a hold selected reports none. + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless'); + assert.equal(app.state()._selectedKey, key); + // The user's selection stamps it, again on a re-selection. + assert.equal(app.layer.focusVehicle(key), true); + const first = app.layer.getSelectedVehicle().selectedAtMs; + assert.ok(Number.isFinite(first)); + app.run(1000); + app.layer.focusVehicle(key); + assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first); + // Deselected, then held: none. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null); + app.layer.setCockpitSubject(key); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); +}); + test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => { const app = harness(t); const key = await busRunningNorth(app); diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index cc9098cbb..6b9ea95d8 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -97,6 +97,7 @@ export function createSelection({ state, services, parts }) { : null; parts.trails.clear(); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; state._detectRevision += 1; // Cleared first: the styling path reads the selection from state. @@ -104,12 +105,18 @@ export function createSelection({ state, services, parts }) { state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); } - function selectVehicle(key) { + /** + * Select a vehicle. `byUser` marks a selection the user made (a click, or + * focusVehicle), even of the selected vehicle again: its time is the + * snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold + * selects without it. + */ + function selectVehicle(key, { byUser = false } = {}) { clearSelection(); const entry = state._vehicles.get(key); if (!entry?.marker) return; state._selectedKey = key; - state._selectedAtMs = Date.now(); + if (byUser) state._selectedAtMs = Date.now(); state._detectRevision += 1; parts.rendering.paintSelected(entry, true); parts.trails.select(entry); @@ -190,11 +197,11 @@ export function createSelection({ state, services, parts }) { typeof primitiveId === 'string' && state._vehicles.has(primitiveId) ) { - selectVehicle(primitiveId); + selectVehicle(primitiveId, { byUser: true }); return; } if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) { - selectVehicle(picked.id); + selectVehicle(picked.id, { byUser: true }); return; } } diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index 847efa429..24ed7f60d 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -349,12 +349,33 @@ function updateSubject(nowMs) { } if (!Number.isFinite(heightM)) { // Nothing to stand on yet, from the first frame: the camera stays where - // it is while the floor is acquired, as the built-in views wait for the - // surface, rather than standing on the ellipsoid (kilometres underground - // on a plateau). + // it is while the floor is acquired, its HUD saying so, rather than + // standing on the ellipsoid (kilometres underground on a plateau). The + // wait is bounded as the built-in views' is. Unlike a parked aircraft, + // whose fallback is its own reported altitude, such a subject has no + // height at all to fall back to: any number would be a guess, so the + // view ends and the provider gets its subject back. + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if ( + cockpitSurfaceWaitExpired( + nowMs, + this.surfaceWaitStartedMs, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, + ) + ) { + this.exit({ restoreTracking: false }); + return; + } this.surfaceAcquiring = true; + if ( + cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS) + ) { + this.lastHudUpdateMs = nowMs; + this.updateHud(this.subjectInfo(frame), nowMs); + } return; } + this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.subjectHeightM = heightM; const target = Cesium.Cartesian3.fromDegrees( diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index e4a98de1a..57d0a418e 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -183,9 +183,12 @@ function updateSubjectHud(info, nowMs, forceContext) { view.trackSource = null; view.kicker = hud.kicker || provider.labels.kicker; view.callsign = hud.title || info.name || ''; - view.meta.text = hud.meta.text; - view.meta.tone = hud.meta.tone; - view.meta.emergency = hud.meta.tone === 'alert'; + // While the floor under a subject with no height is acquired + // (cockpitCamera.js), the meta line says so in place of the provider's. + const acquiring = this.surfaceAcquiring === true; + view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text; + view.meta.tone = acquiring ? 'warning' : hud.meta.tone; + view.meta.emergency = !acquiring && hud.meta.tone === 'alert'; const speed = hud.speed?.value ?? null; view.speed = hud.speed ? { diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index 8fcc1ee10..53c870d47 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -15,6 +15,7 @@ import CockpitView from './views/CockpitView.vue'; import ContextView from './views/ContextView.vue'; import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; import { getLocale, setLocale } from '../i18n/index.js'; +import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; const LAT = 47; @@ -528,18 +529,28 @@ test('a frame with no height and a cold floor never stands the eye on the ellips const clock = manualClock(t, 10_000); const ride = subjectProvider(); ride.subject.frame = { ...ride.subject.frame, heightM: null }; - const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); - // Nothing to stand on from the first frame: the camera waits where it is. + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + // Nothing to stand on from the first frame: the camera waits where it is, + // its HUD saying so and still read from the provider. setFloor(undefined); cockpit.syncEntry(); assert.equal(cockpit.enter(), true); const before = shots.length; + const huds = () => ride.calls.filter(([name]) => name === 'hud').length; + const hudsBefore = huds(); for (let i = 0; i < 5; i += 1) { - clock.advance(16); + clock.advance(200); cockpit.update(); } + await settle(); assert.equal(shots.length, before, 'no camera placed'); assert.equal(cockpit.active, true, 'the view waits'); + assert.ok(huds() > hudsBefore, 'the HUD is not frozen'); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + assert.equal( + $('#cockpit-aircraft-meta').getAttribute('data-tone'), + 'warning', + ); // The floor arrives: the eye stands on it. setFloor(FLOOR_M); clock.advance(16); @@ -561,6 +572,60 @@ test('a frame with no height and a cold floor never stands the eye on the ellips assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); }); +test('a floor that never comes ends the view after the bounded wait, releasing the subject', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + // Acquiring, the HUD updating, for just under the wait... + for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) { + clock.advance(250); + elapsed += 250; + cockpit.update(); + } + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + // ...then no guessed height: the view ends and the subject goes back. + clock.advance(500); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(shots.length, before, 'never placed on a guess'); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); +}); + +test('an asynchronous hold that lands after entry was refused is released again', async (t) => { + let heldId = null; + const releases = []; + const ride = subjectProvider({ + hold: async (id) => { + await Promise.resolve(); + heldId = id; + return true; + }, + release: (id, options) => { + releases.push([id, options.restore]); + heldId = null; + }, + }); + const { cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, 'refused at once'); + assert.deepEqual(releases, [['a1', false]]); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(heldId, null, 'the late hold is let go'); + assert.deepEqual(releases, [ + ['a1', false], + ['a1', false], + ]); + assert.equal(cockpit.active, false); + assert.match(warned[0], /hold\(\) returned a promise/); +}); + test('a provider’s labels, entry face and look follow a change of language', async (t) => { t.after(() => setLocale('en')); setLocale('en'); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 0b50f6343..9f2a6129f 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -399,10 +399,29 @@ function enterSubject(provider) { if (!candidate || !this.isSubjectEntryAllowed(provider, candidate)) return false; const failed = {}; - const held = this.callSubject(provider, 'hold', [candidate.id], failed); + const release = () => + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + // An asynchronous hold may land after it was refused: it is released + // again once it settles, unless this subject is being ridden by then. + const releaseLate = () => { + if ( + this.active && + this.subjectProvider === provider && + this.subjectId === candidate.id + ) + return; + release(); + }; + const held = this.callSubject( + provider, + 'hold', + [candidate.id], + failed, + releaseLate, + ); if (held === failed) { // A hold that threw (or broke the contract) may have half happened. - this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + release(); return false; } if (held !== true) return false;