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:
Said Turksever committed 2026-11-09 00:11:38 +00:00
commit b7531e655d
84 files changed
+7506 -244

No files matched your search

@@ -0,0 +1,7 @@
- The timeline brings launches up as the time passes them, offers play
speeds from real time to a week a second, and plays smoothly, holding only
when the next weather frame has yet to load, with a spinner by the time
while it waits. The time rides in a bubble over the handle. The track zooms
with a scroll or a pinch, drags along, and goes to the time under a tap, on
a phone as on a desktop. Zooming out on the globe stops while the globe is
still in view instead of leaving it a speck (samehkhamis, #1513).
+4
View File
@@ -0,0 +1,4 @@
- Ride the selected AIS vessel from its bridge: an eye height by vessel type,
dead reckoning between fixes, a bridge HUD with a 3–24 NM radar, look-around
with the arrow keys, and a waterline read from the scene so river boats sit on
the river rather than at sea level (Denis Groshev, @ZloyS, #1557).
@@ -0,0 +1,3 @@
- An application built with fewer layers no longer restores, saves or shares a
layer it does not have when a link or saved state from a full build names it
(samehkhamis, #1634).
+98 -8
View File
@@ -1524,6 +1524,87 @@ retained Cockpit actions cannot restart a disposed controller. Input, subscripti
and queued panel work stop before asynchronous layer restoration; final camera
and portal cleanup follows that restoration.
### Ship bridge view
With no aircraft tracked, Cockpit can ride the selected AIS vessel instead.
Entry needs only the layer of the vessel it rides, Live AIS Vessels, and a
selected vessel; Contacts and every other layer may be off, before or during
the bridge (`bridgeEntryAllowed` in `src/contextModePolicy.js`). A Context mode
change still in flight blocks entry until it settles. `C` enters it (when
the bridge cannot be entered, `C` stays the CCTV shortcut), and the
entry button reads BRIDGE with a ship icon both in Contacts (`#cockpit-entry`)
and under the selected vessel's Intel HUD readout in the bottom-right corner
(`#cockpit-entry-readout`, shown with the HUD on, in its Tactical and Cyber
layouts). Turning Live AIS Vessels off ends the bridge: the layer then has no
subject (`getCockpitSubject` returns null). The bridge picture uses AIS alone,
so no other feed being off makes it partial.
Voice (`control_cockpit`) and the selection entry transaction stay aircraft-only;
voice `enter` on a bridge is refused before it touches tracking, with the
reason to exit the bridge first.
The vessel layer supplies the subject (`getCockpitSubject`), hides that ship's
sprite, card, detection bracket and trail while it is held
(`setCockpitSubject`), and
ignores scene clicks so open water cannot deselect it. The eye sits on the AIS
position at a representative bridge height for the AIS type family
(`src/layers/vessels/cockpitSubject.js`: cargo and tanker 30 m, passenger 25 m,
tug 7 m, fishing 6 m, ferry and AIS "Other" (codes 90–99, or a feed's text
type "Other") 4 m, sailing and pleasure 3 m; 12 m when the type names no
family, including the reserved and wing-in-ground codes the vessel layer also
files under OTHER), pitched 2° down with 2 m clearance over the shared ground floor
(`src/cockpitSubjects.js`). The camera faces true heading (course over ground
when heading is missing) and moves along course over ground. A ship reporting
neither has no direction: the camera keeps the heading it had on entry, the
heading readout shows `---`, the visor reads NO HEADING · AIS, the entry signal
names the ship without a heading, no bow or stern mark is drawn, and the eye
stays on the fix rather than moving along a made-up course. The vessel layer
redraws a ship only on its 60-second refresh, so the camera's target is the last
fix dead-reckoned along course over ground for up to 180 s, and the existing
inertial anchor absorbs each new fix. The fix's age is read without comparing
the two clocks (`vesselFixAgeSec`): the time since this client first received
the fix, plus how far it trailed that refresh's newest fix, so a client clock
running fast or slow neither projects the ship nor marks it stale, and a fix
the feed keeps re-serving from its cache goes on ageing into STALE FEED. A
stale vessel, or a projection past that limit, holds the camera on the target.
The vessel layer floats ships on the geoid, which on a river or lake can lie
well below the water (the Seine through Paris is ~27 m up), so the bridge also
reads the rendered surface around its ship: the ship's position and a ring of
eight probes 30 m out, the lowest probe taken as the water when it is 2 m to
4,000 m above sea level. The first read starts from the tiles already loaded, then
the full-detail read replaces it; it repeats every 5 s. Only one full-detail
read runs at a time: while one is still running, a refresh reads the loaded
tiles instead, and the slow read applies whenever it lands. Once a
full-detail read has answered, loaded-tile reads no longer change the height;
before then they may find raised water but never take it back to the sea.
A read that sampled nothing, or failed, keeps the height it had and is
tried again on the next refresh.
The HUD keeps its frame, vision styles, briefing, weather and Display/Radio
utilities. It names the ship, reads `{type} · LIVE AIS · EYE {height}`, shows
speed over ground in knots, true heading and course over ground in place of
altitude, hides the altitude tape, and shows the crew-entered AIS destination as
a Reported Voyage card without an origin or direction arrow. The arrow keys
look around the bridge in 5° steps (Shift for 45°): Left and Right turn the
view all the way round, Up and Down pitch it up to ±90°, and the camera eases
there unless the user prefers reduced motion. Home or `0`, or the LOOK AHEAD
button shown while the view is turned, bring it back over the bow at the rest
pitch. The keys stay with a focused text field, slider, tab, radio group, list
or menu. The heading tape is a straight strip that scrolls continuously with
the camera, so the caret reads the view's exact heading, while the readout
above it keeps the ship's true heading; a ring with a centre dot marks the bow
(prograde) and a ring with a cross the stern (retrograde) at their bearings
from the caret. The bridge radar
shows nearby AIS ships heading-up and opens at 3 NM
(`DEFAULT_RADAR_RANGE_NM` in `src/cockpitRadar.js`); its range button steps
3, 6, 12 and 24 NM and the chosen range is kept across bridges. Own ship
sits where the camera rides (its fix dead-reckoned) and contacts at their
last fixes, where the vessel layer draws them, so the scope matches the view
out of the window. Contact
Previous/Next and Live Signals aircraft handoffs are refused on the bridge and
the Contact card stays hidden. Exit returns the camera to the vessel's map view
through the layer's own focus. Tracking an aircraft or losing the vessel
selection ends the bridge without refocusing the ship. `src/ui/cockpitBridge.test.mjs`
covers entry, readouts, refusal and exit.
## Context coordination
Context controls own Contacts/Space Missions state, entry and exit transactions,
@@ -5719,6 +5800,13 @@ and unreachable upstream (502/504) separately from road geometry.
explicit-empty form); unknown option tokens are ignored. Restoration
settles independently per layer so one failed or unavailable source cannot
block its siblings.
- An application that registers fewer layers (an embedder with a smaller
catalog) keeps only its own: `LayerStateCoordinator` drops enabled-layer ids
the application did not register before it normalizes a share, saved state or
explicit change, so such a layer is never restored, written to local storage
or carried into a link this application shares. Options that depend on a
dropped layer (a tracked selection) go with it. An application with every
layer registered is unchanged.
- Layer tokens are permanent public compatibility identifiers. The reservation
JSON ledger at `src/data/layerStateTokenReservations.json` pins all existing
one-character assignments (all letters plus `0`, `1` and
@@ -6362,14 +6450,16 @@ apply to `public/` assets or built-preview serving.
`src/contextModePolicy.js`). Contacts is not required, and the other aircraft
layer, Live AIS Vessels and every other layer may be turned on or off before
or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
it settles. Turning off the layer the view rides ends it. Riding an aircraft,
or a vessel from its bridge, while Contacts is on adopts its layer as the
user's own, as a direct selection does (`adoptLayerVisibility`), so Contacts
ending, by its own switch or one of its layers going off, restores the
pre-Contacts layers but keeps the ridden one and the view; Live Signals then
drops the rows Contacts built and shows `CONTEXT STANDBY`. The visible
Cockpit actions, the `C` shortcut and voice use the same gate; voice entry
still opens Contacts first, as its layer-targeted entry navigates through
Contacts. The ship bridge has its own own-layer gate (`bridgeEntryAllowed`;
see [Ship bridge view](#ship-bridge-view)).
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+2
View File
@@ -328,6 +328,8 @@
"src/ui/styles/responsive.css",
"src/ui/styles/voice-cost.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/command-dock-compact.css",
"src/app/stateChannel.js",
"src/app/stateChannel.test.mjs",
+25
View File
@@ -39,6 +39,7 @@
"src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue",
"src/ui/views/panels/CockpitRadar.vue",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
@@ -913,6 +914,11 @@
"src/cockpitMath.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/ui/panelRailGeometry.js",
"src/i18n/index.js",
"src/i18n/locale.js",
@@ -981,6 +987,11 @@
"src/cockpitTracking.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/data/detectionPolicy.js",
@@ -1210,6 +1221,8 @@
"src/ui/styles/location.css",
"src/ui/styles/status.css",
"src/ui/styles/cockpit.css",
"src/ui/styles/cockpitBridge.css",
"src/ui/styles/cockpitRadar.css",
"src/ui/styles/overlays.css",
"src/ui/styles/layers.css",
"src/ui/styles/radio.css",
@@ -1529,6 +1542,8 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/geoDistance.js",
"src/data/inputOwnership.js",
"src/i18n/index.js",
"src/i18n/locale.js",
@@ -2400,6 +2415,9 @@
"src/cockpitTracking.js",
"src/cockpitUtilityLayout.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/contactsDetectionPolicy.js",
"src/contextModePolicy.js",
"src/creditKeyboard.js",
@@ -2961,6 +2979,7 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/data/inputOwnership.js",
"src/annotations/drawMarks.js",
"src/annotations/drawMode.js",
@@ -3749,6 +3768,7 @@
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/cockpitSubject.js",
"src/data/inputOwnership.js",
"src/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js",
@@ -4168,6 +4188,11 @@
"src/ui/radioPanelPlacement.js",
"src/bloom.js",
"src/cockpitVisionPolicy.js",
"src/cockpitSubjects.js",
"src/cockpitRadar.js",
"src/cockpitLook.js",
"src/geoDistance.js",
"src/data/motionModel.js",
"src/contactsDetectionPolicy.js",
"src/data/detectionPolicy.js",
"src/styles/anime.js",
+43
View File
@@ -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.
+73
View File
@@ -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);
});
+3 -2
View File
@@ -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
View File
@@ -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);
+183
View File
@@ -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),
};
}
+137
View File
@@ -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',
);
});
+40
View File
@@ -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);
+42
View File
@@ -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');
+166
View File
@@ -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,
}),
};
}
+140
View File
@@ -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);
});
+170
View File
@@ -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 };
}
+137
View File
@@ -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);
});
+2 -1
View File
@@ -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));
+12
View File
@@ -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.
*
+12
View File
@@ -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 () => {
+1 -1
View File
@@ -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) {
+16
View File
@@ -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 {
+67
View File
@@ -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();
});
+24 -9
View File
@@ -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,
+53 -1
View File
@@ -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": "دقيقة/ث"
}
+49 -1
View File
@@ -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"
}
+50 -1
View File
@@ -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"
}
+50 -1
View File
@@ -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"
}
+50 -1
View File
@@ -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"
}
+48 -1
View File
@@ -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分/秒"
}
+50 -1
View File
@@ -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"
}
+51 -1
View File
@@ -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 мин/с"
}
+51 -1
View File
@@ -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 хв/с"
}
+48 -1
View File
@@ -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 分钟/秒"
}
+48 -1
View File
@@ -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 分鐘/秒"
}
+19 -3
View File
@@ -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",
+3
View File
@@ -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),
);
+4 -1
View File
@@ -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,
+37 -10
View File
@@ -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.
+20
View File
@@ -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();
+102
View File
@@ -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 });
});
+142
View File
@@ -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,
};
}
+113
View File
@@ -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);
});
+106
View File
@@ -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
+148
View File
@@ -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), []);
});
+31
View File
@@ -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;
+30
View File
@@ -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);
});
+9 -3
View File
@@ -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,
+3
View File
@@ -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);
+2
View File
@@ -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) */
+94 -4
View File
@@ -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({
+214
View File
@@ -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');
});
+3 -2
View File
@@ -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
View File
@@ -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)
+82
View File
@@ -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);
});
+3
View File
@@ -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),
},
+1 -1
View File
@@ -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
+3 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+44 -12
View File
@@ -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),
+56 -1
View File
@@ -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
View File
@@ -1,5 +1,6 @@
/** Route Cockpit keys while preserving the active disclosure and form-control owner. */
import * as Cesium from 'cesium';
import { lookKeyStep } from '../cockpitLook.js';
import { isPointerFree } from '../data/inputOwnership.js';
/** How far from the followed track a double-click may land, CSS pixels. */
@@ -22,14 +23,33 @@ export function doubleClickHitsSubject(
return Number.isFinite(distance) && distance <= radiusPx;
}
/**
* How long the first press of a double-click keeps its picture of the track,
* ms: a click on a selected ship re-centres the camera, so the ship can move
* on screen before the second click lands.
*/
export const DOUBLE_CLICK_MEMORY_MS = 700;
/** Where the track `kind` would ride is drawn now, as a world position. */
function subjectWorldPosition(kind) {
if (kind !== 'aircraft') return null;
return (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
if (kind === 'aircraft') {
return (
this.viewer.trackedEntity?.position?.getValue?.(
this.viewer.clock?.currentTime,
) ?? null
);
}
const info = this.readVesselInfo();
if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude))
return null;
// The sprite floats on the geoid, not on the ellipsoid (tens of metres
// apart): project the height it is drawn at.
const heightM = Number.isFinite(info.markHeightM)
? info.markHeightM
: Number.isFinite(info.seaSurfaceHeightM)
? info.seaSurfaceHeightM
: 0;
return Cesium.Cartesian3.fromDegrees(info.longitude, info.latitude, heightM);
}
/** The followed track's canvas position now, or null off screen. */
@@ -44,21 +64,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;
}
}
+15
View File
@@ -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'),
+217 -1
View File
@@ -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 =
+5 -3
View File
@@ -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) {
+133 -33
View File
@@ -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.
+24 -1
View File
@@ -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
View File
@@ -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;
}
+4
View File
@@ -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%;
+449
View File
@@ -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;
}
}
+193
View File
@@ -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;
}
}
+126 -23
View File
@@ -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;
}
+66 -20
View File
@@ -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>
+2 -2
View File
@@ -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.
+2 -2
View File
@@ -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>
+164
View File
@@ -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>
+4 -4
View File
@@ -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>
+398
View File
@@ -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);
});
+282 -22
View File
@@ -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>
+2
View File
@@ -8,6 +8,8 @@
@import './src/ui/styles/location.css';
@import './src/ui/styles/status.css';
@import './src/ui/styles/cockpit.css';
@import './src/ui/styles/cockpitBridge.css';
@import './src/ui/styles/cockpitRadar.css';
@import './src/ui/styles/overlays.css';
@import './src/ui/styles/layers.css';
@import './src/ui/styles/radio.css';