diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d3ca6ffce..af2d4c94c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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=/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`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6b86534a8..0fbf6119c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 845bcce42..b6b6f5849 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -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); diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js index 4f044fd6d..46b588ebe 100644 --- a/src/cockpitSubjectProviders.js +++ b/src/cockpitSubjectProviders.js @@ -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; }; } diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs index 487d5d261..b49545eb2 100644 --- a/src/cockpitSubjectProviders.test.mjs +++ b/src/cockpitSubjectProviders.test.mjs @@ -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); +}); diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js index b9b2d16cc..56078b37b 100644 --- a/src/layers/rail/cockpitSubject.js +++ b/src/layers/rail/cockpitSubject.js @@ -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, diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 4a4e6d6ab..21077c0d0 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -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. */ diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 644775961..8818ae195 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -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. diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js index 33f215cf3..93927c63f 100644 --- a/src/layers/transit/cockpitSubject.js +++ b/src/layers/transit/cockpitSubject.js @@ -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; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index e4b9cce69..7467f65d7 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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); diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index f28067f13..cc9098cbb 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -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); diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index 7f442872e..47016ba98 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -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 diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index ce2a8dae5..847efa429 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -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, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 301515b42..34ba0a341 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -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 { diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index 85f82d6bb..25a38bcb2 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -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, { diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 72130a9e8..26dad341b 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -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'); }); diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs index ed0236044..8fcc1ee10 100644 --- a/src/ui/cockpitSubjectView.test.mjs +++ b/src/ui/cockpitSubjectView.test.mjs @@ -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' }; diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 139ca67a3..0b50f6343 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -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;