Files
gods-eye-view/src/ui/cockpitInput.js
T
Sameh Khamis 4bb7b411e9 fix(cockpit): a provider's subject rides where it is drawn, and the documented provider rides a moving train
- A frame with no speedMps gets an exact anchor: an inertial one ran on at
  zero speed and closed at 0.75 m/s, so the camera fell kilometres behind a
  moving train. The docs example now passes speedMps and anchor: 'exact',
  a name for its HUD, and isEntryAllowed for a train bound to its line.
- Transit's snapshot height is the floor where playback draws the vehicle,
  not the newest report's floor uphill, and null while that floor is cold,
  so a view waits for the surface instead of standing on the ellipsoid.
- Rail's direction follows playback once it has placed the train: a report
  that runs back (a reversal, or GPS jitter) no longer turns the view
  while the drawn train still runs forward.
- A frame's id is trimmed as the candidate's is.
- A double-click on the selected vessel no longer enters an application's
  subject that took entry after it.
- The docs import from gods-eye-view/..., the package's name.
2026-11-13 18:23:50 -08:00

289 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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. */
export const SUBJECT_DOUBLE_CLICK_RADIUS_PX = 32;
/**
* Whether a double-click landed on the followed track's mark.
* @param {{x: number, y: number}|null} click Canvas position of the click.
* @param {{x: number, y: number}|null|undefined} subject Canvas position of
* the track, or nothing when it is off screen.
* @param {number} [radiusPx]
*/
export function doubleClickHitsSubject(
click,
subject,
radiusPx = SUBJECT_DOUBLE_CLICK_RADIUS_PX,
) {
if (!click || !subject) return false;
const distance = Math.hypot(click.x - subject.x, click.y - subject.y);
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 (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
}
if (kind === 'satellite')
return this.services.satellitesLayer?.getCockpitPosition?.() ?? null;
// An application's subject has no position before its view is entered:
// the selected vessel's sprite must not stand in for it.
if (kind !== 'vessel') return 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. */
function subjectCanvasPosition() {
const kind = this.resolveEntryKind();
const world = kind ? subjectWorldPosition.call(this, kind) : null;
const scene = this.viewer.scene;
if (!world || typeof scene?.cartesianToCanvasCoordinates !== 'function')
return null;
return (
scene.cartesianToCanvasCoordinates(world, new Cesium.Cartesian2()) ?? null
);
}
/**
* Note where the selected ship 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;
// Only a ship moves under its own click (selecting it re-centres the
// camera); a followed aircraft stays put, so its hit area stays its own.
if (this.resolveEntryKind() !== 'vessel') {
this.doubleClickStart = null;
return;
}
const at = subjectCanvasPosition.call(this);
const vesselId = this.readVesselInfo()?.id ?? null;
this.doubleClickStart = at
? { x: at.x, y: at.y, atMs: nowMs, vesselId }
: null;
}
/**
* Enter first person when the followed track itself is double-clicked: the
* same entry, and the same gates, as 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, 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;
const hit =
doubleClickHitsSubject(position, subjectCanvasPosition.call(this)) ||
(Boolean(start) &&
nowMs - start.atMs <= DOUBLE_CLICK_MEMORY_MS &&
// The memory is the ship's it was taken for: one followed since (an
// aircraft, another ship) is hit only where it is drawn.
this.resolveEntryKind() === 'vessel' &&
start.vesselId === (this.readVesselInfo()?.id ?? null) &&
doubleClickHitsSubject(position, start));
if (!hit) return false;
this.enter();
return true;
}
/**
* Take the scene's double-click through the Cockpit first. Cesium's Viewer
* installs its own (pickAndTrackObject), which untracks whenever the pick is
* not an Entity: on a followed aircraft's model, a primitive, it would drop
* the very track the double-click means to ride. A double-click on the
* followed track is the Cockpit's, as is one on a mapped ground point in
* orbit (a focus); any other goes on to Cesium's.
* @returns {() => void} Puts Cesium's handler back, unless another owner
* (the draw tool borrows it) has replaced ours since.
*/
export function routeSceneDoubleClick() {
const handler = this.viewer.screenSpaceEventHandler;
if (typeof handler?.setInputAction !== 'function') return () => {};
const type = Cesium.ScreenSpaceEventType.LEFT_DOUBLE_CLICK;
const stock = handler.getInputAction?.(type) ?? null;
const routed = (event) => {
if (this.onSceneDoubleClick(event?.position)) return;
// In orbit, a double-click on a mapped ground point is a focus, never a
// retarget that would end the view.
if (this.onOrbitalDoubleClick?.(event?.position)) return;
stock?.(event);
};
handler.setInputAction(routed, type);
return () => {
if (handler.isDestroyed?.() || handler.getInputAction?.(type) !== routed)
return;
if (stock) handler.setInputAction(stock, type);
else handler.removeInputAction(type);
};
}
/**
* 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=combobox]',
'[role=grid]',
'[role=menubar]',
'[role=menuitemcheckbox]',
'[role=menuitemradio]',
'[role=scrollbar]',
'[role=searchbox]',
'[role=slider]',
'[role=spinbutton]',
'[role=textbox]',
'[role=tree]',
'[role=treeitem]',
'[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.looksAround) return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
const step = lookKeyStep(event.key, event.shiftKey);
const reset = !event.repeat && (event.key === 'Home' || event.key === '0');
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;
}
/** Zoom keys in orbit: `+` (or `=`, unshifted) in and `-` (or `_`) out. */
const ORBITAL_ZOOM_KEYS = Object.freeze({ '+': 1, '=': 1, '-': -1, _: -1 });
/**
* In an orbital view: tilt off nadir with the arrows (single 5° steps; Shift
* does not take the bridge's 45° step), return to nadir with Home or 0, and
* step the zoom with + and −. The bridge's focus guards apply.
* @returns {boolean} True when the key was taken.
*/
function handleOrbitalKey(event) {
if (!this.active || this.subjectKind !== 'satellite') return false;
if (event.metaKey || event.ctrlKey || event.altKey) return false;
const step = lookKeyStep(event.key);
const reset = !event.repeat && (event.key === 'Home' || event.key === '0');
const zoom = event.repeat ? 0 : ORBITAL_ZOOM_KEYS[event.key] || 0;
if (!step && !reset && !zoom) return false;
if (event.target?.closest?.(LOOK_KEY_OWNERS)) return false;
if (this.isUtilityDisclosureOpen?.()) return false;
event.preventDefault();
if (step) this.turnOrbitalLook(step);
else if (reset) this.orbitalNadir();
else this.stepOrbitalZoom(zoom);
return true;
}
/**
* The Cockpit's capture-phase keydown: Escape, on a bridge the look-around
* keys and in orbit the tilt and zoom keys. C is left to the app-wide CCTV
* shortcut; entry is by double-click or the entry buttons.
* @param {KeyboardEvent} event
*/
export function onKeyDown(event) {
if (this.destroyed) return false;
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
// focus; Cockpit must stay active behind it.
if (event.target?.closest?.('.cesium-credit-lightbox')) return;
// An open Display or Radio popover takes Escape itself.
if (this.isUtilityDisclosureOpen?.()) return;
if (this.context?.contains(event.target) && !this.contextCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setContextCollapsed(true);
if (
event.target === this.contextToggle ||
this.contextToggle?.contains?.(event.target)
) {
this.contextToggle?.blur?.();
} else {
this.contextToggle?.focus({ preventScroll: true });
}
return;
}
if (this.signalStream?.contains(event.target) && !this.signalCollapsed) {
event.preventDefault();
event.stopImmediatePropagation();
this.setSignalCollapsed(true, { user: true });
if (
event.target === this.signalToggle ||
this.signalToggle?.contains?.(event.target)
) {
this.signalToggle?.blur?.();
} else {
this.signalToggle?.focus({ preventScroll: true });
}
return;
}
event.preventDefault();
event.stopImmediatePropagation();
this.exit();
return;
}
if (event.target?.closest?.('input, textarea, select, [contenteditable]'))
return;
if (handleLookKey.call(this, event)) return;
handleOrbitalKey.call(this, event);
}