fix(cockpit): application subjects follow a change of language, never stand on the ellipsoid, and are guarded on every call; a re-selected subject takes entry again

- Labels, entry face and look may be functions, read again on every use
  and on a locale change; a failing read keeps the last good value.
- A frame with no height keeps the eye's last height, or waits
  (surfaceAcquiring) until the floor is known; heights outside
  -1,000..50,000 m read as null.
- Provider methods are synchronous: a returned promise is refused like a
  throw, and a hold that fails is released.
- A candidate may carry selectedAt; Rail and Transit stamp selectedAtMs
  on every selection, so re-selecting moves a subject to the front.
- A released Transit vehicle leaves the visible set at once.
- docs/CURRENT-STATE.md states the new entry order; docs/APPLICATION.md
  documents cockpit subjects, ./i18n/locale and ./layers/rail/paths.
This commit is contained in:
Denis Groshev committed 2026-11-12 11:13:58 +01:00
1 parent 01a74fceec
commit 57d87d2bbf
18 files changed
+707 -199

No files matched your search

+44 -122
View File
@@ -47,11 +47,7 @@ test('cockpit anchor correction cannot turn a forward step into a reversal', ()
const speedMps = 200;
const dtSec = 0.1;
const forwardStepM = speedMps * dtSec;
const backwardCorrectionM = cockpitAnchorCorrectionStep(
1000,
speedMps,
dtSec,
);
const backwardCorrectionM = cockpitAnchorCorrectionStep(1000, speedMps, dtSec);
assert.ok(backwardCorrectionM > 0);
assert.ok(backwardCorrectionM < forwardStepM);
});
@@ -87,7 +83,10 @@ test('cockpit Context scope distinguishes radius-complete feeds from viewport in
});
test('cockpit Context scope preserves zero and replaces missing values intentionally', () => {
assert.equal(formatCockpitContextScope('', 0), '— · 0 KM AIR/SEA WINDOW');
assert.equal(
formatCockpitContextScope('', 0),
'— · 0 KM AIR/SEA WINDOW',
);
assert.equal(
formatCockpitContextScope(undefined, Number.NaN, {}),
'— · — KM AIR/SEA WINDOW',
@@ -224,29 +223,26 @@ test('relative bearing stays on the shortest signed arc', () => {
});
test('HUD rail layout centers between intersecting upper and lower obstacles', () => {
assert.deepEqual(
resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 200,
laneLeft: 50,
laneRight: 410,
baseTop: 280,
baseBottom: 940,
gap: 10,
obstacles: [
{ left: 40, right: 240, top: 320, bottom: 460 },
{ left: 300, right: 1000, top: 780, bottom: 930 },
{ left: 900, right: 1100, top: 300, bottom: 700 },
],
}),
{
top: 520,
maxHeight: 300,
safeTop: 470,
safeBottom: 770,
constrained: false,
},
);
assert.deepEqual(resolveHudRailLayout({
viewportHeight: 1000,
panelHeight: 200,
laneLeft: 50,
laneRight: 410,
baseTop: 280,
baseBottom: 940,
gap: 10,
obstacles: [
{ left: 40, right: 240, top: 320, bottom: 460 },
{ left: 300, right: 1000, top: 780, bottom: 930 },
{ left: 900, right: 1100, top: 300, bottom: 700 },
],
}), {
top: 520,
maxHeight: 300,
safeTop: 470,
safeBottom: 770,
constrained: false,
});
});
test('HUD rail layout constrains an oversized panel to the safe corridor', () => {
@@ -300,10 +296,7 @@ function contextSnapshot(subject, extra = {}) {
}
test('Contact panel hides only when there is no snapshot at all', () => {
const readout = resolveCockpitContextReadout({
snapshot: null,
info: TRACKED_INFO,
});
const readout = resolveCockpitContextReadout({ snapshot: null, info: TRACKED_INFO });
assert.equal(readout.visible, false);
assert.equal(readout.mode, 'standby');
assert.equal(
@@ -319,22 +312,14 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
// camera: "click next... whole left panel disappears").
for (const subject of [
{ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' },
{
layerId: 'military-installations',
id: 'fort-hood',
label: 'FORT CAVAZOS',
},
{ layerId: 'military-installations', id: 'fort-hood', label: 'FORT CAVAZOS' },
{ layerId: 'military', id: 'ae01ce', label: 'RCH451' },
]) {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot(subject),
info: TRACKED_INFO,
});
assert.equal(
readout.visible,
true,
`${subject.layerId} subject must keep the panel up`,
);
assert.equal(readout.visible, true, `${subject.layerId} subject must keep the panel up`);
assert.equal(readout.mode, 'foreign');
assert.equal(readout.subjectMatchesTracked, false);
}
@@ -342,11 +327,7 @@ test('Contact panel survives NEXT onto a vessel or installation subject', () =>
test('a foreign subject dashes the aircraft-relative fields and keeps the rest live', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({
layerId: 'ais-live-vessels',
id: '353136000',
label: 'MAERSK DETROIT',
}),
snapshot: contextSnapshot({ layerId: 'ais-live-vessels', id: '353136000', label: 'MAERSK DETROIT' }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
@@ -355,20 +336,12 @@ test('a foreign subject dashes the aircraft-relative fields and keeps the rest l
// frame while every other row value is measured from the subject; rendering
// both live would present one mixed-frame reading as a single measurement.
assert.equal(readout.aircraftRelative, false);
assert.equal(
readout.contactLost,
false,
'subject-frame values still refresh',
);
assert.equal(readout.contactLost, false, 'subject-frame values still refresh');
});
test('the tracked aircraft as subject keeps every field in its own frame', () => {
const readout = resolveCockpitContextReadout({
snapshot: contextSnapshot({
layerId: 'flights',
id: 'aaa077',
label: 'SWA1234',
}),
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077', label: 'SWA1234' }),
info: TRACKED_INFO,
});
assert.equal(readout.visible, true);
@@ -387,11 +360,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
snapshot: contextSnapshot(subject, { subjectPresent: false }),
info: TRACKED_INFO,
});
assert.equal(
readout.visible,
true,
'a lost contact must not take the panel down',
);
assert.equal(readout.visible, true, 'a lost contact must not take the panel down');
assert.equal(readout.mode, 'lost');
assert.equal(readout.contactLost, true, 'last-known values stay on screen');
}
@@ -399,10 +368,7 @@ test('a fast-culled subject keeps the panel up as CONTACT LOST instead of collap
test('an explicitly present subject is never reported lost', () => {
const present = resolveCockpitContextReadout({
snapshot: contextSnapshot(
{ layerId: 'flights', id: 'aaa077' },
{ subjectPresent: true },
),
snapshot: contextSnapshot({ layerId: 'flights', id: 'aaa077' }, { subjectPresent: true }),
info: TRACKED_INFO,
});
assert.equal(present.contactLost, false);
@@ -421,36 +387,20 @@ test('the tracked flight layer is resolved by normalized tracked identity', () =
// Both layers describe a tracked aircraft during a cross-layer handoff;
// civilian-first precedence would hand the cockpit the wrong aircraft.
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'military:ae01ce',
}).layerId,
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).layerId,
'military',
);
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'military:ae01ce',
}).icao24,
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'military:ae01ce' }).icao24,
'ae01ce',
);
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'flights:aaa077',
}).layerId,
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'flights:aaa077' }).layerId,
'flights',
);
// Case-insensitive: layers stamp lowercase hex, callers may not.
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'MILITARY:AE01CE',
}).layerId,
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'MILITARY:AE01CE' }).layerId,
'military',
);
});
@@ -460,56 +410,28 @@ test('tracked-identity resolution falls back to layer precedence', () => {
const military = { icao24: 'ae01ce' };
// No stamped identity (or one that matches neither layer) keeps the historic
// civilian-first order so tracking paths without gevTrackedId still work.
assert.equal(resolveTrackedAircraftInfo({ civilian, military }).layerId, 'flights');
assert.equal(
resolveTrackedAircraftInfo({ civilian, military }).layerId,
resolveTrackedAircraftInfo({ civilian, military, trackedId: 'satellites:25544' }).layerId,
'flights',
);
assert.equal(
resolveTrackedAircraftInfo({
civilian,
military,
trackedId: 'satellites:25544',
}).layerId,
'flights',
);
assert.equal(
resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId,
'military',
);
assert.equal(
resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }),
null,
);
assert.equal(resolveTrackedAircraftInfo({ military, trackedId: '' }).layerId, 'military');
assert.equal(resolveTrackedAircraftInfo({ trackedId: 'flights:aaa077' }), null);
assert.equal(resolveTrackedAircraftInfo(), null);
});
test('cockpit emergency wording states the broadcast code, not a conclusion', () => {
assert.equal(
formatCockpitEmergency({
label: 'General emergency',
squawk: '7700',
source: 'squawk',
severity: 'emergency',
}),
formatCockpitEmergency({ label: 'General emergency', squawk: '7700', source: 'squawk', severity: 'emergency' }),
'SQUAWK 7700 · GENERAL EMERGENCY',
);
assert.equal(
formatCockpitEmergency({
label: 'Downed aircraft',
squawk: '1200',
source: 'ads-b',
severity: 'emergency',
}),
formatCockpitEmergency({ label: 'Downed aircraft', squawk: '1200', source: 'ads-b', severity: 'emergency' }),
'ADS-B EMERGENCY · DOWNED AIRCRAFT',
'an unrelated squawk is not quoted beside an ADS-B status',
);
assert.equal(
formatCockpitEmergency({
label: 'Minimum fuel',
squawk: null,
source: 'ads-b',
severity: 'priority',
}),
formatCockpitEmergency({ label: 'Minimum fuel', squawk: null, source: 'ads-b', severity: 'priority' }),
'ADS-B PRIORITY · MINIMUM FUEL',
);
assert.equal(formatCockpitEmergency(null), null);
+131 -40
View File
@@ -30,6 +30,14 @@ export const COCKPIT_SUBJECT_TONES = Object.freeze([
'alert',
]);
/**
* The heights a frame may give (m, ellipsoidal): below the deepest dry
* ground, above anything ridden near it; outside, the frame stands on the
* floor.
*/
export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000;
export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000;
/** The most readouts, sources and signals a HUD view shows. */
export const COCKPIT_SUBJECT_MAX_READOUTS = 4;
export const COCKPIT_SUBJECT_MAX_SOURCES = 4;
@@ -77,6 +85,34 @@ function faceOf(raw) {
return Object.freeze({ icon: raw.icon, label });
}
/** The labels a provider gives, normalized, or null without the required. */
function labelsOf(raw) {
if (!isObject(raw)) return null;
const labels = {
view: cockpitSubjectText(raw.view, 48),
exit: cockpitSubjectText(raw.exit, 48),
exitText: cockpitSubjectText(raw.exitText, 24),
kicker: cockpitSubjectText(raw.kicker, 48),
};
return labels.view && labels.exit && labels.exitText
? Object.freeze(labels)
: null;
}
/**
* A provider's worded member as it reads now: the value, or what the
* function it is returns (so it can follow the page's language); undefined
* when that throws.
*/
function wordedNow(value) {
if (typeof value !== 'function') return value;
try {
return value();
} catch {
return undefined;
}
}
/** Why `provider` cannot be kept, or null. */
function refusal(provider, kinds) {
if (!isObject(provider)) return 'not an object';
@@ -96,19 +132,13 @@ function refusal(provider, kinds) {
typeof provider.isEntryAllowed !== 'function'
)
return 'isEntryAllowed is not a function';
if (!faceOf(provider.entryFace)) return 'malformed entry face';
const labels = provider.labels;
if (
!isObject(labels) ||
!cockpitSubjectText(labels.view, 48) ||
!cockpitSubjectText(labels.exit, 48) ||
!cockpitSubjectText(labels.exitText, 24)
)
if (!faceOf(wordedNow(provider.entryFace))) return 'malformed entry face';
if (!labelsOf(wordedNow(provider.labels)))
return 'labels need view, exit and exitText';
const look = wordedNow(provider.look);
if (
provider.look != null &&
(!isObject(provider.look) ||
!cockpitSubjectText(provider.look.aheadLabel, 64))
(!isObject(look) || !cockpitSubjectText(look.aheadLabel, 64))
)
return 'look needs an aheadLabel';
return null;
@@ -128,12 +158,20 @@ function refusal(provider, kinds) {
* - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as
* on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look
* ahead button named `aheadLabel`;
* `entryFace`, `labels` and `look` may each be a function returning the
* object: it is called again whenever they are shown and when the page's
* language changes, so its words can follow it (`./i18n/locale`). A read
* that throws or is malformed keeps the last good one; `look` is on or off
* and `restPitchDeg` fixed as first read;
* - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0
* updates the camera every rendered frame;
* - `headingSlewDps` (optional): how fast the view turns to a new heading,
* positive or Infinity, null for the Cockpit's default;
* - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`,
* `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`.
* `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})`.
*
* A provider that is not so is left out with one warning naming it.
* @param {unknown} list
@@ -157,28 +195,40 @@ export function cockpitSubjectProviders(
return;
}
kinds.add(provider.kind);
const look = isObject(provider.look)
? Object.freeze({
restPitchDeg: finite(provider.look.restPitchDeg)
? clamp(provider.look.restPitchDeg, -90, 90)
: 0,
aheadLabel: cockpitSubjectText(provider.look.aheadLabel, 64),
})
: null;
// Worded members are read again on each use (see above); the last good
// read stands in for a bad one.
let face = faceOf(wordedNow(provider.entryFace));
let labels = labelsOf(wordedNow(provider.labels));
const firstLook = wordedNow(provider.look);
const lookOf = (raw) =>
Object.freeze({
restPitchDeg: finite(firstLook.restPitchDeg)
? clamp(firstLook.restPitchDeg, -90, 90)
: 0,
aheadLabel: cockpitSubjectText(raw.aheadLabel, 64),
});
let look = isObject(firstLook) ? lookOf(firstLook) : null;
kept.push(
Object.freeze({
kind: provider.kind,
layerId: provider.layerId,
priority: finite(provider.priority) ? provider.priority : 0,
order,
entryFace: faceOf(provider.entryFace),
labels: Object.freeze({
view: cockpitSubjectText(provider.labels.view, 48),
exit: cockpitSubjectText(provider.labels.exit, 48),
exitText: cockpitSubjectText(provider.labels.exitText, 24),
kicker: cockpitSubjectText(provider.labels.kicker, 48),
}),
look,
get entryFace() {
face = faceOf(wordedNow(provider.entryFace)) ?? face;
return face;
},
get labels() {
labels = labelsOf(wordedNow(provider.labels)) ?? labels;
return labels;
},
get look() {
if (!look) return null;
const raw = wordedNow(provider.look);
if (isObject(raw) && cockpitSubjectText(raw.aheadLabel, 64))
look = lookOf(raw);
return look;
},
cameraIntervalMs:
finite(provider.cameraIntervalMs) &&
provider.cameraIntervalMs >= 0 &&
@@ -204,25 +254,38 @@ export function cockpitSubjectProviders(
}
/**
* A provider's candidate: `{id, name, face}`, or null without a usable id.
* `name` falls back to the id; `face` is null unless well formed.
* 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).
* @param {unknown} raw What `candidate()` returned.
*/
export function cockpitSubjectCandidate(raw) {
if (!isObject(raw) || typeof raw.id !== 'string') return null;
const id = raw.id.trim();
if (!id || id.length > 128) return null;
const selectedAt =
finite(raw.selectedAt) ||
(typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64)
? raw.selectedAt
: null;
return Object.freeze({
id,
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
face: faceOf(raw.face),
selectedAt,
});
}
/**
* One camera frame from a provider, normalized, or null when it cannot place
* the camera (the view then ends): not an object, an id other than `heldId`,
* a position that is not finite, or an eye with no valid `upM`.
* a position that is not finite, or an eye with no valid `upM` (0..1000).
* A `heightM` that is not finite or lies outside
* COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null:
* the eye then stands on the shared floor, or where it last stood while that
* is cold.
* @param {unknown} raw What `read()` returned.
* @param {string} heldId The subject the view holds.
* @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg,
@@ -245,7 +308,12 @@ export function cockpitSubjectFrame(raw, heldId) {
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64),
latitude,
longitude: ((((longitude + 180) % 360) + 360) % 360) - 180,
heightM: finite(raw.heightM) ? raw.heightM : null,
heightM:
finite(raw.heightM) &&
raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M &&
raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M
? raw.heightM
: null,
headingDeg,
courseDeg: heading(raw.courseDeg) ?? headingDeg,
speedMps: finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null,
@@ -429,25 +497,48 @@ export function cockpitSubjectHudView(raw) {
* A guarded caller for provider methods: `call(provider, method, args,
* fallback)` returns what the method returns, or `fallback` when it throws,
* warning once per provider and method.
*
* 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.
* @param {{warn?: (message: string, error: unknown) => void}} [options]
*/
export function createCockpitSubjectCaller({
warn = (message, error) => console.warn(message, error),
} = {}) {
const warned = new Set();
const warnOnce = (provider, method, message, error) => {
const key = `${provider.kind}:${method}`;
if (warned.has(key)) return;
warned.add(key);
warn(
`[Cockpit] subject provider ${provider.kind} ${method}() ${message}`,
error,
);
};
return function call(provider, method, args = [], fallback = null) {
let result;
try {
return provider[method](...args);
result = provider[method](...args);
} catch (error) {
const key = `${provider.kind}:${method}`;
if (!warned.has(key)) {
warned.add(key);
warn(
`[Cockpit] subject provider ${provider.kind} ${method}() failed`,
error,
);
}
warnOnce(provider, method, 'failed', error);
return fallback;
}
if (
result !== null &&
(typeof result === 'object' || typeof result === 'function') &&
typeof result.then === 'function'
) {
Promise.resolve(result).catch(() => {});
warnOnce(
provider,
method,
'returned a promise; provider methods are synchronous',
null,
);
return fallback;
}
return result;
};
}
+88
View File
@@ -147,7 +147,22 @@ test('a candidate needs an id; its name and face are read with care', () => {
id: 'a1',
name: 'a1',
face: null,
selectedAt: null,
});
// A selection token is a number or a short string.
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt,
5,
);
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt,
'click-2',
);
for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)])
assert.equal(
cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt,
null,
);
const read = cockpitSubjectCandidate({
id: 'a1',
name: ' Example 12 ',
@@ -213,6 +228,13 @@ test('a frame is the held subject at a finite place with an eye height, else nul
);
assert.equal(full.longitude, -170);
assert.equal(full.heightM, 412.5);
// A height outside any plausible ground reads as none.
for (const heightM of [-6_400_000, 1e9, Infinity])
assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null);
assert.equal(
cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM,
-400,
);
assert.equal(full.headingDeg, 350);
assert.equal(full.courseDeg, 5);
assert.equal(full.speedMps, 22);
@@ -385,3 +407,69 @@ test('a provider method that throws returns the fallback and warns once', () =>
assert.equal(warnings.length, 1);
assert.match(warnings[0], /example-kind read\(\) failed/);
});
test('a provider method that returns a promise is refused as a throw is, warning once', async () => {
const warnings = [];
const call = createCockpitSubjectCaller({
warn: (message) => warnings.push(message),
});
const asynchronous = {
kind: 'example-kind',
hold: async () => true,
read: () => Promise.reject(new Error('late')),
};
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused');
assert.equal(call(asynchronous, 'read', [], null), null);
assert.equal(warnings.length, 2, 'once per method');
assert.match(warnings[0], /hold\(\) returned a promise/);
// The rejected promise is not left unhandled.
await new Promise((resolve) => setTimeout(resolve, 0));
});
test('labels, entry face and look given as functions are read again on each use', () => {
let lang = 'en';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' },
};
const { kept, warnings } = keep([
provider({
labels: () => words[lang],
entryFace: () => ({
icon: 'commute',
label: { en: 'RIDE', de: 'FAHRT' }[lang],
}),
look: () => ({
restPitchDeg: lang === 'de' ? 40 : -3,
aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead',
}),
}),
]);
assert.deepEqual(warnings, []);
const [ride] = kept;
assert.equal(ride.labels.exitText, 'EXIT RIDE');
assert.equal(ride.entryFace.label, 'RIDE');
assert.equal(ride.look.aheadLabel, 'Look ahead');
lang = 'de';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
assert.equal(ride.look.aheadLabel, 'Nach vorn');
assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read');
// A read that fails or is malformed keeps the last good one.
lang = 'xx';
assert.equal(ride.labels.exitText, 'VERLASSEN');
assert.equal(ride.entryFace.label, 'FAHRT');
// Functions that are malformed from the start leave the provider out.
const bad = keep([
provider({ kind: 'bad-labels', labels: () => null }),
provider({
kind: 'thrown-face',
entryFace: () => {
throw new Error('no face');
},
}),
]);
assert.deepEqual(bad.kept, []);
assert.equal(bad.warnings.length, 2);
});
+7 -2
View File
@@ -14,8 +14,11 @@ import { displayMotion } from '../transit/movement.js';
* selection. The hold ends with `setCockpitSubject(null)`, and on its own
* when the train is removed or the layer is turned off. A train removed while
* held leaves its feed polled until the next `setCockpitSubject` call, so a
* 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.
* 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.
*
* @param {object} context The layer's state, parts, services and timetable.
* @returns {{methods: object}}
@@ -98,6 +101,8 @@ export function createRailCockpitSubject({
return Object.freeze({
key: entry.key,
feedId: entry.feedId,
selectedAtMs:
state._selectedKey === entry.key ? state._selectedAtMs : null,
id: String(record.id ?? ''),
tripId: record.tripId || null,
routeId: record.routeId || null,
+2
View File
@@ -79,6 +79,8 @@ function createState({ services }) {
_stopAnchors: new Map(),
_categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY },
_selectedKey: null,
/** When the user last selected a train, even the selected one (ms). */
_selectedAtMs: null,
_selectedCardAt: 0,
_selectedCardText: null,
/** The camera's follow of the selected train (follow.js), or null. */
+2
View File
@@ -108,6 +108,8 @@ export function createSelection({ state, services, parts }) {
*/
function selectAndFocusTrain(key) {
if (!state._trains.has(key)) return;
// A selection by the user, even of the selected train (cockpitSubject.js).
state._selectedAtMs = Date.now();
const following = parts.follow.isFollowing(key);
// Another train's follow lets go first, so this is never taken for a
// move between two copies of one train.
+23 -10
View File
@@ -1,5 +1,7 @@
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
import { transitRouteLabel } from '../../data/transitFeeds.js';
import {
getRegisteredTransitFeed,
transitRouteLabel,
} from '../../data/transitFeeds.js';
import { displayMotion } from './movement.js';
/**
@@ -14,7 +16,9 @@ import { displayMotion } from './movement.js';
* camera's view, its feed stays polled, and clicks and Escape no longer
* 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.
* `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).
*
* @param {object} context The layer's state, parts and services.
* @returns {{methods: object}}
@@ -26,9 +30,12 @@ export function createTransitCockpitSubject({ state, parts, services }) {
const finiteOrNull = (value) =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
function feedOf(feedId) {
const feed =
state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
/** The feed in use, else the registry's entry for it. */
function feedEntry(feedId) {
return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
}
function feedOf(feed) {
if (!feed) return null;
return Object.freeze({
id: feed.id,
@@ -46,9 +53,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
if (!entry?.record) return null;
const record = entry.record;
const sample = entry.sample ?? null;
const feed =
state._activeFeeds.get(entry.feedId) ||
getRegisteredTransitFeed(entry.feedId);
const feed = feedEntry(entry.feedId);
const reported = finiteOrNull(record.speedMps);
const derived = finiteOrNull(sample?.segmentSpeedMps);
const status = state._feedStatus.get(entry.feedId);
@@ -57,6 +62,8 @@ export function createTransitCockpitSubject({ state, parts, services }) {
return Object.freeze({
key: entry.key,
feedId: entry.feedId,
selectedAtMs:
state._selectedKey === entry.key ? state._selectedAtMs : null,
id: String(record.id ?? ''),
routeId: record.routeId || null,
routeLabel:
@@ -68,7 +75,7 @@ export function createTransitCockpitSubject({ state, parts, services }) {
label: record.label || null,
mode: entry.mode ?? null,
modeInferred: entry.modeInferred === true,
feed: feedOf(entry.feedId),
feed: feedOf(feed),
position: Object.freeze({
latitude,
longitude,
@@ -131,6 +138,12 @@ export function createTransitCockpitSubject({ state, parts, services }) {
const previous = state._vehicles.get(state._cockpitSubjectKey);
if (key === null || key === undefined) {
state._cockpitSubjectKey = null;
// The hold kept it in play out of view: it leaves until the next
// visibility sweep finds it in view again.
if (previous) {
state._visible.delete(previous);
parts.rendering.requestVisibility();
}
repaint(previous);
parts.selection.refreshSelectedCard(true);
return true;
+3
View File
@@ -4235,12 +4235,15 @@ test('a held vehicle hides, keeps moving out of view, and lets go when it goes',
assert.equal(app.layer.setCockpitSubject(null), true);
assert.equal(app.layer.getCockpitSubject(), null);
// Let go, it is out of play until a sweep finds it in view.
assert.equal(app.state()._visible.has(entry), false, 'out of play');
app.lookAt({
west: -71.2,
south: 42.2,
east: -70.9,
north: 42.5,
});
app.advance(250);
app.settle();
assert.equal(entry.marker.show, true, 'shown again');
assert.equal(app.layer.focusVehicle('mbta:nope'), false);
+1
View File
@@ -109,6 +109,7 @@ export function createSelection({ state, services, parts }) {
const entry = state._vehicles.get(key);
if (!entry?.marker) return;
state._selectedKey = key;
state._selectedAtMs = Date.now();
state._detectRevision += 1;
parts.rendering.paintSelected(entry, true);
parts.trails.select(entry);
+2
View File
@@ -95,6 +95,8 @@ export function createState({ services }) {
/** @type {string|null} */
state._selectedKey = null;
/** When the user last selected a vehicle, even the selected one (ms). */
state._selectedAtMs = null;
/**
* The vehicle a first-person view holds (cockpitSubject.js), or null: its
* sprite, card, trail and detection box are hidden, and it keeps moving
+13 -1
View File
@@ -343,8 +343,20 @@ function updateSubject(nowMs) {
frame.latitude,
frame.longitude,
);
heightM = Number.isFinite(floorM) ? floorM : 0;
// A frame with no height stands on the shared floor; while that is cold
// it keeps the height it last stood at.
heightM = Number.isFinite(floorM) ? floorM : this.subjectHeightM;
}
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).
this.surfaceAcquiring = true;
return;
}
this.surfaceAcquiring = false;
this.subjectHeightM = heightM;
const target = Cesium.Cartesian3.fromDegrees(
frame.longitude,
frame.latitude,
+11
View File
@@ -14,6 +14,7 @@ import {
toggleTrackedTr3b,
syncTr3bToggle,
syncEntry,
refreshSubjectWords,
publishPresenceView,
navigateContext,
_adoptTrackedEntity,
@@ -185,6 +186,10 @@ export class CockpitViewController {
this.surfaceWaitStartedMs = 0;
this.surfaceAcquiring = false;
this.surfaceFallback = false;
/** The height an application subject's frame last stood at (m). */
this.subjectHeightM = null;
/** The ground clamp's eased lift under that subject's eye (m). */
this.subjectLiftM = null;
/**
* Where the bridge view is turned to (`lookTarget`) and where the camera
* looks on its way there (`lookView`): yaw off the bow, starboard
@@ -581,6 +586,9 @@ export class CockpitViewController {
syncEntry() {
return syncEntry.call(this);
}
refreshSubjectWords() {
return refreshSubjectWords.call(this);
}
navigateContext(direction, options) {
return navigateContext.call(this, direction, options);
}
@@ -783,6 +791,9 @@ export class CockpitViewController {
this.relocalizeBrief();
this.publishVision(createVisionState(this.visionMode));
this.routeSignature = null;
// A provider's words, ridden or on the entry, follow the language.
this.refreshSubjectWords();
if (!this.active) this.syncEntry();
if (this.active && this.lastSubjectInfo) {
this.rewordingSignals = true;
try {
+115 -2
View File
@@ -56,8 +56,10 @@ function fixture(
dataManager: suppliedManager = null,
readContext = null,
subjects = [],
vessel = null,
} = {},
) {
let selectedVessel = vessel;
document.body.replaceChildren();
const host = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
@@ -102,7 +104,13 @@ function fixture(
services: {
flightsLayer: { getTrackedInfo: trackedFrom('flights') },
militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') },
aisLiveVesselsLayer: { getCockpitSubject: () => null },
// Like the layer, no subject while Live AIS Vessels is off.
aisLiveVesselsLayer: {
getCockpitSubject: () =>
selectedVessel && layers.has('ais-live-vessels')
? { ...selectedVessel }
: null,
},
// Like the layer, no subject while Satellites is off.
satellitesLayer: {
getCockpitSubject: () =>
@@ -174,10 +182,35 @@ function fixture(
viewer.trackedEntity = entityFor(subject);
entities.add(viewer.trackedEntity);
};
/** Select a vessel on the map, or none. */
const selectVessel = (info) => {
selectedVessel = info;
};
const $ = (selector) => document.querySelector(selector);
return { $, coordinator, cockpit, store, layers, disable, retrack, pinia };
return {
$,
coordinator,
cockpit,
store,
layers,
disable,
retrack,
selectVessel,
pinia,
};
}
/** A selected vessel as the AIS layer gives it to the bridge view. */
const VESSEL = Object.freeze({
id: '211000001',
name: 'EXAMPLE',
latitude: 54,
longitude: 10,
headingDeg: 90,
courseDeg: 90,
speedMps: 5,
});
test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => {
const { coordinator, cockpit } = fixture(t, {
enabled: ['flights'],
@@ -570,6 +603,32 @@ 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) => {
const { $, cockpit, store, selectVessel } = fixture(t, {
enabled: ['flights', 'ais-live-vessels'],
tracked: { layerId: 'flights', info: CIVIL },
});
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'aircraft');
await new Promise((resolve) => setTimeout(resolve, 2));
selectVessel(VESSEL);
cockpit.syncEntry();
await nextTick();
assert.equal(store.presence.entryKind, 'vessel');
assert.match($('#cockpit-entry-readout').textContent, /BRIDGE/);
// And the other way round: an aircraft tracked after the vessel.
const later = fixture(t, {
enabled: ['flights', 'ais-live-vessels'],
vessel: VESSEL,
});
later.cockpit.syncEntry();
assert.equal(later.store.presence.entryKind, 'vessel');
await new Promise((resolve) => setTimeout(resolve, 2));
later.retrack({ layerId: 'flights', info: CIVIL });
later.cockpit.syncEntry();
assert.equal(later.store.presence.entryKind, 'aircraft');
});
test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => {
const { $, cockpit } = fixture(t, { satellite: SATELLITE });
cockpit.syncEntry();
@@ -730,6 +789,60 @@ test("a provider's view reports its subject, refuses voice entry and ends with i
assert.deepEqual(released, [['a1', false]]);
});
test("a provider's guards: a throwing candidate, gate or release, and a gate that is not true", async (t) => {
const warned = [];
t.mock.method(console, 'warn', (message) => warned.push(String(message)));
const entered = (provider) => {
const { cockpit } = fixture(t, {
enabled: ['example-layer'],
subjects: [provider],
});
cockpit.syncEntry();
return { cockpit, entered: cockpit.enter() };
};
const { provider: base } = exampleSubject();
// A candidate() that throws offers nothing.
const noCandidate = entered({
...base,
candidate: () => {
throw new Error('candidate failed');
},
});
assert.equal(noCandidate.entered, false);
assert.equal(noCandidate.cockpit.resolveEntry(), null);
// An isEntryAllowed() that throws, or answers anything but true, shuts
// the gate.
for (const isEntryAllowed of [
() => {
throw new Error('gate failed');
},
() => 1,
() => 'yes',
() => ({}),
]) {
const gated = entered({ ...base, isEntryAllowed });
assert.equal(gated.entered, false, String(isEntryAllowed));
assert.equal(gated.cockpit.active, false);
}
// A release() that throws still ends the view.
const { provider } = exampleSubject();
const releasing = entered({
...provider,
release: () => {
throw new Error('release failed');
},
});
assert.equal(releasing.entered, true);
assert.equal(releasing.cockpit.exit(), true);
assert.equal(releasing.cockpit.active, false);
assert.equal(document.body.classList.contains('cockpit-subject'), false);
for (const method of ['candidate', 'isEntryAllowed', 'release'])
assert.ok(
warned.some((line) => line.includes(`${method}() failed`)),
method,
);
});
test('a malformed provider is left out and the built-in kinds are unchanged', async (t) => {
t.mock.method(console, 'warn', () => {});
const { cockpit } = fixture(t, {
+4 -1
View File
@@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => {
assert.equal(store.orbital, null, 'no HUD comes back for a stale answer');
});
test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
@@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
};
const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo });
viewer.trackedEntity = aircraft;
// The satellite and the aircraft are both new to this sync; the fixed order
// decides between subjects first seen together. Selected one after the
// other, the later one takes entry (cockpitEntryGate.test.mjs).
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'aircraft');
});
+125
View File
@@ -14,6 +14,7 @@ import { useCockpitStore } from './stores/cockpit.js';
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 { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
const LAT = 47;
@@ -500,6 +501,115 @@ test('entry fails, releasing nothing it did not hold, when hold or the first fra
assert.equal(app.cockpit.enter(), false);
assert.deepEqual(lastRelease(blank.calls), ['release', 'a1', false]);
assert.equal(app.cockpit.active, false);
// A hold that throws may have half happened: it is released.
const thrown = subjectProvider({
hold: (id) => {
thrown.calls.push(['hold', id]);
throw new Error('hold failed');
},
});
app = fixture(t, { providers: [thrown] });
app.cockpit.syncEntry();
assert.equal(app.cockpit.enter(), false);
assert.deepEqual(lastRelease(thrown.calls), ['release', 'a1', false]);
assert.equal(app.warned.length, 1);
// So is one that answers with a promise: provider methods are synchronous.
const later = subjectProvider({
hold: async () => true,
});
app = fixture(t, { providers: [later] });
app.cockpit.syncEntry();
assert.equal(app.cockpit.enter(), false);
assert.deepEqual(lastRelease(later.calls), ['release', 'a1', false]);
assert.match(app.warned[0], /returned a promise/);
});
test('a frame with no height and a cold floor never stands the eye on the ellipsoid', 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] });
// Nothing to stand on from the first frame: the camera waits where it is.
setFloor(undefined);
cockpit.syncEntry();
assert.equal(cockpit.enter(), true);
const before = shots.length;
for (let i = 0; i < 5; i += 1) {
clock.advance(16);
cockpit.update();
}
assert.equal(shots.length, before, 'no camera placed');
assert.equal(cockpit.active, true, 'the view waits');
// The floor arrives: the eye stands on it.
setFloor(FLOOR_M);
clock.advance(16);
cockpit.update();
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
// It goes cold again: the eye keeps the height it stood at, never 0 m,
// with the ground clamp off too.
setFloor(undefined);
ride.subject.frame = { ...ride.subject.frame, groundClamp: false };
for (let i = 0; i < 5; i += 1) {
clock.advance(16);
cockpit.update();
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
}
// A height out of any plausible range reads as none.
ride.subject.frame = { ...ride.subject.frame, heightM: -6_400_000 };
clock.advance(16);
cockpit.update();
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
});
test('a provider’s labels, entry face and look follow a change of language', async (t) => {
t.after(() => setLocale('en'));
setLocale('en');
const clock = manualClock(t, 10_000);
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: {
view: 'Fahrtansicht',
exit: 'Fahrtansicht verlassen',
exitText: 'VERLASSEN',
},
};
const german = () => getLocale().startsWith('de');
const ride = subjectProvider({
labels: () => words[german() ? 'de' : 'en'],
entryFace: () => ({ icon: 'commute', label: german() ? 'FAHRT' : 'RIDE' }),
look: () => ({
restPitchDeg: -3,
aheadLabel: german() ? 'Nach vorn sehen' : 'Look ahead',
}),
});
const { $, cockpit } = fixture(t, { providers: [ride] });
cockpit.syncEntry();
await nextTick();
assert.match($('#cockpit-entry').textContent, /RIDE/);
// On the entry, before the view.
setLocale('de');
await settle();
assert.match($('#cockpit-entry').textContent, /FAHRT/);
setLocale('en');
await settle();
assert.equal(cockpit.enter(), true);
clock.advance(16);
cockpit.update();
await settle();
assert.match($('#map-view-switch').textContent, /EXIT RIDE/);
// While riding.
setLocale('de');
await settle();
assert.match($('#map-view-switch').textContent, /VERLASSEN/);
assert.equal(
$('#map-view-switch').getAttribute('aria-label'),
'Fahrtansicht verlassen',
);
assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'Fahrtansicht');
assert.equal(
$('#cockpit-look-ahead').getAttribute('aria-label'),
'Nach vorn sehen',
);
});
test('a read that throws ends the view once; a hud that throws keeps the last', async (t) => {
@@ -567,6 +677,21 @@ test('the most recently selected subject wins entry', async (t) => {
selectVessel({ ...VESSEL, id: '211000099', mmsi: '211000099' });
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'vessel');
// The same subject selected again comes back to the front.
clock.advance(500);
ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 };
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'example-kind', 're-selected');
clock.advance(500);
selectVessel({ ...VESSEL, id: '211000098', mmsi: '211000098' });
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'vessel');
clock.advance(500);
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'vessel', 'an unchanged token');
ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 3 };
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'example-kind');
// The newest decides alone: its shut gate leaves no entry.
clock.advance(500);
ride.subject.candidate = { id: 'a3', name: 'Example 3' };
+41 -12
View File
@@ -104,9 +104,11 @@ 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); candidates
* first seen together keep the fixed order (aircraft, satellite, vessel, then
* providers by priority). Each carries `allowed()`, its kind's entry gate.
* 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.
* @param {number} [nowMs]
* @returns {Array<{kind: string, id: string, seenAtMs: number, order: number,
* allowed: () => boolean, provider?: object, candidate?: object}>}
@@ -114,9 +116,10 @@ const BUILT_IN_ENTRY_ORDER = Object.freeze({
export function entryCandidates(nowMs = performance.now()) {
const seen = (this._entrySeen ??= new Map());
const list = [];
const note = (kind, id, entry) => {
const note = (kind, id, entry, selectedAt = null) => {
const last = seen.get(kind);
if (!last || last.id !== id) seen.set(kind, { id, atMs: nowMs });
if (!last || last.id !== id || last.selectedAt !== selectedAt)
seen.set(kind, { id, selectedAt, atMs: nowMs });
list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry });
};
const aircraft = this.readAircraftInfo();
@@ -149,12 +152,17 @@ export function entryCandidates(nowMs = performance.now()) {
this.subjectProviders.forEach((provider, index) => {
const candidate = this.readSubjectCandidate(provider);
if (candidate)
note(provider.kind, candidate.id, {
order: 3 + index,
provider,
candidate,
allowed: () => this.isSubjectEntryAllowed(provider, candidate),
});
note(
provider.kind,
candidate.id,
{
order: 3 + index,
provider,
candidate,
allowed: () => this.isSubjectEntryAllowed(provider, candidate),
},
candidate.selectedAt,
);
});
// A subject gone and back counts as selected again.
for (const kind of [...seen.keys()])
@@ -268,6 +276,20 @@ export function syncTr3bToggle(info) {
this.publishPresenceView();
}
/**
* Word the ridden provider subject's labels and Look ahead button again,
* after a change of the page's language: a provider may give them as
* functions (cockpitSubjectProviders).
*/
export function refreshSubjectWords() {
const provider = this.subjectProvider;
if (!this.active || !provider) return;
this.presence.subjectLabels = { ...provider.labels };
const look = provider.look;
this.presence.subjectLook = look ? { aheadLabel: look.aheadLabel } : null;
this.publishPresenceView();
}
export function syncEntry() {
if (this.destroyed) return false;
if (this.active) return;
@@ -376,8 +398,14 @@ function enterSubject(provider) {
const candidate = this.readSubjectCandidate(provider);
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
return false;
if (this.callSubject(provider, 'hold', [candidate.id], false) !== true)
const failed = {};
const held = this.callSubject(provider, 'hold', [candidate.id], failed);
if (held === failed) {
// A hold that threw (or broke the contract) may have half happened.
this.callSubject(provider, 'release', [candidate.id, { restore: false }]);
return false;
}
if (held !== true) return false;
const frame = cockpitSubjectFrame(
this.callSubject(
provider,
@@ -419,6 +447,7 @@ function enterSubject(provider) {
this.surfaceAcquiring = false;
this.surfaceFallback = false;
this.cockpitAnchorValid = false;
this.subjectHeightM = null;
this.lookRestPitchDeg = provider.look?.restPitchDeg ?? null;
this.resetLook({ immediate: true });
this.active = true;