mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 07:12:21 +00:00
feat(cockpit): an application can add its own cockpit subjects; the Rail and Transit layers expose the read and hold APIs such a subject needs
GEV itself registers no new subject: with no provider, the Cockpit offers
aircraft, satellites and vessels as before, except that the most recently
selected of them now wins entry.
- services.cockpitSubjects: providers ({kind, layerId, entryFace, priority,
isEntryAllowed, read, hold, release, hud}) are checked once by the
coordinator; frames and HUD views on every update; a throwing provider
ends its view or keeps the last HUD.
- Entry: the most recently selected subject wins, across all kinds.
- Camera: a frame may turn the ground clamp off, anchor exactly on the
subject and update every frame.
- A generic HUD: kicker, title, cards, always-visible source rows, banner,
a veil with light streaks (off under reduced motion) and a bottom
progress line whose close ticks merge.
- Text comes from the provider, already in the page's language
(./i18n/locale: getLocale, onLocaleChange).
- Rail and Transit read and hold APIs (getSelectedTrain/Vehicle,
getCockpitSubject, setCockpitSubject, focusTrain/Vehicle); a held
train's marker, card and route line are hidden, and its feed keeps
polling while held.
- ./layers/rail/paths is exported.
This commit is contained in:
1 parent
10a788db78
commit
f75179eea0
51 files changed
+4059
-129
No files matched your search
@@ -1985,6 +1985,60 @@ 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 is left out with a warning). 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.
|
||||
|
||||
Entry rides the most recently selected subject of all kinds: a tracked
|
||||
aircraft, the tracked satellite, the selected vessel or a provider's
|
||||
candidate, by when the Cockpit first saw it; subjects first seen together keep
|
||||
the fixed order (aircraft, satellite, vessel, then providers by priority). The
|
||||
newest decides alone: when its gate is shut there is no entry rather than an
|
||||
older subject's.
|
||||
|
||||
A frame places the eye `eye.upM` above `heightM` (the shared floor where the
|
||||
frame gives none), slews toward `headingDeg` at the provider's rate, and may
|
||||
turn off the floor clamps (`groundClamp: false`, for a subject inside a
|
||||
structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a
|
||||
layer that already moves it smoothly); `cameraIntervalMs: 0` updates the
|
||||
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/core/i18n/locale`:
|
||||
`getLocale`, `onLocaleChange`).
|
||||
|
||||
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),
|
||||
`setCockpitSubject(key)` (its marker and card hidden, the selection kept, its
|
||||
feed held, clicks and Escape left to the view) and `focusTrain(key)`; its pure
|
||||
path module is exported as `@gods-eye-view/core/layers/rail/paths`. Transit
|
||||
offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`,
|
||||
`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is
|
||||
out of the camera's view, and its sprite, card, trail and detection box are
|
||||
hidden. `src/ui/cockpitSubjectView.test.mjs`,
|
||||
`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,
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"./ui/cockpit/math": "./src/cockpitMath.js",
|
||||
"./ui/cockpit/layout": "./src/cockpitUtilityLayout.js",
|
||||
"./ui/cockpit/vision": "./src/cockpitVisionPolicy.js",
|
||||
"./i18n/locale": "./src/i18n/hostLocale.js",
|
||||
"./ui/scenes": "./src/ui/scenes.js",
|
||||
"./ui/shell": "./src/ui/applicationShell.js",
|
||||
"./ui/styles": "./src/ui/styles.js",
|
||||
@@ -290,6 +291,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"
|
||||
|
||||
@@ -1422,5 +1422,15 @@
|
||||
"src/ui/ownerFlagSheet.js",
|
||||
"src/data/placePacksLoading.test.mjs",
|
||||
"src/ui/placePacksLoading.js",
|
||||
"src/ui/placePacksLoading.test.mjs"
|
||||
"src/ui/placePacksLoading.test.mjs",
|
||||
"src/cockpitSubjectProviders.js",
|
||||
"src/cockpitSubjectProviders.test.mjs",
|
||||
"src/cockpitProgress.js",
|
||||
"src/cockpitProgress.test.mjs",
|
||||
"src/i18n/hostLocale.js",
|
||||
"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"
|
||||
]
|
||||
@@ -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",
|
||||
@@ -982,6 +985,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",
|
||||
@@ -1147,6 +1152,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",
|
||||
@@ -1329,6 +1336,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",
|
||||
@@ -3066,6 +3074,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",
|
||||
@@ -3253,6 +3263,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",
|
||||
@@ -3279,6 +3290,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",
|
||||
@@ -4097,6 +4109,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",
|
||||
@@ -4122,6 +4135,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",
|
||||
@@ -5020,6 +5034,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",
|
||||
@@ -5083,6 +5098,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",
|
||||
@@ -5704,5 +5720,33 @@
|
||||
"src/layers/wildlifeSightings/performance.js"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"i18n-locale": {
|
||||
"exports": ["./i18n/locale"],
|
||||
"modules": [
|
||||
"src/i18n/hostLocale.js",
|
||||
"src/i18n/marker.js",
|
||||
"src/i18n/index.js",
|
||||
"src/i18n/locale.js",
|
||||
"src/i18n/locales/ar.json",
|
||||
"src/i18n/locales/de.json",
|
||||
"src/i18n/locales/es.json",
|
||||
"src/i18n/locales/fr.json",
|
||||
"src/i18n/locales/he.json",
|
||||
"src/i18n/locales/it.json",
|
||||
"src/i18n/locales/ja.json",
|
||||
"src/i18n/locales/pt.json",
|
||||
"src/i18n/locales/ru.json",
|
||||
"src/i18n/locales/sv.json",
|
||||
"src/i18n/locales/uk.json",
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"rail-paths": {
|
||||
"exports": ["./layers/rail/paths"],
|
||||
"modules": ["src/data/railPaths.js"],
|
||||
"external": []
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -10,6 +10,8 @@ import { t } from '../i18n/index.js';
|
||||
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||
* whose address does not carry one. `localePicker: false` removes the language
|
||||
* picker, for a host that does not initialize Core's locale.
|
||||
* `services.cockpitSubjects` adds the application's own first-person subjects
|
||||
* to the Cockpit (src/cockpitSubjectProviders.js).
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
|
||||
@@ -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 || '',
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* @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 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); '' 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();
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
/** 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 (!faceOf(provider.entryFace)) return 'malformed entry face';
|
||||
const labels = provider.labels;
|
||||
if (
|
||||
!isObject(labels) ||
|
||||
!cockpitSubjectText(labels.view, 48) ||
|
||||
!cockpitSubjectText(labels.exit, 48) ||
|
||||
!cockpitSubjectText(labels.exitText, 24)
|
||||
)
|
||||
return 'labels need view, exit and exitText';
|
||||
if (
|
||||
provider.look != null &&
|
||||
(!isObject(provider.look) ||
|
||||
!cockpitSubjectText(provider.look.aheadLabel, 64))
|
||||
)
|
||||
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`;
|
||||
* - `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})`.
|
||||
*
|
||||
* A provider that is not so is left out with one warning naming it.
|
||||
* @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) => {
|
||||
const reason = refusal(provider, kinds);
|
||||
if (reason) {
|
||||
const name =
|
||||
typeof provider?.kind === 'string'
|
||||
? provider.kind.slice(0, 32)
|
||||
: `#${order}`;
|
||||
warn(`[Cockpit] subject provider ${name} left out: ${reason}`);
|
||||
return;
|
||||
}
|
||||
kinds.add(provider.kind);
|
||||
const look = isObject(provider.look)
|
||||
? Object.freeze({
|
||||
restPitchDeg: finite(provider.look.restPitchDeg)
|
||||
? clamp(provider.look.restPitchDeg, -90, 90)
|
||||
: 0,
|
||||
aheadLabel: cockpitSubjectText(provider.look.aheadLabel, 64),
|
||||
})
|
||||
: null;
|
||||
kept.push(
|
||||
Object.freeze({
|
||||
kind: provider.kind,
|
||||
layerId: provider.layerId,
|
||||
priority: finite(provider.priority) ? provider.priority : 0,
|
||||
order,
|
||||
entryFace: faceOf(provider.entryFace),
|
||||
labels: Object.freeze({
|
||||
view: cockpitSubjectText(provider.labels.view, 48),
|
||||
exit: cockpitSubjectText(provider.labels.exit, 48),
|
||||
exitText: cockpitSubjectText(provider.labels.exitText, 24),
|
||||
kicker: cockpitSubjectText(provider.labels.kicker, 48),
|
||||
}),
|
||||
look,
|
||||
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,
|
||||
candidate: provider.candidate,
|
||||
isEntryAllowed: provider.isEntryAllowed ?? (() => true),
|
||||
hold: provider.hold,
|
||||
read: provider.read,
|
||||
hud: provider.hud,
|
||||
release: provider.release,
|
||||
}),
|
||||
);
|
||||
});
|
||||
kept.sort((a, b) => a.priority - b.priority || a.order - b.order);
|
||||
return Object.freeze(kept);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's candidate: `{id, name, face}`, or null without a usable id.
|
||||
* `name` falls back to the id; `face` is null unless well formed.
|
||||
* @param {unknown} raw What `candidate()` returned.
|
||||
*/
|
||||
export function cockpitSubjectCandidate(raw) {
|
||||
if (!isObject(raw) || typeof raw.id !== 'string') return null;
|
||||
const id = raw.id.trim();
|
||||
if (!id || id.length > 128) return null;
|
||||
return Object.freeze({
|
||||
id,
|
||||
name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64),
|
||||
face: faceOf(raw.face),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One camera frame from a provider, normalized, or null when it cannot place
|
||||
* the camera (the view then ends): not an object, an id other than `heldId`,
|
||||
* a position that is not finite, or an eye with no valid `upM`.
|
||||
* @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) {
|
||||
if (!isObject(raw) || raw.id !== 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);
|
||||
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 : null,
|
||||
headingDeg,
|
||||
courseDeg: heading(raw.courseDeg) ?? headingDeg,
|
||||
speedMps: finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null,
|
||||
stale: raw.stale === true,
|
||||
groundClamp: raw.groundClamp !== false,
|
||||
anchor: raw.anchor === 'exact' ? '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.
|
||||
* All text is shown as given: the provider words it in the page's language.
|
||||
* @param {unknown} raw What `hud()` returned.
|
||||
* @returns {object} `{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) {
|
||||
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.
|
||||
* @param {{warn?: (message: string, error: unknown) => void}} [options]
|
||||
*/
|
||||
export function createCockpitSubjectCaller({
|
||||
warn = (message, error) => console.warn(message, error),
|
||||
} = {}) {
|
||||
const warned = new Set();
|
||||
return function call(provider, method, args = [], fallback = null) {
|
||||
try {
|
||||
return provider[method](...args);
|
||||
} catch (error) {
|
||||
const key = `${provider.kind}:${method}`;
|
||||
if (!warned.has(key)) {
|
||||
warned.add(key);
|
||||
warn(
|
||||
`[Cockpit] subject provider ${provider.kind} ${method}() failed`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
// 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,
|
||||
});
|
||||
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,
|
||||
anchor: 'inertial',
|
||||
eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 },
|
||||
});
|
||||
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);
|
||||
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(loose.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 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/);
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The page's language, for an application that words its own text beside
|
||||
* Core's: read the active locale, and hear when it changes. Core translates
|
||||
* only its own strings; text an application hands Core (a cockpit subject
|
||||
* provider's labels and HUD view, say) is shown as given, so the application
|
||||
* words it for `getLocale()` and words it again on `onLocaleChange`.
|
||||
*/
|
||||
export { getLocale, onLocaleChange } from './index.js';
|
||||
@@ -0,0 +1,200 @@
|
||||
import { pointAtS } from '../../data/railPaths.js';
|
||||
import { getRegisteredRailFeed } from '../../data/railFeeds.js';
|
||||
import { displayMotion } from '../transit/movement.js';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* `setCockpitSubject(key)` holds a train: its marker, 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. 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.
|
||||
*
|
||||
* @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 = {};
|
||||
|
||||
/** The catalog's public entry for a feed, else the registry's. */
|
||||
function feedOf(feedId) {
|
||||
const listed = state._catalog?.find((feed) => feed.id === feedId);
|
||||
const feed = listed ?? getRegisteredRailFeed(feedId);
|
||||
if (!feed) return null;
|
||||
return Object.freeze({
|
||||
id: feed.id,
|
||||
name: feed.name ?? null,
|
||||
operator: feed.operator ?? null,
|
||||
region: feed.region ?? null,
|
||||
positions: listed?.positions ?? feed.positions ?? 'gps',
|
||||
tripUpdates:
|
||||
typeof listed?.tripUpdates === 'boolean'
|
||||
? listed.tripUpdates
|
||||
: typeof feed.tripUpdatesUrl === 'string',
|
||||
attribution: feed.attribution ?? null,
|
||||
license: feed.license ?? null,
|
||||
licenseUrl: feed.licenseUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 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 latitude =
|
||||
finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat;
|
||||
const longitude =
|
||||
finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon;
|
||||
return Object.freeze({
|
||||
key: entry.key,
|
||||
feedId: entry.feedId,
|
||||
id: String(record.id ?? ''),
|
||||
tripId: record.tripId || null,
|
||||
routeId: record.routeId || null,
|
||||
routeName: routeNameOf(entry),
|
||||
label: record.label || null,
|
||||
trainNumber: record.trainNumber || null,
|
||||
headsign: trip?.headsign || null,
|
||||
category: entry.category ?? null,
|
||||
mode: entry.railMode ?? null,
|
||||
feed: feedOf(entry.feedId),
|
||||
position: Object.freeze({
|
||||
latitude,
|
||||
longitude,
|
||||
railHeightM: Number.isFinite(drawn?.h) ? drawn.h - liftM : null,
|
||||
heightQuality: Number.isFinite(drawn?.q) ? drawn.q : 0,
|
||||
atMs: finiteOrNull(drawn?.at),
|
||||
}),
|
||||
path:
|
||||
binding && binding.path
|
||||
? Object.freeze({
|
||||
shapeIndex: binding.shapeIndex,
|
||||
s: binding.drawS,
|
||||
direction: directionOf(entry),
|
||||
lengthM: binding.path.length,
|
||||
via: binding.via,
|
||||
builtAt:
|
||||
state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null,
|
||||
line: binding.path,
|
||||
})
|
||||
: null,
|
||||
motion: Object.freeze({
|
||||
reportedSpeedMps: reported,
|
||||
derivedSpeedMps: derived,
|
||||
speedSource:
|
||||
reported !== null ? 'reported' : derived !== null ? 'derived' : null,
|
||||
courseDeg: finiteOrNull(entry.courseDeg),
|
||||
phase: sample?.phase ?? null,
|
||||
word: displayMotion(entry, Date.now()).word,
|
||||
reportAgeMs: finiteOrNull(sample?.latestReportAgeMs),
|
||||
shownBehindMs: finiteOrNull(sample?.actualDelayMs),
|
||||
feedStale: status?.stale === true,
|
||||
}),
|
||||
provenance: Object.freeze({
|
||||
positionSource: record.positionSource ?? 'gps',
|
||||
timestampSource: record.timestampSource ?? null,
|
||||
}),
|
||||
stops: Object.freeze(Array.isArray(trip?.stops) ? [...trip.stops] : []),
|
||||
match: trip?.match ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
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 train the layer does not show.
|
||||
*/
|
||||
setCockpitSubject(key) {
|
||||
const previous = state._trains.get(state._cockpitSubjectKey);
|
||||
if (key === null || key === undefined) {
|
||||
state._cockpitSubjectKey = null;
|
||||
state._cockpitLostFeedId = null;
|
||||
if (previous) parts.rendering.paint(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);
|
||||
state._cockpitSubjectKey = key;
|
||||
state._cockpitLostFeedId = null;
|
||||
if (previous && previous !== entry) parts.rendering.paint(previous);
|
||||
parts.rendering.paint(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.
|
||||
*/
|
||||
focusTrain(key) {
|
||||
if (!state._trains.has(key)) return false;
|
||||
parts.selection.selectAndFocusTrain(key);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { methods, trainView };
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
// 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, 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';
|
||||
|
||||
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) {
|
||||
const app = harness(t, { paths: { 'mbta-rail': PACK }, at: BOSTON });
|
||||
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',
|
||||
);
|
||||
});
|
||||
|
||||
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 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('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,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';
|
||||
@@ -84,6 +85,16 @@ function createState({ services }) {
|
||||
_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 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 +186,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -125,6 +125,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();
|
||||
// Floor cells and track samples queued for trains now gone.
|
||||
|
||||
@@ -47,11 +47,26 @@ 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 train runs along the shape: +1 along it, -1 against it,
|
||||
* 0 until two reports or a playback segment tell. Kept until a rebind.
|
||||
*/
|
||||
direction: 0,
|
||||
/** Arc and time of the newest report. */
|
||||
fixS: s,
|
||||
fixT: fix.t,
|
||||
@@ -139,6 +154,7 @@ export function followFix(binding, fix) {
|
||||
pathWindowM(fix.t - binding.fixT),
|
||||
);
|
||||
if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false;
|
||||
binding.direction = directionOf(hit.s - binding.fixS, binding.direction);
|
||||
binding.fixS = hit.s;
|
||||
binding.fixT = fix.t;
|
||||
binding.fixLat = fix.lat;
|
||||
@@ -230,6 +246,8 @@ 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.direction = directionOf(sTo - sFrom, binding.direction ?? 0);
|
||||
}
|
||||
if (!cache.ok) return false;
|
||||
const f = Number.isFinite(sample.fraction)
|
||||
|
||||
@@ -201,3 +201,36 @@ 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);
|
||||
});
|
||||
@@ -507,6 +507,15 @@ export function createRendering({ state, services, parts }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the train's marker shows: its category is on, and no
|
||||
* first-person view rides it (cockpitSubject.js). A held train keeps its
|
||||
* playback; only its marker hides.
|
||||
*/
|
||||
function markerShown(entry) {
|
||||
return categoryShown(entry) && entry.key !== state._cockpitSubjectKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* A train bound to its railway line is drawn on the line, at the arc its
|
||||
* playback has reached; any other train (or a bound train whose reports do
|
||||
@@ -536,7 +545,7 @@ export function createRendering({ state, services, parts }) {
|
||||
undefined,
|
||||
scratchPosition,
|
||||
);
|
||||
entry.marker.show = categoryShown(entry);
|
||||
entry.marker.show = markerShown(entry);
|
||||
}
|
||||
|
||||
/** While a train is selected, every other train is drawn see-through. */
|
||||
@@ -554,7 +563,7 @@ export function createRendering({ state, services, parts }) {
|
||||
entry.marker.width = size;
|
||||
entry.marker.height = size;
|
||||
entry.marker.color = tintFor(entry);
|
||||
entry.marker.show = categoryShown(entry);
|
||||
entry.marker.show = markerShown(entry);
|
||||
}
|
||||
|
||||
/** Re-tint every train after the selection appeared, moved or cleared. */
|
||||
@@ -658,7 +667,7 @@ export function createRendering({ state, services, parts }) {
|
||||
function cullSelected() {
|
||||
const entry = state._trains.get(state._selectedKey);
|
||||
if (!entry?.marker) return;
|
||||
const shown = categoryShown(entry) && facesCamera(entry.marker.position);
|
||||
const shown = markerShown(entry) && facesCamera(entry.marker.position);
|
||||
if (entry.marker.show !== shown) entry.marker.show = shown;
|
||||
parts.routes.cullStops(facesCamera);
|
||||
}
|
||||
@@ -697,7 +706,7 @@ export function createRendering({ state, services, parts }) {
|
||||
function applyCategoryVisibility() {
|
||||
for (const entry of state._trains.values()) {
|
||||
if (!entry.marker) continue;
|
||||
entry.marker.show = categoryShown(entry);
|
||||
entry.marker.show = markerShown(entry);
|
||||
schedulePlayback(entry);
|
||||
}
|
||||
syncRenderHold();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,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;
|
||||
@@ -131,6 +141,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();
|
||||
}
|
||||
|
||||
@@ -141,6 +153,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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { getRegisteredTransitFeed } from '../../data/transitFeeds.js';
|
||||
import { transitRouteLabel } from '../../data/transitFeeds.js';
|
||||
import { displayMotion } from './movement.js';
|
||||
|
||||
/**
|
||||
* 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. 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, 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.
|
||||
*
|
||||
* @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;
|
||||
|
||||
function feedOf(feedId) {
|
||||
const feed =
|
||||
state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId);
|
||||
if (!feed) return null;
|
||||
return Object.freeze({
|
||||
id: feed.id,
|
||||
name: feed.name ?? null,
|
||||
operator: feed.operator ?? null,
|
||||
region: feed.region ?? null,
|
||||
attribution: feed.attribution ?? null,
|
||||
license: feed.license ?? null,
|
||||
licenseUrl: feed.licenseUrl ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
/** The vehicle as a frozen snapshot (see the module comment), or null. */
|
||||
function vehicleView(entry) {
|
||||
if (!entry?.record) return null;
|
||||
const record = entry.record;
|
||||
const sample = entry.sample ?? null;
|
||||
const feed =
|
||||
state._activeFeeds.get(entry.feedId) ||
|
||||
getRegisteredTransitFeed(entry.feedId);
|
||||
const reported = finiteOrNull(record.speedMps);
|
||||
const derived = finiteOrNull(sample?.segmentSpeedMps);
|
||||
const status = state._feedStatus.get(entry.feedId);
|
||||
const latitude = finiteOrNull(sample?.lat) ?? record.lat;
|
||||
const longitude = finiteOrNull(sample?.lon) ?? record.lon;
|
||||
return Object.freeze({
|
||||
key: entry.key,
|
||||
feedId: entry.feedId,
|
||||
id: String(record.id ?? ''),
|
||||
routeId: record.routeId || null,
|
||||
routeLabel:
|
||||
(feed && record.routeId
|
||||
? transitRouteLabel(feed, record.routeId)
|
||||
: null) || null,
|
||||
tripId: record.tripId || null,
|
||||
directionId: record.directionId ?? null,
|
||||
label: record.label || null,
|
||||
mode: entry.mode ?? null,
|
||||
modeInferred: entry.modeInferred === true,
|
||||
feed: feedOf(entry.feedId),
|
||||
position: Object.freeze({
|
||||
latitude,
|
||||
longitude,
|
||||
groundHeightM: Number.isFinite(entry.heightM)
|
||||
? entry.heightM - liftM
|
||||
: null,
|
||||
atMs: finiteOrNull(sample?.displayT),
|
||||
}),
|
||||
motion: Object.freeze({
|
||||
reportedSpeedMps: reported,
|
||||
derivedSpeedMps: derived,
|
||||
speedSource:
|
||||
reported !== null ? 'reported' : derived !== null ? 'derived' : null,
|
||||
courseDeg: finiteOrNull(entry.courseDeg),
|
||||
reportedBearingDeg: finiteOrNull(record.bearing),
|
||||
segmentCourseDeg: finiteOrNull(sample?.segmentCourseDeg),
|
||||
phase: sample?.phase ?? null,
|
||||
word: displayMotion(entry, Date.now()).word,
|
||||
reportAgeMs: finiteOrNull(sample?.latestReportAgeMs),
|
||||
shownBehindMs: finiteOrNull(sample?.actualDelayMs),
|
||||
feedStale: status?.stale === true,
|
||||
}),
|
||||
stop: Object.freeze({
|
||||
stopId: record.stopId || null,
|
||||
status: record.status || null,
|
||||
}),
|
||||
provenance: Object.freeze({
|
||||
positionSource: 'gps',
|
||||
timestampSource: record.timestampSource ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
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);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
return { methods, vehicleView };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ 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';
|
||||
@@ -4142,3 +4143,120 @@ 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];
|
||||
assert.equal(view.position.groundHeightM, entry.heightM - 1.5);
|
||||
assert.ok(Number.isFinite(view.position.latitude));
|
||||
});
|
||||
|
||||
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);
|
||||
app.lookAt({
|
||||
west: -71.2,
|
||||
south: 42.2,
|
||||
east: -70.9,
|
||||
north: 42.5,
|
||||
});
|
||||
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);
|
||||
});
|
||||
@@ -100,7 +100,9 @@ export function createRendering({ state, services, parts }) {
|
||||
);
|
||||
entry.surfaceReady = !!known;
|
||||
entry.heightPending = !known;
|
||||
entry.marker.show = !!known && vehicleInView(entry);
|
||||
// A vehicle a first-person view rides keeps moving, unseen.
|
||||
entry.marker.show =
|
||||
!!known && vehicleInView(entry) && entry.key !== state._cockpitSubjectKey;
|
||||
if (known) {
|
||||
entry.marker.position = state._scratchCartesian;
|
||||
entry.hasRendered = true;
|
||||
@@ -191,7 +193,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. */
|
||||
@@ -531,7 +533,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;
|
||||
@@ -556,7 +561,10 @@ export function createRendering({ state, services, parts }) {
|
||||
}
|
||||
}
|
||||
entry.marker.show =
|
||||
visible && !entry.heightPending && entry.surfaceReady !== false;
|
||||
!held &&
|
||||
visible &&
|
||||
!entry.heightPending &&
|
||||
entry.surfaceReady !== false;
|
||||
visibility.heightPending = !!entry.heightPending;
|
||||
visibility.surfaceReady = entry.surfaceReady ?? null;
|
||||
schedulePlayback(entry);
|
||||
|
||||
@@ -46,6 +46,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;
|
||||
@@ -111,6 +117,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();
|
||||
}
|
||||
|
||||
@@ -123,6 +131,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.
|
||||
|
||||
@@ -95,6 +95,13 @@ export function createState({ services }) {
|
||||
|
||||
/** @type {string|null} */
|
||||
state._selectedKey = null;
|
||||
/**
|
||||
* The vehicle a first-person view holds (cockpitSubject.js), or null: its
|
||||
* sprite, card, trail and detection box are hidden, and it keeps moving
|
||||
* 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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
+100
-3
@@ -16,6 +16,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 +54,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 +302,100 @@ 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).
|
||||
*/
|
||||
function updateSubject(nowMs) {
|
||||
const provider = this.subjectProvider;
|
||||
if (this.viewer.trackedEntity?.position) {
|
||||
this.exit({ restoreTracking: false });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!cockpitUiUpdateDue(
|
||||
nowMs,
|
||||
this.lastCameraUpdateMs,
|
||||
provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS,
|
||||
)
|
||||
)
|
||||
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,
|
||||
);
|
||||
heightM = Number.isFinite(floorM) ? floorM : 0;
|
||||
}
|
||||
const target = Cesium.Cartesian3.fromDegrees(
|
||||
frame.longitude,
|
||||
frame.latitude,
|
||||
heightM,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchTarget,
|
||||
);
|
||||
const dtSec = Math.min(0.1, Math.max(0, (nowMs - this.lastFrameMs) / 1000));
|
||||
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.
|
||||
* @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.
|
||||
@@ -339,7 +435,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.
|
||||
@@ -498,7 +595,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) {
|
||||
cockpitFloorM =
|
||||
targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M;
|
||||
}
|
||||
if (anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
||||
if (groundClamp && anchorCartographic && Number.isFinite(cockpitFloorM)) {
|
||||
const minimumAnchorHeightM = cockpitGroundSafeHeight(
|
||||
anchorCartographic.height,
|
||||
cockpitFloorM,
|
||||
@@ -579,7 +676,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);
|
||||
|
||||
@@ -3,7 +3,12 @@ import {
|
||||
readAircraftInfo,
|
||||
readSatelliteInfo,
|
||||
readVesselInfo,
|
||||
readSubjectCandidate,
|
||||
readSubjectLayerId,
|
||||
subjectContext,
|
||||
subjectInfo,
|
||||
entryCandidates,
|
||||
resolveEntry,
|
||||
resolveEntryKind,
|
||||
dispatchCockpitModeChanged,
|
||||
toggleTrackedTr3b,
|
||||
@@ -100,6 +105,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 +126,8 @@ export class CockpitViewController {
|
||||
isEntryAllowed = null,
|
||||
isBridgeEntryAllowed = null,
|
||||
isOrbitEntryAllowed = null,
|
||||
subjectProviders = [],
|
||||
isSubjectEntryAllowed = null,
|
||||
isUtilityDisclosureOpen = null,
|
||||
onEntered = null,
|
||||
onExited = null,
|
||||
@@ -134,10 +142,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.
|
||||
@@ -203,6 +228,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 +276,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 +545,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);
|
||||
}
|
||||
@@ -568,6 +620,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 +640,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 +653,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 +666,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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -55,6 +55,7 @@ function fixture(
|
||||
changing = false,
|
||||
dataManager: suppliedManager = null,
|
||||
readContext = null,
|
||||
subjects = [],
|
||||
} = {},
|
||||
) {
|
||||
document.body.replaceChildren();
|
||||
@@ -113,6 +114,7 @@ function fixture(
|
||||
isTr3b: () => false,
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
cockpitSubjects: subjects,
|
||||
},
|
||||
elements: {},
|
||||
operations: {
|
||||
@@ -147,6 +149,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. */
|
||||
@@ -648,3 +651,94 @@ 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 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');
|
||||
});
|
||||
@@ -189,7 +189,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');
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,158 @@ 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, or a
|
||||
* `hud()` that 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);
|
||||
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 || '';
|
||||
view.meta.text = hud.meta.text;
|
||||
view.meta.tone = hud.meta.tone;
|
||||
view.meta.emergency = 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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
// src/ui/cockpitSubjectView.test.mjs — an application provider's subject in
|
||||
// the Cockpit: its entry face, the hold and release around the view, the
|
||||
// frame that places the camera (eye height, ground clamp, exact anchor), the
|
||||
// generic HUD it words, the most recent selection winning entry, and the
|
||||
// ways the view ends. Over happy-dom.
|
||||
import '../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPinia } from 'pinia';
|
||||
import { createApp, defineComponent, h, nextTick } from 'vue';
|
||||
import * as Cesium from 'cesium';
|
||||
import { CockpitViewController } from './cockpitController.js';
|
||||
import { useCockpitStore } from './stores/cockpit.js';
|
||||
import CockpitView from './views/CockpitView.vue';
|
||||
import ContextView from './views/ContextView.vue';
|
||||
import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js';
|
||||
import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js';
|
||||
|
||||
const LAT = 47;
|
||||
const LON = 8;
|
||||
const FLOOR_M = 400;
|
||||
|
||||
/** A HUD view model as a provider words it. */
|
||||
const HUD = Object.freeze({
|
||||
kicker: 'LINE 12 · OPERATOR',
|
||||
title: 'Example 12 → Terminus',
|
||||
meta: { text: 'LIVE TRACK · EYE 3.5 M', tone: 'live' },
|
||||
speed: { value: 84, unit: 'km/h', tag: 'LIVE', tone: 'live' },
|
||||
readouts: [
|
||||
{
|
||||
id: 'next',
|
||||
label: 'NEXT STOP',
|
||||
value: 'Central',
|
||||
detail: '14:32 · T−4 · +2 MIN',
|
||||
tone: 'normal',
|
||||
},
|
||||
],
|
||||
sources: [{ id: 'track', label: 'TRACK', text: 'operator shape' }],
|
||||
route: { title: 'LINE', from: 'Origin', to: 'Terminus', status: '9 stops' },
|
||||
banner: { title: 'IN STRUCTURE', detail: '2.4 KM · OUT IN 1:30' },
|
||||
veil: { level: 0.8, streaks: true },
|
||||
progress: {
|
||||
ticks: [
|
||||
{ at: 0, label: { text: 'Origin', align: 'start', priority: 1 } },
|
||||
{ at: 0.5 },
|
||||
{ at: 1, label: { text: 'Terminus', align: 'end', priority: 1 } },
|
||||
],
|
||||
marker: { at: 0.25, dwell: false },
|
||||
tag: 'TIMETABLE',
|
||||
summary: 'Route progress: Origin to Terminus',
|
||||
},
|
||||
lookReadout: 'LOOK RIGHT 45°',
|
||||
signals: [{ id: 'note', title: 'NOTE', detail: 'a provider row' }],
|
||||
});
|
||||
|
||||
/** A scriptable provider; `calls` records what the Cockpit asked of it. */
|
||||
function subjectProvider(overrides = {}) {
|
||||
const calls = [];
|
||||
const subject = {
|
||||
candidate: { id: 'a1', name: 'Example 12' },
|
||||
frame: {
|
||||
id: 'a1',
|
||||
name: 'Example 12',
|
||||
latitude: LAT,
|
||||
longitude: LON,
|
||||
heightM: FLOOR_M,
|
||||
headingDeg: 90,
|
||||
speedMps: 20,
|
||||
groundClamp: true,
|
||||
anchor: 'exact',
|
||||
eye: { upM: 3.5, forwardM: 0, pitchDeg: -3, minClearanceM: 1.5 },
|
||||
},
|
||||
hud: HUD,
|
||||
allowed: true,
|
||||
hold: true,
|
||||
};
|
||||
const definition = {
|
||||
kind: 'example-kind',
|
||||
layerId: 'example-layer',
|
||||
priority: 1,
|
||||
entryFace: { icon: 'commute', label: 'RIDE' },
|
||||
labels: {
|
||||
view: 'Example ride view',
|
||||
exit: 'Exit example view',
|
||||
exitText: 'EXIT RIDE',
|
||||
kicker: 'RIDE',
|
||||
},
|
||||
look: { restPitchDeg: -3, aheadLabel: 'Look ahead along the line' },
|
||||
cameraIntervalMs: 0,
|
||||
headingSlewDps: Infinity,
|
||||
candidate: () => subject.candidate,
|
||||
isEntryAllowed: (candidate) => {
|
||||
calls.push(['allowed', candidate.id]);
|
||||
return subject.allowed;
|
||||
},
|
||||
hold: (id) => {
|
||||
calls.push(['hold', id]);
|
||||
return subject.hold;
|
||||
},
|
||||
read: (nowMs, ctx) => {
|
||||
calls.push(['read', typeof ctx.groundHeightAt]);
|
||||
if (subject.frame instanceof Error) throw subject.frame;
|
||||
return typeof subject.frame === 'function'
|
||||
? subject.frame(ctx)
|
||||
: subject.frame;
|
||||
},
|
||||
hud: (frame, ctx) => {
|
||||
calls.push(['hud', frame.id, ctx.look.offset]);
|
||||
if (subject.hud instanceof Error) throw subject.hud;
|
||||
return subject.hud;
|
||||
},
|
||||
release: (id, options) => calls.push(['release', id, options.restore]),
|
||||
...overrides,
|
||||
};
|
||||
return { definition, subject, calls };
|
||||
}
|
||||
|
||||
function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) {
|
||||
document.body.replaceChildren();
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(
|
||||
defineComponent(() => () => [h(ContextView), h(CockpitView)]),
|
||||
).use(pinia);
|
||||
app.mount(host);
|
||||
const store = useCockpitStore(pinia);
|
||||
const listeners = () => ({ addEventListener: () => () => {} });
|
||||
const shots = [];
|
||||
const aircraftPosition = Cesium.Cartesian3.fromDegrees(LON, LAT, 3000);
|
||||
const aircraft = {
|
||||
position: {
|
||||
getValue: (time, result) =>
|
||||
Cesium.Cartesian3.clone(aircraftPosition, result),
|
||||
},
|
||||
show: true,
|
||||
gevTrackedId: 'flights:abc123',
|
||||
};
|
||||
const viewer = {
|
||||
trackedEntity: undefined,
|
||||
clock: { currentTime: null },
|
||||
camera: {
|
||||
heading: 0,
|
||||
cancelFlight() {},
|
||||
setView({ destination, orientation }) {
|
||||
shots.push({
|
||||
destination: Cesium.Cartesian3.clone(destination),
|
||||
direction: Cesium.Cartesian3.clone(orientation.direction),
|
||||
});
|
||||
},
|
||||
},
|
||||
entities: { contains: (candidate) => candidate === aircraft },
|
||||
scene: {
|
||||
preUpdate: listeners(),
|
||||
screenSpaceCameraController: { enableInputs: true },
|
||||
},
|
||||
trackedEntityChanged: listeners(),
|
||||
};
|
||||
let vesselSelected = vessel;
|
||||
let floorM = FLOOR_M;
|
||||
const warned = [];
|
||||
t.mock.method(console, 'warn', (message) => warned.push(String(message)));
|
||||
const subjectProviders = cockpitSubjectProviders(
|
||||
providers.map((entry) => entry.definition),
|
||||
);
|
||||
const signals = [];
|
||||
const dropped = [];
|
||||
const cockpit = new CockpitViewController(viewer, {
|
||||
services: {
|
||||
flightsLayer: { getTrackedInfo: () => null },
|
||||
militaryFlightsLayer: { getTrackedInfo: () => null },
|
||||
aisLiveVesselsLayer: {
|
||||
getCockpitSubject: () =>
|
||||
vesselSelected
|
||||
? { ...vesselCockpitInfo(vesselSelected), seaSurfaceHeightM: 0 }
|
||||
: null,
|
||||
setCockpitSubject: () => true,
|
||||
},
|
||||
militaryAwarenessLayer: {},
|
||||
isTr3b: () => false,
|
||||
holdContinuousRender() {},
|
||||
releaseContinuousRender() {},
|
||||
cachedGroundFloor: () => floorM,
|
||||
warmGroundFloor() {},
|
||||
GROUND_FLOOR_LIFT_M: 1.5,
|
||||
},
|
||||
isBridgeEntryAllowed: () => true,
|
||||
subjectProviders,
|
||||
isSubjectEntryAllowed: (provider, candidate) =>
|
||||
layerOn && provider.isEntryAllowed(candidate) === true,
|
||||
publish: {
|
||||
presence: (view) => store.publishPresence(view),
|
||||
instruments: (view) => store.publishInstruments(view),
|
||||
route: (view) => store.publishRoute(view),
|
||||
radar: (view) => store.publishRadar(view),
|
||||
},
|
||||
});
|
||||
Object.assign(cockpit, {
|
||||
setVisionMode() {},
|
||||
showBriefPage() {},
|
||||
startBriefRotation() {},
|
||||
stopBriefRotation() {},
|
||||
pushCockpitSignal(key, tone, title, detail) {
|
||||
signals.push({ key, tone, title, detail });
|
||||
},
|
||||
dropCockpitSignal(key) {
|
||||
dropped.push(key);
|
||||
},
|
||||
scheduleContextLayout() {},
|
||||
updateLocalPosition() {},
|
||||
maybeRefreshRegionalBrief() {},
|
||||
showContextCard() {},
|
||||
});
|
||||
t.after(() => {
|
||||
cockpit.stop();
|
||||
app.unmount();
|
||||
document.body.classList.remove(
|
||||
'cockpit-mode',
|
||||
'cockpit-subject',
|
||||
'cockpit-bridge',
|
||||
);
|
||||
});
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
return {
|
||||
$,
|
||||
cockpit,
|
||||
viewer,
|
||||
aircraft,
|
||||
store,
|
||||
shots,
|
||||
warned,
|
||||
signals,
|
||||
dropped,
|
||||
selectVessel(record) {
|
||||
vesselSelected = record;
|
||||
},
|
||||
setFloor(value) {
|
||||
floorM = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function manualClock(t, startMs) {
|
||||
const originalNow = performance.now;
|
||||
const originalDateNow = Date.now;
|
||||
let nowMs = startMs;
|
||||
performance.now = () => nowMs;
|
||||
Date.now = () => nowMs;
|
||||
t.after(() => {
|
||||
performance.now = originalNow;
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
return {
|
||||
advance(ms) {
|
||||
nowMs += ms;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
await nextTick();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
/** The provider's last release call. */
|
||||
const lastRelease = (calls) => calls.findLast(([name]) => name === 'release');
|
||||
|
||||
/** The camera's height above the ellipsoid in its last shot (m). */
|
||||
const shotHeight = (shots) =>
|
||||
Cesium.Cartographic.fromCartesian(shots.at(-1).destination).height;
|
||||
|
||||
const VESSEL = Object.freeze({
|
||||
id: '211000000',
|
||||
mmsi: '211000000',
|
||||
name: 'NORDIC STAR',
|
||||
type: '70',
|
||||
lat: 54.5,
|
||||
lon: 10.2,
|
||||
speed: 12,
|
||||
course: 95,
|
||||
heading: 90,
|
||||
missedRefreshes: 0,
|
||||
});
|
||||
|
||||
test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => {
|
||||
const ride = subjectProvider();
|
||||
const { $, cockpit, store } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
const entry = $('#cockpit-entry');
|
||||
assert.equal(entry.hidden, false);
|
||||
assert.match(entry.textContent, /commute/);
|
||||
assert.match(entry.textContent, /RIDE/);
|
||||
assert.equal(store.presence.entryKind, 'example-kind');
|
||||
// A candidate may bring its own face.
|
||||
ride.subject.candidate = {
|
||||
id: 'a2',
|
||||
name: 'Tram 4',
|
||||
face: { icon: 'tram', label: 'BOARD' },
|
||||
};
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.match(entry.textContent, /tram/);
|
||||
assert.match(entry.textContent, /BOARD/);
|
||||
ride.subject.allowed = false;
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(entry.hidden, true, "the provider's own rule shuts entry");
|
||||
assert.equal(cockpit.enter(), false);
|
||||
ride.subject.allowed = true;
|
||||
ride.subject.candidate = null;
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
assert.equal(entry.hidden, true, 'no candidate, no entry');
|
||||
});
|
||||
|
||||
test('entry holds the subject, takes the camera and shows the generic HUD', async (t) => {
|
||||
manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { $, cockpit, shots, store, signals } = fixture(t, {
|
||||
providers: [ride],
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
await nextTick();
|
||||
$('#cockpit-entry').click();
|
||||
await settle();
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.equal(cockpit.subjectKind, 'example-kind');
|
||||
assert.deepEqual(
|
||||
ride.calls.filter(([name]) => name === 'hold'),
|
||||
[['hold', 'a1']],
|
||||
);
|
||||
assert.ok(document.body.classList.contains('cockpit-subject'));
|
||||
assert.equal(store.presence.subjectGeneric, true);
|
||||
const hud = $('#cockpit-hud');
|
||||
assert.equal(hud.getAttribute('data-subject'), 'example-kind');
|
||||
assert.ok(hud.hasAttribute('data-generic'));
|
||||
assert.equal(hud.getAttribute('aria-label'), 'Example ride view');
|
||||
assert.equal(
|
||||
$('#map-view-switch').getAttribute('aria-label'),
|
||||
'Exit example view',
|
||||
);
|
||||
assert.match($('#map-view-switch').textContent, /EXIT RIDE/);
|
||||
// The provider's words, not the aircraft's.
|
||||
assert.equal($('.cockpit-kicker').textContent, HUD.kicker);
|
||||
assert.equal($('#cockpit-callsign').textContent, HUD.title);
|
||||
assert.equal($('#cockpit-aircraft-meta').getAttribute('data-tone'), 'live');
|
||||
assert.match($('.cockpit-speed').textContent, /84/);
|
||||
assert.match($('.cockpit-speed').textContent, /km\/h/);
|
||||
assert.match($('.cockpit-speed').textContent, /LIVE/);
|
||||
assert.equal($('.cockpit-scan-array'), null);
|
||||
assert.equal($('#cockpit-altitude-rim'), null);
|
||||
assert.equal($('#cockpit-speed-rim'), null);
|
||||
assert.equal($('.cockpit-roll-arc'), null);
|
||||
assert.equal($('.cockpit-reticle'), null);
|
||||
const card = $('[data-readout="next"]');
|
||||
assert.match(card.textContent, /NEXT STOP/);
|
||||
assert.match(card.textContent, /Central/);
|
||||
assert.match($('[data-source="track"]').textContent, /operator shape/);
|
||||
assert.match($('.cockpit-banner').textContent, /IN STRUCTURE/);
|
||||
const veil = $('.cockpit-veil');
|
||||
assert.equal(hud.firstElementChild, veil, 'the veil lies under the HUD');
|
||||
assert.ok(veil.classList.contains('streaks'));
|
||||
assert.equal(veil.style.opacity, '0.8');
|
||||
const progress = $('.cockpit-progress');
|
||||
assert.equal(
|
||||
progress.getAttribute('aria-label'),
|
||||
'Route progress: Origin to Terminus',
|
||||
);
|
||||
assert.equal(progress.querySelectorAll('.cockpit-progress-tick').length, 3);
|
||||
assert.match(progress.textContent, /Origin/);
|
||||
assert.match(progress.textContent, /TIMETABLE/);
|
||||
assert.ok(hud.hasAttribute('data-progress'));
|
||||
assert.match($('.cockpit-help').textContent, /ARROWS LOOK/);
|
||||
// The route card is titled by the provider, not as a flight plan.
|
||||
const route = $('#cockpit-route');
|
||||
assert.equal(route.hidden, false);
|
||||
assert.equal($('.cockpit-route-kicker', route).textContent, 'LINE');
|
||||
assert.equal(route.getAttribute('aria-label'), 'LINE');
|
||||
// TRACK ACQUIRED names the subject; the HUD view's rows reach Live Signals.
|
||||
assert.deepEqual(signals[0], {
|
||||
key: 'track',
|
||||
tone: 'track',
|
||||
title: 'TRACK ACQUIRED',
|
||||
detail: 'Example 12',
|
||||
});
|
||||
assert.ok(
|
||||
signals.some(
|
||||
(row) => row.key === 'subject:note' && row.detail === 'a provider row',
|
||||
),
|
||||
);
|
||||
assert.ok(shots.length >= 1 || cockpit.lastSubjectFrame);
|
||||
});
|
||||
|
||||
test('the eye stands its height above the frame, and the ground clamp can be lifted', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
|
||||
// Every frame updates (cameraIntervalMs 0).
|
||||
const before = shots.length;
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.equal(shots.length, before + 1);
|
||||
// Inside a structure the frame stands below the rendered floor: clamped,
|
||||
// the eye is pushed up to the floor; with groundClamp false it stays.
|
||||
setFloor(FLOOR_M + 200);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped');
|
||||
ride.subject.frame = { ...ride.subject.frame, groundClamp: false };
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(
|
||||
Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01,
|
||||
'not clamped',
|
||||
);
|
||||
// No height in the frame: the eye stands on the shared floor.
|
||||
setFloor(FLOOR_M);
|
||||
ride.subject.frame = { ...ride.subject.frame, heightM: null };
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01);
|
||||
});
|
||||
|
||||
test('an exact anchor sits on the subject, wherever the frame moves it', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { cockpit, shots } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
for (const step of [1, 2, 3]) {
|
||||
const longitude = LON + step * 0.001;
|
||||
ride.subject.frame = { ...ride.subject.frame, longitude };
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
const at = Cesium.Cartographic.fromCartesian(shots.at(-1).destination);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(at.longitude) - longitude) < 1e-7,
|
||||
`step ${step}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('the view ends when the frame stops coming, and gives the subject back', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
for (const ending of [
|
||||
null,
|
||||
{ latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } },
|
||||
]) {
|
||||
const ride = subjectProvider();
|
||||
const { cockpit } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
assert.equal(cockpit.enter(), true);
|
||||
ride.subject.frame = ending;
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]);
|
||||
assert.equal(document.body.classList.contains('cockpit-subject'), false);
|
||||
}
|
||||
});
|
||||
|
||||
test('leaving by the exit control asks the provider to restore its subject', async (t) => {
|
||||
const ride = subjectProvider();
|
||||
const { $, cockpit } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
await settle();
|
||||
$('#map-view-switch').click();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]);
|
||||
});
|
||||
|
||||
test('entry fails, releasing nothing it did not hold, when hold or the first frame fails', async (t) => {
|
||||
const refused = subjectProvider();
|
||||
refused.subject.hold = false;
|
||||
let app = fixture(t, { providers: [refused] });
|
||||
app.cockpit.syncEntry();
|
||||
assert.equal(app.cockpit.enter(), false);
|
||||
assert.equal(
|
||||
refused.calls.some(([name]) => name === 'release'),
|
||||
false,
|
||||
);
|
||||
const blank = subjectProvider();
|
||||
blank.subject.frame = null;
|
||||
app = fixture(t, { providers: [blank] });
|
||||
app.cockpit.syncEntry();
|
||||
assert.equal(app.cockpit.enter(), false);
|
||||
assert.deepEqual(lastRelease(blank.calls), ['release', 'a1', false]);
|
||||
assert.equal(app.cockpit.active, false);
|
||||
});
|
||||
|
||||
test('a read that throws ends the view once; a hud that throws keeps the last', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { $, cockpit, warned } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
await settle();
|
||||
ride.subject.hud = new Error('hud failed');
|
||||
clock.advance(200);
|
||||
cockpit.update();
|
||||
await settle();
|
||||
assert.equal(cockpit.active, true);
|
||||
assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept');
|
||||
ride.subject.frame = new Error('read failed');
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.equal(
|
||||
warned.filter((message) => /example-kind read\(\) failed/.test(message))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
warned.filter((message) => /example-kind hud\(\) failed/.test(message))
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('an aircraft tracked during the view ends it, never adopted', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { cockpit, viewer, aircraft } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
viewer.trackedEntity = aircraft;
|
||||
assert.equal(cockpit._adoptTrackedEntity(performance.now()), false);
|
||||
clock.advance(16);
|
||||
cockpit.update();
|
||||
assert.equal(cockpit.active, false);
|
||||
assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]);
|
||||
assert.equal(cockpit.navigateContext(1), false);
|
||||
});
|
||||
|
||||
test('the most recently selected subject wins entry', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
ride.subject.candidate = null;
|
||||
const { cockpit, store, selectVessel } = fixture(t, {
|
||||
providers: [ride],
|
||||
vessel: VESSEL,
|
||||
});
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'vessel');
|
||||
// A subject selected after the vessel takes the entry.
|
||||
clock.advance(500);
|
||||
ride.subject.candidate = { id: 'a1', name: 'Example 12' };
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'example-kind');
|
||||
assert.equal(cockpit.resolveEntryKind(), 'example-kind');
|
||||
// Another vessel selected later takes it back.
|
||||
clock.advance(500);
|
||||
selectVessel({ ...VESSEL, id: '211000099', mmsi: '211000099' });
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryKind, 'vessel');
|
||||
// The newest decides alone: its shut gate leaves no entry.
|
||||
clock.advance(500);
|
||||
ride.subject.candidate = { id: 'a3', name: 'Example 3' };
|
||||
ride.subject.allowed = false;
|
||||
cockpit.syncEntry();
|
||||
assert.equal(store.presence.entryAvailable, false);
|
||||
assert.equal(cockpit.enter(), false);
|
||||
});
|
||||
|
||||
test('a provider view with look turns with the arrow keys and words its readout', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { $, cockpit } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
await settle();
|
||||
assert.equal(cockpit.lookTarget.pitchDeg, -3, 'rests at its own pitch');
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||
);
|
||||
clock.advance(200);
|
||||
cockpit.update();
|
||||
await settle();
|
||||
assert.notEqual(cockpit.lookTarget.yawDeg, 0);
|
||||
assert.ok(ride.calls.some(([name, , offset]) => name === 'hud' && offset));
|
||||
assert.equal($('.cockpit-look').hidden, false);
|
||||
assert.equal($('#cockpit-look-readout').textContent, HUD.lookReadout);
|
||||
assert.equal(
|
||||
$('#cockpit-look-ahead').getAttribute('aria-label'),
|
||||
'Look ahead along the line',
|
||||
);
|
||||
// Without `look` the keys leave the view alone.
|
||||
const still = subjectProvider({ look: undefined });
|
||||
const other = fixture(t, { providers: [still] });
|
||||
other.cockpit.syncEntry();
|
||||
other.cockpit.enter();
|
||||
assert.equal(
|
||||
other.cockpit.turnLook({ axis: 'yaw', sign: 1, stepDeg: 15 }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('rows a HUD view no longer lists leave Live Signals', async (t) => {
|
||||
const clock = manualClock(t, 10_000);
|
||||
const ride = subjectProvider();
|
||||
const { cockpit, dropped } = fixture(t, { providers: [ride] });
|
||||
cockpit.syncEntry();
|
||||
cockpit.enter();
|
||||
ride.subject.hud = { ...HUD, signals: [] };
|
||||
clock.advance(1000);
|
||||
cockpit.update();
|
||||
assert.ok(dropped.includes('subject:note'));
|
||||
});
|
||||
@@ -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,151 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** Entry's fixed order, for candidates first seen at the same moment. */
|
||||
const BUILT_IN_ENTRY_ORDER = Object.freeze({
|
||||
aircraft: 0,
|
||||
satellite: 1,
|
||||
vessel: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Every subject entry could ride now, the most recently selected first: a
|
||||
* tracked aircraft, the tracked satellite, the selected vessel and each
|
||||
* provider's candidate. A candidate's time is when this controller first saw
|
||||
* it (it looks on its entry cadence and on tracking changes); candidates
|
||||
* first seen together keep the fixed order (aircraft, satellite, vessel, then
|
||||
* providers by priority). Each carries `allowed()`, its kind's entry gate.
|
||||
* @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) => {
|
||||
const last = seen.get(kind);
|
||||
if (!last || last.id !== id) seen.set(kind, { id, atMs: nowMs });
|
||||
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),
|
||||
});
|
||||
});
|
||||
// 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);
|
||||
return list.sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order);
|
||||
}
|
||||
|
||||
/**
|
||||
* The subject entry would ride: the most recently selected one. It decides
|
||||
* alone, so when its own gate is shut entry is unavailable rather than taken
|
||||
* by an older selection.
|
||||
* @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 +191,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 most recently selected subject'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 +213,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(
|
||||
@@ -130,37 +271,43 @@ export function syncTr3bToggle(info) {
|
||||
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 most recently selected subject decides (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 +327,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 +365,119 @@ 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, releasing what was
|
||||
* held, when the gate is shut, the provider refuses the hold or no frame
|
||||
* comes.
|
||||
* @param {object} provider From cockpitSubjectProviders.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function enterSubject(provider) {
|
||||
const candidate = this.readSubjectCandidate(provider);
|
||||
if (!candidate || !this.isSubjectEntryAllowed(provider, candidate))
|
||||
return false;
|
||||
if (this.callSubject(provider, 'hold', [candidate.id], false) !== true)
|
||||
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.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 most recently selected subject (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 +626,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 +660,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 +668,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 +684,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;
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/* 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;
|
||||
}
|
||||
#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;
|
||||
}
|
||||
@@ -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,26 @@ 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>
|
||||
<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 +278,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 +291,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 +313,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')">
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<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.
|
||||
|
||||
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="img" :aria-label="progress.summary || undefined">
|
||||
<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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user