Merge pull request #1686 from ZloyS/denis/cockpit-subject-providers

feat(cockpit): application cockpit subjects
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-13 19:06:18 -08:00
commit 083da40d88
60 files changed
+6548 -149

No files matched your search

+12
View File
@@ -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).
+122
View File
@@ -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=<core package>/build/register-vue`.
### Cockpit subjects
Core's Cockpit rides a tracked aircraft, the tracked satellite and the
selected AIS vessel. An application adds kinds of its own with
`services.cockpitSubjects` on `createApplicationControls`: a list of
providers, checked once when the controls are built
(`src/cockpitSubjectProviders.js`). A malformed provider, 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 })`.
+77
View File
@@ -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,
+1
View File
@@ -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"
+10
View File
@@ -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"
]
+21
View File
@@ -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": []
}
}
+2
View File
@@ -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 },
+68 -2
View File
@@ -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.
+52
View File
@@ -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);
});
+182
View File
@@ -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 || '',
};
}
+144
View File
@@ -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);
});
+644
View File
@@ -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<object>}
*/
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;
}
+698
View File
@@ -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])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
for (let max = 2; max <= 24; max += 1)
assert.equal(
lone.test(cockpitSubjectText('🚆 Example 12 🚉 Central', max)),
false,
`cut at ${max}`,
);
});
test('a provider method that throws returns the fallback and warns once', () => {
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, '');
});
+18
View File
@@ -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.
*
+340
View File
@@ -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 };
}
+504
View File
@@ -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);
});
+7 -1
View File
@@ -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;
+14 -5
View File
@@ -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<string, string>} [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<string, string>, hidden: Set<string>}}
* `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) {
+26
View File
@@ -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);
+15
View File
@@ -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,
+7
View File
@@ -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;
+2
View File
@@ -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();
+29
View File
@@ -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)
+61
View File
@@ -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);
});
+23 -5
View File
@@ -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;
}
+20
View File
@@ -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,
+17
View File
@@ -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);
+6
View File
@@ -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
+242
View File
@@ -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 };
}
+3
View File
@@ -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,
);
}
+2
View File
@@ -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;
+1
View File
@@ -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;
+262
View File
@@ -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);
});
+20 -6
View File
@@ -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 &&
+21 -3
View File
@@ -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;
}
}
+9
View File
@@ -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;
+4 -1
View File
@@ -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);
}
+4
View File
@@ -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();
+68
View File
@@ -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,
),
};
}
+26 -2
View File
@@ -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<object[]>|Function|false,
* ground?: object,
* stops?: object|null, scheduleStops?: boolean,
* paths?: Record<string, object>|null}} [options]
* paths?: Record<string, object>|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) {
+3
View File
@@ -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,
+1 -1
View File
@@ -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;
+168 -4
View File
@@ -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({
+81 -5
View File
@@ -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 {
+55 -19
View File
@@ -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),
+209 -2
View File
@@ -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');
});
+4 -1
View File
@@ -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');
+17 -2
View File
@@ -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'),
+150 -4
View File
@@ -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;
+4 -1
View File
@@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => {
assert.equal(store.orbital, null, 'no HUD comes back for a stale answer');
});
test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => {
const aircraftInfo = {
icao24: 'abc123',
callsign: 'TEST1',
@@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => {
};
const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo });
viewer.trackedEntity = aircraft;
// The satellite and the aircraft are both new to this sync; the fixed order
// decides between subjects first seen together. Selected one after the
// other, the later one takes entry (cockpitEntryGate.test.mjs).
cockpit.syncEntry();
assert.equal(store.presence.entryKind, 'aircraft');
});
+9 -5
View File
@@ -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) };
}
File diff suppressed because it is too large. Load diff
+394 -38
View File
@@ -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;
+315
View File
@@ -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;
}
+91 -38
View File
@@ -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) =>
<template>
<!-- Aircraft first-person view, a vessel's bridge or a satellite's nadir view. It can be entered from the visible view switcher. -->
<section id="cockpit-hud" :aria-label="hudLabel" :hidden="!active" :data-subject="subject" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="orbital ? 'satellites' : hud.layer ?? undefined">
<section id="cockpit-hud" :aria-label="hudLabel" :hidden="!active" :data-subject="subject" :data-generic="generic ? '' : undefined" :data-progress="generic && hud.progress ? '' : undefined" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="orbital ? 'satellites' : hud.layer ?? undefined">
<!-- A provider's view may darken the picture (a veil), the HUD above it. -->
<div v-if="generic && hud.veil" class="cockpit-veil" :class="{ streaks: hud.veil.streaks }" :style="veilStyle" aria-hidden="true"></div>
<!-- In orbit the picture keeps only the nadir reticle, readout and zoom. -->
<CockpitOrbital v-if="orbital" />
<!-- One optical coordinate system: every targeting mark shares this center.
@@ -106,7 +137,7 @@ const tickStyle = (tick) =>
<path d="M0 600H324 M876 600H1200"/>
</g>
</svg>
<svg v-else-if="!orbital" class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<svg v-else-if="!orbital && !generic" class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<g fill="none" stroke="currentColor">
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
@@ -116,7 +147,7 @@ const tickStyle = (tick) =>
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
</svg>
<div v-if="!bridge && !orbital" class="cockpit-scan-array" aria-hidden="true">
<div v-if="!bridge && !orbital && !generic" class="cockpit-scan-array" aria-hidden="true">
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
<g class="cockpit-target-hairline">
<circle cx="300" cy="300" r="276"/>
@@ -150,7 +181,7 @@ const tickStyle = (tick) =>
</div>
<div v-if="!orbital" class="cockpit-visor-shell" aria-hidden="true">
<!-- A ship's bridge keeps no roll arc or pitch ladder. -->
<div v-if="!bridge" class="cockpit-roll-arc">
<div v-if="!bridge && !generic" class="cockpit-roll-arc">
<span style="--slot:-3;--depth:3">−30</span>
<span style="--slot:-2;--depth:2">−20</span>
<span style="--slot:-1;--depth:1">−10</span>
@@ -159,10 +190,10 @@ const tickStyle = (tick) =>
<span style="--slot:2;--depth:2">+20</span>
<span style="--slot:3;--depth:3">+30</span>
</div>
<div v-if="!bridge" class="cockpit-pitch-rail cockpit-pitch-rail-left">
<div v-if="!bridge && !generic" class="cockpit-pitch-rail cockpit-pitch-rail-left">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div v-if="!bridge" class="cockpit-pitch-rail cockpit-pitch-rail-right">
<div v-if="!bridge && !generic" class="cockpit-pitch-rail cockpit-pitch-rail-right">
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
</div>
<div class="cockpit-horizon-guide">
@@ -174,10 +205,10 @@ const tickStyle = (tick) =>
</svg>
<small id="cockpit-route-direction-label">{{ direction.label }}</small>
</div>
<div class="cockpit-visor-status cockpit-visor-status-left">{{ bridge ? t('BRIDGE WINDOW · FWD') : t('OPTICAL PLANE · 01') }}</div>
<div class="cockpit-visor-status cockpit-visor-status-right">{{ bridge ? (hud.trackSource === 'course' ? t('COURSE HOLD · AIS') : hud.trackSource === 'heading' ? t('HEADING HOLD · AIS') : t('NO HEADING · AIS')) : t('VISOR LOCK · ACTIVE') }}</div>
<div v-if="!generic" class="cockpit-visor-status cockpit-visor-status-left">{{ bridge ? t('BRIDGE WINDOW · FWD') : t('OPTICAL PLANE · 01') }}</div>
<div v-if="!generic" class="cockpit-visor-status cockpit-visor-status-right">{{ bridge ? (hud.trackSource === 'course' ? t('COURSE HOLD · AIS') : hud.trackSource === 'heading' ? t('HEADING HOLD · AIS') : t('NO HEADING · AIS')) : t('VISOR LOCK · ACTIVE') }}</div>
</div>
<div v-if="!orbital" id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<div v-if="!orbital && !generic" id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
<span class="cockpit-altitude-rim-label">{{ bridge ? t('SPEED OVER GROUND · KTS') : t('GROUND SPEED · KTS') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
@@ -188,7 +219,7 @@ const tickStyle = (tick) =>
<strong id="cockpit-speed-rim-value">{{ hud.speedRim.value }}</strong>
</div>
</div>
<div v-if="!orbital" id="cockpit-altitude-rim" class="cockpit-altitude-rim" :hidden="bridge" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<div v-if="!orbital && !generic" id="cockpit-altitude-rim" class="cockpit-altitude-rim" :hidden="bridge" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
<span class="cockpit-altitude-rim-label">{{ t('ALTITUDE · FT') }}</span>
<div class="cockpit-altitude-rim-rail"></div>
<div class="cockpit-altitude-rim-scale">
@@ -201,9 +232,9 @@ const tickStyle = (tick) =>
</div>
<div class="cockpit-topline">
<div>
<span class="cockpit-kicker">{{ bridge ? t('BRIDGE VIEW') : t('FIRST PERSON') }}</span>
<span class="cockpit-kicker">{{ generic ? hud.kicker : bridge ? t('BRIDGE VIEW') : t('FIRST PERSON') }}</span>
<strong id="cockpit-callsign">{{ hud.callsign }}</strong>
<span id="cockpit-aircraft-meta" v-class-toggle="{ emergency: hud.meta.emergency }" class="cockpit-aircraft-meta">{{ hud.meta.text }}</span>
<span id="cockpit-aircraft-meta" v-class-toggle="{ emergency: hud.meta.emergency }" class="cockpit-aircraft-meta" :data-tone="generic ? hud.meta.tone : undefined">{{ hud.meta.text }}</span>
</div>
<div class="cockpit-vision-controls" role="group" :aria-label="t('Cockpit vision style')">
<button id="cockpit-vision-previous" class="cockpit-vision-nav" type="button" :aria-label="t('Previous cockpit vision style')" :title="t('Previous vision style')">
@@ -220,20 +251,27 @@ const tickStyle = (tick) =>
</button>
</div>
</div>
<div v-if="!orbital" class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<!-- A bridge view turned off the bow says where it looks and offers the way back. -->
<div v-if="bridge" class="cockpit-look" :hidden="!look.offset">
<div v-if="!orbital && !generic" class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
<!-- A provider's banner: a status that names where the subject is. -->
<div v-if="generic && hud.banner" class="cockpit-banner" role="status" :data-tone="hud.banner.tone">
<strong>{{ hud.banner.title }}</strong>
<small v-if="hud.banner.detail">{{ hud.banner.detail }}</small>
</div>
<!-- A view turned off its rest says where it looks and offers the way back. -->
<div v-if="looks" class="cockpit-look" :hidden="!look.offset">
<span id="cockpit-look-readout" class="cockpit-look-readout" aria-live="polite">{{ look.readout }}</span>
<button id="cockpit-look-ahead" type="button" class="cockpit-look-ahead" :aria-label="t('Look ahead: turn the bridge view back to the bow')" :title="t('Look ahead (Home or 0)')" aria-keyshortcuts="Home 0">
<button id="cockpit-look-ahead" type="button" class="cockpit-look-ahead" :aria-label="aheadLabel" :title="t('Look ahead (Home or 0)')" aria-keyshortcuts="Home 0">
<span>{{ t('LOOK AHEAD') }}</span>
</button>
</div>
<div v-if="!orbital" class="cockpit-compass" :aria-label="bridge ? t('Current vessel heading') : t('Current aircraft heading')">
<div v-if="!orbital" class="cockpit-compass" :aria-label="generic ? hudLabel : bridge ? t('Current vessel heading') : t('Current aircraft heading')">
<div class="cockpit-instrument-row">
<div class="cockpit-readout cockpit-speed">
<span class="cockpit-readout-label">{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }}</span>
<!-- A provider's HUD view without a speed has no speed readout. -->
<div v-if="!generic || hud.speed" class="cockpit-readout cockpit-speed">
<span v-if="!generic" class="cockpit-readout-label">{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }}</span>
<span v-else-if="hud.speedTag" class="cockpit-readout-label cockpit-speed-tag" :data-tone="hud.speedTone">{{ hud.speedTag }}</span>
<CockpitRollingValue id="cockpit-speed-value" tag="strong" :reading="hud.speed" placeholder="---" />
<span class="cockpit-readout-unit">{{ t('KTS') }}</span>
<span class="cockpit-readout-unit">{{ generic ? hud.speedUnit : t('KTS') }}</span>
</div>
<div class="cockpit-heading-value"><CockpitRollingValue id="cockpit-heading-value" :reading="hud.heading" placeholder="---" :circular-range="360" /><small>°</small></div>
<div v-if="bridge" class="cockpit-readout cockpit-altitude cockpit-course">
@@ -241,7 +279,7 @@ const tickStyle = (tick) =>
<CockpitRollingValue id="cockpit-course-value" tag="strong" :reading="hud.course" placeholder="---" :circular-range="360" />
<span class="cockpit-readout-unit">°</span>
</div>
<div v-else class="cockpit-readout cockpit-altitude">
<div v-else-if="!generic" class="cockpit-readout cockpit-altitude">
<span class="cockpit-readout-label">{{ t('ALTITUDE') }}</span>
<CockpitRollingValue id="cockpit-altitude-value" tag="strong" :reading="hud.altitude" placeholder="-----" />
<span class="cockpit-readout-unit">{{ t('FT') }}</span>
@@ -254,6 +292,21 @@ const tickStyle = (tick) =>
<span id="cockpit-clock">{{ hud.clock }}</span>
<span id="cockpit-position">{{ hud.position }}</span>
</div>
<!-- A provider's board: its readout cards, then the sources of what they show. -->
<div v-if="generic && (hud.readouts.length || hud.sources.length)" class="cockpit-board">
<div v-for="row in hud.readouts" :key="row.id" class="cockpit-board-card" :data-readout="row.id" :data-tone="row.tone">
<small v-if="row.label">{{ row.label }}</small>
<strong v-if="row.value">{{ row.value }}</strong>
<span v-if="row.detail">{{ row.detail }}</span>
</div>
<dl v-if="hud.sources.length" class="cockpit-sources">
<div v-for="row in hud.sources" :key="row.id" :data-source="row.id" :data-tone="row.tone">
<dt v-if="row.label">{{ row.label }}</dt>
<dd>{{ row.text }}</dd>
</div>
</dl>
</div>
<CockpitProgress v-if="generic && hud.progress" :progress="hud.progress" />
<CockpitContext />
<CockpitBriefing />
<!-- The bridge radar's console: bottom right, below the briefing card. -->
@@ -261,7 +314,7 @@ const tickStyle = (tick) =>
<CockpitRadar />
</div>
<CockpitUtilityControls />
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi><template v-if="bridge"> <span>·</span> <bdi>{{ t('ARROWS LOOK') }}</bdi> <span>·</span> <bdi>{{ t('HOME RESET') }}</bdi></template><template v-if="orbital"> <span>·</span> <bdi>{{ t('+/− ZOOM') }}</bdi> <span>·</span> <bdi>{{ t('ARROWS TILT') }}</bdi> <span>·</span> <bdi>{{ t('HOME NADIR') }}</bdi></template></div>
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi><template v-if="looks"> <span>·</span> <bdi>{{ t('ARROWS LOOK') }}</bdi> <span>·</span> <bdi>{{ t('HOME RESET') }}</bdi></template><template v-if="orbital"> <span>·</span> <bdi>{{ t('+/− ZOOM') }}</bdi> <span>·</span> <bdi>{{ t('ARROWS TILT') }}</bdi> <span>·</span> <bdi>{{ t('HOME NADIR') }}</bdi></template></div>
</section>
<nav id="view-switcher" :aria-label="t('View switcher')">
+3 -1
View File
@@ -21,7 +21,9 @@ import { cockpitEntryFace } from '../cockpitPresentation.js';
const { view: ctx } = storeToRefs(useContextStore());
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry names the subject it would ride (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind));
const entry = computed(() =>
cockpitEntryFace(cockpit.value?.entryKind, cockpit.value?.entryFace),
);
// While the Bhote Koshi event is on, its panel is the rail's only content.
const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore());
// The Scene runtime chip SceneControls presents while a scene plays.
+3 -1
View File
@@ -17,7 +17,9 @@ const view = computed(() => hud.value ?? EMPTY);
// answers its clicks.
const { presence: cockpit } = storeToRefs(useCockpitStore());
// The entry names the subject it would ride (cockpitEntryFace).
const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind));
const entry = computed(() =>
cockpitEntryFace(cockpit.value?.entryKind, cockpit.value?.entryFace),
);
</script>
<template>
+57
View File
@@ -0,0 +1,57 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { cockpitProgressLayout } from '../../../cockpitProgress.js';
// A provider subject's progress line (src/cockpitSubjectProviders.js): a thin
// line along the bottom of the view, its ticks, labels and marker laid out
// at the line's own width (src/cockpitProgress.js). The part before the
// marker is dimmed. Everything it shows is worded by the provider. It is an
// image named by the provider's summary; with no summary it is decoration,
// hidden from assistive technology.
const props = defineProps({
progress: { type: Object, required: true },
});
/** The width laid out at before the line is measured (px). */
const FALLBACK_WIDTH_PX = 960;
const root = ref(null);
const widthPx = ref(0);
let observer = null;
const measure = () => {
widthPx.value = root.value?.clientWidth || 0;
};
onMounted(() => {
measure();
if (typeof ResizeObserver === 'function' && root.value) {
observer = new ResizeObserver(measure);
observer.observe(root.value);
}
});
onBeforeUnmount(() => observer?.disconnect());
const width = computed(() => widthPx.value || FALLBACK_WIDTH_PX);
const layout = computed(() =>
cockpitProgressLayout(props.progress, width.value),
);
// Positions as shares of the line, so a resize between measurements keeps
// every mark in place.
const percent = (x) => `${((x / width.value) * 100).toFixed(3)}%`;
</script>
<template>
<div ref="root" class="cockpit-progress" :role="progress.summary ? 'img' : undefined" :aria-label="progress.summary || undefined" :aria-hidden="progress.summary ? undefined : 'true'">
<template v-if="layout">
<div class="cockpit-progress-line" aria-hidden="true">
<span class="cockpit-progress-travelled" :style="{ width: percent(layout.travelledX) }"></span>
</div>
<i v-for="(tick, index) in layout.ticks" :key="`t${index}`" class="cockpit-progress-tick" :class="{ labelled: tick.labelled, merged: tick.count > 1, travelled: tick.x <= layout.travelledX }" :data-tone="tick.tone" :style="{ left: percent(tick.x) }" aria-hidden="true"></i>
<span v-for="(label, index) in layout.labels" :key="`l${index}`" class="cockpit-progress-label" :data-align="label.align" :data-tone="label.tone" :style="{ left: percent(label.left), width: `${label.width}px` }" aria-hidden="true"><b>{{ label.text }}</b><small v-if="label.detail">{{ label.detail }}</small></span>
<i v-if="layout.marker" class="cockpit-progress-marker" :class="{ dwell: layout.marker.dwell }" :style="{ left: percent(layout.marker.x) }" aria-hidden="true"></i>
<span v-if="layout.tag" class="cockpit-progress-tag" aria-hidden="true">{{ layout.tag }}</span>
</template>
</div>
</template>
+17 -2
View File
@@ -11,12 +11,27 @@ import { useCockpitStore } from '../../stores/cockpit.js';
const { route } = storeToRefs(useCockpitStore());
const initial = createRouteState();
const r = computed(() => route.value ?? initial);
// A provider subject's card is titled by its provider, in the page's language.
const kicker = computed(() =>
r.value.kind === 'subject'
? r.value.title
: r.value.kind === 'vessel'
? t('REPORTED VOYAGE')
: t('ESTIMATED FLIGHT PLAN'),
);
const label = computed(() =>
r.value.kind === 'subject'
? r.value.title || undefined
: r.value.kind === 'vessel'
? t('Reported voyage')
: t('Estimated flight plan'),
);
</script>
<template>
<section id="cockpit-route" class="cockpit-route-card" :aria-label="r.kind === 'vessel' ? t('Reported voyage') : t('Estimated flight plan')" :hidden="!r.visible">
<section id="cockpit-route" class="cockpit-route-card" :aria-label="label" :hidden="!r.visible">
<div class="cockpit-route-heading">
<span class="cockpit-route-kicker">{{ r.kind === 'vessel' ? t('REPORTED VOYAGE') : t('ESTIMATED FLIGHT PLAN') }}</span>
<span class="cockpit-route-kicker">{{ kicker }}</span>
<small id="cockpit-route-status">{{ r.status }}</small>
</div>
<div class="cockpit-route-endpoints">
+1
View File
@@ -9,6 +9,7 @@
@import './src/ui/styles/status.css';
@import './src/ui/styles/cockpit.css';
@import './src/ui/styles/cockpitBridge.css';
@import './src/ui/styles/cockpitSubject.css';
@import './src/ui/styles/cockpitRadar.css';
@import './src/ui/styles/cockpitOrbital.css';
@import './src/ui/styles/cockpitOrbitalFocus.css';