From 72112ecb45e2275401aa8e2bf9fcbab4e2312898 Mon Sep 17 00:00:00 2001 From: Denis Groshev Date: Wed, 4 Nov 2026 17:04:37 +0100 Subject: [PATCH] feat(cockpit): ride the selected vessel from its bridge With no aircraft tracked, Cockpit can ride the selected AIS vessel. Entry needs only Live AIS Vessels and a selected vessel; C, double-click and the entry buttons (reading BRIDGE with a ship icon) enter it, while voice stays aircraft-only. The eye sits on the AIS position at a representative bridge height for the vessel's type family, looks along true heading and moves along course over ground. The last fix is dead-reckoned for up to 180 s between the layer's refreshes, and on rivers and lakes the waterline is read from the rendered surface around the ship rather than the geoid. The bridge HUD shows speed and course over ground, a straight heading tape, window mullions, the AIS destination as a reported voyage and a heading-up radar of nearby ships (3 to 24 NM). The arrow keys look around, Home or 0 looks ahead again. The vessel layer hides the ridden ship's sprite and card, ignores scene clicks while held, and returns the camera to the vessel's map view on exit. --- docs/CURRENT-STATE.md | 89 +- index.html | 2 +- scripts/package-boundaries.json | 23 + src/cameraHandoff.test.mjs | 5 +- src/cockpitLook.js | 183 +++ src/cockpitLook.test.mjs | 137 ++ src/cockpitMath.js | 40 + src/cockpitMath.test.mjs | 42 + src/cockpitRadar.js | 166 +++ src/cockpitRadar.test.mjs | 140 +++ src/cockpitSubjects.js | 170 +++ src/cockpitSubjects.test.mjs | 137 ++ src/cockpitTracking.js | 3 +- src/contextModePolicy.js | 12 + src/contextModePolicy.test.mjs | 12 + src/i18n/locales/ar.json | 48 +- src/i18n/locales/de.json | 44 +- src/i18n/locales/es.json | 45 +- src/i18n/locales/fr.json | 45 +- src/i18n/locales/it.json | 45 +- src/i18n/locales/ja.json | 43 +- src/i18n/locales/pt.json | 45 +- src/i18n/locales/ru.json | 46 +- src/i18n/locales/uk.json | 46 +- src/i18n/locales/zh-CN.json | 43 +- src/i18n/locales/zh-Hant.json | 43 +- src/i18n/same-as-english.json | 18 +- src/layers/vessels/cockpitSubject.js | 142 +++ src/layers/vessels/cockpitSubject.test.mjs | 113 ++ src/layers/vessels/queries.js | 102 ++ src/layers/vessels/radarContacts.test.mjs | 118 ++ src/layers/vessels/records.js | 31 + src/layers/vessels/records.test.mjs | 30 + src/layers/vessels/rendering.js | 12 +- src/layers/vessels/selection.js | 3 + src/layers/vessels/state.js | 2 + src/ui/applicationShell.js | 3 + src/ui/cockpitBodyClasses.test.mjs | 2 +- src/ui/cockpitBridge.test.mjs | 1319 ++++++++++++++++++++ src/ui/cockpitBriefing.js | 2 +- src/ui/cockpitCamera.js | 230 +++- src/ui/cockpitController.js | 121 +- src/ui/cockpitCoordinator.js | 52 +- src/ui/cockpitEntryGate.test.mjs | 36 +- src/ui/cockpitInput.js | 122 +- src/ui/cockpitInstrumentState.js | 15 + src/ui/cockpitInstruments.js | 212 ++++ src/ui/cockpitPresentation.js | 8 +- src/ui/cockpitTrackingController.js | 164 ++- src/ui/stores/cockpit.js | 25 +- src/ui/styles/cockpit.css | 454 +++++++ src/ui/styles/cockpitRadar.css | 193 +++ src/ui/views/CockpitView.vue | 86 +- src/ui/views/ContextView.vue | 4 +- src/ui/views/IntelHudView.vue | 4 +- src/ui/views/panels/CockpitRadar.vue | 164 +++ src/ui/views/panels/CockpitRoute.vue | 8 +- style.css | 1 + 58 files changed, 5313 insertions(+), 137 deletions(-) create mode 100644 src/cockpitLook.js create mode 100644 src/cockpitLook.test.mjs create mode 100644 src/cockpitRadar.js create mode 100644 src/cockpitRadar.test.mjs create mode 100644 src/cockpitSubjects.js create mode 100644 src/cockpitSubjects.test.mjs create mode 100644 src/layers/vessels/cockpitSubject.js create mode 100644 src/layers/vessels/cockpitSubject.test.mjs create mode 100644 src/layers/vessels/radarContacts.test.mjs create mode 100644 src/ui/cockpitBridge.test.mjs create mode 100644 src/ui/styles/cockpitRadar.css create mode 100644 src/ui/views/panels/CockpitRadar.vue diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3bf6b1051..f8f69be47 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1524,6 +1524,77 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti and queued panel work stop before asynchronous layer restoration; final camera and portal cleanup follows that restoration. +### Ship bridge view + +With no aircraft tracked, Cockpit can ride the selected AIS vessel instead. +Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a +selected vessel; Contacts and every other layer may be off, before or during +the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode +change still in flight blocks entry until it settles. `C` enters it, and the +entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`) +and under the selected vessel's Intel HUD readout in the bottom-right corner +(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber +layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no +subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone, +so no other feed being off makes it partial. +Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only. +The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's +sprite, card and detection bracket while it is held (`setCockpitSubject`), and +ignores scene clicks so open water cannot deselect it. The eye sits on the AIS +position at a representative bridge height for the AIS type family +(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m, +tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text +type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no +family, including the reserved and wing-in-ground codes the vessel layer also +files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor +(`src/cockpitSubjects.js`). The camera faces true heading (course over ground +when heading is missing) and moves along course over ground. A ship reporting +neither has no direction: the camera keeps the heading it had on entry, the +heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal +names the ship without a heading, no bow or stern mark is drawn, and the eye +stays on the fix rather than moving along a made-up course. The vessel layer +redraws a ship only on its 60-second refresh, so the camera's target is the last +fix dead-reckoned along course over ground for up to 180 s, and the existing +inertial anchor absorbs each new fix. The fix's age is read without comparing +the two clocks (`vesselFixAgeSec`): the time since this client first received +the fix, plus how far it trailed that refresh's newest fix, so a client clock +running fast or slow neither projects the ship nor marks it stale, and a fix +the feed keeps re-serving from its cache goes on ageing into STALE FEED. A +stale vessel, or a projection past that limit, holds the camera on the target. +The vessel layer floats ships on the geoid, which on a river or lake can lie +well below the water (the Seine through Paris is ~27 m up), so the bridge also +reads the rendered surface around its ship: the ship's position and a ring of +eight probes 30 m out, the lowest probe taken as the water when it is 2 m to +4,000 m above sea level. The first read starts from the tiles already loaded, then +the full-detail read replaces it; it repeats every 5 s. Only one full-detail +read runs at a time: while one is still running, a refresh reads the loaded +tiles instead, and the slow read applies whenever it lands unless a newer +one has. A failed read keeps the sea surface. + +The HUD keeps its frame, vision styles, briefing, weather and Display/Radio +utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows +speed over ground in knots, true heading and course over ground in place of +altitude, hides the altitude tape, and shows the crew-entered AIS destination as +a Reported Voyage card without an origin or direction arrow. The arrow keys +look around the bridge in 5° steps (Shift for 45°): Left and Right turn the +view all the way round, Up and Down pitch it up to ±90°, and the camera eases +there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD +button shown while the view is turned, bring it back over the bow at the rest +pitch. The keys stay with a focused text field, slider, tab, radio group, list +or menu. The heading tape is a straight strip that scrolls continuously with +the camera, so the caret reads the view's exact heading, while the readout +above it keeps the ship's true heading; a ring with a centre dot marks the bow +(prograde) and a ring with a cross the stern (retrograde) at their bearings +from the caret. The bridge radar +shows nearby AIS ships heading-up and opens at 3 NM +(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps +3, 6, 12 and 24 NM and the chosen range is kept across bridges. Contact +Previous/Next and Live Signals aircraft handoffs are refused on the bridge and +the Contact card stays hidden. Exit returns the camera to the vessel's map view +through the layer's own focus. Tracking an aircraft or losing the vessel +selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs` +covers entry, readouts, refusal and exit. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -6270,14 +6341,16 @@ apply to `public/` assets or built-preview serving. `src/contextModePolicy.js`). Contacts is not required, and the other aircraft layer, Live AIS Vessels and every other layer may be turned on or off before or during a session. A Context mode change still in flight blocks entry until - it settles. Turning off the layer the view rides ends it. Riding an aircraft - while Contacts is on adopts its layer as the user's own, as a direct selection - does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of - its layers going off, restores the pre-Contacts layers but keeps the ridden - one and the view; Live Signals then drops the rows Contacts built and shows - `CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use - the same gate; voice entry still opens Contacts first, as its layer-targeted - entry navigates through Contacts. + it settles. Turning off the layer the view rides ends it. Riding an aircraft, + or a vessel from its bridge, while Contacts is on adopts its layer as the + user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts + ending, by its own switch or one of its layers going off, restores the + pre-Contacts layers but keeps the ridden one and the view; Live Signals then + drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible + Cockpit actions, the `C` shortcut and voice use the same gate; voice entry + still opens Contacts first, as its layer-targeted entry navigates through + Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`; + see [Ship bridge view](#ship-bridge-view)). - **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. diff --git a/index.html b/index.html index f5e3adc83..39ee7e01d 100644 --- a/index.html +++ b/index.html @@ -26,7 +26,7 @@ with the sources: src/materialSymbolsSubset.test.mjs fails when a source names a glyph this list is missing, because a missing glyph does not draw a box — the ligature never forms and the element renders the literal word. --> - +
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e224cd87e..8c175d027 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -39,6 +39,7 @@ "src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitRoute.vue", + "src/ui/views/panels/CockpitRadar.vue", "src/ui/cockpitPresentation.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -904,6 +905,11 @@ "src/cockpitMath.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/ui/panelRailGeometry.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -972,6 +978,11 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/data/detectionPolicy.js", @@ -1515,6 +1526,8 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", + "src/geoDistance.js", "src/data/inputOwnership.js", "src/i18n/index.js", "src/i18n/locale.js", @@ -2383,6 +2396,9 @@ "src/cockpitTracking.js", "src/cockpitUtilityLayout.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", "src/contactsDetectionPolicy.js", "src/contextModePolicy.js", "src/creditKeyboard.js", @@ -2944,6 +2960,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/drawMarks.js", "src/annotations/drawMode.js", @@ -3729,6 +3746,7 @@ "src/layers/vessels/records.js", "src/layers/vessels/recordPolicy.js", "src/layers/vessels/snapshotRenderer.js", + "src/layers/vessels/cockpitSubject.js", "src/data/inputOwnership.js", "src/annotations/worldAnnotationRenderer.js", "src/cameraGroundGuard.js", @@ -4145,6 +4163,11 @@ "src/ui/radioPanelPlacement.js", "src/bloom.js", "src/cockpitVisionPolicy.js", + "src/cockpitSubjects.js", + "src/cockpitRadar.js", + "src/cockpitLook.js", + "src/geoDistance.js", + "src/data/motionModel.js", "src/contactsDetectionPolicy.js", "src/data/detectionPolicy.js", "src/styles/anime.js", diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index 398c51c34..a668180fe 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -42,7 +42,7 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); ordered(enter, [ - 'if (!info || !entity?.position) return false;', + 'if (!info || (!vessel && !entity?.position)) return false;', 'this.onCameraTakeover?.();', 'this.viewer.camera.cancelFlight();', 'this.viewer.trackedEntity = undefined;', @@ -106,7 +106,8 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => ); ordered(transaction, [ /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, - 'if (!entryError) entered = Boolean(cockpitView.enter());', + // Aircraft-only: a selected vessel must not stand in for a voice target. + "if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' }));", ], 'Cockpit entry transaction'); // No direct camera control: every mutation goes through a layer tracker or // the cockpit controller, both of which stamp. diff --git a/src/cockpitLook.js b/src/cockpitLook.js new file mode 100644 index 000000000..8292cde25 --- /dev/null +++ b/src/cockpitLook.js @@ -0,0 +1,183 @@ +/** + * Bridge look-around policy: how the arrow keys turn the bridge camera away + * from the bow and back, and how a turned view is described. Yaw is relative + * to the ship's heading; pitch is the view's absolute pitch. Pure: no DOM, no + * Cesium and no translated text; the Cockpit words the readout. + */ + +/** One arrow press turns the view this far. */ +export const LOOK_STEP_DEG = 5; + +/** One Shift+arrow press turns the view this far: two reach either beam. */ +export const LOOK_COARSE_STEP_DEG = 45; + +/** The view pitches no further than straight up or straight down. */ +export const LOOK_PITCH_LIMIT_DEG = 90; + +/** + * The eased view's time constant. Three of them (about 180 ms) close 95% of + * a step, so a press glides rather than snaps and a held key turns smoothly. + */ +export const LOOK_EASE_TAU_MS = 60; + +/** Closer than this the eased view lands on its target. */ +const LOOK_SETTLE_DEG = 0.05; + +/** Arrow key → the axis it turns and the direction it turns it. */ +const LOOK_ARROWS = Object.freeze({ + ArrowLeft: Object.freeze({ axis: 'yaw', sign: -1 }), + ArrowRight: Object.freeze({ axis: 'yaw', sign: 1 }), + ArrowUp: Object.freeze({ axis: 'pitch', sign: 1 }), + ArrowDown: Object.freeze({ axis: 'pitch', sign: -1 }), +}); + +/** + * Wrap a yaw to (-180, 180]: negative to port, positive to starboard. + * @param {number} deg Yaw in degrees; a non-finite yaw reads as ahead. + * @returns {number} + */ +export function wrapLookYaw(deg) { + if (!Number.isFinite(deg)) return 0; + const wrapped = (((deg % 360) + 360) % 360) - 360; + const yaw = wrapped <= -180 ? wrapped + 360 : wrapped; + return yaw === 0 ? 0 : yaw; +} + +/** + * Clamp a view pitch to [-90, 90]. + * @param {number} deg Pitch in degrees; a non-finite pitch reads as level. + * @returns {number} + */ +export function clampLookPitch(deg) { + if (!Number.isFinite(deg)) return 0; + const pitch = Math.min( + LOOK_PITCH_LIMIT_DEG, + Math.max(-LOOK_PITCH_LIMIT_DEG, deg), + ); + return pitch === 0 ? 0 : pitch; +} + +/** + * The step an arrow key asks for, or null for any other key. + * @param {string} key `KeyboardEvent.key`. + * @param {boolean} [coarse] Shift is held: step 45° instead of 5°. + * @returns {{axis: 'yaw'|'pitch', sign: 1|-1, stepDeg: number}|null} + */ +export function lookKeyStep(key, coarse = false) { + const arrow = LOOK_ARROWS[key]; + if (!arrow) return null; + return { + ...arrow, + stepDeg: coarse ? LOOK_COARSE_STEP_DEG : LOOK_STEP_DEG, + }; +} + +/** + * Move one value to the next multiple of `stepDeg` in the direction `sign`. + * A value off the grid (the bridge's -2° rest pitch) lands on it first, so + * the readout shows round angles. + */ +function nextOnGrid(value, sign, stepDeg) { + const cells = value / stepDeg; + const next = + sign > 0 ? Math.floor(cells + 1e-9) + 1 : Math.ceil(cells - 1e-9) - 1; + return next * stepDeg; +} + +/** + * Apply one key step to a look target. + * @param {{yawDeg: number, pitchDeg: number}} target Current look target. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step From + * {@link lookKeyStep}. + * @returns {{yawDeg: number, pitchDeg: number}} The new target: yaw wrapped + * to (-180, 180], pitch clamped to [-90, 90]. + */ +export function stepLook(target, step) { + const yawDeg = wrapLookYaw(target?.yawDeg ?? 0); + const pitchDeg = clampLookPitch(target?.pitchDeg ?? 0); + if (!step) return { yawDeg, pitchDeg }; + if (step.axis === 'yaw') + return { + yawDeg: wrapLookYaw(nextOnGrid(yawDeg, step.sign, step.stepDeg)), + pitchDeg, + }; + return { + yawDeg, + pitchDeg: clampLookPitch(nextOnGrid(pitchDeg, step.sign, step.stepDeg)), + }; +} + +/** + * Advance the eased view toward its target. Yaw takes the shorter way + * round; with `immediate` (reduced motion) the view jumps to the target. + * @param {{yawDeg: number, pitchDeg: number}} current Eased view now. + * @param {{yawDeg: number, pitchDeg: number}} target Look target. + * @param {number} dtMs Milliseconds since the previous step. + * @param {{immediate?: boolean}} [options] + * @returns {{yawDeg: number, pitchDeg: number}} + */ +export function easeLook(current, target, dtMs, { immediate = false } = {}) { + const goalYaw = wrapLookYaw(target?.yawDeg ?? 0); + const goalPitch = clampLookPitch(target?.pitchDeg ?? 0); + if (immediate || !current) return { yawDeg: goalYaw, pitchDeg: goalPitch }; + const fraction = + Number.isFinite(dtMs) && dtMs > 0 + ? 1 - Math.exp(-dtMs / LOOK_EASE_TAU_MS) + : 0; + const fromYaw = wrapLookYaw(current.yawDeg); + const yawError = wrapLookYaw(goalYaw - fromYaw); + const pitchError = goalPitch - clampLookPitch(current.pitchDeg); + const yawDeg = + Math.abs(yawError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalYaw + : wrapLookYaw(fromYaw + yawError * fraction); + const pitchDeg = + Math.abs(pitchError * (1 - fraction)) < LOOK_SETTLE_DEG + ? goalPitch + : clampLookPitch(current.pitchDeg + pitchError * fraction); + return { yawDeg, pitchDeg }; +} + +/** + * Whether a look differs from the rest view (ahead at `restPitchDeg`). + * @param {{yawDeg: number, pitchDeg: number}} look + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {boolean} + */ +export function lookIsOffset(look, restPitchDeg) { + return ( + wrapLookYaw(look?.yawDeg ?? 0) !== 0 || + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg) + ); +} + +/** + * The parts of a look readout, for the Cockpit to word. A part is null when + * that axis rests: yaw ahead, or pitch at the rest pitch. + * @param {{yawDeg: number, pitchDeg: number}} look Look target. + * @param {number} restPitchDeg The camera profile's pitch. + * @returns {{side: 'port'|'starboard'|'astern'|null, bearingDeg: number, + * elevation: 'up'|'down'|'level'|null, angleDeg: number}} `bearingDeg` is + * the angle off the bow (0–180) and `angleDeg` the angle off level (0–90), + * both rounded to whole degrees. + */ +export function lookReadoutParts(look, restPitchDeg) { + const yaw = Math.round(wrapLookYaw(look?.yawDeg ?? 0)); + const pitch = Math.round(clampLookPitch(look?.pitchDeg ?? restPitchDeg)); + const pitchOffset = + clampLookPitch(look?.pitchDeg ?? restPitchDeg) !== + clampLookPitch(restPitchDeg); + let side = null; + if (Math.abs(yaw) >= 180) side = 'astern'; + else if (yaw > 0) side = 'starboard'; + else if (yaw < 0) side = 'port'; + let elevation = null; + if (pitchOffset) elevation = pitch > 0 ? 'up' : pitch < 0 ? 'down' : 'level'; + return { + side, + bearingDeg: Math.abs(yaw), + elevation, + angleDeg: Math.abs(pitch), + }; +} diff --git a/src/cockpitLook.test.mjs b/src/cockpitLook.test.mjs new file mode 100644 index 000000000..f1f4a239c --- /dev/null +++ b/src/cockpitLook.test.mjs @@ -0,0 +1,137 @@ +// src/cockpitLook.test.mjs — the bridge look-around policy: arrow steps, +// yaw wrapping, pitch limits, easing and the readout's parts. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + LOOK_COARSE_STEP_DEG, + LOOK_STEP_DEG, + clampLookPitch, + easeLook, + lookIsOffset, + lookKeyStep, + lookReadoutParts, + stepLook, + wrapLookYaw, +} from './cockpitLook.js'; + +const REST = { yawDeg: 0, pitchDeg: -2 }; + +/** Press one key `count` times from `start`. */ +function press(start, key, count = 1, coarse = false) { + let look = start; + for (let index = 0; index < count; index += 1) + look = stepLook(look, lookKeyStep(key, coarse)); + return look; +} + +test('yaw wraps to (-180, 180] and pitch clamps to [-90, 90]', () => { + assert.equal(wrapLookYaw(0), 0); + assert.equal(wrapLookYaw(-0), 0); + assert.equal(wrapLookYaw(180), 180); + assert.equal(wrapLookYaw(-180), 180); + assert.equal(wrapLookYaw(185), -175); + assert.equal(wrapLookYaw(-185), 175); + assert.equal(wrapLookYaw(720 + 45), 45); + assert.equal(wrapLookYaw(NaN), 0); + assert.equal(clampLookPitch(120), 90); + assert.equal(clampLookPitch(-120), -90); + assert.equal(clampLookPitch(-0), 0); + assert.equal(clampLookPitch(Infinity), 0); +}); + +test('only the arrows step, 5° plain and 45° with Shift', () => { + assert.deepEqual(lookKeyStep('ArrowLeft'), { + axis: 'yaw', + sign: -1, + stepDeg: LOOK_STEP_DEG, + }); + assert.deepEqual(lookKeyStep('ArrowUp', true), { + axis: 'pitch', + sign: 1, + stepDeg: LOOK_COARSE_STEP_DEG, + }); + assert.equal(lookKeyStep('ArrowDown').sign, -1); + assert.equal(lookKeyStep('ArrowRight').sign, 1); + for (const key of ['Home', '0', 'a', 'Escape', undefined]) + assert.equal(lookKeyStep(key), null); +}); + +test('arrows turn the yaw in 5° steps and Shift reaches either beam in two', () => { + assert.deepEqual(press(REST, 'ArrowRight'), { yawDeg: 5, pitchDeg: -2 }); + assert.deepEqual(press(REST, 'ArrowLeft', 3), { yawDeg: -15, pitchDeg: -2 }); + assert.equal(press(REST, 'ArrowRight', 2, true).yawDeg, 90); + assert.equal(press(REST, 'ArrowLeft', 2, true).yawDeg, -90); + // A coarse step from off the 45° grid lands on it. + assert.equal(press({ ...REST, yawDeg: 5 }, 'ArrowRight', 1, true).yawDeg, 45); + // Past astern the yaw wraps to the other side. + assert.equal(press(REST, 'ArrowRight', 4, true).yawDeg, 180); + assert.equal(press(REST, 'ArrowRight', 5, true).yawDeg, -135); + assert.equal(press(REST, 'ArrowLeft', 37).yawDeg, 175); +}); + +test('pitch steps onto the 5° grid from the rest pitch and stops at ±90°', () => { + assert.equal(press(REST, 'ArrowUp').pitchDeg, 0); + assert.equal(press(REST, 'ArrowUp', 3).pitchDeg, 10); + assert.equal(press(REST, 'ArrowDown').pitchDeg, -5); + assert.equal(press(REST, 'ArrowUp', 40).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 40).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp', 3, true).pitchDeg, 90); + assert.equal(press(REST, 'ArrowDown', 2, true).pitchDeg, -90); + assert.equal(press(REST, 'ArrowUp').yawDeg, 0, 'pitch leaves the yaw'); +}); + +test('the eased view closes most of a step in about 180 ms', () => { + const target = { yawDeg: 90, pitchDeg: -2 }; + let view = { ...REST }; + view = easeLook(view, target, 180); + assert.ok(view.yawDeg > 85 && view.yawDeg < 90, `yaw ${view.yawDeg}`); + for (let step = 0; step < 20; step += 1) view = easeLook(view, target, 50); + assert.deepEqual(view, target, 'it lands exactly on the target'); + assert.deepEqual(easeLook(REST, target, 0), REST, 'no time, no motion'); +}); + +test('the eased yaw takes the short way across astern', () => { + const view = easeLook( + { yawDeg: 170, pitchDeg: 0 }, + { yawDeg: -170, pitchDeg: 0 }, + 30, + ); + assert.ok(view.yawDeg > 170 || view.yawDeg < -170, `yaw ${view.yawDeg}`); +}); + +test('reduced motion jumps straight to the target', () => { + const target = { yawDeg: -45, pitchDeg: 30 }; + assert.deepEqual(easeLook(REST, target, 1, { immediate: true }), target); +}); + +test('the readout names the side, the bearing and the elevation', () => { + assert.equal(lookIsOffset(REST, -2), false); + assert.equal(lookIsOffset({ yawDeg: 0, pitchDeg: 0 }, -2), true); + assert.equal(lookIsOffset({ yawDeg: 5, pitchDeg: -2 }, -2), true); + assert.deepEqual(lookReadoutParts({ yawDeg: 45, pitchDeg: 10 }, -2), { + side: 'starboard', + bearingDeg: 45, + elevation: 'up', + angleDeg: 10, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: -90, pitchDeg: -2 }, -2), { + side: 'port', + bearingDeg: 90, + elevation: null, + angleDeg: 2, + }); + assert.deepEqual(lookReadoutParts({ yawDeg: 0, pitchDeg: -30 }, -2), { + side: null, + bearingDeg: 0, + elevation: 'down', + angleDeg: 30, + }); + assert.equal( + lookReadoutParts({ yawDeg: 0, pitchDeg: 0 }, -2).elevation, + 'level', + ); + assert.equal( + lookReadoutParts({ yawDeg: 180, pitchDeg: -2 }, -2).side, + 'astern', + ); +}); diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 3186b1f3c..53daa63d1 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -176,6 +176,46 @@ export function compassDivisions(heading) { ); } +/** + * A heading tape that scrolls continuously: the 30-degree divisions within + * `reach` divisions of `heading`, each placed by its offset from the exact + * heading in division slots, so the centre caret reads the heading itself + * rather than the nearest division. `nearest` marks the division closest to + * the caret. + * @param {number} heading Degrees true. + * @param {number} [reach] Divisions each side of the caret. + * @returns {Array<{heading: number, slot: number, nearest: boolean}>} + */ +export function scrollingCompassDivisions(heading, reach = 4) { + if (!Number.isFinite(heading)) return []; + const exact = normalizeHeading(heading); + const centre = Math.round(exact / 30) * 30; + const divisions = []; + for (let step = -reach; step <= reach; step += 1) { + const division = normalizeHeading(centre + step * 30); + divisions.push({ + heading: division, + slot: Number((relativeBearing(division, exact) / 30).toFixed(4)), + nearest: step === 0, + }); + } + return divisions; +} + +/** + * Where a heading falls on the `scrollingCompassDivisions(tapeHeading)` tape: + * its offset from the caret (the tape's exact heading) in division slots of + * 30 degrees, or null when it lies more than three divisions out. + * @param {number} heading Degrees true. + * @param {number} tapeHeading The heading under the tape's caret. + * @returns {?number} + */ +export function compassMarkerSlot(heading, tapeHeading) { + if (!Number.isFinite(heading) || !Number.isFinite(tapeHeading)) return null; + const slot = Number((relativeBearing(heading, tapeHeading) / 30).toFixed(4)); + return Math.abs(slot) <= 3 ? slot : null; +} + /** Format a compass division as a cardinal/intercardinal label or degrees. */ export function formatCompassDivision(heading) { const normalized = normalizeHeading(heading); diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index 5a7f029c0..a59a4b029 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -11,6 +11,8 @@ import { cockpitSurfaceWaitExpired, cockpitUiUpdateDue, compassDivisions, + compassMarkerSlot, + scrollingCompassDivisions, formatAltitudeRulerTick, formatCockpitContextScope, formatCockpitEmergency, @@ -151,6 +153,46 @@ test('compass divisions remain ordered across north', () => { assert.deepEqual(compassDivisions(359), [270, 300, 330, 0, 30, 60, 90]); }); +test('compass marks sit in division slots off the caret, or off the tape', () => { + // Measured from the exact heading under the caret, not the nearest division. + assert.equal(compassMarkerSlot(350, 350), 0); + assert.equal(compassMarkerSlot(35, 350), 1.5); + assert.equal(compassMarkerSlot(260, 350), -3); + assert.equal(compassMarkerSlot(80, 350), 3); + assert.equal(compassMarkerSlot(90, 350), null); + assert.equal(compassMarkerSlot(180, 350), null); + assert.equal(compassMarkerSlot(Number.NaN, 0), null); +}); + +test('a scrolling tape puts the exact heading under the caret', () => { + // At 100° the divisions sit a third of a slot off their snapped places, + // so the bow mark (also 100°) lines up with the caret, not 10° beside it. + const tape = scrollingCompassDivisions(100); + assert.deepEqual( + tape.map(({ heading }) => heading), + [330, 0, 30, 60, 90, 120, 150, 180, 210], + ); + const nearest = tape.find((division) => division.nearest); + assert.equal(nearest.heading, 90); + assert.equal(nearest.slot, -0.3333); + assert.equal(tape.find(({ heading }) => heading === 120).slot, 0.6667); + assert.equal(compassMarkerSlot(100, 100), 0); + assert.equal(compassMarkerSlot(90, 100), nearest.slot); + // Across north the slots stay continuous. + assert.deepEqual( + scrollingCompassDivisions(355, 1).map(({ heading, slot }) => [ + heading, + slot, + ]), + [ + [330, -0.8333], + [0, 0.1667], + [30, 1.1667], + ], + ); + assert.deepEqual(scrollingCompassDivisions(Number.NaN), []); +}); + test('compass labels use cardinals where exact and degrees elsewhere', () => { assert.equal(formatCompassDivision(270), 'W'); assert.equal(formatCompassDivision(30), '030'); diff --git a/src/cockpitRadar.js b/src/cockpitRadar.js new file mode 100644 index 000000000..5cf1f90f7 --- /dev/null +++ b/src/cockpitRadar.js @@ -0,0 +1,166 @@ +/** + * Bridge radar policy: the range steps, the range rings and the heading-up + * projection of nearby AIS vessels onto the radar's range circle. Pure: no + * DOM, no Cesium and no translated text; the Cockpit words the labels. + */ +import { + bearingBetweenCoordinates, + normalizeHeading, + relativeBearing, +} from './cockpitMath.js'; +import { greatCircleMeters } from './geoDistance.js'; + +/** Metres in one international nautical mile. */ +export const METRES_PER_NM = 1852; + +/** The radar's range steps in nautical miles, shortest first. */ +export const RADAR_RANGES_NM = Object.freeze([3, 6, 12, 24]); + +/** The range the radar opens on. */ +export const DEFAULT_RADAR_RANGE_NM = 3; + +/** At most this many contacts are drawn, nearest first. */ +export const RADAR_MAX_CONTACTS = 60; + +/** Range rings divide the range into this many equal steps. */ +export const RADAR_RING_COUNT = 4; + +/** + * Normalize a range to one of the radar's steps. + * @param {number} rangeNm Requested range in nautical miles. + * @returns {number} The range when it is a step, else the default range. + */ +export function radarRangeNm(rangeNm) { + return RADAR_RANGES_NM.includes(rangeNm) ? rangeNm : DEFAULT_RADAR_RANGE_NM; +} + +/** + * The range after this one; the longest range wraps to the shortest. + * @param {number} rangeNm Current range in nautical miles. + * @returns {number} The next range step. + */ +export function nextRadarRange(rangeNm) { + const index = RADAR_RANGES_NM.indexOf(radarRangeNm(rangeNm)); + return RADAR_RANGES_NM[(index + 1) % RADAR_RANGES_NM.length]; +} + +/** + * Range ring distances at each quarter of the range, innermost first. + * @param {number} rangeNm Range in nautical miles. + * @returns {number[]} Ring distances in nautical miles; the last is the range. + */ +export function radarRingsNm(rangeNm) { + const range = radarRangeNm(rangeNm); + return Array.from( + { length: RADAR_RING_COUNT }, + (_, index) => (range * (index + 1)) / RADAR_RING_COUNT, + ); +} + +/** + * The search radius for a range, in metres. + * @param {number} rangeNm Range in nautical miles. + * @returns {number} + */ +export function radarRangeM(rangeNm) { + return radarRangeNm(rangeNm) * METRES_PER_NM; +} + +const round4 = (value) => Math.round(value * 1e4) / 1e4; + +/** + * Project vessel contacts onto a heading-up radar. + * + * Positions are fractions of the range circle: x to the right (starboard), + * y up (ahead), both in [-1, 1]. Contacts outside the range, or without a + * position, are left out; the rest come nearest first, at most `maxCount`. + * @param {{latitude: number, longitude: number, headingDeg: number}} own + * Own ship position and the heading the radar is drawn up from. + * @param {Array<{id: string, name?: string, latitude: number, + * longitude: number, course?: number|null, color?: string, + * stale?: boolean}>} contacts Nearby vessels. + * @param {{rangeNm?: number, maxCount?: number}} [options] + * @returns {Array<{id: string, name: string, x: number, y: number, + * distanceNm: number, relativeBearingDeg: number, + * relativeCourseDeg: number|null, color: string, stale: boolean}>} + * relativeBearingDeg and relativeCourseDeg are clockwise from the bow in + * [-180, 180). + */ +export function projectRadarContacts(own, contacts, options = {}) { + const rangeNm = radarRangeNm(options.rangeNm ?? DEFAULT_RADAR_RANGE_NM); + const cap = + Number.isFinite(options.maxCount) && options.maxCount > 0 + ? Math.floor(options.maxCount) + : RADAR_MAX_CONTACTS; + const lat = Number(own?.latitude); + const lon = Number(own?.longitude); + if ( + !Number.isFinite(lat) || + !Number.isFinite(lon) || + !Array.isArray(contacts) + ) + return []; + const heading = normalizeHeading(own.headingDeg ?? 0); + const projected = []; + for (const contact of contacts) { + const toLat = Number(contact?.latitude); + const toLon = Number(contact?.longitude); + const distanceNm = + greatCircleMeters({ lat, lon }, { lat: toLat, lon: toLon }) / + METRES_PER_NM; + if (!Number.isFinite(distanceNm) || distanceNm > rangeNm) continue; + const bearing = bearingBetweenCoordinates(lat, lon, toLat, toLon); + const relativeBearingDeg = + bearing === null ? 0 : relativeBearing(bearing, heading); + const radians = (relativeBearingDeg * Math.PI) / 180; + const fraction = distanceNm / rangeNm; + const course = + contact.course === null || contact.course === undefined + ? NaN + : Number(contact.course); + projected.push({ + id: String(contact.id), + name: String(contact.name || contact.id), + x: round4(Math.sin(radians) * fraction), + y: round4(Math.cos(radians) * fraction), + distanceNm, + relativeBearingDeg, + relativeCourseDeg: Number.isFinite(course) + ? relativeBearing(course, heading) + : null, + color: contact.color || '', + stale: contact.stale === true, + }); + } + projected.sort((a, b) => a.distanceNm - b.distanceNm); + return projected.slice(0, cap); +} + +/** + * The radar presentation without its worded labels. + * @param {{latitude: number, longitude: number, headingDeg: number, + * courseDeg?: number|null}} own Own ship; `courseDeg` is course over ground. + * @param {Array} contacts See {@link projectRadarContacts}. + * @param {number} rangeNm Range in nautical miles. + * @returns {{rangeNm: number, ringsNm: number[], headingDeg: number, + * ownRelativeCourseDeg: number|null, contacts: Array}} + */ +export function radarPicture(own, contacts, rangeNm) { + const range = radarRangeNm(rangeNm); + const headingDeg = normalizeHeading(own?.headingDeg ?? 0); + const course = + own?.courseDeg === null || own?.courseDeg === undefined + ? NaN + : Number(own.courseDeg); + return { + rangeNm: range, + ringsNm: radarRingsNm(range), + headingDeg, + ownRelativeCourseDeg: Number.isFinite(course) + ? relativeBearing(course, headingDeg) + : null, + contacts: projectRadarContacts({ ...own, headingDeg }, contacts, { + rangeNm: range, + }), + }; +} diff --git a/src/cockpitRadar.test.mjs b/src/cockpitRadar.test.mjs new file mode 100644 index 000000000..e18403972 --- /dev/null +++ b/src/cockpitRadar.test.mjs @@ -0,0 +1,140 @@ +// src/cockpitRadar.test.mjs — the bridge radar's range steps, rings and +// heading-up contact projection. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_RADAR_RANGE_NM, + METRES_PER_NM, + RADAR_MAX_CONTACTS, + RADAR_RANGES_NM, + nextRadarRange, + projectRadarContacts, + radarPicture, + radarRangeM, + radarRingsNm, +} from './cockpitRadar.js'; + +const OWN = Object.freeze({ latitude: 54.5, longitude: 10.2, headingDeg: 0 }); +/** One nautical mile of latitude, in degrees. */ +const NM_LAT = 1 / 60; +/** One nautical mile of longitude at 54.5°N, in degrees. */ +const NM_LON = 1 / (60 * Math.cos((54.5 * Math.PI) / 180)); + +function contact(id, northNm, eastNm, extra = {}) { + return { + id, + name: id, + latitude: OWN.latitude + northNm * NM_LAT, + longitude: OWN.longitude + eastNm * NM_LON, + course: null, + color: '#fff', + stale: false, + ...extra, + }; +} + +test('the range steps 3, 6, 12, 24 NM, opens on 3 and wraps', () => { + assert.deepEqual(RADAR_RANGES_NM, [3, 6, 12, 24]); + assert.equal(DEFAULT_RADAR_RANGE_NM, 3); + assert.equal(nextRadarRange(3), 6); + assert.equal(nextRadarRange(6), 12); + assert.equal(nextRadarRange(12), 24); + assert.equal(nextRadarRange(24), 3); + assert.equal(nextRadarRange(7), 6, 'an unknown range steps from the default'); + assert.equal(radarRangeM(6), 6 * METRES_PER_NM); +}); + +test('range rings sit at each quarter of the range', () => { + assert.deepEqual(radarRingsNm(6), [1.5, 3, 4.5, 6]); + assert.deepEqual(radarRingsNm(24), [6, 12, 18, 24]); +}); + +test('a contact dead ahead is straight up the scope', () => { + const [ahead] = projectRadarContacts(OWN, [contact('A', 3, 0)], { + rangeNm: 6, + }); + assert.ok(Math.abs(ahead.x) < 0.01, `x ${ahead.x}`); + assert.ok(Math.abs(ahead.y - 0.5) < 0.01, `y ${ahead.y}`); + assert.ok(Math.abs(ahead.distanceNm - 3) < 0.02); + assert.ok(Math.abs(ahead.relativeBearingDeg) < 0.5); +}); + +test('heading-up turns the picture with the bow', () => { + // Heading east: a contact to the north is on the port beam, one to the + // south on the starboard beam, one to the east dead ahead. + const own = { ...OWN, headingDeg: 90 }; + const byId = Object.fromEntries( + projectRadarContacts(own, [ + contact('N', 2, 0), + contact('S', -2, 0), + contact('E', 0, 2), + ]).map((entry) => [entry.id, entry]), + ); + assert.ok(byId.N.x < -0.3 && Math.abs(byId.N.y) < 0.02, 'north is to port'); + assert.ok( + byId.S.x > 0.3 && Math.abs(byId.S.y) < 0.02, + 'south is to starboard', + ); + assert.ok(Math.abs(byId.E.x) < 0.02 && byId.E.y > 0.3, 'east is ahead'); + assert.ok(Math.abs(byId.S.relativeBearingDeg - 90) < 1); + assert.ok(Math.abs(byId.N.relativeBearingDeg + 90) < 1); +}); + +test('a contact to starboard is right of the centre', () => { + const [starboard] = projectRadarContacts(OWN, [contact('B', 0, 1.5)]); + assert.ok(starboard.x > 0.2, `x ${starboard.x}`); + assert.ok(Math.abs(starboard.y) < 0.01); +}); + +test('course is drawn relative to the bow and stays null when unknown', () => { + const own = { ...OWN, headingDeg: 90 }; + const [crossing, drifting] = projectRadarContacts(own, [ + contact('A', 1, 0, { course: 180 }), + contact('B', 2, 0, { course: null }), + ]); + assert.equal(crossing.relativeCourseDeg, 90); + assert.equal(drifting.relativeCourseDeg, null); +}); + +test('contacts beyond the range are left out', () => { + const contacts = projectRadarContacts( + OWN, + [contact('in', 5.9, 0), contact('out', 6.2, 0), contact('far', 0, 30)], + { rangeNm: 6 }, + ); + assert.deepEqual( + contacts.map((entry) => entry.id), + ['in'], + ); + assert.ok(contacts.every((entry) => Math.hypot(entry.x, entry.y) <= 1)); +}); + +test('contacts come nearest first, capped', () => { + const many = Array.from({ length: RADAR_MAX_CONTACTS + 15 }, (_, index) => + contact(`V${index}`, 0, 5.5 - index * 0.07), + ); + const contacts = projectRadarContacts(OWN, many, { rangeNm: 6 }); + assert.equal(contacts.length, RADAR_MAX_CONTACTS); + for (let index = 1; index < contacts.length; index += 1) + assert.ok(contacts[index - 1].distanceNm <= contacts[index].distanceNm); + assert.equal( + projectRadarContacts(OWN, many, { rangeNm: 6, maxCount: 3 }).length, + 3, + 'a smaller cap is honoured', + ); +}); + +test('a picture carries the range, rings, heading and own course over ground', () => { + const picture = radarPicture( + { ...OWN, headingDeg: 350, courseDeg: 10 }, + [contact('A', 1, 0)], + 12, + ); + assert.equal(picture.rangeNm, 12); + assert.deepEqual(picture.ringsNm, [3, 6, 9, 12]); + assert.equal(picture.headingDeg, 350); + assert.equal(picture.ownRelativeCourseDeg, 20); + assert.equal(picture.contacts.length, 1); + assert.ok(picture.contacts[0].x > 0, 'north is to starboard of 350°'); + assert.equal(radarPicture(OWN, [], 6).ownRelativeCourseDeg, null); +}); diff --git a/src/cockpitSubjects.js b/src/cockpitSubjects.js new file mode 100644 index 000000000..aab08ff72 --- /dev/null +++ b/src/cockpitSubjects.js @@ -0,0 +1,170 @@ +/** + * Camera policy for first-person subjects other than aircraft: where a + * vessel's bridge eye sits and how its reported fix advances between feed + * refreshes. No DOM or i18n dependencies, so the policy is unit-testable. + */ +import { projectGroundArcLatLon } from './data/motionModel.js'; + +/** Bridge cameras look along the horizon; a ship's eye is already high. */ +export const VESSEL_BRIDGE_PITCH_DEG = -2; + +/** The AIS antenna usually sits on the bridge, so the eye is not moved forward. */ +export const VESSEL_BRIDGE_FORWARD_OFFSET_M = 0; + +/** Clearance over the shared ground floor for a camera that floats on water. */ +export const VESSEL_BRIDGE_MIN_CLEARANCE_M = 2; + +/** Eye height used when the vessel layer knows no height for the type. */ +export const VESSEL_DEFAULT_EYE_HEIGHT_M = 12; + +/** + * A reported fix older than this stops advancing: the vessel holds where the + * last projection left it instead of sailing on through a feed outage. + */ +export const VESSEL_DEAD_RECKON_MAX_S = 180; + +/** + * The vessel layer floats every ship on the geoid (mean sea level), but a + * river or lake can sit well above it: the Seine through Paris is ~27 m up, + * which put a bridge eye under the water. The bridge reads the rendered + * surface around its ship instead: probes on a ring this far out, plus one + * at the ship itself. + */ +export const VESSEL_WATERLINE_PROBE_RADIUS_M = 30; + +/** Probes on the ring around the ship. */ +export const VESSEL_WATERLINE_PROBE_COUNT = 8; + +/** How often the bridge reads the water under its ship again, ms. */ +export const VESSEL_WATERLINE_REFRESH_MS = 5000; + +/** A surface less than this above sea level is the sea itself, metres. */ +export const VESSEL_WATERLINE_MIN_RISE_M = 2; + +/** Highest navigable water trusted from a sample (Lake Titicaca ~3,810 m). */ +export const VESSEL_WATERLINE_MAX_RISE_M = 4000; + +/** + * Where to read the surface around a ship: the ship's own position, then a + * ring of probes around it. + * @param {number} latitude + * @param {number} longitude + * @returns {Array<{lat: number, lon: number}>} + */ +export function waterlineProbePoints( + latitude, + longitude, + radiusM = VESSEL_WATERLINE_PROBE_RADIUS_M, + count = VESSEL_WATERLINE_PROBE_COUNT, +) { + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return []; + const points = [{ lat: latitude, lon: longitude }]; + const metresPerDegLat = 111_320; + const metresPerDegLon = + metresPerDegLat * Math.max(0.01, Math.cos((latitude * Math.PI) / 180)); + for (let i = 0; i < count; i += 1) { + const bearing = (2 * Math.PI * i) / count; + points.push({ + lat: latitude + (radiusM * Math.cos(bearing)) / metresPerDegLat, + lon: longitude + (radiusM * Math.sin(bearing)) / metresPerDegLon, + }); + } + return points; +} + +/** + * The water surface a bridge floats on, ellipsoidal metres. Quays, bridges, + * moored boats and the ship's own model all stand above the water, so the + * lowest probe is the water. A lowest probe barely above sea level is the + * sea; one implausibly high is a bad sample. Either way the geoid stands. + * @param {number} seaSurfaceM The geoid sea surface the layer floats ships on. + * @param {Array} sampleHeightsM Rendered-surface probes. + * @returns {number} + */ +export function vesselWaterlineM(seaSurfaceM, sampleHeightsM) { + const heights = (sampleHeightsM || []).filter(Number.isFinite); + if (!Number.isFinite(seaSurfaceM) || !heights.length) return seaSurfaceM; + const lowest = Math.min(...heights); + const rise = lowest - seaSurfaceM; + return rise >= VESSEL_WATERLINE_MIN_RISE_M && + rise <= VESSEL_WATERLINE_MAX_RISE_M + ? lowest + : seaSurfaceM; +} + +/** + * Camera placement for a vessel subject, in the shape the Cockpit camera + * reads for every subject kind. + * @param {{eyeHeightM?: number|null}} info Vessel subject info. + * @returns {{forwardM: number, upM: number, pitchDeg: number, minClearanceM: number}} + */ +export function vesselCameraProfile(info) { + return { + forwardM: VESSEL_BRIDGE_FORWARD_OFFSET_M, + upM: Number.isFinite(info?.eyeHeightM) + ? info.eyeHeightM + : VESSEL_DEFAULT_EYE_HEIGHT_M, + pitchDeg: VESSEL_BRIDGE_PITCH_DEG, + minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M, + }; +} + +/** + * How old a vessel's fix is now, seconds, or null without a fix time. + * + * The fix time is on the AIS feed's clock and `nowMs` on this client's, so + * comparing them directly would turn a client clock running minutes fast + * into minutes of age (every bridge projecting its full limit and reading + * STALE FEED). With the time this client first received the fix (client + * clock) and the newest fix in that refresh (feed clock), the age is the + * time since the fix arrived plus how far it trailed that newest fix; each + * part is read on one clock. A fix the feed keeps re-serving keeps its first + * receipt, so it ages into STALE FEED. The age leaves out the newest fix's + * own delay before the refresh, typically seconds: a feed that was already + * stalled when the bridge first saw it ages from then. Without them it falls + * back to the clock difference, never negative. + * @param {{fixEpochMs: number|null, receivedAtMs?: number|null, + * feedEpochMs?: number|null}} fix + * @param {number} nowMs Wall-clock time, ms. + * @returns {number|null} + */ +export function vesselFixAgeSec(fix, nowMs) { + const { fixEpochMs, receivedAtMs, feedEpochMs } = fix || {}; + if (!Number.isFinite(fixEpochMs) || !Number.isFinite(nowMs)) return null; + if (Number.isFinite(receivedAtMs) && Number.isFinite(feedEpochMs)) + return ( + Math.max(0, (nowMs - receivedAtMs) / 1000) + + Math.max(0, (feedEpochMs - fixEpochMs) / 1000) + ); + return Math.max(0, (nowMs - fixEpochMs) / 1000); +} + +/** + * Advance a vessel's last reported fix along its course over ground. + * + * The vessel layer redraws a ship only when the feed refreshes, so its icon + * stands still between fixes. A camera converging on that still point would + * sail backwards against its own motion. Projecting the fix forward gives the + * Cockpit anchor a moving target, and the bounded age keeps a silent feed from + * carrying the ship away indefinitely. + * @param {{latitude: number, longitude: number, courseDeg: number|null, + * speedMps: number|null, fixEpochMs: number|null, + * receivedAtMs?: number|null, feedEpochMs?: number|null}} fix Last + * report; its age is `vesselFixAgeSec`. + * @param {number} nowMs Wall-clock time, ms. + * @returns {{latitude: number, longitude: number, ageSec: number|null}} + */ +export function deadReckonVesselFix(fix, nowMs) { + const { latitude, longitude, courseDeg, speedMps } = fix || {}; + const ageSec = vesselFixAgeSec(fix, nowMs); + if (ageSec === null) return { latitude, longitude, ageSec: null }; + const { lat, lon } = projectGroundArcLatLon( + latitude, + longitude, + courseDeg, + speedMps, + 0, + Math.min(ageSec, VESSEL_DEAD_RECKON_MAX_S), + ); + return { latitude: lat, longitude: lon, ageSec }; +} diff --git a/src/cockpitSubjects.test.mjs b/src/cockpitSubjects.test.mjs new file mode 100644 index 000000000..cf1ff1b58 --- /dev/null +++ b/src/cockpitSubjects.test.mjs @@ -0,0 +1,137 @@ +// src/cockpitSubjects.test.mjs — the vessel camera profile and dead +// reckoning of AIS fixes between feed refreshes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + VESSEL_BRIDGE_MIN_CLEARANCE_M, + VESSEL_BRIDGE_PITCH_DEG, + VESSEL_DEAD_RECKON_MAX_S, + VESSEL_DEFAULT_EYE_HEIGHT_M, + VESSEL_WATERLINE_PROBE_RADIUS_M, + deadReckonVesselFix, + vesselFixAgeSec, + vesselCameraProfile, + vesselWaterlineM, + waterlineProbePoints, +} from './cockpitSubjects.js'; + +const EARTH_M_PER_DEG_LAT = 111_195; + +test('the vessel camera profile puts the eye at the bridge, looking at the horizon', () => { + assert.deepEqual(vesselCameraProfile({ eyeHeightM: 30 }), { + forwardM: 0, + upM: 30, + pitchDeg: VESSEL_BRIDGE_PITCH_DEG, + minClearanceM: VESSEL_BRIDGE_MIN_CLEARANCE_M, + }); +}); + +test('a vessel with no known eye height uses the default', () => { + for (const info of [{}, { eyeHeightM: null }, null]) { + assert.equal(vesselCameraProfile(info).upM, VESSEL_DEFAULT_EYE_HEIGHT_M); + } +}); + +test('a fix advances along course over ground for its age', () => { + const fixEpochMs = 1_000_000; + const advanced = deadReckonVesselFix( + { + latitude: 0, + longitude: 0, + courseDeg: 0, + speedMps: 10, + fixEpochMs, + }, + fixEpochMs + 60_000, + ); + assert.equal(advanced.ageSec, 60); + // Sixty seconds north at 10 m/s. + assert.ok( + Math.abs(advanced.latitude * EARTH_M_PER_DEG_LAT - 600) < 2, + `expected ~600 m north, got ${advanced.latitude * EARTH_M_PER_DEG_LAT}`, + ); + assert.ok(Math.abs(advanced.longitude) < 1e-9); +}); + +test('projection stops at the dead-reckoning limit', () => { + const fixEpochMs = 0; + const at = (ageSec) => + deadReckonVesselFix( + { latitude: 0, longitude: 0, courseDeg: 90, speedMps: 5, fixEpochMs }, + fixEpochMs + ageSec * 1000, + ); + const limit = at(VESSEL_DEAD_RECKON_MAX_S); + const later = at(VESSEL_DEAD_RECKON_MAX_S * 4); + assert.equal(later.longitude, limit.longitude); + assert.equal(later.ageSec, VESSEL_DEAD_RECKON_MAX_S * 4); +}); + +test('a fix without time, course or speed stays where it was reported', () => { + const fix = { + latitude: 54.3, + longitude: 10.1, + courseDeg: null, + speedMps: 6, + fixEpochMs: 0, + }; + assert.deepEqual(deadReckonVesselFix(fix, 30_000), { + latitude: 54.3, + longitude: 10.1, + ageSec: 30, + }); + assert.deepEqual( + deadReckonVesselFix({ ...fix, courseDeg: 90, fixEpochMs: null }, 30_000), + { latitude: 54.3, longitude: 10.1, ageSec: null }, + ); +}); + +test('waterline probes ring the ship at the probe radius', () => { + const points = waterlineProbePoints(48.859, 2.293); + assert.equal(points.length, 9); + assert.deepEqual(points[0], { lat: 48.859, lon: 2.293 }); + for (const point of points.slice(1)) { + const northM = (point.lat - 48.859) * 111_320; + const eastM = + (point.lon - 2.293) * 111_320 * Math.cos((48.859 * Math.PI) / 180); + assert.ok( + Math.abs(Math.hypot(northM, eastM) - VESSEL_WATERLINE_PROBE_RADIUS_M) < + 0.01, + ); + } + assert.deepEqual(waterlineProbePoints(Number.NaN, 2), []); +}); + +test('the waterline is the lowest probe when it stands clear of the sea', () => { + // The Seine at the Eiffel Tower: geoid 44.6 m, water about 71 m. + assert.equal(vesselWaterlineM(44.6, [80, 71.2, 75, null, 90]), 71.2); + // Swell and tile noise are the sea itself. + assert.equal(vesselWaterlineM(44.6, [45.5, 46]), 44.6); + // Under the sea surface (a dock, a bad tile) never sinks the ship. + assert.equal(vesselWaterlineM(44.6, [30, 71]), 44.6); + // Nothing sampled, or implausibly high. + assert.equal(vesselWaterlineM(44.6, [undefined, Number.NaN]), 44.6); + assert.equal(vesselWaterlineM(44.6, [9000]), 44.6); +}); + +test("a fix's age is read on each clock, so a fast client clock adds none", () => { + const fixEpochMs = 1_800_000_000_000; + // The refresh's newest fix is 10 s newer than this one; the client, its + // clock five minutes fast, received the refresh 5 s ago. + const receivedAtMs = fixEpochMs + 12_000 + 300_000; + const fix = { + latitude: 0, + longitude: 0, + courseDeg: 90, + speedMps: 5, + fixEpochMs, + receivedAtMs, + feedEpochMs: fixEpochMs + 10_000, + }; + assert.equal(vesselFixAgeSec(fix, receivedAtMs + 5000), 15); + assert.equal(deadReckonVesselFix(fix, receivedAtMs + 5000).ageSec, 15); + // Without receive data the clocks are compared, never below zero. + const bare = { fixEpochMs }; + assert.equal(vesselFixAgeSec(bare, fixEpochMs + 60_000), 60); + assert.equal(vesselFixAgeSec(bare, fixEpochMs - 60_000), 0); + assert.equal(vesselFixAgeSec({ fixEpochMs: null }, fixEpochMs), null); +}); diff --git a/src/cockpitTracking.js b/src/cockpitTracking.js index 3671e3a36..97aa50d8e 100644 --- a/src/cockpitTracking.js +++ b/src/cockpitTracking.js @@ -65,7 +65,8 @@ export function enterCockpitWithTracking({ ); } } - if (!entryError) entered = Boolean(cockpitView.enter()); + // This transaction selects aircraft; a selected vessel must not stand in. + if (!entryError) entered = Boolean(cockpitView.enter({ kind: 'aircraft' })); } catch (error) { entryThrew = true; entryError = error instanceof Error ? error : new Error(String(error)); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index fd3324760..4c477127a 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -445,6 +445,18 @@ export function cockpitEntryAllowed({ ); } +/** + * Whether the selected vessel's bridge view can be entered. Like aircraft + * Cockpit it needs only its own layer, Live AIS Vessels, with a vessel + * selected. A Context mode change in flight still owns the camera, so entry + * waits it out. + * @param {{vesselsEnabled: boolean, contextModeChanging: boolean}} state + * @returns {boolean} + */ +export function bridgeEntryAllowed({ vesselsEnabled, contextModeChanging }) { + return Boolean(vesselsEnabled) && !contextModeChanging; +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/contextModePolicy.test.mjs b/src/contextModePolicy.test.mjs index 0453bd9b8..e9828834f 100644 --- a/src/contextModePolicy.test.mjs +++ b/src/contextModePolicy.test.mjs @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + bridgeEntryAllowed, cockpitEntryAllowed, contextAllowedLayerIds, contextLayerEnableBlockReason, @@ -426,6 +427,17 @@ test("cockpit entry needs only the tracked aircraft's own layer", () => { subjectLayerEnabled: false, contextModeChanging: false, }), false, 'there is no aircraft to ride'); + assert.equal(cockpitEntryAllowed({ + subjectLayerId: 'ais-live-vessels', + subjectLayerEnabled: true, + contextModeChanging: false, + }), false, "a vessel is the bridge gate's subject, not Cockpit's"); +}); + +test('bridge entry needs only Live AIS Vessels', () => { + assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: false }), true); + assert.equal(bridgeEntryAllowed({ vesselsEnabled: false, contextModeChanging: false }), false); + assert.equal(bridgeEntryAllowed({ vesselsEnabled: true, contextModeChanging: true }), false); }); test('context teardown guard restores coordination state after success and failure', async () => { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 189a025d5..362e8ff7e 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -149,11 +149,26 @@ "Voice model: {pendingId} — click to switch to {target}; applies next session": "النموذج الصوتي: {pendingId} — انقر للتبديل إلى {target}؛ يسري في الجلسة التالية", "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "التكلفة التقديرية للجلسة على {modelId} — عدد الاستجابات: {responses}. تنبيه عند {warn}، وتنتهي الجلسة عند {cap}.", "Aircraft cockpit view": "عرض قمرة قيادة الطائرة", + "Ship bridge view": "عرض غرفة قيادة السفينة", "EXIT COCKPIT": "الخروج من القمرة", + "EXIT BRIDGE": "الخروج من غرفة القيادة", "GROUND SPEED": "السرعة الأرضية", + "SPEED OVER GROUND": "السرعة فوق القاع", + "COURSE OVER GROUND": "المسار فوق القاع", "ALTITUDE": "الارتفاع", "CONTACT": "الهدف", "CONTACTS · 250 KM": "الأهداف · 250 كم", + "RINGS {spacing} NM": "الحلقات كل {spacing} ميل بحري", + "{count} CONTACTS": { + "zero": "لا أهداف ({count})", + "one": "هدف واحد ({count})", + "two": "هدفان ({count})", + "few": "{count} أهداف", + "many": "{count} هدفًا", + "other": "{count} هدف" + }, + "Radar range {range}. Select for the next range.": "مدى الرادار {range}. اختر للانتقال إلى المدى التالي.", + "Bridge radar": "رادار غرفة القيادة", "LIVE SIGNALS": "إشارات مباشرة", "OBSERVED / MAPPED PINGS": "إشارات مرصودة / مرسومة", "CURRENT": "الحالي", @@ -226,16 +241,32 @@ "Landmark: --": "المَعلم: --", "Search any location": "البحث عن أي موقع", "Search any location...": "ابحث عن أي موقع…", + "LOOK STBD {bearing}°": "النظر إلى الميمنة {bearing}°", + "LOOK PORT {bearing}°": "النظر إلى الميسرة {bearing}°", + "LOOK ASTERN": "النظر إلى المؤخرة", + "UP {angle}°": "أعلى {angle}°", + "DOWN {angle}°": "أسفل {angle}°", "LEVEL": "أفقي", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "النظر {elevation}", "Estimated destination direction": "اتجاه الوجهة التقديري", "DEST ---°": "الوجهة ---°", + "BRIDGE WINDOW · FWD": "نافذة غرفة القيادة · الأمام", "OPTICAL PLANE · 01": "المستوى البصري · 01", + "HEADING HOLD · AIS": "تثبيت الاتجاه · AIS", + "NO HEADING · AIS": "لا اتجاه · AIS", "VISOR LOCK · ACTIVE": "قفل واقي الخوذة · نشط", "FIRST PERSON": "منظور الشخص الأول", + "BRIDGE VIEW": "عرض غرفة القيادة", "LIVE TRACK · COURSE ALIGNED": "تتبّع مباشر · بمحاذاة المسار", "Cockpit vision style": "نمط رؤية القمرة", "Current aircraft heading": "اتجاه الطائرة الحالي", + "Look ahead: turn the bridge view back to the bow": "النظر إلى الأمام: إعادة عرض غرفة القيادة نحو مقدمة السفينة", + "Look ahead (Home or 0)": "النظر إلى الأمام (Home أو 0)", + "LOOK AHEAD": "النظر إلى الأمام", + "Current vessel heading": "اتجاه السفينة الحالي", "GROUND SPEED · KTS": "السرعة الأرضية · عقدة", + "SPEED OVER GROUND · KTS": "السرعة فوق القاع · عقدة", "ALTITUDE · FT": "الارتفاع · قدم", "PREV": "السابق", "Previous cockpit vision style": "نمط رؤية القمرة السابق", @@ -247,9 +278,16 @@ "Exit cockpit and return to full globe view": "الخروج من القمرة والعودة إلى عرض الكرة الأرضية كاملة", "RESET": "إعادة ضبط", "Exit cockpit view": "الخروج من عرض القمرة", + "Exit bridge view": "الخروج من عرض غرفة القيادة", "Estimated flight plan": "خطة الرحلة التقديرية", + "Reported voyage": "الرحلة المُبلَّغ عنها", "ESTIMATED FLIGHT PLAN": "خطة الرحلة التقديرية", + "REPORTED VOYAGE": "الرحلة المُبلَّغ عنها", "ROUTE DATA UNAVAILABLE": "بيانات المسار غير متاحة", + "CONTACT NAVIGATION OFF": "التنقل بين الأهداف متوقف", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "تبقى غرفة القيادة مع السفينة المحددة", + "{range} NM": "{range} ميل بحري", + "AIS DESTINATION · CREW REPORTED": "وجهة AIS · أبلغ عنها الطاقم", "FROM": "من", "TO": "إلى", "UNKNOWN": "غير معروف", @@ -292,6 +330,7 @@ "SELECT CONTEXT": "اختر السياق", "Contact Context actions": "إجراءات سياق الهدف", "COCKPIT": "القمرة", + "BRIDGE": "غرفة القيادة", "SEARCH NEARBY SITES": "البحث عن المواقع القريبة", "Reclassify tracked contact as TR-3B": "إعادة تصنيف الهدف المتتبَّع إلى TR-3B", "Reclassify as TR-3B": "إعادة التصنيف إلى TR-3B", @@ -331,6 +370,8 @@ "Current style: {style} — click for next": "النمط الحالي: {style} — انقر للتالي", "TRACK ACQUIRED": "تم التقاط الهدف", "{label} · COURSE {heading}°": "{label} · المسار {heading}°", + "{label} · HEADING {heading}°": "{label} · الاتجاه {heading}°", + "{label} · NO HEADING": "{label} · لا اتجاه", "Select flight {title}": "اختيار الرحلة {title}", "CONTEXT STANDBY": "السياق في وضع الاستعداد", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "فعّل السياق العالمي لإشارات العناصر القريبة", @@ -358,6 +399,8 @@ "STALE FEED": "مصدر متقادم", "LIVE TRACK": "تتبّع مباشر", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · بمحاذاة المسار", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ارتفاع العين {height}", + "LIVE AIS": "AIS مباشر", "ARROW · ESTIMATED DIRECTION": "سهم · اتجاه تقديري", "DEST {bearing}": "الوجهة {bearing}", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "فُقد الهدف · آخر قراءة معروفة · لا يعني أن الوضع آمن", @@ -3110,6 +3153,8 @@ "LOCAL": "محلي", "ESC EXIT": "ESC خروج", "C TOGGLE": "C تبديل", + "ARROWS LOOK": "الأسهم للنظر", + "HOME RESET": "Home إعادة الضبط", "Active weather products": "منتجات الطقس النشطة", "RECENT IMAGERY": "الصور الحديثة", "Imagery days in the box": "أيام الصور في المنطقة", @@ -3484,5 +3529,6 @@ "Close camera preview": "إغلاق معاينة الكاميرا", "Refresh map still": "تحديث صورة الخريطة الثابتة", "The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", - "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك." + "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.", + "COURSE HOLD · AIS": "تثبيت المسار · AIS" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 419afc9a6..902169ca1 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -431,8 +431,14 @@ "LIVE TRACK": "LIVE-TRACK", "COMMERCIAL": "ZIVIL", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · IN KURSRICHTUNG", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · AUGESHÖHE {height}", + "LIVE AIS": "LIVE-AIS", "ARROW · ESTIMATED DIRECTION": "PFEIL · GESCHÄTZTE RICHTUNG", "ROUTE DATA UNAVAILABLE": "ROUTENDATEN NICHT VERFÜGBAR", + "CONTACT NAVIGATION OFF": "KONTAKTNAVIGATION AUS", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "DIE BRÜCKE BLEIBT BEIM GEWÄHLTEN SCHIFF", + "{range} NM": "{range} SM", + "AIS DESTINATION · CREW REPORTED": "AIS-ZIEL · VON DER BESATZUNG GEMELDET", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Cockpit-Wettereffekte deaktivieren", "Enable cockpit weather effects": "Cockpit-Wettereffekte aktivieren", @@ -477,6 +483,8 @@ "Reclassify as TR-3B": "Als TR-3B umklassifizieren", "TRACK ACQUIRED": "TRACK ERFASST", "{label} · COURSE {heading}°": "{label} · KURS {heading}°", + "{label} · HEADING {heading}°": "{label} · STEUERKURS {heading}°", + "{label} · NO HEADING": "{label} · KEIN STEUERKURS", "Context could not restore every layer; try again": "Kontext konnte nicht alle Ebenen wiederherstellen; erneut versuchen", "No selected data layers": "Keine Datenebenen ausgewählt", "{count} data layers could not be cleared": { @@ -663,14 +671,27 @@ "Session ended — cost cap {cost}": "Sitzung beendet: Kostenlimit {cost}", "Radio did not start": "Radio wurde nicht gestartet", "Aircraft cockpit view": "Flugzeug-Cockpitansicht", + "Ship bridge view": "Ansicht von der Schiffsbrücke", + "LOOK STBD {bearing}°": "BLICK STEUERBORD {bearing}°", + "LOOK PORT {bearing}°": "BLICK BACKBORD {bearing}°", + "LOOK ASTERN": "BLICK ACHTERAUS", + "UP {angle}°": "HOCH {angle}°", + "DOWN {angle}°": "TIEF {angle}°", "LEVEL": "HORIZONT", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "BLICK {elevation}", "Estimated destination direction": "Geschätzte Richtung zum Ziel", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "BRÜCKENFENSTER · VORAUS", "OPTICAL PLANE · 01": "OPTISCHE EBENE · 01", + "HEADING HOLD · AIS": "STEUERKURS FIX · AIS", + "NO HEADING · AIS": "KEIN STEUERKURS · AIS", "VISOR LOCK · ACTIVE": "VISIER-LOCK · AKTIV", "GROUND SPEED · KTS": "GESCHW. ÜBER GRUND · KN", + "SPEED OVER GROUND · KTS": "FAHRT ÜBER GRUND · KN", "ALTITUDE · FT": "HÖHE · FT", "FIRST PERSON": "EGO-PERSPEKTIVE", + "BRIDGE VIEW": "BRÜCKENANSICHT", "LIVE TRACK · COURSE ALIGNED": "LIVE-TRACK · IN KURSRICHTUNG", "Cockpit vision style": "Sichtstil im Cockpit", "Previous cockpit vision style": "Vorheriger Cockpit-Sichtstil", @@ -679,11 +700,24 @@ "Next cockpit vision style": "Nächster Cockpit-Sichtstil", "Next vision style": "Nächster Sichtstil", "Current aircraft heading": "Aktueller Flugzeugkurs", + "Look ahead: turn the bridge view back to the bow": "Blick voraus: Brückenansicht zurück zum Bug drehen", + "Look ahead (Home or 0)": "Blick voraus (Pos1 oder 0)", + "LOOK AHEAD": "BLICK VORAUS", + "Current vessel heading": "Aktueller Steuerkurs des Schiffs", "GROUND SPEED": "GESCHW. ÜBER GRUND", + "SPEED OVER GROUND": "FAHRT ÜBER GRUND", + "COURSE OVER GROUND": "KURS ÜBER GRUND", "ALTITUDE": "HÖHE", "Contact cockpit summary": "Cockpit-Zusammenfassung des Kontakts", "CONTACT": "KONTAKT", "CONTACTS · 250 KM": "KONTAKTE · 250 KM", + "RINGS {spacing} NM": "RINGE {spacing} SM", + "{count} CONTACTS": { + "one": "{count} KONTAKT", + "other": "{count} KONTAKTE" + }, + "Radar range {range}. Select for the next range.": "Radarreichweite {range}. Auswählen, um zur nächsten Reichweite zu wechseln.", + "Bridge radar": "Brückenradar", "Contact navigation": "Kontaktnavigation", "CONTEXT ONLY": "NUR KONTEXT", "Nearby cohort counts": "Anzahl Kontakte in der Nähe", @@ -691,7 +725,9 @@ "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "NUR VERFÜGBARE QUELLEN · KEINE ENTWARNUNG", "Cockpit briefing carousel": "Cockpit-Briefing-Karussell", "Estimated flight plan": "Geschätzter Flugplan", + "Reported voyage": "Gemeldete Reise", "ESTIMATED FLIGHT PLAN": "GESCHÄTZTER FLUGPLAN", + "REPORTED VOYAGE": "GEMELDETE REISE", "FROM": "VON", "TO": "NACH", "Cockpit briefing controls": "Cockpit-Briefing-Steuerung", @@ -722,7 +758,9 @@ "Exit cockpit and return to full globe view": "Cockpit verlassen und zur Vollglobus-Ansicht zurückkehren", "RESET": "RESET", "Exit cockpit view": "Cockpitansicht verlassen", + "Exit bridge view": "Brückenansicht verlassen", "EXIT COCKPIT": "COCKPIT VERLASSEN", + "EXIT BRIDGE": "BRÜCKE VERLASSEN", "Navigation, voice, and visual preset controls": "Steuerung für Navigation, Sprache und visuelle Presets", "Expand Visual Presets": "Visuelle Presets ausklappen", "Pin visual presets": "Visuelle Presets anheften", @@ -752,6 +790,7 @@ "SELECT CONTEXT": "KONTEXT WÄHLEN", "Contact Context actions": "Aktionen für Kontaktkontext", "COCKPIT": "COCKPIT", + "BRIDGE": "BRÜCKE", "SEARCH NEARBY SITES": "STANDORTE IN DER NÄHE SUCHEN", "Reclassify tracked contact as TR-3B": "Verfolgten Kontakt als TR-3B umklassifizieren", "CONTACTS CONTEXT OFF": "KONTAKTKONTEXT AUS", @@ -2836,6 +2875,8 @@ "LOCAL": "LOKAL", "ESC EXIT": "ESC BEENDEN", "C TOGGLE": "C UMSCHALTEN", + "ARROWS LOOK": "PFEILE UMSEHEN", + "HOME RESET": "POS1 ZURÜCKSETZEN", "Active weather products": "Aktive Wetterprodukte", "RECENT IMAGERY": "AKTUELLE BILDDATEN", "Imagery days in the box": "Bildtage im Bereich", @@ -3184,5 +3225,6 @@ "Close camera preview": "Kameravorschau schließen", "Refresh map still": "Kartenstandbild aktualisieren", "The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", - "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht." + "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", + "COURSE HOLD · AIS": "KURS FIX · AIS" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 48963ac30..8c83513ed 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -38,11 +38,23 @@ "REFRESHING LIVE DATA": "ACTUALIZANDO DATOS", "Close data attribution": "Cerrar la atribución de datos", "Aircraft cockpit view": "Vista de cabina de la aeronave", + "Ship bridge view": "Vista desde el puente del buque", "EXIT COCKPIT": "SALIR DE CABINA", + "EXIT BRIDGE": "SALIR DEL PUENTE", "GROUND SPEED": "VEL. SUELO", + "SPEED OVER GROUND": "VEL. SOBRE FONDO", + "COURSE OVER GROUND": "RUMBO SOBRE FONDO", "ALTITUDE": "ALTITUD", "CONTACT": "CONTACTO", "CONTACTS · 250 KM": "CONTACTOS · 250 KM", + "RINGS {spacing} NM": "ANILLOS {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTACTO", + "many": "{count} DE CONTACTOS", + "other": "{count} CONTACTOS" + }, + "Radar range {range}. Select for the next range.": "Alcance del radar {range}. Selecciona para pasar al siguiente alcance.", + "Bridge radar": "Radar del puente", "LIVE SIGNALS": "SEÑALES EN VIVO", "OBSERVED / MAPPED PINGS": "PINGS OBSERVADOS / MAPEADOS", "CURRENT": "ACTUAL", @@ -115,16 +127,32 @@ "Landmark: --": "Punto de interés: --", "Search any location": "Buscar un lugar", "Search any location...": "Buscar un lugar…", + "LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°", + "LOOK PORT {bearing}°": "VISTA BABOR {bearing}°", + "LOOK ASTERN": "VISTA A POPA", + "UP {angle}°": "ARRIBA {angle}°", + "DOWN {angle}°": "ABAJO {angle}°", "LEVEL": "NIVEL", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Dirección estimada del destino", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", + "HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS", + "NO HEADING · AIS": "SIN PROA · AIS", "VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO", "FIRST PERSON": "PRIMERA PERSONA", + "BRIDGE VIEW": "VISTA DEL PUENTE", "LIVE TRACK · COURSE ALIGNED": "RASTREO EN VIVO · RUMBO ALINEADO", "Cockpit vision style": "Estilo de visión de la cabina", "Current aircraft heading": "Rumbo actual de la aeronave", + "Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa", + "Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)", + "LOOK AHEAD": "MIRAR A PROA", + "Current vessel heading": "Proa actual del buque", "GROUND SPEED · KTS": "VEL. SUELO · KN", + "SPEED OVER GROUND · KTS": "VEL. SOBRE FONDO · KN", "ALTITUDE · FT": "ALTITUD · FT", "PREV": "ANT.", "Previous cockpit vision style": "Estilo de visión de cabina anterior", @@ -137,9 +165,16 @@ "Exit cockpit and return to full globe view": "Salir de la cabina y volver a la vista de globo completo", "RESET": "RESTABLECER", "Exit cockpit view": "Salir de la vista de cabina", + "Exit bridge view": "Salir de la vista del puente", "Estimated flight plan": "Plan de vuelo estimado", + "Reported voyage": "Viaje notificado", "ESTIMATED FLIGHT PLAN": "PLAN DE VUELO ESTIMADO", + "REPORTED VOYAGE": "VIAJE NOTIFICADO", "ROUTE DATA UNAVAILABLE": "DATOS DE RUTA NO DISPONIBLES", + "CONTACT NAVIGATION OFF": "NAVEGACIÓN DE CONTACTOS DESACTIVADA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "EL PUENTE PERMANECE EN EL BUQUE SELECCIONADO", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINO AIS · NOTIFICADO POR LA TRIPULACIÓN", "FROM": "DESDE", "TO": "HACIA", "UNKNOWN": "DESCONOCIDO", @@ -183,6 +218,7 @@ "SELECT CONTEXT": "SELECCIONAR CONTEXTO", "Contact Context actions": "Acciones de contexto de contacto", "COCKPIT": "CABINA", + "BRIDGE": "PUENTE", "SEARCH NEARBY SITES": "BUSCAR SITIOS CERCANOS", "Reclassify tracked contact as TR-3B": "Reclasificar el contacto rastreado como TR-3B", "Reclassify as TR-3B": "Reclasificar como TR-3B", @@ -223,6 +259,8 @@ "Current style: {style} — click for next": "Estilo actual: {style} — haz clic para el siguiente", "TRACK ACQUIRED": "RASTREO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMBO {heading}°", + "{label} · HEADING {heading}°": "{label} · PROA {heading}°", + "{label} · NO HEADING": "{label} · SIN PROA", "Select flight {title}": "Seleccionar el vuelo {title}", "CONTEXT STANDBY": "CONTEXTO EN ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVAR CONTEXTO GLOBAL PARA PINGS DE PROXIMIDAD", @@ -247,6 +285,8 @@ "STALE FEED": "FUENTE DESACTUALIZADA", "LIVE TRACK": "RASTREO EN VIVO", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · RUMBO ALINEADO", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. OJO {height}", + "LIVE AIS": "AIS EN VIVO", "ARROW · ESTIMATED DIRECTION": "FLECHA · DIRECCIÓN ESTIMADA", "DEST {bearing}": "DEST {bearing}", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACTO PERDIDO · ÚLTIMA LECTURA CONOCIDA · NO IMPLICA TODO DESPEJADO", @@ -1605,6 +1645,8 @@ "Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com", "ESC EXIT": "ESC SALIR", "C TOGGLE": "C ALTERNAR", + "ARROWS LOOK": "FLECHAS MIRAR", + "HOME RESET": "INICIO RESTABLECER", "SIG": "SIG", "NEWS": "NOTICIAS", "LOCAL": "LOCAL", @@ -3259,5 +3301,6 @@ "Close camera preview": "Cerrar vista previa de la cámara", "Refresh map still": "Actualizar imagen fija del mapa", "The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", - "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP." + "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", + "COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 16fc249b6..b98b2cd0a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMMERCIAL", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ALIGNÉ SUR LA ROUTE", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · HAUTEUR D'ŒIL {height}", + "LIVE AIS": "AIS EN DIRECT", "ARROW · ESTIMATED DIRECTION": "FLÈCHE · DIRECTION ESTIMÉE", "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", + "CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Désactiver les effets météo du cockpit", "Enable cockpit weather effects": "Activer les effets météo du cockpit", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Reclasser en TR-3B", "TRACK ACQUIRED": "PISTE ACQUISE", "{label} · COURSE {heading}°": "{label} · ROUTE {heading}°", + "{label} · HEADING {heading}°": "{label} · CAP {heading}°", + "{label} · NO HEADING": "{label} · SANS CAP", "Context could not restore every layer; try again": "Le contexte n'a pas pu restaurer toutes les couches ; réessayez", "No selected data layers": "Aucune couche de données sélectionnée", "{count} data layers could not be cleared": { @@ -2697,14 +2705,27 @@ "Recent speech": "Propos récents", "HERE": "ICI", "Aircraft cockpit view": "Vue cockpit de l'aéronef", + "Ship bridge view": "Vue depuis la passerelle du navire", + "LOOK STBD {bearing}°": "VUE TRIBORD {bearing}°", + "LOOK PORT {bearing}°": "VUE BÂBORD {bearing}°", + "LOOK ASTERN": "VUE ARRIÈRE", + "UP {angle}°": "HAUT {angle}°", + "DOWN {angle}°": "BAS {angle}°", "LEVEL": "HORIZON", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VUE {elevation}", "Estimated destination direction": "Direction estimée de la destination", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "VITRE PASSERELLE · AVANT", "OPTICAL PLANE · 01": "PLAN OPTIQUE · 01", + "HEADING HOLD · AIS": "VERROU CAP · AIS", + "NO HEADING · AIS": "SANS CAP · AIS", "VISOR LOCK · ACTIVE": "VERROU VISIÈRE · ACTIF", "GROUND SPEED · KTS": "VITESSE SOL · ND", + "SPEED OVER GROUND · KTS": "VITESSE FOND · ND", "ALTITUDE · FT": "ALTITUDE · FT", "FIRST PERSON": "PREMIÈRE PERSONNE", + "BRIDGE VIEW": "VUE PASSERELLE", "LIVE TRACK · COURSE ALIGNED": "PISTE EN DIRECT · ALIGNÉ SUR LA ROUTE", "Cockpit vision style": "Style de vision du cockpit", "Previous cockpit vision style": "Style de vision précédent du cockpit", @@ -2713,13 +2734,27 @@ "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", "Current aircraft heading": "Cap actuel de l'aéronef", + "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", + "Look ahead (Home or 0)": "Vue avant (Home ou 0)", + "LOOK AHEAD": "VUE AVANT", + "Current vessel heading": "Cap actuel du navire", "GROUND SPEED": "VITESSE SOL", + "SPEED OVER GROUND": "VITESSE FOND", + "COURSE OVER GROUND": "ROUTE FOND", "KTS": "ND", "ALTITUDE": "ALTITUDE", "FT": "FT", "Contact cockpit summary": "Résumé cockpit du contact", "CONTACT": "CONTACT", "CONTACTS · 250 KM": "CONTACTS · 250 KM", + "RINGS {spacing} NM": "CERCLES {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTACT", + "many": "{count} DE CONTACTS", + "other": "{count} CONTACTS" + }, + "Radar range {range}. Select for the next range.": "Portée radar {range}. Sélectionnez pour passer à la portée suivante.", + "Bridge radar": "Radar de passerelle", "Contact navigation": "Navigation des contacts", "CONTEXT ONLY": "CONTEXTE SEULEMENT", "Nearby cohort counts": "Nombre de contacts par groupe à proximité", @@ -2731,7 +2766,9 @@ "WX": "MÉTÉO", "Cockpit briefing carousel": "Carrousel de briefing du cockpit", "Estimated flight plan": "Plan de vol estimé", + "Reported voyage": "Voyage déclaré", "ESTIMATED FLIGHT PLAN": "PLAN DE VOL ESTIMÉ", + "REPORTED VOYAGE": "VOYAGE DÉCLARÉ", "FROM": "DE", "TO": "VERS", "Cockpit briefing controls": "Commandes du briefing du cockpit", @@ -2765,11 +2802,15 @@ "Cockpit Radio volume": "Volume de la Radio du cockpit", "ESC EXIT": "ESC QUITTER", "C TOGGLE": "C BASCULER", + "ARROWS LOOK": "FLÈCHES REGARDER", + "HOME RESET": "HOME RECENTRER", "View switcher": "Sélecteur de vue", "Exit cockpit and return to full globe view": "Quitter le cockpit et revenir à la vue du globe entier", "RESET": "RÉINIT.", "Exit cockpit view": "Quitter la vue cockpit", + "Exit bridge view": "Quitter la vue passerelle", "EXIT COCKPIT": "QUITTER LE COCKPIT", + "EXIT BRIDGE": "QUITTER LA PASSERELLE", "Navigation, voice, and visual preset controls": "Commandes de navigation, de voix et de préréglages visuels", "Expand Visual Presets": "Développer les préréglages visuels", "Pin visual presets": "Épingler les préréglages visuels", @@ -2802,6 +2843,7 @@ "SELECT CONTEXT": "CHOISIR UN CONTEXTE", "Contact Context actions": "Actions du contexte Contact", "COCKPIT": "COCKPIT", + "BRIDGE": "PASSERELLE", "SEARCH NEARBY SITES": "CHERCHER SITES PROCHES", "Reclassify tracked contact as TR-3B": "Reclasser le contact suivi en TR-3B", "CONTACTS CONTEXT OFF": "CONTEXTE CONTACTS DÉSACTIVÉ", @@ -3259,5 +3301,6 @@ "Close camera preview": "Fermer l’aperçu de la caméra", "Refresh map still": "Actualiser l’image fixe sur la carte", "The map still uses a separate request and may show a different moment.": "L’image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", - "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP." + "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", + "COURSE HOLD · AIS": "VERROU ROUTE · AIS" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 7f130832f..3fbf91cc2 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMMERCIALE", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · ROTTA ALLINEATA", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALTEZZA OCCHIO {height}", + "LIVE AIS": "AIS LIVE", "ARROW · ESTIMATED DIRECTION": "FRECCIA · DIREZIONE STIMATA", "ROUTE DATA UNAVAILABLE": "DATI ROTTA NON DISPONIBILI", + "CONTACT NAVIGATION OFF": "NAVIGAZIONE CONTATTI DISATTIVATA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PLANCIA RESTA SULLA NAVE SELEZIONATA", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINAZIONE AIS · DICHIARATA DALL'EQUIPAGGIO", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Disattiva gli effetti meteo del cockpit", "Enable cockpit weather effects": "Attiva gli effetti meteo del cockpit", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Riclassifica come TR-3B", "TRACK ACQUIRED": "TRACCIA ACQUISITA", "{label} · COURSE {heading}°": "{label} · ROTTA {heading}°", + "{label} · HEADING {heading}°": "{label} · PRUA {heading}°", + "{label} · NO HEADING": "{label} · NESSUNA PRUA", "Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova", "No selected data layers": "Nessun livello dati selezionato", "{count} data layers could not be cleared": { @@ -2697,14 +2705,27 @@ "Recent speech": "Parlato recente", "HERE": "QUI", "Aircraft cockpit view": "Vista cockpit dell'aereo", + "Ship bridge view": "Vista dalla plancia della nave", + "LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°", + "LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°", + "LOOK ASTERN": "VISTA A POPPA", + "UP {angle}°": "SU {angle}°", + "DOWN {angle}°": "GIÙ {angle}°", "LEVEL": "ORIZZONTE", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Direzione stimata della destinazione", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI", "OPTICAL PLANE · 01": "PIANO OTTICO · 01", + "HEADING HOLD · AIS": "BLOCCO PRUA · AIS", + "NO HEADING · AIS": "NESSUNA PRUA · AIS", "VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO", "GROUND SPEED · KTS": "VEL. AL SUOLO · KTS", + "SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS", "ALTITUDE · FT": "QUOTA · FT", "FIRST PERSON": "PRIMA PERSONA", + "BRIDGE VIEW": "VISTA PLANCIA", "LIVE TRACK · COURSE ALIGNED": "TRACCIA LIVE · ROTTA ALLINEATA", "Cockpit vision style": "Stile di visione del cockpit", "Previous cockpit vision style": "Stile di visione precedente del cockpit", @@ -2713,13 +2734,27 @@ "Next cockpit vision style": "Stile di visione successivo del cockpit", "Next vision style": "Stile di visione successivo", "Current aircraft heading": "Prua attuale dell'aereo", + "Look ahead: turn the bridge view back to the bow": "Guarda a prua: riporta la vista plancia verso la prua", + "Look ahead (Home or 0)": "Guarda a prua (Home o 0)", + "LOOK AHEAD": "GUARDA A PRUA", + "Current vessel heading": "Prua attuale della nave", "GROUND SPEED": "VEL. AL SUOLO", + "SPEED OVER GROUND": "VEL. SUL FONDO", + "COURSE OVER GROUND": "ROTTA SUL FONDO", "KTS": "KTS", "ALTITUDE": "ALTITUDINE", "FT": "FT", "Contact cockpit summary": "Riepilogo contatto del cockpit", "CONTACT": "CONTATTO", "CONTACTS · 250 KM": "CONTATTI · 250 KM", + "RINGS {spacing} NM": "CERCHI {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTATTO", + "many": "{count} DI CONTATTI", + "other": "{count} CONTATTI" + }, + "Radar range {range}. Select for the next range.": "Portata radar {range}. Seleziona per passare alla portata successiva.", + "Bridge radar": "Radar di plancia", "Contact navigation": "Navigazione contatti", "CONTEXT ONLY": "SOLO CONTESTO", "Nearby cohort counts": "Conteggi dei gruppi vicini", @@ -2731,7 +2766,9 @@ "WX": "METEO", "Cockpit briefing carousel": "Carosello briefing del cockpit", "Estimated flight plan": "Piano di volo stimato", + "Reported voyage": "Viaggio dichiarato", "ESTIMATED FLIGHT PLAN": "PIANO DI VOLO STIMATO", + "REPORTED VOYAGE": "VIAGGIO DICHIARATO", "FROM": "DA", "TO": "A", "Cockpit briefing controls": "Controlli briefing del cockpit", @@ -2765,11 +2802,15 @@ "Cockpit Radio volume": "Volume Radio del cockpit", "ESC EXIT": "ESC ESCI", "C TOGGLE": "C ALTERNA", + "ARROWS LOOK": "FRECCE GUARDA", + "HOME RESET": "HOME RIPRISTINA", "View switcher": "Selettore vista", "Exit cockpit and return to full globe view": "Esci dal cockpit e torna alla vista del globo intero", "RESET": "RESET", "Exit cockpit view": "Esci dalla vista cockpit", + "Exit bridge view": "Esci dalla vista plancia", "EXIT COCKPIT": "ESCI DAL COCKPIT", + "EXIT BRIDGE": "ESCI DALLA PLANCIA", "Navigation, voice, and visual preset controls": "Controlli di navigazione, voce e preset visivi", "Expand Visual Presets": "Espandi Preset visivi", "Pin visual presets": "Fissa i preset visivi", @@ -2802,6 +2843,7 @@ "SELECT CONTEXT": "SELEZIONA CONTESTO", "Contact Context actions": "Azioni Contesto contatto", "COCKPIT": "COCKPIT", + "BRIDGE": "PLANCIA", "SEARCH NEARBY SITES": "CERCA SITI VICINI", "Reclassify tracked contact as TR-3B": "Riclassifica il contatto tracciato come TR-3B", "CONTACTS CONTEXT OFF": "CONTESTO CONTATTI DISATTIVATO", @@ -3259,5 +3301,6 @@ "Close camera preview": "Chiudi anteprima della telecamera", "Refresh map still": "Aggiorna immagine fissa sulla mappa", "The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", - "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP." + "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", + "COURSE HOLD · AIS": "BLOCCO ROTTA · AIS" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 62e8af101..4c764fe96 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1650,8 +1650,14 @@ "COMMERCIAL": "民間", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 針路一致", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "ライブAIS", "ARROW · ESTIMATED DIRECTION": "矢印 · 推定方向", "ROUTE DATA UNAVAILABLE": "経路データなし", + "CONTACT NAVIGATION OFF": "コンタクトナビゲーション オフ", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "ブリッジは選択中の船舶に固定", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS目的地 · 乗組員入力", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "コックピットの天候エフェクトを無効にする", "Enable cockpit weather effects": "コックピットの天候エフェクトを有効にする", @@ -1696,6 +1702,8 @@ "Reclassify as TR-3B": "TR-3Bに再分類", "TRACK ACQUIRED": "追跡開始", "{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首方位 {heading}°", + "{label} · NO HEADING": "{label} · 船首方位なし", "Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください", "No selected data layers": "選択中のデータレイヤーはありません", "{count} data layers could not be cleared": { @@ -2555,14 +2563,27 @@ "Recent speech": "最近の発話", "HERE": "ここ", "Aircraft cockpit view": "航空機コックピットビュー", + "Ship bridge view": "船舶ブリッジビュー", + "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", + "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", + "LOOK ASTERN": "視線 船尾方向", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "推定目的地の方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "ブリッジ窓 · 前方", "OPTICAL PLANE · 01": "光学面 · 01", + "HEADING HOLD · AIS": "船首方位保持 · AIS", + "NO HEADING · AIS": "船首方位なし · AIS", "VISOR LOCK · ACTIVE": "バイザーロック · 有効", "GROUND SPEED · KTS": "対地速度 · KT", + "SPEED OVER GROUND · KTS": "対地速力 · KT", "ALTITUDE · FT": "高度 · FT", "FIRST PERSON": "一人称視点", + "BRIDGE VIEW": "ブリッジビュー", "LIVE TRACK · COURSE ALIGNED": "ライブ追跡 · 針路一致", "Cockpit vision style": "コックピットのビジョンスタイル", "Previous cockpit vision style": "前のコックピットビジョンスタイル", @@ -2571,13 +2592,25 @@ "Next cockpit vision style": "次のコックピットビジョンスタイル", "Next vision style": "次のビジョンスタイル", "Current aircraft heading": "現在の機首方位", + "Look ahead: turn the bridge view back to the bow": "前方を見る:ブリッジビューを船首方向に戻す", + "Look ahead (Home or 0)": "前方を見る(Homeまたは0)", + "LOOK AHEAD": "前方を見る", + "Current vessel heading": "現在の船首方位", "GROUND SPEED": "対地速度", + "SPEED OVER GROUND": "対地速力", + "COURSE OVER GROUND": "対地針路", "KTS": "KT", "ALTITUDE": "高度", "FT": "FT", "Contact cockpit summary": "コンタクトのコックピット概要", "CONTACT": "コンタクト", "CONTACTS · 250 KM": "コンタクト · 250 km", + "RINGS {spacing} NM": "距離環 {spacing}海里", + "{count} CONTACTS": { + "other": "コンタクト {count}件" + }, + "Radar range {range}. Select for the next range.": "レーダーレンジ {range}。選択すると次のレンジに切り替わります。", + "Bridge radar": "ブリッジレーダー", "Contact navigation": "コンタクトナビゲーション", "CONTEXT ONLY": "参考情報のみ", "Nearby cohort counts": "周辺の種別ごとの件数", @@ -2589,7 +2622,9 @@ "WX": "気象", "Cockpit briefing carousel": "コックピットブリーフィングカルーセル", "Estimated flight plan": "推定フライトプラン", + "Reported voyage": "申告された航海", "ESTIMATED FLIGHT PLAN": "推定フライトプラン", + "REPORTED VOYAGE": "申告された航海", "FROM": "出発", "TO": "到着", "Cockpit briefing controls": "コックピットブリーフィングの操作", @@ -2623,11 +2658,15 @@ "Cockpit Radio volume": "コックピットラジオの音量", "ESC EXIT": "Escで終了", "C TOGGLE": "Cで切替", + "ARROWS LOOK": "矢印キーで見回す", + "HOME RESET": "Homeでリセット", "View switcher": "ビュー切替", "Exit cockpit and return to full globe view": "コックピットを終了して地球全体ビューに戻る", "RESET": "リセット", "Exit cockpit view": "コックピットビューを終了", + "Exit bridge view": "ブリッジビューを終了", "EXIT COCKPIT": "コックピット終了", + "EXIT BRIDGE": "ブリッジ終了", "Navigation, voice, and visual preset controls": "ナビゲーション、音声、ビジュアルプリセットの操作", "Expand Visual Presets": "ビジュアルプリセットを展開", "Pin visual presets": "ビジュアルプリセットを固定", @@ -2660,6 +2699,7 @@ "SELECT CONTEXT": "コンテキストを選択", "Contact Context actions": "コンタクトコンテキストの操作", "COCKPIT": "コックピット", + "BRIDGE": "ブリッジ", "SEARCH NEARBY SITES": "周辺施設を検索", "Reclassify tracked contact as TR-3B": "追跡中のコンタクトをTR-3Bに再分類", "CONTACTS CONTEXT OFF": "コンタクトコンテキスト オフ", @@ -3109,5 +3149,6 @@ "Close camera preview": "カメラのプレビューを閉じる", "Refresh map still": "地図上の静止画を更新", "The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", - "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。" + "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", + "COURSE HOLD · AIS": "対地針路保持 · AIS" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 04cf3ccfb..ff0f567b8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1700,8 +1700,14 @@ "COMMERCIAL": "COMERCIAL", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · CURSO ALINHADO", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ALT. DO OLHO {height}", + "LIVE AIS": "AIS AO VIVO", "ARROW · ESTIMATED DIRECTION": "SETA · DIREÇÃO ESTIMADA", "ROUTE DATA UNAVAILABLE": "DADOS DE ROTA INDISPONÍVEIS", + "CONTACT NAVIGATION OFF": "NAVEGAÇÃO DE CONTATOS DESLIGADA", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "O PASSADIÇO PERMANECE NA EMBARCAÇÃO SELECIONADA", + "{range} NM": "{range} MN", + "AIS DESTINATION · CREW REPORTED": "DESTINO AIS · INFORMADO PELA TRIPULAÇÃO", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Desativar efeitos de clima na cabine", "Enable cockpit weather effects": "Ativar efeitos de clima na cabine", @@ -1748,6 +1754,8 @@ "Reclassify as TR-3B": "Reclassificar como TR-3B", "TRACK ACQUIRED": "RASTRO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMO {heading}°", + "{label} · HEADING {heading}°": "{label} · PROA {heading}°", + "{label} · NO HEADING": "{label} · SEM PROA", "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", "No selected data layers": "Nenhuma camada de dados selecionada", "{count} data layers could not be cleared": { @@ -2699,14 +2707,27 @@ "Recent speech": "Fala recente", "HERE": "AQUI", "Aircraft cockpit view": "Vista da cabine da aeronave", + "Ship bridge view": "Vista do passadiço da embarcação", + "LOOK STBD {bearing}°": "VISTA BORESTE {bearing}°", + "LOOK PORT {bearing}°": "VISTA BOMBORDO {bearing}°", + "LOOK ASTERN": "VISTA À RÉ", + "UP {angle}°": "ACIMA {angle}°", + "DOWN {angle}°": "ABAIXO {angle}°", "LEVEL": "NIVELADO", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "VISTA {elevation}", "Estimated destination direction": "Direção estimada do destino", "DEST ---°": "DEST ---°", + "BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", + "HEADING HOLD · AIS": "TRAVA DE PROA · AIS", + "NO HEADING · AIS": "SEM PROA · AIS", "VISOR LOCK · ACTIVE": "TRAVA DO VISOR · ATIVA", "GROUND SPEED · KTS": "VELOC. SOLO · KT", + "SPEED OVER GROUND · KTS": "VELOC. NO FUNDO · KT", "ALTITUDE · FT": "ALTITUDE · FT", "FIRST PERSON": "PRIMEIRA PESSOA", + "BRIDGE VIEW": "VISTA DO PASSADIÇO", "LIVE TRACK · COURSE ALIGNED": "RASTRO AO VIVO · CURSO ALINHADO", "Cockpit vision style": "Estilo de visão da cabine", "Previous cockpit vision style": "Estilo anterior de visão da cabine", @@ -2715,13 +2736,27 @@ "Next cockpit vision style": "Próximo estilo de visão da cabine", "Next vision style": "Próximo estilo de visão", "Current aircraft heading": "Rumo atual da aeronave", + "Look ahead: turn the bridge view back to the bow": "Olhar à vante: voltar a vista do passadiço para a proa", + "Look ahead (Home or 0)": "Olhar à vante (Home ou 0)", + "LOOK AHEAD": "OLHAR À VANTE", + "Current vessel heading": "Proa atual da embarcação", "GROUND SPEED": "VELOC. SOLO", + "SPEED OVER GROUND": "VELOC. NO FUNDO", + "COURSE OVER GROUND": "RUMO NO FUNDO", "KTS": "KT", "ALTITUDE": "ALTITUDE", "FT": "FT", "Contact cockpit summary": "Resumo do contato na cabine", "CONTACT": "CONTATO", "CONTACTS · 250 KM": "CONTATOS · 250 KM", + "RINGS {spacing} NM": "ANÉIS {spacing} MN", + "{count} CONTACTS": { + "one": "{count} CONTATO", + "many": "{count} DE CONTATOS", + "other": "{count} CONTATOS" + }, + "Radar range {range}. Select for the next range.": "Alcance do radar {range}. Selecione para passar ao próximo alcance.", + "Bridge radar": "Radar do passadiço", "Contact navigation": "Navegação de contatos", "CONTEXT ONLY": "SÓ CONTEXTO", "Nearby cohort counts": "Contagens por categoria nas proximidades", @@ -2733,7 +2768,9 @@ "WX": "WX", "Cockpit briefing carousel": "Carrossel de briefing da cabine", "Estimated flight plan": "Plano de voo estimado", + "Reported voyage": "Viagem informada", "ESTIMATED FLIGHT PLAN": "PLANO DE VOO ESTIMADO", + "REPORTED VOYAGE": "VIAGEM INFORMADA", "FROM": "DE", "TO": "PARA", "Cockpit briefing controls": "Controles do briefing da cabine", @@ -2767,11 +2804,15 @@ "Cockpit Radio volume": "Volume do rádio da cabine", "ESC EXIT": "ESC SAIR", "C TOGGLE": "C ALTERNAR", + "ARROWS LOOK": "SETAS OLHAR", + "HOME RESET": "HOME REDEFINIR", "View switcher": "Alternador de vista", "Exit cockpit and return to full globe view": "Sair da cabine e voltar à vista do globo completo", "RESET": "REDEFINIR", "Exit cockpit view": "Sair da vista da cabine", + "Exit bridge view": "Sair da vista do passadiço", "EXIT COCKPIT": "SAIR DA CABINE", + "EXIT BRIDGE": "SAIR DO PASSADIÇO", "Navigation, voice, and visual preset controls": "Controles de navegação, voz e predefinições visuais", "Expand Visual Presets": "Expandir predefinições visuais", "Pin visual presets": "Fixar predefinições visuais", @@ -2804,6 +2845,7 @@ "SELECT CONTEXT": "SELECIONE O CONTEXTO", "Contact Context actions": "Ações de contexto do contato", "COCKPIT": "CABINE", + "BRIDGE": "PASSADIÇO", "SEARCH NEARBY SITES": "BUSCAR LOCAIS PRÓXIMOS", "Reclassify tracked contact as TR-3B": "Reclassificar o contato rastreado como TR-3B", "CONTACTS CONTEXT OFF": "CONTEXTO DE CONTATOS DESLIGADO", @@ -3259,5 +3301,6 @@ "Close camera preview": "Fechar prévia da câmera", "Refresh map still": "Atualizar imagem fixa no mapa", "The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", - "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP." + "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.", + "COURSE HOLD · AIS": "TRAVA DE RUMO · AIS" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index eb0080bce..5e6f5ed44 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -411,10 +411,16 @@ "STALE FEED": "ПОТОК УСТАРЕЛ", "LIVE TRACK": "ТРЕК ОНЛАЙН", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КУРС СОВМЕЩЁН", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВЫСОТА ГЛАЗА {height}", + "LIVE AIS": "AIS ОНЛАЙН", "COMMERCIAL": "ГРАЖДАНСКИЙ", "UNKNOWN": "НЕИЗВЕСТНО", "ARROW · ESTIMATED DIRECTION": "СТРЕЛКА · РАСЧЁТНОЕ НАПРАВЛЕНИЕ", "ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ", + "CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ", + "{range} NM": "{range} МОР. МИЛЬ", + "AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ", "DEST {bearing}": "НАЗН. {bearing}", "Disable cockpit weather effects": "Выключить погодные эффекты кабины", "Enable cockpit weather effects": "Включить погодные эффекты кабины", @@ -459,6 +465,8 @@ "Select flight {title}": "Выбрать рейс {title}", "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", "{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°", + "{label} · HEADING {heading}°": "{label} · КУРС {heading}°", + "{label} · NO HEADING": "{label} · НЕТ КУРСА", "Restore real aircraft": "Вернуть реальный самолёт", "Reclassify as TR-3B": "Переклассифицировать как TR-3B", "No selected data layers": "Нет выбранных слоёв данных", @@ -639,11 +647,19 @@ "Next filtered radio station": "Следующая отфильтрованная радиостанция", "Exit cockpit and return to full globe view": "Выйти из кабины и вернуться к виду всего глобуса", "Exit cockpit view": "Выйти из кабины", + "Exit bridge view": "Выйти с мостика", "Estimated destination direction": "Расчётное направление на пункт назначения", + "BRIDGE WINDOW · FWD": "ОКНО МОСТИКА · ПО НОСУ", "OPTICAL PLANE · 01": "ОПТИЧЕСКАЯ ПЛОСКОСТЬ · 01", + "HEADING HOLD · AIS": "УДЕРЖАНИЕ КУРСА · AIS", + "NO HEADING · AIS": "НЕТ КУРСА · AIS", "VISOR LOCK · ACTIVE": "БЛОКИРОВКА ВИЗОРА · АКТИВНА", "Cockpit vision style": "Стиль обзора кабины", "Current aircraft heading": "Текущий курс самолёта", + "Look ahead: turn the bridge view back to the bow": "Взгляд по носу: развернуть вид с мостика обратно к носу", + "Look ahead (Home or 0)": "Взгляд по носу (Home или 0)", + "LOOK AHEAD": "ВЗГЛЯД ПО НОСУ", + "Current vessel heading": "Текущий курс судна", "Nearby cohort counts": "Счётчики контактов поблизости", "Cockpit display options": "Параметры экрана кабины", "Cockpit compact Radio controls": "Компактное управление радио кабины", @@ -652,23 +668,37 @@ "Cockpit briefing controls": "Управление брифингом кабины", "View switcher": "Переключатель вида", "Aircraft cockpit view": "Вид кабины самолёта", + "Ship bridge view": "Вид с ходового мостика", "Estimated flight plan": "Расчётный план полёта", + "Reported voyage": "Заявленный рейс", "Live signals": "Сигналы в реальном времени", "Latest regional news": "Последние региональные новости", "Location-based information": "Информация по текущему месту", + "LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°", + "LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ", + "UP {angle}°": "ВВЕРХ {angle}°", + "DOWN {angle}°": "ВНИЗ {angle}°", "LEVEL": "ГОРИЗОНТ", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "ВЗГЛЯД {elevation}", "DEST ---°": "НАЗН. ---°", "CURRENT": "ТЕКУЩИЙ", "NEAREST OBSERVED / MAPPED": "БЛИЖАЙШИЕ НАБЛЮДАЕМЫЕ / НА КАРТЕ", "GROUND SPEED · KTS": "ПУТЕВАЯ СКОРОСТЬ · УЗ", + "SPEED OVER GROUND · KTS": "СКОРОСТЬ ПО ГРУНТУ · УЗ", "ALTITUDE · FT": "ВЫСОТА · ФТ", "FIRST PERSON": "ОТ ПЕРВОГО ЛИЦА", + "BRIDGE VIEW": "ВИД С МОСТИКА", "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН", "GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ", + "SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ", + "COURSE OVER GROUND": "КУРС ПО ГРУНТУ", "ALTITUDE": "ВЫСОТА", "CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "ESTIMATED FLIGHT PLAN": "РАСЧЁТНЫЙ ПЛАН ПОЛЁТА", + "REPORTED VOYAGE": "ЗАЯВЛЕННЫЙ РЕЙС", "FROM": "ОТКУДА", "TO": "КУДА", "TEMP": "ТЕМП.", @@ -679,7 +709,17 @@ "VOLUME": "ГРОМКОСТЬ", "RESET": "СБРОС", "EXIT COCKPIT": "ВЫЙТИ ИЗ КАБИНЫ", + "EXIT BRIDGE": "ВЫЙТИ С МОСТИКА", "CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ", + "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ", + "{count} CONTACTS": { + "one": "{count} КОНТАКТ", + "few": "{count} КОНТАКТА", + "many": "{count} КОНТАКТОВ", + "other": "{count} КОНТАКТА" + }, + "Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.", + "Bridge radar": "Радар ходового мостика", "Expand Visual Presets": "Развернуть визуальные пресеты", "Pin visual presets": "Закрепить визуальные пресеты", "Keep visual presets open": "Держать визуальные пресеты открытыми", @@ -735,6 +775,7 @@ "CONTEXT": "КОНТЕКСТ", "SPACE MISSIONS": "КОСМИЧЕСКИЕ МИССИИ", "COCKPIT": "КАБИНА", + "BRIDGE": "МОСТИК", "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "ВЫБЕРИТЕ КОНТАКТЫ, ЧТОБЫ ЗАГРУЗИТЬ НАБЛЮДАЕМОЕ / КАРТОГРАФИРОВАННОЕ ОКРУЖЕНИЕ", "SELECT A MISSION TO INSPECT": "ВЫБЕРИТЕ МИССИЮ ДЛЯ ПРОСМОТРА", "Enable Radio, then choose a globe marker or use next.": "Включите радио, затем выберите маркер на глобусе или переключите на следующую станцию.", @@ -1632,6 +1673,8 @@ "Weather data by Open-Meteo.com": "Данные о погоде: Open-Meteo.com", "ESC EXIT": "ESC ВЫХОД", "C TOGGLE": "C ПЕРЕКЛ.", + "ARROWS LOOK": "СТРЕЛКИ ОБЗОР", + "HOME RESET": "HOME СБРОС", "SIG": "СИГН.", "NEWS": "НОВОСТИ", "LOCAL": "МЕСТН.", @@ -3334,5 +3377,6 @@ "Close camera preview": "Закрыть просмотр камеры", "Refresh map still": "Обновить кадр на карте", "The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", - "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес." + "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.", + "COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a61335f2d..807e62da1 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1724,8 +1724,14 @@ "COMMERCIAL": "КОМЕРЦІЙНИЙ", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · КАМЕРА ЗА КУРСОМ", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ВИСОТА ОКА {height}", + "LIVE AIS": "AIS ОНЛАЙН", "ARROW · ESTIMATED DIRECTION": "СТРІЛКА · ОРІЄНТОВНИЙ НАПРЯМОК", "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", + "CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ", + "{range} NM": "{range} МОР. МИЛЬ", + "AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ", "DEST {bearing}": "ПРИЗН. {bearing}", "Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни", "Enable cockpit weather effects": "Увімкнути погодні ефекти кабіни", @@ -1773,6 +1779,8 @@ "Reclassify as TR-3B": "Перекласифікувати як TR-3B", "TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО", "{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°", + "{label} · HEADING {heading}°": "{label} · КУРС {heading}°", + "{label} · NO HEADING": "{label} · НЕМАЄ КУРСУ", "Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз", "No selected data layers": "Шари даних не вибрано", "{count} data layers could not be cleared": { @@ -2767,14 +2775,27 @@ "Recent speech": "Нещодавнє мовлення", "HERE": "ТУТ", "Aircraft cockpit view": "Вигляд кабіни повітряного судна", + "Ship bridge view": "Вигляд з ходового містка судна", + "LOOK STBD {bearing}°": "ПОГЛЯД ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ПОГЛЯД ЛІВ. БОРТ {bearing}°", + "LOOK ASTERN": "ПОГЛЯД ЗА КОРМУ", + "UP {angle}°": "ВГОРУ {angle}°", + "DOWN {angle}°": "ВНИЗ {angle}°", "LEVEL": "ГОРИЗОНТ", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "ПОГЛЯД {elevation}", "Estimated destination direction": "Орієнтовний напрямок до пункту призначення", "DEST ---°": "ПРИЗН. ---°", + "BRIDGE WINDOW · FWD": "ВІКНО МІСТКА · ПО НОСІ", "OPTICAL PLANE · 01": "ОПТИЧНА ПЛОЩИНА · 01", + "HEADING HOLD · AIS": "УТРИМАННЯ КУРСУ · AIS", + "NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS", "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", "GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", + "SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ", "ALTITUDE · FT": "ВИСОТА · ФТ", "FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ", + "BRIDGE VIEW": "ВИГЛЯД З МІСТКА", "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КАМЕРА ЗА КУРСОМ", "Cockpit vision style": "Візуальний стиль у кабіні", "Previous cockpit vision style": "Попередній візуальний стиль у кабіні", @@ -2783,13 +2804,28 @@ "Next cockpit vision style": "Наступний візуальний стиль у кабіні", "Next vision style": "Наступний візуальний стиль", "Current aircraft heading": "Поточний курс повітряного судна", + "Look ahead: turn the bridge view back to the bow": "Погляд по носі: повернути вигляд з містка назад до носа", + "Look ahead (Home or 0)": "Погляд по носі (Home або 0)", + "LOOK AHEAD": "ПОГЛЯД ПО НОСІ", + "Current vessel heading": "Поточний курс судна", "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", + "SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ", + "COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ", "KTS": "ВУЗ", "ALTITUDE": "ВИСОТА", "FT": "ФТ", "Contact cockpit summary": "Зведення контакту в кабіні", "CONTACT": "КОНТАКТ", "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", + "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ", + "{count} CONTACTS": { + "one": "{count} КОНТАКТ", + "few": "{count} КОНТАКТИ", + "many": "{count} КОНТАКТІВ", + "other": "{count} КОНТАКТУ" + }, + "Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.", + "Bridge radar": "Радар ходового містка", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", "Nearby cohort counts": "Кількість контактів поблизу за типами", @@ -2801,7 +2837,9 @@ "WX": "ПОГОДА", "Cockpit briefing carousel": "Карусель брифінгу кабіни", "Estimated flight plan": "Орієнтовний план польоту", + "Reported voyage": "Заявлений рейс", "ESTIMATED FLIGHT PLAN": "ОРІЄНТОВНИЙ ПЛАН ПОЛЬОТУ", + "REPORTED VOYAGE": "ЗАЯВЛЕНИЙ РЕЙС", "FROM": "ЗВІДКИ", "TO": "КУДИ", "Cockpit briefing controls": "Керування брифінгом кабіни", @@ -2835,11 +2873,15 @@ "Cockpit Radio volume": "Гучність радіо кабіни", "ESC EXIT": "ESC ВИХІД", "C TOGGLE": "C ПЕРЕМК.", + "ARROWS LOOK": "СТРІЛКИ ОГЛЯД", + "HOME RESET": "HOME СКИДАННЯ", "View switcher": "Перемикач вигляду", "Exit cockpit and return to full globe view": "Вийти з кабіни та повернутися до вигляду всього глобуса", "RESET": "СКИНУТИ", "Exit cockpit view": "Вийти з вигляду кабіни", + "Exit bridge view": "Вийти з вигляду містка", "EXIT COCKPIT": "ВИЙТИ З КАБІНИ", + "EXIT BRIDGE": "ВИЙТИ З МІСТКА", "Navigation, voice, and visual preset controls": "Керування навігацією, голосом і візуальними пресетами", "Expand Visual Presets": "Розгорнути візуальні пресети", "Pin visual presets": "Закріпити візуальні пресети", @@ -2872,6 +2914,7 @@ "SELECT CONTEXT": "ВИБЕРІТЬ КОНТЕКСТ", "Contact Context actions": "Дії контексту контактів", "COCKPIT": "КАБІНА", + "BRIDGE": "МІСТОК", "SEARCH NEARBY SITES": "ПОШУК НАЙБЛИЖЧИХ ОБʼЄКТІВ", "Reclassify tracked contact as TR-3B": "Перекласифікувати відстежуваний контакт як TR-3B", "CONTACTS CONTEXT OFF": "КОНТЕКСТ КОНТАКТІВ ВИМКНЕНО", @@ -3334,5 +3377,6 @@ "Close camera preview": "Закрити перегляд камери", "Refresh map still": "Оновити кадр на карті", "The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", - "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу." + "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.", + "COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 89236f024..f8cce3a03 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1626,8 +1626,14 @@ "COMMERCIAL": "商用", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向对准", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "实时 AIS", "ARROW · ESTIMATED DIRECTION": "箭头 · 预估方向", "ROUTE DATA UNAVAILABLE": "航线数据不可用", + "CONTACT NAVIGATION OFF": "目标导航已关闭", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座舱天气效果", "Enable cockpit weather effects": "启用座舱天气效果", @@ -1672,6 +1678,8 @@ "Reclassify as TR-3B": "重新归类为 TR-3B", "TRACK ACQUIRED": "已获取航迹", "{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首向 {heading}°", + "{label} · NO HEADING": "{label} · 无船首向", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "No selected data layers": "没有已选数据图层", "{count} data layers could not be cleared": { @@ -2454,14 +2462,27 @@ "DONE": "完成", "HERE": "此处", "Aircraft cockpit view": "飞机座舱视图", + "Ship bridge view": "船舶驾驶台视图", + "LOOK STBD {bearing}°": "视线 右舷 {bearing}°", + "LOOK PORT {bearing}°": "视线 左舷 {bearing}°", + "LOOK ASTERN": "视线 船尾", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "视线 {elevation}", "Estimated destination direction": "预估目的地方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "驾驶台窗口 · 前方", "OPTICAL PLANE · 01": "光学平面 · 01", + "HEADING HOLD · AIS": "船首向保持 · AIS", + "NO HEADING · AIS": "无船首向 · AIS", "VISOR LOCK · ACTIVE": "面罩锁定 · 激活", "GROUND SPEED · KTS": "地速 · kn", + "SPEED OVER GROUND · KTS": "对地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人称", + "BRIDGE VIEW": "驾驶台视图", "LIVE TRACK · COURSE ALIGNED": "实时航迹 · 航向对准", "Cockpit vision style": "座舱视觉样式", "Previous cockpit vision style": "上一个座舱视觉样式", @@ -2470,13 +2491,25 @@ "Next cockpit vision style": "下一个座舱视觉样式", "Next vision style": "下一个视觉样式", "Current aircraft heading": "飞机当前航向", + "Look ahead: turn the bridge view back to the bow": "看向前方:将驾驶台视图转回船首", + "Look ahead (Home or 0)": "看向前方(Home 或 0)", + "LOOK AHEAD": "看向前方", + "Current vessel heading": "船舶当前船首向", "GROUND SPEED": "地速", + "SPEED OVER GROUND": "对地航速", + "COURSE OVER GROUND": "对地航向", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", "CONTACTS · 250 KM": "目标 · 250 km", + "RINGS {spacing} NM": "距标圈 {spacing}海里", + "{count} CONTACTS": { + "other": "{count} 个目标" + }, + "Radar range {range}. Select for the next range.": "雷达量程 {range}。选择以切换到下一量程。", + "Bridge radar": "驾驶台雷达", "Contact navigation": "目标导航", "CONTEXT ONLY": "仅供参考", "Nearby cohort counts": "附近各类目标数量", @@ -2488,7 +2521,9 @@ "WX": "天气", "Cockpit briefing carousel": "座舱简报轮播", "Estimated flight plan": "预估飞行计划", + "Reported voyage": "申报航次", "ESTIMATED FLIGHT PLAN": "预估飞行计划", + "REPORTED VOYAGE": "申报航次", "FROM": "出发", "TO": "到达", "Cockpit briefing controls": "座舱简报控件", @@ -2522,11 +2557,15 @@ "Cockpit Radio volume": "座舱电台音量", "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切换", + "ARROWS LOOK": "方向键 环视", + "HOME RESET": "HOME 复位", "View switcher": "视图切换", "Exit cockpit and return to full globe view": "退出座舱并返回完整地球视图", "RESET": "重置", "Exit cockpit view": "退出座舱视图", + "Exit bridge view": "退出驾驶台视图", "EXIT COCKPIT": "退出座舱", + "EXIT BRIDGE": "退出驾驶台", "Navigation, voice, and visual preset controls": "导航、语音和视觉预设控件", "Expand Visual Presets": "展开视觉预设", "Pin visual presets": "固定视觉预设", @@ -2559,6 +2598,7 @@ "SELECT CONTEXT": "选择态势", "Contact Context actions": "目标态势操作", "COCKPIT": "座舱", + "BRIDGE": "驾驶台", "SEARCH NEARBY SITES": "搜索附近设施", "Reclassify tracked contact as TR-3B": "将跟踪目标重新归类为 TR-3B", "CONTACTS CONTEXT OFF": "目标态势已关闭", @@ -3109,5 +3149,6 @@ "Close camera preview": "关闭摄像头预览", "Refresh map still": "刷新地图静止画面", "The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", - "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。" + "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。", + "COURSE HOLD · AIS": "对地航向保持 · AIS" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e3ce492df..a4c7c5460 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1616,8 +1616,14 @@ "COMMERCIAL": "商用", "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · 航向對準", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · 眼高 {height}", + "LIVE AIS": "實時 AIS", "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", "ROUTE DATA UNAVAILABLE": "航線數據不可用", + "CONTACT NAVIGATION OFF": "目標導航已關閉", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上", + "{range} NM": "{range}海里", + "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座艙天氣效果", "Enable cockpit weather effects": "啟用座艙天氣效果", @@ -1662,6 +1668,8 @@ "Reclassify as TR-3B": "重新歸類為 TR-3B", "TRACK ACQUIRED": "已獲取航跡", "{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°", + "{label} · HEADING {heading}°": "{label} · 船首向 {heading}°", + "{label} · NO HEADING": "{label} · 無船首向", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", "No selected data layers": "沒有已選數據圖層", "{count} data layers could not be cleared": { @@ -2442,14 +2450,27 @@ "DONE": "完成", "HERE": "此處", "Aircraft cockpit view": "飛機座艙視圖", + "Ship bridge view": "船舶駕駛台視圖", + "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", + "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", + "LOOK ASTERN": "視線 船尾", + "UP {angle}°": "上 {angle}°", + "DOWN {angle}°": "下 {angle}°", "LEVEL": "水平", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", + "BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方", "OPTICAL PLANE · 01": "光學平面 · 01", + "HEADING HOLD · AIS": "船首向保持 · AIS", + "NO HEADING · AIS": "無船首向 · AIS", "VISOR LOCK · ACTIVE": "面罩鎖定 · 激活", "GROUND SPEED · KTS": "地速 · kn", + "SPEED OVER GROUND · KTS": "對地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人稱", + "BRIDGE VIEW": "駕駛台視圖", "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", "Cockpit vision style": "座艙視覺樣式", "Previous cockpit vision style": "上一個座艙視覺樣式", @@ -2458,13 +2479,25 @@ "Next cockpit vision style": "下一個座艙視覺樣式", "Next vision style": "下一個視覺樣式", "Current aircraft heading": "飛機當前航向", + "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首", + "Look ahead (Home or 0)": "看向前方(Home 或 0)", + "LOOK AHEAD": "看向前方", + "Current vessel heading": "船舶當前船首向", "GROUND SPEED": "地速", + "SPEED OVER GROUND": "對地航速", + "COURSE OVER GROUND": "對地航向", "KTS": "kn", "ALTITUDE": "高度", "FT": "ft", "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", "CONTACTS · 250 KM": "目標 · 250 km", + "RINGS {spacing} NM": "距標圈 {spacing}海里", + "{count} CONTACTS": { + "other": "{count} 個目標" + }, + "Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。", + "Bridge radar": "駕駛台雷達", "Contact navigation": "目標導航", "CONTEXT ONLY": "僅供參考", "Nearby cohort counts": "附近各類目標數量", @@ -2476,7 +2509,9 @@ "WX": "天氣", "Cockpit briefing carousel": "座艙簡報輪播", "Estimated flight plan": "預估飛行計劃", + "Reported voyage": "申報航次", "ESTIMATED FLIGHT PLAN": "預估飛行計劃", + "REPORTED VOYAGE": "申報航次", "FROM": "出發", "TO": "到達", "Cockpit briefing controls": "座艙簡報控件", @@ -2510,11 +2545,15 @@ "Cockpit Radio volume": "座艙電臺音量", "ESC EXIT": "ESC 退出", "C TOGGLE": "C 切換", + "ARROWS LOOK": "方向鍵 環視", + "HOME RESET": "HOME 復位", "View switcher": "視圖切換", "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", "RESET": "重置", "Exit cockpit view": "退出座艙視圖", + "Exit bridge view": "退出駕駛台視圖", "EXIT COCKPIT": "退出座艙", + "EXIT BRIDGE": "退出駕駛台", "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", "Expand Visual Presets": "展開視覺預設", "Pin visual presets": "固定視覺預設", @@ -2547,6 +2586,7 @@ "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標態勢操作", "COCKPIT": "座艙", + "BRIDGE": "駕駛台", "SEARCH NEARBY SITES": "搜索附近設施", "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", "CONTACTS CONTEXT OFF": "目標態勢已關閉", @@ -3109,5 +3149,6 @@ "Close camera preview": "關閉攝影機預覽", "Refresh map still": "重新整理地圖靜態畫面", "The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", - "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。" + "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", + "COURSE HOLD · AIS": "對地航向保持 · AIS" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0dc9841f7..860bc740d 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -35,6 +35,7 @@ "hPa", "iNaturalist", "iNaturalist ↗", + "{direction} · {elevation}", "{source} HTTP {status}", "▲ {frp} MW" ], @@ -202,6 +203,7 @@ "view band|GLOBAL", "{city} CCTV", "{county} County", + "{direction} · {elevation}", "{source} HTTP {status}", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -335,6 +337,7 @@ "panel", "view band|GLOBAL", "voice|ERROR", + "{direction} · {elevation}", "{rate} msg/s", "{speed} kn", "{type} SPD: {speed} HDG: {heading}", @@ -532,9 +535,11 @@ "standard", "station", "voice|OFF", + "{count} CONTACTS", "{count} MSGS", "{count} SOURCES", "{count} messages", + "{direction} · {elevation}", "{n} cyclones", "{n} satellites", "{rate} msg/s", @@ -707,6 +712,7 @@ "{count} MSGS", "{count} feeds live", "{count} scenes", + "{direction} · {elevation}", "{rate} msg/s", "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}", "{source} HTTP {status}", @@ -773,6 +779,7 @@ "kt", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", @@ -906,6 +913,7 @@ "mph", "nominal", "view band|GLOBAL", + "{direction} · {elevation}", "{rate} msg/s", "{type} SPD: {speed} HDG: {heading}", "{value} KM", @@ -942,7 +950,8 @@ "WHOT/BHOT", "acartia.io ↗", "iNaturalist", - "iNaturalist ↗" + "iNaturalist ↗", + "{direction} · {elevation}" ], "uk": [ "AIS: --", @@ -960,7 +969,8 @@ "WHOT/BHOT", "acartia.io ↗", "iNaturalist", - "iNaturalist ↗" + "iNaturalist ↗", + "{direction} · {elevation}" ], "zh-CN": [ "1090 MHz", @@ -1009,6 +1019,7 @@ "kts", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", @@ -1051,9 +1062,9 @@ "MM", "NVG", "REC", - "SRC {value}", "S2 GhostMaps", "S2 · {name}", + "SRC {value}", "Sentinel-2", "Techno", "Trance", @@ -1072,6 +1083,7 @@ "kts", "mph", "{city} CCTV", + "{direction} · {elevation}", "{rate} msg/s", "{source} HTTP {status}", "{speed} kn", diff --git a/src/layers/vessels/cockpitSubject.js b/src/layers/vessels/cockpitSubject.js new file mode 100644 index 000000000..d92689a64 --- /dev/null +++ b/src/layers/vessels/cockpitSubject.js @@ -0,0 +1,142 @@ +/** + * The selected vessel as a Cockpit bridge subject: identity, motion, its + * displayed type and a representative bridge eye height from the AIS type. + * The Cockpit owns camera placement; this module owns what AIS says. + */ +import { normalizeVesselType } from '../../data/vesselTypes.js'; +import { displayVesselType } from '../../data/vesselLabels.js'; + +/** The layer id a vessel subject carries through Cockpit events and state. */ +export const VESSEL_COCKPIT_LAYER_ID = 'ais-live-vessels'; + +/** Knots to metres per second, as the AIS source uses. */ +const KNOTS_TO_MPS = 0.514444; + +/** + * Bridge eye height above the waterline by normalized AIS type family. AIS + * reports no hull height, so these are representative values: deep-sea + * cargo and tanker bridges, passenger and cruise ship bridges, harbour + * craft, small craft. River and harbour ferries, matched by name in the + * patterns below, sit low. Text types from non-numeric feeds fall through + * to the patterns. + */ +const EYE_HEIGHT_BY_FAMILY = Object.freeze({ + CARGO: 30, + TANKER: 30, + PASSENGER: 25, + MILITARY: 18, + 'HIGH-SPEED': 8, + DREDGER: 10, + TOWING: 7, + TUG: 7, + PILOT: 6, + SAR: 6, + 'PORT TENDER': 6, + 'ANTI-POLLUTION': 7, + 'LAW ENFORCE': 7, + MEDICAL: 7, + 'DIVE OPS': 5, + FISHING: 6, + SAILING: 3, + PLEASURE: 3, + // AIS "Other" (90-99) is mostly small craft such as river boats. Only + // that type takes it (see isAisOther). + OTHER: 4, +}); + +const EYE_HEIGHT_PATTERNS = Object.freeze([ + { pattern: /tanker|cargo|container|bulk|carrier/i, heightM: 30 }, + { pattern: /passenger|cruise|ro-?ro/i, heightM: 25 }, + // River and harbour ferries: a low wheelhouse just above the water. + { pattern: /ferry/i, heightM: 4 }, + { pattern: /tug|tow|pilot|supply|service/i, heightM: 7 }, + { pattern: /fishing|trawler/i, heightM: 6 }, + { pattern: /sail|pleasure|yacht/i, heightM: 3 }, +]); + +/** + * Whether a raw type is AIS "Other" proper: codes 90 to 99, or a feed's text + * type. normalizeVesselType also files the reserved, wing-in-ground and + * spare codes (1-29, 38-39, 56-57, 59) under OTHER, but they name no family. + */ +function isAisOther(type) { + const text = String(type ?? '').trim(); + if (!/^\d+$/.test(text)) return true; + const code = Number(text); + return code >= 90 && code <= 99; +} + +/** + * Representative bridge eye height for an AIS vessel type. + * @param {string|number} type Raw AIS type: a numeric code or feed text. + * @returns {number|null} Metres above the waterline, or null when the type + * names no known family (the Cockpit then uses its default). + */ +export function vesselEyeHeightM(type) { + const family = normalizeVesselType(type).toUpperCase(); + if (family === 'OTHER' && !isAisOther(type)) return null; + if (Object.hasOwn(EYE_HEIGHT_BY_FAMILY, family)) + return EYE_HEIGHT_BY_FAMILY[family]; + const matched = EYE_HEIGHT_PATTERNS.find((entry) => + entry.pattern.test(family), + ); + return matched ? matched.heightM : null; +} + +function finiteOrNull(value) { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +/** + * Normalize a selected vessel record into Cockpit subject info. + * + * `track` is where the camera looks and `motionCourse` is where the hull + * moves: a ship faces its true heading while current and wind set it along + * its course over ground. Either falls back to the other when one is missing. + * @param {object|null} record Vessel record from the vessel layer. + * @returns {object|null} Subject info, or null without a usable fix. + */ +export function vesselCockpitInfo(record) { + if (!record?.mmsi) return null; + const latitude = Number(record.lat); + const longitude = Number(record.lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; + const heading = finiteOrNull(record.heading); + const course = finiteOrNull(record.course); + const speedKt = finiteOrNull(record.speed); + const epochSec = finiteOrNull(record.lastPositionEpoch); + const feedEpochSec = finiteOrNull(record.fixFeedEpoch); + return { + kind: 'vessel', + layerId: VESSEL_COCKPIT_LAYER_ID, + id: String(record.mmsi), + mmsi: String(record.mmsi), + name: String(record.name || record.mmsi), + type: String(record.type || ''), + typeLabel: displayVesselType(record.type), + destination: String(record.destination || '').trim(), + latitude, + longitude, + heading, + course, + speedKt, + velocityMps: speedKt === null ? null : Math.max(0, speedKt) * KNOTS_TO_MPS, + track: heading ?? course, + // What `track` is: the true heading, or course over ground standing in + // for a ship that reports none. The HUD says which. + trackSource: + heading !== null ? 'heading' : course !== null ? 'course' : null, + motionCourse: course ?? heading, + fixEpochMs: epochSec === null ? null : epochSec * 1000, + // When this client first received the fix (its own clock) and the + // newest fix in that refresh (the feed's clock): the fix's age without + // clock skew, still ageing while the feed re-serves it. + receivedAtMs: finiteOrNull(record.fixReceivedAtMs), + feedEpochMs: feedEpochSec === null ? null : feedEpochSec * 1000, + eyeHeightM: vesselEyeHeightM(record.type), + stale: (record.missedRefreshes || 0) > 0, + onGround: false, + }; +} diff --git a/src/layers/vessels/cockpitSubject.test.mjs b/src/layers/vessels/cockpitSubject.test.mjs new file mode 100644 index 000000000..572ff199d --- /dev/null +++ b/src/layers/vessels/cockpitSubject.test.mjs @@ -0,0 +1,113 @@ +// src/layers/vessels/cockpitSubject.test.mjs — bridge eye heights by AIS +// type and the selected vessel as Cockpit subject info. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + VESSEL_COCKPIT_LAYER_ID, + vesselCockpitInfo, + vesselEyeHeightM, +} from './cockpitSubject.js'; + +test('bridge eye height follows the AIS type family', () => { + assert.equal(vesselEyeHeightM('71'), 30, 'cargo'); + assert.equal(vesselEyeHeightM('84'), 30, 'tanker'); + assert.equal(vesselEyeHeightM('60'), 25, 'passenger'); + assert.equal(vesselEyeHeightM('52'), 7, 'tug'); + assert.equal(vesselEyeHeightM('30'), 6, 'fishing'); + assert.equal(vesselEyeHeightM('37'), 3, 'pleasure'); + assert.equal(vesselEyeHeightM('36'), 3, 'sailing'); +}); + +test('text vessel types resolve through the same families', () => { + assert.equal(vesselEyeHeightM('Container Ship'), 30); + assert.equal(vesselEyeHeightM('Cruise ship'), 25); + assert.equal(vesselEyeHeightM('Ferry'), 4); + assert.equal(vesselEyeHeightM(99), 4, 'AIS Other'); + assert.equal(vesselEyeHeightM('90'), 4, 'AIS Other'); + assert.equal(vesselEyeHeightM('Other'), 4, 'a feed text type Other'); + assert.equal(vesselEyeHeightM('Yacht'), 3); +}); + +test('codes filed under OTHER that are not AIS Other keep the default', () => { + // Reserved (1-19), wing in ground (20-29), reserved (38-39), spare + // (56-57) and local (59) codes name no family. + for (const type of ['1', '19', '20', '29', '38', '39', '56', '57', '59']) + assert.equal(vesselEyeHeightM(type), null, type); +}); + +test('an unknown or missing type has no eye height of its own', () => { + for (const type of ['', '0', '255', 'Research vessel', null]) { + assert.equal(vesselEyeHeightM(type), null, String(type)); + } +}); + +test('vessel info faces the true heading and moves along course over ground', () => { + const info = vesselCockpitInfo({ + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + destination: ' KIEL ', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + lastPositionEpoch: 1_700_000_000, + missedRefreshes: 0, + }); + assert.equal(info.kind, 'vessel'); + assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID); + assert.equal(info.id, '211000000'); + assert.equal(info.typeLabel, 'CARGO'); + assert.equal(info.track, 90); + assert.equal(info.motionCourse, 95); + assert.equal(info.destination, 'KIEL'); + assert.equal(info.eyeHeightM, 30); + assert.equal(info.fixEpochMs, 1_700_000_000_000); + assert.ok(Math.abs(info.velocityMps - 12 * 0.514444) < 1e-9); + assert.equal(info.stale, false); + assert.equal(info.onGround, false); +}); + +test('a missing heading or course falls back to the other', () => { + const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 }; + assert.equal(vesselCockpitInfo({ ...base, course: 40 }).track, 40); + assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50); + const neither = vesselCockpitInfo(base); + assert.equal(neither.track, null); + assert.equal(neither.motionCourse, null); +}); + +test('the subject says whether its track is the heading or the course', () => { + const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 }; + assert.equal( + vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource, + 'heading', + ); + assert.equal( + vesselCockpitInfo({ ...base, course: 40 }).trackSource, + 'course', + ); + // A heading of 0 is due north, not a missing heading. + assert.equal( + vesselCockpitInfo({ ...base, heading: 0 }).trackSource, + 'heading', + ); + assert.equal(vesselCockpitInfo(base).trackSource, null); +}); + +test('a vessel without identity or position is not a subject', () => { + assert.equal(vesselCockpitInfo(null), null); + assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), null); + assert.equal(vesselCockpitInfo({ mmsi: '1', lat: 'x', lon: 1 }), null); +}); + +test('a vessel missing from recent refreshes reads as stale', () => { + const info = vesselCockpitInfo({ + mmsi: '1', + lat: 1, + lon: 1, + missedRefreshes: 2, + }); + assert.equal(info.stale, true); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d4d9ef796..4fd53c35c 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -1,5 +1,8 @@ import * as Cesium from 'cesium'; import { t, msg } from '../../i18n/index.js'; +import { vesselCockpitInfo } from './cockpitSubject.js'; +import { vesselTypeCss } from '../../data/vesselLabels.js'; +import { greatCircleMeters } from '../../geoDistance.js'; // ingestion.js is a portable export (package.json) and writes English labels // into state; translate the known ones here, pass anything else through. @@ -20,6 +23,7 @@ import { AIS_STATUS_REASON, REFRESH_MS, FOCUS_EVIDENCE_DEV, + VESSEL_LIFT_M, } from './policy.js'; export function createQueries({ @@ -346,6 +350,58 @@ export function createQueries({ return entries.slice(0, cap); }, + /** + * Vessels around a point for the Cockpit bridge radar, nearest first. + * The ship whose bridge the Cockpit occupies is left out. Distances are + * great-circle from the AIS positions, so no rendered visual is needed. + * @param {number} lat Latitude of the centre, degrees. + * @param {number} lon Longitude of the centre, degrees. + * @param {number} rangeM Max distance in metres. + * @param {number} [maxCount=60] Maximum entries to return. + * @returns {Array<{ id: string, name: string, latitude: number, + * longitude: number, course: number|null, heading: number|null, + * type: string, color: string, stale: boolean }>} Empty while the + * layer is off. + */ + getRadarContacts(lat, lon, rangeM, maxCount = 60) { + const records = state.records.all; + if (!state.feed.enabled || !Array.isArray(records) || !records.length) + return []; + if (!Number.isFinite(rangeM) || rangeM <= 0) return []; + const cap = + Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60; + const center = { lat, lon }; + const subject = state.cockpitSubjectMmsi; + const finite = (value) => { + if (value === null || value === undefined || value === '') return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; + }; + + const entries = []; + for (const record of records) { + if (subject && String(record.mmsi) === subject) continue; + const distanceM = greatCircleMeters(center, { + lat: record.lat, + lon: record.lon, + }); + if (!(distanceM <= rangeM)) continue; + entries.push({ record, distanceM }); + } + entries.sort((a, b) => a.distanceM - b.distanceM); + return entries.slice(0, cap).map(({ record }) => ({ + id: String(record.mmsi), + name: String(record.name || record.mmsi), + latitude: record.lat, + longitude: record.lon, + course: finite(record.course), + heading: finite(record.heading), + type: String(record.type || ''), + color: vesselTypeCss(record.type), + stale: (record.missedRefreshes || 0) > 0, + })); + }, + /** * Get positions of all currently loaded vessels. * @param {number} [maxCount=800] Maximum entries to return. @@ -462,6 +518,52 @@ export function createQueries({ }; }, + /** + * Describe the selected vessel for the Cockpit bridge view. + * @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM` + * (the local geoid height the sprite datum uses) and `markHeightM` (the + * height the sprite is drawn at), or null when the layer is off or + * nothing is selected. + */ + getCockpitSubject() { + if (!state.feed.enabled) return null; + const info = vesselCockpitInfo(state.selectedRecord); + if (!info) return null; + const geoidN = components.tracking.currentGeoidN( + info.latitude, + info.longitude, + ); + return { + ...info, + seaSurfaceHeightM: vesselDatumHeightM(geoidN, 0), + markHeightM: vesselDatumHeightM(geoidN, VESSEL_LIFT_M), + }; + }, + + /** + * Mark the vessel whose bridge the Cockpit camera occupies, or release it. + * Its sprite, card and detection bracket stay hidden while it is held. + * @param {string|number|null} mmsi Subject MMSI, or null to release. + * @returns {boolean} Always true. + */ + setCockpitSubject(mmsi) { + const next = normalizeSelectionMmsi(mmsi); + if (state.cockpitSubjectMmsi === next) return true; + state.cockpitSubjectMmsi = next; + components.rendering.updateVisibility(true); + return true; + }, + + /** + * Return the camera to the selected vessel's map view, as a click on it does. + * @returns {boolean} True when a selected vessel took the camera. + */ + focusSelectedVessel() { + const record = state.selectedRecord; + if (!record || !state.feed.enabled) return false; + return components.selection.selectAndFocusVessel(record); + }, + /** * Return a subset of vessels for the universal detection overlay. * Deterministic stride sampling distributes selections evenly across the diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs new file mode 100644 index 000000000..9366e7e3a --- /dev/null +++ b/src/layers/vessels/radarContacts.test.mjs @@ -0,0 +1,118 @@ +// src/layers/vessels/radarContacts.test.mjs — the vessels the Cockpit bridge +// radar draws: within range, nearest first, without the ship it rides. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVesselLayer } from './index.js'; +import { vesselTypeCss } from '../../data/vesselLabels.js'; + +const noop = () => {}; +function services() { + return { + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, + labels: {}, + picking: { unregisterPickOwner: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { + holdContinuousRender: noop, + releaseContinuousRender: noop, + governorRequestRender: noop, + }, + }; +} + +/** A vessel `northNm` nautical miles north of 54.5°N 10.2°E. */ +function record(mmsi, northNm, extra = {}) { + return { + mmsi, + name: `SHIP ${mmsi}`, + type: '70', + lat: 54.5 + northNm / 60, + lon: 10.2, + speed: 10, + course: 45, + heading: 40, + missedRefreshes: 0, + ...extra, + }; +} + +function layerWith(records, options = {}) { + const layer = createVesselLayer({ + source: { getSnapshot: noop }, + services: services(), + }); + layer.testing._setVesselStateForTest({ + viewer: {}, + records, + billboardCollection: { add: (entry) => ({ ...entry }), remove: noop }, + ...options, + }); + return layer; +} + +test('radar contacts are within range, nearest first and capped', () => { + const layer = layerWith([ + record('3', 5), + record('1', 1), + record('far', 20), + record('2', 3, { course: null, heading: '', missedRefreshes: 2 }), + ]); + const contacts = layer.getRadarContacts(54.5, 10.2, 6 * 1852, 60); + assert.deepEqual( + contacts.map((entry) => entry.id), + ['1', '2', '3'], + ); + assert.deepEqual(contacts[0], { + id: '1', + name: 'SHIP 1', + latitude: 54.5 + 1 / 60, + longitude: 10.2, + course: 45, + heading: 40, + type: '70', + color: vesselTypeCss('70'), + stale: false, + }); + assert.equal(contacts[1].course, null); + assert.equal(contacts[1].heading, null); + assert.equal(contacts[1].stale, true); + assert.deepEqual( + layer.getRadarContacts(54.5, 10.2, 6 * 1852, 2).map((entry) => entry.id), + ['1', '2'], + ); +}); + +test('the ship whose bridge the Cockpit rides is not its own contact', () => { + const layer = layerWith([record('own', 0), record('other', 2)]); + layer.setCockpitSubject('own'); + assert.deepEqual( + layer.getRadarContacts(54.5, 10.2, 6 * 1852).map((entry) => entry.id), + ['other'], + ); + layer.setCockpitSubject(null); + assert.equal(layer.getRadarContacts(54.5, 10.2, 6 * 1852).length, 2); +}); + +test('a disabled layer has no radar contacts', () => { + const layer = layerWith([record('1', 1)], { enabled: false }); + assert.deepEqual(layer.getRadarContacts(54.5, 10.2, 6 * 1852), []); +}); diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..2a00fca3c 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -26,6 +26,18 @@ export function normalizeVessel(row) { }; } +/** + * Note when a fix was first received (`fixReceivedAtMs`, this client's + * clock) and the newest fix in that refresh (`fixFeedEpoch`, the feed's + * clock): its age is then read on each clock separately, so neither a + * skewed client clock nor a re-served cached row hides how old it is. + * `receivedAtMs` stays the time the row was last seen, for retention. + */ +function stampFix(record, receivedAtMs, feedLatestEpoch) { + record.fixReceivedAtMs = receivedAtMs; + record.fixFeedEpoch = feedLatestEpoch; +} + function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -46,6 +58,18 @@ export class VesselRecords { effects, ) { const receivedAtMs = this.now(); + // The newest fix in the batch: the feed's own "now", so a fix's age can + // be read on the feed's clock rather than against this client's (see + // stampFix). + let feedLatestEpoch = null; + for (const row of rows) { + const epoch = finiteNumber(row?.last_position_epoch); + if ( + epoch !== null && + (feedLatestEpoch === null || epoch > feedLatestEpoch) + ) + feedLatestEpoch = epoch; + } for (const record of this.unkeyed) effects.remove(record, false); this.unkeyed = []; const seen = new Set(); @@ -53,6 +77,7 @@ export class VesselRecords { const next = normalizeVessel(row); if (!next) continue; next.receivedAtMs = receivedAtMs; + stampFix(next, receivedAtMs, feedLatestEpoch); if (!next.mmsi) { effects.add(next); this.unkeyed.push(next); @@ -63,6 +88,12 @@ export class VesselRecords { const record = this.byMmsi.get(next.mmsi); if (record) { const before = effects.beforeUpdate(record); + // A feed re-serving a cached row repeats its fix: the fix keeps the + // time it was first received, so it goes on ageing. + if (next.lastPositionEpoch !== record.lastPositionEpoch) { + record.fixReceivedAtMs = next.fixReceivedAtMs; + record.fixFeedEpoch = next.fixFeedEpoch; + } record.reference = next.reference; record.receivedAtMs = next.receivedAtMs; record.lat = next.lat; diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs index d3e19dcd6..9efa91130 100644 --- a/src/layers/vessels/records.test.mjs +++ b/src/layers/vessels/records.test.mjs @@ -111,3 +111,33 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown assert.equal(probe.store.all.length, 1); assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null); }); + +test("each fix keeps when it was first received and its refresh's newest fix", () => { + const probe = setup(); + probe.reconcile([ + row('111', { last_position_epoch: 1_800_000_000 }), + row('222', { last_position_epoch: 1_800_000_030 }), + row('333'), + ]); + for (const mmsi of ['111', '222', '333']) { + const record = probe.store.byMmsi.get(mmsi); + assert.equal(record.fixFeedEpoch, 1_800_000_030, mmsi); + assert.equal(record.fixReceivedAtMs, 1000, mmsi); + } + // A new fix is stamped afresh. + probe.advance(60_000); + probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]); + const record = probe.store.byMmsi.get('111'); + assert.equal(record.fixFeedEpoch, 1_800_000_050); + assert.equal(record.fixReceivedAtMs, 61_000); + // The same fix re-served by later polls keeps its first receipt, while + // the row itself counts as seen for retention. + probe.advance(30_000); + probe.reconcile([ + row('111', { last_position_epoch: 1_800_000_050 }), + row('222', { last_position_epoch: 1_800_000_090 }), + ]); + assert.equal(record.fixReceivedAtMs, 61_000); + assert.equal(record.fixFeedEpoch, 1_800_000_050); + assert.equal(record.receivedAtMs, 91_000); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 328dd4aee..aefc43bed 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -286,7 +286,9 @@ export function createRendering({ const visual = getVisual(record); const visible = isVisible(visual.surfacePosition, occluder); if (visual.billboard) { - visual.billboard.show = visible; + // The bridge camera sits on this sprite; showing it would fill the view. + visual.billboard.show = + visible && record.mmsi !== state.cockpitSubjectMmsi; if (visible && doRotations && scene) { const rot = screenProjectedRotation( scene, @@ -422,7 +424,11 @@ export function createRendering({ const viewer = state.viewer; const scene = viewer?.scene; const selected = state.selectedRecord; - const entries = selected + // From the bridge, the selected vessel is the viewer; its card would be + // pinned to the camera position. + const selectedCarded = + selected && selected.mmsi !== state.cockpitSubjectMmsi; + const entries = selectedCarded ? [components.cards.buildSelectedVesselCard(selected)] : []; const maxLabels = labelRowLimit(); @@ -458,7 +464,7 @@ export function createRendering({ // Greedy min-separation pass over the priority-ranked cell winners: the // selected card's anchor seeds the accepted set so ambient cards keep clear. const accepted = []; - if (selected) { + if (selectedCarded) { const screen = Cesium.SceneTransforms.worldToWindowCoordinates( scene, getVisual(selected).billboard?.position || getVisual(selected).position, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 96c5df6eb..4b801b9c9 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -37,6 +37,9 @@ export function createSelection({ // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!state.feed.enabled) return; + // The bridge view owns the selection: a click on open water must not + // deselect the vessel the camera is riding. + if (state.cockpitSubjectMmsi) return; const picked = pickContact ? pickContact(viewer.scene, click.position) : viewer.scene.pick(click.position); diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 3ccaf08d9..3cd769c3e 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -72,6 +72,8 @@ export function createVesselState({ source, services }) { activeFocusCount: 0, activeLabelCount: 0, selectedRecord: null, + /** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */ + cockpitSubjectMmsi: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */ trail: null, /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */ diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index c1effdd7c..13303410f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -361,6 +361,7 @@ export class StyleManager extends ShellFacade { services: { flightsLayer: services.flightsLayer, militaryFlightsLayer: services.militaryFlightsLayer, + aisLiveVesselsLayer: services.aisLiveVesselsLayer, isTr3b: services.isTr3b, toggleTr3b: services.toggleTr3b, militaryAwarenessLayer: services.militaryAwarenessLayer, @@ -400,6 +401,8 @@ export class StyleManager extends ShellFacade { useCockpitStore(applicationPinia()).publishVision(view), signals: (rows) => useCockpitStore(applicationPinia()).publishSignals(rows), + radar: (view) => + useCockpitStore(applicationPinia()).publishRadar(view), utilityLayout: (layout) => useCockpitStore(applicationPinia()).publishUtilityLayout(layout), }, diff --git a/src/ui/cockpitBodyClasses.test.mjs b/src/ui/cockpitBodyClasses.test.mjs index adf897ce8..8636c83e8 100644 --- a/src/ui/cockpitBodyClasses.test.mjs +++ b/src/ui/cockpitBodyClasses.test.mjs @@ -52,7 +52,7 @@ function cockpitBodyClasses() { test('the Cockpit adds its subject classes to ', () => { const classes = cockpitBodyClasses(); - for (const name of ['cockpit-mode']) { + for (const name of ['cockpit-mode', 'cockpit-bridge']) { assert.ok(classes.includes(name), `${name} is not added to `); } }); diff --git a/src/ui/cockpitBridge.test.mjs b/src/ui/cockpitBridge.test.mjs new file mode 100644 index 000000000..3da1d72e5 --- /dev/null +++ b/src/ui/cockpitBridge.test.mjs @@ -0,0 +1,1319 @@ +// src/ui/cockpitBridge.test.mjs — a selected vessel's bridge view: entry, +// the vessel layer's sprite hold, the bridge readouts, refused contact +// navigation and the ways the bridge 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 { CockpitViewController } from './cockpitController.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; +import ContextView from './views/ContextView.vue'; +import * as Cesium from 'cesium'; +import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; +import { VesselRecords } from '../layers/vessels/records.js'; + +const RECORD = Object.freeze({ + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + destination: 'KIEL', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + lastPositionEpoch: null, + missedRefreshes: 0, +}); + +/** A ship 2 NM north of RECORD, inside the radar's default range. */ +const NEIGHBOUR = Object.freeze({ + id: '211000001', + name: 'BALTIC TERN', + latitude: RECORD.lat + 2 / 60, + longitude: RECORD.lon, + course: 270, + heading: 270, + type: '30', + color: '#ffd166', + stale: false, +}); + +function vesselLayer(record) { + const calls = []; + const radarQueries = []; + let selected = record; + let subject = null; + let enabled = true; + return { + calls, + radarQueries, + deselect() { + selected = null; + }, + /** Live AIS Vessels turned off: like the layer, it then has no subject. */ + disable() { + enabled = false; + }, + getCockpitSubject: () => + enabled && selected + ? { ...vesselCockpitInfo(selected), seaSurfaceHeightM: 0 } + : null, + setCockpitSubject(mmsi) { + calls.push(['subject', mmsi]); + subject = mmsi; + return true; + }, + // Like the layer, leaves out the ship whose bridge the Cockpit rides. + getRadarContacts(lat, lon, rangeM, maxCount) { + radarQueries.push({ lat, lon, rangeM, maxCount }); + const own = { + ...NEIGHBOUR, + id: record.mmsi, + latitude: record.lat, + longitude: record.lon, + }; + return [own, NEIGHBOUR].filter((contact) => contact.id !== subject); + }, + focusSelectedVessel() { + calls.push(['focus']); + return true; + }, + }; +} + +function fixture( + t, + { bridgeAllowed = true, record = RECORD, aircraftInfo = null } = {}, +) { + 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 aircraftPosition = Cesium.Cartesian3.fromDegrees(10.2, 54.5, 3000); + const aircraft = { + position: { + getValue: (time, result) => + Cesium.Cartesian3.clone(aircraftPosition, result), + }, + show: true, + gevTrackedId: aircraftInfo ? `flights:${aircraftInfo.icao24}` : '', + }; + const views = []; + // Each placed camera: where it is and where it looks. + const shots = []; + const viewer = { + trackedEntity: undefined, + clock: { currentTime: null }, + camera: { + cancelFlight() {}, + setView({ destination, orientation }) { + views.push(Cesium.Cartesian3.clone(destination)); + shots.push({ + destination: Cesium.Cartesian3.clone(destination), + direction: Cesium.Cartesian3.clone(orientation.direction), + up: Cesium.Cartesian3.clone(orientation.up), + }); + }, + }, + entities: { contains: (candidate) => candidate === aircraft }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + const vessels = vesselLayer(record); + const navigations = []; + // Turning Live Flights off clears its tracking, as the layer does. + let flightsEnabled = true; + const disableFlights = () => { + flightsEnabled = false; + }; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { + getTrackedInfo: () => (flightsEnabled ? aircraftInfo : null), + }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + aisLiveVesselsLayer: vessels, + militaryAwarenessLayer: { + navigateNext: () => navigations.push('next') > 0, + navigatePrevious: () => navigations.push('previous') > 0, + }, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + // Open water: no rendered floor above the sea surface. + cachedGroundFloor: () => null, + GROUND_FLOOR_LIFT_M: 0, + }, + // The aircraft gate stays shut: the bridge must not depend on Contacts. + isEntryAllowed: () => Boolean(aircraftInfo), + isBridgeEntryAllowed: () => bridgeAllowed, + publish: { + presence: (view) => store.publishPresence(view), + instruments: (view) => store.publishInstruments(view), + route: (view) => store.publishRoute(view), + radar: (view) => store.publishRadar(view), + }, + }); + // The briefing and signals have their own tests; the bridge keeps only + // what it shows and owns. + Object.assign(cockpit, { + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal() {}, + scheduleContextLayout() {}, + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + }); + // An aircraft session's Contact card and flight plan have their own tests. + if (aircraftInfo) + Object.assign(cockpit, { updateContext() {}, updateRoute() {} }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove('cockpit-mode'); + }); + const $ = (selector) => document.querySelector(selector); + return { + $, + cockpit, + viewer, + vessels, + aircraft, + disableFlights, + navigations, + views, + shots, + store, + }; +} + +/** Replace both clocks the bridge reads; returns a stepper and a restore. */ +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; + }, + set(ms) { + nowMs = ms; + }, + }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); +} + +test('a selected vessel offers a BRIDGE entry without Contacts', async (t) => { + const { $, cockpit } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + assert.equal(entry.hidden, false); + assert.match(entry.textContent, /directions_boat/); + assert.match(entry.textContent, /BRIDGE/); + assert.equal($('#tr3b-toggle').hidden, true, 'TR-3B stays aircraft-only'); +}); + +test('a shut bridge gate keeps the entry hidden', async (t) => { + const { $, cockpit } = fixture(t, { bridgeAllowed: false }); + cockpit.syncEntry(); + await nextTick(); + assert.equal($('#cockpit-entry').hidden, true); + assert.equal(cockpit.enter(), false); +}); + +test('entering the bridge hides the vessel and shows bridge readouts', async (t) => { + const { $, cockpit, vessels } = fixture(t); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'vessel'); + assert.deepEqual(vessels.calls, [['subject', RECORD.mmsi]]); + assert.equal(document.body.classList.contains('cockpit-mode'), true); + + assert.equal( + $('#cockpit-hud').getAttribute('aria-label'), + 'Ship bridge view', + ); + assert.match($('.cockpit-kicker').textContent, /BRIDGE VIEW/); + assert.equal($('#cockpit-callsign').textContent, 'NORDIC STAR'); + assert.match($('#cockpit-aircraft-meta').textContent, /CARGO · LIVE AIS/); + assert.match($('#cockpit-aircraft-meta').textContent, /EYE 30/); + assert.equal($('#cockpit-altitude-rim').hidden, true); + assert.equal($('#cockpit-altitude-value'), null); + assert.ok($('#cockpit-course-value'), 'course over ground replaces altitude'); + assert.match($('#map-view-switch').textContent, /EXIT BRIDGE/); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Exit bridge view', + ); + + const route = $('#cockpit-route'); + assert.equal(route.hidden, false); + assert.match(route.textContent, /REPORTED VOYAGE/); + assert.equal($('#cockpit-route-to').textContent, 'KIEL'); + assert.equal($('#cockpit-route-from'), null, 'AIS reports no origin'); + assert.equal($('#cockpit-route-direction').hidden, true); +}); + +test('entering the bridge names Live AIS Vessels as the ridden layer', async (t) => { + const { cockpit } = fixture(t); + const rides = []; + cockpit.onRide = (layerId) => rides.push(layerId); + assert.equal(cockpit.enter(), true); + await settle(); + assert.deepEqual(rides, ['ais-live-vessels']); +}); + +test('exiting the bridge releases the vessel and returns to its map view', async (t) => { + const { $, cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + $('#map-view-switch').click(); + await settle(); + assert.equal(cockpit.active, false); + assert.equal(cockpit.subjectKind, null); + assert.deepEqual(vessels.calls, [ + ['subject', RECORD.mmsi], + ['subject', null], + ['focus'], + ]); + assert.match($('#map-view-switch').textContent, /EXIT COCKPIT/); +}); + +test('the bridge radar shows nearby ships without its own and steps its range', async (t) => { + const { $, cockpit, vessels, store } = fixture(t); + assert.equal(store.radar, null, 'no radar before the bridge'); + cockpit.enter(); + await settle(); + const radar = store.radar; + assert.ok(radar, 'entering the bridge publishes a radar'); + assert.equal(radar.rangeNm, 3); + assert.equal(radar.rangeLabel, '3 NM'); + assert.deepEqual(radar.ringsNm, [0.75, 1.5, 2.25, 3]); + assert.deepEqual( + radar.contacts.map((contact) => contact.id), + [NEIGHBOUR.id], + 'the cockpit subject is not its own contact', + ); + const [neighbour] = radar.contacts; + assert.ok(Math.abs(neighbour.distanceNm - 2) < 0.02); + // Heading 090: a ship to the north is on the port beam, heading west. + assert.ok(neighbour.x < -0.3 && Math.abs(neighbour.y) < 0.02); + assert.equal(neighbour.relativeCourseDeg, -180); + assert.equal(radar.ownRelativeCourseDeg, 5, 'COG 095 from heading 090'); + assert.equal(vessels.radarQueries.at(-1).rangeM, 3 * 1852); + + assert.equal($('#cockpit-radar').hidden, false); + assert.equal($('#cockpit-radar-range').textContent.trim(), '3 NM'); + assert.equal($('.cockpit-radar-count').textContent.trim(), '1 CONTACT'); + assert.equal(document.querySelectorAll('.cockpit-radar-contact').length, 1); + + $('#cockpit-radar-range').click(); + await settle(); + assert.equal(store.radar.rangeNm, 6); + assert.equal(vessels.radarQueries.at(-1).rangeM, 6 * 1852); + assert.equal($('#cockpit-radar-range').textContent.trim(), '6 NM'); + assert.match( + $('#cockpit-radar-range').getAttribute('aria-label'), + /Radar range 6 NM/, + ); + + cockpit.exit(); + await settle(); + assert.equal(store.radar, null, 'the radar ends with the bridge'); + assert.ok(!$('#cockpit-radar') || $('#cockpit-radar').hidden); +}); + +test('contact navigation does not jump ship from the bridge', async (t) => { + const { cockpit, navigations } = fixture(t); + cockpit.enter(); + await settle(); + assert.equal(cockpit.navigateContext(1, { origin: 'user' }), false); + assert.equal(cockpit.navigateContext(-1, { origin: 'user' }), false); + assert.deepEqual(navigations, []); + assert.equal(cockpit.subjectKind, 'vessel'); +}); + +test('tracking an aircraft from the bridge ends it without refocusing the vessel', async (t) => { + const { cockpit, viewer, vessels, aircraft } = fixture(t); + cockpit.enter(); + await settle(); + viewer.trackedEntity = aircraft; + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(viewer.trackedEntity, aircraft, 'the aircraft keeps tracking'); + assert.equal(aircraft.show, true, 'the aircraft was never adopted'); + assert.deepEqual(vessels.calls, [ + ['subject', RECORD.mmsi], + ['subject', null], + ]); +}); + +test('a vessel that leaves the selection ends the bridge', async (t) => { + const { cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + vessels.deselect(); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(vessels.calls.at(-1), ['subject', null]); +}); + +test('turning off Live AIS Vessels ends the bridge', async (t) => { + const { cockpit, vessels } = fixture(t); + cockpit.enter(); + await settle(); + assert.equal(cockpit.active, true); + vessels.disable(); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(vessels.calls.at(-1), ['subject', null]); +}); + +test("turning off the ridden aircraft's layer ends the cockpit", async (t) => { + const aircraftInfo = { + icao24: 'abc123', + callsign: 'TEST1', + latitude: 54.5, + longitude: 10.2, + altitudeM: 3000, + velocityMps: 200, + track: 45, + onGround: false, + }; + const { cockpit, viewer, aircraft, disableFlights } = fixture(t, { + aircraftInfo, + }); + viewer.trackedEntity = aircraft; + assert.equal(cockpit.enter({ kind: 'aircraft' }), true); + await settle(); + cockpit.update(); + assert.equal(cockpit.active, true); + disableFlights(); + cockpit.update(); + assert.equal(cockpit.active, false); +}); + +test('C enters the selected vessel bridge and aircraft-only entry refuses it', async (t) => { + const { cockpit } = fixture(t); + assert.equal(cockpit.enter({ kind: 'aircraft' }), false); + const event = { + key: 'c', + target: document.body, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopImmediatePropagation() {}, + }; + cockpit.onKeyDown(event); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'vessel'); + assert.equal(event.defaultPrevented, true); +}); + +test('a slow-frame bridge keeps pace with its ship', async (t) => { + // One rendered frame a second, as software rendering manages. The bridge + // must still cover the ship's distance, not a capped fraction of it. + const startMs = 1_800_000_000_000; + const clock = manualClock(t, startMs); + const record = { ...RECORD, lastPositionEpoch: startMs / 1000 }; + const { cockpit, views } = fixture(t, { record }); + cockpit.enter(); + await settle(); + for (let frame = 0; frame <= 20; frame += 1) { + cockpit.update(); + clock.advance(1000); + } + assert.ok( + views.length >= 20, + `expected a view per frame, got ${views.length}`, + ); + const travelledM = Cesium.Cartesian3.distance(views[0], views.at(-1)); + const expectedM = (views.length - 1) * RECORD.speed * 0.514444; + assert.ok( + Math.abs(travelledM - expectedM) < expectedM * 0.05, + `travelled ${travelledM.toFixed(1)} m, expected ~${expectedM.toFixed(1)} m`, + ); +}); + +/** Press a key through the Cockpit's keydown handler. */ +function press( + cockpit, + key, + { shiftKey = false, repeat = false, target = document.body } = {}, +) { + const event = { + key, + shiftKey, + repeat, + target, + defaultPrevented: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopImmediatePropagation() {}, + }; + cockpit.onKeyDown(event); + return event; +} + +/** A placed camera's view heading and pitch in its local east-north-up frame. */ +function viewAngles({ destination, direction }) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(destination); + const toLocal = Cesium.Matrix4.inverseTransformation( + enu, + new Cesium.Matrix4(), + ); + const local = Cesium.Matrix4.multiplyByPointAsVector( + toLocal, + direction, + new Cesium.Cartesian3(), + ); + const headingDeg = + ((Cesium.Math.toDegrees(Math.atan2(local.x, local.y)) % 360) + 360) % 360; + const pitchDeg = Cesium.Math.toDegrees( + Math.asin(Math.max(-1, Math.min(1, local.z))), + ); + return { headingDeg, pitchDeg }; +} + +/** Run `count` camera updates 50 ms apart; returns the last view. */ +function frames(cockpit, clock, shots, count = 20) { + for (let frame = 0; frame < count; frame += 1) { + clock.advance(50); + cockpit.update(); + } + return viewAngles(shots.at(-1)); +} + +const near = (actual, expected, label, tolerance = 0.2) => + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${label}: ${actual.toFixed(3)} not within ${tolerance} of ${expected}`, + ); + +const START_MS = 1_800_000_000_000; + +async function bridgeAt(t, clock) { + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const scene = fixture(t, { record }); + scene.cockpit.enter(); + await settle(); + scene.cockpit.update(); + return scene; +} + +test('arrows turn the bridge view without moving the eye off its path', async (t) => { + const clock = manualClock(t, START_MS); + // A bridge left alone, for the eye's path. + const still = await bridgeAt(t, clock); + frames(still.cockpit, clock, still.shots, 30); + const stillPath = still.shots.map((shot) => shot.destination); + still.cockpit.stop(); + + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + let view = viewAngles(shots.at(-1)); + near(view.headingDeg, RECORD.heading, 'starts over the bow'); + near(view.pitchDeg, -2, 'at the rest pitch'); + assert.equal(store.instruments.look.offset, false); + + const event = press(cockpit, 'ArrowRight', { shiftKey: true }); + assert.equal(event.defaultPrevented, true); + press(cockpit, 'ArrowRight', { shiftKey: true }); + // Partway through the ease the view is between the bow and the beam. + clock.advance(50); + cockpit.update(); + const midway = viewAngles(shots.at(-1)).headingDeg; + assert.ok(midway > 100 && midway < 175, `midway heading ${midway}`); + view = frames(cockpit, clock, shots, 9); + near(view.headingDeg, RECORD.heading + 90, 'starboard beam'); + press(cockpit, 'ArrowUp'); + press(cockpit, 'ArrowUp'); + press(cockpit, 'ArrowUp'); + view = frames(cockpit, clock, shots, 20); + near(view.pitchDeg, 10, 'pitched up'); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 90, pitchDeg: 10 }); + + // The eye followed exactly the path of the bridge left alone. + assert.equal(shots.length, stillPath.length); + shots.forEach((shot, index) => + assert.ok( + Cesium.Cartesian3.distance(shot.destination, stillPath[index]) < 1e-6, + `frame ${index} moved the eye`, + ), + ); + + await settle(); + assert.equal(store.instruments.look.offset, true); + assert.equal(store.instruments.look.yawDeg, 90); + assert.equal($('.cockpit-look').hidden, false); + assert.equal( + $('#cockpit-look-readout').textContent, + 'LOOK STBD 090° · UP 10°', + ); + assert.ok( + $('.cockpit-radar-look'), + 'the radar draws the look line while the view is turned', + ); + assert.match($('.cockpit-help').textContent, /ARROWS LOOK · HOME RESET/); + // The heading readout keeps the ship's heading. + assert.equal(store.instruments.heading.text, '090'); +}); + +test('the bridge view pitches no further than straight up or down', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store } = await bridgeAt(t, clock); + for (let index = 0; index < 4; index += 1) + press(cockpit, 'ArrowUp', { shiftKey: true }); + assert.equal(cockpit.lookTarget.pitchDeg, 90); + let view = frames(cockpit, clock, shots); + near(view.pitchDeg, 90, 'straight up', 0.5); + const up = viewAngles({ + destination: shots.at(-1).destination, + direction: shots.at(-1).up, + }); + near(up.headingDeg, (RECORD.heading + 180) % 360, 'up stays valid', 1); + + for (let index = 0; index < 5; index += 1) + press(cockpit, 'ArrowDown', { shiftKey: true }); + assert.equal(cockpit.lookTarget.pitchDeg, -90); + view = frames(cockpit, clock, shots); + near(view.pitchDeg, -90, 'straight down', 0.5); + await settle(); + assert.equal(store.instruments.look.readout, 'LOOK DOWN 90°'); +}); + +test('the heading tape follows the bridge look; bow and stern marks stay on the ship', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + const tape = async () => { + frames(cockpit, clock, shots, 40); + await settle(); + const { compass, compassMarkers, heading } = store.instruments; + return { + centre: compass.find((division) => division.active).label, + heading: heading.value, + ...compassMarkers, + }; + }; + // Over the bow: the tape centres on the ship's heading, east. + let reading = await tape(); + assert.equal(reading.centre, 'E'); + assert.equal(reading.prograde, 0); + assert.equal(reading.retrograde, null, 'the stern is off the tape'); + assert.equal($('.cockpit-compass-retrograde'), null); + const bow = $('.cockpit-compass-prograde'); + assert.equal(bow.getAttribute('aria-hidden'), 'true'); + assert.equal(bow.style.getPropertyValue('--slot'), '0'); + // Turned to port onto north: the tape follows the camera, the HDG readout + // keeps the ship's heading and both marks sit at their relative bearings. + press(cockpit, 'ArrowLeft', { shiftKey: true }); + press(cockpit, 'ArrowLeft', { shiftKey: true }); + reading = await tape(); + assert.equal(reading.centre, 'N'); + near(reading.heading, RECORD.heading, 'the HDG readout', 1e-6); + near(reading.prograde, 3, 'the bow, 90° to starboard', 1e-6); + near(reading.retrograde, -3, 'the stern, 90° to port', 1e-6); + assert.equal( + $('.cockpit-compass-retrograde').getAttribute('aria-hidden'), + 'true', + ); + // Further round, onto 315°: the bow leaves the tape, the stern comes + // under W, 45° to port. + press(cockpit, 'ArrowLeft', { shiftKey: true }); + reading = await tape(); + assert.equal(reading.prograde, null); + near(reading.retrograde, -1.5, 'the stern, under W', 1e-6); + const west = store.instruments.compass.find(({ label }) => label === 'W'); + near(west.slot, reading.retrograde, 'W over the stern mark', 1e-6); + assert.equal($('.cockpit-compass-prograde'), null); + assert.equal( + $('.cockpit-compass-retrograde').style.getPropertyValue('--slot'), + String(reading.retrograde), + ); +}); + +test('a held arrow keeps turning the bridge view', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit } = await bridgeAt(t, clock); + press(cockpit, 'ArrowLeft'); + for (let index = 0; index < 5; index += 1) { + const event = press(cockpit, 'ArrowLeft', { repeat: true }); + assert.equal(event.defaultPrevented, true); + } + assert.equal(cockpit.lookTarget.yawDeg, -30); + // A held Home or C still acts only once. + press(cockpit, 'Home', { repeat: true }); + assert.equal(cockpit.lookTarget.yawDeg, -30); + press(cockpit, 'c', { repeat: true }); + assert.equal(cockpit.active, true); +}); + +test('Home, 0 and LOOK AHEAD bring the bridge view back over the bow', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store, $ } = await bridgeAt(t, clock); + const turnAway = () => { + press(cockpit, 'ArrowLeft', { shiftKey: true }); + press(cockpit, 'ArrowDown'); + frames(cockpit, clock, shots); + }; + const resets = [ + () => press(cockpit, 'Home'), + () => press(cockpit, '0'), + () => $('#cockpit-look-ahead').click(), + ]; + for (const reset of resets) { + turnAway(); + await settle(); + assert.equal($('.cockpit-look').hidden, false); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead: turn the bridge view back to the bow', + ); + reset(); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + // It eases back rather than snapping. + clock.advance(50); + cockpit.update(); + const easing = viewAngles(shots.at(-1)).headingDeg; + assert.ok(easing > 46 && easing < 89, `easing heading ${easing}`); + const view = frames(cockpit, clock, shots); + near(view.headingDeg, RECORD.heading, 'back over the bow'); + near(view.pitchDeg, -2, 'back at the rest pitch'); + await settle(); + assert.equal(store.instruments.look.offset, false); + assert.equal($('.cockpit-look').hidden, true); + assert.equal($('.cockpit-radar-look'), null); + } +}); + +test('arrows keep their meaning in sliders, fields and open utilities', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit } = await bridgeAt(t, clock); + const slider = document.body.appendChild(document.createElement('div')); + slider.setAttribute('role', 'slider'); + const thumb = slider.appendChild(document.createElement('span')); + const field = document.body.appendChild(document.createElement('input')); + for (const target of [slider, thumb, field]) { + for (const key of ['ArrowLeft', 'ArrowUp', 'Home', '0']) { + const event = press(cockpit, key, { target }); + assert.equal( + event.defaultPrevented, + false, + `${key} in ${target.tagName}`, + ); + } + } + cockpit.isUtilityDisclosureOpen = () => true; + assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + + // A plain HUD button, where focus sits after entry, still turns the view. + cockpit.isUtilityDisclosureOpen = () => false; + const exit = document.getElementById('map-view-switch'); + const event = press(cockpit, 'ArrowRight', { target: exit }); + assert.equal(event.defaultPrevented, true); + assert.equal(cockpit.lookTarget.yawDeg, 5); +}); + +test('the bridge view opens over the bow on every entry', async (t) => { + const clock = manualClock(t, START_MS); + const { cockpit, shots, store } = await bridgeAt(t, clock); + press(cockpit, 'ArrowRight', { shiftKey: true }); + press(cockpit, 'ArrowUp'); + frames(cockpit, clock, shots, 5); + cockpit.exit(); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + assert.deepEqual(cockpit.lookView, { yawDeg: 0, pitchDeg: -2 }); + assert.equal(store.instruments.look.offset, false); + assert.equal(press(cockpit, 'ArrowRight').defaultPrevented, false); + + cockpit.enter(); + await settle(); + clock.advance(50); + cockpit.update(); + const view = viewAngles(shots.at(-1)); + near(view.headingDeg, RECORD.heading, 're-entry looks over the bow'); + near(view.pitchDeg, -2, 're-entry at the rest pitch'); +}); + +test('arrows do nothing new in an aircraft cockpit', async (t) => { + const clock = manualClock(t, START_MS); + const aircraftInfo = { + icao24: 'abc123', + callsign: 'TEST1', + latitude: 54.5, + longitude: 10.2, + altitudeM: 3000, + velocityMps: 200, + track: 45, + onGround: false, + }; + const { cockpit, viewer, aircraft, shots, store } = fixture(t, { + aircraftInfo, + }); + viewer.trackedEntity = aircraft; + assert.equal(cockpit.enter({ kind: 'aircraft' }), true); + await settle(); + assert.equal(cockpit.subjectKind, 'aircraft'); + const before = frames(cockpit, clock, shots, 3); + near(before.headingDeg, 45, 'the aircraft looks along its track'); + const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', '0']; + for (const key of keys) { + for (const shiftKey of [false, true]) { + const event = press(cockpit, key, { shiftKey }); + assert.equal(event.defaultPrevented, false, `${key} in an aircraft`); + } + } + const held = press(cockpit, 'ArrowLeft', { repeat: true }); + assert.equal(held.defaultPrevented, false); + const after = frames(cockpit, clock, shots, 3); + // The aircraft flies on, so its local frame shifts by a hair. + near(after.headingDeg, before.headingDeg, 'the aircraft heading', 1e-6); + near(after.pitchDeg, before.pitchDeg, 'the aircraft pitch', 1e-6); + assert.deepEqual(cockpit.lookTarget, { yawDeg: 0, pitchDeg: -2 }); + assert.equal(store.instruments.look.offset, false); + assert.equal(document.querySelector('.cockpit-look'), null); + // The tape stays on the aircraft's heading, with no bow or stern marks. + const centre = store.instruments.compass.find( + (division) => division.slot === 0, + ); + assert.equal(centre.label, '060'); + assert.deepEqual(store.instruments.compassMarkers, { + prograde: null, + retrograde: null, + }); + assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.doesNotMatch( + document.querySelector('.cockpit-help').textContent, + /ARROWS LOOK/, + ); +}); + +/** The eye's ellipsoidal height in a placed camera, metres. */ +const eyeHeightM = (shot) => + Cesium.Cartographic.fromCartesian(shot.destination).height; + +test('a bridge on a river above sea level rides the water, not the geoid', async (t) => { + const clock = manualClock(t, START_MS); + // Sea level, as every ship is floated today. + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const river = fixture(t, { record }); + const probed = []; + // The ship's own model reads 40 m; the ring finds the river at 27 m and + // a quay at 33 m. + river.viewer.scene.sampleHeightMostDetailed = async (positions) => { + probed.push(positions.length); + return positions.map((position, index) => + Object.assign(position, { + height: index === 0 ? 40 : index % 2 ? 27 : 33, + }), + ); + }; + river.cockpit.enter(); + await settle(); + river.cockpit.update(); + await settle(); + frames(river.cockpit, clock, river.shots, 60); + near( + eyeHeightM(river.shots.at(-1)) - seaEyeM, + 27, + 'raised by the river', + 0.5, + ); + assert.equal(probed[0], 9, 'the ship and eight probes around it'); + const reads = probed.length; + frames(river.cockpit, clock, river.shots, 20); + await settle(); + assert.equal(probed.length, reads, 'no new read inside five seconds'); + frames(river.cockpit, clock, river.shots, 80); + await settle(); + assert.ok(probed.length > reads, 'a fresh read every five seconds'); +}); + +test('a bridge at sea keeps the geoid when the probes find no raised water', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const open = fixture(t, { record }); + // Swell and tiles a metre off the geoid, and a probe that found nothing. + open.viewer.scene.sampleHeightMostDetailed = async (positions) => + positions.map((position, index) => + Object.assign(position, { height: index === 3 ? undefined : 1 }), + ); + open.cockpit.enter(); + await settle(); + open.cockpit.update(); + await settle(); + frames(open.cockpit, clock, open.shots, 60); + near(eyeHeightM(open.shots.at(-1)), seaEyeM, 'still on the geoid', 0.5); +}); + +test('a bridge rides the river at once from the tiles already loaded', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const river = fixture(t, { record }); + // The full-detail read is still loading tiles; the loaded ones already + // show the river at 27 m beside a quay at 33 m. + river.viewer.scene.sampleHeightMostDetailed = () => new Promise(() => {}); + let index = 0; + river.viewer.scene.sampleHeight = () => (index++ % 2 ? 27 : 33); + river.cockpit.enter(); + await settle(); + river.cockpit.update(); + frames(river.cockpit, clock, river.shots, 60); + near( + eyeHeightM(river.shots.at(-1)) - seaEyeM, + 27, + 'on the river before the detailed read', + 0.5, + ); +}); + +/** + * A scene that draws a world point at (400, 300 − its height in metres): a + * mark's height above the ellipsoid moves it on screen. + */ +function heightSensitiveProjection(viewer, { x = 400 } = {}) { + viewer.scene.cartesianToCanvasCoordinates = (position, result) => { + const { height } = Cesium.Cartographic.fromCartesian(position); + return Object.assign(result, { x, y: 300 - height }); + }; +} + +test('a vessel double-click aims at the sprite as drawn, on the geoid', async (t) => { + const { cockpit, viewer, store } = fixture(t); + // The Baltic geoid sits ~35 m above the ellipsoid; the sprite 3 m above it. + const subject = cockpit.services.aisLiveVesselsLayer.getCockpitSubject; + cockpit.services.aisLiveVesselsLayer.getCockpitSubject = () => ({ + ...subject(), + seaSurfaceHeightM: 35, + markHeightM: 38, + }); + heightSensitiveProjection(viewer); + // Where an ellipsoid-height projection would put it: no hit. + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 262 }), true); + await settle(); + assert.equal(store.presence.active, true); +}); + +test('a vessel double-click lands even when the first click moved the ship', async (t) => { + const { cockpit, viewer, store } = fixture(t); + heightSensitiveProjection(viewer); + // The first press finds the ship at (400, 300); selecting it again + // re-centres the camera, so by the double-click it is drawn at (600, 300). + cockpit.onScenePointerDown(1000); + heightSensitiveProjection(viewer, { x: 600 }); + // Too late to count as one gesture. + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1800), false); + cockpit.onScenePointerDown(2000); + heightSensitiveProjection(viewer, { x: 800 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 600, y: 300 }, 2300), true); + await settle(); + assert.equal(store.presence.active, true); +}); + +test('re-entering a ship reads its water afresh, never the last session height', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + const scene = ship.viewer.scene; + // First visit: on a river 27 m up. + scene.sampleHeightMostDetailed = async (positions) => + positions.map((position) => Object.assign(position, { height: 27 })); + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)) - seaEyeM, 27, 'on the river', 0.5); + ship.cockpit.exit(); + await settle(); + // Back on the same ship, now at sea; the detailed read is slow, the + // loaded tiles already show the sea. + let late; + scene.sampleHeightMostDetailed = () => + new Promise((resolve) => { + late = resolve; + }); + scene.sampleHeight = () => 0.5; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'at sea, not on the river', 0.5); + // A read from before the exit, landing now, changes nothing. + ship.cockpit.exit(); + ship.cockpit.enter(); + await settle(); + late?.([{ height: 27 }]); + await settle(); + ship.cockpit.update(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'the late read is dropped', 0.5); +}); + +test('a failed water read keeps the sea and tries again', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + let reads = 0; + ship.viewer.scene.sampleHeightMostDetailed = async () => { + reads += 1; + throw new Error('tiles unavailable'); + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + frames(ship.cockpit, clock, ship.shots, 60); + near(eyeHeightM(ship.shots.at(-1)), seaEyeM, 'still on the sea', 0.5); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + assert.ok(reads >= 2, `retried: ${reads} reads`); +}); + +test('the water is read where the ship is now, not at its last report', async (t) => { + const clock = manualClock(t, START_MS); + // Reported a minute ago at 12 kn on course 095: since then ~370 m east. + const record = { ...RECORD, lastPositionEpoch: (START_MS - 60_000) / 1000 }; + const ship = fixture(t, { record }); + const centres = []; + ship.viewer.scene.sampleHeightMostDetailed = async (positions) => { + centres.push(Cesium.Math.toDegrees(positions[0].longitude)); + return positions; + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + assert.ok(centres.length > 0, 'a read was made'); + const eastM = + (centres[0] - RECORD.lon) * + 111_320 * + Math.cos((RECORD.lat * Math.PI) / 180); + assert.ok(eastM > 300 && eastM < 450, `probed ${eastM.toFixed(0)} m east`); +}); + +test('the radar look line turns the short way across astern', async (t) => { + const clock = manualClock(t, START_MS); + const { $, store } = await bridgeAt(t, clock); + const lookAt = async (yawDeg) => { + store.publishInstruments({ + ...store.instruments, + look: { ...store.instruments.look, offset: true, yawDeg }, + }); + await settle(); + return $('.cockpit-radar-look')?.getAttribute('style') ?? ''; + }; + assert.match(await lookAt(170), /rotate\(170deg\)/); + // From 170° to -170° is 20° through astern, not 340° back over the bow. + assert.match(await lookAt(-170), /rotate\(190deg\)/); +}); + +test('a ship with no true heading is held, and labelled, on its course', async (t) => { + const clock = manualClock(t, START_MS); + clock.set(START_MS); + const record = { + ...RECORD, + heading: null, + lastPositionEpoch: START_MS / 1000, + }; + const { $, cockpit } = fixture(t, { record }); + const signals = []; + cockpit.pushCockpitSignal = (...args) => signals.push(args); + cockpit.enter(); + await settle(); + cockpit.update(); + await settle(); + assert.match( + $('.cockpit-visor-status-right').textContent, + /COURSE HOLD · AIS/, + ); + assert.ok( + signals.some(([, , , detail]) => /COURSE 095°/.test(detail)), + JSON.stringify(signals), + ); +}); + +test('both entries name the subject the same way', async () => { + const { cockpitEntryFace } = await import('./cockpitPresentation.js'); + assert.deepEqual(cockpitEntryFace('vessel'), { + icon: 'directions_boat', + label: 'BRIDGE', + }); + assert.deepEqual(cockpitEntryFace('aircraft'), { + icon: 'flight', + label: 'COCKPIT', + }); + assert.deepEqual(cockpitEntryFace(null), cockpitEntryFace('aircraft')); +}); + +test('a ship with neither heading nor course makes up no direction', async (t) => { + const clock = manualClock(t, START_MS); + const record = { + ...RECORD, + heading: null, + course: null, + lastPositionEpoch: START_MS / 1000, + }; + const { $, cockpit, viewer, shots, store } = fixture(t, { record }); + // The map camera looks south-south-west as the bridge opens. + viewer.camera.heading = Cesium.Math.toRadians(200); + const signals = []; + cockpit.pushCockpitSignal = (...args) => signals.push(args); + cockpit.enter(); + await settle(); + cockpit.update(); + const start = shots.at(-1).destination; + // Ten seconds at 12 kn would carry a made-up course some 60 m. + const view = frames(cockpit, clock, shots, 200); + await settle(); + near(view.headingDeg, 200, 'keeps the entry camera heading'); + const driftM = Cesium.Cartesian3.distance(start, shots.at(-1).destination); + assert.ok(driftM < 1, `the eye stays on the fix, drifted ${driftM} m`); + assert.equal(store.instruments.heading.text, '---'); + assert.deepEqual(store.instruments.compassMarkers, { + prograde: null, + retrograde: null, + }); + assert.equal(document.querySelector('.cockpit-compass-marker'), null); + assert.match( + $('.cockpit-visor-status-right').textContent, + /NO HEADING · AIS/, + ); + const details = signals.map(([, , , detail]) => detail); + assert.ok(details.includes('NORDIC STAR · NO HEADING'), details.join(' | ')); + assert.ok(!details.some((detail) => /000°/.test(detail)), details.join()); +}); + +test('the bridge tape reads the exact heading at the caret', async (t) => { + const clock = manualClock(t, START_MS); + const record = { + ...RECORD, + heading: 100, + lastPositionEpoch: START_MS / 1000, + }; + const { cockpit, shots, store } = fixture(t, { record }); + cockpit.enter(); + await settle(); + frames(cockpit, clock, shots, 40); + await settle(); + const { compass, compassMarkers } = store.instruments; + // Over the bow at 100°: the bow mark sits under the caret, and the tape + // has scrolled a third of a division past 090. + near(compassMarkers.prograde, 0, 'the bow under the caret', 1e-3); + const nearest = compass.find((division) => division.active); + assert.equal(nearest.label, 'E'); + near(nearest.slot, -1 / 3, 'E a third of a division to port', 1e-3); + const tape = document.querySelector('#cockpit-compass-tape'); + near( + Number(tape.style.getPropertyValue('--tape-shift')), + -1 / 3, + 'the minor ticks scroll with it', + 1e-3, + ); + const east = [...tape.querySelectorAll('span')].find( + (span) => span.textContent === 'E', + ); + assert.equal(east.className, 'active'); + near(Number(east.style.getPropertyValue('--slot')), -1 / 3, 'E drawn', 1e-3); +}); + +test('a slow water read is never stacked, and lands however late', async (t) => { + const clock = manualClock(t, START_MS); + const sea = await bridgeAt(t, clock); + frames(sea.cockpit, clock, sea.shots, 60); + const seaEyeM = eyeHeightM(sea.shots.at(-1)); + sea.cockpit.exit(); + + clock.set(START_MS); + const record = { ...RECORD, lastPositionEpoch: START_MS / 1000 }; + const ship = fixture(t, { record }); + const reads = []; + // Every full-detail read hangs until the test answers it. + ship.viewer.scene.sampleHeightMostDetailed = (positions) => + new Promise((resolve) => reads.push({ positions, resolve })); + let loadedReads = 0; + ship.viewer.scene.sampleHeight = () => { + loadedReads += 1; + return 0; + }; + ship.cockpit.enter(); + await settle(); + ship.cockpit.update(); + await settle(); + assert.equal(reads.length, 1); + const loadedAtStart = loadedReads; + // Twelve seconds on, still one read in flight; the refreshes read the + // loaded tiles meanwhile. + frames(ship.cockpit, clock, ship.shots, 240); + await settle(); + assert.equal(reads.length, 1, 'no second read while the first runs'); + assert.ok(loadedReads > loadedAtStart, 'the loaded tiles are read again'); + // The 12 s read lands (a river at 27 m) and applies. + const [slow] = reads; + slow.resolve( + slow.positions.map((position) => Object.assign(position, { height: 27 })), + ); + await settle(); + frames(ship.cockpit, clock, ship.shots, 120); + await settle(); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'raised by the late read', + 0.5, + ); + // Settled, the next refresh starts a new read. + assert.equal(reads.length, 2); + // It hangs over tiles not loaded yet: the refreshes sample nothing, and the + // river height stands rather than falling back to the sea. + ship.viewer.scene.sampleHeight = () => undefined; + frames(ship.cockpit, clock, ship.shots, 240); + await settle(); + assert.equal(reads.length, 2); + near( + eyeHeightM(ship.shots.at(-1)) - seaEyeM, + 27, + 'kept through refreshes that sample nothing', + 0.5, + ); +}); + +test('a client clock minutes fast does not age the bridge into a stale feed', async (t) => { + // The feed's newest fix is this ship's; the client clock runs 200 s + // ahead of the feed and received the refresh just now. + const skewMs = 200_000; + const clock = manualClock(t, START_MS + skewMs); + const record = { + ...RECORD, + lastPositionEpoch: START_MS / 1000, + fixFeedEpoch: START_MS / 1000, + fixReceivedAtMs: START_MS + skewMs, + }; + const { $, cockpit, shots } = fixture(t, { record }); + cockpit.enter(); + await settle(); + cockpit.update(); + frames(cockpit, clock, shots, 20); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/); + assert.doesNotMatch($('#cockpit-aircraft-meta').textContent, /STALE/); +}); + +test('a fix the feed keeps re-serving goes on ageing into a stale feed', async (t) => { + const clock = manualClock(t, START_MS); + // The layer's own records, polled every 30 s with the same fix, as a feed + // serving its cache does. + const records = new VesselRecords({ now: () => Date.now() }); + const effects = { + add() {}, + remove() {}, + beforeUpdate() {}, + updated() {}, + staleSelected() {}, + removed() {}, + }; + const poll = () => + records.reconcile( + [ + { + ...RECORD, + last_position_epoch: START_MS / 1000, + }, + ], + { complete: true }, + effects, + ); + poll(); + const record = records.byMmsi.get(RECORD.mmsi); + const { $, cockpit, shots } = fixture(t, { record }); + cockpit.enter(); + await settle(); + cockpit.update(); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /LIVE AIS/); + // Seven polls later (210 s), the fix is past the 180 s projection. + for (let polls = 0; polls < 7; polls += 1) { + frames(cockpit, clock, shots, 600); + poll(); + } + frames(cockpit, clock, shots, 20); + await settle(); + assert.match($('#cockpit-aircraft-meta').textContent, /STALE FEED/); +}); + +test("a double-click's second press keeps the first press's picture of the ship", async (t) => { + const { cockpit, viewer, store } = fixture(t); + heightSensitiveProjection(viewer); + // The first press finds the ship at (400, 300); the first click + // re-centres the camera, so the second press finds it at (600, 300). + cockpit.onScenePointerDown(1000); + heightSensitiveProjection(viewer, { x: 600 }); + cockpit.onScenePointerDown(1200); + // By the double-click it has moved again; the gesture began at (400, 300). + heightSensitiveProjection(viewer, { x: 800 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }, 1250), true); + await settle(); + assert.equal(store.presence.active, true); +}); diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index a5c6d8c5a..b701ddab0 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -156,7 +156,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || info.mmsi || 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 0a2d1f372..50a43510f 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -7,6 +7,15 @@ import { cockpitUiUpdateDue, slewHeading, } from '../cockpitMath.js'; +import { + VESSEL_DEAD_RECKON_MAX_S, + VESSEL_WATERLINE_REFRESH_MS, + deadReckonVesselFix, + vesselCameraProfile, + vesselWaterlineM, + waterlineProbePoints, +} from '../cockpitSubjects.js'; +import { easeLook } from '../cockpitLook.js'; import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, @@ -44,6 +53,10 @@ export function update() { } const nowMs = performance.now(); + if (this.subjectKind === 'vessel') { + updateVessel.call(this, nowMs); + return; + } const info = this.readAircraftInfo(); // Adopt a newly selected track before the camera cadence gate. A context @@ -86,17 +99,208 @@ export function update() { placeCamera.call(this, target, info, nowMs, dtSec, AIRCRAFT_CAMERA_PROFILE); } +/** + * The surface the ridden ship floats on: the layer's geoid sea surface, or + * the water read off the rendered scene when that sits higher (a river or + * lake above sea level). The read runs in the background every + * VESSEL_WATERLINE_REFRESH_MS. The full-detail read can take many seconds + * from cold tiles, so a new ship's first read starts from the tiles already + * on screen; until any read lands the sea surface stands. Only one + * full-detail read runs at a time (each is nine picks Cesium traverses + * every frame until it settles): while one is still running, a refresh + * reads the loaded tiles instead, and the slow read applies whenever it + * lands unless a newer one has. + * @param {{latitude: number, longitude: number}} [at] Where to read the + * water: the dead-reckoned position the camera rides. + * @returns {number} Ellipsoidal metres. + */ +function vesselSurfaceM(info, nowMs, at = info) { + const seaM = Number.isFinite(info.seaSurfaceHeightM) + ? info.seaSurfaceHeightM + : 0; + const scene = this.viewer.scene; + let read = + this.vesselWaterline?.mmsi === info.mmsi ? this.vesselWaterline : null; + if ( + typeof scene?.sampleHeightMostDetailed === 'function' && + (!read || nowMs - read.sampledMs >= VESSEL_WATERLINE_REFRESH_MS) + ) { + const probes = waterlineProbePoints(at.latitude, at.longitude).map( + ({ lat, lon }) => Cesium.Cartographic.fromDegrees(lon, lat), + ); + if (!read) { + read = { + mmsi: info.mmsi, + heightM: loadedWaterlineM(scene, probes, seaM), + sampledMs: nowMs, + // Full-detail reads by number: the last started, the last applied. + startedSeq: 0, + landedSeq: 0, + inFlight: false, + }; + this.vesselWaterline = read; + } else { + read.sampledMs = nowMs; + if (read.inFlight) { + const loadedM = loadedWaterlineM(scene, probes, seaM); + if (Number.isFinite(loadedM)) read.heightM = loadedM; + } + } + if (!read.inFlight) readWaterline.call(this, read, scene, probes, seaM); + } + return read && Number.isFinite(read.heightM) + ? Math.max(seaM, read.heightM) + : seaM; +} + +/** Start one full-detail water read for `read`; its answer lands on it. */ +function readWaterline(read, scene, probes, seaM) { + const seq = ++read.startedSeq; + read.inFlight = true; + Promise.resolve() + .then(() => scene.sampleHeightMostDetailed(probes)) + .then( + (samples) => { + if (this.vesselWaterline !== read) return; + read.inFlight = false; + // However late it lands, it applies unless a newer read has. + if (seq <= read.landedSeq) return; + read.landedSeq = seq; + read.heightM = vesselWaterlineM( + seaM, + (samples || []).map((sample) => sample?.height), + ); + }, + () => { + if (this.vesselWaterline === read) read.inFlight = false; + }, + ); +} + +/** + * The waterline from the tiles already loaded, read at once: a first answer + * while the full-detail read loads. Null when the scene cannot sample or no + * probe met loaded geometry: nothing sampled is no answer, not the sea, so it + * never undoes a river height a full read set. + */ +function loadedWaterlineM(scene, probes, seaM) { + if (typeof scene.sampleHeight !== 'function') return null; + if (scene.sampleHeightSupported === false) return null; + try { + const heights = probes.map((probe) => scene.sampleHeight(probe)); + if (!heights.some(Number.isFinite)) return null; + return vesselWaterlineM(seaM, heights); + } catch { + return null; + } +} + +/** + * Drive the bridge camera from the selected vessel. The vessel layer redraws + * a ship only when its feed refreshes, so the target is the last fix advanced + * along course over ground; the shared anchor smoothing then absorbs the + * correction each new fix brings. + */ +function updateVessel(nowMs) { + // Tracking an aircraft from the bridge clears the vessel selection; end the + // bridge and leave Cesium tracking with that aircraft. + if (this.viewer.trackedEntity?.position) { + this.exit({ restoreTracking: false }); + return; + } + const info = this.readVesselInfo(); + if (!info || info.mmsi !== this.subjectId) { + this.exit({ restoreTracking: false }); + return; + } + if ( + !cockpitUiUpdateDue( + nowMs, + this.lastCameraUpdateMs, + COCKPIT_CAMERA_UPDATE_MS, + ) + ) + return; + this.lastCameraUpdateMs = nowMs; + + const fix = deadReckonVesselFix( + { + latitude: info.latitude, + longitude: info.longitude, + courseDeg: info.motionCourse, + speedMps: info.velocityMps, + fixEpochMs: info.fixEpochMs, + receivedAtMs: info.receivedAtMs, + feedEpochMs: info.feedEpochMs, + }, + Date.now(), + ); + const target = Cesium.Cartesian3.fromDegrees( + fix.longitude, + fix.latitude, + // Read the water where the ship is now, not at its last report. + vesselSurfaceM.call(this, info, nowMs, fix), + Cesium.Ellipsoid.WGS84, + this.scratchTarget, + ); + // The target advances in wall-clock time, so the anchor must too: the + // aircraft path's 0.1 s step cap would leave a slow-frame or hitching bridge + // falling ever further behind its own ship. + const dtSec = Math.min( + VESSEL_DEAD_RECKON_MAX_S, + Math.max(0, (nowMs - this.lastFrameMs) / 1000), + ); + this.lastFrameMs = nowMs; + if (Number.isFinite(info.track)) { + this.heading = slewHeading( + this.heading ?? info.track, + info.track, + COCKPIT_HEADING_SLEW_DPS * dtSec, + ); + } + // Past the projection limit the target stops advancing; hold on it rather + // than let the inertial anchor sail on alone. + const projectionExpired = + Number.isFinite(fix.ageSec) && fix.ageSec > VESSEL_DEAD_RECKON_MAX_S; + // The look eases on its own clock: the anchor's step is capped differently. + this.lookView = easeLook( + this.lookView, + this.lookTarget, + nowMs - (this.lookLastMs || nowMs), + ); + this.lookLastMs = nowMs; + // A ship reporting neither course nor heading has no direction to move + // in: its anchor stays on the fix, as the dead reckoning does. + const ridden = Number.isFinite(info.motionCourse) + ? info + : { ...info, velocityMps: 0 }; + placeCamera.call( + this, + target, + projectionExpired ? { ...ridden, stale: true } : ridden, + nowMs, + dtSec, + vesselCameraProfile(info), + this.lookView, + ); +} + /** * 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. * @param {Cesium.Cartesian3} target Authoritative subject position. - * @param {object} info Subject info. + * @param {object} info Subject info; `motionCourse` (when present) is the + * direction the subject moves, otherwise it moves where it faces. * @param {number} nowMs Frame time. * @param {number} dtSec Seconds since the previous camera update. * @param {{forwardM: number, upM: number, pitchDeg: number, * minClearanceM: number}} profile Eye offsets for the subject kind. + * @param {?{yawDeg: number, pitchDeg: number}} [look] A bridge view turned + * off the bow: yaw relative to the heading and absolute pitch. It turns + * only the view; the eye and its motion keep the heading and course. + * Aircraft pass none and look along the heading at the profile pitch. */ -function placeCamera(target, info, nowMs, dtSec, profile) { +function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; @@ -110,7 +314,15 @@ function placeCamera(target, info, nowMs, dtSec, profile) { // 15/30-second interpolation and per-frame cache without exposing its // sample-boundary corrections to the camera. const headingRad = Cesium.Math.toRadians(this.heading ?? 0); - const pitchRad = Cesium.Math.toRadians(profile.pitchDeg); + const courseRad = Number.isFinite(info.motionCourse) + ? Cesium.Math.toRadians(info.motionCourse) + : headingRad; + const pitchRad = Cesium.Math.toRadians( + look ? look.pitchDeg : profile.pitchDeg, + ); + const viewHeadingRad = look + ? Cesium.Math.toRadians((this.heading ?? 0) + look.yawDeg) + : headingRad; const speedMps = Number.isFinite(info.velocityMps) ? Math.max(0, info.velocityMps) : 0; @@ -126,8 +338,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { Cesium.Ellipsoid.WGS84, this.scratchEnu, ); - this.scratchLocal.x = Math.sin(headingRad); - this.scratchLocal.y = Math.cos(headingRad); + this.scratchLocal.x = Math.sin(courseRad); + this.scratchLocal.y = Math.cos(courseRad); this.scratchLocal.z = 0; Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, @@ -296,8 +508,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { ); Cesium.Cartesian3.normalize(this.scratchHorizontal, this.scratchHorizontal); - this.scratchLocal.x = Math.sin(headingRad) * Math.cos(pitchRad); - this.scratchLocal.y = Math.cos(headingRad) * Math.cos(pitchRad); + this.scratchLocal.x = Math.sin(viewHeadingRad) * Math.cos(pitchRad); + this.scratchLocal.y = Math.cos(viewHeadingRad) * Math.cos(pitchRad); this.scratchLocal.z = Math.sin(pitchRad); Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, @@ -306,8 +518,8 @@ function placeCamera(target, info, nowMs, dtSec, profile) { ); Cesium.Cartesian3.normalize(this.scratchForward, this.scratchForward); - this.scratchLocal.x = -Math.sin(headingRad) * Math.sin(pitchRad); - this.scratchLocal.y = -Math.cos(headingRad) * Math.sin(pitchRad); + this.scratchLocal.x = -Math.sin(viewHeadingRad) * Math.sin(pitchRad); + this.scratchLocal.y = -Math.cos(viewHeadingRad) * Math.sin(pitchRad); this.scratchLocal.z = Math.cos(pitchRad); Cesium.Matrix4.multiplyByPointAsVector( this.scratchEnu, diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index 6010c3ce7..1fd4b7733 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -1,6 +1,7 @@ /** Coordinate Cockpit lifecycle through supplied aircraft, terrain, rendering and briefing operations. */ import { readAircraftInfo, + readVesselInfo, readSubjectLayerId, resolveEntryKind, dispatchCockpitModeChanged, @@ -19,6 +20,8 @@ import { publishRouteView, updateHud, updateRoute, + updateRadar, + presentLook, syncWeatherToggle, setVisionMode, cycleVisionMode, @@ -67,10 +70,21 @@ import { import { onKeyDown, onSceneDoubleClick, + onScenePointerDown, routeSceneDoubleClick, } from './cockpitInput.js'; +import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js'; +import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js'; +import { stepLook } from '../cockpitLook.js'; import * as Cesium from 'cesium'; +/** Whether the user asked for reduced motion: look turns then jump. */ +function prefersReducedMotion() { + return Boolean( + globalThis.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches, + ); +} + export class CockpitViewController { constructor( viewer, @@ -80,6 +94,7 @@ export class CockpitViewController { getInitialVisionMode = null, onCameraTakeover = null, isEntryAllowed = null, + isBridgeEntryAllowed = null, isUtilityDisclosureOpen = null, onEntered = null, onExited = null, @@ -93,6 +108,10 @@ export class CockpitViewController { this.destroyed = false; this.disposed = false; this.active = false; + /** 'aircraft' or 'vessel' while active; which subject the camera rides. */ + this.subjectKind = null; + /** The vessel subject's MMSI; aircraft are identified by trackedEntity. */ + this.subjectId = null; this.trackedEntity = null; this.trackedEntityWasShown = true; this.heading = null; @@ -105,6 +124,14 @@ export class CockpitViewController { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; + /** + * Where the bridge view is turned to (`lookTarget`) and where the camera + * looks on its way there (`lookView`): yaw off the bow, starboard + * positive, and absolute pitch. Aircraft never turn it. + */ + this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + this.lookView = { ...this.lookTarget }; + this.lookLastMs = 0; this.entry = document.getElementById('cockpit-entry'); this.tr3bToggle = document.getElementById('tr3b-toggle'); this._tr3bSignature = null; @@ -130,6 +157,11 @@ export class CockpitViewController { : () => false; this.isEntryAllowed = typeof isEntryAllowed === 'function' ? isEntryAllowed : () => true; + // Without a supplied gate there is no vessel layer to ride either. + this.isBridgeEntryAllowed = + typeof isBridgeEntryAllowed === 'function' + ? isBridgeEntryAllowed + : () => false; this.isUtilityDisclosureOpen = typeof isUtilityDisclosureOpen === 'function' ? isUtilityDisclosureOpen @@ -164,9 +196,15 @@ export class CockpitViewController { this.publishVision = publish.vision ?? (() => {}); this.publishSignals = publish.signals ?? (() => {}); this.publishUtilityLayout = publish.utilityLayout ?? (() => {}); + this.publishRadar = publish.radar ?? (() => {}); + /** The bridge radar's range in nautical miles; kept across bridges. */ + this.radarRangeNm = DEFAULT_RADAR_RANGE_NM; this.presence = { active: false, entryAvailable: false, + // Which subject the entry button would enter, and the active subject. + entryKind: null, + subjectKind: null, tr3b: null, context: false, // The Contact and briefing cards' collapse, which their toggles show. @@ -216,9 +254,15 @@ export class CockpitViewController { ); this._listen(this.entry, 'click', () => this.enter()); // Double-clicking the followed track is a third way in. + this._listen( + viewer.scene?.canvas, + 'pointerdown', + () => this.onScenePointerDown(), + true, + ); this._listenerRemovers.push(routeSceneDoubleClick.call(this)); // The readout's entry renders inside the Intel HUD, which may mount after - // this controller, so its clicks are delegated. + // this controller; delegated like the radar's range button. this._listen(document, 'click', (event) => { if (event.target?.closest?.('#cockpit-entry-readout')) this.enter(); }); @@ -248,6 +292,15 @@ export class CockpitViewController { this._listen(window, 'gev:cockpit-weather-state', (event) => { this.syncWeatherToggle(event?.detail?.enabled !== false); }); + // The radar mounts only on a bridge, so its range button is delegated. + this._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-radar-range')) + this.cycleRadarRange(); + }); + // The look-ahead button shows only on a turned bridge view; delegated. + this._listen(document, 'click', (event) => { + if (event.target?.closest?.('#cockpit-look-ahead')) this.lookAhead(); + }); this._listen(this.signalToggle, 'click', () => this.setSignalCollapsed(!this.signalCollapsed, { user: true }), ); @@ -289,6 +342,9 @@ export class CockpitViewController { readAircraftInfo() { return readAircraftInfo.call(this); } + readVesselInfo() { + return readVesselInfo.call(this); + } readSubjectLayerId() { return readSubjectLayerId.call(this); } @@ -316,8 +372,8 @@ export class CockpitViewController { _adoptTrackedEntity(nowMs, suppliedInfo) { return _adoptTrackedEntity.call(this, nowMs, suppliedInfo); } - enter() { - return enter.call(this); + enter(options) { + return enter.call(this, options); } exit(options) { return exit.call(this, options); @@ -337,6 +393,58 @@ export class CockpitViewController { updateRoute(info) { return updateRoute.call(this, info); } + updateRadar(info) { + return updateRadar.call(this, info); + } + /** Step the bridge radar to its next range and redraw it at once. */ + cycleRadarRange() { + this.radarRangeNm = nextRadarRange(this.radarRangeNm); + if (this.active && this.subjectKind === 'vessel' && this.lastAircraftInfo) + this.updateRadar(this.lastAircraftInfo); + } + presentLook() { + return presentLook.call(this); + } + /** + * Turn the bridge view one arrow step (see `lookKeyStep`). The camera + * eases there unless the user prefers reduced motion. + * @param {{axis: 'yaw'|'pitch', sign: number, stepDeg: number}} step + * @returns {boolean} True when a bridge view was turned. + */ + turnLook(step) { + if (!this.active || this.subjectKind !== 'vessel' || !step) return false; + this.lookTarget = stepLook(this.lookTarget, step); + if (prefersReducedMotion()) this.lookView = { ...this.lookTarget }; + this.presentLook(); + this.publishInstrumentsView(); + return true; + } + /** + * Point the bridge view back over the bow at the rest pitch. + * @param {{immediate?: boolean}} [options] Jump there instead of easing, + * as entry and exit do. + */ + resetLook({ immediate = false } = {}) { + this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + if (immediate || prefersReducedMotion()) + this.lookView = { ...this.lookTarget }; + this.lookLastMs = performance.now(); + this.presentLook(); + this.publishInstrumentsView(); + } + /** + * The LOOK AHEAD control and the Home and 0 keys: ease the bridge view + * back over the bow. Focus leaves the button before it hides. + * @returns {boolean} True on a bridge. + */ + lookAhead() { + if (!this.active || this.subjectKind !== 'vessel') return false; + const button = document.getElementById('cockpit-look-ahead'); + const hadFocus = !!button && button.contains(document.activeElement); + this.resetLook(); + if (hadFocus) this.mapViewButton?.focus({ preventScroll: true }); + return true; + } syncWeatherToggle(enabled) { return syncWeatherToggle.call(this, enabled); } @@ -426,8 +534,11 @@ export class CockpitViewController { onKeyDown(event) { return onKeyDown.call(this, event); } - onSceneDoubleClick(position) { - return onSceneDoubleClick.call(this, position); + onSceneDoubleClick(position, nowMs) { + return onSceneDoubleClick.call(this, position, nowMs); + } + onScenePointerDown(nowMs) { + return onScenePointerDown.call(this, nowMs); } handleSignalClick(event) { diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index 3839aa3af..9a0eb258a 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -2,6 +2,7 @@ import { nextTick } from 'vue'; import { CockpitViewController, CockpitDisplayPortal } from './cockpit.js'; import { COCKPIT_AIRCRAFT_LAYER_IDS, + bridgeEntryAllowed, cockpitEntryAllowed, } from '../contextModePolicy.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; @@ -40,6 +41,7 @@ export class CockpitCoordinator { const { flightsLayer, militaryFlightsLayer, + aisLiveVesselsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, @@ -60,6 +62,7 @@ export class CockpitCoordinator { services: { flightsLayer, militaryFlightsLayer, + aisLiveVesselsLayer, isTr3b, toggleTr3b, militaryAwarenessLayer, @@ -93,6 +96,11 @@ export class CockpitCoordinator { contextModeChanging: this._contextModeChanging, }); }, + isBridgeEntryAllowed: () => + bridgeEntryAllowed({ + vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), + contextModeChanging: this._contextModeChanging, + }), publish: this.publishCockpit, isUtilityDisclosureOpen: () => !!this.readUtilityDisclosureOpen?.(), onEntered: () => this.enterPanels(), @@ -129,15 +137,21 @@ export class CockpitCoordinator { return Boolean(layerId) && !!this._dataManager?.isEnabled(layerId); } /** - * Make the ridden aircraft's layer the user's own while Contacts is on. - * Contacts may have turned that layer on, and it releases the layers it - * owns when it goes off; the Cockpit must outlive Contacts, so riding an - * aircraft adopts its layer as a direct selection does. Contacts then stops - * owning it and the Context restore keeps it (session `userAdded`). - * @param {string|null} layerId The ridden aircraft's layer. + * Make the ridden layer the user's own while Contacts is on. Contacts may + * have turned that layer on (an aircraft layer, or Live AIS Vessels), and + * it releases the layers it owns when it goes off; the Cockpit must outlive + * Contacts, so riding an aircraft or a vessel adopts its layer as a direct + * selection does. Contacts then stops owning it and the Context restore + * keeps it (session `userAdded`). + * @param {string|null} layerId The ridden aircraft's layer, or + * 'ais-live-vessels' on a bridge. */ _adoptRiddenLayer(layerId) { - if (!COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId)) return; + if ( + !COCKPIT_AIRCRAFT_LAYER_IDS.includes(layerId) && + layerId !== 'ais-live-vessels' + ) + return; if (!this._isLayerEnabled('military-awareness')) return; this._dataManager?.adoptLayerVisibility?.(layerId, true, { origin: 'user', @@ -167,6 +181,11 @@ export class CockpitCoordinator { const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); const info = this.cockpitView?.readAircraftInfo?.(); const active = Boolean(this.cockpitView?.active); + // Voice enters aircraft only; a bridge in use is still reported as it is. + const vessel = + active && this.cockpitView?.subjectKind === 'vessel' + ? this.cockpitView.readVesselInfo?.() + : 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 @@ -195,13 +214,22 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: info + subject: vessel ? { - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.mmsi, + 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 834f1866f..d7e8fd226 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -86,6 +86,7 @@ function fixture( services: { flightsLayer: { getTrackedInfo: trackedFrom('flights') }, militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, + aisLiveVesselsLayer: { getCockpitSubject: () => null }, militaryAwarenessLayer: { getContextSnapshot: () => null }, isTr3b: () => false, holdContinuousRender() {}, @@ -120,7 +121,7 @@ function fixture( t.after(() => { coordinator.stop(); app.unmount(); - document.body.classList.remove('cockpit-mode'); + document.body.classList.remove('cockpit-mode', 'cockpit-bridge'); }); /** Turn a layer off and tell the Context owner, as the data manager does. */ const disable = (layerId) => { @@ -157,6 +158,7 @@ test('a civilian aircraft enters with Live Flights alone: no Contacts, no Milita assert.equal(state.entryAllowed, true); assert.equal(state.entryBlockedReason, null); assert.equal(cockpit.enter(), true); + assert.equal(cockpit.subjectKind, 'aircraft'); }); test('a military aircraft enters with Military Flights alone', (t) => { @@ -439,6 +441,22 @@ test('a ride handed to an aircraft on a layer Contacts turned on keeps that laye assert.deepEqual(on(manager), ['flights', 'military']); }); +test('a bridge ride keeps Live AIS Vessels when Contacts had turned it on', async (t) => { + const live = await liveFixture(t, { + tracked: { layerId: 'flights', info: CIVIL }, + }); + const { manager, controls, coordinator, settle } = live; + assert.equal(await controls._selectContextMode('flights'), true); + await settle(); + assert.equal(manager.isEnabled('ais-live-vessels'), true); + // The bridge reports its layer on entry (cockpitBridge.test.mjs). + coordinator._adoptRiddenLayer('ais-live-vessels'); + await controls._selectContextMode(null); + await settle(); + assert.equal(controls.getContextModeState().mode, null); + assert.deepEqual(on(manager), ['ais-live-vessels']); +}); + test('the ridden layer still ends the Cockpit while Contacts is on', async (t) => { const live = await liveFixture(t, { tracked: { layerId: 'flights', info: CIVIL }, @@ -465,3 +483,19 @@ test('without a Cockpit, ending Contacts restores the layers it found', async (t assert.equal(controls.getContextModeState().mode, null); assert.deepEqual(on(manager), [], 'Live Flights goes back off'); }); + +test('the readout entry reads BRIDGE for a selected vessel', async (t) => { + const { $, store } = fixture(t); + store.publishPresence({ + active: false, + entryAvailable: true, + entryKind: 'vessel', + subjectKind: null, + tr3b: null, + }); + await nextTick(); + const readoutEntry = $('#cockpit-entry-readout'); + assert.equal(readoutEntry.hidden, false); + assert.match(readoutEntry.textContent, /directions_boat/); + assert.match(readoutEntry.textContent, /BRIDGE/); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 15b4207e1..a5c1146c2 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -1,5 +1,6 @@ /** Route Cockpit keys while preserving the active disclosure and form-control owner. */ import * as Cesium from 'cesium'; +import { lookKeyStep } from '../cockpitLook.js'; import { isPointerFree } from '../data/inputOwnership.js'; /** How far from the followed track a double-click may land, CSS pixels. */ @@ -22,14 +23,33 @@ export function doubleClickHitsSubject( return Number.isFinite(distance) && distance <= radiusPx; } +/** + * How long the first press of a double-click keeps its picture of the track, + * ms: a click on a selected ship re-centres the camera, so the ship can move + * on screen before the second click lands. + */ +export const DOUBLE_CLICK_MEMORY_MS = 700; + /** Where the track `kind` would ride is drawn now, as a world position. */ function subjectWorldPosition(kind) { - if (kind !== 'aircraft') return null; - return ( - this.viewer.trackedEntity?.position?.getValue?.( - this.viewer.clock?.currentTime, - ) ?? null - ); + if (kind === 'aircraft') { + return ( + this.viewer.trackedEntity?.position?.getValue?.( + this.viewer.clock?.currentTime, + ) ?? null + ); + } + const info = this.readVesselInfo(); + if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) + return null; + // The sprite floats on the geoid, not on the ellipsoid (tens of metres + // apart): project the height it is drawn at. + const heightM = Number.isFinite(info.markHeightM) + ? info.markHeightM + : Number.isFinite(info.seaSurfaceHeightM) + ? info.seaSurfaceHeightM + : 0; + return Cesium.Cartesian3.fromDegrees(info.longitude, info.latitude, heightM); } /** The followed track's canvas position now, or null off screen. */ @@ -44,21 +64,43 @@ function subjectCanvasPosition() { ); } +/** + * Note where the followed track is drawn as a press begins, for a + * double-click whose first click moves it. The second press of the gesture + * keeps the first one's picture: by then the first click may have moved it. + * @param {number} [nowMs] + */ +export function onScenePointerDown(nowMs = performance.now()) { + if (this.active || this.destroyed) return; + const start = this.doubleClickStart; + if (start && nowMs - start.atMs <= DOUBLE_CLICK_MEMORY_MS) return; + const at = subjectCanvasPosition.call(this); + this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs } : null; +} + /** * Enter first person when the followed track itself is double-clicked: the - * same entry, and the same gates, as C and the entry buttons. A + * same entry, and the same gates, as C and the entry buttons. The click may + * land where the track is drawn now or where it was as the gesture began. A * double-click anywhere else, or one a tool owns (drawing ends a shape with * one), is left alone. * @param {{x: number, y: number}|null|undefined} position Canvas position. + * @param {number} [nowMs] * @returns {boolean} True when it landed on the followed track, entered or * refused by the gate: the caller does nothing else with it. */ -export function onSceneDoubleClick(position) { +export function onSceneDoubleClick(position, nowMs = performance.now()) { + const start = this.doubleClickStart; + this.doubleClickStart = null; if (this.active || this.destroyed || !isPointerFree()) return false; if (!Number.isFinite(position?.x) || !Number.isFinite(position?.y)) return false; - if (!doubleClickHitsSubject(position, subjectCanvasPosition.call(this))) - return false; + const hit = + doubleClickHitsSubject(position, subjectCanvasPosition.call(this)) || + (Boolean(start) && + nowMs - start.atMs <= DOUBLE_CLICK_MEMORY_MS && + doubleClickHitsSubject(position, start)); + if (!hit) return false; this.enter(); return true; } @@ -91,12 +133,55 @@ export function routeSceneDoubleClick() { } /** - * The Cockpit's capture-phase keydown: Escape and C. + * Focus inside one of these keeps its arrows (and Home): text entry, sliders, + * tabs, radio groups, lists and menus all move with them. + */ +const LOOK_KEY_OWNERS = [ + 'input', + 'textarea', + 'select', + '[contenteditable]', + '[role=slider]', + '[role=tab]', + '[role=tablist]', + '[role=radio]', + '[role=radiogroup]', + '[role=listbox]', + '[role=menu]', + '[role=menuitem]', + '[role=option]', +].join(', '); + +/** + * Turn the bridge view with the arrow keys (Shift for 45° steps) and bring + * it back over the bow with Home or 0. Only a bridge looks around, and only + * while focus is on the scene or a plain HUD button. + * @returns {boolean} True when the key was taken. + */ +function handleLookKey(event) { + if (!this.active || this.subjectKind !== 'vessel') 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'); + if (!step && !reset) return false; + if (event.target?.closest?.(LOOK_KEY_OWNERS)) return false; + if (this.isUtilityDisclosureOpen?.()) return false; + event.preventDefault(); + if (step) this.turnLook(step); + else this.lookAhead(); + return true; +} + +/** + * The Cockpit's capture-phase keydown: Escape and C, and on a bridge the + * look-around keys. * @param {KeyboardEvent} event */ export function onKeyDown(event) { if (this.destroyed) return false; - if (event.repeat || event.isComposing) return; + if (event.isComposing) return; + // Held arrows turn a bridge view smoothly; every other key acts once. + if (event.repeat && !lookKeyStep(event.key)) return; if (event.key === 'Escape' && this.active) { // The credit lightbox owns Escape while its Close control or links hold // focus. Its target handler closes the overlay and restores attribution @@ -139,19 +224,28 @@ export function onKeyDown(event) { } if (event.target?.closest?.('input, textarea, select, [contenteditable]')) return; + if (handleLookKey.call(this, event)) return; const key = event.key?.toLowerCase(); if (key === 'c' && !event.metaKey && !event.ctrlKey && !event.altKey) { if (!this.active) { const cockpitAttempt = !!( this.readAircraftInfo() && this.viewer.trackedEntity?.position ); - if (!cockpitAttempt) return; + // With no tracked aircraft, C takes the selected vessel's bridge. + const bridgeAttempt = !cockpitAttempt && !!this.readVesselInfo(); + if (!cockpitAttempt && !bridgeAttempt) return; + if (bridgeAttempt) { + event.preventDefault(); + event.stopImmediatePropagation(); + if (this.isBridgeEntryAllowed()) this.enter({ kind: 'vessel' }); + return; + } } event.preventDefault(); event.stopImmediatePropagation(); if (!this.active && !this.isEntryAllowed()) return; if (this.active) this.exit(); - else this.enter(); + else this.enter({ kind: 'aircraft' }); return; } } diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 90b553ea5..0ba28d89a 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,15 +52,28 @@ function createRimState(value) { */ export function createInstrumentState() { return { + // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), meta: { text: t('LIVE TRACK · COURSE ALIGNED'), emergency: false }, speed: null, altitude: null, + // A vessel's course over ground, shown where an aircraft shows altitude. + course: null, heading: null, + // A vessel's `heading` readout is its true heading, or its course over + // ground when it reports none: 'heading' | 'course' | null. + trackSource: null, speedRim: createRimState('---'), altitudeRim: createRimState('-----'), compass: [], + // A bridge tape's bow (prograde) and stern (retrograde) marks, in + // division slots off its centre; null when off the tape or not a bridge. + compassMarkers: { prograde: null, retrograde: null }, + // A bridge view turned off the bow: whether it is, the target yaw off + // the bow (degrees, -180..180, starboard positive) and its worded readout. + look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', }; @@ -73,6 +86,8 @@ export function createInstrumentState() { */ export function createRouteState() { return { + // A vessel's card shows only the free-text AIS destination. + kind: 'aircraft', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index dc9964ff8..1d32a4cd4 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -11,6 +11,8 @@ import { cockpitAltitudeDisplayFt, cockpitUiUpdateDue, compassDivisions, + compassMarkerSlot, + scrollingCompassDivisions, formatAltitudeRulerTick, formatCompassDivision, formatCockpitEmergency, @@ -22,10 +24,23 @@ import { } from '../cockpitMath.js'; import { COCKPIT_CONTEXT_UPDATE_MS, + cockpitUnitCase, compassPointLabel, formatCockpitCoordinate, } from './cockpitPresentation.js'; import { createVisionState } from './cockpitInstrumentState.js'; +import { unitLabel } from './unitLabels.js'; +import { + VESSEL_BRIDGE_PITCH_DEG, + vesselCameraProfile, +} from '../cockpitSubjects.js'; +import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { + RADAR_MAX_CONTACTS, + radarPicture, + radarRangeM, + radarRangeNm, +} from '../cockpitRadar.js'; /** A transponder status name (src/data/aircraftEmergency.js) for the HUD. */ const emergencyLabel = ({ name }) => t(name).toUpperCase(); @@ -68,20 +83,68 @@ export function publishInstrumentsView() { meta: { ...view.meta }, speed: view.speed && { ...view.speed }, altitude: view.altitude && { ...view.altitude }, + course: view.course && { ...view.course }, heading: view.heading && { ...view.heading }, speedRim: rim(view.speedRim), altitudeRim: rim(view.altitudeRim), compass: view.compass.map((division) => ({ ...division })), + compassMarkers: { ...view.compassMarkers }, + look: { ...view.look }, }); } +/** Word a bridge look's readout parts (see `lookReadoutParts`). */ +function wordLook({ side, bearingDeg, elevation, angleDeg }) { + const bearing = formatHeadingReadout(bearingDeg); + const angle = formatNumber(angleDeg); + let direction = ''; + if (side === 'starboard') direction = t('LOOK STBD {bearing}°', { bearing }); + else if (side === 'port') direction = t('LOOK PORT {bearing}°', { bearing }); + else if (side === 'astern') direction = t('LOOK ASTERN'); + let tilt = ''; + if (elevation === 'up') tilt = t('UP {angle}°', { angle }); + else if (elevation === 'down') tilt = t('DOWN {angle}°', { angle }); + else if (elevation === 'level') tilt = t('LEVEL'); + if (direction && tilt) + return t('{direction} · {elevation}', { direction, elevation: tilt }); + if (tilt) return t('LOOK {elevation}', { elevation: tilt }); + return direction; +} + +/** + * Present the bridge look target: whether the view is turned off the bow, + * its yaw for the radar and its worded readout. Outside a bridge the look + * always rests. The caller publishes. + */ +export function presentLook() { + const target = this.lookTarget; + const offset = + this.active && + this.subjectKind === 'vessel' && + lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG); + this.instruments.look = offset + ? { + offset: true, + yawDeg: wrapLookYaw(target.yawDeg), + readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), + } + : { offset: false, yawDeg: 0, readout: '' }; +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { + if (info?.kind === 'vessel') { + updateVesselHud.call(this, info, nowMs, forceContext); + return; + } this.lastAircraftInfo = info; const view = this.instruments; + view.kind = 'aircraft'; + view.trackSource = null; + view.course = null; const heading = normalizeHeading(this.heading ?? info.track ?? 0); view.callsign = info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); @@ -128,6 +191,7 @@ export function updateHud( slot: index - 3, label: compassPointLabel(formatCompassDivision(division)), })); + view.compassMarkers = { prograde: null, retrograde: null }; view.clock = new Date().toISOString().slice(11, 19) + 'Z'; const lat = Number.isFinite(info.latitude) ? formatCockpitCoordinate(info.latitude, 'N', 'S') @@ -175,6 +239,153 @@ export function updateHud( } } +/** + * Present the bridge readouts: speed and course over ground, true heading, + * the vessel type and its assumed eye height. Contact navigation and Live + * Signals stay aircraft-only, so the Contact card is withdrawn. + */ +function updateVesselHud(info, nowMs, forceContext) { + this.lastAircraftInfo = info; + const view = this.instruments; + view.kind = 'vessel'; + view.trackSource = info.trackSource ?? null; + // A ship reporting neither heading nor course has no heading to read out + // and no bow to mark; the tape still follows the camera. + const headingKnown = Number.isFinite(info.track); + const cameraHeading = normalizeHeading(this.heading ?? info.track ?? 0); + const heading = headingKnown ? cameraHeading : null; + view.callsign = info.name; + const speedKt = Number.isFinite(info.speedKt) + ? Math.max(0, info.speedKt) + : null; + view.speed = { + text: formatSpeedRulerTick(speedKt), + value: speedKt, + immediate: forceContext, + }; + view.speedRim.unavailable = speedKt === null; + view.speedRim.value = formatSpeedRulerTick(speedKt); + placeRimTicks( + view.speedRim.ticks, + speedRulerTicks(speedKt, view.speedRim.ticks.length), + (tick) => formatSpeedRulerTick(tick.valueKt), + ); + view.altitude = null; + view.altitudeRim.unavailable = true; + const course = Number.isFinite(info.course) + ? normalizeHeading(info.course) + : null; + view.course = { + text: formatHeadingReadout(course), + value: course, + immediate: forceContext, + }; + view.heading = { + text: formatHeadingReadout(heading), + value: heading, + immediate: forceContext, + }; + // Looking around turns the tape with the camera; the readout above it + // keeps the ship's heading, and the bow and stern marks stay on the ship. + const viewHeading = normalizeHeading( + cameraHeading + (this.lookView?.yawDeg ?? 0), + ); + // The tape scrolls continuously, so the caret reads the view's exact + // heading and the marks sit at their true bearings from it. + view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({ + slot: division.slot, + heading: division.heading, + active: division.nearest, + label: compassPointLabel(formatCompassDivision(division.heading)), + })); + view.compassMarkers = headingKnown + ? { + prograde: compassMarkerSlot(heading, viewHeading), + retrograde: compassMarkerSlot(heading + 180, viewHeading), + } + : { prograde: null, retrograde: null }; + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`; + const eyeHeightM = vesselCameraProfile(info).upM; + view.meta.text = t('{vesselType} · {feedState} · EYE {height}', { + vesselType: info.typeLabel || t('VESSEL'), + feedState: info.stale ? t('STALE FEED') : t('LIVE AIS'), + height: `${formatNumber(Math.round(eyeHeightM))} ${cockpitUnitCase(unitLabel('m'))}`, + }); + view.meta.emergency = false; + view.layer = info.layerId; + // Reworded here too, so a language switch reaches the look readout. + this.presentLook(); + this.publishInstrumentsView(); + updateVesselRoute.call(this, info); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.updateRadar(info); + this.showContextCard(false); + this.pushCockpitSignal( + 'context-status', + 'info', + t('CONTACT NAVIGATION OFF'), + t('THE BRIDGE STAYS WITH THE SELECTED VESSEL'), + ); + } +} + +/** + * Publish the bridge radar: nearby AIS vessels drawn heading-up from the + * camera's current heading, within the chosen range. + * @param {object} info The vessel subject (`readVesselInfo`). + */ +export function updateRadar(info) { + const layer = this.services.aisLiveVesselsLayer; + if (!info || typeof layer?.getRadarContacts !== 'function') { + this.publishRadar(null); + return; + } + const rangeNm = radarRangeNm(this.radarRangeNm); + const contacts = layer.getRadarContacts( + info.latitude, + info.longitude, + radarRangeM(rangeNm), + RADAR_MAX_CONTACTS, + ); + const picture = radarPicture( + { + latitude: info.latitude, + longitude: info.longitude, + headingDeg: normalizeHeading(this.heading ?? info.track ?? 0), + courseDeg: info.course, + }, + contacts, + rangeNm, + ); + this.publishRadar({ + ...picture, + rangeLabel: t('{range} NM', { range: formatNumber(rangeNm) }), + }); +} + +/** Show the AIS voyage destination; it is crew-entered text, not a place. */ +function updateVesselRoute(info) { + const route = this.routeView; + route.kind = 'vessel'; + route.from = ''; + route.to = info.destination || t('UNKNOWN'); + route.status = t('AIS DESTINATION · CREW REPORTED'); + route.visible = Boolean(info.destination); + // No coordinates, so no arrow; clearing also publishes the card. + this.clearPredictiveRoute(); +} + /** Publish the route card and destination arrow when either changed. */ export function publishRouteView() { const signature = JSON.stringify(this.routeView); @@ -188,6 +399,7 @@ export function publishRouteView() { export function updateRoute(info) { const route = this.routeView; + route.kind = 'aircraft'; const origin = info?.route?.origin; const destination = info?.route?.destination; const validDestination = diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index b56a81892..d6088d559 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -146,16 +146,18 @@ export function formatCockpitWindDirection(value) { } /** - * The Cockpit entry for each subject it can ride: an aircraft's cockpit. - * Both entry buttons (Contacts and the Intel HUD readout) show it. + * The Cockpit entry for each subject it can ride: an aircraft's cockpit or a + * vessel's bridge. Both entry buttons (Contacts and + * the Intel HUD readout) show it. */ const COCKPIT_ENTRY_FACES = Object.freeze({ aircraft: Object.freeze({ icon: 'flight', label: msg('COCKPIT') }), + vessel: Object.freeze({ icon: 'directions_boat', label: msg('BRIDGE') }), }); /** * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|null|undefined} kind + * @param {'aircraft'|'vessel'|null|undefined} kind * @returns {{icon: string, label: string}} */ export function cockpitEntryFace(kind) { diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 8675f3908..38a24ef8a 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -6,6 +6,12 @@ import { resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +/** The camera's own heading in degrees, or 0 when it has none. */ +function cameraHeadingDeg(camera) { + const heading = camera?.heading; + return Number.isFinite(heading) ? (heading * 180) / Math.PI : 0; +} + export function readAircraftInfo() { // In cockpit mode the controller takes the entity off `viewer.trackedEntity` // (see update()), so the cockpit's own handle is the tracked identity there. @@ -17,14 +23,20 @@ export function readAircraftInfo() { }); } +/** The selected vessel the bridge view would ride, or rides; null without one. */ +export function readVesselInfo() { + return this.services.aisLiveVesselsLayer?.getCockpitSubject?.() || null; +} + /** - * The layer the active view rides: 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'|null} + * The layer the active view rides: Live AIS Vessels on a bridge, 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'|null} */ export function readSubjectLayerId() { if (!this.active) return null; + if (this.subjectKind === 'vessel') return 'ais-live-vessels'; const layerId = this.readAircraftInfo()?.layerId; if (layerId) return layerId; const [prefix] = String(this.trackedEntity?.gevTrackedId || '').split(':'); @@ -32,23 +44,26 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft. - * @returns {'aircraft'|null} + * The subject entry would take: a Cesium-tracked aircraft first, else the + * selected vessel. Vessels never take Cesium tracking, so an aircraft and a + * vessel cannot be live through the same owner. + * @returns {'aircraft'|'vessel'|null} */ export function resolveEntryKind() { - return this.readAircraftInfo() && this.viewer.trackedEntity?.position - ? 'aircraft' - : null; + if (this.readAircraftInfo() && this.viewer.trackedEntity?.position) + return 'aircraft'; + return this.readVesselInfo() ? 'vessel' : null; } export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active - ? String(info?.icao24 || '') + ? String(info?.icao24 || info?.mmsi || '') .trim() .toLowerCase() || null : null; const layerId = - active && ['flights', 'military'].includes(info?.layerId) + active && + ['flights', 'military', 'ais-live-vessels'].includes(info?.layerId) ? info.layerId : null; window.dispatchEvent( @@ -103,17 +118,29 @@ export function syncEntry() { const info = this.readAircraftInfo(); const trackedContact = !!(info && this.viewer.trackedEntity?.position); this.syncTr3bToggle(trackedContact ? info : null); - const available = !!(this.isEntryAllowed() && trackedContact); + const bridgeContact = !trackedContact && !!this.readVesselInfo(); + let entryKind = null; + if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null; + else if (bridgeContact) + entryKind = this.isBridgeEntryAllowed() ? 'vessel' : 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) return; + if ( + this.presence.entryAvailable === available && + this.presence.entryKind === entryKind + ) + return; this.presence.entryAvailable = available; + this.presence.entryKind = entryKind; this.publishPresenceView(); } export function navigateContext(direction, options = {}) { if (this.destroyed) return false; + // Contact navigation moves between aircraft; a bridge does not jump ship. + if (this.subjectKind === 'vessel') return false; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; if (wasActive) this.contextNavigationDeadlineMs = performance.now() + 1500; @@ -132,6 +159,9 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { + // An aircraft tracked from the bridge ends it (see the vessel camera + // update); it is never adopted as the bridge's subject. + if (this.subjectKind === 'vessel') return false; const nextEntity = this.viewer.trackedEntity; if ( !this.active || @@ -160,13 +190,24 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { return true; } -export function enter() { +/** + * Enter first person on a tracked aircraft or, with none tracked, the + * selected vessel's bridge. + * @param {{kind?: 'aircraft'|'vessel'|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; - if (!this.isEntryAllowed()) return false; - const info = this.readAircraftInfo(); - const entity = this.viewer.trackedEntity; - if (!info || !entity?.position) return false; + const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft'; + const vessel = subjectKind === 'vessel'; + const allowed = vessel ? this.isBridgeEntryAllowed() : this.isEntryAllowed(); + if (!allowed) return false; + const info = vessel ? this.readVesselInfo() : this.readAircraftInfo(); + // Only an aircraft is ridden through its Cesium entity. + const entity = vessel ? null : this.viewer.trackedEntity; + if (!info || (!vessel && !entity?.position)) return false; this.entryFocusOrigin = document.activeElement instanceof HTMLElement ? document.activeElement @@ -174,10 +215,26 @@ export function enter() { // Retire deferred navigation before cancelFlight can run its callbacks. this.onCameraTakeover?.(); this.viewer.camera.cancelFlight(); + this.subjectKind = subjectKind; + // Each session reads its own water: never a height left from the last. + this.vesselWaterline = null; + this.subjectId = vessel ? info.mmsi : null; this.trackedEntity = entity; - this.trackedEntityWasShown = entity.show; - entity.show = false; - this.heading = normalizeHeading(info.track ?? 0); + if (entity) { + this.trackedEntityWasShown = entity.show; + entity.show = false; + } else { + this.services.aisLiveVesselsLayer?.setCockpitSubject?.(info.mmsi); + } + // A ship reporting neither heading nor course keeps the camera's own + // heading: no direction is made up for it. + this.heading = normalizeHeading( + Number.isFinite(info.track) + ? info.track + : vessel + ? cameraHeadingDeg(this.viewer.camera) + : 0, + ); this.lastFrameMs = performance.now(); this.lastCameraUpdateMs = 0; this.lastHudUpdateMs = 0; @@ -189,6 +246,8 @@ export function enter() { this.surfaceFallback = false; this.cockpitAnchorValid = false; this.lastCameraUpdateMs = 0; + // Every bridge opens looking over the bow. + this.resetLook({ immediate: true }); this.active = true; // Cockpit animates the camera from preUpdate every frame — preUpdate only // runs on rendered frames, so idle mode would freeze the cockpit solid. @@ -197,31 +256,58 @@ export function enter() { this.viewer.trackedEntity = undefined; this.viewer.scene.screenSpaceCameraController.enableInputs = false; document.body.classList.add('cockpit-mode'); + // Bridge styling reaches surfaces outside the HUD (exit row, side panels). + if (vessel) document.body.classList.add('cockpit-bridge'); // Activation hides the entry and TR-3B chip; clearing the chip's signature // lets the exit-path syncEntry publish it again. this._tr3bSignature = null; Object.assign(this.presence, { active: true, entryAvailable: false, + entryKind: null, + subjectKind, tr3b: null, }); this.publishPresenceView(); + // Only a bridge has a radar. + if (!vessel) this.publishRadar(null); this.signalItems = []; this.signalSignatures.clear(); this.showBriefPage(0); this.startBriefRotation(); - const trackLabel = - info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); const trackHeading = formatHeadingReadout(info.track ?? 0); - this.pushCockpitSignal( - 'track', - 'track', - t('TRACK ACQUIRED'), - t('{label} · COURSE {heading}°', { - label: trackLabel, - heading: trackHeading, - }), - ); + if (vessel) { + this.pushCockpitSignal( + 'track', + 'track', + t('TRACK ACQUIRED'), + // A ship with no true heading is held on its course over ground; one + // with neither is named without a direction. + info.trackSource === 'course' + ? t('{label} · COURSE {heading}°', { + label: info.name, + heading: trackHeading, + }) + : info.trackSource === 'heading' + ? t('{label} · HEADING {heading}°', { + label: info.name, + heading: trackHeading, + }) + : t('{label} · NO HEADING', { label: info.name }), + ); + } else { + const trackLabel = + info.callsign || info.registration || info.icao24 || t('AIRCRAFT'); + this.pushCockpitSignal( + 'track', + 'track', + t('TRACK ACQUIRED'), + t('{label} · COURSE {heading}°', { + label: trackLabel, + heading: trackHeading, + }), + ); + } this.updateHud(info, performance.now(), true); this.setVisionMode(this.getInitialVisionMode()); this.scheduleContextLayout(); @@ -239,10 +325,15 @@ export function enter() { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null; this.active = false; + this.subjectKind = null; + this.vesselWaterline = null; + this.subjectId = null; this.services.releaseContinuousRender('cockpit'); this.trackedEntity = null; this.heading = null; + this.resetLook({ immediate: true }); this.cockpitAnchorValid = false; this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; @@ -255,22 +346,29 @@ export function exit({ restoreTracking = true } = {}) { this.regionalBriefAbort = null; this.regionalBriefRequestToken += 1; this.regionalBriefSubjectId = null; - document.body.classList.remove('cockpit-mode'); + document.body.classList.remove('cockpit-mode', 'cockpit-bridge'); this.onExited?.(); this.publishUtilityLayout(null); this.presence.active = false; + this.presence.subjectKind = null; this.publishPresenceView(); this.routeView.visible = false; this.clearPredictiveRoute(); + this.publishRadar(null); this.setVisionMode('optical'); this.viewer.scene.screenSpaceCameraController.enableInputs = true; if (entity && this.viewer.entities.contains(entity)) entity.show = this.trackedEntityWasShown; this.trackedEntityWasShown = true; + if (vesselSubject) + this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null); this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; this.restoreTrackingFrame(entity); + } else if (restoreTracking && vesselSubject) { + // Back to the selected vessel's map view, through the layer's own focus. + this.services.aisLiveVesselsLayer?.focusSelectedVessel?.(); } this.syncEntry(); // Back to whichever entry was used: the Contacts button or the readout's. diff --git a/src/ui/stores/cockpit.js b/src/ui/stores/cockpit.js index 03f38f911..2f4bfdb08 100644 --- a/src/ui/stores/cockpit.js +++ b/src/ui/stores/cockpit.js @@ -34,7 +34,13 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { context.value = next && markRaw(next); } - /** The presented instruments; null until the first HUD update. */ + /** + * The presented instruments; null until the first HUD update. Besides the + * readouts, `look: { offset, yawDeg, readout }` says whether a bridge view + * is turned off the bow, the yaw it is turned to (degrees in -180..180, + * starboard positive; the radar draws its look line there) and the worded + * readout. An aircraft's look always rests (`offset: false`). + */ const instruments = shallowRef(null); /** @param {?object} next A fresh presentation of the instruments. */ @@ -66,6 +72,21 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { signals.value = next && markRaw(next); } + /** + * The bridge radar: null outside a vessel bridge. Shape: + * `{ rangeNm, rangeLabel, ringsNm: number[], headingDeg, + * ownRelativeCourseDeg|null, + * contacts: [{ id, name, x, y, distanceNm, relativeBearingDeg, + * relativeCourseDeg|null, color, stale }] }` where x/y are heading-up + * screen fractions in [-1, 1] (x right, y up) of the range circle. + */ + const radar = shallowRef(null); + + /** @param {?object} next A fresh presentation of the bridge radar. */ + function publishRadar(next) { + radar.value = next && markRaw(next); + } + /** * The utility strip's measured layout: its top and height limits as CSS * lengths (null leaves the stylesheet's), whether only the expanded control @@ -101,6 +122,8 @@ export const useCockpitStore = defineStore('gev-cockpit', () => { publishVision, signals, publishSignals, + radar, + publishRadar, utilityLayout, publishUtilityLayout, }; diff --git a/src/ui/styles/cockpit.css b/src/ui/styles/cockpit.css index d7ad845b6..ae842d593 100644 --- a/src/ui/styles/cockpit.css +++ b/src/ui/styles/cockpit.css @@ -283,6 +283,10 @@ body.cockpit-mode:has(#left-panel-stack > .panel-collapsible:not(.collapsed)) color: var(--cockpit-accent); opacity: 0.9; } +/* A bridge has no altitude tape; course over ground replaces the readout. */ +#cockpit-altitude-rim[hidden] { + display: none; +} .cockpit-altitude-rim-label { position: absolute; top: 20%; @@ -2249,3 +2253,453 @@ body.cockpit-mode #intel-hud .hud-right-edge { animation: none; } } + +/* Ship bridge + A vessel's bridge reads as a wheelhouse window rather than an aircraft + visor: straight mullions, a straight speed tape and heading tape, square + panels, and a radar console in the bottom-right corner. Every rule is + scoped to the bridge, so the aircraft cockpit keeps its keyhole. */ +#cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 240px; + --cockpit-radar-label-height: 28px; + --cockpit-radar-right: clamp(24px, 4vw, 58px); + /* Above the position readout, which sits above the language picker. */ + --cockpit-radar-bottom: calc(max(clamp(22px, 4vh, 42px), 3.5rem) + 40px); + /* From the viewport bottom to clear above the console (border + gap). */ + --cockpit-radar-clearance: calc( + var(--cockpit-radar-bottom) + var(--cockpit-radar-size) + + var(--cockpit-radar-label-height) + 14px + ); +} +:root[data-ui-theme='cyber'] #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-right: 30px; +} +:root[data-ui-theme='cyber'] #cockpit-hud[data-subject='vessel']::before { + clip-path: none; +} +:root:not([data-ui-theme='cyber']) body.cockpit-bridge { + --panel-radius: 2px; + --btn-radius: 2px; +} +#cockpit-hud[data-subject='vessel'] .cockpit-bridge-frame { + display: block; + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + color: color-mix(in srgb, var(--cockpit-accent) 32%, transparent); + stroke-width: 1; +} +#cockpit-hud[data-subject='vessel'] .cockpit-bridge-frame path { + vector-effect: non-scaling-stroke; +} +#cockpit-hud[data-subject='vessel'] .cockpit-reticle { + width: 36px; + height: 14px; + border: 0; + border-radius: 0; + color: transparent; + background: + linear-gradient(var(--cockpit-accent), var(--cockpit-accent)) 50% 0 / 1px + 100% no-repeat, + linear-gradient(90deg, var(--cockpit-accent), var(--cockpit-accent)) 0 50% / + 100% 1px no-repeat; + font-size: 0; + box-shadow: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-reticle::before, +#cockpit-hud[data-subject='vessel'] .cockpit-reticle::after { + display: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-horizon-guide { + transform: translate(-50%, -50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-horizon-guide span, +#cockpit-hud[data-subject='vessel'] .cockpit-visor-status { + transform: none; +} + +/* Speed over ground: a straight vertical tape beside the left pane post. */ +#cockpit-hud[data-subject='vessel'] .cockpit-speed-rim { + top: 22vh; + right: auto; + left: calc(27vw + 14px); + width: 150px; + height: 46vh; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-label { + top: 0; + right: auto; + left: 0; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + :is(.cockpit-altitude-rim-rail, .cockpit-altitude-rim-scale) { + inset: 18px 0 0; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-rail { + background: linear-gradient(90deg, rgba(0, 7, 12, 0.3), transparent 70%); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-rail::before { + top: 0; + bottom: 0; + left: 0; + width: 0; + height: auto; + border: 0; + border-left: 2px solid + color-mix(in srgb, var(--cockpit-accent) 72%, transparent); + border-radius: 0; + box-shadow: -3px 0 12px + color-mix(in srgb, var(--cockpit-accent) 18%, transparent); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-scale + > span { + left: 2px; + transform: translateY(-50%); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-pointer { + z-index: 1; + top: calc(50% + 9px); +} +#cockpit-hud[data-subject='vessel'] + .cockpit-speed-rim + .cockpit-altitude-rim-pointer + strong { + min-width: 0; + padding: 2px 5px; + border: 1px solid var(--cockpit-accent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); +} + +/* Heading: a straight horizontal tape under flat readouts. */ +#cockpit-hud[data-subject='vessel'] .cockpit-compass { + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] :is(.cockpit-speed, .cockpit-altitude) { + transform: none; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape { + height: 44px; + margin-top: 0; + overflow: hidden; + /* The minor ticks below scroll by a fraction of the tape's width. */ + container-type: inline-size; + transform-style: flat; + -webkit-mask-image: linear-gradient( + 90deg, + transparent, + #000 12% 88%, + transparent + ); + mask-image: linear-gradient(90deg, transparent, #000 12% 88%, transparent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape::before { + inset: 0 0 auto; + height: 26px; + border-top: 1px solid + color-mix(in srgb, var(--cockpit-accent) 78%, transparent); + border-bottom: 1px solid + color-mix(in srgb, var(--cockpit-accent) 22%, transparent); + border-radius: 0; + /* Minor ticks every 10 degrees, centred on the 30-degree divisions. */ + background: + linear-gradient( + 90deg, + transparent calc(50% - 0.5px), + color-mix(in srgb, var(--cockpit-accent) 46%, transparent) + calc(50% - 0.5px) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ) + 50% 0 / calc(14% / 3) 6px repeat-x, + rgba(0, 7, 12, 0.34); + /* Scrolled with the divisions (--tape-shift slots of 14% each). */ + background-position: + calc(50% + var(--tape-shift, 0) * 14cqw) 0, + 0 0; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span { + top: 0; + left: calc(50% + (var(--slot) * 14%)); + font-size: 11px; + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span::before { + height: 10px; + margin: 0 auto 5px; +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-tape span.active { + font-size: 15px; +} +/* The tape follows the look; the bow (prograde: a ring and centre dot) and + stern (retrograde: a ring and cross) marks sit under it at the ship's + heading and its reciprocal. */ +#cockpit-hud[data-subject='vessel'] .cockpit-compass-marker { + position: absolute; + top: 30px; + left: calc(50% + (var(--slot) * 14%)); + box-sizing: border-box; + width: 11px; + height: 11px; + border: 1.5px solid var(--cockpit-accent); + border-radius: 50%; + transform: translateX(-50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-marker::before, +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + background: var(--cockpit-accent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-prograde::before { + width: 3px; + height: 3px; + transform: translate(-50%, -50%); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::before, +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + width: 1.5px; + height: 9px; + transform: translate(-50%, -50%) rotate(45deg); +} +#cockpit-hud[data-subject='vessel'] .cockpit-compass-retrograde::after { + transform: translate(-50%, -50%) rotate(-45deg); +} + +/* Square panels and buttons. */ +#cockpit-hud[data-subject='vessel'] + :is( + .cockpit-vision-controls, + .cockpit-vision-controls button, + .cockpit-card-actions button, + .cockpit-utility-glyph, + .cockpit-radio-transport button, + .cockpit-route-direction small, + #cockpit-brief-auto + ) { + border-radius: 2px; +} +body.cockpit-bridge #view-switcher button { + border-radius: 2px; +} + +/* The briefing card sits flat and shorter, above the radar console. Its top + leaves the utility strip room below the REC readout. */ +#cockpit-hud[data-subject='vessel'] .cockpit-signal-window { + bottom: var(--cockpit-radar-clearance); + max-height: max( + 44px, + min(30vh, 280px, calc(100vh - var(--cockpit-radar-clearance) - 290px)) + ); + border-radius: 2px; + transform: none; +} +/* Cyber anchors the card's top from the layout pass; bound its height. */ +:root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-signal-window { + bottom: auto; + max-height: max( + 44px, + min( + 30vh, + 280px, + calc( + 100vh - var(--cockpit-signal-top, clamp(255px, 28vh, 300px)) - + var(--cockpit-radar-clearance) + ) + ) + ); + clip-path: none; +} + +/* The radar console: CockpitRadar fills it, label row included. */ +#cockpit-hud[data-subject='vessel'] .cockpit-radar-console { + position: absolute; + z-index: 4; + right: var(--cockpit-radar-right); + bottom: var(--cockpit-radar-bottom); + display: grid; + width: var(--cockpit-radar-size); + height: calc(var(--cockpit-radar-size) + var(--cockpit-radar-label-height)); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 30%, transparent); + border-top-color: color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.68); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.04), + 0 0 28px rgba(0, 0, 0, 0.26); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + pointer-events: auto; +} +#cockpit-hud[data-subject='vessel'] + .cockpit-radar-console:has(> .cockpit-radar[hidden]) { + display: none; +} +:root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-radar-console { + border-color: rgba(105, 203, 209, 0.34); + border-top-color: rgba(196, 84, 84, 0.82); + background: + linear-gradient(135deg, rgba(105, 203, 209, 0.07), transparent 38%), + rgba(8, 23, 27, 0.78); +} + +@media (min-width: 761px) { + /* The heading tape narrows to stay clear of the console. */ + #cockpit-hud[data-subject='vessel'] .cockpit-compass { + width: min( + 760px, + 72vw, + calc( + 100vw - 2 * + (var(--cockpit-radar-right) + var(--cockpit-radar-size) + 24px) + ) + ); + } + :root[data-ui-theme='cyber'] + #cockpit-hud[data-subject='vessel'] + .cockpit-compass { + width: min( + 650px, + 56vw, + calc( + 100vw - 2 * + (var(--cockpit-radar-right) + var(--cockpit-radar-size) + 24px) + ) + ); + } +} +@media (min-width: 761px) and (min-height: 601px) { + /* The console takes the intel HUD's bottom-right corner. */ + body.cockpit-bridge #intel-hud .hud-bottom-right { + display: none; + } +} +@media (max-width: 1280px), (max-height: 820px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 192px; + --cockpit-radar-label-height: 24px; + } +} +@media (max-width: 960px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-size: 152px; + --cockpit-radar-label-height: 22px; + } +} +/* Too small for a console: the briefing card returns to its usual place. */ +@media (max-width: 760px), (max-height: 600px) { + #cockpit-hud[data-subject='vessel'] { + --cockpit-radar-clearance: var(--cockpit-bottom-rail-safe); + } + #cockpit-hud[data-subject='vessel'] .cockpit-radar-console { + display: none; + } +} + +/* Look around: the readout and LOOK AHEAD control sit beside the radar + console, out of the forward view, while the view is turned off the bow; + the radar scope draws the matching look line. */ +#cockpit-hud[data-subject='vessel'] .cockpit-look { + position: absolute; + right: calc(var(--cockpit-radar-right) + var(--cockpit-radar-size) + 14px); + bottom: var(--cockpit-radar-bottom); + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; + opacity: 0.78; +} +#cockpit-hud[data-subject='vessel'] .cockpit-look:focus-within, +#cockpit-hud[data-subject='vessel'] .cockpit-look:hover { + opacity: 1; +} +/* Without the console, the controls take its corner. */ +@media (max-width: 760px), (max-height: 600px) { + #cockpit-hud[data-subject='vessel'] .cockpit-look { + right: var(--cockpit-radar-right); + } +} +#cockpit-hud[data-subject='vessel'] .cockpit-look[hidden] { + display: none; +} +#cockpit-hud[data-subject='vessel'] .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-subject='vessel'] .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-subject='vessel'] .cockpit-look-ahead:hover { + border-color: var(--cockpit-accent); +} +#cockpit-hud[data-subject='vessel'] .cockpit-look-ahead:focus-visible { + outline: 2px solid var(--cockpit-accent); + outline-offset: 2px; +} +/* A narrow wedge from own ship with a line along its centre, eased with the + camera's turn. Both point up (ahead) before rotation. */ +#cockpit-hud[data-subject='vessel'] .cockpit-radar-look { + position: absolute; + inset: 0; + border-radius: 50%; + background: conic-gradient( + from -6deg, + color-mix(in srgb, var(--cockpit-accent) 18%, transparent) 0deg 12deg, + transparent 12deg + ); + pointer-events: none; + transition: transform 180ms ease-out; +} +#cockpit-hud[data-subject='vessel'] .cockpit-radar-look::before { + position: absolute; + bottom: 50%; + left: 50%; + width: 0; + height: 50%; + border-left: 1px dashed + color-mix(in srgb, var(--cockpit-accent) 80%, transparent); + content: ''; +} +@media (prefers-reduced-motion: reduce) { + #cockpit-hud[data-subject='vessel'] .cockpit-radar-look { + transition: none; + } +} diff --git a/src/ui/styles/cockpitRadar.css b/src/ui/styles/cockpitRadar.css new file mode 100644 index 000000000..2377c24ad --- /dev/null +++ b/src/ui/styles/cockpitRadar.css @@ -0,0 +1,193 @@ +/* Bridge radar internals (src/ui/views/panels/CockpitRadar.vue). The + Cockpit view places and sizes the console; the radar fills it, with a + square scope as large as the smaller side allows. */ +.cockpit-radar { + --radar-line: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 34%, + transparent + ); + --radar-faint: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 16%, + transparent + ); + --radar-readout-height: 22px; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + box-sizing: border-box; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + color: rgba(244, 251, 255, 0.82); + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.08em; +} +.cockpit-radar[hidden] { + display: none; +} +.cockpit-radar-scope { + position: relative; + flex: none; + width: min(100cqw, calc(100cqh - var(--radar-readout-height) - 4px)); + aspect-ratio: 1; + overflow: hidden; + border: 1px solid var(--radar-line); + border-radius: 50%; + background: radial-gradient( + circle, + rgba(0, 24, 32, 0.72) 0%, + rgba(0, 9, 14, 0.82) 100% + ); + box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.5); +} +.cockpit-radar-ring, +.cockpit-radar-sweep, +.cockpit-radar-own, +.cockpit-radar-contact { + position: absolute; + top: 50%; + left: 50%; +} +.cockpit-radar-ring { + border: 1px solid var(--radar-faint); + border-radius: 50%; + transform: translate(-50%, -50%); +} +.cockpit-radar-tick { + position: absolute; + inset: 0; +} +.cockpit-radar-tick::before { + position: absolute; + top: 0; + left: 50%; + width: 1px; + height: 5px; + background: var(--radar-line); + content: ''; +} +.cockpit-radar-tick-major::before { + height: 8px; + background: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 62%, + transparent + ); +} +.cockpit-radar-sweep { + width: 100%; + height: 100%; + margin: -50% 0 0 -50%; + border-radius: 50%; + /* The bright edge leads clockwise; the trail fades behind it. */ + background: conic-gradient( + transparent 0deg 270deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 4%, transparent) 300deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 26%, transparent) 357deg, + color-mix(in srgb, var(--cockpit-accent, #00dcff) 70%, transparent) 360deg + ); + will-change: transform; +} +.cockpit-radar-heading-line { + position: absolute; + bottom: 50%; + left: 50%; + width: 1px; + height: 50%; + background: color-mix( + in srgb, + var(--cockpit-accent, #00dcff) 72%, + transparent + ); +} +.cockpit-radar-own { + width: 7px; + height: 7px; + margin: -3.5px 0 0 -3.5px; + border: 1px solid var(--cockpit-accent, #00dcff); + border-radius: 1px; + background: rgba(0, 9, 14, 0.9); +} +.cockpit-radar-contact { + --radar-contact-color: #9fb3c8; + width: 5px; + height: 5px; + margin: -2.5px 0 0 -2.5px; + border-radius: 50%; + background: var(--radar-contact-color); + box-shadow: 0 0 4px var(--radar-contact-color); + will-change: opacity; +} +.cockpit-radar-contact-stale { + border: 1px solid var(--radar-contact-color); + background: transparent; + box-shadow: none; +} +/* A course vector starts at the mark's centre and points along the course. */ +.cockpit-radar-vector { + position: absolute; + bottom: 50%; + left: 50%; + width: 1px; + height: 9px; + margin-left: -0.5px; + background: var(--radar-contact-color); + transform-origin: 50% 100%; +} +.cockpit-radar-own-vector { + height: 14px; + background: #f4fbff; +} +.cockpit-radar-readout { + display: flex; + flex: none; + align-items: center; + justify-content: space-between; + gap: 6px; + box-sizing: border-box; + width: 100%; + height: var(--radar-readout-height); + overflow: hidden; + white-space: nowrap; +} +.cockpit-radar-range { + flex: none; + min-width: 44px; + height: 20px; + padding: 0 6px; + border: 1px solid var(--radar-line); + border-radius: 2px; + background: rgba(0, 7, 12, 0.6); + color: #f4fbff; + font: inherit; + font-weight: 700; + letter-spacing: 0.1em; + cursor: pointer; + pointer-events: auto; +} +.cockpit-radar-range:hover, +.cockpit-radar-range:focus-visible { + border-color: var(--cockpit-accent, #00dcff); + color: #fff; + outline: none; +} +.cockpit-radar-rings, +.cockpit-radar-count { + overflow: hidden; + text-overflow: ellipsis; +} +.cockpit-radar-count { + color: var(--cockpit-accent, #00dcff); +} +@media (prefers-reduced-motion: reduce) { + .cockpit-radar-sweep { + display: none; + } +} diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index 32f2166ee..0ec7e675a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -12,6 +12,7 @@ import { useCockpitStore } from '../stores/cockpit.js'; import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; +import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -23,13 +24,32 @@ import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; const { presence, instruments, route, vision, utilityLayout } = storeToRefs(useCockpitStore()); const active = computed(() => Boolean(presence.value?.active)); +// A vessel subject swaps the aviation readouts for bridge ones. +const bridge = computed(() => presence.value?.subjectKind === 'vessel'); +const subject = computed(() => (bridge.value ? 'vessel' : 'aircraft')); +const hudLabel = computed(() => + bridge.value ? t('Ship bridge view') : t('Aircraft cockpit view'), +); +const exitLabel = computed(() => + bridge.value ? t('Exit bridge view') : t('Exit cockpit view'), +); +const exitText = computed(() => + bridge.value ? t('EXIT BRIDGE') : t('EXIT COCKPIT'), +); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); const initialRoute = createRouteState(); const initialVision = createVisionState(); const hud = computed(() => instruments.value ?? initialInstruments); +// The bridge tape scrolls continuously: its minor ticks shift with the +// division nearest the caret. +const tapeShift = computed( + () => hud.value.compass?.find((division) => division.active)?.slot ?? 0, +); const direction = computed(() => (route.value ?? initialRoute).direction); +// Where a bridge view is turned; an aircraft's always rests. +const look = computed(() => hud.value.look ?? initialInstruments.look); const v = computed(() => vision.value ?? initialVision); const visionLabel = computed(() => t('Current cockpit vision style: {style}. Activate for next style.', { @@ -54,11 +74,20 @@ const tickStyle = (tick) => diff --git a/src/ui/views/ContextView.vue b/src/ui/views/ContextView.vue index c40665fe4..93e05abe4 100644 --- a/src/ui/views/ContextView.vue +++ b/src/ui/views/ContextView.vue @@ -20,8 +20,8 @@ import { cockpitEntryFace } from '../cockpitPresentation.js'; // publishes them; until it does, the panel shows its initial markup. const { view: ctx } = storeToRefs(useContextStore()); const { presence: cockpit } = storeToRefs(useCockpitStore()); -// The entry rides the tracked aircraft (cockpitEntryFace). -const entry = computed(() => cockpitEntryFace('aircraft')); +// The entry names the subject it would ride (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind)); // While the Bhote Koshi event is on, its panel is the rail's only content. const { panel: bhotePanel } = storeToRefs(useBhoteKoshiPanelStore()); // The Scene runtime chip SceneControls presents while a scene plays. diff --git a/src/ui/views/IntelHudView.vue b/src/ui/views/IntelHudView.vue index 6f60bce76..d49f9aa6f 100644 --- a/src/ui/views/IntelHudView.vue +++ b/src/ui/views/IntelHudView.vue @@ -16,8 +16,8 @@ const view = computed(() => hud.value ?? EMPTY); // button, so first person needs no Contacts panel. The Cockpit controller // answers its clicks. const { presence: cockpit } = storeToRefs(useCockpitStore()); -// The entry rides the tracked aircraft (cockpitEntryFace). -const entry = computed(() => cockpitEntryFace('aircraft')); +// The entry names the subject it would ride (cockpitEntryFace). +const entry = computed(() => cockpitEntryFace(cockpit.value?.entryKind));