Merge pull request #500 from bilawalsidhu/refactor/vessel-record-ownership

Separate vessel records and acquisition from rendering
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-09-14 16:44:24 -07:00
commit c56ef52ce3
26 files changed
+877 -508

No files matched your search

+2
View File
@@ -1,5 +1,7 @@
# Changelog
- Separate vessel records and feed acquisition from rendering while preserving selection, partial-feed retention, sea-surface placement and request cancellation.
- Separate military-flight records and acquisition from rendering while preserving ground-model ownership, source units and follow behavior.
- Separate civil-flight acquisition and record reconciliation from Cesium resource updates, preserving source timing, ground placement and tracking behavior.
+6
View File
@@ -497,3 +497,9 @@ ownership boundary. Military observations retain their aviation-foot readout,
source-time fallback and model-owned ground policy. The renderer supplies that
ownership fact and handles stale-ground lifting, Cesium history and primitives;
record reconciliation does not import the engine or application.
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
retention. `layers/vessels/ingestion` owns source requests and feed state through
explicit operations. Both exports exclude Cesium, DOM and application assembly.
The snapshot renderer applies record changes; rendering owns a weak map of
geometry and billboard resources used by cards, picking and trails.
+5
View File
@@ -1,5 +1,10 @@
# God's Eye View Current State
Vessels separate plain, stable MMSI records and source acquisition from scene
resources. The renderer owns geometry and billboards; cards, picking and trails
resolve those resources through it. Partial-feed retention, selected-vessel
pinning, first-position grace, source time and sea-surface placement are unchanged.
Military flights also separate portable records and source acquisition from
Cesium snapshot application. The metadata owner retains aviation feet, source
fix times, sticky fields and partial-feed retention. Rendering retains ground
+3 -1
View File
@@ -204,6 +204,8 @@
"./layers/flights/records": "./src/layers/flights/records.js",
"./layers/flights/ingestion": "./src/layers/flights/ingestion.js",
"./layers/military/records": "./src/layers/military/records.js",
"./layers/military/ingestion": "./src/layers/military/ingestion.js"
"./layers/military/ingestion": "./src/layers/military/ingestion.js",
"./layers/vessels/records": "./src/layers/vessels/records.js",
"./layers/vessels/ingestion": "./src/layers/vessels/ingestion.js"
}
}
+6 -2
View File
@@ -303,7 +303,6 @@
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs",
@@ -605,5 +604,10 @@
"src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js",
"src/layers/military/records.test.mjs",
"src/layers/military/ingestion.test.mjs"
"src/layers/military/ingestion.test.mjs",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js",
"src/layers/vessels/records.test.mjs",
"src/layers/vessels/ingestion.test.mjs"
]
+28 -6
View File
@@ -700,11 +700,13 @@
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/data/iconOrientation.js",
"src/data/vesselLabels.js"
"src/data/vesselLabels.js",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js"
],
"external": ["cesium"]
},
@@ -1410,7 +1412,6 @@
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/loadingFeedback.js",
@@ -1551,7 +1552,10 @@
"src/layers/flights/snapshotRenderer.js",
"src/layers/military/records.js",
"src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js"
"src/layers/military/snapshotRenderer.js",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js"
],
"external": [
"@mapbox/vector-tile",
@@ -1857,7 +1861,6 @@
"src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js",
"src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js",
"src/locations.js",
@@ -1881,7 +1884,10 @@
"src/layers/flights/snapshotRenderer.js",
"src/layers/military/records.js",
"src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js"
"src/layers/military/snapshotRenderer.js",
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js",
"src/layers/vessels/snapshotRenderer.js"
],
"external": [
"cesium",
@@ -2033,5 +2039,21 @@
"src/layers/military/recordPolicy.js"
],
"external": []
},
"vessel-records": {
"exports": ["./layers/vessels/records"],
"modules": [
"src/layers/vessels/records.js",
"src/layers/vessels/recordPolicy.js"
],
"external": []
},
"vessel-ingestion": {
"exports": ["./layers/vessels/ingestion"],
"modules": [
"src/layers/vessels/ingestion.js",
"src/layers/vessels/recordPolicy.js"
],
"external": []
}
}
+1 -1
View File
@@ -1358,7 +1358,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
['militaryFlights', readLayerSource(new URL('./militaryFlights.js', import.meta.url)),
/hasContact\(\s*icao24,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!(?:flightState\.)?_billboardCollection\s*\|\|\s*!(?:flightState\.)?_billboardCollection\.show\s*\|\|\s*(?:flightState\.)?_billboards\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
['aisLiveVessels', readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.enabled\s*\|\|\s*!state\.vesselMap\s*\|\|\s*state\.vesselMap\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/],
]) {
assert.match(source, guard, `${name}.hasContact must decline while the layer is disabled`);
}
+6 -2
View File
@@ -60,7 +60,9 @@ export function createCards({
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 10,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 26),
@@ -97,7 +99,9 @@ export function createCards({
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 12,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 32),
+13 -13
View File
@@ -17,28 +17,28 @@ export function createEvidence({
return { ok: false, count: 0 };
}
components.selection.clearVesselInspection();
components.store.reconcileVessels(
components.snapshots.reconcileVessels(
state.viewer,
Array.isArray(rows) ? rows : [],
);
state.count = state.vesselRecords.length;
state.loaded = true;
state.error = null;
state.stale = false;
state.lastUpdate = Date.now();
state.transportStatus = 'synthetic';
state.lastMessageAt = null;
state.rawRowCount = Array.isArray(rows) ? rows.length : 0;
state.acceptedRowCount = state.count;
return { ok: true, count: state.count };
state.feed.count = state.records.all.length;
state.feed.loaded = true;
state.feed.error = null;
state.feed.stale = false;
state.feed.lastUpdate = Date.now();
state.feed.transportStatus = 'synthetic';
state.feed.lastMessageAt = null;
state.feed.rawRowCount = Array.isArray(rows) ? rows.length : 0;
state.feed.acceptedRowCount = state.feed.count;
return { ok: true, count: state.feed.count };
}
/** JSON-safe vessel alpha/position snapshot for the evidence report. */
function _focusEvidenceVesselSnapshot() {
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
return state.vesselRecords.map((record) => {
const bb = record.billboard;
return state.records.all.map((record) => {
const bb = components.rendering.getVisual(record).billboard;
const screen = bb?.position
? Cesium.SceneTransforms.worldToWindowCoordinates(
state.viewer.scene,
+27 -3
View File
@@ -1,7 +1,7 @@
import { createVesselState } from './state.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createStore } from './store.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
import { createTracking } from './tracking.js';
@@ -16,8 +16,6 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
const layer = {};
const context = { vesselState, services, parts, layer, options };
parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context);
parts.store = createStore(context);
parts.rendering = createRendering(context);
parts.selection = createSelection(context);
parts.tracking = createTracking(context);
@@ -25,6 +23,32 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.testing = createTesting(context);
parts.evidence = createEvidence(context);
parts.queries = createQueries(context);
parts.snapshots = createVesselSnapshotRenderer({
state: vesselState.state,
records: vesselState.state.records,
rendering: parts.rendering,
tracking: parts.tracking,
selection: parts.selection,
cards: parts.cards,
});
parts.ingestion = createIngestion({
feed: vesselState.state.feed,
readSource: () => vesselState._source,
readViewer: () => vesselState.state.viewer,
getRowLimit: parts.rendering.renderRowLimit,
readCount: () => vesselState.state.records.all.length,
applyRows: parts.snapshots.reconcileVessels,
classifySnapshot: parts.queries.classifyAisFeedSnapshot,
isDefinitiveTransportFailure: parts.lifecycle.isDefinitiveTransportFailure,
isGraceEligibleTransport: parts.lifecycle.isGraceEligibleTransport,
markUnavailable: parts.lifecycle.markAisUnavailable,
settleFirstConnect: parts.lifecycle.settleFirstConnectPhase,
now: () => vesselState._aisRuntime.now(),
setSourceLabel: (source) => {
layer.source = source;
},
});
Object.assign(
layer,
parts.queries.methods,
+89 -63
View File
@@ -1,22 +1,28 @@
import { AIS_FIRST_CONNECT_LABEL } from './policy.js';
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
/** Own source requests and classified feed state through explicit operations. */
export function createIngestion({
vesselState,
services,
parts: components,
layer,
options,
feed,
readSource,
readViewer,
getRowLimit,
readCount,
applyRows,
classifySnapshot,
isDefinitiveTransportFailure,
isGraceEligibleTransport,
markUnavailable,
settleFirstConnect,
now,
setSourceLabel,
}) {
const { state } = vesselState;
const aisLiveVesselsLayer = layer;
async function loadLivePositions(viewer) {
if (!viewer || state.loading) return;
state.loading = true;
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...';
if (!viewer || feed.loading) return;
feed.loading = true;
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
const requestController = new AbortController();
const requestSessionId = state.sessionId;
state.abort = requestController;
const requestSessionId = feed.sessionId;
feed.abort = requestController;
try {
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
@@ -28,12 +34,12 @@ export function createIngestion({
AbortSignal.timeout(10000),
])
: requestController.signal;
const snapshot = await vesselState._source.getSnapshot(
{ maxRows: components.rendering.renderRowLimit() },
const snapshot = await readSource().getSnapshot(
{ maxRows: getRowLimit() },
{ signal },
);
if (!ownsAisRequest(requestController, requestSessionId)) return;
aisLiveVesselsLayer.source = snapshot.source;
setSourceLabel(snapshot.source);
// Map observations into the existing display store; source fields stop here.
applyAisFeedSnapshot(viewer, {
rows: snapshot.records.map(vesselDisplayRow),
@@ -58,20 +64,18 @@ export function createIngestion({
ownsAisRequest(requestController, requestSessionId) &&
error?.name !== 'AbortError'
) {
components.lifecycle.markAisUnavailable(
error?.message || 'AIS live load failed',
);
console.warn('[Data:ais-live-vessels]', state.error, error);
markUnavailable(error?.message || 'AIS live load failed');
console.warn('[Data:ais-live-vessels]', feed.error, error);
}
} finally {
if (
state.abort === requestController &&
state.sessionId === requestSessionId
feed.abort === requestController &&
feed.sessionId === requestSessionId
) {
state.loading = false;
state.loadingLabel =
state.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
state.abort = null;
feed.loading = false;
feed.loadingLabel =
feed.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
feed.abort = null;
}
}
}
@@ -80,9 +84,9 @@ export function createIngestion({
function ownsAisRequest(controller, sessionId) {
return (
state.enabled &&
state.sessionId === sessionId &&
state.abort === controller &&
feed.enabled &&
feed.sessionId === sessionId &&
feed.abort === controller &&
!controller.signal.aborted
);
}
@@ -90,59 +94,55 @@ export function createIngestion({
/** Apply a classified snapshot while preserving warm state on zero accepted rows. */
function applyAisFeedSnapshot(viewer, payload) {
const snapshot = components.queries.classifyAisFeedSnapshot(payload);
state.loaded = true;
state.loadingLabel = '';
state.transportStatus = snapshot.transportStatus;
state.nextAttemptAt = Number(payload?.nextAttemptAt) || null;
state.lastMessageAt = snapshot.lastMessageAt;
state.rawRowCount = snapshot.rawRowCount;
state.acceptedRowCount = snapshot.acceptedRowCount;
const snapshot = classifySnapshot(payload);
feed.loaded = true;
feed.loadingLabel = '';
feed.transportStatus = snapshot.transportStatus;
feed.nextAttemptAt = Number(payload?.nextAttemptAt) || null;
feed.lastMessageAt = snapshot.lastMessageAt;
feed.rawRowCount = snapshot.rawRowCount;
feed.acceptedRowCount = snapshot.acceptedRowCount;
if (snapshot.acceptedRowCount === 0) {
state.count = state.vesselRecords.length;
state.stale = state.count > 0 || Boolean(payload?.refreshing);
if (
components.lifecycle.isDefinitiveTransportFailure(
snapshot.transportStatus,
)
) {
components.lifecycle.markAisUnavailable(snapshot.error);
feed.count = readCount();
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
markUnavailable(snapshot.error);
return { reconciled: false, ...snapshot };
}
if (
state.firstConnectPhase === 'loading' &&
components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus)
feed.firstConnectPhase === 'loading' &&
isGraceEligibleTransport(snapshot.transportStatus)
) {
state.error = null;
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
feed.error = null;
feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
return { reconciled: false, ...snapshot };
}
if (state.firstConnectPhase === 'loading') {
components.lifecycle.markAisUnavailable(snapshot.error);
if (feed.firstConnectPhase === 'loading') {
markUnavailable(snapshot.error);
return { reconciled: false, ...snapshot };
}
state.error = snapshot.error;
feed.error = snapshot.error;
return { reconciled: false, ...snapshot };
}
components.lifecycle.settleFirstConnectPhase('ready');
components.store.reconcileVessels(viewer, snapshot.acceptedRows, {
settleFirstConnect('ready');
applyRows(viewer, snapshot.acceptedRows, {
complete: payload?.complete !== false,
});
state.count = state.vesselRecords.length;
state.stale =
feed.count = readCount();
feed.stale =
Boolean(payload?.refreshing) ||
payload?.complete === false ||
payload?.freshness === 'unknown';
state.newestPositionAt = payload?.newestPositionAt || null;
feed.newestPositionAt = payload?.newestPositionAt || null;
// Not unconditionally null: a degraded feed keeps its reason even though the
// cached vessels are still drawable, so the chip cannot go quiet on an
// outage the user is still looking at.
state.error = snapshot.error || payload?.reason || null;
state.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
feed.error = snapshot.error || payload?.reason || null;
feed.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
? payload.observedAtMs
: vesselState._aisRuntime.now();
: now();
return { reconciled: true, ...snapshot };
}
@@ -169,8 +169,8 @@ export function createIngestion({
}
const methods = {
update(viewer) {
if (!state.enabled) return Promise.resolve();
return loadLivePositions(viewer || state.viewer);
if (!feed.enabled) return Promise.resolve();
return loadLivePositions(viewer || readViewer());
},
};
@@ -182,3 +182,29 @@ export function createIngestion({
methods,
};
}
/** Construct request admission and first-position status for one vessel layer. */
export function createVesselFeed() {
return {
enabled: false,
loading: false,
loaded: false,
stale: false,
error: null,
loadingLabel: '',
lastUpdate: null,
count: 0,
newestPositionAt: null,
transportStatus: null,
nextAttemptAt: null,
lastMessageAt: null,
rawRowCount: 0,
acceptedRowCount: 0,
sessionId: 0,
firstConnectPhase: 'idle',
firstConnectStartedAt: null,
firstConnectDeadline: null,
firstConnectTimer: null,
abort: null,
};
}
+106
View File
@@ -0,0 +1,106 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createIngestion, createVesselFeed } from './ingestion.js';
function setup(source) {
const feed = createVesselFeed();
feed.enabled = true;
feed.sessionId = 1;
const applied = [];
const labels = [];
let count = 0;
const ingestion = createIngestion({
feed,
readSource: () => source,
readViewer: () => ({}),
getRowLimit: () => 500,
readCount: () => count,
now: () => 9999,
setSourceLabel: (value) => labels.push(value),
applyRows: (_, rows) => {
applied.push(rows);
count = rows.length;
},
classifySnapshot: (payload) => ({
acceptedRows: payload.rows,
acceptedRowCount: payload.rows.length,
rawRowCount: payload.rows.length,
transportStatus: payload.status,
lastMessageAt: payload.lastMessageAt,
error: payload.rows.length ? null : 'No accepted positions',
}),
isDefinitiveTransportFailure: () => false,
isGraceEligibleTransport: () => false,
markUnavailable: (error) => {
feed.error = error;
},
settleFirstConnect: (phase) => {
feed.firstConnectPhase = phase;
},
});
return { feed, applied, labels, ...ingestion };
}
test('an old vessel request cannot publish or clear the loading state of a newer enable session', async () => {
let release;
const probe = setup({
getSnapshot: () =>
new Promise((resolve) => {
release = resolve;
}),
});
const request = probe.methods.update();
const old = probe.feed.abort;
probe.feed.sessionId++;
probe.feed.abort = new AbortController();
probe.feed.loading = true;
release({ records: [], source: 'Old session' });
await request;
assert.equal(probe.applied.length, 0);
assert.equal(probe.labels.length, 0);
assert.equal(probe.feed.loading, true);
assert.notEqual(probe.feed.abort, old);
});
test('vessel ingestion converts source units once and retains warm records on a zero-row snapshot', async () => {
let snapshot = {
records: [
{
id: '111',
reference: '111',
latitude: 51.93,
longitude: 4.05,
speedMps: 5.14444,
courseDeg: 90,
headingDeg: 100,
observedAtMs: 1700000000000,
},
],
source: 'Fixture',
observedAtMs: null,
freshness: 'unknown',
complete: false,
transportStatus: 'live',
};
const probe = setup({
async getSnapshot(query) {
assert.equal(query.maxRows, 500);
return snapshot;
},
});
await probe.methods.update();
const record = probe.applied[0][0];
assert.equal(record.speed, 5.14444 / 0.514444);
assert.equal(record.last_position_epoch, 1700000000);
assert.equal(record.last_position_UTC, new Date(1700000000000).toISOString());
assert.equal(probe.feed.lastUpdate, null);
assert.equal(probe.feed.stale, true);
assert.equal(probe.feed.count, 1);
snapshot = { ...snapshot, records: [] };
await probe.methods.update();
assert.equal(probe.applied.length, 1);
assert.equal(probe.feed.count, 1);
assert.equal(probe.feed.stale, true);
assert.equal(probe.feed.error, 'No accepted positions');
assert.equal(probe.feed.loading, false);
assert.equal(probe.feed.abort, null);
});
+65 -63
View File
@@ -19,60 +19,60 @@ export function createLifecycle({
const { registerPickOwner, unregisterPickOwner } = services.picking;
function clearFirstConnectTimer() {
if (state.firstConnectTimer === null) return;
vesselState._aisRuntime.clearTimeout(state.firstConnectTimer);
state.firstConnectTimer = null;
if (state.feed.firstConnectTimer === null) return;
vesselState._aisRuntime.clearTimeout(state.feed.firstConnectTimer);
state.feed.firstConnectTimer = null;
}
function invalidateAisSession() {
clearFirstConnectTimer();
state.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null;
state.firstConnectDeadline = null;
state.feed.sessionId = ++vesselState._aisSessionSequence;
state.feed.firstConnectPhase = 'idle';
state.feed.firstConnectStartedAt = null;
state.feed.firstConnectDeadline = null;
}
function beginAisSession() {
clearFirstConnectTimer();
const sessionId = ++vesselState._aisSessionSequence;
const startedAt = vesselState._aisRuntime.now();
state.sessionId = sessionId;
state.firstConnectPhase = 'loading';
state.firstConnectStartedAt = startedAt;
state.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.error = null;
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
state.feed.sessionId = sessionId;
state.feed.firstConnectPhase = 'loading';
state.feed.firstConnectStartedAt = startedAt;
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.feed.error = null;
state.feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
function scheduleFirstConnectExpiry(sessionId, delayMs) {
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
state.feed.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
if (
!state.enabled ||
state.sessionId !== sessionId ||
state.firstConnectPhase !== 'loading'
!state.feed.enabled ||
state.feed.sessionId !== sessionId ||
state.feed.firstConnectPhase !== 'loading'
)
return;
const remainingMs =
state.firstConnectDeadline - vesselState._aisRuntime.now();
state.feed.firstConnectDeadline - vesselState._aisRuntime.now();
if (remainingMs > 0) {
scheduleFirstConnectExpiry(sessionId, remainingMs);
return;
}
state.firstConnectTimer = null;
state.firstConnectPhase = 'unavailable';
state.loadingLabel = '';
state.error = state.lastMessageAt
state.feed.firstConnectTimer = null;
state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
state.stale = state.count > 0;
state.feed.stale = state.feed.count > 0;
}, delayMs);
}
function settleFirstConnectPhase(phase) {
clearFirstConnectTimer();
state.firstConnectPhase = phase;
state.loadingLabel = '';
state.feed.firstConnectPhase = phase;
state.feed.loadingLabel = '';
}
function isGraceEligibleTransport(status) {
@@ -85,40 +85,41 @@ export function createLifecycle({
function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable');
state.error = reason || 'AIS live load failed';
state.stale = state.count > 0;
state.feed.error = reason || 'AIS live load failed';
state.feed.stale = state.feed.count > 0;
}
function resetState() {
state.abort?.abort();
components.rendering.resetRecordVisuals();
state.feed.abort?.abort();
state.trailAbort?.abort();
state.trailAbort = null;
clearFirstConnectTimer();
state.viewer = null;
state.enabled = false;
state.loading = false;
state.loaded = false;
state.stale = false;
state.error = null;
state.loadingLabel = '';
state.lastUpdate = null;
state.count = 0;
state.newestPositionAt = null;
state.transportStatus = null;
state.nextAttemptAt = null;
state.lastMessageAt = null;
state.rawRowCount = 0;
state.acceptedRowCount = 0;
state.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null;
state.firstConnectDeadline = null;
state.firstConnectTimer = null;
state.abort = null;
state.feed.enabled = false;
state.feed.loading = false;
state.feed.loaded = false;
state.feed.stale = false;
state.feed.error = null;
state.feed.loadingLabel = '';
state.feed.lastUpdate = null;
state.feed.count = 0;
state.feed.newestPositionAt = null;
state.feed.transportStatus = null;
state.feed.nextAttemptAt = null;
state.feed.lastMessageAt = null;
state.feed.rawRowCount = 0;
state.feed.acceptedRowCount = 0;
state.feed.sessionId = ++vesselState._aisSessionSequence;
state.feed.firstConnectPhase = 'idle';
state.feed.firstConnectStartedAt = null;
state.feed.firstConnectDeadline = null;
state.feed.firstConnectTimer = null;
state.feed.abort = null;
state.billboardCollection = null;
state.vesselRecords = [];
state.vesselMap = new Map();
state.unkeyedRecords = [];
state.records.all = [];
state.records.byMmsi = new Map();
state.records.unkeyed = [];
state.clickHandler = null;
state.keyTarget = null;
state.keydownHandler = null;
@@ -162,8 +163,8 @@ export function createLifecycle({
},
enable(viewer) {
const wasEnabled = state.enabled;
state.enabled = true;
const wasEnabled = state.feed.enabled;
state.feed.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
@@ -176,10 +177,11 @@ export function createLifecycle({
// failure leaves N = 0 forever, which is safe: sprites are depth-test-
// free, so vessels stay visible either way.
if (!vesselState._geoidReady) {
const sessionId = state.sessionId;
const sessionId = state.feed.sessionId;
ensureGeoidReady()
.then(() => {
if (!state.enabled || state.sessionId !== sessionId) return;
if (!state.feed.enabled || state.feed.sessionId !== sessionId)
return;
vesselState._geoidReady = true;
components.tracking.refloorVesselRecords();
})
@@ -190,14 +192,14 @@ export function createLifecycle({
// 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).
registerPickOwner('ais-live-vessels', (pickedId) =>
state.vesselMap.has(pickedId),
state.records.byMmsi.has(pickedId),
);
restoreSpriteOrderOnEnable('ais', activeViewer);
return components.ingestion.loadLivePositions(activeViewer);
},
disable() {
state.enabled = false;
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
unregisterPickOwner('ais-live-vessels');
@@ -206,19 +208,19 @@ export function createLifecycle({
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
components.selection.removeVesselInteraction();
if (state.abort) {
state.abort.abort();
state.abort = null;
if (state.feed.abort) {
state.feed.abort.abort();
state.feed.abort = null;
}
state.loading = false;
state.loadingLabel = '';
state.feed.loading = false;
state.feed.loadingLabel = '';
},
destroy(viewer) {
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
if (state.abort) state.abort.abort();
if (state.feed.abort) state.feed.abort.abort();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
+38 -2
View File
@@ -34,9 +34,17 @@ function services() {
}
function setup(source, options = {}) {
const layer = createVesselLayer({ source, services: services(), options });
const primitives = new Set();
layer.testing._setVesselStateForTest({
viewer: {},
billboardCollection: { add: (options) => ({ ...options }), remove: noop },
billboardCollection: {
add(options) {
const primitive = { ...options };
primitives.add(primitive);
return primitive;
},
remove: (primitive) => primitives.delete(primitive),
},
});
let now = 1000;
layer.testing._setAisRuntimeForTest({
@@ -46,6 +54,7 @@ function setup(source, options = {}) {
});
return {
layer,
primitives,
advance: (ms) => {
now += ms;
},
@@ -85,7 +94,10 @@ test('vessel construction is inert and each instance owns its records, icon cach
const two = createVesselState({ source, services: services() });
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
assert.notEqual(one[key], two[key]);
assert.notEqual(one.state.vesselMap, two.state.vesselMap);
assert.notEqual(one.state.records.byMmsi, two.state.records.byMmsi);
assert.notEqual(one.state.feed, two.state.feed);
assert.notEqual(one.state.records.all, two.state.records.all);
assert.notEqual(one.state.records.unkeyed, two.state.records.unkeyed);
assert.notEqual(one.state.trailPositions, two.state.trailPositions);
const { layer } = setup(source);
const other = createVesselLayer({ source, services: services() });
@@ -265,3 +277,27 @@ test('partial-feed expiry releases a selected vessel and its pending trail', asy
await new Promise(setImmediate);
assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0);
});
test('refresh keeps pick identity and renderer geometry separate, and eviction releases the primitive', async () => {
let current = snapshot([observation('111')]);
const { layer, primitives } = 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.equal(layer.findByQuery('111').position, primitive.position);
const oldPosition = primitive.position;
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.equal(layer.findByQuery('111').position, primitive.position);
current = snapshot([observation('222')]);
await layer.update();
assert.equal(primitives.has(primitive), false);
assert.equal(layer.findByQuery('111'), null);
});
+5 -9
View File
@@ -1,3 +1,8 @@
export {
PARTIAL_RETENTION_MS,
SELECTED_PIN_REFRESHES,
AIS_FIRST_CONNECT_LABEL,
} from './recordPolicy.js';
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -12,8 +17,6 @@ export const REFRESH_MS = 60000;
export const AIS_FIRST_CONNECT_GRACE_MS = 30000;
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
export const VISIBILITY_UPDATE_MS = 800;
/** Focus alpha alone samples faster inside the existing preRender pass. */
@@ -30,10 +33,6 @@ export const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX;
export const CARD_MIN_SEP_PX = 150;
/** Number of consecutive refreshes a selected-but-vanished vessel is retained. */
export const SELECTED_PIN_REFRESHES = 3;
/** Trail hue for the selected vessel (PRD F4, pinned to the AIS teal-green family). */
export const TRAIL_COLOR = '#39ffd5';
@@ -100,6 +99,3 @@ export const AIS_DEGRADED_STATUSES = new Set([
'down',
'auth-failed',
]);
/** Missing records in an incomplete observation are retained for at most five minutes. */
export const PARTIAL_RETENTION_MS = 5 * 60 * 1000;
+43 -31
View File
@@ -27,8 +27,8 @@ export function createQueries({
function aisRetryInSec() {
// A rejected key is terminal until someone changes it; an hour-long
// countdown would imply waiting is the fix.
if (state.transportStatus === 'auth-failed') return 0;
const at = Number(state.nextAttemptAt);
if (state.feed.transportStatus === 'auth-failed') return 0;
const at = Number(state.feed.nextAttemptAt);
if (!Number.isFinite(at) || at <= 0) return 0;
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
}
@@ -142,7 +142,7 @@ export function createQueries({
* fields are null, never NaN/undefined. navStatus is always null: the
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side.
* @param {Object|null|undefined} record - `state.vesselMap`/`state.vesselRecords` entry.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null,
* lat: number|null, lon: number|null, speedKts: number|null,
* courseDeg: number|null, shipType: string|null, destination: string|null,
@@ -248,14 +248,14 @@ export function createQueries({
*/
findByQuery(query) {
if (query === null || query === undefined) return null;
const records = state.vesselRecords;
const records = state.records.all;
if (!Array.isArray(records) || !records.length) return null;
const q = String(query).trim();
if (!q) return null;
let record = null;
if (/^\d+$/.test(q)) {
record = state.vesselMap.get(q) || null;
record = state.records.byMmsi.get(q) || null;
}
if (!record) {
const lower = q.toLowerCase();
@@ -268,7 +268,9 @@ export function createQueries({
}
if (!record) return null;
const position = record.billboard?.position || record.position;
const position =
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position;
if (!position) return null;
return {
mmsi: record.mmsi,
@@ -290,7 +292,7 @@ export function createQueries({
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
*/
getNearby(centerCartesian, rangeM, maxCount = 25) {
const records = state.vesselRecords;
const records = state.records.all;
if (!centerCartesian || !Array.isArray(records) || !records.length)
return [];
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
@@ -299,9 +301,10 @@ export function createQueries({
const entries = [];
for (const record of records) {
const visual = components.rendering.getVisual(record);
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
continue;
const position = record.billboard?.position || record.position;
const position = visual.billboard?.position || visual.position;
if (!position) continue;
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
if (!Number.isFinite(distanceM) || distanceM > range) continue;
@@ -333,22 +336,27 @@ export function createQueries({
* holds no data and therefore cannot answer.
*/
hasContact(mmsi) {
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0)
if (
!state.feed.enabled ||
!state.records.byMmsi ||
state.records.byMmsi.size === 0
)
return null;
if (!mmsi) return false;
return state.vesselMap.has(String(mmsi).trim());
return state.records.byMmsi.has(String(mmsi).trim());
},
getAllPositions(maxCount = 800) {
const result = [];
const records = state.vesselRecords;
const records = state.records.all;
if (!Array.isArray(records)) return result;
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
for (const record of records) {
const visual = components.rendering.getVisual(record);
if (result.length >= cap) break;
const position = record.billboard?.position || record.position;
const position = visual.billboard?.position || visual.position;
if (!position) continue;
result.push({
id: record.mmsi,
@@ -370,8 +378,8 @@ export function createQueries({
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/
getAnalystRecords(maxCount = 2000) {
if (!state.enabled) return [];
const records = state.vesselRecords;
if (!state.feed.enabled) return [];
const records = state.records.all;
if (!Array.isArray(records) || !records.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
@@ -394,7 +402,7 @@ export function createQueries({
if (mmsi === null || mmsi === undefined) return false;
const target = String(mmsi).trim();
if (!target) return false;
const record = state.vesselMap.get(target);
const record = state.records.byMmsi.get(target);
if (!record) return false;
components.selection.selectVessel(record);
return true;
@@ -438,12 +446,12 @@ export function createQueries({
*/
getDetectableObjects(options = {}) {
if (
!state.enabled ||
!state.feed.enabled ||
!state.billboardCollection ||
!state.billboardCollection.show
)
return [];
const records = state.vesselRecords;
const records = state.records.all;
if (!Array.isArray(records) || !records.length) return [];
const maxCount = Number.isFinite(options.maxCount)
@@ -459,8 +467,9 @@ export function createQueries({
for (let idx = 0; idx < records.length; idx += 1) {
if ((idx - start) % stride !== 0) continue;
const record = records[idx];
if (record.billboard && !record.billboard.show) continue;
const position = record.billboard?.position || record.position;
const visual = components.rendering.getVisual(record);
if (visual.billboard && !visual.billboard.show) continue;
const position = visual.billboard?.position || visual.position;
if (!position) continue;
result.push({
position,
@@ -488,22 +497,25 @@ export function createQueries({
: {}),
getStats() {
const waitingForFirstPosition = state.firstConnectPhase === 'loading';
const waitingForFirstPosition =
state.feed.firstConnectPhase === 'loading';
return {
count: state.count,
lastUpdate: state.lastUpdate,
loading: state.loading || waitingForFirstPosition,
count: state.feed.count,
lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? AIS_FIRST_CONNECT_LABEL
: state.loadingLabel,
error: state.error,
stale: state.stale,
: state.feed.loadingLabel,
error: state.feed.error,
stale: state.feed.stale,
status:
state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
transportStatus: state.transportStatus,
lastMessageAt: state.lastMessageAt,
rawRowCount: state.rawRowCount,
acceptedRowCount: state.acceptedRowCount,
state.feed.firstConnectPhase === 'unavailable'
? 'unavailable'
: undefined,
transportStatus: state.feed.transportStatus,
lastMessageAt: state.feed.lastMessageAt,
rawRowCount: state.feed.rawRowCount,
acceptedRowCount: state.feed.acceptedRowCount,
// Same chip affordance the flights layer uses: when the server is
// backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening.
+7
View File
@@ -0,0 +1,7 @@
/** Maximum retention of missing records during an incomplete refresh. */
export const PARTIAL_RETENTION_MS = 5 * 60 * 1000;
/** Complete refreshes a selected-but-missing vessel remains pinned. */
export const SELECTED_PIN_REFRESHES = 3;
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
+123
View File
@@ -0,0 +1,123 @@
import {
PARTIAL_RETENTION_MS,
SELECTED_PIN_REFRESHES,
} from './recordPolicy.js';
/** Normalize AIS display fields without allocating scene resources. */
export function normalizeVessel(row) {
const lat = Number(row.lat);
const lon = Number(row.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return {
lat,
lon,
name: String(row.name || row.mmsi || 'VESSEL'),
mmsi: String(row.mmsi || '').trim(),
reference: row.reference ?? String(row.mmsi || '').trim(),
imo: String(row.imo || ''),
type: String(row.type || ''),
destination: String(row.destination || ''),
speed: finiteNumber(row.speed),
course: finiteNumber(row.course),
heading: finiteNumber(row.heading),
lastPositionUtc: String(row.last_position_UTC || ''),
lastPositionEpoch: finiteNumber(row.last_position_epoch),
missedRefreshes: 0,
};
}
function finiteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
/** Own stable vessel records and bounded incomplete/selected retention. */
export class VesselRecords {
constructor({ now = () => Date.now() } = {}) {
this.now = now;
this.byMmsi = new Map();
this.unkeyed = [];
this.all = [];
}
reconcile(
rows,
{ complete = true, selectedRecord = null, cap = Infinity },
effects,
) {
const receivedAtMs = this.now();
for (const record of this.unkeyed) effects.remove(record, false);
this.unkeyed = [];
const seen = new Set();
for (const row of rows) {
const next = normalizeVessel(row);
if (!next) continue;
next.receivedAtMs = receivedAtMs;
if (!next.mmsi) {
effects.add(next);
this.unkeyed.push(next);
continue;
}
if (seen.has(next.mmsi)) continue;
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;
record.lon = next.lon;
record.name = next.name;
record.imo = next.imo;
record.type = next.type;
record.destination = next.destination;
record.speed = next.speed;
record.course = next.course;
record.heading = next.heading;
record.lastPositionUtc = next.lastPositionUtc;
record.lastPositionEpoch = next.lastPositionEpoch;
record.missedRefreshes = 0;
effects.updated(record, before);
} else {
effects.add(next);
this.byMmsi.set(next.mmsi, next);
}
}
for (const [mmsi, record] of this.byMmsi) {
if (seen.has(mmsi)) continue;
if (
!complete &&
Number.isFinite(record.receivedAtMs) &&
receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS
) {
if (record === selectedRecord) {
record.missedRefreshes = Math.max(1, record.missedRefreshes || 0);
effects.staleSelected(record);
}
continue;
}
if (record === selectedRecord) {
record.missedRefreshes = (record.missedRefreshes || 0) + 1;
if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) {
effects.staleSelected(record);
continue;
}
}
// Selection teardown still sees the record before its store entry is removed.
effects.remove(record, record === selectedRecord);
this.byMmsi.delete(mmsi);
effects.removed(mmsi);
}
if (this.byMmsi.size + this.unkeyed.length > cap) {
for (const [mmsi, record] of this.byMmsi) {
if (this.byMmsi.size + this.unkeyed.length <= cap) break;
if (seen.has(mmsi) || record === selectedRecord) continue;
effects.remove(record, false);
this.byMmsi.delete(mmsi);
effects.removed(mmsi);
}
}
this.all = [...this.byMmsi.values(), ...this.unkeyed];
}
}
+113
View File
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { VesselRecords, normalizeVessel } from './records.js';
const row = (mmsi, fields = {}) => ({
mmsi,
lat: 51.93,
lon: 4.05,
name: mmsi,
speed: 8,
...fields,
});
function setup() {
let now = 1000;
const store = new VesselRecords({ now: () => now });
const events = [];
const effects = {
add: (record) => events.push(['add', record]),
beforeUpdate: (record) => record.type,
updated: (record, before) => events.push(['update', record, before]),
remove: (record, evicted) =>
events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]),
removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]),
staleSelected: (record) => events.push(['stale', record]),
};
return {
store,
events,
advance: (ms) => {
now += ms;
},
reconcile: (rows, options = {}) => store.reconcile(rows, options, effects),
};
}
test('vessel reconciliation preserves record identity and first duplicate while emitting plain metadata', () => {
const probe = setup();
probe.reconcile([
row('111', { type: 'Cargo' }),
row('222'),
row('222', { name: 'duplicate' }),
row('bad', { lat: NaN }),
]);
assert.equal(probe.store.byMmsi.size, 2);
assert.equal(probe.store.byMmsi.get('222').name, '222');
const record = probe.store.byMmsi.get('111');
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.store.byMmsi.has('222'), false);
for (const field of ['position', 'surfacePosition', 'normal', 'billboard'])
assert.equal(Object.hasOwn(record, field), false);
assert.deepEqual(structuredClone(record), record);
});
test('selected vessel remains pinned for three complete misses and teardown precedes removal', () => {
const probe = setup();
probe.reconcile([row('111'), row('222')]);
const selectedRecord = probe.store.byMmsi.get('111');
for (let i = 1; i <= 3; i++) {
probe.reconcile([row('222')], { selectedRecord });
assert.equal(probe.store.byMmsi.get('111'), selectedRecord);
assert.equal(selectedRecord.missedRefreshes, i);
}
probe.reconcile([row('222')], { selectedRecord });
assert.equal(probe.store.byMmsi.has('111'), false);
const removal = probe.events.find(
(event) => event[0] === 'remove' && event[1] === selectedRecord,
);
assert.deepEqual(removal.slice(2), [true, true]);
assert.deepEqual(
probe.events.find((event) => event[0] === 'removed' && event[1] === '111'),
['removed', '111', false],
);
});
test('partial retention respects age and row cap while preserving the selected contact', () => {
const probe = setup();
probe.reconcile([row('111'), row('222')], { cap: 2 });
const selectedRecord = probe.store.byMmsi.get('111');
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
assert.deepEqual([...probe.store.byMmsi.keys()], ['111', '333']);
assert.equal(selectedRecord.missedRefreshes, 1);
probe.advance(5 * 60 * 1000);
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
assert.equal(probe.store.byMmsi.has('111'), false);
assert.equal(
probe.events.find(
(event) => event[0] === 'remove' && event[1] === selectedRecord,
)[2],
true,
);
});
test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown values null', () => {
const probe = setup();
probe.reconcile([row('', { speed: '', course: '12.5', heading: 'bad' })]);
const original = probe.store.unkeyed[0];
assert.equal(original.speed, null);
assert.equal(original.course, 12.5);
assert.equal(original.heading, null);
probe.reconcile([row('')]);
assert.notEqual(probe.store.unkeyed[0], original);
assert.equal(
probe.events.some(
(event) => event[0] === 'remove' && event[1] === original,
),
true,
);
assert.equal(probe.store.byMmsi.size, 0);
assert.equal(probe.store.all.length, 1);
assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null);
});
+70 -24
View File
@@ -11,6 +11,7 @@ import {
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import {
VESSEL_LIFT_M,
DEFAULT_RENDER_ROWS,
DEFAULT_ACTIVE_LABELS,
VISIBILITY_UPDATE_MS,
@@ -27,6 +28,44 @@ export function createRendering({
options,
}) {
const { state } = vesselState;
let visualRecords = new WeakMap();
// The fallback preserves helper calls with externally supplied render records.
function getVisual(record) {
return visualRecords.get(record) || record;
}
function prepareRecordVisual(record) {
let visual = visualRecords.get(record);
if (!visual) {
visual = { billboard: record.billboard || null };
visualRecords.set(record, visual);
}
const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(record.lat, record.lon),
VESSEL_LIFT_M,
);
visual.position = Cesium.Cartesian3.fromDegrees(
record.lon,
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;
}
function resetRecordVisuals() {
visualRecords = new WeakMap();
}
const { registerSpriteCollection } = services.sprites;
const {
forgetSpriteFocus,
@@ -58,7 +97,7 @@ export function createRendering({
state.billboardCollection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT,
});
state.billboardCollection.show = state.enabled;
state.billboardCollection.show = state.feed.enabled;
viewer.scene.primitives.add(state.billboardCollection);
registerSpriteCollection('ais', state.billboardCollection);
}
@@ -73,9 +112,10 @@ export function createRendering({
function addRecordPrimitives(record, occluder) {
const visible =
state.enabled && isVisible(record.surfacePosition, occluder);
record.billboard = state.billboardCollection.add({
position: record.position,
state.feed.enabled &&
isVisible(getVisual(record).surfacePosition, occluder);
getVisual(record).billboard = state.billboardCollection.add({
position: getVisual(record).position,
show: visible,
image: shipIcon(record, false),
scale: shipScale(record),
@@ -100,11 +140,11 @@ export function createRendering({
function removeRecordPrimitives(record) {
if (!record) return;
if (record.billboard && state.billboardCollection) {
forgetSpriteFocus(record.billboard);
state.billboardCollection.remove(record.billboard);
if (getVisual(record).billboard && state.billboardCollection) {
forgetSpriteFocus(getVisual(record).billboard);
state.billboardCollection.remove(getVisual(record).billboard);
}
record.billboard = null;
getVisual(record).billboard = null;
}
function shipScale(record) {
@@ -163,7 +203,7 @@ export function createRendering({
}
function updateVisibility(force = false) {
if (!state.enabled) return;
if (!state.feed.enabled) return;
const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget();
const regularPass =
@@ -174,7 +214,7 @@ export function createRendering({
if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now;
if (!state.vesselRecords.length) {
if (!state.records.all.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
@@ -191,22 +231,23 @@ export function createRendering({
if (doRotations) vesselState._lastCamPoseSig = poseSig;
const occluder = makeOccluder();
const labelCandidates = [];
for (const record of state.vesselRecords) {
const visible = isVisible(record.surfacePosition, occluder);
if (record.billboard) {
record.billboard.show = visible;
for (const record of state.records.all) {
const visual = getVisual(record);
const visible = isVisible(visual.surfacePosition, occluder);
if (visual.billboard) {
visual.billboard.show = visible;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
record.position,
visual.position,
vesselCourseDeg(record),
record.billboard.rotation,
visual.billboard.rotation,
);
if (
rot !== null &&
Math.abs(rot - record.billboard.rotation) > 0.002
Math.abs(rot - visual.billboard.rotation) > 0.002
) {
record.billboard.rotation = rot;
visual.billboard.rotation = rot;
}
}
}
@@ -216,7 +257,7 @@ export function createRendering({
}
if (focusPass && scene && camera) {
const result = applyVesselFocusDeemphasis({
records: state.vesselRecords,
records: state.records.all,
target: focusTarget,
previousActiveCount: state.activeFocusCount,
nowMs: now,
@@ -256,8 +297,9 @@ export function createRendering({
let transitioning = false;
let activeCount = 0;
for (const record of records || []) {
const bb = record?.billboard;
const position = bb?.position || record?.position;
const visual = getVisual(record);
const bb = visual?.billboard;
const position = bb?.position || visual?.position;
if (!bb || !position) continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
@@ -329,10 +371,11 @@ export function createRendering({
const cells = new Map();
for (const record of records) {
const visual = getVisual(record);
if (record === selected) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
record.position,
visual.position,
);
if (!screen) continue;
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
@@ -354,7 +397,7 @@ export function createRendering({
if (selected) {
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene,
selected.billboard?.position || selected.position,
getVisual(selected).billboard?.position || getVisual(selected).position,
);
if (screen) accepted.push({ x: screen.x, y: screen.y });
}
@@ -407,7 +450,7 @@ export function createRendering({
...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
activate: () => {
const record = state.vesselMap.get(mmsi);
const record = state.records.byMmsi.get(mmsi);
if (!record) return false;
components.selection.selectAndFocusVessel(record);
return true;
@@ -450,6 +493,9 @@ export function createRendering({
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}
return {
getVisual,
prepareRecordVisual,
resetRecordVisuals,
renderRowLimit,
labelRowLimit,
ensureCollections,
+19 -12
View File
@@ -33,10 +33,10 @@ export function createSelection({
function bindVesselInteraction(viewer, handler, keyTarget) {
state.clickHandler = handler;
handler.setInputAction((click) => {
if (!state.enabled) return;
if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.vesselMap.get(pickedId) : null;
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
const ownRecordPick =
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
@@ -69,7 +69,7 @@ export function createSelection({
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
: null;
record = mmsi ? state.vesselMap.get(mmsi) || null : null;
record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
// A stale card id is not empty terrain and must not clear a newer
// selection. The next paint will evict its hit rectangle.
if (!record) return;
@@ -115,7 +115,9 @@ export function createSelection({
kind: 'vessel',
id: record.mmsi,
label: record.name || record.mmsi,
position: record.billboard?.position || record.position,
position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
});
return true;
}
@@ -137,7 +139,7 @@ export function createSelection({
}
function onVesselKeyDown(event) {
if (!state.enabled || event.key !== 'Escape') return;
if (!state.feed.enabled || event.key !== 'Escape') return;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
gesture: 'escape',
@@ -153,9 +155,11 @@ export function createSelection({
clearSelection({ preserveTrail: reuseTrail });
state.selectedRecord = record;
record.missedRefreshes = 0;
if (record.billboard) {
record.billboard.image = components.rendering.shipIcon(record, true);
record.billboard.scale = components.rendering.shipScale(record) * 1.2;
if (components.rendering.getVisual(record).billboard) {
components.rendering.getVisual(record).billboard.image =
components.rendering.shipIcon(record, true);
components.rendering.getVisual(record).billboard.scale =
components.rendering.shipScale(record) * 1.2;
}
// Rebuild the card set immediately so the full-detail card appears on the
// click, not up to VISIBILITY_UPDATE_MS later.
@@ -207,14 +211,17 @@ export function createSelection({
function clearSelection({ preserveTrail = false, evicted = false } = {}) {
const record = state.selectedRecord;
if (record?.billboard) {
record.billboard.image = components.rendering.shipIcon(record, false);
record.billboard.scale = components.rendering.shipScale(record);
if (components.rendering.getVisual(record)?.billboard) {
components.rendering.getVisual(record).billboard.image =
components.rendering.shipIcon(record, false);
components.rendering.getVisual(record).billboard.scale =
components.rendering.shipScale(record);
}
state.selectedRecord = null;
// Drop the full-detail card right away (no-op when the layer is disabled —
// disable() clears the entry set itself).
if (record && state.enabled) components.rendering.updateVisibility(true);
if (record && state.feed.enabled)
components.rendering.updateVisibility(true);
if (!preserveTrail) components.tracking.clearSelectedVesselTrail();
try {
clearSelectedEntityContextForLayer('ais-live-vessels', { evicted });
+61
View File
@@ -0,0 +1,61 @@
/** Connect portable vessel reconciliation to scene and selection operations. */
export function createVesselSnapshotRenderer({
state,
records,
rendering,
tracking,
selection,
cards,
}) {
function reconcileVessels(viewer, rows, { complete = true } = {}) {
rendering.ensureCollections(viewer);
const occluder = rendering.makeOccluder();
records.reconcile(
rows,
{
complete,
selectedRecord: state.selectedRecord,
cap: rendering.renderRowLimit(),
},
{
add(record) {
rendering.prepareRecordVisual(record);
rendering.addRecordPrimitives(record, occluder);
},
beforeUpdate(record) {
return rendering.shipIcon(record, record === state.selectedRecord);
},
updated(record, prevIcon) {
const selected = record === state.selectedRecord;
const visual = rendering.prepareRecordVisual(record);
if (visual.billboard) {
visual.billboard.position = visual.position;
visual.billboard.scale =
rendering.shipScale(record) * (selected ? 1.2 : 1);
const nextIcon = rendering.shipIcon(record, selected);
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
}
if (record.mmsi === state.trailMmsi)
tracking.appendSelectedVesselTrailFix(record);
if (selected) {
cards.updateSelectedVesselHud(record);
selection.registerSelectedContext(record);
}
},
remove(record, evicted) {
if (evicted) selection.clearVesselInspection({ evicted: true });
rendering.removeRecordPrimitives(record);
},
removed(mmsi) {
if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail();
},
staleSelected(record) {
cards.updateSelectedVesselHud(record);
},
},
);
state.lastVisibilityUpdate = 0;
rendering.updateVisibility(true);
}
return { reconcileVessels };
}
+5 -29
View File
@@ -1,3 +1,5 @@
import { createVesselFeed } from './ingestion.js';
import { VesselRecords } from './records.js';
import * as Cesium from 'cesium';
import { DEFAULT_AIS_RUNTIME } from './policy.js';
@@ -45,37 +47,11 @@ export function createVesselState({ source, services }) {
vesselState.shipIconCache = new Map();
vesselState.state = {
feed: createVesselFeed(),
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
viewer: null,
enabled: false,
loading: false,
loaded: false,
stale: false,
error: null,
loadingLabel: '',
lastUpdate: null,
count: 0,
newestPositionAt: null,
transportStatus: null,
/** Server epoch-ms of the next reconnect attempt while the feed is degraded. */
nextAttemptAt: null,
lastMessageAt: null,
rawRowCount: 0,
acceptedRowCount: 0,
/** Monotonic enable/reset owner for requests and first-connect timers. */
sessionId: 0,
/** @type {'idle'|'loading'|'ready'|'unavailable'} */
firstConnectPhase: 'idle',
firstConnectStartedAt: null,
firstConnectDeadline: null,
firstConnectTimer: null,
abort: null,
billboardCollection: null,
/** @type {Array<Object>} Flat render list: keyed records + unkeyed records */
vesselRecords: [],
/** @type {Map<string, Object>} MMSI -> vessel record (identity across refreshes) */
vesselMap: new Map(),
/** @type {Array<Object>} Records with no MMSI — rebuilt fresh each refresh */
unkeyedRecords: [],
clickHandler: null,
/** Exact EventTarget currently holding the Escape listener. */
keyTarget: null,
-212
View File
@@ -1,212 +0,0 @@
import * as Cesium from 'cesium';
import { PARTIAL_RETENTION_MS } from './policy.js';
import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js';
export function createStore({
vesselState,
services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
/**
* Reconcile the incoming AIS rows against the MMSI-keyed record map.
* Existing records are updated in place (position/heading/label) so identity
* and selection survive refreshes; new vessels are added; vanished vessels are
* removed — except the selected vessel, which is pinned for up to
* SELECTED_PIN_REFRESHES consecutive misses with a stale HUD readout.
* Rows without an MMSI are rendered unkeyed and rebuilt fresh each refresh.
* @param {Cesium.Viewer} viewer - The Cesium viewer instance.
* @param {Array<Object>} rows - Raw AIS rows from the live API.
*/
function reconcileVessels(viewer, rows, { complete = true } = {}) {
const receivedAtMs = vesselState._aisRuntime.now();
components.rendering.ensureCollections(viewer);
// Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them.
for (const record of state.unkeyedRecords) {
components.rendering.removeRecordPrimitives(record);
}
state.unkeyedRecords = [];
const occluder = components.rendering.makeOccluder();
const seen = new Set();
for (let index = 0; index < rows.length; index += 1) {
const next = normalizeVessel(rows[index]);
if (!next) continue;
next.receivedAtMs = receivedAtMs;
if (!next.mmsi) {
components.rendering.addRecordPrimitives(next, occluder);
state.unkeyedRecords.push(next);
continue;
}
if (seen.has(next.mmsi)) continue; // defensive: dedupe payload rows
seen.add(next.mmsi);
const existing = state.vesselMap.get(next.mmsi);
if (existing) {
updateRecordInPlace(existing, next);
} else {
components.rendering.addRecordPrimitives(next, occluder);
state.vesselMap.set(next.mmsi, next);
}
}
// Remove vanished vessels, pinning the selected one for a few refreshes.
for (const [mmsi, record] of state.vesselMap) {
if (seen.has(mmsi)) continue;
if (
!complete &&
Number.isFinite(record.receivedAtMs) &&
receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS
) {
if (record === state.selectedRecord) {
record.missedRefreshes = Math.max(1, record.missedRefreshes || 0);
components.cards.updateSelectedVesselHud(record);
}
continue;
}
if (record === state.selectedRecord) {
record.missedRefreshes = (record.missedRefreshes || 0) + 1;
if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) {
components.cards.updateSelectedVesselHud(record); // re-render with STALE marker
continue;
}
// Aged out of the feed after exhausting its pin — not a deselect.
components.selection.clearVesselInspection({ evicted: true });
}
components.rendering.removeRecordPrimitives(record);
state.vesselMap.delete(mmsi);
// Defensive lifecycle closure: a trail may outlive selection state during
// asynchronous handoff/refresh ordering, but never its owning record.
if (state.trailMmsi === mmsi)
components.tracking.clearSelectedVesselTrail();
}
const cap = components.rendering.renderRowLimit();
if (state.vesselMap.size + state.unkeyedRecords.length > cap) {
for (const [mmsi, record] of state.vesselMap) {
if (state.vesselMap.size + state.unkeyedRecords.length <= cap) break;
if (seen.has(mmsi) || record === state.selectedRecord) continue;
components.rendering.removeRecordPrimitives(record);
state.vesselMap.delete(mmsi);
if (state.trailMmsi === mmsi)
components.tracking.clearSelectedVesselTrail();
}
}
state.vesselRecords = [
...state.vesselMap.values(),
...state.unkeyedRecords,
];
state.lastVisibilityUpdate = 0;
components.rendering.updateVisibility(true);
}
/**
* Update an existing record (and its primitives) from a freshly normalized row,
* preserving object identity so selection and the click-pick id stay valid.
* Billboard image is only reassigned when the resolved icon actually changes
* (type recolor) to avoid thousands of redundant texture lookups per refresh.
* @param {Object} record - Existing vessel record in state.vesselMap.
* @param {Object} next - Freshly normalized record for the same MMSI.
*/
function updateRecordInPlace(record, next) {
const selected = record === state.selectedRecord;
const prevIcon = components.rendering.shipIcon(record, selected);
record.reference = next.reference;
record.receivedAtMs = next.receivedAtMs;
record.lat = next.lat;
record.lon = next.lon;
record.name = next.name;
record.imo = next.imo;
record.type = next.type;
record.destination = next.destination;
record.speed = next.speed;
record.course = next.course;
record.heading = next.heading;
record.lastPositionUtc = next.lastPositionUtc;
record.lastPositionEpoch = next.lastPositionEpoch;
record.position = next.position;
record.surfacePosition = next.surfacePosition;
record.normal = next.normal;
record.missedRefreshes = 0;
if (record.billboard) {
record.billboard.position = record.position;
// Rotation is owned by the projected-rotation pass (updateVisibility).
record.billboard.scale =
components.rendering.shipScale(record) * (selected ? 1.2 : 1);
const nextIcon = components.rendering.shipIcon(record, selected);
if (nextIcon !== prevIcon) {
record.billboard.image = nextIcon;
}
}
if (record.mmsi === state.trailMmsi) {
components.tracking.appendSelectedVesselTrailFix(record);
}
if (selected) {
components.cards.updateSelectedVesselHud(record);
components.selection.registerSelectedContext(record);
}
}
function normalizeVessel(row) {
const lat = Number(row.lat);
const lon = Number(row.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
// Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the
// ellipsoid — at height 0 everything that projects record.position (clicks,
// detection brackets, cards, getNearby) points up to ~45 m under the water.
const heightM = components.queries.vesselDatumHeightM(
components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M,
);
const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
// Surface normal at this position — used as alignedAxis so billboard
// rotation operates in the local tangent plane (true world heading)
const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
position,
new Cesium.Cartesian3(),
);
return {
lat,
lon,
name: String(row.name || row.mmsi || 'VESSEL'),
mmsi: String(row.mmsi || '').trim(),
reference: row.reference ?? String(row.mmsi || '').trim(),
imo: String(row.imo || ''),
type: String(row.type || ''),
destination: String(row.destination || ''),
speed: finiteNumber(row.speed),
course: finiteNumber(row.course),
heading: finiteNumber(row.heading),
lastPositionUtc: String(row.last_position_UTC || ''),
lastPositionEpoch: finiteNumber(row.last_position_epoch),
position,
// Ellipsoid-surface point (height 0) — feeds ONLY the horizon occluder,
// which tests against the WGS84 ellipsoid; keep it off the sea datum.
surfacePosition: Cesium.Cartesian3.fromDegrees(lon, lat, 0),
normal,
missedRefreshes: 0,
billboard: null,
};
}
function finiteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
return {
reconcileVessels,
updateRecordInPlace,
normalizeVessel,
finiteNumber,
};
}
+31 -31
View File
@@ -33,15 +33,15 @@ export function createTesting({
components.lifecycle.resetState();
const records = Array.isArray(options.records) ? options.records : [];
state.viewer = options.viewer || null;
state.enabled = options.enabled !== false;
state.loaded = options.loaded === true;
state.loading = options.loading === true;
state.stale = options.stale === true;
state.error = options.error || null;
state.lastUpdate = options.lastUpdate ?? null;
state.vesselRecords = records;
state.count = records.length;
state.vesselMap = new Map(
state.feed.enabled = options.enabled !== false;
state.feed.loaded = options.loaded === true;
state.feed.loading = options.loading === true;
state.feed.stale = options.stale === true;
state.feed.error = options.error || null;
state.feed.lastUpdate = options.lastUpdate ?? null;
state.records.all = records;
state.feed.count = records.length;
state.records.byMmsi = new Map(
records
.filter((record) => record?.mmsi)
.map((record) => [record.mmsi, record]),
@@ -53,17 +53,17 @@ export function createTesting({
state.trailPositions = Array.isArray(options.trailPositions)
? [...options.trailPositions]
: [];
state.transportStatus = options.transportStatus || null;
state.lastMessageAt = options.lastMessageAt ?? null;
state.rawRowCount = Number.isFinite(options.rawRowCount)
state.feed.transportStatus = options.transportStatus || null;
state.feed.lastMessageAt = options.lastMessageAt ?? null;
state.feed.rawRowCount = Number.isFinite(options.rawRowCount)
? options.rawRowCount
: 0;
state.acceptedRowCount = Number.isFinite(options.acceptedRowCount)
state.feed.acceptedRowCount = Number.isFinite(options.acceptedRowCount)
? options.acceptedRowCount
: records.length;
state.firstConnectPhase = options.firstConnectPhase || 'idle';
state.firstConnectStartedAt = options.firstConnectStartedAt ?? null;
state.firstConnectDeadline = options.firstConnectDeadline ?? null;
state.feed.firstConnectPhase = options.firstConnectPhase || 'idle';
state.feed.firstConnectStartedAt = options.firstConnectStartedAt ?? null;
state.feed.firstConnectDeadline = options.firstConnectDeadline ?? null;
state.interactionHandlerFactory = options.interactionHandlerFactory || null;
state.interactionKeyTarget = options.interactionKeyTarget || null;
}
@@ -89,7 +89,7 @@ export function createTesting({
*/
function _reconcileVesselsForTest(viewer, rows) {
components.store.reconcileVessels(viewer, rows);
components.snapshots.reconcileVessels(viewer, rows);
}
/** Apply one server snapshot through the production pre-reconcile health gate. */
@@ -128,25 +128,25 @@ export function createTesting({
function _getVesselFeedStateForTest() {
const stats = aisLiveVesselsLayer.getStats();
return {
count: state.count,
loaded: state.loaded,
count: state.feed.count,
loaded: state.feed.loaded,
loading: stats.loading,
loadingLabel: stats.loadingLabel,
stale: state.stale,
error: state.error,
stale: state.feed.stale,
error: state.feed.error,
status: stats.status,
lastUpdate: state.lastUpdate,
transportStatus: state.transportStatus,
lastMessageAt: state.lastMessageAt,
rawRowCount: state.rawRowCount,
acceptedRowCount: state.acceptedRowCount,
lastUpdate: state.feed.lastUpdate,
transportStatus: state.feed.transportStatus,
lastMessageAt: state.feed.lastMessageAt,
rawRowCount: state.feed.rawRowCount,
acceptedRowCount: state.feed.acceptedRowCount,
selectedMmsi: state.selectedRecord?.mmsi || null,
trailMmsi: state.trailMmsi,
trailPositionCount: state.trailPositions.length,
sessionId: state.sessionId,
firstConnectPhase: state.firstConnectPhase,
firstConnectStartedAt: state.firstConnectStartedAt,
firstConnectDeadline: state.firstConnectDeadline,
sessionId: state.feed.sessionId,
firstConnectPhase: state.feed.firstConnectPhase,
firstConnectStartedAt: state.feed.firstConnectStartedAt,
firstConnectDeadline: state.feed.firstConnectDeadline,
};
}
@@ -160,7 +160,7 @@ export function createTesting({
return {
trailMmsi: state.trailMmsi,
trailPositionCount: state.trailPositions.length,
vesselCount: state.vesselMap.size,
vesselCount: state.records.byMmsi.size,
};
}
return {
+5 -4
View File
@@ -57,20 +57,21 @@ export function createTracking({
*/
function refloorVesselRecords() {
if (!state.vesselRecords.length) return;
for (const record of state.vesselRecords) {
if (!state.records.all.length) return;
for (const record of state.records.all) {
const visual = components.rendering.getVisual(record);
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
continue;
const heightM = components.queries.vesselDatumHeightM(
currentGeoidN(record.lat, record.lon),
VESSEL_LIFT_M,
);
record.position = Cesium.Cartesian3.fromDegrees(
visual.position = Cesium.Cartesian3.fromDegrees(
record.lon,
record.lat,
heightM,
);
if (record.billboard) record.billboard.position = record.position;
if (visual.billboard) visual.billboard.position = visual.position;
}
}