diff --git a/changelog.d/1686-cockpit-subjects.md b/changelog.d/1686-cockpit-subjects.md new file mode 100644 index 000000000..715561e8e --- /dev/null +++ b/changelog.d/1686-cockpit-subjects.md @@ -0,0 +1,12 @@ +- Let an application add its own Cockpit subjects through + `services.cockpitSubjects`. A provider supplies the subject's position, + heading and HUD (in the page's language, via `getLocale` and + `onLocaleChange` from `./i18n`); the camera can follow it without the + ground clamp, anchored exactly on it, and a generic HUD adds source rows, a + banner, a veil (off under reduced motion) and a bottom progress line. The + Rail and Transit layers expose read and hold APIs so a selected train or + vehicle can be ridden, and `./layers/rail/paths` is exported; that export + and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal + provider. GEV itself registers none, and entry to the built-in aircraft, + satellite and vessel views is unchanged; a provider's subject takes entry + when it was selected after them (Denis Groshev, @ZloyS, #1686). diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 7c2206467..3aa85f72c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -230,3 +230,125 @@ starts and switches the language with the `./i18n` entry (`startPageLocale`, `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, or one with a member +that throws when read (a getter), is left out with a warning and the rest are +kept. A provider's subject takes entry when it was selected after every +built-in subject present (a tracked aircraft or satellite, a selected vessel), +whose own fixed order is unchanged, 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 and are called on the provider, so a class instance's methods keep +their `this`; one that throws or returns a promise is treated as having +failed. What a method returns may carry getters: one that throws when read +makes a candidate none, a frame malformed (the view ends) and a HUD view keep +the last; a worded member (labels, entry face, look) that throws keeps its +last good words. + +```js +import { getLocale, onLocaleChange } from 'gods-eye-view/i18n'; +import { pointAtS } from 'gods-eye-view/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, + } + ); + }, + // Only a train bound to its line can be ridden: read() needs its path. + isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path), + 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 + name: train.label, + headingDeg: + train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg, + speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps, + // The layer already moves the train smoothly: sit on it. + anchor: 'exact', + eye: { upM: 3.5 }, + }; + }, + hud: (frame) => ({ title: frame.name }), + release(id, { restore }) { + rail.setCockpitSubject(null); + if (restore) rail.focusTrain(id); + }, +}; + +createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } }); +``` + +The `./i18n` entry 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/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`. A snapshot read again while nothing in +it changed is the one read before, so `read` may take one on every update; a +train's `path.line` is a frozen copy of its line, for `pointAtS` and +`projectOnPath`. The `layers/rail/paths` export and the +Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`, +`getCockpitSubject` and the snapshots they return, `path.line` among them) +are experimental and may change without notice. + +A candidate's `selectedAt` is a token that changes each time the user selects +the subject, even the same one again, so entry counts it as selected then; a +new `id` under the same token is the same selection (a train's selection +moving to another feed's copy of it). The Rail and Transit snapshots give one, +`selectedAtMs`: when the user last selected the train or vehicle by a click or +`focusTrain`/`focusVehicle`. It is null after a deselection and for one +selected only by a hold, since a hold is not the user's selection. + +A frame with `heightM: null` stands on the shared floor, or where it last +stood while that floor is cold. If no height has been known since entry, the +view waits, showing ACQUIRING SURFACE, for at most five seconds +(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. + +`hold(id)` returns `true` once it holds the subject, or `false` to refuse +entry; any other answer, `undefined` included, refuses entry too, with one +warning. A refused hold, and one that throws or returns a promise, is followed +by `release(id, { restore: false })`, again once a returned promise settles, +so `release` must be harmless for a subject it does not hold and when called +more than once. + +Leaving the view does not track again an aircraft that was tracked before +entry, as with the bridge and orbital views: when the user leaves by the exit +control, the map goes back to the ridden subject through +`release(id, { restore: true })`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce37fa0fc..57c3743c4 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -2208,6 +2208,83 @@ the subject (tracking stopped, Satellites off) or tracking another target ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` covers entry, camera, zoom, look, HUD and exit. +### Application subjects + +An application can add its own first-person subjects beside the aircraft, +vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`, +a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by +the coordinator; a malformed one, or one with a member that throws when read, +is left out with a warning and the rest are kept). A provider names +its layer, an entry face and labels in the page's language, and supplies four +operations: `candidate()` (what entry would ride now), `hold(id)`, +`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD +view model), with `release(id, {restore})` on exit. Entry needs the provider's +layer on, no Context change in flight and the provider's own +`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with +the reason, while a provider's view is in use. + +The built-in kinds keep their fixed entry order (a tracked aircraft, then the +tracked satellite, then the selected vessel), so without providers entry is +unchanged. A provider's candidate takes entry only when it was selected after +every built-in subject present, by when the Cockpit first saw it or saw it +selected again (its `selectedAt` changing): a train picked after an aircraft +was tracked takes entry, and an aircraft tracked after it takes it back. +Providers' candidates are ordered most recently selected first, then by +priority; one first seen together with a built-in subject comes after it. The +first in that order decides alone: when its gate is shut there is no entry +rather than the next subject's. + +A frame places the eye `eye.upM` above `heightM` (the shared floor where the +frame gives none, or where it last stood while that floor is cold; with +neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for +at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the +subject is released: no height stands in for a floor never read), 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; a frame with no `speedMps` is always +exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the +camera every rendered frame. A frame that is null, malformed or for another +subject ends the view (`restoreTracking: false`), as an aircraft tracked +meanwhile and the provider's layer turning off do; the exit control asks the +provider to restore its subject. The generic HUD shows the provider's kicker, +title, meta line, speed with its unit and tag, readout cards, always-visible +source rows, banner, route card and Live Signals rows, a veil that darkens the +picture under the HUD (with passing light streaks, which stop under reduced +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/i18n`: +`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, and +`selectedAtMs`, when the user last selected it by a click or `focusTrain`, +even again; null after a deselection and for a train only a hold selected), +`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its +feed held, clicks and Escape left to the view; the held copy stays the one +drawn of its train while held, another feed's copy hidden, and it keeps +moving, still selected, when its category is turned off) and +`focusTrain(key)` (false for a train the layer does not draw); its pure path +module is exported as `gods-eye-view/layers/rail/paths`. That export +and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is +the one read before, and its `path.line` is a frozen copy. 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, 3D model, card, trail and detection +box are hidden; released, it is out of play until a visibility sweep finds it in +view. Its snapshot's `position.groundHeightM` is the floor where playback draws +it (the drawn marker's height while it is placed, else its segment's two +reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the +way the drawn train runs: once playback has placed it, a report that runs back +turns it only when playback reaches that report. `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. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, diff --git a/package.json b/package.json index c1476342d..7559b1ddd 100644 --- a/package.json +++ b/package.json @@ -298,6 +298,7 @@ }, "./sources/rail-service": "./src/sources/railService.js", "./layers/rail/source": "./src/layers/rail/source.js", + "./layers/rail/paths": "./src/data/railPaths.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./server/providers/mapillary": { "node": "./server/providers/mapillary.js" diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 42cc132d7..c11489f1f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1436,8 +1436,18 @@ "src/data/placePacksLoading.test.mjs", "src/ui/placePacksLoading.js", "src/ui/placePacksLoading.test.mjs", + "src/cockpitSubjectProviders.js", + "src/cockpitSubjectProviders.test.mjs", + "src/cockpitProgress.js", + "src/cockpitProgress.test.mjs", + "src/ui/cockpitSubjectView.test.mjs", + "src/ui/styles/cockpitSubject.css", + "src/layers/rail/cockpitSubject.js", + "src/layers/rail/cockpitSubject.test.mjs", + "src/layers/transit/cockpitSubject.js", "src/data/fleetModels.test.mjs", "src/data/fleetModelInstances.test.mjs", "src/testSupport/fleetGlb.mjs", + "src/testSupport/fleetModelFakes.mjs", "scripts/measure-fleet-trail-anchors.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c1dc35c25..e4c9b6230 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -22,6 +22,9 @@ "src/ui/vueRuntime.js", "src/ui/views/index.js", "src/ui/views/CockpitView.vue", + "src/ui/views/panels/CockpitProgress.vue", + "src/cockpitProgress.js", + "src/cockpitSubjectProviders.js", "src/ui/views/CommandDockView.vue", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", @@ -1010,6 +1013,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1175,6 +1180,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1357,6 +1364,7 @@ "src/ui/styles/status.css", "src/ui/styles/cockpit.css", "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitSubject.css", "src/ui/styles/cockpitRadar.css", "src/ui/styles/overlays.css", "src/ui/styles/layers.css", @@ -3116,6 +3124,8 @@ "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -3304,6 +3314,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -3330,6 +3341,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -4292,6 +4304,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -4317,6 +4330,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -5228,6 +5242,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/movement.js", @@ -5298,6 +5313,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -5920,5 +5936,10 @@ "src/layers/wildlifeSightings/performance.js" ], "external": [] + }, + "rail-paths": { + "exports": ["./layers/rail/paths"], + "modules": ["src/data/railPaths.js"], + "external": [] } } diff --git a/src/app/controls.js b/src/app/controls.js index def69325d..16da2c295 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -11,6 +11,8 @@ import { t } from '../i18n/index.js'; * whose address does not carry one. `localePicker: false` removes the language * picker, for a host that offers its own language choice (`switchLocale` from * the `./i18n` entry). + * `services.cockpitSubjects` adds the application's own first-person subjects + * to the Cockpit (src/cockpitSubjectProviders.js). */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 53daa63d1..c304cac75 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) { * then uses this amount to converge on the layer's delayed display position. * Capping correction below forward speed prevents a late feed fix from * becoming a first-person surge or reversal while still removing drift. + * @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an + * application subject's own (cockpitSubjectStepSec). */ -export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { +export function cockpitAnchorCorrectionStep( + distanceM, + speedMps, + dtSec, + maxDtSec = 0.1, +) { if ( !Number.isFinite(distanceM) || distanceM <= 0 || @@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { dtSec <= 0 ) return 0; - const dt = Math.min(0.1, dtSec); + const dt = Math.min( + Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1, + dtSec, + ); const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const eased = distanceM * (1 - Math.exp(-1.25 * dt)); const correctionRateMps = Math.max(0.75, speed * 0.22); return Math.min(distanceM, eased, correctionRateMps * dt); } +/** + * The seconds an application subject's camera update advances its heading + * slew, ground lift and anchor by: the time since the last update, at most + * its update interval plus 0.1 s. A subject updated every 500 ms then turns + * and eases at its configured rates, while a long stall (a hidden tab) still + * moves it no further than one late update would. + * @param {number} elapsedMs Time since the last camera update. + * @param {number} intervalMs The subject's update interval (0: every frame). + * @returns {number} + */ +export function cockpitSubjectStepSec(elapsedMs, intervalMs) { + const elapsedSec = Number.isFinite(elapsedMs) + ? Math.max(0, elapsedMs) / 1000 + : 0; + const intervalSec = Number.isFinite(intervalMs) + ? Math.max(0, intervalMs) / 1000 + : 0; + return Math.min(intervalSec + 0.1, elapsedSec); +} + /** Return whether a throttled cockpit presentation update is due. */ export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) @@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { return nowMs - startedMs >= timeoutMs; } +/** + * How fast the clearance a ground clamp adds under an application subject's + * eye comes and goes (m/s). + */ +export const COCKPIT_SUBJECT_LIFT_MPS = 1.5; + +/** + * The lift the ground clamp gives an application subject's eye this update. + * The floor is read from coarse cells that refine as tiles load, so the lift + * it asks for jumps; a ridden eye takes the clearance part of it at + * `rateMps`, while what it needs to stay out of the floor itself (`hardM`) + * applies at once. + * @param {number|null} previousM The lift given last update; null on entry. + * @param {number} targetM The lift the floor and clearance ask for (m). + * @param {number} hardM The lift that keeps the eye out of the floor (m). + * @param {number} dtSec Seconds since the last update. + * @param {number} [rateMps] + * @returns {number} At least 0, at least `hardM`. + */ +export function easeCockpitLift( + previousM, + targetM, + hardM, + dtSec, + rateMps = COCKPIT_SUBJECT_LIFT_MPS, +) { + const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0; + const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0; + if (!Number.isFinite(previousM)) return Math.max(target, hard); + const step = + Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) * + Math.max(0, Number.isFinite(dtSec) ? dtSec : 0); + const eased = previousM + Math.max(-step, Math.min(step, target - previousM)); + return Math.max(0, hard, eased); +} + /** * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index a59a4b029..104190fa8 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -8,8 +8,11 @@ import { cockpitAnchorCorrectionStep, cockpitAltitudeDisplayFt, cockpitGroundSafeHeight, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, + COCKPIT_SUBJECT_LIFT_MPS, + easeCockpitLift, compassDivisions, compassMarkerSlot, scrollingCompassDivisions, @@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', () assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0); }); +test('an application subject on a slow cadence is corrected for its whole step', () => { + // The built-in views' step stays capped at 0.1 s... + const capped = cockpitAnchorCorrectionStep(100, 0, 0.5); + assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1)); + // ...while a subject updated every 500 ms is corrected for all of it. + const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5); + assert.ok(Math.abs(whole / capped - 5) < 1e-9); + assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped); +}); + +test('an application subject steps by its interval, never by a long stall', () => { + assert.equal(cockpitSubjectStepSec(16, 0), 0.016); + assert.equal(cockpitSubjectStepSec(500, 500), 0.5); + assert.equal(cockpitSubjectStepSec(520, 500), 0.52); + // A hidden tab's minutes count as one late update. + assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6); + assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1); + assert.equal(cockpitSubjectStepSec(-5, 500), 0); + assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0); + assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1); +}); + test('cockpit camera clearance never falls below the rendered ground floor', () => { assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69); assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200); @@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', () assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency({ label: '' }), null); }); + +test('an application subject’s ground lift eases its clearance, never the floor', () => { + // On entry the lift asked for applies at once. + assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5); + // A floor cell that rises by a metre is met at the lift rate... + let lift = 0; + const frames = []; + for (let i = 0; i < 60; i += 1) { + lift = easeCockpitLift(lift, 1, 0, 0.016); + frames.push(lift); + } + assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9); + for (let i = 1; i < frames.length; i += 1) + assert.ok( + frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9, + ); + assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second'); + // ...and given back the same way. + assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9); + // What keeps the eye out of the floor itself is never eased. + assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200); + assert.equal( + easeCockpitLift(0.2, -3, -3, 0.016), + 0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016, + ); + assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0); +}); diff --git a/src/cockpitProgress.js b/src/cockpitProgress.js new file mode 100644 index 000000000..67a3be951 --- /dev/null +++ b/src/cockpitProgress.js @@ -0,0 +1,182 @@ +/** + * @module cockpitProgress + * @description Lay out a subject's progress line (cockpitSubjectProviders.js) + * at a width in pixels: where each tick, label and the marker go. + * + * Ticks sit at fractions of the line, 0 at the left. Ticks closer than + * `minTickPx` merge, so a long run still reads as a line: a labelled tick + * keeps its place and takes in the bare ticks beside it, and bare ticks merge + * with each other at their mean place. A merged tick takes the strongest tone + * of those it holds. Labels never overlap: where two would, the one of lower + * priority is left out (on a tie, the later one). + * + * Pure: no DOM. + */ + +/** Bare ticks closer than this merge (px). */ +export const COCKPIT_PROGRESS_MIN_TICK_PX = 6; +/** Room kept between two labels (px). */ +export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8; + +/** Tones from weakest to strongest, for a merged tick. */ +const TONE_RANK = Object.freeze([ + 'muted', + 'normal', + 'live', + 'estimate', + 'warning', + 'alert', +]); +const stronger = (a, b) => + TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a; + +/** A label's width estimate (px): its longer line in small capitals. */ +export function estimateProgressLabelPx(label) { + const longest = Math.max( + String(label?.text ?? '').length, + String(label?.detail ?? '').length, + ); + return longest * 6.4 + 8; +} + +/** + * The progress line at `widthPx`, or null when there is nothing to draw. + * @param {{ticks: Array<{at: number, tone: string, label: object|null}>, + * marker: {at: number, dwell: boolean}|null, tag: string}|null} progress + * As cockpitSubjectHudView gives it: ticks sorted by `at`. + * @param {number} widthPx + * @param {{minTickPx?: number, gapPx?: number, + * measure?: (label: object) => number}} [options] + * @returns {{ticks: Array<{x: number, tone: string, count: number, + * labelled: boolean}>, labels: Array<{x: number, left: number, + * width: number, align: string, text: string, detail: string, + * tone: string}>, marker: {x: number, dwell: boolean}|null, + * travelledX: number, tag: string}|null} + */ +export function cockpitProgressLayout( + progress, + widthPx, + { + minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX, + gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX, + measure = estimateProgressLabelPx, + } = {}, +) { + const ticks = progress?.ticks; + if (!Array.isArray(ticks) || ticks.length < 2) return null; + if (!Number.isFinite(widthPx) || widthPx <= 0) return null; + const placed = ticks.map((tick, order) => ({ + x: tick.at * widthPx, + tone: tick.tone, + label: tick.label, + order, + })); + const labelled = placed.filter((tick) => tick.label); + // Bare ticks beside a labelled one go into it. + const kept = labelled.map((tick) => ({ + x: tick.x, + tone: tick.tone, + count: 1, + labelled: true, + label: tick.label, + order: tick.order, + })); + const bare = []; + for (const tick of placed) { + if (tick.label) continue; + const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx); + if (host) { + host.tone = stronger(host.tone, tick.tone); + host.count += 1; + } else bare.push(tick); + } + // Bare ticks in a chain closer than minTickPx merge at their mean place. + let cluster = null; + const flush = () => { + if (!cluster) return; + kept.push({ + x: cluster.sum / cluster.count, + tone: cluster.tone, + count: cluster.count, + labelled: false, + label: null, + order: cluster.order, + }); + cluster = null; + }; + for (const tick of bare) { + if (cluster && tick.x - cluster.last < minTickPx) { + cluster.sum += tick.x; + cluster.count += 1; + cluster.last = tick.x; + cluster.tone = stronger(cluster.tone, tick.tone); + } else { + flush(); + cluster = { + sum: tick.x, + count: 1, + last: tick.x, + tone: tick.tone, + order: tick.order, + }; + } + } + flush(); + kept.sort((a, b) => a.x - b.x || a.order - b.order); + + // Labels by priority; one that would overlap a placed label is left out. + const boxes = []; + const candidates = kept + .filter((tick) => tick.labelled) + .sort((a, b) => b.label.priority - a.label.priority || a.order - b.order); + for (const tick of candidates) { + const width = Math.min(widthPx, Math.max(0, measure(tick.label))); + let left = + tick.label.align === 'start' + ? tick.x + : tick.label.align === 'end' + ? tick.x - width + : tick.x - width / 2; + left = Math.min(Math.max(0, left), widthPx - width); + const right = left + width; + if ( + boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx) + ) + continue; + boxes.push({ + x: tick.x, + left, + right, + width, + align: tick.label.align, + text: tick.label.text, + detail: tick.label.detail, + tone: tick.tone, + order: tick.order, + }); + } + boxes.sort((a, b) => a.left - b.left); + const marker = progress.marker + ? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell } + : null; + return { + ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({ + x, + tone, + count, + labelled: isLabelled, + })), + labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({ + x, + left, + width, + align, + text, + detail, + tone, + })), + marker, + travelledX: marker ? marker.x : 0, + tag: progress.tag || '', + }; +} diff --git a/src/cockpitProgress.test.mjs b/src/cockpitProgress.test.mjs new file mode 100644 index 000000000..f1bd9d819 --- /dev/null +++ b/src/cockpitProgress.test.mjs @@ -0,0 +1,144 @@ +// src/cockpitProgress.test.mjs — a subject's progress line laid out at a +// width: close bare ticks merge, labelled ticks keep their place, labels never +// overlap (the lower priority gives way) and the part before the marker is +// the travelled part. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cockpitProgressLayout } from './cockpitProgress.js'; + +const tick = (at, label = null, tone = 'normal') => ({ at, tone, label }); +const label = (text, priority, align = 'center', detail = '') => ({ + text, + detail, + priority, + align, +}); +/** Every label is 50 px wide. */ +const measure = () => 50; + +test('nothing is drawn without two ticks or a width', () => { + assert.equal(cockpitProgressLayout(null, 500), null); + assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null); + assert.equal( + cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0), + null, + ); +}); + +test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.1), + tick(0.101, null, 'warning'), + tick(0.102), + tick(0.5), + tick(1, label('Z', 1, 'end')), + ], + marker: { at: 0.3, dwell: false }, + tag: 'TIMETABLE', + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.ticks.map(({ x, count, tone, labelled }) => [ + Math.round(x), + count, + tone, + labelled, + ]), + [ + [0, 1, 'normal', true], + [101, 3, 'warning', false], + [500, 1, 'normal', false], + [1000, 1, 'normal', true], + ], + ); + assert.deepEqual(layout.marker, { x: 300, dwell: false }); + assert.equal(layout.travelledX, 300); + assert.equal(layout.tag, 'TIMETABLE'); +}); + +test('a labelled tick keeps its place and takes in the bare ticks beside it', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.498), + tick(0.5, label('Next', 3), 'alert'), + tick(0.503, null, 'warning'), + tick(1, label('Z', 1, 'end')), + ], + marker: null, + }, + 1000, + { measure }, + ); + const next = layout.ticks.find(({ x }) => Math.round(x) === 500); + assert.equal(next.count, 3); + assert.equal(next.labelled, true); + assert.equal(next.tone, 'alert'); + assert.equal(layout.ticks.length, 3); + assert.equal(layout.marker, null); + assert.equal(layout.travelledX, 0); +}); + +test('labels hang from their ticks inside the line, and overlaps give way by priority', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('First', 1, 'start')), + tick(0.04, label('Previous', 2)), + tick(0.5, label('Next', 3)), + tick(0.52, label('Close', 0)), + tick(1, label('Last', 1, 'end')), + ], + marker: { at: 0.2, dwell: true }, + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.labels.map(({ text, left }) => [text, Math.round(left)]), + [ + ['Previous', 15], + ['Next', 475], + ['Last', 950], + ], + ); + // The first stop's label would cover the previous stop's: it gives way. + assert.ok(!layout.labels.some(({ text }) => text === 'First')); + assert.ok(!layout.labels.some(({ text }) => text === 'Close')); + assert.deepEqual(layout.marker, { x: 200, dwell: true }); +}); + +test('a centred label at an edge is clamped inside the line', () => { + const layout = cockpitProgressLayout( + { ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null }, + 300, + { measure }, + ); + assert.equal(layout.labels[0].left, 0); + const right = cockpitProgressLayout( + { ticks: [tick(0), tick(1, label('Edge', 1))], marker: null }, + 300, + { measure }, + ); + assert.equal(right.labels[0].left, 250); +}); + +test('a long run of calls still lays out, every one a tick or part of one', () => { + const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59)); + ticks[0].label = label('First', 1, 'start'); + ticks[59].label = label('Last', 1, 'end'); + const narrow = cockpitProgressLayout({ ticks, marker: null }, 200); + const wide = cockpitProgressLayout({ ticks, marker: null }, 2000); + const count = (layout) => + layout.ticks.reduce((sum, entry) => sum + entry.count, 0); + assert.equal(count(narrow), 60); + assert.equal(count(wide), 60); + assert.ok(narrow.ticks.length < wide.ticks.length); + assert.equal(wide.ticks.length, 60); +}); diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js new file mode 100644 index 000000000..88b91e62c --- /dev/null +++ b/src/cockpitSubjectProviders.js @@ -0,0 +1,644 @@ +/** + * @module cockpitSubjectProviders + * @description Subject providers: kinds of subject an application adds to the + * Cockpit beside its own aircraft, satellite and vessel. A provider names the + * subject entry would take, holds it while the view rides it, hands the camera + * a frame each update and the HUD a view model, and releases it on exit. Core + * knows nothing else about it. + * + * Everything a provider returns is normalized here before the Cockpit uses + * it: a malformed provider is left out, a malformed frame ends the view, and a + * malformed HUD view is read field by field, never thrown on. + * + * Pure: no Cesium, no DOM. + */ + +/** The kinds the Cockpit rides itself; no provider may take one. */ +export const COCKPIT_BUILT_IN_KINDS = Object.freeze([ + 'aircraft', + 'satellite', + 'vessel', +]); + +/** The tones a provider's HUD texts may take. */ +export const COCKPIT_SUBJECT_TONES = Object.freeze([ + 'normal', + 'muted', + 'live', + 'estimate', + 'warning', + '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; +export const COCKPIT_SUBJECT_MAX_SIGNALS = 4; +/** The most ticks a progress line takes. */ +export const COCKPIT_SUBJECT_MAX_TICKS = 512; + +const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/; +const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ICON_PATTERN = /^[a-z0-9_]{1,40}$/; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/; +const REQUIRED_METHODS = Object.freeze([ + 'candidate', + 'hold', + 'read', + 'hud', + 'release', +]); +const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS); +const TONES = new Set(COCKPIT_SUBJECT_TONES); + +const isObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); +const finite = (value) => typeof value === 'number' && Number.isFinite(value); +const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); + +/** + * `value` as display text: trimmed, at most `max` characters (the last one + * an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is + * kept whole or left out); '' for anything that is not a string or a number. + */ +export function cockpitSubjectText(value, max) { + if (typeof value !== 'string' && !finite(value)) return ''; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (text.length <= max) return text; + let end = max - 1; + const last = text.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; + return `${text.slice(0, end)}…`; +} + +const tone = (value) => (TONES.has(value) ? value : 'normal'); + +/** A Material Symbols entry face, or null. */ +function faceOf(raw) { + if (!isObject(raw)) return null; + const label = cockpitSubjectText(raw.label, 16); + if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label) + return null; + 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 look, normalized, or null without an aheadLabel. */ +function lookOf(raw) { + if (!isObject(raw)) return null; + const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64); + if (!aheadLabel) return null; + return Object.freeze({ + restPitchDeg: finite(raw.restPitchDeg) + ? clamp(raw.restPitchDeg, -90, 90) + : 0, + aheadLabel, + }); +} + +/** + * A provider's worded member `key` as it reads now, normalized: the value, + * or what the function it is returns when called on `provider` (so it can + * follow the page's language); null when it is malformed or a read throws. + * Reading the member, and reading what it gives, runs provider code too (a + * getter), so both are guarded as the call is. + */ +function wordedNow(provider, key, normalize) { + try { + const value = provider[key]; + return ( + normalize(typeof value === 'function' ? value.call(provider) : value) ?? + null + ); + } catch { + return null; + } +} + +/** A provider's name in a warning: its kind, cut, or its place in the list. */ +function providerName(provider, order) { + try { + if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32); + } catch { + // A kind getter that throws gives no name. + } + return `#${order}`; +} + +/** Why `provider` cannot be kept, or null. */ +function refusal(provider, kinds) { + if (!isObject(provider)) return 'not an object'; + if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind)) + return 'malformed kind'; + if (RESERVED.has(provider.kind)) return 'reserved kind'; + if (kinds.has(provider.kind)) return 'repeated kind'; + if ( + typeof provider.layerId !== 'string' || + !LAYER_ID_PATTERN.test(provider.layerId) + ) + return 'malformed layer id'; + for (const method of REQUIRED_METHODS) + if (typeof provider[method] !== 'function') return `no ${method}()`; + if ( + provider.isEntryAllowed !== undefined && + typeof provider.isEntryAllowed !== 'function' + ) + return 'isEntryAllowed is not a function'; + if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face'; + if (!wordedNow(provider, 'labels', labelsOf)) + return 'labels need view, exit and exitText'; + if (provider.look != null && !wordedNow(provider, 'look', lookOf)) + return 'look needs an aheadLabel'; + return null; +} + +/** + * The application's subject providers, checked once and frozen, in the order + * entry asks them: ascending `priority`, ties in list order. Each has + * - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique; + * - `layerId`: the catalog layer the subject belongs to (entry needs it on); + * - `priority`: a finite number (default 0); + * - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at + * most 16 characters, in the page's language; + * - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit + * button's accessible names, the exit button's text and the default kicker, + * in the page's language; + * - `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 on the provider again whenever they are shown and + * when the page's language changes, so its words can follow it + * (`./i18n`). 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})`, all + * synchronous (createCockpitSubjectCaller) and called on the provider, so + * a class instance's methods keep their `this`. `isEntryAllowed` allows + * entry only by returning `true`. `hold` returns `true` once it holds the + * subject, or `false` to refuse entry; any other answer refuses it too, + * with one warning. A refused hold, and one that throws or returns a + * promise, is followed by `release(id, {restore: false})`, and by another + * once that promise settles; `release` must therefore be harmless for a + * subject it does not hold or already gave back. + * + * A provider that is not so, or whose members throw when read (a getter), is + * left out with one warning naming it; the providers after it are kept. + * @param {unknown} list + * @param {{warn?: (message: string) => void}} [options] + * @returns {ReadonlyArray} + */ +export function cockpitSubjectProviders( + list = [], + { warn = (message) => console.warn(message) } = {}, +) { + const kept = []; + const kinds = new Set(); + (Array.isArray(list) ? list : []).forEach((provider, order) => { + let reason; + let copy = null; + try { + reason = refusal(provider, kinds); + if (!reason) copy = keptProvider(provider, order); + } catch { + reason = 'a member threw when read'; + } + if (reason) { + warn( + `[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`, + ); + return; + } + kinds.add(copy.kind); + kept.push(copy); + }); + kept.sort((a, b) => a.priority - b.priority || a.order - b.order); + return Object.freeze(kept); +} + +/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */ +function keptProvider(provider, order) { + // Worded members are read again on each use (see above); the last good + // read stands in for one that is malformed or throws. + let face = wordedNow(provider, 'entryFace', faceOf); + let labels = wordedNow(provider, 'labels', labelsOf); + // On or off, and its rest pitch, as first read. + let look = wordedNow(provider, 'look', lookOf); + const restPitchDeg = look?.restPitchDeg ?? 0; + return Object.freeze({ + kind: provider.kind, + layerId: provider.layerId, + priority: finite(provider.priority) ? provider.priority : 0, + order, + get entryFace() { + face = wordedNow(provider, 'entryFace', faceOf) ?? face; + return face; + }, + get labels() { + labels = wordedNow(provider, 'labels', labelsOf) ?? labels; + return labels; + }, + get look() { + if (!look) return null; + const now = wordedNow(provider, 'look', lookOf); + if (now) + look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel }); + return look; + }, + cameraIntervalMs: + finite(provider.cameraIntervalMs) && + provider.cameraIntervalMs >= 0 && + provider.cameraIntervalMs <= 1000 + ? provider.cameraIntervalMs + : null, + headingSlewDps: + provider.headingSlewDps === Infinity || + (finite(provider.headingSlewDps) && provider.headingSlewDps > 0) + ? provider.headingSlewDps + : null, + // Bound, so a method that uses `this` (a class instance's, say) still + // reads its own provider when called on this copy. + candidate: provider.candidate.bind(provider), + isEntryAllowed: provider.isEntryAllowed + ? provider.isEntryAllowed.bind(provider) + : () => true, + hold: provider.hold.bind(provider), + read: provider.read.bind(provider), + hud: provider.hud.bind(provider), + release: provider.release.bind(provider), + }); +} + +/** + * 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: a + * subject selected after the built-in ones takes entry (entryCandidates). + * Null too when reading `raw` throws (a getter runs provider code). + * @param {unknown} raw What `candidate()` returned. + */ +export function cockpitSubjectCandidate(raw) { + try { + return candidateOf(raw); + } catch { + // A getter on what candidate() returned threw: no candidate. + return null; + } +} + +function candidateOf(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, an eye with no valid `upM` (0..1000), or a + * read of it that throws (a getter runs provider code). + * 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. The anchor is `'inertial'` only when asked for and the frame + * gives a speed; otherwise it is `'exact'`. An id is compared with `heldId` + * trimmed, as `cockpitSubjectCandidate` trims it. + * @param {unknown} raw What `read()` returned. + * @param {string} heldId The subject the view holds. + * @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg, + * courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM, + * pitchDeg, minClearanceM}}`. + */ +export function cockpitSubjectFrame(raw, heldId) { + try { + return frameOf(raw, heldId); + } catch { + // A getter on what read() returned threw: as a malformed frame. + return null; + } +} + +function frameOf(raw, heldId) { + if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId) + return null; + const latitude = raw.latitude; + const longitude = raw.longitude; + if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90) + return null; + const eye = isObject(raw.eye) ? raw.eye : null; + if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null; + const heading = (value) => + finite(value) ? ((value % 360) + 360) % 360 : null; + const headingDeg = heading(raw.headingDeg); + const speedMps = + finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null; + return { + id: heldId, + name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64), + latitude, + longitude: ((((longitude + 180) % 360) + 360) % 360) - 180, + 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, + stale: raw.stale === true, + groundClamp: raw.groundClamp !== false, + // An inertial anchor runs on at the frame's speed; with none it would + // close on a moving subject at the correction's floor and fall behind. + anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial', + eye: { + upM: eye.upM, + forwardM: + finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500 + ? eye.forwardM + : 0, + pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0, + minClearanceM: + finite(eye.minClearanceM) && eye.minClearanceM >= 0 + ? eye.minClearanceM + : 0, + }, + }; +} + +/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */ +function rowsOf(raw, max, row) { + if (!Array.isArray(raw)) return []; + const rows = []; + const ids = new Set(); + for (const entry of raw) { + if (rows.length >= max) break; + if (!isObject(entry)) continue; + const id = typeof entry.id === 'string' ? entry.id : ''; + if (!ID_PATTERN.test(id) || ids.has(id)) continue; + const read = row(entry); + if (!read) continue; + ids.add(id); + rows.push({ id, ...read }); + } + return rows; +} + +/** A progress line, or null without at least two ticks. */ +function progressOf(raw) { + if (!isObject(raw) || !Array.isArray(raw.ticks)) return null; + const ticks = []; + for (const tick of raw.ticks) { + if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break; + if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1) + continue; + let label = null; + if (isObject(tick.label)) { + const text = cockpitSubjectText(tick.label.text, 40); + if (text) + label = { + text, + detail: cockpitSubjectText(tick.label.detail, 64), + priority: finite(tick.label.priority) + ? clamp(Math.round(tick.label.priority), 0, 9) + : 0, + align: ['start', 'center', 'end'].includes(tick.label.align) + ? tick.label.align + : 'center', + }; + } + ticks.push({ at: tick.at, tone: tone(tick.tone), label }); + } + if (ticks.length < 2) return null; + ticks.sort((a, b) => a.at - b.at); + const marker = + isObject(raw.marker) && finite(raw.marker.at) + ? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true } + : null; + return { + ticks, + marker, + tag: cockpitSubjectText(raw.tag, 16), + summary: cockpitSubjectText(raw.summary, 160), + }; +} + +/** + * A provider's HUD view model, normalized; never throws. Texts are coerced + * and cut to their caps, lists to their lengths, tones to + * COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent. + * Null when reading `raw` throws (a getter runs provider code), so the caller + * keeps the view it has, as when `hud()` itself throws. + * All text is shown as given: the provider words it in the page's language. + * @param {unknown} raw What `hud()` returned. + * @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value, + * unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}], + * sources: [{id, label, text, tone}], + * route: {title, from, to, status}|null, + * progress: {ticks: [{at, tone, label: {text, detail, priority, + * align}|null}], marker: {at, dwell}|null, tag, summary}|null, + * banner: {title, detail, tone}|null, veil: {level, streaks}|null, + * lookReadout, signals: [{id, tone, title, detail}]}`. + */ +export function cockpitSubjectHudView(raw) { + try { + return hudViewOf(raw); + } catch { + // A getter on what hud() returned threw: the caller keeps its last view. + return null; + } +} + +function hudViewOf(raw) { + const view = isObject(raw) ? raw : {}; + const meta = isObject(view.meta) ? view.meta : {}; + const speed = + isObject(view.speed) && + (view.speed.value === null || + (finite(view.speed.value) && view.speed.value >= 0)) + ? { + value: + view.speed.value === null + ? null + : Math.min(view.speed.value, 99_999), + unit: cockpitSubjectText(view.speed.unit, 12), + tag: cockpitSubjectText(view.speed.tag, 8), + tone: tone(view.speed.tone), + } + : null; + const route = isObject(view.route) + ? { + title: cockpitSubjectText(view.route.title, 32), + from: cockpitSubjectText(view.route.from, 48), + to: cockpitSubjectText(view.route.to, 48), + status: cockpitSubjectText(view.route.status, 64), + } + : null; + const bannerTitle = isObject(view.banner) + ? cockpitSubjectText(view.banner.title, 48) + : ''; + const veil = + isObject(view.veil) && finite(view.veil.level) + ? { + level: clamp(view.veil.level, 0, 1), + streaks: view.veil.streaks === true, + } + : null; + return { + kicker: cockpitSubjectText(view.kicker, 48), + title: cockpitSubjectText(view.title, 64), + meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) }, + speed, + readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => { + const label = cockpitSubjectText(row.label, 24); + const value = cockpitSubjectText(row.value, 48); + return label || value + ? { + label, + value, + detail: cockpitSubjectText(row.detail, 80), + tone: tone(row.tone), + } + : null; + }), + sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => { + const text = cockpitSubjectText(row.text, 120); + return text + ? { + label: cockpitSubjectText(row.label, 16), + text, + tone: tone(row.tone), + } + : null; + }), + route: route && (route.from || route.to) ? route : null, + progress: progressOf(view.progress), + banner: bannerTitle + ? { + title: bannerTitle, + detail: cockpitSubjectText(view.banner.detail, 96), + tone: tone(view.banner.tone), + } + : null, + veil: veil && veil.level > 0 ? veil : null, + lookReadout: cockpitSubjectText(view.lookReadout, 48), + signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => { + const title = cockpitSubjectText(row.title, 48); + return title + ? { + title, + detail: cockpitSubjectText(row.detail, 96), + tone: tone(row.tone), + } + : null; + }), + }; +} + +/** + * 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, and + * `onSettled`, when given, runs once the promise has settled, so a caller + * can undo work that lands late. + * + * `call.warnOnce(provider, method, message, error)` warns on the same + * once-per-provider-and-method footing, for an answer the caller finds + * malformed. + * @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, + ); + }; + function call( + provider, + method, + args = [], + fallback = null, + onSettled = null, + ) { + let result; + let thenable; + try { + result = provider[method](...args); + // Reading `then` runs provider code too (a getter may throw), so it is + // guarded as the call is. + thenable = + result !== null && + (typeof result === 'object' || typeof result === 'function') && + typeof result.then === 'function'; + } catch (error) { + warnOnce(provider, method, 'failed', error); + return fallback; + } + if (thenable) { + // Its work may still land: `onSettled` runs once it has, either way. + const settled = () => { + if (typeof onSettled === 'function') onSettled(); + }; + Promise.resolve(result).then(settled, settled); + warnOnce( + provider, + method, + 'returned a promise; provider methods are synchronous', + null, + ); + return fallback; + } + return result; + } + call.warnOnce = warnOnce; + return call; +} diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs new file mode 100644 index 000000000..2063cca41 --- /dev/null +++ b/src/cockpitSubjectProviders.test.mjs @@ -0,0 +1,698 @@ +// src/cockpitSubjectProviders.test.mjs — an application's subject providers +// are checked once and what they return is normalized before the Cockpit +// uses it: a malformed provider is left out, a malformed frame ends the view, +// and a malformed HUD view is read field by field and never thrown on. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COCKPIT_BUILT_IN_KINDS, + COCKPIT_SUBJECT_MAX_READOUTS, + COCKPIT_SUBJECT_MAX_TICKS, + cockpitSubjectCandidate, + cockpitSubjectFrame, + cockpitSubjectHudView, + cockpitSubjectProviders, + cockpitSubjectText, + createCockpitSubjectCaller, +} from './cockpitSubjectProviders.js'; + +const noop = () => null; +/** A well-formed provider, with `overrides`. */ +const provider = (overrides = {}) => ({ + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + }, + candidate: noop, + hold: () => true, + read: noop, + hud: noop, + release: noop, + ...overrides, +}); + +const keep = (list) => { + const warnings = []; + const kept = cockpitSubjectProviders(list, { + warn: (message) => warnings.push(message), + }); + return { kept, warnings }; +}; + +test('well-formed providers are kept, frozen, in priority then list order', () => { + const { kept, warnings } = keep([ + provider({ kind: 'late', priority: 5 }), + provider({ kind: 'first-of-two', priority: 1 }), + provider({ kind: 'second-of-two', priority: 1 }), + provider({ kind: 'default-priority' }), + ]); + assert.deepEqual(warnings, []); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['default-priority', 'first-of-two', 'second-of-two', 'late'], + ); + assert.ok(Object.isFrozen(kept)); + assert.ok(Object.isFrozen(kept[0])); + const [first] = kept; + assert.equal(first.priority, 0); + assert.equal(first.look, null); + assert.equal(first.cameraIntervalMs, null); + assert.equal(first.headingSlewDps, null); + assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows'); + assert.deepEqual(first.labels, { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + kicker: '', + }); +}); + +test('malformed, reserved and repeated providers are left out with one warning each', () => { + const cases = [ + [null, /not an object/], + [provider({ kind: 'X' }), /malformed kind/], + [provider({ kind: 'a' }), /malformed kind/], + [provider({ layerId: 'Bad Layer' }), /malformed layer id/], + [provider({ hold: undefined }), /no hold\(\)/], + [provider({ read: 'read' }), /no read\(\)/], + [provider({ isEntryAllowed: true }), /isEntryAllowed/], + [provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/], + [provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/], + [provider({ labels: { view: 'v', exit: 'e' } }), /labels/], + [provider({ look: { restPitchDeg: -3 } }), /aheadLabel/], + ]; + for (const [entry, reason] of cases) { + const { kept, warnings } = keep([entry]); + assert.equal(kept.length, 0, String(reason)); + assert.equal(warnings.length, 1, String(reason)); + assert.match(warnings[0], reason); + } + for (const kind of COCKPIT_BUILT_IN_KINDS) { + const { kept, warnings } = keep([provider({ kind })]); + assert.equal(kept.length, 0, kind); + assert.match(warnings[0], /reserved kind/); + } + const { kept, warnings } = keep([provider(), provider()]); + assert.equal(kept.length, 1); + assert.match(warnings[0], /repeated kind/); + assert.deepEqual(keep('not a list').kept, []); +}); + +test('optional settings are kept when in range and defaulted otherwise', () => { + const [ranged] = keep([ + provider({ + priority: -2, + look: { restPitchDeg: -120, aheadLabel: 'Look ahead' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + labels: { + view: 'v', + exit: 'e', + exitText: 'x', + kicker: 'K'.repeat(80), + }, + }), + ]).kept; + assert.equal(ranged.priority, -2); + assert.deepEqual(ranged.look, { + restPitchDeg: -90, + aheadLabel: 'Look ahead', + }); + assert.equal(ranged.cameraIntervalMs, 0); + assert.equal(ranged.headingSlewDps, Infinity); + assert.equal(ranged.labels.kicker.length, 48); + assert.ok(ranged.labels.kicker.endsWith('…')); + const [defaulted] = keep([ + provider({ + priority: NaN, + cameraIntervalMs: 5000, + headingSlewDps: -1, + }), + ]).kept; + assert.equal(defaulted.priority, 0); + assert.equal(defaulted.cameraIntervalMs, null); + assert.equal(defaulted.headingSlewDps, null); +}); + +test('a candidate needs an id; its name and face are read with care', () => { + assert.equal(cockpitSubjectCandidate(null), null); + assert.equal(cockpitSubjectCandidate({ id: 7 }), null); + assert.equal(cockpitSubjectCandidate({ id: ' ' }), null); + assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null); + assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), { + 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 ', + face: { icon: 'tram', label: 'RIDE' }, + }); + assert.equal(read.name, 'Example 12'); + assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' }); + assert.equal( + cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face, + null, + ); +}); + +test('a frame is the held subject at a finite place with an eye height, else null', () => { + const frame = (overrides = {}) => ({ + id: 'a1', + latitude: 47, + longitude: 8, + eye: { upM: 3.5 }, + ...overrides, + }); + const read = cockpitSubjectFrame(frame(), 'a1'); + assert.deepEqual(read, { + id: 'a1', + name: 'a1', + latitude: 47, + longitude: 8, + heightM: null, + headingDeg: null, + courseDeg: null, + speedMps: null, + stale: false, + groundClamp: true, + // No speed to run an inertial anchor on: it sits on the subject. + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 }, + }); + assert.equal( + cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor, + 'inertial', + 'with a speed it runs on', + ); + // A held id is trimmed (cockpitSubjectCandidate), and so is a frame's. + assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1'); + for (const bad of [ + null, + frame({ id: 'other' }), + frame({ latitude: 91 }), + frame({ latitude: NaN }), + frame({ longitude: Infinity }), + frame({ eye: null }), + frame({ eye: { upM: -1 } }), + frame({ eye: { upM: 2000 } }), + frame({ eye: { upM: '3' } }), + ]) + assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad)); + const full = cockpitSubjectFrame( + frame({ + name: 'Example', + longitude: 190, + heightM: 412.5, + headingDeg: -10, + courseDeg: 725, + speedMps: 22, + stale: true, + groundClamp: false, + anchor: 'exact', + eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 }, + }), + 'a1', + ); + 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); + assert.equal(full.stale, true); + assert.equal(full.groundClamp, false); + assert.equal(full.anchor, 'exact'); + assert.deepEqual(full.eye, { + upM: 2, + forwardM: 0, + pitchDeg: -90, + minClearanceM: 1.5, + }); + // Only exactly false turns the ground clamp off; course falls back to heading. + const loose = cockpitSubjectFrame( + frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }), + 'a1', + ); + assert.equal(loose.groundClamp, true); + assert.equal( + cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor, + 'inertial', + ); + assert.equal(loose.courseDeg, 90); + assert.equal(loose.speedMps, null); +}); + +test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => { + const empty = cockpitSubjectHudView(null); + assert.deepEqual(empty, { + kicker: '', + title: '', + meta: { text: '', tone: 'normal' }, + speed: null, + readouts: [], + sources: [], + route: null, + progress: null, + banner: null, + veil: null, + lookReadout: '', + signals: [], + }); + for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }]) + assert.doesNotThrow(() => cockpitSubjectHudView(odd)); + const view = cockpitSubjectHudView({ + kicker: 'LINE · OPERATOR', + title: 'Name → Destination', + meta: { text: 'LIVE', tone: 'bogus' }, + speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + ...Array.from({ length: 6 }, (_, i) => ({ + id: `r${i}`, + label: 'NEXT', + value: `Stop ${i}`, + tone: 'warning', + })), + ], + sources: [ + { id: 'track', label: 'TRACK', text: 'operator shape' }, + { id: 'track', label: 'TRACK', text: 'repeated id' }, + { id: 'Bad Id', text: 'refused' }, + { id: 'empty', label: 'X', text: '' }, + ], + route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' }, + banner: { title: 'BANNER', detail: 'detail', tone: 'alert' }, + veil: { level: 4, streaks: 1 }, + lookReadout: 'LOOK RIGHT', + signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }], + }); + assert.equal(view.meta.tone, 'normal'); + assert.deepEqual(view.speed, { + value: 84.4, + unit: 'km/h', + tag: 'LIVE', + tone: 'live', + }); + assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS); + assert.equal(view.readouts[0].tone, 'warning'); + assert.deepEqual( + view.sources.map(({ id }) => id), + ['track'], + ); + assert.deepEqual(view.route, { + title: 'ROUTE', + from: 'A', + to: 'B', + status: '3 stops', + }); + assert.equal(view.banner.tone, 'alert'); + assert.deepEqual(view.veil, { level: 1, streaks: false }); + assert.equal(view.lookReadout, 'LOOK RIGHT'); + assert.equal(view.signals[0].tone, 'warning'); + // A veil at 0, a speed below 0 and a route with no ends are absent. + const absent = cockpitSubjectHudView({ + veil: { level: 0 }, + speed: { value: -3 }, + route: { from: '', to: '' }, + }); + assert.equal(absent.veil, null); + assert.equal(absent.speed, null); + assert.equal(absent.route, null); + // A speed of null is a speed not yet known. + assert.equal( + cockpitSubjectHudView({ speed: { value: null } }).speed.value, + null, + ); +}); + +test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => { + const read = cockpitSubjectHudView({ + progress: { + ticks: [ + { at: 1, label: { text: 'End', align: 'end', priority: 1 } }, + { at: 0.5, tone: 'warning' }, + { at: 2 }, + { at: -0.1 }, + { at: 0, label: { text: 'Start', align: 'start', priority: 12 } }, + { at: 0.25, label: { text: '', detail: 'no text, no label' } }, + ], + marker: { at: 1.4, dwell: true }, + tag: 'TIMETABLE', + summary: 'From Start to End', + }, + }).progress; + assert.deepEqual( + read.ticks.map(({ at }) => at), + [0, 0.25, 0.5, 1], + ); + assert.equal(read.ticks[0].label.priority, 9); + assert.equal(read.ticks[0].label.align, 'start'); + assert.equal(read.ticks[1].label, null); + assert.equal(read.ticks[2].tone, 'warning'); + assert.deepEqual(read.marker, { at: 1, dwell: true }); + assert.equal(read.tag, 'TIMETABLE'); + assert.equal(read.summary, 'From Start to End'); + assert.equal( + cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress, + null, + ); + const many = cockpitSubjectHudView({ + progress: { + ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({ + at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40), + })), + }, + }).progress; + assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS); +}); + +test('texts are trimmed, collapsed and cut with an ellipsis', () => { + assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b'); + assert.equal(cockpitSubjectText('abcdef', 4), 'abc…'); + assert.equal(cockpitSubjectText(12.5, 10), '12.5'); + assert.equal(cockpitSubjectText({}, 10), ''); + assert.equal(cockpitSubjectText(NaN, 10), ''); +}); + +test('a cut never splits a surrogate pair', () => { + // The cut would fall between the emoji's halves: it is left out whole. + assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…'); + // One that fits before the cut is kept whole. + assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…'); + const lone = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const failing = { + kind: 'example-kind', + read() { + throw new Error('boom'); + }, + hud: (value) => value * 2, + }; + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'hud', [21]), 42); + 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('an answer whose `then` throws when read is handled as a throw', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const tricky = { + kind: 'example-kind', + hold: () => ({ + get then() { + throw new Error('no then'); + }, + }), + }; + assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /example-kind hold\(\) failed/); +}); + +test('the caller’s warnOnce shares its once-per-provider-and-method record', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const ride = { + kind: 'example-kind', + hold() { + throw new Error('boom'); + }, + }; + call.warnOnce(ride, 'read', 'is odd'); + call.warnOnce(ride, 'read', 'is odd again'); + call(ride, 'hold', ['a1']); + call.warnOnce(ride, 'hold', 'is odd'); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider example-kind read() is odd', + '[Cockpit] subject provider example-kind hold() failed', + ]); +}); + +test('a class-based provider keeps its `this`, in its methods and its worded members', () => { + class Ride { + #word = 'RIDE'; + #held = null; + kind = 'example-kind'; + layerId = 'example-layer'; + speak(word) { + this.#word = word; + } + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: 'EXIT RIDE', + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: this.#word }; + } + isEntryAllowed(candidate) { + return candidate.id === 'a1' && this.#held === null; + } + hold(id) { + this.#held = id; + return true; + } + read() { + return this.#held; + } + hud(frame) { + return { title: `${this.#word} ${frame}` }; + } + release() { + this.#held = null; + return 'released'; + } + } + const instance = new Ride(); + const { kept, warnings } = keep([instance]); + assert.deepEqual(warnings, []); + const [ride] = kept; + assert.equal(ride.entryFace.label, 'RIDE'); + assert.equal(ride.labels.view, 'RIDE view'); + assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE'); + instance.speak('FAHRT'); + assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance'); + assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT'); + // Called on the kept copy, as the Cockpit calls them. + const failures = []; + const call = createCockpitSubjectCaller({ + warn: (message) => failures.push(message), + }); + assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' }); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), true); + assert.equal(call(ride, 'hold', ['a1']), true); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), false); + assert.equal(call(ride, 'read', [0, {}]), 'a1'); + assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' }); + assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released'); + assert.equal(call(ride, 'read', [0, {}]), null); + assert.deepEqual(failures, []); +}); + +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); +}); + +/** `target` with a getter for `key` that throws (provider code runs on a read). */ +const throwingGetter = (target, key) => + Object.defineProperty(target, key, { + enumerable: true, + get() { + throw new Error(`${key} unavailable`); + }, + }); + +test('a provider whose getter throws is left out with one warning; the next is kept', () => { + const { kept, warnings } = keep([ + throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'), + throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'), + throwingGetter(provider({ kind: 'thrown-look' }), 'look'), + throwingGetter(provider(), 'kind'), + throwingGetter(provider({ kind: 'thrown-method' }), 'read'), + // Read only once the provider is accepted, so its kind must stay free. + throwingGetter(provider({ kind: 'example-kind' }), 'priority'), + provider({ kind: 'example-kind' }), + ]); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['example-kind'], + ); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider thrown-face left out: malformed entry face', + '[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText', + '[Cockpit] subject provider thrown-look left out: a member threw when read', + '[Cockpit] subject provider #3 left out: a member threw when read', + '[Cockpit] subject provider thrown-method left out: a member threw when read', + '[Cockpit] subject provider example-kind left out: a member threw when read', + ]); +}); + +test('a worded getter that starts throwing keeps the last good words', () => { + let failing = false; + const words = (value) => () => { + if (failing) throw new Error('translations unavailable'); + return value; + }; + const definition = provider(); + for (const [key, value] of Object.entries({ + labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' }, + entryFace: { icon: 'commute', label: 'RIDE' }, + look: { restPitchDeg: 10, aheadLabel: 'Look ahead' }, + })) + Object.defineProperty(definition, key, { get: words(value) }); + const { kept, warnings } = keep([definition]); + assert.deepEqual(warnings, []); + const [ride] = kept; + failing = true; + assert.equal(ride.labels.view, 'Ride view'); + assert.equal(ride.entryFace.label, 'RIDE'); + assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' }); + // So does a worded object whose own member throws when read. + failing = false; + Object.defineProperty(definition, 'labels', { + get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'), + }); + assert.equal(ride.labels.view, 'Ride view'); +}); + +test('a candidate, frame or HUD view whose getter throws reads as null', () => { + assert.equal( + cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')), + null, + ); + const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } }; + assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed'); + assert.equal( + cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'), + null, + ); + assert.equal( + cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'), + null, + ); + assert.equal( + cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')), + null, + ); + assert.equal( + cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }), + null, + ); + // Nothing to read is still the empty view, never null. + assert.equal(cockpitSubjectHudView(null).title, ''); +}); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 6e3238ec5..ec6b1f32c 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -481,6 +481,24 @@ export function orbitEntryAllowed({ ); } +/** + * Whether an application subject provider's candidate can be ridden. Like the + * bridge and orbit it needs only its own layer on, and waits out a Context + * change in flight; the provider then has its own say (`isEntryAllowed`). + * @param {{layerEnabled: boolean, contextModeChanging: boolean, + * providerAllows: boolean}} state + * @returns {boolean} + */ +export function subjectEntryAllowed({ + layerEnabled, + contextModeChanging, + providerAllows, +}) { + return ( + Boolean(layerEnabled) && !contextModeChanging && providerAllows === true + ); +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js new file mode 100644 index 000000000..80cfc1daa --- /dev/null +++ b/src/layers/rail/cockpitSubject.js @@ -0,0 +1,340 @@ +import { pointAtS } from '../../data/railPaths.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { displayMotion } from '../transit/movement.js'; + +/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */ +const lines = new WeakMap(); + +/** + * A line as a snapshot hands it out: a frozen copy of the layer's path, its + * number arrays frozen arrays, built once per line. `pointAtS` and + * `projectOnPath` read it as they read the path, and nothing written to it + * reaches the layer. + */ +function lineOf(path) { + let line = lines.get(path); + if (!line) { + const copy = {}; + for (const [name, value] of Object.entries(path)) + copy[name] = ArrayBuffer.isView(value) + ? Object.freeze(Array.from(value)) + : value; + line = Object.freeze(copy); + lines.set(path, line); + } + return line; +} + +const NO_STOPS = Object.freeze([]); +/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ +const stopLists = new WeakMap(); + +/** Whether `copy` still holds each of `list`'s stops, field by field. */ +function sameStops(copy, list) { + if (copy.length !== list.length) return false; + for (let i = 0; i < list.length; i++) { + const stop = list[i]; + const kept = copy[i]; + if ( + !stop || + typeof stop !== 'object' || + !kept || + typeof kept !== 'object' + ) { + if (!Object.is(stop, kept)) return false; + continue; + } + const names = Object.keys(stop); + if (names.length !== Object.keys(kept).length) return false; + for (const name of names) + if (!Object.is(stop[name], kept[name])) return false; + } + return true; +} + +/** + * A trip's stops as a snapshot hands them out: a frozen array of frozen + * copies, kept per list the stops provider returns while that list still + * holds the same stops, so a provider that updates its list in place is + * copied again. + */ +function stopsOf(list) { + if (!Array.isArray(list)) return NO_STOPS; + let stops = stopLists.get(list); + if (!stops || !sameStops(stops, list)) { + stops = Object.freeze( + list.map((stop) => + stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop, + ), + ); + stopLists.set(list, stops); + } + return stops; +} + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one train, and a hold on it for a first-person view. + * + * `getSelectedTrain()` and `getCockpitSubject()` describe the selected train + * and the held one as a frozen snapshot, read again on each call: where it is + * along its line, how fast it moves and how that is known, and its stops. A + * call while nothing in it changed returns the snapshot it returned before, + * and a part that did not change (its feed, its line, its stops) is the same + * object in the next one, so a view can read it on every frame. `path.line` + * is a frozen copy of the train's line for `pointAtS` and `projectOnPath`. + * `setCockpitSubject(key)` holds a train: its marker, 3D model, card and + * selected line are hidden, it stays selected (so its stops stay fresh and its feed stays + * polled at any distance) and clicks and Escape no longer change the + * selection. A held train keeps moving, hidden, and keeps the selection when + * its category is turned off, and is the copy of its train drawn though + * another feed's copy would outrank it (dedupe.js). Released, it is a train + * like any other: one in a category turned off is deselected and stops, and + * a better copy takes over at the next poll. 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. + * A snapshot's `selectedAtMs` is when the user last selected the selected + * train (a click, or `focusTrain`), even again, for a view's entry to tell a + * re-selection. It is null for any other train, after a deselection, and for + * a train selected only by a hold: a hold is not a user's selection. + * + * @param {object} context The layer's state, parts, services and timetable. + * @returns {{methods: object}} + */ +export function createRailCockpitSubject({ + state, + parts, + services, + timetable, +}) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const scratch = {}; + /** Each train's last snapshot, returned again while nothing in it changed. */ + const views = new WeakMap(); + /** The drafts trainView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + path: {}, + motion: {}, + provenance: {}, + }; + + /** The catalog's public entry for a feed, else the registry's. */ + function feedOf(feedId, previous) { + const listed = state._catalog?.find((feed) => feed.id === feedId); + const feed = listed ?? getRegisteredRailFeed(feedId); + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.positions = listed?.positions ?? feed.positions ?? 'gps'; + next.tripUpdates = + typeof listed?.tripUpdates === 'boolean' + ? listed.tripUpdates + : typeof feed.tripUpdatesUrl === 'string'; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + /** The route's short (else long) name in the feed's timetable, or null. */ + function routeNameOf(entry) { + const routeId = entry.record?.routeId; + if (!routeId) return null; + const schedule = timetable?.scheduleFor?.(entry.feedId); + const route = schedule?.routes?.[routeId]; + const name = route?.short || route?.long; + return typeof name === 'string' && name ? name : null; + } + + /** + * Which way the train runs along its shape: as its binding learnt it from + * successive reports, else from its drawn course against the line's + * bearing there; 0 while neither tells. + */ + function directionOf(entry) { + const binding = entry.path; + if (binding.direction === 1 || binding.direction === -1) + return binding.direction; + const course = finiteOrNull(entry.courseDeg); + const point = pointAtS(binding.path, binding.drawS, scratch); + if (course === null || !Number.isFinite(point?.bearingDeg)) return 0; + const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180); + return turn > 90 ? -1 : 1; + } + + /** The train as a frozen snapshot (see the module comment), or null. */ + function trainView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const drawn = entry.drawnHeight ?? null; + const binding = entry.path ?? null; + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const trip = state._selectedKey === entry.key ? state._trip : null; + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = + finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = + finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon; + position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null; + position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0; + position.atMs = finiteOrNull(drawn?.at); + + let path = null; + if (binding && binding.path) { + path = draft.path; + path.shapeIndex = binding.shapeIndex; + path.s = binding.drawS; + path.direction = directionOf(entry); + path.lengthM = binding.path.length; + path.via = binding.via; + path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null; + path.line = lineOf(binding.path); + } + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const provenance = draft.provenance; + provenance.positionSource = record.positionSource ?? 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.tripId = record.tripId || null; + view.routeId = record.routeId || null; + view.routeName = routeNameOf(entry); + view.label = record.label || null; + view.trainNumber = record.trainNumber || null; + view.headsign = trip?.headsign || null; + view.category = entry.category ?? null; + view.mode = entry.railMode ?? null; + view.feed = feedOf(entry.feedId, previous?.feed); + view.position = reuse(previous?.position, position); + view.path = path && reuse(previous?.path, path); + view.motion = reuse(previous?.motion, motion); + view.provenance = reuse(previous?.provenance, provenance); + view.stops = stopsOf(trip?.stops); + view.match = trip?.match ?? null; + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** A train's marker and playback again, after a hold on it began or ended. */ + function replay(entry) { + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + } + + const methods = { + /** The selected train (see createRailCockpitSubject), or null. */ + getSelectedTrain() { + return trainView(state._trains.get(state._selectedKey)); + }, + /** The held train, or null when none is held. */ + getCockpitSubject() { + return trainView(state._trains.get(state._cockpitSubjectKey)); + }, + /** + * Hold the train `key` for a first-person view, or release the hold with + * null. A train it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a key the layer does not list. A listed + * train is held even when its category is off or another feed's copy + * is drawn: the held copy is then the one drawn. Releasing returns true. + */ + setCockpitSubject(key) { + const previous = state._trains.get(state._cockpitSubjectKey); + if (key === null || key === undefined) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; + if (previous) { + // Its category turned off meanwhile: deselected, as the toggle + // does to any other train (controls.js). + if ( + previous.key === state._selectedKey && + state._categories[previous.category] === false + ) + parts.selection.clearSelection(); + replay(previous); + } + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + } + const entry = state._trains.get(key); + if (!entry) return false; + if (state._selectedKey !== key) { + parts.selection.selectTrain(key); + // A hold is not a selection the user made (selectedAtMs). + state._selectedAtMs = null; + } + state._cockpitSubjectKey = key; + state._cockpitLostFeedId = null; + // Another feed's copy drew it: the held copy is drawn now (dedupe.js). + if (entry.duplicate === true) parts.ingestion.duplicates?.refresh(); + if (previous && previous !== entry) replay(previous); + replay(entry); + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + }, + /** + * Select the train `key` and hand it the camera, as a click on it does. + * @param {string} key + * @returns {boolean} False for a train the layer does not show: gone, in + * a category turned off, or a copy another feed draws (dedupe.js). + */ + focusTrain(key) { + const entry = state._trains.get(key); + if (!entry || !parts.rendering.categoryShown(entry)) return false; + parts.selection.selectAndFocusTrain(key); + return true; + }, + }; + return { methods, trainView }; +} diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs new file mode 100644 index 000000000..4700e71e6 --- /dev/null +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -0,0 +1,504 @@ +// The Rail layer's read API for a first-person view: the selected or held +// train as a snapshot (where it is along its line and which way it runs, its +// speed and how that is known, its feed's credit, its stops), and a hold that +// hides its marker and card, keeps it selected and ends when it goes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_POLL_MS, + STOP_REFRESH_MS, + railTrainFollowable, + railTrainKey, +} from './policy.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js'; +import { publicRailCatalog } from '../../data/railFeeds.js'; +import { pointAtS } from '../../data/railPaths.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; + +const M_PER_DEG = 111_320; +const LAT = 42.36; +const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180); +const LON0 = -71.08; + +/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */ +function eastLine(lengthM) { + const out = []; + for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX); + return out; +} + +const PACK = Object.freeze({ + version: 1, + feedId: 'mbta-rail', + builtAt: '2026-09-09T00:00:00.000Z', + shapes: [eastLine(6000)], + dist: [[]], + shapeSources: ['operator'], + trips: { 'trip-T1': 0, 'trip-T2': 0 }, + routes: { R1: [0] }, +}); + +/** Two trains on the line, T1 running east and T2 west, a step a poll. */ +async function onTheLine(t, options = {}) { + const app = harness(t, { + paths: { 'mbta-rail': PACK }, + at: BOSTON, + ...options, + }); + let step = 0; + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }), + train('T2', LAT, LON0 + (5000 - step * 150) / KX), + ]); + await app.enable(); + for (let i = 0; i < 4; i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + return app; +} + +const T1 = railTrainKey('mbta-rail', 'T1'); +const T2 = railTrainKey('mbta-rail', 'T2'); + +test('the selected train reads as a snapshot of its place, motion and sources', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected'); + app.parts().selection.selectTrain(T1); + const view = app.layer.getSelectedTrain(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, T1); + assert.equal(view.feedId, 'mbta-rail'); + assert.equal(view.id, 'T1'); + assert.equal(view.tripId, 'trip-T1'); + assert.equal(view.routeId, 'R1'); + assert.equal(view.category, 'regional'); + assert.equal(view.mode, 'rail'); + const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail'); + assert.equal(view.feed.attribution, listed.attribution); + assert.equal(view.feed.license, listed.license); + assert.equal(view.feed.licenseUrl, listed.licenseUrl); + assert.equal(view.feed.positions, 'gps'); + assert.equal(view.path.shapeIndex, 0); + assert.equal(view.path.builtAt, PACK.builtAt); + assert.equal(view.path.via, 'trip'); + assert.ok(Math.abs(view.path.lengthM - 6000) < 1); + assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`); + assert.equal(view.path.direction, 1, 'runs along the shape'); + assert.equal(typeof view.path.line.length, 'number'); + assert.equal(view.motion.reportedSpeedMps, 15); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.equal(view.provenance.timestampSource, 'vehicle'); + const drawn = app.entry(T1).drawnHeight; + assert.equal(view.position.railHeightM, drawn.h - 1.5); + assert.ok(Number.isFinite(view.position.latitude)); + assert.ok(Array.isArray(view.stops)); + + app.parts().selection.selectTrain(T2); + const west = app.layer.getSelectedTrain(); + assert.equal(west.path.direction, -1, 'runs against the shape'); + assert.equal(west.motion.reportedSpeedMps, null); + assert.equal( + west.motion.speedSource, + west.motion.derivedSpeedMps === null ? null : 'derived', + ); +}); + +/** A trip's stops as a stops provider lists them. */ +const stopList = (...names) => + names.map((name, i) => ({ + stopId: name, + name, + lat: LAT, + lon: LON0 + (i * 2000) / KX, + state: 'upcoming', + })); + +test('a snapshot read again while nothing changed is the one read before', async (t) => { + let list = stopList('Alpha', 'Bravo', 'Charlie'); + const app = await onTheLine(t, { + stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) }, + }); + app.parts().selection.selectTrain(T1); + const first = app.layer.getSelectedTrain(); + assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot'); + // Its stops are frozen copies of the provider's. + assert.equal(first.stops.length, 3); + assert.ok(Object.isFrozen(first.stops)); + assert.ok(Object.isFrozen(first.stops[0])); + assert.notEqual(first.stops[0], list[0]); + assert.equal(first.stops[0].name, 'Alpha'); + + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + const moved = app.layer.getSelectedTrain(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path)); + assert.ok(moved.path.s > first.path.s, 'at its new place'); + assert.equal(moved.stops, first.stops, 'the same stops'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.provenance, first.provenance); + assert.equal(moved.path.line, first.path.line); + assert.equal(app.layer.getSelectedTrain(), moved); + + // The provider lists other stops: new ones. + list = stopList('Delta', 'Echo'); + app.run(STOP_REFRESH_MS); + const later = app.layer.getSelectedTrain(); + assert.notEqual(later.stops, first.stops); + assert.deepEqual( + later.stops.map((stop) => stop.name), + ['Delta', 'Echo'], + ); + // A provider that updates its list in place: the stops are copied again. + list[0].state = 'passed'; + app.run(STOP_REFRESH_MS); + const updated = app.layer.getSelectedTrain(); + assert.notEqual(updated.stops, later.stops); + assert.equal(updated.stops[0].state, 'passed'); + assert.equal(app.layer.getSelectedTrain().stops, updated.stops); +}); + +test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => { + const app = await onTheLine(t); + app.parts().selection.selectTrain(T1); + const { line, s } = app.layer.getSelectedTrain().path; + const own = app.entry(T1).path.path; + assert.notEqual(line, own, 'not the layer’s own'); + assert.ok(Object.isFrozen(line)); + for (const name of ['lat', 'lon', 'x', 'y', 's', 'box']) + assert.ok(Object.isFrozen(line[name]), name); + const lat = own.lat[1]; + assert.throws(() => { + line.lat[1] = 0; + }, TypeError); + assert.equal(own.lat[1], lat, 'the layer’s line is as it was'); + assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same'); + // Copied once per line: T2 runs on the same one. + app.parts().selection.selectTrain(T2); + assert.equal(app.layer.getSelectedTrain().path.line, line); +}); + +test('a held train hides its marker and card and keeps the selection', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._selectedKey, T1, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, T1); + app.run(1000); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, true); + // From inside the train its selected line would run under the eye. + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // Escape is the view's while it holds the train. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, T1); + // It still moves. + const before = app.layer.getCockpitSubject().path.s; + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.getCockpitSubject(), null); + app.run(1000); + assert.equal(app.entry(T1).marker.show, true); + assert.equal(app.state()._route.primitive.show, true, 'the line is back'); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'the card is back', + ); +}); + +test('a held train draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 }); + // A model's load answers off the frame; the next frames place it. + const frames = async () => { + app.run(500); + await app.settle(); + app.run(500); + }; + await frames(); + assert.equal(models.drawn(T1).length, 1, 'near the camera: its model'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(T1), true); + app.run(250); + // The view's eye is inside the train. + assert.equal(models.drawn(T1).length, 0, 'no model'); + assert.equal(app.entry(T1).modelOwnsVisual, false); + assert.equal(app.entry(T1).marker.show, false, 'and no icon'); + assert.equal(models.drawn(T2).length, 1, 'another train keeps its model'); + await frames(); + assert.equal(models.drawn(T1).length, 0, 'not on a later frame either'); + assert.equal(app.entry(T1).marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(T1).length, 1, 'let go: its model again'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false); +}); + +const MADRID = { lat: 40.4168, lon: -3.7038 }; + +test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy, held, and a poll later a + // GPS copy that would outrank it. + let step = 0; + let gpsCopy = false; + const lat = (offset) => MADRID.lat + offset + step * 0.003; + app.feeds.set('renfe-cercanias', () => [ + train('A1', lat(0), MADRID.lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving'); + assert.equal(app.layer.setCockpitSubject(a), true); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + // The view knows the train by its key, and the copies stand apart: the + // held copy stays drawn and selected, the other is the one hidden. + assert.equal(app.layer.getCockpitSubject()?.key, a); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(a).marker.show, false); + assert.equal(app.entry(b).marker.show, false, 'no other marker on it'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(a)), 'still playing'); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(2000); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + + // Let go: the better copy takes over at the next poll, with the selection. + app.layer.setCockpitSubject(null); + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.entry(a).duplicate, true); + assert.equal(app.entry(b).duplicate, false); + assert.equal(app.state()._selectedKey, b); + assert.equal(app.entry(b).marker.show, true); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'its card', + ); + // The copy hidden is not focused: the selection stays on the one drawn. + assert.equal(app.layer.focusTrain(a), false); + assert.equal(app.state()._selectedKey, b); + + // A hold of the copy hidden draws it at once, in place of the other. + assert.equal(app.layer.setCockpitSubject(a), true); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(b).marker.show, false); + assert.ok(app.state()._moving.has(app.entry(a)), 'playing'); +}); + +test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.run(1000); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing'); + app.layer.setParams({ regional: false }); + assert.equal(app.state()._selectedKey, T1, 'still selected'); + assert.equal(app.layer.getCockpitSubject()?.key, T1); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, false, 'its category is hidden'); + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing'); + const before = app.layer.getCockpitSubject().path.s; + app.run(2000); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + assert.equal(app.entry(T1).marker.show, false, 'and still hidden'); + + // Let go: a train of a hidden category, deselected and stopped as the + // toggle would have left it. + app.layer.setCockpitSubject(null); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + assert.equal(app.entry(T1).marker.show, false); + const parked = app.entry(T1).path.drawS; + app.run(2000); + assert.equal(app.entry(T1).path.drawS, parked, 'stopped'); + // Held again, hidden, it plays at once; let go, it stops again. + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once'); + app.layer.setCockpitSubject(null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + // Shown again, it is drawn and moves as any other. + app.layer.setParams({ regional: true }); + assert.equal(app.entry(T1).marker.show, true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again'); +}); + +test('a ride let go with restore does not focus a train its category hides', async (t) => { + const app = await onTheLine(t); + // The documented provider: hold on entry; on exit, release, then focus + // the train again when asked to restore. + assert.equal(app.layer.setCockpitSubject(T1), true); + app.layer.setParams({ regional: false }); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.focusTrain(T1), false, 'not drawn'); + assert.equal(app.state()._selectedKey, null, 'not selected'); + assert.equal(app.parts().follow.stats(), null, 'not followed'); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.length, 0, 'not flown to'); + assert.equal(app.entry(T1).marker.show, false); + + // Shown again, it focuses as before. + app.layer.setParams({ regional: true }); + assert.equal(app.layer.focusTrain(T1), true); + assert.equal(app.state()._selectedKey, T1); + assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it'); +}); + +test('a held train that goes takes the hold with it; focusTrain selects', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.focusTrain(T1), false); + assert.equal(app.layer.focusTrain(T2), true); + assert.equal(app.state()._selectedKey, T2); +}); + +test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + // From the cab the camera looks along the ground, here past the feed's range. + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train'); + const others = () => [train('T2', LAT, LON0 + 4000 / KX)]; + app.feeds.set('mbta-rail', others); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + await app.recheck(); + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled'); + await app.moveCamera({ lat: 40.72, lon: -74.0 }); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera'); + + // Listed again: the view holds it again. + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }), + ...others(), + ]); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.state()._selectedKey, T1); + + // Let go, and the feed is the viewport's again: out of range, released. + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.setCockpitSubject(null); + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._activeFeeds.has('mbta-rail'), false); + assert.equal(app.state()._trains.size, 0); +}); + +test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.focusTrain(T1), true); + const first = app.layer.getSelectedTrain().selectedAtMs; + assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it'); + // Selected again: a later stamp. + app.run(1000); + app.layer.focusTrain(T1); + assert.ok(app.layer.getSelectedTrain().selectedAtMs > first); + // A hold of another train is not the user's selection. + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().key, T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + // Nor after a deselection (Escape), then a hold. + app.layer.focusTrain(T1); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null, 'deselection clears it'); + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + // Releasing twice is harmless: the selection stays. + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.state()._selectedKey, T2); + assert.equal(app.layer.getCockpitSubject(), null); +}); + +test('turning the layer off ends the hold', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.disable(app.viewer); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js index 0899ed8e9..f937cd6ae 100644 --- a/src/layers/rail/controls.js +++ b/src/layers/rail/controls.js @@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) { return true; state._categories = next; const selected = state._trains.get(state._selectedKey); - if (selected && next[selected.category] === false) + // A held train keeps the selection until its view lets it go + // (cockpitSubject.js). + if ( + selected && + next[selected.category] === false && + selected.key !== state._cockpitSubjectKey + ) parts.selection.clearSelection(); parts.rendering.applyCategoryVisibility(); state._revision += 1; diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js index e1ce79f78..f699c0c67 100644 --- a/src/layers/rail/dedupe.js +++ b/src/layers/rail/dedupe.js @@ -25,9 +25,13 @@ import { greatCircleKm } from '../../geoDistance.js'; * 2. the line: a copy bound to its operator's shape (a built pack), then * one without. * The copy drawn stays drawn until another scores strictly higher, so equal - * copies never trade places. A losing copy keeps its playback and is only - * hidden; when a feed leaves the viewport its copies' partners come back. - * The comparison runs once per poll, never per frame. + * copies never trade places. A copy a first-person view holds + * (cockpitSubject.js) stays drawn whatever the scores until the view lets + * go: the view knows the train by that copy's key and rides it where that + * copy's feed puts it, tens of kilometres from another feed's copy at + * times. A losing copy keeps its playback and is only hidden; when a feed + * leaves the viewport its copies' partners come back. The comparison runs + * once per poll, never per frame. */ /** @@ -288,10 +292,12 @@ function better(a, b) { * scores strictly higher. * @param {object[][]} groups From matchCopies. * @param {Map} [previous] copy key → key drawn last time. + * @param {string|null} [held] A copy drawn whatever the scores: the train a + * first-person view holds. * @returns {{winners: Map, hidden: Set}} * `winners`: every grouped copy's key → the key drawn for its train. */ -export function chooseCopies(groups, previous = new Map()) { +export function chooseCopies(groups, previous = new Map(), held = null) { const winners = new Map(); const hidden = new Set(); for (const group of groups) { @@ -305,6 +311,7 @@ export function chooseCopies(groups, previous = new Map()) { scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); for (const candidate of scored) if (better(candidate.score, best.score)) best = candidate; + best = scored.find(({ copy }) => copy.key === held) ?? best; for (const { copy } of scored) { winners.set(copy.key, best.copy.key); if (copy.key !== best.copy.key) hidden.add(copy.key); @@ -318,7 +325,8 @@ export function chooseCopies(groups, previous = new Map()) { * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and * moves a selection that sat on a copy now hidden to the copy drawn, so the * card follows the same physical train (its feed line names the new - * source). + * source). A held copy is never the one hidden, so the hold and the + * selection stay on it. * @param {{state: object, parts: object, * defer?: (fn: Function) => unknown}} context */ @@ -355,6 +363,7 @@ export function createDuplicateFilter({ const { winners, hidden } = chooseCopies( matchCopies(entries.map(copyOf)), previous, + state._cockpitSubjectKey ?? null, ); previous = winners; for (const entry of entries) { diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs index ac3f61f6b..0d6538d3c 100644 --- a/src/layers/rail/dedupe.test.mjs +++ b/src/layers/rail/dedupe.test.mjs @@ -252,6 +252,32 @@ test('the filter hides the weaker copy and the selection follows its train', () assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); }); +test('a copy a first-person view holds stays drawn, with the selection', () => { + const pair = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'gps' }), + ]; + assert.equal(chooseCopies([pair]).winners.get('a'), 'b'); + const held = chooseCopies([pair], new Map(), 'a'); + assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores'); + assert.deepEqual([...held.hidden], ['b']); + // Let go, the better copy takes over. + assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b'); + + const layer = fakeLayer({ selected: 'feed-a:v160' }); + layer.state._cockpitSubjectKey = 'feed-a:v160'; + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + filter.refresh(); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, false, 'the held copy'); + assert.equal(operator.duplicate, true); + assert.deepEqual(layer.calls.selected, [], 'the selection stays on it'); + layer.state._cockpitSubjectKey = null; + filter.refresh(); + assert.equal(unbound.duplicate, true); + assert.deepEqual(layer.calls.selected, [operator.key]); +}); + test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) assert.equal(recordTrainNumber({ label }), null, label); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 0a191c62a..ec6322fc3 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -7,6 +7,7 @@ import { createIngestion } from './ingestion.js'; import { createViewport } from './viewport.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createRailCockpitSubject } from './cockpitSubject.js'; import { createRouteDisplay } from './routeDisplay.js'; import { createRailSchedules } from './schedules.js'; import { getRegisteredRailFeed } from '../../data/railFeeds.js'; @@ -78,12 +79,24 @@ 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. */ _follow: null, /** A feed polled only for the selected train (viewport.js), or null. */ _heldFeedId: null, + /** + * The train a first-person view holds (cockpitSubject.js), or null: its + * marker, 3D model and card are hidden and the selection stays on it. + */ + _cockpitSubjectKey: null, + /** + * The feed of a held train the feed dropped (ingestion.js), polled until + * the view takes a train again or lets go (cockpitSubject.js), or null. + */ + _cockpitLostFeedId: null, _revision: 0, _controlsListener: null, _dataManager: null, @@ -175,11 +188,13 @@ export function createRailLayer({ parts.ingestion = createIngestion(context); parts.viewport = createViewport(context); parts.lifecycle = createLifecycle(context); + parts.cockpit = createRailCockpitSubject(context); return Object.assign( { updateInterval: RAIL_POLL_MS }, parts.lifecycle.methods, parts.controls.methods, + parts.cockpit.methods, { /** Test seams: this instance's own state and parts. */ _railStateForTest: () => state, diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js index 54d885e37..8bd4a342f 100644 --- a/src/layers/rail/ingestion.js +++ b/src/layers/rail/ingestion.js @@ -69,6 +69,13 @@ export function createIngestion({ state, services, parts, source }) { function removeTrain(key) { const entry = state._trains.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js); its + // feed stays polled until the view lets go, so the train is found again + // if the feed lists it again. + if (state._cockpitSubjectKey === key) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = entry.feedId; + } if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) state._markers?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index bcf79fadc..67501df7c 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -126,6 +126,8 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.ingestion.abortAllInFlight(); state._activeFeeds.clear(); state._heldFeedId = null; + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; state._feedStatus.clear(); dropAllTrains(); parts.rendering.stopModels(); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js index 36814c036..9eba452d3 100644 --- a/src/layers/rail/pathBinding.js +++ b/src/layers/rail/pathBinding.js @@ -47,11 +47,31 @@ export function pathWindowM(dtMs) { return window > PATH_WINDOW_MAX_M ? NaN : window; } +/** Arc moved between two reports before it says which way a train runs (m). */ +const DIRECTION_MIN_M = 1; + +/** +1 or -1 for an arc step longer than DIRECTION_MIN_M, else `known`. */ +function directionOf(step, known) { + return Number.isFinite(step) && Math.abs(step) > DIRECTION_MIN_M + ? Math.sign(step) + : known; +} + function createBinding(shapeIndex, path, via, s, fix) { return { shapeIndex, path, via, + /** + * Which way the drawn train runs along the shape: +1 along it, -1 + * against it, 0 until two reports or a playback segment tell. Kept until + * a rebind. Once playback has placed the train, only playback changes + * it: the newest reports run ahead of the drawn train, so a reversal + * they show turns it when playback reaches it. + */ + direction: 0, + /** The direction the last placed playback segment ran, 0 before one. */ + drawDirection: 0, /** Arc and time of the newest report. */ fixS: s, fixT: fix.t, @@ -139,6 +159,8 @@ export function followFix(binding, fix) { pathWindowM(fix.t - binding.fixT), ); if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; + if (!binding.drawDirection) + binding.direction = directionOf(hit.s - binding.fixS, binding.direction); binding.fixS = hit.s; binding.fixT = fix.t; binding.fixLat = fix.lat; @@ -230,6 +252,13 @@ export function placeOnPath(binding, sample, segment, out) { cache.sFrom = sFrom; cache.sTo = sTo; cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); + if (cache.ok) { + binding.drawDirection = directionOf( + sTo - sFrom, + binding.drawDirection || binding.direction || 0, + ); + binding.direction = binding.drawDirection; + } } if (!cache.ok) return false; const f = Number.isFinite(sample.fraction) diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs index c8983a696..7b9e11c0c 100644 --- a/src/layers/rail/pathBinding.test.mjs +++ b/src/layers/rail/pathBinding.test.mjs @@ -201,3 +201,64 @@ test('a segment whose reports do not fit the line keeps the straight line', () = assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false); assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false); }); + +test('a binding learns which way its train runs, and keeps it through a stop', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const along = bindTrainPath(p, ids, fix(0, 500, 0)); + assert.equal(along.direction, 0, 'one report tells no direction'); + assert.ok(followFix(along, fix(10_000, 700, 0))); + assert.equal(along.direction, 1); + // Standing still (under a metre) keeps what it learnt. + assert.ok(followFix(along, fix(20_000, 700.4, 0))); + assert.equal(along.direction, 1); + const against = bindTrainPath(p, ids, fix(0, 1500, 0)); + assert.ok(followFix(against, fix(10_000, 1200, 0))); + assert.equal(against.direction, -1); + // A playback segment between two reports tells it too. + const played = bindTrainPath(p, ids, fix(0, 300, 0)); + const segment = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 100, 0) }, + }; + assert.ok( + placeOnPath(played, { fromSeq: 1, toSeq: 2, fraction: 0.5 }, segment, {}), + ); + assert.equal(played.direction, -1); + // A rebind starts again. + const rebound = rebindTrainPath( + p, + { routeId: 'main' }, + along, + fix(30_000, 2000, -300), + ); + assert.equal(rebound?.direction ?? 0, 0); +}); + +test('a report that runs back turns the drawn train only when playback does', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const binding = bindTrainPath(p, ids, fix(0, 300, 0)); + const forward = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 500, 0) }, + }; + const sample = (fraction) => ({ fromSeq: 1, toSeq: 2, fraction }); + assert.ok(placeOnPath(binding, sample(0.2), forward, {})); + assert.equal(binding.direction, 1); + // The newest report lies behind the last one (a reversal, or GPS jitter) + // while playback still draws the forward segment. + assert.ok(followFix(binding, fix(10_000, 500, 0))); + assert.ok(followFix(binding, fix(20_000, 450, 0))); + assert.ok(placeOnPath(binding, sample(0.6), forward, {})); + assert.equal(binding.direction, 1, 'the drawn train still runs forward'); + // Playback reaches the reversed segment: now it turns. + const back = { + from: { seq: 2, ...fix(10_000, 500, 0) }, + to: { seq: 3, ...fix(20_000, 450, 0) }, + }; + assert.ok( + placeOnPath(binding, { fromSeq: 2, toSeq: 3, fraction: 0.5 }, back, {}), + ); + assert.equal(binding.direction, -1); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index db124b752..21b719980 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -523,9 +523,25 @@ export function createRendering({ state, services, parts }) { ); } - /** Whether a train's icon is drawn: shown, and not drawn by its model. */ + /** + * Whether a train's icon is drawn: shown, not drawn by its model, and no + * first-person view rides it (cockpitSubject.js). A held train keeps its + * playback; only its icon and model hide. + */ function markerShown(entry) { - return categoryShown(entry) && !entry.modelOwnsVisual; + return ( + categoryShown(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey + ); + } + + /** + * Whether the train's playback runs: it is drawn, or a first-person view + * rides it, which keeps it moving, hidden, in a category turned off. + */ + function playing(entry) { + return categoryShown(entry) || entry.key === state._cockpitSubjectKey; } /** @@ -614,7 +630,7 @@ export function createRendering({ state, services, parts }) { place(entry); const sample = entry.sample; const moving = - categoryShown(entry) && + playing(entry) && sample?.phase === 'playing' && sample.segmentSpeedMps > 0; if (moving) { @@ -622,7 +638,7 @@ export function createRendering({ state, services, parts }) { return; } state._moving.delete(entry); - if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { entry.wakeTimer = setTimeout( () => { entry.wakeTimer = null; @@ -744,7 +760,9 @@ export function createRendering({ state, services, parts }) { } item.position = entry.marker.position; item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; - item.show = categoryShown(entry); + // A train a first-person view rides draws no model: the camera is inside + // it (cockpitSubject.js). + item.show = categoryShown(entry) && entry.key !== state._cockpitSubjectKey; return item; } diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js index 283d19295..69c1dc840 100644 --- a/src/layers/rail/routeDisplay.js +++ b/src/layers/rail/routeDisplay.js @@ -427,10 +427,29 @@ export function createRouteDisplay({ updateSplit(); } + /** + * Show the selected line unless a first-person view rides its train + * (cockpitSubject.js): from inside the train it would run up the middle of + * the view and under the eye. + */ + function applyHold() { + const route = state._route; + if (!route?.primitive) return; + const shown = !( + state._cockpitSubjectKey !== null && + state._cockpitSubjectKey === state._selectedKey + ); + if (route.primitive.show !== shown) { + route.primitive.show = shown; + governorRequestRender('rail-route'); + } + } + /** Move the travelled/ahead split to where the train is drawn now. */ function updateSplit() { const route = state._route; if (!route?.primitive) return; + applyHold(); const binding = route.binding; const s = binding ? binding.drawS : NaN; if (route.direction === 0 && Number.isFinite(s)) { @@ -777,6 +796,7 @@ export function createRouteDisplay({ applyNetwork, onCamera, syncRoute, + applyHold, refreshStops, cullStops, onSelectionChanged, diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 8a4dc3a87..e644c26d1 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -35,6 +35,16 @@ export function createSelection({ state, services, parts }) { function refreshSelectedCard(force) { const entry = state._trains.get(state._selectedKey); if (!entry) return; + // A train a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -103,6 +113,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. @@ -120,6 +132,7 @@ export function createSelection({ state, services, parts }) { const hadSelection = state._selectedKey !== null; parts.follow.release({ untrack }); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; if (hadSelection) { parts.rendering.paintAll(); @@ -136,6 +149,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the train, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -146,6 +161,8 @@ export function createSelection({ state, services, parts }) { ); state._clickHandler.setInputAction((click) => { if (!state._enabled || !isPointerFree()) return; + // A held train keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const picked = viewer.scene.pick(click.position); const id = typeof picked?.id === 'string' ? picked.id : null; if (id && state._trains.has(id)) selectAndFocusTrain(id); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js index 03771bd6c..065687d7d 100644 --- a/src/layers/rail/viewport.js +++ b/src/layers/rail/viewport.js @@ -148,6 +148,12 @@ export function createViewport({ state, services, parts, source }) { )) if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } + // A first-person view whose train its feed dropped keeps the feed polled + // until it lets go (cockpitSubject.js): from the cab the camera looks + // along the ground, often at a point beyond the feed's range. + const lost = state._cockpitLostFeedId; + if (lost && !desired.has(lost) && state._activeFeeds.has(lost)) + desired.set(lost, state._activeFeeds.get(lost)); // The selected train's feed stays polled however far out or away the // camera goes, so the train and its line stay on the map; while nothing // else would keep the feed, it carries the selected train alone and its diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js new file mode 100644 index 000000000..d598e572a --- /dev/null +++ b/src/layers/transit/cockpitSubject.js @@ -0,0 +1,242 @@ +import * as Cesium from 'cesium'; +import { + getRegisteredTransitFeed, + transitRouteLabel, +} from '../../data/transitFeeds.js'; +import { displayMotion } from './movement.js'; + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one vehicle, and a hold on it for a first-person view. + * + * `getSelectedVehicle()` and `getCockpitSubject()` describe the selected + * vehicle and the held one as a frozen snapshot, read again on each call: + * where it is drawn, how fast it moves and how that is known. A call while + * nothing in it changed returns the snapshot it returned before, and a part + * that did not change (its feed, its stop) is the same object in the next + * one, so a view can read it on every frame. Transit has no route shapes or + * stops, so a snapshot names only the vehicle's last reported stop. + * `setCockpitSubject(key)` holds a vehicle: its sprite, 3D model, card, + * trail and detection box are hidden, it keeps moving when it is out of the 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. A snapshot's + * `selectedAtMs` is when the user last selected the selected vehicle (a + * click, or `focusVehicle`), even again. It is null for any other vehicle, + * after a deselection, and for a vehicle selected only by a hold: a hold is + * not a user's selection. + * + * @param {object} context The layer's state, parts and services. + * @returns {{methods: object}} + */ +export function createTransitCockpitSubject({ state, parts, services }) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + /** Each vehicle's last snapshot, returned again while nothing changed. */ + const views = new WeakMap(); + /** The drafts vehicleView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + motion: {}, + stop: {}, + provenance: {}, + }; + + /** The feed in use, else the registry's entry for it. */ + function feedEntry(feedId) { + return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId); + } + + function feedOf(feed, previous) { + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + const scratchCartographic = new Cesium.Cartographic(); + /** + * The floor under the vehicle where playback draws it, without the shared + * lift: the drawn marker's height while the frame pass places it (in view, + * or held), which near the ground follows the terrain between reports; + * else its segment's two reports' floors at the playback fraction. Null + * while either floor is unknown, as on cold terrain, so a view waits for + * the surface. Not `entry.heightM`, the floor at the newest report, which + * playback has not reached yet. + */ + function drawnFloorOf(entry) { + const segment = entry.segment; + if (!segment || entry.heightPending) return null; + const placed = + entry.surfaceReady === true && + entry.hasRendered && + entry.marker?.position && + (state._visible.has(entry) || entry.key === state._cockpitSubjectKey); + if (placed) { + const drawn = Cesium.Cartographic.fromCartesian( + entry.marker.position, + undefined, + scratchCartographic, + ); + if (drawn) return drawn.height - liftM; + } + const from = segment.from?.h; + const to = segment.to?.h; + if (!Number.isFinite(from) || !Number.isFinite(to)) return null; + const f = Number.isFinite(segment.fraction) + ? Math.max(0, Math.min(1, segment.fraction)) + : 1; + return from + (to - from) * f - liftM; + } + + /** The vehicle as a frozen snapshot (see the module comment), or null. */ + function vehicleView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const feed = feedEntry(entry.feedId); + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = finiteOrNull(sample?.lon) ?? record.lon; + position.groundHeightM = drawnFloorOf(entry); + position.atMs = finiteOrNull(sample?.displayT); + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.reportedBearingDeg = finiteOrNull(record.bearing); + motion.segmentCourseDeg = finiteOrNull(sample?.segmentCourseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const stop = draft.stop; + stop.stopId = record.stopId || null; + stop.status = record.status || null; + + const provenance = draft.provenance; + provenance.positionSource = 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.routeId = record.routeId || null; + view.routeLabel = + (feed && record.routeId + ? transitRouteLabel(feed, record.routeId) + : null) || null; + view.tripId = record.tripId || null; + view.directionId = record.directionId ?? null; + view.label = record.label || null; + view.mode = entry.mode ?? null; + view.modeInferred = entry.modeInferred === true; + view.feed = feedOf(feed, previous?.feed); + view.position = reuse(previous?.position, position); + view.motion = reuse(previous?.motion, motion); + view.stop = reuse(previous?.stop, stop); + view.provenance = reuse(previous?.provenance, provenance); + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** Show or hide a vehicle's sprite and trail for the hold. */ + function repaint(entry) { + if (!entry?.marker) return; + if (entry.key === state._cockpitSubjectKey) entry.marker.show = false; + else parts.rendering.placeSample(entry); + parts.rendering.schedulePlayback(entry); + parts.trails.update(); + state._detectRevision += 1; + } + + const methods = { + /** The selected vehicle (see createTransitCockpitSubject), or null. */ + getSelectedVehicle() { + return vehicleView(state._vehicles.get(state._selectedKey)); + }, + /** The held vehicle, or null when none is held. */ + getCockpitSubject() { + return vehicleView(state._vehicles.get(state._cockpitSubjectKey)); + }, + /** + * Hold the vehicle `key` for a first-person view, or release the hold + * with null. A vehicle it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + setCockpitSubject(key) { + 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; + } + const entry = state._vehicles.get(key); + if (!entry?.marker) return false; + if (state._selectedKey !== key) parts.selection.selectVehicle(key); + state._cockpitSubjectKey = key; + state._visible.add(entry); + if (previous && previous !== entry) repaint(previous); + repaint(entry); + parts.selection.refreshSelectedCard(true); + return true; + }, + /** + * Select the vehicle `key`, as a click on it does. + * @param {string} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + focusVehicle(key) { + if (!state._vehicles.get(key)?.marker) return false; + parts.selection.selectVehicle(key, { byUser: true }); + return true; + }, + }; + return { methods, vehicleView }; +} diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 171e0fb82..e6bf1ffa7 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -10,6 +10,7 @@ import { createQueries } from './queries.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { createTesting } from './testing.js'; +import { createTransitCockpitSubject } from './cockpitSubject.js'; import { TRANSIT_POLL_MS } from './policy.js'; /** @@ -54,12 +55,14 @@ export function createTransitLayer({ parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); + parts.cockpit = createTransitCockpitSubject(context); return Object.assign( { updateInterval: TRANSIT_POLL_MS }, parts.lifecycle.methods, parts.queries.methods, parts.controls.methods, + parts.cockpit.methods, parts.testing, ); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index afce6a4aa..3f5669c65 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -71,6 +71,8 @@ export function createIngestion({ state, services, parts, source }) { function removeVehicle(key) { const entry = state._vehicles.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js). + if (state._cockpitSubjectKey === key) state._cockpitSubjectKey = null; if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) entry.markerCollection?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 2b24f51de..ad5a4d109 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ state, services, parts }) { state._vehicles.clear(); parts.height.clear(); state._selectedKey = null; + state._cockpitSubjectKey = null; state._lastUpdate = null; state._error = null; state._limitWarned = false; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index fee9a60ed..71d9d1df4 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -7,9 +7,11 @@ import { FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, TRANSIT_POLL_MS, + TRANSIT_SELECTED_OVERLAY_SOURCE_ID, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; import { FLOOR_WARM_PER_POLL } from './policy.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; import { FUTURE_STAMP_TOLERANCE_MS, ORDER_REJECTS_BEFORE_RESET, @@ -65,6 +67,25 @@ function snapshot( }; } +/** A camera's place and frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const positionWC = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + positionWC, + new Cesium.Cartesian3(), + ); + return { + positionWC, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A vehicle record as the proxy emits it, with its timestamp source resolved. */ function vehicle(id, lat, lon, atSeconds, extra = {}) { return { @@ -159,6 +180,9 @@ function harness( east: at.lon + 0.1, north: at.lat + 0.1, }, + // Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera + // looking straight down for them; without them vehicles stay icons. + fleet = null, } = {}, ) { const viewRect = { ...view }; @@ -219,6 +243,7 @@ function harness( viewRect.north, ); }, + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -265,6 +290,7 @@ function harness( const layer = createTransitLayer({ source, services: { + ...(fleet ? { fleet } : {}), ground, mesh: { sampleMeshFloorCells(scene, points, options) { @@ -727,6 +753,27 @@ test('disable aborts in-flight work and a response that lands late is ignored', assert.equal(app.vehicles().length, 1); }); +test('a held vehicle with no floor yet tells its view no height', async (t) => { + // Not the ellipsoid: a first-person view waits for the surface instead. + const app = harness(t, { floorAt: () => undefined }); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + const entry = app.vehicles()[0]; + assert.equal(entry.heightPending, true, 'it has nothing to stand on'); + assert.ok(app.layer.setCockpitSubject(entry.key) !== false); + assert.equal(app.layer.getCockpitSubject().position.groundHeightM, null); +}); + test('a vehicle waits for its floor rather than standing on the ellipsoid', async (t) => { // The shared ground-floor system answers from the rendered mesh cell or the // real DEM cell; until one of them is warm there is no honest height to draw @@ -1774,11 +1821,19 @@ test('a segment climbs from the ground it left to the ground it reaches', async assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill'); const height = () => Cesium.Cartographic.fromCartesian(entry.marker.position).height; + // A first-person view reads the floor where the vehicle is drawn, not the + // newest report's floor uphill. + app.layer.setCockpitSubject(entry.key); + const floor = () => app.layer.getCockpitSubject().position.groundHeightM; app.run(400); assert.ok( height() < 30, `still on the ground it is drawn on, got ${height().toFixed(1)} m`, ); + assert.ok( + Math.abs(floor() + 1.5 - height()) < 1, + `its snapshot stands where it is drawn, got ${floor().toFixed(1)} m`, + ); // Halfway along the segment it is halfway up; at the end, on the high ground. const firstFixAt = start - AGE_MS; @@ -1792,6 +1847,7 @@ test('a segment climbs from the ground it left to the ground it reaches', async Math.abs(height() - 101.5) < 1.5, `and arrives on the high ground, got ${height().toFixed(1)} m`, ); + assert.ok(Math.abs(floor() - 100) < 1.5, 'and so does its snapshot'); }); test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => { const app = harness(t, { floorAt: () => 5 }); @@ -4142,3 +4198,209 @@ test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) 'Fly below 2,850 km to a covered region', ); }); + +/** A bus running north a step a poll, polled `polls` times; its key. */ +async function busRunningNorth(app, polls = 4, extra = {}) { + let step = 0; + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [ + vehicle('bus-1', 42.36 + step * 0.001, -71.06, reported(), { + stopId: 'place-1', + status: 'IN_TRANSIT_TO', + ...extra, + }), + ], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + for (let i = 0; i < polls; i += 1) { + step += 1; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + } + return 'mbta:bus-1'; +} + +test('the selected vehicle reads as a snapshot of its place, motion and source', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app, 4, { speedMps: 9 }); + assert.equal(app.layer.getSelectedVehicle(), null); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const view = app.layer.getSelectedVehicle(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, key); + assert.equal(view.feedId, 'mbta'); + assert.equal(view.id, 'bus-1'); + assert.equal(view.routeId, '1'); + assert.equal(typeof view.mode, 'string'); + assert.ok(view.feed.attribution, 'the feed names its credit'); + assert.equal(view.motion.reportedSpeedMps, 9); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.deepEqual(view.stop, { stopId: 'place-1', status: 'IN_TRANSIT_TO' }); + const entry = app.vehicles()[0]; + // Where it is drawn, without the shared lift (read back from the marker). + assert.ok( + Math.abs(view.position.groundHeightM - (entry.heightM - 1.5)) < 1e-3, + ); + assert.ok(Number.isFinite(view.position.latitude)); +}); + +test('a vehicle read again while nothing changed is the snapshot read before', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const first = app.layer.getSelectedVehicle(); + assert.equal(app.layer.getSelectedVehicle(), first, 'the same snapshot'); + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + const moved = app.layer.getSelectedVehicle(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.position)); + assert.ok(moved.position.latitude > first.position.latitude, 'moved'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.stop, first.stop); + assert.equal(moved.provenance, first.provenance); + assert.equal(app.layer.getSelectedVehicle(), moved); +}); + +test('a held vehicle draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = harness(t, { fleet: models.fleet, altitude: 1_000 }); + const key = await busRunningNorth(app); + const [entry] = app.vehicles(); + assert.notEqual(entry.mode, 'unknown', 'a mode its model is chosen by'); + // The throttled sweep runs; a model's load answers off the frame and the + // next frames place it. + const frames = async () => { + app.advance(250); + app.settle(); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + app.settle(); + }; + await frames(); + assert.equal(models.drawn(key).length, 1, 'near the camera: its model'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(key), true); + app.frame(); + // The view's eye is inside the vehicle. + assert.equal(models.drawn(key).length, 0, 'no model'); + assert.equal(entry.modelOwnsVisual, false); + assert.equal(entry.marker.show, false, 'and no icon'); + await frames(); + assert.equal(models.drawn(key).length, 0, 'not on a later frame either'); + assert.equal(entry.marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(key).length, 1, 'let go: its model again'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false); +}); + +test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + // A vehicle only a hold selected reports none. + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless'); + assert.equal(app.state()._selectedKey, key); + // The user's selection stamps it, again on a re-selection. + assert.equal(app.layer.focusVehicle(key), true); + const first = app.layer.getSelectedVehicle().selectedAtMs; + assert.ok(Number.isFinite(first)); + app.run(1000); + app.layer.focusVehicle(key); + assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first); + // Deselected, then held: none. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null); + app.layer.setCockpitSubject(key); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); +}); + +test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + assert.equal(app.layer.setCockpitSubject('mbta:nope'), false); + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.state()._selectedKey, key, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, key); + assert.equal( + app.overlaySources.has(TRANSIT_SELECTED_OVERLAY_SOURCE_ID), + false, + 'no card', + ); + const [entry] = app.vehicles(); + app.frame(); + assert.equal(entry.marker.show, false); + // The camera's view leaves it, as a view from inside it does: it plays on. + app.lookAt({ west: 10, south: 10, east: 11, north: 11 }); + app.settle(); + assert.equal( + app.layer._transitPartsForTest().rendering.vehicleInView(entry), + true, + ); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + // Escape and clicks are the view's while it holds the vehicle. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, key); + + 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); + assert.equal(app.layer.focusVehicle(key), true); + + app.layer.setCockpitSubject(key); + app.silence('mbta'); + app.serve('mbta', () => ({ + status: 200, + body: snapshot('mbta', 'MBTA', [], { fetchedAt: Date.now() }), + })); + for (let i = 0; i < MISSED_POLLS_TO_DROP + 1; i += 1) { + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + } + assert.equal(app.vehicles().length, 0, 'the vehicle is gone'); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 0888945a4..058aa3a5d 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -106,8 +106,12 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; + // A vehicle a first-person view rides keeps moving, unseen. entry.marker.show = - !!known && vehicleInView(entry) && !entry.modelOwnsVisual; + !!known && + vehicleInView(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey; if (known) { entry.marker.position = state._scratchCartesian; entry.hasRendered = true; @@ -198,7 +202,7 @@ export function createRendering({ state, services, parts }) { * @returns {boolean} */ function vehicleInView(entry) { - return state._visible.has(entry); + return state._visible.has(entry) || entry.key === state._cockpitSubjectKey; } /** Cesium colour for a preset tuple, cached per profile+mode. */ @@ -489,7 +493,7 @@ export function createRendering({ state, services, parts }) { }); } - /** Hand an entry's visual back to its icon. */ + /** Hand an entry's visual back to its icon, unless a view rides it. */ function handBack(entry) { if (!entry.modelOwnsVisual) return; entry.modelOwnsVisual = false; @@ -497,7 +501,8 @@ export function createRendering({ state, services, parts }) { entry.marker.show = vehicleInView(entry) && !entry.heightPending && - entry.surfaceReady !== false; + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; } function updateModels() { @@ -534,7 +539,12 @@ export function createRendering({ state, services, parts }) { } item.position = entry.marker.position; item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; - item.show = !entry.heightPending && entry.surfaceReady !== false; + // A vehicle a first-person view rides draws no model: the camera is + // inside it (cockpitSubject.js). + item.show = + !entry.heightPending && + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; next.set(entry.key, item); } for (const [key, item] of modelItems) @@ -641,7 +651,10 @@ export function createRendering({ state, services, parts }) { } else if (frustum) inFrustum = frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE; - const visible = (frustum || bounds) && horizon && inFrustum; + // A held vehicle (cockpitSubject.js) is the camera's own: it stays in + // play wherever the view looks. + const held = entry.key === state._cockpitSubjectKey; + const visible = held || ((frustum || bounds) && horizon && inFrustum); const visibility = (entry.visibility ||= {}); visibility.bounds = bounds; visibility.boundsApplied = !frustum; @@ -666,6 +679,7 @@ export function createRendering({ state, services, parts }) { } } entry.marker.show = + !held && visible && !entry.heightPending && entry.surfaceReady !== false && diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index d557b1b99..26bf024c1 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -48,6 +48,12 @@ export function createSelection({ state, services, parts }) { ? state._vehicles.get(state._selectedKey) : null; if (!entry) return; + // A vehicle a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -93,6 +99,7 @@ export function createSelection({ state, services, parts }) { : null; parts.trails.clear(); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; state._detectRevision += 1; // Cleared first: the styling path reads the selection from state. @@ -100,11 +107,18 @@ export function createSelection({ state, services, parts }) { state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); } - function selectVehicle(key) { + /** + * Select a vehicle. `byUser` marks a selection the user made (a click, or + * focusVehicle), even of the selected vehicle again: its time is the + * snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold + * selects without it. + */ + function selectVehicle(key, { byUser = false } = {}) { clearSelection(); const entry = state._vehicles.get(key); if (!entry?.marker) return; state._selectedKey = key; + if (byUser) state._selectedAtMs = Date.now(); state._detectRevision += 1; parts.rendering.paintSelected(entry, true); parts.trails.select(entry); @@ -113,6 +127,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the vehicle, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -125,6 +141,8 @@ export function createSelection({ state, services, parts }) { // Ambient selection never competes for a pointer a tool has claimed: // placing a draw vertex on top of a bus must not also select the bus. if (!isPointerFree()) return; + // A held vehicle keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const scene = viewer.scene; // Pick buffers precede post-processing. Map a sensor pixel back through // the active shader's barrel distortion and quantization before picking. @@ -181,11 +199,11 @@ export function createSelection({ state, services, parts }) { typeof primitiveId === 'string' && state._vehicles.has(primitiveId) ) { - selectVehicle(primitiveId); + selectVehicle(primitiveId, { byUser: true }); return; } if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) { - selectVehicle(picked.id); + selectVehicle(picked.id, { byUser: true }); return; } } diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index b58f181d5..da21a08d1 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -95,6 +95,15 @@ 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, 3D model, card, trail and detection box are hidden, and it keeps + * moving out of the camera's view. + * @type {string|null} + */ + state._cockpitSubjectKey = null; state._selectedCardAt = 0; /** The card text last published, so an unchanged card is not re-sent. */ state._selectedCardText = null; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 47973c1a2..4080766ee 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -235,8 +235,11 @@ export function createTrails({ state, services, parts, source }) { } function update() { if (!selected || !renderer) return; + // A first-person view on the vehicle rides where its trail would start. renderer.setVisible( - state._enabled && state._vehicles.get(selected.key) === selected, + state._enabled && + state._vehicles.get(selected.key) === selected && + selected.key !== state._cockpitSubjectKey, ); renderer.setDisplaySample(selected.sample, selected.marker?.position); } diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 38315e4ae..ba8f25a5d 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -101,6 +101,10 @@ export function createViewport({ state, services, parts }) { if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } } + // A vehicle a first-person view rides keeps its feed polled. + const held = state._vehicles.get(state._cockpitSubjectKey); + if (held && state._activeFeeds.has(held.feedId)) + desired.set(held.feedId, state._activeFeeds.get(held.feedId)); for (const feedId of [...state._activeFeeds.keys()]) { if (!desired.has(feedId)) { state._inFlight.get(feedId)?.controller.abort(); diff --git a/src/testSupport/fleetModelFakes.mjs b/src/testSupport/fleetModelFakes.mjs new file mode 100644 index 000000000..3a51e994a --- /dev/null +++ b/src/testSupport/fleetModelFakes.mjs @@ -0,0 +1,68 @@ +// Fleet models for a layer test (fleetModelInstances.js): the project's model +// set loaded from public/, a DISPLAY 3D toggle that is on, and Cesium's glTF +// loader replaced by one that answers at once with a ready model, recorded. +import * as Cesium from 'cesium'; +import { loadFleetModelSet } from '../data/fleetModels.js'; +import { fetchPublicFile } from './fleetGlb.mjs'; + +/** A model policy whose eligibility is read again on every frame. */ +export const FLEET_TEST_POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 4, + capAll: 8, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 0, +}); + +/** + * Load the fleet set (once a process, as the app does) and fake the loader + * for this test. Call it before the layer's harness, which replaces `fetch`. + * @param {import('node:test').TestContext} t + * @returns {Promise<{fleet: object, created: object[], + * drawn: (id: string) => object[]}>} `fleet` for the layer's services; + * `drawn(id)` the models drawn for the vehicle `id` now. + */ +export async function fleetModelFakes(t) { + const info = console.info; + console.info = () => {}; + try { + await loadFleetModelSet({ fetchImpl: fetchPublicFile }); + } finally { + console.info = info; + } + const created = []; + t.mock.method(Cesium.Model, 'fromGltfAsync', async (options) => { + const model = { + ...options, + ready: true, + show: false, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; + created.push(model); + return model; + }); + return { + fleet: { + policy: FLEET_TEST_POLICY, + display: () => ({ models3d: true, models3dMode: 'proximity' }), + resolveAsset: (url) => url, + }, + created, + drawn: (id) => + created.filter( + (model) => model.id === id && !model.destroyed && model.show, + ), + }; +} diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs index 5f7a2ae62..ddfcba030 100644 --- a/src/testSupport/railLayerHarness.mjs +++ b/src/testSupport/railLayerHarness.mjs @@ -12,6 +12,24 @@ import { publicRailCatalog } from '../data/railFeeds.js'; export const RAIL_HARNESS_T0 = 1_789_000_000_000; export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 }); +/** A camera's frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const position = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + return { + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A train as the rail proxy sends it. */ export function railTrain(id, lat, lon, extra = {}) { return { @@ -64,7 +82,7 @@ const headersOf = (values = {}) => ({ * catalog?: object[]|Promise|Function|false, * ground?: object, * stops?: object|null, scheduleStops?: boolean, - * paths?: Record|null}} [options] + * paths?: Record|null, fleet?: object|null}} [options] * `ground`: a floor service in place of the always-cold stub; `catalog`: * the server's feed catalog (it may list fewer feeds than are bundled; a * promise answers when it settles, a function is called per request and @@ -74,7 +92,9 @@ const headersOf = (values = {}) => ({ * layer's own provider, its schedule tier, in its place; * `paths`: feed id → the path pack the proxy serves, or a * failure status it answers with (404 for any other feed; without it the - * source has no path route at all). + * source has no path route at all); `fleet`: the fleet-model services + * (testSupport/fleetModelFakes.mjs), with a camera that looks straight + * down for them (none by default: trains stay icons). */ export function railLayerHarness( t, @@ -86,6 +106,7 @@ export function railLayerHarness( stops = null, scheduleStops = false, paths = null, + fleet = null, } = {}, ) { const T0 = RAIL_HARNESS_T0; @@ -179,6 +200,8 @@ export function railLayerHarness( cancelFlight() { this.flightsCancelled += 1; }, + // What the fleet models' eligibility reads: a camera looking down. + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -308,6 +331,7 @@ export function railLayerHarness( cachedGroundFloor: () => null, warmGroundFloor() {}, }, + ...(fleet ? { fleet } : {}), camera: { framing: WORLD_FOCUS_FRAMING.train, requestWorldFocus(detail) { diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 8122bda29..1fd86824f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade { owner: 'cockpit-orbit', switchPolicy: 'esri-if-photoreal', }) ?? null, + // The application's own first-person subjects + // (src/cockpitSubjectProviders.js), checked by the coordinator. + cockpitSubjects: services.cockpitSubjects, }, elements: { _ppToggles: this._ppToggles, diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index e2046d72f..85678ad1f 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index e97b7f232..ed66369b9 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -3,6 +3,8 @@ import * as Cesium from 'cesium'; import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, + easeCockpitLift, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading, @@ -16,6 +18,7 @@ import { waterlineProbePoints, } from '../cockpitSubjects.js'; import { easeLook } from '../cockpitLook.js'; +import { cockpitSubjectFrame } from '../cockpitSubjectProviders.js'; import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, @@ -53,6 +56,10 @@ export function update() { } const nowMs = performance.now(); + if (this.subjectProvider) { + updateSubject.call(this, nowMs); + return; + } if (this.subjectKind === 'vessel') { updateVessel.call(this, nowMs); return; @@ -297,9 +304,128 @@ function updateVessel(nowMs) { ); } +/** + * Drive the camera from an application provider's subject: read its frame, + * stand the eye `eye.upM` above `heightM` (the shared floor where the frame + * gives none) and slew toward its heading. A frame that is null, malformed or + * for another subject ends the view, as an aircraft tracked meanwhile does. + * The update runs on the provider's `cameraIntervalMs` (0: every frame), and + * its rates advance by the time that interval spans (cockpitSubjectStepSec). + */ +function updateSubject(nowMs) { + const provider = this.subjectProvider; + if (this.viewer.trackedEntity?.position) { + this.exit({ restoreTracking: false }); + return; + } + const intervalMs = provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS; + if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, intervalMs)) return; + this.lastCameraUpdateMs = nowMs; + const frame = cockpitSubjectFrame( + this.callSubject(provider, 'read', [nowMs, this.subjectContext()], null), + this.subjectId, + ); + if (!frame) { + this.exit({ restoreTracking: false }); + return; + } + this.lastSubjectFrame = frame; + let heightM = frame.heightM; + if (heightM === null) { + this.services.warmGroundFloor?.([ + { lat: frame.latitude, lon: frame.longitude }, + ]); + const floorM = this.services.cachedGroundFloor?.( + frame.latitude, + frame.longitude, + ); + // 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, its HUD saying so, rather than + // standing on the ellipsoid (kilometres underground on a plateau). The + // wait is bounded as the built-in views' is. Unlike a parked aircraft, + // whose fallback is its own reported altitude, such a subject has no + // height at all to fall back to: any number would be a guess, so the + // view ends and the provider gets its subject back. + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if ( + cockpitSurfaceWaitExpired( + nowMs, + this.surfaceWaitStartedMs, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, + ) + ) { + this.exit({ restoreTracking: false }); + return; + } + this.surfaceAcquiring = true; + if ( + cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS) + ) { + this.lastHudUpdateMs = nowMs; + this.updateHud(this.subjectInfo(frame), nowMs); + } + return; + } + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.subjectHeightM = heightM; + const target = Cesium.Cartesian3.fromDegrees( + frame.longitude, + frame.latitude, + heightM, + Cesium.Ellipsoid.WGS84, + this.scratchTarget, + ); + const dtSec = cockpitSubjectStepSec(nowMs - this.lastFrameMs, intervalMs); + this.lastFrameMs = nowMs; + if (frame.headingDeg !== null) { + const rate = provider.headingSlewDps ?? COCKPIT_HEADING_SLEW_DPS; + this.heading = + rate === Infinity + ? frame.headingDeg + : slewHeading( + this.heading ?? frame.headingDeg, + frame.headingDeg, + rate * dtSec, + ); + } + let look = null; + if (provider.look) { + this.lookView = easeLook( + this.lookView, + this.lookTarget, + nowMs - (this.lookLastMs || nowMs), + ); + this.lookLastMs = nowMs; + look = this.lookView; + } + placeCamera.call( + this, + target, + this.subjectInfo(frame), + nowMs, + dtSec, + { + forwardM: frame.eye.forwardM, + upM: frame.eye.upM, + pitchDeg: frame.eye.pitchDeg, + minClearanceM: frame.eye.minClearanceM, + }, + look, + ); +} + /** * Place the camera for one update: advance the inertial anchor, clamp it and * the eye against the rendered surface, and look along the slewed heading. + * A subject with `anchor: 'exact'` keeps the anchor on its target (its layer + * already moves it smoothly), and one with `groundClamp: false` (inside a + * structure, say) is never pushed up to the floor: a lift it had eases out. * @param {Cesium.Cartesian3} target Authoritative subject position. * @param {object} info Subject info; `motionCourse` (when present) is the * direction the subject moves, otherwise it moves where it faces. @@ -316,6 +442,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; + this.subjectLiftM = null; } // First-person motion cannot use the delayed feed correction as a raw @@ -339,7 +466,8 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { ? Math.max(0, info.velocityMps) : 0; - if (info.stale) { + const groundClamp = info.groundClamp !== false; + if (info.stale || info.anchor === 'exact') { // A feed backoff has no authoritative velocity epoch to advance from. // Hold the cockpit on the exact layer-rendered position so the camera // cannot coast away while the icon correctly remains fixed. @@ -377,10 +505,13 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { const correctionDistanceM = Cesium.Cartesian3.magnitude( this.scratchCorrection, ); + // An application subject's step is already bounded by its own update + // interval (cockpitSubjectStepSec); the built-in views keep the 0.1 s. const correctionStepM = cockpitAnchorCorrectionStep( correctionDistanceM, speedMps, dtSec, + info.generic === true ? dtSec : undefined, ); if (correctionStepM > 0 && correctionDistanceM > 0) { Cesium.Cartesian3.multiplyByScalar( @@ -498,7 +629,14 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { + // An application subject's anchor is where its provider puts it; only its + // eye is kept out of the floor, below. + if ( + groundClamp && + info.generic !== true && + anchorCartographic && + Number.isFinite(cockpitFloorM) + ) { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, @@ -579,7 +717,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, ); - if (cameraCartographic) { + if (groundClamp && cameraCartographic) { const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); @@ -589,11 +727,26 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cameraFloorM, ); } - const safeHeightM = cockpitGroundSafeHeight( + let safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, profile.minClearanceM, ); + // An application subject's eye rides its provider's smoothed height: + // the clearance over the coarse, refining floor eases in and out + // rather than jumping at each cell (easeCockpitLift). + if (info.generic === true) { + const hardM = + cockpitGroundSafeHeight(cameraCartographic.height, cockpitFloorM, 0) - + cameraCartographic.height; + this.subjectLiftM = easeCockpitLift( + this.subjectLiftM, + safeHeightM - cameraCartographic.height, + hardM, + dtSec, + ); + safeHeightM = cameraCartographic.height + this.subjectLiftM; + } if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian( @@ -601,6 +754,17 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { this.scratchCamera, ); } + } else if (info.generic === true) { + // Inside a structure the floor holds the eye up no more: the lift it gave + // goes at the rate it came (easeCockpitLift), never at once. + this.subjectLiftM = easeCockpitLift(this.subjectLiftM, 0, 0, dtSec); + if (cameraCartographic && this.subjectLiftM > 0) { + cameraCartographic.height += this.subjectLiftM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian( + cameraCartographic, + this.scratchCamera, + ); + } } this.viewer.camera.setView({ diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index f7f236dea..34ba0a341 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -3,12 +3,18 @@ import { readAircraftInfo, readSatelliteInfo, readVesselInfo, + readSubjectCandidate, readSubjectLayerId, + subjectContext, + subjectInfo, + entryCandidates, + resolveEntry, resolveEntryKind, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, + refreshSubjectWords, publishPresenceView, navigateContext, _adoptTrackedEntity, @@ -100,6 +106,7 @@ import { import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js'; import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js'; import { stepLook } from '../cockpitLook.js'; +import { createCockpitSubjectCaller } from '../cockpitSubjectProviders.js'; import * as Cesium from 'cesium'; /** Whether the user asked for reduced motion: look turns then jump. */ @@ -120,6 +127,8 @@ export class CockpitViewController { isEntryAllowed = null, isBridgeEntryAllowed = null, isOrbitEntryAllowed = null, + subjectProviders = [], + isSubjectEntryAllowed = null, isUtilityDisclosureOpen = null, onEntered = null, onExited = null, @@ -134,10 +143,27 @@ export class CockpitViewController { this.disposed = false; this.active = false; /** - * 'aircraft', 'vessel' or 'satellite' while active; which subject the - * camera rides. + * 'aircraft', 'vessel', 'satellite' or a provider's kind while active; + * which subject the camera rides. */ this.subjectKind = null; + /** + * The application's subject providers (cockpitSubjectProviders.js), + * checked by the coordinator; the one ridden while active, else null. + */ + this.subjectProviders = Array.isArray(subjectProviders) + ? subjectProviders + : []; + this.subjectProvider = null; + /** The ridden provider subject's last frame and normalized HUD view. */ + this.lastSubjectFrame = null; + this.subjectHud = null; + /** The Live Signals rows the provider's HUD view pushed. */ + this.subjectSignalKeys = new Set(); + /** Provider methods run through one guard: a throw warns once. */ + this.callSubject = createCockpitSubjectCaller(); + /** The look's rest pitch for a provider's view; null on a bridge. */ + this.lookRestPitchDeg = null; /** * The vessel subject's id or the satellite's NORAD id; aircraft are * identified by trackedEntity. @@ -160,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 @@ -203,6 +233,11 @@ export class CockpitViewController { typeof isOrbitEntryAllowed === 'function' ? isOrbitEntryAllowed : () => false; + // Nor a provider's subject without a gate for it. + this.isSubjectEntryAllowed = + typeof isSubjectEntryAllowed === 'function' + ? isSubjectEntryAllowed + : () => false; this.isUtilityDisclosureOpen = typeof isUtilityDisclosureOpen === 'function' ? isUtilityDisclosureOpen @@ -246,7 +281,14 @@ export class CockpitViewController { entryAvailable: false, // Which subject the entry button would enter, and the active subject. entryKind: null, + // A provider's entry face, `{icon, label}`; null for the built-ins. + entryFace: null, subjectKind: null, + // While a provider's subject is ridden: its HUD and exit labels, and + // `{aheadLabel}` when it looks around. + subjectGeneric: false, + subjectLabels: null, + subjectLook: null, tr3b: null, context: false, // The Contact and briefing cards' collapse, which their toggles show. @@ -508,6 +550,21 @@ export class CockpitViewController { if (reset && hadFocus) this.mapViewButton?.focus({ preventScroll: true }); return reset; } + readSubjectCandidate(provider) { + return readSubjectCandidate.call(this, provider); + } + subjectContext() { + return subjectContext.call(this); + } + subjectInfo(frame) { + return subjectInfo.call(this, frame); + } + entryCandidates(nowMs) { + return entryCandidates.call(this, nowMs); + } + resolveEntry(nowMs) { + return resolveEntry.call(this, nowMs); + } readSubjectLayerId() { return readSubjectLayerId.call(this); } @@ -529,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); } @@ -568,6 +628,19 @@ export class CockpitViewController { presentLook() { return presentLook.call(this); } + /** Whether the active view looks around: a bridge, or a provider's with `look`. */ + get looksAround() { + return ( + this.active && + (this.subjectKind === 'vessel' || Boolean(this.subjectProvider?.look)) + ); + } + /** Where the look rests: the bridge's pitch, or the provider's. */ + get lookRestPitch() { + return Number.isFinite(this.lookRestPitchDeg) + ? this.lookRestPitchDeg + : VESSEL_BRIDGE_PITCH_DEG; + } /** * Turn the bridge view one arrow step (see `lookKeyStep`). The camera * eases there unless the user prefers reduced motion. @@ -575,7 +648,7 @@ export class CockpitViewController { * @returns {boolean} True when a bridge view was turned. */ turnLook(step) { - if (!this.active || this.subjectKind !== 'vessel' || !step) return false; + if (!this.looksAround || !step) return false; this.lookTarget = stepLook(this.lookTarget, step); if (prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.presentLook(); @@ -588,7 +661,7 @@ export class CockpitViewController { * as entry and exit do. */ resetLook({ immediate = false } = {}) { - this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + this.lookTarget = { yawDeg: 0, pitchDeg: this.lookRestPitch }; if (immediate || prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.lookLastMs = performance.now(); @@ -601,7 +674,7 @@ export class CockpitViewController { * @returns {boolean} True on a bridge. */ lookAhead() { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; const button = document.getElementById('cockpit-look-ahead'); const hadFocus = !!button && button.contains(document.activeElement); this.resetLook(); @@ -718,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/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index c431a2ef5..baf6cc862 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -5,7 +5,9 @@ import { bridgeEntryAllowed, cockpitEntryAllowed, orbitEntryAllowed, + subjectEntryAllowed, } from '../contextModePolicy.js'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -60,8 +62,11 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, acquireOrbitImagery, + cockpitSubjects, } = services; this._cockpitDisplayPortal = null; + // The application's own subjects (services.cockpitSubjects), checked once. + const subjectProviders = cockpitSubjectProviders(cockpitSubjects ?? []); this.cockpitView = new CockpitViewController(viewer, { services: { flightsLayer, @@ -108,6 +113,21 @@ export class CockpitCoordinator { vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), contextModeChanging: this._contextModeChanging, }), + subjectProviders, + // A provider's subject needs its own layer on, no Context change in + // flight, and the provider's own say. + isSubjectEntryAllowed: (provider, candidate) => + subjectEntryAllowed({ + layerEnabled: this._isLayerEnabled(provider.layerId), + contextModeChanging: this._contextModeChanging, + providerAllows: + this.cockpitView?.callSubject( + provider, + 'isEntryAllowed', + [candidate], + false, + ) === true, + }), isOrbitEntryAllowed: () => orbitEntryAllowed({ satellitesEnabled: !!this._dataManager?.isEnabled('satellites'), @@ -197,6 +217,9 @@ export class CockpitCoordinator { this.cockpitView.subjectKind === 'satellite' ) return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit'; + // And in an application's own first-person view. + if (this.cockpitView?.active && this.cockpitView.subjectProvider) + return 'Cockpit is in another first-person view — exit it first, then track an aircraft to enter Cockpit'; if (!this._aircraftLayerEnabled) return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; return null; @@ -216,6 +239,17 @@ export class CockpitCoordinator { active && this.cockpitView?.subjectKind === 'satellite' ? this.cockpitView.readSatelliteInfo?.() : null; + // And a provider's subject in use. + const provided = + active && this.cockpitView?.subjectProvider + ? { + kind: this.cockpitView.subjectProvider.kind, + id: this.cockpitView.subjectId, + layerId: this.cockpitView.subjectProvider.layerId, + callsign: null, + name: this.cockpitView.lastSubjectFrame?.name ?? null, + } + : null; const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off @@ -244,30 +278,32 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: satellite - ? { - kind: 'satellite', - id: satellite.id, - layerId: satellite.layerId, - callsign: null, - name: satellite.name, - } - : vessel + subject: provided + ? provided + : satellite ? { - kind: 'vessel', - id: vessel.id, - layerId: vessel.layerId, + kind: 'satellite', + id: satellite.id, + layerId: satellite.layerId, callsign: null, - name: vessel.name, + name: satellite.name, } - : info + : vessel ? { - kind: 'aircraft', - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.id, + layerId: vessel.layerId, + callsign: null, + name: vessel.name, } - : null, + : info + ? { + kind: 'aircraft', + id: info.icao24 || info.id || null, + layerId: info.layerId || null, + callsign: info.callsign || null, + } + : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index fd5dfb52b..8abebe9e9 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -55,8 +55,11 @@ function fixture( changing = false, 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(); @@ -101,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: () => @@ -113,6 +122,7 @@ function fixture( isTr3b: () => false, holdContinuousRender() {}, releaseContinuousRender() {}, + cockpitSubjects: subjects, }, elements: {}, operations: { @@ -147,6 +157,7 @@ function fixture( 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); }); /** Turn a layer off and tell the Context owner, as the data manager does. */ @@ -171,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'], @@ -567,6 +603,32 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => { assert.match(readoutEntry.textContent, /BRIDGE/); }); +test('without providers the built-in order decides: a vessel selected after an aircraft still reads COCKPIT', async (t) => { + const { $, cockpit, store, selectVessel } = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + 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, 'aircraft'); + assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/); + // And the other way round: an aircraft tracked after the vessel. + const later = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + 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(); @@ -648,3 +710,148 @@ test('turning Satellites off ends the orbital view', (t) => { disable('satellites'); assert.equal(cockpit.active, false); }); + +/** An application provider whose subject sits on `example-layer`. */ +function exampleSubject() { + const released = []; + return { + released, + provider: { + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { view: 'Example view', exit: 'Exit example', exitText: 'EXIT' }, + candidate: () => ({ id: 'a1', name: 'Example 12' }), + hold: () => true, + read: () => ({ + id: 'a1', + name: 'Example 12', + latitude: 30, + longitude: -97, + heightM: 200, + headingDeg: 90, + eye: { upM: 2 }, + }), + hud: () => ({ title: 'Example 12' }), + release: (id, { restore }) => released.push([id, restore]), + }, + }; +} + +test("a provider's subject needs only its own layer, and never a Context change in flight", async (t) => { + const { provider } = exampleSubject(); + let changing = false; + const off = fixture(t, { subjects: [provider] }); + off.cockpit.syncEntry(); + await nextTick(); + assert.equal(off.$('#cockpit-entry').hidden, true, 'its layer is off'); + assert.equal(off.cockpit.enter(), false); + const on = fixture(t, { + enabled: ['example-layer'], + subjects: [provider], + readContext: () => ({ mode: null, changing }), + }); + changing = true; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, true, 'Context is changing'); + changing = false; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, false); + assert.match(on.$('#cockpit-entry').textContent, /RIDE/); + assert.match(on.$('#cockpit-entry-readout').textContent, /commute/); +}); + +test("a provider's view reports its subject, refuses voice entry and ends with its layer", async (t) => { + const { provider, released } = exampleSubject(); + const { cockpit, coordinator, disable } = fixture(t, { + enabled: ['example-layer', 'flights'], + subjects: [provider], + }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const state = coordinator.getCockpitState(); + assert.equal(state.active, true); + assert.deepEqual(state.subject, { + kind: 'example-kind', + id: 'a1', + layerId: 'example-layer', + callsign: null, + name: 'Example 12', + }); + assert.equal(coordinator.controlCockpit('enter').ok, false); + assert.match(coordinator._cockpitEntryRefusal(), /another first-person view/); + disable('flights'); + assert.equal(cockpit.active, true, 'another layer is free to go off'); + disable('example-layer'); + assert.equal(cockpit.active, false, 'its own layer going off ends it'); + 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, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + subjects: [{ kind: 'aircraft', layerId: 'flights' }], + }); + assert.deepEqual(cockpit.subjectProviders, []); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + assert.equal(cockpit.subjectKind, 'aircraft'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 4ce5d5b63..9574068ce 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -41,6 +41,9 @@ function subjectWorldPosition(kind) { } if (kind === 'satellite') return this.services.satellitesLayer?.getCockpitPosition?.() ?? null; + // An application's subject has no position before its view is entered: + // the selected vessel's sprite must not stand in for it. + if (kind !== 'vessel') return null; const info = this.readVesselInfo(); if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) return null; @@ -189,7 +192,7 @@ const LOOK_KEY_OWNERS = [ * @returns {boolean} True when the key was taken. */ function handleLookKey(event) { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; const step = lookKeyStep(event.key, event.shiftKey); const reset = !event.repeat && (event.key === 'Home' || event.key === '0'); diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 0ba28d89a..ccc6a5b89 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,7 +52,7 @@ function createRimState(value) { */ export function createInstrumentState() { return { - // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + // 'aircraft', 'vessel' or 'subject': which readouts the HUD shows. kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), @@ -76,6 +76,19 @@ export function createInstrumentState() { look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', + // A provider subject's HUD (kind 'subject', cockpitSubjectProviders.js): + // its kicker, speed unit and tag, readouts, sources, banner, veil and + // progress line, all worded by the provider. + kicker: '', + speedUnit: '', + speedTag: '', + speedTone: 'normal', + readouts: [], + sources: [], + banner: null, + veil: null, + progress: null, + lookEnabled: false, }; } @@ -86,8 +99,10 @@ export function createInstrumentState() { */ export function createRouteState() { return { - // A vessel's card shows only the free-text AIS destination. + // A vessel's card shows only the free-text AIS destination; a provider's + // subject's (kind 'subject') carries its own title. kind: 'aircraft', + title: '', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index c5e9863cd..62cb0e6ec 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -35,6 +35,7 @@ import { vesselCameraProfile, } from '../cockpitSubjects.js'; import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { cockpitSubjectHudView } from '../cockpitSubjectProviders.js'; import { RADAR_MAX_CONTACTS, radarPicture, @@ -90,6 +91,13 @@ export function publishInstrumentsView() { compass: view.compass.map((division) => ({ ...division })), compassMarkers: { ...view.compassMarkers }, look: { ...view.look }, + // A provider's view model is rebuilt on each HUD update, never edited + // in place, so it is published as it stands. + readouts: view.readouts, + sources: view.sources, + banner: view.banner, + veil: view.veil, + progress: view.progress, }); } @@ -118,24 +126,162 @@ function wordLook({ side, bearingDeg, elevation, angleDeg }) { */ export function presentLook() { const target = this.lookTarget; + // A provider's view words its own readout (its HUD view's `lookReadout`). + const provider = Boolean(this.subjectProvider); + const rest = provider ? this.lookRestPitch : VESSEL_BRIDGE_PITCH_DEG; const offset = - this.active && - this.subjectKind === 'vessel' && - lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG); + (this.active && this.subjectKind === 'vessel') || + (provider && this.looksAround) + ? lookIsOffset(target, rest) + : false; this.instruments.look = offset ? { offset: true, yawDeg: wrapLookYaw(target.yawDeg), - readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), + readout: provider + ? (this.subjectHud?.lookReadout ?? '') + : wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), } : { offset: false, yawDeg: 0, readout: '' }; } +/** Live Signals' tone for a provider's signal row. */ +const SUBJECT_SIGNAL_TONES = Object.freeze({ + warning: 'warning', + alert: 'warning', +}); + +/** + * Present a provider subject's HUD: its view model (cockpitSubjectHudView) + * in the generic readouts, the compass at the camera's heading, the route + * card, and its signals in Live Signals. A HUD view that is null, a `hud()` + * that throws, or a view whose reading throws keeps the last one. Contact + * navigation stays with aircraft, so the Contact card is withdrawn. + */ +function updateSubjectHud(info, nowMs, forceContext) { + const provider = this.subjectProvider; + this.lastSubjectInfo = info; + const frame = this.lastSubjectFrame; + const look = { + yawDeg: this.lookTarget?.yawDeg ?? 0, + pitchDeg: this.lookTarget?.pitchDeg ?? 0, + offset: + this.looksAround && lookIsOffset(this.lookTarget, this.lookRestPitch), + }; + const raw = frame + ? this.callSubject( + provider, + 'hud', + [frame, Object.freeze({ ...this.subjectContext(), look })], + null, + ) + : null; + if (raw != null) + this.subjectHud = cockpitSubjectHudView(raw) ?? this.subjectHud; + const hud = this.subjectHud ?? cockpitSubjectHudView(null); + const view = this.instruments; + view.kind = 'subject'; + view.trackSource = null; + view.kicker = hud.kicker || provider.labels.kicker; + view.callsign = hud.title || info.name || ''; + // While the floor under a subject with no height is acquired + // (cockpitCamera.js), the meta line says so in place of the provider's. + const acquiring = this.surfaceAcquiring === true; + view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text; + view.meta.tone = acquiring ? 'warning' : hud.meta.tone; + view.meta.emergency = !acquiring && hud.meta.tone === 'alert'; + const speed = hud.speed?.value ?? null; + view.speed = hud.speed + ? { + text: speed === null ? '---' : formatNumber(Math.round(speed)), + value: speed === null ? null : Math.round(speed), + immediate: forceContext, + } + : null; + view.speedUnit = hud.speed?.unit ?? ''; + view.speedTag = hud.speed?.tag ?? ''; + view.speedTone = hud.speed?.tone ?? 'normal'; + view.speedRim.unavailable = true; + view.altitude = null; + view.altitudeRim.unavailable = true; + view.course = null; + const heading = normalizeHeading(this.heading ?? info.track ?? 0); + view.heading = { + text: formatHeadingReadout(heading), + value: heading, + immediate: forceContext, + }; + const viewHeading = normalizeHeading( + heading + (provider.look ? (this.lookView?.yawDeg ?? 0) : 0), + ); + view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({ + slot: division.slot, + heading: division.heading, + active: division.nearest, + label: compassPointLabel(formatCompassDivision(division.heading)), + })); + view.compassMarkers = { + prograde: compassMarkerSlot(heading, viewHeading), + retrograde: compassMarkerSlot(heading + 180, viewHeading), + }; + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`; + view.readouts = hud.readouts; + view.sources = hud.sources; + view.banner = hud.banner; + view.veil = hud.veil; + view.progress = hud.progress; + view.lookEnabled = Boolean(provider.look); + view.layer = provider.layerId; + this.presentLook(); + this.publishInstrumentsView(); + const route = this.routeView; + route.kind = 'subject'; + route.title = hud.route?.title ?? ''; + route.from = hud.route?.from ?? ''; + route.to = hud.route?.to ?? ''; + route.status = hud.route?.status ?? ''; + route.visible = Boolean(hud.route); + // No destination arrow; clearing also publishes the card. + this.clearPredictiveRoute(); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.showContextCard(false); + const keys = new Set(); + for (const signal of hud.signals) { + const key = `subject:${signal.id}`; + keys.add(key); + this.pushCockpitSignal( + key, + SUBJECT_SIGNAL_TONES[signal.tone] ?? 'info', + signal.title, + signal.detail || signal.title, + ); + } + for (const key of this.subjectSignalKeys ?? []) + if (!keys.has(key)) this.dropCockpitSignal(key); + this.subjectSignalKeys = keys; + } +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { + if (this.subjectProvider && info?.generic === true) { + updateSubjectHud.call(this, info, nowMs, forceContext); + return; + } if (info?.kind === 'vessel') { updateVesselHud.call(this, info, nowMs, forceContext); return; 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/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 344c6f799..99727c7be 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -184,11 +184,15 @@ const COCKPIT_ENTRY_FACES = Object.freeze({ }); /** - * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind + * The entry's glyph and localized label for the subject it would ride. An + * application provider's subject brings its own face, already in the page's + * language (`presence.entryFace`). + * @param {string|null|undefined} kind + * @param {{icon: string, label: string}|null} [face] * @returns {{icon: string, label: string}} */ -export function cockpitEntryFace(kind) { - const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; - return { icon: face.icon, label: t(face.label) }; +export function cockpitEntryFace(kind, face = null) { + if (face?.icon && face?.label) return { icon: face.icon, label: face.label }; + const known = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: known.icon, label: t(known.label) }; } diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs new file mode 100644 index 000000000..e8549442f --- /dev/null +++ b/src/ui/cockpitSubjectView.test.mjs @@ -0,0 +1,1182 @@ +// src/ui/cockpitSubjectView.test.mjs — an application provider's subject in +// the Cockpit: its entry face, the hold and release around the view, the +// frame that places the camera (eye height, ground clamp, exact anchor), the +// generic HUD it words, the most recent selection winning entry, and the +// ways the view ends. Over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import * as Cesium from 'cesium'; +import { CockpitViewController } from './cockpitController.js'; +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 { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; +import { COCKPIT_SUBJECT_LIFT_MPS } from '../cockpitMath.js'; +import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; + +const LAT = 47; +const LON = 8; +const FLOOR_M = 400; + +/** A HUD view model as a provider words it. */ +const HUD = Object.freeze({ + kicker: 'LINE 12 · OPERATOR', + title: 'Example 12 → Terminus', + meta: { text: 'LIVE TRACK · EYE 3.5 M', tone: 'live' }, + speed: { value: 84, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + { + id: 'next', + label: 'NEXT STOP', + value: 'Central', + detail: '14:32 · T−4 · +2 MIN', + tone: 'normal', + }, + ], + sources: [{ id: 'track', label: 'TRACK', text: 'operator shape' }], + route: { title: 'LINE', from: 'Origin', to: 'Terminus', status: '9 stops' }, + banner: { title: 'IN STRUCTURE', detail: '2.4 KM · OUT IN 1:30' }, + veil: { level: 0.8, streaks: true }, + progress: { + ticks: [ + { at: 0, label: { text: 'Origin', align: 'start', priority: 1 } }, + { at: 0.5 }, + { at: 1, label: { text: 'Terminus', align: 'end', priority: 1 } }, + ], + marker: { at: 0.25, dwell: false }, + tag: 'TIMETABLE', + summary: 'Route progress: Origin to Terminus', + }, + lookReadout: 'LOOK RIGHT 45°', + signals: [{ id: 'note', title: 'NOTE', detail: 'a provider row' }], +}); + +/** A scriptable provider; `calls` records what the Cockpit asked of it. */ +function subjectProvider(overrides = {}) { + const calls = []; + const subject = { + candidate: { id: 'a1', name: 'Example 12' }, + frame: { + id: 'a1', + name: 'Example 12', + latitude: LAT, + longitude: LON, + heightM: FLOOR_M, + headingDeg: 90, + speedMps: 20, + groundClamp: true, + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: -3, minClearanceM: 1.5 }, + }, + hud: HUD, + allowed: true, + hold: true, + }; + const definition = { + kind: 'example-kind', + layerId: 'example-layer', + priority: 1, + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'Example ride view', + exit: 'Exit example view', + exitText: 'EXIT RIDE', + kicker: 'RIDE', + }, + look: { restPitchDeg: -3, aheadLabel: 'Look ahead along the line' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + candidate: () => subject.candidate, + isEntryAllowed: (candidate) => { + calls.push(['allowed', candidate.id]); + return subject.allowed; + }, + hold: (id) => { + calls.push(['hold', id]); + return subject.hold; + }, + read: (nowMs, ctx) => { + calls.push(['read', typeof ctx.groundHeightAt]); + if (subject.frame instanceof Error) throw subject.frame; + return typeof subject.frame === 'function' + ? subject.frame(ctx) + : subject.frame; + }, + hud: (frame, ctx) => { + calls.push(['hud', frame.id, ctx.look.offset]); + if (subject.hud instanceof Error) throw subject.hud; + return subject.hud; + }, + release: (id, options) => calls.push(['release', id, options.restore]), + ...overrides, + }; + return { definition, subject, calls }; +} + +function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(CockpitView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const listeners = () => ({ addEventListener: () => () => {} }); + const shots = []; + const aircraftPosition = Cesium.Cartesian3.fromDegrees(LON, LAT, 3000); + const aircraft = { + position: { + getValue: (time, result) => + Cesium.Cartesian3.clone(aircraftPosition, result), + }, + show: true, + gevTrackedId: 'flights:abc123', + }; + const viewer = { + trackedEntity: undefined, + clock: { currentTime: null }, + camera: { + heading: 0, + cancelFlight() {}, + setView({ destination, orientation }) { + shots.push({ + destination: Cesium.Cartesian3.clone(destination), + direction: Cesium.Cartesian3.clone(orientation.direction), + }); + }, + }, + entities: { contains: (candidate) => candidate === aircraft }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + let vesselSelected = vessel; + let trackedAircraftInfo = null; + let floorM = FLOOR_M; + const warned = []; + t.mock.method(console, 'warn', (message) => warned.push(String(message))); + const subjectProviders = cockpitSubjectProviders( + providers.map((entry) => entry.definition), + ); + const signals = []; + const dropped = []; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { getTrackedInfo: () => trackedAircraftInfo }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + aisLiveVesselsLayer: { + getCockpitSubject: () => + vesselSelected + ? { ...vesselCockpitInfo(vesselSelected), seaSurfaceHeightM: 0 } + : null, + setCockpitSubject: () => true, + }, + militaryAwarenessLayer: {}, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + cachedGroundFloor: () => floorM, + warmGroundFloor() {}, + GROUND_FLOOR_LIFT_M: 1.5, + }, + isBridgeEntryAllowed: () => true, + subjectProviders, + isSubjectEntryAllowed: (provider, candidate) => + layerOn && provider.isEntryAllowed(candidate) === true, + publish: { + presence: (view) => store.publishPresence(view), + instruments: (view) => store.publishInstruments(view), + route: (view) => store.publishRoute(view), + radar: (view) => store.publishRadar(view), + }, + }); + Object.assign(cockpit, { + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal(key, tone, title, detail) { + signals.push({ key, tone, title, detail }); + }, + dropCockpitSignal(key) { + dropped.push(key); + }, + scheduleContextLayout() {}, + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + showContextCard() {}, + }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove( + 'cockpit-mode', + 'cockpit-subject', + 'cockpit-bridge', + ); + }); + const $ = (selector) => document.querySelector(selector); + return { + $, + cockpit, + viewer, + aircraft, + store, + shots, + warned, + signals, + dropped, + selectVessel(record) { + vesselSelected = record; + }, + /** Track the fixture's aircraft as Live Flights does, or untrack it. */ + trackAircraft(info) { + trackedAircraftInfo = info; + viewer.trackedEntity = info ? aircraft : undefined; + }, + setFloor(value) { + floorM = value; + }, + }; +} + +function manualClock(t, startMs) { + const originalNow = performance.now; + const originalDateNow = Date.now; + let nowMs = startMs; + performance.now = () => nowMs; + Date.now = () => nowMs; + t.after(() => { + performance.now = originalNow; + Date.now = originalDateNow; + }); + return { + advance(ms) { + nowMs += ms; + }, + }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); +} + +/** The provider's last release call. */ +const lastRelease = (calls) => calls.findLast(([name]) => name === 'release'); + +/** The camera's height above the ellipsoid in its last shot (m). */ +const shotHeight = (shots) => + Cesium.Cartographic.fromCartesian(shots.at(-1).destination).height; + +const VESSEL = Object.freeze({ + id: '211000000', + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + missedRefreshes: 0, +}); + +/** The fixture's aircraft as Live Flights reports it once tracked. */ +const TRACKED_AIRCRAFT = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: LAT, + longitude: LON, +}); + +test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => { + const ride = subjectProvider(); + const { $, cockpit, store } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + assert.equal(entry.hidden, false); + assert.match(entry.textContent, /commute/); + assert.match(entry.textContent, /RIDE/); + assert.equal(store.presence.entryKind, 'example-kind'); + // A candidate may bring its own face. + ride.subject.candidate = { + id: 'a2', + name: 'Tram 4', + face: { icon: 'tram', label: 'BOARD' }, + }; + cockpit.syncEntry(); + await nextTick(); + assert.match(entry.textContent, /tram/); + assert.match(entry.textContent, /BOARD/); + ride.subject.allowed = false; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, "the provider's own rule shuts entry"); + assert.equal(cockpit.enter(), false); + ride.subject.allowed = true; + ride.subject.candidate = null; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, 'no candidate, no entry'); +}); + +test('entry holds the subject, takes the camera and shows the generic HUD', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, shots, store, signals } = fixture(t, { + providers: [ride], + }); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'example-kind'); + assert.deepEqual( + ride.calls.filter(([name]) => name === 'hold'), + [['hold', 'a1']], + ); + assert.ok(document.body.classList.contains('cockpit-subject')); + assert.equal(store.presence.subjectGeneric, true); + const hud = $('#cockpit-hud'); + assert.equal(hud.getAttribute('data-subject'), 'example-kind'); + assert.ok(hud.hasAttribute('data-generic')); + assert.equal(hud.getAttribute('aria-label'), 'Example ride view'); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Exit example view', + ); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + // The provider's words, not the aircraft's. + assert.equal($('.cockpit-kicker').textContent, HUD.kicker); + assert.equal($('#cockpit-callsign').textContent, HUD.title); + assert.equal($('#cockpit-aircraft-meta').getAttribute('data-tone'), 'live'); + assert.match($('.cockpit-speed').textContent, /84/); + assert.match($('.cockpit-speed').textContent, /km\/h/); + assert.match($('.cockpit-speed').textContent, /LIVE/); + assert.equal($('.cockpit-scan-array'), null); + assert.equal($('#cockpit-altitude-rim'), null); + assert.equal($('#cockpit-speed-rim'), null); + assert.equal($('.cockpit-roll-arc'), null); + assert.equal($('.cockpit-reticle'), null); + const card = $('[data-readout="next"]'); + assert.match(card.textContent, /NEXT STOP/); + assert.match(card.textContent, /Central/); + assert.match($('[data-source="track"]').textContent, /operator shape/); + assert.match($('.cockpit-banner').textContent, /IN STRUCTURE/); + const veil = $('.cockpit-veil'); + assert.equal(hud.firstElementChild, veil, 'the veil lies under the HUD'); + assert.ok(veil.classList.contains('streaks')); + assert.equal(veil.style.opacity, '0.8'); + const progress = $('.cockpit-progress'); + assert.equal( + progress.getAttribute('aria-label'), + 'Route progress: Origin to Terminus', + ); + assert.equal(progress.querySelectorAll('.cockpit-progress-tick').length, 3); + assert.match(progress.textContent, /Origin/); + assert.match(progress.textContent, /TIMETABLE/); + assert.ok(hud.hasAttribute('data-progress')); + assert.match($('.cockpit-help').textContent, /ARROWS LOOK/); + // The route card is titled by the provider, not as a flight plan. + const route = $('#cockpit-route'); + assert.equal(route.hidden, false); + assert.equal($('.cockpit-route-kicker', route).textContent, 'LINE'); + assert.equal(route.getAttribute('aria-label'), 'LINE'); + // TRACK ACQUIRED names the subject; the HUD view's rows reach Live Signals. + assert.deepEqual(signals[0], { + key: 'track', + tone: 'track', + title: 'TRACK ACQUIRED', + detail: 'Example 12', + }); + assert.ok( + signals.some( + (row) => row.key === 'subject:note' && row.detail === 'a provider row', + ), + ); + assert.ok(shots.length >= 1 || cockpit.lastSubjectFrame); +}); + +test('a HUD view with no speed shows no speed readout; one with no value shows it unknown', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, speed: undefined }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + assert.ok($('.cockpit-speed') === null, 'no speed readout'); + assert.ok($('#cockpit-speed-value') === null, 'no speed value'); + assert.ok($('#cockpit-heading-value') !== null, 'the heading stays'); + ride.subject.hud = { ...HUD, speed: { value: null, unit: 'km/h' } }; + clock.advance(1000); + cockpit.update(); + await settle(); + assert.match($('.cockpit-speed').textContent, /---/); + assert.match($('.cockpit-speed').textContent, /km\/h/); +}); + +test('a progress line is an image named by its summary, else hidden from assistive technology', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, progress: { ...HUD.progress, summary: '' } }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + let progress = $('.cockpit-progress'); + assert.equal(progress.hasAttribute('role'), false); + assert.equal(progress.hasAttribute('aria-label'), false); + assert.equal(progress.getAttribute('aria-hidden'), 'true'); + ride.subject.hud = HUD; + clock.advance(1000); + cockpit.update(); + await settle(); + progress = $('.cockpit-progress'); + assert.equal(progress.getAttribute('role'), 'img'); + assert.equal(progress.getAttribute('aria-label'), HUD.progress.summary); + assert.equal(progress.hasAttribute('aria-hidden'), false); +}); + +test('the eye stands its height above the frame, and the ground clamp can be lifted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // Every frame updates (cameraIntervalMs 0). + const before = shots.length; + clock.advance(16); + cockpit.update(); + assert.equal(shots.length, before + 1); + // Inside a structure the frame stands below the rendered floor: clamped, + // the eye is pushed out of the floor at once and its clearance eases in; + // a view entered with groundClamp false stays inside (a clamp lifted + // mid-ride lets the eye down gradually, below). + setFloor(FLOOR_M + 200); + clock.advance(16); + cockpit.update(); + const lifted = shotHeight(shots); + assert.ok(lifted >= FLOOR_M + 200 - 0.01, 'out of the floor at once'); + assert.ok(lifted < FLOOR_M + 200 + 1.5 - 0.01, 'the clearance eases in'); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped'); + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + cockpit.exit(); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'not clamped', + ); + // No height in the frame: the eye stands on the shared floor. + setFloor(FLOOR_M); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); +}); + +test('a ground clamp lifted mid-ride lets the eye down at the rate it came up', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + // The floor rises to the eye: its clearance eases in. + setFloor(FLOOR_M + 3.5); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 5)) < 0.01, 'clamped'); + // Into a structure: the lift goes at the same rate, not at once. + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + clock.advance(100); + cockpit.update(); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 5 - COCKPIT_SUBJECT_LIFT_MPS * 0.1), + ) < 0.01, + 'eased out', + ); + for (let i = 0; i < 12; i += 1) { + clock.advance(100); + cockpit.update(); + } + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'inside the structure', + ); +}); + +test('a subject updated every 500 ms turns, eases and closes on its target at its rates', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider({ cameraIntervalMs: 500, headingSlewDps: 10 }); + ride.subject.frame = { + ...ride.subject.frame, + anchor: 'inertial', + speedMps: 0, + }; + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // One 500 ms update: the subject turns, the floor rises to the eye and the + // frame moves 100 m east of the anchor. Each rate advances by the 0.5 s, + // five times the 0.1 s a frame's step is capped at. + ride.subject.frame = { + ...ride.subject.frame, + headingDeg: 180, + longitude: LON + 100 / (111_320 * Math.cos(Cesium.Math.toRadians(LAT))), + }; + setFloor(FLOOR_M + 3.5); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 95) < 1e-6, 'heading 10°/s × 0.5 s'); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 3.5 + COCKPIT_SUBJECT_LIFT_MPS * 0.5), + ) < 0.01, + 'lift 1.5 m/s × 0.5 s', + ); + const ground = (shot) => { + const at = Cesium.Cartographic.fromCartesian(shot.destination); + return Cesium.Cartesian3.fromRadians(at.longitude, at.latitude, 0); + }; + const closedM = Cesium.Cartesian3.distance( + ground(shots.at(-1)), + ground(shots.at(-2)), + ); + // A standing subject's anchor closes at the correction's 0.75 m/s. + assert.ok(Math.abs(closedM - 0.75 * 0.5) < 0.01, 'anchor 0.75 m/s × 0.5 s'); + // A long stall (a hidden tab) moves it as one late update would. + clock.advance(60_000); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 101) < 1e-6, 'at most 0.6 s'); +}); + +test('an exact anchor sits on the subject, wherever the frame moves it', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + for (const step of [1, 2, 3]) { + const longitude = LON + step * 0.001; + ride.subject.frame = { ...ride.subject.frame, longitude }; + clock.advance(16); + cockpit.update(); + const at = Cesium.Cartographic.fromCartesian(shots.at(-1).destination); + assert.ok( + Math.abs(Cesium.Math.toDegrees(at.longitude) - longitude) < 1e-7, + `step ${step}`, + ); + } +}); + +test('the view ends when the frame stops coming, and gives the subject back', async (t) => { + const clock = manualClock(t, 10_000); + for (const ending of [ + null, + { latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } }, + // A frame whose getter throws runs provider code: as a malformed frame. + Object.defineProperty( + { id: 'a1', longitude: LON, eye: { upM: 2 } }, + 'latitude', + { + get() { + throw new Error('latitude unavailable'); + }, + }, + ), + ]) { + const ride = subjectProvider(); + const { cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + ride.subject.frame = ending; + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(document.body.classList.contains('cockpit-subject'), false); + } +}); + +test('leaving by the exit control asks the provider to restore its subject', async (t) => { + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); +}); + +test('entry fails when the gate is shut, the hold fails or no first frame comes, releasing only what it held', async (t) => { + const shut = subjectProvider(); + shut.subject.allowed = false; + let app = fixture(t, { providers: [shut] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.equal( + shut.calls.some(([name]) => name === 'hold' || name === 'release'), + false, + 'a shut gate neither holds nor releases', + ); + const blank = subjectProvider(); + blank.subject.frame = null; + app = fixture(t, { providers: [blank] }); + app.cockpit.syncEntry(); + 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 hold that answers false refuses entry; any answer but true or false warns once; each is released', async (t) => { + for (const [answer, warns] of [ + [false, false], + [undefined, true], + [1, true], + ['held', true], + ]) { + const ride = subjectProvider(); + ride.subject.hold = answer; + const { cockpit, warned } = fixture(t, { providers: [ride] }); + const holdWarnings = () => + warned.filter((message) => + /example-kind hold\(\) must return true or false/.test(message), + ).length; + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, `hold answered ${String(answer)}`); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal(warned.length, warns ? 1 : 0, 'no other warning'); + // Once per provider and method. + assert.equal(cockpit.enter(), false); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal( + ride.calls.filter(([name]) => name === 'release').length, + 2, + 'released each time', + ); + } +}); + +test('a class-based provider keeps its `this` from entry to release', async (t) => { + manualClock(t, 10_000); + const { frame } = subjectProvider().subject; + class Ride { + #word = 'RIDE'; + #held = null; + calls = []; + kind = 'example-kind'; + layerId = 'example-layer'; + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: `EXIT ${this.#word}`, + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: `Example ${this.#word}` }; + } + isEntryAllowed(candidate) { + return this.#held === null && candidate.id === 'a1'; + } + hold(id) { + this.#held = id; + this.calls.push(['hold', id]); + return true; + } + read() { + this.calls.push(['read', this.#held]); + return this.#held ? { ...frame, id: this.#held } : null; + } + hud(view) { + this.calls.push(['hud', view.id]); + return { ...HUD, title: `${this.#word} ${view.id}` }; + } + release(id, { restore }) { + this.calls.push(['release', id, restore]); + this.#held = null; + } + } + const ride = new Ride(); + const { $, cockpit, warned } = fixture(t, { + providers: [{ definition: ride }], + }); + cockpit.syncEntry(); + await nextTick(); + assert.match($('#cockpit-entry').textContent, /RIDE/); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, 'RIDE a1'); + assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'RIDE view'); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead, RIDE', + ); + assert.ok(ride.calls.some(([name, id]) => name === 'read' && id === 'a1')); + assert.ok(ride.calls.some(([name, id]) => name === 'hud' && id === 'a1')); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); + assert.deepEqual(warned, []); +}); + +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, + // its HUD saying so and still read from the provider. + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + const huds = () => ride.calls.filter(([name]) => name === 'hud').length; + const hudsBefore = huds(); + for (let i = 0; i < 5; i += 1) { + clock.advance(200); + cockpit.update(); + } + await settle(); + assert.equal(shots.length, before, 'no camera placed'); + assert.equal(cockpit.active, true, 'the view waits'); + assert.ok(huds() > hudsBefore, 'the HUD is not frozen'); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + assert.equal( + $('#cockpit-aircraft-meta').getAttribute('data-tone'), + 'warning', + ); + // 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 floor that never comes ends the view after the bounded wait, releasing the subject', 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] }); + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + // Acquiring, the HUD updating, for just under the wait... + for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) { + clock.advance(250); + elapsed += 250; + cockpit.update(); + } + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + // ...then no guessed height: the view ends and the subject goes back. + clock.advance(500); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(shots.length, before, 'never placed on a guess'); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); +}); + +test('an asynchronous hold that lands after entry was refused is released again', async (t) => { + let heldId = null; + const releases = []; + const ride = subjectProvider({ + hold: async (id) => { + await Promise.resolve(); + heldId = id; + return true; + }, + release: (id, options) => { + releases.push([id, options.restore]); + heldId = null; + }, + }); + const { cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, 'refused at once'); + assert.deepEqual(releases, [['a1', false]]); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(heldId, null, 'the late hold is let go'); + assert.deepEqual(releases, [ + ['a1', false], + ['a1', false], + ]); + assert.equal(cockpit.active, false); + assert.match(warned[0], /hold\(\) returned a promise/); +}); + +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) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + ride.subject.hud = new Error('hud failed'); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + // So does a view whose getter throws when read. + ride.subject.hud = Object.defineProperty({ ...HUD }, 'title', { + get() { + throw new Error('title unavailable'); + }, + }); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + ride.subject.frame = new Error('read failed'); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal( + warned.filter((message) => /example-kind read\(\) failed/.test(message)) + .length, + 1, + ); + assert.equal( + warned.filter((message) => /example-kind hud\(\) failed/.test(message)) + .length, + 1, + ); +}); + +test('an aircraft tracked during the view ends it, never adopted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, viewer, aircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + viewer.trackedEntity = aircraft; + assert.equal(cockpit._adoptTrackedEntity(performance.now()), false); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(cockpit.navigateContext(1), false); +}); + +test('the most recently selected subject wins entry', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, selectVessel } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + // A subject selected after the vessel takes the entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind'); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Another vessel selected later takes it back. + clock.advance(500); + 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' }; + ride.subject.allowed = false; + cockpit.syncEntry(); + assert.equal(store.presence.entryAvailable, false); + assert.equal(cockpit.enter(), false); +}); + +test('the built-in kinds keep their order; a subject takes entry only when selected after all of them', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, selectVessel, trackAircraft } = fixture(t, { + providers: [ride], + }); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A subject selected after the aircraft was tracked takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // A vessel selected after it: a built-in kind is newer, so the built-in + // order decides, as without providers, and the aircraft comes first. + clock.advance(500); + selectVessel(VESSEL); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The subject selected again is newer than every built-in one. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry back. + selectVessel(null); + trackAircraft(null); + cockpit.syncEntry(); + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); +}); + +test('a new id under the same selectedAt is not a new selection', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + const { cockpit, trackAircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry. + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The selection moves to another copy of the same subject: same token. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A new token is a new selection. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Without a token, a new id is a new selection. + clock.advance(500); + trackAircraft(null); + cockpit.syncEntry(); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + clock.advance(500); + ride.subject.candidate = { id: 'c1', name: 'Example 3' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); +}); + +test('a subject first seen together with a built-in one comes after it', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, store } = fixture(t, { providers: [ride], vessel: VESSEL }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'vessel'); + assert.equal(store.presence.entryKind, 'vessel'); +}); + +test("a double-click on the selected vessel does not enter an application's subject", async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, viewer } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + // The application's subject is selected after the vessel: it takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Every world position lands under the pointer, the vessel's sprite too. + viewer.scene.cartesianToCanvasCoordinates = (_position, result = {}) => + Object.assign(result, { x: 400, y: 300 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false); + await settle(); + assert.notEqual(store.presence.active, true); +}); + +test('a provider view with look turns with the arrow keys and words its readout', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + assert.equal(cockpit.lookTarget.pitchDeg, -3, 'rests at its own pitch'); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + clock.advance(200); + cockpit.update(); + await settle(); + assert.notEqual(cockpit.lookTarget.yawDeg, 0); + assert.ok(ride.calls.some(([name, , offset]) => name === 'hud' && offset)); + assert.equal($('.cockpit-look').hidden, false); + assert.equal($('#cockpit-look-readout').textContent, HUD.lookReadout); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead along the line', + ); + // Without `look` the keys leave the view alone. + const still = subjectProvider({ look: undefined }); + const other = fixture(t, { providers: [still] }); + other.cockpit.syncEntry(); + other.cockpit.enter(); + assert.equal( + other.cockpit.turnLook({ axis: 'yaw', sign: 1, stepDeg: 15 }), + false, + ); +}); + +test('rows a HUD view no longer lists leave Live Signals', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, dropped } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + ride.subject.hud = { ...HUD, signals: [] }; + clock.advance(1000); + cockpit.update(); + assert.ok(dropped.includes('subject:note')); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 069441149..8327d5a07 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -5,6 +5,10 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +import { + cockpitSubjectCandidate, + cockpitSubjectFrame, +} from '../cockpitSubjectProviders.js'; /** The camera's own heading in degrees, or 0 when it has none. */ function cameraHeadingDeg(camera) { @@ -33,15 +37,187 @@ export function readSatelliteInfo() { return this.services.satellitesLayer?.getCockpitSubject?.() || null; } +/** + * What an application subject provider would have entry ride now, or null. + * @param {object} provider A provider from cockpitSubjectProviders. + * @returns {{id: string, name: string, face: object|null}|null} + */ +export function readSubjectCandidate(provider) { + return cockpitSubjectCandidate( + this.callSubject(provider, 'candidate', [], null), + ); +} + +/** + * What a provider's `read` and `hud` receive besides the frame: the shared + * ground floor at a point (metres, or null), a way to warm its cells, and the + * floor service's lift. + */ +export function subjectContext() { + this._subjectContext ??= Object.freeze({ + groundHeightAt: (latitude, longitude) => { + const height = this.services.cachedGroundFloor?.(latitude, longitude); + return Number.isFinite(height) ? height : null; + }, + warmGround: (points) => { + this.services.warmGroundFloor?.(points); + }, + liftM: Number.isFinite(this.services.GROUND_FLOOR_LIFT_M) + ? this.services.GROUND_FLOOR_LIFT_M + : 0, + }); + return this._subjectContext; +} + +/** + * The ridden provider subject as the camera and HUD read it: the frame's + * place and motion, named like the built-in kinds' info. + * @param {object} frame From cockpitSubjectFrame. + */ +export function subjectInfo(frame) { + return { + kind: this.subjectProvider?.kind ?? null, + generic: true, + layerId: this.subjectProvider?.layerId ?? null, + id: frame.id, + name: frame.name, + latitude: frame.latitude, + longitude: frame.longitude, + track: frame.headingDeg, + motionCourse: frame.courseDeg, + velocityMps: frame.speedMps ?? 0, + stale: frame.stale, + groundClamp: frame.groundClamp, + anchor: frame.anchor, + onGround: false, + }; +} + +/** The built-in kinds' fixed entry order. */ +const BUILT_IN_ENTRY_ORDER = Object.freeze({ + aircraft: 0, + satellite: 1, + vessel: 2, +}); + +/** + * Every subject entry could ride now, in entry order: a tracked aircraft, + * the tracked satellite, the selected vessel and each provider's candidate. + * The built-in kinds keep their fixed order (aircraft, satellite, vessel), + * whenever each was selected. A provider's candidate goes ahead of them only + * when it was selected after every built-in one present: a train picked + * after an aircraft was tracked takes entry, an aircraft tracked after it + * takes it back. Providers' candidates are ordered most recently selected + * first, then by priority. A candidate's time is when this controller first + * saw it (it looks on its entry cadence and on tracking changes), or saw it + * selected again: another subject of a built-in kind, a provider candidate + * whose `selectedAt` changes, or one without a `selectedAt` whose id changes. + * A new id under the same `selectedAt` is the same selection, as when a + * train's selection moves to another feed's copy of it. One + * first seen together with a built-in candidate comes after it. With no + * provider, entry is the built-in order alone. Each carries `allowed()`, its + * kind's entry gate. + * @param {number} [nowMs] + * @returns {Array<{kind: string, id: string, seenAtMs: number, order: number, + * allowed: () => boolean, provider?: object, candidate?: object}>} + */ +export function entryCandidates(nowMs = performance.now()) { + const seen = (this._entrySeen ??= new Map()); + const list = []; + const note = (kind, id, entry, selectedAt = null) => { + const last = seen.get(kind); + // A provider's token says when its subject was selected: a new id under + // the same token (a train handed to another feed's copy of it) is not a + // new selection. Without a token, a new id is. + if ( + !last || + last.selectedAt !== selectedAt || + (selectedAt === null && last.id !== id) + ) + seen.set(kind, { id, selectedAt, atMs: nowMs }); + else if (last.id !== id) seen.set(kind, { ...last, id }); + list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry }); + }; + const aircraft = this.readAircraftInfo(); + if (aircraft && this.viewer.trackedEntity?.position) + note( + 'aircraft', + String( + this.viewer.trackedEntity.gevTrackedId || + aircraft.icao24 || + aircraft.id || + 'aircraft', + ), + { + order: BUILT_IN_ENTRY_ORDER.aircraft, + allowed: () => this.isEntryAllowed(), + }, + ); + const satellite = this.readSatelliteInfo(); + if (satellite) + note('satellite', String(satellite.noradId ?? satellite.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.satellite, + allowed: () => this.isOrbitEntryAllowed(), + }); + const vessel = this.readVesselInfo(); + if (vessel) + note('vessel', String(vessel.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.vessel, + allowed: () => this.isBridgeEntryAllowed(), + }); + 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), + }, + candidate.selectedAt, + ); + }); + // A subject gone and back counts as selected again. + for (const kind of [...seen.keys()]) + if (!list.some((entry) => entry.kind === kind)) seen.delete(kind); + const builtIns = list + .filter((entry) => !entry.provider) + .sort((a, b) => a.order - b.order); + const provided = list + .filter((entry) => entry.provider) + .sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order); + const builtInSeenAtMs = Math.max( + -Infinity, + ...builtIns.map((entry) => entry.seenAtMs), + ); + const later = provided.filter((entry) => entry.seenAtMs > builtInSeenAtMs); + const earlier = provided.filter((entry) => entry.seenAtMs <= builtInSeenAtMs); + return [...later, ...builtIns, ...earlier]; +} + +/** + * The subject entry would ride: the first in entry order (entryCandidates). + * It decides alone, so when its own gate is shut entry is unavailable rather + * than taken by the next one. + * @returns {object|null} An entry of entryCandidates, or null. + */ +export function resolveEntry(nowMs = performance.now()) { + return this.entryCandidates(nowMs)[0] ?? null; +} + /** * The layer the active view rides: Live AIS Vessels on a bridge, Satellites - * in orbit, else the aircraft's own layer. It is read from the tracked - * entity's id once the layer has dropped its tracking, so a layer turned off - * still names itself. - * @returns {'flights'|'military'|'ais-live-vessels'|'satellites'|null} + * in orbit, a provider's own layer, else the aircraft's own layer. It is read + * from the tracked entity's id once the layer has dropped its tracking, so a + * layer turned off still names itself. + * @returns {string|null} */ export function readSubjectLayerId() { if (!this.active) return null; + if (this.subjectProvider) return this.subjectProvider.layerId; if (this.subjectKind === 'vessel') return 'ais-live-vessels'; if (this.subjectKind === 'satellite') return 'satellites'; const layerId = this.readAircraftInfo()?.layerId; @@ -51,23 +227,20 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft first, then the - * tracked satellite, else the selected vessel. Vessels never take Cesium - * tracking, so an aircraft and a vessel cannot be live through the same owner. - * @returns {'aircraft'|'satellite'|'vessel'|null} + * The kind entry would take, the first in entry order's (resolveEntry): a + * Cesium-tracked aircraft, the tracked satellite, the selected vessel or a + * provider's candidate. + * @returns {string|null} */ export function resolveEntryKind() { - if (this.readAircraftInfo() && this.viewer.trackedEntity?.position) - return 'aircraft'; - if (this.readSatelliteInfo()) return 'satellite'; - return this.readVesselInfo() ? 'vessel' : null; + return this.resolveEntry()?.kind ?? null; } export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String( info?.icao24 || - (info?.kind === 'vessel' ? info.id : '') || + (info?.kind === 'vessel' || info?.generic === true ? info.id : '') || info?.noradId || '', ) @@ -76,9 +249,13 @@ export function dispatchCockpitModeChanged(active, info = null) { : null; const layerId = active && - ['flights', 'military', 'ais-live-vessels', 'satellites'].includes( + (['flights', 'military', 'ais-live-vessels', 'satellites'].includes( info?.layerId, - ) + ) || + (info?.generic === true && + this.subjectProviders.some( + (provider) => provider.layerId === info.layerId, + ))) ? info.layerId : null; window.dispatchEvent( @@ -127,40 +304,60 @@ 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; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const orbitContact = !trackedContact && !!this.readSatelliteInfo(); - const bridgeContact = - !trackedContact && !orbitContact && !!this.readVesselInfo(); - let entryKind = null; - if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null; - else if (orbitContact) - entryKind = this.isOrbitEntryAllowed() ? 'satellite' : null; - else if (bridgeContact) - entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null; + const candidates = this.entryCandidates(performance.now()); + const tracked = candidates.some(({ kind }) => kind === 'aircraft'); + this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null); + // The first in entry order decides alone (resolveEntry). + const chosen = candidates[0] ?? null; + const entryKind = chosen?.allowed() ? chosen.kind : null; + const face = + entryKind && chosen.provider + ? (chosen.candidate.face ?? chosen.provider.entryFace) + : null; const available = entryKind !== null; // Change-only publishes: this runs on a preUpdate cadence, and a fresh // presence every frame would re-render the views even when nothing // changed. (perf item 9) if ( this.presence.entryAvailable === available && - this.presence.entryKind === entryKind + this.presence.entryKind === entryKind && + this.presence.entryFace?.icon === face?.icon && + this.presence.entryFace?.label === face?.label ) return; this.presence.entryAvailable = available; this.presence.entryKind = entryKind; + this.presence.entryFace = face && { icon: face.icon, label: face.label }; this.publishPresenceView(); } export function navigateContext(direction, options = {}) { if (this.destroyed) return false; - // Contact navigation moves between aircraft; a bridge does not jump ship - // and an orbital view stays with its satellite. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // Contact navigation moves between aircraft; a bridge does not jump ship, + // an orbital view stays with its satellite and a provider's view with its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; @@ -180,9 +377,14 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - // An aircraft tracked from the bridge or from orbit ends that view (see the - // vessel and orbital camera updates); it is never adopted as its subject. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // An aircraft tracked from the bridge, from orbit or from a provider's view + // ends that view (see the camera updates); it is never adopted as its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const nextEntity = this.viewer.trackedEntity; if ( @@ -213,16 +415,153 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { } /** - * Enter first person on a tracked aircraft or, with none tracked, the - * tracked satellite's orbital view or the selected vessel's bridge. - * @param {{kind?: 'aircraft'|'satellite'|'vessel'|null}} [options] Restrict - * entry to one subject kind; voice entry is aircraft-only. + * Enter a provider's view on its candidate: hold it, read a first frame, then + * take the camera as the built-in views do. Entry fails when the gate is shut, + * the hold answers anything but `true` or no frame comes; past the gate, a + * failed entry releases the subject, since the hold may have taken it. + * @param {object} provider From cockpitSubjectProviders. + * @returns {boolean} + */ +function enterSubject(provider) { + const candidate = this.readSubjectCandidate(provider); + if (!candidate || !this.isSubjectEntryAllowed(provider, candidate)) + return false; + const failed = {}; + const release = () => + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + // An asynchronous hold may land after it was refused: it is released + // again once it settles, unless this subject is being ridden by then. + const releaseLate = () => { + if ( + this.active && + this.subjectProvider === provider && + this.subjectId === candidate.id + ) + return; + release(); + }; + const held = this.callSubject( + provider, + 'hold', + [candidate.id], + failed, + releaseLate, + ); + if (held !== true) { + // `false` refuses entry; a hold that threw, returned a promise or + // answered anything else (warned of once) may still have taken the + // subject. Each is released. + if (held !== false && held !== failed) + this.callSubject.warnOnce( + provider, + 'hold', + 'must return true or false; entry refused', + held, + ); + release(); + return false; + } + const frame = cockpitSubjectFrame( + this.callSubject( + provider, + 'read', + [performance.now(), this.subjectContext()], + null, + ), + candidate.id, + ); + if (!frame) { + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + return false; + } + this.entryFocusOrigin = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + this.onCameraTakeover?.(); + this.viewer.camera.cancelFlight(); + this.subjectKind = provider.kind; + this.subjectProvider = provider; + this.subjectId = candidate.id; + this.lastSubjectFrame = frame; + this.subjectHud = null; + this.subjectSignalKeys = new Set(); + this.vesselWaterline = null; + this.vesselFix = null; + this.trackedEntity = null; + this.heading = normalizeHeading( + frame.headingDeg ?? cameraHeadingDeg(this.viewer.camera), + ); + this.lastFrameMs = performance.now(); + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastGroundProbeMs = 0; + this.surfaceWaitStartedMs = 0; + 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; + this.services.holdContinuousRender('cockpit'); + this.viewer.trackedEntity = undefined; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + document.body.classList.add('cockpit-mode', 'cockpit-subject'); + this._tr3bSignature = null; + Object.assign(this.presence, { + active: true, + entryAvailable: false, + entryKind: null, + entryFace: null, + subjectKind: provider.kind, + subjectGeneric: true, + subjectLabels: { ...provider.labels }, + subjectLook: provider.look + ? { aheadLabel: provider.look.aheadLabel } + : null, + tr3b: null, + }); + this.publishPresenceView(); + this.publishRadar(null); + this.signalItems = []; + this.signalSignatures.clear(); + this.showBriefPage(0); + this.startBriefRotation(); + this.pushCockpitSignal('track', 'track', t('TRACK ACQUIRED'), frame.name); + const info = this.subjectInfo(frame); + this.updateHud(info, performance.now(), true); + this.setVisionMode(this.getInitialVisionMode()); + this.scheduleContextLayout(); + Promise.resolve().then(() => { + if (this.active && !this.destroyed) + this.mapViewButton?.focus({ preventScroll: true }); + }); + this.onEntered?.(); + this.onRide?.(provider.layerId); + this.dispatchCockpitModeChanged(true, info); + return true; +} + +/** + * Enter first person on the first subject in entry order (resolveEntry): a + * tracked aircraft, the tracked satellite's orbital view, the selected + * vessel's bridge or an application provider's subject. + * @param {{kind?: string|null}} [options] Restrict entry to one subject kind; + * voice entry is aircraft-only. * @returns {boolean} True when the view was entered. */ export function enter({ kind = null } = {}) { if (this.destroyed) return false; if (this.active) return false; const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft'; + const provider = this.subjectProviders.find( + (entry) => entry.kind === subjectKind, + ); + if (provider) return enterSubject.call(this, provider); + if (!['aircraft', 'satellite', 'vessel'].includes(subjectKind)) return false; const vessel = subjectKind === 'vessel'; const satellite = subjectKind === 'satellite'; const allowed = vessel @@ -371,11 +710,17 @@ export function enter({ kind = null } = {}) { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const provider = this.subjectProvider; + const providerSubject = provider ? this.subjectId : null; const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null; const satelliteSubject = this.subjectKind === 'satellite' ? this.subjectId : null; this.active = false; this.subjectKind = null; + this.subjectProvider = null; + this.lastSubjectFrame = null; + this.subjectHud = null; + this.lookRestPitchDeg = null; this.vesselWaterline = null; this.vesselFix = null; this.subjectId = null; @@ -399,6 +744,7 @@ export function exit({ restoreTracking = true } = {}) { 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); // The camera's own field of view returns before any follow resumes. this.endOrbital(); @@ -406,6 +752,9 @@ export function exit({ restoreTracking = true } = {}) { this.publishUtilityLayout(null); this.presence.active = false; this.presence.subjectKind = null; + this.presence.subjectGeneric = false; + this.presence.subjectLabels = null; + this.presence.subjectLook = null; this.publishPresenceView(); this.routeView.visible = false; this.clearPredictiveRoute(); @@ -419,6 +768,13 @@ export function exit({ restoreTracking = true } = {}) { this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null); if (satelliteSubject !== null) this.services.satellitesLayer?.setCockpitSubject?.(null); + // The provider gives its subject back; `restore` asks it to bring the map + // back to it, as the built-in kinds' refocus does. + if (provider) + this.callSubject(provider, 'release', [ + providerSubject, + { restore: restoreTracking === true }, + ]); this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; diff --git a/src/ui/styles/cockpitSubject.css b/src/ui/styles/cockpitSubject.css new file mode 100644 index 000000000..37031ee8b --- /dev/null +++ b/src/ui/styles/cockpitSubject.css @@ -0,0 +1,315 @@ +/* An application provider's first-person view + (src/cockpitSubjectProviders.js): a window view with the provider's board, + sources, banner, darkening veil and progress line. Every rule is scoped to + the generic view, so the built-in views keep their own looks. */ +#cockpit-hud[data-generic] { + --cockpit-subject-panel: rgba(0, 7, 12, 0.66); + --cockpit-subject-live: var(--cockpit-accent); + --cockpit-subject-estimate: #c9b6ff; + --cockpit-subject-warning: #ffb800; + --cockpit-subject-alert: var(--cockpit-emergency); + --cockpit-subject-progress-band: 46px; +} +#cockpit-hud[data-generic] [data-tone='muted'] { + opacity: 0.62; +} +#cockpit-hud[data-generic] [data-tone='live'] { + color: var(--cockpit-subject-live); +} +#cockpit-hud[data-generic] [data-tone='estimate'] { + color: var(--cockpit-subject-estimate); +} +#cockpit-hud[data-generic] [data-tone='warning'] { + color: var(--cockpit-subject-warning); +} +#cockpit-hud[data-generic] [data-tone='alert'] { + color: var(--cockpit-subject-alert); +} + +/* The veil darkens the picture under the HUD; its streaks are lights + passing the window. */ +#cockpit-hud[data-generic] .cockpit-veil { + position: absolute; + inset: 0; + background: #000; + pointer-events: none; + transition: opacity 0.25s linear; +} +#cockpit-hud[data-generic] .cockpit-veil.streaks::before { + content: ''; + position: absolute; + inset: 34% -40% 46%; + background: repeating-linear-gradient( + 90deg, + transparent 0 22%, + rgba(255, 214, 150, 0.16) 23%, + rgba(255, 236, 200, 0.42) 24%, + rgba(255, 214, 150, 0.16) 25%, + transparent 26% 48% + ); + filter: blur(3px); + animation: cockpit-veil-streaks 1.4s linear infinite; +} +@keyframes cockpit-veil-streaks { + from { + transform: translateX(0); + } + to { + transform: translateX(-26%); + } +} +@media (prefers-reduced-motion: reduce) { + #cockpit-hud[data-generic] .cockpit-veil.streaks::before { + animation: none; + } + #cockpit-hud[data-generic] .cockpit-veil { + transition: none; + } +} + +/* The banner: top centre, below the top line. */ +#cockpit-hud[data-generic] .cockpit-banner { + position: absolute; + top: clamp(92px, 13vh, 128px); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + max-width: min(560px, 80vw); + padding: 6px 14px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 56%, transparent); + border-radius: 2px; + background: var(--cockpit-subject-panel); + text-align: center; + transform: translateX(-50%); +} +#cockpit-hud[data-generic] .cockpit-banner strong { + font-size: 15px; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-banner small { + font-size: 11px; + letter-spacing: 0.08em; +} + +/* The board: cards on the left, below the page's left rail and the Intel + HUD's top corner (both stay on in first person), the sources under them. */ +#cockpit-hud[data-generic] { + --cockpit-subject-board-top: max(23rem, 41vh); +} +#cockpit-hud[data-generic] .cockpit-board { + position: absolute; + top: var(--cockpit-subject-board-top); + left: clamp(22px, 4vw, 58px); + display: flex; + flex-direction: column; + gap: 8px; + width: min(320px, 30vw); + min-width: 220px; + max-height: calc(100vh - var(--cockpit-subject-board-top) - 11rem); + overflow: hidden; +} +#cockpit-hud[data-generic] .cockpit-board-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 10px; + border-left: 2px solid + color-mix(in srgb, var(--cockpit-accent) 72%, transparent); + background: var(--cockpit-subject-panel); +} +#cockpit-hud[data-generic] .cockpit-board-card small { + font-size: 10px; + letter-spacing: 0.16em; + opacity: 0.78; +} +#cockpit-hud[data-generic] .cockpit-board-card strong { + font-size: 17px; + letter-spacing: 0.04em; + overflow-wrap: anywhere; +} +#cockpit-hud[data-generic] .cockpit-board-card span { + font-size: 12px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 6px 10px; + background: rgba(0, 7, 12, 0.5); + font-size: 10px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources div { + display: flex; + gap: 6px; +} +#cockpit-hud[data-generic] .cockpit-sources dt { + flex: none; + min-width: 64px; + opacity: 0.7; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-sources dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* The speed's tag (live or estimated) sits where a label would. */ +#cockpit-hud[data-generic] .cockpit-speed-tag { + font-weight: 700; +} +/* With no speed readout the heading still holds the middle of the row. */ +#cockpit-hud[data-generic] .cockpit-heading-value { + grid-column: 2; +} +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='warning'], +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='alert'] { + font-weight: 700; +} + +/* Look-around: the readout and LOOK AHEAD centred under the style switcher, + in the sky over the forward view; the bottom corners hold the position + readouts. Styled as on a bridge. */ +#cockpit-hud[data-generic] .cockpit-look { + position: absolute; + top: calc(clamp(22px, 4vh, 42px) + 70px); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; +} +#cockpit-hud[data-generic] .cockpit-look[hidden] { + display: none; +} +#cockpit-hud[data-generic] .cockpit-look-readout { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 46%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.62); + color: var(--cockpit-accent); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-look-ahead { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); + color: #f4fbff; + font: inherit; + font-size: 10px; + letter-spacing: 0.14em; + cursor: pointer; + pointer-events: auto; +} +#cockpit-hud[data-generic] .cockpit-look-ahead:hover { + border-color: var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-look-ahead:focus-visible { + outline: 2px solid var(--cockpit-accent); + outline-offset: 2px; +} + +/* The progress line takes a band along the bottom edge; the readouts that + sit there move up above it. */ +#cockpit-hud[data-generic][data-progress] + :is(.cockpit-help, .cockpit-position-readout) { + bottom: calc( + clamp(22px, 4vh, 42px) + var(--cockpit-subject-progress-band) - 14px + ); +} +#cockpit-hud[data-generic][data-progress] .cockpit-compass { + bottom: calc(clamp(44px, 7vh, 72px) + var(--cockpit-subject-progress-band)); +} +#cockpit-hud[data-generic] .cockpit-progress { + position: absolute; + right: clamp(22px, 4vw, 58px); + bottom: 6px; + left: clamp(22px, 4vw, 58px); + height: var(--cockpit-subject-progress-band); +} +#cockpit-hud[data-generic] .cockpit-progress-line { + position: absolute; + right: 0; + bottom: 8px; + left: 0; + height: 2px; + background: color-mix(in srgb, var(--cockpit-accent) 78%, transparent); +} +#cockpit-hud[data-generic] .cockpit-progress-travelled { + position: absolute; + inset: 0 auto 0 0; + background: rgba(0, 7, 12, 0.72); +} +#cockpit-hud[data-generic] .cockpit-progress-tick { + position: absolute; + bottom: 5px; + width: 2px; + height: 8px; + margin-left: -1px; + background: currentColor; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.labelled { + height: 12px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.merged { + width: 4px; + margin-left: -2px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.travelled { + opacity: 0.45; +} +#cockpit-hud[data-generic] .cockpit-progress-label { + position: absolute; + bottom: 20px; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: 10px; + letter-spacing: 0.06em; + line-height: 1.2; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='center'] { + align-items: center; + text-align: center; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='end'] { + align-items: flex-end; + text-align: end; +} +#cockpit-hud[data-generic] .cockpit-progress-label small { + opacity: 0.82; +} +#cockpit-hud[data-generic] .cockpit-progress-marker { + position: absolute; + bottom: 4px; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background: var(--cockpit-accent); + box-shadow: 0 0 10px var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-marker.dwell { + background: transparent; + border: 2px solid var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-tag { + position: absolute; + right: 0; + bottom: -2px; + font-size: 9px; + letter-spacing: 0.14em; + opacity: 0.7; +} diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index d02122878..0d7fc4e9a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -13,6 +13,7 @@ import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; import CockpitOrbital from './panels/CockpitOrbital.vue'; +import CockpitProgress from './panels/CockpitProgress.vue'; import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -29,30 +30,58 @@ const active = computed(() => Boolean(presence.value?.active)); const bridge = computed(() => presence.value?.subjectKind === 'vessel'); // A satellite subject keeps only its orbital HUD over the nadir picture. const orbital = computed(() => presence.value?.subjectKind === 'satellite'); +// An application provider's subject (src/cockpitSubjectProviders.js): a +// window view whose readouts, labels and texts the provider words. +const generic = computed(() => presence.value?.subjectGeneric === true); +const labels = computed(() => presence.value?.subjectLabels ?? null); const subject = computed(() => - bridge.value ? 'vessel' : orbital.value ? 'satellite' : 'aircraft', + generic.value + ? presence.value.subjectKind + : bridge.value + ? 'vessel' + : orbital.value + ? 'satellite' + : 'aircraft', ); const hudLabel = computed(() => - bridge.value - ? t('Ship bridge view') - : orbital.value - ? t('Satellite orbital view') - : t('Aircraft cockpit view'), + generic.value + ? labels.value?.view + : bridge.value + ? t('Ship bridge view') + : orbital.value + ? t('Satellite orbital view') + : t('Aircraft cockpit view'), ); const exitLabel = computed(() => - bridge.value - ? t('Exit bridge view') - : orbital.value - ? t('Exit orbital view') - : t('Exit cockpit view'), + generic.value + ? labels.value?.exit + : bridge.value + ? t('Exit bridge view') + : orbital.value + ? t('Exit orbital view') + : t('Exit cockpit view'), ); const exitText = computed(() => - bridge.value - ? t('EXIT BRIDGE') - : orbital.value - ? t('EXIT ORBIT') - : t('EXIT COCKPIT'), + generic.value + ? labels.value?.exitText + : bridge.value + ? t('EXIT BRIDGE') + : orbital.value + ? t('EXIT ORBIT') + : t('EXIT COCKPIT'), ); +// Look-around: on a bridge, or in a provider's view that has it. +const looks = computed( + () => bridge.value || (generic.value && Boolean(presence.value?.subjectLook)), +); +const aheadLabel = computed(() => + generic.value + ? presence.value?.subjectLook?.aheadLabel + : t('Look ahead: turn the bridge view back to the bow'), +); +const veilStyle = computed(() => ({ + opacity: String(hud.value.veil?.level ?? 0), +})); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); @@ -92,7 +121,9 @@ const tickStyle = (tick) =>