diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index c94739ced..d658f4290 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1471,6 +1471,12 @@ "src/layers/vessels/rendering.js", "src/data/entityRecords.js", "src/data/billboardMirror.js", + "src/data/iconSetPrimitive.js", + "src/data/iconSet.js", + "src/data/iconSegment.js", + "src/data/iconAtlas.js", + "src/data/aircraftRecession.js", + "src/data/motionModel.js", "src/layers/vessels/selection.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 210f6aa43..529cf545d 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -39,7 +39,7 @@ import { presentSelectedVessel, subscribeSelectedVessel, } from '../hudReadouts.js'; -import { mirrorBillboard, setBillboard } from './billboardMirror.js'; +import { createIconSet } from './iconSet.js'; test('open feed with vessels is healthy (null)', () => { assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null); @@ -656,17 +656,18 @@ test('disable and destroy make abort-ignoring AIS responses inert', async () => } }); +/** One vessel row in a fresh set, as the layer adds it, plus its record. */ +function focusFixture() { + const icons = createIconSet(); + const row = icons.add('353136000', { layer: 'ais-live-vessels', id: '353136000' }); + icons.setSize(row, 32, 32); + const record = { iconId: '353136000', position: { x: 1, y: 2, z: 3 }, visible: true }; + return { icons, records: [record], record, focus: () => icons.focus(icons.row('353136000')) }; +} + test('vessel focus wire animates alpha with deadband and restores after tracking ends', () => { let writes = 0; - const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); - let color = makeColor(1); - const billboard = mirrorBillboard({ - position: { x: 1, y: 2, z: 3 }, - show: true, - get color() { return color; }, - set color(next) { writes += 1; color = next; }, - }); - const records = [{ position: billboard.position, billboard }]; + const { icons, records, focus } = focusFixture(); const target = { screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, paddingPx: 0, @@ -686,6 +687,7 @@ test('vessel focus wire animates alpha with deadband and restores after tracking const apply = (nowMs, focusTarget) => { const result = applyVesselFocusDeemphasis({ records, + icons, target: focusTarget, previousActiveCount: activeCount, nowMs, @@ -694,34 +696,30 @@ test('vessel focus wire animates alpha with deadband and restores after tracking params, }); activeCount = result.activeCount; + writes += result.writes; return result; }; assert.equal(apply(0, target).writes, 0); assert.equal(apply(150, target).writes, 1); - assert.ok(color.alpha > 0.25 && color.alpha < 1); + assert.ok(focus() > 0.25 && focus() < 1); assert.equal(apply(150, target).writes, 0, 'same timestamp stays inside deadband'); assert.equal(apply(300, target).writes, 1); - assert.equal(color.alpha, 0.25); + assert.equal(focus(), 0.25); assert.equal(apply(300, null).writes, 0, 'release starts continuously from the floor'); assert.equal(apply(600, null).writes, 1); - assert.ok(color.alpha > 0.25 && color.alpha < 1); + assert.ok(focus() > 0.25 && focus() < 1); assert.equal(apply(900, null).writes, 1); - assert.equal(color.alpha, 1); + assert.equal(focus(), 1); assert.equal(apply(901, null).writes, 0, 'settled restoration causes no churn'); assert.equal(writes, 4); }); test('vessel focus wire performs no alpha writes when nothing has been tracked', () => { - let writes = 0; - const billboard = mirrorBillboard({ - position: { x: 0, y: 0, z: 0 }, - show: true, - get color() { return { alpha: 1 }; }, - set color(_next) { writes += 1; }, - }); + const { icons, records, focus } = focusFixture(); const result = applyVesselFocusDeemphasis({ - records: [{ position: billboard.position, billboard }], + records, + icons, target: null, nowMs: 100, screenPositionFor: () => ({ x: 50, y: 50 }), @@ -733,17 +731,11 @@ test('vessel focus wire performs no alpha writes when nothing has been tracked', activeCount: 0, ran: false, }); - assert.equal(writes, 0); + assert.equal(focus(), 1); }); test('vessel focus wire restores a hidden sprite before releasing the active pass', () => { - const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); - const billboard = mirrorBillboard({ - position: { x: 1, y: 2, z: 3 }, - show: true, - color: makeColor(1), - }); - const records = [{ billboard }]; + const { icons, records, record, focus } = focusFixture(); const target = { screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, paddingPx: 0, @@ -759,6 +751,7 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas }; const pass = (nowMs, focusTarget, previousActiveCount) => applyVesselFocusDeemphasis({ records, + icons, target: focusTarget, previousActiveCount, nowMs, @@ -770,15 +763,15 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas pass(0, target, 0); const dimmed = pass(params.attackMs, target, 0); assert.equal(dimmed.activeCount, 1); - assert.equal(billboard.color.alpha, params.dimFloor); + assert.equal(focus(), params.dimFloor); - setBillboard(billboard, 'show', false); + record.visible = false; const releaseStart = pass(params.attackMs, null, dimmed.activeCount); const restored = pass(params.attackMs + params.releaseMs, null, releaseStart.activeCount); assert.equal(restored.ran, true); assert.equal(restored.activeCount, 0); assert.equal(restored.writes, 1); - assert.equal(billboard.color.alpha, 1); + assert.equal(focus(), 1); }); // --- Selection gestures (FB-1) --------------------------------------------- @@ -847,7 +840,6 @@ function makeRecord(overrides = {}) { lastPositionUtc: '', missedRefreshes: 0, position: POS, - billboard: null, ...overrides, }; } @@ -1056,18 +1048,9 @@ test('vessel interaction wire: CCTV-owned pick preserves vessel selection', () = }); test('vessel interaction wire: own-layer unkeyed record pick does not deselect', () => { - const harness = installWireHarness({ id: makeRecord({ mmsi: undefined }) }); - try { - harness.handler.click({ position: { x: 10, y: 20 } }); - assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); - assert.equal(harness.trail.clearCalls, 0); - } finally { - harness.cleanup(); - } -}); - -test('vessel interaction wire: own-shaped evicted record pick does not deselect', () => { - const harness = installWireHarness({ id: makeRecord({ mmsi: '999999999' }) }); + const icons = createIconSet(); + icons.add('ais-unkeyed:1', { id: 'ais-unkeyed:1' }); + const harness = installWireHarness({ id: 'ais-unkeyed:1' }, { icons }); try { harness.handler.click({ position: { x: 10, y: 20 } }); assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi); @@ -1350,11 +1333,9 @@ test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () = assert.ok(long.title.length <= 26, `title too long: ${long.title.length}`); }); -test('buildVesselCard: anchors to the billboard position when present', () => { +test('buildVesselCard: anchors to the drawn position', () => { const rendered = new Cesium.Cartesian3(9, 9, 9); - const card = buildVesselCard( - makeRecord({ billboard: mirrorBillboard({ position: rendered }) }), - ); + const card = buildVesselCard(makeRecord({ position: rendered })); assert.deepEqual(card.position, rendered); }); @@ -1452,7 +1433,6 @@ test('vessel real layer lifecycle publishes protected selection and leaves no st viewer: { scene: { canvas: { clientWidth: 1600, clientHeight: 900 } } }, records: [record], selectedRecord: record, - billboardCollection: { show: true, remove() {} }, }); try { _updateVesselCardsForTest([]); @@ -1535,7 +1515,7 @@ test('EGM96 N pins the field finding: Rotterdam sea sits ~45 m ABOVE the ellipso // camera, whether the camera was free or tracking something else. The layer // only announces the click — the UI owns the flight (src/worldFocus.js). test('vessel interaction wire: selecting a vessel by click requests a camera transfer', () => { - const harness = installWireHarness({ id: makeRecord() }, { selectedRecord: null }); + const harness = installWireHarness({ id: makeRecord().mmsi }, { selectedRecord: null }); const requests = []; harness.windowTarget.addEventListener( WORLD_FOCUS_REQUEST_EVENT, @@ -1744,19 +1724,13 @@ test('vessel selection passes the opaque source reference to optional history', }); test('vessel focus wire skips hidden sprites at rest while a target is tracked', () => { - const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); - const sprite = (show) => - mirrorBillboard({ - position: { x: 1, y: 2, z: 3 }, - show, - color: makeColor(1), - }); - const visible = sprite(true); - const hidden = Array.from({ length: 50 }, () => sprite(false)); - const records = [visible, ...hidden].map((billboard) => ({ - position: billboard.position, - billboard, - })); + const icons = createIconSet(); + const records = Array.from({ length: 51 }, (_, i) => { + const iconId = String(i); + icons.setSize(icons.add(iconId, { id: iconId }), 32, 32); + return { iconId, position: { x: 1, y: 2, z: 3 }, visible: i === 0 }; + }); + const focus = (record) => icons.focus(icons.row(record.iconId)); const target = { screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, paddingPx: 0, @@ -1767,6 +1741,7 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked', const apply = (nowMs) => applyVesselFocusDeemphasis({ records, + icons, target, nowMs, screenPositionFor: () => { @@ -1786,7 +1761,7 @@ test('vessel focus wire skips hidden sprites at rest while a target is tracked', // Only the on-screen sprite pays for projection and distance. assert.equal(projected, 2); assert.equal(measured, 2); - assert.equal(visible.color.alpha, 0.25); + assert.equal(focus(records[0]), 0.25); assert.equal(result.activeCount, 1); - for (const billboard of hidden) assert.equal(billboard.color.alpha, 1); + for (const record of records.slice(1)) assert.equal(focus(record), 1); }); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index d735df3f8..eeac4c979 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -274,7 +274,7 @@ test('a click on neither sprite nor card clears without moving the camera', () = const SIBLING_PICKS = [ ['flights', 'a1b2c3', { id: 'a1b2c3' }], ['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }], - ['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }], + ['ais-live-vessels', '353136000', { id: '353136000' }], ['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }], ['satellites', '25544', { id: 25544 }], ]; diff --git a/src/data/focusResumeGate.test.mjs b/src/data/focusResumeGate.test.mjs index 93d4c2ba1..8719bb436 100644 --- a/src/data/focusResumeGate.test.mjs +++ b/src/data/focusResumeGate.test.mjs @@ -5,6 +5,7 @@ import { applyCctvFocusDeemphasis } from './cctv.js'; import { applySatellitePointFocusDeemphasis } from './satellites.js'; import { DEFAULT_FOCUS_DEEMPHASIS_PARAMS } from './focusDeemphasis.js'; import { mirrorBillboard } from './billboardMirror.js'; +import { createIconSet } from './iconSet.js'; const params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS, paddingPx: 0 }; const target = { @@ -52,18 +53,14 @@ function assertResumeGate(name, makePass) { } assertResumeGate('vessel', () => { - const billboard = mirrorBillboard({ - position: { x: 1, y: 2, z: 3 }, - show: true, - width: 32, - height: 32, - scale: 1, - color: color(), - }); + const icons = createIconSet(); + icons.setSize(icons.add('353136000', { id: '353136000' }), 32, 32); + const record = { iconId: '353136000', position: { x: 1, y: 2, z: 3 }, visible: true }; return { pass: ({ nowMs, target: focusTarget, previousActiveCount }) => ( applyVesselFocusDeemphasis({ - records: [{ billboard }], + records: [record], + icons, target: focusTarget, previousActiveCount, nowMs, @@ -72,7 +69,7 @@ assertResumeGate('vessel', () => { params, }) ), - readAlpha: () => billboard.color.alpha, + readAlpha: () => icons.focus(icons.row('353136000')), }; }); diff --git a/src/data/iconAtlas.js b/src/data/iconAtlas.js index 8e08c2532..9dd7be8d5 100644 --- a/src/data/iconAtlas.js +++ b/src/data/iconAtlas.js @@ -19,6 +19,7 @@ const MAX_SIZE_PX = 4096; function createAtlas() { const slotOfKey = new Map(); const entries = []; + const listeners = new Set(); const atlas = { /** Bumped whenever the canvas or a slot rectangle changes. */ version: 0, @@ -57,6 +58,17 @@ function createAtlas() { ready(slot) { return atlas.rects[slot * 4 + 2] > 0; }, + + /** + * Call `listener` after every repack, so a renderer that draws on demand + * can ask for the frame that shows a newly loaded image. + * @param {() => void} listener + * @returns {() => void} Stops listening. + */ + onChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, }; function load(entry) { @@ -120,6 +132,7 @@ function createAtlas() { atlas.rects[o + 3] = (entry.y + entry.sizePx) / size; } atlas.version++; + for (const listener of listeners) listener(); return true; } diff --git a/src/data/iconSetPrimitive.js b/src/data/iconSetPrimitive.js index 6aaf9907e..90f0900ce 100644 --- a/src/data/iconSetPrimitive.js +++ b/src/data/iconSetPrimitive.js @@ -208,13 +208,19 @@ const EARTH_BOUNDS = new Cesium.BoundingSphere( ); /** - * @param {{ set: object, renderTime: () => Cesium.JulianDate }} options `renderTime` - * gives the instant to draw at (the layer's delayed clock). + * @param {{ set: object, renderTime: () => Cesium.JulianDate, + * requestRender?: () => void }} options `renderTime` gives the instant to draw + * at (the layer's delayed clock). `requestRender` asks for a frame when the + * atlas packs a newly loaded image, for a layer that does not render + * continuously. */ export class IconSetPrimitive { - constructor({ set, renderTime }) { + constructor({ set, renderTime, requestRender }) { this.set = set; this.renderTime = renderTime; + this._stopAtlasListener = requestRender + ? iconAtlas.onChange(requestRender) + : null; this.show = true; this._tau = 0; this._generation = -1; @@ -571,6 +577,7 @@ export class IconSetPrimitive { } destroy() { + this._stopAtlasListener?.(); this._destroyBuffers(); this._programs?.opaque.destroy(); this._programs?.translucent.destroy(); diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index ef3d8074e..a891b6c5b 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -19,11 +19,10 @@ const _owners = new Map(); /** * Resolves a scene.pick() result to a String pick id for the ownership scan. * - * Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use - * strings, satellites use numeric NORAD catalog ids, live AIS vessels attach - * the vessel record OBJECT (identity = its `mmsi`), and entity picks surface - * the Cesium Entity (identity = its string `id`). Everything is coerced to a - * String so predicates match against one canonical form. + * Layers use heterogeneous pick ids: flights/military/vessels/bikeshare/CCTV + * use strings, satellites use numeric NORAD catalog ids, and entity picks + * surface the Cesium Entity (identity = its string `id`). Everything is + * coerced to a String so predicates match against one canonical form. * * @param {object|null|undefined} picked - Result of `scene.pick()`. * @returns {string|null} Canonical pick id, or null when the pick carries none. @@ -33,9 +32,7 @@ export function resolvePickId(picked) { const unwrap = (id) => { if (id === null || id === undefined) return undefined; if (typeof id === 'object') { - // AIS vessel record (id object with .mmsi) or Cesium Entity (.id string) - if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number') - return id.mmsi; + // A Cesium Entity (.id string) if (typeof id.id === 'string' || typeof id.id === 'number') return id.id; return undefined; } diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index 43f11260f..a9076b02b 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -33,12 +33,6 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () = assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013'); }); -test('resolvePickId: AIS vessel record object → its mmsi', () => { - const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} }; - assert.equal(resolvePickId({ id: record }), '367123450'); - assert.equal(resolvePickId({ primitive: { id: record } }), '367123450'); -}); - test('resolvePickId: Cesium-Entity-like object → its string id', () => { const entity = { id: 'cctv-atx-cam-3-center', properties: {} }; assert.equal(resolvePickId({ id: entity }), 'cctv-atx-cam-3-center'); @@ -48,7 +42,7 @@ test('resolvePickId: unresolvable picks → null', () => { assert.equal(resolvePickId(null), null); assert.equal(resolvePickId(undefined), null); assert.equal(resolvePickId({}), null); - assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id + assert.equal(resolvePickId({ id: {} }), null); // object with no id assert.equal(resolvePickId({ primitive: {} }), null); }); @@ -67,8 +61,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', () try { // Flights asking about a satellite pick (raw numeric id → String upstream) assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true); - // Flights asking about a vessel pick (record object → mmsi) - assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true); + // Flights asking about a vessel pick (an icon row id: the mmsi) + assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: '367123450' })), true); // A layer never owns its own pick via the sibling scan assert.equal(isOwnedByOtherLayer('satellites', '25544'), false); // Unknown ids belong to nobody @@ -147,7 +141,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => { primaryPointer(t, true); contactOwners(t); - for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) { + for (const first of [{ id: 'military' }, { id: '123456789' }]) { const scene = { pick: () => mapTile, drillPick: () => [mapTile, first, { id: 'civil' }], diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index 9cbf6521a..8041840f7 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -1,6 +1,5 @@ import * as Cesium from 'cesium'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; -import { billboardState } from '../../data/billboardMirror.js'; export function createEvidence({ vesselState, @@ -14,7 +13,7 @@ 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) { + if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.icons) { return { ok: false, count: 0 }; } components.selection.clearVesselInspection(); @@ -40,18 +39,19 @@ export function createEvidence({ function _focusEvidenceVesselSnapshot() { if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; return state.records.all.map((record) => { - const bb = components.rendering.getVisual(record).billboard; - const icon = billboardState(bb); - const screen = icon?.position + const visual = components.rendering.getVisual(record); + const row = state.icons?.row(visual.iconId) ?? -1; + const position = row >= 0 ? visual.position : null; + const screen = position ? Cesium.SceneTransforms.worldToWindowCoordinates( state.viewer.scene, - icon.position, + position, ) : null; return { id: record.mmsi, - show: icon?.show === true, - alpha: bb?.color?.alpha ?? null, + show: row >= 0 && visual.visible === true, + alpha: row >= 0 ? state.icons.focus(row) : null, x: screen?.x ?? null, y: screen?.y ?? null, }; diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 1c1f611cc..eafd05b35 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -114,7 +114,8 @@ export function createLifecycle({ state.feed.firstConnectDeadline = null; state.feed.firstConnectTimer = null; state.feed.abort = null; - state.billboardCollection = null; + state.icons = null; + state.iconPrimitive = null; state.records.all = []; state.records.byMmsi = new Map(); state.records.unkeyed = []; @@ -187,8 +188,7 @@ export function createLifecycle({ /* grid failed to load — anchors stay at ellipsoid 0 */ }); } - // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; - // the registry resolver reduces it to the record's mmsi (a string key). + // Pick-ownership (H2): a vessel icon's pick id is its row id, the MMSI. registerPickOwner('ais-live-vessels', (pickedId) => state.records.byMmsi.has(pickedId), ); @@ -233,9 +233,13 @@ export function createLifecycle({ unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); components.tracking.destroySelectedVesselTrail(); - if (state.billboardCollection && activeViewer?.scene?.primitives) { - activeViewer.scene.primitives.remove(state.billboardCollection); + if (state.iconPrimitive && activeViewer?.scene?.primitives) { + activeViewer.scene.primitives.remove(state.iconPrimitive); } + // Not in a scene (or no viewer to remove it from): still stop its atlas + // listener and free its GPU resources. + if (state.iconPrimitive && !state.iconPrimitive.isDestroyed()) + state.iconPrimitive.destroy(); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.setVisible( VESSEL_OVERLAY_SOURCE_ID, diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index d5cd274c2..955d012d4 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -2,6 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createVesselLayer } from './index.js'; import { createVesselState } from './state.js'; +import * as Cesium from 'cesium'; +import { createIconSet } from '../../data/iconSet.js'; const noop = () => {}; function services() { @@ -13,7 +15,7 @@ function services() { trails: { createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), }, - labels: {}, + labels: { formatKnots: (knots) => `${knots} kt` }, picking: { unregisterPickOwner: noop }, overlay: { setOverlayEntries: noop, @@ -36,20 +38,10 @@ function services() { }, }; } -function setup(source, options = {}) { +function setup(source, options = {}, viewer = {}) { const layer = createVesselLayer({ source, services: services(), options }); - const primitives = new Set(); - layer.testing._setVesselStateForTest({ - viewer: {}, - billboardCollection: { - add(options) { - const primitive = { ...options }; - primitives.add(primitive); - return primitive; - }, - remove: (primitive) => primitives.delete(primitive), - }, - }); + const icons = createIconSet(); + layer.testing._setVesselStateForTest({ viewer, icons }); let now = 1000; layer.testing._setAisRuntimeForTest({ now: () => now, @@ -58,7 +50,7 @@ function setup(source, options = {}) { }); return { layer, - primitives, + icons, advance: (ms) => { now += ms; }, @@ -283,27 +275,31 @@ test('partial-feed expiry releases a selected vessel and its pending trail', asy assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); }); -test('refresh keeps pick identity and renderer geometry separate, and eviction releases the primitive', async () => { +test('refresh keeps the icon row and its pick identity, and eviction releases the row', async () => { let current = snapshot([observation('111')]); - const { layer, primitives } = setup({ getSnapshot: async () => current }); + const { layer, icons } = setup({ getSnapshot: async () => current }); await layer.update(); - const [primitive] = primitives; - const record = primitive.id; - for (const key of ['position', 'surfacePosition', 'normal', 'billboard']) { - assert.equal(Object.hasOwn(record, key), false); - } - assert.deepEqual(layer.findByQuery('111').position, primitive.position); - const oldPosition = primitive.position; + assert.equal(icons.count, 1); + assert.equal(icons.idAt(0), '111'); + const meta = icons.metaAt(0); + const drawn = () => + icons.position(icons.row('111'), 0, new Cesium.Cartesian3()); + const near = (a, b) => Cesium.Cartesian3.distance(a, b) < 0.01; + assert.ok(near(layer.findByQuery('111').position, drawn())); + const oldPosition = drawn(); current = snapshot([{ ...observation('111'), longitude: 4.06 }]); await layer.update(); - assert.equal(primitives.size, 1); - assert.equal([...primitives][0], primitive); - assert.equal(primitive.id, record); - assert.notDeepEqual(primitive.position, oldPosition); - assert.deepEqual(layer.findByQuery('111').position, primitive.position); + assert.equal(icons.count, 1); + assert.equal( + icons.metaAt(icons.row('111')), + meta, + 'focus identity survives a refresh', + ); + assert.ok(!near(drawn(), oldPosition)); + assert.ok(near(layer.findByQuery('111').position, drawn())); current = snapshot([observation('222')]); await layer.update(); - assert.equal(primitives.has(primitive), false); + assert.equal(icons.has('111'), false); assert.equal(layer.findByQuery('111'), null); }); @@ -409,3 +405,43 @@ test('empty coverage cannot hide stale, incomplete or rejected source data', asy assert.ok(layer.getStats().error, JSON.stringify(patch)); } }); + +test('cards and detection judge the horizon where the icon shader culls the row', async () => { + // A camera over the limb that sees the row's cull point (lifted to 12 m) but + // not the drawn position (3 m above the ellipsoid before the geoid loads). + const ship = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3); + const lifted = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 12); + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, ship); + let positionWC = null; + for (let lon = 4.05; lon < 40 && !positionWC; lon += 0.0005) { + occluder.cameraPosition = Cesium.Cartesian3.fromDegrees( + lon, + 51.93, + 400_000, + ); + if (occluder.isPointVisible(lifted) && !occluder.isPointVisible(ship)) + positionWC = occluder.cameraPosition; + } + assert.ok(positionWC, 'found a camera between the two horizons'); + const viewer = { camera: { positionWC, heading: 0, pitch: 0, roll: 0 } }; + const current = snapshot([observation('111')]); + const { layer, icons } = setup( + { getSnapshot: async () => current }, + {}, + viewer, + ); + await layer.update(); + const row = icons.row('111'); + occluder.cameraPosition = positionWC; + assert.equal( + occluder.isPointVisible( + icons.cullPoint(row, icons.position(row, 0, new Cesium.Cartesian3())), + ), + true, + ); + assert.deepEqual( + layer.getDetectableObjects().map((entry) => entry.sourceId), + ['111'], + ); + layer.testing._setVesselStateForTest({ enabled: false }); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 56d86e104..320c977d1 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -21,7 +21,6 @@ import { REFRESH_MS, FOCUS_EVIDENCE_DEV, } from './policy.js'; -import { billboardState } from '../../data/billboardMirror.js'; export function createQueries({ vesselState, @@ -469,12 +468,7 @@ export function createQueries({ * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} */ getDetectableObjects(options = {}) { - if ( - !state.feed.enabled || - !state.billboardCollection || - !state.billboardCollection.show - ) - return []; + if (!state.feed.enabled || !state.icons?.show) return []; const records = state.records.all; if (!Array.isArray(records) || !records.length) return []; @@ -492,9 +486,8 @@ export function createQueries({ if ((idx - start) % stride !== 0) continue; const record = records[idx]; const visual = components.rendering.getVisual(record); - const icon = billboardState(visual.billboard); - if (icon && !icon.show) continue; - const position = icon?.position || visual.position; + if (!visual.visible) continue; + const position = visual.position; if (!position) continue; result.push({ position, diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index ad6d85cbd..011b7cf26 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -62,7 +62,6 @@ export class VesselRecords { seen.add(next.mmsi); const record = this.byMmsi.get(next.mmsi); if (record) { - const before = effects.beforeUpdate(record); record.reference = next.reference; record.receivedAtMs = next.receivedAtMs; record.lat = next.lat; @@ -78,7 +77,7 @@ export class VesselRecords { record.lastPositionEpoch = next.lastPositionEpoch; record.missedRefreshes = 0; - effects.updated(record, before); + effects.updated(record); } else { effects.add(next); this.byMmsi.set(next.mmsi, next); diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs index d3e19dcd6..a22e8dcc9 100644 --- a/src/layers/vessels/records.test.mjs +++ b/src/layers/vessels/records.test.mjs @@ -15,8 +15,7 @@ function setup() { const events = []; const effects = { add: (record) => events.push(['add', record]), - beforeUpdate: (record) => record.type, - updated: (record, before) => events.push(['update', record, before]), + updated: (record) => events.push(['update', record]), remove: (record, evicted) => events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]), removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]), @@ -46,9 +45,16 @@ test('vessel reconciliation preserves record identity and first duplicate while probe.reconcile([row('111', { lat: 51.94, type: 'Tanker' })]); assert.equal(probe.store.byMmsi.get('111'), record); assert.equal(record.lat, 51.94); - assert.equal(probe.events.find((event) => event[0] === 'update')[2], 'Cargo'); + assert.equal(probe.events.find((event) => event[0] === 'update')[1], record); + assert.equal(record.type, 'Tanker'); assert.equal(probe.store.byMmsi.has('222'), false); - for (const field of ['position', 'surfacePosition', 'normal', 'billboard']) + for (const field of [ + 'position', + 'surfacePosition', + 'normal', + 'billboard', + 'iconId', + ]) assert.equal(Object.hasOwn(record, field), false); assert.deepEqual(structuredClone(record), record); }); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index eaf7851be..bea2b3a2d 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -7,10 +7,15 @@ import { applyVesselOverlayPolicy, VESSEL_CARD_FADE_DISTANCE_M, } from '../../data/vesselLabels.js'; +import { cameraPoseSignature } from '../../data/iconOrientation.js'; import { - cameraPoseSignature, - screenProjectedRotation, -} from '../../data/iconOrientation.js'; + createIconSet, + ICON_CULL_LIFT, + ICON_ROTATES, +} from '../../data/iconSet.js'; +import { writeHoldSegment } from '../../data/iconSegment.js'; +import { IconSetPrimitive } from '../../data/iconSetPrimitive.js'; +import { iconAtlas } from '../../data/iconAtlas.js'; import { VESSEL_LIFT_M, DEFAULT_RENDER_ROWS, @@ -20,11 +25,9 @@ import { LABEL_GRID_PX, CARD_MIN_SEP_PX, } from './policy.js'; -import { - addBillboard, - billboardState, - setBillboard, -} from '../../data/billboardMirror.js'; + +/** The ship artwork's own raster size (px). */ +const SHIP_ICON_PX = 32; export function createRendering({ vesselState, @@ -41,16 +44,21 @@ export function createRendering({ return visualRecords.get(record) || record; } - /** Where a record's icon was drawn, from Core's mirror, else its visual position. */ + /** Where a record's icon is drawn: its row holds at the visual position. */ function iconPosition(record) { - const visual = getVisual(record); - return billboardState(visual.billboard)?.position || visual.position; + return getVisual(record).position; } function prepareRecordVisual(record) { let visual = visualRecords.get(record); if (!visual) { - visual = { billboard: record.billboard || null }; + // Unkeyed records (no MMSI) are rebuilt every poll, so their rows take + // a fresh id that no MMSI can collide with. + visual = { + iconId: + record.mmsi || `ais-unkeyed:${++vesselState._unkeyedIconSequence}`, + visible: false, + }; visualRecords.set(record, visual); } const heightM = components.queries.vesselDatumHeightM( @@ -62,15 +70,6 @@ export function createRendering({ record.lat, heightM, ); - visual.surfacePosition = Cesium.Cartesian3.fromDegrees( - record.lon, - record.lat, - 0, - ); - visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( - visual.position, - new Cesium.Cartesian3(), - ); return visual; } @@ -110,62 +109,95 @@ export function createRendering({ return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit()); } + /** + * The layer's icon set and its primitive. Vessels hold still between polls, + * so each row is a hold segment and the primitive's clock only keeps segment + * times small. The set draws depth-test-free on the near plane and culls the + * far side on the GPU (locked height-datum principle #2: contacts are ALWAYS + * visible; the tile sea mesh ≠ the geoid, so a depth-tested chevron at the + * geoid would clip behind local tide/mesh noise). + */ function ensureCollections(viewer) { - if (!viewer || state.billboardCollection) return; - state.billboardCollection = new Cesium.BillboardCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, + if (!viewer || state.icons) return; + const epoch = Cesium.JulianDate.now(); + state.icons = createIconSet({ epoch }); + state.icons.show = state.feed.enabled; + state.iconPrimitive = new IconSetPrimitive({ + set: state.icons, + renderTime: () => epoch, + requestRender: () => governorRequestRender('ais-vessels-icons'), }); - state.billboardCollection.show = state.feed.enabled; - viewer.scene.primitives.add(state.billboardCollection); - registerSpriteCollection('ais', state.billboardCollection); + viewer.scene.primitives.add(state.iconPrimitive); + registerSpriteCollection('ais', state.iconPrimitive); } /** - * Create the billboard primitive for a freshly added vessel record. Map labels - * are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no - * per-record label primitive exists anymore. + * Add a freshly added vessel record's icon row. Map labels 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. */ function addRecordPrimitives(record, occluder) { - const visible = - state.feed.enabled && - isVisible(getVisual(record).surfacePosition, occluder); - getVisual(record).billboard = addBillboard(state.billboardCollection, { - position: getVisual(record).position, - show: visible, - image: shipIcon(record, false), - // The ship artwork's own size, stated so Core's mirror knows it. - width: 32, - height: 32, - 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, + const visual = getVisual(record); + const set = state.icons; + const row = set.add(visual.iconId, { + layer: 'ais-live-vessels', + id: visual.iconId, }); + set.setSize(row, SHIP_ICON_PX, SHIP_ICON_PX); + set.setFlag(row, ICON_ROTATES, true); + // Sea level sits at geoid N, below the ellipsoid across much of the ocean. + set.setFlag(row, ICON_CULL_LIFT, true); + set.setShown(row, true); + syncRecordIcon(record); + visual.visible = isVisible(visual.cullPosition, occluder); } /** - * Remove a record's billboard primitive from its collection. + * Write a record's row from its visual position, course, tint and selection, + * and keep the point the shader judges the horizon at (`cullPosition`) so + * the CPU card/focus pass draws the same boundary. + * @param {Object} record - Vessel record with a prepared visual. + */ + + function syncRecordIcon(record) { + const visual = getVisual(record); + const set = state.icons; + const row = set?.row(visual.iconId) ?? -1; + if (row < 0 || !visual.position) return; + const selected = record === state.selectedRecord; + const slot = iconAtlas.slot(shipIcon(record, selected), SHIP_ICON_PX); + set.setImage(row, slot, slot); + set.setScale(row, shipScale(record) * (selected ? 1.2 : 1)); + writeHoldSegment( + set.segments, + row, + visual.position, + vesselCourseDeg(record), + ); + set.segmentWritten(row); + visual.cullPosition = set.cullPoint( + row, + visual.position, + visual.cullPosition, + ); + } + + /** + * Remove a record's icon row. * @param {Object} record - Vessel record to tear down. */ function removeRecordPrimitives(record) { if (!record) return; - if (getVisual(record).billboard && state.billboardCollection) { - forgetSpriteFocus(getVisual(record).billboard); - state.billboardCollection.remove(getVisual(record).billboard); - } - getVisual(record).billboard = null; + const set = state.icons; + const visual = getVisual(record); + const row = set?.row(visual.iconId) ?? -1; + if (row < 0) return; + forgetSpriteFocus(set.metaAt(row)); + set.remove(visual.iconId); } function shipScale(record) { @@ -178,8 +210,7 @@ export function createRendering({ /** * Best available real-world direction of travel for a vessel, degrees * clockwise from north (true heading preferred, course-over-ground fallback). - * Screen rotation is computed from this by the shared projected-rotation - * pass in updateVisibility — never directly from the compass value. + * The icon set turns this into its screen rotation on the GPU each frame. * @param {Object} record - Vessel record. * @returns {number} Course in degrees (0 when unknown). */ @@ -191,8 +222,8 @@ export function createRendering({ /** * Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel. - * The shape points north (up) so billboard rotation maps directly to heading. - * One icon is generated per color+variant and reused across all billboards. + * The shape points north (up) so the row's course maps directly to heading. + * One icon is generated per color+variant and shares one atlas slot. * @param {Object} record - Vessel record (drives per-type tint). * @param {boolean} selected - True for the white/brighter selected variant. * @returns {string} SVG data URL. @@ -291,31 +322,15 @@ export function createRendering({ if (regularPass) { // Candidate construction stays on the original 800 ms selector cadence. // The 80 ms focus-only pass below never allocates label candidates. - const poseSig = camera ? cameraPoseSignature(camera) : ''; - const doRotations = force || poseSig !== vesselState._lastCamPoseSig; - if (doRotations) vesselState._lastCamPoseSig = poseSig; + // Icons cull and rotate on the GPU; this pass only picks the cards and + // remembers which vessels the horizon leaves in view for focus. + if (camera) vesselState._lastCamPoseSig = cameraPoseSignature(camera); const occluder = makeOccluder(); const labelCandidates = []; for (const record of state.records.all) { const visual = getVisual(record); - const visible = isVisible(visual.surfacePosition, occluder); - if (visual.billboard) { - if (billboardState(visual.billboard).show !== visible) - setBillboard(visual.billboard, 'show', visible); - if (visible && doRotations && scene) { - const { rotation } = billboardState(visual.billboard); - const rot = screenProjectedRotation( - scene, - visual.position, - vesselCourseDeg(record), - rotation, - ); - if (rot !== null && Math.abs(rot - rotation) > 0.002) { - setBillboard(visual.billboard, 'rotation', rot); - } - } - } - if (visible) labelCandidates.push(record); + visual.visible = isVisible(visual.cullPosition, occluder); + if (visual.visible) labelCandidates.push(record); } updateClusteredLabels(labelCandidates); } @@ -342,14 +357,19 @@ export function createRendering({ } /** - * Apply focus alpha to vessel sprites. Kept as a production wire seam so the - * animation/deadband contract can be tested without constructing WebGL. + * Apply focus emphasis to vessel icons through the set's focus column + * (narrow always-visible amendment: the ship chevron remains present at the + * non-zero floor while it competes with the tracked target). Kept as a + * production wire seam so the animation/deadband contract can be tested + * without constructing WebGL. * @param {object} input + * @param {object} [input.icons] The icon set (defaults to the layer's). * @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}} */ function applyVesselFocusDeemphasis({ records, + icons = state.icons, target, previousActiveCount = 0, nowMs, @@ -365,43 +385,37 @@ export function createRendering({ let activeCount = 0; for (const record of records || []) { const visual = getVisual(record); - const bb = visual?.billboard; - const icon = billboardState(bb); - const position = icon?.position || visual?.position; - if (!bb || !position) continue; - // Most of a worldwide feed sits behind the horizon. A hidden sprite with - // no pending focus state and full alpha would advance to exactly what it - // already is, so skip it instead of paying its distance and state work - // every 80 ms while anything is tracked. + const row = icons?.row(visual?.iconId) ?? -1; + const position = visual?.position; + if (row < 0 || !position) continue; + const sprite = icons.metaAt(row); + // Most of a worldwide feed sits behind the horizon. A hidden icon with + // no pending focus state at full emphasis would advance to exactly what + // it already is, so skip it instead of paying its distance and state + // work every 80 ms while anything is tracked. if ( - icon.show === false && - spriteFocusAtRest(bb) && - !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) + !visual.visible && + spriteFocusAtRest(sprite) && + !focusAlphaNeedsWrite(icons.focus(row), 1, params) ) continue; - const focus = advanceSpriteFocus(bb, { - // Hidden/far-side sprites still finish any pending release so the active + const focus = advanceSpriteFocus(sprite, { + // Hidden/far-side icons still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. - screenPosition: - icon.show === false ? null : screenPositionFor(position), + screenPosition: visual.visible ? screenPositionFor(position) : null, cameraDistance: cameraDistanceFor(position), nowMs, target, params, - // Vessel artwork is 32 px before billboard scale. Including the - // ambient chevron's own rendered extent prevents edge-overlap misses. - spriteHalfWidthPx: (icon.width || 32) * (icon.scale || 1) * 0.5, - spriteHalfHeightPx: (icon.height || 32) * (icon.scale || 1) * 0.5, + // Vessel artwork is 32 px before scale. Including the ambient + // chevron's own rendered extent prevents edge-overlap misses. + spriteHalfWidthPx: icons.width(row) * icons.scale(row) * 0.5, + spriteHalfHeightPx: icons.height(row) * icons.scale(row) * 0.5, }); transitioning ||= focus.transitioning; if (focus.active) activeCount += 1; - if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) { - // Narrow always-visible amendment: the ship chevron remains present at - // 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; - bb.color = baseColor.withAlpha(focus.factor); + if (focusAlphaNeedsWrite(icons.focus(row), focus.factor, params)) { + icons.setFocus(row, focus.factor); writes += 1; } } @@ -417,9 +431,9 @@ export function createRendering({ ); } - function isVisible(surfacePosition, occluder) { - if (!surfacePosition || !occluder) return true; - return occluder.isPointVisible(surfacePosition); + function isVisible(cullPosition, occluder) { + if (!cullPosition || !occluder) return true; + return occluder.isPointVisible(cullPosition); } /** @@ -569,9 +583,7 @@ export function createRendering({ } function setVisible(show) { - if (state.billboardCollection) { - state.billboardCollection.show = show; - } + if (state.icons) state.icons.show = show; vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); } return { @@ -583,6 +595,7 @@ export function createRendering({ labelRowLimit, ensureCollections, addRecordPrimitives, + syncRecordIcon, removeRecordPrimitives, shipScale, vesselCourseDeg, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 42e6b7ae5..5111d8ed4 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,7 +1,6 @@ import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; -import { setBillboard } from '../../data/billboardMirror.js'; export function createSelection({ vesselState, @@ -43,11 +42,14 @@ export function createSelection({ : 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; + // A vessel icon's pick id is its row id: the MMSI, or an unkeyed row's. + // Removing a row destroys its pick id, so an evicted vessel cannot be + // picked at all; a live row whose record is gone stays a no-op below. + const rawId = picked?.id; const ownRecordPick = - rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi'); + typeof rawId === 'string' && state.icons?.has(rawId) === true; - // An own-layer record without a live map key is a strict no-op (FB-1 + // An own-layer row 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. @@ -170,15 +172,7 @@ export function createSelection({ clearSelection({ preserveTrail: reuseTrail }); state.selectedRecord = record; record.missedRefreshes = 0; - if (components.rendering.getVisual(record).billboard) { - components.rendering.getVisual(record).billboard.image = - components.rendering.shipIcon(record, true); - setBillboard( - components.rendering.getVisual(record).billboard, - 'scale', - components.rendering.shipScale(record) * 1.2, - ); - } + components.rendering.syncRecordIcon(record); // Rebuild the card set immediately so the full-detail card appears on the // click, not up to VISIBILITY_UPDATE_MS later. components.rendering.updateVisibility(true); @@ -229,16 +223,8 @@ export function createSelection({ function clearSelection({ preserveTrail = false, evicted = false } = {}) { const record = state.selectedRecord; - if (components.rendering.getVisual(record)?.billboard) { - components.rendering.getVisual(record).billboard.image = - components.rendering.shipIcon(record, false); - setBillboard( - components.rendering.getVisual(record).billboard, - 'scale', - components.rendering.shipScale(record), - ); - } state.selectedRecord = null; + if (record) components.rendering.syncRecordIcon(record); // Drop the full-detail card right away (no-op when the layer is disabled — // disable() clears the entry set itself). if (record && state.feed.enabled) diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js index 99c6b47bf..887fc5540 100644 --- a/src/layers/vessels/snapshotRenderer.js +++ b/src/layers/vessels/snapshotRenderer.js @@ -1,5 +1,4 @@ /** Connect portable vessel reconciliation to scene and selection operations. */ -import { setBillboard } from '../../data/billboardMirror.js'; export function createVesselSnapshotRenderer({ state, @@ -24,22 +23,10 @@ export function createVesselSnapshotRenderer({ rendering.prepareRecordVisual(record); rendering.addRecordPrimitives(record, occluder); }, - beforeUpdate(record) { - return rendering.shipIcon(record, record === state.selectedRecord); - }, - updated(record, prevIcon) { + updated(record) { const selected = record === state.selectedRecord; - const visual = rendering.prepareRecordVisual(record); - if (visual.billboard) { - setBillboard(visual.billboard, 'position', visual.position); - setBillboard( - visual.billboard, - 'scale', - rendering.shipScale(record) * (selected ? 1.2 : 1), - ); - const nextIcon = rendering.shipIcon(record, selected); - if (nextIcon !== prevIcon) visual.billboard.image = nextIcon; - } + rendering.prepareRecordVisual(record); + rendering.syncRecordIcon(record); if (record.mmsi === state.trailMmsi) tracking.appendSelectedVesselTrailFix(record); if (selected) { diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 3ccaf08d9..02ce6e752 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -46,12 +46,18 @@ export function createVesselState({ source, services }) { vesselState.shipIconCache = new Map(); + /** Row ids for unkeyed (no-MMSI) records, which are rebuilt every poll. */ + + vesselState._unkeyedIconSequence = 0; + vesselState.state = { feed: createVesselFeed(), records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }), viewer: null, - billboardCollection: null, + /** The vessels' icon set and the primitive that draws it. */ + icons: null, + iconPrimitive: null, clickHandler: null, /** Exact EventTarget currently holding the Escape listener. */ keyTarget: null, diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 6cab17086..9aa35d8ff 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -1,4 +1,5 @@ import { DEFAULT_AIS_RUNTIME } from './policy.js'; +import { createIconSet } from '../../data/iconSet.js'; export function createTesting({ vesselState, @@ -48,7 +49,8 @@ export function createTesting({ .map((record) => [record.mmsi, record]), ); state.selectedRecord = options.selectedRecord || null; - state.billboardCollection = options.billboardCollection || { remove() {} }; + state.icons = options.icons || createIconSet(); + state.iconPrimitive = 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 b2faf0cd3..bd6b793a1 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -6,7 +6,6 @@ import { TRAIL_MAX_POINTS, TRAIL_MIN_MOVE_M, } from './policy.js'; -import { setBillboard } from '../../data/billboardMirror.js'; export function createTracking({ vesselState, @@ -18,6 +17,7 @@ export function createTracking({ const { state } = vesselState; const { geoidHeight } = services.geoid; const { createTrail } = services.trails; + const { governorRequestRender } = services.render; /** * Geoid undulation N at (lat, lon), or null until the grid has loaded. @@ -72,9 +72,10 @@ export function createTracking({ record.lat, heightM, ); - if (visual.billboard) - setBillboard(visual.billboard, 'position', visual.position); + components.rendering.syncRecordIcon(record); } + // The rows moved with no poll behind them: draw the frame that shows it. + governorRequestRender('ais-vessels-refloor'); } /**