diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs
index cae59345b..b7d5f622f 100644
--- a/scripts/check-import-directions.mjs
+++ b/scripts/check-import-directions.mjs
@@ -37,6 +37,7 @@ const RENDERED_LAYER_DIRS = [
'src/layers/flights/',
'src/layers/military/',
'src/layers/localAdsb/',
+ 'src/layers/vessels/',
'src/layers/aircraft/',
'src/layers/shared/',
];
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index 842b4de6e..17af12c52 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -361,6 +361,10 @@
"src/layers/aircraft/classification.test.mjs",
"src/data/aisLiveVessels.js",
"src/layers/vessels/cards.js",
+ "src/layers/vessels/icons.js",
+ "src/layers/vessels/render/cesium.js",
+ "src/layers/vessels/render/contract.js",
+ "src/layers/vessels/renderer.test.mjs",
"src/layers/vessels/evidence.js",
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index 91cb972af..b8727c81f 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -1016,10 +1016,12 @@
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
+ "src/layers/vessels/icons.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
+ "src/layers/shared/color.js",
"src/data/iconOrientation.js",
"src/data/vesselLabels.js",
"src/layers/vessels/records.js",
@@ -2170,6 +2172,7 @@
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
+ "src/layers/vessels/icons.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/testing.js",
@@ -2602,6 +2605,7 @@
"src/layers/military/render/navara.js",
"src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/cesium.js",
+ "src/layers/vessels/render/cesium.js",
"src/layers/localAdsb/render/navara.js"
],
"external": [
@@ -2975,6 +2979,7 @@
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
+ "src/layers/vessels/icons.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/testing.js",
@@ -3180,7 +3185,8 @@
"src/layers/flights/render/cesium.js",
"src/layers/military/render/cesium.js",
"src/layers/aircraft/qaDrawn.js",
- "src/layers/localAdsb/render/cesium.js"
+ "src/layers/localAdsb/render/cesium.js",
+ "src/layers/vessels/render/cesium.js"
],
"external": [
"hls.js",
diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js
index 32dfb8638..71bd6843d 100644
--- a/src/app/layers/aisLiveVessels.js
+++ b/src/app/layers/aisLiveVessels.js
@@ -9,9 +9,22 @@ import * as sprites from '../../data/spriteOrder.js';
import * as focus from '../../data/focusDeemphasis.js';
import * as worldFocus from '../../worldFocus.js';
import * as render from '../../renderGovernor.js';
+import * as selection from '../../data/selectionDispatcher.js';
+import { rendererFor } from './renderers.js';
+import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
/** Construct one layer using the application scene owners and a supplied source. */
-export function createApplicationVessels({ source, options = {} }) {
+export function createApplicationVessels({
+ source,
+ options = {},
+ // Test seam: the renderer factory (default: the engine's, from the registry).
+ renderer = {
+ create: ({ engine = null, ...rendererOptions }) =>
+ engine
+ ? rendererFor('ais-live-vessels', engine, rendererOptions)
+ : createVesselsCesiumRenderer(null, rendererOptions),
+ },
+}) {
return createVesselLayer({
source,
options,
@@ -26,6 +39,10 @@ export function createApplicationVessels({ source, options = {} }) {
focus,
worldFocus,
render,
+ selection,
+ // The engine's vessel renderer from the registry (render/cesium.js on
+ // Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
+ renderer,
},
});
}
diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js
index 3817a8f5e..9d9c8a5c3 100644
--- a/src/app/layers/renderers.js
+++ b/src/app/layers/renderers.js
@@ -27,6 +27,7 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
import { createFlightsCesiumRenderer } from '../../layers/flights/render/cesium.js';
import { createMilitaryCesiumRenderer } from '../../layers/military/render/cesium.js';
import { createLocalAdsbCesiumRenderer } from '../../layers/localAdsb/render/cesium.js';
+import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
const ENGINE_KINDS = new Set(['cesium', 'navara']);
@@ -89,6 +90,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
flights: createFlightsCesiumRenderer,
military: createMilitaryCesiumRenderer,
'local-adsb': createLocalAdsbCesiumRenderer,
+ 'ais-live-vessels': createVesselsCesiumRenderer,
});
// The other application layers still draw themselves on Cesium.
diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs
index b21d1ffd1..e8529c734 100644
--- a/src/app/layers/renderers.test.mjs
+++ b/src/app/layers/renderers.test.mjs
@@ -56,12 +56,21 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
}
});
-test('the aircraft layers have extracted Cesium renderers; the others still draw themselves', () => {
+test('the aircraft layers and vessels have extracted Cesium renderers; the others still draw themselves', () => {
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
'flights',
'military',
'local-adsb',
+ 'ais-live-vessels',
]);
+ const vessels = rendererFor(
+ 'ais-live-vessels',
+ { kind: 'cesium' },
+ {
+ viewer: { scene: {}, camera: {} },
+ },
+ );
+ assert.equal(vessels.kind, 'cesium');
const viewer = { scene: {}, camera: {} };
const renderer = rendererFor('flights', { kind: 'cesium' }, { viewer });
assert.equal(renderer.kind, 'cesium');
diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs
index 5fa00a9f4..5fe04b0ab 100644
--- a/src/cameraHandoff.test.mjs
+++ b/src/cameraHandoff.test.mjs
@@ -388,9 +388,11 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
assert.match(source, /requestWorldFocus\(\{/);
assert.doesNotMatch(source, /camera\.flyTo/);
}
+ // Vessels' click rule is a function the engine's selection dispatcher
+ // calls (phase 3, 13a): the same body, out of the old LEFT_CLICK handler.
const vesselClick = body(
vessels,
- /handler\.setInputAction\(\s*\(click\) => \{([\s\S]*?)\n\s*\},\s*Cesium\.ScreenSpaceEventType\.LEFT_CLICK,?\s*\);/,
+ /function handleClick\(click\) \{([\s\S]*?)\n \}/,
'vessel click',
);
ordered(vesselClick, [
diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs
index 9b20f3ad4..e3faf3b05 100644
--- a/src/data/inputOwnership.test.mjs
+++ b/src/data/inputOwnership.test.mjs
@@ -134,7 +134,9 @@ test('every scene click handler consults ownership before it picks', () => {
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
- ['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
+ // Vessels take the engine dispatcher's pick (phase 3, 13a): the guard
+ // comes before the click rule reads it.
+ ['src/layers/vessels/selection.js', 'resolvePickId(picked)'],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js
index 240e22828..72fe5c88d 100644
--- a/src/data/vesselLabels.js
+++ b/src/data/vesselLabels.js
@@ -39,6 +39,11 @@ const TYPE_STYLES = [
];
const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' };
+/** Every chevron hue vesselTypeCss() can return, once each. */
+export const VESSEL_TYPE_CSS_COLORS = Object.freeze([
+ ...new Set([...TYPE_STYLES, DEFAULT_STYLE].map(({ css }) => css)),
+]);
+
const NUMERIC_TYPE_SPECIALS = {
30: 'FISHING',
31: 'TOWING',
diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js
index c5e593e26..91d4c29de 100644
--- a/src/layers/vessels/evidence.js
+++ b/src/layers/vessels/evidence.js
@@ -1,4 +1,3 @@
-import * as Cesium from 'cesium';
import { FOCUS_EVIDENCE_DEV } from './policy.js';
export function createEvidence({
@@ -13,12 +12,13 @@ export function createEvidence({
/** Replace live AIS rows through the production reconciliation path (DEV only). */
function _setFocusEvidenceVessels(rows = []) {
- if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) {
+ const scene = state.viewer || state.renderer?.view;
+ if (!FOCUS_EVIDENCE_DEV || !scene || !state.renderer?.fleet.attached()) {
return { ok: false, count: 0 };
}
components.selection.clearVesselInspection();
components.snapshots.reconcileVessels(
- state.viewer,
+ scene,
Array.isArray(rows) ? rows : [],
);
state.feed.count = state.records.all.length;
@@ -37,15 +37,11 @@ export function createEvidence({
/** JSON-safe vessel alpha/position snapshot for the evidence report. */
function _focusEvidenceVesselSnapshot() {
- if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
+ const view = state.renderer?.view;
+ if (!FOCUS_EVIDENCE_DEV || !view) return [];
return state.records.all.map((record) => {
const bb = components.rendering.getVisual(record).billboard;
- const screen = bb?.position
- ? Cesium.SceneTransforms.worldToWindowCoordinates(
- state.viewer.scene,
- bb.position,
- )
- : null;
+ const screen = bb?.position ? view.worldToWindow(bb.position) : null;
return {
id: record.mmsi,
show: bb?.show === true,
diff --git a/src/layers/vessels/icons.js b/src/layers/vessels/icons.js
new file mode 100644
index 000000000..dc3a1d66e
--- /dev/null
+++ b/src/layers/vessels/icons.js
@@ -0,0 +1,37 @@
+// The vessel chevron artwork, engine-neutral: one SVG data URL per colour
+// and variant. Cesium's billboards load the URL; the Navara renderer draws
+// the same SVGs into its atlas, keyed by the same URL.
+
+import { VESSEL_TYPE_CSS_COLORS } from '../../data/vesselLabels.js';
+
+/** The chevron's artwork size (CSS px), before the billboard scale. */
+export const VESSEL_ICON_PX = 32;
+
+/** The selected variant's colour. */
+export const VESSEL_SELECTED_CSS = '#ffffff';
+
+/**
+ * A chevron/delta-wing SVG data URL tinted `cssColor`. The shape points north
+ * (up) so the billboard rotation maps directly to heading.
+ * @param {string} cssColor
+ * @param {boolean} selected The white/brighter selected variant.
+ * @returns {string}
+ */
+export function vesselChevronIcon(cssColor, selected) {
+ const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
+ const strokeWidth = selected ? 1.1 : 0.7;
+ const svg = ``;
+ return 'data:image/svg+xml;base64,' + btoa(svg);
+}
+
+/** Every chevron the layer can draw: the type colours and the selected one. */
+export function allVesselChevronIcons() {
+ return [
+ ...VESSEL_TYPE_CSS_COLORS.map((css) => vesselChevronIcon(css, false)),
+ vesselChevronIcon(VESSEL_SELECTED_CSS, true),
+ ];
+}
diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js
index f5ee9b497..ca4472141 100644
--- a/src/layers/vessels/index.js
+++ b/src/layers/vessels/index.js
@@ -36,7 +36,9 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.ingestion = createIngestion({
feed: vesselState.state.feed,
readSource: () => vesselState._source,
- readViewer: () => vesselState.state.viewer,
+ // The viewer, or the renderer's view on an engine without one.
+ readViewer: () =>
+ vesselState.state.viewer || vesselState.state.renderer?.view || null,
readArea: vesselViewArea,
getRowLimit: parts.rendering.renderRowLimit,
readCount: () => vesselState.state.records.all.length,
diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js
index def0799b8..b386f29cf 100644
--- a/src/layers/vessels/lifecycle.js
+++ b/src/layers/vessels/lifecycle.js
@@ -117,11 +117,13 @@ export function createLifecycle({
state.feed.firstConnectDeadline = null;
state.feed.firstConnectTimer = null;
state.feed.abort = null;
- state.billboardCollection = null;
+ state.renderer?.destroy();
+ state.renderer = null;
state.records.all = [];
state.records.byMmsi = new Map();
state.records.unkeyed = [];
state.clickHandler = null;
+ state.selectionRemove = null;
state.keyTarget = null;
state.keydownHandler = null;
state.trackedEntityRemover = null;
@@ -141,7 +143,8 @@ export function createLifecycle({
const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
- if (state.viewer)
+ // Initialized: a viewer, or an engine's renderer without one.
+ if (state.viewer || state.renderer?.engine)
throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
@@ -149,17 +152,28 @@ export function createLifecycle({
this.source = source.label || this.source;
},
- init(viewer) {
+ /**
+ * Creates the engine's vessel renderer (render/*.js), its sprite
+ * collection, the click rule and the per-frame pass.
+ * @param {object|null} viewer The Cesium viewer (null on an engine
+ * without one).
+ * @param {{ engine?: object }} [context] LayerLifecycle's context.
+ */
+ init(viewer, { engine = null } = {}) {
if (typeof vesselState._source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
state.viewer = viewer;
- components.rendering.ensureCollections(viewer);
+ state.renderer ??= createRenderer(viewer, engine);
+ // The scene the layer draws in: the viewer, or the renderer's view on
+ // an engine without one.
+ const scene = viewer || state.renderer?.view || null;
+ components.rendering.ensureCollections(scene);
vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID,
false,
);
- components.selection.installInteraction(viewer);
- components.rendering.installRuntime(viewer);
+ components.selection.installInteraction(scene);
+ components.rendering.installRuntime(scene);
restoreSpriteOrder(viewer);
},
@@ -169,8 +183,11 @@ export function createLifecycle({
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
- components.rendering.ensureCollections(activeViewer);
- components.selection.installInteraction(activeViewer);
+ // The scene the layer draws in: the viewer, or the renderer's view on
+ // an engine without one.
+ const scene = activeViewer || state.renderer?.view || null;
+ components.rendering.ensureCollections(scene);
+ components.selection.installInteraction(scene);
components.rendering.setVisible(true);
// Height-datum fix: warm the geoid grid once per layer-enable, never
// blocking a poll. The first refresh may land pre-resolve (N = 0), and
@@ -198,12 +215,20 @@ export function createLifecycle({
restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away.
removeViewListener();
- const moveEnd = activeViewer?.camera?.moveEnd;
- if (typeof moveEnd?.addEventListener === 'function')
- state.feed.removeViewListener = moveEnd.addEventListener(() => {
- components.ingestion.refreshIfMoved(activeViewer);
- });
- return components.ingestion.loadLivePositions(activeViewer);
+ const onMoveEnd = () => {
+ components.ingestion.refreshIfMoved(scene);
+ };
+ if (state.renderer)
+ state.feed.removeViewListener = state.renderer.view.on(
+ 'moveEnd',
+ onMoveEnd,
+ );
+ else {
+ const moveEnd = activeViewer?.camera?.moveEnd;
+ if (typeof moveEnd?.addEventListener === 'function')
+ state.feed.removeViewListener = moveEnd.addEventListener(onMoveEnd);
+ }
+ return components.ingestion.loadLivePositions(scene);
},
disable() {
@@ -225,18 +250,15 @@ export function createLifecycle({
state.feed.loadingLabel = '';
},
- destroy(viewer) {
+ destroy() {
removeViewListener();
- const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
- if (state.billboardCollection && activeViewer?.scene?.primitives) {
- activeViewer.scene.primitives.remove(state.billboardCollection);
- }
+ state.renderer?.fleet.detach();
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID,
@@ -250,6 +272,17 @@ export function createLifecycle({
},
};
+ /** The engine's vessel renderer (the Cesium one without an engine). */
+ function createRenderer(viewer, engine) {
+ return (
+ services.renderer?.create({
+ viewer,
+ engine,
+ createTrail: services.trails?.createTrail ?? null,
+ }) ?? null
+ );
+ }
+
function removeViewListener() {
const remove = state.feed.removeViewListener;
state.feed.removeViewListener = null;
@@ -266,6 +299,7 @@ export function createLifecycle({
isDefinitiveTransportFailure,
markAisUnavailable,
resetState,
+ createRenderer,
methods,
};
}
diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs
index 28455a293..8746000d9 100644
--- a/src/layers/vessels/ownership.test.mjs
+++ b/src/layers/vessels/ownership.test.mjs
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createVesselLayer } from './index.js';
import { createVesselState } from './state.js';
+import { createVesselsCesiumRenderer } from './render/cesium.js';
const noop = () => {};
function services() {
@@ -30,6 +31,10 @@ function services() {
},
worldFocus: {},
render: { releaseContinuousRender: noop },
+ // The renderer service as the application's without an engine (Cesium).
+ renderer: {
+ create: (options) => createVesselsCesiumRenderer(null, options),
+ },
};
}
function setup(source, options = {}) {
diff --git a/src/layers/vessels/render/cesium.js b/src/layers/vessels/render/cesium.js
new file mode 100644
index 000000000..933797f96
--- /dev/null
+++ b/src/layers/vessels/render/cesium.js
@@ -0,0 +1,159 @@
+// The vessels layer's renderer on Cesium (phase 3 Task 13a): the Cesium
+// objects the layer used to build itself, moved here one for one — its
+// BillboardCollection of ship chevrons (translucent blend, screen-aligned
+// rotation, centred, never depth-tested), the camera reads (horizon
+// occluder, window projection, canvas size), the "another layer took the
+// camera" signal (viewer.trackedEntityChanged) and the selected vessel's
+// trail (data/trailRenderer's entity polyline). Nothing here decides
+// anything: the layer's logic decides, this draws.
+//
+// The trail stays an entity polyline (createTrail) rather than engine.lines:
+// Cesium's engine.lines is a Primitive with a depth-fail appearance, the
+// path that did not draw segments below the photoreal mesh in round 6
+// (plan, the T13/T15 blocker; flights keeps the same entity path in 10a).
+//
+// Interface (shared with render/navara.js): ./contract.js.
+
+import * as Cesium from 'cesium';
+
+/**
+ * Registry factory (src/app/layers/renderers.js): `(engine, options)`.
+ * @param {object|null} engine The Cesium globe engine (null in unit tests).
+ * @param {{ viewer: object, createTrail?: Function,
+ * adopt?: { billboardCollection?: object } }} options
+ * `createTrail`: data/trailRenderer's (no trail without it); `adopt`: an
+ * existing collection (test seam).
+ * @returns {import('./contract.js').VesselRenderer}
+ */
+export function createVesselsCesiumRenderer(
+ engine,
+ { viewer = null, createTrail = null, adopt = {} } = {},
+) {
+ let collection = adopt.billboardCollection ?? null;
+
+ const view = {
+ get camera() {
+ return viewer?.camera;
+ },
+ get scene() {
+ return viewer?.scene;
+ },
+ /** For this pass: the ellipsoid horizon at the camera, or null. */
+ horizon() {
+ const cameraPosition = viewer?.camera?.positionWC;
+ if (!cameraPosition) return null;
+ return new Cesium.EllipsoidalOccluder(
+ Cesium.Ellipsoid.WGS84,
+ cameraPosition,
+ );
+ },
+ worldToWindow: (position, result) =>
+ Cesium.SceneTransforms.worldToWindowCoordinates(
+ viewer.scene,
+ position,
+ result,
+ ),
+ canvasSize() {
+ const canvas = viewer?.scene?.canvas || viewer?.canvas;
+ return {
+ width: Number(canvas?.clientWidth) || 0,
+ height: Number(canvas?.clientHeight) || 0,
+ };
+ },
+ requestRender: () => viewer?.scene?.requestRender?.(),
+ on(event, listener) {
+ const source =
+ event === 'preRender'
+ ? viewer?.scene?.preRender
+ : event === 'moveEnd'
+ ? viewer?.camera?.moveEnd
+ : null;
+ return typeof source?.addEventListener === 'function'
+ ? source.addEventListener(listener)
+ : null;
+ },
+ };
+
+ const fleet = {
+ /** The Cesium collection (data/spriteOrder registers it), or null. */
+ get collection() {
+ return collection;
+ },
+ attached: () => Boolean(collection),
+ attach({ show }) {
+ if (collection || !viewer) return;
+ collection = new Cesium.BillboardCollection({
+ blendOption: Cesium.BlendOption.TRANSLUCENT,
+ });
+ collection.show = show;
+ viewer.scene.primitives.add(collection);
+ },
+ setVisible(show) {
+ if (collection) collection.show = show;
+ },
+ /**
+ * One chevron: the Billboard is the handle (the logic writes position,
+ * show, rotation, image, scale and color on it).
+ */
+ add({ position, show, image, scale, rotation, id }) {
+ return collection.add({
+ position,
+ show,
+ image,
+ scale,
+ rotation,
+ alignedAxis: Cesium.Cartesian3.ZERO,
+ horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
+ verticalOrigin: Cesium.VerticalOrigin.CENTER,
+ // Locked height-datum principle #2: contacts are ALWAYS visible —
+ // depth-test-free sprites; the EllipsoidalOccluder handles the far
+ // side. (The tile sea mesh ≠ the geoid exactly, so a depth-tested
+ // chevron at the geoid still clips out behind local tide/mesh noise.)
+ disableDepthTestDistance: Number.POSITIVE_INFINITY,
+ id,
+ });
+ },
+ remove(handle) {
+ collection?.remove(handle);
+ },
+ detach() {
+ if (collection && viewer?.scene?.primitives)
+ viewer.scene.primitives.remove(collection);
+ collection = null;
+ },
+ };
+
+ /** The record a pick is of (Cesium: the billboard id is the record). */
+ const pickedRecord = (picked) => {
+ const rawId = picked?.id ?? picked?.primitive?.id;
+ return rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi')
+ ? rawId
+ : null;
+ };
+
+ // Vessels never set viewer.trackedEntity: a new tracked entity belongs to
+ // another layer, which takes the scene.
+ const follow = {
+ onChanged: (listener) =>
+ viewer.trackedEntityChanged.addEventListener(listener),
+ active: () => Boolean(viewer?.trackedEntity),
+ };
+
+ const trails = {
+ create: ({ color, width }) =>
+ typeof createTrail === 'function' && viewer
+ ? createTrail(viewer, { color, width })
+ : null,
+ };
+
+ return {
+ kind: 'cesium',
+ engine,
+ view,
+ fleet,
+ pickedRecord,
+ follow,
+ trails,
+ destroy() {},
+ };
+}
diff --git a/src/layers/vessels/render/contract.js b/src/layers/vessels/render/contract.js
new file mode 100644
index 000000000..8cf979e7d
--- /dev/null
+++ b/src/layers/vessels/render/contract.js
@@ -0,0 +1,92 @@
+// The vessel renderer: what the vessels layer's logic asks of an engine to
+// draw its ship chevrons and the selected vessel's trail, and to read the
+// camera. render/cesium.js implements it over Cesium (the objects the layer
+// used to build itself, one for one); render/navara.js on the Navara engine
+// over engine.movers / engine.lines / engine.projector. Nothing here imports
+// an engine.
+//
+// The logic holds HANDLES and writes plain fields on them; the field names
+// are Cesium's Billboard fields (the extraction is a pure move), and an
+// engine other than Cesium implements them as setters:
+//
+// SpriteHandle (fleet.add) — one chevron: position (Cartesian3), show,
+// rotation (screen angle, rad), image (a chevron data URL of
+// ../icons.js), scale, color ({red, green, blue, alpha} with
+// withAlpha(); reads return the engine's copy), width/height (CSS px;
+// undefined until drawn on Cesium), id (the record). Data/focus keys
+// its emphasis on the handle object.
+// Trail (trails.create): setPositions(Cartesian3[]), clear(), destroy().
+//
+// The view: `camera` camera-shaped (positionWC, positionCartographic,
+// rightWC, upWC, heading, pitch, roll, pickEllipsoid(screen)) and `scene`
+// scene-shaped ({ camera, canvas, globe? }) for the shared helpers that take
+// them (iconOrientation, the view-area request); on Navara they are adapters
+// over engine.camera, with the engine registered for the scene
+// (engineFromViewer) so projections go through its projector.
+
+/**
+ * @typedef {object} VesselView
+ * @property {object} camera
+ * @property {object} scene
+ * @property {() => ({ isPointVisible(position: object): boolean } | null)} horizon
+ * For this pass: the ellipsoid horizon at the camera.
+ * @property {(position: object, result?: object) => ({x: number, y: number} | undefined)} worldToWindow
+ * @property {() => { width: number, height: number }} canvasSize CSS px.
+ * @property {() => void} requestRender
+ * @property {(event: 'preRender'|'moveEnd', listener: Function) => (() => void) | null} on
+ */
+
+/**
+ * @typedef {object} VesselRenderer
+ * @property {'cesium'|'navara'} kind
+ * @property {object|null} engine
+ * @property {VesselView} view
+ * @property {{ collection: object|null, attached(): boolean,
+ * attach(options: { show: boolean }): void, setVisible(show: boolean): void,
+ * add(spec: { position: object, show: boolean, image: string, scale: number,
+ * rotation: number, id: object }): object,
+ * remove(handle: object): void, detach(): void }} fleet
+ * @property {(picked: object) => object|null} pickedRecord The vessel record
+ * a pick is of (keyed or not), or null.
+ * @property {{ onChanged(listener: Function): () => void, active(): boolean }} follow
+ * The engine's follow camera (vessels never follow: a follow belongs to
+ * another layer).
+ * @property {{ create(options: { color: string, width: number }): object|null }} trails
+ * @property {() => void} destroy
+ */
+
+/** The members every vessel renderer has (both engines). */
+export const VESSEL_RENDERER_SHAPE = Object.freeze({
+ top: ['kind', 'view', 'fleet', 'pickedRecord', 'follow', 'trails', 'destroy'],
+ view: [
+ 'camera',
+ 'scene',
+ 'horizon',
+ 'worldToWindow',
+ 'canvasSize',
+ 'requestRender',
+ 'on',
+ ],
+ fleet: [
+ 'collection',
+ 'attached',
+ 'attach',
+ 'setVisible',
+ 'add',
+ 'remove',
+ 'detach',
+ ],
+ follow: ['onChanged', 'active'],
+ trails: ['create'],
+});
+
+/** The members of VESSEL_RENDERER_SHAPE a renderer lacks ([] when whole). */
+export function missingVesselRendererMembers(renderer) {
+ const missing = [];
+ for (const name of VESSEL_RENDERER_SHAPE.top)
+ if (!(name in (renderer ?? {}))) missing.push(name);
+ for (const part of ['view', 'fleet', 'follow', 'trails'])
+ for (const name of VESSEL_RENDERER_SHAPE[part])
+ if (!(name in (renderer?.[part] ?? {}))) missing.push(`${part}.${name}`);
+ return missing;
+}
diff --git a/src/layers/vessels/renderer.test.mjs b/src/layers/vessels/renderer.test.mjs
new file mode 100644
index 000000000..fa6573b31
--- /dev/null
+++ b/src/layers/vessels/renderer.test.mjs
@@ -0,0 +1,210 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
+import * as selection from '../../data/selectionDispatcher.js';
+import * as picking from '../../data/pickRegistry.js';
+import { createVesselLayer } from './index.js';
+import { createVesselsCesiumRenderer } from './render/cesium.js';
+import { missingVesselRendererMembers } from './render/contract.js';
+import { allVesselChevronIcons, vesselChevronIcon } from './icons.js';
+import {
+ VESSEL_TYPE_CSS_COLORS,
+ vesselTypeCss,
+} from '../../data/vesselLabels.js';
+
+const noop = () => {};
+
+/** A renderer of plain records over the engine: what the logic asks for. */
+function recordingRenderer(engine) {
+ const sprites = new Set();
+ const followListeners = new Set();
+ let following = false;
+ let attached = false;
+ return {
+ sprites,
+ raiseFollow(active) {
+ following = active;
+ for (const listener of followListeners) listener();
+ },
+ renderer: {
+ kind: 'plain',
+ engine,
+ view: {
+ camera: {
+ positionWC: { x: 0, y: 0, z: 20_000_000 },
+ positionCartographic: { latitude: 0, longitude: 0, height: 1e7 },
+ rightWC: { x: 1, y: 0, z: 0 },
+ upWC: { x: 0, y: 1, z: 0 },
+ heading: 0,
+ pitch: 0,
+ roll: 0,
+ },
+ scene: { canvas: { clientWidth: 0, clientHeight: 0 } },
+ horizon: () => ({ isPointVisible: () => true }),
+ worldToWindow: () => undefined,
+ canvasSize: () => ({ width: 1280, height: 800 }),
+ requestRender: noop,
+ on: () => noop,
+ },
+ fleet: {
+ collection: null,
+ attached: () => attached,
+ attach() {
+ attached = true;
+ },
+ setVisible: noop,
+ add(spec) {
+ const sprite = { ...spec };
+ sprites.add(sprite);
+ return sprite;
+ },
+ remove: (sprite) => sprites.delete(sprite),
+ detach() {
+ attached = false;
+ },
+ },
+ pickedRecord: (picked) =>
+ picked?.id && typeof picked.id === 'object' ? picked.id : null,
+ follow: {
+ onChanged(listener) {
+ followListeners.add(listener);
+ return () => followListeners.delete(listener);
+ },
+ active: () => following,
+ },
+ trails: { create: () => null },
+ destroy: noop,
+ },
+ };
+}
+
+function services(renderer) {
+ return {
+ context: {
+ clearSelectedEntityContextForLayer: noop,
+ registerEntityContext: () => null,
+ selectEntityContext: noop,
+ },
+ trails: { createTrail: null },
+ labels: {},
+ picking,
+ overlay: {
+ setOverlayEntries: noop,
+ setOverlaySourceVisible: noop,
+ clearOverlaySource: noop,
+ hitTestWorldOverlay: () => null,
+ },
+ geoid: { ensureGeoidReady: () => new Promise(noop), geoidHeight: () => 0 },
+ sprites: {
+ registerSpriteCollection: noop,
+ restoreSpriteOrder: noop,
+ restoreSpriteOrderOnEnable: noop,
+ },
+ focus: {
+ focusNowMs: (now) => now,
+ getFocusTarget: () => null,
+ focusPassIsNeeded: () => false,
+ forgetSpriteFocus: noop,
+ },
+ worldFocus: { requestWorldFocus: noop },
+ render: { holdContinuousRender: noop, releaseContinuousRender: noop },
+ selection,
+ renderer: { create: () => renderer },
+ };
+}
+
+const row = (mmsi, latitude, longitude) => ({
+ id: mmsi,
+ reference: mmsi,
+ latitude,
+ longitude,
+ name: `SHIP ${mmsi}`,
+ type: 'Cargo',
+ observedAtMs: 500,
+ speedMps: 2,
+ headingDeg: 90,
+ courseDeg: 90,
+});
+
+test('the vessels Cesium renderer has every member of the contract', () => {
+ const renderer = createVesselsCesiumRenderer(null, {
+ viewer: { scene: {}, camera: {} },
+ });
+ assert.deepEqual(missingVesselRendererMembers(renderer), []);
+ assert.equal(renderer.kind, 'cesium');
+ // The record a pick is of: Cesium's billboard id is the record.
+ const record = { mmsi: '111' };
+ assert.equal(renderer.pickedRecord({ id: record }), record);
+ assert.equal(renderer.pickedRecord({ primitive: { id: record } }), record);
+ assert.equal(renderer.pickedRecord({ id: 'ae01ce' }), null);
+});
+
+test('the chevron artwork: one icon per type colour plus the selected one', () => {
+ const icons = allVesselChevronIcons();
+ assert.equal(icons.length, VESSEL_TYPE_CSS_COLORS.length + 1);
+ assert.equal(new Set(icons).size, icons.length);
+ assert.ok(icons.includes(vesselChevronIcon(vesselTypeCss('Tanker'), false)));
+ assert.ok(icons.includes(vesselChevronIcon('#ffffff', true)));
+});
+
+test('vessel logic draws and selects through a plain renderer on an engine, with no Cesium', async () => {
+ const engine = createFakeEngine();
+ const recording = recordingRenderer(engine);
+ const layer = createVesselLayer({
+ source: {
+ label: 'Fixture vessels',
+ getSnapshot: async () => ({
+ records: [row('111', 40.6, -73.9), row('222', 40.7, -74.0)],
+ source: 'Fixture vessels',
+ complete: true,
+ stale: false,
+ freshness: 'current',
+ observedAtMs: 500,
+ transportStatus: 'live',
+ rawRowCount: 2,
+ }),
+ },
+ services: services(recording.renderer),
+ });
+ const previousDocument = globalThis.document;
+ globalThis.document = Object.assign(new EventTarget(), {
+ getElementById: () => null,
+ });
+ try {
+ layer.init(null, { engine });
+ await layer.enable(null, { engine });
+ assert.equal(recording.sprites.size, 2);
+ const sprite = [...recording.sprites].find((s) => s.id.mmsi === '111');
+ assert.equal(sprite.scale, 0.6);
+ assert.equal(sprite.rotation, 0);
+ assert.equal(
+ sprite.image,
+ vesselChevronIcon(vesselTypeCss('Cargo'), false),
+ );
+
+ // A click on its sprite selects it (the engine's dispatcher, own rule).
+ engine.fake.script('pick', () => ({ id: sprite.id }));
+ engine.fake.input('click', { position: { x: 10, y: 10 } });
+ assert.equal(layer.getSelectedInfo()?.mmsi, '111');
+ assert.equal(sprite.scale, 0.6 * 1.2);
+ assert.equal(sprite.image, vesselChevronIcon('#ffffff', true));
+
+ // Another layer takes the camera: the selection goes.
+ recording.raiseFollow(true);
+ assert.equal(layer.getSelectedInfo(), null);
+ recording.raiseFollow(false);
+
+ // Select again, then a click on empty map releases it.
+ engine.fake.input('click', { position: { x: 10, y: 10 } });
+ assert.equal(layer.getSelectedInfo()?.mmsi, '111');
+ engine.fake.script('pick', () => undefined);
+ engine.fake.input('click', { position: { x: 500, y: 10 } });
+ assert.equal(layer.getSelectedInfo(), null);
+
+ layer.disable(null, { engine });
+ layer.destroy(null, { engine });
+ assert.equal(recording.renderer.fleet.attached(), false);
+ } finally {
+ globalThis.document = previousDocument;
+ }
+});
diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js
index 2d3c7d276..c4829a29b 100644
--- a/src/layers/vessels/rendering.js
+++ b/src/layers/vessels/rendering.js
@@ -1,4 +1,6 @@
-import * as Cesium from 'cesium';
+import * as geo from '../../geo/index.js';
+import { WHITE } from '../shared/color.js';
+import { vesselChevronIcon, VESSEL_SELECTED_CSS } from './icons.js';
import {
vesselTypeCss,
vesselOverlayCohortLimit,
@@ -45,19 +47,19 @@ export function createRendering({
components.tracking.currentGeoidN(record.lat, record.lon),
VESSEL_LIFT_M,
);
- visual.position = Cesium.Cartesian3.fromDegrees(
+ visual.position = geo.cartesian3.fromDegrees(
record.lon,
record.lat,
heightM,
);
- visual.surfacePosition = Cesium.Cartesian3.fromDegrees(
+ visual.surfacePosition = geo.cartesian3.fromDegrees(
record.lon,
record.lat,
0,
);
- visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
+ visual.normal = geo.ellipsoid.WGS84.geodeticSurfaceNormal(
visual.position,
- new Cesium.Cartesian3(),
+ geo.cartesian3.create(),
);
return visual;
}
@@ -92,14 +94,13 @@ export function createRendering({
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
}
- function ensureCollections(viewer) {
- if (!viewer || state.billboardCollection) return;
- state.billboardCollection = new Cesium.BillboardCollection({
- blendOption: Cesium.BlendOption.TRANSLUCENT,
- });
- state.billboardCollection.show = state.feed.enabled;
- viewer.scene.primitives.add(state.billboardCollection);
- registerSpriteCollection('ais', state.billboardCollection);
+ // The renderer's sprite collection, once a scene is there (the argument:
+ // the scene the caller holds — the viewer, or the renderer's view).
+ function ensureCollections(scene) {
+ const fleet = state.renderer?.fleet;
+ if (!scene || !fleet || fleet.attached()) return;
+ fleet.attach({ show: state.feed.enabled });
+ if (fleet.collection) registerSpriteCollection('ais', fleet.collection);
}
/**
@@ -107,28 +108,21 @@ export function createRendering({
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
* per-record label primitive exists anymore.
* @param {Object} record - Normalized vessel record.
- * @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
+ * @param {{isPointVisible: Function}|null} occluder - Horizon occluder for initial visibility.
*/
function addRecordPrimitives(record, occluder) {
const visible =
state.feed.enabled &&
isVisible(getVisual(record).surfacePosition, occluder);
- getVisual(record).billboard = state.billboardCollection.add({
+ // Depth-test-free, centred, screen-aligned (the renderer's sprite).
+ getVisual(record).billboard = state.renderer.fleet.add({
position: getVisual(record).position,
show: visible,
image: shipIcon(record, false),
scale: shipScale(record),
// Screen-projected rotation lands on the next visibility/rotation pass.
rotation: 0,
- alignedAxis: Cesium.Cartesian3.ZERO,
- horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
- verticalOrigin: Cesium.VerticalOrigin.CENTER,
- // Locked height-datum principle #2: contacts are ALWAYS visible —
- // depth-test-free sprites; the EllipsoidalOccluder handles the far side.
- // (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
- // the geoid still clips out behind local tide/mesh noise.)
- disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: record,
});
}
@@ -140,9 +134,9 @@ export function createRendering({
function removeRecordPrimitives(record) {
if (!record) return;
- if (getVisual(record).billboard && state.billboardCollection) {
+ if (getVisual(record).billboard && state.renderer?.fleet.attached()) {
forgetSpriteFocus(getVisual(record).billboard);
- state.billboardCollection.remove(getVisual(record).billboard);
+ state.renderer.fleet.remove(getVisual(record).billboard);
}
getVisual(record).billboard = null;
}
@@ -178,26 +172,21 @@ export function createRendering({
*/
function shipIcon(record, selected) {
- const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
+ const cssColor = selected
+ ? VESSEL_SELECTED_CSS
+ : vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
if (vesselState.shipIconCache.has(key))
return vesselState.shipIconCache.get(key);
- const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
- const strokeWidth = selected ? 1.1 : 0.7;
- const svg = ``;
- const icon = 'data:image/svg+xml;base64,' + btoa(svg);
+ const icon = vesselChevronIcon(cssColor, selected);
vesselState.shipIconCache.set(key, icon);
return icon;
}
function installRuntime(viewer) {
- if (state.preRenderRemover || !viewer) return;
- state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
+ if (state.preRenderRemover || !viewer || !state.renderer) return;
+ state.preRenderRemover = state.renderer.view.on('preRender', () =>
updateVisibility(),
);
}
@@ -221,8 +210,9 @@ export function createRendering({
return;
}
- const scene = state.viewer?.scene;
- const camera = state.viewer?.camera;
+ const view = state.renderer?.view;
+ const scene = view?.scene;
+ const camera = view?.camera;
if (regularPass) {
// Candidate construction stays on the original 800 ms selector cadence.
// The 80 ms focus-only pass below never allocates label candidates.
@@ -262,13 +252,9 @@ export function createRendering({
previousActiveCount: state.activeFocusCount,
nowMs: now,
screenPositionFor: (position) =>
- Cesium.SceneTransforms.worldToWindowCoordinates(
- scene,
- position,
- vesselState._scratchFocusScreen,
- ),
+ view.worldToWindow(position, vesselState._scratchFocusScreen),
cameraDistanceFor: (position) =>
- Cesium.Cartesian3.distance(camera.positionWC, position),
+ geo.cartesian3.distance(camera.positionWC, position),
});
state.activeFocusCount = result.activeCount;
}
@@ -321,7 +307,7 @@ export function createRendering({
// the non-zero floor while it competes with the tracked target. Preserve
// the billboard's existing base RGB, matching the other layer patterns,
// rather than repainting every chevron from a hard-coded WHITE base.
- const baseColor = bb.color || Cesium.Color.WHITE;
+ const baseColor = bb.color || WHITE;
bb.color = baseColor.withAlpha(focus.factor);
writes += 1;
}
@@ -330,12 +316,7 @@ export function createRendering({
}
function makeOccluder() {
- const cameraPosition = state.viewer?.camera?.positionWC;
- if (!cameraPosition) return null;
- return new Cesium.EllipsoidalOccluder(
- Cesium.Ellipsoid.WGS84,
- cameraPosition,
- );
+ return state.renderer?.view.horizon() ?? null;
}
function isVisible(surfacePosition, occluder) {
@@ -355,8 +336,8 @@ export function createRendering({
*/
function updateClusteredLabels(records) {
- const viewer = state.viewer;
- const scene = viewer?.scene;
+ const view = state.renderer?.view;
+ const scene = view?.scene;
const selected = state.selectedRecord;
const entries = selected
? [components.cards.buildSelectedVesselCard(selected)]
@@ -373,10 +354,7 @@ export function createRendering({
for (const record of records) {
const visual = getVisual(record);
if (record === selected) continue;
- const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
- scene,
- visual.position,
- );
+ const screen = view.worldToWindow(visual.position);
if (!screen) continue;
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
const candidate = {
@@ -395,8 +373,7 @@ export function createRendering({
// selected card's anchor seeds the accepted set so ambient cards keep clear.
const accepted = [];
if (selected) {
- const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
- scene,
+ const screen = view.worldToWindow(
getVisual(selected).billboard?.position || getVisual(selected).position,
);
if (screen) accepted.push({ x: screen.x, y: screen.y });
@@ -427,9 +404,10 @@ export function createRendering({
*/
function publishVesselOverlayEntries(entries) {
- const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
- const width = Number(canvas?.clientWidth) || 0;
- const height = Number(canvas?.clientHeight) || 0;
+ const { width, height } = state.renderer?.view.canvasSize() ?? {
+ width: 0,
+ height: 0,
+ };
const ambientLimit = vesselOverlayCohortLimit(
width,
height,
@@ -487,9 +465,7 @@ export function createRendering({
}
function setVisible(show) {
- if (state.billboardCollection) {
- state.billboardCollection.show = show;
- }
+ state.renderer?.fleet.setVisible(show);
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}
return {
diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js
index 88e5aa2ec..856715cac 100644
--- a/src/layers/vessels/selection.js
+++ b/src/layers/vessels/selection.js
@@ -1,4 +1,3 @@
-import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
@@ -19,100 +18,140 @@ export function createSelection({
} = services.context;
const aisLiveVesselsLayer = layer;
- function installInteraction(viewer) {
- if (state.clickHandler || !viewer) return;
- const handler = state.interactionHandlerFactory
- ? state.interactionHandlerFactory(viewer)
- : new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
- bindVesselInteraction(
- viewer,
- handler,
- state.interactionKeyTarget || document,
+ /**
+ * Join the engine's selection dispatcher with this layer's own click rule
+ * (`onClick`: every click with its top pick, no gesture rule, as its old
+ * LEFT_CLICK handler), plus Escape and the "another layer took the camera"
+ * signal. The test seam (interactionHandlerFactory) gives a handler-like
+ * click source over a fake viewer's scene.pick instead.
+ * @param {object|null} scene The viewer, or the renderer's view.
+ */
+ function installInteraction(scene) {
+ if (state.clickHandler || state.selectionRemove || !scene) return;
+ const keyTarget = state.interactionKeyTarget || document;
+ if (state.interactionHandlerFactory) {
+ bindVesselInteraction(
+ scene,
+ state.interactionHandlerFactory(scene),
+ keyTarget,
+ );
+ return;
+ }
+ const renderer = state.renderer;
+ const dispatcher = services.selection?.selectionDispatcherFor?.(
+ renderer?.engine,
+ );
+ if (!dispatcher) return;
+ state.selectionRemove = dispatcher.register('ais-live-vessels', {
+ // Owned while enabled (the lifecycle registers the same predicate on
+ // enable and drops it on disable).
+ owns: (id) => state.feed.enabled && state.records.byMmsi.has(id),
+ onClick: (ctx) => handleClick(ctx),
+ });
+ bindKeysAndFollow(renderer, keyTarget);
+ }
+
+ /** Test seam: a handler-like click source over `viewer.scene.pick`. */
+ function bindVesselInteraction(viewer, handler, keyTarget) {
+ state.clickHandler = handler;
+ handler.setInputAction((click) =>
+ handleClick({
+ position: click.position,
+ picked: viewer.scene.pick(click.position),
+ }),
+ );
+ // The follow signal of the Cesium renderer over that viewer.
+ bindKeysAndFollow(
+ services.renderer?.create({ viewer, engine: null }),
+ keyTarget,
);
}
- function bindVesselInteraction(viewer, handler, keyTarget) {
- state.clickHandler = handler;
- handler.setInputAction((click) => {
- // A tool owns the pointer (src/data/inputOwnership.js): yield the click.
- if (!isPointerFree()) return;
- if (!state.feed.enabled) return;
- const picked = viewer.scene.pick(click.position);
- const pickedId = resolvePickId(picked);
- let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
- const rawId = picked?.id ?? picked?.primitive?.id;
- const ownRecordPick =
- rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
-
- // An own-layer record without a live map key is a strict no-op (FB-1
- // residual). Trails carry no layer identity and hug their contacts, so any
- // `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
- // unowned scene picks dismiss the current vessel inspection.
- if (ownRecordPick && (!pickedId || !record)) return;
- if (pickedId && !record && String(pickedId).startsWith('gev-trail:'))
- return;
-
- // A sibling layer already owns this click. Preserve the current vessel
- // selection and do not compete with its camera command.
- const siblingOwned =
- pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
- // Cyclone geometry is ambient context behind painted cards. Its handler
- // yields to the same foreground AIS hit. Keep moving-contact precedence
- // unchanged: those handlers may issue their own camera command.
- const cycloneBackground =
- siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
- if (siblingOwned && !cycloneBackground) return;
-
- // Cards are painted on a pointer-events:none canvas, so the scene pick is
- // usually terrain behind the card. Resolve against the host's current
- // actionable hit rectangles before treating the click as empty space.
- const cardHit = !record
- ? vesselState._vesselOverlayHost.hitTest?.(
- click.position?.x,
- click.position?.y,
- {
- ...(cycloneBackground
- ? {}
- : { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
- },
- )
- : null;
- if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
- return;
- if (!record && cardHit) {
- const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
- ? cardHit.entryId.slice('vessel:'.length)
- : null;
- record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
- // A stale card id is not empty terrain and must not clear a newer
- // selection. The next paint will evict its hit rectangle.
- if (!record) return;
- }
-
- if (record) {
- // A valid sprite or card click always transfers the camera exactly once,
- // including a second click on the already-selected vessel.
- selectAndFocusVessel(record);
- } else {
- const transition = components.queries.reduceVesselSelection({
- selectedMmsi: state.selectedRecord?.mmsi,
- pickedMmsi: null,
- gesture: 'click',
- });
- if (transition.action === 'deselect') clearVesselInspection();
- }
- }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+ function bindKeysAndFollow(renderer, keyTarget) {
state.keyTarget = keyTarget;
state.keydownHandler = onVesselKeyDown;
keyTarget.addEventListener('keydown', state.keydownHandler);
- // Vessels never set viewer.trackedEntity, so any new tracked entity belongs
- // to another layer and takes interaction ownership of the scene.
- state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(
- () => {
- if (viewer.trackedEntity && state.selectedRecord)
- clearVesselInspection();
- },
- );
+ // Vessels never follow, so any new follow belongs to another layer and
+ // takes interaction ownership of the scene.
+ const follow = renderer?.follow;
+ state.trackedEntityRemover =
+ follow?.onChanged(() => {
+ if (follow.active() && state.selectedRecord) clearVesselInspection();
+ }) ?? null;
+ }
+
+ /** The vessel click rule (the old LEFT_CLICK handler's, as is). */
+ function handleClick(click) {
+ // A tool owns the pointer (src/data/inputOwnership.js): yield the click.
+ if (!isPointerFree()) return;
+ if (!state.feed.enabled) return;
+ const picked = click.picked;
+ const pickedId = resolvePickId(picked);
+ let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
+ const ownRecordPick = Boolean(pickedRecordOf(picked));
+
+ // An own-layer record without a live map key is a strict no-op (FB-1
+ // residual). Trails carry no layer identity and hug their contacts, so any
+ // `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
+ // unowned scene picks dismiss the current vessel inspection.
+ if (ownRecordPick && (!pickedId || !record)) return;
+ if (pickedId && !record && String(pickedId).startsWith('gev-trail:'))
+ return;
+
+ // A sibling layer already owns this click. Preserve the current vessel
+ // selection and do not compete with its camera command.
+ const siblingOwned =
+ pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId);
+ // Cyclone geometry is ambient context behind painted cards. Its handler
+ // yields to the same foreground AIS hit. Keep moving-contact precedence
+ // unchanged: those handlers may issue their own camera command.
+ const cycloneBackground =
+ siblingOwned && !isOwnedByOtherLayer('weather-cyclones', pickedId);
+ if (siblingOwned && !cycloneBackground) return;
+
+ // Cards are painted on a pointer-events:none canvas, so the scene pick is
+ // usually terrain behind the card. Resolve against the host's current
+ // actionable hit rectangles before treating the click as empty space.
+ const cardHit = !record
+ ? vesselState._vesselOverlayHost.hitTest?.(
+ click.position?.x,
+ click.position?.y,
+ {
+ ...(cycloneBackground
+ ? {}
+ : { sourceId: VESSEL_OVERLAY_SOURCE_ID }),
+ },
+ )
+ : null;
+ if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
+ return;
+ if (!record && cardHit) {
+ const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
+ ? cardHit.entryId.slice('vessel:'.length)
+ : null;
+ record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
+ // A stale card id is not empty terrain and must not clear a newer
+ // selection. The next paint will evict its hit rectangle.
+ if (!record) return;
+ }
+
+ if (record) {
+ // A valid sprite or card click always transfers the camera exactly once,
+ // including a second click on the already-selected vessel.
+ selectAndFocusVessel(record);
+ } else {
+ const transition = components.queries.reduceVesselSelection({
+ selectedMmsi: state.selectedRecord?.mmsi,
+ pickedMmsi: null,
+ gesture: 'click',
+ });
+ if (transition.action === 'deselect') clearVesselInspection();
+ }
+ }
+
+ /** The vessel record a pick is of (keyed or not), through the renderer. */
+ function pickedRecordOf(picked) {
+ return state.renderer?.pickedRecord(picked) ?? null;
}
/** Select one live vessel and request one UI-owned camera transfer. */
@@ -141,6 +180,8 @@ export function createSelection({
state.clickHandler.destroy();
state.clickHandler = null;
}
+ state.selectionRemove?.();
+ state.selectionRemove = null;
if (state.keyTarget && state.keydownHandler) {
state.keyTarget.removeEventListener('keydown', state.keydownHandler);
}
diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js
index a200e5b55..5addea1b0 100644
--- a/src/layers/vessels/state.js
+++ b/src/layers/vessels/state.js
@@ -51,13 +51,17 @@ export function createVesselState({ source, services }) {
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
viewer: null,
- billboardCollection: null,
+ /** The engine's vessel renderer (render/*.js), per lifecycle. */
+ renderer: null,
+ /** Test-only: the handler-like click source (interactionHandlerFactory). */
clickHandler: null,
+ /** Leaves the engine's selection dispatcher. */
+ selectionRemove: null,
/** Exact EventTarget currently holding the Escape listener. */
keyTarget: null,
/** Exact callback registered on keyTarget. */
keydownHandler: null,
- /** Cesium trackedEntityChanged listener disposer. */
+ /** The renderer's follow-changed listener disposer. */
trackedEntityRemover: null,
/** Test-only factory used to exercise enable-time interaction installation. */
interactionHandlerFactory: null,
@@ -72,7 +76,7 @@ export function createVesselState({ source, services }) {
selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
- /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
+ /** @type {object[]} Chronological trail vertices, Cartesian3 (oldest first) */
trailPositions: [],
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
trailMmsi: null,
diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js
index dc11f3003..a206f5036 100644
--- a/src/layers/vessels/testing.js
+++ b/src/layers/vessels/testing.js
@@ -48,7 +48,16 @@ export function createTesting({
.map((record) => [record.mmsi, record]),
);
state.selectedRecord = options.selectedRecord || null;
- state.billboardCollection = options.billboardCollection || { remove() {} };
+ // The Cesium renderer over the test viewer, adopting the test collection.
+ state.renderer =
+ services.renderer?.create({
+ viewer: state.viewer,
+ engine: null,
+ createTrail: services.trails?.createTrail ?? null,
+ adopt: {
+ billboardCollection: options.billboardCollection || { remove() {} },
+ },
+ }) ?? null;
state.trail = options.trail || null;
state.trailMmsi = options.trailMmsi || null;
state.trailPositions = Array.isArray(options.trailPositions)
diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js
index 761b236fc..a19d5b015 100644
--- a/src/layers/vessels/tracking.js
+++ b/src/layers/vessels/tracking.js
@@ -16,7 +16,6 @@ export function createTracking({
}) {
const { state } = vesselState;
const { geoidHeight } = services.geoid;
- const { createTrail } = services.trails;
/**
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
@@ -34,7 +33,7 @@ export function createTracking({
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
* to avoid z-fighting.
* @param {Object} record - Vessel record with lat/lon.
- * @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
+ * @returns {object|null} Lifted position (Cartesian3), or null without a fix.
*/
function vesselTrailPosition(record) {
@@ -89,8 +88,8 @@ export function createTracking({
state.trailPositions = [];
const current = vesselTrailPosition(record);
if (current) state.trailPositions.push(current);
- if (!state.trail && state.viewer) {
- state.trail = createTrail(state.viewer, {
+ if (!state.trail && state.renderer) {
+ state.trail = state.renderer.trails.create({
color: TRAIL_COLOR,
width: 2.5,
});
diff --git a/src/tooling/importDirections.test.mjs b/src/tooling/importDirections.test.mjs
index 22ff1be3e..9a45fc8c8 100644
--- a/src/tooling/importDirections.test.mjs
+++ b/src/tooling/importDirections.test.mjs
@@ -311,7 +311,7 @@ test('layers moved onto renderers keep each engine in its render file', (t) => {
"export const load = () => import('../layers/flights/render/navara.js');",
);
// A layer not moved yet still imports cesium anywhere (until its *a task).
- write('src/layers/vessels/rendering.js', "import 'cesium';");
+ write('src/layers/satellites/rendering.js', "import 'cesium';");
assert.doesNotThrow(() => checkImportDirections(root));
write('src/layers/flights/tracking.js', "import 'cesium';");