mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-08 04:14:02 +00:00
Draw live AIS vessels from a GPU icon set
This commit is contained in:
1 parent
fe32e47bdf
commit
89b07fddfd
20 files changed
+340
-318
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 }],
|
||||
];
|
||||
|
||||
@@ -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')),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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' }],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user