Draw live AIS vessels from a GPU icon set

This commit is contained in:
Sameh Khamis committed 2026-11-03 21:16:19 -08:00
1 parent fe32e47bdf
commit 89b07fddfd
20 files changed
+340 -318

No files matched your search

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