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 # 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 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. - 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 source-time fallback and model-owned ground policy. The renderer supplies that
ownership fact and handles stale-ground lifting, Cesium history and primitives; ownership fact and handles stale-ground lifting, Cesium history and primitives;
record reconciliation does not import the engine or application. 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 # 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 Military flights also separate portable records and source acquisition from
Cesium snapshot application. The metadata owner retains aviation feet, source Cesium snapshot application. The metadata owner retains aviation feet, source
fix times, sticky fields and partial-feed retention. Rendering retains ground 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/records": "./src/layers/flights/records.js",
"./layers/flights/ingestion": "./src/layers/flights/ingestion.js", "./layers/flights/ingestion": "./src/layers/flights/ingestion.js",
"./layers/military/records": "./src/layers/military/records.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/rendering.js",
"src/layers/vessels/selection.js", "src/layers/vessels/selection.js",
"src/layers/vessels/state.js", "src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js", "src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/layers/vessels/ownership.test.mjs", "src/layers/vessels/ownership.test.mjs",
@@ -605,5 +604,10 @@
"src/layers/military/recordPolicy.js", "src/layers/military/recordPolicy.js",
"src/layers/military/snapshotRenderer.js", "src/layers/military/snapshotRenderer.js",
"src/layers/military/records.test.mjs", "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/rendering.js",
"src/layers/vessels/selection.js", "src/layers/vessels/selection.js",
"src/layers/vessels/state.js", "src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js", "src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/data/iconOrientation.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"] "external": ["cesium"]
}, },
@@ -1410,7 +1412,6 @@
"src/layers/vessels/rendering.js", "src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js", "src/layers/vessels/selection.js",
"src/layers/vessels/state.js", "src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js", "src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/loadingFeedback.js", "src/loadingFeedback.js",
@@ -1551,7 +1552,10 @@
"src/layers/flights/snapshotRenderer.js", "src/layers/flights/snapshotRenderer.js",
"src/layers/military/records.js", "src/layers/military/records.js",
"src/layers/military/recordPolicy.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": [ "external": [
"@mapbox/vector-tile", "@mapbox/vector-tile",
@@ -1857,7 +1861,6 @@
"src/layers/vessels/rendering.js", "src/layers/vessels/rendering.js",
"src/layers/vessels/selection.js", "src/layers/vessels/selection.js",
"src/layers/vessels/state.js", "src/layers/vessels/state.js",
"src/layers/vessels/store.js",
"src/layers/vessels/testing.js", "src/layers/vessels/testing.js",
"src/layers/vessels/tracking.js", "src/layers/vessels/tracking.js",
"src/locations.js", "src/locations.js",
@@ -1881,7 +1884,10 @@
"src/layers/flights/snapshotRenderer.js", "src/layers/flights/snapshotRenderer.js",
"src/layers/military/records.js", "src/layers/military/records.js",
"src/layers/military/recordPolicy.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": [ "external": [
"cesium", "cesium",
@@ -2033,5 +2039,21 @@
"src/layers/military/recordPolicy.js" "src/layers/military/recordPolicy.js"
], ],
"external": [] "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)), ['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;/], /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)), ['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`); 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 { return {
id: vesselOverlayEntryId(record), id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi), actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position, position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 10, gapPx: 10,
accent: accentForVesselType(record.type), accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 26), title: trimHudValue(displayVesselName(record), 26),
@@ -97,7 +99,9 @@ export function createCards({
return { return {
id: vesselOverlayEntryId(record), id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi), actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position, position:
components.rendering.getVisual(record).billboard?.position ||
components.rendering.getVisual(record).position,
gapPx: 12, gapPx: 12,
accent: accentForVesselType(record.type), accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 32), title: trimHudValue(displayVesselName(record), 32),
+13 -13
View File
@@ -17,28 +17,28 @@ export function createEvidence({
return { ok: false, count: 0 }; return { ok: false, count: 0 };
} }
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
components.store.reconcileVessels( components.snapshots.reconcileVessels(
state.viewer, state.viewer,
Array.isArray(rows) ? rows : [], Array.isArray(rows) ? rows : [],
); );
state.count = state.vesselRecords.length; state.feed.count = state.records.all.length;
state.loaded = true; state.feed.loaded = true;
state.error = null; state.feed.error = null;
state.stale = false; state.feed.stale = false;
state.lastUpdate = Date.now(); state.feed.lastUpdate = Date.now();
state.transportStatus = 'synthetic'; state.feed.transportStatus = 'synthetic';
state.lastMessageAt = null; state.feed.lastMessageAt = null;
state.rawRowCount = Array.isArray(rows) ? rows.length : 0; state.feed.rawRowCount = Array.isArray(rows) ? rows.length : 0;
state.acceptedRowCount = state.count; state.feed.acceptedRowCount = state.feed.count;
return { ok: true, count: state.count }; return { ok: true, count: state.feed.count };
} }
/** JSON-safe vessel alpha/position snapshot for the evidence report. */ /** JSON-safe vessel alpha/position snapshot for the evidence report. */
function _focusEvidenceVesselSnapshot() { function _focusEvidenceVesselSnapshot() {
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
return state.vesselRecords.map((record) => { return state.records.all.map((record) => {
const bb = record.billboard; const bb = components.rendering.getVisual(record).billboard;
const screen = bb?.position const screen = bb?.position
? Cesium.SceneTransforms.worldToWindowCoordinates( ? Cesium.SceneTransforms.worldToWindowCoordinates(
state.viewer.scene, state.viewer.scene,
+27 -3
View File
@@ -1,7 +1,7 @@
import { createVesselState } from './state.js'; import { createVesselState } from './state.js';
import { createLifecycle } from './lifecycle.js'; import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js'; import { createIngestion } from './ingestion.js';
import { createStore } from './store.js'; import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { createRendering } from './rendering.js'; import { createRendering } from './rendering.js';
import { createSelection } from './selection.js'; import { createSelection } from './selection.js';
import { createTracking } from './tracking.js'; import { createTracking } from './tracking.js';
@@ -16,8 +16,6 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
const layer = {}; const layer = {};
const context = { vesselState, services, parts, layer, options }; const context = { vesselState, services, parts, layer, options };
parts.lifecycle = createLifecycle(context); parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context);
parts.store = createStore(context);
parts.rendering = createRendering(context); parts.rendering = createRendering(context);
parts.selection = createSelection(context); parts.selection = createSelection(context);
parts.tracking = createTracking(context); parts.tracking = createTracking(context);
@@ -25,6 +23,32 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.testing = createTesting(context); parts.testing = createTesting(context);
parts.evidence = createEvidence(context); parts.evidence = createEvidence(context);
parts.queries = createQueries(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( Object.assign(
layer, layer,
parts.queries.methods, 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({ export function createIngestion({
vesselState, feed,
services, readSource,
parts: components, readViewer,
layer, getRowLimit,
options, readCount,
applyRows,
classifySnapshot,
isDefinitiveTransportFailure,
isGraceEligibleTransport,
markUnavailable,
settleFirstConnect,
now,
setSourceLabel,
}) { }) {
const { state } = vesselState;
const aisLiveVesselsLayer = layer;
async function loadLivePositions(viewer) { async function loadLivePositions(viewer) {
if (!viewer || state.loading) return; if (!viewer || feed.loading) return;
state.loading = true; feed.loading = true;
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...'; feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
const requestController = new AbortController(); const requestController = new AbortController();
const requestSessionId = state.sessionId; const requestSessionId = feed.sessionId;
state.abort = requestController; feed.abort = requestController;
try { try {
// Combine the layer's teardown-abort with a hard timeout so a hung upstream // Combine the layer's teardown-abort with a hard timeout so a hung upstream
@@ -28,12 +34,12 @@ export function createIngestion({
AbortSignal.timeout(10000), AbortSignal.timeout(10000),
]) ])
: requestController.signal; : requestController.signal;
const snapshot = await vesselState._source.getSnapshot( const snapshot = await readSource().getSnapshot(
{ maxRows: components.rendering.renderRowLimit() }, { maxRows: getRowLimit() },
{ signal }, { signal },
); );
if (!ownsAisRequest(requestController, requestSessionId)) return; if (!ownsAisRequest(requestController, requestSessionId)) return;
aisLiveVesselsLayer.source = snapshot.source; setSourceLabel(snapshot.source);
// Map observations into the existing display store; source fields stop here. // Map observations into the existing display store; source fields stop here.
applyAisFeedSnapshot(viewer, { applyAisFeedSnapshot(viewer, {
rows: snapshot.records.map(vesselDisplayRow), rows: snapshot.records.map(vesselDisplayRow),
@@ -58,20 +64,18 @@ export function createIngestion({
ownsAisRequest(requestController, requestSessionId) && ownsAisRequest(requestController, requestSessionId) &&
error?.name !== 'AbortError' error?.name !== 'AbortError'
) { ) {
components.lifecycle.markAisUnavailable( markUnavailable(error?.message || 'AIS live load failed');
error?.message || 'AIS live load failed', console.warn('[Data:ais-live-vessels]', feed.error, error);
);
console.warn('[Data:ais-live-vessels]', state.error, error);
} }
} finally { } finally {
if ( if (
state.abort === requestController && feed.abort === requestController &&
state.sessionId === requestSessionId feed.sessionId === requestSessionId
) { ) {
state.loading = false; feed.loading = false;
state.loadingLabel = feed.loadingLabel =
state.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : ''; feed.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
state.abort = null; feed.abort = null;
} }
} }
} }
@@ -80,9 +84,9 @@ export function createIngestion({
function ownsAisRequest(controller, sessionId) { function ownsAisRequest(controller, sessionId) {
return ( return (
state.enabled && feed.enabled &&
state.sessionId === sessionId && feed.sessionId === sessionId &&
state.abort === controller && feed.abort === controller &&
!controller.signal.aborted !controller.signal.aborted
); );
} }
@@ -90,59 +94,55 @@ export function createIngestion({
/** Apply a classified snapshot while preserving warm state on zero accepted rows. */ /** Apply a classified snapshot while preserving warm state on zero accepted rows. */
function applyAisFeedSnapshot(viewer, payload) { function applyAisFeedSnapshot(viewer, payload) {
const snapshot = components.queries.classifyAisFeedSnapshot(payload); const snapshot = classifySnapshot(payload);
state.loaded = true; feed.loaded = true;
state.loadingLabel = ''; feed.loadingLabel = '';
state.transportStatus = snapshot.transportStatus; feed.transportStatus = snapshot.transportStatus;
state.nextAttemptAt = Number(payload?.nextAttemptAt) || null; feed.nextAttemptAt = Number(payload?.nextAttemptAt) || null;
state.lastMessageAt = snapshot.lastMessageAt; feed.lastMessageAt = snapshot.lastMessageAt;
state.rawRowCount = snapshot.rawRowCount; feed.rawRowCount = snapshot.rawRowCount;
state.acceptedRowCount = snapshot.acceptedRowCount; feed.acceptedRowCount = snapshot.acceptedRowCount;
if (snapshot.acceptedRowCount === 0) { if (snapshot.acceptedRowCount === 0) {
state.count = state.vesselRecords.length; feed.count = readCount();
state.stale = state.count > 0 || Boolean(payload?.refreshing); feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
if ( if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
components.lifecycle.isDefinitiveTransportFailure( markUnavailable(snapshot.error);
snapshot.transportStatus,
)
) {
components.lifecycle.markAisUnavailable(snapshot.error);
return { reconciled: false, ...snapshot }; return { reconciled: false, ...snapshot };
} }
if ( if (
state.firstConnectPhase === 'loading' && feed.firstConnectPhase === 'loading' &&
components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus) isGraceEligibleTransport(snapshot.transportStatus)
) { ) {
state.error = null; feed.error = null;
state.loadingLabel = AIS_FIRST_CONNECT_LABEL; feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
return { reconciled: false, ...snapshot }; return { reconciled: false, ...snapshot };
} }
if (state.firstConnectPhase === 'loading') { if (feed.firstConnectPhase === 'loading') {
components.lifecycle.markAisUnavailable(snapshot.error); markUnavailable(snapshot.error);
return { reconciled: false, ...snapshot }; return { reconciled: false, ...snapshot };
} }
state.error = snapshot.error; feed.error = snapshot.error;
return { reconciled: false, ...snapshot }; return { reconciled: false, ...snapshot };
} }
components.lifecycle.settleFirstConnectPhase('ready'); settleFirstConnect('ready');
components.store.reconcileVessels(viewer, snapshot.acceptedRows, { applyRows(viewer, snapshot.acceptedRows, {
complete: payload?.complete !== false, complete: payload?.complete !== false,
}); });
state.count = state.vesselRecords.length; feed.count = readCount();
state.stale = feed.stale =
Boolean(payload?.refreshing) || Boolean(payload?.refreshing) ||
payload?.complete === false || payload?.complete === false ||
payload?.freshness === 'unknown'; 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 // 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 // cached vessels are still drawable, so the chip cannot go quiet on an
// outage the user is still looking at. // outage the user is still looking at.
state.error = snapshot.error || payload?.reason || null; feed.error = snapshot.error || payload?.reason || null;
state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') feed.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
? payload.observedAtMs ? payload.observedAtMs
: vesselState._aisRuntime.now(); : now();
return { reconciled: true, ...snapshot }; return { reconciled: true, ...snapshot };
} }
@@ -169,8 +169,8 @@ export function createIngestion({
} }
const methods = { const methods = {
update(viewer) { update(viewer) {
if (!state.enabled) return Promise.resolve(); if (!feed.enabled) return Promise.resolve();
return loadLivePositions(viewer || state.viewer); return loadLivePositions(viewer || readViewer());
}, },
}; };
@@ -182,3 +182,29 @@ export function createIngestion({
methods, 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; const { registerPickOwner, unregisterPickOwner } = services.picking;
function clearFirstConnectTimer() { function clearFirstConnectTimer() {
if (state.firstConnectTimer === null) return; if (state.feed.firstConnectTimer === null) return;
vesselState._aisRuntime.clearTimeout(state.firstConnectTimer); vesselState._aisRuntime.clearTimeout(state.feed.firstConnectTimer);
state.firstConnectTimer = null; state.feed.firstConnectTimer = null;
} }
function invalidateAisSession() { function invalidateAisSession() {
clearFirstConnectTimer(); clearFirstConnectTimer();
state.sessionId = ++vesselState._aisSessionSequence; state.feed.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle'; state.feed.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null; state.feed.firstConnectStartedAt = null;
state.firstConnectDeadline = null; state.feed.firstConnectDeadline = null;
} }
function beginAisSession() { function beginAisSession() {
clearFirstConnectTimer(); clearFirstConnectTimer();
const sessionId = ++vesselState._aisSessionSequence; const sessionId = ++vesselState._aisSessionSequence;
const startedAt = vesselState._aisRuntime.now(); const startedAt = vesselState._aisRuntime.now();
state.sessionId = sessionId; state.feed.sessionId = sessionId;
state.firstConnectPhase = 'loading'; state.feed.firstConnectPhase = 'loading';
state.firstConnectStartedAt = startedAt; state.feed.firstConnectStartedAt = startedAt;
state.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS; state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.error = null; state.feed.error = null;
state.loadingLabel = AIS_FIRST_CONNECT_LABEL; state.feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS); scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
} }
function scheduleFirstConnectExpiry(sessionId, delayMs) { function scheduleFirstConnectExpiry(sessionId, delayMs) {
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => { state.feed.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
if ( if (
!state.enabled || !state.feed.enabled ||
state.sessionId !== sessionId || state.feed.sessionId !== sessionId ||
state.firstConnectPhase !== 'loading' state.feed.firstConnectPhase !== 'loading'
) )
return; return;
const remainingMs = const remainingMs =
state.firstConnectDeadline - vesselState._aisRuntime.now(); state.feed.firstConnectDeadline - vesselState._aisRuntime.now();
if (remainingMs > 0) { if (remainingMs > 0) {
scheduleFirstConnectExpiry(sessionId, remainingMs); scheduleFirstConnectExpiry(sessionId, remainingMs);
return; return;
} }
state.firstConnectTimer = null; state.feed.firstConnectTimer = null;
state.firstConnectPhase = 'unavailable'; state.feed.firstConnectPhase = 'unavailable';
state.loadingLabel = ''; state.feed.loadingLabel = '';
state.error = state.lastMessageAt state.feed.error = state.feed.lastMessageAt
? 'awaiting usable AIS positions…' ? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…'; : 'awaiting first AIS message…';
state.stale = state.count > 0; state.feed.stale = state.feed.count > 0;
}, delayMs); }, delayMs);
} }
function settleFirstConnectPhase(phase) { function settleFirstConnectPhase(phase) {
clearFirstConnectTimer(); clearFirstConnectTimer();
state.firstConnectPhase = phase; state.feed.firstConnectPhase = phase;
state.loadingLabel = ''; state.feed.loadingLabel = '';
} }
function isGraceEligibleTransport(status) { function isGraceEligibleTransport(status) {
@@ -85,40 +85,41 @@ export function createLifecycle({
function markAisUnavailable(reason) { function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable'); settleFirstConnectPhase('unavailable');
state.error = reason || 'AIS live load failed'; state.feed.error = reason || 'AIS live load failed';
state.stale = state.count > 0; state.feed.stale = state.feed.count > 0;
} }
function resetState() { function resetState() {
state.abort?.abort(); components.rendering.resetRecordVisuals();
state.feed.abort?.abort();
state.trailAbort?.abort(); state.trailAbort?.abort();
state.trailAbort = null; state.trailAbort = null;
clearFirstConnectTimer(); clearFirstConnectTimer();
state.viewer = null; state.viewer = null;
state.enabled = false; state.feed.enabled = false;
state.loading = false; state.feed.loading = false;
state.loaded = false; state.feed.loaded = false;
state.stale = false; state.feed.stale = false;
state.error = null; state.feed.error = null;
state.loadingLabel = ''; state.feed.loadingLabel = '';
state.lastUpdate = null; state.feed.lastUpdate = null;
state.count = 0; state.feed.count = 0;
state.newestPositionAt = null; state.feed.newestPositionAt = null;
state.transportStatus = null; state.feed.transportStatus = null;
state.nextAttemptAt = null; state.feed.nextAttemptAt = null;
state.lastMessageAt = null; state.feed.lastMessageAt = null;
state.rawRowCount = 0; state.feed.rawRowCount = 0;
state.acceptedRowCount = 0; state.feed.acceptedRowCount = 0;
state.sessionId = ++vesselState._aisSessionSequence; state.feed.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle'; state.feed.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null; state.feed.firstConnectStartedAt = null;
state.firstConnectDeadline = null; state.feed.firstConnectDeadline = null;
state.firstConnectTimer = null; state.feed.firstConnectTimer = null;
state.abort = null; state.feed.abort = null;
state.billboardCollection = null; state.billboardCollection = null;
state.vesselRecords = []; state.records.all = [];
state.vesselMap = new Map(); state.records.byMmsi = new Map();
state.unkeyedRecords = []; state.records.unkeyed = [];
state.clickHandler = null; state.clickHandler = null;
state.keyTarget = null; state.keyTarget = null;
state.keydownHandler = null; state.keydownHandler = null;
@@ -162,8 +163,8 @@ export function createLifecycle({
}, },
enable(viewer) { enable(viewer) {
const wasEnabled = state.enabled; const wasEnabled = state.feed.enabled;
state.enabled = true; state.feed.enabled = true;
if (!wasEnabled) beginAisSession(); if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2) holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer; const activeViewer = viewer || state.viewer;
@@ -176,10 +177,11 @@ export function createLifecycle({
// failure leaves N = 0 forever, which is safe: sprites are depth-test- // failure leaves N = 0 forever, which is safe: sprites are depth-test-
// free, so vessels stay visible either way. // free, so vessels stay visible either way.
if (!vesselState._geoidReady) { if (!vesselState._geoidReady) {
const sessionId = state.sessionId; const sessionId = state.feed.sessionId;
ensureGeoidReady() ensureGeoidReady()
.then(() => { .then(() => {
if (!state.enabled || state.sessionId !== sessionId) return; if (!state.feed.enabled || state.feed.sessionId !== sessionId)
return;
vesselState._geoidReady = true; vesselState._geoidReady = true;
components.tracking.refloorVesselRecords(); components.tracking.refloorVesselRecords();
}) })
@@ -190,14 +192,14 @@ export function createLifecycle({
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id; // 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). // the registry resolver reduces it to the record's mmsi (a string key).
registerPickOwner('ais-live-vessels', (pickedId) => registerPickOwner('ais-live-vessels', (pickedId) =>
state.vesselMap.has(pickedId), state.records.byMmsi.has(pickedId),
); );
restoreSpriteOrderOnEnable('ais', activeViewer); restoreSpriteOrderOnEnable('ais', activeViewer);
return components.ingestion.loadLivePositions(activeViewer); return components.ingestion.loadLivePositions(activeViewer);
}, },
disable() { disable() {
state.enabled = false; state.feed.enabled = false;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); releaseContinuousRender('ais-vessels');
unregisterPickOwner('ais-live-vessels'); unregisterPickOwner('ais-live-vessels');
@@ -206,19 +208,19 @@ export function createLifecycle({
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail(); components.tracking.destroySelectedVesselTrail();
components.selection.removeVesselInteraction(); components.selection.removeVesselInteraction();
if (state.abort) { if (state.feed.abort) {
state.abort.abort(); state.feed.abort.abort();
state.abort = null; state.feed.abort = null;
} }
state.loading = false; state.feed.loading = false;
state.loadingLabel = ''; state.feed.loadingLabel = '';
}, },
destroy(viewer) { destroy(viewer) {
const activeViewer = viewer || state.viewer; const activeViewer = viewer || state.viewer;
invalidateAisSession(); invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) 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'); unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection(); components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail(); components.tracking.destroySelectedVesselTrail();
+38 -2
View File
@@ -34,9 +34,17 @@ function services() {
} }
function setup(source, options = {}) { function setup(source, options = {}) {
const layer = createVesselLayer({ source, services: services(), options }); const layer = createVesselLayer({ source, services: services(), options });
const primitives = new Set();
layer.testing._setVesselStateForTest({ layer.testing._setVesselStateForTest({
viewer: {}, 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; let now = 1000;
layer.testing._setAisRuntimeForTest({ layer.testing._setAisRuntimeForTest({
@@ -46,6 +54,7 @@ function setup(source, options = {}) {
}); });
return { return {
layer, layer,
primitives,
advance: (ms) => { advance: (ms) => {
now += 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() }); const two = createVesselState({ source, services: services() });
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen']) for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
assert.notEqual(one[key], two[key]); 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); assert.notEqual(one.state.trailPositions, two.state.trailPositions);
const { layer } = setup(source); const { layer } = setup(source);
const other = createVesselLayer({ source, services: services() }); 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); await new Promise(setImmediate);
assert.equal(layer.testing._getVesselStateForTest().trailPositionCount, 0); 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'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; 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_GRACE_MS = 30000;
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
export const VISIBILITY_UPDATE_MS = 800; export const VISIBILITY_UPDATE_MS = 800;
/** Focus alpha alone samples faster inside the existing preRender pass. */ /** 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; 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). */ /** Trail hue for the selected vessel (PRD F4, pinned to the AIS teal-green family). */
export const TRAIL_COLOR = '#39ffd5'; export const TRAIL_COLOR = '#39ffd5';
@@ -100,6 +99,3 @@ export const AIS_DEGRADED_STATUSES = new Set([
'down', 'down',
'auth-failed', '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() { function aisRetryInSec() {
// A rejected key is terminal until someone changes it; an hour-long // A rejected key is terminal until someone changes it; an hour-long
// countdown would imply waiting is the fix. // countdown would imply waiting is the fix.
if (state.transportStatus === 'auth-failed') return 0; if (state.feed.transportStatus === 'auth-failed') return 0;
const at = Number(state.nextAttemptAt); const at = Number(state.feed.nextAttemptAt);
if (!Number.isFinite(at) || at <= 0) return 0; if (!Number.isFinite(at) || at <= 0) return 0;
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000)); 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 * fields are null, never NaN/undefined. navStatus is always null: the
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it * /api/ais-live proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side. * 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, * @returns {{id: string|null, mmsi: string|null, name: string|null,
* lat: number|null, lon: number|null, speedKts: number|null, * lat: number|null, lon: number|null, speedKts: number|null,
* courseDeg: number|null, shipType: string|null, destination: string|null, * courseDeg: number|null, shipType: string|null, destination: string|null,
@@ -248,14 +248,14 @@ export function createQueries({
*/ */
findByQuery(query) { findByQuery(query) {
if (query === null || query === undefined) return null; if (query === null || query === undefined) return null;
const records = state.vesselRecords; const records = state.records.all;
if (!Array.isArray(records) || !records.length) return null; if (!Array.isArray(records) || !records.length) return null;
const q = String(query).trim(); const q = String(query).trim();
if (!q) return null; if (!q) return null;
let record = null; let record = null;
if (/^\d+$/.test(q)) { if (/^\d+$/.test(q)) {
record = state.vesselMap.get(q) || null; record = state.records.byMmsi.get(q) || null;
} }
if (!record) { if (!record) {
const lower = q.toLowerCase(); const lower = q.toLowerCase();
@@ -268,7 +268,9 @@ export function createQueries({
} }
if (!record) return null; 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; if (!position) return null;
return { return {
mmsi: record.mmsi, mmsi: record.mmsi,
@@ -290,7 +292,7 @@ export function createQueries({
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>} * @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
*/ */
getNearby(centerCartesian, rangeM, maxCount = 25) { getNearby(centerCartesian, rangeM, maxCount = 25) {
const records = state.vesselRecords; const records = state.records.all;
if (!centerCartesian || !Array.isArray(records) || !records.length) if (!centerCartesian || !Array.isArray(records) || !records.length)
return []; return [];
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity; const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
@@ -299,9 +301,10 @@ export function createQueries({
const entries = []; const entries = [];
for (const record of records) { for (const record of records) {
const visual = components.rendering.getVisual(record);
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
continue; continue;
const position = record.billboard?.position || record.position; const position = visual.billboard?.position || visual.position;
if (!position) continue; if (!position) continue;
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
if (!Number.isFinite(distanceM) || distanceM > range) continue; if (!Number.isFinite(distanceM) || distanceM > range) continue;
@@ -333,22 +336,27 @@ export function createQueries({
* holds no data and therefore cannot answer. * holds no data and therefore cannot answer.
*/ */
hasContact(mmsi) { 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; return null;
if (!mmsi) return false; if (!mmsi) return false;
return state.vesselMap.has(String(mmsi).trim()); return state.records.byMmsi.has(String(mmsi).trim());
}, },
getAllPositions(maxCount = 800) { getAllPositions(maxCount = 800) {
const result = []; const result = [];
const records = state.vesselRecords; const records = state.records.all;
if (!Array.isArray(records)) return result; if (!Array.isArray(records)) return result;
const cap = const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800; Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
for (const record of records) { for (const record of records) {
const visual = components.rendering.getVisual(record);
if (result.length >= cap) break; if (result.length >= cap) break;
const position = record.billboard?.position || record.position; const position = visual.billboard?.position || visual.position;
if (!position) continue; if (!position) continue;
result.push({ result.push({
id: record.mmsi, id: record.mmsi,
@@ -370,8 +378,8 @@ export function createQueries({
* @returns {Array<Object>} See mapAnalystRecord for the record shape. * @returns {Array<Object>} See mapAnalystRecord for the record shape.
*/ */
getAnalystRecords(maxCount = 2000) { getAnalystRecords(maxCount = 2000) {
if (!state.enabled) return []; if (!state.feed.enabled) return [];
const records = state.vesselRecords; const records = state.records.all;
if (!Array.isArray(records) || !records.length) return []; if (!Array.isArray(records) || !records.length) return [];
const limit = Number.isFinite(maxCount) const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount)) ? Math.max(1, Math.floor(maxCount))
@@ -394,7 +402,7 @@ export function createQueries({
if (mmsi === null || mmsi === undefined) return false; if (mmsi === null || mmsi === undefined) return false;
const target = String(mmsi).trim(); const target = String(mmsi).trim();
if (!target) return false; if (!target) return false;
const record = state.vesselMap.get(target); const record = state.records.byMmsi.get(target);
if (!record) return false; if (!record) return false;
components.selection.selectVessel(record); components.selection.selectVessel(record);
return true; return true;
@@ -438,12 +446,12 @@ export function createQueries({
*/ */
getDetectableObjects(options = {}) { getDetectableObjects(options = {}) {
if ( if (
!state.enabled || !state.feed.enabled ||
!state.billboardCollection || !state.billboardCollection ||
!state.billboardCollection.show !state.billboardCollection.show
) )
return []; return [];
const records = state.vesselRecords; const records = state.records.all;
if (!Array.isArray(records) || !records.length) return []; if (!Array.isArray(records) || !records.length) return [];
const maxCount = Number.isFinite(options.maxCount) const maxCount = Number.isFinite(options.maxCount)
@@ -459,8 +467,9 @@ export function createQueries({
for (let idx = 0; idx < records.length; idx += 1) { for (let idx = 0; idx < records.length; idx += 1) {
if ((idx - start) % stride !== 0) continue; if ((idx - start) % stride !== 0) continue;
const record = records[idx]; const record = records[idx];
if (record.billboard && !record.billboard.show) continue; const visual = components.rendering.getVisual(record);
const position = record.billboard?.position || record.position; if (visual.billboard && !visual.billboard.show) continue;
const position = visual.billboard?.position || visual.position;
if (!position) continue; if (!position) continue;
result.push({ result.push({
position, position,
@@ -488,22 +497,25 @@ export function createQueries({
: {}), : {}),
getStats() { getStats() {
const waitingForFirstPosition = state.firstConnectPhase === 'loading'; const waitingForFirstPosition =
state.feed.firstConnectPhase === 'loading';
return { return {
count: state.count, count: state.feed.count,
lastUpdate: state.lastUpdate, lastUpdate: state.feed.lastUpdate,
loading: state.loading || waitingForFirstPosition, loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition loadingLabel: waitingForFirstPosition
? AIS_FIRST_CONNECT_LABEL ? AIS_FIRST_CONNECT_LABEL
: state.loadingLabel, : state.feed.loadingLabel,
error: state.error, error: state.feed.error,
stale: state.stale, stale: state.feed.stale,
status: status:
state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined, state.feed.firstConnectPhase === 'unavailable'
transportStatus: state.transportStatus, ? 'unavailable'
lastMessageAt: state.lastMessageAt, : undefined,
rawRowCount: state.rawRowCount, transportStatus: state.feed.transportStatus,
acceptedRowCount: state.acceptedRowCount, lastMessageAt: state.feed.lastMessageAt,
rawRowCount: state.feed.rawRowCount,
acceptedRowCount: state.feed.acceptedRowCount,
// Same chip affordance the flights layer uses: when the server is // Same chip affordance the flights layer uses: when the server is
// backing off, say how long until the next attempt instead of leaving // backing off, say how long until the next attempt instead of leaving
// the user to guess whether anything is still happening. // 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, screenProjectedRotation,
} from '../../data/iconOrientation.js'; } from '../../data/iconOrientation.js';
import { import {
VESSEL_LIFT_M,
DEFAULT_RENDER_ROWS, DEFAULT_RENDER_ROWS,
DEFAULT_ACTIVE_LABELS, DEFAULT_ACTIVE_LABELS,
VISIBILITY_UPDATE_MS, VISIBILITY_UPDATE_MS,
@@ -27,6 +28,44 @@ export function createRendering({
options, options,
}) { }) {
const { state } = vesselState; 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 { registerSpriteCollection } = services.sprites;
const { const {
forgetSpriteFocus, forgetSpriteFocus,
@@ -58,7 +97,7 @@ export function createRendering({
state.billboardCollection = new Cesium.BillboardCollection({ state.billboardCollection = new Cesium.BillboardCollection({
blendOption: Cesium.BlendOption.TRANSLUCENT, blendOption: Cesium.BlendOption.TRANSLUCENT,
}); });
state.billboardCollection.show = state.enabled; state.billboardCollection.show = state.feed.enabled;
viewer.scene.primitives.add(state.billboardCollection); viewer.scene.primitives.add(state.billboardCollection);
registerSpriteCollection('ais', state.billboardCollection); registerSpriteCollection('ais', state.billboardCollection);
} }
@@ -73,9 +112,10 @@ export function createRendering({
function addRecordPrimitives(record, occluder) { function addRecordPrimitives(record, occluder) {
const visible = const visible =
state.enabled && isVisible(record.surfacePosition, occluder); state.feed.enabled &&
record.billboard = state.billboardCollection.add({ isVisible(getVisual(record).surfacePosition, occluder);
position: record.position, getVisual(record).billboard = state.billboardCollection.add({
position: getVisual(record).position,
show: visible, show: visible,
image: shipIcon(record, false), image: shipIcon(record, false),
scale: shipScale(record), scale: shipScale(record),
@@ -100,11 +140,11 @@ export function createRendering({
function removeRecordPrimitives(record) { function removeRecordPrimitives(record) {
if (!record) return; if (!record) return;
if (record.billboard && state.billboardCollection) { if (getVisual(record).billboard && state.billboardCollection) {
forgetSpriteFocus(record.billboard); forgetSpriteFocus(getVisual(record).billboard);
state.billboardCollection.remove(record.billboard); state.billboardCollection.remove(getVisual(record).billboard);
} }
record.billboard = null; getVisual(record).billboard = null;
} }
function shipScale(record) { function shipScale(record) {
@@ -163,7 +203,7 @@ export function createRendering({
} }
function updateVisibility(force = false) { function updateVisibility(force = false) {
if (!state.enabled) return; if (!state.feed.enabled) return;
const now = focusNowMs(performance.now()); const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget(); const focusTarget = getFocusTarget();
const regularPass = const regularPass =
@@ -174,7 +214,7 @@ export function createRendering({
if (!regularPass && !focusPass) return; if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now; if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = 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). // No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]); if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0; if (focusPass) state.activeFocusCount = 0;
@@ -191,22 +231,23 @@ export function createRendering({
if (doRotations) vesselState._lastCamPoseSig = poseSig; if (doRotations) vesselState._lastCamPoseSig = poseSig;
const occluder = makeOccluder(); const occluder = makeOccluder();
const labelCandidates = []; const labelCandidates = [];
for (const record of state.vesselRecords) { for (const record of state.records.all) {
const visible = isVisible(record.surfacePosition, occluder); const visual = getVisual(record);
if (record.billboard) { const visible = isVisible(visual.surfacePosition, occluder);
record.billboard.show = visible; if (visual.billboard) {
visual.billboard.show = visible;
if (visible && doRotations && scene) { if (visible && doRotations && scene) {
const rot = screenProjectedRotation( const rot = screenProjectedRotation(
scene, scene,
record.position, visual.position,
vesselCourseDeg(record), vesselCourseDeg(record),
record.billboard.rotation, visual.billboard.rotation,
); );
if ( if (
rot !== null && 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) { if (focusPass && scene && camera) {
const result = applyVesselFocusDeemphasis({ const result = applyVesselFocusDeemphasis({
records: state.vesselRecords, records: state.records.all,
target: focusTarget, target: focusTarget,
previousActiveCount: state.activeFocusCount, previousActiveCount: state.activeFocusCount,
nowMs: now, nowMs: now,
@@ -256,8 +297,9 @@ export function createRendering({
let transitioning = false; let transitioning = false;
let activeCount = 0; let activeCount = 0;
for (const record of records || []) { for (const record of records || []) {
const bb = record?.billboard; const visual = getVisual(record);
const position = bb?.position || record?.position; const bb = visual?.billboard;
const position = bb?.position || visual?.position;
if (!bb || !position) continue; if (!bb || !position) continue;
const focus = advanceSpriteFocus(bb, { const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active // Hidden/far-side sprites still finish any pending release so the active
@@ -329,10 +371,11 @@ export function createRendering({
const cells = new Map(); const cells = new Map();
for (const record of records) { for (const record of records) {
const visual = getVisual(record);
if (record === selected) continue; if (record === selected) continue;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene, scene,
record.position, visual.position,
); );
if (!screen) continue; if (!screen) continue;
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`; 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) { if (selected) {
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
scene, scene,
selected.billboard?.position || selected.position, getVisual(selected).billboard?.position || getVisual(selected).position,
); );
if (screen) accepted.push({ x: screen.x, y: screen.y }); if (screen) accepted.push({ x: screen.x, y: screen.y });
} }
@@ -407,7 +450,7 @@ export function createRendering({
...card, ...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`, accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
activate: () => { activate: () => {
const record = state.vesselMap.get(mmsi); const record = state.records.byMmsi.get(mmsi);
if (!record) return false; if (!record) return false;
components.selection.selectAndFocusVessel(record); components.selection.selectAndFocusVessel(record);
return true; return true;
@@ -450,6 +493,9 @@ export function createRendering({
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
} }
return { return {
getVisual,
prepareRecordVisual,
resetRecordVisuals,
renderRowLimit, renderRowLimit,
labelRowLimit, labelRowLimit,
ensureCollections, ensureCollections,
+19 -12
View File
@@ -33,10 +33,10 @@ export function createSelection({
function bindVesselInteraction(viewer, handler, keyTarget) { function bindVesselInteraction(viewer, handler, keyTarget) {
state.clickHandler = handler; state.clickHandler = handler;
handler.setInputAction((click) => { handler.setInputAction((click) => {
if (!state.enabled) return; if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position); const picked = viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked); 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 rawId = picked?.id ?? picked?.primitive?.id;
const ownRecordPick = const ownRecordPick =
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi'); rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
@@ -69,7 +69,7 @@ export function createSelection({
const mmsi = String(cardHit.entryId || '').startsWith('vessel:') const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length) ? cardHit.entryId.slice('vessel:'.length)
: null; : 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 // A stale card id is not empty terrain and must not clear a newer
// selection. The next paint will evict its hit rectangle. // selection. The next paint will evict its hit rectangle.
if (!record) return; if (!record) return;
@@ -115,7 +115,9 @@ export function createSelection({
kind: 'vessel', kind: 'vessel',
id: record.mmsi, id: record.mmsi,
label: record.name || 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; return true;
} }
@@ -137,7 +139,7 @@ export function createSelection({
} }
function onVesselKeyDown(event) { function onVesselKeyDown(event) {
if (!state.enabled || event.key !== 'Escape') return; if (!state.feed.enabled || event.key !== 'Escape') return;
const transition = components.queries.reduceVesselSelection({ const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi, selectedMmsi: state.selectedRecord?.mmsi,
gesture: 'escape', gesture: 'escape',
@@ -153,9 +155,11 @@ export function createSelection({
clearSelection({ preserveTrail: reuseTrail }); clearSelection({ preserveTrail: reuseTrail });
state.selectedRecord = record; state.selectedRecord = record;
record.missedRefreshes = 0; record.missedRefreshes = 0;
if (record.billboard) { if (components.rendering.getVisual(record).billboard) {
record.billboard.image = components.rendering.shipIcon(record, true); components.rendering.getVisual(record).billboard.image =
record.billboard.scale = components.rendering.shipScale(record) * 1.2; 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 // Rebuild the card set immediately so the full-detail card appears on the
// click, not up to VISIBILITY_UPDATE_MS later. // click, not up to VISIBILITY_UPDATE_MS later.
@@ -207,14 +211,17 @@ export function createSelection({
function clearSelection({ preserveTrail = false, evicted = false } = {}) { function clearSelection({ preserveTrail = false, evicted = false } = {}) {
const record = state.selectedRecord; const record = state.selectedRecord;
if (record?.billboard) { if (components.rendering.getVisual(record)?.billboard) {
record.billboard.image = components.rendering.shipIcon(record, false); components.rendering.getVisual(record).billboard.image =
record.billboard.scale = components.rendering.shipScale(record); components.rendering.shipIcon(record, false);
components.rendering.getVisual(record).billboard.scale =
components.rendering.shipScale(record);
} }
state.selectedRecord = null; state.selectedRecord = null;
// Drop the full-detail card right away (no-op when the layer is disabled — // Drop the full-detail card right away (no-op when the layer is disabled —
// disable() clears the entry set itself). // 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(); if (!preserveTrail) components.tracking.clearSelectedVesselTrail();
try { try {
clearSelectedEntityContextForLayer('ais-live-vessels', { evicted }); 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 * as Cesium from 'cesium';
import { DEFAULT_AIS_RUNTIME } from './policy.js'; import { DEFAULT_AIS_RUNTIME } from './policy.js';
@@ -45,37 +47,11 @@ export function createVesselState({ source, services }) {
vesselState.shipIconCache = new Map(); vesselState.shipIconCache = new Map();
vesselState.state = { vesselState.state = {
feed: createVesselFeed(),
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
viewer: null, 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, 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, clickHandler: null,
/** Exact EventTarget currently holding the Escape listener. */ /** Exact EventTarget currently holding the Escape listener. */
keyTarget: null, 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(); components.lifecycle.resetState();
const records = Array.isArray(options.records) ? options.records : []; const records = Array.isArray(options.records) ? options.records : [];
state.viewer = options.viewer || null; state.viewer = options.viewer || null;
state.enabled = options.enabled !== false; state.feed.enabled = options.enabled !== false;
state.loaded = options.loaded === true; state.feed.loaded = options.loaded === true;
state.loading = options.loading === true; state.feed.loading = options.loading === true;
state.stale = options.stale === true; state.feed.stale = options.stale === true;
state.error = options.error || null; state.feed.error = options.error || null;
state.lastUpdate = options.lastUpdate ?? null; state.feed.lastUpdate = options.lastUpdate ?? null;
state.vesselRecords = records; state.records.all = records;
state.count = records.length; state.feed.count = records.length;
state.vesselMap = new Map( state.records.byMmsi = new Map(
records records
.filter((record) => record?.mmsi) .filter((record) => record?.mmsi)
.map((record) => [record.mmsi, record]), .map((record) => [record.mmsi, record]),
@@ -53,17 +53,17 @@ export function createTesting({
state.trailPositions = Array.isArray(options.trailPositions) state.trailPositions = Array.isArray(options.trailPositions)
? [...options.trailPositions] ? [...options.trailPositions]
: []; : [];
state.transportStatus = options.transportStatus || null; state.feed.transportStatus = options.transportStatus || null;
state.lastMessageAt = options.lastMessageAt ?? null; state.feed.lastMessageAt = options.lastMessageAt ?? null;
state.rawRowCount = Number.isFinite(options.rawRowCount) state.feed.rawRowCount = Number.isFinite(options.rawRowCount)
? options.rawRowCount ? options.rawRowCount
: 0; : 0;
state.acceptedRowCount = Number.isFinite(options.acceptedRowCount) state.feed.acceptedRowCount = Number.isFinite(options.acceptedRowCount)
? options.acceptedRowCount ? options.acceptedRowCount
: records.length; : records.length;
state.firstConnectPhase = options.firstConnectPhase || 'idle'; state.feed.firstConnectPhase = options.firstConnectPhase || 'idle';
state.firstConnectStartedAt = options.firstConnectStartedAt ?? null; state.feed.firstConnectStartedAt = options.firstConnectStartedAt ?? null;
state.firstConnectDeadline = options.firstConnectDeadline ?? null; state.feed.firstConnectDeadline = options.firstConnectDeadline ?? null;
state.interactionHandlerFactory = options.interactionHandlerFactory || null; state.interactionHandlerFactory = options.interactionHandlerFactory || null;
state.interactionKeyTarget = options.interactionKeyTarget || null; state.interactionKeyTarget = options.interactionKeyTarget || null;
} }
@@ -89,7 +89,7 @@ export function createTesting({
*/ */
function _reconcileVesselsForTest(viewer, rows) { 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. */ /** Apply one server snapshot through the production pre-reconcile health gate. */
@@ -128,25 +128,25 @@ export function createTesting({
function _getVesselFeedStateForTest() { function _getVesselFeedStateForTest() {
const stats = aisLiveVesselsLayer.getStats(); const stats = aisLiveVesselsLayer.getStats();
return { return {
count: state.count, count: state.feed.count,
loaded: state.loaded, loaded: state.feed.loaded,
loading: stats.loading, loading: stats.loading,
loadingLabel: stats.loadingLabel, loadingLabel: stats.loadingLabel,
stale: state.stale, stale: state.feed.stale,
error: state.error, error: state.feed.error,
status: stats.status, status: stats.status,
lastUpdate: state.lastUpdate, lastUpdate: state.feed.lastUpdate,
transportStatus: state.transportStatus, transportStatus: state.feed.transportStatus,
lastMessageAt: state.lastMessageAt, lastMessageAt: state.feed.lastMessageAt,
rawRowCount: state.rawRowCount, rawRowCount: state.feed.rawRowCount,
acceptedRowCount: state.acceptedRowCount, acceptedRowCount: state.feed.acceptedRowCount,
selectedMmsi: state.selectedRecord?.mmsi || null, selectedMmsi: state.selectedRecord?.mmsi || null,
trailMmsi: state.trailMmsi, trailMmsi: state.trailMmsi,
trailPositionCount: state.trailPositions.length, trailPositionCount: state.trailPositions.length,
sessionId: state.sessionId, sessionId: state.feed.sessionId,
firstConnectPhase: state.firstConnectPhase, firstConnectPhase: state.feed.firstConnectPhase,
firstConnectStartedAt: state.firstConnectStartedAt, firstConnectStartedAt: state.feed.firstConnectStartedAt,
firstConnectDeadline: state.firstConnectDeadline, firstConnectDeadline: state.feed.firstConnectDeadline,
}; };
} }
@@ -160,7 +160,7 @@ export function createTesting({
return { return {
trailMmsi: state.trailMmsi, trailMmsi: state.trailMmsi,
trailPositionCount: state.trailPositions.length, trailPositionCount: state.trailPositions.length,
vesselCount: state.vesselMap.size, vesselCount: state.records.byMmsi.size,
}; };
} }
return { return {
+5 -4
View File
@@ -57,20 +57,21 @@ export function createTracking({
*/ */
function refloorVesselRecords() { function refloorVesselRecords() {
if (!state.vesselRecords.length) return; if (!state.records.all.length) return;
for (const record of state.vesselRecords) { for (const record of state.records.all) {
const visual = components.rendering.getVisual(record);
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
continue; continue;
const heightM = components.queries.vesselDatumHeightM( const heightM = components.queries.vesselDatumHeightM(
currentGeoidN(record.lat, record.lon), currentGeoidN(record.lat, record.lon),
VESSEL_LIFT_M, VESSEL_LIFT_M,
); );
record.position = Cesium.Cartesian3.fromDegrees( visual.position = Cesium.Cartesian3.fromDegrees(
record.lon, record.lon,
record.lat, record.lat,
heightM, heightM,
); );
if (record.billboard) record.billboard.position = record.position; if (visual.billboard) visual.billboard.position = visual.position;
} }
} }