From 1fe8267550de8f079c1dbfc7386e1c7387834c0d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 12:47:14 +0400 Subject: [PATCH] refactor(vessels): Cesium rendering behind a renderer interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/layers/vessels/render/cesium.js holds the Cesium objects the layer built itself, one for one: the translucent BillboardCollection of chevrons (centred, screen-aligned, never depth-tested), the horizon occluder, window projection, canvas size, viewer.trackedEntityChanged, and the trail (data/trailRenderer's entity polyline, kept on Cesium: the engine.lines Primitive path did not draw below the photoreal mesh in round 6 — the plan's T13/T15 blocker; flights does the same). The logic imports no Cesium: geo math, Rgba WHITE, the chevron artwork in icons.js, clicks through the engine's selection dispatcher (own-rule owner, same rule). Registered as CESIUM_LAYER_RENDERERS['ais-live-vessels']; src/layers/vessels joins the boundary rule. W1 replay vs 7c7422e2: vessels/flights/military/localAdsb counts, known objects, screen points, requests and shot sha256 identical. qa-vessel-cards --data synthetic 2/2 and qa-vessel-datum (?qaFeed= vessels:fixture) 4/4, the same as 6eec8f6c. Tests moved with the code (same meaning): cameraHandoff (the click body is handleClick), inputOwnership (the guard precedes the click rule's read of the pick), renderers.test (vessels in the extracted list), importDirections (an unmoved example layer is satellites), the ownership services fixture (renderer service). --- scripts/check-import-directions.mjs | 1 + scripts/format-scope.json | 4 + scripts/package-boundaries.json | 8 +- src/app/layers/aisLiveVessels.js | 19 ++- src/app/layers/renderers.js | 2 + src/app/layers/renderers.test.mjs | 11 +- src/cameraHandoff.test.mjs | 4 +- src/data/inputOwnership.test.mjs | 4 +- src/data/vesselLabels.js | 5 + src/layers/vessels/evidence.js | 16 +- src/layers/vessels/icons.js | 37 +++++ src/layers/vessels/index.js | 4 +- src/layers/vessels/lifecycle.js | 72 ++++++--- src/layers/vessels/ownership.test.mjs | 5 + src/layers/vessels/render/cesium.js | 159 +++++++++++++++++++ src/layers/vessels/render/contract.js | 92 +++++++++++ src/layers/vessels/renderer.test.mjs | 210 ++++++++++++++++++++++++ src/layers/vessels/rendering.js | 106 +++++-------- src/layers/vessels/selection.js | 219 +++++++++++++++----------- src/layers/vessels/state.js | 10 +- src/layers/vessels/testing.js | 11 +- src/layers/vessels/tracking.js | 7 +- src/tooling/importDirections.test.mjs | 2 +- 23 files changed, 810 insertions(+), 198 deletions(-) create mode 100644 src/layers/vessels/icons.js create mode 100644 src/layers/vessels/render/cesium.js create mode 100644 src/layers/vessels/render/contract.js create mode 100644 src/layers/vessels/renderer.test.mjs 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';");