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
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).
+14 -13
View File
@@ -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
+2 -2
View File
@@ -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) {
+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/);
});
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'],
+61 -1
View File
@@ -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();
+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({
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;