mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge pull request #500 from bilawalsidhu/refactor/vessel-record-ownership
Separate vessel records and acquisition from rendering
This commit is contained in:
26 files changed
+877
-508
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,10 @@
|
||||
# God's Eye View Current State
|
||||
|
||||
Vessels separate plain, stable MMSI records and source acquisition from scene
|
||||
resources. The renderer owns geometry and billboards; cards, picking and trails
|
||||
resolve those resources through it. Partial-feed retention, selected-vessel
|
||||
pinning, first-position grace, source time and sea-surface placement are unchanged.
|
||||
|
||||
Military flights also separate portable records and source acquisition from
|
||||
Cesium snapshot application. The metadata owner retains aviation feet, source
|
||||
fix times, sticky fields and partial-feed retention. Rendering retains ground
|
||||
|
||||
+3
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -27,8 +27,8 @@ export function createQueries({
|
||||
function aisRetryInSec() {
|
||||
// A rejected key is terminal until someone changes it; an hour-long
|
||||
// countdown would imply waiting is the fix.
|
||||
if (state.transportStatus === 'auth-failed') return 0;
|
||||
const at = Number(state.nextAttemptAt);
|
||||
if (state.feed.transportStatus === 'auth-failed') return 0;
|
||||
const at = Number(state.feed.nextAttemptAt);
|
||||
if (!Number.isFinite(at) || at <= 0) return 0;
|
||||
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
|
||||
}
|
||||
@@ -142,7 +142,7 @@ export function createQueries({
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.vesselMap`/`state.vesselRecords` entry.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
* lat: number|null, lon: number|null, speedKts: number|null,
|
||||
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
||||
@@ -248,14 +248,14 @@ export function createQueries({
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (query === null || query === undefined) return null;
|
||||
const records = state.vesselRecords;
|
||||
const records = state.records.all;
|
||||
if (!Array.isArray(records) || !records.length) return null;
|
||||
const q = String(query).trim();
|
||||
if (!q) return null;
|
||||
|
||||
let record = null;
|
||||
if (/^\d+$/.test(q)) {
|
||||
record = state.vesselMap.get(q) || null;
|
||||
record = state.records.byMmsi.get(q) || null;
|
||||
}
|
||||
if (!record) {
|
||||
const lower = q.toLowerCase();
|
||||
@@ -268,7 +268,9 @@ export function createQueries({
|
||||
}
|
||||
if (!record) return null;
|
||||
|
||||
const position = record.billboard?.position || record.position;
|
||||
const position =
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position;
|
||||
if (!position) return null;
|
||||
return {
|
||||
mmsi: record.mmsi,
|
||||
@@ -290,7 +292,7 @@ export function createQueries({
|
||||
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||||
*/
|
||||
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||||
const records = state.vesselRecords;
|
||||
const records = state.records.all;
|
||||
if (!centerCartesian || !Array.isArray(records) || !records.length)
|
||||
return [];
|
||||
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
|
||||
@@ -299,9 +301,10 @@ export function createQueries({
|
||||
|
||||
const entries = [];
|
||||
for (const record of records) {
|
||||
const visual = components.rendering.getVisual(record);
|
||||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||||
continue;
|
||||
const position = record.billboard?.position || record.position;
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||
@@ -333,22 +336,27 @@ export function createQueries({
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(mmsi) {
|
||||
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0)
|
||||
if (
|
||||
!state.feed.enabled ||
|
||||
!state.records.byMmsi ||
|
||||
state.records.byMmsi.size === 0
|
||||
)
|
||||
return null;
|
||||
if (!mmsi) return false;
|
||||
return state.vesselMap.has(String(mmsi).trim());
|
||||
return state.records.byMmsi.has(String(mmsi).trim());
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 800) {
|
||||
const result = [];
|
||||
const records = state.vesselRecords;
|
||||
const records = state.records.all;
|
||||
if (!Array.isArray(records)) return result;
|
||||
const cap =
|
||||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
|
||||
|
||||
for (const record of records) {
|
||||
const visual = components.rendering.getVisual(record);
|
||||
if (result.length >= cap) break;
|
||||
const position = record.billboard?.position || record.position;
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
id: record.mmsi,
|
||||
@@ -370,8 +378,8 @@ export function createQueries({
|
||||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||||
*/
|
||||
getAnalystRecords(maxCount = 2000) {
|
||||
if (!state.enabled) return [];
|
||||
const records = state.vesselRecords;
|
||||
if (!state.feed.enabled) return [];
|
||||
const records = state.records.all;
|
||||
if (!Array.isArray(records) || !records.length) return [];
|
||||
const limit = Number.isFinite(maxCount)
|
||||
? Math.max(1, Math.floor(maxCount))
|
||||
@@ -394,7 +402,7 @@ export function createQueries({
|
||||
if (mmsi === null || mmsi === undefined) return false;
|
||||
const target = String(mmsi).trim();
|
||||
if (!target) return false;
|
||||
const record = state.vesselMap.get(target);
|
||||
const record = state.records.byMmsi.get(target);
|
||||
if (!record) return false;
|
||||
components.selection.selectVessel(record);
|
||||
return true;
|
||||
@@ -438,12 +446,12 @@ export function createQueries({
|
||||
*/
|
||||
getDetectableObjects(options = {}) {
|
||||
if (
|
||||
!state.enabled ||
|
||||
!state.feed.enabled ||
|
||||
!state.billboardCollection ||
|
||||
!state.billboardCollection.show
|
||||
)
|
||||
return [];
|
||||
const records = state.vesselRecords;
|
||||
const records = state.records.all;
|
||||
if (!Array.isArray(records) || !records.length) return [];
|
||||
|
||||
const maxCount = Number.isFinite(options.maxCount)
|
||||
@@ -459,8 +467,9 @@ export function createQueries({
|
||||
for (let idx = 0; idx < records.length; idx += 1) {
|
||||
if ((idx - start) % stride !== 0) continue;
|
||||
const record = records[idx];
|
||||
if (record.billboard && !record.billboard.show) continue;
|
||||
const position = record.billboard?.position || record.position;
|
||||
const visual = components.rendering.getVisual(record);
|
||||
if (visual.billboard && !visual.billboard.show) continue;
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
position,
|
||||
@@ -488,22 +497,25 @@ export function createQueries({
|
||||
: {}),
|
||||
|
||||
getStats() {
|
||||
const waitingForFirstPosition = state.firstConnectPhase === 'loading';
|
||||
const waitingForFirstPosition =
|
||||
state.feed.firstConnectPhase === 'loading';
|
||||
return {
|
||||
count: state.count,
|
||||
lastUpdate: state.lastUpdate,
|
||||
loading: state.loading || waitingForFirstPosition,
|
||||
count: state.feed.count,
|
||||
lastUpdate: state.feed.lastUpdate,
|
||||
loading: state.feed.loading || waitingForFirstPosition,
|
||||
loadingLabel: waitingForFirstPosition
|
||||
? AIS_FIRST_CONNECT_LABEL
|
||||
: state.loadingLabel,
|
||||
error: state.error,
|
||||
stale: state.stale,
|
||||
: state.feed.loadingLabel,
|
||||
error: state.feed.error,
|
||||
stale: state.feed.stale,
|
||||
status:
|
||||
state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
|
||||
transportStatus: state.transportStatus,
|
||||
lastMessageAt: state.lastMessageAt,
|
||||
rawRowCount: state.rawRowCount,
|
||||
acceptedRowCount: state.acceptedRowCount,
|
||||
state.feed.firstConnectPhase === 'unavailable'
|
||||
? 'unavailable'
|
||||
: undefined,
|
||||
transportStatus: state.feed.transportStatus,
|
||||
lastMessageAt: state.feed.lastMessageAt,
|
||||
rawRowCount: state.feed.rawRowCount,
|
||||
acceptedRowCount: state.feed.acceptedRowCount,
|
||||
// Same chip affordance the flights layer uses: when the server is
|
||||
// backing off, say how long until the next attempt instead of leaving
|
||||
// the user to guess whether anything is still happening.
|
||||
|
||||
@@ -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…';
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createVesselFeed } from './ingestion.js';
|
||||
import { VesselRecords } from './records.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||
|
||||
@@ -45,37 +47,11 @@ export function createVesselState({ source, services }) {
|
||||
vesselState.shipIconCache = new Map();
|
||||
|
||||
vesselState.state = {
|
||||
feed: createVesselFeed(),
|
||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||
viewer: null,
|
||||
enabled: false,
|
||||
loading: false,
|
||||
loaded: false,
|
||||
stale: false,
|
||||
error: null,
|
||||
loadingLabel: '',
|
||||
lastUpdate: null,
|
||||
count: 0,
|
||||
newestPositionAt: null,
|
||||
transportStatus: null,
|
||||
/** Server epoch-ms of the next reconnect attempt while the feed is degraded. */
|
||||
nextAttemptAt: null,
|
||||
lastMessageAt: null,
|
||||
rawRowCount: 0,
|
||||
acceptedRowCount: 0,
|
||||
/** Monotonic enable/reset owner for requests and first-connect timers. */
|
||||
sessionId: 0,
|
||||
/** @type {'idle'|'loading'|'ready'|'unavailable'} */
|
||||
firstConnectPhase: 'idle',
|
||||
firstConnectStartedAt: null,
|
||||
firstConnectDeadline: null,
|
||||
firstConnectTimer: null,
|
||||
abort: null,
|
||||
|
||||
billboardCollection: null,
|
||||
/** @type {Array<Object>} Flat render list: keyed records + unkeyed records */
|
||||
vesselRecords: [],
|
||||
/** @type {Map<string, Object>} MMSI -> vessel record (identity across refreshes) */
|
||||
vesselMap: new Map(),
|
||||
/** @type {Array<Object>} Records with no MMSI — rebuilt fresh each refresh */
|
||||
unkeyedRecords: [],
|
||||
clickHandler: null,
|
||||
/** Exact EventTarget currently holding the Escape listener. */
|
||||
keyTarget: null,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user