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.
This commit is contained in:
Sameh Khamis committed 2026-11-03 11:14:33 -08:00
1 parent 1cfff5803a
commit 47f7eeb00e
23 files changed
+152 -97

No files matched your search

+5
View File
@@ -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",
+19 -15
View File
@@ -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) => ({
+5 -7
View File
@@ -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}
*/
+1 -1
View File
@@ -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);
});
+4 -3
View File
@@ -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,
},
+7 -2
View File
@@ -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 };
},
},
+3 -2
View File
@@ -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. */
+5 -4
View File
@@ -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 }) => (
+2 -1
View File
@@ -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 = [
+3 -1
View File
@@ -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;
+2 -1
View File
@@ -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,
+11 -7
View File
@@ -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.
}
+2 -1
View File
@@ -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');
+7 -5
View File
@@ -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;
+7 -2
View File
@@ -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,
+2 -6
View File
@@ -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),
+5 -3
View File
@@ -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,
+2 -2
View File
@@ -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);
+7 -9
View File
@@ -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,
+30 -14
View File
@@ -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,
+12 -7
View File
@@ -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 —
+8 -3
View File
@@ -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;
}
+3 -1
View File
@@ -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);
}
}