mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 03:14:00 +00:00
Format extracted vessel components
This commit is contained in:
1 parent
2f4839e8f2
commit
a0b2d9f6aa
17 files changed
+1195
-887
No files matched your search
+71
-218
@@ -10,47 +10,27 @@
|
|||||||
"src/data/localGeojsonCore.js",
|
"src/data/localGeojsonCore.js",
|
||||||
"src/data/localGeojsonLod.js"
|
"src/data/localGeojsonLod.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"application": {
|
"application": {
|
||||||
"exports": [
|
"exports": ["./application"],
|
||||||
"./application"
|
"modules": ["src/app/application.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/app/application.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"viewer": {
|
"viewer": {
|
||||||
"exports": [
|
"exports": ["./application/viewer"],
|
||||||
"./application/viewer"
|
"modules": ["src/app/viewer.js"],
|
||||||
],
|
"external": ["cesium"]
|
||||||
"modules": [
|
|
||||||
"src/app/viewer.js"
|
|
||||||
],
|
|
||||||
"external": [
|
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"vite-build": {
|
"vite-build": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./build/vite"],
|
||||||
"./build/vite"
|
"modules": ["build/vite.js"],
|
||||||
],
|
"external": ["vite-plugin-cesium"]
|
||||||
"modules": [
|
|
||||||
"build/vite.js"
|
|
||||||
],
|
|
||||||
"external": [
|
|
||||||
"vite-plugin-cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"live-providers": {
|
"live-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/live"],
|
||||||
"./server/providers/live"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/live.js",
|
"server/providers/live.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -65,24 +45,16 @@
|
|||||||
"src/data/aisStreamAdapter.js",
|
"src/data/aisStreamAdapter.js",
|
||||||
"src/data/aisWatchdog.js"
|
"src/data/aisWatchdog.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["ws"]
|
||||||
"ws"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"adsb-lol-source": {
|
"adsb-lol-source": {
|
||||||
"exports": [
|
"exports": ["./sources/adsb-lol"],
|
||||||
"./sources/adsb-lol"
|
"modules": ["src/data/adsbLolFallback.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/adsbLolFallback.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-providers": {
|
"place-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/places"],
|
||||||
"./server/providers/places"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/places.js",
|
"server/providers/places.js",
|
||||||
"server/providers/places/google.js",
|
"server/providers/places/google.js",
|
||||||
@@ -97,19 +69,13 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-payloads": {
|
"place-payloads": {
|
||||||
"exports": [
|
"exports": ["./sources/places"],
|
||||||
"./sources/places"
|
"modules": ["src/data/placeProviderPayloads.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/placeProviderPayloads.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-providers": {
|
"space-providers": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/space"],
|
||||||
"./server/providers/space"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/space.js",
|
"server/providers/space.js",
|
||||||
"server/providers/space/celestrak.js",
|
"server/providers/space/celestrak.js",
|
||||||
@@ -120,19 +86,13 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"space-requests": {
|
"space-requests": {
|
||||||
"exports": [
|
"exports": ["./sources/space"],
|
||||||
"./sources/space"
|
"modules": ["src/data/spaceProviderRequests.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/spaceProviderRequests.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-provider": {
|
"terrain-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/terrain"],
|
||||||
"./server/providers/terrain"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/terrain.js",
|
"server/providers/terrain.js",
|
||||||
"src/data/terrainHeightsProxy.js"
|
"src/data/terrainHeightsProxy.js"
|
||||||
@@ -140,50 +100,30 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"terrain-source": {
|
"terrain-source": {
|
||||||
"exports": [
|
"exports": ["./sources/terrain"],
|
||||||
"./sources/terrain"
|
"modules": ["src/data/terrainHeightsProxy.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/terrainHeightsProxy.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-provider": {
|
"traffic-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/traffic"],
|
||||||
"./server/providers/traffic"
|
"modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"server/providers/traffic.js",
|
|
||||||
"src/data/tomtomTiles.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"traffic-source": {
|
"traffic-source": {
|
||||||
"exports": [
|
"exports": ["./sources/traffic"],
|
||||||
"./sources/traffic"
|
"modules": ["src/data/tomtomTiles.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/tomtomTiles.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"firms-provider": {
|
"firms-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/firms"],
|
||||||
"./server/providers/firms"
|
"modules": ["server/providers/firms.js", "src/data/firmsCsv.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"server/providers/firms.js",
|
|
||||||
"src/data/firmsCsv.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-provider": {
|
"gbfs-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/gbfs"],
|
||||||
"./server/providers/gbfs"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/gbfs.js",
|
"server/providers/gbfs.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -192,19 +132,13 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"gbfs-source": {
|
"gbfs-source": {
|
||||||
"exports": [
|
"exports": ["./sources/gbfs"],
|
||||||
"./sources/gbfs"
|
"modules": ["src/data/gbfsSource.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/data/gbfsSource.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"cctv-provider": {
|
"cctv-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/cctv"],
|
||||||
"./server/providers/cctv"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/cctv.js",
|
"server/providers/cctv.js",
|
||||||
"server/providers/cctv/catalog.js",
|
"server/providers/cctv/catalog.js",
|
||||||
@@ -221,9 +155,7 @@
|
|||||||
},
|
},
|
||||||
"radio-provider": {
|
"radio-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/radio"],
|
||||||
"./server/providers/radio"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/radio.js",
|
"server/providers/radio.js",
|
||||||
"server/providers/radio/catalog.js",
|
"server/providers/radio/catalog.js",
|
||||||
@@ -237,9 +169,7 @@
|
|||||||
},
|
},
|
||||||
"overpass-provider": {
|
"overpass-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/overpass"],
|
||||||
"./server/providers/overpass"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/geo.js",
|
"server/providers/common/geo.js",
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
@@ -258,9 +188,7 @@
|
|||||||
},
|
},
|
||||||
"military-installations-provider": {
|
"military-installations-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/military-installations"],
|
||||||
"./server/providers/military-installations"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -277,9 +205,7 @@
|
|||||||
},
|
},
|
||||||
"regional-provider": {
|
"regional-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/regional"],
|
||||||
"./server/providers/regional"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/http.js",
|
"server/providers/common/http.js",
|
||||||
"server/providers/common/query.js",
|
"server/providers/common/query.js",
|
||||||
@@ -298,9 +224,7 @@
|
|||||||
},
|
},
|
||||||
"openai-provider": {
|
"openai-provider": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/providers/openai"],
|
||||||
"./server/providers/openai"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"server/providers/common/rate-limit.js",
|
"server/providers/common/rate-limit.js",
|
||||||
"server/providers/common/request.js",
|
"server/providers/common/request.js",
|
||||||
@@ -320,9 +244,7 @@
|
|||||||
},
|
},
|
||||||
"standalone-key-setup": {
|
"standalone-key-setup": {
|
||||||
"runtime": "node",
|
"runtime": "node",
|
||||||
"exports": [
|
"exports": ["./server/standalone/key-setup"],
|
||||||
"./server/standalone/key-setup"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"scripts/pinokio-environment.mjs",
|
"scripts/pinokio-environment.mjs",
|
||||||
"server/providers/common/source-root.js",
|
"server/providers/common/source-root.js",
|
||||||
@@ -333,9 +255,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"place-search": {
|
"place-search": {
|
||||||
"exports": [
|
"exports": ["./search"],
|
||||||
"./search"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/search/index.js",
|
"src/search/index.js",
|
||||||
"src/search/placeSearch.js",
|
"src/search/placeSearch.js",
|
||||||
@@ -345,27 +265,17 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-disclosure": {
|
"panel-disclosure": {
|
||||||
"exports": [
|
"exports": ["./ui/panels"],
|
||||||
"./ui/panels"
|
"modules": ["src/ui/panelDisclosure.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/ui/panelDisclosure.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"surface-keyboard": {
|
"surface-keyboard": {
|
||||||
"exports": [
|
"exports": ["./ui/surfaces"],
|
||||||
"./ui/surfaces"
|
"modules": ["src/ui/surfaceKeyboard.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/ui/surfaceKeyboard.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"panel-rail-layout": {
|
"panel-rail-layout": {
|
||||||
"exports": [
|
"exports": ["./ui/layout"],
|
||||||
"./ui/layout"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/panelRails.js",
|
"src/ui/panelRails.js",
|
||||||
"src/ui/leftPanelRail.js",
|
"src/ui/leftPanelRail.js",
|
||||||
@@ -377,9 +287,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-input": {
|
"visual-input": {
|
||||||
"exports": [
|
"exports": ["./ui/input"],
|
||||||
"./ui/input"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/visualInput.js",
|
"src/ui/visualInput.js",
|
||||||
"src/ui/applicationShortcuts.js",
|
"src/ui/applicationShortcuts.js",
|
||||||
@@ -388,19 +296,12 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"display-controls": {
|
"display-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/display"],
|
||||||
"./ui/display"
|
"modules": ["src/ui/displayControls.js"],
|
||||||
],
|
|
||||||
"modules": [
|
|
||||||
"src/ui/displayControls.js"
|
|
||||||
],
|
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"visual-effects": {
|
"visual-effects": {
|
||||||
"exports": [
|
"exports": ["./ui/effects", "./ui/effects/bloom"],
|
||||||
"./ui/effects",
|
|
||||||
"./ui/effects/bloom"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/effects.js",
|
"src/ui/effects.js",
|
||||||
"src/ui/visualEffects.js",
|
"src/ui/visualEffects.js",
|
||||||
@@ -413,14 +314,10 @@
|
|||||||
"src/styles/noir.js",
|
"src/styles/noir.js",
|
||||||
"src/styles/snow.js"
|
"src/styles/snow.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"map-source-controls": {
|
"map-source-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/maps"],
|
||||||
"./ui/maps"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/mapSource.js",
|
"src/ui/mapSource.js",
|
||||||
"src/ui/mapSourceControls.js",
|
"src/ui/mapSourceControls.js",
|
||||||
@@ -430,10 +327,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"layer-panel": {
|
"layer-panel": {
|
||||||
"exports": [
|
"exports": ["./ui/layers", "./ui/layers/feedback"],
|
||||||
"./ui/layers",
|
|
||||||
"./ui/layers/feedback"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/layers.js",
|
"src/ui/layers.js",
|
||||||
"src/ui/layerPanel.js",
|
"src/ui/layerPanel.js",
|
||||||
@@ -444,9 +338,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"location-controls": {
|
"location-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/location"],
|
||||||
"./ui/location"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/location.js",
|
"src/ui/location.js",
|
||||||
"src/ui/locationControls.js",
|
"src/ui/locationControls.js",
|
||||||
@@ -457,9 +349,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"radio-controls": {
|
"radio-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/radio"],
|
||||||
"./ui/radio"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/radio.js",
|
"src/ui/radio.js",
|
||||||
"src/ui/radioControls.js",
|
"src/ui/radioControls.js",
|
||||||
@@ -470,9 +360,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"cctv-controls": {
|
"cctv-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/cctv"],
|
||||||
"./ui/cctv"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/cctv.js",
|
"src/ui/cctv.js",
|
||||||
"src/ui/cctvBindings.js",
|
"src/ui/cctvBindings.js",
|
||||||
@@ -484,10 +372,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"context-controls": {
|
"context-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/context", "./ui/context/policy"],
|
||||||
"./ui/context",
|
|
||||||
"./ui/context/policy"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/context.js",
|
"src/ui/context.js",
|
||||||
"src/ui/contextActions.js",
|
"src/ui/contextActions.js",
|
||||||
@@ -529,14 +414,10 @@
|
|||||||
"src/cockpitVisionPolicy.js",
|
"src/cockpitVisionPolicy.js",
|
||||||
"src/ui/panelRailGeometry.js"
|
"src/ui/panelRailGeometry.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"scene-controls": {
|
"scene-controls": {
|
||||||
"exports": [
|
"exports": ["./ui/scenes"],
|
||||||
"./ui/scenes"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/scenes.js",
|
"src/ui/scenes.js",
|
||||||
"src/ui/sceneControls.js",
|
"src/ui/sceneControls.js",
|
||||||
@@ -545,9 +426,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"ui-shell": {
|
"ui-shell": {
|
||||||
"exports": [
|
"exports": ["./ui/shell"],
|
||||||
"./ui/shell"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/bloom.js",
|
"src/bloom.js",
|
||||||
"src/cctvFocusPolicy.js",
|
"src/cctvFocusPolicy.js",
|
||||||
@@ -643,14 +522,10 @@
|
|||||||
"src/ui/uiLifetime.js",
|
"src/ui/uiLifetime.js",
|
||||||
"src/app/stateChannel.js"
|
"src/app/stateChannel.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ui-styles": {
|
"ui-styles": {
|
||||||
"exports": [
|
"exports": ["./ui/styles"],
|
||||||
"./ui/styles"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/ui/styles.js",
|
"src/ui/styles.js",
|
||||||
"style.css",
|
"style.css",
|
||||||
@@ -677,9 +552,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"scenes": {
|
"scenes": {
|
||||||
"exports": [
|
"exports": ["./scenes"],
|
||||||
"./scenes"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/scenes/director.js",
|
"src/scenes/director.js",
|
||||||
"src/scenes/recipes.js",
|
"src/scenes/recipes.js",
|
||||||
@@ -691,14 +564,10 @@
|
|||||||
"src/ui/scenePresentation.js",
|
"src/ui/scenePresentation.js",
|
||||||
"src/contextModePolicy.js"
|
"src/contextModePolicy.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"live-observations": {
|
"live-observations": {
|
||||||
"exports": [
|
"exports": ["./sources/live"],
|
||||||
"./sources/live"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/sources/live/aircraft.js",
|
"src/sources/live/aircraft.js",
|
||||||
"src/sources/live/contract.js",
|
"src/sources/live/contract.js",
|
||||||
@@ -709,9 +578,7 @@
|
|||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
"civil-flights": {
|
"civil-flights": {
|
||||||
"exports": [
|
"exports": ["./layers/flights"],
|
||||||
"./layers/flights"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -742,14 +609,10 @@
|
|||||||
"src/layers/flights/testing.js",
|
"src/layers/flights/testing.js",
|
||||||
"src/layers/flights/tracking.js"
|
"src/layers/flights/tracking.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"military-flights": {
|
"military-flights": {
|
||||||
"exports": [
|
"exports": ["./layers/military"],
|
||||||
"./layers/military"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -777,14 +640,10 @@
|
|||||||
"src/layers/military/testing.js",
|
"src/layers/military/testing.js",
|
||||||
"src/layers/military/tracking.js"
|
"src/layers/military/tracking.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"aircraft-mechanics": {
|
"aircraft-mechanics": {
|
||||||
"exports": [
|
"exports": ["./aircraft"],
|
||||||
"./aircraft"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/aircraftClass.js",
|
"src/data/aircraftClass.js",
|
||||||
"src/data/aircraftIcons.js",
|
"src/data/aircraftIcons.js",
|
||||||
@@ -804,14 +663,10 @@
|
|||||||
"src/layers/aircraft/classification.js",
|
"src/layers/aircraft/classification.js",
|
||||||
"src/layers/aircraft/index.js"
|
"src/layers/aircraft/index.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"vessel-layer": {
|
"vessel-layer": {
|
||||||
"exports": [
|
"exports": ["./layers/vessels"],
|
||||||
"./layers/vessels"
|
|
||||||
],
|
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/vessels/cards.js",
|
"src/layers/vessels/cards.js",
|
||||||
"src/layers/vessels/evidence.js",
|
"src/layers/vessels/evidence.js",
|
||||||
@@ -829,8 +684,6 @@
|
|||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/vesselLabels.js"
|
"src/data/vesselLabels.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": ["cesium"]
|
||||||
"cesium"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+49
-15
@@ -11,26 +11,60 @@ import * as focus from './focusDeemphasis.js';
|
|||||||
import * as worldFocus from '../worldFocus.js';
|
import * as worldFocus from '../worldFocus.js';
|
||||||
import * as render from '../renderGovernor.js';
|
import * as render from '../renderGovernor.js';
|
||||||
|
|
||||||
const aisLiveVesselsLayer = createVesselLayer({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live' }), options: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS }, services: { context, trails, labels, picking, overlay, geoid, sprites, focus, worldFocus, render } });
|
const aisLiveVesselsLayer = createVesselLayer({
|
||||||
|
source: createAisStreamSource({
|
||||||
|
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||||
|
}),
|
||||||
|
options: {
|
||||||
|
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
|
||||||
|
maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS,
|
||||||
|
},
|
||||||
|
services: {
|
||||||
|
context,
|
||||||
|
trails,
|
||||||
|
labels,
|
||||||
|
picking,
|
||||||
|
overlay,
|
||||||
|
geoid,
|
||||||
|
sprites,
|
||||||
|
focus,
|
||||||
|
worldFocus,
|
||||||
|
render,
|
||||||
|
},
|
||||||
|
});
|
||||||
export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js';
|
export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js';
|
||||||
export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError;
|
export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError;
|
||||||
export const classifyAisFeedSnapshot = aisLiveVesselsLayer.classifyAisFeedSnapshot;
|
export const classifyAisFeedSnapshot =
|
||||||
|
aisLiveVesselsLayer.classifyAisFeedSnapshot;
|
||||||
export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord;
|
export const mapAnalystRecord = aisLiveVesselsLayer.mapAnalystRecord;
|
||||||
export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM;
|
export const vesselDatumHeightM = aisLiveVesselsLayer.vesselDatumHeightM;
|
||||||
export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection;
|
export const reduceVesselSelection = aisLiveVesselsLayer.reduceVesselSelection;
|
||||||
export const applyVesselFocusDeemphasis = aisLiveVesselsLayer.applyVesselFocusDeemphasis;
|
export const applyVesselFocusDeemphasis =
|
||||||
|
aisLiveVesselsLayer.applyVesselFocusDeemphasis;
|
||||||
export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard;
|
export const buildVesselCard = aisLiveVesselsLayer.buildVesselCard;
|
||||||
export const buildSelectedVesselCard = aisLiveVesselsLayer.buildSelectedVesselCard;
|
export const buildSelectedVesselCard =
|
||||||
|
aisLiveVesselsLayer.buildSelectedVesselCard;
|
||||||
export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated;
|
export const cardScreenSeparated = aisLiveVesselsLayer.cardScreenSeparated;
|
||||||
export const _bindVesselInteractionForTest = aisLiveVesselsLayer.testing._bindVesselInteractionForTest;
|
export const _bindVesselInteractionForTest =
|
||||||
export const _setVesselStateForTest = aisLiveVesselsLayer.testing._setVesselStateForTest;
|
aisLiveVesselsLayer.testing._bindVesselInteractionForTest;
|
||||||
export const _setVesselOverlayHostForTest = aisLiveVesselsLayer.testing._setVesselOverlayHostForTest;
|
export const _setVesselStateForTest =
|
||||||
export const _updateVesselCardsForTest = aisLiveVesselsLayer.testing._updateVesselCardsForTest;
|
aisLiveVesselsLayer.testing._setVesselStateForTest;
|
||||||
export const _reconcileVesselsForTest = aisLiveVesselsLayer.testing._reconcileVesselsForTest;
|
export const _setVesselOverlayHostForTest =
|
||||||
export const _applyAisFeedSnapshotForTest = aisLiveVesselsLayer.testing._applyAisFeedSnapshotForTest;
|
aisLiveVesselsLayer.testing._setVesselOverlayHostForTest;
|
||||||
export const _loadLivePositionsForTest = aisLiveVesselsLayer.testing._loadLivePositionsForTest;
|
export const _updateVesselCardsForTest =
|
||||||
export const _beginAisSessionForTest = aisLiveVesselsLayer.testing._beginAisSessionForTest;
|
aisLiveVesselsLayer.testing._updateVesselCardsForTest;
|
||||||
export const _setAisRuntimeForTest = aisLiveVesselsLayer.testing._setAisRuntimeForTest;
|
export const _reconcileVesselsForTest =
|
||||||
export const _getVesselFeedStateForTest = aisLiveVesselsLayer.testing._getVesselFeedStateForTest;
|
aisLiveVesselsLayer.testing._reconcileVesselsForTest;
|
||||||
export const _getVesselStateForTest = aisLiveVesselsLayer.testing._getVesselStateForTest;
|
export const _applyAisFeedSnapshotForTest =
|
||||||
|
aisLiveVesselsLayer.testing._applyAisFeedSnapshotForTest;
|
||||||
|
export const _loadLivePositionsForTest =
|
||||||
|
aisLiveVesselsLayer.testing._loadLivePositionsForTest;
|
||||||
|
export const _beginAisSessionForTest =
|
||||||
|
aisLiveVesselsLayer.testing._beginAisSessionForTest;
|
||||||
|
export const _setAisRuntimeForTest =
|
||||||
|
aisLiveVesselsLayer.testing._setAisRuntimeForTest;
|
||||||
|
export const _getVesselFeedStateForTest =
|
||||||
|
aisLiveVesselsLayer.testing._getVesselFeedStateForTest;
|
||||||
|
export const _getVesselStateForTest =
|
||||||
|
aisLiveVesselsLayer.testing._getVesselStateForTest;
|
||||||
export default aisLiveVesselsLayer;
|
export default aisLiveVesselsLayer;
|
||||||
+67
-57
@@ -1,11 +1,18 @@
|
|||||||
import { accentForVesselType, normalizeVesselType } from '../../data/vesselLabels.js';
|
import {
|
||||||
|
accentForVesselType,
|
||||||
|
normalizeVesselType,
|
||||||
|
} from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
export function createCards({ vesselState, services, parts: components, layer, options }) {
|
export function createCards({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
|
||||||
|
function updateSelectedVesselHud(record) {
|
||||||
|
|
||||||
function updateSelectedVesselHud(record) {
|
|
||||||
const el = document.getElementById('hud-ais-vessel');
|
const el = document.getElementById('hud-ais-vessel');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
@@ -17,26 +24,23 @@ function updateSelectedVesselHud(record) {
|
|||||||
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
|
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
|
||||||
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
|
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetSelectedVesselHud() {
|
||||||
|
|
||||||
function resetSelectedVesselHud() {
|
|
||||||
const el = document.getElementById('hud-ais-vessel');
|
const el = document.getElementById('hud-ais-vessel');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.classList.remove('active');
|
el.classList.remove('active');
|
||||||
el.textContent = 'AIS: --';
|
el.textContent = 'AIS: --';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trimHudValue(value, maxLength) {
|
||||||
|
|
||||||
function trimHudValue(value, maxLength) {
|
|
||||||
const text = String(value || '--').trim() || '--';
|
const text = String(value || '--').trim() || '--';
|
||||||
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
return text.length > maxLength
|
||||||
}
|
? `${text.slice(0, maxLength - 3)}...`
|
||||||
|
: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
* Card model for an ambient (decluttered-in) vessel — name title plus one
|
||||||
* compact type/speed/heading detail line, anchored at the record's current
|
* compact type/speed/heading detail line, anchored at the record's current
|
||||||
* rendered position (height-datum caveat: no datum work here). Pure —
|
* rendered position (height-datum caveat: no datum work here). Pure —
|
||||||
@@ -45,11 +49,12 @@ function trimHudValue(value, maxLength) {
|
|||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildVesselCard(record) {
|
function buildVesselCard(record) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
const type = vesselTypeShort(record);
|
const type = vesselTypeShort(record);
|
||||||
if (type) parts.push(type);
|
if (type) parts.push(type);
|
||||||
if (record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed));
|
if (record.speed !== null && record.speed !== undefined)
|
||||||
|
parts.push(formatSpeed(record.speed));
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
|
if (Number.isFinite(direction)) parts.push(`${Math.round(direction)}°`);
|
||||||
return {
|
return {
|
||||||
@@ -63,10 +68,9 @@ function buildVesselCard(record) {
|
|||||||
selected: false,
|
selected: false,
|
||||||
priority: components.rendering.labelPriority(record, null),
|
priority: components.rendering.labelPriority(record, null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Card model for the click-selected vessel — the full-detail card, drawn last
|
* 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
|
* (on top) and never distance-faded by the overlay. Pinned-but-vanished
|
||||||
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
* vessels carry a STALE marker (mirrors the HUD readout). Pure — exported
|
||||||
@@ -75,17 +79,21 @@ function buildVesselCard(record) {
|
|||||||
* @returns {Object} vesselLabels entry.
|
* @returns {Object} vesselLabels entry.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function buildSelectedVesselCard(record) {
|
function buildSelectedVesselCard(record) {
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
const details = [[
|
const details = [
|
||||||
|
[
|
||||||
vesselTypeShort(record) || 'VESSEL',
|
vesselTypeShort(record) || 'VESSEL',
|
||||||
formatSpeed(record.speed),
|
formatSpeed(record.speed),
|
||||||
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
|
||||||
].join(' · ')];
|
].join(' · '),
|
||||||
|
];
|
||||||
const destination = String(record.destination || '').trim();
|
const destination = String(record.destination || '').trim();
|
||||||
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
|
||||||
const stale = (record.missedRefreshes || 0) > 0;
|
const stale = (record.missedRefreshes || 0) > 0;
|
||||||
details.push(`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`);
|
details.push(
|
||||||
|
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: vesselOverlayEntryId(record),
|
id: vesselOverlayEntryId(record),
|
||||||
actionable: Boolean(record?.mmsi),
|
actionable: Boolean(record?.mmsi),
|
||||||
@@ -97,29 +105,26 @@ function buildSelectedVesselCard(record) {
|
|||||||
selected: true,
|
selected: true,
|
||||||
priority: 100000,
|
priority: 100000,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
|
||||||
|
|
||||||
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
|
function vesselOverlayEntryId(record) {
|
||||||
|
|
||||||
function vesselOverlayEntryId(record) {
|
|
||||||
const mmsi = String(record?.mmsi || '').trim();
|
const mmsi = String(record?.mmsi || '').trim();
|
||||||
if (mmsi) return `vessel:${mmsi}`;
|
if (mmsi) return `vessel:${mmsi}`;
|
||||||
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
||||||
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
||||||
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
||||||
return `vessel:unkeyed:${name}:${lat}:${lon}`;
|
return `vessel:unkeyed:${name}:${lat}:${lon}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
||||||
|
|
||||||
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
|
function vesselTypeShort(record) {
|
||||||
|
|
||||||
function vesselTypeShort(record) {
|
|
||||||
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
|
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* True when `screen` is at least `minSepPx` away from every accepted screen
|
* True when `screen` is at least `minSepPx` away from every accepted screen
|
||||||
* position (greedy card-declutter accept test, mirroring the FIRMS pass).
|
* position (greedy card-declutter accept test, mirroring the FIRMS pass).
|
||||||
* Exported for unit tests.
|
* Exported for unit tests.
|
||||||
@@ -129,7 +134,7 @@ function vesselTypeShort(record) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function cardScreenSeparated(accepted, screen, minSepPx) {
|
function cardScreenSeparated(accepted, screen, minSepPx) {
|
||||||
const minSq = minSepPx * minSepPx;
|
const minSq = minSepPx * minSepPx;
|
||||||
for (let i = 0; i < accepted.length; i += 1) {
|
for (let i = 0; i < accepted.length; i += 1) {
|
||||||
const dx = screen.x - accepted[i].x;
|
const dx = screen.x - accepted[i].x;
|
||||||
@@ -137,35 +142,40 @@ function cardScreenSeparated(accepted, screen, minSepPx) {
|
|||||||
if (dx * dx + dy * dy < minSq) return false;
|
if (dx * dx + dy * dy < minSq) return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function displayVesselName(record) {
|
||||||
|
|
||||||
function displayVesselName(record) {
|
|
||||||
const name = String(record.name || '').trim();
|
const name = String(record.name || '').trim();
|
||||||
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
||||||
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
|
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSpeed(speed) {
|
||||||
|
|
||||||
function formatSpeed(speed) {
|
|
||||||
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
|
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHeading(heading) {
|
||||||
|
|
||||||
function formatHeading(heading) {
|
|
||||||
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
|
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatPositionTime(record) {
|
||||||
|
|
||||||
function formatPositionTime(record) {
|
|
||||||
if (!record.lastPositionUtc) return 'POS: LIVE';
|
if (!record.lastPositionUtc) return 'POS: LIVE';
|
||||||
const date = new Date(record.lastPositionUtc);
|
const date = new Date(record.lastPositionUtc);
|
||||||
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
|
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
|
||||||
return `POS: ${date.toISOString().slice(11, 19)}Z`;
|
return `POS: ${date.toISOString().slice(11, 19)}Z`;
|
||||||
}
|
}
|
||||||
return { updateSelectedVesselHud, resetSelectedVesselHud, trimHudValue, buildVesselCard, buildSelectedVesselCard, vesselOverlayEntryId, vesselTypeShort, cardScreenSeparated, displayVesselName, formatSpeed, formatHeading, formatPositionTime };
|
return {
|
||||||
|
updateSelectedVesselHud,
|
||||||
|
resetSelectedVesselHud,
|
||||||
|
trimHudValue,
|
||||||
|
buildVesselCard,
|
||||||
|
buildSelectedVesselCard,
|
||||||
|
vesselOverlayEntryId,
|
||||||
|
vesselTypeShort,
|
||||||
|
cardScreenSeparated,
|
||||||
|
displayVesselName,
|
||||||
|
formatSpeed,
|
||||||
|
formatHeading,
|
||||||
|
formatPositionTime,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,26 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
import { FOCUS_EVIDENCE_DEV } from './policy.js';
|
||||||
|
|
||||||
export function createEvidence({ vesselState, services, parts: components, layer, options }) {
|
export function createEvidence({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
|
||||||
|
/** Replace live AIS rows through the production reconciliation path (DEV only). */
|
||||||
|
|
||||||
/** Replace live AIS rows through the production reconciliation path (DEV only). */
|
function _setFocusEvidenceVessels(rows = []) {
|
||||||
|
|
||||||
function _setFocusEvidenceVessels(rows = []) {
|
|
||||||
if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) {
|
if (!FOCUS_EVIDENCE_DEV || !state.viewer || !state.billboardCollection) {
|
||||||
return { ok: false, count: 0 };
|
return { ok: false, count: 0 };
|
||||||
}
|
}
|
||||||
components.selection.clearVesselInspection();
|
components.selection.clearVesselInspection();
|
||||||
components.store.reconcileVessels(state.viewer, Array.isArray(rows) ? rows : []);
|
components.store.reconcileVessels(
|
||||||
|
state.viewer,
|
||||||
|
Array.isArray(rows) ? rows : [],
|
||||||
|
);
|
||||||
state.count = state.vesselRecords.length;
|
state.count = state.vesselRecords.length;
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -23,17 +31,19 @@ function _setFocusEvidenceVessels(rows = []) {
|
|||||||
state.rawRowCount = Array.isArray(rows) ? rows.length : 0;
|
state.rawRowCount = Array.isArray(rows) ? rows.length : 0;
|
||||||
state.acceptedRowCount = state.count;
|
state.acceptedRowCount = state.count;
|
||||||
return { ok: true, count: 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() {
|
||||||
|
|
||||||
function _focusEvidenceVesselSnapshot() {
|
|
||||||
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
|
if (!FOCUS_EVIDENCE_DEV || !state.viewer) return [];
|
||||||
return state.vesselRecords.map((record) => {
|
return state.vesselRecords.map((record) => {
|
||||||
const bb = record.billboard;
|
const bb = record.billboard;
|
||||||
const screen = bb?.position
|
const screen = bb?.position
|
||||||
? Cesium.SceneTransforms.worldToWindowCoordinates(state.viewer.scene, bb.position)
|
? Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
state.viewer.scene,
|
||||||
|
bb.position,
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
id: record.mmsi,
|
id: record.mmsi,
|
||||||
@@ -43,6 +53,6 @@ function _focusEvidenceVesselSnapshot() {
|
|||||||
y: screen?.y ?? null,
|
y: screen?.y ?? null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot };
|
return { _setFocusEvidenceVessels, _focusEvidenceVesselSnapshot };
|
||||||
}
|
}
|
||||||
+39
-22
@@ -11,29 +11,46 @@ import { createEvidence } from './evidence.js';
|
|||||||
import { createQueries } from './queries.js';
|
import { createQueries } from './queries.js';
|
||||||
/** Compose one vessel layer with application-owned scene services. */
|
/** Compose one vessel layer with application-owned scene services. */
|
||||||
export function createVesselLayer({ source, services, options = {} } = {}) {
|
export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||||
const vesselState = createVesselState({ source, services });
|
const vesselState = createVesselState({ source, services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const layer = {};
|
const layer = {};
|
||||||
const context = { vesselState, services, parts, layer, options };
|
const context = { vesselState, services, parts, layer, options };
|
||||||
parts.lifecycle = createLifecycle(context);
|
parts.lifecycle = createLifecycle(context);
|
||||||
parts.ingestion = createIngestion(context);
|
parts.ingestion = createIngestion(context);
|
||||||
parts.store = createStore(context);
|
parts.store = createStore(context);
|
||||||
parts.rendering = createRendering(context);
|
parts.rendering = createRendering(context);
|
||||||
parts.selection = createSelection(context);
|
parts.selection = createSelection(context);
|
||||||
parts.tracking = createTracking(context);
|
parts.tracking = createTracking(context);
|
||||||
parts.cards = createCards(context);
|
parts.cards = createCards(context);
|
||||||
parts.testing = createTesting(context);
|
parts.testing = createTesting(context);
|
||||||
parts.evidence = createEvidence(context);
|
parts.evidence = createEvidence(context);
|
||||||
parts.queries = createQueries(context);
|
parts.queries = createQueries(context);
|
||||||
Object.assign(layer, parts.queries.methods, parts.lifecycle.methods, parts.ingestion.methods);
|
Object.assign(
|
||||||
layer.source = source?.label || 'Vessels';
|
layer,
|
||||||
Object.assign(layer, { applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis, buildVesselCard: parts.cards.buildVesselCard, buildSelectedVesselCard: parts.cards.buildSelectedVesselCard, cardScreenSeparated: parts.cards.cardScreenSeparated });
|
parts.queries.methods,
|
||||||
Object.defineProperty(layer, 'testing', { value: parts.testing });
|
parts.lifecycle.methods,
|
||||||
return layer;
|
parts.ingestion.methods,
|
||||||
|
);
|
||||||
|
layer.source = source?.label || 'Vessels';
|
||||||
|
Object.assign(layer, {
|
||||||
|
applyVesselFocusDeemphasis: parts.rendering.applyVesselFocusDeemphasis,
|
||||||
|
buildVesselCard: parts.cards.buildVesselCard,
|
||||||
|
buildSelectedVesselCard: parts.cards.buildSelectedVesselCard,
|
||||||
|
cardScreenSeparated: parts.cards.cardScreenSeparated,
|
||||||
|
});
|
||||||
|
Object.defineProperty(layer, 'testing', { value: parts.testing });
|
||||||
|
return layer;
|
||||||
}
|
}
|
||||||
export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js';
|
export { AIS_FIRST_CONNECT_GRACE_MS } from './policy.js';
|
||||||
export {
|
export {
|
||||||
VESSEL_OVERLAY_SOURCE_ID, VESSEL_LABEL_GRID_PX, VESSEL_DEFAULT_LABEL_LIMIT,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
VESSEL_OVERLAY_MAX_COHORT, VESSEL_CARD_FADE_DISTANCE_M, normalizeVesselType,
|
VESSEL_LABEL_GRID_PX,
|
||||||
vesselTypeCss, accentForVesselType, vesselOverlayCohortLimit, applyVesselOverlayPolicy,
|
VESSEL_DEFAULT_LABEL_LIMIT,
|
||||||
|
VESSEL_OVERLAY_MAX_COHORT,
|
||||||
|
VESSEL_CARD_FADE_DISTANCE_M,
|
||||||
|
normalizeVesselType,
|
||||||
|
vesselTypeCss,
|
||||||
|
accentForVesselType,
|
||||||
|
vesselOverlayCohortLimit,
|
||||||
|
applyVesselOverlayPolicy,
|
||||||
} from '../../data/vesselLabels.js';
|
} from '../../data/vesselLabels.js';
|
||||||
+107
-53
@@ -1,12 +1,16 @@
|
|||||||
import { AIS_FIRST_CONNECT_LABEL } from './policy.js';
|
import { AIS_FIRST_CONNECT_LABEL } from './policy.js';
|
||||||
|
|
||||||
export function createIngestion({ vesselState, services, parts: components, layer, options }) {
|
export function createIngestion({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const aisLiveVesselsLayer = layer;
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const aisLiveVesselsLayer = layer;
|
||||||
|
|
||||||
|
async function loadLivePositions(viewer) {
|
||||||
|
|
||||||
async function loadLivePositions(viewer) {
|
|
||||||
if (!viewer || state.loading) return;
|
if (!viewer || state.loading) return;
|
||||||
state.loading = true;
|
state.loading = true;
|
||||||
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...';
|
state.loadingLabel = state.loaded ? 'refreshing...' : 'loading...';
|
||||||
@@ -17,49 +21,75 @@ async function loadLivePositions(viewer) {
|
|||||||
try {
|
try {
|
||||||
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
|
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
|
||||||
// can't wedge the poll indefinitely (parity with the track fetch + flights).
|
// can't wedge the poll indefinitely (parity with the track fetch + flights).
|
||||||
const signal = typeof AbortSignal.any === 'function'
|
const signal =
|
||||||
? AbortSignal.any([requestController.signal, AbortSignal.timeout(10000)])
|
typeof AbortSignal.any === 'function'
|
||||||
|
? AbortSignal.any([
|
||||||
|
requestController.signal,
|
||||||
|
AbortSignal.timeout(10000),
|
||||||
|
])
|
||||||
: requestController.signal;
|
: requestController.signal;
|
||||||
const snapshot = await vesselState._source.getSnapshot({ maxRows: components.rendering.renderRowLimit() }, { signal });
|
const snapshot = await vesselState._source.getSnapshot(
|
||||||
|
{ maxRows: components.rendering.renderRowLimit() },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||||
aisLiveVesselsLayer.source = snapshot.source;
|
aisLiveVesselsLayer.source = snapshot.source;
|
||||||
// Map observations into the existing display store; source fields stop here.
|
// Map observations into the existing display store; source fields stop here.
|
||||||
applyAisFeedSnapshot(viewer, {
|
applyAisFeedSnapshot(viewer, {
|
||||||
rows: snapshot.records.map(vesselDisplayRow), observedAtMs: snapshot.observedAtMs, freshness: snapshot.freshness, complete: snapshot.complete, rawRowCount: snapshot.rawRowCount, reason: snapshot.reason, status: snapshot.transportStatus,
|
rows: snapshot.records.map(vesselDisplayRow),
|
||||||
lastMessageAt: snapshot.lastMessageAt, nextAttemptAt: snapshot.nextAttemptAt,
|
observedAtMs: snapshot.observedAtMs,
|
||||||
refreshing: snapshot.stale, newestPositionAt: snapshot.observedAtMs == null ? null : new Date(snapshot.observedAtMs).toISOString(),
|
freshness: snapshot.freshness,
|
||||||
silentForMs: snapshot.silentForMs, reconnectAttempt: snapshot.reconnectAttempt,
|
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) {
|
} catch (error) {
|
||||||
if (ownsAisRequest(requestController, requestSessionId) && error?.name !== 'AbortError') {
|
if (
|
||||||
components.lifecycle.markAisUnavailable(error?.message || 'AIS live load failed');
|
ownsAisRequest(requestController, requestSessionId) &&
|
||||||
|
error?.name !== 'AbortError'
|
||||||
|
) {
|
||||||
|
components.lifecycle.markAisUnavailable(
|
||||||
|
error?.message || 'AIS live load failed',
|
||||||
|
);
|
||||||
console.warn('[Data:ais-live-vessels]', state.error, error);
|
console.warn('[Data:ais-live-vessels]', state.error, error);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (state.abort === requestController && state.sessionId === requestSessionId) {
|
if (
|
||||||
|
state.abort === requestController &&
|
||||||
|
state.sessionId === requestSessionId
|
||||||
|
) {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.loadingLabel = state.firstConnectPhase === 'loading'
|
state.loadingLabel =
|
||||||
? AIS_FIRST_CONNECT_LABEL
|
state.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
|
||||||
: '';
|
|
||||||
state.abort = null;
|
state.abort = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while a request still owns this enabled layer lifecycle. */
|
||||||
|
|
||||||
/** 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ownsAisRequest(controller, sessionId) {
|
/** Apply a classified snapshot while preserving warm state on zero accepted rows. */
|
||||||
return state.enabled
|
|
||||||
&& state.sessionId === sessionId
|
|
||||||
&& state.abort === controller
|
|
||||||
&& !controller.signal.aborted;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function applyAisFeedSnapshot(viewer, payload) {
|
||||||
/** Apply a classified snapshot while preserving warm state on zero accepted rows. */
|
|
||||||
|
|
||||||
function applyAisFeedSnapshot(viewer, payload) {
|
|
||||||
const snapshot = components.queries.classifyAisFeedSnapshot(payload);
|
const snapshot = components.queries.classifyAisFeedSnapshot(payload);
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
state.loadingLabel = '';
|
state.loadingLabel = '';
|
||||||
@@ -72,13 +102,17 @@ function applyAisFeedSnapshot(viewer, payload) {
|
|||||||
if (snapshot.acceptedRowCount === 0) {
|
if (snapshot.acceptedRowCount === 0) {
|
||||||
state.count = state.vesselRecords.length;
|
state.count = state.vesselRecords.length;
|
||||||
state.stale = state.count > 0 || Boolean(payload?.refreshing);
|
state.stale = state.count > 0 || Boolean(payload?.refreshing);
|
||||||
if (components.lifecycle.isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
if (
|
||||||
|
components.lifecycle.isDefinitiveTransportFailure(
|
||||||
|
snapshot.transportStatus,
|
||||||
|
)
|
||||||
|
) {
|
||||||
components.lifecycle.markAisUnavailable(snapshot.error);
|
components.lifecycle.markAisUnavailable(snapshot.error);
|
||||||
return { reconciled: false, ...snapshot };
|
return { reconciled: false, ...snapshot };
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
state.firstConnectPhase === 'loading'
|
state.firstConnectPhase === 'loading' &&
|
||||||
&& components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus)
|
components.lifecycle.isGraceEligibleTransport(snapshot.transportStatus)
|
||||||
) {
|
) {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
||||||
@@ -93,38 +127,58 @@ function applyAisFeedSnapshot(viewer, payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
components.lifecycle.settleFirstConnectPhase('ready');
|
components.lifecycle.settleFirstConnectPhase('ready');
|
||||||
components.store.reconcileVessels(viewer, snapshot.acceptedRows, { complete: payload?.complete !== false });
|
components.store.reconcileVessels(viewer, snapshot.acceptedRows, {
|
||||||
|
complete: payload?.complete !== false,
|
||||||
|
});
|
||||||
state.count = state.vesselRecords.length;
|
state.count = state.vesselRecords.length;
|
||||||
state.stale = Boolean(payload?.refreshing) || payload?.complete === false || payload?.freshness === 'unknown';
|
state.stale =
|
||||||
|
Boolean(payload?.refreshing) ||
|
||||||
|
payload?.complete === false ||
|
||||||
|
payload?.freshness === 'unknown';
|
||||||
state.newestPositionAt = payload?.newestPositionAt || null;
|
state.newestPositionAt = payload?.newestPositionAt || null;
|
||||||
// Not unconditionally null: a degraded feed keeps its reason even though the
|
// Not unconditionally null: a degraded feed keeps its reason even though the
|
||||||
// cached vessels are still drawable, so the chip cannot go quiet on an
|
// cached vessels are still drawable, so the chip cannot go quiet on an
|
||||||
// outage the user is still looking at.
|
// outage the user is still looking at.
|
||||||
state.error = snapshot.error || payload?.reason || null;
|
state.error = snapshot.error || payload?.reason || null;
|
||||||
state.lastUpdate = Object.hasOwn(payload, 'observedAtMs') ? payload.observedAtMs : vesselState._aisRuntime.now();
|
state.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
|
||||||
|
? payload.observedAtMs
|
||||||
|
: vesselState._aisRuntime.now();
|
||||||
return { reconciled: true, ...snapshot };
|
return { reconciled: true, ...snapshot };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vesselDisplayRow(record) {
|
||||||
|
|
||||||
function vesselDisplayRow(record) {
|
|
||||||
return {
|
return {
|
||||||
mmsi: record.id, reference: record.reference, lat: record.latitude, lon: record.longitude,
|
mmsi: record.id,
|
||||||
name: record.name, imo: record.imo, type: record.type, destination: record.destination,
|
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,
|
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
||||||
course: record.courseDeg, heading: record.headingDeg,
|
course: record.courseDeg,
|
||||||
last_position_epoch: record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
heading: record.headingDeg,
|
||||||
last_position_UTC: record.observedAtMs == null ? '' : new Date(record.observedAtMs).toISOString(),
|
last_position_epoch:
|
||||||
|
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
||||||
|
last_position_UTC:
|
||||||
|
record.observedAtMs == null
|
||||||
|
? ''
|
||||||
|
: new Date(record.observedAtMs).toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const methods = {
|
const methods = {
|
||||||
|
|
||||||
|
|
||||||
update(viewer) {
|
update(viewer) {
|
||||||
if (!state.enabled) return Promise.resolve();
|
if (!state.enabled) return Promise.resolve();
|
||||||
return loadLivePositions(viewer || state.viewer);
|
return loadLivePositions(viewer || state.viewer);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { loadLivePositions, ownsAisRequest, applyAisFeedSnapshot, vesselDisplayRow, methods };
|
return {
|
||||||
|
loadLivePositions,
|
||||||
|
ownsAisRequest,
|
||||||
|
applyAisFeedSnapshot,
|
||||||
|
vesselDisplayRow,
|
||||||
|
methods,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,38 @@
|
|||||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||||
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_FIRST_CONNECT_LABEL, AIS_HEALTHY_STATUSES } from './policy.js';
|
import {
|
||||||
|
AIS_FIRST_CONNECT_GRACE_MS,
|
||||||
|
AIS_FIRST_CONNECT_LABEL,
|
||||||
|
AIS_HEALTHY_STATUSES,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
export function createLifecycle({ vesselState, services, parts: components, layer, options }) {
|
export function createLifecycle({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
|
services,
|
||||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
parts: components,
|
||||||
const { ensureGeoidReady } = services.geoid;
|
layer,
|
||||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites;
|
||||||
|
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||||
|
const { ensureGeoidReady } = services.geoid;
|
||||||
|
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||||
|
|
||||||
|
function clearFirstConnectTimer() {
|
||||||
|
|
||||||
function clearFirstConnectTimer() {
|
|
||||||
if (state.firstConnectTimer === null) return;
|
if (state.firstConnectTimer === null) return;
|
||||||
vesselState._aisRuntime.clearTimeout(state.firstConnectTimer);
|
vesselState._aisRuntime.clearTimeout(state.firstConnectTimer);
|
||||||
state.firstConnectTimer = null;
|
state.firstConnectTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function invalidateAisSession() {
|
||||||
|
|
||||||
function invalidateAisSession() {
|
|
||||||
clearFirstConnectTimer();
|
clearFirstConnectTimer();
|
||||||
state.sessionId = ++vesselState._aisSessionSequence;
|
state.sessionId = ++vesselState._aisSessionSequence;
|
||||||
state.firstConnectPhase = 'idle';
|
state.firstConnectPhase = 'idle';
|
||||||
state.firstConnectStartedAt = null;
|
state.firstConnectStartedAt = null;
|
||||||
state.firstConnectDeadline = null;
|
state.firstConnectDeadline = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function beginAisSession() {
|
||||||
|
|
||||||
function beginAisSession() {
|
|
||||||
clearFirstConnectTimer();
|
clearFirstConnectTimer();
|
||||||
const sessionId = ++vesselState._aisSessionSequence;
|
const sessionId = ++vesselState._aisSessionSequence;
|
||||||
const startedAt = vesselState._aisRuntime.now();
|
const startedAt = vesselState._aisRuntime.now();
|
||||||
@@ -39,18 +43,18 @@ function beginAisSession() {
|
|||||||
state.error = null;
|
state.error = null;
|
||||||
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
state.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
||||||
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
|
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleFirstConnectExpiry(sessionId, delayMs) {
|
||||||
|
|
||||||
function scheduleFirstConnectExpiry(sessionId, delayMs) {
|
|
||||||
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
|
state.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => {
|
||||||
if (
|
if (
|
||||||
!state.enabled
|
!state.enabled ||
|
||||||
|| state.sessionId !== sessionId
|
state.sessionId !== sessionId ||
|
||||||
|| state.firstConnectPhase !== 'loading'
|
state.firstConnectPhase !== 'loading'
|
||||||
) return;
|
)
|
||||||
const remainingMs = state.firstConnectDeadline - vesselState._aisRuntime.now();
|
return;
|
||||||
|
const remainingMs =
|
||||||
|
state.firstConnectDeadline - vesselState._aisRuntime.now();
|
||||||
if (remainingMs > 0) {
|
if (remainingMs > 0) {
|
||||||
scheduleFirstConnectExpiry(sessionId, remainingMs);
|
scheduleFirstConnectExpiry(sessionId, remainingMs);
|
||||||
return;
|
return;
|
||||||
@@ -63,39 +67,29 @@ function scheduleFirstConnectExpiry(sessionId, delayMs) {
|
|||||||
: 'awaiting first AIS message…';
|
: 'awaiting first AIS message…';
|
||||||
state.stale = state.count > 0;
|
state.stale = state.count > 0;
|
||||||
}, delayMs);
|
}, delayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settleFirstConnectPhase(phase) {
|
||||||
|
|
||||||
function settleFirstConnectPhase(phase) {
|
|
||||||
clearFirstConnectTimer();
|
clearFirstConnectTimer();
|
||||||
state.firstConnectPhase = phase;
|
state.firstConnectPhase = phase;
|
||||||
state.loadingLabel = '';
|
state.loadingLabel = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGraceEligibleTransport(status) {
|
||||||
|
|
||||||
function isGraceEligibleTransport(status) {
|
|
||||||
return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting';
|
return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDefinitiveTransportFailure(status) {
|
||||||
|
|
||||||
function isDefinitiveTransportFailure(status) {
|
|
||||||
return Boolean(status) && !isGraceEligibleTransport(status);
|
return Boolean(status) && !isGraceEligibleTransport(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markAisUnavailable(reason) {
|
||||||
|
|
||||||
function markAisUnavailable(reason) {
|
|
||||||
settleFirstConnectPhase('unavailable');
|
settleFirstConnectPhase('unavailable');
|
||||||
state.error = reason || 'AIS live load failed';
|
state.error = reason || 'AIS live load failed';
|
||||||
state.stale = state.count > 0;
|
state.stale = state.count > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
|
||||||
function resetState() {
|
|
||||||
state.abort?.abort();
|
state.abort?.abort();
|
||||||
state.trailAbort?.abort();
|
state.trailAbort?.abort();
|
||||||
state.trailAbort = null;
|
state.trailAbort = null;
|
||||||
@@ -141,30 +135,32 @@ function resetState() {
|
|||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
state.trailMmsi = null;
|
state.trailMmsi = null;
|
||||||
state.trailBackfillToken = 0;
|
state.trailBackfillToken = 0;
|
||||||
}
|
}
|
||||||
const methods = {
|
const methods = {
|
||||||
|
|
||||||
|
|
||||||
/** Configure the source before initialization; an active layer keeps its owner. */
|
/** Configure the source before initialization; an active layer keeps its owner. */
|
||||||
setSource(source) {
|
setSource(source) {
|
||||||
if (state.viewer) throw new Error('Configure the source before layer initialization');
|
if (state.viewer)
|
||||||
if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
|
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;
|
vesselState._source = source;
|
||||||
this.source = source.label || this.source;
|
this.source = source.label || this.source;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
init(viewer) {
|
init(viewer) {
|
||||||
if (typeof vesselState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required');
|
if (typeof vesselState._source?.getSnapshot !== 'function')
|
||||||
|
throw new TypeError('A snapshot source is required');
|
||||||
state.viewer = viewer;
|
state.viewer = viewer;
|
||||||
components.rendering.ensureCollections(viewer);
|
components.rendering.ensureCollections(viewer);
|
||||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false);
|
vesselState._vesselOverlayHost.setVisible(
|
||||||
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
|
false,
|
||||||
|
);
|
||||||
components.selection.installInteraction(viewer);
|
components.selection.installInteraction(viewer);
|
||||||
components.rendering.installRuntime(viewer);
|
components.rendering.installRuntime(viewer);
|
||||||
restoreSpriteOrder(viewer);
|
restoreSpriteOrder(viewer);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
enable(viewer) {
|
enable(viewer) {
|
||||||
const wasEnabled = state.enabled;
|
const wasEnabled = state.enabled;
|
||||||
state.enabled = true;
|
state.enabled = true;
|
||||||
@@ -187,16 +183,19 @@ const methods = {
|
|||||||
vesselState._geoidReady = true;
|
vesselState._geoidReady = true;
|
||||||
components.tracking.refloorVesselRecords();
|
components.tracking.refloorVesselRecords();
|
||||||
})
|
})
|
||||||
.catch(() => { /* grid failed to load — anchors stay at ellipsoid 0 */ });
|
.catch(() => {
|
||||||
|
/* grid failed to load — anchors stay at ellipsoid 0 */
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
||||||
// the registry resolver reduces it to the record's mmsi (a string key).
|
// the registry resolver reduces it to the record's mmsi (a string key).
|
||||||
registerPickOwner('ais-live-vessels', (pickedId) => state.vesselMap.has(pickedId));
|
registerPickOwner('ais-live-vessels', (pickedId) =>
|
||||||
|
state.vesselMap.has(pickedId),
|
||||||
|
);
|
||||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||||
return components.ingestion.loadLivePositions(activeViewer);
|
return components.ingestion.loadLivePositions(activeViewer);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
state.enabled = false;
|
state.enabled = false;
|
||||||
invalidateAisSession();
|
invalidateAisSession();
|
||||||
@@ -215,7 +214,6 @@ const methods = {
|
|||||||
state.loadingLabel = '';
|
state.loadingLabel = '';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
destroy(viewer) {
|
destroy(viewer) {
|
||||||
const activeViewer = viewer || state.viewer;
|
const activeViewer = viewer || state.viewer;
|
||||||
invalidateAisSession();
|
invalidateAisSession();
|
||||||
@@ -228,14 +226,28 @@ const methods = {
|
|||||||
activeViewer.scene.primitives.remove(state.billboardCollection);
|
activeViewer.scene.primitives.remove(state.billboardCollection);
|
||||||
}
|
}
|
||||||
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID);
|
||||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, false);
|
vesselState._vesselOverlayHost.setVisible(
|
||||||
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
|
false,
|
||||||
|
);
|
||||||
components.selection.removeVesselInteraction();
|
components.selection.removeVesselInteraction();
|
||||||
if (state.preRenderRemover) {
|
if (state.preRenderRemover) {
|
||||||
state.preRenderRemover();
|
state.preRenderRemover();
|
||||||
}
|
}
|
||||||
resetState();
|
resetState();
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { clearFirstConnectTimer, invalidateAisSession, beginAisSession, scheduleFirstConnectExpiry, settleFirstConnectPhase, isGraceEligibleTransport, isDefinitiveTransportFailure, markAisUnavailable, resetState, methods };
|
return {
|
||||||
|
clearFirstConnectTimer,
|
||||||
|
invalidateAisSession,
|
||||||
|
beginAisSession,
|
||||||
|
scheduleFirstConnectExpiry,
|
||||||
|
settleFirstConnectPhase,
|
||||||
|
isGraceEligibleTransport,
|
||||||
|
isDefinitiveTransportFailure,
|
||||||
|
markAisUnavailable,
|
||||||
|
resetState,
|
||||||
|
methods,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -6,32 +6,85 @@ import { createVesselState } from './state.js';
|
|||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
function services() {
|
function services() {
|
||||||
return {
|
return {
|
||||||
context: { clearSelectedEntityContextForLayer: noop, registerEntityContext: () => null },
|
context: {
|
||||||
trails: { createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }) },
|
clearSelectedEntityContextForLayer: noop,
|
||||||
|
registerEntityContext: () => null,
|
||||||
|
},
|
||||||
|
trails: {
|
||||||
|
createTrail: () => ({ setPositions: noop, clear: noop, destroy: noop }),
|
||||||
|
},
|
||||||
labels: {},
|
labels: {},
|
||||||
picking: { unregisterPickOwner: noop },
|
picking: { unregisterPickOwner: noop },
|
||||||
overlay: { setOverlayEntries: noop, setOverlaySourceVisible: noop, clearOverlaySource: noop },
|
overlay: {
|
||||||
geoid: {}, sprites: {},
|
setOverlayEntries: noop,
|
||||||
focus: { focusNowMs: (now) => now, getFocusTarget: () => null, focusPassIsNeeded: () => false, forgetSpriteFocus: noop },
|
setOverlaySourceVisible: noop,
|
||||||
worldFocus: {}, render: { releaseContinuousRender: noop },
|
clearOverlaySource: noop,
|
||||||
|
},
|
||||||
|
geoid: {},
|
||||||
|
sprites: {},
|
||||||
|
focus: {
|
||||||
|
focusNowMs: (now) => now,
|
||||||
|
getFocusTarget: () => null,
|
||||||
|
focusPassIsNeeded: () => false,
|
||||||
|
forgetSpriteFocus: noop,
|
||||||
|
},
|
||||||
|
worldFocus: {},
|
||||||
|
render: { releaseContinuousRender: noop },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function setup(source, options = {}) {
|
function setup(source, options = {}) {
|
||||||
const layer = createVesselLayer({ source, services: services(), options });
|
const layer = createVesselLayer({ source, services: services(), options });
|
||||||
layer.testing._setVesselStateForTest({ viewer: {}, billboardCollection: { add: (options) => ({ ...options }), remove: noop } });
|
layer.testing._setVesselStateForTest({
|
||||||
|
viewer: {},
|
||||||
|
billboardCollection: { add: (options) => ({ ...options }), remove: noop },
|
||||||
|
});
|
||||||
let now = 1000;
|
let now = 1000;
|
||||||
layer.testing._setAisRuntimeForTest({ now: () => now, setTimeout, clearTimeout });
|
layer.testing._setAisRuntimeForTest({
|
||||||
return { layer, advance: (ms) => { now += ms; } };
|
now: () => now,
|
||||||
|
setTimeout,
|
||||||
|
clearTimeout,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
layer,
|
||||||
|
advance: (ms) => {
|
||||||
|
now += ms;
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const observation = (id, reference = id) => ({ id, reference, latitude: 51.93, longitude: 4.05, name: id, observedAtMs: 500, speedMps: 4, headingDeg: 180, courseDeg: 170 });
|
const observation = (id, reference = id) => ({
|
||||||
const snapshot = (records, extra = {}) => ({ records, source: 'Fixture vessels', complete: true, stale: false, freshness: 'current', observedAtMs: 500, transportStatus: 'live', rawRowCount: records.length, ...extra });
|
id,
|
||||||
|
reference,
|
||||||
|
latitude: 51.93,
|
||||||
|
longitude: 4.05,
|
||||||
|
name: id,
|
||||||
|
observedAtMs: 500,
|
||||||
|
speedMps: 4,
|
||||||
|
headingDeg: 180,
|
||||||
|
courseDeg: 170,
|
||||||
|
});
|
||||||
|
const snapshot = (records, extra = {}) => ({
|
||||||
|
records,
|
||||||
|
source: 'Fixture vessels',
|
||||||
|
complete: true,
|
||||||
|
stale: false,
|
||||||
|
freshness: 'current',
|
||||||
|
observedAtMs: 500,
|
||||||
|
transportStatus: 'live',
|
||||||
|
rawRowCount: records.length,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
test('vessel construction is inert and each instance owns its records, icon cache and clock', () => {
|
test('vessel construction is inert and each instance owns its records, icon cache and clock', () => {
|
||||||
let requested = 0;
|
let requested = 0;
|
||||||
const source = { getSnapshot() { requested++; } };
|
const source = {
|
||||||
|
getSnapshot() {
|
||||||
|
requested++;
|
||||||
|
},
|
||||||
|
};
|
||||||
const one = createVesselState({ source, services: services() });
|
const one = createVesselState({ source, services: services() });
|
||||||
const two = createVesselState({ source, services: services() });
|
const two = createVesselState({ source, services: services() });
|
||||||
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen']) assert.notEqual(one[key], two[key]);
|
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
|
||||||
|
assert.notEqual(one[key], two[key]);
|
||||||
assert.notEqual(one.state.vesselMap, two.state.vesselMap);
|
assert.notEqual(one.state.vesselMap, two.state.vesselMap);
|
||||||
assert.notEqual(one.state.trailPositions, two.state.trailPositions);
|
assert.notEqual(one.state.trailPositions, two.state.trailPositions);
|
||||||
const { layer } = setup(source);
|
const { layer } = setup(source);
|
||||||
@@ -50,7 +103,11 @@ test('missing vessel source fails before mutating the viewer', () => {
|
|||||||
|
|
||||||
test('partial observations retain missing contacts only until their receipt deadline', async () => {
|
test('partial observations retain missing contacts only until their receipt deadline', async () => {
|
||||||
let current = snapshot([observation('111'), observation('222')]);
|
let current = snapshot([observation('111'), observation('222')]);
|
||||||
const { layer, advance } = setup({ async getSnapshot() { return current; } });
|
const { layer, advance } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
|
current = snapshot([observation('111')], { complete: false, rawRowCount: 2 });
|
||||||
await layer.update();
|
await layer.update();
|
||||||
@@ -66,7 +123,11 @@ test('partial observations retain missing contacts only until their receipt dead
|
|||||||
|
|
||||||
test('a complete feed still removes an absent unselected vessel immediately', async () => {
|
test('a complete feed still removes an absent unselected vessel immediately', async () => {
|
||||||
let current = snapshot([observation('111'), observation('222')]);
|
let current = snapshot([observation('111'), observation('222')]);
|
||||||
const { layer } = setup({ async getSnapshot() { return current; } });
|
const { layer } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
current = snapshot([observation('111')]);
|
current = snapshot([observation('111')]);
|
||||||
await layer.update();
|
await layer.update();
|
||||||
@@ -74,10 +135,22 @@ test('a complete feed still removes an absent unselected vessel immediately', as
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an incomplete feed bounds its retained union to the configured row budget', async () => {
|
test('an incomplete feed bounds its retained union to the configured row budget', async () => {
|
||||||
let current = snapshot(Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))));
|
let current = snapshot(
|
||||||
const { layer } = setup({ async getSnapshot() { return current; } }, { maxRows: 500 });
|
Array.from({ length: 500 }, (_, i) => observation(String(i + 1000))),
|
||||||
|
);
|
||||||
|
const { layer } = setup(
|
||||||
|
{
|
||||||
|
async getSnapshot() {
|
||||||
|
return current;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ maxRows: 500 },
|
||||||
|
);
|
||||||
await layer.update();
|
await layer.update();
|
||||||
current = snapshot(Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))), { complete: false });
|
current = snapshot(
|
||||||
|
Array.from({ length: 500 }, (_, i) => observation(String(i + 2000))),
|
||||||
|
{ complete: false },
|
||||||
|
);
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getStats().count, 500);
|
assert.equal(layer.getStats().count, 500);
|
||||||
assert.equal(layer.hasContact('1000'), false);
|
assert.equal(layer.hasContact('1000'), false);
|
||||||
@@ -85,7 +158,14 @@ test('an incomplete feed bounds its retained union to the configured row budget'
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('unknown source time does not become fresh at the time of receipt', async () => {
|
test('unknown source time does not become fresh at the time of receipt', async () => {
|
||||||
const { layer } = setup({ async getSnapshot() { return snapshot([observation('111')], { observedAtMs: null, freshness: 'unknown' }); } });
|
const { layer } = setup({
|
||||||
|
async getSnapshot() {
|
||||||
|
return snapshot([observation('111')], {
|
||||||
|
observedAtMs: null,
|
||||||
|
freshness: 'unknown',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getStats().lastUpdate, null);
|
assert.equal(layer.getStats().lastUpdate, null);
|
||||||
assert.equal(layer.getStats().stale, true);
|
assert.equal(layer.getStats().stale, true);
|
||||||
@@ -94,13 +174,23 @@ test('unknown source time does not become fresh at the time of receipt', async (
|
|||||||
test('refresh replaces the history reference and clearing selection cancels late history', async (t) => {
|
test('refresh replaces the history reference and clearing selection cancels late history', async (t) => {
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
globalThis.document = { getElementById: () => null };
|
globalThis.document = { getElementById: () => null };
|
||||||
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; });
|
t.after(() => {
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
});
|
||||||
let current = snapshot([observation('111', 'old-reference')]);
|
let current = snapshot([observation('111', 'old-reference')]);
|
||||||
let request;
|
let request;
|
||||||
let resolveTrack;
|
let resolveTrack;
|
||||||
const { layer } = setup({
|
const { layer } = setup({
|
||||||
async getSnapshot() { return current; },
|
async getSnapshot() {
|
||||||
getTrack(reference, { signal }) { request = { reference, signal }; return new Promise((resolve) => { resolveTrack = resolve; }); },
|
return current;
|
||||||
|
},
|
||||||
|
getTrack(reference, { signal }) {
|
||||||
|
request = { reference, signal };
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
resolveTrack = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
current = snapshot([observation('111', 'new-reference')]);
|
current = snapshot([observation('111', 'new-reference')]);
|
||||||
@@ -117,10 +207,20 @@ test('refresh replaces the history reference and clearing selection cancels late
|
|||||||
test('destroy cancels pending observations and ignores their later completion', async (t) => {
|
test('destroy cancels pending observations and ignores their later completion', async (t) => {
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
globalThis.document = { getElementById: () => null };
|
globalThis.document = { getElementById: () => null };
|
||||||
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; });
|
t.after(() => {
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
});
|
||||||
let requestSignal;
|
let requestSignal;
|
||||||
let resolve;
|
let resolve;
|
||||||
const { layer } = setup({ getSnapshot(_query, { signal }) { requestSignal = signal; return new Promise((done) => { resolve = done; }); } });
|
const { layer } = setup({
|
||||||
|
getSnapshot(_query, { signal }) {
|
||||||
|
requestSignal = signal;
|
||||||
|
return new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
const pending = layer.update();
|
const pending = layer.update();
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(requestSignal.aborted, true);
|
assert.equal(requestSignal.aborted, true);
|
||||||
@@ -130,17 +230,26 @@ test('destroy cancels pending observations and ignores their later completion',
|
|||||||
assert.equal(layer.hasContact('111'), null);
|
assert.equal(layer.hasContact('111'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => {
|
test('partial-feed expiry releases a selected vessel and its pending trail', async (t) => {
|
||||||
const originalDocument = globalThis.document;
|
const originalDocument = globalThis.document;
|
||||||
globalThis.document = { getElementById: () => null };
|
globalThis.document = { getElementById: () => null };
|
||||||
t.after(() => { if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; });
|
t.after(() => {
|
||||||
|
if (originalDocument === undefined) delete globalThis.document;
|
||||||
|
else globalThis.document = originalDocument;
|
||||||
|
});
|
||||||
let current = snapshot([observation('111'), observation('222')]);
|
let current = snapshot([observation('111'), observation('222')]);
|
||||||
let signal;
|
let signal;
|
||||||
let finish;
|
let finish;
|
||||||
const { layer, advance } = setup({
|
const { layer, advance } = setup({
|
||||||
async getSnapshot() { return current; },
|
async getSnapshot() {
|
||||||
getTrack(_reference, options) { signal = options.signal; return new Promise((resolve) => { finish = resolve; }); },
|
return current;
|
||||||
|
},
|
||||||
|
getTrack(_reference, options) {
|
||||||
|
signal = options.signal;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
finish = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
layer.selectById('222');
|
layer.selectById('222');
|
||||||
|
|||||||
@@ -1,34 +1,25 @@
|
|||||||
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
|
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_RENDER_ROWS = 12000;
|
export const DEFAULT_RENDER_ROWS = 12000;
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_ACTIVE_LABELS = 900;
|
export const DEFAULT_ACTIVE_LABELS = 900;
|
||||||
|
|
||||||
|
|
||||||
export const REFRESH_MS = 60000;
|
export const REFRESH_MS = 60000;
|
||||||
|
|
||||||
/** Bounded wait for the first accepted vessel position in one enabled session. */
|
/** Bounded wait for the first accepted vessel position in one enabled session. */
|
||||||
|
|
||||||
export const AIS_FIRST_CONNECT_GRACE_MS = 30000;
|
export const AIS_FIRST_CONNECT_GRACE_MS = 30000;
|
||||||
|
|
||||||
|
|
||||||
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
|
export const AIS_FIRST_CONNECT_LABEL = 'awaiting first AIS position…';
|
||||||
|
|
||||||
|
|
||||||
export const VISIBILITY_UPDATE_MS = 800;
|
export const VISIBILITY_UPDATE_MS = 800;
|
||||||
|
|
||||||
/** Focus alpha alone samples faster inside the existing preRender pass. */
|
/** Focus alpha alone samples faster inside the existing preRender pass. */
|
||||||
|
|
||||||
export const FOCUS_UPDATE_MS = 80;
|
export const FOCUS_UPDATE_MS = 80;
|
||||||
|
|
||||||
|
|
||||||
export const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX;
|
export const LABEL_GRID_PX = VESSEL_LABEL_GRID_PX;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,15 +58,12 @@ export const TRAIL_MAX_POINTS = 400;
|
|||||||
|
|
||||||
export const TRAIL_MIN_MOVE_M = 25;
|
export const TRAIL_MIN_MOVE_M = 25;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
||||||
now: () => Date.now(),
|
now: () => Date.now(),
|
||||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||||
clearTimeout: (timer) => clearTimeout(timer),
|
clearTimeout: (timer) => clearTimeout(timer),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable reasons for a non-'open' AISStream feed status, keyed to the
|
* Human-readable reasons for a non-'open' AISStream feed status, keyed to the
|
||||||
* server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in
|
* server's `_aisStreamStatus` values (see vite.config.js). Surfaced verbatim in
|
||||||
@@ -92,7 +80,6 @@ export const AIS_STATUS_REASON = {
|
|||||||
idle: 'feed idle',
|
idle: 'feed idle',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Statuses in which fresh data is flowing. 'open' is the pre-watchdog spelling
|
* Statuses in which fresh data is flowing. 'open' is the pre-watchdog spelling
|
||||||
* and is still accepted so a cached bundle and a restarted server never
|
* and is still accepted so a cached bundle and a restarted server never
|
||||||
@@ -101,14 +88,18 @@ export const AIS_STATUS_REASON = {
|
|||||||
|
|
||||||
export const AIS_HEALTHY_STATUSES = new Set(['live', 'open']);
|
export const AIS_HEALTHY_STATUSES = new Set(['live', 'open']);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server statuses meaning "the feed is not delivering right now". These are
|
* Server statuses meaning "the feed is not delivering right now". These are
|
||||||
* surfaced even while cached vessels are still drawn: rows retained from
|
* surfaced even while cached vessels are still drawn: rows retained from
|
||||||
* before the outage must never make a dead feed read as a healthy one.
|
* before the outage must never make a dead feed read as a healthy one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const AIS_DEGRADED_STATUSES = new Set(['stale', 'reconnecting', 'down', 'auth-failed']);
|
export const AIS_DEGRADED_STATUSES = new Set([
|
||||||
|
'stale',
|
||||||
|
'reconnecting',
|
||||||
|
'down',
|
||||||
|
'auth-failed',
|
||||||
|
]);
|
||||||
|
|
||||||
/** Missing records in an incomplete observation are retained for at most five minutes. */
|
/** Missing records in an incomplete observation are retained for at most five minutes. */
|
||||||
export const PARTIAL_RETENTION_MS = 5 * 60 * 1000;
|
export const PARTIAL_RETENTION_MS = 5 * 60 * 1000;
|
||||||
+134
-85
@@ -1,35 +1,46 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { AIS_DEGRADED_STATUSES, AIS_HEALTHY_STATUSES, AIS_STATUS_REASON, REFRESH_MS, FOCUS_EVIDENCE_DEV, AIS_FIRST_CONNECT_LABEL } from './policy.js';
|
import {
|
||||||
|
AIS_DEGRADED_STATUSES,
|
||||||
|
AIS_HEALTHY_STATUSES,
|
||||||
|
AIS_STATUS_REASON,
|
||||||
|
REFRESH_MS,
|
||||||
|
FOCUS_EVIDENCE_DEV,
|
||||||
|
AIS_FIRST_CONNECT_LABEL,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
export function createQueries({ vesselState, services, parts: components, layer, options }) {
|
export function createQueries({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const { formatKnots } = services.labels;
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const { formatKnots } = services.labels;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Seconds until the server's next reconnect attempt, or 0 when none is
|
* Seconds until the server's next reconnect attempt, or 0 when none is
|
||||||
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function aisRetryInSec() {
|
function aisRetryInSec() {
|
||||||
// A rejected key is terminal until someone changes it; an hour-long
|
// A rejected key is terminal until someone changes it; an hour-long
|
||||||
// countdown would imply waiting is the fix.
|
// countdown would imply waiting is the fix.
|
||||||
if (state.transportStatus === 'auth-failed') return 0;
|
if (state.transportStatus === 'auth-failed') return 0;
|
||||||
const at = Number(state.nextAttemptAt);
|
const at = Number(state.nextAttemptAt);
|
||||||
if (!Number.isFinite(at) || at <= 0) return 0;
|
if (!Number.isFinite(at) || at <= 0) return 0;
|
||||||
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
|
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Chip text for a feed the server has reported as not delivering.
|
* Chip text for a feed the server has reported as not delivering.
|
||||||
* @param {string} status - 'stale' | 'reconnecting' | 'down'
|
* @param {string} status - 'stale' | 'reconnecting' | 'down'
|
||||||
* @param {Object} payload - Parsed /api/ais-live JSON.
|
* @param {Object} payload - Parsed /api/ais-live JSON.
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function describeDegradedAisFeed(status, payload) {
|
function describeDegradedAisFeed(status, payload) {
|
||||||
if (status === 'auth-failed') {
|
if (status === 'auth-failed') {
|
||||||
// Actionable, not a countdown: retrying cannot fix a rejected credential,
|
// Actionable, not a countdown: retrying cannot fix a rejected credential,
|
||||||
// so the chip asks the operator to do the one thing that can.
|
// so the chip asks the operator to do the one thing that can.
|
||||||
@@ -42,14 +53,14 @@ function describeDegradedAisFeed(status, payload) {
|
|||||||
: 'feed silent — no AIS data';
|
: 'feed silent — no AIS data';
|
||||||
}
|
}
|
||||||
const attempt = Number(payload?.reconnectAttempt);
|
const attempt = Number(payload?.reconnectAttempt);
|
||||||
const suffix = Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
|
const suffix =
|
||||||
|
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
|
||||||
return status === 'down'
|
return status === 'down'
|
||||||
? `feed down — retrying slowly${suffix}`
|
? `feed down — retrying slowly${suffix}`
|
||||||
: `reconnecting to feed…${suffix}`;
|
: `reconnecting to feed…${suffix}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Derive a surfaced error string from an /api/ais-live payload, or null when the
|
* 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
|
* feed has accepted product data. Socket transport, message receipt, and usable
|
||||||
* vessel positions are separate health stages: an open socket with no message
|
* vessel positions are separate health stages: an open socket with no message
|
||||||
@@ -60,8 +71,9 @@ function describeDegradedAisFeed(status, payload) {
|
|||||||
* @returns {string|null} A short reason for the chip, or null if healthy.
|
* @returns {string|null} A short reason for the chip, or null if healthy.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function deriveAisFeedError(payload, acceptedRowCount) {
|
function deriveAisFeedError(payload, acceptedRowCount) {
|
||||||
const status = payload && typeof payload.status === 'string' ? payload.status : null;
|
const status =
|
||||||
|
payload && typeof payload.status === 'string' ? payload.status : null;
|
||||||
// A feed the server reports as not delivering outranks the row count: the
|
// A feed the server reports as not delivering outranks the row count: the
|
||||||
// cached vessels on screen are exactly what makes an outage invisible.
|
// cached vessels on screen are exactly what makes an outage invisible.
|
||||||
if (status && AIS_DEGRADED_STATUSES.has(status)) {
|
if (status && AIS_DEGRADED_STATUSES.has(status)) {
|
||||||
@@ -74,20 +86,25 @@ function deriveAisFeedError(payload, acceptedRowCount) {
|
|||||||
: 'awaiting first AIS message…';
|
: 'awaiting first AIS message…';
|
||||||
}
|
}
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
const detail = typeof payload.error === 'string' && payload.error.trim() ? payload.error.trim() : '';
|
const detail =
|
||||||
|
typeof payload.error === 'string' && payload.error.trim()
|
||||||
|
? payload.error.trim()
|
||||||
|
: '';
|
||||||
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
|
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
|
||||||
return detail && !AIS_STATUS_REASON[status] ? `${reason} (${detail})` : reason;
|
return detail && !AIS_STATUS_REASON[status]
|
||||||
}
|
? `${reason} (${detail})`
|
||||||
|
: reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a raw AIS row can enter the production vessel normalizer. */
|
||||||
|
|
||||||
/** 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hasUsableVesselCoordinates(row) {
|
/**
|
||||||
return Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify one server snapshot before any destructive reconciliation.
|
* Classify one server snapshot before any destructive reconciliation.
|
||||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
|
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
|
||||||
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
|
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
|
||||||
@@ -95,10 +112,11 @@ function hasUsableVesselCoordinates(row) {
|
|||||||
* acceptedRowCount: number, error: string|null}}
|
* acceptedRowCount: number, error: string|null}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function classifyAisFeedSnapshot(payload) {
|
function classifyAisFeedSnapshot(payload) {
|
||||||
const rawRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
const rawRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||||||
const acceptedRows = rawRows.filter(hasUsableVesselCoordinates);
|
const acceptedRows = rawRows.filter(hasUsableVesselCoordinates);
|
||||||
const transportStatus = typeof payload?.status === 'string' ? payload.status : null;
|
const transportStatus =
|
||||||
|
typeof payload?.status === 'string' ? payload.status : null;
|
||||||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||||||
const acceptedRowCount = acceptedRows.length;
|
const acceptedRowCount = acceptedRows.length;
|
||||||
return {
|
return {
|
||||||
@@ -106,15 +124,19 @@ function classifyAisFeedSnapshot(payload) {
|
|||||||
lastMessageAt,
|
lastMessageAt,
|
||||||
rawRows,
|
rawRows,
|
||||||
acceptedRows,
|
acceptedRows,
|
||||||
rawRowCount: Number.isInteger(payload?.rawRowCount) && payload.rawRowCount >= rawRows.length ? payload.rawRowCount : rawRows.length,
|
rawRowCount:
|
||||||
|
Number.isInteger(payload?.rawRowCount) &&
|
||||||
|
payload.rawRowCount >= rawRows.length
|
||||||
|
? payload.rawRowCount
|
||||||
|
: rawRows.length,
|
||||||
acceptedRowCount,
|
acceptedRowCount,
|
||||||
error: deriveAisFeedError(payload, acceptedRowCount)
|
error:
|
||||||
|| (acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
deriveAisFeedError(payload, acceptedRowCount) ||
|
||||||
|
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Map one internal vessel record to a plain JSON-safe analyst record
|
* Map one internal vessel record to a plain JSON-safe analyst record
|
||||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||||
@@ -127,9 +149,12 @@ function classifyAisFeedSnapshot(payload) {
|
|||||||
* navStatus: null}}
|
* navStatus: null}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function mapAnalystRecord(record) {
|
function mapAnalystRecord(record) {
|
||||||
const num = (v) => (Number.isFinite(v) ? v : null);
|
const num = (v) => (Number.isFinite(v) ? v : null);
|
||||||
const text = (v) => { const t = String(v ?? '').trim(); return t || null; };
|
const text = (v) => {
|
||||||
|
const t = String(v ?? '').trim();
|
||||||
|
return t || null;
|
||||||
|
};
|
||||||
const mmsi = text(record?.mmsi);
|
const mmsi = text(record?.mmsi);
|
||||||
const name = text(record?.name);
|
const name = text(record?.name);
|
||||||
return {
|
return {
|
||||||
@@ -144,10 +169,9 @@ function mapAnalystRecord(record) {
|
|||||||
destination: text(record?.destination),
|
destination: text(record?.destination),
|
||||||
navStatus: null,
|
navStatus: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Ellipsoidal render height (m) for a sea-surface object: the local geoid
|
* 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
|
* 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
|
* −106…+85 m off the WGS84 ellipsoid worldwide (Rotterdam ≈ +45 m — at
|
||||||
@@ -158,12 +182,11 @@ function mapAnalystRecord(record) {
|
|||||||
* @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent).
|
* @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselDatumHeightM(geoidN, liftM) {
|
function vesselDatumHeightM(geoidN, liftM) {
|
||||||
return (Number.isFinite(geoidN) ? geoidN : 0) + liftM;
|
return (Number.isFinite(geoidN) ? geoidN : 0) + liftM;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Reduce one vessel-selection gesture to the layer-owned action it should
|
* Reduce one vessel-selection gesture to the layer-owned action it should
|
||||||
* perform. The interaction handler reserves only vessel-record residuals and
|
* perform. The interaction handler reserves only vessel-record residuals and
|
||||||
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
||||||
@@ -174,15 +197,13 @@ function vesselDatumHeightM(geoidN, liftM) {
|
|||||||
* @returns {{action: 'none'|'select'|'deselect'}}
|
* @returns {{action: 'none'|'select'|'deselect'}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function reduceVesselSelection(input = {}) {
|
function reduceVesselSelection(input = {}) {
|
||||||
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
||||||
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
||||||
const gesture = input.gesture || 'click';
|
const gesture = input.gesture || 'click';
|
||||||
|
|
||||||
if (gesture === 'escape') {
|
if (gesture === 'escape') {
|
||||||
return selectedMmsi
|
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||||
? { action: 'deselect' }
|
|
||||||
: { action: 'none' };
|
|
||||||
}
|
}
|
||||||
if (gesture !== 'click') {
|
if (gesture !== 'click') {
|
||||||
return { action: 'none' };
|
return { action: 'none' };
|
||||||
@@ -193,19 +214,20 @@ function reduceVesselSelection(input = {}) {
|
|||||||
}
|
}
|
||||||
return { action: 'select' };
|
return { action: 'select' };
|
||||||
}
|
}
|
||||||
return selectedMmsi
|
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||||
? { action: 'deselect' }
|
}
|
||||||
: { action: 'none' };
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function normalizeSelectionMmsi(value) {
|
||||||
|
|
||||||
function normalizeSelectionMmsi(value) {
|
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
const text = String(value).trim();
|
const text = String(value).trim();
|
||||||
return text || null;
|
return text || null;
|
||||||
}
|
}
|
||||||
const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot, vesselDatumHeightM, reduceVesselSelection,
|
const methods = {
|
||||||
|
mapAnalystRecord,
|
||||||
|
deriveAisFeedError,
|
||||||
|
classifyAisFeedSnapshot,
|
||||||
|
vesselDatumHeightM,
|
||||||
|
reduceVesselSelection,
|
||||||
|
|
||||||
id: 'ais-live-vessels',
|
id: 'ais-live-vessels',
|
||||||
|
|
||||||
@@ -219,7 +241,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
|
|
||||||
statsRefreshInterval: 1000,
|
statsRefreshInterval: 1000,
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a vessel by exact MMSI or case-insensitive name substring.
|
* Find a vessel by exact MMSI or case-insensitive name substring.
|
||||||
* @param {string|number} query MMSI or partial vessel name.
|
* @param {string|number} query MMSI or partial vessel name.
|
||||||
@@ -238,7 +259,12 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
}
|
}
|
||||||
if (!record) {
|
if (!record) {
|
||||||
const lower = q.toLowerCase();
|
const lower = q.toLowerCase();
|
||||||
record = records.find((r) => String(r.name || '').toLowerCase().includes(lower)) || null;
|
record =
|
||||||
|
records.find((r) =>
|
||||||
|
String(r.name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(lower),
|
||||||
|
) || null;
|
||||||
}
|
}
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
|
|
||||||
@@ -256,7 +282,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get vessels within a range of a point, sorted nearest-first.
|
* Get vessels within a range of a point, sorted nearest-first.
|
||||||
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
||||||
@@ -266,24 +291,31 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
*/
|
*/
|
||||||
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||||||
const records = state.vesselRecords;
|
const records = state.vesselRecords;
|
||||||
if (!centerCartesian || !Array.isArray(records) || !records.length) return [];
|
if (!centerCartesian || !Array.isArray(records) || !records.length)
|
||||||
|
return [];
|
||||||
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
|
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
|
||||||
const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25;
|
const cap =
|
||||||
|
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25;
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue;
|
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||||||
|
continue;
|
||||||
const position = record.billboard?.position || record.position;
|
const position = record.billboard?.position || record.position;
|
||||||
if (!position) continue;
|
if (!position) continue;
|
||||||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||||
entries.push({ mmsi: record.mmsi, name: record.name, position, distanceM });
|
entries.push({
|
||||||
|
mmsi: record.mmsi,
|
||||||
|
name: record.name,
|
||||||
|
position,
|
||||||
|
distanceM,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||||||
return entries.slice(0, cap);
|
return entries.slice(0, cap);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get positions of all currently loaded vessels.
|
* Get positions of all currently loaded vessels.
|
||||||
* @param {number} [maxCount=800] Maximum entries to return.
|
* @param {number} [maxCount=800] Maximum entries to return.
|
||||||
@@ -301,17 +333,18 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
* holds no data and therefore cannot answer.
|
* holds no data and therefore cannot answer.
|
||||||
*/
|
*/
|
||||||
hasContact(mmsi) {
|
hasContact(mmsi) {
|
||||||
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0) return null;
|
if (!state.enabled || !state.vesselMap || state.vesselMap.size === 0)
|
||||||
|
return null;
|
||||||
if (!mmsi) return false;
|
if (!mmsi) return false;
|
||||||
return state.vesselMap.has(String(mmsi).trim());
|
return state.vesselMap.has(String(mmsi).trim());
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
getAllPositions(maxCount = 800) {
|
getAllPositions(maxCount = 800) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const records = state.vesselRecords;
|
const records = state.vesselRecords;
|
||||||
if (!Array.isArray(records)) return result;
|
if (!Array.isArray(records)) return result;
|
||||||
const cap = Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
|
const cap =
|
||||||
|
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
|
||||||
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (result.length >= cap) break;
|
if (result.length >= cap) break;
|
||||||
@@ -328,7 +361,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot the layer's in-memory vessel records as plain JSON-safe
|
* Snapshot the layer's in-memory vessel records as plain JSON-safe
|
||||||
* objects for the analyst query engine. On-demand only (called at most
|
* objects for the analyst query engine. On-demand only (called at most
|
||||||
@@ -341,7 +373,9 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
if (!state.enabled) return [];
|
if (!state.enabled) return [];
|
||||||
const records = state.vesselRecords;
|
const records = state.vesselRecords;
|
||||||
if (!Array.isArray(records) || !records.length) return [];
|
if (!Array.isArray(records) || !records.length) return [];
|
||||||
const limit = Number.isFinite(maxCount) ? Math.max(1, Math.floor(maxCount)) : 2000;
|
const limit = Number.isFinite(maxCount)
|
||||||
|
? Math.max(1, Math.floor(maxCount))
|
||||||
|
: 2000;
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (result.length >= limit) break;
|
if (result.length >= limit) break;
|
||||||
@@ -350,7 +384,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Select a vessel by MMSI via the same path as a map click
|
* Select a vessel by MMSI via the same path as a map click
|
||||||
* (highlight + HUD update).
|
* (highlight + HUD update).
|
||||||
@@ -367,7 +400,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the current vessel selection and reset the HUD readout.
|
* Clear the current vessel selection and reset the HUD readout.
|
||||||
* @returns {boolean} Always true.
|
* @returns {boolean} Always true.
|
||||||
@@ -377,7 +409,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get info about the currently selected vessel.
|
* 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}
|
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||||
@@ -396,7 +427,6 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a subset of vessels for the universal detection overlay.
|
* Return a subset of vessels for the universal detection overlay.
|
||||||
* Deterministic stride sampling distributes selections evenly across the
|
* Deterministic stride sampling distributes selections evenly across the
|
||||||
@@ -407,7 +437,12 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||||||
*/
|
*/
|
||||||
getDetectableObjects(options = {}) {
|
getDetectableObjects(options = {}) {
|
||||||
if (!state.enabled || !state.billboardCollection || !state.billboardCollection.show) return [];
|
if (
|
||||||
|
!state.enabled ||
|
||||||
|
!state.billboardCollection ||
|
||||||
|
!state.billboardCollection.show
|
||||||
|
)
|
||||||
|
return [];
|
||||||
const records = state.vesselRecords;
|
const records = state.vesselRecords;
|
||||||
if (!Array.isArray(records) || !records.length) return [];
|
if (!Array.isArray(records) || !records.length) return [];
|
||||||
|
|
||||||
@@ -422,7 +457,7 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
const selected = state.selectedRecord;
|
const selected = state.selectedRecord;
|
||||||
const result = [];
|
const result = [];
|
||||||
for (let idx = 0; idx < records.length; idx += 1) {
|
for (let idx = 0; idx < records.length; idx += 1) {
|
||||||
if (((idx - start) % stride) !== 0) continue;
|
if ((idx - start) % stride !== 0) continue;
|
||||||
const record = records[idx];
|
const record = records[idx];
|
||||||
if (record.billboard && !record.billboard.show) continue;
|
if (record.billboard && !record.billboard.show) continue;
|
||||||
const position = record.billboard?.position || record.position;
|
const position = record.billboard?.position || record.position;
|
||||||
@@ -433,7 +468,9 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
id: record.name || record.mmsi || 'VESSEL',
|
id: record.name || record.mmsi || 'VESSEL',
|
||||||
type: 'SEA',
|
type: 'SEA',
|
||||||
skipLabel: record === selected,
|
skipLabel: record === selected,
|
||||||
klass: record.type ? String(record.type).toUpperCase().slice(0, 14) : undefined,
|
klass: record.type
|
||||||
|
? String(record.type).toUpperCase().slice(0, 14)
|
||||||
|
: undefined,
|
||||||
metric: formatKnots(record.speed), // record.speed is knots
|
metric: formatKnots(record.speed), // record.speed is knots
|
||||||
});
|
});
|
||||||
if (result.length >= maxCount) break;
|
if (result.length >= maxCount) break;
|
||||||
@@ -441,14 +478,14 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...(FOCUS_EVIDENCE_DEV
|
||||||
...(FOCUS_EVIDENCE_DEV ? {
|
? {
|
||||||
__focusEvidence: Object.freeze({
|
__focusEvidence: Object.freeze({
|
||||||
setVessels: components.evidence._setFocusEvidenceVessels,
|
setVessels: components.evidence._setFocusEvidenceVessels,
|
||||||
snapshot: components.evidence._focusEvidenceVesselSnapshot,
|
snapshot: components.evidence._focusEvidenceVesselSnapshot,
|
||||||
}),
|
}),
|
||||||
} : {}),
|
}
|
||||||
|
: {}),
|
||||||
|
|
||||||
getStats() {
|
getStats() {
|
||||||
const waitingForFirstPosition = state.firstConnectPhase === 'loading';
|
const waitingForFirstPosition = state.firstConnectPhase === 'loading';
|
||||||
@@ -461,7 +498,8 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
: state.loadingLabel,
|
: state.loadingLabel,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
stale: state.stale,
|
stale: state.stale,
|
||||||
status: state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
|
status:
|
||||||
|
state.firstConnectPhase === 'unavailable' ? 'unavailable' : undefined,
|
||||||
transportStatus: state.transportStatus,
|
transportStatus: state.transportStatus,
|
||||||
lastMessageAt: state.lastMessageAt,
|
lastMessageAt: state.lastMessageAt,
|
||||||
rawRowCount: state.rawRowCount,
|
rawRowCount: state.rawRowCount,
|
||||||
@@ -471,8 +509,19 @@ const methods = { mapAnalystRecord, deriveAisFeedError, classifyAisFeedSnapshot,
|
|||||||
// the user to guess whether anything is still happening.
|
// the user to guess whether anything is still happening.
|
||||||
retryInSec: aisRetryInSec(),
|
retryInSec: aisRetryInSec(),
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return { aisRetryInSec, describeDegradedAisFeed, deriveAisFeedError, hasUsableVesselCoordinates, classifyAisFeedSnapshot, mapAnalystRecord, vesselDatumHeightM, reduceVesselSelection, normalizeSelectionMmsi, methods };
|
return {
|
||||||
|
aisRetryInSec,
|
||||||
|
describeDegradedAisFeed,
|
||||||
|
deriveAisFeedError,
|
||||||
|
hasUsableVesselCoordinates,
|
||||||
|
classifyAisFeedSnapshot,
|
||||||
|
mapAnalystRecord,
|
||||||
|
vesselDatumHeightM,
|
||||||
|
reduceVesselSelection,
|
||||||
|
normalizeSelectionMmsi,
|
||||||
|
methods,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+171
-102
@@ -1,36 +1,59 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { vesselTypeCss, vesselOverlayCohortLimit, VESSEL_OVERLAY_SOURCE_ID, applyVesselOverlayPolicy, VESSEL_CARD_FADE_DISTANCE_M } from '../../data/vesselLabels.js';
|
import {
|
||||||
import { cameraPoseSignature, screenProjectedRotation } from '../../data/iconOrientation.js';
|
vesselTypeCss,
|
||||||
import { DEFAULT_RENDER_ROWS, DEFAULT_ACTIVE_LABELS, VISIBILITY_UPDATE_MS, FOCUS_UPDATE_MS, LABEL_GRID_PX, CARD_MIN_SEP_PX } from './policy.js';
|
vesselOverlayCohortLimit,
|
||||||
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
|
applyVesselOverlayPolicy,
|
||||||
|
VESSEL_CARD_FADE_DISTANCE_M,
|
||||||
|
} from '../../data/vesselLabels.js';
|
||||||
|
import {
|
||||||
|
cameraPoseSignature,
|
||||||
|
screenProjectedRotation,
|
||||||
|
} from '../../data/iconOrientation.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_RENDER_ROWS,
|
||||||
|
DEFAULT_ACTIVE_LABELS,
|
||||||
|
VISIBILITY_UPDATE_MS,
|
||||||
|
FOCUS_UPDATE_MS,
|
||||||
|
LABEL_GRID_PX,
|
||||||
|
CARD_MIN_SEP_PX,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
export function createRendering({ vesselState, services, parts: components, layer, options }) {
|
export function createRendering({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const { registerSpriteCollection } = services.sprites;
|
services,
|
||||||
const { forgetSpriteFocus, focusNowMs, getFocusTarget, focusPassIsNeeded, advanceSpriteFocus, focusAlphaNeedsWrite } = services.focus;
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const { registerSpriteCollection } = services.sprites;
|
||||||
|
const {
|
||||||
|
forgetSpriteFocus,
|
||||||
|
focusNowMs,
|
||||||
|
getFocusTarget,
|
||||||
|
focusPassIsNeeded,
|
||||||
|
advanceSpriteFocus,
|
||||||
|
focusAlphaNeedsWrite,
|
||||||
|
} = services.focus;
|
||||||
|
|
||||||
|
function renderRowLimit() {
|
||||||
|
|
||||||
function renderRowLimit() {
|
|
||||||
const configured = Number(options.maxRows);
|
const configured = Number(options.maxRows);
|
||||||
if (Number.isFinite(configured) && configured > 0) {
|
if (Number.isFinite(configured) && configured > 0) {
|
||||||
return Math.max(500, Math.min(50000, Math.round(configured)));
|
return Math.max(500, Math.min(50000, Math.round(configured)));
|
||||||
}
|
}
|
||||||
return DEFAULT_RENDER_ROWS;
|
return DEFAULT_RENDER_ROWS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelRowLimit() {
|
||||||
|
|
||||||
function labelRowLimit() {
|
|
||||||
const configured = Number(options.maxLabels);
|
const configured = Number(options.maxLabels);
|
||||||
if (Number.isFinite(configured) && configured >= 0) {
|
if (Number.isFinite(configured) && configured >= 0) {
|
||||||
return Math.max(0, Math.min(renderRowLimit(), Math.round(configured)));
|
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) {
|
||||||
|
|
||||||
function ensureCollections(viewer) {
|
|
||||||
if (!viewer || state.billboardCollection) return;
|
if (!viewer || state.billboardCollection) return;
|
||||||
state.billboardCollection = new Cesium.BillboardCollection({
|
state.billboardCollection = new Cesium.BillboardCollection({
|
||||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||||
@@ -38,10 +61,9 @@ function ensureCollections(viewer) {
|
|||||||
state.billboardCollection.show = state.enabled;
|
state.billboardCollection.show = state.enabled;
|
||||||
viewer.scene.primitives.add(state.billboardCollection);
|
viewer.scene.primitives.add(state.billboardCollection);
|
||||||
registerSpriteCollection('ais', state.billboardCollection);
|
registerSpriteCollection('ais', state.billboardCollection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Create the billboard primitive for a freshly added vessel record. Map labels
|
* Create the billboard primitive for a freshly added vessel record. Map labels
|
||||||
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
* are canvas cards (vesselLabels.js) rebuilt by the declutter pass — no
|
||||||
* per-record label primitive exists anymore.
|
* per-record label primitive exists anymore.
|
||||||
@@ -49,8 +71,9 @@ function ensureCollections(viewer) {
|
|||||||
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
|
* @param {Cesium.EllipsoidalOccluder|null} occluder - Horizon occluder for initial visibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function addRecordPrimitives(record, occluder) {
|
function addRecordPrimitives(record, occluder) {
|
||||||
const visible = state.enabled && isVisible(record.surfacePosition, occluder);
|
const visible =
|
||||||
|
state.enabled && isVisible(record.surfacePosition, occluder);
|
||||||
record.billboard = state.billboardCollection.add({
|
record.billboard = state.billboardCollection.add({
|
||||||
position: record.position,
|
position: record.position,
|
||||||
show: visible,
|
show: visible,
|
||||||
@@ -68,34 +91,30 @@ function addRecordPrimitives(record, occluder) {
|
|||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
id: record,
|
id: record,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Remove a record's billboard primitive from its collection.
|
* Remove a record's billboard primitive from its collection.
|
||||||
* @param {Object} record - Vessel record to tear down.
|
* @param {Object} record - Vessel record to tear down.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function removeRecordPrimitives(record) {
|
function removeRecordPrimitives(record) {
|
||||||
if (!record) return;
|
if (!record) return;
|
||||||
if (record.billboard && state.billboardCollection) {
|
if (record.billboard && state.billboardCollection) {
|
||||||
forgetSpriteFocus(record.billboard);
|
forgetSpriteFocus(record.billboard);
|
||||||
state.billboardCollection.remove(record.billboard);
|
state.billboardCollection.remove(record.billboard);
|
||||||
}
|
}
|
||||||
record.billboard = null;
|
record.billboard = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shipScale(record) {
|
||||||
|
|
||||||
function shipScale(record) {
|
|
||||||
const speed = Number(record.speed || 0);
|
const speed = Number(record.speed || 0);
|
||||||
if (speed >= 18) return 0.78;
|
if (speed >= 18) return 0.78;
|
||||||
if (speed >= 8) return 0.68;
|
if (speed >= 8) return 0.68;
|
||||||
return 0.6;
|
return 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Best available real-world direction of travel for a vessel, degrees
|
* Best available real-world direction of travel for a vessel, degrees
|
||||||
* clockwise from north (true heading preferred, course-over-ground fallback).
|
* clockwise from north (true heading preferred, course-over-ground fallback).
|
||||||
* Screen rotation is computed from this by the shared projected-rotation
|
* Screen rotation is computed from this by the shared projected-rotation
|
||||||
@@ -104,13 +123,12 @@ function shipScale(record) {
|
|||||||
* @returns {number} Course in degrees (0 when unknown).
|
* @returns {number} Course in degrees (0 when unknown).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselCourseDeg(record) {
|
function vesselCourseDeg(record) {
|
||||||
const direction = record.heading ?? record.course;
|
const direction = record.heading ?? record.course;
|
||||||
return Number.isFinite(direction) ? direction : 0;
|
return Number.isFinite(direction) ? direction : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
* Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel.
|
||||||
* The shape points north (up) so billboard rotation maps directly to heading.
|
* The shape points north (up) so billboard rotation maps directly to heading.
|
||||||
* One icon is generated per color+variant and reused across all billboards.
|
* One icon is generated per color+variant and reused across all billboards.
|
||||||
@@ -119,10 +137,11 @@ function vesselCourseDeg(record) {
|
|||||||
* @returns {string} SVG data URL.
|
* @returns {string} SVG data URL.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function shipIcon(record, selected) {
|
function shipIcon(record, selected) {
|
||||||
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type);
|
||||||
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
const key = `${cssColor}:${selected ? 'selected' : 'normal'}`;
|
||||||
if (vesselState.shipIconCache.has(key)) return vesselState.shipIconCache.get(key);
|
if (vesselState.shipIconCache.has(key))
|
||||||
|
return vesselState.shipIconCache.get(key);
|
||||||
|
|
||||||
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)';
|
||||||
const strokeWidth = selected ? 1.1 : 0.7;
|
const strokeWidth = selected ? 1.1 : 0.7;
|
||||||
@@ -134,24 +153,24 @@ function shipIcon(record, selected) {
|
|||||||
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
const icon = 'data:image/svg+xml;base64,' + btoa(svg);
|
||||||
vesselState.shipIconCache.set(key, icon);
|
vesselState.shipIconCache.set(key, icon);
|
||||||
return icon;
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function installRuntime(viewer) {
|
||||||
|
|
||||||
function installRuntime(viewer) {
|
|
||||||
if (state.preRenderRemover || !viewer) return;
|
if (state.preRenderRemover || !viewer) return;
|
||||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => updateVisibility());
|
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||||
}
|
updateVisibility(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVisibility(force = false) {
|
||||||
|
|
||||||
function updateVisibility(force = false) {
|
|
||||||
if (!state.enabled) return;
|
if (!state.enabled) return;
|
||||||
const now = focusNowMs(performance.now());
|
const now = focusNowMs(performance.now());
|
||||||
const focusTarget = getFocusTarget();
|
const focusTarget = getFocusTarget();
|
||||||
const regularPass = force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
|
const regularPass =
|
||||||
const focusPass = focusPassIsNeeded(focusTarget, state.activeFocusCount)
|
force || now - state.lastVisibilityUpdate >= VISIBILITY_UPDATE_MS;
|
||||||
&& (force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
const focusPass =
|
||||||
|
focusPassIsNeeded(focusTarget, state.activeFocusCount) &&
|
||||||
|
(force || now - state.lastFocusUpdate >= FOCUS_UPDATE_MS);
|
||||||
if (!regularPass && !focusPass) return;
|
if (!regularPass && !focusPass) return;
|
||||||
if (regularPass) state.lastVisibilityUpdate = now;
|
if (regularPass) state.lastVisibilityUpdate = now;
|
||||||
if (focusPass) state.lastFocusUpdate = now;
|
if (focusPass) state.lastFocusUpdate = now;
|
||||||
@@ -178,9 +197,15 @@ function updateVisibility(force = false) {
|
|||||||
record.billboard.show = visible;
|
record.billboard.show = visible;
|
||||||
if (visible && doRotations && scene) {
|
if (visible && doRotations && scene) {
|
||||||
const rot = screenProjectedRotation(
|
const rot = screenProjectedRotation(
|
||||||
scene, record.position, vesselCourseDeg(record), record.billboard.rotation
|
scene,
|
||||||
|
record.position,
|
||||||
|
vesselCourseDeg(record),
|
||||||
|
record.billboard.rotation,
|
||||||
);
|
);
|
||||||
if (rot !== null && Math.abs(rot - record.billboard.rotation) > 0.002) {
|
if (
|
||||||
|
rot !== null &&
|
||||||
|
Math.abs(rot - record.billboard.rotation) > 0.002
|
||||||
|
) {
|
||||||
record.billboard.rotation = rot;
|
record.billboard.rotation = rot;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,24 +220,27 @@ function updateVisibility(force = false) {
|
|||||||
target: focusTarget,
|
target: focusTarget,
|
||||||
previousActiveCount: state.activeFocusCount,
|
previousActiveCount: state.activeFocusCount,
|
||||||
nowMs: now,
|
nowMs: now,
|
||||||
screenPositionFor: (position) => (
|
screenPositionFor: (position) =>
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene, position, vesselState._scratchFocusScreen)
|
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
position,
|
||||||
|
vesselState._scratchFocusScreen,
|
||||||
),
|
),
|
||||||
cameraDistanceFor: (position) => Cesium.Cartesian3.distance(camera.positionWC, position),
|
cameraDistanceFor: (position) =>
|
||||||
|
Cesium.Cartesian3.distance(camera.positionWC, position),
|
||||||
});
|
});
|
||||||
state.activeFocusCount = result.activeCount;
|
state.activeFocusCount = result.activeCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
|
* Apply focus alpha to vessel sprites. Kept as a production wire seam so the
|
||||||
* animation/deadband contract can be tested without constructing WebGL.
|
* animation/deadband contract can be tested without constructing WebGL.
|
||||||
* @param {object} input
|
* @param {object} input
|
||||||
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
|
* @returns {{writes:number,transitioning:boolean,activeCount:number,ran:boolean}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function applyVesselFocusDeemphasis({
|
function applyVesselFocusDeemphasis({
|
||||||
records,
|
records,
|
||||||
target,
|
target,
|
||||||
previousActiveCount = 0,
|
previousActiveCount = 0,
|
||||||
@@ -220,7 +248,7 @@ function applyVesselFocusDeemphasis({
|
|||||||
screenPositionFor,
|
screenPositionFor,
|
||||||
cameraDistanceFor,
|
cameraDistanceFor,
|
||||||
params,
|
params,
|
||||||
}) {
|
}) {
|
||||||
if (!focusPassIsNeeded(target, previousActiveCount)) {
|
if (!focusPassIsNeeded(target, previousActiveCount)) {
|
||||||
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
|
return { writes: 0, transitioning: false, activeCount: 0, ran: false };
|
||||||
}
|
}
|
||||||
@@ -257,25 +285,23 @@ function applyVesselFocusDeemphasis({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { writes, transitioning, activeCount, ran: true };
|
return { writes, transitioning, activeCount, ran: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeOccluder() {
|
||||||
|
|
||||||
function makeOccluder() {
|
|
||||||
const cameraPosition = state.viewer?.camera?.positionWC;
|
const cameraPosition = state.viewer?.camera?.positionWC;
|
||||||
if (!cameraPosition) return null;
|
if (!cameraPosition) return null;
|
||||||
return new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPosition);
|
return new Cesium.EllipsoidalOccluder(
|
||||||
}
|
Cesium.Ellipsoid.WGS84,
|
||||||
|
cameraPosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVisible(surfacePosition, occluder) {
|
||||||
|
|
||||||
function isVisible(surfacePosition, occluder) {
|
|
||||||
if (!surfacePosition || !occluder) return true;
|
if (!surfacePosition || !occluder) return true;
|
||||||
return occluder.isPointVisible(surfacePosition);
|
return occluder.isPointVisible(surfacePosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Source selector for the shared world-overlay pipeline: the
|
* Source selector for the shared world-overlay pipeline: the
|
||||||
* grid declutter picks which vessels get cards — one winner per screen-space
|
* grid declutter picks which vessels get cards — one winner per screen-space
|
||||||
* grid cell, priority-ranked, capped — and publishes presentation entries to
|
* grid cell, priority-ranked, capped — and publishes presentation entries to
|
||||||
@@ -286,11 +312,13 @@ function isVisible(surfacePosition, occluder) {
|
|||||||
* @param {Array<Object>} records - Horizon-visible vessel records.
|
* @param {Array<Object>} records - Horizon-visible vessel records.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function updateClusteredLabels(records) {
|
function updateClusteredLabels(records) {
|
||||||
const viewer = state.viewer;
|
const viewer = state.viewer;
|
||||||
const scene = viewer?.scene;
|
const scene = viewer?.scene;
|
||||||
const selected = state.selectedRecord;
|
const selected = state.selectedRecord;
|
||||||
const entries = selected ? [components.cards.buildSelectedVesselCard(selected)] : [];
|
const entries = selected
|
||||||
|
? [components.cards.buildSelectedVesselCard(selected)]
|
||||||
|
: [];
|
||||||
const maxLabels = labelRowLimit();
|
const maxLabels = labelRowLimit();
|
||||||
|
|
||||||
if (!scene || !records.length || maxLabels <= 0) {
|
if (!scene || !records.length || maxLabels <= 0) {
|
||||||
@@ -302,10 +330,18 @@ function updateClusteredLabels(records) {
|
|||||||
const cells = new Map();
|
const cells = new Map();
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (record === selected) continue;
|
if (record === selected) continue;
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(scene, record.position);
|
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
|
scene,
|
||||||
|
record.position,
|
||||||
|
);
|
||||||
if (!screen) continue;
|
if (!screen) continue;
|
||||||
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
const key = `${Math.floor(screen.x / LABEL_GRID_PX)}:${Math.floor(screen.y / LABEL_GRID_PX)}`;
|
||||||
const candidate = { record, score: labelPriority(record, selected), x: screen.x, y: screen.y };
|
const candidate = {
|
||||||
|
record,
|
||||||
|
score: labelPriority(record, selected),
|
||||||
|
x: screen.x,
|
||||||
|
y: screen.y,
|
||||||
|
};
|
||||||
const existing = cells.get(key);
|
const existing = cells.get(key);
|
||||||
if (!existing || candidate.score > existing.score) {
|
if (!existing || candidate.score > existing.score) {
|
||||||
cells.set(key, candidate);
|
cells.set(key, candidate);
|
||||||
@@ -317,38 +353,52 @@ function updateClusteredLabels(records) {
|
|||||||
const accepted = [];
|
const accepted = [];
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
const screen = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||||
scene, selected.billboard?.position || selected.position
|
scene,
|
||||||
|
selected.billboard?.position || selected.position,
|
||||||
);
|
);
|
||||||
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
if (screen) accepted.push({ x: screen.x, y: screen.y });
|
||||||
}
|
}
|
||||||
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
|
const ranked = [...cells.values()].sort((a, b) => b.score - a.score);
|
||||||
for (const candidate of ranked) {
|
for (const candidate of ranked) {
|
||||||
if (entries.length >= maxLabels) break;
|
if (entries.length >= maxLabels) break;
|
||||||
if (!components.cards.cardScreenSeparated(accepted, candidate, CARD_MIN_SEP_PX)) continue;
|
if (
|
||||||
|
!components.cards.cardScreenSeparated(
|
||||||
|
accepted,
|
||||||
|
candidate,
|
||||||
|
CARD_MIN_SEP_PX,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
accepted.push({ x: candidate.x, y: candidate.y });
|
accepted.push({ x: candidate.x, y: candidate.y });
|
||||||
entries.push(components.cards.buildVesselCard(candidate.record));
|
entries.push(components.cards.buildVesselCard(candidate.record));
|
||||||
}
|
}
|
||||||
state.activeLabelCount = entries.length;
|
state.activeLabelCount = entries.length;
|
||||||
publishVesselOverlayEntries(entries);
|
publishVesselOverlayEntries(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Publish a complete, bounded source snapshot to the shared host. The source
|
* Publish a complete, bounded source snapshot to the shared host. The source
|
||||||
* selector remains authoritative for the 118 px grid and 150 px separation;
|
* selector remains authoritative for the 118 px grid and 150 px separation;
|
||||||
* the host then composes this demand with sibling ambient-card sources.
|
* the host then composes this demand with sibling ambient-card sources.
|
||||||
* @param {Object[]} entries Formatted vessel card entries.
|
* @param {Object[]} entries Formatted vessel card entries.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function publishVesselOverlayEntries(entries) {
|
function publishVesselOverlayEntries(entries) {
|
||||||
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
|
const canvas = state.viewer?.scene?.canvas || state.viewer?.canvas;
|
||||||
const width = Number(canvas?.clientWidth) || 0;
|
const width = Number(canvas?.clientWidth) || 0;
|
||||||
const height = Number(canvas?.clientHeight) || 0;
|
const height = Number(canvas?.clientHeight) || 0;
|
||||||
const ambientLimit = vesselOverlayCohortLimit(width, height, labelRowLimit());
|
const ambientLimit = vesselOverlayCohortLimit(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
labelRowLimit(),
|
||||||
|
);
|
||||||
vesselState._vesselOverlayHost.setEntries(
|
vesselState._vesselOverlayHost.setEntries(
|
||||||
VESSEL_OVERLAY_SOURCE_ID,
|
VESSEL_OVERLAY_SOURCE_ID,
|
||||||
entries.map((entry) => {
|
entries.map((entry) => {
|
||||||
const card = applyVesselOverlayPolicy(entry, VESSEL_CARD_FADE_DISTANCE_M);
|
const card = applyVesselOverlayPolicy(
|
||||||
|
entry,
|
||||||
|
VESSEL_CARD_FADE_DISTANCE_M,
|
||||||
|
);
|
||||||
if (!card.interactive) return card;
|
if (!card.interactive) return card;
|
||||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
const mmsi = String(card.id || '').startsWith('vessel:')
|
||||||
? card.id.slice('vessel:'.length)
|
? card.id.slice('vessel:'.length)
|
||||||
@@ -370,34 +420,53 @@ function publishVesselOverlayEntries(entries) {
|
|||||||
moving: false,
|
moving: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function labelPriority(record, selected) {
|
||||||
|
|
||||||
function labelPriority(record, selected) {
|
|
||||||
if (record === selected) return 100000;
|
if (record === selected) return 100000;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
if (hasUsefulName(record)) score += 1000;
|
if (hasUsefulName(record)) score += 1000;
|
||||||
if (record.speed !== null) score += Math.min(400, Math.max(0, record.speed) * 20);
|
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.heading !== null || record.course !== null) score += 80;
|
||||||
if (record.type) score += 40;
|
if (record.type) score += 40;
|
||||||
return score;
|
return score;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUsefulName(record) {
|
||||||
|
|
||||||
function hasUsefulName(record) {
|
|
||||||
const text = String(record.name || '').trim();
|
const text = String(record.name || '').trim();
|
||||||
return Boolean(text && text !== 'VESSEL' && !/^MMSI\s*\d+$/i.test(text) && text !== record.mmsi);
|
return Boolean(
|
||||||
}
|
text &&
|
||||||
|
text !== 'VESSEL' &&
|
||||||
|
!/^MMSI\s*\d+$/i.test(text) &&
|
||||||
|
text !== record.mmsi,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVisible(show) {
|
||||||
|
|
||||||
function setVisible(show) {
|
|
||||||
if (state.billboardCollection) {
|
if (state.billboardCollection) {
|
||||||
state.billboardCollection.show = show;
|
state.billboardCollection.show = show;
|
||||||
}
|
}
|
||||||
vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, 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 };
|
return {
|
||||||
|
renderRowLimit,
|
||||||
|
labelRowLimit,
|
||||||
|
ensureCollections,
|
||||||
|
addRecordPrimitives,
|
||||||
|
removeRecordPrimitives,
|
||||||
|
shipScale,
|
||||||
|
vesselCourseDeg,
|
||||||
|
shipIcon,
|
||||||
|
installRuntime,
|
||||||
|
updateVisibility,
|
||||||
|
applyVesselFocusDeemphasis,
|
||||||
|
makeOccluder,
|
||||||
|
isVisible,
|
||||||
|
updateClusteredLabels,
|
||||||
|
publishVesselOverlayEntries,
|
||||||
|
labelPriority,
|
||||||
|
hasUsefulName,
|
||||||
|
setVisible,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,36 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||||
|
|
||||||
export function createSelection({ vesselState, services, parts: components, layer, options }) {
|
export function createSelection({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
services,
|
||||||
const { requestWorldFocus } = services.worldFocus;
|
parts: components,
|
||||||
const { selectEntityContext, registerEntityContext, clearSelectedEntityContextForLayer } = services.context;
|
layer,
|
||||||
const aisLiveVesselsLayer = layer;
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||||
|
const { requestWorldFocus } = services.worldFocus;
|
||||||
|
const {
|
||||||
|
selectEntityContext,
|
||||||
|
registerEntityContext,
|
||||||
|
clearSelectedEntityContextForLayer,
|
||||||
|
} = services.context;
|
||||||
|
const aisLiveVesselsLayer = layer;
|
||||||
|
|
||||||
|
function installInteraction(viewer) {
|
||||||
|
|
||||||
function installInteraction(viewer) {
|
|
||||||
if (state.clickHandler || !viewer) return;
|
if (state.clickHandler || !viewer) return;
|
||||||
const handler = state.interactionHandlerFactory
|
const handler = state.interactionHandlerFactory
|
||||||
? state.interactionHandlerFactory(viewer)
|
? state.interactionHandlerFactory(viewer)
|
||||||
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
: new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||||
bindVesselInteraction(viewer, handler, state.interactionKeyTarget || document);
|
bindVesselInteraction(
|
||||||
}
|
viewer,
|
||||||
|
handler,
|
||||||
|
state.interactionKeyTarget || document,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVesselInteraction(viewer, handler, keyTarget) {
|
||||||
|
|
||||||
function bindVesselInteraction(viewer, handler, keyTarget) {
|
|
||||||
state.clickHandler = handler;
|
state.clickHandler = handler;
|
||||||
handler.setInputAction((click) => {
|
handler.setInputAction((click) => {
|
||||||
if (!state.enabled) return;
|
if (!state.enabled) return;
|
||||||
@@ -28,14 +38,16 @@ function bindVesselInteraction(viewer, handler, keyTarget) {
|
|||||||
const pickedId = resolvePickId(picked);
|
const pickedId = resolvePickId(picked);
|
||||||
let record = pickedId ? state.vesselMap.get(pickedId) : null;
|
let record = pickedId ? state.vesselMap.get(pickedId) : null;
|
||||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||||
const ownRecordPick = rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
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
|
// 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
|
// 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
|
// `gev-trail:*` pick is also a no-op. Every other non-vessel pick — sibling
|
||||||
// unowned scene picks dismiss the current vessel inspection.
|
// unowned scene picks dismiss the current vessel inspection.
|
||||||
if (ownRecordPick && (!pickedId || !record)) return;
|
if (ownRecordPick && (!pickedId || !record)) return;
|
||||||
if (pickedId && !record && String(pickedId).startsWith('gev-trail:')) return;
|
if (pickedId && !record && String(pickedId).startsWith('gev-trail:'))
|
||||||
|
return;
|
||||||
|
|
||||||
// A sibling layer already owns this click. Preserve the current vessel
|
// A sibling layer already owns this click. Preserve the current vessel
|
||||||
// selection and do not compete with its camera command.
|
// selection and do not compete with its camera command.
|
||||||
@@ -45,9 +57,13 @@ function bindVesselInteraction(viewer, handler, keyTarget) {
|
|||||||
// usually terrain behind the card. Resolve against the host's current
|
// usually terrain behind the card. Resolve against the host's current
|
||||||
// actionable hit rectangles before treating the click as empty space.
|
// actionable hit rectangles before treating the click as empty space.
|
||||||
const cardHit = !record
|
const cardHit = !record
|
||||||
? vesselState._vesselOverlayHost.hitTest?.(click.position?.x, click.position?.y, {
|
? vesselState._vesselOverlayHost.hitTest?.(
|
||||||
|
click.position?.x,
|
||||||
|
click.position?.y,
|
||||||
|
{
|
||||||
sourceId: VESSEL_OVERLAY_SOURCE_ID,
|
sourceId: VESSEL_OVERLAY_SOURCE_ID,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
if (!record && cardHit) {
|
if (!record && cardHit) {
|
||||||
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
||||||
@@ -77,15 +93,17 @@ function bindVesselInteraction(viewer, handler, keyTarget) {
|
|||||||
keyTarget.addEventListener('keydown', state.keydownHandler);
|
keyTarget.addEventListener('keydown', state.keydownHandler);
|
||||||
// Vessels never set viewer.trackedEntity, so any new tracked entity belongs
|
// Vessels never set viewer.trackedEntity, so any new tracked entity belongs
|
||||||
// to another layer and takes interaction ownership of the scene.
|
// to another layer and takes interaction ownership of the scene.
|
||||||
state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(() => {
|
state.trackedEntityRemover = viewer.trackedEntityChanged.addEventListener(
|
||||||
if (viewer.trackedEntity && state.selectedRecord) clearVesselInspection();
|
() => {
|
||||||
});
|
if (viewer.trackedEntity && state.selectedRecord)
|
||||||
}
|
clearVesselInspection();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select one live vessel and request one UI-owned camera transfer. */
|
||||||
|
|
||||||
/** Select one live vessel and request one UI-owned camera transfer. */
|
function selectAndFocusVessel(record) {
|
||||||
|
|
||||||
function selectAndFocusVessel(record) {
|
|
||||||
if (!record?.mmsi) return false;
|
if (!record?.mmsi) return false;
|
||||||
const transition = components.queries.reduceVesselSelection({
|
const transition = components.queries.reduceVesselSelection({
|
||||||
selectedMmsi: state.selectedRecord?.mmsi,
|
selectedMmsi: state.selectedRecord?.mmsi,
|
||||||
@@ -100,11 +118,9 @@ function selectAndFocusVessel(record) {
|
|||||||
position: record.billboard?.position || record.position,
|
position: record.billboard?.position || record.position,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeVesselInteraction() {
|
||||||
|
|
||||||
function removeVesselInteraction() {
|
|
||||||
if (state.clickHandler) {
|
if (state.clickHandler) {
|
||||||
state.clickHandler.destroy();
|
state.clickHandler.destroy();
|
||||||
state.clickHandler = null;
|
state.clickHandler = null;
|
||||||
@@ -118,11 +134,9 @@ function removeVesselInteraction() {
|
|||||||
state.trackedEntityRemover();
|
state.trackedEntityRemover();
|
||||||
state.trackedEntityRemover = null;
|
state.trackedEntityRemover = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onVesselKeyDown(event) {
|
||||||
|
|
||||||
function onVesselKeyDown(event) {
|
|
||||||
if (!state.enabled || event.key !== 'Escape') return;
|
if (!state.enabled || event.key !== 'Escape') return;
|
||||||
const transition = components.queries.reduceVesselSelection({
|
const transition = components.queries.reduceVesselSelection({
|
||||||
selectedMmsi: state.selectedRecord?.mmsi,
|
selectedMmsi: state.selectedRecord?.mmsi,
|
||||||
@@ -131,11 +145,9 @@ function onVesselKeyDown(event) {
|
|||||||
if (transition.action === 'deselect') {
|
if (transition.action === 'deselect') {
|
||||||
clearVesselInspection();
|
clearVesselInspection();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectVessel(record) {
|
||||||
|
|
||||||
function selectVessel(record) {
|
|
||||||
if (!record?.mmsi) return;
|
if (!record?.mmsi) return;
|
||||||
const reuseTrail = state.trailMmsi === record.mmsi;
|
const reuseTrail = state.trailMmsi === record.mmsi;
|
||||||
clearSelection({ preserveTrail: reuseTrail });
|
clearSelection({ preserveTrail: reuseTrail });
|
||||||
@@ -159,17 +171,16 @@ function selectVessel(record) {
|
|||||||
} else {
|
} else {
|
||||||
components.tracking.startSelectedVesselTrail(record);
|
components.tracking.startSelectedVesselTrail(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Register (or refresh) the selected vessel in the shared context store so
|
* Register (or refresh) the selected vessel in the shared context store so
|
||||||
* the realtime/voice layer can describe what the user has selected.
|
* the realtime/voice layer can describe what the user has selected.
|
||||||
* @param {Object} record - Selected vessel record.
|
* @param {Object} record - Selected vessel record.
|
||||||
* @returns {Object|null} The context record, or null if registration failed.
|
* @returns {Object|null} The context record, or null if registration failed.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function registerSelectedContext(record) {
|
function registerSelectedContext(record) {
|
||||||
if (!record?.mmsi) return null;
|
if (!record?.mmsi) return null;
|
||||||
try {
|
try {
|
||||||
return registerEntityContext(record, {
|
return registerEntityContext(record, {
|
||||||
@@ -192,11 +203,9 @@ function registerSelectedContext(record) {
|
|||||||
console.warn('[Data:ais-live-vessels] context register failed', error);
|
console.warn('[Data:ais-live-vessels] context register failed', error);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearSelection({ preserveTrail = false, evicted = false } = {}) {
|
||||||
|
|
||||||
function clearSelection({ preserveTrail = false, evicted = false } = {}) {
|
|
||||||
const record = state.selectedRecord;
|
const record = state.selectedRecord;
|
||||||
if (record?.billboard) {
|
if (record?.billboard) {
|
||||||
record.billboard.image = components.rendering.shipIcon(record, false);
|
record.billboard.image = components.rendering.shipIcon(record, false);
|
||||||
@@ -212,18 +221,27 @@ function clearSelection({ preserveTrail = false, evicted = false } = {}) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Data:ais-live-vessels] context clear failed', error);
|
console.warn('[Data:ais-live-vessels] context clear failed', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* @param {object} [options] Clear origin.
|
* @param {object} [options] Clear origin.
|
||||||
* @param {boolean} [options.evicted=false] The vessel aged out of the feed
|
* @param {boolean} [options.evicted=false] The vessel aged out of the feed
|
||||||
* rather than being deselected.
|
* rather than being deselected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function clearVesselInspection({ evicted = false } = {}) {
|
function clearVesselInspection({ evicted = false } = {}) {
|
||||||
clearSelection({ evicted });
|
clearSelection({ evicted });
|
||||||
components.cards.resetSelectedVesselHud();
|
components.cards.resetSelectedVesselHud();
|
||||||
}
|
}
|
||||||
return { installInteraction, bindVesselInteraction, selectAndFocusVessel, removeVesselInteraction, onVesselKeyDown, selectVessel, registerSelectedContext, clearSelection, clearVesselInspection };
|
return {
|
||||||
|
installInteraction,
|
||||||
|
bindVesselInteraction,
|
||||||
|
selectAndFocusVessel,
|
||||||
|
removeVesselInteraction,
|
||||||
|
onVesselKeyDown,
|
||||||
|
selectVessel,
|
||||||
|
registerSelectedContext,
|
||||||
|
clearSelection,
|
||||||
|
clearVesselInspection,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+23
-31
@@ -2,57 +2,49 @@ import * as Cesium from 'cesium';
|
|||||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||||
|
|
||||||
export function createVesselState({ source, services }) {
|
export function createVesselState({ source, services }) {
|
||||||
const { setOverlayEntries, setOverlaySourceVisible, clearOverlaySource, hitTestWorldOverlay } = services.overlay;
|
const {
|
||||||
const vesselState = {};
|
setOverlayEntries,
|
||||||
|
setOverlaySourceVisible,
|
||||||
|
clearOverlaySource,
|
||||||
|
hitTestWorldOverlay,
|
||||||
|
} = services.overlay;
|
||||||
|
const vesselState = {};
|
||||||
|
|
||||||
|
vesselState._source = source;
|
||||||
|
|
||||||
|
/** Camera pose signature at the last vessel rotation pass. */
|
||||||
|
|
||||||
vesselState._source = source;
|
vesselState._lastCamPoseSig = '';
|
||||||
|
|
||||||
|
vesselState._scratchFocusScreen = new Cesium.Cartesian2();
|
||||||
|
|
||||||
/** Camera pose signature at the last vessel rotation pass. */
|
vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({
|
||||||
|
|
||||||
vesselState._lastCamPoseSig = '';
|
|
||||||
|
|
||||||
|
|
||||||
vesselState._scratchFocusScreen = new Cesium.Cartesian2();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
vesselState.DEFAULT_VESSEL_OVERLAY_HOST = Object.freeze({
|
|
||||||
setEntries: setOverlayEntries,
|
setEntries: setOverlayEntries,
|
||||||
setVisible: setOverlaySourceVisible,
|
setVisible: setOverlaySourceVisible,
|
||||||
clearSource: clearOverlaySource,
|
clearSource: clearOverlaySource,
|
||||||
hitTest: hitTestWorldOverlay,
|
hitTest: hitTestWorldOverlay,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
|
||||||
|
|
||||||
vesselState._vesselOverlayHost = vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
|
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
|
||||||
|
|
||||||
|
vesselState._aisSessionSequence = 0;
|
||||||
|
|
||||||
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
|
/**
|
||||||
|
|
||||||
|
|
||||||
vesselState._aisSessionSequence = 0;
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True once the EGM96 geoid grid has loaded (fire-and-forget warm at
|
* True once the EGM96 geoid grid has loaded (fire-and-forget warm at
|
||||||
* enable(), aircraft idiom — see militaryFlights.js). Gates all synchronous
|
* enable(), aircraft idiom — see militaryFlights.js). Gates all synchronous
|
||||||
* geoidHeight() reads so a poll can never throw pre-load.
|
* geoidHeight() reads so a poll can never throw pre-load.
|
||||||
* @type {boolean}
|
* @type {boolean}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
vesselState._geoidReady = false;
|
vesselState._geoidReady = false;
|
||||||
|
|
||||||
|
/** @type {Map<string, string>} `${cssColor}:${variant}` -> chevron SVG data URL */
|
||||||
|
|
||||||
/** @type {Map<string, string>} `${cssColor}:${variant}` -> chevron SVG data URL */
|
vesselState.shipIconCache = new Map();
|
||||||
|
|
||||||
vesselState.shipIconCache = new Map();
|
vesselState.state = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
vesselState.state = {
|
|
||||||
viewer: null,
|
viewer: null,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
@@ -111,6 +103,6 @@ vesselState.state = {
|
|||||||
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
||||||
trailBackfillToken: 0,
|
trailBackfillToken: 0,
|
||||||
trailAbort: null,
|
trailAbort: null,
|
||||||
};
|
};
|
||||||
return vesselState;
|
return vesselState;
|
||||||
}
|
}
|
||||||
+47
-26
@@ -2,11 +2,16 @@ import * as Cesium from 'cesium';
|
|||||||
import { PARTIAL_RETENTION_MS } from './policy.js';
|
import { PARTIAL_RETENTION_MS } from './policy.js';
|
||||||
import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js';
|
import { SELECTED_PIN_REFRESHES, VESSEL_LIFT_M } from './policy.js';
|
||||||
|
|
||||||
export function createStore({ vesselState, services, parts: components, layer, options }) {
|
export function createStore({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Reconcile the incoming AIS rows against the MMSI-keyed record map.
|
* Reconcile the incoming AIS rows against the MMSI-keyed record map.
|
||||||
* Existing records are updated in place (position/heading/label) so identity
|
* Existing records are updated in place (position/heading/label) so identity
|
||||||
* and selection survive refreshes; new vessels are added; vanished vessels are
|
* and selection survive refreshes; new vessels are added; vanished vessels are
|
||||||
@@ -17,7 +22,7 @@ const { state } = vesselState;
|
|||||||
* @param {Array<Object>} rows - Raw AIS rows from the live API.
|
* @param {Array<Object>} rows - Raw AIS rows from the live API.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
||||||
const receivedAtMs = vesselState._aisRuntime.now();
|
const receivedAtMs = vesselState._aisRuntime.now();
|
||||||
components.rendering.ensureCollections(viewer);
|
components.rendering.ensureCollections(viewer);
|
||||||
|
|
||||||
@@ -54,7 +59,11 @@ function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
|||||||
// Remove vanished vessels, pinning the selected one for a few refreshes.
|
// Remove vanished vessels, pinning the selected one for a few refreshes.
|
||||||
for (const [mmsi, record] of state.vesselMap) {
|
for (const [mmsi, record] of state.vesselMap) {
|
||||||
if (seen.has(mmsi)) continue;
|
if (seen.has(mmsi)) continue;
|
||||||
if (!complete && Number.isFinite(record.receivedAtMs) && receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS) {
|
if (
|
||||||
|
!complete &&
|
||||||
|
Number.isFinite(record.receivedAtMs) &&
|
||||||
|
receivedAtMs - record.receivedAtMs < PARTIAL_RETENTION_MS
|
||||||
|
) {
|
||||||
if (record === state.selectedRecord) {
|
if (record === state.selectedRecord) {
|
||||||
record.missedRefreshes = Math.max(1, record.missedRefreshes || 0);
|
record.missedRefreshes = Math.max(1, record.missedRefreshes || 0);
|
||||||
components.cards.updateSelectedVesselHud(record);
|
components.cards.updateSelectedVesselHud(record);
|
||||||
@@ -74,7 +83,8 @@ function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
|||||||
state.vesselMap.delete(mmsi);
|
state.vesselMap.delete(mmsi);
|
||||||
// Defensive lifecycle closure: a trail may outlive selection state during
|
// Defensive lifecycle closure: a trail may outlive selection state during
|
||||||
// asynchronous handoff/refresh ordering, but never its owning record.
|
// asynchronous handoff/refresh ordering, but never its owning record.
|
||||||
if (state.trailMmsi === mmsi) components.tracking.clearSelectedVesselTrail();
|
if (state.trailMmsi === mmsi)
|
||||||
|
components.tracking.clearSelectedVesselTrail();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = components.rendering.renderRowLimit();
|
const cap = components.rendering.renderRowLimit();
|
||||||
@@ -84,16 +94,19 @@ function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
|||||||
if (seen.has(mmsi) || record === state.selectedRecord) continue;
|
if (seen.has(mmsi) || record === state.selectedRecord) continue;
|
||||||
components.rendering.removeRecordPrimitives(record);
|
components.rendering.removeRecordPrimitives(record);
|
||||||
state.vesselMap.delete(mmsi);
|
state.vesselMap.delete(mmsi);
|
||||||
if (state.trailMmsi === mmsi) components.tracking.clearSelectedVesselTrail();
|
if (state.trailMmsi === mmsi)
|
||||||
|
components.tracking.clearSelectedVesselTrail();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.vesselRecords = [...state.vesselMap.values(), ...state.unkeyedRecords];
|
state.vesselRecords = [
|
||||||
|
...state.vesselMap.values(),
|
||||||
|
...state.unkeyedRecords,
|
||||||
|
];
|
||||||
state.lastVisibilityUpdate = 0;
|
state.lastVisibilityUpdate = 0;
|
||||||
components.rendering.updateVisibility(true);
|
components.rendering.updateVisibility(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Update an existing record (and its primitives) from a freshly normalized row,
|
* Update an existing record (and its primitives) from a freshly normalized row,
|
||||||
* preserving object identity so selection and the click-pick id stay valid.
|
* preserving object identity so selection and the click-pick id stay valid.
|
||||||
* Billboard image is only reassigned when the resolved icon actually changes
|
* Billboard image is only reassigned when the resolved icon actually changes
|
||||||
@@ -102,7 +115,7 @@ function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
|||||||
* @param {Object} next - Freshly normalized record for the same MMSI.
|
* @param {Object} next - Freshly normalized record for the same MMSI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function updateRecordInPlace(record, next) {
|
function updateRecordInPlace(record, next) {
|
||||||
const selected = record === state.selectedRecord;
|
const selected = record === state.selectedRecord;
|
||||||
const prevIcon = components.rendering.shipIcon(record, selected);
|
const prevIcon = components.rendering.shipIcon(record, selected);
|
||||||
|
|
||||||
@@ -127,7 +140,8 @@ function updateRecordInPlace(record, next) {
|
|||||||
if (record.billboard) {
|
if (record.billboard) {
|
||||||
record.billboard.position = record.position;
|
record.billboard.position = record.position;
|
||||||
// Rotation is owned by the projected-rotation pass (updateVisibility).
|
// Rotation is owned by the projected-rotation pass (updateVisibility).
|
||||||
record.billboard.scale = components.rendering.shipScale(record) * (selected ? 1.2 : 1);
|
record.billboard.scale =
|
||||||
|
components.rendering.shipScale(record) * (selected ? 1.2 : 1);
|
||||||
const nextIcon = components.rendering.shipIcon(record, selected);
|
const nextIcon = components.rendering.shipIcon(record, selected);
|
||||||
if (nextIcon !== prevIcon) {
|
if (nextIcon !== prevIcon) {
|
||||||
record.billboard.image = nextIcon;
|
record.billboard.image = nextIcon;
|
||||||
@@ -140,22 +154,26 @@ function updateRecordInPlace(record, next) {
|
|||||||
components.cards.updateSelectedVesselHud(record);
|
components.cards.updateSelectedVesselHud(record);
|
||||||
components.selection.registerSelectedContext(record);
|
components.selection.registerSelectedContext(record);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVessel(row) {
|
||||||
|
|
||||||
function normalizeVessel(row) {
|
|
||||||
const lat = Number(row.lat);
|
const lat = Number(row.lat);
|
||||||
const lon = Number(row.lon);
|
const lon = Number(row.lon);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||||
// Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the
|
// Vertical datum: anchor at the SEA SURFACE (geoid, h = N + lift), not the
|
||||||
// ellipsoid — at height 0 everything that projects record.position (clicks,
|
// ellipsoid — at height 0 everything that projects record.position (clicks,
|
||||||
// detection brackets, cards, getNearby) points up to ~45 m under the water.
|
// 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 heightM = components.queries.vesselDatumHeightM(
|
||||||
|
components.tracking.currentGeoidN(lat, lon),
|
||||||
|
VESSEL_LIFT_M,
|
||||||
|
);
|
||||||
const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
|
const position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
|
||||||
// Surface normal at this position — used as alignedAxis so billboard
|
// Surface normal at this position — used as alignedAxis so billboard
|
||||||
// rotation operates in the local tangent plane (true world heading)
|
// rotation operates in the local tangent plane (true world heading)
|
||||||
const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(position, new Cesium.Cartesian3());
|
const normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||||
|
position,
|
||||||
|
new Cesium.Cartesian3(),
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -178,14 +196,17 @@ function normalizeVessel(row) {
|
|||||||
missedRefreshes: 0,
|
missedRefreshes: 0,
|
||||||
billboard: null,
|
billboard: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value) {
|
||||||
|
|
||||||
function finiteNumber(value) {
|
|
||||||
if (value === null || value === undefined || value === '') return null;
|
if (value === null || value === undefined || value === '') return null;
|
||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isFinite(number) ? number : null;
|
return Number.isFinite(number) ? number : null;
|
||||||
}
|
}
|
||||||
return { reconcileVessels, updateRecordInPlace, normalizeVessel, finiteNumber };
|
return {
|
||||||
|
reconcileVessels,
|
||||||
|
updateRecordInPlace,
|
||||||
|
normalizeVessel,
|
||||||
|
finiteNumber,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||||
|
|
||||||
export function createTesting({ vesselState, services, parts: components, layer, options }) {
|
export function createTesting({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const aisLiveVesselsLayer = layer;
|
services,
|
||||||
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const aisLiveVesselsLayer = layer;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Bind the production interaction callbacks to mockable viewer/handler
|
* Bind the production interaction callbacks to mockable viewer/handler
|
||||||
* surfaces. Test-only seam; behavior is shared with installInteraction().
|
* surfaces. Test-only seam; behavior is shared with installInteraction().
|
||||||
* @param {Object} viewer - Viewer-like object with scene.pick().
|
* @param {Object} viewer - Viewer-like object with scene.pick().
|
||||||
@@ -14,18 +19,17 @@ const aisLiveVesselsLayer = layer;
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _bindVesselInteractionForTest(viewer, handler, keyTarget) {
|
function _bindVesselInteractionForTest(viewer, handler, keyTarget) {
|
||||||
components.selection.bindVesselInteraction(viewer, handler, keyTarget);
|
components.selection.bindVesselInteraction(viewer, handler, keyTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Prime the minimum live state needed by interaction/lifecycle wire tests.
|
* Prime the minimum live state needed by interaction/lifecycle wire tests.
|
||||||
* @param {Object} [options={}] - Test state values.
|
* @param {Object} [options={}] - Test state values.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _setVesselStateForTest(options = {}) {
|
function _setVesselStateForTest(options = {}) {
|
||||||
components.lifecycle.resetState();
|
components.lifecycle.resetState();
|
||||||
const records = Array.isArray(options.records) ? options.records : [];
|
const records = Array.isArray(options.records) ? options.records : [];
|
||||||
state.viewer = options.viewer || null;
|
state.viewer = options.viewer || null;
|
||||||
@@ -38,75 +42,77 @@ function _setVesselStateForTest(options = {}) {
|
|||||||
state.vesselRecords = records;
|
state.vesselRecords = records;
|
||||||
state.count = records.length;
|
state.count = records.length;
|
||||||
state.vesselMap = new Map(
|
state.vesselMap = new Map(
|
||||||
records.filter((record) => record?.mmsi).map((record) => [record.mmsi, record])
|
records
|
||||||
|
.filter((record) => record?.mmsi)
|
||||||
|
.map((record) => [record.mmsi, record]),
|
||||||
);
|
);
|
||||||
state.selectedRecord = options.selectedRecord || null;
|
state.selectedRecord = options.selectedRecord || null;
|
||||||
state.billboardCollection = options.billboardCollection || { remove() {} };
|
state.billboardCollection = options.billboardCollection || { remove() {} };
|
||||||
state.trail = options.trail || null;
|
state.trail = options.trail || null;
|
||||||
state.trailMmsi = options.trailMmsi || null;
|
state.trailMmsi = options.trailMmsi || null;
|
||||||
state.trailPositions = Array.isArray(options.trailPositions) ? [...options.trailPositions] : [];
|
state.trailPositions = Array.isArray(options.trailPositions)
|
||||||
|
? [...options.trailPositions]
|
||||||
|
: [];
|
||||||
state.transportStatus = options.transportStatus || null;
|
state.transportStatus = options.transportStatus || null;
|
||||||
state.lastMessageAt = options.lastMessageAt ?? null;
|
state.lastMessageAt = options.lastMessageAt ?? null;
|
||||||
state.rawRowCount = Number.isFinite(options.rawRowCount) ? options.rawRowCount : 0;
|
state.rawRowCount = Number.isFinite(options.rawRowCount)
|
||||||
state.acceptedRowCount = Number.isFinite(options.acceptedRowCount) ? options.acceptedRowCount : records.length;
|
? options.rawRowCount
|
||||||
|
: 0;
|
||||||
|
state.acceptedRowCount = Number.isFinite(options.acceptedRowCount)
|
||||||
|
? options.acceptedRowCount
|
||||||
|
: records.length;
|
||||||
state.firstConnectPhase = options.firstConnectPhase || 'idle';
|
state.firstConnectPhase = options.firstConnectPhase || 'idle';
|
||||||
state.firstConnectStartedAt = options.firstConnectStartedAt ?? null;
|
state.firstConnectStartedAt = options.firstConnectStartedAt ?? null;
|
||||||
state.firstConnectDeadline = options.firstConnectDeadline ?? null;
|
state.firstConnectDeadline = options.firstConnectDeadline ?? null;
|
||||||
state.interactionHandlerFactory = options.interactionHandlerFactory || null;
|
state.interactionHandlerFactory = options.interactionHandlerFactory || null;
|
||||||
state.interactionKeyTarget = options.interactionKeyTarget || null;
|
state.interactionKeyTarget = options.interactionKeyTarget || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inject a host recorder for lifecycle/contract tests; null restores production. */
|
||||||
|
|
||||||
/** Inject a host recorder for lifecycle/contract tests; null restores production. */
|
function _setVesselOverlayHostForTest(host = null) {
|
||||||
|
vesselState._vesselOverlayHost =
|
||||||
|
host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
|
||||||
|
}
|
||||||
|
|
||||||
function _setVesselOverlayHostForTest(host = null) {
|
/** Exercise the production selector/publisher through a test-owned state. */
|
||||||
vesselState._vesselOverlayHost = host || vesselState.DEFAULT_VESSEL_OVERLAY_HOST;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function _updateVesselCardsForTest(records = []) {
|
||||||
/** Exercise the production selector/publisher through a test-owned state. */
|
|
||||||
|
|
||||||
function _updateVesselCardsForTest(records = []) {
|
|
||||||
components.rendering.updateClusteredLabels(records);
|
components.rendering.updateClusteredLabels(records);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Reconcile AIS rows through the production lifecycle. Test-only seam.
|
* Reconcile AIS rows through the production lifecycle. Test-only seam.
|
||||||
* @param {Object} viewer - Viewer-like object.
|
* @param {Object} viewer - Viewer-like object.
|
||||||
* @param {Array<Object>} rows - Raw AIS rows.
|
* @param {Array<Object>} rows - Raw AIS rows.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _reconcileVesselsForTest(viewer, rows) {
|
function _reconcileVesselsForTest(viewer, rows) {
|
||||||
components.store.reconcileVessels(viewer, rows);
|
components.store.reconcileVessels(viewer, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Apply one server snapshot through the production pre-reconcile health gate. */
|
||||||
|
|
||||||
/** Apply one server snapshot through the production pre-reconcile health gate. */
|
function _applyAisFeedSnapshotForTest(viewer, payload) {
|
||||||
|
|
||||||
function _applyAisFeedSnapshotForTest(viewer, payload) {
|
|
||||||
return components.ingestion.applyAisFeedSnapshot(viewer, payload);
|
return components.ingestion.applyAisFeedSnapshot(viewer, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Exercise the request-owned live loader with a test-controlled fetch. */
|
||||||
|
|
||||||
/** Exercise the request-owned live loader with a test-controlled fetch. */
|
function _loadLivePositionsForTest(viewer) {
|
||||||
|
|
||||||
function _loadLivePositionsForTest(viewer) {
|
|
||||||
return components.ingestion.loadLivePositions(viewer);
|
return components.ingestion.loadLivePositions(viewer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Start the production first-connect grace state without installing UI. */
|
||||||
|
|
||||||
/** Start the production first-connect grace state without installing UI. */
|
function _beginAisSessionForTest() {
|
||||||
|
|
||||||
function _beginAisSessionForTest() {
|
|
||||||
components.lifecycle.beginAisSession();
|
components.lifecycle.beginAisSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Inject a deterministic clock/scheduler; null restores production runtime. */
|
||||||
|
|
||||||
/** Inject a deterministic clock/scheduler; null restores production runtime. */
|
function _setAisRuntimeForTest(runtime = null) {
|
||||||
|
|
||||||
function _setAisRuntimeForTest(runtime = null) {
|
|
||||||
components.lifecycle.clearFirstConnectTimer();
|
components.lifecycle.clearFirstConnectTimer();
|
||||||
vesselState._aisRuntime = runtime
|
vesselState._aisRuntime = runtime
|
||||||
? {
|
? {
|
||||||
@@ -115,12 +121,11 @@ function _setAisRuntimeForTest(runtime = null) {
|
|||||||
clearTimeout: runtime.clearTimeout,
|
clearTimeout: runtime.clearTimeout,
|
||||||
}
|
}
|
||||||
: DEFAULT_AIS_RUNTIME;
|
: DEFAULT_AIS_RUNTIME;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read feed-health fields without exposing mutable production state. */
|
||||||
|
|
||||||
/** Read feed-health fields without exposing mutable production state. */
|
function _getVesselFeedStateForTest() {
|
||||||
|
|
||||||
function _getVesselFeedStateForTest() {
|
|
||||||
const stats = aisLiveVesselsLayer.getStats();
|
const stats = aisLiveVesselsLayer.getStats();
|
||||||
return {
|
return {
|
||||||
count: state.count,
|
count: state.count,
|
||||||
@@ -143,21 +148,32 @@ function _getVesselFeedStateForTest() {
|
|||||||
firstConnectStartedAt: state.firstConnectStartedAt,
|
firstConnectStartedAt: state.firstConnectStartedAt,
|
||||||
firstConnectDeadline: state.firstConnectDeadline,
|
firstConnectDeadline: state.firstConnectDeadline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Read lifecycle ownership state without exposing the mutable state object.
|
* Read lifecycle ownership state without exposing the mutable state object.
|
||||||
* Test-only seam.
|
* Test-only seam.
|
||||||
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
|
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function _getVesselStateForTest() {
|
function _getVesselStateForTest() {
|
||||||
return {
|
return {
|
||||||
trailMmsi: state.trailMmsi,
|
trailMmsi: state.trailMmsi,
|
||||||
trailPositionCount: state.trailPositions.length,
|
trailPositionCount: state.trailPositions.length,
|
||||||
vesselCount: state.vesselMap.size,
|
vesselCount: state.vesselMap.size,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { _bindVesselInteractionForTest, _setVesselStateForTest, _setVesselOverlayHostForTest, _updateVesselCardsForTest, _reconcileVesselsForTest, _applyAisFeedSnapshotForTest, _loadLivePositionsForTest, _beginAisSessionForTest, _setAisRuntimeForTest, _getVesselFeedStateForTest, _getVesselStateForTest };
|
return {
|
||||||
|
_bindVesselInteractionForTest,
|
||||||
|
_setVesselStateForTest,
|
||||||
|
_setVesselOverlayHostForTest,
|
||||||
|
_updateVesselCardsForTest,
|
||||||
|
_reconcileVesselsForTest,
|
||||||
|
_applyAisFeedSnapshotForTest,
|
||||||
|
_loadLivePositionsForTest,
|
||||||
|
_beginAisSessionForTest,
|
||||||
|
_setAisRuntimeForTest,
|
||||||
|
_getVesselFeedStateForTest,
|
||||||
|
_getVesselStateForTest,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,35 @@
|
|||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { TRAIL_HEIGHT_M, VESSEL_LIFT_M, TRAIL_COLOR, TRAIL_MAX_POINTS, TRAIL_MIN_MOVE_M } from './policy.js';
|
import {
|
||||||
|
TRAIL_HEIGHT_M,
|
||||||
|
VESSEL_LIFT_M,
|
||||||
|
TRAIL_COLOR,
|
||||||
|
TRAIL_MAX_POINTS,
|
||||||
|
TRAIL_MIN_MOVE_M,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
export function createTracking({ vesselState, services, parts: components, layer, options }) {
|
export function createTracking({
|
||||||
const { state } = vesselState;
|
vesselState,
|
||||||
const { geoidHeight } = services.geoid;
|
services,
|
||||||
const { createTrail } = services.trails;
|
parts: components,
|
||||||
|
layer,
|
||||||
|
options,
|
||||||
|
}) {
|
||||||
|
const { state } = vesselState;
|
||||||
|
const { geoidHeight } = services.geoid;
|
||||||
|
const { createTrail } = services.trails;
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
|
* Geoid undulation N at (lat, lon), or null until the grid has loaded.
|
||||||
* @param {number} lat
|
* @param {number} lat
|
||||||
* @param {number} lon
|
* @param {number} lon
|
||||||
* @returns {number|null}
|
* @returns {number|null}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function currentGeoidN(lat, lon) {
|
function currentGeoidN(lat, lon) {
|
||||||
return vesselState._geoidReady ? geoidHeight(lat, lon) : null;
|
return vesselState._geoidReady ? geoidHeight(lat, lon) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Build a slightly lifted trail vertex for a vessel record — raised
|
* Build a slightly lifted trail vertex for a vessel record — raised
|
||||||
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
|
* TRAIL_HEIGHT_M above the sea surface (geoid, same datum as the anchor)
|
||||||
* to avoid z-fighting.
|
* to avoid z-fighting.
|
||||||
@@ -27,14 +37,17 @@ function currentGeoidN(lat, lon) {
|
|||||||
* @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
|
* @returns {Cesium.Cartesian3|null} Lifted position, or null without a fix.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function vesselTrailPosition(record) {
|
function vesselTrailPosition(record) {
|
||||||
if (!Number.isFinite(record?.lat) || !Number.isFinite(record?.lon)) return null;
|
if (!Number.isFinite(record?.lat) || !Number.isFinite(record?.lon))
|
||||||
const heightM = components.queries.vesselDatumHeightM(currentGeoidN(record.lat, record.lon), TRAIL_HEIGHT_M);
|
return null;
|
||||||
|
const heightM = components.queries.vesselDatumHeightM(
|
||||||
|
currentGeoidN(record.lat, record.lon),
|
||||||
|
TRAIL_HEIGHT_M,
|
||||||
|
);
|
||||||
return Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM);
|
return Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* One-shot datum re-lift when the geoid grid warms mid-session: the first
|
* 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
|
* 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
|
* the next refresh is up to REFRESH_MS out — re-derive every record's
|
||||||
@@ -43,24 +56,31 @@ function vesselTrailPosition(record) {
|
|||||||
* needs a rendered pick — so trail vertices are not revisited.)
|
* needs a rendered pick — so trail vertices are not revisited.)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function refloorVesselRecords() {
|
function refloorVesselRecords() {
|
||||||
if (!state.vesselRecords.length) return;
|
if (!state.vesselRecords.length) return;
|
||||||
for (const record of state.vesselRecords) {
|
for (const record of state.vesselRecords) {
|
||||||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon)) continue;
|
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||||||
const heightM = components.queries.vesselDatumHeightM(currentGeoidN(record.lat, record.lon), VESSEL_LIFT_M);
|
continue;
|
||||||
record.position = Cesium.Cartesian3.fromDegrees(record.lon, record.lat, heightM);
|
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 (record.billboard) record.billboard.position = record.position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Start (or restart) the selected vessel's trail: seed with the current
|
* Start (or restart) the selected vessel's trail: seed with the current
|
||||||
* position, render, then fire-and-forget the server ring-buffer backfill.
|
* position, render, then fire-and-forget the server ring-buffer backfill.
|
||||||
* @param {Object} record - Freshly selected vessel record.
|
* @param {Object} record - Freshly selected vessel record.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function startSelectedVesselTrail(record) {
|
function startSelectedVesselTrail(record) {
|
||||||
state.trailAbort?.abort();
|
state.trailAbort?.abort();
|
||||||
state.trailAbort = new AbortController();
|
state.trailAbort = new AbortController();
|
||||||
state.trailBackfillToken += 1;
|
state.trailBackfillToken += 1;
|
||||||
@@ -69,14 +89,20 @@ function startSelectedVesselTrail(record) {
|
|||||||
const current = vesselTrailPosition(record);
|
const current = vesselTrailPosition(record);
|
||||||
if (current) state.trailPositions.push(current);
|
if (current) state.trailPositions.push(current);
|
||||||
if (!state.trail && state.viewer) {
|
if (!state.trail && state.viewer) {
|
||||||
state.trail = createTrail(state.viewer, { color: TRAIL_COLOR, width: 2.5 });
|
state.trail = createTrail(state.viewer, {
|
||||||
|
color: TRAIL_COLOR,
|
||||||
|
width: 2.5,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (state.trail) state.trail.setPositions(state.trailPositions);
|
if (state.trail) state.trail.setPositions(state.trailPositions);
|
||||||
backfillVesselTrail(record.mmsi, state.trailBackfillToken, record.reference);
|
backfillVesselTrail(
|
||||||
}
|
record.mmsi,
|
||||||
|
state.trailBackfillToken,
|
||||||
|
record.reference,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Fire-and-forget backfill from the server-side per-MMSI ring buffer
|
* Fire-and-forget backfill from the server-side per-MMSI ring buffer
|
||||||
* (PRD F3 — "recent path" since server boot, not voyage history). Older
|
* (PRD F3 — "recent path" since server boot, not voyage history). Older
|
||||||
* samples are spliced AHEAD of the live accumulation, capped at
|
* samples are spliced AHEAD of the live accumulation, capped at
|
||||||
@@ -87,9 +113,11 @@ function startSelectedVesselTrail(record) {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
||||||
const owner = state.trailAbort;
|
const owner = state.trailAbort;
|
||||||
const signal = owner ? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)]) : AbortSignal.timeout(8000);
|
const signal = owner
|
||||||
|
? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)])
|
||||||
|
: AbortSignal.timeout(8000);
|
||||||
let samples = null;
|
let samples = null;
|
||||||
try {
|
try {
|
||||||
const track = await vesselState._source.getTrack?.(reference, { signal });
|
const track = await vesselState._source.getTrack?.(reference, { signal });
|
||||||
@@ -97,7 +125,13 @@ async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
|||||||
} catch {
|
} catch {
|
||||||
return; // silent — keep the live-accumulated trail
|
return; // silent — keep the live-accumulated trail
|
||||||
}
|
}
|
||||||
if (signal.aborted || owner !== state.trailAbort || !samples || token !== state.trailBackfillToken) return;
|
if (
|
||||||
|
signal.aborted ||
|
||||||
|
owner !== state.trailAbort ||
|
||||||
|
!samples ||
|
||||||
|
token !== state.trailBackfillToken
|
||||||
|
)
|
||||||
|
return;
|
||||||
if (state.trailMmsi !== mmsi) return;
|
if (state.trailMmsi !== mmsi) return;
|
||||||
|
|
||||||
const older = [];
|
const older = [];
|
||||||
@@ -107,61 +141,74 @@ async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
|||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||||
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
|
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
|
||||||
// the live vertices so the spliced trail is height-continuous.
|
// the live vertices so the spliced trail is height-continuous.
|
||||||
const heightM = components.queries.vesselDatumHeightM(currentGeoidN(lat, lon), TRAIL_HEIGHT_M);
|
const heightM = components.queries.vesselDatumHeightM(
|
||||||
|
currentGeoidN(lat, lon),
|
||||||
|
TRAIL_HEIGHT_M,
|
||||||
|
);
|
||||||
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, heightM));
|
older.push(Cesium.Cartesian3.fromDegrees(lon, lat, heightM));
|
||||||
}
|
}
|
||||||
if (!older.length) return;
|
if (!older.length) return;
|
||||||
|
|
||||||
state.trailPositions = older.concat(state.trailPositions);
|
state.trailPositions = older.concat(state.trailPositions);
|
||||||
if (state.trailPositions.length > TRAIL_MAX_POINTS) {
|
if (state.trailPositions.length > TRAIL_MAX_POINTS) {
|
||||||
state.trailPositions = state.trailPositions.slice(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
|
* 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.
|
* moved more than TRAIL_MIN_MOVE_M from the last trail vertex.
|
||||||
* @param {Object} record - Selected vessel record after an in-place update.
|
* @param {Object} record - Selected vessel record after an in-place update.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function appendSelectedVesselTrailFix(record) {
|
function appendSelectedVesselTrailFix(record) {
|
||||||
if (!state.trail) return;
|
if (!state.trail) return;
|
||||||
const next = vesselTrailPosition(record);
|
const next = vesselTrailPosition(record);
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
const last = state.trailPositions[state.trailPositions.length - 1];
|
const last = state.trailPositions[state.trailPositions.length - 1];
|
||||||
if (last && Cesium.Cartesian3.distance(last, next) <= TRAIL_MIN_MOVE_M) return;
|
if (last && Cesium.Cartesian3.distance(last, next) <= TRAIL_MIN_MOVE_M)
|
||||||
|
return;
|
||||||
state.trailPositions.push(next);
|
state.trailPositions.push(next);
|
||||||
if (state.trailPositions.length > TRAIL_MAX_POINTS) state.trailPositions.shift();
|
if (state.trailPositions.length > TRAIL_MAX_POINTS)
|
||||||
|
state.trailPositions.shift();
|
||||||
state.trail.setPositions(state.trailPositions);
|
state.trail.setPositions(state.trailPositions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Clear the rendered trail and accumulation; invalidate pending backfills.
|
* Clear the rendered trail and accumulation; invalidate pending backfills.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function clearSelectedVesselTrail() {
|
function clearSelectedVesselTrail() {
|
||||||
state.trailAbort?.abort();
|
state.trailAbort?.abort();
|
||||||
state.trailAbort = null;
|
state.trailAbort = null;
|
||||||
state.trailBackfillToken += 1;
|
state.trailBackfillToken += 1;
|
||||||
state.trailMmsi = null;
|
state.trailMmsi = null;
|
||||||
state.trailPositions = [];
|
state.trailPositions = [];
|
||||||
if (state.trail) state.trail.clear();
|
if (state.trail) state.trail.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
|
||||||
* Destroy the trail primitive entirely (layer disable/teardown).
|
* Destroy the trail primitive entirely (layer disable/teardown).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function destroySelectedVesselTrail() {
|
function destroySelectedVesselTrail() {
|
||||||
clearSelectedVesselTrail();
|
clearSelectedVesselTrail();
|
||||||
if (state.trail) {
|
if (state.trail) {
|
||||||
state.trail.destroy();
|
state.trail.destroy();
|
||||||
state.trail = null;
|
state.trail = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { currentGeoidN, vesselTrailPosition, refloorVesselRecords, startSelectedVesselTrail, backfillVesselTrail, appendSelectedVesselTrailFix, clearSelectedVesselTrail, destroySelectedVesselTrail };
|
return {
|
||||||
|
currentGeoidN,
|
||||||
|
vesselTrailPosition,
|
||||||
|
refloorVesselRecords,
|
||||||
|
startSelectedVesselTrail,
|
||||||
|
backfillVesselTrail,
|
||||||
|
appendSelectedVesselTrailFix,
|
||||||
|
clearSelectedVesselTrail,
|
||||||
|
destroySelectedVesselTrail,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -5,13 +5,19 @@ import { fileURLToPath } from 'node:url';
|
|||||||
/** Read actual component owners for structural regression assertions. */
|
/** Read actual component owners for structural regression assertions. */
|
||||||
export function readLayerSource(file) {
|
export function readLayerSource(file) {
|
||||||
const path = file instanceof URL ? fileURLToPath(file) : file;
|
const path = file instanceof URL ? fileURLToPath(file) : file;
|
||||||
if (!['flights.js', 'militaryFlights.js', 'aisLiveVessels.js'].includes(basename(path)))
|
if (
|
||||||
|
!['flights.js', 'militaryFlights.js', 'aisLiveVessels.js'].includes(
|
||||||
|
basename(path),
|
||||||
|
)
|
||||||
|
)
|
||||||
return readFileSync(path, 'utf8');
|
return readFileSync(path, 'utf8');
|
||||||
const directory = join(
|
const directory = join(
|
||||||
dirname(path),
|
dirname(path),
|
||||||
basename(path) === 'flights.js'
|
basename(path) === 'flights.js'
|
||||||
? '../layers/flights'
|
? '../layers/flights'
|
||||||
: basename(path) === 'militaryFlights.js' ? '../layers/military' : '../layers/vessels',
|
: basename(path) === 'militaryFlights.js'
|
||||||
|
? '../layers/military'
|
||||||
|
: '../layers/vessels',
|
||||||
);
|
);
|
||||||
return readdirSync(directory)
|
return readdirSync(directory)
|
||||||
.filter((name) => name.endsWith('.js'))
|
.filter((name) => name.endsWith('.js'))
|
||||||
|
|||||||
Reference in new issue
Block a user