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); } }