Format extracted vessel components

This commit is contained in:
Sameh Khamis committed 2026-09-13 10:54:35 -07:00
1 parent 2f4839e8f2
commit a0b2d9f6aa
17 files changed
+2693 -2385

No files matched your search

+71 -218
View File
@@ -10,47 +10,27 @@
"src/data/localGeojsonCore.js", "src/data/localGeojsonCore.js",
"src/data/localGeojsonLod.js" "src/data/localGeojsonLod.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"application": { "application": {
"exports": [ "exports": ["./application"],
"./application" "modules": ["src/app/application.js"],
],
"modules": [
"src/app/application.js"
],
"external": [] "external": []
}, },
"viewer": { "viewer": {
"exports": [ "exports": ["./application/viewer"],
"./application/viewer" "modules": ["src/app/viewer.js"],
], "external": ["cesium"]
"modules": [
"src/app/viewer.js"
],
"external": [
"cesium"
]
}, },
"vite-build": { "vite-build": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./build/vite"],
"./build/vite" "modules": ["build/vite.js"],
], "external": ["vite-plugin-cesium"]
"modules": [
"build/vite.js"
],
"external": [
"vite-plugin-cesium"
]
}, },
"live-providers": { "live-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/live"],
"./server/providers/live"
],
"modules": [ "modules": [
"server/providers/live.js", "server/providers/live.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -65,24 +45,16 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js" "src/data/aisWatchdog.js"
], ],
"external": [ "external": ["ws"]
"ws"
]
}, },
"adsb-lol-source": { "adsb-lol-source": {
"exports": [ "exports": ["./sources/adsb-lol"],
"./sources/adsb-lol" "modules": ["src/data/adsbLolFallback.js"],
],
"modules": [
"src/data/adsbLolFallback.js"
],
"external": [] "external": []
}, },
"place-providers": { "place-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/places"],
"./server/providers/places"
],
"modules": [ "modules": [
"server/providers/places.js", "server/providers/places.js",
"server/providers/places/google.js", "server/providers/places/google.js",
@@ -97,19 +69,13 @@
"external": [] "external": []
}, },
"place-payloads": { "place-payloads": {
"exports": [ "exports": ["./sources/places"],
"./sources/places" "modules": ["src/data/placeProviderPayloads.js"],
],
"modules": [
"src/data/placeProviderPayloads.js"
],
"external": [] "external": []
}, },
"space-providers": { "space-providers": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/space"],
"./server/providers/space"
],
"modules": [ "modules": [
"server/providers/space.js", "server/providers/space.js",
"server/providers/space/celestrak.js", "server/providers/space/celestrak.js",
@@ -120,19 +86,13 @@
"external": [] "external": []
}, },
"space-requests": { "space-requests": {
"exports": [ "exports": ["./sources/space"],
"./sources/space" "modules": ["src/data/spaceProviderRequests.js"],
],
"modules": [
"src/data/spaceProviderRequests.js"
],
"external": [] "external": []
}, },
"terrain-provider": { "terrain-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/terrain"],
"./server/providers/terrain"
],
"modules": [ "modules": [
"server/providers/terrain.js", "server/providers/terrain.js",
"src/data/terrainHeightsProxy.js" "src/data/terrainHeightsProxy.js"
@@ -140,50 +100,30 @@
"external": [] "external": []
}, },
"terrain-source": { "terrain-source": {
"exports": [ "exports": ["./sources/terrain"],
"./sources/terrain" "modules": ["src/data/terrainHeightsProxy.js"],
],
"modules": [
"src/data/terrainHeightsProxy.js"
],
"external": [] "external": []
}, },
"traffic-provider": { "traffic-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/traffic"],
"./server/providers/traffic" "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
],
"modules": [
"server/providers/traffic.js",
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"traffic-source": { "traffic-source": {
"exports": [ "exports": ["./sources/traffic"],
"./sources/traffic" "modules": ["src/data/tomtomTiles.js"],
],
"modules": [
"src/data/tomtomTiles.js"
],
"external": [] "external": []
}, },
"firms-provider": { "firms-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/firms"],
"./server/providers/firms" "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
],
"modules": [
"server/providers/firms.js",
"src/data/firmsCsv.js"
],
"external": [] "external": []
}, },
"gbfs-provider": { "gbfs-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/gbfs"],
"./server/providers/gbfs"
],
"modules": [ "modules": [
"server/providers/gbfs.js", "server/providers/gbfs.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -192,19 +132,13 @@
"external": [] "external": []
}, },
"gbfs-source": { "gbfs-source": {
"exports": [ "exports": ["./sources/gbfs"],
"./sources/gbfs" "modules": ["src/data/gbfsSource.js"],
],
"modules": [
"src/data/gbfsSource.js"
],
"external": [] "external": []
}, },
"cctv-provider": { "cctv-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/cctv"],
"./server/providers/cctv"
],
"modules": [ "modules": [
"server/providers/cctv.js", "server/providers/cctv.js",
"server/providers/cctv/catalog.js", "server/providers/cctv/catalog.js",
@@ -221,9 +155,7 @@
}, },
"radio-provider": { "radio-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/radio"],
"./server/providers/radio"
],
"modules": [ "modules": [
"server/providers/radio.js", "server/providers/radio.js",
"server/providers/radio/catalog.js", "server/providers/radio/catalog.js",
@@ -237,9 +169,7 @@
}, },
"overpass-provider": { "overpass-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/overpass"],
"./server/providers/overpass"
],
"modules": [ "modules": [
"server/providers/common/geo.js", "server/providers/common/geo.js",
"server/providers/common/http.js", "server/providers/common/http.js",
@@ -258,9 +188,7 @@
}, },
"military-installations-provider": { "military-installations-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/military-installations"],
"./server/providers/military-installations"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -277,9 +205,7 @@
}, },
"regional-provider": { "regional-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/regional"],
"./server/providers/regional"
],
"modules": [ "modules": [
"server/providers/common/http.js", "server/providers/common/http.js",
"server/providers/common/query.js", "server/providers/common/query.js",
@@ -298,9 +224,7 @@
}, },
"openai-provider": { "openai-provider": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/providers/openai"],
"./server/providers/openai"
],
"modules": [ "modules": [
"server/providers/common/rate-limit.js", "server/providers/common/rate-limit.js",
"server/providers/common/request.js", "server/providers/common/request.js",
@@ -320,9 +244,7 @@
}, },
"standalone-key-setup": { "standalone-key-setup": {
"runtime": "node", "runtime": "node",
"exports": [ "exports": ["./server/standalone/key-setup"],
"./server/standalone/key-setup"
],
"modules": [ "modules": [
"scripts/pinokio-environment.mjs", "scripts/pinokio-environment.mjs",
"server/providers/common/source-root.js", "server/providers/common/source-root.js",
@@ -333,9 +255,7 @@
"external": [] "external": []
}, },
"place-search": { "place-search": {
"exports": [ "exports": ["./search"],
"./search"
],
"modules": [ "modules": [
"src/search/index.js", "src/search/index.js",
"src/search/placeSearch.js", "src/search/placeSearch.js",
@@ -345,27 +265,17 @@
"external": [] "external": []
}, },
"panel-disclosure": { "panel-disclosure": {
"exports": [ "exports": ["./ui/panels"],
"./ui/panels" "modules": ["src/ui/panelDisclosure.js"],
],
"modules": [
"src/ui/panelDisclosure.js"
],
"external": [] "external": []
}, },
"surface-keyboard": { "surface-keyboard": {
"exports": [ "exports": ["./ui/surfaces"],
"./ui/surfaces" "modules": ["src/ui/surfaceKeyboard.js"],
],
"modules": [
"src/ui/surfaceKeyboard.js"
],
"external": [] "external": []
}, },
"panel-rail-layout": { "panel-rail-layout": {
"exports": [ "exports": ["./ui/layout"],
"./ui/layout"
],
"modules": [ "modules": [
"src/ui/panelRails.js", "src/ui/panelRails.js",
"src/ui/leftPanelRail.js", "src/ui/leftPanelRail.js",
@@ -377,9 +287,7 @@
"external": [] "external": []
}, },
"visual-input": { "visual-input": {
"exports": [ "exports": ["./ui/input"],
"./ui/input"
],
"modules": [ "modules": [
"src/ui/visualInput.js", "src/ui/visualInput.js",
"src/ui/applicationShortcuts.js", "src/ui/applicationShortcuts.js",
@@ -388,19 +296,12 @@
"external": [] "external": []
}, },
"display-controls": { "display-controls": {
"exports": [ "exports": ["./ui/display"],
"./ui/display" "modules": ["src/ui/displayControls.js"],
],
"modules": [
"src/ui/displayControls.js"
],
"external": [] "external": []
}, },
"visual-effects": { "visual-effects": {
"exports": [ "exports": ["./ui/effects", "./ui/effects/bloom"],
"./ui/effects",
"./ui/effects/bloom"
],
"modules": [ "modules": [
"src/ui/effects.js", "src/ui/effects.js",
"src/ui/visualEffects.js", "src/ui/visualEffects.js",
@@ -413,14 +314,10 @@
"src/styles/noir.js", "src/styles/noir.js",
"src/styles/snow.js" "src/styles/snow.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"map-source-controls": { "map-source-controls": {
"exports": [ "exports": ["./ui/maps"],
"./ui/maps"
],
"modules": [ "modules": [
"src/ui/mapSource.js", "src/ui/mapSource.js",
"src/ui/mapSourceControls.js", "src/ui/mapSourceControls.js",
@@ -430,10 +327,7 @@
"external": [] "external": []
}, },
"layer-panel": { "layer-panel": {
"exports": [ "exports": ["./ui/layers", "./ui/layers/feedback"],
"./ui/layers",
"./ui/layers/feedback"
],
"modules": [ "modules": [
"src/ui/layers.js", "src/ui/layers.js",
"src/ui/layerPanel.js", "src/ui/layerPanel.js",
@@ -444,9 +338,7 @@
"external": [] "external": []
}, },
"location-controls": { "location-controls": {
"exports": [ "exports": ["./ui/location"],
"./ui/location"
],
"modules": [ "modules": [
"src/ui/location.js", "src/ui/location.js",
"src/ui/locationControls.js", "src/ui/locationControls.js",
@@ -457,9 +349,7 @@
"external": [] "external": []
}, },
"radio-controls": { "radio-controls": {
"exports": [ "exports": ["./ui/radio"],
"./ui/radio"
],
"modules": [ "modules": [
"src/ui/radio.js", "src/ui/radio.js",
"src/ui/radioControls.js", "src/ui/radioControls.js",
@@ -470,9 +360,7 @@
"external": [] "external": []
}, },
"cctv-controls": { "cctv-controls": {
"exports": [ "exports": ["./ui/cctv"],
"./ui/cctv"
],
"modules": [ "modules": [
"src/ui/cctv.js", "src/ui/cctv.js",
"src/ui/cctvBindings.js", "src/ui/cctvBindings.js",
@@ -484,10 +372,7 @@
"external": [] "external": []
}, },
"context-controls": { "context-controls": {
"exports": [ "exports": ["./ui/context", "./ui/context/policy"],
"./ui/context",
"./ui/context/policy"
],
"modules": [ "modules": [
"src/ui/context.js", "src/ui/context.js",
"src/ui/contextActions.js", "src/ui/contextActions.js",
@@ -529,14 +414,10 @@
"src/cockpitVisionPolicy.js", "src/cockpitVisionPolicy.js",
"src/ui/panelRailGeometry.js" "src/ui/panelRailGeometry.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"scene-controls": { "scene-controls": {
"exports": [ "exports": ["./ui/scenes"],
"./ui/scenes"
],
"modules": [ "modules": [
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/sceneControls.js", "src/ui/sceneControls.js",
@@ -545,9 +426,7 @@
"external": [] "external": []
}, },
"ui-shell": { "ui-shell": {
"exports": [ "exports": ["./ui/shell"],
"./ui/shell"
],
"modules": [ "modules": [
"src/bloom.js", "src/bloom.js",
"src/cctvFocusPolicy.js", "src/cctvFocusPolicy.js",
@@ -643,14 +522,10 @@
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/app/stateChannel.js" "src/app/stateChannel.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"ui-styles": { "ui-styles": {
"exports": [ "exports": ["./ui/styles"],
"./ui/styles"
],
"modules": [ "modules": [
"src/ui/styles.js", "src/ui/styles.js",
"style.css", "style.css",
@@ -677,9 +552,7 @@
"external": [] "external": []
}, },
"scenes": { "scenes": {
"exports": [ "exports": ["./scenes"],
"./scenes"
],
"modules": [ "modules": [
"src/scenes/director.js", "src/scenes/director.js",
"src/scenes/recipes.js", "src/scenes/recipes.js",
@@ -691,14 +564,10 @@
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/contextModePolicy.js" "src/contextModePolicy.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"live-observations": { "live-observations": {
"exports": [ "exports": ["./sources/live"],
"./sources/live"
],
"modules": [ "modules": [
"src/sources/live/aircraft.js", "src/sources/live/aircraft.js",
"src/sources/live/contract.js", "src/sources/live/contract.js",
@@ -709,9 +578,7 @@
"external": [] "external": []
}, },
"civil-flights": { "civil-flights": {
"exports": [ "exports": ["./layers/flights"],
"./layers/flights"
],
"modules": [ "modules": [
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftIcons.js", "src/data/aircraftIcons.js",
@@ -742,14 +609,10 @@
"src/layers/flights/testing.js", "src/layers/flights/testing.js",
"src/layers/flights/tracking.js" "src/layers/flights/tracking.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"military-flights": { "military-flights": {
"exports": [ "exports": ["./layers/military"],
"./layers/military"
],
"modules": [ "modules": [
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftIcons.js", "src/data/aircraftIcons.js",
@@ -777,14 +640,10 @@
"src/layers/military/testing.js", "src/layers/military/testing.js",
"src/layers/military/tracking.js" "src/layers/military/tracking.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"aircraft-mechanics": { "aircraft-mechanics": {
"exports": [ "exports": ["./aircraft"],
"./aircraft"
],
"modules": [ "modules": [
"src/data/aircraftClass.js", "src/data/aircraftClass.js",
"src/data/aircraftIcons.js", "src/data/aircraftIcons.js",
@@ -804,14 +663,10 @@
"src/layers/aircraft/classification.js", "src/layers/aircraft/classification.js",
"src/layers/aircraft/index.js" "src/layers/aircraft/index.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
}, },
"vessel-layer": { "vessel-layer": {
"exports": [ "exports": ["./layers/vessels"],
"./layers/vessels"
],
"modules": [ "modules": [
"src/layers/vessels/cards.js", "src/layers/vessels/cards.js",
"src/layers/vessels/evidence.js", "src/layers/vessels/evidence.js",
@@ -829,8 +684,6 @@
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/vesselLabels.js" "src/data/vesselLabels.js"
], ],
"external": [ "external": ["cesium"]
"cesium"
]
} }
} }
+49 -15
View File
@@ -11,26 +11,60 @@ import * as focus from './focusDeemphasis.js';
import * as worldFocus from '../worldFocus.js'; import * as worldFocus from '../worldFocus.js';
import * as render from '../renderGovernor.js'; import * as render from '../renderGovernor.js';
const aisLiveVesselsLayer = createVesselLayer({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' }), options: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS }, services: { context, trails, labels, picking, overlay, geoid, sprites, focus, worldFocus, render } }); const aisLiveVesselsLayer = createVesselLayer({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS,
},
services: {
context,
trails,
labels,
picking,
overlay,
geoid,
sprites,
focus,
worldFocus,
render,
},
});
export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js'; export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js';
export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError; export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError;
export const classifyAisFeedSnapshot = aisLiveVesselsLayer.classifyAisFeedSnapshot; export const classifyAisFeedSnapshot =
aisLiveVesselsLayer.classifyAisFeedSnapshot;
export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord; export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord;
export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM; export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM;
export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection; export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection;
export const applyVesselFocusDeemphasis = aisLiveVesselsLayer.applyVesselFocusDeemphasis; export const applyVesselFocusDeemphasis =
aisLiveVesselsLayer.applyVesselFocusDeemphasis;
export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard; export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard;
export const buildSelectedVesselCard = aisLiveVesselsLayer.buildSelectedVesselCard; export const buildSelectedVesselCard =
aisLiveVesselsLayer.buildSelectedVesselCard;
export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated; export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated;
export const _bindVesselInteractionForTest = aisLiveVesselsLayer.testing._bindVesselInteractionForTest; export const _bindVesselInteractionForTest =
export const _setVesselStateForTest = aisLiveVesselsLayer.testing._setVesselStateForTest; aisLiveVesselsLayer.testing._bindVesselInteractionForTest;
export const _setVesselOverlayHostForTest = aisLiveVesselsLayer.testing._setVesselOverlayHostForTest; export const _setVesselStateForTest =
export const _updateVesselCardsForTest = aisLiveVesselsLayer.testing._updateVesselCardsForTest; aisLiveVesselsLayer.testing._setVesselStateForTest;
export const _reconcileVesselsForTest = aisLiveVesselsLayer.testing._reconcileVesselsForTest; export const _setVesselOverlayHostForTest =
export const _applyAisFeedSnapshotForTest = aisLiveVesselsLayer.testing._applyAisFeedSnapshotForTest; aisLiveVesselsLayer.testing._setVesselOverlayHostForTest;
export const _loadLivePositionsForTest = aisLiveVesselsLayer.testing._loadLivePositionsForTest; export const _updateVesselCardsForTest =
export const _beginAisSessionForTest = aisLiveVesselsLayer.testing._beginAisSessionForTest; aisLiveVesselsLayer.testing._updateVesselCardsForTest;
export const _setAisRuntimeForTest = aisLiveVesselsLayer.testing._setAisRuntimeForTest; export const _reconcileVesselsForTest =
export const _getVesselFeedStateForTest = aisLiveVesselsLayer.testing._getVesselFeedStateForTest; aisLiveVesselsLayer.testing._reconcileVesselsForTest;
export const _getVesselStateForTest = aisLiveVesselsLayer.testing._getVesselStateForTest; export const _applyAisFeedSnapshotForTest =
aisLiveVesselsLayer.testing._applyAisFeedSnapshotForTest;
export const _loadLivePositionsForTest =
aisLiveVesselsLayer.testing._loadLivePositionsForTest;
export const _beginAisSessionForTest =
aisLiveVesselsLayer.testing._beginAisSessionForTest;
export const _setAisRuntimeForTest =
aisLiveVesselsLayer.testing._setAisRuntimeForTest;
export const _getVesselFeedStateForTest =
aisLiveVesselsLayer.testing._getVesselFeedStateForTest;
export const _getVesselStateForTest =
aisLiveVesselsLayer.testing._getVesselStateForTest;
export default aisLiveVesselsLayer; export default aisLiveVesselsLayer;
+164 -154
View File
@@ -1,171 +1,181 @@
import { accentForVesselType, normalizeVesselType } from '../../data/vesselLabels.js'; import {
accentForVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
export function createCards({ vesselState, services, parts: components, layer, options }) { export function createCards({
const { state } = vesselState; vesselState,
services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
// Pinned vessels missing from recent refreshes get a stale marker
function updateSelectedVesselHud(record) { const stale = (record.missedRefreshes || 0) > 0;
const el = document.getElementById('hud-ais-vessel'); el.classList.add('active');
if (!el) return; el.textContent = [
`AIS: ${trimHudValue(record.name, 32)}`,
// Pinned vessels missing from recent refreshes get a stale marker `${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
const stale = (record.missedRefreshes || 0) > 0; `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
el.classList.add('active'); ].join('\n');
el.textContent = [
`AIS: ${trimHudValue(record.name, 32)}`,
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
].join('\n');
}
function resetSelectedVesselHud() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = 'AIS: --';
}
function trimHudValue(value, maxLength) {
const text = String(value || '--').trim() || '--';
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
/**
* Card model for an ambient (decluttered-in) vessel — name title plus one
* compact type/speed/heading detail line, anchored at the record's current
* rendered position (height-datum caveat: no datum work here). Pure —
* exported for unit tests.
* @param {Object} record - Vessel record.
* @returns {Object} vesselLabels entry.
*/
function buildVesselCard(record) {
const parts = [];
const type = vesselTypeShort(record);
if (type) parts.push(type);
if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
gapPx: 10,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 26),
details: parts.length ? [parts.join(' · ')] : [],
selected: false,
priority: components.rendering.labelPriority(record, null),
};
}
/**
* Card model for the click-selected vessel — the full-detail card, drawn last
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
* for unit tests.
* @param {Object} record - Selected vessel record.
* @returns {Object} vesselLabels entry.
*/
function buildSelectedVesselCard(record) {
const direction = record.heading ?? record.course;
const details = [[
vesselTypeShort(record) || 'VESSEL',
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · ')];
const destination = String(record.destination || '').trim();
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0;
details.push(`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
gapPx: 12,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 32),
details,
selected: true,
priority: 100000,
};
}
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
function vesselOverlayEntryId(record) {
const mmsi = String(record?.mmsi || '').trim();
if (mmsi) return `vessel:${mmsi}`;
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
return `vessel:unkeyed:${name}:${lat}:${lon}`;
}
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
function vesselTypeShort(record) {
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
}
/**
* True when `screen` is at least `minSepPx` away from every accepted screen
* position (greedy card-declutter accept test, mirroring the FIRMS pass).
* Exported for unit tests.
* @param {Array<{x: number, y: number}>} accepted - Accepted card positions.
* @param {{x: number, y: number}} screen - Candidate window coordinates.
* @param {number} minSepPx - Minimum separation in pixels.
* @returns {boolean}
*/
function cardScreenSeparated(accepted, screen, minSepPx) {
const minSq = minSepPx * minSepPx;
for (let i = 0; i < accepted.length; i += 1) {
const dx = screen.x - accepted[i].x;
const dy = screen.y - accepted[i].y;
if (dx * dx + dy * dy < minSq) return false;
} }
return true;
}
function resetSelectedVesselHud() {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = 'AIS: --';
}
function trimHudValue(value, maxLength) {
const text = String(value || '--').trim() || '--';
return text.length > maxLength
? `${text.slice(0, maxLength - 3)}...`
: text;
}
function displayVesselName(record) { /**
const name = String(record.name || '').trim(); * Card model for an ambient (decluttered-in) vessel — name title plus one
if (name && name !== 'VESSEL' && name !== record.mmsi) return name; * compact type/speed/heading detail line, anchored at the record's current
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL'; * rendered position (height-datum caveat: no datum work here). Pure —
} * exported for unit tests.
* @param {Object} record - Vessel record.
* @returns {Object} vesselLabels entry.
*/
function buildVesselCard(record) {
const parts = [];
const type = vesselTypeShort(record);
if (type) parts.push(type);
if (record.speed !== null && record.speed !== undefined)
parts.push(formatSpeed(record.speed));
const direction = record.heading ?? record.course;
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
gapPx: 10,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 26),
details: parts.length ? [parts.join(' · ')] : [],
selected: false,
priority: components.rendering.labelPriority(record, null),
};
}
/**
* Card model for the click-selected vessel — the full-detail card, drawn last
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
* for unit tests.
* @param {Object} record - Selected vessel record.
* @returns {Object} vesselLabels entry.
*/
function formatSpeed(speed) { function buildSelectedVesselCard(record) {
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`; const direction = record.heading ?? record.course;
} const details = [
[
vesselTypeShort(record) || 'VESSEL',
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '),
];
const destination = String(record.destination || '').trim();
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0;
details.push(
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
);
return {
id: vesselOverlayEntryId(record),
actionable: Boolean(record?.mmsi),
position: record.billboard?.position || record.position,
gapPx: 12,
accent: accentForVesselType(record.type),
title: trimHudValue(displayVesselName(record), 32),
details,
selected: true,
priority: 100000,
};
}
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
function vesselOverlayEntryId(record) {
const mmsi = String(record?.mmsi || '').trim();
if (mmsi) return `vessel:${mmsi}`;
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
return `vessel:unkeyed:${name}:${lat}:${lon}`;
}
function formatHeading(heading) { /** Uppercased, card-width-bounded AIS type (empty string when unknown). */
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
}
function vesselTypeShort(record) {
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
}
/**
* True when `screen` is at least `minSepPx` away from every accepted screen
* position (greedy card-declutter accept test, mirroring the FIRMS pass).
* Exported for unit tests.
* @param {Array<{x: number, y: number}>} accepted - Accepted card positions.
* @param {{x: number, y: number}} screen - Candidate window coordinates.
* @param {number} minSepPx - Minimum separation in pixels.
* @returns {boolean}
*/
function formatPositionTime(record) { function cardScreenSeparated(accepted, screen, minSepPx) {
if (!record.lastPositionUtc) return 'POS: LIVE'; const minSq = minSepPx * minSepPx;
const date = new Date(record.lastPositionUtc); for (let i = 0; i < accepted.length; i += 1) {
if (Number.isNaN(date.getTime())) return 'POS: LIVE'; const dx = screen.x - accepted[i].x;
return `POS: ${date.toISOString().slice(11, 19)}Z`; const dy = screen.y - accepted[i].y;
} if (dx * dx + dy * dy < minSq) return false;
return { updateSelectedVesselHud, resetSelectedVesselHud, trimHudValue, buildVesselCard, buildSelectedVesselCard, vesselOverlayEntryId, vesselTypeShort, cardScreenSeparated, displayVesselName, formatSpeed, formatHeading, formatPositionTime }; }
return true;
}
function displayVesselName(record) {
const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
}
function formatSpeed(speed) {
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
}
function formatHeading(heading) {
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
}
function formatPositionTime(record) {
if (!record.lastPositionUtc) return 'POS: LIVE';
const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
return `POS: ${date.toISOString().slice(11, 19)}Z`;
}
return {
updateSelectedVesselHud,
resetSelectedVesselHud,
trimHudValue,
buildVesselCard,
buildSelectedVesselCard,
vesselOverlayEntryId,
vesselTypeShort,
cardScreenSeparated,
displayVesselName,
formatSpeed,
formatHeading,
formatPositionTime,
};
} }
+49 -39
View File
@@ -1,48 +1,58 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { FOCUS_EVIDENCE_DEV } from './policy.js'; import { FOCUS_EVIDENCE_DEV } from './policy.js';
export function createEvidence({ vesselState, services, parts: components, layer, options }) { export function createEvidence({
const { state } = vesselState; vesselState,
services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
/** Replace live AIS rows through the production reconciliation path (DEV only). */
/** Replace live AIS rows through the production reconciliation path (DEV only). */ function _setFocusEvidenceVessels(rows = []) {
if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) {
function _setFocusEvidenceVessels(rows = []) { return { ok: false, count: 0 };
if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) { }
return { ok: false, count: 0 }; components.selection.clearVesselInspection();
components.store.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 };
} }
components.selection.clearVesselInspection();
components.store.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 };
}
/** JSON-safe vessel alpha/position snapshot for the evidence report. */
/** JSON-safe vessel alpha/position snapshot for the evidence report. */ function _focusEvidenceVesselSnapshot() {
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
function _focusEvidenceVesselSnapshot() { return state.vesselRecords.map((record) => {
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; const bb = record.billboard;
return state.vesselRecords.map((record) => { const screen = bb?.position
const bb = record.billboard; ? Cesium.SceneTransforms.worldToWindowCoordinates(
const screen = bb?.position state.viewer.scene,
? Cesium.SceneTransforms.worldToWindowCoordinates(state.viewer.scene, bb.position) bb.position,
: null; )
return { : null;
id: record.mmsi, return {
show: bb?.show === true, id: record.mmsi,
alpha: bb?.color?.alpha ?? null, show: bb?.show === true,
x: screen?.x ?? null, alpha: bb?.color?.alpha ?? null,
y: screen?.y ?? null, x: screen?.x ?? null,
}; y: screen?.y ?? null,
}); };
} });
return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot }; }
return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot };
} }
+39 -22
View File
@@ -11,29 +11,46 @@ import { createEvidence } from './evidence.js';
import { createQueries } from './queries.js'; import { createQueries } from './queries.js';
/** Compose one vessel layer with application-owned scene services. */ /** Compose one vessel layer with application-owned scene services. */
export function createVesselLayer({ source, services, options = {} } = {}) { export function createVesselLayer({ source, services, options = {} } = {}) {
const vesselState = createVesselState({ source, services }); const vesselState = createVesselState({ source, services });
const parts = {}; const parts = {};
const layer = {}; const layer = {};
const context = { vesselState, services, parts, layer, options }; const context = { vesselState, services, parts, layer, options };
parts.lifecycle = createLifecycle(context); parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context); parts.ingestion = createIngestion(context);
parts.store = createStore(context); parts.store = createStore(context);
parts.rendering = createRendering(context); parts.rendering = createRendering(context);
parts.selection = createSelection(context); parts.selection = createSelection(context);
parts.tracking = createTracking(context); parts.tracking = createTracking(context);
parts.cards = createCards(context); parts.cards = createCards(context);
parts.testing = createTesting(context); parts.testing = createTesting(context);
parts.evidence = createEvidence(context); parts.evidence = createEvidence(context);
parts.queries = createQueries(context); parts.queries = createQueries(context);
Object.assign(layer, parts.queries.methods, parts.lifecycle.methods, parts.ingestion.methods); Object.assign(
layer.source = source?.label || 'Vessels'; layer,
Object.assign(layer, { applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis, buildVesselCard: parts.cards.buildVesselCard, buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, cardScreenSeparated: parts.cards.cardScreenSeparated }); parts.queries.methods,
Object.defineProperty(layer, 'testing', { value: parts.testing }); parts.lifecycle.methods,
return layer; parts.ingestion.methods,
);
layer.source = source?.label || 'Vessels';
Object.assign(layer, {
applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis,
buildVesselCard: parts.cards.buildVesselCard,
buildSelectedVesselCard: parts.cards.buildSelectedVesselCard,
cardScreenSeparated: parts.cards.cardScreenSeparated,
});
Object.defineProperty(layer, 'testing', { value: parts.testing });
return layer;
} }
export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js'; export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js';
export { export {
VESSEL_OVERLAY_SOURCE_ID, VESSEL_LABEL_GRID_PX, VESSEL_DEFAULT_LABEL_LIMIT, VESSEL_OVERLAY_SOURCE_ID,
VESSEL_OVERLAY_MAX_COHORT, VESSEL_CARD_FADE_DISTANCE_M, normalizeVesselType, VESSEL_LABEL_GRID_PX,
vesselTypeCss, accentForVesselType, vesselOverlayCohortLimit, applyVesselOverlayPolicy, VESSEL_DEFAULT_LABEL_LIMIT,
VESSEL_OVERLAY_MAX_COHORT,
VESSEL_CARD_FADE_DISTANCE_M,
normalizeVesselType,
vesselTypeCss,
accentForVesselType,
vesselOverlayCohortLimit,
applyVesselOverlayPolicy,
} from '../../data/vesselLabels.js'; } from '../../data/vesselLabels.js';
+170 -116
View File
@@ -1,130 +1,184 @@
import { AIS_FIRST_CONNECT_LABEL } from './policy.js'; import { AIS_FIRST_CONNECT_LABEL } from './policy.js';
export function createIngestion({ vesselState, services, parts: components, layer, options }) { export function createIngestion({
const { state } = vesselState; vesselState,
const aisLiveVesselsLayer = layer; services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
const aisLiveVesselsLayer = layer;
async function loadLivePositions(viewer) {
if (!viewer || state.loading) return;
state.loading = true;
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...';
const requestController = new AbortController();
const requestSessionId = state.sessionId;
state.abort = requestController;
try {
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
// can't wedge the poll indefinitely (parity with the track fetch + flights).
const signal =
typeof AbortSignal.any === 'function'
? AbortSignal.any([
requestController.signal,
AbortSignal.timeout(10000),
])
: requestController.signal;
const snapshot = await vesselState._source.getSnapshot(
{ maxRows: components.rendering.renderRowLimit() },
{ signal },
);
if (!ownsAisRequest(requestController, requestSessionId)) return;
aisLiveVesselsLayer.source = snapshot.source;
// Map observations into the existing display store; source fields stop here.
applyAisFeedSnapshot(viewer, {
rows: snapshot.records.map(vesselDisplayRow),
observedAtMs: snapshot.observedAtMs,
freshness: snapshot.freshness,
complete: snapshot.complete,
rawRowCount: snapshot.rawRowCount,
reason: snapshot.reason,
status: snapshot.transportStatus,
lastMessageAt: snapshot.lastMessageAt,
nextAttemptAt: snapshot.nextAttemptAt,
refreshing: snapshot.stale,
newestPositionAt:
snapshot.observedAtMs == null
? null
: new Date(snapshot.observedAtMs).toISOString(),
silentForMs: snapshot.silentForMs,
reconnectAttempt: snapshot.reconnectAttempt,
});
} catch (error) {
if (
ownsAisRequest(requestController, requestSessionId) &&
error?.name !== 'AbortError'
) {
components.lifecycle.markAisUnavailable(
error?.message || 'AIS live load failed',
);
console.warn('[Data:ais-live-vessels]', state.error, error);
}
} finally {
if (
state.abort === requestController &&
state.sessionId === requestSessionId
) {
state.loading = false;
state.loadingLabel =
state.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
state.abort = null;
}
}
}
async function loadLivePositions(viewer) { /** True while a request still owns this enabled layer lifecycle. */
if (!viewer || state.loading) return;
state.loading = true;
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...';
const requestController = new AbortController();
const requestSessionId = state.sessionId;
state.abort = requestController;
try { function ownsAisRequest(controller, sessionId) {
// Combine the layer's teardown-abort with a hard timeout so a hung upstream return (
// can't wedge the poll indefinitely (parity with the track fetch + flights). state.enabled &&
const signal = typeof AbortSignal.any === 'function' state.sessionId === sessionId &&
? AbortSignal.any([requestController.signal, AbortSignal.timeout(10000)]) state.abort === controller &&
: requestController.signal; !controller.signal.aborted
const snapshot = await vesselState._source.getSnapshot({ maxRows: components.rendering.renderRowLimit() }, { signal }); );
if (!ownsAisRequest(requestController, requestSessionId)) return; }
aisLiveVesselsLayer.source = snapshot.source;
// Map observations into the existing display store; source fields stop here. /** Apply a classified snapshot while preserving warm state on zero accepted rows. */
applyAisFeedSnapshot(viewer, {
rows: snapshot.records.map(vesselDisplayRow), observedAtMs: snapshot.observedAtMs, freshness: snapshot.freshness, complete: snapshot.complete, rawRowCount: snapshot.rawRowCount, reason: snapshot.reason, status: snapshot.transportStatus, function applyAisFeedSnapshot(viewer, payload) {
lastMessageAt: snapshot.lastMessageAt, nextAttemptAt: snapshot.nextAttemptAt, const snapshot = components.queries.classifyAisFeedSnapshot(payload);
refreshing: snapshot.stale, newestPositionAt: snapshot.observedAtMs == null ? null : new Date(snapshot.observedAtMs).toISOString(), state.loaded = true;
silentForMs: snapshot.silentForMs, reconnectAttempt: snapshot.reconnectAttempt, state.loadingLabel = '';
state.transportStatus = snapshot.transportStatus;
state.nextAttemptAt = Number(payload?.nextAttemptAt) || null;
state.lastMessageAt = snapshot.lastMessageAt;
state.rawRowCount = snapshot.rawRowCount;
state.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);
return { reconciled: false, ...snapshot };
}
if (
state.firstConnectPhase === 'loading' &&
components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus)
) {
state.error = null;
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
return { reconciled: false, ...snapshot };
}
if (state.firstConnectPhase === 'loading') {
components.lifecycle.markAisUnavailable(snapshot.error);
return { reconciled: false, ...snapshot };
}
state.error = snapshot.error;
return { reconciled: false, ...snapshot };
}
components.lifecycle.settleFirstConnectPhase('ready');
components.store.reconcileVessels(viewer, snapshot.acceptedRows, {
complete: payload?.complete !== false,
}); });
} catch (error) {
if (ownsAisRequest(requestController, requestSessionId) && error?.name !== 'AbortError') {
components.lifecycle.markAisUnavailable(error?.message || 'AIS live load failed');
console.warn('[Data:ais-live-vessels]', state.error, error);
}
} finally {
if (state.abort === requestController && state.sessionId === requestSessionId) {
state.loading = false;
state.loadingLabel = state.firstConnectPhase === 'loading'
? AIS_FIRST_CONNECT_LABEL
: '';
state.abort = null;
}
}
}
/** True while a request still owns this enabled layer lifecycle. */
function ownsAisRequest(controller, sessionId) {
return state.enabled
&& state.sessionId === sessionId
&& state.abort === controller
&& !controller.signal.aborted;
}
/** 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;
if (snapshot.acceptedRowCount === 0) {
state.count = state.vesselRecords.length; state.count = state.vesselRecords.length;
state.stale = state.count > 0 || Boolean(payload?.refreshing); state.stale =
if (components.lifecycle.isDefinitiveTransportFailure(snapshot.transportStatus)) { Boolean(payload?.refreshing) ||
components.lifecycle.markAisUnavailable(snapshot.error); payload?.complete === false ||
return { reconciled: false, ...snapshot }; payload?.freshness === 'unknown';
} state.newestPositionAt = payload?.newestPositionAt || null;
if ( // Not unconditionally null: a degraded feed keeps its reason even though the
state.firstConnectPhase === 'loading' // cached vessels are still drawable, so the chip cannot go quiet on an
&& components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus) // outage the user is still looking at.
) { state.error = snapshot.error || payload?.reason || null;
state.error = null; state.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
state.loadingLabel = AIS_FIRST_CONNECT_LABEL; ? payload.observedAtMs
return { reconciled: false, ...snapshot }; : vesselState._aisRuntime.now();
} return { reconciled: true, ...snapshot };
if (state.firstConnectPhase === 'loading') {
components.lifecycle.markAisUnavailable(snapshot.error);
return { reconciled: false, ...snapshot };
}
state.error = snapshot.error;
return { reconciled: false, ...snapshot };
} }
components.lifecycle.settleFirstConnectPhase('ready'); function vesselDisplayRow(record) {
components.store.reconcileVessels(viewer, snapshot.acceptedRows, { complete: payload?.complete !== false }); return {
state.count = state.vesselRecords.length; mmsi: record.id,
state.stale = Boolean(payload?.refreshing) || payload?.complete === false || payload?.freshness === 'unknown'; reference: record.reference,
state.newestPositionAt = payload?.newestPositionAt || null; lat: record.latitude,
// Not unconditionally null: a degraded feed keeps its reason even though the lon: record.longitude,
// cached vessels are still drawable, so the chip cannot go quiet on an name: record.name,
// outage the user is still looking at. imo: record.imo,
state.error = snapshot.error || payload?.reason || null; type: record.type,
state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') ? payload.observedAtMs : vesselState._aisRuntime.now(); destination: record.destination,
return { reconciled: true, ...snapshot }; speed: record.speedMps == null ? null : record.speedMps / 0.514444,
} course: record.courseDeg,
heading: record.headingDeg,
last_position_epoch:
record.observedAtMs == null ? null : record.observedAtMs / 1000,
last_position_UTC:
record.observedAtMs == null
? ''
: new Date(record.observedAtMs).toISOString(),
};
}
const methods = {
update(viewer) {
if (!state.enabled) return Promise.resolve();
return loadLivePositions(viewer || state.viewer);
},
};
function vesselDisplayRow(record) {
return { return {
mmsi: record.id, reference: record.reference, lat: record.latitude, lon: record.longitude, loadLivePositions,
name: record.name, imo: record.imo, type: record.type, destination: record.destination, ownsAisRequest,
speed: record.speedMps == null ? null : record.speedMps / 0.514444, applyAisFeedSnapshot,
course: record.courseDeg, heading: record.headingDeg, vesselDisplayRow,
last_position_epoch: record.observedAtMs == null ? null : record.observedAtMs / 1000, methods,
last_position_UTC: record.observedAtMs == null ? '' : new Date(record.observedAtMs).toISOString(),
}; };
} }
const methods = {
update(viewer) {
if (!state.enabled) return Promise.resolve();
return loadLivePositions(viewer || state.viewer);
}
};
return { loadLivePositions, ownsAisRequest, applyAisFeedSnapshot, vesselDisplayRow, methods };
}
+246 -234
View File
@@ -1,241 +1,253 @@
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_FIRST_CONNECT_LABEL, AIS_HEALTHY_STATUSES } from './policy.js'; import {
AIS_FIRST_CONNECT_GRACE_MS,
AIS_FIRST_CONNECT_LABEL,
AIS_HEALTHY_STATUSES,
} from './policy.js';
export function createLifecycle({ vesselState, services, parts: components, layer, options }) { export function createLifecycle({
const { state } = vesselState; vesselState,
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; services,
const { holdContinuousRender, releaseContinuousRender } = services.render; parts: components,
const { ensureGeoidReady } = services.geoid; layer,
const { registerPickOwner, unregisterPickOwner } = services.picking; options,
}) {
const { state } = vesselState;
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
const { holdContinuousRender, releaseContinuousRender } = services.render;
const { ensureGeoidReady } = services.geoid;
const { registerPickOwner, unregisterPickOwner } = services.picking;
function clearFirstConnectTimer() {
if (state.firstConnectTimer === null) return;
function clearFirstConnectTimer() { vesselState._aisRuntime.clearTimeout(state.firstConnectTimer);
if (state.firstConnectTimer === null) return;
vesselState._aisRuntime.clearTimeout(state.firstConnectTimer);
state.firstConnectTimer = null;
}
function invalidateAisSession() {
clearFirstConnectTimer();
state.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null;
state.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;
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
function scheduleFirstConnectExpiry(sessionId, delayMs) {
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
if (
!state.enabled
|| state.sessionId !== sessionId
|| state.firstConnectPhase !== 'loading'
) return;
const remainingMs = state.firstConnectDeadline - vesselState._aisRuntime.now();
if (remainingMs > 0) {
scheduleFirstConnectExpiry(sessionId, remainingMs);
return;
}
state.firstConnectTimer = null; state.firstConnectTimer = null;
state.firstConnectPhase = 'unavailable';
state.loadingLabel = '';
state.error = state.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
state.stale = state.count > 0;
}, delayMs);
}
function settleFirstConnectPhase(phase) {
clearFirstConnectTimer();
state.firstConnectPhase = phase;
state.loadingLabel = '';
}
function isGraceEligibleTransport(status) {
return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting';
}
function isDefinitiveTransportFailure(status) {
return Boolean(status) && !isGraceEligibleTransport(status);
}
function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable');
state.error = reason || 'AIS live load failed';
state.stale = state.count > 0;
}
function resetState() {
state.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.billboardCollection = null;
state.vesselRecords = [];
state.vesselMap = new Map();
state.unkeyedRecords = [];
state.clickHandler = null;
state.keyTarget = null;
state.keydownHandler = null;
state.trackedEntityRemover = null;
state.interactionHandlerFactory = null;
state.interactionKeyTarget = null;
state.preRenderRemover = null;
state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
state.activeLabelCount = 0;
state.selectedRecord = null;
state.trail = null;
state.trailPositions = [];
state.trailMmsi = null;
state.trailBackfillToken = 0;
}
const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (state.viewer) throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
vesselState._source = source;
this.source = source.label || this.source;
},
init(viewer) {
if (typeof vesselState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
state.viewer = viewer;
components.rendering.ensureCollections(viewer);
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false);
components.selection.installInteraction(viewer);
components.rendering.installRuntime(viewer);
restoreSpriteOrder(viewer);
},
enable(viewer) {
const wasEnabled = state.enabled;
state.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer);
components.rendering.setVisible(true);
// Height-datum fix: warm the geoid grid once per layer-enable, never
// blocking a poll. The first refresh may land pre-resolve (N = 0), and
// the next is up to 60 s out — so re-floor in place on resolve. A load
// 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;
ensureGeoidReady()
.then(() => {
if (!state.enabled || state.sessionId !== sessionId) return;
vesselState._geoidReady = true;
components.tracking.refloorVesselRecords();
})
.catch(() => { /* grid failed to load — anchors stay at ellipsoid 0 */ });
}
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
// the registry resolver reduces it to the record's mmsi (a string key).
registerPickOwner('ais-live-vessels', (pickedId) => state.vesselMap.has(pickedId));
restoreSpriteOrderOnEnable('ais', activeViewer);
return components.ingestion.loadLivePositions(activeViewer);
},
disable() {
state.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
components.selection.removeVesselInteraction();
if (state.abort) {
state.abort.abort();
state.abort = null;
}
state.loading = false;
state.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();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
if (state.billboardCollection && activeViewer?.scene?.primitives) {
activeViewer.scene.primitives.remove(state.billboardCollection);
}
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false);
components.selection.removeVesselInteraction();
if (state.preRenderRemover) {
state.preRenderRemover();
}
resetState();
} }
};
return { clearFirstConnectTimer, invalidateAisSession, beginAisSession, scheduleFirstConnectExpiry, settleFirstConnectPhase, isGraceEligibleTransport, isDefinitiveTransportFailure, markAisUnavailable, resetState, methods }; function invalidateAisSession() {
clearFirstConnectTimer();
state.sessionId = ++vesselState._aisSessionSequence;
state.firstConnectPhase = 'idle';
state.firstConnectStartedAt = null;
state.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;
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
function scheduleFirstConnectExpiry(sessionId, delayMs) {
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
if (
!state.enabled ||
state.sessionId !== sessionId ||
state.firstConnectPhase !== 'loading'
)
return;
const remainingMs =
state.firstConnectDeadline - vesselState._aisRuntime.now();
if (remainingMs > 0) {
scheduleFirstConnectExpiry(sessionId, remainingMs);
return;
}
state.firstConnectTimer = null;
state.firstConnectPhase = 'unavailable';
state.loadingLabel = '';
state.error = state.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
state.stale = state.count > 0;
}, delayMs);
}
function settleFirstConnectPhase(phase) {
clearFirstConnectTimer();
state.firstConnectPhase = phase;
state.loadingLabel = '';
}
function isGraceEligibleTransport(status) {
return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting';
}
function isDefinitiveTransportFailure(status) {
return Boolean(status) && !isGraceEligibleTransport(status);
}
function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable');
state.error = reason || 'AIS live load failed';
state.stale = state.count > 0;
}
function resetState() {
state.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.billboardCollection = null;
state.vesselRecords = [];
state.vesselMap = new Map();
state.unkeyedRecords = [];
state.clickHandler = null;
state.keyTarget = null;
state.keydownHandler = null;
state.trackedEntityRemover = null;
state.interactionHandlerFactory = null;
state.interactionKeyTarget = null;
state.preRenderRemover = null;
state.lastVisibilityUpdate = 0;
state.lastFocusUpdate = 0;
state.activeFocusCount = 0;
state.activeLabelCount = 0;
state.selectedRecord = null;
state.trail = null;
state.trailPositions = [];
state.trailMmsi = null;
state.trailBackfillToken = 0;
}
const methods = {
/** Configure the source before initialization; an active layer keeps its owner. */
setSource(source) {
if (state.viewer)
throw new Error('Configure the source before layer initialization');
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
vesselState._source = source;
this.source = source.label || this.source;
},
init(viewer) {
if (typeof vesselState._source?.getSnapshot !== 'function')
throw new TypeError('A snapshot source is required');
state.viewer = viewer;
components.rendering.ensureCollections(viewer);
vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID,
false,
);
components.selection.installInteraction(viewer);
components.rendering.installRuntime(viewer);
restoreSpriteOrder(viewer);
},
enable(viewer) {
const wasEnabled = state.enabled;
state.enabled = true;
if (!wasEnabled) beginAisSession();
holdContinuousRender('ais-vessels'); // per-frame animator (perf wave 2)
const activeViewer = viewer || state.viewer;
components.rendering.ensureCollections(activeViewer);
components.selection.installInteraction(activeViewer);
components.rendering.setVisible(true);
// Height-datum fix: warm the geoid grid once per layer-enable, never
// blocking a poll. The first refresh may land pre-resolve (N = 0), and
// the next is up to 60 s out — so re-floor in place on resolve. A load
// 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;
ensureGeoidReady()
.then(() => {
if (!state.enabled || state.sessionId !== sessionId) return;
vesselState._geoidReady = true;
components.tracking.refloorVesselRecords();
})
.catch(() => {
/* grid failed to load — anchors stay at ellipsoid 0 */
});
}
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
// the registry resolver reduces it to the record's mmsi (a string key).
registerPickOwner('ais-live-vessels', (pickedId) =>
state.vesselMap.has(pickedId),
);
restoreSpriteOrderOnEnable('ais', activeViewer);
return components.ingestion.loadLivePositions(activeViewer);
},
disable() {
state.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
unregisterPickOwner('ais-live-vessels');
components.rendering.setVisible(false);
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
components.selection.removeVesselInteraction();
if (state.abort) {
state.abort.abort();
state.abort = null;
}
state.loading = false;
state.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();
unregisterPickOwner('ais-live-vessels');
components.selection.clearVesselInspection();
components.tracking.destroySelectedVesselTrail();
if (state.billboardCollection && activeViewer?.scene?.primitives) {
activeViewer.scene.primitives.remove(state.billboardCollection);
}
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
vesselState._vesselOverlayHost.setVisible(
VESSEL_OVERLAY_SOURCE_ID,
false,
);
components.selection.removeVesselInteraction();
if (state.preRenderRemover) {
state.preRenderRemover();
}
resetState();
},
};
return {
clearFirstConnectTimer,
invalidateAisSession,
beginAisSession,
scheduleFirstConnectExpiry,
settleFirstConnectPhase,
isGraceEligibleTransport,
isDefinitiveTransportFailure,
markAisUnavailable,
resetState,
methods,
};
} }
+137 -28
View File
@@ -6,32 +6,85 @@ import { createVesselState } from './state.js';
const noop = () => {}; const noop = () => {};
function services() { function services() {
return { return {
context: { clearSelectedEntityContextForLayer: noop, registerEntityContext: () => null }, context: {
trails: { createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }) }, clearSelectedEntityContextForLayer: noop,
registerEntityContext: () => null,
},
trails: {
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
},
labels: {}, labels: {},
picking: { unregisterPickOwner: noop }, picking: { unregisterPickOwner: noop },
overlay: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, overlay: {
geoid: {}, sprites: {}, setOverlayEntries: noop,
focus: { focusNowMs: (now) => now, getFocusTarget: () => null, focusPassIsNeeded: () => false, forgetSpriteFocus: noop }, setOverlaySourceVisible: noop,
worldFocus: {}, render: { releaseContinuousRender: noop }, clearOverlaySource: noop,
},
geoid: {},
sprites: {},
focus: {
focusNowMs: (now) => now,
getFocusTarget: () => null,
focusPassIsNeeded: () => false,
forgetSpriteFocus: noop,
},
worldFocus: {},
render: { releaseContinuousRender: noop },
}; };
} }
function setup(source, options = {}) { function setup(source, options = {}) {
const layer = createVesselLayer({ source, services: services(), options }); const layer = createVesselLayer({ source, services: services(), options });
layer.testing._setVesselStateForTest({ viewer: {}, billboardCollection: { add: (options) => ({ ...options }), remove: noop } }); layer.testing._setVesselStateForTest({
viewer: {},
billboardCollection: { add: (options) => ({ ...options }), remove: noop },
});
let now = 1000; let now = 1000;
layer.testing._setAisRuntimeForTest({ now: () => now, setTimeout, clearTimeout }); layer.testing._setAisRuntimeForTest({
return { layer, advance: (ms) => { now += ms; } }; now: () => now,
setTimeout,
clearTimeout,
});
return {
layer,
advance: (ms) => {
now += ms;
},
};
} }
const observation = (id, reference = id) => ({ id, reference, latitude: 51.93, longitude: 4.05, name: id, observedAtMs: 500, speedMps: 4, headingDeg: 180, courseDeg: 170 }); const observation = (id, reference = id) => ({
const snapshot = (records, extra = {}) => ({ records, source: 'Fixture vessels', complete: true, stale: false, freshness: 'current', observedAtMs: 500, transportStatus: 'live', rawRowCount: records.length, ...extra }); id,
reference,
latitude: 51.93,
longitude: 4.05,
name: id,
observedAtMs: 500,
speedMps: 4,
headingDeg: 180,
courseDeg: 170,
});
const snapshot = (records, extra = {}) => ({
records,
source: 'Fixture vessels',
complete: true,
stale: false,
freshness: 'current',
observedAtMs: 500,
transportStatus: 'live',
rawRowCount: records.length,
...extra,
});
test('vessel construction is inert and each instance owns its records, icon cache and clock', () => { test('vessel construction is inert and each instance owns its records, icon cache and clock', () => {
let requested = 0; let requested = 0;
const source = { getSnapshot() { requested++; } }; const source = {
getSnapshot() {
requested++;
},
};
const one = createVesselState({ source, services: services() }); const one = createVesselState({ source, services: services() });
const two = createVesselState({ source, services: services() }); const two = createVesselState({ source, services: services() });
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen']) assert.notEqual(one[key], two[key]); 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.vesselMap, two.state.vesselMap);
assert.notEqual(one.state.trailPositions, two.state.trailPositions); assert.notEqual(one.state.trailPositions, two.state.trailPositions);
const { layer } = setup(source); const { layer } = setup(source);
@@ -50,7 +103,11 @@ test('missing vessel source fails before mutating the viewer', () => {
test('partial observations retain missing contacts only until their receipt deadline', async () => { test('partial observations retain missing contacts only until their receipt deadline', async () => {
let current = snapshot([observation('111'), observation('222')]); let current = snapshot([observation('111'), observation('222')]);
const { layer, advance } = setup({ async getSnapshot() { return current; } }); const { layer, advance } = setup({
async getSnapshot() {
return current;
},
});
await layer.update(); await layer.update();
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 }); current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
await layer.update(); await layer.update();
@@ -66,7 +123,11 @@ test('partial observations retain missing contacts only until their receipt dead
test('a complete feed still removes an absent unselected vessel immediately', async () => { test('a complete feed still removes an absent unselected vessel immediately', async () => {
let current = snapshot([observation('111'), observation('222')]); let current = snapshot([observation('111'), observation('222')]);
const { layer } = setup({ async getSnapshot() { return current; } }); const { layer } = setup({
async getSnapshot() {
return current;
},
});
await layer.update(); await layer.update();
current = snapshot([observation('111')]); current = snapshot([observation('111')]);
await layer.update(); await layer.update();
@@ -74,10 +135,22 @@ test('a complete feed still removes an absent unselected vessel immediately', as
}); });
test('an incomplete feed bounds its retained union to the configured row budget', async () => { test('an incomplete feed bounds its retained union to the configured row budget', async () => {
let current = snapshot(Array.from({ length: 500 }, (_, i) => observation(String(i + 1000)))); let current = snapshot(
const { layer } = setup({ async getSnapshot() { return current; } }, { maxRows: 500 }); Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))),
);
const { layer } = setup(
{
async getSnapshot() {
return current;
},
},
{ maxRows: 500 },
);
await layer.update(); await layer.update();
current = snapshot(Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))), { complete: false }); current = snapshot(
Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))),
{ complete: false },
);
await layer.update(); await layer.update();
assert.equal(layer.getStats().count, 500); assert.equal(layer.getStats().count, 500);
assert.equal(layer.hasContact('1000'), false); assert.equal(layer.hasContact('1000'), false);
@@ -85,7 +158,14 @@ test('an incomplete feed bounds its retained union to the configured row budget'
}); });
test('unknown source time does not become fresh at the time of receipt', async () => { test('unknown source time does not become fresh at the time of receipt', async () => {
const { layer } = setup({ async getSnapshot() { return snapshot([observation('111')], { observedAtMs: null, freshness: 'unknown' }); } }); const { layer } = setup({
async getSnapshot() {
return snapshot([observation('111')], {
observedAtMs: null,
freshness: 'unknown',
});
},
});
await layer.update(); await layer.update();
assert.equal(layer.getStats().lastUpdate, null); assert.equal(layer.getStats().lastUpdate, null);
assert.equal(layer.getStats().stale, true); assert.equal(layer.getStats().stale, true);
@@ -94,13 +174,23 @@ test('unknown source time does not become fresh at the time of receipt', async (
test('refresh replaces the history reference and clearing selection cancels late history', async (t) => { test('refresh replaces the history reference and clearing selection cancels late history', async (t) => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null }; globalThis.document = { getElementById: () => null };
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; }); t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
let current = snapshot([observation('111', 'old-reference')]); let current = snapshot([observation('111', 'old-reference')]);
let request; let request;
let resolveTrack; let resolveTrack;
const { layer } = setup({ const { layer } = setup({
async getSnapshot() { return current; }, async getSnapshot() {
getTrack(reference, { signal }) { request = { reference, signal }; return new Promise((resolve) => { resolveTrack = resolve; }); }, return current;
},
getTrack(reference, { signal }) {
request = { reference, signal };
return new Promise((resolve) => {
resolveTrack = resolve;
});
},
}); });
await layer.update(); await layer.update();
current = snapshot([observation('111', 'new-reference')]); current = snapshot([observation('111', 'new-reference')]);
@@ -117,10 +207,20 @@ test('refresh replaces the history reference and clearing selection cancels late
test('destroy cancels pending observations and ignores their later completion', async (t) => { test('destroy cancels pending observations and ignores their later completion', async (t) => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null }; globalThis.document = { getElementById: () => null };
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; }); t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
let requestSignal; let requestSignal;
let resolve; let resolve;
const { layer } = setup({ getSnapshot(_query, { signal }) { requestSignal = signal; return new Promise((done) => { resolve = done; }); } }); const { layer } = setup({
getSnapshot(_query, { signal }) {
requestSignal = signal;
return new Promise((done) => {
resolve = done;
});
},
});
const pending = layer.update(); const pending = layer.update();
layer.destroy(); layer.destroy();
assert.equal(requestSignal.aborted, true); assert.equal(requestSignal.aborted, true);
@@ -130,17 +230,26 @@ test('destroy cancels pending observations and ignores their later completion',
assert.equal(layer.hasContact('111'), null); assert.equal(layer.hasContact('111'), null);
}); });
test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => { test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => {
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
globalThis.document = { getElementById: () => null }; globalThis.document = { getElementById: () => null };
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; }); t.after(() => {
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
});
let current = snapshot([observation('111'), observation('222')]); let current = snapshot([observation('111'), observation('222')]);
let signal; let signal;
let finish; let finish;
const { layer, advance } = setup({ const { layer, advance } = setup({
async getSnapshot() { return current; }, async getSnapshot() {
getTrack(_reference, options) { signal = options.signal; return new Promise((resolve) => { finish = resolve; }); }, return current;
},
getTrack(_reference, options) {
signal = options.signal;
return new Promise((resolve) => {
finish = resolve;
});
},
}); });
await layer.update(); await layer.update();
layer.selectById('222'); layer.selectById('222');
+6 -15
View File
@@ -1,34 +1,25 @@
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
export const DEFAULT_RENDER_ROWS = 12000; export const DEFAULT_RENDER_ROWS = 12000;
export const DEFAULT_ACTIVE_LABELS = 900; export const DEFAULT_ACTIVE_LABELS = 900;
export const REFRESH_MS = 60000; export const REFRESH_MS = 60000;
/** Bounded wait for the first accepted vessel position in one enabled session. */ /** Bounded wait for the first accepted vessel position in one enabled session. */
export const AIS_FIRST_CONNECT_GRACE_MS = 30000; export const AIS_FIRST_CONNECT_GRACE_MS = 30000;
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…'; export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
export const VISIBILITY_UPDATE_MS = 800; export const VISIBILITY_UPDATE_MS = 800;
/** Focus alpha alone samples faster inside the existing preRender pass. */ /** Focus alpha alone samples faster inside the existing preRender pass. */
export const FOCUS_UPDATE_MS = 80; export const FOCUS_UPDATE_MS = 80;
export const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX; export const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX;
/** /**
@@ -67,15 +58,12 @@ export const TRAIL_MAX_POINTS = 400;
export const TRAIL_MIN_MOVE_M = 25; export const TRAIL_MIN_MOVE_M = 25;
export const DEFAULT_AIS_RUNTIME = Object.freeze({ export const DEFAULT_AIS_RUNTIME = Object.freeze({
now: () => Date.now(), now: () => Date.now(),
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
clearTimeout: (timer) => clearTimeout(timer), clearTimeout: (timer) => clearTimeout(timer),
}); });
/** /**
* Human-readable reasons for a non-'open' AISStream feed status, keyed to the * Human-readable reasons for a non-'open' AISStream feed status, keyed to the
* server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in * server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in
@@ -92,7 +80,6 @@ export const AIS_STATUS_REASON = {
idle: 'feed idle', idle: 'feed idle',
}; };
/** /**
* Statuses in which fresh data is flowing. 'open' is the pre-watchdog spelling * Statuses in which fresh data is flowing. 'open' is the pre-watchdog spelling
* and is still accepted so a cached bundle and a restarted server never * and is still accepted so a cached bundle and a restarted server never
@@ -101,14 +88,18 @@ export const AIS_STATUS_REASON = {
export const AIS_HEALTHY_STATUSES = new Set(['live', 'open']); export const AIS_HEALTHY_STATUSES = new Set(['live', 'open']);
/** /**
* Server statuses meaning "the feed is not delivering right now". These are * Server statuses meaning "the feed is not delivering right now". These are
* surfaced even while cached vessels are still drawn: rows retained from * surfaced even while cached vessels are still drawn: rows retained from
* before the outage must never make a dead feed read as a healthy one. * before the outage must never make a dead feed read as a healthy one.
*/ */
export const AIS_DEGRADED_STATUSES = new Set(['stale', 'reconnecting', 'down', 'auth-failed']); export const AIS_DEGRADED_STATUSES = new Set([
'stale',
'reconnecting',
'down',
'auth-failed',
]);
/** Missing records in an incomplete observation are retained for at most five minutes. */ /** Missing records in an incomplete observation are retained for at most five minutes. */
export const PARTIAL_RETENTION_MS = 5 * 60 * 1000; export const PARTIAL_RETENTION_MS = 5 * 60 * 1000;
+480 -431
View File
@@ -1,478 +1,527 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { AIS_DEGRADED_STATUSES, AIS_HEALTHY_STATUSES, AIS_STATUS_REASON, REFRESH_MS, FOCUS_EVIDENCE_DEV, AIS_FIRST_CONNECT_LABEL } from './policy.js'; import {
AIS_DEGRADED_STATUSES,
AIS_HEALTHY_STATUSES,
AIS_STATUS_REASON,
REFRESH_MS,
FOCUS_EVIDENCE_DEV,
AIS_FIRST_CONNECT_LABEL,
} from './policy.js';
export function createQueries({ vesselState, services, parts: components, layer, options }) { export function createQueries({
const { state } = vesselState; vesselState,
const { formatKnots } = services.labels; services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
const { formatKnots } = services.labels;
/**
* Seconds until the server's next reconnect attempt, or 0 when none is
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
* @returns {number}
*/
/** function aisRetryInSec() {
* Seconds until the server's next reconnect attempt, or 0 when none is // A rejected key is terminal until someone changes it; an hour-long
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance. // countdown would imply waiting is the fix.
* @returns {number} if (state.transportStatus === 'auth-failed') return 0;
*/ const at = Number(state.nextAttemptAt);
if (!Number.isFinite(at) || at <= 0) return 0;
function aisRetryInSec() { return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
// 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 (!Number.isFinite(at) || at <= 0) return 0;
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
}
/**
* Chip text for a feed the server has reported as not delivering.
* @param {string} status - 'stale' | 'reconnecting' | 'down'
* @param {Object} payload - Parsed /api/ais-live JSON.
* @returns {string}
*/
function describeDegradedAisFeed(status, payload) {
if (status === 'auth-failed') {
// Actionable, not a countdown: retrying cannot fix a rejected credential,
// so the chip asks the operator to do the one thing that can.
return 'API key rejected — check AISSTREAM_API_KEY';
} }
if (status === 'stale') {
const silentSec = Math.round(Number(payload?.silentForMs) / 1000); /**
return Number.isFinite(silentSec) && silentSec > 0 * Chip text for a feed the server has reported as not delivering.
? `feed silent ${silentSec}s — no AIS data` * @param {string} status - 'stale' | 'reconnecting' | 'down'
: 'feed silent — no AIS data'; * @param {Object} payload - Parsed /api/ais-live JSON.
* @returns {string}
*/
function describeDegradedAisFeed(status, payload) {
if (status === 'auth-failed') {
// Actionable, not a countdown: retrying cannot fix a rejected credential,
// so the chip asks the operator to do the one thing that can.
return 'API key rejected — check AISSTREAM_API_KEY';
}
if (status === 'stale') {
const silentSec = Math.round(Number(payload?.silentForMs) / 1000);
return Number.isFinite(silentSec) && silentSec > 0
? `feed silent ${silentSec}s — no AIS data`
: 'feed silent — no AIS data';
}
const attempt = Number(payload?.reconnectAttempt);
const suffix =
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
return status === 'down'
? `feed down — retrying slowly${suffix}`
: `reconnecting to feed…${suffix}`;
} }
const attempt = Number(payload?.reconnectAttempt);
const suffix = Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
return status === 'down'
? `feed down — retrying slowly${suffix}`
: `reconnecting to feed…${suffix}`;
}
/**
* Derive a surfaced error string from an /api/ais-live payload, or null when the
* feed has accepted product data. Socket transport, message receipt, and usable
* vessel positions are separate health stages: an open socket with no message
* or no accepted positions must not read as a fresh successful update.
*
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
* @returns {string|null} A short reason for the chip, or null if healthy.
*/
/** function deriveAisFeedError(payload, acceptedRowCount) {
* Derive a surfaced error string from an /api/ais-live payload, or null when the const status =
* feed has accepted product data. Socket transport, message receipt, and usable payload && typeof payload.status === 'string' ? payload.status : null;
* vessel positions are separate health stages: an open socket with no message // A feed the server reports as not delivering outranks the row count: the
* or no accepted positions must not read as a fresh successful update. // cached vessels on screen are exactly what makes an outage invisible.
* if (status && AIS_DEGRADED_STATUSES.has(status)) {
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON. return describeDegradedAisFeed(status, payload);
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization. }
* @returns {string|null} A short reason for the chip, or null if healthy. if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
*/ if (AIS_HEALTHY_STATUSES.has(status)) {
return payload?.lastMessageAt
function deriveAisFeedError(payload, acceptedRowCount) { ? 'awaiting usable AIS positions…'
const status = payload && typeof payload.status === 'string' ? payload.status : null; : 'awaiting first AIS message…';
// A feed the server reports as not delivering outranks the row count: the }
// cached vessels on screen are exactly what makes an outage invisible. if (!status) return null;
if (status && AIS_DEGRADED_STATUSES.has(status)) { const detail =
return describeDegradedAisFeed(status, payload); typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: '';
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
return detail && !AIS_STATUS_REASON[status]
? `${reason} (${detail})`
: reason;
} }
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
if (AIS_HEALTHY_STATUSES.has(status)) { /** True when a raw AIS row can enter the production vessel normalizer. */
return payload?.lastMessageAt
? 'awaiting usable AIS positions…' function hasUsableVesselCoordinates(row) {
: 'awaiting first AIS message…'; return (
Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon))
);
} }
if (!status) return null;
const detail = typeof payload.error === 'string' && payload.error.trim() ? payload.error.trim() : '';
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
return detail && !AIS_STATUS_REASON[status] ? `${reason} (${detail})` : reason;
}
/**
* Classify one server snapshot before any destructive reconciliation.
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
* acceptedRowCount: number, error: string|null}}
*/
/** True when a raw AIS row can enter the production vessel normalizer. */ function classifyAisFeedSnapshot(payload) {
const rawRows = Array.isArray(payload?.rows) ? payload.rows : [];
function hasUsableVesselCoordinates(row) { const acceptedRows = rawRows.filter(hasUsableVesselCoordinates);
return Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon)); const transportStatus =
} typeof payload?.status === 'string' ? payload.status : null;
const lastMessageAt = payload?.lastMessageAt ?? null;
const acceptedRowCount = acceptedRows.length;
/** return {
* Classify one server snapshot before any destructive reconciliation. transportStatus,
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload. lastMessageAt,
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null, rawRows,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number, acceptedRows,
* acceptedRowCount: number, error: string|null}} rawRowCount:
*/ Number.isInteger(payload?.rawRowCount) &&
payload.rawRowCount >= rawRows.length
function classifyAisFeedSnapshot(payload) { ? payload.rawRowCount
const rawRows = Array.isArray(payload?.rows) ? payload.rows : []; : rawRows.length,
const acceptedRows = rawRows.filter(hasUsableVesselCoordinates); acceptedRowCount,
const transportStatus = typeof payload?.status === 'string' ? payload.status : null; error:
const lastMessageAt = payload?.lastMessageAt ?? null; deriveAisFeedError(payload, acceptedRowCount) ||
const acceptedRowCount = acceptedRows.length; (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
return { };
transportStatus,
lastMessageAt,
rawRows,
acceptedRows,
rawRowCount: Number.isInteger(payload?.rawRowCount) && payload.rawRowCount >= rawRows.length ? payload.rawRowCount : rawRows.length,
acceptedRowCount,
error: deriveAisFeedError(payload, acceptedRowCount)
|| (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
};
}
/**
* Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* 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.
* @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,
* navStatus: null}}
*/
function mapAnalystRecord(record) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
const mmsi = text(record?.mmsi);
const name = text(record?.name);
return {
id: name || mmsi,
mmsi,
name,
lat: num(record?.lat),
lon: num(record?.lon),
speedKts: num(record?.speed),
courseDeg: num(record?.course),
shipType: text(record?.type),
destination: text(record?.destination),
navStatus: null,
};
}
/**
* Ellipsoidal render height (m) for a sea-surface object: the local geoid
* undulation N plus a small lift. The sea surface ≈ the geoid, which sits
* −106…+85 m off the WGS84 ellipsoid worldwide (Rotterdam ≈ +45 m — at
* height 0 the tile sea mesh occludes every chevron; Houston ≈ −27 m).
* Pure seam, exported for unit tests.
* @param {number|null|undefined} geoidN - Undulation N (m), or null/undefined while the grid is cold.
* @param {number} liftM - Lift above the sea surface (m).
* @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent).
*/
function vesselDatumHeightM(geoidN, liftM) {
return (Number.isFinite(geoidN) ? geoidN : 0) + liftM;
}
/**
* Reduce one vessel-selection gesture to the layer-owned action it should
* perform. The interaction handler reserves only vessel-record residuals and
* trail picks as no-ops. The interaction wire also reserves sibling-owned
* picks before this reducer so their camera action cannot mutate AIS state.
*
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
* @returns {{action: 'none'|'select'|'deselect'}}
*/
function reduceVesselSelection(input = {}) {
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
const gesture = input.gesture || 'click';
if (gesture === 'escape') {
return selectedMmsi
? { action: 'deselect' }
: { action: 'none' };
} }
if (gesture !== 'click') {
return { action: 'none' }; /**
* Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* 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.
* @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,
* navStatus: null}}
*/
function mapAnalystRecord(record) {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t || null;
};
const mmsi = text(record?.mmsi);
const name = text(record?.name);
return {
id: name || mmsi,
mmsi,
name,
lat: num(record?.lat),
lon: num(record?.lon),
speedKts: num(record?.speed),
courseDeg: num(record?.course),
shipType: text(record?.type),
destination: text(record?.destination),
navStatus: null,
};
} }
if (pickedMmsi) {
if (pickedMmsi === selectedMmsi) { /**
* Ellipsoidal render height (m) for a sea-surface object: the local geoid
* undulation N plus a small lift. The sea surface ≈ the geoid, which sits
* −106…+85 m off the WGS84 ellipsoid worldwide (Rotterdam ≈ +45 m — at
* height 0 the tile sea mesh occludes every chevron; Houston ≈ −27 m).
* Pure seam, exported for unit tests.
* @param {number|null|undefined} geoidN - Undulation N (m), or null/undefined while the grid is cold.
* @param {number} liftM - Lift above the sea surface (m).
* @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent).
*/
function vesselDatumHeightM(geoidN, liftM) {
return (Number.isFinite(geoidN) ? geoidN : 0) + liftM;
}
/**
* Reduce one vessel-selection gesture to the layer-owned action it should
* perform. The interaction handler reserves only vessel-record residuals and
* trail picks as no-ops. The interaction wire also reserves sibling-owned
* picks before this reducer so their camera action cannot mutate AIS state.
*
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
* @returns {{action: 'none'|'select'|'deselect'}}
*/
function reduceVesselSelection(input = {}) {
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
const gesture = input.gesture || 'click';
if (gesture === 'escape') {
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
}
if (gesture !== 'click') {
return { action: 'none' }; return { action: 'none' };
} }
return { action: 'select' }; if (pickedMmsi) {
if (pickedMmsi === selectedMmsi) {
return { action: 'none' };
}
return { action: 'select' };
}
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
} }
return selectedMmsi
? { action: 'deselect' }
: { action: 'none' };
}
function normalizeSelectionMmsi(value) {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text || null;
}
const methods = {
mapAnalystRecord,
deriveAisFeedError,
classifyAisFeedSnapshot,
vesselDatumHeightM,
reduceVesselSelection,
id: 'ais-live-vessels',
function normalizeSelectionMmsi(value) { name: 'Live AIS Vessels',
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text || null;
}
const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot, vesselDatumHeightM, reduceVesselSelection,
id: 'ais-live-vessels', icon: '◭',
name: 'Live AIS Vessels', source: 'AISStream',
icon: '◭', updateInterval: REFRESH_MS,
source: 'AISStream', statsRefreshInterval: 1000,
updateInterval: REFRESH_MS, /**
* Find a vessel by exact MMSI or case-insensitive name substring.
* @param {string|number} query MMSI or partial vessel name.
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
*/
findByQuery(query) {
if (query === null || query === undefined) return null;
const records = state.vesselRecords;
if (!Array.isArray(records) || !records.length) return null;
const q = String(query).trim();
if (!q) return null;
statsRefreshInterval: 1000, let record = null;
if (/^\d+$/.test(q)) {
record = state.vesselMap.get(q) || null;
}
if (!record) {
const lower = q.toLowerCase();
record =
records.find((r) =>
String(r.name || '')
.toLowerCase()
.includes(lower),
) || null;
}
if (!record) return null;
/**
* Find a vessel by exact MMSI or case-insensitive name substring.
* @param {string|number} query MMSI or partial vessel name.
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
*/
findByQuery(query) {
if (query === null || query === undefined) return null;
const records = state.vesselRecords;
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;
}
if (!record) {
const lower = q.toLowerCase();
record = records.find((r) => String(r.name || '').toLowerCase().includes(lower)) || null;
}
if (!record) return null;
const position = record.billboard?.position || record.position;
if (!position) return null;
return {
mmsi: record.mmsi,
name: record.name,
position,
latitude: record.lat,
longitude: record.lon,
speedKt: record.speed,
course: record.course,
type: record.type,
};
},
/**
* Get vessels within a range of a point, sorted nearest-first.
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
* @param {number} [maxCount=25] Maximum entries to return.
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
*/
getNearby(centerCartesian, rangeM, maxCount = 25) {
const records = state.vesselRecords;
if (!centerCartesian || !Array.isArray(records) || !records.length) return [];
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25;
const entries = [];
for (const record of records) {
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue;
const position = record.billboard?.position || record.position; const position = record.billboard?.position || record.position;
if (!position) continue; if (!position) return null;
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); return {
if (!Number.isFinite(distanceM) || distanceM > range) continue; mmsi: record.mmsi,
entries.push({ mmsi: record.mmsi, name: record.name, position, distanceM }); name: record.name,
}
entries.sort((a, b) => a.distanceM - b.distanceM);
return entries.slice(0, cap);
},
/**
* Get positions of all currently loaded vessels.
* @param {number} [maxCount=800] Maximum entries to return.
* @returns {Array<{ id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number }>}
*/
/**
* Whether this layer still carries a vessel, in O(1).
*
* Mirror of `flights.hasContact`: presence consumers must not infer absence
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
* A disabled layer keeps its records, so it must decline rather than answer
* from data the user can no longer see.
* @param {string} mmsi Vessel identifier.
* @returns {boolean|null} Presence, or null when the layer is disabled or
* holds no data and therefore cannot answer.
*/
hasContact(mmsi) {
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0) return null;
if (!mmsi) return false;
return state.vesselMap.has(String(mmsi).trim());
},
getAllPositions(maxCount = 800) {
const result = [];
const records = state.vesselRecords;
if (!Array.isArray(records)) return result;
const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
for (const record of records) {
if (result.length >= cap) break;
const position = record.billboard?.position || record.position;
if (!position) continue;
result.push({
id: record.mmsi,
label: record.name || record.mmsi,
position, position,
latitude: record.lat, latitude: record.lat,
longitude: record.lon, longitude: record.lon,
}); speedKt: record.speed,
} course: record.course,
return result; type: record.type,
}, };
},
/**
* Get vessels within a range of a point, sorted nearest-first.
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
* @param {number} [maxCount=25] Maximum entries to return.
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
*/
getNearby(centerCartesian, rangeM, maxCount = 25) {
const records = state.vesselRecords;
if (!centerCartesian || !Array.isArray(records) || !records.length)
return [];
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
const cap =
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25;
/** const entries = [];
* Snapshot the layer's in-memory vessel records as plain JSON-safe for (const record of records) {
* objects for the analyst query engine. On-demand only (called at most if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
* once per spoken query) — zero per-frame cost, no listeners, no caching. continue;
* Returns [] while the layer is disabled or empty. const position = record.billboard?.position || record.position;
* @param {number} [maxCount=2000] - Maximum records to return (truncation). if (!position) continue;
* @returns {Array<Object>} See mapAnalystRecord for the record shape. const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
*/ if (!Number.isFinite(distanceM) || distanceM > range) continue;
getAnalystRecords(maxCount = 2000) { entries.push({
if (!state.enabled) return []; mmsi: record.mmsi,
const records = state.vesselRecords; name: record.name,
if (!Array.isArray(records) || !records.length) return []; position,
const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000; distanceM,
const result = []; });
for (const record of records) { }
if (result.length >= limit) break; entries.sort((a, b) => a.distanceM - b.distanceM);
result.push(mapAnalystRecord(record)); return entries.slice(0, cap);
} },
return result;
},
/**
* Get positions of all currently loaded vessels.
* @param {number} [maxCount=800] Maximum entries to return.
* @returns {Array<{ id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number }>}
*/
/**
* Whether this layer still carries a vessel, in O(1).
*
* Mirror of `flights.hasContact`: presence consumers must not infer absence
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
* A disabled layer keeps its records, so it must decline rather than answer
* from data the user can no longer see.
* @param {string} mmsi Vessel identifier.
* @returns {boolean|null} Presence, or null when the layer is disabled or
* holds no data and therefore cannot answer.
*/
hasContact(mmsi) {
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0)
return null;
if (!mmsi) return false;
return state.vesselMap.has(String(mmsi).trim());
},
/** getAllPositions(maxCount = 800) {
* Select a vessel by MMSI via the same path as a map click const result = [];
* (highlight + HUD update). const records = state.vesselRecords;
* @param {string|number} mmsi Vessel MMSI. if (!Array.isArray(records)) return result;
* @returns {boolean} True if a matching vessel was selected. const cap =
*/ Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
selectById(mmsi) {
if (mmsi === null || mmsi === undefined) return false;
const target = String(mmsi).trim();
if (!target) return false;
const record = state.vesselMap.get(target);
if (!record) return false;
components.selection.selectVessel(record);
return true;
},
for (const record of records) {
if (result.length >= cap) break;
const position = record.billboard?.position || record.position;
if (!position) continue;
result.push({
id: record.mmsi,
label: record.name || record.mmsi,
position,
latitude: record.lat,
longitude: record.lon,
});
}
return result;
},
/** /**
* Clear the current vessel selection and reset the HUD readout. * Snapshot the layer's in-memory vessel records as plain JSON-safe
* @returns {boolean} Always true. * objects for the analyst query engine. On-demand only (called at most
*/ * once per spoken query) — zero per-frame cost, no listeners, no caching.
clearSelection() { * Returns [] while the layer is disabled or empty.
components.selection.clearVesselInspection(); * @param {number} [maxCount=2000] - Maximum records to return (truncation).
return true; * @returns {Array<Object>} See mapAnalystRecord for the record shape.
}, */
getAnalystRecords(maxCount = 2000) {
if (!state.enabled) return [];
const records = state.vesselRecords;
if (!Array.isArray(records) || !records.length) return [];
const limit = Number.isFinite(maxCount)
? Math.max(1, Math.floor(maxCount))
: 2000;
const result = [];
for (const record of records) {
if (result.length >= limit) break;
result.push(mapAnalystRecord(record));
}
return result;
},
/**
* Select a vessel by MMSI via the same path as a map click
* (highlight + HUD update).
* @param {string|number} mmsi Vessel MMSI.
* @returns {boolean} True if a matching vessel was selected.
*/
selectById(mmsi) {
if (mmsi === null || mmsi === undefined) return false;
const target = String(mmsi).trim();
if (!target) return false;
const record = state.vesselMap.get(target);
if (!record) return false;
components.selection.selectVessel(record);
return true;
},
/** /**
* Get info about the currently selected vessel. * Clear the current vessel selection and reset the HUD readout.
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null} * @returns {boolean} Always true.
*/ */
getSelectedInfo() { clearSelection() {
const record = state.selectedRecord; components.selection.clearVesselInspection();
if (!record) return null; return true;
return { },
mmsi: record.mmsi,
name: record.name,
latitude: record.lat,
longitude: record.lon,
speedKt: record.speed,
course: record.course,
type: record.type,
};
},
/**
* Get info about the currently selected vessel.
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
*/
getSelectedInfo() {
const record = state.selectedRecord;
if (!record) return null;
return {
mmsi: record.mmsi,
name: record.name,
latitude: record.lat,
longitude: record.lon,
speedKt: record.speed,
course: record.course,
type: record.type,
};
},
/** /**
* Return a subset of vessels for the universal detection overlay. * Return a subset of vessels for the universal detection overlay.
* Deterministic stride sampling distributes selections evenly across the * Deterministic stride sampling distributes selections evenly across the
* current record list while honoring the overlay's per-layer budget. * current record list while honoring the overlay's per-layer budget.
* @param {Object} [options={}] - Options from the detection system. * @param {Object} [options={}] - Options from the detection system.
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all). * @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
* @param {number} [options.seed] - Seed offset for stride sampling. * @param {number} [options.seed] - Seed offset for stride sampling.
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
*/ */
getDetectableObjects(options = {}) { getDetectableObjects(options = {}) {
if (!state.enabled || !state.billboardCollection || !state.billboardCollection.show) return []; if (
const records = state.vesselRecords; !state.enabled ||
if (!Array.isArray(records) || !records.length) return []; !state.billboardCollection ||
!state.billboardCollection.show
)
return [];
const records = state.vesselRecords;
if (!Array.isArray(records) || !records.length) return [];
const maxCount = Number.isFinite(options.maxCount) const maxCount = Number.isFinite(options.maxCount)
? Math.max(1, Math.floor(options.maxCount)) ? Math.max(1, Math.floor(options.maxCount))
: records.length; : records.length;
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
// Deterministic stride: evenly space selections across the record list // Deterministic stride: evenly space selections across the record list
const stride = Math.max(1, Math.ceil(records.length / maxCount)); const stride = Math.max(1, Math.ceil(records.length / maxCount));
const start = seed % stride; const start = seed % stride;
const selected = state.selectedRecord; const selected = state.selectedRecord;
const result = []; const result = [];
for (let idx = 0; idx < records.length; idx += 1) { for (let idx = 0; idx < records.length; idx += 1) {
if (((idx - start) % stride) !== 0) continue; if ((idx - start) % stride !== 0) continue;
const record = records[idx]; const record = records[idx];
if (record.billboard && !record.billboard.show) continue; if (record.billboard && !record.billboard.show) continue;
const position = record.billboard?.position || record.position; const position = record.billboard?.position || record.position;
if (!position) continue; if (!position) continue;
result.push({ result.push({
position, position,
sourceId: record.mmsi, sourceId: record.mmsi,
id: record.name || record.mmsi || 'VESSEL', id: record.name || record.mmsi || 'VESSEL',
type: 'SEA', type: 'SEA',
skipLabel: record === selected, skipLabel: record === selected,
klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, klass: record.type
metric: formatKnots(record.speed), // record.speed is knots ? String(record.type).toUpperCase().slice(0, 14)
}); : undefined,
if (result.length >= maxCount) break; metric: formatKnots(record.speed), // record.speed is knots
} });
return result; if (result.length >= maxCount) break;
}, }
return result;
},
...(FOCUS_EVIDENCE_DEV
? {
__focusEvidence: Object.freeze({
setVessels: components.evidence._setFocusEvidenceVessels,
snapshot: components.evidence._focusEvidenceVesselSnapshot,
}),
}
: {}),
...(FOCUS_EVIDENCE_DEV ? { getStats() {
__focusEvidence: Object.freeze({ const waitingForFirstPosition = state.firstConnectPhase === 'loading';
setVessels: components.evidence._setFocusEvidenceVessels, return {
snapshot: components.evidence._focusEvidenceVesselSnapshot, count: state.count,
}), lastUpdate: state.lastUpdate,
} : {}), loading: state.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? AIS_FIRST_CONNECT_LABEL
: state.loadingLabel,
error: state.error,
stale: state.stale,
status:
state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
transportStatus: state.transportStatus,
lastMessageAt: state.lastMessageAt,
rawRowCount: state.rawRowCount,
acceptedRowCount: state.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.
retryInSec: aisRetryInSec(),
};
},
};
return {
getStats() { aisRetryInSec,
const waitingForFirstPosition = state.firstConnectPhase === 'loading'; describeDegradedAisFeed,
return { deriveAisFeedError,
count: state.count, hasUsableVesselCoordinates,
lastUpdate: state.lastUpdate, classifyAisFeedSnapshot,
loading: state.loading || waitingForFirstPosition, mapAnalystRecord,
loadingLabel: waitingForFirstPosition vesselDatumHeightM,
? AIS_FIRST_CONNECT_LABEL reduceVesselSelection,
: state.loadingLabel, normalizeSelectionMmsi,
error: state.error, methods,
stale: state.stale, };
status: state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
transportStatus: state.transportStatus,
lastMessageAt: state.lastMessageAt,
rawRowCount: state.rawRowCount,
acceptedRowCount: state.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.
retryInSec: aisRetryInSec(),
};
}
};
return { aisRetryInSec, describeDegradedAisFeed, deriveAisFeedError, hasUsableVesselCoordinates, classifyAisFeedSnapshot, mapAnalystRecord, vesselDatumHeightM, reduceVesselSelection, normalizeSelectionMmsi, methods };
} }
+422 -353
View File
@@ -1,403 +1,472 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { vesselTypeCss, vesselOverlayCohortLimit, VESSEL_OVERLAY_SOURCE_ID, applyVesselOverlayPolicy, VESSEL_CARD_FADE_DISTANCE_M } from '../../data/vesselLabels.js'; import {
import { cameraPoseSignature, screenProjectedRotation } from '../../data/iconOrientation.js'; vesselTypeCss,
import { DEFAULT_RENDER_ROWS, DEFAULT_ACTIVE_LABELS, VISIBILITY_UPDATE_MS, FOCUS_UPDATE_MS, LABEL_GRID_PX, CARD_MIN_SEP_PX } from './policy.js'; vesselOverlayCohortLimit,
VESSEL_OVERLAY_SOURCE_ID,
applyVesselOverlayPolicy,
VESSEL_CARD_FADE_DISTANCE_M,
} from '../../data/vesselLabels.js';
import {
cameraPoseSignature,
screenProjectedRotation,
} from '../../data/iconOrientation.js';
import {
DEFAULT_RENDER_ROWS,
DEFAULT_ACTIVE_LABELS,
VISIBILITY_UPDATE_MS,
FOCUS_UPDATE_MS,
LABEL_GRID_PX,
CARD_MIN_SEP_PX,
} from './policy.js';
export function createRendering({ vesselState, services, parts: components, layer, options }) { export function createRendering({
const { state } = vesselState; vesselState,
const { registerSpriteCollection } = services.sprites; services,
const { forgetSpriteFocus, focusNowMs, getFocusTarget, focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite } = services.focus; parts: components,
layer,
options,
}) {
const { state } = vesselState;
const { registerSpriteCollection } = services.sprites;
const {
forgetSpriteFocus,
focusNowMs,
getFocusTarget,
focusPassIsNeeded,
advanceSpriteFocus,
focusAlphaNeedsWrite,
} = services.focus;
function renderRowLimit() {
const configured = Number(options.maxRows);
function renderRowLimit() { if (Number.isFinite(configured) && configured > 0) {
const configured = Number(options.maxRows); return Math.max(500, Math.min(50000, Math.round(configured)));
if (Number.isFinite(configured) && configured > 0) { }
return Math.max(500, Math.min(50000, Math.round(configured))); return DEFAULT_RENDER_ROWS;
} }
return DEFAULT_RENDER_ROWS;
}
function labelRowLimit() {
const configured = Number(options.maxLabels);
function labelRowLimit() { if (Number.isFinite(configured) && configured >= 0) {
const configured = Number(options.maxLabels); return Math.max(0, Math.min(renderRowLimit(), Math.round(configured)));
if (Number.isFinite(configured) && configured >= 0) { }
return Math.max(0, Math.min(renderRowLimit(), Math.round(configured))); return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
} }
return Math.min(DEFAULT_ACTIVE_LABELS, renderRowLimit());
}
function ensureCollections(viewer) {
if (!viewer || state.billboardCollection) return;
function ensureCollections(viewer) { state.billboardCollection = new Cesium.BillboardCollection({
if (!viewer || state.billboardCollection) return; blendOption: Cesium.BlendOption.TRANSLUCENT,
state.billboardCollection = new Cesium.BillboardCollection({ });
blendOption: Cesium.BlendOption.TRANSLUCENT, state.billboardCollection.show = state.enabled;
}); viewer.scene.primitives.add(state.billboardCollection);
state.billboardCollection.show = state.enabled; registerSpriteCollection('ais', state.billboardCollection);
viewer.scene.primitives.add(state.billboardCollection);
registerSpriteCollection('ais', state.billboardCollection);
}
/**
* Create the billboard primitive for a freshly added vessel record. Map labels
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
* per-record label primitive exists anymore.
* @param {Object} record - Normalized vessel record.
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
*/
function addRecordPrimitives(record, occluder) {
const visible = state.enabled && isVisible(record.surfacePosition, occluder);
record.billboard = state.billboardCollection.add({
position: record.position,
show: visible,
image: shipIcon(record, false),
scale: shipScale(record),
// Screen-projected rotation lands on the next visibility/rotation pass.
rotation: 0,
alignedAxis: Cesium.Cartesian3.ZERO,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
// Locked height-datum principle #2: contacts are ALWAYS visible —
// depth-test-free sprites; the EllipsoidalOccluder handles the far side.
// (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
// the geoid still clips out behind local tide/mesh noise.)
disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: record,
});
}
/**
* Remove a record's billboard primitive from its collection.
* @param {Object} record - Vessel record to tear down.
*/
function removeRecordPrimitives(record) {
if (!record) return;
if (record.billboard && state.billboardCollection) {
forgetSpriteFocus(record.billboard);
state.billboardCollection.remove(record.billboard);
} }
record.billboard = null;
}
/**
* Create the billboard primitive for a freshly added vessel record. Map labels
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
* per-record label primitive exists anymore.
* @param {Object} record - Normalized vessel record.
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
*/
function addRecordPrimitives(record, occluder) {
const visible =
state.enabled && isVisible(record.surfacePosition, occluder);
record.billboard = state.billboardCollection.add({
position: record.position,
show: visible,
image: shipIcon(record, false),
scale: shipScale(record),
// Screen-projected rotation lands on the next visibility/rotation pass.
rotation: 0,
alignedAxis: Cesium.Cartesian3.ZERO,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
// Locked height-datum principle #2: contacts are ALWAYS visible —
// depth-test-free sprites; the EllipsoidalOccluder handles the far side.
// (The tile sea mesh ≠ the geoid exactly, so a depth-tested chevron at
// the geoid still clips out behind local tide/mesh noise.)
disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: record,
});
}
function shipScale(record) { /**
const speed = Number(record.speed || 0); * Remove a record's billboard primitive from its collection.
if (speed >= 18) return 0.78; * @param {Object} record - Vessel record to tear down.
if (speed >= 8) return 0.68; */
return 0.6;
}
function removeRecordPrimitives(record) {
if (!record) return;
if (record.billboard && state.billboardCollection) {
forgetSpriteFocus(record.billboard);
state.billboardCollection.remove(record.billboard);
}
record.billboard = null;
}
/** function shipScale(record) {
* Best available real-world direction of travel for a vessel, degrees const speed = Number(record.speed || 0);
* clockwise from north (true heading preferred, course-over-ground fallback). if (speed >= 18) return 0.78;
* Screen rotation is computed from this by the shared projected-rotation if (speed >= 8) return 0.68;
* pass in updateVisibility — never directly from the compass value. return 0.6;
* @param {Object} record - Vessel record. }
* @returns {number} Course in degrees (0 when unknown).
*/
function vesselCourseDeg(record) { /**
const direction = record.heading ?? record.course; * Best available real-world direction of travel for a vessel, degrees
return Number.isFinite(direction) ? direction : 0; * clockwise from north (true heading preferred, course-over-ground fallback).
} * Screen rotation is computed from this by the shared projected-rotation
* pass in updateVisibility — never directly from the compass value.
* @param {Object} record - Vessel record.
* @returns {number} Course in degrees (0 when unknown).
*/
function vesselCourseDeg(record) {
const direction = record.heading ?? record.course;
return Number.isFinite(direction) ? direction : 0;
}
/** /**
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel. * Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
* The shape points north (up) so billboard rotation maps directly to heading. * The shape points north (up) so billboard rotation maps directly to heading.
* One icon is generated per color+variant and reused across all billboards. * One icon is generated per color+variant and reused across all billboards.
* @param {Object} record - Vessel record (drives per-type tint). * @param {Object} record - Vessel record (drives per-type tint).
* @param {boolean} selected - True for the white/brighter selected variant. * @param {boolean} selected - True for the white/brighter selected variant.
* @returns {string} SVG data URL. * @returns {string} SVG data URL.
*/ */
function shipIcon(record, selected) { function shipIcon(record, selected) {
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type); const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`; const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
if (vesselState.shipIconCache.has(key)) return vesselState.shipIconCache.get(key); if (vesselState.shipIconCache.has(key))
return vesselState.shipIconCache.get(key);
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)'; const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
const strokeWidth = selected ? 1.1 : 0.7; const strokeWidth = selected ? 1.1 : 0.7;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"> const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g transform="translate(16,16)"> <g transform="translate(16,16)">
<path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/> <path d="M0,-14 L11,10 L4,7 L0,14 L-4,7 L-11,10 Z" fill="${cssColor}" stroke="${stroke}" stroke-width="${strokeWidth}" stroke-linejoin="round"/>
</g> </g>
</svg>`; </svg>`;
const icon = 'data:image/svg+xml;base64,' + btoa(svg); const icon = 'data:image/svg+xml;base64,' + btoa(svg);
vesselState.shipIconCache.set(key, icon); vesselState.shipIconCache.set(key, icon);
return icon; return icon;
}
function installRuntime(viewer) {
if (state.preRenderRemover || !viewer) return;
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => updateVisibility());
}
function updateVisibility(force = false) {
if (!state.enabled) return;
const now = focusNowMs(performance.now());
const focusTarget = getFocusTarget();
const regularPass = force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount)
&& (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
if (!regularPass && !focusPass) return;
if (regularPass) state.lastVisibilityUpdate = now;
if (focusPass) state.lastFocusUpdate = now;
if (!state.vesselRecords.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
return;
} }
const scene = state.viewer?.scene; function installRuntime(viewer) {
const camera = state.viewer?.camera; if (state.preRenderRemover || !viewer) return;
if (regularPass) { state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
// Candidate construction stays on the original 800 ms selector cadence. updateVisibility(),
// The 80 ms focus-only pass below never allocates label candidates. );
const poseSig = camera ? cameraPoseSignature(camera) : ''; }
const doRotations = force || poseSig !== vesselState._lastCamPoseSig;
if (doRotations) vesselState._lastCamPoseSig = poseSig; function updateVisibility(force = false) {
const occluder = makeOccluder(); if (!state.enabled) return;
const labelCandidates = []; const now = focusNowMs(performance.now());
for (const record of state.vesselRecords) { const focusTarget = getFocusTarget();
const visible = isVisible(record.surfacePosition, occluder); const regularPass =
if (record.billboard) { force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
record.billboard.show = visible; const focusPass =
if (visible && doRotations && scene) { focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
const rot = screenProjectedRotation( (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
scene, record.position, vesselCourseDeg(record), record.billboard.rotation if (!regularPass && !focusPass) return;
); if (regularPass) state.lastVisibilityUpdate = now;
if (rot !== null && Math.abs(rot - record.billboard.rotation) > 0.002) { if (focusPass) state.lastFocusUpdate = now;
record.billboard.rotation = rot; if (!state.vesselRecords.length) {
// No records — flush any lingering card entries (vanished-feed case).
if (regularPass) updateClusteredLabels([]);
if (focusPass) state.activeFocusCount = 0;
return;
}
const scene = state.viewer?.scene;
const camera = state.viewer?.camera;
if (regularPass) {
// Candidate construction stays on the original 800 ms selector cadence.
// The 80 ms focus-only pass below never allocates label candidates.
const poseSig = camera ? cameraPoseSignature(camera) : '';
const doRotations = force || poseSig !== vesselState._lastCamPoseSig;
if (doRotations) vesselState._lastCamPoseSig = poseSig;
const occluder = makeOccluder();
const labelCandidates = [];
for (const record of state.vesselRecords) {
const visible = isVisible(record.surfacePosition, occluder);
if (record.billboard) {
record.billboard.show = visible;
if (visible && doRotations && scene) {
const rot = screenProjectedRotation(
scene,
record.position,
vesselCourseDeg(record),
record.billboard.rotation,
);
if (
rot !== null &&
Math.abs(rot - record.billboard.rotation) > 0.002
) {
record.billboard.rotation = rot;
}
} }
} }
if (visible) labelCandidates.push(record);
} }
if (visible) labelCandidates.push(record); updateClusteredLabels(labelCandidates);
} }
updateClusteredLabels(labelCandidates); if (focusPass && scene && camera) {
} const result = applyVesselFocusDeemphasis({
if (focusPass && scene && camera) { records: state.vesselRecords,
const result = applyVesselFocusDeemphasis({ target: focusTarget,
records: state.vesselRecords, previousActiveCount: state.activeFocusCount,
target: focusTarget, nowMs: now,
previousActiveCount: state.activeFocusCount, screenPositionFor: (position) =>
nowMs: now, Cesium.SceneTransforms.worldToWindowCoordinates(
screenPositionFor: (position) => ( scene,
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, vesselState._scratchFocusScreen) position,
), vesselState._scratchFocusScreen,
cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position), ),
}); cameraDistanceFor: (position) =>
state.activeFocusCount = result.activeCount; Cesium.Cartesian3.distance(camera.positionWC, position),
} });
} state.activeFocusCount = result.activeCount;
/**
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
* animation/deadband contract can be tested without constructing WebGL.
* @param {object} input
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
*/
function applyVesselFocusDeemphasis({
records,
target,
previousActiveCount = 0,
nowMs,
screenPositionFor,
cameraDistanceFor,
params,
}) {
if (!focusPassIsNeeded(target, previousActiveCount)) {
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
}
let writes = 0;
let transitioning = false;
let activeCount = 0;
for (const record of records || []) {
const bb = record?.billboard;
const position = bb?.position || record?.position;
if (!bb || !position) continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha.
screenPosition: bb.show === false ? null : screenPositionFor(position),
cameraDistance: cameraDistanceFor(position),
nowMs,
target,
params,
// Vessel artwork is 32 px before billboard scale. Including the
// ambient chevron's own rendered extent prevents edge-overlap misses.
spriteHalfWidthPx: (bb.width || 32) * (bb.scale || 1) * 0.5,
spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5,
});
transitioning ||= focus.transitioning;
if (focus.active) activeCount += 1;
if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) {
// Narrow always-visible amendment: the ship chevron remains present at
// the non-zero floor while it competes with the tracked target. Preserve
// the billboard's existing base RGB, matching the other layer patterns,
// rather than repainting every chevron from a hard-coded WHITE base.
const baseColor = bb.color || Cesium.Color.WHITE;
bb.color = baseColor.withAlpha(focus.factor);
writes += 1;
} }
} }
return { writes, transitioning, activeCount, ran: true };
}
/**
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
* animation/deadband contract can be tested without constructing WebGL.
* @param {object} input
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
*/
function applyVesselFocusDeemphasis({
records,
target,
previousActiveCount = 0,
nowMs,
screenPositionFor,
cameraDistanceFor,
params,
}) {
if (!focusPassIsNeeded(target, previousActiveCount)) {
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
}
let writes = 0;
let transitioning = false;
let activeCount = 0;
for (const record of records || []) {
const bb = record?.billboard;
const position = bb?.position || record?.position;
if (!bb || !position) continue;
const focus = advanceSpriteFocus(bb, {
// Hidden/far-side sprites still finish any pending release so the active
// count remains truthful and they cannot reappear with stale dim alpha.
screenPosition: bb.show === false ? null : screenPositionFor(position),
cameraDistance: cameraDistanceFor(position),
nowMs,
target,
params,
// Vessel artwork is 32 px before billboard scale. Including the
// ambient chevron's own rendered extent prevents edge-overlap misses.
spriteHalfWidthPx: (bb.width || 32) * (bb.scale || 1) * 0.5,
spriteHalfHeightPx: (bb.height || 32) * (bb.scale || 1) * 0.5,
});
transitioning ||= focus.transitioning;
if (focus.active) activeCount += 1;
if (focusAlphaNeedsWrite(bb.color?.alpha, focus.factor, params)) {
// Narrow always-visible amendment: the ship chevron remains present at
// the non-zero floor while it competes with the tracked target. Preserve
// the billboard's existing base RGB, matching the other layer patterns,
// rather than repainting every chevron from a hard-coded WHITE base.
const baseColor = bb.color || Cesium.Color.WHITE;
bb.color = baseColor.withAlpha(focus.factor);
writes += 1;
}
}
return { writes, transitioning, activeCount, ran: true };
}
function makeOccluder() { function makeOccluder() {
const cameraPosition = state.viewer?.camera?.positionWC; const cameraPosition = state.viewer?.camera?.positionWC;
if (!cameraPosition) return null; if (!cameraPosition) return null;
return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPosition); return new Cesium.EllipsoidalOccluder(
} Cesium.Ellipsoid.WGS84,
cameraPosition,
);
}
function isVisible(surfacePosition, occluder) {
if (!surfacePosition || !occluder) return true;
return occluder.isPointVisible(surfacePosition);
}
/**
* Source selector for the shared world-overlay pipeline: the
* grid declutter picks which vessels get cards — one winner per screen-space
* grid cell, priority-ranked, capped — and publishes presentation entries to
* the host, which owns projection, final placement, fade and paint. Runs on the
* throttled visibility pass and forced refreshes, never per frame. The
* selected vessel always gets its full-detail card, even when horizon-culled
* from the ambient candidates; its protected entry bypasses ambient quotas.
* @param {Array<Object>} records - Horizon-visible vessel records.
*/
function isVisible(surfacePosition, occluder) { function updateClusteredLabels(records) {
if (!surfacePosition || !occluder) return true; const viewer = state.viewer;
return occluder.isPointVisible(surfacePosition); const scene = viewer?.scene;
} const selected = state.selectedRecord;
const entries = selected
? [components.cards.buildSelectedVesselCard(selected)]
: [];
const maxLabels = labelRowLimit();
if (!scene || !records.length || maxLabels <= 0) {
state.activeLabelCount = entries.length;
publishVesselOverlayEntries(entries);
return;
}
/** const cells = new Map();
* Source selector for the shared world-overlay pipeline: the for (const record of records) {
* grid declutter picks which vessels get cards — one winner per screen-space if (record === selected) continue;
* grid cell, priority-ranked, capped — and publishes presentation entries to const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
* the host, which owns projection, final placement, fade and paint. Runs on the scene,
* throttled visibility pass and forced refreshes, never per frame. The record.position,
* selected vessel always gets its full-detail card, even when horizon-culled );
* from the ambient candidates; its protected entry bypasses ambient quotas. if (!screen) continue;
* @param {Array<Object>} records - Horizon-visible vessel records. const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
*/ const candidate = {
record,
score: labelPriority(record, selected),
x: screen.x,
y: screen.y,
};
const existing = cells.get(key);
if (!existing || candidate.score > existing.score) {
cells.set(key, candidate);
}
}
function updateClusteredLabels(records) { // Greedy min-separation pass over the priority-ranked cell winners: the
const viewer = state.viewer; // selected card's anchor seeds the accepted set so ambient cards keep clear.
const scene = viewer?.scene; const accepted = [];
const selected = state.selectedRecord; if (selected) {
const entries = selected ? [components.cards.buildSelectedVesselCard(selected)] : []; const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
const maxLabels = labelRowLimit(); scene,
selected.billboard?.position || selected.position,
if (!scene || !records.length || maxLabels <= 0) { );
if (screen) accepted.push({ x: screen.x, y: screen.y });
}
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
for (const candidate of ranked) {
if (entries.length >= maxLabels) break;
if (
!components.cards.cardScreenSeparated(
accepted,
candidate,
CARD_MIN_SEP_PX,
)
)
continue;
accepted.push({ x: candidate.x, y: candidate.y });
entries.push(components.cards.buildVesselCard(candidate.record));
}
state.activeLabelCount = entries.length; state.activeLabelCount = entries.length;
publishVesselOverlayEntries(entries); publishVesselOverlayEntries(entries);
return;
} }
const cells = new Map(); /**
for (const record of records) { * Publish a complete, bounded source snapshot to the shared host. The source
if (record === selected) continue; * selector remains authoritative for the 118 px grid and 150 px separation;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, record.position); * the host then composes this demand with sibling ambient-card sources.
if (!screen) continue; * @param {Object[]} entries Formatted vessel card entries.
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`; */
const candidate = { record, score: labelPriority(record, selected), x: screen.x, y: screen.y };
const existing = cells.get(key);
if (!existing || candidate.score > existing.score) {
cells.set(key, candidate);
}
}
// Greedy min-separation pass over the priority-ranked cell winners: the function publishVesselOverlayEntries(entries) {
// selected card's anchor seeds the accepted set so ambient cards keep clear. const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
const accepted = []; const width = Number(canvas?.clientWidth) || 0;
if (selected) { const height = Number(canvas?.clientHeight) || 0;
const screen = Cesium.SceneTransforms.worldToWindowCoordinates( const ambientLimit = vesselOverlayCohortLimit(
scene, selected.billboard?.position || selected.position width,
height,
labelRowLimit(),
);
vesselState._vesselOverlayHost.setEntries(
VESSEL_OVERLAY_SOURCE_ID,
entries.map((entry) => {
const card = applyVesselOverlayPolicy(
entry,
VESSEL_CARD_FADE_DISTANCE_M,
);
if (!card.interactive) return card;
const mmsi = String(card.id || '').startsWith('vessel:')
? card.id.slice('vessel:'.length)
: '';
return {
...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
activate: () => {
const record = state.vesselMap.get(mmsi);
if (!record) return false;
components.selection.selectAndFocusVessel(record);
return true;
},
};
}),
{
cohortLimit: Math.max(1, ambientLimit),
collisionCapacity: ambientLimit,
moving: false,
},
); );
if (screen) accepted.push({ x: screen.x, y: screen.y });
} }
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
for (const candidate of ranked) { function labelPriority(record, selected) {
if (entries.length >= maxLabels) break; if (record === selected) return 100000;
if (!components.cards.cardScreenSeparated(accepted, candidate, CARD_MIN_SEP_PX)) continue; let score = 0;
accepted.push({ x: candidate.x, y: candidate.y }); if (hasUsefulName(record)) score += 1000;
entries.push(components.cards.buildVesselCard(candidate.record)); if (record.speed !== null)
score += Math.min(400, Math.max(0, record.speed) * 20);
if (record.heading !== null || record.course !== null) score += 80;
if (record.type) score += 40;
return score;
} }
state.activeLabelCount = entries.length;
publishVesselOverlayEntries(entries);
}
function hasUsefulName(record) {
/** const text = String(record.name || '').trim();
* Publish a complete, bounded source snapshot to the shared host. The source return Boolean(
* selector remains authoritative for the 118 px grid and 150 px separation; text &&
* the host then composes this demand with sibling ambient-card sources. text !== 'VESSEL' &&
* @param {Object[]} entries Formatted vessel card entries. !/^MMSI\s*\d+$/i.test(text) &&
*/ text !== record.mmsi,
);
function publishVesselOverlayEntries(entries) {
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
const width = Number(canvas?.clientWidth) || 0;
const height = Number(canvas?.clientHeight) || 0;
const ambientLimit = vesselOverlayCohortLimit(width, height, labelRowLimit());
vesselState._vesselOverlayHost.setEntries(
VESSEL_OVERLAY_SOURCE_ID,
entries.map((entry) => {
const card = applyVesselOverlayPolicy(entry, VESSEL_CARD_FADE_DISTANCE_M);
if (!card.interactive) return card;
const mmsi = String(card.id || '').startsWith('vessel:')
? card.id.slice('vessel:'.length)
: '';
return {
...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
activate: () => {
const record = state.vesselMap.get(mmsi);
if (!record) return false;
components.selection.selectAndFocusVessel(record);
return true;
},
};
}),
{
cohortLimit: Math.max(1, ambientLimit),
collisionCapacity: ambientLimit,
moving: false,
},
);
}
function labelPriority(record, selected) {
if (record === selected) return 100000;
let score = 0;
if (hasUsefulName(record)) score += 1000;
if (record.speed !== null) score += Math.min(400, Math.max(0, record.speed) * 20);
if (record.heading !== null || record.course !== null) score += 80;
if (record.type) score += 40;
return score;
}
function hasUsefulName(record) {
const text = String(record.name || '').trim();
return Boolean(text && text !== 'VESSEL' && !/^MMSI\s*\d+$/i.test(text) && text !== record.mmsi);
}
function setVisible(show) {
if (state.billboardCollection) {
state.billboardCollection.show = show;
} }
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
} function setVisible(show) {
return { renderRowLimit, labelRowLimit, ensureCollections, addRecordPrimitives, removeRecordPrimitives, shipScale, vesselCourseDeg, shipIcon, installRuntime, updateVisibility, applyVesselFocusDeemphasis, makeOccluder, isVisible, updateClusteredLabels, publishVesselOverlayEntries, labelPriority, hasUsefulName, setVisible }; if (state.billboardCollection) {
state.billboardCollection.show = show;
}
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show);
}
return {
renderRowLimit,
labelRowLimit,
ensureCollections,
addRecordPrimitives,
removeRecordPrimitives,
shipScale,
vesselCourseDeg,
shipIcon,
installRuntime,
updateVisibility,
applyVesselFocusDeemphasis,
makeOccluder,
isVisible,
updateClusteredLabels,
publishVesselOverlayEntries,
labelPriority,
hasUsefulName,
setVisible,
};
} }
+227 -209
View File
@@ -1,229 +1,247 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
export function createSelection({ vesselState, services, parts: components, layer, options }) { export function createSelection({
const { state } = vesselState; vesselState,
const { resolvePickId, isOwnedByOtherLayer } = services.picking; services,
const { requestWorldFocus } = services.worldFocus; parts: components,
const { selectEntityContext, registerEntityContext, clearSelectedEntityContextForLayer } = services.context; layer,
const aisLiveVesselsLayer = layer; options,
}) {
const { state } = vesselState;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { requestWorldFocus } = services.worldFocus;
const {
selectEntityContext,
registerEntityContext,
clearSelectedEntityContextForLayer,
} = services.context;
const aisLiveVesselsLayer = layer;
function installInteraction(viewer) {
if (state.clickHandler || !viewer) return;
const handler = state.interactionHandlerFactory
? state.interactionHandlerFactory(viewer)
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
bindVesselInteraction(
viewer,
handler,
state.interactionKeyTarget || document,
);
}
function bindVesselInteraction(viewer, handler, keyTarget) {
state.clickHandler = handler;
handler.setInputAction((click) => {
if (!state.enabled) return;
const picked = viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.vesselMap.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
const ownRecordPick =
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
function installInteraction(viewer) { // An own-layer record without a live map key is a strict no-op (FB-1
if (state.clickHandler || !viewer) return; // residual). Trails carry no layer identity and hug their contacts, so any
const handler = state.interactionHandlerFactory // `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
? state.interactionHandlerFactory(viewer) // unowned scene picks dismiss the current vessel inspection.
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); if (ownRecordPick && (!pickedId || !record)) return;
bindVesselInteraction(viewer, handler, state.interactionKeyTarget || document); if (pickedId && !record && String(pickedId).startsWith('gev-trail:'))
} return;
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
function bindVesselInteraction(viewer, handler, keyTarget) { // usually terrain behind the card. Resolve against the host's current
state.clickHandler = handler; // actionable hit rectangles before treating the click as empty space.
handler.setInputAction((click) => { const cardHit = !record
if (!state.enabled) return; ? vesselState._vesselOverlayHost.hitTest?.(
const picked = viewer.scene.pick(click.position); click.position?.x,
const pickedId = resolvePickId(picked); click.position?.y,
let record = pickedId ? state.vesselMap.get(pickedId) : null; {
const rawId = picked?.id ?? picked?.primitive?.id; sourceId: VESSEL_OVERLAY_SOURCE_ID,
const ownRecordPick = rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi'); },
)
// An own-layer record without a live map key is a strict no-op (FB-1
// residual). Trails carry no layer identity and hug their contacts, so any
// `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
// unowned scene picks dismiss the current vessel inspection.
if (ownRecordPick && (!pickedId || !record)) return;
if (pickedId && !record && String(pickedId).startsWith('gev-trail:')) return;
// A sibling layer already owns this click. Preserve the current vessel
// selection and do not compete with its camera command.
if (pickedId && isOwnedByOtherLayer('ais-live-vessels', pickedId)) return;
// Cards are painted on a pointer-events:none canvas, so the scene pick is
// usually terrain behind the card. Resolve against the host's current
// actionable hit rectangles before treating the click as empty space.
const cardHit = !record
? vesselState._vesselOverlayHost.hitTest?.(click.position?.x, click.position?.y, {
sourceId: VESSEL_OVERLAY_SOURCE_ID,
})
: null;
if (!record && cardHit) {
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
? cardHit.entryId.slice('vessel:'.length)
: null; : null;
record = mmsi ? state.vesselMap.get(mmsi) || null : null; if (!record && cardHit) {
// A stale card id is not empty terrain and must not clear a newer const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
// selection. The next paint will evict its hit rectangle. ? cardHit.entryId.slice('vessel:'.length)
if (!record) return; : null;
} record = mmsi ? state.vesselMap.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;
}
if (record) { if (record) {
// A valid sprite or card click always transfers the camera exactly once, // A valid sprite or card click always transfers the camera exactly once,
// including a second click on the already-selected vessel. // including a second click on the already-selected vessel.
selectAndFocusVessel(record); selectAndFocusVessel(record);
} else { } else {
const transition = components.queries.reduceVesselSelection({ const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi, selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: null, pickedMmsi: null,
gesture: 'click', gesture: 'click',
}); });
if (transition.action === 'deselect') clearVesselInspection(); if (transition.action === 'deselect') clearVesselInspection();
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
state.keyTarget = keyTarget; state.keyTarget = keyTarget;
state.keydownHandler = onVesselKeyDown; state.keydownHandler = onVesselKeyDown;
keyTarget.addEventListener('keydown', state.keydownHandler); keyTarget.addEventListener('keydown', state.keydownHandler);
// Vessels never set viewer.trackedEntity, so any new tracked entity belongs // Vessels never set viewer.trackedEntity, so any new tracked entity belongs
// to another layer and takes interaction ownership of the scene. // to another layer and takes interaction ownership of the scene.
state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(() => { state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(
if (viewer.trackedEntity && state.selectedRecord) clearVesselInspection(); () => {
}); if (viewer.trackedEntity && state.selectedRecord)
} clearVesselInspection();
/** Select one live vessel and request one UI-owned camera transfer. */
function selectAndFocusVessel(record) {
if (!record?.mmsi) return false;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: record.mmsi,
gesture: 'click',
});
if (transition.action === 'select') selectVessel(record);
requestWorldFocus({
kind: 'vessel',
id: record.mmsi,
label: record.name || record.mmsi,
position: record.billboard?.position || record.position,
});
return true;
}
function removeVesselInteraction() {
if (state.clickHandler) {
state.clickHandler.destroy();
state.clickHandler = null;
}
if (state.keyTarget && state.keydownHandler) {
state.keyTarget.removeEventListener('keydown', state.keydownHandler);
}
state.keyTarget = null;
state.keydownHandler = null;
if (state.trackedEntityRemover) {
state.trackedEntityRemover();
state.trackedEntityRemover = null;
}
}
function onVesselKeyDown(event) {
if (!state.enabled || event.key !== 'Escape') return;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
gesture: 'escape',
});
if (transition.action === 'deselect') {
clearVesselInspection();
}
}
function selectVessel(record) {
if (!record?.mmsi) return;
const reuseTrail = state.trailMmsi === record.mmsi;
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;
}
// Rebuild the card set immediately so the full-detail card appears on the
// click, not up to VISIBILITY_UPDATE_MS later.
components.rendering.updateVisibility(true);
components.cards.updateSelectedVesselHud(record);
if (registerSelectedContext(record)) {
selectEntityContext(record);
}
// Track-history trail (PRD F3/F4): seed with the current position + async
// backfill from the server-side per-MMSI ring buffer.
if (reuseTrail) {
components.tracking.appendSelectedVesselTrailFix(record);
} else {
components.tracking.startSelectedVesselTrail(record);
}
}
/**
* Register (or refresh) the selected vessel in the shared context store so
* the realtime/voice layer can describe what the user has selected.
* @param {Object} record - Selected vessel record.
* @returns {Object|null} The context record, or null if registration failed.
*/
function registerSelectedContext(record) {
if (!record?.mmsi) return null;
try {
return registerEntityContext(record, {
id: `ais-${record.mmsi}`,
layerId: 'ais-live-vessels',
layerName: 'Live AIS Vessels',
source: aisLiveVesselsLayer.source,
label: components.cards.displayVesselName(record),
latitude: record.lat,
longitude: record.lon,
properties: {
mmsi: record.mmsi,
type: record.type,
speedKt: record.speed,
course: record.course,
destination: record.destination,
}, },
);
}
/** Select one live vessel and request one UI-owned camera transfer. */
function selectAndFocusVessel(record) {
if (!record?.mmsi) return false;
const transition = components.queries.reduceVesselSelection({
selectedMmsi: state.selectedRecord?.mmsi,
pickedMmsi: record.mmsi,
gesture: 'click',
}); });
} catch (error) { if (transition.action === 'select') selectVessel(record);
console.warn('[Data:ais-live-vessels] context register failed', error); requestWorldFocus({
return null; kind: 'vessel',
id: record.mmsi,
label: record.name || record.mmsi,
position: record.billboard?.position || record.position,
});
return true;
} }
}
function removeVesselInteraction() {
if (state.clickHandler) {
function clearSelection({ preserveTrail = false, evicted = false } = {}) { state.clickHandler.destroy();
const record = state.selectedRecord; state.clickHandler = null;
if (record?.billboard) { }
record.billboard.image = components.rendering.shipIcon(record, false); if (state.keyTarget && state.keydownHandler) {
record.billboard.scale = components.rendering.shipScale(record); state.keyTarget.removeEventListener('keydown', state.keydownHandler);
}
state.keyTarget = null;
state.keydownHandler = null;
if (state.trackedEntityRemover) {
state.trackedEntityRemover();
state.trackedEntityRemover = null;
}
} }
state.selectedRecord = null;
// Drop the full-detail card right away (no-op when the layer is disabled — function onVesselKeyDown(event) {
// disable() clears the entry set itself). if (!state.enabled || event.key !== 'Escape') return;
if (record && state.enabled) components.rendering.updateVisibility(true); const transition = components.queries.reduceVesselSelection({
if (!preserveTrail) components.tracking.clearSelectedVesselTrail(); selectedMmsi: state.selectedRecord?.mmsi,
try { gesture: 'escape',
clearSelectedEntityContextForLayer('ais-live-vessels', { evicted }); });
} catch (error) { if (transition.action === 'deselect') {
console.warn('[Data:ais-live-vessels] context clear failed', error); clearVesselInspection();
}
} }
}
function selectVessel(record) {
if (!record?.mmsi) return;
const reuseTrail = state.trailMmsi === record.mmsi;
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;
}
// Rebuild the card set immediately so the full-detail card appears on the
// click, not up to VISIBILITY_UPDATE_MS later.
components.rendering.updateVisibility(true);
components.cards.updateSelectedVesselHud(record);
if (registerSelectedContext(record)) {
selectEntityContext(record);
}
// Track-history trail (PRD F3/F4): seed with the current position + async
// backfill from the server-side per-MMSI ring buffer.
if (reuseTrail) {
components.tracking.appendSelectedVesselTrailFix(record);
} else {
components.tracking.startSelectedVesselTrail(record);
}
}
/** /**
* @param {object} [options] Clear origin. * Register (or refresh) the selected vessel in the shared context store so
* @param {boolean} [options.evicted=false] The vessel aged out of the feed * the realtime/voice layer can describe what the user has selected.
* rather than being deselected. * @param {Object} record - Selected vessel record.
*/ * @returns {Object|null} The context record, or null if registration failed.
*/
function clearVesselInspection({ evicted = false } = {}) { function registerSelectedContext(record) {
clearSelection({ evicted }); if (!record?.mmsi) return null;
components.cards.resetSelectedVesselHud(); try {
} return registerEntityContext(record, {
return { installInteraction, bindVesselInteraction, selectAndFocusVessel, removeVesselInteraction, onVesselKeyDown, selectVessel, registerSelectedContext, clearSelection, clearVesselInspection }; id: `ais-${record.mmsi}`,
layerId: 'ais-live-vessels',
layerName: 'Live AIS Vessels',
source: aisLiveVesselsLayer.source,
label: components.cards.displayVesselName(record),
latitude: record.lat,
longitude: record.lon,
properties: {
mmsi: record.mmsi,
type: record.type,
speedKt: record.speed,
course: record.course,
destination: record.destination,
},
});
} catch (error) {
console.warn('[Data:ais-live-vessels] context register failed', error);
return null;
}
}
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);
}
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 (!preserveTrail) components.tracking.clearSelectedVesselTrail();
try {
clearSelectedEntityContextForLayer('ais-live-vessels', { evicted });
} catch (error) {
console.warn('[Data:ais-live-vessels] context clear failed', error);
}
}
/**
* @param {object} [options] Clear origin.
* @param {boolean} [options.evicted=false] The vessel aged out of the feed
* rather than being deselected.
*/
function clearVesselInspection({ evicted = false } = {}) {
clearSelection({ evicted });
components.cards.resetSelectedVesselHud();
}
return {
installInteraction,
bindVesselInteraction,
selectAndFocusVessel,
removeVesselInteraction,
onVesselKeyDown,
selectVessel,
registerSelectedContext,
clearSelection,
clearVesselInspection,
};
} }
+90 -98
View File
@@ -2,115 +2,107 @@ import * as Cesium from 'cesium';
import { DEFAULT_AIS_RUNTIME } from './policy.js'; import { DEFAULT_AIS_RUNTIME } from './policy.js';
export function createVesselState({ source, services }) { export function createVesselState({ source, services }) {
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource, hitTestWorldOverlay } = services.overlay; const {
const vesselState = {}; setOverlayEntries,
setOverlaySourceVisible,
clearOverlaySource,
hitTestWorldOverlay,
} = services.overlay;
const vesselState = {};
vesselState._source = source;
/** Camera pose signature at the last vessel rotation pass. */
vesselState._source = source; vesselState._lastCamPoseSig = '';
vesselState._scratchFocusScreen = new Cesium.Cartesian2();
/** Camera pose signature at the last vessel rotation pass. */ vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
hitTest: hitTestWorldOverlay,
});
vesselState._lastCamPoseSig = ''; vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
vesselState._scratchFocusScreen = new Cesium.Cartesian2(); vesselState._aisSessionSequence = 0;
/**
* True once the EGM96 geoid grid has loaded (fire-and-forget warm at
* enable(), aircraft idiom — see militaryFlights.js). Gates all synchronous
* geoidHeight() reads so a poll can never throw pre-load.
* @type {boolean}
*/
vesselState._geoidReady = false;
vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({ /** @type {Map<string, string>} `${cssColor}:${variant}` -> chevron SVG data URL */
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
clearSource: clearOverlaySource,
hitTest: hitTestWorldOverlay,
});
vesselState.shipIconCache = new Map();
vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST; vesselState.state = {
viewer: null,
enabled: false,
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME; loading: false,
loaded: false,
stale: false,
vesselState._aisSessionSequence = 0; error: null,
loadingLabel: '',
lastUpdate: null,
/** count: 0,
* True once the EGM96 geoid grid has loaded (fire-and-forget warm at newestPositionAt: null,
* enable(), aircraft idiom — see militaryFlights.js). Gates all synchronous transportStatus: null,
* geoidHeight() reads so a poll can never throw pre-load. /** Server epoch-ms of the next reconnect attempt while the feed is degraded. */
* @type {boolean} nextAttemptAt: null,
*/ lastMessageAt: null,
rawRowCount: 0,
vesselState._geoidReady = false; acceptedRowCount: 0,
/** Monotonic enable/reset owner for requests and first-connect timers. */
sessionId: 0,
/** @type {Map<string, string>} `${cssColor}:${variant}` -> chevron SVG data URL */ /** @type {'idle'|'loading'|'ready'|'unavailable'} */
firstConnectPhase: 'idle',
vesselState.shipIconCache = new Map(); firstConnectStartedAt: null,
firstConnectDeadline: null,
firstConnectTimer: null,
abort: null,
vesselState.state = { billboardCollection: null,
viewer: null, /** @type {Array<Object>} Flat render list: keyed records + unkeyed records */
enabled: false, vesselRecords: [],
loading: false, /** @type {Map<string, Object>} MMSI -> vessel record (identity across refreshes) */
loaded: false, vesselMap: new Map(),
stale: false, /** @type {Array<Object>} Records with no MMSI — rebuilt fresh each refresh */
error: null, unkeyedRecords: [],
loadingLabel: '', clickHandler: null,
lastUpdate: null, /** Exact EventTarget currently holding the Escape listener. */
count: 0, keyTarget: null,
newestPositionAt: null, /** Exact callback registered on keyTarget. */
transportStatus: null, keydownHandler: null,
/** Server epoch-ms of the next reconnect attempt while the feed is degraded. */ /** Cesium trackedEntityChanged listener disposer. */
nextAttemptAt: null, trackedEntityRemover: null,
lastMessageAt: null, /** Test-only factory used to exercise enable-time interaction installation. */
rawRowCount: 0, interactionHandlerFactory: null,
acceptedRowCount: 0, /** Test-only key target paired with interactionHandlerFactory. */
/** Monotonic enable/reset owner for requests and first-connect timers. */ interactionKeyTarget: null,
sessionId: 0, preRenderRemover: null,
/** @type {'idle'|'loading'|'ready'|'unavailable'} */ lastVisibilityUpdate: 0,
firstConnectPhase: 'idle', lastFocusUpdate: 0,
firstConnectStartedAt: null, /** Sprites whose animated emphasis remains outside the 1.0 deadband. */
firstConnectDeadline: null, activeFocusCount: 0,
firstConnectTimer: null, activeLabelCount: 0,
abort: null, selectedRecord: null,
billboardCollection: null, /** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
/** @type {Array<Object>} Flat render list: keyed records + unkeyed records */ trail: null,
vesselRecords: [], /** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
/** @type {Map<string, Object>} MMSI -> vessel record (identity across refreshes) */ trailPositions: [],
vesselMap: new Map(), /** @type {string|null} MMSI that owns the active selected-vessel trail. */
/** @type {Array<Object>} Records with no MMSI — rebuilt fresh each refresh */ trailMmsi: null,
unkeyedRecords: [], /** @type {number} Monotonic token — invalidates in-flight backfill responses */
clickHandler: null, trailBackfillToken: 0,
/** Exact EventTarget currently holding the Escape listener. */ trailAbort: null,
keyTarget: null, };
/** Exact callback registered on keyTarget. */ return vesselState;
keydownHandler: null,
/** Cesium trackedEntityChanged listener disposer. */
trackedEntityRemover: null,
/** Test-only factory used to exercise enable-time interaction installation. */
interactionHandlerFactory: null,
/** Test-only key target paired with interactionHandlerFactory. */
interactionKeyTarget: null,
preRenderRemover: null,
lastVisibilityUpdate: 0,
lastFocusUpdate: 0,
/** Sprites whose animated emphasis remains outside the 1.0 deadband. */
activeFocusCount: 0,
activeLabelCount: 0,
selectedRecord: null,
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
trail: null,
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
trailPositions: [],
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
trailMmsi: null,
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
trailBackfillToken: 0,
trailAbort: null,
};
return vesselState;
} }
+188 -167
View File
@@ -2,190 +2,211 @@ import * as Cesium from 'cesium';
import { PARTIAL_RETENTION_MS } from './policy.js'; import { PARTIAL_RETENTION_MS } from './policy.js';
import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js'; import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js';
export function createStore({ vesselState, services, parts: components, layer, options }) { export function createStore({
const { state } = vesselState; 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 } = {}) {
* Reconcile the incoming AIS rows against the MMSI-keyed record map. const receivedAtMs = vesselState._aisRuntime.now();
* Existing records are updated in place (position/heading/label) so identity components.rendering.ensureCollections(viewer);
* 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 } = {}) { // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them.
const receivedAtMs = vesselState._aisRuntime.now(); for (const record of state.unkeyedRecords) {
components.rendering.ensureCollections(viewer); components.rendering.removeRecordPrimitives(record);
// 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 state.unkeyedRecords = [];
seen.add(next.mmsi);
const existing = state.vesselMap.get(next.mmsi); const occluder = components.rendering.makeOccluder();
if (existing) { const seen = new Set();
updateRecordInPlace(existing, next); for (let index = 0; index < rows.length; index += 1) {
} else { const next = normalizeVessel(rows[index]);
components.rendering.addRecordPrimitives(next, occluder); if (!next) continue;
state.vesselMap.set(next.mmsi, next); next.receivedAtMs = receivedAtMs;
}
}
// Remove vanished vessels, pinning the selected one for a few refreshes. if (!next.mmsi) {
for (const [mmsi, record] of state.vesselMap) { components.rendering.addRecordPrimitives(next, occluder);
if (seen.has(mmsi)) continue; state.unkeyedRecords.push(next);
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; continue;
} }
// Aged out of the feed after exhausting its pin — not a deselect. if (seen.has(next.mmsi)) continue; // defensive: dedupe payload rows
components.selection.clearVesselInspection({ evicted: true }); seen.add(next.mmsi);
}
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(); const existing = state.vesselMap.get(next.mmsi);
if (state.vesselMap.size + state.unkeyedRecords.length > cap) { 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) { for (const [mmsi, record] of state.vesselMap) {
if (state.vesselMap.size + state.unkeyedRecords.length <= cap) break; if (seen.has(mmsi)) continue;
if (seen.has(mmsi) || record === state.selectedRecord) 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); components.rendering.removeRecordPrimitives(record);
state.vesselMap.delete(mmsi); state.vesselMap.delete(mmsi);
if (state.trailMmsi === mmsi) components.tracking.clearSelectedVesselTrail(); // 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);
} }
} }
state.vesselRecords = [...state.vesselMap.values(), ...state.unkeyedRecords];
state.lastVisibilityUpdate = 0;
components.rendering.updateVisibility(true);
}
function normalizeVessel(row) {
/** const lat = Number(row.lat);
* Update an existing record (and its primitives) from a freshly normalized row, const lon = Number(row.lon);
* preserving object identity so selection and the click-pick id stay valid. if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
* Billboard image is only reassigned when the resolved icon actually changes // Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the
* (type recolor) to avoid thousands of redundant texture lookups per refresh. // ellipsoid — at height 0 everything that projects record.position (clicks,
* @param {Object} record - Existing vessel record in state.vesselMap. // detection brackets, cards, getNearby) points up to ~45 m under the water.
* @param {Object} next - Freshly normalized record for the same MMSI. const heightM = components.queries.vesselDatumHeightM(
*/ components.tracking.currentGeoidN(lat, lon),
VESSEL_LIFT_M,
function updateRecordInPlace(record, next) { );
const selected = record === state.selectedRecord; const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
const prevIcon = components.rendering.shipIcon(record, selected); // Surface normal at this position — used as alignedAxis so billboard
// rotation operates in the local tangent plane (true world heading)
record.reference = next.reference; const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
record.receivedAtMs = next.receivedAtMs; position,
record.lat = next.lat; new Cesium.Cartesian3(),
record.lon = next.lon; );
record.name = next.name; return {
record.imo = next.imo; lat,
record.type = next.type; lon,
record.destination = next.destination; name: String(row.name || row.mmsi || 'VESSEL'),
record.speed = next.speed; mmsi: String(row.mmsi || '').trim(),
record.course = next.course; reference: row.reference ?? String(row.mmsi || '').trim(),
record.heading = next.heading; imo: String(row.imo || ''),
record.lastPositionUtc = next.lastPositionUtc; type: String(row.type || ''),
record.lastPositionEpoch = next.lastPositionEpoch; destination: String(row.destination || ''),
record.position = next.position; speed: finiteNumber(row.speed),
record.surfacePosition = next.surfacePosition; course: finiteNumber(row.course),
record.normal = next.normal; heading: finiteNumber(row.heading),
record.missedRefreshes = 0; lastPositionUtc: String(row.last_position_UTC || ''),
lastPositionEpoch: finiteNumber(row.last_position_epoch),
if (record.billboard) { position,
record.billboard.position = record.position; // Ellipsoid-surface point (height 0) — feeds ONLY the horizon occluder,
// Rotation is owned by the projected-rotation pass (updateVisibility). // which tests against the WGS84 ellipsoid; keep it off the sea datum.
record.billboard.scale = components.rendering.shipScale(record) * (selected ? 1.2 : 1); surfacePosition: Cesium.Cartesian3.fromDegrees(lon, lat, 0),
const nextIcon = components.rendering.shipIcon(record, selected); normal,
if (nextIcon !== prevIcon) { missedRefreshes: 0,
record.billboard.image = nextIcon; billboard: null,
} };
} }
if (record.mmsi === state.trailMmsi) {
components.tracking.appendSelectedVesselTrailFix(record); function finiteNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
} }
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 { return {
lat, reconcileVessels,
lon, updateRecordInPlace,
name: String(row.name || row.mmsi || 'VESSEL'), normalizeVessel,
mmsi: String(row.mmsi || '').trim(), finiteNumber,
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 };
}
+152 -136
View File
@@ -1,163 +1,179 @@
import { DEFAULT_AIS_RUNTIME } from './policy.js'; import { DEFAULT_AIS_RUNTIME } from './policy.js';
export function createTesting({ vesselState, services, parts: components, layer, options }) { export function createTesting({
const { state } = vesselState; vesselState,
const aisLiveVesselsLayer = layer; services,
parts: components,
layer,
options,
}) {
const { state } = vesselState;
const aisLiveVesselsLayer = layer;
/**
* Bind the production interaction callbacks to mockable viewer/handler
* surfaces. Test-only seam; behavior is shared with installInteraction().
* @param {Object} viewer - Viewer-like object with scene.pick().
* @param {Object} handler - Handler-like object with setInputAction().
* @param {Object} keyTarget - EventTarget-like object with add/removeEventListener().
* @returns {void}
*/
/** function _bindVesselInteractionForTest(viewer, handler, keyTarget) {
* Bind the production interaction callbacks to mockable viewer/handler components.selection.bindVesselInteraction(viewer, handler, keyTarget);
* surfaces. Test-only seam; behavior is shared with installInteraction(). }
* @param {Object} viewer - Viewer-like object with scene.pick().
* @param {Object} handler - Handler-like object with setInputAction().
* @param {Object} keyTarget - EventTarget-like object with add/removeEventListener().
* @returns {void}
*/
function _bindVesselInteractionForTest(viewer, handler, keyTarget) { /**
components.selection.bindVesselInteraction(viewer, handler, keyTarget); * Prime the minimum live state needed by interaction/lifecycle wire tests.
} * @param {Object} [options={}] - Test state values.
* @returns {void}
*/
function _setVesselStateForTest(options = {}) {
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(
records
.filter((record) => record?.mmsi)
.map((record) => [record.mmsi, record]),
);
state.selectedRecord = options.selectedRecord || null;
state.billboardCollection = options.billboardCollection || { remove() {} };
state.trail = options.trail || null;
state.trailMmsi = options.trailMmsi || null;
state.trailPositions = Array.isArray(options.trailPositions)
? [...options.trailPositions]
: [];
state.transportStatus = options.transportStatus || null;
state.lastMessageAt = options.lastMessageAt ?? null;
state.rawRowCount = Number.isFinite(options.rawRowCount)
? options.rawRowCount
: 0;
state.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.interactionHandlerFactory = options.interactionHandlerFactory || null;
state.interactionKeyTarget = options.interactionKeyTarget || null;
}
/** /** Inject a host recorder for lifecycle/contract tests; null restores production. */
* Prime the minimum live state needed by interaction/lifecycle wire tests.
* @param {Object} [options={}] - Test state values.
* @returns {void}
*/
function _setVesselStateForTest(options = {}) { function _setVesselOverlayHostForTest(host = null) {
components.lifecycle.resetState(); vesselState._vesselOverlayHost =
const records = Array.isArray(options.records) ? options.records : []; host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
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(
records.filter((record) => record?.mmsi).map((record) => [record.mmsi, record])
);
state.selectedRecord = options.selectedRecord || null;
state.billboardCollection = options.billboardCollection || { remove() {} };
state.trail = options.trail || null;
state.trailMmsi = options.trailMmsi || null;
state.trailPositions = Array.isArray(options.trailPositions) ? [...options.trailPositions] : [];
state.transportStatus = options.transportStatus || null;
state.lastMessageAt = options.lastMessageAt ?? null;
state.rawRowCount = Number.isFinite(options.rawRowCount) ? options.rawRowCount : 0;
state.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.interactionHandlerFactory = options.interactionHandlerFactory || null;
state.interactionKeyTarget = options.interactionKeyTarget || null;
}
/** Exercise the production selector/publisher through a test-owned state. */
/** Inject a host recorder for lifecycle/contract tests; null restores production. */ function _updateVesselCardsForTest(records = []) {
components.rendering.updateClusteredLabels(records);
}
function _setVesselOverlayHostForTest(host = null) { /**
vesselState._vesselOverlayHost = host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST; * Reconcile AIS rows through the production lifecycle. Test-only seam.
} * @param {Object} viewer - Viewer-like object.
* @param {Array<Object>} rows - Raw AIS rows.
* @returns {void}
*/
function _reconcileVesselsForTest(viewer, rows) {
components.store.reconcileVessels(viewer, rows);
}
/** Exercise the production selector/publisher through a test-owned state. */ /** Apply one server snapshot through the production pre-reconcile health gate. */
function _updateVesselCardsForTest(records = []) { function _applyAisFeedSnapshotForTest(viewer, payload) {
components.rendering.updateClusteredLabels(records); return components.ingestion.applyAisFeedSnapshot(viewer, payload);
} }
/** Exercise the request-owned live loader with a test-controlled fetch. */
/** function _loadLivePositionsForTest(viewer) {
* Reconcile AIS rows through the production lifecycle. Test-only seam. return components.ingestion.loadLivePositions(viewer);
* @param {Object} viewer - Viewer-like object. }
* @param {Array<Object>} rows - Raw AIS rows.
* @returns {void}
*/
function _reconcileVesselsForTest(viewer, rows) { /** Start the production first-connect grace state without installing UI. */
components.store.reconcileVessels(viewer, rows);
}
function _beginAisSessionForTest() {
components.lifecycle.beginAisSession();
}
/** Apply one server snapshot through the production pre-reconcile health gate. */ /** Inject a deterministic clock/scheduler; null restores production runtime. */
function _applyAisFeedSnapshotForTest(viewer, payload) { function _setAisRuntimeForTest(runtime = null) {
return components.ingestion.applyAisFeedSnapshot(viewer, payload); components.lifecycle.clearFirstConnectTimer();
} vesselState._aisRuntime = runtime
? {
now: runtime.now,
setTimeout: runtime.setTimeout,
clearTimeout: runtime.clearTimeout,
}
: DEFAULT_AIS_RUNTIME;
}
/** Read feed-health fields without exposing mutable production state. */
/** Exercise the request-owned live loader with a test-controlled fetch. */ function _getVesselFeedStateForTest() {
const stats = aisLiveVesselsLayer.getStats();
return {
count: state.count,
loaded: state.loaded,
loading: stats.loading,
loadingLabel: stats.loadingLabel,
stale: state.stale,
error: state.error,
status: stats.status,
lastUpdate: state.lastUpdate,
transportStatus: state.transportStatus,
lastMessageAt: state.lastMessageAt,
rawRowCount: state.rawRowCount,
acceptedRowCount: state.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,
};
}
function _loadLivePositionsForTest(viewer) { /**
return components.ingestion.loadLivePositions(viewer); * Read lifecycle ownership state without exposing the mutable state object.
} * Test-only seam.
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
*/
function _getVesselStateForTest() {
/** Start the production first-connect grace state without installing UI. */ return {
trailMmsi: state.trailMmsi,
function _beginAisSessionForTest() { trailPositionCount: state.trailPositions.length,
components.lifecycle.beginAisSession(); vesselCount: state.vesselMap.size,
} };
}
/** Inject a deterministic clock/scheduler; null restores production runtime. */
function _setAisRuntimeForTest(runtime = null) {
components.lifecycle.clearFirstConnectTimer();
vesselState._aisRuntime = runtime
? {
now: runtime.now,
setTimeout: runtime.setTimeout,
clearTimeout: runtime.clearTimeout,
}
: DEFAULT_AIS_RUNTIME;
}
/** Read feed-health fields without exposing mutable production state. */
function _getVesselFeedStateForTest() {
const stats = aisLiveVesselsLayer.getStats();
return { return {
count: state.count, _bindVesselInteractionForTest,
loaded: state.loaded, _setVesselStateForTest,
loading: stats.loading, _setVesselOverlayHostForTest,
loadingLabel: stats.loadingLabel, _updateVesselCardsForTest,
stale: state.stale, _reconcileVesselsForTest,
error: state.error, _applyAisFeedSnapshotForTest,
status: stats.status, _loadLivePositionsForTest,
lastUpdate: state.lastUpdate, _beginAisSessionForTest,
transportStatus: state.transportStatus, _setAisRuntimeForTest,
lastMessageAt: state.lastMessageAt, _getVesselFeedStateForTest,
rawRowCount: state.rawRowCount, _getVesselStateForTest,
acceptedRowCount: state.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,
}; };
} }
/**
* Read lifecycle ownership state without exposing the mutable state object.
* Test-only seam.
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
*/
function _getVesselStateForTest() {
return {
trailMmsi: state.trailMmsi,
trailPositionCount: state.trailPositions.length,
vesselCount: state.vesselMap.size,
};
}
return { _bindVesselInteractionForTest, _setVesselStateForTest, _setVesselOverlayHostForTest, _updateVesselCardsForTest, _reconcileVesselsForTest, _applyAisFeedSnapshotForTest, _loadLivePositionsForTest, _beginAisSessionForTest, _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest };
}
+195 -148
View File
@@ -1,167 +1,214 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { TRAIL_HEIGHT_M, VESSEL_LIFT_M, TRAIL_COLOR, TRAIL_MAX_POINTS, TRAIL_MIN_MOVE_M } from './policy.js'; import {
TRAIL_HEIGHT_M,
VESSEL_LIFT_M,
TRAIL_COLOR,
TRAIL_MAX_POINTS,
TRAIL_MIN_MOVE_M,
} from './policy.js';
export function createTracking({ vesselState, services, parts: components, layer, options }) { export function createTracking({
const { state } = vesselState; vesselState,
const { geoidHeight } = services.geoid; services,
const { createTrail } = services.trails; parts: components,
layer,
options,
}) {
const { state } = vesselState;
const { geoidHeight } = services.geoid;
const { createTrail } = services.trails;
/**
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
/** function currentGeoidN(lat, lon) {
* Geoid undulation N at (lat, lon), or null until the grid has loaded. return vesselState._geoidReady ? geoidHeight(lat, lon) : null;
* @param {number} lat
* @param {number} lon
* @returns {number|null}
*/
function currentGeoidN(lat, lon) {
return vesselState._geoidReady ? geoidHeight(lat, lon) : null;
}
/**
* Build a slightly lifted trail vertex for a vessel record — raised
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
* to avoid z-fighting.
* @param {Object} record - Vessel record with lat/lon.
* @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
*/
function vesselTrailPosition(record) {
if (!Number.isFinite(record?.lat) || !Number.isFinite(record?.lon)) return null;
const heightM = components.queries.vesselDatumHeightM(currentGeoidN(record.lat, record.lon), TRAIL_HEIGHT_M);
return Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM);
}
/**
* One-shot datum re-lift when the geoid grid warms mid-session: the first
* refresh can land before ensureGeoidReady() resolves (anchors at N = 0) and
* the next refresh is up to REFRESH_MS out — re-derive every record's
* position in place so chevrons/labels snap to the sea surface as soon as N
* is known. (A selected-vessel trail cannot exist that early — selection
* needs a rendered pick — so trail vertices are not revisited.)
*/
function refloorVesselRecords() {
if (!state.vesselRecords.length) return;
for (const record of state.vesselRecords) {
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(record.lon, record.lat, heightM);
if (record.billboard) record.billboard.position = record.position;
} }
}
/**
* Build a slightly lifted trail vertex for a vessel record — raised
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
* to avoid z-fighting.
* @param {Object} record - Vessel record with lat/lon.
* @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
*/
/** function vesselTrailPosition(record) {
* Start (or restart) the selected vessel's trail: seed with the current if (!Number.isFinite(record?.lat) || !Number.isFinite(record?.lon))
* position, render, then fire-and-forget the server ring-buffer backfill. return null;
* @param {Object} record - Freshly selected vessel record. const heightM = components.queries.vesselDatumHeightM(
*/ currentGeoidN(record.lat, record.lon),
TRAIL_HEIGHT_M,
function startSelectedVesselTrail(record) { );
state.trailAbort?.abort(); return Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM);
state.trailAbort = new AbortController();
state.trailBackfillToken += 1;
state.trailMmsi = record.mmsi;
state.trailPositions = [];
const current = vesselTrailPosition(record);
if (current) state.trailPositions.push(current);
if (!state.trail && state.viewer) {
state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 });
} }
if (state.trail) state.trail.setPositions(state.trailPositions);
backfillVesselTrail(record.mmsi, state.trailBackfillToken, record.reference);
}
/**
* One-shot datum re-lift when the geoid grid warms mid-session: the first
* refresh can land before ensureGeoidReady() resolves (anchors at N = 0) and
* the next refresh is up to REFRESH_MS out — re-derive every record's
* position in place so chevrons/labels snap to the sea surface as soon as N
* is known. (A selected-vessel trail cannot exist that early — selection
* needs a rendered pick — so trail vertices are not revisited.)
*/
/** function refloorVesselRecords() {
* Fire-and-forget backfill from the server-side per-MMSI ring buffer if (!state.vesselRecords.length) return;
* (PRD F3 — "recent path" since server boot, not voyage history). Older for (const record of state.vesselRecords) {
* samples are spliced AHEAD of the live accumulation, capped at if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed) continue;
* silently keeps the live-only trail. const heightM = components.queries.vesselDatumHeightM(
* @param {string} mmsi - MMSI of the selected vessel. currentGeoidN(record.lat, record.lon),
* @param {number} token - Backfill token captured at request time. VESSEL_LIFT_M,
* @returns {Promise<void>} );
*/ record.position = Cesium.Cartesian3.fromDegrees(
record.lon,
async function backfillVesselTrail(mmsi, token, reference = mmsi) { record.lat,
const owner = state.trailAbort; heightM,
const signal = owner ? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000); );
let samples = null; if (record.billboard) record.billboard.position = record.position;
try { }
const track = await vesselState._source.getTrack?.(reference, { signal });
samples = track?.records ?? null;
} catch {
return; // silent — keep the live-accumulated trail
} }
if (signal.aborted || owner !== state.trailAbort || !samples || token !== state.trailBackfillToken) return;
if (state.trailMmsi !== mmsi) return;
const older = []; /**
for (const sample of samples) { * Start (or restart) the selected vessel's trail: seed with the current
const lat = Number(sample?.latitude); * position, render, then fire-and-forget the server ring-buffer backfill.
const lon = Number(sample?.longitude); * @param {Object} record - Freshly selected vessel record.
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue; */
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
// the live vertices so the spliced trail is height-continuous. function startSelectedVesselTrail(record) {
const heightM = components.queries.vesselDatumHeightM(currentGeoidN(lat, lon), TRAIL_HEIGHT_M); state.trailAbort?.abort();
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, heightM)); state.trailAbort = new AbortController();
state.trailBackfillToken += 1;
state.trailMmsi = record.mmsi;
state.trailPositions = [];
const current = vesselTrailPosition(record);
if (current) state.trailPositions.push(current);
if (!state.trail && state.viewer) {
state.trail = createTrail(state.viewer, {
color: TRAIL_COLOR,
width: 2.5,
});
}
if (state.trail) state.trail.setPositions(state.trailPositions);
backfillVesselTrail(
record.mmsi,
state.trailBackfillToken,
record.reference,
);
} }
if (!older.length) return;
state.trailPositions = older.concat(state.trailPositions); /**
if (state.trailPositions.length > TRAIL_MAX_POINTS) { * Fire-and-forget backfill from the server-side per-MMSI ring buffer
state.trailPositions = state.trailPositions.slice(state.trailPositions.length - TRAIL_MAX_POINTS); * (PRD F3 — "recent path" since server boot, not voyage history). Older
* samples are spliced AHEAD of the live accumulation, capped at
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
* silently keeps the live-only trail.
* @param {string} mmsi - MMSI of the selected vessel.
* @param {number} token - Backfill token captured at request time.
* @returns {Promise<void>}
*/
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
const owner = state.trailAbort;
const signal = owner
? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)])
: AbortSignal.timeout(8000);
let samples = null;
try {
const track = await vesselState._source.getTrack?.(reference, { signal });
samples = track?.records ?? null;
} catch {
return; // silent — keep the live-accumulated trail
}
if (
signal.aborted ||
owner !== state.trailAbort ||
!samples ||
token !== state.trailBackfillToken
)
return;
if (state.trailMmsi !== mmsi) return;
const older = [];
for (const sample of samples) {
const lat = Number(sample?.latitude);
const lon = Number(sample?.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
// the live vertices so the spliced trail is height-continuous.
const heightM = components.queries.vesselDatumHeightM(
currentGeoidN(lat, lon),
TRAIL_HEIGHT_M,
);
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, heightM));
}
if (!older.length) return;
state.trailPositions = older.concat(state.trailPositions);
if (state.trailPositions.length > TRAIL_MAX_POINTS) {
state.trailPositions = state.trailPositions.slice(
state.trailPositions.length - TRAIL_MAX_POINTS,
);
}
if (state.trail) state.trail.setPositions(state.trailPositions);
} }
if (state.trail) state.trail.setPositions(state.trailPositions);
}
/**
* Append the selected vessel's refreshed position to its trail when it has
* moved more than TRAIL_MIN_MOVE_M from the last trail vertex.
* @param {Object} record - Selected vessel record after an in-place update.
*/
/** function appendSelectedVesselTrailFix(record) {
* Append the selected vessel's refreshed position to its trail when it has if (!state.trail) return;
* moved more than TRAIL_MIN_MOVE_M from the last trail vertex. const next = vesselTrailPosition(record);
* @param {Object} record - Selected vessel record after an in-place update. if (!next) return;
*/ const last = state.trailPositions[state.trailPositions.length - 1];
if (last && Cesium.Cartesian3.distance(last, next) <= TRAIL_MIN_MOVE_M)
function appendSelectedVesselTrailFix(record) { return;
if (!state.trail) return; state.trailPositions.push(next);
const next = vesselTrailPosition(record); if (state.trailPositions.length > TRAIL_MAX_POINTS)
if (!next) return; state.trailPositions.shift();
const last = state.trailPositions[state.trailPositions.length - 1]; state.trail.setPositions(state.trailPositions);
if (last && Cesium.Cartesian3.distance(last, next) <= TRAIL_MIN_MOVE_M) return;
state.trailPositions.push(next);
if (state.trailPositions.length > TRAIL_MAX_POINTS) state.trailPositions.shift();
state.trail.setPositions(state.trailPositions);
}
/**
* Clear the rendered trail and accumulation; invalidate pending backfills.
*/
function clearSelectedVesselTrail() {
state.trailAbort?.abort();
state.trailAbort = null;
state.trailBackfillToken += 1;
state.trailMmsi = null;
state.trailPositions = [];
if (state.trail) state.trail.clear();
}
/**
* Destroy the trail primitive entirely (layer disable/teardown).
*/
function destroySelectedVesselTrail() {
clearSelectedVesselTrail();
if (state.trail) {
state.trail.destroy();
state.trail = null;
} }
}
return { currentGeoidN, vesselTrailPosition, refloorVesselRecords, startSelectedVesselTrail, backfillVesselTrail, appendSelectedVesselTrailFix, clearSelectedVesselTrail, destroySelectedVesselTrail }; /**
* Clear the rendered trail and accumulation; invalidate pending backfills.
*/
function clearSelectedVesselTrail() {
state.trailAbort?.abort();
state.trailAbort = null;
state.trailBackfillToken += 1;
state.trailMmsi = null;
state.trailPositions = [];
if (state.trail) state.trail.clear();
}
/**
* Destroy the trail primitive entirely (layer disable/teardown).
*/
function destroySelectedVesselTrail() {
clearSelectedVesselTrail();
if (state.trail) {
state.trail.destroy();
state.trail = null;
}
}
return {
currentGeoidN,
vesselTrailPosition,
refloorVesselRecords,
startSelectedVesselTrail,
backfillVesselTrail,
appendSelectedVesselTrailFix,
clearSelectedVesselTrail,
destroySelectedVesselTrail,
};
} }
+8 -2
View File
@@ -5,13 +5,19 @@ import { fileURLToPath } from 'node:url';
/** Read actual component owners for structural regression assertions. */ /** Read actual component owners for structural regression assertions. */
export function readLayerSource(file) { export function readLayerSource(file) {
const path = file instanceof URL ? fileURLToPath(file) : file; const path = file instanceof URL ? fileURLToPath(file) : file;
if (!['flights.js', 'militaryFlights.js', 'aisLiveVessels.js'].includes(basename(path))) if (
!['flights.js', 'militaryFlights.js', 'aisLiveVessels.js'].includes(
basename(path),
)
)
return readFileSync(path, 'utf8'); return readFileSync(path, 'utf8');
const directory = join( const directory = join(
dirname(path), dirname(path),
basename(path) === 'flights.js' basename(path) === 'flights.js'
? '../layers/flights' ? '../layers/flights'
: basename(path) === 'militaryFlights.js' ? '../layers/military' : '../layers/vessels', : basename(path) === 'militaryFlights.js'
? '../layers/military'
: '../layers/vessels',
); );
return readdirSync(directory) return readdirSync(directory)
.filter((name) => name.endsWith('.js')) .filter((name) => name.endsWith('.js'))