diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 839fd1623..1a782824d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,47 +10,27 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "application": { - "exports": [ - "./application" - ], - "modules": [ - "src/app/application.js" - ], + "exports": ["./application"], + "modules": ["src/app/application.js"], "external": [] }, "viewer": { - "exports": [ - "./application/viewer" - ], - "modules": [ - "src/app/viewer.js" - ], - "external": [ - "cesium" - ] + "exports": ["./application/viewer"], + "modules": ["src/app/viewer.js"], + "external": ["cesium"] }, "vite-build": { "runtime": "node", - "exports": [ - "./build/vite" - ], - "modules": [ - "build/vite.js" - ], - "external": [ - "vite-plugin-cesium" - ] + "exports": ["./build/vite"], + "modules": ["build/vite.js"], + "external": ["vite-plugin-cesium"] }, "live-providers": { "runtime": "node", - "exports": [ - "./server/providers/live" - ], + "exports": ["./server/providers/live"], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -65,24 +45,16 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": [ - "ws" - ] + "external": ["ws"] }, "adsb-lol-source": { - "exports": [ - "./sources/adsb-lol" - ], - "modules": [ - "src/data/adsbLolFallback.js" - ], + "exports": ["./sources/adsb-lol"], + "modules": ["src/data/adsbLolFallback.js"], "external": [] }, "place-providers": { "runtime": "node", - "exports": [ - "./server/providers/places" - ], + "exports": ["./server/providers/places"], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -97,19 +69,13 @@ "external": [] }, "place-payloads": { - "exports": [ - "./sources/places" - ], - "modules": [ - "src/data/placeProviderPayloads.js" - ], + "exports": ["./sources/places"], + "modules": ["src/data/placeProviderPayloads.js"], "external": [] }, "space-providers": { "runtime": "node", - "exports": [ - "./server/providers/space" - ], + "exports": ["./server/providers/space"], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -120,19 +86,13 @@ "external": [] }, "space-requests": { - "exports": [ - "./sources/space" - ], - "modules": [ - "src/data/spaceProviderRequests.js" - ], + "exports": ["./sources/space"], + "modules": ["src/data/spaceProviderRequests.js"], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": [ - "./server/providers/terrain" - ], + "exports": ["./server/providers/terrain"], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -140,50 +100,30 @@ "external": [] }, "terrain-source": { - "exports": [ - "./sources/terrain" - ], - "modules": [ - "src/data/terrainHeightsProxy.js" - ], + "exports": ["./sources/terrain"], + "modules": ["src/data/terrainHeightsProxy.js"], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": [ - "./server/providers/traffic" - ], - "modules": [ - "server/providers/traffic.js", - "src/data/tomtomTiles.js" - ], + "exports": ["./server/providers/traffic"], + "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], "external": [] }, "traffic-source": { - "exports": [ - "./sources/traffic" - ], - "modules": [ - "src/data/tomtomTiles.js" - ], + "exports": ["./sources/traffic"], + "modules": ["src/data/tomtomTiles.js"], "external": [] }, "firms-provider": { "runtime": "node", - "exports": [ - "./server/providers/firms" - ], - "modules": [ - "server/providers/firms.js", - "src/data/firmsCsv.js" - ], + "exports": ["./server/providers/firms"], + "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": [ - "./server/providers/gbfs" - ], + "exports": ["./server/providers/gbfs"], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -192,19 +132,13 @@ "external": [] }, "gbfs-source": { - "exports": [ - "./sources/gbfs" - ], - "modules": [ - "src/data/gbfsSource.js" - ], + "exports": ["./sources/gbfs"], + "modules": ["src/data/gbfsSource.js"], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": [ - "./server/providers/cctv" - ], + "exports": ["./server/providers/cctv"], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -221,9 +155,7 @@ }, "radio-provider": { "runtime": "node", - "exports": [ - "./server/providers/radio" - ], + "exports": ["./server/providers/radio"], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -237,9 +169,7 @@ }, "overpass-provider": { "runtime": "node", - "exports": [ - "./server/providers/overpass" - ], + "exports": ["./server/providers/overpass"], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -258,9 +188,7 @@ }, "military-installations-provider": { "runtime": "node", - "exports": [ - "./server/providers/military-installations" - ], + "exports": ["./server/providers/military-installations"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -277,9 +205,7 @@ }, "regional-provider": { "runtime": "node", - "exports": [ - "./server/providers/regional" - ], + "exports": ["./server/providers/regional"], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -298,9 +224,7 @@ }, "openai-provider": { "runtime": "node", - "exports": [ - "./server/providers/openai" - ], + "exports": ["./server/providers/openai"], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -320,9 +244,7 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": [ - "./server/standalone/key-setup" - ], + "exports": ["./server/standalone/key-setup"], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -333,9 +255,7 @@ "external": [] }, "place-search": { - "exports": [ - "./search" - ], + "exports": ["./search"], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -345,27 +265,17 @@ "external": [] }, "panel-disclosure": { - "exports": [ - "./ui/panels" - ], - "modules": [ - "src/ui/panelDisclosure.js" - ], + "exports": ["./ui/panels"], + "modules": ["src/ui/panelDisclosure.js"], "external": [] }, "surface-keyboard": { - "exports": [ - "./ui/surfaces" - ], - "modules": [ - "src/ui/surfaceKeyboard.js" - ], + "exports": ["./ui/surfaces"], + "modules": ["src/ui/surfaceKeyboard.js"], "external": [] }, "panel-rail-layout": { - "exports": [ - "./ui/layout" - ], + "exports": ["./ui/layout"], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -377,9 +287,7 @@ "external": [] }, "visual-input": { - "exports": [ - "./ui/input" - ], + "exports": ["./ui/input"], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -388,19 +296,12 @@ "external": [] }, "display-controls": { - "exports": [ - "./ui/display" - ], - "modules": [ - "src/ui/displayControls.js" - ], + "exports": ["./ui/display"], + "modules": ["src/ui/displayControls.js"], "external": [] }, "visual-effects": { - "exports": [ - "./ui/effects", - "./ui/effects/bloom" - ], + "exports": ["./ui/effects", "./ui/effects/bloom"], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -413,14 +314,10 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "map-source-controls": { - "exports": [ - "./ui/maps" - ], + "exports": ["./ui/maps"], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -430,10 +327,7 @@ "external": [] }, "layer-panel": { - "exports": [ - "./ui/layers", - "./ui/layers/feedback" - ], + "exports": ["./ui/layers", "./ui/layers/feedback"], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -444,9 +338,7 @@ "external": [] }, "location-controls": { - "exports": [ - "./ui/location" - ], + "exports": ["./ui/location"], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -457,9 +349,7 @@ "external": [] }, "radio-controls": { - "exports": [ - "./ui/radio" - ], + "exports": ["./ui/radio"], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -470,9 +360,7 @@ "external": [] }, "cctv-controls": { - "exports": [ - "./ui/cctv" - ], + "exports": ["./ui/cctv"], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -484,10 +372,7 @@ "external": [] }, "context-controls": { - "exports": [ - "./ui/context", - "./ui/context/policy" - ], + "exports": ["./ui/context", "./ui/context/policy"], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -529,14 +414,10 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "scene-controls": { - "exports": [ - "./ui/scenes" - ], + "exports": ["./ui/scenes"], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -545,9 +426,7 @@ "external": [] }, "ui-shell": { - "exports": [ - "./ui/shell" - ], + "exports": ["./ui/shell"], "modules": [ "src/bloom.js", "src/cctvFocusPolicy.js", @@ -643,14 +522,10 @@ "src/ui/uiLifetime.js", "src/app/stateChannel.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "ui-styles": { - "exports": [ - "./ui/styles" - ], + "exports": ["./ui/styles"], "modules": [ "src/ui/styles.js", "style.css", @@ -677,9 +552,7 @@ "external": [] }, "scenes": { - "exports": [ - "./scenes" - ], + "exports": ["./scenes"], "modules": [ "src/scenes/director.js", "src/scenes/recipes.js", @@ -691,14 +564,10 @@ "src/ui/scenePresentation.js", "src/contextModePolicy.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "live-observations": { - "exports": [ - "./sources/live" - ], + "exports": ["./sources/live"], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -709,9 +578,7 @@ "external": [] }, "civil-flights": { - "exports": [ - "./layers/flights" - ], + "exports": ["./layers/flights"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -742,14 +609,10 @@ "src/layers/flights/testing.js", "src/layers/flights/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "military-flights": { - "exports": [ - "./layers/military" - ], + "exports": ["./layers/military"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -777,14 +640,10 @@ "src/layers/military/testing.js", "src/layers/military/tracking.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "aircraft-mechanics": { - "exports": [ - "./aircraft" - ], + "exports": ["./aircraft"], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -804,14 +663,10 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] }, "vessel-layer": { - "exports": [ - "./layers/vessels" - ], + "exports": ["./layers/vessels"], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -829,8 +684,6 @@ "src/data/iconOrientation.js", "src/data/vesselLabels.js" ], - "external": [ - "cesium" - ] + "external": ["cesium"] } } diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index a58fc9642..9a63a36cd 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -11,26 +11,60 @@ import * as focus from './focusDeemphasis.js'; import * as worldFocus from '../worldFocus.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 const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError; -export const classifyAisFeedSnapshot = aisLiveVesselsLayer.classifyAisFeedSnapshot; +export const classifyAisFeedSnapshot = + aisLiveVesselsLayer.classifyAisFeedSnapshot; export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord; export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM; export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection; -export const applyVesselFocusDeemphasis = aisLiveVesselsLayer.applyVesselFocusDeemphasis; +export const applyVesselFocusDeemphasis = + aisLiveVesselsLayer.applyVesselFocusDeemphasis; export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard; -export const buildSelectedVesselCard = aisLiveVesselsLayer.buildSelectedVesselCard; +export const buildSelectedVesselCard = + aisLiveVesselsLayer.buildSelectedVesselCard; export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated; -export const _bindVesselInteractionForTest = aisLiveVesselsLayer.testing._bindVesselInteractionForTest; -export const _setVesselStateForTest = aisLiveVesselsLayer.testing._setVesselStateForTest; -export const _setVesselOverlayHostForTest = aisLiveVesselsLayer.testing._setVesselOverlayHostForTest; -export const _updateVesselCardsForTest = aisLiveVesselsLayer.testing._updateVesselCardsForTest; -export const _reconcileVesselsForTest = aisLiveVesselsLayer.testing._reconcileVesselsForTest; -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 const _bindVesselInteractionForTest = + aisLiveVesselsLayer.testing._bindVesselInteractionForTest; +export const _setVesselStateForTest = + aisLiveVesselsLayer.testing._setVesselStateForTest; +export const _setVesselOverlayHostForTest = + aisLiveVesselsLayer.testing._setVesselOverlayHostForTest; +export const _updateVesselCardsForTest = + aisLiveVesselsLayer.testing._updateVesselCardsForTest; +export const _reconcileVesselsForTest = + aisLiveVesselsLayer.testing._reconcileVesselsForTest; +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; diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index f503233a5..d4341d363 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -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 }) { -const { state } = vesselState; +export function createCards({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + function updateSelectedVesselHud(record) { + const el = document.getElementById('hud-ais-vessel'); + if (!el) return; - -function updateSelectedVesselHud(record) { - const el = document.getElementById('hud-ais-vessel'); - if (!el) return; - - // Pinned vessels missing from recent refreshes get a stale marker - const stale = (record.missedRefreshes || 0) > 0; - el.classList.add('active'); - 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; + // Pinned vessels missing from recent refreshes get a stale marker + const stale = (record.missedRefreshes || 0) > 0; + el.classList.add('active'); + 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'); } - 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(); - if (name && name !== 'VESSEL' && name !== record.mmsi) return name; - return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL'; -} + /** + * 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 formatSpeed(speed) { - return speed === null ? '--KT' : `${speed.toFixed(1)}KT`; -} + 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}`; + } -function formatHeading(heading) { - return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG'; -} + /** 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 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 }; + 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 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, + }; } diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index de7e8a8aa..abab8821c 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -1,48 +1,58 @@ import * as Cesium from 'cesium'; import { FOCUS_EVIDENCE_DEV } from './policy.js'; -export function createEvidence({ vesselState, services, parts: components, layer, options }) { -const { state } = vesselState; +export function createEvidence({ + 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) { - return { ok: false, count: 0 }; + function _setFocusEvidenceVessels(rows = []) { + 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 []; - return state.vesselRecords.map((record) => { - const bb = record.billboard; - const screen = bb?.position - ? Cesium.SceneTransforms.worldToWindowCoordinates(state.viewer.scene, bb.position) - : null; - return { - id: record.mmsi, - show: bb?.show === true, - alpha: bb?.color?.alpha ?? null, - x: screen?.x ?? null, - y: screen?.y ?? null, - }; - }); -} -return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot }; + function _focusEvidenceVesselSnapshot() { + if (!FOCUS_EVIDENCE_DEV || !state.viewer) return []; + return state.vesselRecords.map((record) => { + const bb = record.billboard; + const screen = bb?.position + ? Cesium.SceneTransforms.worldToWindowCoordinates( + state.viewer.scene, + bb.position, + ) + : null; + return { + id: record.mmsi, + show: bb?.show === true, + alpha: bb?.color?.alpha ?? null, + x: screen?.x ?? null, + y: screen?.y ?? null, + }; + }); + } + return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot }; } diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index 2850b68a5..d459aa873 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -11,29 +11,46 @@ import { createEvidence } from './evidence.js'; import { createQueries } from './queries.js'; /** Compose one vessel layer with application-owned scene services. */ export function createVesselLayer({ source, services, options = {} } = {}) { -const vesselState = createVesselState({ source, services }); -const parts = {}; -const layer = {}; -const context = { vesselState, services, parts, layer, options }; -parts.lifecycle = createLifecycle(context); -parts.ingestion = createIngestion(context); -parts.store = createStore(context); -parts.rendering = createRendering(context); -parts.selection = createSelection(context); -parts.tracking = createTracking(context); -parts.cards = createCards(context); -parts.testing = createTesting(context); -parts.evidence = createEvidence(context); -parts.queries = createQueries(context); -Object.assign(layer, parts.queries.methods, parts.lifecycle.methods, 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; + const vesselState = createVesselState({ source, services }); + const parts = {}; + const layer = {}; + const context = { vesselState, services, parts, layer, options }; + parts.lifecycle = createLifecycle(context); + parts.ingestion = createIngestion(context); + parts.store = createStore(context); + parts.rendering = createRendering(context); + parts.selection = createSelection(context); + parts.tracking = createTracking(context); + parts.cards = createCards(context); + parts.testing = createTesting(context); + parts.evidence = createEvidence(context); + parts.queries = createQueries(context); + Object.assign( + layer, + parts.queries.methods, + parts.lifecycle.methods, + 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 { - VESSEL_OVERLAY_SOURCE_ID, VESSEL_LABEL_GRID_PX, VESSEL_DEFAULT_LABEL_LIMIT, - VESSEL_OVERLAY_MAX_COHORT, VESSEL_CARD_FADE_DISTANCE_M, normalizeVesselType, - vesselTypeCss, accentForVesselType, vesselOverlayCohortLimit, applyVesselOverlayPolicy, + VESSEL_OVERLAY_SOURCE_ID, + VESSEL_LABEL_GRID_PX, + VESSEL_DEFAULT_LABEL_LIMIT, + VESSEL_OVERLAY_MAX_COHORT, + VESSEL_CARD_FADE_DISTANCE_M, + normalizeVesselType, + vesselTypeCss, + accentForVesselType, + vesselOverlayCohortLimit, + applyVesselOverlayPolicy, } from '../../data/vesselLabels.js'; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index fe8b34ece..ba0d6d538 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -1,130 +1,184 @@ import { AIS_FIRST_CONNECT_LABEL } from './policy.js'; -export function createIngestion({ vesselState, services, parts: components, layer, options }) { -const { state } = vesselState; -const aisLiveVesselsLayer = layer; +export function createIngestion({ + vesselState, + 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) { - 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; + /** True while a request still owns this enabled layer lifecycle. */ - 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, + 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.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.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 }; + state.stale = + Boolean(payload?.refreshing) || + payload?.complete === false || + payload?.freshness === 'unknown'; + state.newestPositionAt = payload?.newestPositionAt || null; + // Not unconditionally null: a degraded feed keeps its reason even though the + // cached vessels are still drawable, so the chip cannot go quiet on an + // outage the user is still looking at. + state.error = snapshot.error || payload?.reason || null; + state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') + ? payload.observedAtMs + : vesselState._aisRuntime.now(); + return { reconciled: true, ...snapshot }; } - components.lifecycle.settleFirstConnectPhase('ready'); - components.store.reconcileVessels(viewer, snapshot.acceptedRows, { complete: payload?.complete !== false }); - state.count = state.vesselRecords.length; - state.stale = Boolean(payload?.refreshing) || payload?.complete === false || payload?.freshness === 'unknown'; - state.newestPositionAt = payload?.newestPositionAt || null; - // Not unconditionally null: a degraded feed keeps its reason even though the - // cached vessels are still drawable, so the chip cannot go quiet on an - // outage the user is still looking at. - state.error = snapshot.error || payload?.reason || null; - state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') ? payload.observedAtMs : vesselState._aisRuntime.now(); - return { reconciled: true, ...snapshot }; -} + function vesselDisplayRow(record) { + return { + mmsi: record.id, + reference: record.reference, + lat: record.latitude, + lon: record.longitude, + name: record.name, + imo: record.imo, + type: record.type, + destination: record.destination, + 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 { - mmsi: record.id, reference: record.reference, lat: record.latitude, lon: record.longitude, - name: record.name, imo: record.imo, type: record.type, destination: record.destination, - 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(), + loadLivePositions, + ownsAisRequest, + applyAisFeedSnapshot, + vesselDisplayRow, + methods, }; } -const methods = { - - - update(viewer) { - if (!state.enabled) return Promise.resolve(); - return loadLivePositions(viewer || state.viewer); - } -}; - -return { loadLivePositions, ownsAisRequest, applyAisFeedSnapshot, vesselDisplayRow, methods }; -} diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 46578e168..c878b398c 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -1,241 +1,253 @@ 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 }) { -const { state } = vesselState; -const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; -const { holdContinuousRender, releaseContinuousRender } = services.render; -const { ensureGeoidReady } = services.geoid; -const { registerPickOwner, unregisterPickOwner } = services.picking; +export function createLifecycle({ + vesselState, + services, + parts: components, + layer, + 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; - 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; - } + function clearFirstConnectTimer() { + if (state.firstConnectTimer === null) return; + vesselState._aisRuntime.clearTimeout(state.firstConnectTimer); 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, + }; } diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 7327c03ff..1be09728a 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -6,32 +6,85 @@ import { createVesselState } from './state.js'; const noop = () => {}; function services() { return { - context: { clearSelectedEntityContextForLayer: noop, registerEntityContext: () => null }, - trails: { createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }) }, + context: { + clearSelectedEntityContextForLayer: noop, + registerEntityContext: () => null, + }, + trails: { + createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }), + }, labels: {}, picking: { unregisterPickOwner: noop }, - overlay: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop }, - geoid: {}, sprites: {}, - focus: { focusNowMs: (now) => now, getFocusTarget: () => null, focusPassIsNeeded: () => false, forgetSpriteFocus: noop }, - worldFocus: {}, render: { releaseContinuousRender: noop }, + overlay: { + setOverlayEntries: noop, + setOverlaySourceVisible: noop, + clearOverlaySource: noop, + }, + geoid: {}, + sprites: {}, + focus: { + focusNowMs: (now) => now, + getFocusTarget: () => null, + focusPassIsNeeded: () => false, + forgetSpriteFocus: noop, + }, + worldFocus: {}, + render: { releaseContinuousRender: noop }, }; } function setup(source, 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; - layer.testing._setAisRuntimeForTest({ now: () => now, setTimeout, clearTimeout }); - return { layer, advance: (ms) => { now += ms; } }; + layer.testing._setAisRuntimeForTest({ + 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 snapshot = (records, extra = {}) => ({ records, source: 'Fixture vessels', complete: true, stale: false, freshness: 'current', observedAtMs: 500, transportStatus: 'live', rawRowCount: records.length, ...extra }); +const observation = (id, reference = id) => ({ + 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', () => { let requested = 0; - const source = { getSnapshot() { requested++; } }; + const source = { + getSnapshot() { + requested++; + }, + }; const one = 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.trailPositions, two.state.trailPositions); 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 () => { 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(); current = snapshot([observation('111')], { complete: false, rawRowCount: 2 }); 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 () => { let current = snapshot([observation('111'), observation('222')]); - const { layer } = setup({ async getSnapshot() { return current; } }); + const { layer } = setup({ + async getSnapshot() { + return current; + }, + }); await layer.update(); current = snapshot([observation('111')]); 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 () => { - let current = snapshot(Array.from({ length: 500 }, (_, i) => observation(String(i + 1000)))); - const { layer } = setup({ async getSnapshot() { return current; } }, { maxRows: 500 }); + let current = snapshot( + Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))), + ); + const { layer } = setup( + { + async getSnapshot() { + return current; + }, + }, + { maxRows: 500 }, + ); 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(); assert.equal(layer.getStats().count, 500); 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 () => { - 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(); assert.equal(layer.getStats().lastUpdate, null); 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) => { const originalDocument = globalThis.document; 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 request; let resolveTrack; const { layer } = setup({ - async getSnapshot() { return current; }, - getTrack(reference, { signal }) { request = { reference, signal }; return new Promise((resolve) => { resolveTrack = resolve; }); }, + async getSnapshot() { + return current; + }, + getTrack(reference, { signal }) { + request = { reference, signal }; + return new Promise((resolve) => { + resolveTrack = resolve; + }); + }, }); await layer.update(); 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) => { const originalDocument = globalThis.document; 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 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(); layer.destroy(); 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); }); - test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => { const originalDocument = globalThis.document; 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 signal; let finish; const { layer, advance } = setup({ - async getSnapshot() { return current; }, - getTrack(_reference, options) { signal = options.signal; return new Promise((resolve) => { finish = resolve; }); }, + async getSnapshot() { + return current; + }, + getTrack(_reference, options) { + signal = options.signal; + return new Promise((resolve) => { + finish = resolve; + }); + }, }); await layer.update(); layer.selectById('222'); diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index d74805c69..b13dcb947 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -1,34 +1,25 @@ import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js'; - - export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true; - - export const DEFAULT_RENDER_ROWS = 12000; - export const DEFAULT_ACTIVE_LABELS = 900; - export const REFRESH_MS = 60000; /** 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_LABEL = 'awaiting first AIS position…'; - export const VISIBILITY_UPDATE_MS = 800; /** Focus alpha alone samples faster inside the existing preRender pass. */ export const FOCUS_UPDATE_MS = 80; - 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 DEFAULT_AIS_RUNTIME = Object.freeze({ now: () => Date.now(), setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), clearTimeout: (timer) => clearTimeout(timer), }); - /** * Human-readable reasons for a non-'open' AISStream feed status, keyed to the * server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in @@ -92,7 +80,6 @@ export const AIS_STATUS_REASON = { idle: 'feed idle', }; - /** * 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 @@ -101,14 +88,18 @@ export const AIS_STATUS_REASON = { export const AIS_HEALTHY_STATUSES = new Set(['live', 'open']); - /** * Server statuses meaning "the feed is not delivering right now". These are * 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. */ -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. */ export const PARTIAL_RETENTION_MS = 5 * 60 * 1000; diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 2e73fb05d..05109e2b2 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -1,478 +1,527 @@ 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 }) { -const { state } = vesselState; -const { formatKnots } = services.labels; +export function createQueries({ + vesselState, + 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} + */ -/** - * 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() { - // 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'; + function aisRetryInSec() { + // A rejected key is terminal until someone changes it; an hour-long + // countdown would imply waiting is the fix. + if (state.transportStatus === 'auth-failed') return 0; + const at = Number(state.nextAttemptAt); + if (!Number.isFinite(at) || at <= 0) return 0; + return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000)); } - 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'; + + /** + * 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 + ? `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. + */ -/** - * 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) { - const status = payload && typeof payload.status === 'string' ? payload.status : null; - // 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 && AIS_DEGRADED_STATUSES.has(status)) { - return describeDegradedAisFeed(status, payload); + function deriveAisFeedError(payload, acceptedRowCount) { + const status = + payload && typeof payload.status === 'string' ? payload.status : null; + // 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 && AIS_DEGRADED_STATUSES.has(status)) { + return describeDegradedAisFeed(status, payload); + } + if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable + if (AIS_HEALTHY_STATUSES.has(status)) { + return payload?.lastMessageAt + ? 'awaiting usable AIS positions…' + : 'awaiting first AIS message…'; + } + 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; } - if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable - if (AIS_HEALTHY_STATUSES.has(status)) { - return payload?.lastMessageAt - ? 'awaiting usable AIS positions…' - : 'awaiting first AIS message…'; + + /** True when a raw AIS row can enter the production vessel normalizer. */ + + function hasUsableVesselCoordinates(row) { + 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, acceptedRows: Array, rawRowCount: number, + * acceptedRowCount: number, error: string|null}} + */ -/** True when a raw AIS row can enter the production vessel normalizer. */ - -function hasUsableVesselCoordinates(row) { - return Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon)); -} - - -/** - * 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, acceptedRows: Array, rawRowCount: number, - * acceptedRowCount: number, error: string|null}} - */ - -function classifyAisFeedSnapshot(payload) { - const rawRows = Array.isArray(payload?.rows) ? payload.rows : []; - const acceptedRows = rawRows.filter(hasUsableVesselCoordinates); - const transportStatus = typeof payload?.status === 'string' ? payload.status : null; - const lastMessageAt = payload?.lastMessageAt ?? null; - const acceptedRowCount = acceptedRows.length; - 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' }; + function classifyAisFeedSnapshot(payload) { + const rawRows = Array.isArray(payload?.rows) ? payload.rows : []; + const acceptedRows = rawRows.filter(hasUsableVesselCoordinates); + const transportStatus = + typeof payload?.status === 'string' ? payload.status : null; + const lastMessageAt = payload?.lastMessageAt ?? null; + const acceptedRowCount = acceptedRows.length; + 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), + }; } - 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: '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) { - if (value === null || value === undefined) return null; - const text = String(value).trim(); - return text || null; -} -const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot, vesselDatumHeightM, reduceVesselSelection, + name: 'Live AIS Vessels', - 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; - if (!position) continue; - const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); - if (!Number.isFinite(distanceM) || distanceM > range) continue; - entries.push({ mmsi: record.mmsi, name: record.name, position, distanceM }); - } - 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, + if (!position) return null; + return { + mmsi: record.mmsi, + name: record.name, position, latitude: record.lat, longitude: record.lon, - }); - } - return result; - }, + 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; - /** - * Snapshot the layer's in-memory vessel records as plain JSON-safe - * objects for the analyst query engine. On-demand only (called at most - * once per spoken query) — zero per-frame cost, no listeners, no caching. - * Returns [] while the layer is disabled or empty. - * @param {number} [maxCount=2000] - Maximum records to return (truncation). - * @returns {Array} 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; - }, + const entries = []; + for (const record of records) { + if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) + continue; + const position = record.billboard?.position || record.position; + if (!position) continue; + const distanceM = Cesium.Cartesian3.distance(centerCartesian, position); + if (!Number.isFinite(distanceM) || distanceM > range) continue; + entries.push({ + mmsi: record.mmsi, + name: record.name, + position, + distanceM, + }); + } + 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()); + }, - /** - * 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; - }, + 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, + latitude: record.lat, + longitude: record.lon, + }); + } + return result; + }, - /** - * Clear the current vessel selection and reset the HUD readout. - * @returns {boolean} Always true. - */ - clearSelection() { - components.selection.clearVesselInspection(); - return true; - }, + /** + * Snapshot the layer's in-memory vessel records as plain JSON-safe + * objects for the analyst query engine. On-demand only (called at most + * once per spoken query) — zero per-frame cost, no listeners, no caching. + * Returns [] while the layer is disabled or empty. + * @param {number} [maxCount=2000] - Maximum records to return (truncation). + * @returns {Array} 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. - * @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, - }; - }, + /** + * Clear the current vessel selection and reset the HUD readout. + * @returns {boolean} Always true. + */ + clearSelection() { + components.selection.clearVesselInspection(); + return true; + }, + /** + * 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. - * Deterministic stride sampling distributes selections evenly across the - * current record list while honoring the overlay's per-layer budget. - * @param {Object} [options={}] - Options from the detection system. - * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). - * @param {number} [options.seed] - Seed offset for stride sampling. - * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} - */ - getDetectableObjects(options = {}) { - if (!state.enabled || !state.billboardCollection || !state.billboardCollection.show) return []; - const records = state.vesselRecords; - if (!Array.isArray(records) || !records.length) return []; + /** + * Return a subset of vessels for the universal detection overlay. + * Deterministic stride sampling distributes selections evenly across the + * current record list while honoring the overlay's per-layer budget. + * @param {Object} [options={}] - Options from the detection system. + * @param {number} [options.maxCount] - Maximum objects to return (defaults to all). + * @param {number} [options.seed] - Seed offset for stride sampling. + * @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>} + */ + getDetectableObjects(options = {}) { + if ( + !state.enabled || + !state.billboardCollection || + !state.billboardCollection.show + ) + return []; + const records = state.vesselRecords; + if (!Array.isArray(records) || !records.length) return []; - const maxCount = Number.isFinite(options.maxCount) - ? Math.max(1, Math.floor(options.maxCount)) - : records.length; - const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; - // Deterministic stride: evenly space selections across the record list - const stride = Math.max(1, Math.ceil(records.length / maxCount)); - const start = seed % stride; + const maxCount = Number.isFinite(options.maxCount) + ? Math.max(1, Math.floor(options.maxCount)) + : records.length; + const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0; + // Deterministic stride: evenly space selections across the record list + const stride = Math.max(1, Math.ceil(records.length / maxCount)); + const start = seed % stride; - const selected = state.selectedRecord; - const result = []; - for (let idx = 0; idx < records.length; idx += 1) { - if (((idx - start) % stride) !== 0) continue; - const record = records[idx]; - if (record.billboard && !record.billboard.show) continue; - const position = record.billboard?.position || record.position; - if (!position) continue; - result.push({ - position, - sourceId: record.mmsi, - id: record.name || record.mmsi || 'VESSEL', - type: 'SEA', - skipLabel: record === selected, - klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined, - metric: formatKnots(record.speed), // record.speed is knots - }); - if (result.length >= maxCount) break; - } - return result; - }, + const selected = state.selectedRecord; + const result = []; + for (let idx = 0; idx < records.length; idx += 1) { + if ((idx - start) % stride !== 0) continue; + const record = records[idx]; + if (record.billboard && !record.billboard.show) continue; + const position = record.billboard?.position || record.position; + if (!position) continue; + result.push({ + position, + sourceId: record.mmsi, + id: record.name || record.mmsi || 'VESSEL', + type: 'SEA', + skipLabel: record === selected, + klass: record.type + ? String(record.type).toUpperCase().slice(0, 14) + : undefined, + metric: formatKnots(record.speed), // record.speed is knots + }); + if (result.length >= maxCount) break; + } + return result; + }, + ...(FOCUS_EVIDENCE_DEV + ? { + __focusEvidence: Object.freeze({ + setVessels: components.evidence._setFocusEvidenceVessels, + snapshot: components.evidence._focusEvidenceVesselSnapshot, + }), + } + : {}), - ...(FOCUS_EVIDENCE_DEV ? { - __focusEvidence: Object.freeze({ - setVessels: components.evidence._setFocusEvidenceVessels, - snapshot: components.evidence._focusEvidenceVesselSnapshot, - }), - } : {}), + getStats() { + const waitingForFirstPosition = state.firstConnectPhase === 'loading'; + return { + 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(), + }; + }, + }; - - getStats() { - const waitingForFirstPosition = state.firstConnectPhase === 'loading'; - return { - 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 { aisRetryInSec, describeDegradedAisFeed, deriveAisFeedError, hasUsableVesselCoordinates, classifyAisFeedSnapshot, mapAnalystRecord, vesselDatumHeightM, reduceVesselSelection, normalizeSelectionMmsi, methods }; + return { + aisRetryInSec, + describeDegradedAisFeed, + deriveAisFeedError, + hasUsableVesselCoordinates, + classifyAisFeedSnapshot, + mapAnalystRecord, + vesselDatumHeightM, + reduceVesselSelection, + normalizeSelectionMmsi, + methods, + }; } diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 229e44946..87f09537d 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -1,403 +1,472 @@ import * as Cesium from 'cesium'; -import { vesselTypeCss, 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'; +import { + vesselTypeCss, + 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 }) { -const { state } = vesselState; -const { registerSpriteCollection } = services.sprites; -const { forgetSpriteFocus, focusNowMs, getFocusTarget, focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite } = services.focus; +export function createRendering({ + vesselState, + services, + 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); - if (Number.isFinite(configured) && configured > 0) { - return Math.max(500, Math.min(50000, Math.round(configured))); + function renderRowLimit() { + const configured = Number(options.maxRows); + 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); - if (Number.isFinite(configured) && configured >= 0) { - return Math.max(0, Math.min(renderRowLimit(), Math.round(configured))); + function labelRowLimit() { + const configured = Number(options.maxLabels); + 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; - state.billboardCollection = new Cesium.BillboardCollection({ - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - state.billboardCollection.show = state.enabled; - 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); + function ensureCollections(viewer) { + if (!viewer || state.billboardCollection) return; + state.billboardCollection = new Cesium.BillboardCollection({ + blendOption: Cesium.BlendOption.TRANSLUCENT, + }); + state.billboardCollection.show = state.enabled; + viewer.scene.primitives.add(state.billboardCollection); + registerSpriteCollection('ais', state.billboardCollection); } - 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); - if (speed >= 18) return 0.78; - if (speed >= 8) return 0.68; - return 0.6; -} + /** + * 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; + } -/** - * Best available real-world direction of travel for a vessel, degrees - * 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 shipScale(record) { + const speed = Number(record.speed || 0); + if (speed >= 18) return 0.78; + if (speed >= 8) return 0.68; + return 0.6; + } -function vesselCourseDeg(record) { - const direction = record.heading ?? record.course; - return Number.isFinite(direction) ? direction : 0; -} + /** + * Best available real-world direction of travel for a vessel, degrees + * 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. - * The shape points north (up) so billboard rotation maps directly to heading. - * One icon is generated per color+variant and reused across all billboards. - * @param {Object} record - Vessel record (drives per-type tint). - * @param {boolean} selected - True for the white/brighter selected variant. - * @returns {string} SVG data URL. - */ + /** + * 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. + * One icon is generated per color+variant and reused across all billboards. + * @param {Object} record - Vessel record (drives per-type tint). + * @param {boolean} selected - True for the white/brighter selected variant. + * @returns {string} SVG data URL. + */ -function shipIcon(record, selected) { - const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type); - const key = `${cssColor}:${selected ? 'selected' : 'normal'}`; - if (vesselState.shipIconCache.has(key)) return vesselState.shipIconCache.get(key); + function shipIcon(record, selected) { + const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type); + const key = `${cssColor}:${selected ? 'selected' : 'normal'}`; + 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 strokeWidth = selected ? 1.1 : 0.7; - const svg = ` + const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)'; + const strokeWidth = selected ? 1.1 : 0.7; + const svg = ` `; - const icon = 'data:image/svg+xml;base64,' + btoa(svg); - vesselState.shipIconCache.set(key, 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 icon = 'data:image/svg+xml;base64,' + btoa(svg); + vesselState.shipIconCache.set(key, icon); + return icon; } - 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; + 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; + 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({ - records: state.vesselRecords, - target: focusTarget, - previousActiveCount: state.activeFocusCount, - nowMs: now, - screenPositionFor: (position) => ( - Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, vesselState._scratchFocusScreen) - ), - cameraDistanceFor: (position) => 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; + if (focusPass && scene && camera) { + const result = applyVesselFocusDeemphasis({ + records: state.vesselRecords, + target: focusTarget, + previousActiveCount: state.activeFocusCount, + nowMs: now, + screenPositionFor: (position) => + Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + position, + vesselState._scratchFocusScreen, + ), + cameraDistanceFor: (position) => + Cesium.Cartesian3.distance(camera.positionWC, position), + }); + state.activeFocusCount = result.activeCount; } } - 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() { - const cameraPosition = state.viewer?.camera?.positionWC; - if (!cameraPosition) return null; - return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPosition); -} + function makeOccluder() { + const cameraPosition = state.viewer?.camera?.positionWC; + if (!cameraPosition) return null; + 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} records - Horizon-visible vessel records. + */ -function isVisible(surfacePosition, occluder) { - if (!surfacePosition || !occluder) return true; - return occluder.isPointVisible(surfacePosition); -} + function updateClusteredLabels(records) { + const viewer = state.viewer; + 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; + } -/** - * 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} records - Horizon-visible vessel records. - */ + const cells = new Map(); + for (const record of records) { + if (record === selected) continue; + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + record.position, + ); + if (!screen) continue; + 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) { - const viewer = state.viewer; - const scene = viewer?.scene; - const selected = state.selectedRecord; - const entries = selected ? [components.cards.buildSelectedVesselCard(selected)] : []; - const maxLabels = labelRowLimit(); - - if (!scene || !records.length || maxLabels <= 0) { + // Greedy min-separation pass over the priority-ranked cell winners: the + // selected card's anchor seeds the accepted set so ambient cards keep clear. + const accepted = []; + if (selected) { + const screen = Cesium.SceneTransforms.worldToWindowCoordinates( + scene, + selected.billboard?.position || selected.position, + ); + 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; publishVesselOverlayEntries(entries); - return; } - const cells = new Map(); - for (const record of records) { - if (record === selected) continue; - const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, record.position); - if (!screen) continue; - 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); - } - } + /** + * Publish a complete, bounded source snapshot to the shared host. The source + * selector remains authoritative for the 118 px grid and 150 px separation; + * the host then composes this demand with sibling ambient-card sources. + * @param {Object[]} entries Formatted vessel card entries. + */ - // Greedy min-separation pass over the priority-ranked cell winners: the - // selected card's anchor seeds the accepted set so ambient cards keep clear. - const accepted = []; - if (selected) { - const screen = Cesium.SceneTransforms.worldToWindowCoordinates( - scene, selected.billboard?.position || selected.position + 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, + }, ); - 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)); + + 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; } - state.activeLabelCount = entries.length; - publishVesselOverlayEntries(entries); -} - -/** - * Publish a complete, bounded source snapshot to the shared host. The source - * selector remains authoritative for the 118 px grid and 150 px separation; - * the host then composes this demand with sibling ambient-card sources. - * @param {Object[]} entries Formatted vessel card entries. - */ - -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; + function hasUsefulName(record) { + const text = String(record.name || '').trim(); + return Boolean( + text && + text !== 'VESSEL' && + !/^MMSI\s*\d+$/i.test(text) && + text !== record.mmsi, + ); } - 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 }; + + function setVisible(show) { + 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, + }; } diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 94c2b58f8..1ef7cbd9c 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -1,229 +1,247 @@ import * as Cesium from 'cesium'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; -export function createSelection({ vesselState, services, parts: components, layer, options }) { -const { state } = vesselState; -const { resolvePickId, isOwnedByOtherLayer } = services.picking; -const { requestWorldFocus } = services.worldFocus; -const { selectEntityContext, registerEntityContext, clearSelectedEntityContextForLayer } = services.context; -const aisLiveVesselsLayer = layer; +export function createSelection({ + vesselState, + services, + parts: components, + 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) { - if (state.clickHandler || !viewer) return; - const handler = state.interactionHandlerFactory - ? state.interactionHandlerFactory(viewer) - : new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - bindVesselInteraction(viewer, handler, state.interactionKeyTarget || document); -} + // 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; - -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'); - - // 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) + // 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; - 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 && cardHit) { + const mmsi = String(cardHit.entryId || '').startsWith('vessel:') + ? cardHit.entryId.slice('vessel:'.length) + : 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) { - // A valid sprite or card click always transfers the camera exactly once, - // including a second click on the already-selected vessel. - selectAndFocusVessel(record); - } else { - const transition = components.queries.reduceVesselSelection({ - selectedMmsi: state.selectedRecord?.mmsi, - pickedMmsi: null, - gesture: 'click', - }); - if (transition.action === 'deselect') clearVesselInspection(); - } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - state.keyTarget = keyTarget; - state.keydownHandler = onVesselKeyDown; - keyTarget.addEventListener('keydown', state.keydownHandler); - // Vessels never set viewer.trackedEntity, so any new tracked entity belongs - // to another layer and takes interaction ownership of the scene. - state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(() => { - 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, + if (record) { + // A valid sprite or card click always transfers the camera exactly once, + // including a second click on the already-selected vessel. + selectAndFocusVessel(record); + } else { + const transition = components.queries.reduceVesselSelection({ + selectedMmsi: state.selectedRecord?.mmsi, + pickedMmsi: null, + gesture: 'click', + }); + if (transition.action === 'deselect') clearVesselInspection(); + } + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + state.keyTarget = keyTarget; + state.keydownHandler = onVesselKeyDown; + keyTarget.addEventListener('keydown', state.keydownHandler); + // Vessels never set viewer.trackedEntity, so any new tracked entity belongs + // to another layer and takes interaction ownership of the scene. + state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener( + () => { + 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', }); - } catch (error) { - console.warn('[Data:ais-live-vessels] context register failed', error); - return null; + 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 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); + 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; + } } - 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); + + 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); + } + } -/** - * @param {object} [options] Clear origin. - * @param {boolean} [options.evicted=false] The vessel aged out of the feed - * rather than being deselected. - */ + /** + * 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 clearVesselInspection({ evicted = false } = {}) { - clearSelection({ evicted }); - components.cards.resetSelectedVesselHud(); -} -return { installInteraction, bindVesselInteraction, selectAndFocusVessel, removeVesselInteraction, onVesselKeyDown, selectVessel, registerSelectedContext, clearSelection, clearVesselInspection }; + 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, + }, + }); + } 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, + }; } diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 060745588..5f17de65f 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -2,115 +2,107 @@ import * as Cesium from 'cesium'; import { DEFAULT_AIS_RUNTIME } from './policy.js'; export function createVesselState({ source, services }) { -const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource, hitTestWorldOverlay } = services.overlay; -const vesselState = {}; + const { + 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({ - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - hitTest: hitTestWorldOverlay, -}); + /** @type {Map} `${cssColor}:${variant}` -> chevron SVG data URL */ + vesselState.shipIconCache = new Map(); -vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST; - - -vesselState._aisRuntime = DEFAULT_AIS_RUNTIME; - - -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; - - -/** @type {Map} `${cssColor}:${variant}` -> chevron SVG data URL */ - -vesselState.shipIconCache = new Map(); - - - -vesselState.state = { - viewer: null, - enabled: false, - loading: false, - loaded: false, - stale: false, - error: null, - loadingLabel: '', - lastUpdate: null, - count: 0, - newestPositionAt: null, - transportStatus: null, - /** Server epoch-ms of the next reconnect attempt while the feed is degraded. */ - nextAttemptAt: null, - lastMessageAt: null, - rawRowCount: 0, - acceptedRowCount: 0, - /** Monotonic enable/reset owner for requests and first-connect timers. */ - sessionId: 0, - /** @type {'idle'|'loading'|'ready'|'unavailable'} */ - firstConnectPhase: 'idle', - firstConnectStartedAt: null, - firstConnectDeadline: null, - firstConnectTimer: null, - abort: null, - billboardCollection: null, - /** @type {Array} Flat render list: keyed records + unkeyed records */ - vesselRecords: [], - /** @type {Map} MMSI -> vessel record (identity across refreshes) */ - vesselMap: new Map(), - /** @type {Array} Records with no MMSI — rebuilt fresh each refresh */ - unkeyedRecords: [], - clickHandler: null, - /** Exact EventTarget currently holding the Escape listener. */ - keyTarget: null, - /** Exact callback registered on keyTarget. */ - 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; + vesselState.state = { + viewer: null, + enabled: false, + loading: false, + loaded: false, + stale: false, + error: null, + loadingLabel: '', + lastUpdate: null, + count: 0, + newestPositionAt: null, + transportStatus: null, + /** Server epoch-ms of the next reconnect attempt while the feed is degraded. */ + nextAttemptAt: null, + lastMessageAt: null, + rawRowCount: 0, + acceptedRowCount: 0, + /** Monotonic enable/reset owner for requests and first-connect timers. */ + sessionId: 0, + /** @type {'idle'|'loading'|'ready'|'unavailable'} */ + firstConnectPhase: 'idle', + firstConnectStartedAt: null, + firstConnectDeadline: null, + firstConnectTimer: null, + abort: null, + billboardCollection: null, + /** @type {Array} Flat render list: keyed records + unkeyed records */ + vesselRecords: [], + /** @type {Map} MMSI -> vessel record (identity across refreshes) */ + vesselMap: new Map(), + /** @type {Array} Records with no MMSI — rebuilt fresh each refresh */ + unkeyedRecords: [], + clickHandler: null, + /** Exact EventTarget currently holding the Escape listener. */ + keyTarget: null, + /** Exact callback registered on keyTarget. */ + 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; } diff --git a/src/layers/vessels/store.js b/src/layers/vessels/store.js index a07cc9f68..80890452c 100644 --- a/src/layers/vessels/store.js +++ b/src/layers/vessels/store.js @@ -2,190 +2,211 @@ import * as Cesium from 'cesium'; import { PARTIAL_RETENTION_MS } from './policy.js'; import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js'; -export function createStore({ vesselState, services, parts: components, layer, options }) { -const { state } = vesselState; +export function createStore({ + vesselState, + services, + parts: components, + layer, + options, +}) { + const { state } = vesselState; + /** + * Reconcile the incoming AIS rows against the MMSI-keyed record map. + * Existing records are updated in place (position/heading/label) so identity + * and selection survive refreshes; new vessels are added; vanished vessels are + * removed — except the selected vessel, which is pinned for up to + * SELECTED_PIN_REFRESHES consecutive misses with a stale HUD readout. + * Rows without an MMSI are rendered unkeyed and rebuilt fresh each refresh. + * @param {Cesium.Viewer} viewer - The Cesium viewer instance. + * @param {Array} rows - Raw AIS rows from the live API. + */ -/** - * Reconcile the incoming AIS rows against the MMSI-keyed record map. - * Existing records are updated in place (position/heading/label) so identity - * and selection survive refreshes; new vessels are added; vanished vessels are - * removed — except the selected vessel, which is pinned for up to - * SELECTED_PIN_REFRESHES consecutive misses with a stale HUD readout. - * Rows without an MMSI are rendered unkeyed and rebuilt fresh each refresh. - * @param {Cesium.Viewer} viewer - The Cesium viewer instance. - * @param {Array} rows - Raw AIS rows from the live API. - */ + function reconcileVessels(viewer, rows, { complete = true } = {}) { + const receivedAtMs = vesselState._aisRuntime.now(); + components.rendering.ensureCollections(viewer); -function reconcileVessels(viewer, rows, { complete = true } = {}) { - const receivedAtMs = vesselState._aisRuntime.now(); - components.rendering.ensureCollections(viewer); - - // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them. - for (const record of state.unkeyedRecords) { - components.rendering.removeRecordPrimitives(record); - } - state.unkeyedRecords = []; - - const occluder = components.rendering.makeOccluder(); - const seen = new Set(); - for (let index = 0; index < rows.length; index += 1) { - const next = normalizeVessel(rows[index]); - if (!next) continue; - next.receivedAtMs = receivedAtMs; - - if (!next.mmsi) { - components.rendering.addRecordPrimitives(next, occluder); - state.unkeyedRecords.push(next); - continue; + // Unkeyed (no-MMSI) records cannot be diffed — drop and rebuild them. + for (const record of state.unkeyedRecords) { + components.rendering.removeRecordPrimitives(record); } - if (seen.has(next.mmsi)) continue; // defensive: dedupe payload rows - seen.add(next.mmsi); + state.unkeyedRecords = []; - const existing = state.vesselMap.get(next.mmsi); - if (existing) { - updateRecordInPlace(existing, next); - } else { - components.rendering.addRecordPrimitives(next, occluder); - state.vesselMap.set(next.mmsi, next); - } - } + 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; - // Remove vanished vessels, pinning the selected one for a few refreshes. - for (const [mmsi, record] of state.vesselMap) { - if (seen.has(mmsi)) continue; - if (!complete && Number.isFinite(record.receivedAtMs) && receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS) { - if (record === state.selectedRecord) { - record.missedRefreshes = Math.max(1, record.missedRefreshes || 0); - components.cards.updateSelectedVesselHud(record); - } - continue; - } - if (record === state.selectedRecord) { - record.missedRefreshes = (record.missedRefreshes || 0) + 1; - if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) { - components.cards.updateSelectedVesselHud(record); // re-render with STALE marker + if (!next.mmsi) { + components.rendering.addRecordPrimitives(next, occluder); + state.unkeyedRecords.push(next); continue; } - // Aged out of the feed after exhausting its pin — not a deselect. - components.selection.clearVesselInspection({ evicted: true }); - } - components.rendering.removeRecordPrimitives(record); - state.vesselMap.delete(mmsi); - // Defensive lifecycle closure: a trail may outlive selection state during - // asynchronous handoff/refresh ordering, but never its owning record. - if (state.trailMmsi === mmsi) components.tracking.clearSelectedVesselTrail(); - } + if (seen.has(next.mmsi)) continue; // defensive: dedupe payload rows + seen.add(next.mmsi); - const cap = components.rendering.renderRowLimit(); - if (state.vesselMap.size + state.unkeyedRecords.length > cap) { + const existing = state.vesselMap.get(next.mmsi); + if (existing) { + updateRecordInPlace(existing, next); + } else { + components.rendering.addRecordPrimitives(next, occluder); + state.vesselMap.set(next.mmsi, next); + } + } + + // Remove vanished vessels, pinning the selected one for a few refreshes. for (const [mmsi, record] of state.vesselMap) { - if (state.vesselMap.size + state.unkeyedRecords.length <= cap) break; - if (seen.has(mmsi) || record === state.selectedRecord) continue; + if (seen.has(mmsi)) continue; + if ( + !complete && + Number.isFinite(record.receivedAtMs) && + receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS + ) { + if (record === state.selectedRecord) { + record.missedRefreshes = Math.max(1, record.missedRefreshes || 0); + components.cards.updateSelectedVesselHud(record); + } + continue; + } + if (record === state.selectedRecord) { + record.missedRefreshes = (record.missedRefreshes || 0) + 1; + if (complete && record.missedRefreshes <= SELECTED_PIN_REFRESHES) { + components.cards.updateSelectedVesselHud(record); // re-render with STALE marker + continue; + } + // Aged out of the feed after exhausting its pin — not a deselect. + components.selection.clearVesselInspection({ evicted: true }); + } components.rendering.removeRecordPrimitives(record); state.vesselMap.delete(mmsi); - 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); -} - -/** - * 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; - } + function normalizeVessel(row) { + const lat = Number(row.lat); + const lon = Number(row.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + // Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the + // ellipsoid — at height 0 everything that projects record.position (clicks, + // detection brackets, cards, getNearby) points up to ~45 m under the water. + const heightM = components.queries.vesselDatumHeightM( + components.tracking.currentGeoidN(lat, lon), + VESSEL_LIFT_M, + ); + const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM); + // Surface normal at this position — used as alignedAxis so billboard + // rotation operates in the local tangent plane (true world heading) + const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + return { + lat, + lon, + name: String(row.name || row.mmsi || 'VESSEL'), + mmsi: String(row.mmsi || '').trim(), + reference: row.reference ?? String(row.mmsi || '').trim(), + imo: String(row.imo || ''), + type: String(row.type || ''), + destination: String(row.destination || ''), + speed: finiteNumber(row.speed), + course: finiteNumber(row.course), + heading: finiteNumber(row.heading), + lastPositionUtc: String(row.last_position_UTC || ''), + lastPositionEpoch: finiteNumber(row.last_position_epoch), + position, + // Ellipsoid-surface point (height 0) — feeds ONLY the horizon occluder, + // which tests against the WGS84 ellipsoid; keep it off the sea datum. + surfacePosition: Cesium.Cartesian3.fromDegrees(lon, lat, 0), + normal, + missedRefreshes: 0, + billboard: null, + }; } - 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 { - lat, - lon, - name: String(row.name || row.mmsi || 'VESSEL'), - mmsi: String(row.mmsi || '').trim(), - reference: row.reference ?? String(row.mmsi || '').trim(), - imo: String(row.imo || ''), - type: String(row.type || ''), - destination: String(row.destination || ''), - speed: finiteNumber(row.speed), - course: finiteNumber(row.course), - heading: finiteNumber(row.heading), - lastPositionUtc: String(row.last_position_UTC || ''), - lastPositionEpoch: finiteNumber(row.last_position_epoch), - position, - // Ellipsoid-surface point (height 0) — feeds ONLY the horizon occluder, - // which tests against the WGS84 ellipsoid; keep it off the sea datum. - surfacePosition: Cesium.Cartesian3.fromDegrees(lon, lat, 0), - normal, - missedRefreshes: 0, - billboard: null, + reconcileVessels, + updateRecordInPlace, + normalizeVessel, + finiteNumber, }; } - - - -function finiteNumber(value) { - if (value === null || value === undefined || value === '') return null; - const number = Number(value); - return Number.isFinite(number) ? number : null; -} -return { reconcileVessels, updateRecordInPlace, normalizeVessel, finiteNumber }; -} diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 738333e51..e0c8f37fe 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -1,163 +1,179 @@ import { DEFAULT_AIS_RUNTIME } from './policy.js'; -export function createTesting({ vesselState, services, parts: components, layer, options }) { -const { state } = vesselState; -const aisLiveVesselsLayer = layer; +export function createTesting({ + vesselState, + 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} + */ -/** - * 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) { + components.selection.bindVesselInteraction(viewer, handler, keyTarget); + } -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; + } -/** - * Prime the minimum live state needed by interaction/lifecycle wire tests. - * @param {Object} [options={}] - Test state values. - * @returns {void} - */ + /** Inject a host recorder for lifecycle/contract tests; null restores production. */ -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; -} + function _setVesselOverlayHostForTest(host = null) { + vesselState._vesselOverlayHost = + host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST; + } + /** 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} 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 = []) { - components.rendering.updateClusteredLabels(records); -} + function _applyAisFeedSnapshotForTest(viewer, payload) { + return components.ingestion.applyAisFeedSnapshot(viewer, payload); + } + /** Exercise the request-owned live loader with a test-controlled fetch. */ -/** - * Reconcile AIS rows through the production lifecycle. Test-only seam. - * @param {Object} viewer - Viewer-like object. - * @param {Array} rows - Raw AIS rows. - * @returns {void} - */ + function _loadLivePositionsForTest(viewer) { + return components.ingestion.loadLivePositions(viewer); + } -function _reconcileVesselsForTest(viewer, rows) { - components.store.reconcileVessels(viewer, rows); -} + /** Start the production first-connect grace state without installing UI. */ + 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) { - return components.ingestion.applyAisFeedSnapshot(viewer, payload); -} + 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. */ -/** 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}} + */ - -/** Start the production first-connect grace state without installing UI. */ - -function _beginAisSessionForTest() { - components.lifecycle.beginAisSession(); -} - - -/** 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(); + function _getVesselStateForTest() { + return { + trailMmsi: state.trailMmsi, + trailPositionCount: state.trailPositions.length, + vesselCount: state.vesselMap.size, + }; + } 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, + _bindVesselInteractionForTest, + _setVesselStateForTest, + _setVesselOverlayHostForTest, + _updateVesselCardsForTest, + _reconcileVesselsForTest, + _applyAisFeedSnapshotForTest, + _loadLivePositionsForTest, + _beginAisSessionForTest, + _setAisRuntimeForTest, + _getVesselFeedStateForTest, + _getVesselStateForTest, }; } - - -/** - * 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 }; -} diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index e7ff7005b..52de1e0de 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -1,167 +1,214 @@ 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 }) { -const { state } = vesselState; -const { geoidHeight } = services.geoid; -const { createTrail } = services.trails; +export function createTracking({ + vesselState, + services, + 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} + */ -/** - * 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) { - 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; + 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. + */ -/** - * Start (or restart) the selected vessel's trail: seed with the current - * position, render, then fire-and-forget the server ring-buffer backfill. - * @param {Object} record - Freshly selected vessel record. - */ - -function startSelectedVesselTrail(record) { - state.trailAbort?.abort(); - 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 }); + 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); } - 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.) + */ -/** - * Fire-and-forget backfill from the server-side per-MMSI ring buffer - * (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} - */ - -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 + 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; + } } - 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)); + /** + * Start (or restart) the selected vessel's trail: seed with the current + * position, render, then fire-and-forget the server ring-buffer backfill. + * @param {Object} record - Freshly selected vessel record. + */ + + function startSelectedVesselTrail(record) { + state.trailAbort?.abort(); + 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) { - state.trailPositions = state.trailPositions.slice(state.trailPositions.length - TRAIL_MAX_POINTS); + /** + * Fire-and-forget backfill from the server-side per-MMSI ring buffer + * (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} + */ + + 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. + */ -/** - * 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) { - if (!state.trail) return; - const next = vesselTrailPosition(record); - if (!next) return; - const last = state.trailPositions[state.trailPositions.length - 1]; - 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; + function appendSelectedVesselTrailFix(record) { + if (!state.trail) return; + const next = vesselTrailPosition(record); + if (!next) return; + const last = state.trailPositions[state.trailPositions.length - 1]; + 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); } -} -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, + }; } diff --git a/src/testSupport/readLayerSource.mjs b/src/testSupport/readLayerSource.mjs index 99f2831b7..e0693bc20 100644 --- a/src/testSupport/readLayerSource.mjs +++ b/src/testSupport/readLayerSource.mjs @@ -5,13 +5,19 @@ import { fileURLToPath } from 'node:url'; /** Read actual component owners for structural regression assertions. */ export function readLayerSource(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'); const directory = join( dirname(path), basename(path) === 'flights.js' ? '../layers/flights' - : basename(path) === 'militaryFlights.js' ? '../layers/military' : '../layers/vessels', + : basename(path) === 'militaryFlights.js' + ? '../layers/military' + : '../layers/vessels', ); return readdirSync(directory) .filter((name) => name.endsWith('.js'))