diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index bdfefe043..6b86534a8 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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, diff --git a/package.json b/package.json index b8f5c581a..223144a13 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b49781778..24937b3b6 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index a771cc26f..92d2e2669 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -22,6 +22,9 @@ "src/ui/vueRuntime.js", "src/ui/views/index.js", "src/ui/views/CockpitView.vue", + "src/ui/views/panels/CockpitProgress.vue", + "src/cockpitProgress.js", + "src/cockpitSubjectProviders.js", "src/ui/views/CommandDockView.vue", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", @@ -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": [] } } diff --git a/src/app/controls.js b/src/app/controls.js index 99bc2555f..906e867e6 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -10,6 +10,8 @@ import { t } from '../i18n/index.js'; * opens a share code (the `` of `/share/`) 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 }, diff --git a/src/cockpitProgress.js b/src/cockpitProgress.js new file mode 100644 index 000000000..67a3be951 --- /dev/null +++ b/src/cockpitProgress.js @@ -0,0 +1,182 @@ +/** + * @module cockpitProgress + * @description Lay out a subject's progress line (cockpitSubjectProviders.js) + * at a width in pixels: where each tick, label and the marker go. + * + * Ticks sit at fractions of the line, 0 at the left. Ticks closer than + * `minTickPx` merge, so a long run still reads as a line: a labelled tick + * keeps its place and takes in the bare ticks beside it, and bare ticks merge + * with each other at their mean place. A merged tick takes the strongest tone + * of those it holds. Labels never overlap: where two would, the one of lower + * priority is left out (on a tie, the later one). + * + * Pure: no DOM. + */ + +/** Bare ticks closer than this merge (px). */ +export const COCKPIT_PROGRESS_MIN_TICK_PX = 6; +/** Room kept between two labels (px). */ +export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8; + +/** Tones from weakest to strongest, for a merged tick. */ +const TONE_RANK = Object.freeze([ + 'muted', + 'normal', + 'live', + 'estimate', + 'warning', + 'alert', +]); +const stronger = (a, b) => + TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a; + +/** A label's width estimate (px): its longer line in small capitals. */ +export function estimateProgressLabelPx(label) { + const longest = Math.max( + String(label?.text ?? '').length, + String(label?.detail ?? '').length, + ); + return longest * 6.4 + 8; +} + +/** + * The progress line at `widthPx`, or null when there is nothing to draw. + * @param {{ticks: Array<{at: number, tone: string, label: object|null}>, + * marker: {at: number, dwell: boolean}|null, tag: string}|null} progress + * As cockpitSubjectHudView gives it: ticks sorted by `at`. + * @param {number} widthPx + * @param {{minTickPx?: number, gapPx?: number, + * measure?: (label: object) => number}} [options] + * @returns {{ticks: Array<{x: number, tone: string, count: number, + * labelled: boolean}>, labels: Array<{x: number, left: number, + * width: number, align: string, text: string, detail: string, + * tone: string}>, marker: {x: number, dwell: boolean}|null, + * travelledX: number, tag: string}|null} + */ +export function cockpitProgressLayout( + progress, + widthPx, + { + minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX, + gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX, + measure = estimateProgressLabelPx, + } = {}, +) { + const ticks = progress?.ticks; + if (!Array.isArray(ticks) || ticks.length < 2) return null; + if (!Number.isFinite(widthPx) || widthPx <= 0) return null; + const placed = ticks.map((tick, order) => ({ + x: tick.at * widthPx, + tone: tick.tone, + label: tick.label, + order, + })); + const labelled = placed.filter((tick) => tick.label); + // Bare ticks beside a labelled one go into it. + const kept = labelled.map((tick) => ({ + x: tick.x, + tone: tick.tone, + count: 1, + labelled: true, + label: tick.label, + order: tick.order, + })); + const bare = []; + for (const tick of placed) { + if (tick.label) continue; + const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx); + if (host) { + host.tone = stronger(host.tone, tick.tone); + host.count += 1; + } else bare.push(tick); + } + // Bare ticks in a chain closer than minTickPx merge at their mean place. + let cluster = null; + const flush = () => { + if (!cluster) return; + kept.push({ + x: cluster.sum / cluster.count, + tone: cluster.tone, + count: cluster.count, + labelled: false, + label: null, + order: cluster.order, + }); + cluster = null; + }; + for (const tick of bare) { + if (cluster && tick.x - cluster.last < minTickPx) { + cluster.sum += tick.x; + cluster.count += 1; + cluster.last = tick.x; + cluster.tone = stronger(cluster.tone, tick.tone); + } else { + flush(); + cluster = { + sum: tick.x, + count: 1, + last: tick.x, + tone: tick.tone, + order: tick.order, + }; + } + } + flush(); + kept.sort((a, b) => a.x - b.x || a.order - b.order); + + // Labels by priority; one that would overlap a placed label is left out. + const boxes = []; + const candidates = kept + .filter((tick) => tick.labelled) + .sort((a, b) => b.label.priority - a.label.priority || a.order - b.order); + for (const tick of candidates) { + const width = Math.min(widthPx, Math.max(0, measure(tick.label))); + let left = + tick.label.align === 'start' + ? tick.x + : tick.label.align === 'end' + ? tick.x - width + : tick.x - width / 2; + left = Math.min(Math.max(0, left), widthPx - width); + const right = left + width; + if ( + boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx) + ) + continue; + boxes.push({ + x: tick.x, + left, + right, + width, + align: tick.label.align, + text: tick.label.text, + detail: tick.label.detail, + tone: tick.tone, + order: tick.order, + }); + } + boxes.sort((a, b) => a.left - b.left); + const marker = progress.marker + ? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell } + : null; + return { + ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({ + x, + tone, + count, + labelled: isLabelled, + })), + labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({ + x, + left, + width, + align, + text, + detail, + tone, + })), + marker, + travelledX: marker ? marker.x : 0, + tag: progress.tag || '', + }; +} diff --git a/src/cockpitProgress.test.mjs b/src/cockpitProgress.test.mjs new file mode 100644 index 000000000..f1bd9d819 --- /dev/null +++ b/src/cockpitProgress.test.mjs @@ -0,0 +1,144 @@ +// src/cockpitProgress.test.mjs — a subject's progress line laid out at a +// width: close bare ticks merge, labelled ticks keep their place, labels never +// overlap (the lower priority gives way) and the part before the marker is +// the travelled part. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cockpitProgressLayout } from './cockpitProgress.js'; + +const tick = (at, label = null, tone = 'normal') => ({ at, tone, label }); +const label = (text, priority, align = 'center', detail = '') => ({ + text, + detail, + priority, + align, +}); +/** Every label is 50 px wide. */ +const measure = () => 50; + +test('nothing is drawn without two ticks or a width', () => { + assert.equal(cockpitProgressLayout(null, 500), null); + assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null); + assert.equal( + cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0), + null, + ); +}); + +test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.1), + tick(0.101, null, 'warning'), + tick(0.102), + tick(0.5), + tick(1, label('Z', 1, 'end')), + ], + marker: { at: 0.3, dwell: false }, + tag: 'TIMETABLE', + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.ticks.map(({ x, count, tone, labelled }) => [ + Math.round(x), + count, + tone, + labelled, + ]), + [ + [0, 1, 'normal', true], + [101, 3, 'warning', false], + [500, 1, 'normal', false], + [1000, 1, 'normal', true], + ], + ); + assert.deepEqual(layout.marker, { x: 300, dwell: false }); + assert.equal(layout.travelledX, 300); + assert.equal(layout.tag, 'TIMETABLE'); +}); + +test('a labelled tick keeps its place and takes in the bare ticks beside it', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.498), + tick(0.5, label('Next', 3), 'alert'), + tick(0.503, null, 'warning'), + tick(1, label('Z', 1, 'end')), + ], + marker: null, + }, + 1000, + { measure }, + ); + const next = layout.ticks.find(({ x }) => Math.round(x) === 500); + assert.equal(next.count, 3); + assert.equal(next.labelled, true); + assert.equal(next.tone, 'alert'); + assert.equal(layout.ticks.length, 3); + assert.equal(layout.marker, null); + assert.equal(layout.travelledX, 0); +}); + +test('labels hang from their ticks inside the line, and overlaps give way by priority', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('First', 1, 'start')), + tick(0.04, label('Previous', 2)), + tick(0.5, label('Next', 3)), + tick(0.52, label('Close', 0)), + tick(1, label('Last', 1, 'end')), + ], + marker: { at: 0.2, dwell: true }, + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.labels.map(({ text, left }) => [text, Math.round(left)]), + [ + ['Previous', 15], + ['Next', 475], + ['Last', 950], + ], + ); + // The first stop's label would cover the previous stop's: it gives way. + assert.ok(!layout.labels.some(({ text }) => text === 'First')); + assert.ok(!layout.labels.some(({ text }) => text === 'Close')); + assert.deepEqual(layout.marker, { x: 200, dwell: true }); +}); + +test('a centred label at an edge is clamped inside the line', () => { + const layout = cockpitProgressLayout( + { ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null }, + 300, + { measure }, + ); + assert.equal(layout.labels[0].left, 0); + const right = cockpitProgressLayout( + { ticks: [tick(0), tick(1, label('Edge', 1))], marker: null }, + 300, + { measure }, + ); + assert.equal(right.labels[0].left, 250); +}); + +test('a long run of calls still lays out, every one a tick or part of one', () => { + const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59)); + ticks[0].label = label('First', 1, 'start'); + ticks[59].label = label('Last', 1, 'end'); + const narrow = cockpitProgressLayout({ ticks, marker: null }, 200); + const wide = cockpitProgressLayout({ ticks, marker: null }, 2000); + const count = (layout) => + layout.ticks.reduce((sum, entry) => sum + entry.count, 0); + assert.equal(count(narrow), 60); + assert.equal(count(wide), 60); + assert.ok(narrow.ticks.length < wide.ticks.length); + assert.equal(wide.ticks.length, 60); +}); diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js new file mode 100644 index 000000000..4f044fd6d --- /dev/null +++ b/src/cockpitSubjectProviders.js @@ -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} + */ +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; + } + }; +} diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs new file mode 100644 index 000000000..487d5d261 --- /dev/null +++ b/src/cockpitSubjectProviders.test.mjs @@ -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/); +}); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 6e3238ec5..ec6b1f32c 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -481,6 +481,24 @@ export function orbitEntryAllowed({ ); } +/** + * Whether an application subject provider's candidate can be ridden. Like the + * bridge and orbit it needs only its own layer on, and waits out a Context + * change in flight; the provider then has its own say (`isEntryAllowed`). + * @param {{layerEnabled: boolean, contextModeChanging: boolean, + * providerAllows: boolean}} state + * @returns {boolean} + */ +export function subjectEntryAllowed({ + layerEnabled, + contextModeChanging, + providerAllows, +}) { + return ( + Boolean(layerEnabled) && !contextModeChanging && providerAllows === true + ); +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/i18n/hostLocale.js b/src/i18n/hostLocale.js new file mode 100644 index 000000000..5e05e3a07 --- /dev/null +++ b/src/i18n/hostLocale.js @@ -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'; diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js new file mode 100644 index 000000000..b9b2d16cc --- /dev/null +++ b/src/layers/rail/cockpitSubject.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 }; +} diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs new file mode 100644 index 000000000..d04c62277 --- /dev/null +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -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); +}); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 0a191c62a..4a4e6d6ab 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -7,6 +7,7 @@ import { createIngestion } from './ingestion.js'; import { createViewport } from './viewport.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createRailCockpitSubject } from './cockpitSubject.js'; import { createRouteDisplay } from './routeDisplay.js'; import { createRailSchedules } from './schedules.js'; import { getRegisteredRailFeed } from '../../data/railFeeds.js'; @@ -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, diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js index 54d885e37..8bd4a342f 100644 --- a/src/layers/rail/ingestion.js +++ b/src/layers/rail/ingestion.js @@ -69,6 +69,13 @@ export function createIngestion({ state, services, parts, source }) { function removeTrain(key) { const entry = state._trains.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js); its + // feed stays polled until the view lets go, so the train is found again + // if the feed lists it again. + if (state._cockpitSubjectKey === key) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = entry.feedId; + } if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) state._markers?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index e44dc0ce3..515fef080 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -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. diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js index 36814c036..eb407d1d3 100644 --- a/src/layers/rail/pathBinding.js +++ b/src/layers/rail/pathBinding.js @@ -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) diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs index c8983a696..79aee4bce 100644 --- a/src/layers/rail/pathBinding.test.mjs +++ b/src/layers/rail/pathBinding.test.mjs @@ -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); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index eff1ace98..12ab9ab1a 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -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(); diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js index 283d19295..69c1dc840 100644 --- a/src/layers/rail/routeDisplay.js +++ b/src/layers/rail/routeDisplay.js @@ -427,10 +427,29 @@ export function createRouteDisplay({ updateSplit(); } + /** + * Show the selected line unless a first-person view rides its train + * (cockpitSubject.js): from inside the train it would run up the middle of + * the view and under the eye. + */ + function applyHold() { + const route = state._route; + if (!route?.primitive) return; + const shown = !( + state._cockpitSubjectKey !== null && + state._cockpitSubjectKey === state._selectedKey + ); + if (route.primitive.show !== shown) { + route.primitive.show = shown; + governorRequestRender('rail-route'); + } + } + /** Move the travelled/ahead split to where the train is drawn now. */ function updateSplit() { const route = state._route; if (!route?.primitive) return; + applyHold(); const binding = route.binding; const s = binding ? binding.drawS : NaN; if (route.direction === 0 && Number.isFinite(s)) { @@ -777,6 +796,7 @@ export function createRouteDisplay({ applyNetwork, onCamera, syncRoute, + applyHold, refreshStops, cullStops, onSelectionChanged, diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 4f4564bc6..644775961 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -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); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js index 03771bd6c..065687d7d 100644 --- a/src/layers/rail/viewport.js +++ b/src/layers/rail/viewport.js @@ -148,6 +148,12 @@ export function createViewport({ state, services, parts, source }) { )) if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } + // A first-person view whose train its feed dropped keeps the feed polled + // until it lets go (cockpitSubject.js): from the cab the camera looks + // along the ground, often at a point beyond the feed's range. + const lost = state._cockpitLostFeedId; + if (lost && !desired.has(lost) && state._activeFeeds.has(lost)) + desired.set(lost, state._activeFeeds.get(lost)); // The selected train's feed stays polled however far out or away the // camera goes, so the train and its line stay on the map; while nothing // else would keep the feed, it carries the selected train alone and its diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js new file mode 100644 index 000000000..33f215cf3 --- /dev/null +++ b/src/layers/transit/cockpitSubject.js @@ -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 }; +} diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 171e0fb82..e6bf1ffa7 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -10,6 +10,7 @@ import { createQueries } from './queries.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { createTesting } from './testing.js'; +import { createTransitCockpitSubject } from './cockpitSubject.js'; import { TRANSIT_POLL_MS } from './policy.js'; /** @@ -54,12 +55,14 @@ export function createTransitLayer({ parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); + parts.cockpit = createTransitCockpitSubject(context); return Object.assign( { updateInterval: TRANSIT_POLL_MS }, parts.lifecycle.methods, parts.queries.methods, parts.controls.methods, + parts.cockpit.methods, parts.testing, ); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index afce6a4aa..3f5669c65 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -71,6 +71,8 @@ export function createIngestion({ state, services, parts, source }) { function removeVehicle(key) { const entry = state._vehicles.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js). + if (state._cockpitSubjectKey === key) state._cockpitSubjectKey = null; if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) entry.markerCollection?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 90210063a..5fafb1026 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ state, services, parts }) { state._vehicles.clear(); parts.height.clear(); state._selectedKey = null; + state._cockpitSubjectKey = null; state._lastUpdate = null; state._error = null; state._limitWarned = false; diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index fee9a60ed..e4b9cce69 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -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); +}); diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 664b1688d..02c9378e9 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -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); diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index fd066ca55..f28067f13 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -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. diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index b58f181d5..7f442872e 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -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; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index 47973c1a2..4080766ee 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -235,8 +235,11 @@ export function createTrails({ state, services, parts, source }) { } function update() { if (!selected || !renderer) return; + // A first-person view on the vehicle rides where its trail would start. renderer.setVisible( - state._enabled && state._vehicles.get(selected.key) === selected, + state._enabled && + state._vehicles.get(selected.key) === selected && + selected.key !== state._cockpitSubjectKey, ); renderer.setDisplaySample(selected.sample, selected.marker?.position); } diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 38315e4ae..ba8f25a5d 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -101,6 +101,10 @@ export function createViewport({ state, services, parts }) { if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } } + // A vehicle a first-person view rides keeps its feed polled. + const held = state._vehicles.get(state._cockpitSubjectKey); + if (held && state._activeFeeds.has(held.feedId)) + desired.set(held.feedId, state._activeFeeds.get(held.feedId)); for (const feedId of [...state._activeFeeds.keys()]) { if (!desired.has(feedId)) { state._inFlight.get(feedId)?.controller.abort(); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0adedd7a5..1b7e9168c 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade { owner: 'cockpit-orbit', switchPolicy: 'esri-if-photoreal', }) ?? null, + // The application's own first-person subjects + // (src/cockpitSubjectProviders.js), checked by the coordinator. + cockpitSubjects: services.cockpitSubjects, }, elements: { _ppToggles: this._ppToggles, diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index e2046d72f..85678ad1f 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index e97b7f232..b6da530b7 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -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); diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index f7f236dea..301515b42 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -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(); diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index c431a2ef5..baf6cc862 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -5,7 +5,9 @@ import { bridgeEntryAllowed, cockpitEntryAllowed, orbitEntryAllowed, + subjectEntryAllowed, } from '../contextModePolicy.js'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -60,8 +62,11 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, acquireOrbitImagery, + cockpitSubjects, } = services; this._cockpitDisplayPortal = null; + // The application's own subjects (services.cockpitSubjects), checked once. + const subjectProviders = cockpitSubjectProviders(cockpitSubjects ?? []); this.cockpitView = new CockpitViewController(viewer, { services: { flightsLayer, @@ -108,6 +113,21 @@ export class CockpitCoordinator { vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), contextModeChanging: this._contextModeChanging, }), + subjectProviders, + // A provider's subject needs its own layer on, no Context change in + // flight, and the provider's own say. + isSubjectEntryAllowed: (provider, candidate) => + subjectEntryAllowed({ + layerEnabled: this._isLayerEnabled(provider.layerId), + contextModeChanging: this._contextModeChanging, + providerAllows: + this.cockpitView?.callSubject( + provider, + 'isEntryAllowed', + [candidate], + false, + ) === true, + }), isOrbitEntryAllowed: () => orbitEntryAllowed({ satellitesEnabled: !!this._dataManager?.isEnabled('satellites'), @@ -197,6 +217,9 @@ export class CockpitCoordinator { this.cockpitView.subjectKind === 'satellite' ) return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit'; + // And in an application's own first-person view. + if (this.cockpitView?.active && this.cockpitView.subjectProvider) + return 'Cockpit is in another first-person view — exit it first, then track an aircraft to enter Cockpit'; if (!this._aircraftLayerEnabled) return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; return null; @@ -216,6 +239,17 @@ export class CockpitCoordinator { active && this.cockpitView?.subjectKind === 'satellite' ? this.cockpitView.readSatelliteInfo?.() : null; + // And a provider's subject in use. + const provided = + active && this.cockpitView?.subjectProvider + ? { + kind: this.cockpitView.subjectProvider.kind, + id: this.cockpitView.subjectId, + layerId: this.cockpitView.subjectProvider.layerId, + callsign: null, + name: this.cockpitView.lastSubjectFrame?.name ?? null, + } + : null; const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off @@ -244,30 +278,32 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: satellite - ? { - kind: 'satellite', - id: satellite.id, - layerId: satellite.layerId, - callsign: null, - name: satellite.name, - } - : vessel + subject: provided + ? provided + : satellite ? { - kind: 'vessel', - id: vessel.id, - layerId: vessel.layerId, + kind: 'satellite', + id: satellite.id, + layerId: satellite.layerId, callsign: null, - name: vessel.name, + name: satellite.name, } - : info + : vessel ? { - kind: 'aircraft', - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.id, + layerId: vessel.layerId, + callsign: null, + name: vessel.name, } - : null, + : info + ? { + kind: 'aircraft', + id: info.icao24 || info.id || null, + layerId: info.layerId || null, + callsign: info.callsign || null, + } + : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index fd5dfb52b..85f82d6bb 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -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'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 4ce5d5b63..c3a982f92 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -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'); diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 0ba28d89a..ccc6a5b89 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,7 +52,7 @@ function createRimState(value) { */ export function createInstrumentState() { return { - // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + // 'aircraft', 'vessel' or 'subject': which readouts the HUD shows. kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), @@ -76,6 +76,19 @@ export function createInstrumentState() { look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', + // A provider subject's HUD (kind 'subject', cockpitSubjectProviders.js): + // its kicker, speed unit and tag, readouts, sources, banner, veil and + // progress line, all worded by the provider. + kicker: '', + speedUnit: '', + speedTag: '', + speedTone: 'normal', + readouts: [], + sources: [], + banner: null, + veil: null, + progress: null, + lookEnabled: false, }; } @@ -86,8 +99,10 @@ export function createInstrumentState() { */ export function createRouteState() { return { - // A vessel's card shows only the free-text AIS destination. + // A vessel's card shows only the free-text AIS destination; a provider's + // subject's (kind 'subject') carries its own title. kind: 'aircraft', + title: '', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index c5e9863cd..e4a98de1a 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -35,6 +35,7 @@ import { vesselCameraProfile, } from '../cockpitSubjects.js'; import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { cockpitSubjectHudView } from '../cockpitSubjectProviders.js'; import { RADAR_MAX_CONTACTS, radarPicture, @@ -90,6 +91,13 @@ export function publishInstrumentsView() { compass: view.compass.map((division) => ({ ...division })), compassMarkers: { ...view.compassMarkers }, look: { ...view.look }, + // A provider's view model is rebuilt on each HUD update, never edited + // in place, so it is published as it stands. + readouts: view.readouts, + sources: view.sources, + banner: view.banner, + veil: view.veil, + progress: view.progress, }); } @@ -118,24 +126,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; diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 344c6f799..99727c7be 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -184,11 +184,15 @@ const COCKPIT_ENTRY_FACES = Object.freeze({ }); /** - * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind + * The entry's glyph and localized label for the subject it would ride. An + * application provider's subject brings its own face, already in the page's + * language (`presence.entryFace`). + * @param {string|null|undefined} kind + * @param {{icon: string, label: string}|null} [face] * @returns {{icon: string, label: string}} */ -export function cockpitEntryFace(kind) { - const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; - return { icon: face.icon, label: t(face.label) }; +export function cockpitEntryFace(kind, face = null) { + if (face?.icon && face?.label) return { icon: face.icon, label: face.label }; + const known = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: known.icon, label: t(known.label) }; } diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs new file mode 100644 index 000000000..f7a9de2c4 --- /dev/null +++ b/src/ui/cockpitSubjectView.test.mjs @@ -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')); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 069441149..139ca67a3 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -5,6 +5,10 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +import { + cockpitSubjectCandidate, + cockpitSubjectFrame, +} from '../cockpitSubjectProviders.js'; /** The camera's own heading in degrees, or 0 when it has none. */ function cameraHeadingDeg(camera) { @@ -33,15 +37,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; diff --git a/src/ui/styles/cockpitSubject.css b/src/ui/styles/cockpitSubject.css new file mode 100644 index 000000000..172d41f6c --- /dev/null +++ b/src/ui/styles/cockpitSubject.css @@ -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; +} diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index d02122878..024165859 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -13,6 +13,7 @@ import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; import CockpitOrbital from './panels/CockpitOrbital.vue'; +import CockpitProgress from './panels/CockpitProgress.vue'; import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -29,30 +30,58 @@ const active = computed(() => Boolean(presence.value?.active)); const bridge = computed(() => presence.value?.subjectKind === 'vessel'); // A satellite subject keeps only its orbital HUD over the nadir picture. const orbital = computed(() => presence.value?.subjectKind === 'satellite'); +// An application provider's subject (src/cockpitSubjectProviders.js): a +// window view whose readouts, labels and texts the provider words. +const generic = computed(() => presence.value?.subjectGeneric === true); +const labels = computed(() => presence.value?.subjectLabels ?? null); const subject = computed(() => - bridge.value ? 'vessel' : orbital.value ? 'satellite' : 'aircraft', + generic.value + ? presence.value.subjectKind + : bridge.value + ? 'vessel' + : orbital.value + ? 'satellite' + : 'aircraft', ); const hudLabel = computed(() => - bridge.value - ? t('Ship bridge view') - : orbital.value - ? t('Satellite orbital view') - : t('Aircraft cockpit view'), + generic.value + ? labels.value?.view + : bridge.value + ? t('Ship bridge view') + : orbital.value + ? t('Satellite orbital view') + : t('Aircraft cockpit view'), ); const exitLabel = computed(() => - bridge.value - ? t('Exit bridge view') - : orbital.value - ? t('Exit orbital view') - : t('Exit cockpit view'), + generic.value + ? labels.value?.exit + : bridge.value + ? t('Exit bridge view') + : orbital.value + ? t('Exit orbital view') + : t('Exit cockpit view'), ); const exitText = computed(() => - bridge.value - ? t('EXIT BRIDGE') - : orbital.value - ? t('EXIT ORBIT') - : t('EXIT COCKPIT'), + generic.value + ? labels.value?.exitText + : bridge.value + ? t('EXIT BRIDGE') + : orbital.value + ? t('EXIT ORBIT') + : t('EXIT COCKPIT'), ); +// Look-around: on a bridge, or in a provider's view that has it. +const looks = computed( + () => bridge.value || (generic.value && Boolean(presence.value?.subjectLook)), +); +const aheadLabel = computed(() => + generic.value + ? presence.value?.subjectLook?.aheadLabel + : t('Look ahead: turn the bridge view back to the bow'), +); +const veilStyle = computed(() => ({ + opacity: String(hud.value.veil?.level ?? 0), +})); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); @@ -92,7 +121,9 @@ const tickStyle = (tick) =>