From 47f7eeb00e49cdd3cf776b3d3bbfaab2e4a6b64b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Tue, 3 Nov 2026 11:14:33 -0800 Subject: [PATCH] Read vessel, CCTV and FIRMS billboard state from Core's mirror The three layers now add their billboards through addBillboard and write the mirrored fields through setBillboard, so selection, cards, queries, focus, view thinning and the FIRMS horizon cull read what was pushed from Core instead of from Cesium. Vessels and FIRMS state their sprites' own sizes (32 px ship artwork, the glow canvas at twice the core size), which the mirror needs. A vessel helper, iconPosition, replaces the repeated mirror-or-visual position lookups, and the vessel visibility pass writes show only when it changes, as CCTV and FIRMS already did. A billboard never given a position now has none in the mirror rather than the origin. --- scripts/package-boundaries.json | 5 +++ src/data/aisLiveVessels.test.mjs | 34 +++++++++++--------- src/data/billboardMirror.js | 12 +++---- src/data/billboardMirror.test.mjs | 2 +- src/data/cctv.test.mjs | 7 ++-- src/data/cctvViewThinning.test.mjs | 9 ++++-- src/data/firmsHorizonCull.test.mjs | 5 +-- src/data/focusResumeGate.test.mjs | 9 +++--- src/layers/cctv/geometry.js | 3 +- src/layers/cctv/ground.js | 4 ++- src/layers/cctv/lifecycle.js | 3 +- src/layers/cctv/rendering.js | 18 +++++++---- src/layers/cctv/rendering.test.mjs | 3 +- src/layers/firms/model.js | 12 ++++--- src/layers/firms/rendering.js | 9 ++++-- src/layers/vessels/cards.js | 8 ++--- src/layers/vessels/evidence.js | 8 +++-- src/layers/vessels/ownership.test.mjs | 4 +-- src/layers/vessels/queries.js | 16 ++++------ src/layers/vessels/rendering.js | 44 ++++++++++++++++++-------- src/layers/vessels/selection.js | 19 +++++++---- src/layers/vessels/snapshotRenderer.js | 11 +++++-- src/layers/vessels/tracking.js | 4 ++- 23 files changed, 152 insertions(+), 97 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index f469c92da..ee13c0f9d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1447,6 +1447,8 @@ "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", "src/layers/vessels/rendering.js", + "src/data/entityRecords.js", + "src/data/billboardMirror.js", "src/layers/vessels/selection.js", "src/layers/vessels/state.js", "src/layers/vessels/testing.js", @@ -1528,6 +1530,8 @@ "src/layers/firms/policy.js", "src/layers/firms/queries.js", "src/layers/firms/rendering.js", + "src/data/entityRecords.js", + "src/data/billboardMirror.js", "src/layers/firms/selection.js", "src/layers/firms/source.js", "src/layers/firms/state.js", @@ -1857,6 +1861,7 @@ "src/layers/cctv/projection.js", "src/layers/cctv/videoPlayback.js", "src/layers/cctv/rendering.js", + "src/data/billboardMirror.js", "src/layers/cctv/selection.js", "src/layers/cctv/source.js", "src/layers/cctv/state.js", diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 8e12bff94..210f6aa43 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -39,6 +39,7 @@ import { presentSelectedVessel, subscribeSelectedVessel, } from '../hudReadouts.js'; +import { mirrorBillboard, setBillboard } from './billboardMirror.js'; test('open feed with vessels is healthy (null)', () => { assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null); @@ -659,12 +660,12 @@ test('vessel focus wire animates alpha with deadband and restores after tracking let writes = 0; const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); let color = makeColor(1); - const billboard = { + 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 target = { screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, @@ -713,12 +714,12 @@ test('vessel focus wire animates alpha with deadband and restores after tracking test('vessel focus wire performs no alpha writes when nothing has been tracked', () => { let writes = 0; - const billboard = { + const billboard = mirrorBillboard({ position: { x: 0, y: 0, z: 0 }, show: true, get color() { return { alpha: 1 }; }, set color(_next) { writes += 1; }, - }; + }); const result = applyVesselFocusDeemphasis({ records: [{ position: billboard.position, billboard }], target: null, @@ -737,11 +738,11 @@ test('vessel focus wire performs no alpha writes when nothing has been tracked', test('vessel focus wire restores a hidden sprite before releasing the active pass', () => { const makeColor = (alpha) => ({ alpha, withAlpha: (next) => makeColor(next) }); - const billboard = { + const billboard = mirrorBillboard({ position: { x: 1, y: 2, z: 3 }, show: true, color: makeColor(1), - }; + }); const records = [{ billboard }]; const target = { screenRect: { left: 40, top: 40, right: 60, bottom: 60 }, @@ -771,7 +772,7 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas assert.equal(dimmed.activeCount, 1); assert.equal(billboard.color.alpha, params.dimFloor); - billboard.show = false; + setBillboard(billboard, 'show', false); const releaseStart = pass(params.attackMs, null, dimmed.activeCount); const restored = pass(params.attackMs + params.releaseMs, null, releaseStart.activeCount); assert.equal(restored.ran, true); @@ -1350,9 +1351,11 @@ test('buildVesselCard: unnamed vessels title as MMSI; long names truncate', () = }); test('buildVesselCard: anchors to the billboard position when present', () => { - const rendered = { x: 9, y: 9, z: 9 }; - const card = buildVesselCard(makeRecord({ billboard: { position: rendered } })); - assert.equal(card.position, rendered); + const rendered = new Cesium.Cartesian3(9, 9, 9); + const card = buildVesselCard( + makeRecord({ billboard: mirrorBillboard({ position: rendered }) }), + ); + assert.deepEqual(card.position, rendered); }); test('buildVesselCard: tanker types carry the amber accent', () => { @@ -1742,11 +1745,12 @@ 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) => ({ - position: { x: 1, y: 2, z: 3 }, - show, - color: makeColor(1), - }); + 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) => ({ diff --git a/src/data/billboardMirror.js b/src/data/billboardMirror.js index 4084791e7..138d6a5ad 100644 --- a/src/data/billboardMirror.js +++ b/src/data/billboardMirror.js @@ -31,12 +31,10 @@ const DEFAULTS = Object.freeze({ /** Cesium copies positions and scalers on write; the mirror keeps its own copy too. */ function copyInto(record, field, value) { if (field === 'position') { - // Cesium keeps its old position when handed none; so does the mirror. - if (!value && record.position) return; - record.position = Cesium.Cartesian3.clone( - value ?? Cesium.Cartesian3.ZERO, - record.position, - ); + // Cesium keeps its old position when handed none; so does the mirror. A + // billboard never given one has none here (Cesium would report the origin). + if (!value) return; + record.position = Cesium.Cartesian3.clone(value, record.position); } else if (field === 'scaleByDistance') { record.scaleByDistance = value ? Cesium.NearFarScalar.clone(value, record.scaleByDistance) @@ -97,7 +95,7 @@ export function setBillboard(billboard, field, value) { * Core's copy of a billboard's mirrored fields, or null when it has none (or * there is no billboard). * @param {Object|null|undefined} billboard - * @returns {{ show: boolean, position: Cesium.Cartesian3, scale: number, + * @returns {{ show: boolean, position?: Cesium.Cartesian3, scale: number, * rotation: number, width?: number, height?: number, * scaleByDistance?: Cesium.NearFarScalar }|null} */ diff --git a/src/data/billboardMirror.test.mjs b/src/data/billboardMirror.test.mjs index b53b489ae..892bd04cf 100644 --- a/src/data/billboardMirror.test.mjs +++ b/src/data/billboardMirror.test.mjs @@ -62,7 +62,7 @@ test('a billboard Core did not add can be mirrored from its fields', () => { const bb = mirrorBillboard({ show: false, scale: 2 }); assert.equal(billboardState(bb).show, false); assert.equal(billboardState(bb).scale, 2); - assert.ok(Cesium.Cartesian3.equals(billboardState(bb).position, Cesium.Cartesian3.ZERO)); + assert.equal(billboardState(bb).position, undefined, 'none was pushed'); assert.equal(billboardState(null), null); assert.equal(mirrorBillboard(null), null); }); diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index f76acdb72..d7cc131b5 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -79,6 +79,7 @@ import { IDLE_CAMERA_COLOR, LIVE_CAMERA_COLOR, } from '../layers/cctv/policy.js'; +import { mirrorBillboard } from './billboardMirror.js'; const ASPECT = 16 / 9; const toRad = (deg) => (deg * Math.PI) / 180; @@ -355,7 +356,7 @@ test('default COVERAGE refresh materializes the active and visible camera frustu test('camera icons are violet for video feeds, cyan for stills, amber when active', () => { const record = (id, feedType) => ({ camera: { ...UNCLAMPED_CAMERA, id, feedType, groundElevationM: 0 }, - billboard: {}, + billboard: mirrorBillboard({}), coverageEntities: [], projection: null, }); @@ -682,7 +683,7 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou camera: { id: 'a' }, activationDone: true, probeClampRangeM: 105, - billboard: { color: 'keep-color', scale: 1.25 }, + billboard: mirrorBillboard({ color: 'keep-color', scale: 1.25 }), coverageEntities: [{ show: true }, { show: true }], projection: { planeEntity: { show: true } }, viewshedPrimitive: viewshedA, @@ -691,7 +692,7 @@ test('CCTV disable hide sweep hides record visuals and destroys viewsheds withou camera: { id: 'b' }, activationDone: true, probeClampRangeM: 146, - billboard: { color: 'keep-color', scale: 1 }, + billboard: mirrorBillboard({ color: 'keep-color', scale: 1 }), projection: null, viewshedPrimitive: viewshedB, }, diff --git a/src/data/cctvViewThinning.test.mjs b/src/data/cctvViewThinning.test.mjs index 1bc94eed8..62eacbab6 100644 --- a/src/data/cctvViewThinning.test.mjs +++ b/src/data/cctvViewThinning.test.mjs @@ -10,6 +10,7 @@ import cctvLayer, { _viewRecordIdsForTest, refreshHorizonCulling, } from './cctv.js'; +import { mirrorBillboard } from './billboardMirror.js'; const WIDTH = 800; const HEIGHT = 600; @@ -40,7 +41,7 @@ function recordsAt( mountHeightM: 8, }, position, - billboard: { position, show: true }, + billboard: mirrorBillboard({ position, show: true }), coverageEntities: [], projection: null, }; @@ -59,7 +60,11 @@ function viewerAt(heightM, screenByPosition, shiftX = 0) { scene: { canvas: { clientWidth: WIDTH, clientHeight: HEIGHT }, cartesianToCanvasCoordinates: (position) => { - const screen = screenByPosition.get(position); + // The mirror holds its own copy of each position, so match by value. + const key = [...screenByPosition.keys()].find((candidate) => + Cesium.Cartesian3.equals(candidate, position), + ); + const screen = screenByPosition.get(key); return screen && { x: screen.x + shiftX, y: screen.y }; }, }, diff --git a/src/data/firmsHorizonCull.test.mjs b/src/data/firmsHorizonCull.test.mjs index 198ffefdc..277e4f483 100644 --- a/src/data/firmsHorizonCull.test.mjs +++ b/src/data/firmsHorizonCull.test.mjs @@ -15,6 +15,7 @@ import { } from './groundFloor.js'; import { unregisterSpriteCollection } from './spriteOrder.js'; import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js'; +import { mirrorBillboard } from './billboardMirror.js'; const AUSTIN = { lon: -97.7, lat: 30.2 }; /** Antipode of Austin — as far behind the limb as a point on Earth can be. */ @@ -34,13 +35,13 @@ const LIMB_DLON = 41.87; * the pass does not dirty the collection's vertex buffer needlessly. */ function billboard(position, show = true) { - return { + return mirrorBillboard({ position, _show: show, writes: 0, get show() { return this._show; }, set show(value) { this._show = value; this.writes += 1; }, - }; + }); } /** Cesium BillboardCollection-shaped stub. */ diff --git a/src/data/focusResumeGate.test.mjs b/src/data/focusResumeGate.test.mjs index ecd30e831..93d4c2ba1 100644 --- a/src/data/focusResumeGate.test.mjs +++ b/src/data/focusResumeGate.test.mjs @@ -4,6 +4,7 @@ import { applyVesselFocusDeemphasis } from './aisLiveVessels.js'; import { applyCctvFocusDeemphasis } from './cctv.js'; import { applySatellitePointFocusDeemphasis } from './satellites.js'; import { DEFAULT_FOCUS_DEEMPHASIS_PARAMS } from './focusDeemphasis.js'; +import { mirrorBillboard } from './billboardMirror.js'; const params = { ...DEFAULT_FOCUS_DEEMPHASIS_PARAMS, paddingPx: 0 }; const target = { @@ -51,14 +52,14 @@ function assertResumeGate(name, makePass) { } assertResumeGate('vessel', () => { - const billboard = { + const billboard = mirrorBillboard({ position: { x: 1, y: 2, z: 3 }, show: true, width: 32, height: 32, scale: 1, color: color(), - }; + }); return { pass: ({ nowMs, target: focusTarget, previousActiveCount }) => ( applyVesselFocusDeemphasis({ @@ -76,14 +77,14 @@ assertResumeGate('vessel', () => { }); assertResumeGate('CCTV', () => { - const billboard = { + const billboard = mirrorBillboard({ position: { x: 1, y: 2, z: 3 }, show: true, width: 24, height: 24, scale: 1, color: color(), - }; + }); const record = { camera: { id: 'cam-1' }, billboard }; return { pass: ({ nowMs, target: focusTarget, previousActiveCount }) => ( diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index 808ddda25..7a816374d 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -16,6 +16,7 @@ import { poseHash, requiredPlaneLift, } from '../../data/cctvFootprint.js'; +import { setBillboard } from '../../data/billboardMirror.js'; export function createGeometry({ state: layerState, services, parts, source }) { const { warmGroundFloor, cachedGroundFloor } = services.ground; @@ -347,7 +348,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { record.position = positions.mount; record.camera.absoluteHeightM = geometry.mount.alt; if (record.billboard) { - record.billboard.position = positions.mount; + setBillboard(record.billboard, 'position', positions.mount); } if (record.coverageEntities?.length >= 5) { record.coverageEntities[0].polyline.positions = [ diff --git a/src/layers/cctv/ground.js b/src/layers/cctv/ground.js index fdf36db14..a7e610c4d 100644 --- a/src/layers/cctv/ground.js +++ b/src/layers/cctv/ground.js @@ -4,6 +4,7 @@ import { planeSupportPoints, poseHash, } from '../../data/cctvFootprint.js'; +import { setBillboard } from '../../data/billboardMirror.js'; /** Cooldown before a provisional (geoid-fallback / partial) footprint is retried. */ const FOOTPRINT_RETRY_MS = 60_000; @@ -238,7 +239,8 @@ export function createGround({ state: layerState, services, parts, source }) { undefined, record.position, ); - if (record.billboard) record.billboard.position = record.position; + if (record.billboard) + setBillboard(record.billboard, 'position', record.position); moved += 1; } continue; diff --git a/src/layers/cctv/lifecycle.js b/src/layers/cctv/lifecycle.js index 4e4443df8..40ccf8401 100644 --- a/src/layers/cctv/lifecycle.js +++ b/src/layers/cctv/lifecycle.js @@ -12,6 +12,7 @@ import { VIEW_MOTION_MIN_TURN_DEG, VIEW_MOTION_COST_FACTOR, } from './policy.js'; +import { addBillboard } from '../../data/billboardMirror.js'; const MIN_TURN_COS = Math.cos((VIEW_MOTION_MIN_TURN_DEG * Math.PI) / 180); @@ -237,7 +238,7 @@ export function createLifecycle({ camera.lat, camera.absoluteHeightM, ); - const billboard = layerState._billboards.add({ + const billboard = addBillboard(layerState._billboards, { id: camera.id, image: CAMERA_ICON, position, diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 4b6f215e4..36ac3d8d4 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -14,6 +14,7 @@ import { VIEW_ICON_MARGIN, VIEW_MIN_CELL_M, } from './policy.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; /** FNV-1a over a camera id: a stable, well-spread order for crowded cells. */ function idHash(id) { @@ -110,19 +111,20 @@ export function createRendering({ for (const record of records || []) { const bb = record.billboard; if (!bb) continue; - const position = bb.position; + const icon = billboardState(bb); + const position = icon.position; // CCTV never publishes a tracked focus target, so every icon is ambient. const focus = advanceSpriteFocus(bb, { // Keep hidden icons in the state/release pass so the active count cannot // drop while a stale dim alpha remains waiting to reappear. screenPosition: - bb.show === false || !position ? null : screenPositionFor(position), + icon.show === false || !position ? null : screenPositionFor(position), cameraDistance: position ? cameraDistanceFor(position) : Number.NaN, nowMs, target, params, - spriteHalfWidthPx: (bb.width || 24) * (bb.scale || 1) * 0.5, - spriteHalfHeightPx: (bb.height || 24) * (bb.scale || 1) * 0.5, + spriteHalfWidthPx: (icon.width || 24) * (icon.scale || 1) * 0.5, + spriteHalfHeightPx: (icon.height || 24) * (icon.scale || 1) * 0.5, }); transitioning ||= focus.transitioning; if (focus.active) activeCount += 1; @@ -200,7 +202,7 @@ export function createRendering({ const shown = []; const cells = new Map(); for (const record of layerState._records) { - const position = record.billboard?.position; + const position = billboardState(record.billboard)?.position; if (!position) continue; const vx = position.x * inverseRadii.x - cx; const vy = position.y * inverseRadii.y - cy; @@ -245,7 +247,8 @@ export function createRendering({ const bb = record.billboard; if (!bb) continue; const visible = record.viewPass === pass; - if (bb.show !== visible) bb.show = visible; + if (billboardState(bb).show !== visible) + setBillboard(bb, 'show', visible); } const previous = layerState._viewRecords; const changed = @@ -397,7 +400,8 @@ export function createRendering({ if (!Cesium.Color.equals(record.billboard.color, color)) record.billboard.color = color; const scale = isActive ? 1.25 : 1.0; - if (record.billboard.scale !== scale) record.billboard.scale = scale; + if (billboardState(record.billboard).scale !== scale) + setBillboard(record.billboard, 'scale', scale); // disableDepthTestDistance stays POSITIVE_INFINITY for every billboard // (set at creation) — see the field-test far-zoom submerge fix there. } diff --git a/src/layers/cctv/rendering.test.mjs b/src/layers/cctv/rendering.test.mjs index c4b490dd2..4ba0c2b02 100644 --- a/src/layers/cctv/rendering.test.mjs +++ b/src/layers/cctv/rendering.test.mjs @@ -11,6 +11,7 @@ import { LIVE_CAMERA_COLOR, } from './policy.js'; import { isVideoFeedType } from '../../sources/cctvTypes.js'; +import { mirrorBillboard } from '../../data/billboardMirror.js'; const FOCUS_FACTOR = 0.5; @@ -29,7 +30,7 @@ test('focus de-emphasis fades each icon from its own color', () => { camera: { id, feedType }, // No position, so the pass skips screen projection; the focus stub below // decides the factor on its own. - billboard: { show: true }, + billboard: mirrorBillboard({ show: true }), }); const idleVideo = record('idle-hls', 'hls'); const idleStill = record('idle-image', 'image'); diff --git a/src/layers/firms/model.js b/src/layers/firms/model.js index 9e3cf7f95..1c5873812 100644 --- a/src/layers/firms/model.js +++ b/src/layers/firms/model.js @@ -13,6 +13,7 @@ import { CULL_LIFT_M, LABEL_MIN_SEP_PX, } from './policy.js'; +import { billboardState, setBillboard } from '../../data/billboardMirror.js'; export function createModel({ layerState, @@ -273,9 +274,9 @@ export function createModel({ * the opposite side of the Earth shine through the planet at tilted mid-altitude * views. Same pattern as the flights/CCTV layers' `EllipsoidalOccluder` passes. * - * Pure and allocation-free: reads `billboard.position`, writes `billboard.show` - * only when it actually flips (assigning `show` dirties the collection's vertex - * buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without + * Pure and allocation-free: reads the billboard's mirrored position and show, + * and writes `show` only when it actually flips (assigning `show` dirties the + * collection's vertex buffer). Accepts any `{length, get(i)}` shape so it is unit-testable without * a WebGL scene. * * @param {?{length: number, get: function(number): (Object|undefined)}} billboards @@ -295,9 +296,10 @@ export function createModel({ for (let i = 0; i < total; i += 1) { const billboard = billboards.get(i); if (!billboard) continue; - const point = (cullPositions && cullPositions[i]) || billboard.position; + const pushed = billboardState(billboard); + const point = (cullPositions && cullPositions[i]) || pushed.position; const visible = occluder.isPointVisible(point) === true; - if (billboard.show !== visible) billboard.show = visible; + if (pushed.show !== visible) setBillboard(billboard, 'show', visible); if (visible) visibleCount += 1; } return visibleCount; diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index 8ae445f91..1a8652f0e 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -2,6 +2,7 @@ import * as Cesium from 'cesium'; import { FIRMS_OVERLAY_SOURCE_ID } from '../../data/firmsLabels.js'; import { horizonOccluder } from '../../data/iconOrientation.js'; import { LOD_LEVELS, CONTEXT_TOP_N } from './policy.js'; +import { addBillboard } from '../../data/billboardMirror.js'; export function createRendering({ layerState, @@ -237,13 +238,17 @@ export function createRendering({ const pickId = `firms-${fire.index}`; layerState._pickIndexById.set(pickId, fire); layerState._cullPositions.push(cullPosition); - layerState._billboards.add({ + const corePx = components.model.sizeBucket(coreSize); + addBillboard(layerState._billboards, { id: pickId, position, image: components.model.glowSprite( components.model.detectionColorStop(fire), - components.model.sizeBucket(coreSize), + corePx, ), + // The glow sprite's own size (see glowSprite), stated for Core's mirror. + width: corePx * 2, + height: corePx * 2, verticalOrigin: Cesium.VerticalOrigin.CENTER, horizontalOrigin: Cesium.HorizontalOrigin.CENTER, disableDepthTestDistance: Number.POSITIVE_INFINITY, diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..4666228d8 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -61,9 +61,7 @@ export function createCards({ return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), - position: - components.rendering.getVisual(record).billboard?.position || - components.rendering.getVisual(record).position, + position: components.rendering.iconPosition(record), gapPx: 10, accent: accentForVesselType(record.type), title: trimHudValue(displayVesselName(record), 26), @@ -102,9 +100,7 @@ export function createCards({ return { id: vesselOverlayEntryId(record), actionable: Boolean(record?.mmsi), - position: - components.rendering.getVisual(record).billboard?.position || - components.rendering.getVisual(record).position, + position: components.rendering.iconPosition(record), gapPx: 12, accent: accentForVesselType(record.type), title: trimHudValue(displayVesselName(record), 32), diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index c5e593e26..9cbf6521a 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -1,5 +1,6 @@ import * as Cesium from 'cesium'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; +import { billboardState } from '../../data/billboardMirror.js'; export function createEvidence({ vesselState, @@ -40,15 +41,16 @@ export function createEvidence({ if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; return state.records.all.map((record) => { const bb = components.rendering.getVisual(record).billboard; - const screen = bb?.position + const icon = billboardState(bb); + const screen = icon?.position ? Cesium.SceneTransforms.worldToWindowCoordinates( state.viewer.scene, - bb.position, + icon.position, ) : null; return { id: record.mmsi, - show: bb?.show === true, + show: icon?.show === true, alpha: bb?.color?.alpha ?? null, x: screen?.x ?? null, y: screen?.y ?? null, diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 4ef9b7525..d5cd274c2 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -292,7 +292,7 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r for (const key of ['position', 'surfacePosition', 'normal', 'billboard']) { assert.equal(Object.hasOwn(record, key), false); } - assert.equal(layer.findByQuery('111').position, primitive.position); + assert.deepEqual(layer.findByQuery('111').position, primitive.position); const oldPosition = primitive.position; current = snapshot([{ ...observation('111'), longitude: 4.06 }]); await layer.update(); @@ -300,7 +300,7 @@ test('refresh keeps pick identity and renderer geometry separate, and eviction r assert.equal([...primitives][0], primitive); assert.equal(primitive.id, record); assert.notDeepEqual(primitive.position, oldPosition); - assert.equal(layer.findByQuery('111').position, primitive.position); + assert.deepEqual(layer.findByQuery('111').position, primitive.position); current = snapshot([observation('222')]); await layer.update(); assert.equal(primitives.has(primitive), false); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index c848acc2d..56d86e104 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -21,6 +21,7 @@ import { REFRESH_MS, FOCUS_EVIDENCE_DEV, } from './policy.js'; +import { billboardState } from '../../data/billboardMirror.js'; export function createQueries({ vesselState, @@ -295,9 +296,7 @@ export function createQueries({ } if (!record) return null; - const position = - components.rendering.getVisual(record).billboard?.position || - components.rendering.getVisual(record).position; + const position = components.rendering.iconPosition(record); if (!position) return null; return { mmsi: record.mmsi, @@ -328,10 +327,9 @@ export function createQueries({ const entries = []; for (const record of records) { - const visual = components.rendering.getVisual(record); if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue; - const position = visual.billboard?.position || visual.position; + const position = components.rendering.iconPosition(record); if (!position) continue; const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); if (!Number.isFinite(distanceM) || distanceM > range) continue; @@ -381,9 +379,8 @@ export function createQueries({ Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800; for (const record of records) { - const visual = components.rendering.getVisual(record); if (result.length >= cap) break; - const position = visual.billboard?.position || visual.position; + const position = components.rendering.iconPosition(record); if (!position) continue; result.push({ id: record.mmsi, @@ -495,8 +492,9 @@ export function createQueries({ if ((idx - start) % stride !== 0) continue; const record = records[idx]; const visual = components.rendering.getVisual(record); - if (visual.billboard && !visual.billboard.show) continue; - const position = visual.billboard?.position || visual.position; + const icon = billboardState(visual.billboard); + if (icon && !icon.show) continue; + const position = icon?.position || visual.position; if (!position) continue; result.push({ position, diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 328dd4aee..eaf7851be 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -20,6 +20,11 @@ import { LABEL_GRID_PX, CARD_MIN_SEP_PX, } from './policy.js'; +import { + addBillboard, + billboardState, + setBillboard, +} from '../../data/billboardMirror.js'; export function createRendering({ vesselState, @@ -36,6 +41,12 @@ export function createRendering({ return visualRecords.get(record) || record; } + /** Where a record's icon was drawn, from Core's mirror, else its visual position. */ + function iconPosition(record) { + const visual = getVisual(record); + return billboardState(visual.billboard)?.position || visual.position; + } + function prepareRecordVisual(record) { let visual = visualRecords.get(record); if (!visual) { @@ -121,10 +132,13 @@ export function createRendering({ const visible = state.feed.enabled && isVisible(getVisual(record).surfacePosition, occluder); - getVisual(record).billboard = state.billboardCollection.add({ + 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, @@ -286,19 +300,18 @@ export function createRendering({ const visual = getVisual(record); const visible = isVisible(visual.surfacePosition, occluder); if (visual.billboard) { - visual.billboard.show = visible; + 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), - visual.billboard.rotation, + rotation, ); - if ( - rot !== null && - Math.abs(rot - visual.billboard.rotation) > 0.002 - ) { - visual.billboard.rotation = rot; + if (rot !== null && Math.abs(rot - rotation) > 0.002) { + setBillboard(visual.billboard, 'rotation', rot); } } } @@ -353,14 +366,15 @@ export function createRendering({ for (const record of records || []) { const visual = getVisual(record); const bb = visual?.billboard; - const position = bb?.position || visual?.position; + 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. if ( - bb.show === false && + icon.show === false && spriteFocusAtRest(bb) && !focusAlphaNeedsWrite(bb.color?.alpha, 1, params) ) @@ -368,15 +382,16 @@ export function createRendering({ const focus = advanceSpriteFocus(bb, { // Hidden/far-side sprites still finish any pending release so the active // count remains truthful and they cannot reappear with stale dim alpha. - screenPosition: bb.show === false ? null : screenPositionFor(position), + screenPosition: + icon.show === false ? null : screenPositionFor(position), 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: (bb.width || 32) * (bb.scale || 1) * 0.5, - spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5, + spriteHalfWidthPx: (icon.width || 32) * (icon.scale || 1) * 0.5, + spriteHalfHeightPx: (icon.height || 32) * (icon.scale || 1) * 0.5, }); transitioning ||= focus.transitioning; if (focus.active) activeCount += 1; @@ -461,7 +476,7 @@ export function createRendering({ if (selected) { const screen = Cesium.SceneTransforms.worldToWindowCoordinates( scene, - getVisual(selected).billboard?.position || getVisual(selected).position, + iconPosition(selected), ); if (screen) accepted.push({ x: screen.x, y: screen.y }); } @@ -561,6 +576,7 @@ export function createRendering({ } return { getVisual, + iconPosition, prepareRecordVisual, resetRecordVisuals, renderRowLimit, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 96c5df6eb..42e6b7ae5 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,6 +1,7 @@ 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, @@ -131,9 +132,7 @@ export function createSelection({ kind: 'vessel', id: record.mmsi, label: record.name || record.mmsi, - position: - components.rendering.getVisual(record).billboard?.position || - components.rendering.getVisual(record).position, + position: components.rendering.iconPosition(record), }); return true; } @@ -174,8 +173,11 @@ export function createSelection({ if (components.rendering.getVisual(record).billboard) { components.rendering.getVisual(record).billboard.image = components.rendering.shipIcon(record, true); - components.rendering.getVisual(record).billboard.scale = - components.rendering.shipScale(record) * 1.2; + setBillboard( + components.rendering.getVisual(record).billboard, + 'scale', + components.rendering.shipScale(record) * 1.2, + ); } // Rebuild the card set immediately so the full-detail card appears on the // click, not up to VISIBILITY_UPDATE_MS later. @@ -230,8 +232,11 @@ export function createSelection({ if (components.rendering.getVisual(record)?.billboard) { components.rendering.getVisual(record).billboard.image = components.rendering.shipIcon(record, false); - components.rendering.getVisual(record).billboard.scale = - components.rendering.shipScale(record); + setBillboard( + components.rendering.getVisual(record).billboard, + 'scale', + components.rendering.shipScale(record), + ); } state.selectedRecord = null; // Drop the full-detail card right away (no-op when the layer is disabled — diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js index 03abb81dc..99c6b47bf 100644 --- a/src/layers/vessels/snapshotRenderer.js +++ b/src/layers/vessels/snapshotRenderer.js @@ -1,4 +1,6 @@ /** Connect portable vessel reconciliation to scene and selection operations. */ +import { setBillboard } from '../../data/billboardMirror.js'; + export function createVesselSnapshotRenderer({ state, records, @@ -29,9 +31,12 @@ export function createVesselSnapshotRenderer({ const selected = record === state.selectedRecord; const visual = rendering.prepareRecordVisual(record); if (visual.billboard) { - visual.billboard.position = visual.position; - visual.billboard.scale = - rendering.shipScale(record) * (selected ? 1.2 : 1); + 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; } diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index 2c11ff463..b2faf0cd3 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -6,6 +6,7 @@ import { TRAIL_MAX_POINTS, TRAIL_MIN_MOVE_M, } from './policy.js'; +import { setBillboard } from '../../data/billboardMirror.js'; export function createTracking({ vesselState, @@ -71,7 +72,8 @@ export function createTracking({ record.lat, heightM, ); - if (visual.billboard) visual.billboard.position = visual.position; + if (visual.billboard) + setBillboard(visual.billboard, 'position', visual.position); } }