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';");