diff --git a/changelog.d/1513-timeline-launches-speed-buffering.md b/changelog.d/1513-timeline-launches-speed-buffering.md new file mode 100644 index 000000000..e84d8e205 --- /dev/null +++ b/changelog.d/1513-timeline-launches-speed-buffering.md @@ -0,0 +1,7 @@ +- The timeline brings launches up as the time passes them, offers play + speeds from real time to a week a second, and plays smoothly, holding only + when the next weather frame has yet to load, with a spinner by the time + while it waits. The time rides in a bubble over the handle. The track zooms + with a scroll or a pinch, drags along, and goes to the time under a tap, on + a phone as on a desktop. Zooming out on the globe stops while the globe is + still in view instead of leaving it a speck (samehkhamis, #1513). diff --git a/changelog.d/1557-cockpit-ship-bridge.md b/changelog.d/1557-cockpit-ship-bridge.md new file mode 100644 index 000000000..f07545c84 --- /dev/null +++ b/changelog.d/1557-cockpit-ship-bridge.md @@ -0,0 +1,4 @@ +- Ride the selected AIS vessel from its bridge: an eye height by vessel type, + dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around + with the arrow keys, and a waterline read from the scene so river boats sit on + the river rather than at sea level (Denis Groshev, @ZloyS, #1557). diff --git a/changelog.d/1634-layer-state-registered-only.md b/changelog.d/1634-layer-state-registered-only.md new file mode 100644 index 000000000..4d8eaa897 --- /dev/null +++ b/changelog.d/1634-layer-state-registered-only.md @@ -0,0 +1,3 @@ +- An application built with fewer layers no longer restores, saves or shares a + layer it does not have when a link or saved state from a full build names it + (samehkhamis, #1634). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0869c32c9..1f91d18a0 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1524,6 +1524,87 @@ 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 (when +the bridge cannot be entered, `C` stays the CCTV shortcut), 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; +voice `enter` on a bridge is refused before it touches tracking, with the +reason to exit the bridge first. +The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's +sprite, card, detection bracket and trail 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. Once a +full-detail read has answered, loaded-tile reads no longer change the height; +before then they may find raised water but never take it back to the sea. +A read that sampled nothing, or failed, keeps the height it had and is +tried again on the next refresh. + +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. Own ship +sits where the camera rides (its fix dead-reckoned) and contacts at their +last fixes, where the vessel layer draws them, so the scope matches the view +out of the window. 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, @@ -5719,6 +5800,13 @@ and unreachable upstream (502/504) separately from road geometry. explicit-empty form); unknown option tokens are ignored. Restoration settles independently per layer so one failed or unavailable source cannot block its siblings. +- An application that registers fewer layers (an embedder with a smaller + catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids + the application did not register before it normalizes a share, saved state or + explicit change, so such a layer is never restored, written to local storage + or carried into a link this application shares. Options that depend on a + dropped layer (a tracked selection) go with it. An application with every + layer registered is unchanged. - Layer tokens are permanent public compatibility identifiers. The reservation JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing one-character assignments (all letters plus `0`, `1` and @@ -6362,14 +6450,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 cc773b4d6..291b99708 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/format-scope.json b/scripts/format-scope.json index b23ab75b0..5ed51e7fd 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -328,6 +328,8 @@ "src/ui/styles/responsive.css", "src/ui/styles/voice-cost.css", "src/ui/styles/cockpit.css", + "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitRadar.css", "src/ui/styles/command-dock-compact.css", "src/app/stateChannel.js", "src/app/stateChannel.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 517265ef7..d1c988097 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", @@ -913,6 +914,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", @@ -981,6 +987,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", @@ -1210,6 +1221,8 @@ "src/ui/styles/location.css", "src/ui/styles/status.css", "src/ui/styles/cockpit.css", + "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitRadar.css", "src/ui/styles/overlays.css", "src/ui/styles/layers.css", "src/ui/styles/radio.css", @@ -1529,6 +1542,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", @@ -2400,6 +2415,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", @@ -2961,6 +2979,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", @@ -3749,6 +3768,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", @@ -4168,6 +4188,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/app/viewer.js b/src/app/viewer.js index fc75c0498..e4288e8a3 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -7,6 +7,48 @@ import { isFreeGlobeCamera, } from '../globeInView.js'; +/** + * At the farthest a hand can zoom out, the globe is this share of the + * screen's height across. Cesium sets no limit, so zooming on left the globe + * a speck among the stars. + */ +const MIN_GLOBE_SHARE = 0.3; + +/** + * The farthest a hand can zoom out, in metres above the ellipsoid, for a + * vertical field of view. It follows the screen's shape, as the app's own + * whole-globe view does, so that view always lies within it. + * @param {number} fovy Vertical field of view, radians. + */ +export function zoomOutLimit(fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.atan(MIN_GLOBE_SHARE * Math.tan(fovy / 2)); + return radius / Math.sin(angular) - radius; +} + +/** + * Hold a hand's zoom out to `zoomOutLimit` on the globe, each frame, since the + * field of view follows the screen's shape. The limit never sits below the + * camera: one already past it (a flight, a screen turned) stops there rather + * than jumping in at the next zoom. Flat maps keep Cesium's own, unlimited. + * @returns {() => void} Stop limiting. + */ +export function limitZoomOut(viewer) { + const { scene, camera } = viewer; + return scene.preRender.addEventListener(() => { + const controller = scene.screenSpaceCameraController; + const fovy = camera.frustum.fovy; + if (scene.mode !== Cesium.SceneMode.SCENE3D || !Number.isFinite(fovy)) { + controller.maximumZoomDistance = Number.POSITIVE_INFINITY; + return; + } + controller.maximumZoomDistance = Math.max( + zoomOutLimit(fovy), + camera.positionCartographic?.height ?? 0, + ); + }); +} + const PINCH_ZOOM_MULTIPLIER = 8; const MAX_PINCH_PIXEL_DELTA = 120; @@ -372,6 +414,7 @@ export function createApplicationViewer({ container, creditContainer }) { // required on touch-first screens. Mouse/trackpad sensitivity stays native. if (globalThis.matchMedia?.('(pointer: coarse)').matches) viewer.scene.screenSpaceCameraController.zoomFactor *= 3; + limitZoomOut(viewer); // Before any tile builds a draw command: Cesium's per-vertex model // atmosphere fails to LINK on Apple's Metal backend and kills the // render loop. See app/atmosphereCompat.js. diff --git a/src/app/viewer.test.mjs b/src/app/viewer.test.mjs new file mode 100644 index 000000000..94d80e20a --- /dev/null +++ b/src/app/viewer.test.mjs @@ -0,0 +1,73 @@ +// A hand zooming out stops while the globe is still a fair share of the +// screen, on any screen's shape, rather than leaving it a speck. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { limitZoomOut, zoomOutLimit } from './viewer.js'; + +// The share of the screen's height the globe spans from `altitude`. +function globeShare(altitude, fovy) { + const radius = Cesium.Ellipsoid.WGS84.maximumRadius; + const angular = Math.asin(radius / (radius + altitude)); + return Math.tan(angular) / Math.tan(fovy / 2); +} + +test("zooming out stops with the globe 30% of the screen's height across", () => { + for (const fovy of [Math.PI / 3, 0.4, 0.2]) { + const limit = zoomOutLimit(fovy); + assert.ok(Math.abs(globeShare(limit, fovy) - 0.3) < 1e-9, `fovy ${fovy}`); + } +}); + +test('a wide screen, whose view is narrower, may zoom out farther', () => { + assert.ok(zoomOutLimit(0.4) > zoomOutLimit(Math.PI / 3)); + assert.ok(zoomOutLimit(Math.PI / 3) > 20_000_000, 'past the views a link opens'); +}); + +function fakeViewer({ fovy = Math.PI / 3, height = 10_000_000 } = {}) { + const scene = { + mode: Cesium.SceneMode.SCENE3D, + preRender: new Cesium.Event(), + screenSpaceCameraController: { maximumZoomDistance: Infinity }, + }; + const camera = { + frustum: { fovy }, + positionCartographic: { height }, + }; + return { scene, camera }; +} + +test('the limit follows the screen, and holds a camera already past it', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + const stop = limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(Math.PI / 3)); + + // A wider screen narrows the view, and the globe may go farther. + viewer.camera.frustum.fovy = 0.4; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4)); + + // Flown past it, the next zoom out stops there instead of jumping in. + viewer.camera.positionCartographic.height = zoomOutLimit(0.4) * 2; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); + + stop(); + viewer.camera.positionCartographic.height = 0; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, zoomOutLimit(0.4) * 2); +}); + +test('flat maps keep zooming out unlimited', () => { + const viewer = fakeViewer(); + const controller = viewer.scene.screenSpaceCameraController; + limitZoomOut(viewer); + viewer.scene.preRender.raiseEvent(); + assert.ok(Number.isFinite(controller.maximumZoomDistance)); + viewer.scene.mode = Cesium.SceneMode.SCENE2D; + viewer.camera.frustum = {}; + viewer.scene.preRender.raiseEvent(); + assert.equal(controller.maximumZoomDistance, Infinity); +}); 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/celestialRing.js b/src/celestialRing.js index f470e7d18..525ded44e 100644 --- a/src/celestialRing.js +++ b/src/celestialRing.js @@ -32,6 +32,9 @@ export const KEYHOLE_LABEL_FEATHER_MAX_RATIO = 0.4; */ export const KEYHOLE_OUTSIDE_OPACITY_DEFAULT = 0.01; +// How far the world time moves before the sun and moon move with it: a +// minute moves the sun a quarter of a degree. +const EPHEMERIS_WORLD_STEP_MS = 60_000; const RING_INSET_PX = 11; const MARKER_INSET_PX = 36; const COLLIDING_MARKER_EXTRA_INSET_PX = 32; @@ -452,7 +455,15 @@ export class CelestialRing { // A jump in the world time moves the sun and moon at once. this._skyDate = new Date(); this._skyTime = new Cesium.JulianDate(); - this._unsubscribeWorldTime = worldClock.subscribe(() => { + // Played through, the sky moves on each minute of world time it passes. + this._ephemerisWorldMs = null; + this._unsubscribeWorldTime = worldClock.subscribe(({ timeMs }) => { + if ( + this._ephemerisWorldMs !== null && + Math.abs(timeMs - this._ephemerisWorldMs) < EPHEMERIS_WORLD_STEP_MS + ) + return; + this._ephemerisWorldMs = timeMs; this._ephemerisDirty = true; }); this.setEnabled(this.enabled); 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