diff --git a/CHANGELOG.md b/CHANGELOG.md index 4277f6dd9..d28b0cac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index a655484bc..fb56d4d11 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 9ee7f6cb0..ba03ad08a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/package.json b/package.json index 43bbc2889..a953f25aa 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 693cbb243..be3cba0ed 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e0cde1837..202159eab 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": [] } } diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 66d286756..b582e9b17 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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`); } diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index d4341d363..4eb027565 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -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), diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index abab8821c..07a67c967 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -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, diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index d459aa873..af52e9e3c 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -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, diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index ba0d6d538..73bb764dc 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -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, + }; +} diff --git a/src/layers/vessels/ingestion.test.mjs b/src/layers/vessels/ingestion.test.mjs new file mode 100644 index 000000000..2563a7f8d --- /dev/null +++ b/src/layers/vessels/ingestion.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index c878b398c..f00e68d16 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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(); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 1be09728a..b9a3709f6 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index b13dcb947..b84c1026e 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -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; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 05109e2b2..ab6a6af0c 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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} 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. diff --git a/src/layers/vessels/recordPolicy.js b/src/layers/vessels/recordPolicy.js new file mode 100644 index 000000000..8b0ee72e6 --- /dev/null +++ b/src/layers/vessels/recordPolicy.js @@ -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…'; diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js new file mode 100644 index 000000000..ad6d85cbd --- /dev/null +++ b/src/layers/vessels/records.js @@ -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]; + } +} diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs new file mode 100644 index 000000000..d3e19dcd6 --- /dev/null +++ b/src/layers/vessels/records.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 87f09537d..2d3c7d276 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -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, diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 1ef7cbd9c..56c59a16b 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -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 }); diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js new file mode 100644 index 000000000..03abb81dc --- /dev/null +++ b/src/layers/vessels/snapshotRenderer.js @@ -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 }; +} diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 5f17de65f..d0b8afb18 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -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} Flat render list: keyed records + unkeyed records */ - vesselRecords: [], - /** @type {Map} MMSI -> vessel record (identity across refreshes) */ - vesselMap: new Map(), - /** @type {Array} Records with no MMSI — rebuilt fresh each refresh */ - unkeyedRecords: [], clickHandler: null, /** Exact EventTarget currently holding the Escape listener. */ keyTarget: null, diff --git a/src/layers/vessels/store.js b/src/layers/vessels/store.js deleted file mode 100644 index 80890452c..000000000 --- a/src/layers/vessels/store.js +++ /dev/null @@ -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} 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, - }; -} diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index e0c8f37fe..c4b7a2547 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -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 { diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index 52de1e0de..2c11ff463 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -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; } }