diff --git a/changelog.d/1686-cockpit-subjects.md b/changelog.d/1686-cockpit-subjects.md index 9390225fb..fedbadb17 100644 --- a/changelog.d/1686-cockpit-subjects.md +++ b/changelog.d/1686-cockpit-subjects.md @@ -6,6 +6,7 @@ under reduced motion) and a bottom progress line. The Rail and Transit layers expose read and hold APIs so a selected train or vehicle can be ridden, and `./layers/rail/paths` is exported; `docs/APPLICATION.md` shows - a minimal provider. GEV itself registers none. Cockpit entry now goes to - the most recently selected subject, so a vessel selected after an aircraft - is tracked enters BRIDGE (Denis Groshev, @ZloyS, #1686). + a minimal provider. GEV itself registers none, and entry to the built-in + aircraft, satellite and vessel views is unchanged; a provider's subject + takes entry when it was selected after them (Denis Groshev, @ZloyS, + #1686). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 88f968ff8..1e0cd5e06 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1696,9 +1696,7 @@ and portal cleanup follows that restoration. ### Ship bridge view -Cockpit can also ride the selected AIS vessel; when other subjects are -selected too, entry takes the most recently selected one (see Application -subjects below). +With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a selected vessel; Contacts and every other layer may be off, before or during the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode @@ -1784,9 +1782,9 @@ satellite instead. Entry needs only the Satellites layer layer may be off. A Context mode change in flight blocks entry until it settles. Debris (rocket bodies, fragments and dead satellites; see the satellite class toggles) can be tracked but never entered: no ORBIT entry is -offered and double-click refuses it. When an aircraft or a vessel is also -tracked or selected, entry takes the most recently selected subject (see -Application subjects below). Double-clicking the tracked satellite +offered and double-click refuses it. A tracked aircraft still takes +entry first, and a tracked satellite +takes it before a selected vessel. Double-clicking the tracked satellite enters it, and the entry button reads ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays @@ -2001,13 +1999,16 @@ layer on, no Context change in flight and the provider's own `isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with the reason, while a provider's view is in use. -Entry rides the most recently selected subject of all kinds: a tracked -aircraft, the tracked satellite, the selected vessel or a provider's -candidate, by when the Cockpit first saw it, or saw it selected again (a -provider candidate's `selectedAt` changing); subjects first seen together keep -the fixed order (aircraft, satellite, vessel, then providers by priority). The -newest decides alone: when its gate is shut there is no entry rather than an -older subject's. +The built-in kinds keep their fixed entry order (a tracked aircraft, then the +tracked satellite, then the selected vessel), so without providers entry is +unchanged. A provider's candidate takes entry only when it was selected after +every built-in subject present, by when the Cockpit first saw it or saw it +selected again (its `selectedAt` changing): a train picked after an aircraft +was tracked takes entry, and an aircraft tracked after it takes it back. +Providers' candidates are ordered most recently selected first, then by +priority; one first seen together with a built-in subject comes after it. The +first in that order decides alone: when its gate is shut there is no entry +rather than the next 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 diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js index bc58d8d6f..a8620bea4 100644 --- a/src/cockpitSubjectProviders.js +++ b/src/cockpitSubjectProviders.js @@ -273,8 +273,8 @@ export function cockpitSubjectProviders( * A provider's candidate: `{id, name, face, selectedAt}`, or null without a * usable id. `name` falls back to the id; `face` is null unless well formed; * `selectedAt` (a number or a short string, else null) is a token the - * provider changes each time its subject is selected, even again: entry - * takes the most recently selected subject (entryCandidates). + * provider changes each time its subject is selected, even again: a + * subject selected after the built-in ones takes entry (entryCandidates). * @param {unknown} raw What `candidate()` returned. */ export function cockpitSubjectCandidate(raw) { diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index 25a38bcb2..8abebe9e9 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -603,7 +603,7 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => { assert.match(readoutEntry.textContent, /BRIDGE/); }); -test('without providers too, the later selection takes entry: a vessel after an aircraft reads BRIDGE', async (t) => { +test('without providers the built-in order decides: a vessel selected after an aircraft still reads COCKPIT', async (t) => { const { $, cockpit, store, selectVessel } = fixture(t, { enabled: ['flights', 'ais-live-vessels'], tracked: { layerId: 'flights', info: CIVIL }, @@ -614,8 +614,8 @@ test('without providers too, the later selection takes entry: a vessel after an selectVessel(VESSEL); cockpit.syncEntry(); await nextTick(); - assert.equal(store.presence.entryKind, 'vessel'); - assert.match($('#cockpit-entry-readout').textContent, /BRIDGE/); + assert.equal(store.presence.entryKind, 'aircraft'); + assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/); // And the other way round: an aircraft tracked after the vessel. const later = fixture(t, { enabled: ['flights', 'ais-live-vessels'], diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index 339045af2..e20b03917 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -158,6 +158,7 @@ function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { trackedEntityChanged: listeners(), }; let vesselSelected = vessel; + let trackedAircraftInfo = null; let floorM = FLOOR_M; const warned = []; t.mock.method(console, 'warn', (message) => warned.push(String(message))); @@ -168,7 +169,7 @@ function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { const dropped = []; const cockpit = new CockpitViewController(viewer, { services: { - flightsLayer: { getTrackedInfo: () => null }, + flightsLayer: { getTrackedInfo: () => trackedAircraftInfo }, militaryFlightsLayer: { getTrackedInfo: () => null }, aisLiveVesselsLayer: { getCockpitSubject: () => @@ -235,6 +236,11 @@ function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { selectVessel(record) { vesselSelected = record; }, + /** Track the fixture's aircraft as Live Flights does, or untrack it. */ + trackAircraft(info) { + trackedAircraftInfo = info; + viewer.trackedEntity = info ? aircraft : undefined; + }, setFloor(value) { floorM = value; }, @@ -283,6 +289,15 @@ const VESSEL = Object.freeze({ missedRefreshes: 0, }); +/** The fixture's aircraft as Live Flights reports it once tracked. */ +const TRACKED_AIRCRAFT = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: LAT, + longitude: LON, +}); + test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => { const ride = subjectProvider(); const { $, cockpit, store } = fixture(t, { providers: [ride] }); @@ -869,6 +884,51 @@ test('the most recently selected subject wins entry', async (t) => { assert.equal(cockpit.enter(), false); }); +test('the built-in kinds keep their order; a subject takes entry only when selected after all of them', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, selectVessel, trackAircraft } = fixture(t, { + providers: [ride], + }); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A subject selected after the aircraft was tracked takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // A vessel selected after it: a built-in kind is newer, so the built-in + // order decides, as without providers, and the aircraft comes first. + clock.advance(500); + selectVessel(VESSEL); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The subject selected again is newer than every built-in one. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry back. + selectVessel(null); + trackAircraft(null); + cockpit.syncEntry(); + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); +}); + +test('a subject first seen together with a built-in one comes after it', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, store } = fixture(t, { providers: [ride], vessel: VESSEL }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'vessel'); + assert.equal(store.presence.entryKind, 'vessel'); +}); + test('a provider view with look turns with the arrow keys and words its readout', async (t) => { const clock = manualClock(t, 10_000); const ride = subjectProvider(); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index a71ee137e..7367c10f6 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -93,7 +93,7 @@ export function subjectInfo(frame) { }; } -/** Entry's fixed order, for candidates first seen at the same moment. */ +/** The built-in kinds' fixed entry order. */ const BUILT_IN_ENTRY_ORDER = Object.freeze({ aircraft: 0, satellite: 1, @@ -101,14 +101,19 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({ }); /** - * Every subject entry could ride now, the most recently selected first: a - * tracked aircraft, the tracked satellite, the selected vessel and each - * provider's candidate. A candidate's time is when this controller first saw - * it (it looks on its entry cadence and on tracking changes), or saw it - * selected again: a provider candidate whose `selectedAt` changes, the same - * subject clicked again, moves to the front. Candidates first seen together - * keep the fixed order (aircraft, satellite, vessel, then providers by - * priority). Each carries `allowed()`, its kind's entry gate. + * Every subject entry could ride now, in entry order: a tracked aircraft, + * the tracked satellite, the selected vessel and each provider's candidate. + * The built-in kinds keep their fixed order (aircraft, satellite, vessel), + * whenever each was selected. A provider's candidate goes ahead of them only + * when it was selected after every built-in one present: a train picked + * after an aircraft was tracked takes entry, an aircraft tracked after it + * takes it back. Providers' candidates are ordered most recently selected + * first, then by priority. A candidate's time is when this controller first + * saw it (it looks on its entry cadence and on tracking changes), or saw it + * selected again (a provider candidate whose `selectedAt` changes). One + * first seen together with a built-in candidate comes after it. With no + * provider, entry is the built-in order alone. Each carries `allowed()`, its + * kind's entry gate. * @param {number} [nowMs] * @returns {Array<{kind: string, id: string, seenAtMs: number, order: number, * allowed: () => boolean, provider?: object, candidate?: object}>} @@ -167,13 +172,25 @@ export function entryCandidates(nowMs = performance.now()) { // A subject gone and back counts as selected again. for (const kind of [...seen.keys()]) if (!list.some((entry) => entry.kind === kind)) seen.delete(kind); - return list.sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order); + const builtIns = list + .filter((entry) => !entry.provider) + .sort((a, b) => a.order - b.order); + const provided = list + .filter((entry) => entry.provider) + .sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order); + const builtInSeenAtMs = Math.max( + -Infinity, + ...builtIns.map((entry) => entry.seenAtMs), + ); + const later = provided.filter((entry) => entry.seenAtMs > builtInSeenAtMs); + const earlier = provided.filter((entry) => entry.seenAtMs <= builtInSeenAtMs); + return [...later, ...builtIns, ...earlier]; } /** - * The subject entry would ride: the most recently selected one. It decides - * alone, so when its own gate is shut entry is unavailable rather than taken - * by an older selection. + * The subject entry would ride: the first in entry order (entryCandidates). + * It decides alone, so when its own gate is shut entry is unavailable rather + * than taken by the next one. * @returns {object|null} An entry of entryCandidates, or null. */ export function resolveEntry(nowMs = performance.now()) { @@ -199,9 +216,9 @@ export function readSubjectLayerId() { } /** - * The kind entry would take: the most recently selected subject's - * (resolveEntry): a Cesium-tracked aircraft, the tracked satellite, the - * selected vessel or a provider's candidate. + * The kind entry would take, the first in entry order's (resolveEntry): a + * Cesium-tracked aircraft, the tracked satellite, the selected vessel or a + * provider's candidate. * @returns {string|null} */ export function resolveEntryKind() { @@ -296,7 +313,7 @@ export function syncEntry() { const candidates = this.entryCandidates(performance.now()); const tracked = candidates.some(({ kind }) => kind === 'aircraft'); this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null); - // The most recently selected subject decides (resolveEntry). + // The first in entry order decides alone (resolveEntry). const chosen = candidates[0] ?? null; const entryKind = chosen?.allowed() ? chosen.kind : null; const face = @@ -518,7 +535,7 @@ function enterSubject(provider) { } /** - * Enter first person on the most recently selected subject (resolveEntry): a + * Enter first person on the first subject in entry order (resolveEntry): a * tracked aircraft, the tracked satellite's orbital view, the selected * vessel's bridge or an application provider's subject. * @param {{kind?: string|null}} [options] Restrict entry to one subject kind;