fix(cockpit): entry to the built-in views is as before: aircraft, satellite and vessel keep their fixed order whenever each was selected, and an application's subject takes entry only when it was selected after every built-in one present, so without providers nothing changes

This commit is contained in:
Denis Groshev committed 2026-11-12 16:58:38 +01:00
1 parent 6fbc98952f
commit a4f321a9ef
6 files changed
+119 -40

No files matched your search

+4 -3
View File
@@ -6,6 +6,7 @@
under reduced motion) and a bottom progress line. The Rail and Transit 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 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 ridden, and `./layers/rail/paths` is exported; `docs/APPLICATION.md` shows
a minimal provider. GEV itself registers none. Cockpit entry now goes to a minimal provider. GEV itself registers none, and entry to the built-in
the most recently selected subject, so a vessel selected after an aircraft aircraft, satellite and vessel views is unchanged; a provider's subject
is tracked enters BRIDGE (Denis Groshev, @ZloyS, #1686). takes entry when it was selected after them (Denis Groshev, @ZloyS,
#1686).
+14 -13
View File
@@ -1696,9 +1696,7 @@ and portal cleanup follows that restoration.
### Ship bridge view ### Ship bridge view
Cockpit can also ride the selected AIS vessel; when other subjects are With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
selected too, entry takes the most recently selected one (see Application
subjects below).
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a 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 selected vessel; Contacts and every other layer may be off, before or during
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode 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 layer may be off. A Context mode change in flight blocks entry until it
settles. Debris (rocket bodies, fragments and dead satellites; see the settles. Debris (rocket bodies, fragments and dead satellites; see the
satellite class toggles) can be tracked but never entered: no ORBIT entry is 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 offered and double-click refuses it. A tracked aircraft still takes
tracked or selected, entry takes the most recently selected subject (see entry first, and a tracked satellite
Application subjects below). Double-clicking the tracked satellite takes it before a selected vessel. Double-clicking the tracked satellite
enters it, and the entry button reads enters it, and the entry button reads
ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel ORBIT with a satellite icon in Contacts (`#cockpit-entry`) and under the Intel
HUD readout (`#cockpit-entry-readout`). Voice (`control_cockpit`) stays 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 `isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with
the reason, while a provider's view is in use. the reason, while a provider's view is in use.
Entry rides the most recently selected subject of all kinds: a tracked The built-in kinds keep their fixed entry order (a tracked aircraft, then the
aircraft, the tracked satellite, the selected vessel or a provider's tracked satellite, then the selected vessel), so without providers entry is
candidate, by when the Cockpit first saw it, or saw it selected again (a unchanged. A provider's candidate takes entry only when it was selected after
provider candidate's `selectedAt` changing); subjects first seen together keep every built-in subject present, by when the Cockpit first saw it or saw it
the fixed order (aircraft, satellite, vessel, then providers by priority). The selected again (its `selectedAt` changing): a train picked after an aircraft
newest decides alone: when its gate is shut there is no entry rather than an was tracked takes entry, and an aircraft tracked after it takes it back.
older subject's. 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 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 frame gives none, or where it last stood while that floor is cold; with
+2 -2
View File
@@ -273,8 +273,8 @@ export function cockpitSubjectProviders(
* A provider's candidate: `{id, name, face, selectedAt}`, or null without a * 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; * 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 * `selectedAt` (a number or a short string, else null) is a token the
* provider changes each time its subject is selected, even again: entry * provider changes each time its subject is selected, even again: a
* takes the most recently selected subject (entryCandidates). * subject selected after the built-in ones takes entry (entryCandidates).
* @param {unknown} raw What `candidate()` returned. * @param {unknown} raw What `candidate()` returned.
*/ */
export function cockpitSubjectCandidate(raw) { export function cockpitSubjectCandidate(raw) {
+3 -3
View File
@@ -603,7 +603,7 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => {
assert.match(readoutEntry.textContent, /BRIDGE/); 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, { const { $, cockpit, store, selectVessel } = fixture(t, {
enabled: ['flights', 'ais-live-vessels'], enabled: ['flights', 'ais-live-vessels'],
tracked: { layerId: 'flights', info: CIVIL }, tracked: { layerId: 'flights', info: CIVIL },
@@ -614,8 +614,8 @@ test('without providers too, the later selection takes entry: a vessel after an
selectVessel(VESSEL); selectVessel(VESSEL);
cockpit.syncEntry(); cockpit.syncEntry();
await nextTick(); await nextTick();
assert.equal(store.presence.entryKind, 'vessel'); assert.equal(store.presence.entryKind, 'aircraft');
assert.match($('#cockpit-entry-readout').textContent, /BRIDGE/); assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/);
// And the other way round: an aircraft tracked after the vessel. // And the other way round: an aircraft tracked after the vessel.
const later = fixture(t, { const later = fixture(t, {
enabled: ['flights', 'ais-live-vessels'], enabled: ['flights', 'ais-live-vessels'],
+61 -1
View File
@@ -158,6 +158,7 @@ function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) {
trackedEntityChanged: listeners(), trackedEntityChanged: listeners(),
}; };
let vesselSelected = vessel; let vesselSelected = vessel;
let trackedAircraftInfo = null;
let floorM = FLOOR_M; let floorM = FLOOR_M;
const warned = []; const warned = [];
t.mock.method(console, 'warn', (message) => warned.push(String(message))); 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 dropped = [];
const cockpit = new CockpitViewController(viewer, { const cockpit = new CockpitViewController(viewer, {
services: { services: {
flightsLayer: { getTrackedInfo: () => null }, flightsLayer: { getTrackedInfo: () => trackedAircraftInfo },
militaryFlightsLayer: { getTrackedInfo: () => null }, militaryFlightsLayer: { getTrackedInfo: () => null },
aisLiveVesselsLayer: { aisLiveVesselsLayer: {
getCockpitSubject: () => getCockpitSubject: () =>
@@ -235,6 +236,11 @@ function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) {
selectVessel(record) { selectVessel(record) {
vesselSelected = 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) { setFloor(value) {
floorM = value; floorM = value;
}, },
@@ -283,6 +289,15 @@ const VESSEL = Object.freeze({
missedRefreshes: 0, 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) => { test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => {
const ride = subjectProvider(); const ride = subjectProvider();
const { $, cockpit, store } = fixture(t, { providers: [ride] }); 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); 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) => { test('a provider view with look turns with the arrow keys and words its readout', async (t) => {
const clock = manualClock(t, 10_000); const clock = manualClock(t, 10_000);
const ride = subjectProvider(); const ride = subjectProvider();
+35 -18
View File
@@ -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({ const BUILT_IN_ENTRY_ORDER = Object.freeze({
aircraft: 0, aircraft: 0,
satellite: 1, 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 * Every subject entry could ride now, in entry order: a tracked aircraft,
* tracked aircraft, the tracked satellite, the selected vessel and each * the tracked satellite, the selected vessel and each provider's candidate.
* provider's candidate. A candidate's time is when this controller first saw * The built-in kinds keep their fixed order (aircraft, satellite, vessel),
* it (it looks on its entry cadence and on tracking changes), or saw it * whenever each was selected. A provider's candidate goes ahead of them only
* selected again: a provider candidate whose `selectedAt` changes, the same * when it was selected after every built-in one present: a train picked
* subject clicked again, moves to the front. Candidates first seen together * after an aircraft was tracked takes entry, an aircraft tracked after it
* keep the fixed order (aircraft, satellite, vessel, then providers by * takes it back. Providers' candidates are ordered most recently selected
* priority). Each carries `allowed()`, its kind's entry gate. * 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] * @param {number} [nowMs]
* @returns {Array<{kind: string, id: string, seenAtMs: number, order: number, * @returns {Array<{kind: string, id: string, seenAtMs: number, order: number,
* allowed: () => boolean, provider?: object, candidate?: object}>} * 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. // A subject gone and back counts as selected again.
for (const kind of [...seen.keys()]) for (const kind of [...seen.keys()])
if (!list.some((entry) => entry.kind === kind)) seen.delete(kind); 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 * The subject entry would ride: the first in entry order (entryCandidates).
* alone, so when its own gate is shut entry is unavailable rather than taken * It decides alone, so when its own gate is shut entry is unavailable rather
* by an older selection. * than taken by the next one.
* @returns {object|null} An entry of entryCandidates, or null. * @returns {object|null} An entry of entryCandidates, or null.
*/ */
export function resolveEntry(nowMs = performance.now()) { export function resolveEntry(nowMs = performance.now()) {
@@ -199,9 +216,9 @@ export function readSubjectLayerId() {
} }
/** /**
* The kind entry would take: the most recently selected subject's * The kind entry would take, the first in entry order's (resolveEntry): a
* (resolveEntry): a Cesium-tracked aircraft, the tracked satellite, the * Cesium-tracked aircraft, the tracked satellite, the selected vessel or a
* selected vessel or a provider's candidate. * provider's candidate.
* @returns {string|null} * @returns {string|null}
*/ */
export function resolveEntryKind() { export function resolveEntryKind() {
@@ -296,7 +313,7 @@ export function syncEntry() {
const candidates = this.entryCandidates(performance.now()); const candidates = this.entryCandidates(performance.now());
const tracked = candidates.some(({ kind }) => kind === 'aircraft'); const tracked = candidates.some(({ kind }) => kind === 'aircraft');
this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null); 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 chosen = candidates[0] ?? null;
const entryKind = chosen?.allowed() ? chosen.kind : null; const entryKind = chosen?.allowed() ? chosen.kind : null;
const face = 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 * tracked aircraft, the tracked satellite's orbital view, the selected
* vessel's bridge or an application provider's subject. * vessel's bridge or an application provider's subject.
* @param {{kind?: string|null}} [options] Restrict entry to one subject kind; * @param {{kind?: string|null}} [options] Restrict entry to one subject kind;