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

+78
View File
@@ -228,3 +228,81 @@ view name is an error. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`.
### Cockpit subjects
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
selected AIS vessel. An application adds kinds of its own with
`services.cockpitSubjects` on `createApplicationControls`: a list of
providers, checked once when the controls are built
(`src/cockpitSubjectProviders.js`). A malformed provider is left out with a
warning. Entry rides the most recently selected subject of all kinds and needs
the provider's `layerId` on; the provider then holds its subject, hands the
camera a frame and the generic HUD a view model on every update, and releases
it on exit. Provider methods are synchronous; one that throws or returns a
promise is treated as having failed.
```js
import { getLocale, onLocaleChange } from '@gods-eye-view/core/i18n/locale';
import { pointAtS } from '@gods-eye-view/core/layers/rail/paths';
const words = {
en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' },
de: {
view: 'Fahrtansicht',
exit: 'Fahrtansicht verlassen',
exitText: 'VERLASSEN',
},
};
/** Ride the Rail layer's selected train; `rail` is its layer instance. */
const ride = {
kind: 'example-ride',
layerId: 'rail',
entryFace: { icon: 'train', label: 'RIDE' },
// A function is read again when the page's language changes.
labels: () => words[getLocale()] ?? words.en,
candidate() {
const train = rail.getSelectedTrain();
return (
train && {
id: train.key,
name: train.label,
selectedAt: train.selectedAtMs,
}
);
},
hold: (id) => rail.setCockpitSubject(id),
read(nowMs, ctx) {
const train = rail.getCockpitSubject();
if (!train?.path) return null; // ends the view
const point = pointAtS(train.path.line, train.path.s);
return {
id: train.key,
latitude: point.lat,
longitude: point.lon,
heightM: train.position.railHeightM, // null stands it on the shared floor
headingDeg:
train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg,
eye: { upM: 3.5 },
};
},
hud: (frame) => ({ title: frame.name }),
release(id, { restore }) {
rail.setCockpitSubject(null);
if (restore) rail.focusTrain(id);
},
};
createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } });
```
`@gods-eye-view/core/i18n/locale` gives `getLocale()` and `onLocaleChange()`:
Core translates only its own strings, so a provider words its labels, entry
face and HUD for the page's language (labels may be functions, as above; the
HUD is read again on a change). `@gods-eye-view/core/layers/rail/paths` is the
Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`)
for a provider placing a train along its line. The frame, HUD view and
candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail
and Transit read APIs in `src/layers/rail/cockpitSubject.js` and
`src/layers/transit/cockpitSubject.js`.
+17 -9
View File
@@ -1696,7 +1696,9 @@ and portal cleanup follows that restoration.
### Ship bridge view
With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
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).
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
@@ -1782,9 +1784,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. A tracked aircraft still takes
entry first, and a tracked satellite
takes it before a selected vessel. Double-clicking the tracked satellite
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
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 +2003,15 @@ 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; subjects first seen together keep
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.
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
frame gives none), slews toward `headingDeg` at the provider's rate, and may
frame gives none, or where it last stood while that floor is cold; with
neither the camera waits where it is), slews toward `headingDeg` at the provider's rate, and may
turn off the floor clamps (`groundClamp: false`, for a subject inside a
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
@@ -2022,19 +2026,23 @@ motion), arrow-key look-around when the provider asks for it, and a progress
line along the bottom edge (`src/cockpitProgress.js`: close ticks merge,
labels never overlap). Core translates none of the provider's text: a host
words it for the page's language (`@gods-eye-view/core/i18n/locale`:
`getLocale`, `onLocaleChange`).
`getLocale`, `onLocaleChange`). Its entry face, labels and look may be
functions, read again when they are shown and on a change of language, as the
HUD view is.
The Rail layer offers a read API for such a view: `getSelectedTrain()` and
`getCockpitSubject()` (a snapshot of the train: where it is along its line,
which way it runs (`pathBinding.js` learns it), its rail height, speed as
reported or derived, position source, feed credit and stops),
reported or derived, position source, feed credit and stops, and
`selectedAtMs`, when the user last selected it, even again),
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
out of the camera's view, and its sprite, card, trail and detection box are
hidden. `src/ui/cockpitSubjectView.test.mjs`,
hidden; released, it is out of play until a visibility sweep finds it in
view. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`,
`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`,
`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests
cover them.
+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;