mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Merge upstream main into pr/street-level-show-action
Locale conflicts: main's catalogs plus the PR's 10 street-level strings.
This commit is contained in:
commit
b7531e655d
84 files changed
+7506
-244
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
+12
-1
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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<object>} contacts See {@link projectRadarContacts}.
|
||||
* @param {number} rangeNm Range in nautical miles.
|
||||
* @returns {{rangeNm: number, ringsNm: number[], headingDeg: number,
|
||||
* ownRelativeCourseDeg: number|null, contacts: Array<object>}}
|
||||
*/
|
||||
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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<number|null|undefined>} 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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -241,7 +241,7 @@ const VETTED_VARS = new Set([
|
||||
// Custom properties CSS sets to a fixed length besides their 0 default. The
|
||||
// clearance checks run at each length CSS gives them, not only at 0.
|
||||
const FIXED_LENGTH_VARS = new Set(['--world-timeline-lift']);
|
||||
const LIFTS_PX = [0, 50, 80];
|
||||
const LIFTS_PX = [0, 66, 80];
|
||||
let liftPx = 0;
|
||||
function eachLift(check) {
|
||||
for (const lift of LIFTS_PX) {
|
||||
|
||||
@@ -989,6 +989,22 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a click on open water keeps the ship the bridge rides', () => {
|
||||
const harness = installWireHarness({ id: 'gev-trailing-contact' });
|
||||
try {
|
||||
aisLiveVesselsLayer.setCockpitSubject(harness.record.mmsi);
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
// Released, the same click deselects as it always has.
|
||||
aisLiveVesselsLayer.setCockpitSubject(null);
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo(), null);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: only the gev-trail: namespace receives the trail no-op', () => {
|
||||
const harness = installWireHarness({ id: 'gev-trailing-contact' });
|
||||
try {
|
||||
|
||||
@@ -3121,3 +3121,70 @@ test('Active Fires: the codec accepts whole megawatts in bounds and the three co
|
||||
confidence: 'nominal',
|
||||
});
|
||||
});
|
||||
|
||||
// An application built without a layer (a smaller catalog) receives a link or
|
||||
// saved state from a build that has it: the layer is neither restored nor
|
||||
// stored nor carried into the links this application shares.
|
||||
function managerWithout(withheld) {
|
||||
const manager = new DataLayerManager({});
|
||||
for (const id of REGISTERED_LAYER_IDS)
|
||||
if (!withheld.includes(id)) manager.register(fakeLayer(id));
|
||||
manager.finalizeRegistrations(
|
||||
LAYER_STATE_REGISTRY.filter(({ id }) => !withheld.includes(id)),
|
||||
);
|
||||
return manager;
|
||||
}
|
||||
|
||||
test('layers the application did not register are never restored, stored or shared', async () => {
|
||||
const manager = managerWithout(['earthquakes']);
|
||||
const storage = memoryStorage();
|
||||
const share = shareSink();
|
||||
const coordinator = new LayerStateCoordinator(manager, share, { storage });
|
||||
const incoming = createDefaultLayerState();
|
||||
incoming.enabledLayerIds = ['earthquakes', 'traffic'];
|
||||
await coordinator.start({ shareLayerState: incoming });
|
||||
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']);
|
||||
assert.deepEqual(share.provider().enabledLayerIds, ['traffic']);
|
||||
assert.equal(manager.layers.has('earthquakes'), false);
|
||||
|
||||
await manager.setEnabled('cctv', true, { origin: 'user' });
|
||||
const [[, written]] = storage.writes;
|
||||
assert.deepEqual(parseStoredLayerState(written).enabledLayerIds, [
|
||||
'cctv',
|
||||
'traffic',
|
||||
]);
|
||||
assert.deepEqual(share.provider().enabledLayerIds, ['cctv', 'traffic']);
|
||||
|
||||
await coordinator.applyShareLayerState({ shareLayerState: incoming });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, ['traffic']);
|
||||
coordinator.destroy();
|
||||
|
||||
const saved = createDefaultLayerState();
|
||||
saved.enabledLayerIds = ['earthquakes', 'traffic'];
|
||||
const local = new LayerStateCoordinator(
|
||||
managerWithout(['earthquakes']),
|
||||
shareSink(),
|
||||
{ storage: memoryStorage(serializeStoredLayerState(saved)) },
|
||||
);
|
||||
await local.start();
|
||||
assert.equal(local.source, 'local');
|
||||
assert.deepEqual(local.getDurableState().enabledLayerIds, ['traffic']);
|
||||
local.destroy();
|
||||
});
|
||||
|
||||
test('an application with every layer registered keeps every requested layer', async () => {
|
||||
const coordinator = new LayerStateCoordinator(
|
||||
productionManager(),
|
||||
shareSink(),
|
||||
{ storage: memoryStorage() },
|
||||
);
|
||||
const incoming = createDefaultLayerState();
|
||||
incoming.enabledLayerIds = ['earthquakes', 'traffic'];
|
||||
await coordinator.start({ shareLayerState: incoming });
|
||||
assert.deepEqual(coordinator.getDurableState().enabledLayerIds, [
|
||||
'earthquakes',
|
||||
'traffic',
|
||||
]);
|
||||
coordinator.destroy();
|
||||
});
|
||||
@@ -106,9 +106,7 @@ export class LayerStateCoordinator {
|
||||
} = {}) {
|
||||
if (this._destroyed)
|
||||
throw new Error('Layer-state coordinator is destroyed');
|
||||
let selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
let selected = shareLayerState ? this._normalize(shareLayerState) : null;
|
||||
if (selected) {
|
||||
this._source = 'share';
|
||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||
@@ -124,7 +122,7 @@ export class LayerStateCoordinator {
|
||||
/* best effort */
|
||||
}
|
||||
if (stored) {
|
||||
selected = stored;
|
||||
selected = this._normalize(stored);
|
||||
this._source = 'local';
|
||||
}
|
||||
} else {
|
||||
@@ -163,9 +161,7 @@ export class LayerStateCoordinator {
|
||||
for (const controller of this._restoreControllers.values())
|
||||
controller.abort('superseded-by-share-navigation');
|
||||
this._revokePendingTrackingWatch('superseded-by-share-navigation');
|
||||
const selected = shareLayerState
|
||||
? normalizeLayerState(shareLayerState)
|
||||
: null;
|
||||
const selected = shareLayerState ? this._normalize(shareLayerState) : null;
|
||||
this._source = selected ? 'share' : 'legacy-share';
|
||||
this._shareCreatedAtMs =
|
||||
selected && Number.isFinite(shareCreatedAtMs) ? shareCreatedAtMs : null;
|
||||
@@ -182,6 +178,25 @@ export class LayerStateCoordinator {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical layer state holding only the layers this application
|
||||
* registered. An application built without a layer never restores, stores
|
||||
* or shares it, even when a link or saved state from a build that has it
|
||||
* names it. Filtering before normalizing lets normalization drop options
|
||||
* that depend on a layer that is not here (a tracked selection).
|
||||
*/
|
||||
_normalize(candidate) {
|
||||
const input = candidate && typeof candidate === 'object' ? candidate : {};
|
||||
return normalizeLayerState({
|
||||
...input,
|
||||
enabledLayerIds: Array.isArray(input.enabledLayerIds)
|
||||
? input.enabledLayerIds.filter((id) =>
|
||||
this.dataManager.layers.has(String(id)),
|
||||
)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
|
||||
getDurableState() {
|
||||
return cloneLayerState(this._durableState);
|
||||
}
|
||||
@@ -345,7 +360,7 @@ export class LayerStateCoordinator {
|
||||
}
|
||||
|
||||
_commitExplicit(candidate) {
|
||||
this._durableState = normalizeLayerState(candidate);
|
||||
this._durableState = this._normalize(candidate);
|
||||
const serialized = serializeStoredLayerState(this._durableState);
|
||||
try {
|
||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||
@@ -478,7 +493,7 @@ export class LayerStateCoordinator {
|
||||
...this._durableState.options[selected.optionOwner],
|
||||
[selected.optionKey]: null,
|
||||
};
|
||||
this._durableState = normalizeLayerState({
|
||||
this._durableState = this._normalize({
|
||||
...this._durableState,
|
||||
options: {
|
||||
...this._durableState.options,
|
||||
|
||||
@@ -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": "أيام الصور في المنطقة",
|
||||
@@ -3495,7 +3540,14 @@
|
||||
"Opening imagery": "جارٍ فتح الصور",
|
||||
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
|
||||
"COURSE HOLD · AIS": "تثبيت المسار · AIS",
|
||||
"World time": "وقت العالم",
|
||||
"Back to live": "العودة إلى البث المباشر",
|
||||
"Live": "مباشر"
|
||||
"Live": "مباشر",
|
||||
"1 h/s": "ساعة/ث",
|
||||
"1 d/s": "يوم/ث",
|
||||
"1 wk/s": "أسبوع/ث",
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -3195,7 +3236,14 @@
|
||||
"Opening imagery": "Bilder werden geöffnet",
|
||||
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.",
|
||||
"COURSE HOLD · AIS": "KURS FIX · AIS",
|
||||
"World time": "Weltzeit",
|
||||
"Back to live": "Zurück zu live",
|
||||
"Live": "Live"
|
||||
"Live": "Live",
|
||||
"1 h/s": "1 Std./s",
|
||||
"1 d/s": "1 Tag/s",
|
||||
"1 wk/s": "1 Wo./s",
|
||||
"Play speed": "Wiedergabegeschwindigkeit",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles",
|
||||
"1 min/s": "1 Min./s"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -3270,7 +3312,14 @@
|
||||
"Opening imagery": "Abriendo imágenes",
|
||||
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.",
|
||||
"COURSE HOLD · AIS": "BLOQUEO DE RUMBO · AIS",
|
||||
"World time": "Hora mundial",
|
||||
"Back to live": "Volver al directo",
|
||||
"Live": "En directo"
|
||||
"Live": "En directo",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 d/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidad de reproducción",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo",
|
||||
"1 min/s": "1 min/s"
|
||||
}
|
||||
@@ -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É",
|
||||
@@ -3270,7 +3312,14 @@
|
||||
"Opening imagery": "Ouverture des images",
|
||||
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.",
|
||||
"COURSE HOLD · AIS": "VERROU ROUTE · AIS",
|
||||
"World time": "Heure du monde",
|
||||
"Back to live": "Revenir au direct",
|
||||
"Live": "Direct"
|
||||
"Live": "Direct",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 j/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Vitesse de lecture",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir",
|
||||
"1 min/s": "1 min/s"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -3270,7 +3312,14 @@
|
||||
"Opening imagery": "Apertura delle immagini",
|
||||
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.",
|
||||
"COURSE HOLD · AIS": "BLOCCO ROTTA · AIS",
|
||||
"World time": "Ora del mondo",
|
||||
"Back to live": "Torna alla diretta",
|
||||
"Live": "Diretta"
|
||||
"Live": "Diretta",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 g/s",
|
||||
"1 wk/s": "1 sett/s",
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s"
|
||||
}
|
||||
@@ -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": "コンタクトコンテキスト オフ",
|
||||
@@ -3120,7 +3160,14 @@
|
||||
"Opening imagery": "画像を開いています",
|
||||
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
|
||||
"COURSE HOLD · AIS": "対地針路保持 · AIS",
|
||||
"World time": "世界時刻",
|
||||
"Back to live": "ライブに戻る",
|
||||
"Live": "ライブ"
|
||||
"Live": "ライブ",
|
||||
"1 h/s": "1時間/秒",
|
||||
"1 d/s": "1日/秒",
|
||||
"1 wk/s": "1週間/秒",
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒"
|
||||
}
|
||||
@@ -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",
|
||||
@@ -3270,7 +3312,14 @@
|
||||
"Opening imagery": "Abrindo imagens",
|
||||
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.",
|
||||
"COURSE HOLD · AIS": "TRAVA DE RUMO · AIS",
|
||||
"World time": "Hora do mundo",
|
||||
"Back to live": "Voltar ao vivo",
|
||||
"Live": "Ao vivo"
|
||||
"Live": "Ao vivo",
|
||||
"1 h/s": "1 h/s",
|
||||
"1 d/s": "1 d/s",
|
||||
"1 wk/s": "1 sem/s",
|
||||
"Play speed": "Velocidade de reprodução",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo",
|
||||
"1 min/s": "1 min/s"
|
||||
}
|
||||
@@ -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": "МЕСТН.",
|
||||
@@ -3345,7 +3388,14 @@
|
||||
"Opening imagery": "Открытие снимков",
|
||||
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
|
||||
"COURSE HOLD · AIS": "УДЕРЖАНИЕ ПУТЕВОГО УГЛА · AIS",
|
||||
"World time": "Мировое время",
|
||||
"Back to live": "Вернуться к прямому эфиру",
|
||||
"Live": "Сейчас"
|
||||
"Live": "Сейчас",
|
||||
"1 h/s": "1 ч/с",
|
||||
"1 d/s": "1 дн/с",
|
||||
"1 wk/s": "1 нед/с",
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с"
|
||||
}
|
||||
@@ -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": "КОНТЕКСТ КОНТАКТІВ ВИМКНЕНО",
|
||||
@@ -3345,7 +3388,14 @@
|
||||
"Opening imagery": "Відкриття знімків",
|
||||
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
|
||||
"COURSE HOLD · AIS": "УТРИМАННЯ ШЛЯХОВОГО КУТА · AIS",
|
||||
"World time": "Світовий час",
|
||||
"Back to live": "Повернутися до прямого ефіру",
|
||||
"Live": "Зараз"
|
||||
"Live": "Зараз",
|
||||
"1 h/s": "1 год/с",
|
||||
"1 d/s": "1 дн/с",
|
||||
"1 wk/s": "1 тиж/с",
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с"
|
||||
}
|
||||
@@ -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": "目标态势已关闭",
|
||||
@@ -3120,7 +3160,14 @@
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
|
||||
"COURSE HOLD · AIS": "对地航向保持 · AIS",
|
||||
"World time": "世界时间",
|
||||
"Back to live": "返回实时",
|
||||
"Live": "实时"
|
||||
"Live": "实时",
|
||||
"1 h/s": "1 小时/秒",
|
||||
"1 d/s": "1 天/秒",
|
||||
"1 wk/s": "1 周/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒"
|
||||
}
|
||||
@@ -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": "目標態勢已關閉",
|
||||
@@ -3120,7 +3160,14 @@
|
||||
"Opening imagery": "正在打開影像",
|
||||
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入",
|
||||
"Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。",
|
||||
"COURSE HOLD · AIS": "對地航向保持 · AIS",
|
||||
"World time": "世界時間",
|
||||
"Back to live": "返回即時",
|
||||
"Live": "即時"
|
||||
"Live": "即時",
|
||||
"1 h/s": "1 小時/秒",
|
||||
"1 d/s": "1 天/秒",
|
||||
"1 wk/s": "1 週/秒",
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒"
|
||||
}
|
||||
@@ -35,6 +35,7 @@
|
||||
"hPa",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
@@ -203,6 +204,7 @@
|
||||
"view band|GLOBAL",
|
||||
"{city} CCTV",
|
||||
"{county} County",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -216,6 +218,7 @@
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
@@ -336,6 +339,7 @@
|
||||
"panel",
|
||||
"view band|GLOBAL",
|
||||
"voice|ERROR",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{speed} kn",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
@@ -355,6 +359,7 @@
|
||||
"iNaturalist"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"ALTITUDE",
|
||||
@@ -533,9 +538,11 @@
|
||||
"standard",
|
||||
"station",
|
||||
"voice|OFF",
|
||||
"{count} CONTACTS",
|
||||
"{count} MSGS",
|
||||
"{count} SOURCES",
|
||||
"{count} messages",
|
||||
"{direction} · {elevation}",
|
||||
"{n} cyclones",
|
||||
"{n} satellites",
|
||||
"{rate} msg/s",
|
||||
@@ -549,6 +556,7 @@
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"it": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AFRICA",
|
||||
@@ -708,6 +716,7 @@
|
||||
"{count} MSGS",
|
||||
"{count} feeds live",
|
||||
"{count} scenes",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{scene} / {pack}: {status}. File: {path}. {attribution} · {license}",
|
||||
"{source} HTTP {status}",
|
||||
@@ -774,6 +783,7 @@
|
||||
"kt",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
@@ -784,6 +794,7 @@
|
||||
"▲ {frp} MW"
|
||||
],
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"AIS: --",
|
||||
@@ -907,6 +918,7 @@
|
||||
"mph",
|
||||
"nominal",
|
||||
"view band|GLOBAL",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"{value} KM",
|
||||
@@ -943,7 +955,8 @@
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}"
|
||||
],
|
||||
"uk": [
|
||||
"AIS: --",
|
||||
@@ -961,7 +974,8 @@
|
||||
"WHOT/BHOT",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist",
|
||||
"iNaturalist ↗"
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -1010,6 +1024,7 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
@@ -1052,9 +1067,9 @@
|
||||
"MM",
|
||||
"NVG",
|
||||
"REC",
|
||||
"SRC {value}",
|
||||
"S2 GhostMaps",
|
||||
"S2 · {name}",
|
||||
"SRC {value}",
|
||||
"Sentinel-2",
|
||||
"Techno",
|
||||
"Trance",
|
||||
@@ -1073,6 +1088,7 @@
|
||||
"kts",
|
||||
"mph",
|
||||
"{city} CCTV",
|
||||
"{direction} · {elevation}",
|
||||
"{rate} msg/s",
|
||||
"{source} HTTP {status}",
|
||||
"{speed} kn",
|
||||
|
||||
@@ -67,6 +67,9 @@ export function createOrbitRendering({
|
||||
return Boolean(
|
||||
layerState._enabled &&
|
||||
layerState._dataSource?.show &&
|
||||
!layerState.launchHidden(
|
||||
layerState._missionOverlayRecords.get(launchId)?.launch,
|
||||
) &&
|
||||
(!layerState._selectedLaunchId ||
|
||||
layerState._selectedLaunchId === launchId),
|
||||
);
|
||||
|
||||
@@ -411,8 +411,11 @@ export function createOverlays({ state: layerState, services, parts, source }) {
|
||||
false,
|
||||
);
|
||||
layerState._selectedMissionOverlayTimeText = null;
|
||||
const records = [...layerState._missionOverlayRecords.values()].filter(
|
||||
(record) => !layerState.launchHidden(record.launch),
|
||||
);
|
||||
const entries = selectRocketMissionMarkerOverlayCohort(
|
||||
Array.from(layerState._missionOverlayRecords.values(), (record) => {
|
||||
records.map((record) => {
|
||||
const entry = createRocketMissionMarkerOverlayEntry(
|
||||
record.launch,
|
||||
record.anchorPosition,
|
||||
|
||||
@@ -22,6 +22,25 @@ export function createRendering({
|
||||
if (current !== next) graphic.show = next;
|
||||
}
|
||||
|
||||
// Away from live, a launch shows from its lift-off on, so scrubbing the
|
||||
// world time forward brings the launches up in the order they flew. The
|
||||
// selected mission stays, whenever it flies.
|
||||
function syncPendingLaunches() {
|
||||
const pending = new Set();
|
||||
for (const { launch } of layerState._missionOverlayRecords.values()) {
|
||||
if (layerState.launchHidden(launch)) pending.add(launch.id);
|
||||
}
|
||||
const previous = layerState._pendingLaunchIds;
|
||||
if (
|
||||
pending.size === previous.size &&
|
||||
[...pending].every((id) => previous.has(id))
|
||||
)
|
||||
return;
|
||||
layerState._pendingLaunchIds = pending;
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
}
|
||||
|
||||
function updateMissionFrame() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
@@ -31,6 +50,7 @@ export function createRendering({
|
||||
parts.overlays.hideReplayVehicleOverlay();
|
||||
return;
|
||||
}
|
||||
syncPendingLaunches();
|
||||
parts.panel.updateMissionTelemetry();
|
||||
const time = Cesium.JulianDate.now(layerState._declutterTime);
|
||||
layerState._declutterOccluder.cameraPosition =
|
||||
@@ -39,21 +59,28 @@ export function createRendering({
|
||||
parts.overlays.refreshSelectedMissionOverlayText();
|
||||
for (const entity of layerState._dataSource.entities.values) {
|
||||
if (!entity.show) continue;
|
||||
if (!entity.position) continue;
|
||||
const id = parts.selection.entityLaunchId(entity);
|
||||
if (!id) continue;
|
||||
const pending = layerState._pendingLaunchIds.has(id);
|
||||
if (entity.polyline)
|
||||
setGraphicVisibility(entity.polyline, !pending, time);
|
||||
if (!entity.position) continue;
|
||||
const position = entity.position.getValue(time);
|
||||
let horizonVisible = Boolean(
|
||||
position && layerState._declutterOccluder.isPointVisible(position),
|
||||
);
|
||||
let horizonVisible =
|
||||
!pending &&
|
||||
Boolean(
|
||||
position && layerState._declutterOccluder.isPointVisible(position),
|
||||
);
|
||||
const launchAnchor = entity.id.startsWith('rocket-launch:');
|
||||
if (launchAnchor) {
|
||||
horizonVisible = parts.overlays.missionAnchorVisible(
|
||||
layerState._viewer.scene.camera.positionWC,
|
||||
position,
|
||||
id,
|
||||
layerState._selectedLaunchId,
|
||||
);
|
||||
horizonVisible =
|
||||
!pending &&
|
||||
parts.overlays.missionAnchorVisible(
|
||||
layerState._viewer.scene.camera.positionWC,
|
||||
position,
|
||||
id,
|
||||
layerState._selectedLaunchId,
|
||||
);
|
||||
// Keep entity.show reserved for mission-selection isolation. Horizon
|
||||
// state is applied to the individual graphics below so a rear-side
|
||||
// anchor remains eligible for reevaluation as the camera moves.
|
||||
|
||||
@@ -169,6 +169,26 @@ export function createState({ services }) {
|
||||
|
||||
state.coverageChanged = () => worldClock.coverageChanged();
|
||||
|
||||
/**
|
||||
* Whether a launch has yet to lift off at the world time. While live none
|
||||
* has, so the layer shows every launch in its window as it always did.
|
||||
*/
|
||||
|
||||
state.launchPending = (launch) => {
|
||||
if (worldClock.isLive()) return false;
|
||||
const launchMs = Date.parse(launch?.launchTime);
|
||||
return Number.isFinite(launchMs) && launchMs > state.now();
|
||||
};
|
||||
|
||||
/** Whether a launch is hidden for lifting off after the world time. */
|
||||
|
||||
state.launchHidden = (launch) =>
|
||||
launch?.id !== state._selectedLaunchId && state.launchPending(launch);
|
||||
|
||||
/** The launches hidden at the last frame, by id, to spot a change. */
|
||||
|
||||
state._pendingLaunchIds = new Set();
|
||||
|
||||
state._missionOrbitPatternRegistered = false;
|
||||
|
||||
state._pathDistanceCache = new WeakMap();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Away from live, a launch shows from its lift-off on, so scrubbing the world
|
||||
// time brings the launches up in the order they flew.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
import { createState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
|
||||
const noop = () => {};
|
||||
const LAUNCH_MS = Date.UTC(2026, 9, 20, 12);
|
||||
|
||||
function scene(t) {
|
||||
t.after(() => worldClock.goLive());
|
||||
const state = createState({
|
||||
services: {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
},
|
||||
});
|
||||
const launch = {
|
||||
id: 'ceres-1',
|
||||
launchTime: new Date(LAUNCH_MS).toISOString(),
|
||||
};
|
||||
state._enabled = true;
|
||||
state._dataSource = new Cesium.CustomDataSource('launches');
|
||||
state._viewer = {
|
||||
scene: { camera: { positionWC: new Cesium.Cartesian3(2e7, 0, 0) } },
|
||||
};
|
||||
state._missionOverlayRecords.set(launch.id, { launch });
|
||||
const position = Cesium.Cartesian3.fromDegrees(0, 0);
|
||||
const anchor = state._dataSource.entities.add({
|
||||
id: `rocket-launch:${launch.id}`,
|
||||
position,
|
||||
point: { pixelSize: 5 },
|
||||
});
|
||||
const path = state._dataSource.entities.add({
|
||||
id: `rocket-transfer:${launch.id}`,
|
||||
polyline: { positions: [position, Cesium.Cartesian3.fromDegrees(1, 1)] },
|
||||
});
|
||||
const syncs = { labels: 0, orbits: 0 };
|
||||
const parts = {
|
||||
panel: { updateMissionTelemetry: noop },
|
||||
overlays: {
|
||||
hideReplayVehicleOverlay: noop,
|
||||
updateReplayVehicleOverlay: noop,
|
||||
refreshSelectedMissionOverlayText: noop,
|
||||
missionAnchorVisible: () => true,
|
||||
syncMissionOverlayEntries: () => syncs.labels++,
|
||||
},
|
||||
orbitRendering: {
|
||||
syncMissionOrbitPrimitiveVisibility: () => syncs.orbits++,
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
geometry: { getKeyholeGeometry: noop },
|
||||
satellites: {},
|
||||
};
|
||||
parts.selection = createSelection({ state, services, parts });
|
||||
const rendering = createRendering({ state, services, parts });
|
||||
const shown = () => {
|
||||
rendering.updateMissionFrame();
|
||||
const time = Cesium.JulianDate.now();
|
||||
return {
|
||||
marker: anchor.point.show.getValue(time),
|
||||
path: path.polyline.show?.getValue(time) ?? true,
|
||||
};
|
||||
};
|
||||
return { state, launch, syncs, shown };
|
||||
}
|
||||
|
||||
test('live shows every launch in the window, lifted off or not', (t) => {
|
||||
const { state, launch, shown } = scene(t);
|
||||
assert.equal(state.launchPending(launch), false);
|
||||
assert.deepEqual(shown(), { marker: true, path: true });
|
||||
});
|
||||
|
||||
test('away from live a launch shows from its lift-off on', (t) => {
|
||||
const { state, launch, syncs, shown } = scene(t);
|
||||
worldClock.seek(LAUNCH_MS - 60_000);
|
||||
assert.equal(state.launchPending(launch), true);
|
||||
assert.deepEqual(shown(), { marker: false, path: false });
|
||||
assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'its label and orbit go');
|
||||
shown();
|
||||
assert.deepEqual(syncs, { labels: 1, orbits: 1 }, 'once, not each frame');
|
||||
|
||||
worldClock.seek(LAUNCH_MS);
|
||||
assert.equal(state.launchPending(launch), false);
|
||||
assert.deepEqual(shown(), { marker: true, path: true });
|
||||
assert.deepEqual(syncs, { labels: 2, orbits: 2 });
|
||||
});
|
||||
|
||||
test('the selected mission stays whenever it flies', (t) => {
|
||||
const { state, launch, shown } = scene(t);
|
||||
state._selectedLaunchId = launch.id;
|
||||
worldClock.seek(LAUNCH_MS - 60_000);
|
||||
assert.deepEqual(shown(), { marker: true, path: true });
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,59 @@ 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. A ship with no MMSI has an `unkeyed:` id.
|
||||
*/
|
||||
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 = [];
|
||||
records.forEach((record, index) => {
|
||||
if (subject && String(record.mmsi) === subject) return;
|
||||
const distanceM = greatCircleMeters(center, {
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
});
|
||||
if (!(distanceM <= rangeM)) return;
|
||||
entries.push({ record, index, distanceM });
|
||||
});
|
||||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return entries.slice(0, cap).map(({ record, index }) => ({
|
||||
// A ship with no MMSI still needs a key of its own on the scope.
|
||||
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
|
||||
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 +519,55 @@ 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;
|
||||
// The ridden ship's trail runs from under the eye: hidden, its history
|
||||
// kept, while the bridge holds the ship it belongs to.
|
||||
state.trail?.setVisible?.(!next || next !== state.trailMmsi);
|
||||
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
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// src/layers/vessels/radarContacts.test.mjs — the vessels the Cockpit bridge
|
||||
// radar draws: within range, nearest first, without the ship it rides; and
|
||||
// the ridden ship's own trail, hidden while the bridge holds it.
|
||||
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('ships with no MMSI each keep a key of their own', () => {
|
||||
const layer = layerWith([
|
||||
record('', 1, { name: 'A' }),
|
||||
record('', 2, { name: 'B' }),
|
||||
]);
|
||||
const ids = layer.getRadarContacts(54.5, 10.2, 6 * 1852).map((c) => c.id);
|
||||
assert.equal(ids.length, 2);
|
||||
assert.equal(new Set(ids).size, 2, ids.join(', '));
|
||||
assert.ok(ids.every((id) => id.startsWith('unkeyed:')));
|
||||
});
|
||||
|
||||
test("the ridden ship's trail is hidden while the bridge holds it", () => {
|
||||
const shown = [];
|
||||
const trail = {
|
||||
setPositions() {},
|
||||
clear() {},
|
||||
destroy() {},
|
||||
setVisible: (visible) => shown.push(visible),
|
||||
};
|
||||
const layer = layerWith([record('own', 0), record('other', 2)], {
|
||||
trail,
|
||||
trailMmsi: 'own',
|
||||
});
|
||||
layer.setCockpitSubject('own');
|
||||
assert.deepEqual(shown, [false]);
|
||||
layer.setCockpitSubject(null);
|
||||
assert.deepEqual(shown, [false, true]);
|
||||
});
|
||||
|
||||
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), []);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -4,6 +4,15 @@ export const RADAR_MAX_GAP_MS = 30 * 60_000;
|
||||
export const REGIONAL_INFRARED_MAX_GAP_MS = 30 * 60_000;
|
||||
export const GLOBAL_INFRARED_MAX_GAP_MS = 3 * 60 * 60_000;
|
||||
export const LIGHTNING_MAX_GAP_MS = 30 * 60_000;
|
||||
/** The longest play waits on a frame before it goes on without it. */
|
||||
export const PLAY_WAIT_MS = 8_000;
|
||||
// Playing on its own, the world does not wait on the frame it is at: that
|
||||
// frame loads as play goes on. Play holds only at the next frame while the
|
||||
// one before it still loads, then goes on to it once it shows, so every frame
|
||||
// shows, play stops only for a load that is slow, and a frame loaded ahead
|
||||
// costs play nothing.
|
||||
|
||||
let clocks = 0;
|
||||
|
||||
/**
|
||||
* Transient observed time. Products must honor the signal before installing a
|
||||
@@ -33,6 +42,34 @@ export function createWeatherClock({
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let pending = null;
|
||||
// This clock's name on the world, for its holds and its gate.
|
||||
const holdId = `weather:${++clocks}`;
|
||||
// While the world plays, the hold on it at the next frame as the one before
|
||||
// it loads, and the timer that lets play go on should that frame never
|
||||
// come; and whether the move to the next frame waits on it.
|
||||
let waiting = null;
|
||||
let waitTimer = null;
|
||||
let deferred = false;
|
||||
const stopWaiting = () => {
|
||||
const release = waiting;
|
||||
waiting = null;
|
||||
if (waitTimer !== null) clearTimeout(waitTimer);
|
||||
waitTimer = null;
|
||||
release?.();
|
||||
};
|
||||
const startWaiting = () => {
|
||||
if (waiting || destroyed || !registry.size) return;
|
||||
waiting = world.wait(holdId);
|
||||
waitTimer = setTimeout(() => {
|
||||
waitTimer = null;
|
||||
stopWaiting();
|
||||
// The frame that never came gives way to the one play is at.
|
||||
if (deferred) void transition();
|
||||
}, PLAY_WAIT_MS);
|
||||
};
|
||||
// Playing on its own, the world waits for frames as they load.
|
||||
const worldPlaysOn = (state = world.getState()) =>
|
||||
state.playing && !state.live && !state.stepping;
|
||||
let destroyed = false;
|
||||
const timesFor = (product) =>
|
||||
[...new Set(product.getTimes())]
|
||||
@@ -65,6 +102,9 @@ export function createWeatherClock({
|
||||
mode,
|
||||
target,
|
||||
playing,
|
||||
// Frames come on one after another: this clock replays them, or the world
|
||||
// plays on through them, so the next is worth loading ahead.
|
||||
advancing: playing || worldPlaysOn(),
|
||||
timeline: getTimeline(),
|
||||
products: [...registry.values()].map(({ product }) => ({
|
||||
id: product.id,
|
||||
@@ -130,7 +170,10 @@ export function createWeatherClock({
|
||||
cancelTimer();
|
||||
const owner = ++generation;
|
||||
if (!canPlay()) stopPlaying();
|
||||
// A scrub drops what loads and moves on; play goes on as this loads.
|
||||
applied = selections();
|
||||
deferred = false;
|
||||
stopWaiting();
|
||||
for (const entry of registry.values()) entry.controller?.abort();
|
||||
// Start all products together; a rejection must not skip the settle barrier.
|
||||
const tasks = [...registry.values()].map((entry) => {
|
||||
@@ -144,10 +187,13 @@ export function createWeatherClock({
|
||||
});
|
||||
const result = Promise.allSettled(tasks).then(() => {
|
||||
if (destroyed || owner !== generation) return false;
|
||||
stopWaiting();
|
||||
pending = null;
|
||||
if (!canPlay()) stopPlaying();
|
||||
notify();
|
||||
schedule();
|
||||
// Play held at the next frame for this one goes on to it.
|
||||
if (deferred) void transition();
|
||||
return true;
|
||||
});
|
||||
pending = result;
|
||||
@@ -269,8 +315,10 @@ export function createWeatherClock({
|
||||
},
|
||||
refresh() {
|
||||
if (destroyed) return Promise.resolve(false);
|
||||
// New frames, or products added or dropped.
|
||||
// New frames, or products added or dropped, and with them the next.
|
||||
world.coverageChanged();
|
||||
gated = undefined;
|
||||
gateNext(world.getState());
|
||||
if (!canPlay()) {
|
||||
stopPlaying();
|
||||
cancelTimer();
|
||||
@@ -289,6 +337,8 @@ export function createWeatherClock({
|
||||
stopPlaying();
|
||||
destroyed = true;
|
||||
++generation;
|
||||
stopWaiting();
|
||||
world.gate(holdId, null);
|
||||
cancelTimer();
|
||||
unfollowWorld();
|
||||
// The last weather clock leaves the world live, as a new one expects.
|
||||
@@ -306,18 +356,58 @@ export function createWeatherClock({
|
||||
// changes the mode or a product's frame.
|
||||
function followWorld(state) {
|
||||
if (destroyed) return;
|
||||
gateNext(state);
|
||||
// Play starting or stopping changes whether the next frame loads ahead.
|
||||
const playsOn = worldPlaysOn(state);
|
||||
const playChanged = playsOn !== playedOn;
|
||||
playedOn = playsOn;
|
||||
const nextMode = state.live ? 'latest' : 'history';
|
||||
const nextTarget = state.live
|
||||
? null
|
||||
: new Date(Math.floor(state.timeMs)).toISOString();
|
||||
const key = nextTarget ?? 'latest';
|
||||
if (key === seen) return;
|
||||
if (key === seen) {
|
||||
if (playChanged) notify();
|
||||
return;
|
||||
}
|
||||
seen = key;
|
||||
const modeChanged = nextMode !== mode;
|
||||
mode = nextMode;
|
||||
target = nextTarget;
|
||||
if (moving || modeChanged || selections() !== applied) void transition();
|
||||
else notify();
|
||||
if (!moving && !modeChanged && selections() === applied) {
|
||||
// Back at the frame that loads, play need not hold for it.
|
||||
if (deferred) {
|
||||
deferred = false;
|
||||
stopWaiting();
|
||||
}
|
||||
// Played through, the time moves each frame with nothing new to show:
|
||||
// listeners hear of the frames as they change.
|
||||
if (!playsOn || playChanged) notify();
|
||||
return;
|
||||
}
|
||||
// Playing on into the next frame while the one before it loads, the
|
||||
// world holds here until that one shows.
|
||||
if (!moving && !modeChanged && pending && playsOn) {
|
||||
deferred = true;
|
||||
startWaiting();
|
||||
notify();
|
||||
return;
|
||||
}
|
||||
void transition();
|
||||
}
|
||||
// Playing on its own, the world stops at each next frame to load it rather
|
||||
// than run past frames between its ticks.
|
||||
let gated = null;
|
||||
let playedOn = false;
|
||||
function gateNext(state) {
|
||||
const next = worldPlaysOn(state)
|
||||
? (getTimeline()
|
||||
.map((time) => Date.parse(time))
|
||||
.find((time) => time > Math.floor(state.timeMs)) ?? null)
|
||||
: null;
|
||||
if (next === gated) return;
|
||||
gated = next;
|
||||
world.gate(holdId, next);
|
||||
}
|
||||
const unfollowWorld = world.subscribe(followWorld);
|
||||
driver = world.attachDriver({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
REGIONAL_INFRARED_MAX_GAP_MS,
|
||||
GLOBAL_INFRARED_MAX_GAP_MS,
|
||||
LIGHTNING_MAX_GAP_MS,
|
||||
PLAY_WAIT_MS,
|
||||
} from './clock.js';
|
||||
import { createWorldClock } from '../../time/worldClock.js';
|
||||
|
||||
@@ -493,3 +494,216 @@ test('a clock destroyed while it registers a product lets go of it', (t) => {
|
||||
assert.equal(world.coverage(), null);
|
||||
assert.equal(world.getState().supported, false);
|
||||
});
|
||||
|
||||
// A radar whose frames load only when the test says.
|
||||
function slowRadar(minutes) {
|
||||
const radar = product('radar', minutes);
|
||||
radar.loads = [];
|
||||
radar.apply = (selected, { signal }) =>
|
||||
new Promise((resolve) => {
|
||||
radar.loads.push({
|
||||
selected,
|
||||
signal,
|
||||
done() {
|
||||
radar.shown = selected;
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
return radar;
|
||||
}
|
||||
|
||||
test('play stops at the next frame, not past it, until it shows', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = slowRadar([0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(1)));
|
||||
await flush();
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
world.play({ rate: 3_600, continuous: true });
|
||||
world.coverageChanged();
|
||||
tick(1_000);
|
||||
assert.equal(
|
||||
world.now(),
|
||||
Date.parse(time(10)),
|
||||
'an hour on, but held at the frame',
|
||||
);
|
||||
});
|
||||
|
||||
test('playing, a frame loads as play goes on, and play holds at the next until it shows', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = slowRadar([0, 10, 20, 30]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(9)));
|
||||
await flush();
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
world.play({ rate: 60, continuous: true });
|
||||
// Into the next frame (the page's frames tell of it): it loads as play goes on.
|
||||
tick(2_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
assert.equal(radar.loads.at(-1).selected, time(10));
|
||||
assert.equal(
|
||||
world.getState().buffering,
|
||||
false,
|
||||
'no hold for the frame play is at',
|
||||
);
|
||||
// At the frame after while that one still loads, play holds there.
|
||||
tick(10_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
assert.equal(world.now(), Date.parse(time(20)));
|
||||
assert.equal(world.getState().buffering, true);
|
||||
assert.equal(
|
||||
radar.loads.at(-1).selected,
|
||||
time(10),
|
||||
'the frame before still loads',
|
||||
);
|
||||
tick(5_000);
|
||||
assert.equal(world.now(), Date.parse(time(20)), 'held while it loads');
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
assert.equal(
|
||||
radar.loads.at(-1).selected,
|
||||
time(20),
|
||||
'and on to the next once it shows',
|
||||
);
|
||||
assert.equal(world.getState().buffering, false);
|
||||
tick(1_000);
|
||||
assert.equal(world.now(), Date.parse(time(20)) + 60_000);
|
||||
|
||||
// A scrub aborts what loads and loads where the hand is, with no wait.
|
||||
world.pause();
|
||||
world.seek(Date.parse(time(30)));
|
||||
await flush();
|
||||
const scrubbed = radar.loads.at(-1);
|
||||
world.seek(Date.parse(time(0)));
|
||||
await flush();
|
||||
assert.equal(scrubbed.signal.aborted, true);
|
||||
assert.equal(radar.loads.at(-1).selected, time(0));
|
||||
assert.equal(world.getState().buffering, false);
|
||||
});
|
||||
|
||||
test('a frame that never comes holds play only so long', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = slowRadar([0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(9)));
|
||||
await flush();
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
world.play({ rate: 60, continuous: true });
|
||||
tick(2_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
tick(10_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
assert.equal(world.getState().buffering, true);
|
||||
const stuck = radar.loads.at(-1);
|
||||
tick(PLAY_WAIT_MS);
|
||||
await flush();
|
||||
assert.equal(world.getState().buffering, false, 'play goes on without it');
|
||||
assert.equal(stuck.signal.aborted, true);
|
||||
assert.equal(radar.loads.at(-1).selected, time(20), 'loading where play is');
|
||||
});
|
||||
|
||||
test('held at the next frame, a scrub back to the one loading lets play go', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = slowRadar([0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(9)));
|
||||
await flush();
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
world.play({ rate: 60, continuous: true });
|
||||
tick(2_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
tick(10_000);
|
||||
world.coverageChanged();
|
||||
await flush();
|
||||
assert.equal(world.getState().buffering, true);
|
||||
world.pause();
|
||||
world.seek(Date.parse(time(11)));
|
||||
await flush();
|
||||
world.play({ rate: 60, continuous: true });
|
||||
assert.equal(
|
||||
world.getState().buffering,
|
||||
false,
|
||||
'no hold for the frame it is at',
|
||||
);
|
||||
const at = world.now();
|
||||
tick(1_000);
|
||||
assert.equal(world.now(), at + 60_000);
|
||||
});
|
||||
|
||||
test('a frame in flight as play starts loads as it goes on; a refresh does not hold', async (t) => {
|
||||
const { clock, world } = harness(t);
|
||||
const radar = slowRadar([0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(5)));
|
||||
await flush();
|
||||
world.play({ continuous: true });
|
||||
assert.equal(world.getState().buffering, false);
|
||||
radar.loads.at(-1).done();
|
||||
await flush();
|
||||
void clock.refresh();
|
||||
await flush();
|
||||
assert.equal(world.getState().buffering, false, 'the same frames, no hold');
|
||||
radar.loads.at(-1).done();
|
||||
});
|
||||
|
||||
test('weather clocks sharing a world gate it each on their own', async (t) => {
|
||||
let mono = 0;
|
||||
const world = createWorldClock({
|
||||
now: () => Date.parse(time(30)),
|
||||
monotonicNow: () => mono,
|
||||
setInterval: () => 0,
|
||||
clearInterval() {},
|
||||
});
|
||||
const a = createWeatherClock({ world, now: () => Date.parse(time(30)) });
|
||||
const b = createWeatherClock({ world, now: () => Date.parse(time(30)) });
|
||||
t.after(() => {
|
||||
a.destroy();
|
||||
b.destroy();
|
||||
});
|
||||
a.register(product('radar', [0, 10]));
|
||||
b.register(product('clouds', [0, 15]));
|
||||
world.seek(Date.parse(time(1)));
|
||||
await flush();
|
||||
world.play({ rate: 3_600, continuous: true });
|
||||
world.coverageChanged();
|
||||
b.destroy();
|
||||
world.coverageChanged();
|
||||
mono += 1_000;
|
||||
assert.equal(
|
||||
world.now(),
|
||||
Date.parse(time(10)),
|
||||
'the other clock’s gate stays',
|
||||
);
|
||||
});
|
||||
|
||||
test('played through, listeners hear of frames as they change, and of play stopping', async (t) => {
|
||||
const { clock, world, tick } = harness(t);
|
||||
const radar = product('radar', [0, 10, 20]);
|
||||
clock.register(radar);
|
||||
world.seek(Date.parse(time(1)));
|
||||
await flush();
|
||||
world.play({ rate: 60, continuous: true });
|
||||
await flush();
|
||||
const heard = [];
|
||||
clock.subscribe((state) => heard.push(state));
|
||||
// Seconds of world time within one frame: nothing new to show.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
tick(100);
|
||||
world.coverageChanged();
|
||||
}
|
||||
await flush();
|
||||
assert.equal(heard.length, 0);
|
||||
world.pause();
|
||||
await flush();
|
||||
assert.equal(heard.at(-1)?.advancing, false, 'heard play stop');
|
||||
});
|
||||
@@ -109,7 +109,8 @@ export function createWeatherLayer({
|
||||
const notify = () => listener?.();
|
||||
const shownTime = () => (noFrame ? null : rendering?.getDiagnostics().time);
|
||||
const unsubscribeClock = clock?.subscribe(() => {
|
||||
if (!clock.getState().playing || suspended()) rendering?.cancelPrefetch?.();
|
||||
if (!clock.getState().advancing || suspended())
|
||||
rendering?.cancelPrefetch?.();
|
||||
notify();
|
||||
});
|
||||
const observationDelayed = () =>
|
||||
@@ -211,7 +212,7 @@ export function createWeatherLayer({
|
||||
if (
|
||||
!enabled ||
|
||||
suspended() ||
|
||||
!(clock ? clock.getState().playing : playing)
|
||||
!(clock ? clock.getState().advancing : playing)
|
||||
)
|
||||
return;
|
||||
const times = manifest?.times ?? [];
|
||||
|
||||
+81
-11
@@ -24,15 +24,21 @@ const MAX_DATE_MS = 8.64e15;
|
||||
export const isWorldTime = (ms) =>
|
||||
Number.isFinite(ms) && Math.abs(ms) <= MAX_DATE_MS;
|
||||
|
||||
/** How often subscribers hear of a continuously playing cursor. */
|
||||
/**
|
||||
* How often subscribers hear of a continuously playing cursor with no
|
||||
* animation frames to go by; with them, they hear of it every frame.
|
||||
*/
|
||||
export const WORLD_CLOCK_TICK_MS = 250;
|
||||
|
||||
/**
|
||||
* @param {{now?: () => number, monotonicNow?: () => number,
|
||||
* setInterval?: typeof globalThis.setInterval,
|
||||
* clearInterval?: typeof globalThis.clearInterval,
|
||||
* requestFrame?: typeof globalThis.requestAnimationFrame | null,
|
||||
* cancelFrame?: typeof globalThis.cancelAnimationFrame | null,
|
||||
* follow?: Iterable<string>}} [options] `follow` lists the layers that
|
||||
* move with the cursor from the start.
|
||||
* move with the cursor from the start. Playing, the clock tells its
|
||||
* listeners of the time each animation frame, or each tick without frames.
|
||||
*/
|
||||
export function createWorldClock({
|
||||
// Read at each call, so a stand-in for Date.now (a test's, a harness's)
|
||||
@@ -41,6 +47,8 @@ export function createWorldClock({
|
||||
monotonicNow = () => globalThis.performance.now(),
|
||||
setInterval = globalThis.setInterval,
|
||||
clearInterval = globalThis.clearInterval,
|
||||
requestFrame = globalThis.requestAnimationFrame?.bind(globalThis) ?? null,
|
||||
cancelFrame = globalThis.cancelAnimationFrame?.bind(globalThis) ?? null,
|
||||
follow = [],
|
||||
} = {}) {
|
||||
const listeners = new Set();
|
||||
@@ -60,8 +68,13 @@ export function createWorldClock({
|
||||
let active = null;
|
||||
let ticker = null;
|
||||
let notifying = 0;
|
||||
// Layers loading what the cursor needs; continuous play holds while any is.
|
||||
const waits = new Map();
|
||||
// Times continuous play may not pass yet, by layer: each one's next frame.
|
||||
const gates = new Map();
|
||||
|
||||
const continuous = () => playing && !active;
|
||||
const buffering = () => waits.size > 0;
|
||||
const isFollowed = (id) => following.has(id) || (holds.get(id) ?? 0) > 0;
|
||||
const followedIds = () => [
|
||||
...new Set([...following, ...[...holds.keys()].filter(isFollowed)]),
|
||||
@@ -87,8 +100,11 @@ export function createWorldClock({
|
||||
};
|
||||
const time = () => {
|
||||
if (live) return now();
|
||||
if (continuous()) return cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||
return cursorMs;
|
||||
if (!continuous() || buffering()) return cursorMs;
|
||||
let at = cursorMs + (monotonicNow() - anchorMono) * rate;
|
||||
for (const gate of gates.values())
|
||||
if (gate >= cursorMs) at = Math.min(at, gate);
|
||||
return at;
|
||||
};
|
||||
const getState = () =>
|
||||
Object.freeze({
|
||||
@@ -96,6 +112,10 @@ export function createWorldClock({
|
||||
timeMs: time(),
|
||||
playing,
|
||||
rate,
|
||||
// A driver steps the clock, at its own pace rather than the rate.
|
||||
stepping: Boolean(active),
|
||||
// Playing, but held while a layer loads what the cursor needs.
|
||||
buffering: continuous() && buffering(),
|
||||
following: followedIds(),
|
||||
supported: isSupported(),
|
||||
});
|
||||
@@ -116,11 +136,23 @@ export function createWorldClock({
|
||||
}
|
||||
}
|
||||
};
|
||||
// Playing, the time moves on smoothly: each frame, so the timeline and the
|
||||
// globe move with it and a layer's next frame is met as it comes, or each
|
||||
// tick where there are no frames.
|
||||
const frames = Boolean(requestFrame && cancelFrame);
|
||||
const frame = () => {
|
||||
ticker = requestFrame(frame);
|
||||
notify();
|
||||
};
|
||||
const syncTicker = () => {
|
||||
if (continuous() && listeners.size) {
|
||||
ticker ??= setInterval(notify, WORLD_CLOCK_TICK_MS);
|
||||
if (ticker === null)
|
||||
ticker = frames
|
||||
? requestFrame(frame)
|
||||
: setInterval(notify, WORLD_CLOCK_TICK_MS);
|
||||
} else if (ticker !== null) {
|
||||
clearInterval(ticker);
|
||||
if (frames) cancelFrame(ticker);
|
||||
else clearInterval(ticker);
|
||||
ticker = null;
|
||||
}
|
||||
};
|
||||
@@ -152,23 +184,27 @@ export function createWorldClock({
|
||||
anchorMono = monotonicNow();
|
||||
changed();
|
||||
},
|
||||
/** Back to the wall clock; playback stops. */
|
||||
/** Back to the wall clock; playback stops and its rate goes back to 1. */
|
||||
goLive() {
|
||||
if (live && !playing) return;
|
||||
if (live && !playing && rate === 1) return;
|
||||
const stepping = active;
|
||||
live = true;
|
||||
cursorMs = null;
|
||||
playing = false;
|
||||
active = null;
|
||||
rate = 1;
|
||||
// Its driven(false) finds nothing left to stop.
|
||||
stepping?.driver.pause();
|
||||
changed();
|
||||
},
|
||||
/** Play from the cursor (from now, when live). */
|
||||
play({ rate: nextRate } = {}) {
|
||||
/**
|
||||
* Play from the cursor (from now, when live). A driver steps it unless
|
||||
* `continuous`, which runs the cursor at the rate past every driver.
|
||||
*/
|
||||
play({ rate: nextRate, continuous: runOn = false } = {}) {
|
||||
if (nextRate !== undefined) api.setRate(nextRate);
|
||||
if (playing) return;
|
||||
for (let index = drivers.length - 1; index >= 0; index -= 1) {
|
||||
for (let index = drivers.length - 1; !runOn && index >= 0; index -= 1) {
|
||||
if (drivers[index].driver.play() !== false) return;
|
||||
}
|
||||
if (live) {
|
||||
@@ -192,6 +228,40 @@ export function createWorldClock({
|
||||
togglePlay() {
|
||||
return playing ? api.pause() : api.play();
|
||||
},
|
||||
/**
|
||||
* Hold continuous play where it is while layer `id` loads what the cursor
|
||||
* needs, as a video waits on its buffer; play goes on once every hold is
|
||||
* released. A seek still moves the cursor. Returns the release. Neither
|
||||
* tells listeners (play's own ticks do), so a layer can take and release a
|
||||
* hold mid-change without anyone moving the clock under it.
|
||||
*/
|
||||
wait(id) {
|
||||
settle();
|
||||
waits.set(id, (waits.get(id) ?? 0) + 1);
|
||||
let held = true;
|
||||
return () => {
|
||||
if (!held) return;
|
||||
held = false;
|
||||
const count = waits.get(id) - 1;
|
||||
if (count > 0) {
|
||||
waits.set(id, count);
|
||||
return;
|
||||
}
|
||||
waits.delete(id);
|
||||
// Play goes on from where it held, not from where it would have been.
|
||||
if (!buffering()) anchorMono = monotonicNow();
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Keep continuous play from passing `ms` (null lifts it): the time layer
|
||||
* `id` shows next, which it has yet to load. Play stops there, to wait on
|
||||
* the load as it starts, rather than rush past frames between ticks.
|
||||
*/
|
||||
gate(id, ms) {
|
||||
settle();
|
||||
if (ms === null || ms === undefined) gates.delete(id);
|
||||
else if (isWorldTime(ms)) gates.set(id, ms);
|
||||
},
|
||||
/** Simulated ms per real ms for continuous playback. */
|
||||
setRate(nextRate) {
|
||||
if (!Number.isFinite(nextRate) || nextRate <= 0)
|
||||
|
||||
@@ -305,3 +305,85 @@ test('a coverage that throws counts as none', (t) => {
|
||||
assert.equal(clock.getState().supported, false);
|
||||
assert.equal(clock.coverage(), null);
|
||||
});
|
||||
|
||||
test('continuous play runs past every driver, and live resets the rate', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
const calls = [];
|
||||
clock.attachDriver({ play: () => calls.push('play'), pause() {} });
|
||||
clock.seek(t.wall - 60_000);
|
||||
clock.play({ rate: 30, continuous: true });
|
||||
assert.deepEqual(calls, []);
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), t.wall - 61_000 + 30_000);
|
||||
clock.goLive();
|
||||
assert.equal(clock.getRate(), 1);
|
||||
});
|
||||
|
||||
test('continuous play holds while a layer waits, and goes on from there', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
const start = t.wall - 600_000;
|
||||
clock.seek(start);
|
||||
clock.play({ rate: 60, continuous: true });
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), start + 60_000);
|
||||
const release = clock.wait('radar');
|
||||
const other = clock.wait('clouds');
|
||||
assert.equal(clock.getState().buffering, true);
|
||||
t.advance(5_000);
|
||||
assert.equal(clock.now(), start + 60_000, 'held while loading');
|
||||
release();
|
||||
release();
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), start + 60_000, 'held until every layer is ready');
|
||||
other();
|
||||
assert.equal(clock.getState().buffering, false);
|
||||
t.advance(1_000);
|
||||
assert.equal(clock.now(), start + 120_000, 'on from where it held');
|
||||
});
|
||||
|
||||
test('a seek still moves a waiting clock', () => {
|
||||
const t = fakeTime();
|
||||
const clock = createWorldClock(t.options);
|
||||
clock.seek(t.wall - 600_000);
|
||||
clock.play({ continuous: true });
|
||||
const release = clock.wait('radar');
|
||||
clock.seek(t.wall - 60_000);
|
||||
assert.equal(clock.now(), t.wall - 60_000);
|
||||
release();
|
||||
});
|
||||
|
||||
test('playing, listeners hear of the time every animation frame', () => {
|
||||
const t = fakeTime();
|
||||
const frames = new Map();
|
||||
let nextFrame = 1;
|
||||
const clock = createWorldClock({
|
||||
...t.options,
|
||||
requestFrame: (fn) => {
|
||||
frames.set(nextFrame, fn);
|
||||
return nextFrame++;
|
||||
},
|
||||
cancelFrame: (id) => frames.delete(id),
|
||||
});
|
||||
const heard = [];
|
||||
clock.subscribe((state) => heard.push(state.timeMs));
|
||||
const start = t.wall - 60_000;
|
||||
clock.seek(start);
|
||||
clock.play({ rate: 60 });
|
||||
assert.equal(t.timers.size, 0, 'no ticks with frames to go by');
|
||||
assert.equal(frames.size, 1);
|
||||
const runFrame = () => {
|
||||
const [[id, fn]] = frames;
|
||||
frames.delete(id);
|
||||
fn();
|
||||
};
|
||||
heard.length = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
t.advance(16);
|
||||
runFrame();
|
||||
}
|
||||
assert.deepEqual(heard, [start + 960, start + 1_920, start + 2_880]);
|
||||
clock.pause();
|
||||
assert.equal(frames.size, 0);
|
||||
});
|
||||
@@ -365,6 +365,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,
|
||||
@@ -404,6 +405,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),
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ function cockpitBodyClasses() {
|
||||
|
||||
test('the Cockpit adds its subject classes to <body>', () => {
|
||||
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 <body>`);
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -85,12 +85,9 @@ export function relocalizeBrief() {
|
||||
Object.assign(this.brief, cycleWords(this.briefAutoRotateEnabled));
|
||||
this.showBriefPage(this.briefPageIndex);
|
||||
if (this.regionalBrief)
|
||||
this.renderRegionalBrief(this.regionalBrief, this.lastAircraftInfo);
|
||||
this.renderRegionalBrief(this.regionalBrief, this.lastSubjectInfo);
|
||||
else if (this.brief.news.state)
|
||||
this.renderRegionalBriefStatus(
|
||||
this.brief.news.state,
|
||||
this.lastAircraftInfo,
|
||||
);
|
||||
this.renderRegionalBriefStatus(this.brief.news.state, this.lastSubjectInfo);
|
||||
}
|
||||
|
||||
export function setBriefAutoRotate(enabled) {
|
||||
@@ -156,7 +153,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;
|
||||
|
||||
+240
-11
@@ -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,216 @@ 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. Once a full-detail read has answered, only full-detail reads
|
||||
* change the height; before then a loaded-tile read may find raised water
|
||||
* but never takes a raised height back to the sea. One coarse probe cannot
|
||||
* drop a river bridge to the sea, and a read that sampled nothing keeps
|
||||
* the height it had.
|
||||
* @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,
|
||||
// Whether a full-detail read has answered for this ship.
|
||||
landed: false,
|
||||
inFlight: false,
|
||||
};
|
||||
this.vesselWaterline = read;
|
||||
} else {
|
||||
read.sampledMs = nowMs;
|
||||
if (read.inFlight && !read.landed) {
|
||||
const loadedM = loadedWaterlineM(scene, probes, seaM);
|
||||
// Taking a river back to the sea is the full-detail read's to say.
|
||||
const lowers =
|
||||
Number.isFinite(read.heightM) &&
|
||||
read.heightM > seaM &&
|
||||
!(loadedM > seaM);
|
||||
if (Number.isFinite(loadedM) && !lowers) 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) {
|
||||
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; one that sampled nothing (tiles
|
||||
// not loaded yet) is no answer and keeps the height already read.
|
||||
const heights = (samples || []).map((sample) => sample?.height);
|
||||
if (!heights.some(Number.isFinite)) return;
|
||||
read.landed = true;
|
||||
read.heightM = vesselWaterlineM(seaM, heights);
|
||||
},
|
||||
() => {
|
||||
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(),
|
||||
);
|
||||
// The radar centres here too, so it agrees with the view out the window.
|
||||
this.vesselFix = fix;
|
||||
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 +322,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 +346,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,
|
||||
@@ -187,9 +407,18 @@ function placeCamera(target, info, nowMs, dtSec, profile) {
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
this.scratchTargetCartographic,
|
||||
);
|
||||
// A bridge's target is its fix dead-reckoned up to minutes ahead: read
|
||||
// the floor where the ship is now, as its water is.
|
||||
const floorAt =
|
||||
info.kind === 'vessel'
|
||||
? {
|
||||
latitude: Cesium.Math.toDegrees(targetCartographic.latitude),
|
||||
longitude: Cesium.Math.toDegrees(targetCartographic.longitude),
|
||||
}
|
||||
: info;
|
||||
let cockpitFloorM = this.services.cachedGroundFloor(
|
||||
info.latitude,
|
||||
info.longitude,
|
||||
floorAt.latitude,
|
||||
floorAt.longitude,
|
||||
);
|
||||
if (info.onGround === true) {
|
||||
const groundPoint = [{ lat: info.latitude, lon: info.longitude }];
|
||||
@@ -296,8 +525,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 +535,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,
|
||||
|
||||
+120
-8
@@ -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.
|
||||
@@ -179,7 +217,8 @@ export class CockpitViewController {
|
||||
this.contextView = createContextState();
|
||||
this.brief = createBriefState();
|
||||
this.briefTimer = null;
|
||||
this.lastAircraftInfo = null;
|
||||
// The last aircraft or vessel the HUD drew.
|
||||
this.lastSubjectInfo = null;
|
||||
this.regionalBrief = null;
|
||||
this.regionalBriefAnchor = null;
|
||||
this.regionalBriefFetchedAt = 0;
|
||||
@@ -216,9 +255,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 +293,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 +343,9 @@ export class CockpitViewController {
|
||||
readAircraftInfo() {
|
||||
return readAircraftInfo.call(this);
|
||||
}
|
||||
readVesselInfo() {
|
||||
return readVesselInfo.call(this);
|
||||
}
|
||||
readSubjectLayerId() {
|
||||
return readSubjectLayerId.call(this);
|
||||
}
|
||||
@@ -316,8 +373,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 +394,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.lastSubjectInfo)
|
||||
this.updateRadar(this.lastSubjectInfo);
|
||||
}
|
||||
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 +535,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) {
|
||||
@@ -444,10 +556,10 @@ export class CockpitViewController {
|
||||
this.relocalizeBrief();
|
||||
this.publishVision(createVisionState(this.visionMode));
|
||||
this.routeSignature = null;
|
||||
if (this.active && this.lastAircraftInfo) {
|
||||
if (this.active && this.lastSubjectInfo) {
|
||||
this.rewordingSignals = true;
|
||||
try {
|
||||
this.updateHud(this.lastAircraftInfo, performance.now(), true);
|
||||
this.updateHud(this.lastSubjectInfo, performance.now(), true);
|
||||
} finally {
|
||||
this.rewordingSignals = false;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -158,6 +172,10 @@ export class CockpitCoordinator {
|
||||
_cockpitEntryRefusal() {
|
||||
if (this._contextModeChanging)
|
||||
return 'Context is still changing — try Cockpit again in a moment';
|
||||
// Tracking an aircraft from the bridge ends the bridge and clears the
|
||||
// vessel selection before the entry can run, leaving neither.
|
||||
if (this.cockpitView?.active && this.cockpitView.subjectKind === 'vessel')
|
||||
return 'Cockpit is on a vessel bridge — exit the bridge first, then track an aircraft to enter Cockpit';
|
||||
if (!this._aircraftLayerEnabled)
|
||||
return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit';
|
||||
return null;
|
||||
@@ -167,6 +185,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 +218,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),
|
||||
|
||||
@@ -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) => {
|
||||
@@ -197,6 +199,27 @@ test('with no aircraft layer on, entry is refused for that reason, never for Con
|
||||
assert.equal(cockpit.active, false);
|
||||
});
|
||||
|
||||
test('voice entry on a vessel bridge refuses before it touches tracking', (t) => {
|
||||
const { coordinator, cockpit } = fixture(t, { enabled: ['flights'] });
|
||||
let navigated = false;
|
||||
coordinator.services.militaryAwarenessLayer.navigateNext = () => {
|
||||
navigated = true;
|
||||
return true;
|
||||
};
|
||||
// Stand in for a bridge ride: the refusal reads only these two.
|
||||
cockpit.active = true;
|
||||
cockpit.subjectKind = 'vessel';
|
||||
t.after(() => {
|
||||
cockpit.active = false;
|
||||
cockpit.subjectKind = null;
|
||||
});
|
||||
const voice = coordinator.controlCockpit('enter', { targetLayer: 'flights' });
|
||||
assert.equal(voice.ok, false);
|
||||
assert.match(voice.error, /vessel bridge/);
|
||||
assert.equal(navigated, false, 'no aircraft was tracked');
|
||||
assert.equal(cockpit.subjectKind, 'vessel');
|
||||
});
|
||||
|
||||
test('with an aircraft layer on but nothing tracked, the reason is the missing aircraft', (t) => {
|
||||
const { coordinator } = fixture(t, { enabled: ['military'] });
|
||||
assert.equal(
|
||||
@@ -439,6 +462,22 @@ test('a ride handed to an aircraft on a layer Contacts turned on keeps that laye
|
||||
assert.deepEqual(on(manager), ['flights', 'military']);
|
||||
});
|
||||
|
||||
test('Live AIS Vessels, once adopted as the ridden layer, outlives Contacts', 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 +504,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/);
|
||||
});
|
||||
+135
-14
@@ -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,54 @@ function subjectCanvasPosition() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 mmsi = this.readVesselInfo()?.mmsi ?? null;
|
||||
this.doubleClickStart = at ? { x: at.x, y: at.y, atMs: nowMs, mmsi } : 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 &&
|
||||
// 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.mmsi === (this.readVesselInfo()?.mmsi ?? null) &&
|
||||
doubleClickHitsSubject(position, start));
|
||||
if (!hit) return false;
|
||||
this.enter();
|
||||
return true;
|
||||
}
|
||||
@@ -91,12 +144,66 @@ 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=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.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 +246,33 @@ 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, but
|
||||
// only when the bridge can be entered: otherwise C stays the CCTV
|
||||
// shortcut, as it is with nothing selected.
|
||||
const bridgeAttempt =
|
||||
!cockpitAttempt &&
|
||||
!!this.readVesselInfo() &&
|
||||
this.isBridgeEntryAllowed();
|
||||
if (!cockpitAttempt && !bridgeAttempt) return;
|
||||
if (bridgeAttempt) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
this.lastAircraftInfo = info;
|
||||
if (info?.kind === 'vessel') {
|
||||
updateVesselHud.call(this, info, nowMs, forceContext);
|
||||
return;
|
||||
}
|
||||
this.lastSubjectInfo = 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,157 @@ 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.lastSubjectInfo = 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. Own ship sits where
|
||||
* the camera rides (its fix dead-reckoned); contacts sit at their last
|
||||
* fixes, where the vessel layer draws them, so the scope matches the
|
||||
* window.
|
||||
* @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 own = this.vesselFix ?? info;
|
||||
const contacts = layer.getRadarContacts(
|
||||
own.latitude,
|
||||
own.longitude,
|
||||
radarRangeM(rangeNm),
|
||||
RADAR_MAX_CONTACTS,
|
||||
);
|
||||
const picture = radarPicture(
|
||||
{
|
||||
latitude: own.latitude,
|
||||
longitude: own.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 +403,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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,27 @@ 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.vesselFix = 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 +247,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 +257,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 +326,16 @@ 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.vesselFix = 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 +348,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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+95
-12
@@ -8,10 +8,18 @@ const LIVE_TICK_MS = 1000;
|
||||
const LIVE_EDGE = 0.005;
|
||||
/** The least that counts as at now, past the readout's own second. */
|
||||
const LIVE_EDGE_MS = 5000;
|
||||
/** The speeds play offers, in world ms per real ms. */
|
||||
export const WORLD_TIME_SPEEDS = Object.freeze({
|
||||
real: 1,
|
||||
minute: 60,
|
||||
hour: 3_600,
|
||||
day: 86_400,
|
||||
week: 604_800,
|
||||
});
|
||||
|
||||
/**
|
||||
* The world clock as the timeline shows it: `{live, playing, timeMs,
|
||||
* supported, span}`, where `supported` says a layer that moves with the clock
|
||||
* The world clock as the timeline shows it: `{live, playing, stepping,
|
||||
* buffering, timeMs, supported, span}`, where `supported` says a layer that moves with the clock
|
||||
* has time to show and `span` is the stretch the timeline runs over: the
|
||||
* time those layers cover, reaching to now and to the world time, with their
|
||||
* marks. It follows the page's world clock, which tells it of every move, and
|
||||
@@ -20,26 +28,68 @@ const LIVE_EDGE_MS = 5000;
|
||||
* track's end keep up with now.
|
||||
*/
|
||||
export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
const spanOf = (state) => {
|
||||
if (!state.supported) return null;
|
||||
// The time the layers cover, reaching to now.
|
||||
const reach = () => {
|
||||
const coverage = worldClock.coverage();
|
||||
if (!coverage) return null;
|
||||
const now = worldClock.wallNow();
|
||||
return Object.freeze({
|
||||
start: Math.min(coverage.start, now, state.timeMs),
|
||||
end: Math.max(coverage.end, now, state.timeMs),
|
||||
return {
|
||||
start: Math.min(coverage.start, now),
|
||||
end: Math.max(coverage.end, now),
|
||||
marks: coverage.marks,
|
||||
};
|
||||
};
|
||||
const spanOf = (state) => {
|
||||
if (!state.supported) return null;
|
||||
const covered = reach();
|
||||
if (!covered) return null;
|
||||
return Object.freeze({
|
||||
start: Math.min(covered.start, state.timeMs),
|
||||
end: Math.max(covered.end, state.timeMs),
|
||||
marks: covered.marks,
|
||||
});
|
||||
};
|
||||
const nearNow = (ms, length) =>
|
||||
Math.abs(ms - worldClock.wallNow()) <=
|
||||
Math.max(LIVE_EDGE_MS, length * LIVE_EDGE);
|
||||
// Whether the track ends at now (and moves on with it), not past it.
|
||||
const endsAtNow = (covered) =>
|
||||
covered.end - worldClock.wallNow() <= LIVE_EDGE_MS;
|
||||
// Play stops at the end of the track: at now it goes live, past now (a
|
||||
// satellite's future) it holds there.
|
||||
let stopping = false;
|
||||
const stopAtEnd = (state) => {
|
||||
if (stopping || !state.playing || state.live || !state.supported)
|
||||
return false;
|
||||
const covered = reach();
|
||||
if (!covered || state.timeMs < covered.end) return false;
|
||||
stopping = true;
|
||||
try {
|
||||
if (endsAtNow(covered)) worldClock.goLive();
|
||||
else {
|
||||
// Back to the end first, so nothing hears the overshoot as paused.
|
||||
worldClock.seek(covered.end);
|
||||
worldClock.pause();
|
||||
worldClock.seek(covered.end);
|
||||
}
|
||||
} finally {
|
||||
stopping = false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const read = (state = worldClock.getState()) =>
|
||||
Object.freeze({
|
||||
live: state.live,
|
||||
playing: state.playing,
|
||||
stepping: state.stepping,
|
||||
buffering: state.buffering,
|
||||
timeMs: state.timeMs,
|
||||
supported: state.supported,
|
||||
span: spanOf(state),
|
||||
});
|
||||
const time = shallowRef(read());
|
||||
const speed = shallowRef('real');
|
||||
const rate = () => WORLD_TIME_SPEEDS[speed.value];
|
||||
let ticker = null;
|
||||
const syncTicker = () => {
|
||||
// Playing, the clock tells of the time itself.
|
||||
@@ -54,6 +104,7 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
}
|
||||
};
|
||||
const unsubscribe = worldClock.subscribe((state) => {
|
||||
if (stopAtEnd(state)) return;
|
||||
time.value = read(state);
|
||||
syncTicker();
|
||||
});
|
||||
@@ -66,13 +117,45 @@ export const useWorldTimeStore = defineStore('gev-world-time', () => {
|
||||
|
||||
return {
|
||||
time,
|
||||
togglePlay: () => worldClock.togglePlay(),
|
||||
/**
|
||||
* Play runs the track at the speed chosen, from the start when it is at
|
||||
* its end (at live, say), as a video plays again from the top. It runs
|
||||
* the clock itself, past weather's own replay: weather follows the time
|
||||
* frame by frame like every other layer the track covers.
|
||||
*/
|
||||
togglePlay() {
|
||||
const { playing, live, timeMs, span } = time.value;
|
||||
if (playing) return worldClock.pause();
|
||||
const covered = reach();
|
||||
if (span && covered && (live || timeMs >= covered.end))
|
||||
worldClock.seek(span.start);
|
||||
worldClock.play({ rate: rate(), continuous: true });
|
||||
},
|
||||
speed,
|
||||
/**
|
||||
* Choose how fast play runs; playing, it changes at once. Weather's own
|
||||
* replay steps at its own pace, so a speed chosen while it plays takes
|
||||
* playback over from where it is.
|
||||
*/
|
||||
setSpeed(next) {
|
||||
if (!Object.hasOwn(WORLD_TIME_SPEEDS, next)) return;
|
||||
speed.value = next;
|
||||
const { playing, stepping } = worldClock.getState();
|
||||
if (!playing) return;
|
||||
if (!stepping) return worldClock.setRate(rate());
|
||||
worldClock.pause();
|
||||
worldClock.play({ rate: rate(), continuous: true });
|
||||
},
|
||||
goLive: () => worldClock.goLive(),
|
||||
/** Scrub to `ms`: playback stops where the hand puts it; at now, live. */
|
||||
scrub(ms) {
|
||||
/**
|
||||
* Scrub to `ms`: playback stops where the hand puts it; at now, live.
|
||||
* How near now counts as now goes by `length`, the stretch the track
|
||||
* shows, all of the span unless it is zoomed.
|
||||
*/
|
||||
scrub(ms, length) {
|
||||
const span = time.value.span;
|
||||
const edge = span ? (span.end - span.start) * LIVE_EDGE : 0;
|
||||
if (Math.abs(ms - worldClock.wallNow()) <= Math.max(LIVE_EDGE_MS, edge)) {
|
||||
length ??= span ? span.end - span.start : 0;
|
||||
if (nearNow(ms, length)) {
|
||||
worldClock.goLive();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,9 @@
|
||||
--world-timeline-lift: 0px;
|
||||
}
|
||||
|
||||
/* The bar and the time's bubble over its handle. */
|
||||
body:has(#world-timeline) {
|
||||
--world-timeline-lift: 50px;
|
||||
--world-timeline-lift: 66px;
|
||||
}
|
||||
|
||||
/* Clean view, recording and the cockpit put the dock away; the bar goes too. */
|
||||
@@ -22,7 +23,10 @@ body.cockpit-mode #world-timeline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Time runs left to right in every language, so the handle, the fill, the
|
||||
ticks and the bubble agree under a right-to-left page too. */
|
||||
#world-timeline {
|
||||
direction: ltr;
|
||||
position: fixed;
|
||||
bottom: 2vh;
|
||||
left: 50%;
|
||||
@@ -77,32 +81,83 @@ body.cockpit-mode #world-timeline {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-time {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
#world-timeline .world-timeline-speed {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-edge {
|
||||
flex: none;
|
||||
color: var(--text-secondary);
|
||||
/* The time rides over the handle. It slides across itself as the handle
|
||||
crosses the track (its start at the handle at the track's start, its end at
|
||||
the end), so however wide its date it stays within the track. */
|
||||
#world-timeline .world-timeline-time {
|
||||
--inset: 0px;
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: calc(var(--inset) + (100% - var(--inset)) * var(--at));
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(0, 212, 255, 0.4);
|
||||
border-radius: 6px;
|
||||
background: var(--glass-bg);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
transform: translateX(calc(var(--at) * -100%));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Playing, held while a layer loads the time's data: a spinner by the time. */
|
||||
#world-timeline .world-timeline-time.is-waiting {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: world-timeline-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes world-timeline-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#world-timeline .world-timeline-spinner {
|
||||
animation: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* The track: ticks for each launch or frame, the elapsed fill, and a native
|
||||
range input on top for dragging and the keyboard. */
|
||||
#world-timeline .world-timeline-track {
|
||||
position: relative;
|
||||
/* Two fingers pinch the track, not the page. */
|
||||
touch-action: none;
|
||||
flex: 1 1 auto;
|
||||
cursor: pointer;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Zoomed, the track's ends fade, as it goes on past them. */
|
||||
#world-timeline .world-timeline-track.is-zoomed::before {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(123, 189, 211, 0.22) 12px,
|
||||
rgba(123, 189, 211, 0.22) calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-track::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
@@ -139,6 +194,17 @@ body.cockpit-mode #world-timeline {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Zoomed, the track drags along. */
|
||||
#world-timeline .world-timeline-track.is-zoomed {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-track.is-zoomed:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Only the handle takes the pointer as a slider; the track around it takes
|
||||
taps, drags and pinches itself. */
|
||||
#world-timeline .world-timeline-scrub {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -146,8 +212,29 @@ body.cockpit-mode #world-timeline {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
|
||||
cursor: ew-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub::-moz-range-thumb {
|
||||
cursor: ew-resize;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* The time dragged off the track: the handle waits at its edge, unseen. */
|
||||
#world-timeline .world-timeline-scrub.is-off-view::-webkit-slider-thumb {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub.is-off-view::-moz-range-thumb {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub::-webkit-slider-runnable-track {
|
||||
@@ -178,6 +265,20 @@ body.cockpit-mode #world-timeline {
|
||||
background: #031018;
|
||||
}
|
||||
|
||||
/* A finger needs a bigger handle. */
|
||||
@media (pointer: coarse) {
|
||||
#world-timeline .world-timeline-scrub::-webkit-slider-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub::-moz-range-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-scrub:focus-visible {
|
||||
outline: 1px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
@@ -190,7 +291,7 @@ body.cockpit-mode #world-timeline {
|
||||
}
|
||||
|
||||
/* On phones the bar sits just above the tab bar, under the voice dock, with
|
||||
the track on a row of its own beneath play, the time and live. */
|
||||
the track and the speed on a row of their own beneath play and live. */
|
||||
body.mobile-shell:has(#world-timeline) {
|
||||
--world-timeline-lift: 80px;
|
||||
}
|
||||
@@ -202,21 +303,23 @@ body.mobile-shell #world-timeline {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* The bubble rises into the first row, clear of play on its left. */
|
||||
body.mobile-shell #world-timeline .world-timeline-time {
|
||||
flex: 1 1 0;
|
||||
--inset: 48px;
|
||||
}
|
||||
|
||||
/* The speed sits on the track's row, a full-size target beside it. */
|
||||
body.mobile-shell #world-timeline .world-timeline-track {
|
||||
order: 1;
|
||||
flex-basis: 100%;
|
||||
flex: 1 1 calc(100% - 96px);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#world-timeline .world-timeline-edge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#world-timeline .world-timeline-time {
|
||||
font-size: 11px;
|
||||
}
|
||||
body.mobile-shell #world-timeline .world-timeline-live {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
body.mobile-shell #world-timeline .world-timeline-speed {
|
||||
order: 2;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Aircraft first-person view. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" :aria-label="t('Aircraft cockpit view')" :hidden="!active" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="hud.layer ?? undefined">
|
||||
<!-- Aircraft first-person view or a vessel's bridge. It can be entered from the visible view switcher. -->
|
||||
<section id="cockpit-hud" :aria-label="hudLabel" :hidden="!active" :data-subject="subject" :style="utilityStyle" v-class-toggle="{ 'signals-active': active, 'context-active': Boolean(presence?.context) }" :data-layer="hud.layer ?? undefined">
|
||||
<!-- One optical coordinate system: every targeting mark shares this center.
|
||||
Decorative only; telemetry and interactive panels remain separate. -->
|
||||
<svg class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
||||
Decorative only; telemetry and interactive panels remain separate.
|
||||
A bridge looks out through straight window mullions instead: a header
|
||||
beam, two pane posts and the sill either side of the console. -->
|
||||
<svg v-if="bridge" class="cockpit-frame-lines cockpit-bridge-frame" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
||||
<g fill="none" stroke="currentColor">
|
||||
<path d="M0 96H1200 M0 101H1200"/>
|
||||
<path d="M324 101V600 M876 101V600"/>
|
||||
<path d="M0 600H324 M876 600H1200"/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else class="cockpit-frame-lines" viewBox="0 0 1200 720" preserveAspectRatio="none" aria-hidden="true" focusable="false">
|
||||
<g fill="none" stroke="currentColor">
|
||||
<path d="M42 106H234L266 126H334 M866 126H934L966 106H1158 M42 114H226L258 136H318 M882 136H942L974 114H1158"/>
|
||||
<path d="M42 616H226L254 598H320 M880 598H946L974 616H1158 M42 624H238L266 606H330 M870 606H934L962 624H1158"/>
|
||||
@@ -68,7 +97,7 @@ const tickStyle = (tick) =>
|
||||
<path class="cockpit-frame-warning" d="M338 645H476L490 654H710L724 645H862" fill="none"/>
|
||||
<path class="cockpit-frame-meter" d="M372 642H418 M782 642H828"/>
|
||||
</svg>
|
||||
<div class="cockpit-scan-array" aria-hidden="true">
|
||||
<div v-if="!bridge" class="cockpit-scan-array" aria-hidden="true">
|
||||
<svg class="cockpit-target-geometry" viewBox="0 0 600 600" fill="none" focusable="false">
|
||||
<g class="cockpit-target-hairline">
|
||||
<circle cx="300" cy="300" r="276"/>
|
||||
@@ -101,7 +130,8 @@ const tickStyle = (tick) =>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="cockpit-visor-shell" aria-hidden="true">
|
||||
<div class="cockpit-roll-arc">
|
||||
<!-- A ship's bridge keeps no roll arc or pitch ladder. -->
|
||||
<div v-if="!bridge" class="cockpit-roll-arc">
|
||||
<span style="--slot:-3;--depth:3">−30</span>
|
||||
<span style="--slot:-2;--depth:2">−20</span>
|
||||
<span style="--slot:-1;--depth:1">−10</span>
|
||||
@@ -110,10 +140,10 @@ const tickStyle = (tick) =>
|
||||
<span style="--slot:2;--depth:2">+20</span>
|
||||
<span style="--slot:3;--depth:3">+30</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-left">
|
||||
<div v-if="!bridge" class="cockpit-pitch-rail cockpit-pitch-rail-left">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-pitch-rail cockpit-pitch-rail-right">
|
||||
<div v-if="!bridge" class="cockpit-pitch-rail cockpit-pitch-rail-right">
|
||||
<span>+20</span><span>+10</span><span class="active">00</span><span>−10</span><span>−20</span>
|
||||
</div>
|
||||
<div class="cockpit-horizon-guide">
|
||||
@@ -125,11 +155,11 @@ const tickStyle = (tick) =>
|
||||
</svg>
|
||||
<small id="cockpit-route-direction-label">{{ direction.label }}</small>
|
||||
</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left">{{ t('OPTICAL PLANE · 01') }}</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right">{{ t('VISOR LOCK · ACTIVE') }}</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-left">{{ bridge ? t('BRIDGE WINDOW · FWD') : t('OPTICAL PLANE · 01') }}</div>
|
||||
<div class="cockpit-visor-status cockpit-visor-status-right">{{ bridge ? (hud.trackSource === 'course' ? t('COURSE HOLD · AIS') : hud.trackSource === 'heading' ? t('HEADING HOLD · AIS') : t('NO HEADING · AIS')) : t('VISOR LOCK · ACTIVE') }}</div>
|
||||
</div>
|
||||
<div id="cockpit-speed-rim" class="cockpit-altitude-rim cockpit-speed-rim" v-class-toggle="{ unavailable: hud.speedRim.unavailable }">
|
||||
<span class="cockpit-altitude-rim-label">{{ t('GROUND SPEED · KTS') }}</span>
|
||||
<span class="cockpit-altitude-rim-label">{{ bridge ? t('SPEED OVER GROUND · KTS') : t('GROUND SPEED · KTS') }}</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
<span v-for="(tick, index) in hud.speedRim.ticks" :key="index" data-speed-rim-tick :hidden="tick.hidden" :class="{ major: tick.major }" :style="tickStyle(tick)"><b>{{ tick.label }}</b><i></i></span>
|
||||
@@ -139,7 +169,7 @@ const tickStyle = (tick) =>
|
||||
<strong id="cockpit-speed-rim-value">{{ hud.speedRim.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
|
||||
<div id="cockpit-altitude-rim" class="cockpit-altitude-rim" :hidden="bridge" v-class-toggle="{ unavailable: hud.altitudeRim.unavailable }">
|
||||
<span class="cockpit-altitude-rim-label">{{ t('ALTITUDE · FT') }}</span>
|
||||
<div class="cockpit-altitude-rim-rail"></div>
|
||||
<div class="cockpit-altitude-rim-scale">
|
||||
@@ -152,7 +182,7 @@ const tickStyle = (tick) =>
|
||||
</div>
|
||||
<div class="cockpit-topline">
|
||||
<div>
|
||||
<span class="cockpit-kicker">{{ t('FIRST PERSON') }}</span>
|
||||
<span class="cockpit-kicker">{{ bridge ? t('BRIDGE VIEW') : t('FIRST PERSON') }}</span>
|
||||
<strong id="cockpit-callsign">{{ hud.callsign }}</strong>
|
||||
<span id="cockpit-aircraft-meta" v-class-toggle="{ emergency: hud.meta.emergency }" class="cockpit-aircraft-meta">{{ hud.meta.text }}</span>
|
||||
</div>
|
||||
@@ -172,22 +202,34 @@ const tickStyle = (tick) =>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cockpit-reticle material-symbols-outlined" aria-hidden="true">my_location</div>
|
||||
<div class="cockpit-compass" :aria-label="t('Current aircraft heading')">
|
||||
<!-- A bridge view turned off the bow says where it looks and offers the way back. -->
|
||||
<div v-if="bridge" class="cockpit-look" :hidden="!look.offset">
|
||||
<span id="cockpit-look-readout" class="cockpit-look-readout" aria-live="polite">{{ look.readout }}</span>
|
||||
<button id="cockpit-look-ahead" type="button" class="cockpit-look-ahead" :aria-label="t('Look ahead: turn the bridge view back to the bow')" :title="t('Look ahead (Home or 0)')" aria-keyshortcuts="Home 0">
|
||||
<span>{{ t('LOOK AHEAD') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cockpit-compass" :aria-label="bridge ? t('Current vessel heading') : t('Current aircraft heading')">
|
||||
<div class="cockpit-instrument-row">
|
||||
<div class="cockpit-readout cockpit-speed">
|
||||
<span class="cockpit-readout-label">{{ t('GROUND SPEED') }}</span>
|
||||
<span class="cockpit-readout-label">{{ bridge ? t('SPEED OVER GROUND') : t('GROUND SPEED') }}</span>
|
||||
<CockpitRollingValue id="cockpit-speed-value" tag="strong" :reading="hud.speed" placeholder="---" />
|
||||
<span class="cockpit-readout-unit">{{ t('KTS') }}</span>
|
||||
</div>
|
||||
<div class="cockpit-heading-value"><CockpitRollingValue id="cockpit-heading-value" :reading="hud.heading" placeholder="---" :circular-range="360" /><small>°</small></div>
|
||||
<div class="cockpit-readout cockpit-altitude">
|
||||
<div v-if="bridge" class="cockpit-readout cockpit-altitude cockpit-course">
|
||||
<span class="cockpit-readout-label">{{ t('COURSE OVER GROUND') }}</span>
|
||||
<CockpitRollingValue id="cockpit-course-value" tag="strong" :reading="hud.course" placeholder="---" :circular-range="360" />
|
||||
<span class="cockpit-readout-unit">°</span>
|
||||
</div>
|
||||
<div v-else class="cockpit-readout cockpit-altitude">
|
||||
<span class="cockpit-readout-label">{{ t('ALTITUDE') }}</span>
|
||||
<CockpitRollingValue id="cockpit-altitude-value" tag="strong" :reading="hud.altitude" placeholder="-----" />
|
||||
<span class="cockpit-readout-unit">{{ t('FT') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="cockpit-heading-caret material-symbols-outlined" aria-hidden="true">arrow_drop_down</span>
|
||||
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true"><span v-for="division in hud.compass" :key="division.slot" :class="division.slot === 0 ? 'active' : ''" :style="{ '--slot': division.slot, '--depth': Math.abs(division.slot) }">{{ division.label }}</span></div>
|
||||
<div class="cockpit-compass-tape" id="cockpit-compass-tape" aria-hidden="true" :style="bridge ? { '--tape-shift': tapeShift } : undefined"><span v-for="division in hud.compass" :key="division.heading ?? division.slot" :class="(division.active ?? division.slot === 0) ? 'active' : ''" :style="{ '--slot': division.slot, '--depth': Math.abs(division.slot) }">{{ division.label }}</span><template v-if="bridge"><i v-if="hud.compassMarkers?.prograde != null" class="cockpit-compass-marker cockpit-compass-prograde" aria-hidden="true" :style="{ '--slot': hud.compassMarkers.prograde }"></i><i v-if="hud.compassMarkers?.retrograde != null" class="cockpit-compass-marker cockpit-compass-retrograde" aria-hidden="true" :style="{ '--slot': hud.compassMarkers.retrograde }"></i></template></div>
|
||||
</div>
|
||||
<div class="cockpit-position-readout">
|
||||
<span id="cockpit-clock">{{ hud.clock }}</span>
|
||||
@@ -195,8 +237,12 @@ const tickStyle = (tick) =>
|
||||
</div>
|
||||
<CockpitContext />
|
||||
<CockpitBriefing />
|
||||
<!-- The bridge radar's console: bottom right, below the briefing card. -->
|
||||
<div v-if="bridge" class="cockpit-radar-console">
|
||||
<CockpitRadar />
|
||||
</div>
|
||||
<CockpitUtilityControls />
|
||||
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi> <span>·</span> <bdi>{{ t('C TOGGLE') }}</bdi></div>
|
||||
<div class="cockpit-help"><bdi>{{ t('ESC EXIT') }}</bdi> <span>·</span> <bdi>{{ t('C TOGGLE') }}</bdi><template v-if="bridge"> <span>·</span> <bdi>{{ t('ARROWS LOOK') }}</bdi> <span>·</span> <bdi>{{ t('HOME RESET') }}</bdi></template></div>
|
||||
</section>
|
||||
|
||||
<nav id="view-switcher" :aria-label="t('View switcher')">
|
||||
@@ -204,9 +250,9 @@ const tickStyle = (tick) =>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">public</span>
|
||||
<span>{{ t('RESET') }}</span>
|
||||
</button>
|
||||
<button id="map-view-switch" type="button" :aria-label="t('Exit cockpit view')" aria-keyshortcuts="Escape C" :title="t('Exit cockpit view')" :hidden="!active">
|
||||
<button id="map-view-switch" type="button" :aria-label="exitLabel" aria-keyshortcuts="Escape C" :title="exitLabel" :hidden="!active">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">close_fullscreen</span>
|
||||
<span>{{ t('EXIT COCKPIT') }}</span>
|
||||
<span>{{ exitText }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { formatNumber, t, tn } from '../../../i18n/index.js';
|
||||
import { useCockpitStore } from '../../stores/cockpit.js';
|
||||
|
||||
// The bridge radar: a heading-up scope of nearby AIS vessels, with a line
|
||||
// where the bridge view looks when it is turned off the bow. The Cockpit
|
||||
// controller presents them as positions on the range circle and keeps the
|
||||
// range button's listener; this view draws them. The sweep and each
|
||||
// contact's afterglow are compositor animations sharing one clock, so a
|
||||
// contact brightens as the sweep crosses its bearing without a frame loop.
|
||||
|
||||
/** One sweep turn, in milliseconds. */
|
||||
const SWEEP_MS = 4000;
|
||||
/** Relative bearings of the rim ticks. */
|
||||
const RIM_TICKS = Array.from({ length: 12 }, (_, index) => index * 30);
|
||||
const SWEEP_FRAMES = [
|
||||
{ transform: 'rotate(0turn)' },
|
||||
{ transform: 'rotate(1turn)' },
|
||||
];
|
||||
const GLOW_FRAMES = [
|
||||
{ opacity: 1, offset: 0 },
|
||||
{ opacity: 0.6, offset: 0.2 },
|
||||
{ opacity: 0.25, offset: 1 },
|
||||
];
|
||||
|
||||
const { radar, instruments } = storeToRefs(useCockpitStore());
|
||||
|
||||
// The bridge view's look line: the yaw the view is turned to off the bow,
|
||||
// kept unwrapped so its turn eases the short way across astern. Unwrapping
|
||||
// needs the last angle shown, so it is state a watch keeps, not a computed.
|
||||
const lookDeg = ref(null);
|
||||
watch(
|
||||
() => {
|
||||
const look = instruments.value?.look;
|
||||
return look?.offset && look.yawDeg !== 0 ? look.yawDeg : null;
|
||||
},
|
||||
(yawDeg) => {
|
||||
if (yawDeg === null) {
|
||||
lookDeg.value = null;
|
||||
return;
|
||||
}
|
||||
const from = lookDeg.value ?? 0;
|
||||
lookDeg.value = from + ((((yawDeg - from) % 360) + 540) % 360) - 180;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const motionQuery =
|
||||
typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
: null;
|
||||
const reducedMotion = ref(Boolean(motionQuery?.matches));
|
||||
const onMotionChange = (event) => {
|
||||
reducedMotion.value = event.matches;
|
||||
};
|
||||
motionQuery?.addEventListener?.('change', onMotionChange);
|
||||
onBeforeUnmount(() =>
|
||||
motionQuery?.removeEventListener?.('change', onMotionChange),
|
||||
);
|
||||
|
||||
/**
|
||||
* Run an element's sweep-phased animation, or stop it (`phaseMs` null).
|
||||
* Each animation starts on the document timeline at its phase, so every
|
||||
* one keeps time with the sweep however late it was created.
|
||||
*/
|
||||
function phase(el, frames, phaseMs) {
|
||||
if (phaseMs === null || typeof el.animate !== 'function') {
|
||||
el._radarAnimation?.cancel();
|
||||
el._radarAnimation = null;
|
||||
return;
|
||||
}
|
||||
if (!el._radarAnimation) {
|
||||
el._radarAnimation = el.animate(frames, {
|
||||
duration: SWEEP_MS,
|
||||
iterations: Infinity,
|
||||
easing: 'linear',
|
||||
});
|
||||
// Cancelling on unmount rejects `finished`; nothing waits on it.
|
||||
el._radarAnimation.finished?.catch(() => {});
|
||||
}
|
||||
// Before zero, so the animation is already running at page start.
|
||||
const startTime = phaseMs - SWEEP_MS;
|
||||
if (el._radarAnimation.startTime !== startTime)
|
||||
el._radarAnimation.startTime = startTime;
|
||||
}
|
||||
|
||||
const vSweep = {
|
||||
mounted: (el) => phase(el, SWEEP_FRAMES, 0),
|
||||
unmounted: (el) => phase(el, SWEEP_FRAMES, null),
|
||||
};
|
||||
|
||||
const vAfterglow = {
|
||||
mounted: (el, { value }) => phase(el, GLOW_FRAMES, value),
|
||||
updated: (el, { value }) => phase(el, GLOW_FRAMES, value),
|
||||
unmounted: (el) => phase(el, GLOW_FRAMES, null),
|
||||
};
|
||||
|
||||
/** When in the sweep's turn a bearing is crossed, or null when steady. */
|
||||
function glowPhase(relativeBearingDeg) {
|
||||
if (reducedMotion.value) return null;
|
||||
const clockwise = (((relativeBearingDeg % 360) + 360) % 360) / 360;
|
||||
return Math.round(clockwise * SWEEP_MS);
|
||||
}
|
||||
|
||||
const percent = (fraction) => `${(fraction * 100).toFixed(2)}%`;
|
||||
const contactStyle = (contact) => ({
|
||||
left: percent(0.5 + contact.x / 2),
|
||||
top: percent(0.5 - contact.y / 2),
|
||||
'--radar-contact-color': contact.color || undefined,
|
||||
});
|
||||
const rotate = (deg) => ({ transform: `rotate(${deg}deg)` });
|
||||
|
||||
const rangeLabel = computed(() => radar.value?.rangeLabel ?? '');
|
||||
const ringFractions = computed(() =>
|
||||
radar.value
|
||||
? radar.value.ringsNm.map((ring) => ring / radar.value.rangeNm)
|
||||
: [],
|
||||
);
|
||||
const ringLabel = computed(() =>
|
||||
radar.value?.ringsNm.length
|
||||
? t('RINGS {spacing} NM', {
|
||||
spacing: formatNumber(radar.value.ringsNm[0]),
|
||||
})
|
||||
: '',
|
||||
);
|
||||
const countLabel = computed(() => {
|
||||
const count = radar.value?.contacts.length ?? 0;
|
||||
return tn(count, '{count} CONTACT', '{count} CONTACTS', {
|
||||
count: formatNumber(count),
|
||||
});
|
||||
});
|
||||
const rangeAction = computed(() =>
|
||||
t('Radar range {range}. Select for the next range.', {
|
||||
range: rangeLabel.value,
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="cockpit-radar" class="cockpit-radar" :aria-label="t('Bridge radar')" :hidden="!radar">
|
||||
<div class="cockpit-radar-scope" aria-hidden="true">
|
||||
<template v-if="radar">
|
||||
<span v-for="fraction in ringFractions" :key="fraction" class="cockpit-radar-ring" :style="{ width: percent(fraction), height: percent(fraction) }"></span>
|
||||
<span v-for="tick in RIM_TICKS" :key="tick" class="cockpit-radar-tick" :class="{ 'cockpit-radar-tick-major': tick % 90 === 0 }" :style="rotate(tick)"></span>
|
||||
<span v-if="!reducedMotion" v-sweep class="cockpit-radar-sweep"></span>
|
||||
<span class="cockpit-radar-heading-line"></span>
|
||||
<span v-if="lookDeg !== null" class="cockpit-radar-look" :style="rotate(lookDeg)"></span>
|
||||
<span v-for="contact in radar.contacts" :key="contact.id" v-afterglow="glowPhase(contact.relativeBearingDeg)" class="cockpit-radar-contact" :class="{ 'cockpit-radar-contact-stale': contact.stale }" :style="contactStyle(contact)">
|
||||
<span v-if="contact.relativeCourseDeg !== null" class="cockpit-radar-vector" :style="rotate(contact.relativeCourseDeg)"></span>
|
||||
</span>
|
||||
<span class="cockpit-radar-own">
|
||||
<span v-if="radar.ownRelativeCourseDeg !== null && radar.ownRelativeCourseDeg !== undefined" class="cockpit-radar-vector cockpit-radar-own-vector" :style="rotate(radar.ownRelativeCourseDeg)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="cockpit-radar-readout">
|
||||
<button id="cockpit-radar-range" type="button" class="cockpit-radar-range" :aria-label="rangeAction" :title="rangeAction">{{ rangeLabel }}</button>
|
||||
<span class="cockpit-radar-rings">{{ ringLabel }}</span>
|
||||
<span class="cockpit-radar-count">{{ countLabel }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -14,17 +14,17 @@ const r = computed(() => route.value ?? initial);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="cockpit-route" class="cockpit-route-card" :aria-label="t('Estimated flight plan')" :hidden="!r.visible">
|
||||
<section id="cockpit-route" class="cockpit-route-card" :aria-label="r.kind === 'vessel' ? t('Reported voyage') : t('Estimated flight plan')" :hidden="!r.visible">
|
||||
<div class="cockpit-route-heading">
|
||||
<span class="cockpit-route-kicker">{{ t('ESTIMATED FLIGHT PLAN') }}</span>
|
||||
<span class="cockpit-route-kicker">{{ r.kind === 'vessel' ? t('REPORTED VOYAGE') : t('ESTIMATED FLIGHT PLAN') }}</span>
|
||||
<small id="cockpit-route-status">{{ r.status }}</small>
|
||||
</div>
|
||||
<div class="cockpit-route-endpoints">
|
||||
<div class="cockpit-route-airport">
|
||||
<div v-if="r.kind !== 'vessel'" class="cockpit-route-airport">
|
||||
<span>{{ t('FROM') }}</span>
|
||||
<strong id="cockpit-route-from">{{ r.from }}</strong>
|
||||
</div>
|
||||
<span class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<span v-if="r.kind !== 'vessel'" class="cockpit-route-arrow material-symbols-outlined" aria-hidden="true">east</span>
|
||||
<div class="cockpit-route-airport">
|
||||
<span>{{ t('TO') }}</span>
|
||||
<strong id="cockpit-route-to">{{ r.to }}</strong>
|
||||
|
||||
@@ -148,3 +148,401 @@ test('the track runs over the time the layers show, to now, and scrubs', async (
|
||||
assert.equal(worldClock.isLive(), false, 'a scrub past now stays there');
|
||||
assert.equal(worldClock.now(), ahead);
|
||||
});
|
||||
|
||||
test('play runs in real time, from the start of the track when at live', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 10 * 86_400_000;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({ start, end: now - 86_400_000 }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
// Weather's replay stepping its own frames does not take the track's play.
|
||||
const stepped = [];
|
||||
const driver = worldClock.attachDriver({
|
||||
play: () => stepped.push('play'),
|
||||
pause: () => stepped.push('pause'),
|
||||
});
|
||||
t.after(() => driver.detach());
|
||||
bar().querySelector('.world-timeline-play').click();
|
||||
worldClock.pause();
|
||||
await nextTick();
|
||||
assert.deepEqual(stepped, []);
|
||||
const rate = worldClock.getRate();
|
||||
assert.ok(worldClock.now() - start < rate * 1_000, 'from the start of the track');
|
||||
assert.equal(rate, 1, 'real time to start with');
|
||||
worldClock.play({ continuous: true });
|
||||
|
||||
// Playing on to now goes live.
|
||||
worldClock.seek(Date.now() + 1_000);
|
||||
await nextTick();
|
||||
assert.equal(worldClock.isLive(), true);
|
||||
assert.equal(worldClock.isPlaying(), false);
|
||||
assert.equal(worldClock.getRate(), 1, 'live plays at the rate of the world');
|
||||
});
|
||||
|
||||
test('play holds at the end of a track that reaches past now', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
// An hour ahead is within the track's share of now, but still ahead of it.
|
||||
const end = now + 3_600_000;
|
||||
const release = worldClock.hold('test-ahead');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-ahead',
|
||||
coverage: () => ({ start: now - 10 * 86_400_000, end }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
// Paused just past now, play plays on rather than from the top.
|
||||
worldClock.seek(Date.now() + 1_000);
|
||||
await nextTick();
|
||||
const from = worldClock.now();
|
||||
bar().querySelector('.world-timeline-play').click();
|
||||
worldClock.pause();
|
||||
assert.ok(worldClock.now() >= from, 'plays on from where it was');
|
||||
worldClock.play();
|
||||
worldClock.seek(end + 60_000);
|
||||
await nextTick();
|
||||
assert.equal(worldClock.isPlaying(), false);
|
||||
assert.equal(worldClock.isLive(), false);
|
||||
assert.equal(worldClock.now(), end);
|
||||
});
|
||||
|
||||
test('the speed chosen sets how fast play runs, at once while playing', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({ start: now - 10 * 86_400_000, end: now - 86_400_000 }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const select = bar().querySelector('select.world-timeline-speed');
|
||||
assert.equal(select.value, 'real');
|
||||
assert.deepEqual(
|
||||
[...select.options].map((option) => option.value),
|
||||
['real', 'minute', 'hour', 'day', 'week'],
|
||||
);
|
||||
select.value = 'week';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
bar().querySelector('.world-timeline-play').click();
|
||||
assert.equal(worldClock.getRate(), 604_800, 'a week a second');
|
||||
select.value = 'hour';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
assert.equal(worldClock.isPlaying(), true);
|
||||
assert.equal(worldClock.getRate(), 3_600, 'changed while playing');
|
||||
select.value = 'minute';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
assert.equal(worldClock.getRate(), 60, 'a minute a second');
|
||||
select.value = 'real';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
assert.equal(worldClock.getRate(), 1, 'the world as it goes');
|
||||
});
|
||||
|
||||
test('a speed chosen while weather replay steps takes playback over', async (t) => {
|
||||
const bar = mount(t);
|
||||
const at = Date.now() - 3_600_000;
|
||||
supportingLayer(t, at);
|
||||
const calls = [];
|
||||
const driver = worldClock.attachDriver({
|
||||
play() {
|
||||
calls.push('play');
|
||||
driver.driven(true, at - 600_000);
|
||||
},
|
||||
pause() {
|
||||
calls.push('pause');
|
||||
driver.driven(false);
|
||||
},
|
||||
});
|
||||
t.after(() => driver.detach());
|
||||
worldClock.play();
|
||||
await nextTick();
|
||||
assert.equal(worldClock.getState().stepping, true);
|
||||
const select = bar().querySelector('select.world-timeline-speed');
|
||||
select.value = 'hour';
|
||||
select.dispatchEvent(new Event('change'));
|
||||
await nextTick();
|
||||
assert.deepEqual(calls, ['play', 'pause']);
|
||||
assert.equal(worldClock.getState().stepping, false);
|
||||
assert.equal(worldClock.isPlaying(), true);
|
||||
assert.equal(worldClock.getRate(), 3_600);
|
||||
assert.ok(worldClock.now() - (at - 600_000) < 3_600 * 1_000, 'on from where it was');
|
||||
worldClock.pause();
|
||||
});
|
||||
|
||||
test('paused just before the end, play carries on rather than restarting', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const end = now + 86_400_000;
|
||||
const release = worldClock.hold('test-ahead');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-ahead',
|
||||
coverage: () => ({ start: now - 86_400_000, end }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
worldClock.seek(end - 4_000);
|
||||
await nextTick();
|
||||
bar().querySelector('.world-timeline-play').click();
|
||||
worldClock.pause();
|
||||
assert.ok(worldClock.now() >= end - 4_000, 'not back to the start');
|
||||
});
|
||||
|
||||
test('the track zooms around the handle, follows it, and shows it all again', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 26 * 86_400_000;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
// A month of launches and three hours of lightning, sharing one track.
|
||||
coverage: () => ({
|
||||
start,
|
||||
end: now,
|
||||
marks: [start, now - 3 * 3_600_000, now - 2 * 3_600_000, now - 3_600_000],
|
||||
}),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const track = bar().querySelector('.world-timeline-track');
|
||||
const input = bar().querySelector('input[type=range]');
|
||||
const length = () => Number(input.max) - Number(input.min);
|
||||
const full = length();
|
||||
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 4);
|
||||
|
||||
// Live, the handle is at the end: zooming in keeps the view on now.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -200, cancelable: true }));
|
||||
await nextTick();
|
||||
}
|
||||
assert.ok(length() < full / 100, 'zoomed in');
|
||||
assert.ok(Number(input.max) >= now, 'still ending at now');
|
||||
assert.equal(track.classList.contains('is-zoomed'), true);
|
||||
assert.equal(bar().querySelectorAll('.world-timeline-tick').length, 3, 'only the ticks in view');
|
||||
|
||||
// A seek off the view takes the view along.
|
||||
worldClock.seek(start + 86_400_000);
|
||||
await nextTick();
|
||||
assert.ok(Number(input.min) <= start + 86_400_000 && Number(input.max) >= start + 86_400_000);
|
||||
assert.ok(length() < full / 100, 'at the same zoom');
|
||||
|
||||
// Minus widens, zero and a double-click show it all.
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: '-' }));
|
||||
await nextTick();
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: '0' }));
|
||||
await nextTick();
|
||||
assert.ok(Math.abs(length() - full) < 1_000, 'all of it');
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
|
||||
await nextTick();
|
||||
assert.ok(length() < full);
|
||||
track.dispatchEvent(new MouseEvent('dblclick'));
|
||||
await nextTick();
|
||||
assert.equal(track.classList.contains('is-zoomed'), false);
|
||||
});
|
||||
|
||||
test('zoomed, a scrub near now stays off live, and the view keeps a still handle', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 26 * 86_400_000;
|
||||
let end = now;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({ start, end, marks: [start, now - 3_600_000] }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const track = bar().querySelector('.world-timeline-track');
|
||||
const input = bar().querySelector('input[type=range]');
|
||||
// Ten notches of a line-mode wheel, as Firefox sends them.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
track.dispatchEvent(new WheelEvent('wheel', { deltaY: -12, deltaMode: 1, cancelable: true }));
|
||||
await nextTick();
|
||||
}
|
||||
assert.ok(Number(input.max) - Number(input.min) < 6 * 3_600_000, 'lines zoom like pixels');
|
||||
|
||||
// Twenty minutes back is near now on a month, not on a few hours.
|
||||
input.value = String(now - 20 * 60_000);
|
||||
input.dispatchEvent(new Event('input'));
|
||||
await nextTick();
|
||||
assert.equal(worldClock.isLive(), false);
|
||||
assert.ok(Math.abs(worldClock.now() - (now - 20 * 60_000)) < 1_000);
|
||||
|
||||
// The span running on past the paused handle takes the view back to it.
|
||||
end = now + 86_400_000;
|
||||
worldClock.coverageChanged();
|
||||
await nextTick();
|
||||
const at = worldClock.now();
|
||||
assert.ok(Number(input.min) <= at && Number(input.max) >= at, 'handle in view');
|
||||
|
||||
// With a modifier, plus is the browser's zoom.
|
||||
const browserZoom = new KeyboardEvent('keydown', { key: '+', ctrlKey: true, cancelable: true });
|
||||
input.dispatchEvent(browserZoom);
|
||||
assert.equal(browserZoom.defaultPrevented, false);
|
||||
|
||||
// A double-click puts back the time its first press moved.
|
||||
track.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, pointerId: 1 }));
|
||||
track.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, pointerId: 1 }));
|
||||
input.value = String(at - 3_600_000);
|
||||
input.dispatchEvent(new Event('input'));
|
||||
track.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
||||
await nextTick();
|
||||
assert.equal(track.classList.contains('is-zoomed'), false);
|
||||
assert.ok(Math.abs(worldClock.now() - at) < 1_000, 'time as it was');
|
||||
});
|
||||
|
||||
test('play held for a layer to load shows a spinner once the hold lasts', async (t) => {
|
||||
const bar = mount(t);
|
||||
const at = Date.UTC(2026, 9, 30, 6, 15, 42);
|
||||
supportingLayer(t, at);
|
||||
worldClock.seek(at - 1_800_000);
|
||||
worldClock.play({ continuous: true });
|
||||
await nextTick();
|
||||
const bubble = () => bar().querySelector('.world-timeline-time');
|
||||
const release = worldClock.wait('test-radar');
|
||||
t.after(release);
|
||||
// A clock tick (a quarter second) tells of the hold; the spinner waits on.
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
await nextTick();
|
||||
assert.equal(bubble().querySelector('.world-timeline-spinner'), null, 'not for a quick frame');
|
||||
// The clock tells of the hold on its next tick, and the bar shows it after.
|
||||
const until = async (shown) => {
|
||||
for (let waited = 0; waited < 3_000; waited += 50) {
|
||||
if (Boolean(bubble().querySelector('.world-timeline-spinner')) === shown) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await nextTick();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
assert.ok(await until(true), 'spinner once the hold lasts');
|
||||
assert.match(bubble().textContent, /UTC$/, 'the time stays');
|
||||
release();
|
||||
assert.ok(await until(false), 'gone once play goes on');
|
||||
});
|
||||
|
||||
test('the track drags along, a tap goes to its time, and the handle waits off it', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 26 * 86_400_000;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({ start, end: now, marks: [start] }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const track = bar().querySelector('.world-timeline-track');
|
||||
const input = bar().querySelector('input[type=range]');
|
||||
// A 1000 px track from x = 0.
|
||||
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
|
||||
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
|
||||
const press = (type, clientX, pointerId = 1) =>
|
||||
track.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
|
||||
const view = () => ({ min: Number(input.min), max: Number(input.max) });
|
||||
|
||||
// All of it shown, a tap goes to the time under it.
|
||||
press('pointerdown', 500);
|
||||
press('pointerup', 500);
|
||||
await nextTick();
|
||||
const middle = start + (now - start) / 2;
|
||||
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'went to the middle');
|
||||
|
||||
// Zoomed to a day around it, dragging right by half the track goes back half a day.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
|
||||
await nextTick();
|
||||
}
|
||||
const before = view();
|
||||
const day = before.max - before.min;
|
||||
press('pointerdown', 600, 2);
|
||||
press('pointermove', 700, 2);
|
||||
press('pointermove', 1_100, 2);
|
||||
press('pointerup', 1_100, 2);
|
||||
await nextTick();
|
||||
assert.ok(Math.abs(view().max - (before.max - day / 2)) < 1_000, 'dragged half a view');
|
||||
assert.ok(Math.abs(worldClock.now() - middle) < 60_000, 'a drag leaves the time');
|
||||
|
||||
// Dragged off the handle, it waits unseen at the edge; a sideways swipe drags too.
|
||||
track.dispatchEvent(new WheelEvent('wheel', { deltaX: -2_000, deltaY: 0, cancelable: true }));
|
||||
await nextTick();
|
||||
assert.ok(view().max < worldClock.now(), 'the handle is past the view');
|
||||
assert.equal(input.classList.contains('is-off-view'), true);
|
||||
// Moving the time takes the view back to it.
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'PageUp' }));
|
||||
await nextTick();
|
||||
assert.equal(input.classList.contains('is-off-view'), false);
|
||||
});
|
||||
|
||||
test('a pinch measures from where the fingers are, and a lifted finger ends it', async (t) => {
|
||||
const bar = mount(t);
|
||||
const now = Date.now();
|
||||
const start = now - 26 * 86_400_000;
|
||||
const release = worldClock.hold('test-launches');
|
||||
const unregister = worldClock.register({
|
||||
id: 'test-launches',
|
||||
coverage: () => ({ start, end: now, marks: [start] }),
|
||||
});
|
||||
t.after(() => {
|
||||
unregister();
|
||||
release();
|
||||
});
|
||||
await nextTick();
|
||||
const track = bar().querySelector('.world-timeline-track');
|
||||
const input = bar().querySelector('input[type=range]');
|
||||
Object.defineProperty(track, 'clientWidth', { value: 1_000 });
|
||||
track.getBoundingClientRect = () => ({ left: 0, width: 1_000, top: 0, height: 24 });
|
||||
const on = (target, type, clientX, pointerId) =>
|
||||
target.dispatchEvent(new PointerEvent(type, { bubbles: true, clientX, pointerId }));
|
||||
const length = () => Number(input.max) - Number(input.min);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: '+' }));
|
||||
await nextTick();
|
||||
}
|
||||
const before = length();
|
||||
// A finger on the handle moves before the second comes down.
|
||||
on(input, 'pointerdown', 300, 1);
|
||||
on(input, 'pointermove', 500, 1);
|
||||
on(track, 'pointerdown', 700, 2);
|
||||
on(input, 'pointermove', 500, 1);
|
||||
await nextTick();
|
||||
assert.ok(Math.abs(length() - before) < 1_000, 'no zoom from a stale finger');
|
||||
on(track, 'pointermove', 900, 2);
|
||||
await nextTick();
|
||||
assert.ok(length() < before, 'spread apart, it zooms in');
|
||||
// One finger up: the other moving does nothing, and throws nothing.
|
||||
const zoomedTo = length();
|
||||
on(track, 'pointerup', 900, 2);
|
||||
on(input, 'pointermove', 100, 1);
|
||||
await nextTick();
|
||||
assert.ok(Math.abs(length() - zoomedTo) < 1_000);
|
||||
on(input, 'pointercancel', 100, 1);
|
||||
});
|
||||
@@ -1,58 +1,316 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { formatDate, t } from '../../../i18n/index.js';
|
||||
import { useWorldTimeStore } from '../../stores/worldTime.js';
|
||||
import UiButton from '../../kit/UiButton.vue';
|
||||
import UiIcon from '../../kit/UiIcon.vue';
|
||||
import UiSelect from '../../kit/UiSelect.js';
|
||||
|
||||
// The world timeline: play and pause on the left, the time, a track across
|
||||
// the stretch the layers on can show (each launch or frame a tick) with a
|
||||
// handle to scrub, and the way back to live on the right, lit while live. It
|
||||
// shows while a layer with a time of its own (weather, satellites, launches,
|
||||
// The world timeline: play and pause and the speed on the left, a track
|
||||
// across the stretch the layers on can show (each launch or frame a tick)
|
||||
// with a handle to scrub and the time in a bubble over it, and the way back
|
||||
// to live on the right, lit while live. Scrolling or pinching the track
|
||||
// zooms it around the handle, dragging it moves along it, and a double-click
|
||||
// shows it all again. It shows
|
||||
// while a layer with a time of its own (weather, satellites, launches,
|
||||
// or one a host follows) is on with time to show; with only live feeds such
|
||||
// as flights on, the bar stays hidden.
|
||||
const store = useWorldTimeStore();
|
||||
const { time } = storeToRefs(store);
|
||||
const { time, speed } = storeToRefs(store);
|
||||
const speeds = computed(() => [
|
||||
{ value: 'real', label: '1×' },
|
||||
{ value: 'minute', label: t('1 min/s') },
|
||||
{ value: 'hour', label: t('1 h/s') },
|
||||
{ value: 'day', label: t('1 d/s') },
|
||||
{ value: 'week', label: t('1 wk/s') },
|
||||
]);
|
||||
|
||||
const DATE_TIME = Object.freeze({
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const EDGE_DATE = Object.freeze({
|
||||
// The bubble over the handle: the date and the minute, kept short.
|
||||
const BUBBLE_DATE = Object.freeze({
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
const BUBBLE_TIME = Object.freeze({
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
// The keyboard's fallback step, as a share of the track, with no mark to go to.
|
||||
const KEY_STEP = 0.01;
|
||||
const PAGE_STEP = 0.1;
|
||||
// Zooming: the shortest stretch the track narrows to, the step a key or a
|
||||
// notch of the wheel takes, and how far a wheel's pixel of scroll zooms.
|
||||
const MIN_VIEW_MS = 5 * 60_000;
|
||||
const ZOOM_STEP = 1.5;
|
||||
const WHEEL_ZOOM = 0.0025;
|
||||
// Two presses this close are a double-click.
|
||||
const DOUBLE_PRESS_MS = 500;
|
||||
|
||||
const readout = computed(() =>
|
||||
Number.isFinite(time.value.timeMs)
|
||||
? `${formatDate(time.value.timeMs, DATE_TIME)} UTC`
|
||||
: '',
|
||||
);
|
||||
const bubble = computed(() =>
|
||||
Number.isFinite(time.value.timeMs)
|
||||
? `${formatDate(time.value.timeMs, BUBBLE_DATE)} · ${formatDate(time.value.timeMs, BUBBLE_TIME)} UTC`
|
||||
: '',
|
||||
);
|
||||
// Play held while a layer loads the time's data shows a spinner by the time,
|
||||
// once the hold lasts long enough to notice, so a quick frame does not flash
|
||||
// it, and until play has run a while, so a run of slow frames keeps it still.
|
||||
const WAIT_SHOWN_MS = 300;
|
||||
const WAIT_HIDDEN_MS = 500;
|
||||
const waiting = shallowRef(false);
|
||||
let waitTimer = null;
|
||||
watch(
|
||||
() => time.value.buffering,
|
||||
(buffering) => {
|
||||
clearTimeout(waitTimer);
|
||||
waitTimer = null;
|
||||
if (buffering === waiting.value) return;
|
||||
waitTimer = setTimeout(
|
||||
() => {
|
||||
waitTimer = null;
|
||||
waiting.value = buffering;
|
||||
},
|
||||
buffering ? WAIT_SHOWN_MS : WAIT_HIDDEN_MS,
|
||||
);
|
||||
},
|
||||
);
|
||||
onBeforeUnmount(() => clearTimeout(waitTimer));
|
||||
const span = computed(() => time.value.span);
|
||||
// Where the handle is, or null with no track.
|
||||
const at = computed(() => {
|
||||
if (!span.value) return null;
|
||||
const { start, end } = span.value;
|
||||
return Math.min(end, Math.max(start, time.value.timeMs));
|
||||
});
|
||||
const place = (ms) => {
|
||||
|
||||
// The track shows the whole span, or zoomed, a stretch of it: `length` long,
|
||||
// ending at `end`, or at the span's end (and so at now) while `end` is null.
|
||||
// Everything still plays together; zooming only changes what the track shows.
|
||||
const zoom = shallowRef(null);
|
||||
const view = computed(() => {
|
||||
if (!span.value) return null;
|
||||
const { start, end } = span.value;
|
||||
const length = zoom.value?.length;
|
||||
if (!length || length >= end - start) return { start, end };
|
||||
const viewEnd = Math.min(end, Math.max(start + length, zoom.value.end ?? end));
|
||||
return { start: viewEnd - length, end: viewEnd };
|
||||
});
|
||||
const zoomed = computed(() => zoom.value !== null);
|
||||
const viewLength = () => view.value.end - view.value.start;
|
||||
// Show `length` of the span ending at `viewEnd`, held to the span.
|
||||
function showView(length, viewEnd) {
|
||||
const { start, end } = span.value;
|
||||
if (length >= end - start) {
|
||||
showWhole();
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(end, Math.max(start + length, viewEnd));
|
||||
zoom.value = { length, end: clamped >= end ? null : clamped };
|
||||
}
|
||||
// Narrow or widen the view to `length`, keeping the handle where it is on it,
|
||||
// or with the handle off it, the middle.
|
||||
function zoomTo(length) {
|
||||
const { start: from, end: to } = view.value;
|
||||
const pivot = offView(at.value) ? (from + to) / 2 : at.value;
|
||||
const share = (pivot - from) / (to - from);
|
||||
const next = Math.max(MIN_VIEW_MS, length);
|
||||
showView(next, pivot + (1 - share) * next);
|
||||
}
|
||||
const zoomBy = (factor) => zoomTo(viewLength() * factor);
|
||||
// A view dragged off the handle stays where it was put while the handle only
|
||||
// creeps on with now; playing or a move of the handle takes it back.
|
||||
let panned = false;
|
||||
const follow = (ms) => {
|
||||
panned = false;
|
||||
showView(zoom.value.length, ms + zoom.value.length / 2);
|
||||
};
|
||||
const offView = (ms) => ms < view.value.start || ms > view.value.end;
|
||||
const showWhole = () => {
|
||||
zoom.value = null;
|
||||
panned = false;
|
||||
};
|
||||
// The span narrowing to the zoom or less, or going, lets go of the zoom.
|
||||
watch(span, (next) => {
|
||||
if (zoom.value && (!next || zoom.value.length >= next.end - next.start))
|
||||
showWhole();
|
||||
});
|
||||
watch(at, (ms) => {
|
||||
if (!zoom.value || ms === null || !offView(ms)) return;
|
||||
if (panned && time.value.live && !time.value.playing) return;
|
||||
follow(ms);
|
||||
});
|
||||
// Left behind as now moves the view on, the handle takes the view back.
|
||||
watch(view, () => {
|
||||
if (zoom.value && !panned && at.value !== null && offView(at.value))
|
||||
follow(at.value);
|
||||
});
|
||||
const pan = (ms) => {
|
||||
panned = true;
|
||||
showView(zoom.value.length, view.value.end + ms);
|
||||
};
|
||||
// A wheel's lines and pages, in pixels.
|
||||
const WHEEL_UNITS = [1, 16, 400];
|
||||
const onWheel = (event) => {
|
||||
const unit = WHEEL_UNITS[event.deltaMode] ?? 1;
|
||||
const sideways = Math.abs(event.deltaX) > Math.abs(event.deltaY);
|
||||
// A sideways swipe, or a wheel with shift, drags; a wheel zooms.
|
||||
if (sideways || event.shiftKey) {
|
||||
if (!zoom.value) return;
|
||||
event.preventDefault();
|
||||
const width = event.currentTarget.clientWidth;
|
||||
const pixels = (sideways ? event.deltaX : event.deltaY) * unit;
|
||||
if (width > 0) pan((pixels / width) * viewLength());
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
zoomBy(Math.exp(event.deltaY * unit * WHEEL_ZOOM));
|
||||
};
|
||||
// A double-click starts with presses that move the time, so it puts back the
|
||||
// time from before its first; so does a pinch, which may start on the handle.
|
||||
let before = null;
|
||||
let pressedAt = -Infinity;
|
||||
const onPress = (event) => {
|
||||
if (event.timeStamp - pressedAt > DOUBLE_PRESS_MS)
|
||||
before = { live: time.value.live, timeMs: time.value.timeMs };
|
||||
pressedAt = event.timeStamp;
|
||||
};
|
||||
const restore = () => {
|
||||
if (!before) return;
|
||||
if (before.live) store.goLive();
|
||||
else store.scrub(before.timeMs, 0);
|
||||
};
|
||||
const showAll = () => {
|
||||
restore();
|
||||
showWhole();
|
||||
};
|
||||
|
||||
// The handle drags the time. The rest of the track takes the pointer: a tap
|
||||
// goes to the time under it, a drag drags a zoomed track along (or the time,
|
||||
// with all of it shown), and two fingers pinch it.
|
||||
const DRAG_PX = 4;
|
||||
const pointers = new Map();
|
||||
let gesture = null;
|
||||
const timeAt = (clientX, track) => {
|
||||
const { left, width } = track.getBoundingClientRect();
|
||||
const share = width > 0 ? Math.min(1, Math.max(0, (clientX - left) / width)) : 1;
|
||||
return view.value.start + share * viewLength();
|
||||
};
|
||||
// Whether the first finger has moved the time, for a pinch to put it back.
|
||||
let movedTime = false;
|
||||
const onPointerDown = (event) => {
|
||||
// Only the main button: a right-click is for the menu.
|
||||
if (event.button > 0) return;
|
||||
const onHandle = event.target instanceof HTMLInputElement;
|
||||
pointers.set(event.pointerId, { x: event.clientX, onHandle });
|
||||
if (pointers.size === 1) {
|
||||
onPress(event);
|
||||
movedTime = onHandle;
|
||||
// The keys zoom and step the track as soon as it is touched.
|
||||
if (!onHandle)
|
||||
event.currentTarget.querySelector('input')?.focus({ preventScroll: true });
|
||||
}
|
||||
if (pointers.size === 2) {
|
||||
const [a, b] = [...pointers.values()];
|
||||
if (movedTime) restore();
|
||||
gesture = {
|
||||
kind: 'pinch',
|
||||
spread: Math.abs(a.x - b.x),
|
||||
length: viewLength(),
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (onHandle || pointers.size !== 1) return;
|
||||
// The drag goes on past the track's ends; without the capture, to them.
|
||||
try {
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// A pointer the page no longer knows of drags no further than the track.
|
||||
}
|
||||
gesture = { kind: 'press', x: event.clientX, end: view.value.end };
|
||||
};
|
||||
const onPointerMove = (event) => {
|
||||
const pointer = pointers.get(event.pointerId);
|
||||
if (!pointer) return;
|
||||
pointer.x = event.clientX;
|
||||
if (!gesture) return;
|
||||
const track = event.currentTarget;
|
||||
if (gesture.kind === 'pinch') {
|
||||
const [a, b] = [...pointers.values()];
|
||||
const spreadNow = Math.abs(a.x - b.x);
|
||||
if (spreadNow > 0) zoomTo((gesture.length * gesture.spread) / spreadNow);
|
||||
return;
|
||||
}
|
||||
if (gesture.kind === 'press') {
|
||||
if (Math.abs(event.clientX - gesture.x) < DRAG_PX) return;
|
||||
gesture.kind = zoom.value ? 'pan' : 'scrub';
|
||||
movedTime = gesture.kind === 'scrub';
|
||||
}
|
||||
if (gesture.kind === 'pan') {
|
||||
if (!track.clientWidth) return;
|
||||
panned = true;
|
||||
const moved = ((gesture.x - event.clientX) / track.clientWidth) * viewLength();
|
||||
showView(zoom.value.length, gesture.end + moved);
|
||||
} else store.scrub(timeAt(event.clientX, track), viewLength());
|
||||
};
|
||||
const onPointerUp = (event) => {
|
||||
if (!pointers.delete(event.pointerId)) return;
|
||||
if (gesture?.kind === 'press' && event.type === 'pointerup')
|
||||
store.scrub(timeAt(event.clientX, event.currentTarget), viewLength());
|
||||
// A finger lifted from a pinch ends it; the other one moves nothing.
|
||||
gesture = null;
|
||||
};
|
||||
|
||||
const place = (ms) => {
|
||||
const { start, end } = view.value;
|
||||
return end > start ? ((ms - start) / (end - start)) * 100 : 100;
|
||||
};
|
||||
const ticks = computed(() =>
|
||||
span.value.marks.map((mark) => `${place(mark)}%`),
|
||||
const ticks = computed(() => {
|
||||
const { start, end } = view.value;
|
||||
return span.value.marks
|
||||
.filter((mark) => mark >= start && mark <= end)
|
||||
.map((mark) => `${place(mark)}%`);
|
||||
});
|
||||
const shown = computed(() =>
|
||||
Math.min(view.value.end, Math.max(view.value.start, at.value)),
|
||||
);
|
||||
const scrub = (event) => store.scrub(Number(event.target.value));
|
||||
const handleOff = computed(() => at.value !== shown.value);
|
||||
const scrub = (event) => {
|
||||
// A pinch only zooms; the input goes back to the handle.
|
||||
if (gesture?.kind === 'pinch') {
|
||||
event.target.value = shown.value;
|
||||
return;
|
||||
}
|
||||
store.scrub(Number(event.target.value), viewLength());
|
||||
};
|
||||
// Arrows go to the previous or next mark (a launch, a frame), or a step along
|
||||
// the track; Page keys take a tenth of it; Home and End go to its ends.
|
||||
// the track as shown; Page keys take a tenth of it; Home and End go to the
|
||||
// ends of all of it. Plus and minus zoom, and zero shows it all; with a
|
||||
// modifier they are the browser's own zoom.
|
||||
const ZOOM_KEYS = ['+', '=', '-', '0'];
|
||||
const onKey = (event) => {
|
||||
if (ZOOM_KEYS.includes(event.key)) {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
||||
event.preventDefault();
|
||||
if (event.key === '-') zoomBy(ZOOM_STEP);
|
||||
else if (event.key === '0') showWhole();
|
||||
else zoomBy(1 / ZOOM_STEP);
|
||||
return;
|
||||
}
|
||||
const { start, end, marks } = span.value;
|
||||
const now = at.value;
|
||||
const length = end - start;
|
||||
const length = viewLength();
|
||||
let to = null;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown')
|
||||
to = marks.findLast((mark) => mark < now) ?? now - length * KEY_STEP;
|
||||
@@ -64,7 +322,7 @@ const onKey = (event) => {
|
||||
else if (event.key === 'End') to = end;
|
||||
if (to === null) return;
|
||||
event.preventDefault();
|
||||
store.scrub(Math.min(end, Math.max(start, to)));
|
||||
store.scrub(Math.min(end, Math.max(start, to)), length);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -72,24 +330,26 @@ const onKey = (event) => {
|
||||
<div v-if="time.supported && time.span" id="world-timeline" role="group" :aria-label="t('World time')"
|
||||
><UiButton class="world-timeline-play" :label="time.playing ? t('Pause') : t('Play')" @click="store.togglePlay()"
|
||||
><UiIcon :name="time.playing ? 'pause' : 'play_arrow'" :size="20" /></UiButton
|
||||
><time class="world-timeline-time" :datetime="new Date(time.timeMs).toISOString()" aria-live="off">{{ readout }}</time
|
||||
><span class="world-timeline-edge">{{ formatDate(span.start, EDGE_DATE) }}</span
|
||||
><div class="world-timeline-track"
|
||||
><UiSelect class="gev-field gev-field--compact world-timeline-speed" :aria-label="t('Play speed')" :title="t('Play speed')" :options="speeds" :model-value="speed" @update:model-value="store.setSpeed($event)"
|
||||
/><div class="world-timeline-track" :class="{ 'is-zoomed': zoomed }" :title="t('Scroll or pinch to zoom, drag to move along · double-click to see it all')" @wheel="onWheel" @dblclick="showAll" @pointerdown="onPointerDown" @pointermove="onPointerMove" @pointerup="onPointerUp" @pointercancel="onPointerUp"
|
||||
><time class="world-timeline-time" :class="{ 'is-waiting': waiting }" :style="{ '--at': place(shown) / 100 }" :datetime="new Date(time.timeMs).toISOString()" aria-live="off"
|
||||
><span v-if="waiting" class="world-timeline-spinner" aria-hidden="true"></span>{{ bubble }}</time
|
||||
><span v-for="(left, index) in ticks" :key="index" class="world-timeline-tick" :style="{ left }" aria-hidden="true"></span
|
||||
><span class="world-timeline-fill" :style="{ width: `${place(at)}%` }" aria-hidden="true"></span
|
||||
><span class="world-timeline-fill" :style="{ width: `${place(shown)}%` }" aria-hidden="true"></span
|
||||
><input
|
||||
class="world-timeline-scrub"
|
||||
:class="{ 'is-off-view': handleOff }"
|
||||
type="range"
|
||||
:aria-label="t('World time')"
|
||||
:aria-valuetext="readout"
|
||||
:min="span.start"
|
||||
:max="span.end"
|
||||
:min="view.start"
|
||||
:max="view.end"
|
||||
step="any"
|
||||
:value="at"
|
||||
:value="shown"
|
||||
@input="scrub"
|
||||
aria-keyshortcuts="+ - 0"
|
||||
@keydown="onKey"
|
||||
/></div
|
||||
><span class="world-timeline-edge">{{ formatDate(span.end, EDGE_DATE) }}</span
|
||||
><UiButton class="world-timeline-live" :label="time.live ? t('Live') : t('Back to live')" :pressed="time.live" pressed-class="is-live" :disabled="time.live" @click="store.goLive()">{{ t('Live') }}</UiButton
|
||||
></div>
|
||||
</template>
|
||||
Reference in new issue
Block a user