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.
This commit is contained in:
Denis Groshev committed 2026-11-07 19:52:30 +01:00
1 parent 52187c91d1
commit 72112ecb45
58 files changed
+5313 -137

No files matched your search

+108 -14
View File
@@ -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;
}
}