mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Draw vessels, satellites, Space Missions, ALPR, bikeshare and OSM Places through one display each
Each layer now has one render/display.js on the globe contract, the same factory on both engines; its render/cesium* and render/navara* files go. Cesium's behaviour is the reference, and the logic that was written twice is written once: - AIS vessels: atlas chevrons and engine.lines trails. The display registers the gev-trail pick claim itself and its trailing visibility pass now also runs on Navara. - Satellites: point banks per look, orbit rings as lines turned by their model matrix, and selection and follow through engine.camera.follow. The emulated _viewer is gone, and the tracked readout republishes card text on Navara too. - Space Missions: dots, reticle, trajectories and tactical orbits as movers and lines. The layer keeps its own records instead of Entities and DataSources, and camera work goes through engine.camera. Clicks go through the selection dispatcher, and hover keeps Cesium's throttle. The pad zone is its rim only: the contract has no surface fill yet. - ALPR, bikeshare and OSM Places: one display and, for bikeshare, one style module each; clicks through engine.input, no ScreenSpaceEventHandler or EntityCluster. ALPR keeps Navara's horizon cull, and OSM Places points sample the surface where Cesium clamped them. Coupling: layer renderer lines fall from 4546/11106 to 2638/8735 (Cesium/Navara), `viewer` lines from 2209 to 2014, ScreenSpaceEventHandler files from 20 to 15.
This commit is contained in:
1 parent
3e261d1baf
commit
f06b93fbc9
120 files changed
+6626
-6998
No files matched your search
@@ -206,7 +206,9 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
engine-neutral files and draws through `render/cesium*.js` or
|
||||
`render/navara*.js`, or through one `render/display.js` that runs on every
|
||||
engine over the globe contract: the aircraft family (flights, military and
|
||||
local ADS-B) draws through `src/layers/aircraft/render/display.js`. Cesium
|
||||
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
|
||||
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places and
|
||||
Wildlife Sightings through their own `render/display.js`. Cesium
|
||||
renderers are listed in `src/app/layers/renderers.js`
|
||||
(`CESIUM_LAYER_RENDERERS`), Navara ones are loaded by
|
||||
`registerNavaraLayerRenderers` in `src/app/navaraMode.js`; both register the
|
||||
@@ -1328,13 +1330,14 @@ screen. Cyan camera badges use Manjunath's camera artwork; selection switches to
|
||||
larger coral badge with corner brackets and an animated tactical card. Numeric
|
||||
mapped bearings draw illustrative 90 m direction wedges (not measured coverage).
|
||||
The shared overlay paints gradients for at most 64 nearby cameras, promoting the
|
||||
selected camera; farther cameras keep native Cesium badges without wedges.
|
||||
selected camera; farther cameras keep native badges without wedges.
|
||||
Native ground-clamped wedges (a ground polyline plus a classification polygon per
|
||||
camera) cost about 40 ms per frame for 200 downtown cameras on photoreal tiles
|
||||
and were removed. Badges sit on the cached ground floor (validated mesh or DEM,
|
||||
+1.5 m) rather than ground clamping, which re-sampled every badge whenever a
|
||||
tile streamed in; floors resolve before new badges appear (bounded wait), and a
|
||||
badge whose cell has not resolved clamps until it does, then moves in place.
|
||||
badge whose cell has not resolved sits on the terrain (hidden while none is
|
||||
known) until it does, then moves in place.
|
||||
Overlay anchors start on the same floor and write marker properties only on
|
||||
change. Once the camera is still and tiles have settled, each overlay anchor is
|
||||
replaced once by the rendered surface under it (two depth samples per paint,
|
||||
@@ -5595,7 +5598,7 @@ silently demoting every later lookup for the session.
|
||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
A selected mission renders its orbit as four repeating tactical sectors, each containing one prominent cyan dot followed by one hundred thin translucent dashes. The bright dots act as orbit anchors while the subdued dash field remains depth-tested against the globe and is shown only for the selected mission.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring with a low-opacity translucent fill over the sampled photoreal launch-site surface. The single scene primitive is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
Close selected-pad views add one static 500 m-radius cyan launch-zone ring at the terrain height under the pad, drawn over the scene; the contract has no surface fill yet, so the translucent fill is gone. The ring is created only for the visible selected site and is otherwise dormant. It appears during Focus, sufficiently close manual zoom, and the replay countdown, but is suppressed above 120 km camera altitude, beyond 180 km direct camera-to-pad range, for unselected missions, and whenever Space Missions is inactive. Focus establishes a launch-site-centered camera transform once; subsequent manual heading and pitch changes remain centered on that site without an automated per-frame correction. Surface mission markers and labels use an additional conservative globe-limb margin before the exact ellipsoid occluder boundary, preventing near-horizon visibility from alternating between frames.
|
||||
- **AIS vessels**: chevron symbology (naval cyan base, type tints), world-space headings, MMSI-keyed reconciliation (selection survives refreshes; pinned 3 refreshes with STALE marker when absent), detection-overlay integration (`type: 'SEA'`), contextStore registration for voice Q&A. Empty-space clicks, id-less photorealistic-tile picks, and Escape dismiss the vessel card/HUD/context and clear its trail; picks owned by another layer (including `gev-trail:*`) and raw vessel-record picks without a live MMSI key are no-ops for vessel selection. Click and key handlers detach while the layer is disabled and reinstall on enable. Selecting another vessel replaces the selection and trail, and reconciliation clears a trail if its owning vessel is evicted.
|
||||
- **Track trails**: server accumulates per-MMSI ring buffers (`/api/vessels/track?mmsi=`, Float32+Uint32, 64 samples, 30s/25m thinning); aircraft backfill proxies `/api/flights/track` (OAuth, own credit bucket). Military aircraft keep only their locally accumulated trails: `/api/military/track` validates the hex, then answers `503 history_unavailable` without an upstream request.
|
||||
- Shared `src/data/pickRegistry.js` stops the two flight layers' click handlers from fighting over the camera.
|
||||
@@ -6724,7 +6727,7 @@ easier to meet (detection is now on more often), but does not create it.
|
||||
counters, and timing labels.
|
||||
- Voice debug log: `tail -f .gev-logs/realtime-conversations.jsonl` (gitignored).
|
||||
|
||||
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone ground primitive and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
||||
Replay chase-camera updates run in Cesium `preUpdate` before scene traversal, preventing 3D-tile refinement stutter when the mission replay speed is reduced. Replay Ascent first gives the selected launch site a five-second tile-preparation hold before displaying the T-minus countdown. Replay ascent duration is mission-specific: disclosed insertion, SECO, or separation timing is compressed into the replay, while sparse records use reconstructed path length with bounded fallback timing instead of a universal fixed duration. Replay transitions directly from ascent to orbit; stage re-entry/recovery remains static contextual linework and is never a camera-tracked playback phase. The screen-space rocket/thrust symbol renders at 50% of its 92 × 138 px design box, while the separate callout text remains unchanged. Successful/upcoming selected missions show a current orbit marker: green for a reliable TLE match or amber and explicitly estimated when no live match exists. Failed launches show their source status and suppress live/estimated orbit markers and fallback rings. A retained Launch Library orbit is labeled as the planned target, an absent ascent is reported as unavailable, and replay-only controls remain hidden unless the selected mission has a rendered track; authoritative supplied trajectory points remain visible when present. Mission orbit primitives realign by model matrix each tick from the same current-GMST frame as their marker, preventing ring/marker drift; their host annotation reads the position cache updated in that same tick. During depth-dominant ascent segments, the replay rocket retains its last valid path-facing rotation rather than snapping toward the camera. Mission selection and replay overlays never call photoreal `sampleHeight()` from the render loop; the launch-zone rim (a ring at the terrain height under the pad, drawn over the scene; the contract has no surface fill) and precomputed surface-safe replay path avoid remote tile-refinement probes that previously caused a one-second globe texture pulse. The shared host replaces the former mission-label visibility churn and quadratic overlap loop; the layer's remaining frame sweep only culls native point/billboard geometry and refreshes selected UTC copy when its displayed second changes. Replay samples uneven path vertices by cumulative distance and normalizes camera-yaw easing to frame time.
|
||||
|
||||
Orbit replay framing uses one combined bounding sphere for Earth and every sample of the selected orbit. The camera derives its final range from that full envelope, while its look-at target retains a radial bias toward the moving vehicle rather than collapsing onto the singular Earth-center frame. Compact-orbit launchers therefore remain tracked during camera rotation, and highly eccentric transfer orbits still keep both the globe and their distant apogee arc visible. The orbital camera stays on one side of the mission's 3D orbit plane and uses the vehicle radial as visual up, so forward motion remains screen-left through polar/local-heading wraps instead of alternating left and right. The fixed-size cyan orbit dot retains one pixel scale throughout the pullback. While replay owns the camera, the selected launch-site host label is suppressed so it cannot duplicate or overlap the replay vehicle's DOM callout; cancel/completion restores it.
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 4546,
|
||||
"navaraLayerRendererLines": 11106,
|
||||
"cesiumImportFiles": 66,
|
||||
"viewerLines": 2209,
|
||||
"navaraKindChecks": 17,
|
||||
"cesiumLayerRendererLines": 2638,
|
||||
"navaraLayerRendererLines": 8735,
|
||||
"cesiumImportFiles": 65,
|
||||
"viewerLines": 2014,
|
||||
"navaraKindChecks": 15,
|
||||
"engineSniffCalls": 14,
|
||||
"engineExtensionReads": 27,
|
||||
"groundEffectPasses": 9,
|
||||
"engineExtensionReads": 26,
|
||||
"groundEffectPasses": 8,
|
||||
"cesiumSdkLoads": 6,
|
||||
"screenSpaceHandlerFiles": 20,
|
||||
"engineNameLines": 162
|
||||
"screenSpaceHandlerFiles": 15,
|
||||
"engineNameLines": 141
|
||||
},
|
||||
"measuredAt": "after the aircraft, Wildlife and Cockpit displays and engine-only app code, 2026-11-07"
|
||||
"measuredAt": "after the vessels, satellites, launches, ALPR, bikeshare and OSM Places displays, 2026-11-07"
|
||||
}
|
||||
+15
-25
@@ -1231,11 +1231,9 @@
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/app/navaraLayers.test.mjs",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/vessels/render/cesium.js",
|
||||
"src/layers/vessels/render/contract.js",
|
||||
"src/layers/vessels/renderer.test.mjs",
|
||||
"src/layers/vessels/testFixtures.mjs",
|
||||
"src/layers/vessels/render/navara.js",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/layers/shared/render/navaraGroundEffect.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
@@ -1244,18 +1242,10 @@
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/presentation.test.mjs",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.test.mjs",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/bikeshare/render/navara.test.mjs",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/cctv/projectionScheduling.test.mjs",
|
||||
"src/layers/cctv/gizmo.test.mjs",
|
||||
"src/layers/cctv/render/cesiumGizmo.js",
|
||||
@@ -1272,15 +1262,8 @@
|
||||
"src/layers/cctv/lifecycle.test.mjs",
|
||||
"src/layers/cctv/projectionMedia.test.mjs",
|
||||
"src/layers/cctv/ingestion.test.mjs",
|
||||
"src/layers/alpr/render/cesiumPresentation.js",
|
||||
"src/layers/alpr/render/cesium.js",
|
||||
"src/layers/alpr/render/navara.js",
|
||||
"src/layers/alpr/render/navara.test.mjs",
|
||||
"src/layers/alpr/render/navaraOverlay.js",
|
||||
"src/layers/alpr/render/navaraOverlay.test.mjs",
|
||||
"scripts/fixtures/w2/alpr.mjs",
|
||||
"scripts/fixtures/w2/cctv.mjs",
|
||||
"src/layers/alpr/render/cesiumOverlay.js",
|
||||
"src/layers/radio/render/cesium.js",
|
||||
"src/layers/radio/render/navara.js",
|
||||
"src/layers/radio/render/navara.test.mjs",
|
||||
@@ -1418,20 +1401,13 @@
|
||||
"src/bench/w1Feeds.js",
|
||||
"src/bench/satelliteFixture.js",
|
||||
"src/bench/satelliteFixture.test.mjs",
|
||||
"src/layers/satellites/render/cesium.js",
|
||||
"src/celestialRing/render/cesium.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/satellites/render/navara.js",
|
||||
"src/layers/satellites/render/navara.test.mjs",
|
||||
"src/layers/satellites/render/cesium.test.mjs",
|
||||
"src/geo/ellipsoidGeodesic.test.mjs",
|
||||
"src/bench/launchFixture.js",
|
||||
"src/layers/launches/replayClock.test.mjs",
|
||||
"src/geo/boundingSphere.test.mjs",
|
||||
"src/layers/launches/render/cesium.js",
|
||||
"src/layers/launches/launchPadFrame.test.mjs",
|
||||
"src/layers/launches/render/navara.js",
|
||||
"src/layers/launches/render/navara.test.mjs",
|
||||
"src/layers/satellites/interaction.test.mjs",
|
||||
"src/layers/launches/lifecycleSnapshot.test.mjs",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
@@ -1624,5 +1600,19 @@
|
||||
"scripts/qa-contract-suite.mjs",
|
||||
"src/globe/cesium/doubleClick.js",
|
||||
"scripts/engine-coupling-baseline.json",
|
||||
"src/layers/wildlifeSightings/display.js"
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/layers/alpr/render/display.js",
|
||||
"src/layers/alpr/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/display.js",
|
||||
"src/layers/bikeshare/render/display.test.mjs",
|
||||
"src/layers/bikeshare/render/style.js",
|
||||
"src/layers/launches/render/display.js",
|
||||
"src/layers/launches/render/display.test.mjs",
|
||||
"src/layers/osmPlaces/render/display.js",
|
||||
"src/layers/osmPlaces/render/display.test.mjs",
|
||||
"src/layers/satellites/render/display.js",
|
||||
"src/layers/satellites/render/display.test.mjs",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/vessels/render/display.test.mjs",
|
||||
"src/testSupport/vesselViewerRenderer.mjs"
|
||||
]
|
||||
@@ -1737,7 +1737,6 @@
|
||||
"src/i18n/locales/zh-CN.json",
|
||||
"src/i18n/locales/zh-Hant.json",
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/launches/render/cesium.js",
|
||||
"src/geo/",
|
||||
"src/time/clock.js",
|
||||
"src/globe/contract.js",
|
||||
@@ -3426,23 +3425,14 @@
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/layers/alpr/render/cesiumPresentation.js",
|
||||
"src/layers/alpr/render/cesiumOverlay.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/render/cesium.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/launches/render/cesium.js",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/maps/sceneApply.js",
|
||||
"src/layers/transit/render/cesium.js",
|
||||
@@ -3490,15 +3480,9 @@
|
||||
"src/layers/shared/color.js",
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/layers/localAdsb/render/display.js",
|
||||
"src/layers/vessels/render/cesium.js",
|
||||
"src/layers/vessels/render/navara.js",
|
||||
"src/layers/launches/render/navara.js",
|
||||
"src/layers/launches/render/navaraPadGround.js",
|
||||
"src/layers/satellites/render/navara.js",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/celestialRing/render/cesium.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/satellites/render/cesium.js",
|
||||
"src/layers/earthquakes/render/cesium.js",
|
||||
"src/layers/earthquakes/render/navara.js",
|
||||
"src/layers/airQuality/render/navara.js",
|
||||
@@ -3507,7 +3491,6 @@
|
||||
"src/layers/shared/render/navaraPolygons.js",
|
||||
"src/layers/shared/render/navaraZones.js",
|
||||
"src/layers/droneAirspace/render/navara.js",
|
||||
"src/layers/osmPlaces/render/navara.js",
|
||||
"src/layers/earthquakes/render/navaraGeometry.js",
|
||||
"src/layers/earthquakes/render/navaraGrid.js",
|
||||
"src/layers/installations/render/navara.js",
|
||||
@@ -3532,7 +3515,6 @@
|
||||
"src/layers/shared/render/navaraGroundPicks.js",
|
||||
"src/layers/cyclones/render/navara.js",
|
||||
"src/layers/shared/render/groundRing.js",
|
||||
"src/layers/bikeshare/render/navara.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/shared/viewRectangle.js",
|
||||
"src/layers/installations/render/navaraGeometry.js",
|
||||
@@ -3556,8 +3538,6 @@
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/cctv/render/navaraGizmo.js",
|
||||
"src/layers/cctv/render/navaraLine.js",
|
||||
"src/layers/alpr/render/navara.js",
|
||||
"src/layers/alpr/render/navaraOverlay.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
@@ -3617,7 +3597,14 @@
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/globe/cesium/doubleClick.js",
|
||||
"src/layers/wildlifeSightings/display.js"
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/layers/osmPlaces/render/display.js",
|
||||
"src/layers/bikeshare/render/style.js",
|
||||
"src/layers/satellites/render/display.js",
|
||||
"src/layers/launches/render/display.js",
|
||||
"src/layers/bikeshare/render/display.js",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/alpr/render/display.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4272,24 +4259,15 @@
|
||||
"src/data/render/cesiumProjection.js",
|
||||
"src/data/render/cesiumConstants.js",
|
||||
"src/data/render/cesiumTrailRenderer.js",
|
||||
"src/layers/alpr/render/cesiumPresentation.js",
|
||||
"src/layers/alpr/render/cesiumOverlay.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/render/cesium.js",
|
||||
"src/layers/bikeshare/selectionModel.js",
|
||||
"src/layers/bikeshare/presentation.js",
|
||||
"src/layers/bikeshare/render/cesium.js",
|
||||
"src/layers/bikeshare/render/cesium/viewport.js",
|
||||
"src/layers/bikeshare/render/cesium/lifecycle.js",
|
||||
"src/layers/bikeshare/render/cesium/rendering.js",
|
||||
"src/layers/bikeshare/stationPoints.js",
|
||||
"src/layers/bikeshare/render/cesium/selection.js",
|
||||
"src/layers/installations/render/cesium/lifecycle.js",
|
||||
"src/layers/installations/render/cesium.js",
|
||||
"src/layers/installations/render/cesium/rendering.js",
|
||||
"src/layers/installations/render/cesium/selection.js",
|
||||
"src/layers/launches/render/cesium.js",
|
||||
"src/layers/vessels/icons.js",
|
||||
"src/layers/transit/render/cesium.js",
|
||||
"src/data/render/cesiumContactTrailRenderer.js",
|
||||
@@ -4309,11 +4287,9 @@
|
||||
"src/layers/aircraft/qaDrawn.js",
|
||||
"src/layers/localAdsb/render/display.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/vessels/render/cesium.js",
|
||||
"src/celestialRing/render/cesium.js",
|
||||
"src/celestialRing/view.js",
|
||||
"src/layers/shared/render/engineView.js",
|
||||
"src/layers/satellites/render/cesium.js",
|
||||
"src/layers/earthquakes/render/cesium.js",
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/firms/render/cesium.js",
|
||||
@@ -4352,7 +4328,16 @@
|
||||
"src/overlays/worldOverlayChrome.js",
|
||||
"src/layers/wildlifeSightings/photoText.js",
|
||||
"src/layers/wildlifeSightings/performance.js",
|
||||
"src/layers/wildlifeSightings/display.js"
|
||||
"src/layers/wildlifeSightings/display.js",
|
||||
"src/layers/satellites/render/display.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/osmPlaces/render/display.js",
|
||||
"src/layers/vessels/render/display.js",
|
||||
"src/layers/launches/render/display.js",
|
||||
"src/globe/cpuPicks.js",
|
||||
"src/layers/bikeshare/render/style.js",
|
||||
"src/layers/bikeshare/render/display.js",
|
||||
"src/layers/alpr/render/display.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createVesselLayer } from '../../layers/vessels/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
import * as trails from '../../data/trailRenderer.js';
|
||||
import * as labels from '../../data/detectionDraw.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlay from '../../overlays/worldOverlay.js';
|
||||
@@ -11,19 +10,16 @@ import * as worldFocus from '../../worldFocus.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as selection from '../../data/selectionDispatcher.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
|
||||
import * as readouts from '../../hudReadouts.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationVessels({
|
||||
source,
|
||||
options = {},
|
||||
// Test seam: the renderer factory (default: the engine's, from the registry).
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the vessels display on every engine; none without an engine).
|
||||
renderer = {
|
||||
create: ({ engine = null, ...rendererOptions }) =>
|
||||
engine
|
||||
? rendererFor('ais-live-vessels', engine, rendererOptions)
|
||||
: createVesselsCesiumRenderer(null, rendererOptions),
|
||||
create: ({ engine = null } = {}) => rendererFor('ais-live-vessels', engine),
|
||||
},
|
||||
}) {
|
||||
return createVesselLayer({
|
||||
@@ -31,7 +27,6 @@ export function createApplicationVessels({
|
||||
options,
|
||||
services: {
|
||||
context,
|
||||
trails,
|
||||
labels,
|
||||
picking,
|
||||
overlay,
|
||||
@@ -41,8 +36,7 @@ export function createApplicationVessels({
|
||||
worldFocus,
|
||||
render,
|
||||
selection,
|
||||
// The engine's vessel renderer from the registry (render/cesium.js on
|
||||
// Cesium); a bare viewer without an engine (unit tests) draws on Cesium.
|
||||
// The engine's vessel display from the registry (render/display.js).
|
||||
renderer,
|
||||
readouts,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createAlprCesiumRenderer } from '../../layers/alpr/render/cesium.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
import * as credits from '../../data/dataCredits.js';
|
||||
import { createAlprCamerasLayer } from '../../layers/alpr/index.js';
|
||||
@@ -9,14 +8,16 @@ import { registerWorldOverlayPaintLane } from '../../overlays/worldOverlay.js';
|
||||
import { keyholeLabelAlphaFromGeometry } from '../../celestialRing.js';
|
||||
import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationAlpr({
|
||||
surface,
|
||||
source,
|
||||
rendererFactory = ({ engine, ...context }) =>
|
||||
engine?.kind === 'navara'
|
||||
? rendererFor('alpr-cameras', engine, context)
|
||||
: createAlprCesiumRenderer(context),
|
||||
rendererFor('alpr-cameras', engine, context),
|
||||
}) {
|
||||
const { groundFloor } = surface;
|
||||
return createAlprCamerasLayer({
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { createBikeshareLayer } from '../../layers/bikeshare/index.js';
|
||||
import { createBikeshareCesiumRenderer } from '../../layers/bikeshare/render/cesium.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
import * as render from '../../renderGovernor.js';
|
||||
import * as sprites from '../../data/spriteOrder.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationBikeshare({ source }) {
|
||||
return createBikeshareLayer({
|
||||
source,
|
||||
rendererFactory: ({ engine, ...context }) =>
|
||||
engine?.kind === 'navara'
|
||||
? rendererFor('bikeshare', engine, context)
|
||||
: createBikeshareCesiumRenderer({ ...context, engine }),
|
||||
services: { render, sprites, picking, overlays },
|
||||
rendererFor('bikeshare', engine, context),
|
||||
services: { render, picking, overlays },
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,14 @@
|
||||
import {
|
||||
createOsmPlacesLayer,
|
||||
createOsmPlacesCesiumRenderer,
|
||||
} from '../../layers/osmPlaces/index.js';
|
||||
import { createOsmPlacesLayer } from '../../layers/osmPlaces/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
|
||||
/** Wire OSM Places labels to the application overlay host. */
|
||||
/**
|
||||
* Wire OSM Places labels to the application overlay host; the points are the
|
||||
* engine's renderer from the registry (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationOsmPlaces() {
|
||||
return createOsmPlacesLayer({
|
||||
overlayHost,
|
||||
createRenderer: ({ viewer, engine }) =>
|
||||
engine
|
||||
? rendererFor('osm-places', engine, { viewer })
|
||||
: createOsmPlacesCesiumRenderer({ viewer }),
|
||||
createRenderer: ({ engine }) => rendererFor('osm-places', engine),
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
|
||||
import { createDroneAirspaceCesiumRenderer } from '../../layers/droneAirspace/index.js';
|
||||
import { createOsmPlacesCesiumRenderer } from '../../layers/osmPlaces/index.js';
|
||||
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
||||
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
|
||||
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
|
||||
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
|
||||
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
|
||||
// Which engine draws which layer: the layer renderer registry and the layer
|
||||
// availability API built on it.
|
||||
//
|
||||
@@ -36,8 +34,12 @@ import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { createFlightsDisplay } from '../../layers/flights/render/display.js';
|
||||
import { createMilitaryDisplay } from '../../layers/military/render/display.js';
|
||||
import { createLocalAdsbDisplay } from '../../layers/localAdsb/render/display.js';
|
||||
import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js';
|
||||
import { createVesselsCesiumRenderer } from '../../layers/vessels/render/cesium.js';
|
||||
import { createSatellitesDisplay } from '../../layers/satellites/render/display.js';
|
||||
import { createVesselsDisplay } from '../../layers/vessels/render/display.js';
|
||||
import { createLaunchesDisplay } from '../../layers/launches/render/display.js';
|
||||
import { createOsmPlacesDisplay } from '../../layers/osmPlaces/render/display.js';
|
||||
import { createAlprDisplay } from '../../layers/alpr/render/display.js';
|
||||
import { createBikeshareDisplay } from '../../layers/bikeshare/render/display.js';
|
||||
|
||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||
|
||||
@@ -104,14 +106,16 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
createPerimetersCesiumRenderer(options),
|
||||
'drone-airspace': (_engine, options) =>
|
||||
createDroneAirspaceCesiumRenderer(options),
|
||||
'osm-places': (_engine, options) => createOsmPlacesCesiumRenderer(options),
|
||||
'osm-places': createOsmPlacesDisplay,
|
||||
'alpr-cameras': createAlprDisplay,
|
||||
bikeshare: createBikeshareDisplay,
|
||||
'local-firms': createFirmsCesiumRenderer,
|
||||
'rocket-launches': createLaunchesCesiumRenderer,
|
||||
satellites: createSatellitesCesiumRenderer,
|
||||
'rocket-launches': createLaunchesDisplay,
|
||||
satellites: createSatellitesDisplay,
|
||||
flights: createFlightsDisplay,
|
||||
military: createMilitaryDisplay,
|
||||
'local-adsb': createLocalAdsbDisplay,
|
||||
'ais-live-vessels': createVesselsCesiumRenderer,
|
||||
'ais-live-vessels': createVesselsDisplay,
|
||||
});
|
||||
|
||||
// The other application layers still draw themselves on Cesium.
|
||||
|
||||
@@ -58,7 +58,7 @@ test('the default registry: every layer on Cesium, none yet on Navara', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('the aircraft, vessels, satellites, launches and earthquakes have extracted Cesium renderers', () => {
|
||||
test('the layers with extracted renderers', () => {
|
||||
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
@@ -66,6 +66,8 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
|
||||
'fire-perimeters',
|
||||
'drone-airspace',
|
||||
'osm-places',
|
||||
'alpr-cameras',
|
||||
'bikeshare',
|
||||
'local-firms',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
@@ -74,22 +76,19 @@ test('the aircraft, vessels, satellites, launches and earthquakes have extracted
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
]);
|
||||
const vessels = rendererFor(
|
||||
'ais-live-vessels',
|
||||
{ kind: 'cesium' },
|
||||
{
|
||||
viewer: { scene: {}, camera: {} },
|
||||
},
|
||||
);
|
||||
assert.equal(vessels.kind, 'cesium');
|
||||
assert.equal(rendererFor('satellites', { kind: 'cesium' }).kind, 'cesium');
|
||||
});
|
||||
|
||||
test('the aircraft family draws through one display, the same factory on every engine', async () => {
|
||||
test('the layers drawn through one display get the same factory on every engine', async () => {
|
||||
const displays = {
|
||||
flights: ['flights', 'createFlightsDisplay'],
|
||||
military: ['military', 'createMilitaryDisplay'],
|
||||
'local-adsb': ['localAdsb', 'createLocalAdsbDisplay'],
|
||||
'ais-live-vessels': ['vessels', 'createVesselsDisplay'],
|
||||
satellites: ['satellites', 'createSatellitesDisplay'],
|
||||
'rocket-launches': ['launches', 'createLaunchesDisplay'],
|
||||
'osm-places': ['osmPlaces', 'createOsmPlacesDisplay'],
|
||||
'alpr-cameras': ['alpr', 'createAlprDisplay'],
|
||||
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
|
||||
};
|
||||
// The Navara mode registers the same modules' factories.
|
||||
const navaraMode = readFileSync(
|
||||
@@ -109,6 +108,9 @@ test('the aircraft family draws through one display, the same factory on every e
|
||||
),
|
||||
id,
|
||||
);
|
||||
// Each display's own tests build it with its services; the aircraft
|
||||
// family needs none beyond an atlas.
|
||||
if (!['flights', 'military', 'local-adsb'].includes(id)) continue;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
const engine = createFakeEngine({ kind });
|
||||
const display = factory(engine, { drawAtlas });
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { rendererFor } from './renderers.js';
|
||||
import { createLaunchesCesiumRenderer } from '../../layers/launches/render/cesium.js';
|
||||
import { createRocketLaunchesLayer } from '../../layers/launches/index.js';
|
||||
import * as geometry from '../../celestialRing.js';
|
||||
import * as overlays from '../../overlays/worldOverlay.js';
|
||||
@@ -9,11 +8,10 @@ import * as render from '../../renderGovernor.js';
|
||||
export function createApplicationLaunches({
|
||||
source,
|
||||
satellites,
|
||||
// Test seam: the display factory (default: the engine's, from the
|
||||
// registry: render/display.js on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null, ...options }) =>
|
||||
engine
|
||||
? rendererFor('rocket-launches', engine, options)
|
||||
: createLaunchesCesiumRenderer(null, options),
|
||||
create: ({ engine }) => rendererFor('rocket-launches', engine),
|
||||
},
|
||||
}) {
|
||||
return createRocketLaunchesLayer({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { rendererFor } from './renderers.js';
|
||||
import { createSatellitesCesiumRenderer } from '../../layers/satellites/render/cesium.js';
|
||||
import { createSatellitesLayer } from '../../layers/satellites/index.js';
|
||||
import * as picking from '../../data/pickRegistry.js';
|
||||
import * as focus from '../../data/focusDeemphasis.js';
|
||||
@@ -12,11 +11,10 @@ import * as layerState from '../../data/layerState.js';
|
||||
/** Construct one layer using the application scene owners and a supplied source. */
|
||||
export function createApplicationSatellites({
|
||||
source,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the satellites display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null, ...options }) =>
|
||||
engine
|
||||
? rendererFor('satellites', engine, options)
|
||||
: createSatellitesCesiumRenderer(null, options),
|
||||
create: ({ engine = null } = {}) => rendererFor('satellites', engine),
|
||||
},
|
||||
}) {
|
||||
return createSatellitesLayer({
|
||||
|
||||
+13
-19
@@ -122,14 +122,13 @@ export async function registerNavaraLayerRenderers() {
|
||||
});
|
||||
},
|
||||
'alpr-cameras': async () =>
|
||||
(await import('../layers/alpr/render/navara.js'))
|
||||
.createAlprNavaraRenderer,
|
||||
(await import('../layers/alpr/render/display.js')).createAlprDisplay,
|
||||
radio: async () =>
|
||||
(await import('../layers/radio/render/navara.js'))
|
||||
.createRadioNavaraRenderer,
|
||||
bikeshare: async () =>
|
||||
(await import('../layers/bikeshare/render/navara.js'))
|
||||
.createBikeshareNavaraRenderer,
|
||||
(await import('../layers/bikeshare/render/display.js'))
|
||||
.createBikeshareDisplay,
|
||||
...Object.fromEntries(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) => [
|
||||
id,
|
||||
@@ -243,8 +242,8 @@ export async function registerNavaraLayerRenderers() {
|
||||
});
|
||||
},
|
||||
'osm-places': async () =>
|
||||
(await import('../layers/osmPlaces/render/navara.js'))
|
||||
.createOsmPlacesNavaraRenderer,
|
||||
(await import('../layers/osmPlaces/render/display.js'))
|
||||
.createOsmPlacesDisplay,
|
||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||
'wildlife-sightings': async () => () => null,
|
||||
'drone-airspace': async () => {
|
||||
@@ -280,18 +279,13 @@ export async function registerNavaraLayerRenderers() {
|
||||
...engine.extensions.navara,
|
||||
});
|
||||
},
|
||||
'rocket-launches': async () => {
|
||||
const { createLaunchesNavaraRenderer } =
|
||||
await import('../layers/launches/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createLaunchesNavaraRenderer(engine, {
|
||||
...options,
|
||||
...engine.extensions.navara,
|
||||
});
|
||||
},
|
||||
// The same displays as on Cesium (renderers.js).
|
||||
'rocket-launches': async () =>
|
||||
(await import('../layers/launches/render/display.js'))
|
||||
.createLaunchesDisplay,
|
||||
satellites: async () =>
|
||||
(await import('../layers/satellites/render/navara.js'))
|
||||
.createSatellitesNavaraRenderer,
|
||||
(await import('../layers/satellites/render/display.js'))
|
||||
.createSatellitesDisplay,
|
||||
// The aircraft family: the same displays as on Cesium (renderers.js).
|
||||
flights: async () =>
|
||||
(await import('../layers/flights/render/display.js'))
|
||||
@@ -303,8 +297,8 @@ export async function registerNavaraLayerRenderers() {
|
||||
(await import('../layers/localAdsb/render/display.js'))
|
||||
.createLocalAdsbDisplay,
|
||||
'ais-live-vessels': async () =>
|
||||
(await import('../layers/vessels/render/navara.js'))
|
||||
.createVesselsNavaraRenderer,
|
||||
(await import('../layers/vessels/render/display.js'))
|
||||
.createVesselsDisplay,
|
||||
// The open map overlays drape themselves through engine.maps.addOverlay.
|
||||
'sea-marks': async () => () => null,
|
||||
'ski-pistes': async () => () => null,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
reduceVesselSelection,
|
||||
vesselDatumHeightM,
|
||||
_bindVesselInteractionForTest,
|
||||
_setVesselStateForTest,
|
||||
_setVesselStateForTest as setVesselStateForTest,
|
||||
_reconcileVesselsForTest,
|
||||
_applyAisFeedSnapshotForTest,
|
||||
_loadLivePositionsForTest,
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
presentSelectedVessel,
|
||||
subscribeSelectedVessel,
|
||||
} from '../hudReadouts.js';
|
||||
import { vesselViewerRenderer } from '../testSupport/vesselViewerRenderer.mjs';
|
||||
|
||||
// The layer's state over the test's viewer, drawn by the Cesium-shaped test
|
||||
// renderer (its billboard collection, projection and trackedEntityChanged).
|
||||
const _setVesselStateForTest = (options = {}) =>
|
||||
setVesselStateForTest({ renderer: vesselViewerRenderer, ...options });
|
||||
|
||||
test('open feed with vessels is healthy (null)', () => {
|
||||
assert.equal(deriveAisFeedError({ status: 'open', lastMessageAt: 1, error: null }, 42), null);
|
||||
@@ -944,6 +950,16 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
globalThis.document = {
|
||||
getElementById: () => null,
|
||||
};
|
||||
// The test renderer draws in no scene; the harness viewer's
|
||||
// trackedEntityChanged is its follow signal.
|
||||
const followViewer = {
|
||||
get trackedEntity() {
|
||||
return viewer.trackedEntity;
|
||||
},
|
||||
trackedEntityChanged,
|
||||
};
|
||||
const renderer = ({ billboardCollection }) =>
|
||||
vesselViewerRenderer({ viewer: followViewer, billboardCollection });
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
@@ -952,6 +968,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory,
|
||||
interactionKeyTarget: keyTarget,
|
||||
renderer,
|
||||
...stateOverrides,
|
||||
});
|
||||
_bindVesselInteractionForTest(viewer, handler, keyTarget);
|
||||
@@ -966,6 +983,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
windowTarget,
|
||||
reinstalledHandlers,
|
||||
interactionHandlerFactory,
|
||||
renderer,
|
||||
cleanup() {
|
||||
stopReadout();
|
||||
aisLiveVesselsLayer.destroy();
|
||||
@@ -1201,6 +1219,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||
interactionKeyTarget: harness.keyTarget,
|
||||
renderer: harness.renderer,
|
||||
});
|
||||
disabledClick({ position: { x: 10, y: 20 } });
|
||||
disabledKeydown({ key: 'Escape' });
|
||||
|
||||
+254
-258
@@ -1,9 +1,11 @@
|
||||
import { createAlprCesiumRenderer } from '../layers/alpr/render/cesium.js';
|
||||
// src/data/alprCameras.test.mjs
|
||||
// Pure helpers and actual layer lifecycle with a minimal viewer/input harness.
|
||||
// Pure helpers and the actual layer lifecycle, drawn by its display on the
|
||||
// fake engine.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createAlprDisplay } from '../layers/alpr/render/display.js';
|
||||
import alprCamerasLayer, {
|
||||
createAlprCamerasLayer,
|
||||
configureAlprSource,
|
||||
@@ -252,8 +254,8 @@ const cameraResponse = (elements = [cameraNode()], stale = false) => ({
|
||||
json: async () => ({ elements }),
|
||||
});
|
||||
|
||||
// Real layer, Cesium entities and context store. Only the DOM, upstream fetch,
|
||||
// camera rectangle and input event delivery are substituted (no GL in Node).
|
||||
// Real layer, display and context store on the fake engine. Only the DOM,
|
||||
// upstream fetch and the view's centre and range are substituted.
|
||||
function cameraHarness(layer = alprCamerasLayer) {
|
||||
const originals = {
|
||||
document: globalThis.document,
|
||||
@@ -308,80 +310,88 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
},
|
||||
});
|
||||
let box = { south: 30.26, west: -97.75, north: 30.28, east: -97.73 };
|
||||
let source, click, picked;
|
||||
const credits = new Set();
|
||||
const viewer = {
|
||||
creditDisplay: {
|
||||
addStaticCredit: (credit) => credits.add(credit),
|
||||
removeStaticCredit: (credit) => credits.delete(credit),
|
||||
},
|
||||
camera: {
|
||||
moveEnd: new Cesium.Event(),
|
||||
computeViewRectangle: () =>
|
||||
box
|
||||
? Cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
)
|
||||
: null,
|
||||
},
|
||||
scene: {
|
||||
canvas: { addEventListener() {}, removeEventListener() {} },
|
||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
||||
postRender: new Cesium.Event(),
|
||||
pick: () => picked,
|
||||
},
|
||||
dataSources: {
|
||||
add: (value) => {
|
||||
source = value;
|
||||
},
|
||||
remove() {},
|
||||
},
|
||||
const engine = createFakeEngine();
|
||||
// The view: the ground point at the canvas centre and the camera straight
|
||||
// above it.
|
||||
const focus = (latitude, longitude, range) => {
|
||||
if (latitude == null) {
|
||||
engine.fake.script('pickEllipsoid', undefined);
|
||||
return;
|
||||
}
|
||||
engine.fake.setPose({
|
||||
latitude: geo.math.toRadians(latitude),
|
||||
longitude: geo.math.toRadians(longitude),
|
||||
height: range,
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
});
|
||||
engine.fake.script(
|
||||
'pickEllipsoid',
|
||||
geo.cartesian3.fromDegrees(longitude, latitude),
|
||||
);
|
||||
};
|
||||
const setInput = Cesium.ScreenSpaceEventHandler.prototype.setInputAction;
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = function (
|
||||
fn,
|
||||
type,
|
||||
modifier,
|
||||
) {
|
||||
if (type === Cesium.ScreenSpaceEventType.LEFT_CLICK) click = fn;
|
||||
return setInput.call(this, fn, type, modifier);
|
||||
// The camera range whose nearby box (alprFocusBox) spans `box`.
|
||||
const setBox = (box) => {
|
||||
if (!box) return focus(null);
|
||||
const east = box.east < box.west ? box.east + 360 : box.east;
|
||||
const latitude = (box.south + box.north) / 2;
|
||||
const longitude = (((box.west + east) / 2 + 540) % 360) - 180;
|
||||
const halfSpan = Math.max(
|
||||
(box.north - box.south) / 2,
|
||||
((east - box.west) / 2) * Math.cos(geo.math.toRadians(latitude)),
|
||||
);
|
||||
focus(latitude, longitude, (halfSpan * 111000) / 2);
|
||||
};
|
||||
try {
|
||||
layer.init(viewer);
|
||||
} finally {
|
||||
Cesium.ScreenSpaceEventHandler.prototype.setInputAction = setInput;
|
||||
}
|
||||
setBox({ south: 30.26, west: -97.75, north: 30.28, east: -97.73 });
|
||||
let picked;
|
||||
engine.fake.script('pick', () => picked);
|
||||
const markerSet = () => engine.fake.moverSet('alpr-cameras');
|
||||
const selectedSet = () => engine.fake.moverSet('alpr-selected');
|
||||
const shownIds = () => {
|
||||
const set = markerSet();
|
||||
return set ? [...set.store.pickIds].slice(0, set.store.count) : [];
|
||||
};
|
||||
layer.init(null, { engine });
|
||||
layer.enable();
|
||||
return {
|
||||
viewer,
|
||||
engine,
|
||||
requests,
|
||||
credits,
|
||||
get source() {
|
||||
return source;
|
||||
},
|
||||
credits: engine.fake.credits,
|
||||
focus,
|
||||
setFetch(fn) {
|
||||
response = fn;
|
||||
},
|
||||
setBox(value) {
|
||||
box = value;
|
||||
},
|
||||
setBox,
|
||||
expire() {
|
||||
now += QUERY_REUSE_MS + 1;
|
||||
},
|
||||
advance(ms) {
|
||||
now += ms;
|
||||
},
|
||||
click(id) {
|
||||
const entity = typeof id === 'string' ? source.entities.getById(id) : id;
|
||||
picked = entity ? { id: entity } : undefined;
|
||||
click({ position: new Cesium.Cartesian2() });
|
||||
/** The ids of the displayed badges, in draw order. */
|
||||
shownIds,
|
||||
/** A displayed camera's marker handle (its entity context). */
|
||||
marker: (id) => getContextStore().entities.get(id)?.entity,
|
||||
/** The size a camera's badge draws at (0 when hidden). */
|
||||
badgeSize: (id) => {
|
||||
const chosen = selectedSet()?.store;
|
||||
if (chosen?.count && chosen.pickIds[0] === id && chosen.shows[0])
|
||||
return selectedSet().options.sprite.sizePx;
|
||||
const { store, options } = markerSet();
|
||||
const index = shownIds().indexOf(id);
|
||||
return index >= 0 && store.shows[index] ? options.sprite.sizePx : 0;
|
||||
},
|
||||
/** A click on a camera's badge (id) or on the map (null). */
|
||||
click: (id) => {
|
||||
picked =
|
||||
typeof id === 'string'
|
||||
? { id, owner: 'alpr-cameras', instance: shownIds().indexOf(id) }
|
||||
: undefined;
|
||||
engine.fake.input('click', { position: { x: 1, y: 1 } });
|
||||
},
|
||||
restore() {
|
||||
layer.destroy(viewer);
|
||||
layer.destroy();
|
||||
Date.now = originals.now;
|
||||
for (const name of ['window', 'document', 'fetch']) {
|
||||
if (originals[name] === undefined) delete globalThis[name];
|
||||
@@ -391,24 +401,32 @@ function cameraHarness(layer = alprCamerasLayer) {
|
||||
};
|
||||
}
|
||||
|
||||
/** A renderer factory that keeps the display it made, for its markers. */
|
||||
function trackedDisplay() {
|
||||
const made = { display: null };
|
||||
made.factory = ({ engine, ...context }) =>
|
||||
(made.display = createAlprDisplay(engine, context));
|
||||
return made;
|
||||
}
|
||||
|
||||
test('OSM attribution stays inline while ALPR displays and follows toggles', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
assert.equal(h.credits.size, 0);
|
||||
assert.equal(h.credits.length, 0);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
assert.equal(h.credits.length, 1);
|
||||
const [credit] = h.credits;
|
||||
assert.equal(credit.showOnScreen, true);
|
||||
assert.equal(credit.onScreen, true);
|
||||
assert.match(credit.html, />© OpenStreetMap</);
|
||||
assert.doesNotMatch(credit.html, /OpenMapTiles|Map and place data/);
|
||||
t.mock.timers.tick(6000);
|
||||
assert.equal(h.credits.size, 1);
|
||||
assert.equal(h.credits.length, 1);
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(h.credits.size, 0);
|
||||
assert.equal(h.credits.length, 0);
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.credits.size, 1);
|
||||
assert.equal(h.credits.length, 1);
|
||||
assert.match(
|
||||
DATA_CREDITS.find((entry) => entry.key === 'openstreetmap').html,
|
||||
/OpenStreetMap contributors.*ODbL/,
|
||||
@@ -441,15 +459,16 @@ test('all manufacturers share one ALPR title and color; only supplied metadata a
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
const entities = h.source.entities.values;
|
||||
const entities = h.shownIds().map(h.marker);
|
||||
assert.equal(alprCamerasLayer.name, 'ALPR Cameras');
|
||||
assert.deepEqual(
|
||||
entities.map((entity) => entity.gevLabelModel.title),
|
||||
['ALPR-0042', 'ALPR-0043', 'ALPR-0044'],
|
||||
);
|
||||
assert.equal(
|
||||
new Set(entities.map((entity) => entity.billboard.image.getValue())).size,
|
||||
1,
|
||||
assert.deepEqual(
|
||||
h.shownIds().map(h.badgeSize),
|
||||
[38, 38, 38],
|
||||
'one badge for every manufacturer',
|
||||
);
|
||||
assert.deepEqual(entities[0].gevLabelModel.details, [
|
||||
'OSM MAPPED',
|
||||
@@ -487,7 +506,7 @@ test('the camera label stays English data in a translated interface', async () =
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click(h.source.entities.values[0].id);
|
||||
h.click(h.shownIds()[0]);
|
||||
assert.equal(getSelectedEntityContext().label, 'ALPR camera');
|
||||
assert.equal(
|
||||
shownEntityLabel(getSelectedEntityContext().label),
|
||||
@@ -524,10 +543,7 @@ test('clicking the selected camera or empty map deselects, including after refre
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
||||
38,
|
||||
);
|
||||
assert.equal(h.badgeSize('alpr:42'), 38);
|
||||
}
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -583,10 +599,7 @@ test('disable removes contexts and highlight; cache reuse after re-enable does n
|
||||
alprCamerasLayer.enable();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42').billboard.width.getValue(),
|
||||
38,
|
||||
);
|
||||
assert.equal(h.badgeSize('alpr:42'), 38);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -675,7 +688,7 @@ test('duplicate nodes render once with a count that agrees with the entity set',
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'ready');
|
||||
assert.equal(alprCamerasLayer.getStats().count, 1);
|
||||
assert.equal(h.source.entities.values.length, 1);
|
||||
assert.equal(h.shownIds().length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -691,7 +704,7 @@ test('zooming out hides local markers, releases selection and makes no unbounded
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(alprCamerasLayer.getStats().count, 0);
|
||||
assert.equal(getSelectedEntityContext(), null);
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
assert.equal(h.shownIds().length, 0);
|
||||
assert.equal(h.requests.length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -713,7 +726,7 @@ test('state, country and dateline views never request data; returning to the cit
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.requests.length, 0, 'fresh wide view must not query');
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(h.credits.size, 0, 'no data presentation has occurred');
|
||||
assert.equal(h.credits.length, 0, 'no data presentation has occurred');
|
||||
}
|
||||
h.setBox(city);
|
||||
await alprCamerasLayer.update();
|
||||
@@ -747,13 +760,11 @@ test('nearby camera moves reuse the snapped query but count and register only in
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().count, 1);
|
||||
h.setBox({ south: 30.26, west: -97.72, north: 30.28, east: -97.7 });
|
||||
// Still inside the snapped query box (east of it at -97.70).
|
||||
h.setBox({ south: 30.26, west: -97.722, north: 30.28, east: -97.702 });
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.requests.length, 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
assert.deepEqual(h.shownIds(), ['alpr:43']);
|
||||
assert.deepEqual([...getContextStore().entities.keys()], ['alpr:43']);
|
||||
h.expire();
|
||||
await alprCamerasLayer.update();
|
||||
@@ -797,12 +808,15 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
let samples = 0;
|
||||
h.viewer.scene.sampleHeightSupported = true;
|
||||
h.viewer.scene.sampleHeight = (point, excluded) => {
|
||||
h.engine.fake.script('sampleHeight', (point, excluded) => {
|
||||
samples += 1;
|
||||
assert.equal(excluded[0].id, 'alpr:42');
|
||||
assert.deepEqual(
|
||||
excluded.map((set) => set.id),
|
||||
['alpr-cameras', 'alpr-selected'],
|
||||
'the badges are not the ground',
|
||||
);
|
||||
return 187;
|
||||
};
|
||||
});
|
||||
h.setFetch(async () =>
|
||||
cameraResponse([
|
||||
cameraNode(42, {
|
||||
@@ -812,25 +826,21 @@ test('selected readout samples the rendered ground, not zero elevation or the di
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const anchor = Cesium.Cartographic.fromCartesian(
|
||||
entity.gevDisplayPosition(),
|
||||
);
|
||||
const entity = h.marker('alpr:42');
|
||||
const anchor = geo.cartographic.fromCartesian(entity.gevDisplayPosition());
|
||||
assert.ok(Math.abs(anchor.height - 187) < 0.001);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(anchor.longitude) + 97.7431) < 1e-7,
|
||||
);
|
||||
assert.ok(Math.abs(geo.math.toDegrees(anchor.longitude) + 97.7431) < 1e-7);
|
||||
for (let i = 0; i < 60; i++) {
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
h.engine.fake.frame();
|
||||
entity.gevDisplayPosition();
|
||||
}
|
||||
assert.equal(samples, 1, 'no per-frame raycasts');
|
||||
h.advance(1000);
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
h.engine.fake.frame();
|
||||
assert.equal(samples, 2);
|
||||
h.click(null);
|
||||
h.advance(1000);
|
||||
h.viewer.scene.postRender.raiseEvent();
|
||||
h.engine.fake.frame();
|
||||
assert.equal(samples, 2, 'no terrain sampling without a selection');
|
||||
} finally {
|
||||
h.restore();
|
||||
@@ -879,8 +889,9 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
},
|
||||
};
|
||||
let resolveFirst, firstSignal;
|
||||
const shownB = trackedDisplay();
|
||||
const first = createAlprCamerasLayer({
|
||||
rendererFactory: createAlprCesiumRenderer,
|
||||
rendererFactory: trackedDisplay().factory,
|
||||
services,
|
||||
source: {
|
||||
fetch: (_box, signal) => {
|
||||
@@ -892,7 +903,7 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
},
|
||||
});
|
||||
const second = createAlprCamerasLayer({
|
||||
rendererFactory: createAlprCesiumRenderer,
|
||||
rendererFactory: shownB.factory,
|
||||
services,
|
||||
source: {
|
||||
label: 'Test camera directory',
|
||||
@@ -921,22 +932,24 @@ test('two factories keep their requests, records and destruction independent', a
|
||||
await pending;
|
||||
assert.equal(first.getStats().count, 0);
|
||||
assert.equal(second.getStats().count, 1);
|
||||
assert.equal(b.source.entities.values[0].id, 'directory:99');
|
||||
const [marker] = shownB.display.markers();
|
||||
assert.equal(marker.id, 'directory:99');
|
||||
assert.equal(second.source, 'Test camera directory');
|
||||
assert.ok(
|
||||
b.source.entities.values[0].gevLabelModel.details.includes(
|
||||
'Source: Test directory',
|
||||
),
|
||||
assert.ok(marker.gevLabelModel.details.includes('Source: Test directory'));
|
||||
assert.equal(a.engine.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(a.engine.frames.preRender.numberOfListeners, 0);
|
||||
assert.deepEqual(
|
||||
a.engine.movers.list(),
|
||||
[],
|
||||
'the destroyed layer leaves no sets',
|
||||
);
|
||||
assert.equal(a.viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(a.viewer.scene.postRender.numberOfListeners, 0);
|
||||
} finally {
|
||||
b.restore();
|
||||
a.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('markers sit on the cached floor; unknown floors clamp only until they resolve', async () => {
|
||||
test('markers sit on the cached floor; unknown floors wait on the terrain until they resolve', async () => {
|
||||
// Ground clamping re-samples every marker whenever a photoreal tile streams
|
||||
// in; a whole-city view (about 1,000 cameras) stuttered while zooming.
|
||||
const floors = new Map([['30.2672', 150]]);
|
||||
@@ -965,8 +978,9 @@ test('markers sit on the cached floor; unknown floors clamp only until they reso
|
||||
selectEntityContext() {},
|
||||
},
|
||||
};
|
||||
const shown = trackedDisplay();
|
||||
const layer = createAlprCamerasLayer({
|
||||
rendererFactory: createAlprCesiumRenderer,
|
||||
rendererFactory: shown.factory,
|
||||
services,
|
||||
source: {
|
||||
fetch: async () => ({
|
||||
@@ -980,42 +994,39 @@ test('markers sit on the cached floor; unknown floors clamp only until they reso
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
h.engine.fake.script('terrainHeight', 140);
|
||||
const marker = (id) => shown.display.markers().find((m) => m.id === id);
|
||||
const height = (m) => geo.cartographic.fromCartesian(m.position).height;
|
||||
try {
|
||||
await layer.update();
|
||||
const known = h.source.entities.getById('directory:1');
|
||||
const unknown = h.source.entities.getById('directory:2');
|
||||
assert.equal(
|
||||
known.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(known.position.getValue()).height -
|
||||
151.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
);
|
||||
const known = marker('directory:1');
|
||||
const unknown = marker('directory:2');
|
||||
assert.equal(known.clamped, false);
|
||||
assert.ok(Math.abs(height(known) - 151.5) < 0.01);
|
||||
assert.equal(unknown.clamped, true);
|
||||
assert.ok(Math.abs(height(unknown) - 141.5) < 0.01, 'on the terrain');
|
||||
assert.equal(resolveCalls, 1);
|
||||
releaseResolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(unknown.clamped, false);
|
||||
assert.ok(Math.abs(height(unknown) - 161.5) < 0.01);
|
||||
assert.equal(
|
||||
unknown.billboard.heightReference.getValue(),
|
||||
Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(unknown.position.getValue()).height -
|
||||
161.5,
|
||||
) < 0.01,
|
||||
);
|
||||
assert.equal(
|
||||
h.source.entities.getById('directory:2'),
|
||||
marker('directory:2'),
|
||||
unknown,
|
||||
'placed in place, not rebuilt',
|
||||
);
|
||||
const { store } = h.engine.fake.moverSet('alpr-cameras');
|
||||
const index = h.shownIds().indexOf('directory:2');
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
geo.cartographic.fromCartesian({
|
||||
x: store.positions[index * 3],
|
||||
y: store.positions[index * 3 + 1],
|
||||
z: store.positions[index * 3 + 2],
|
||||
}).height - 161.5,
|
||||
) < 0.01,
|
||||
'and its badge with it',
|
||||
);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -1025,30 +1036,22 @@ test('loaded cameras outside the view say so instead of looking unloaded', async
|
||||
// Field review: "nearby" spans twice the camera-to-ground range, so a view
|
||||
// could hold cameras with none on screen and read as a failed load.
|
||||
const h = cameraHarness();
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
let inView = false;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () =>
|
||||
inView ? new Cesium.Cartesian2(100, 100) : new Cesium.Cartesian2(-500, 100);
|
||||
h.engine.fake.script('worldToWindow', () =>
|
||||
inView ? { x: 100, y: 100 } : { x: -500, y: 100 },
|
||||
);
|
||||
try {
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
2000,
|
||||
);
|
||||
h.viewer.scene.canvas.clientWidth = 800;
|
||||
h.viewer.scene.canvas.clientHeight = 600;
|
||||
await alprCamerasLayer.update();
|
||||
let stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 0);
|
||||
assert.match(stats.countLabel, /1 nearby · 0 on screen/);
|
||||
assert.match(stats.loadingLabel, /None on screen/);
|
||||
inView = true;
|
||||
h.viewer.camera.moveEnd.raiseEvent();
|
||||
h.engine.camera.moveEnd.raiseEvent();
|
||||
stats = alprCamerasLayer.getStats();
|
||||
assert.equal(stats.onScreen, 1);
|
||||
assert.doesNotMatch(stats.loadingLabel, /None on screen/);
|
||||
} finally {
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
@@ -1065,23 +1068,16 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
h.click('alpr:42');
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const position = entity.position,
|
||||
line = entity.polyline;
|
||||
const entity = h.marker('alpr:42');
|
||||
const position = entity.position;
|
||||
// Ground-clamped native wedges cost ~40 ms per frame for a downtown view;
|
||||
// bearings are painted by the overlay for the nearest cameras instead.
|
||||
assert.equal(line, undefined);
|
||||
assert.equal(entity.polygon, undefined);
|
||||
assert.deepEqual(h.engine.lines.list(), [], 'no native wedge lines');
|
||||
let selections = 0,
|
||||
clears = 0,
|
||||
collectionChanges = 0;
|
||||
clears = 0;
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
window.addEventListener('gev:entity-selected', () => selections++);
|
||||
window.addEventListener('gev:entity-selection-cleared', () => clears++);
|
||||
h.source.entities.collectionChanged.addEventListener(
|
||||
(_collection, added, removed) => {
|
||||
collectionChanges += added.length + removed.length;
|
||||
},
|
||||
);
|
||||
h.setBox({ south: 30.261, west: -97.749, north: 30.279, east: -97.731 });
|
||||
await alprCamerasLayer.update();
|
||||
h.expire();
|
||||
@@ -1097,32 +1093,32 @@ test('orbit cache hits and metadata refreshes preserve marker geometry and selec
|
||||
]),
|
||||
);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
||||
assert.equal(h.marker('alpr:42'), entity);
|
||||
assert.equal(entity.position, position);
|
||||
assert.equal(entity.polyline, line);
|
||||
assert.equal(
|
||||
getSelectedEntityContext().properties.operator,
|
||||
'Updated directory',
|
||||
);
|
||||
assert.equal(selections, 0);
|
||||
assert.equal(clears, 0);
|
||||
assert.equal(collectionChanges, 0);
|
||||
assert.equal(
|
||||
alprCamerasLayer.getStats().renderRevision,
|
||||
revision,
|
||||
'no marker was added or removed',
|
||||
);
|
||||
assert.equal(h.requests.length, 2, 'only expiry needs another request');
|
||||
h.expire();
|
||||
h.setFetch(async () => cameraResponse([cameraNode(42, { lon: -97.742 })]));
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
||||
assert.equal(h.marker('alpr:42'), entity);
|
||||
assert.equal(
|
||||
entity.polyline,
|
||||
undefined,
|
||||
entity.gevAlprWedge,
|
||||
null,
|
||||
'removed direction must not persist',
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.Cartesian3.equals(
|
||||
entity.position.getValue(),
|
||||
Cesium.Cartesian3.fromDegrees(-97.742, 30.2672),
|
||||
),
|
||||
);
|
||||
const moved = geo.cartographic.fromCartesian(entity.position);
|
||||
assert.ok(Math.abs(geo.math.toDegrees(moved.longitude) + 97.742) < 1e-9);
|
||||
assert.ok(Math.abs(geo.math.toDegrees(moved.latitude) - 30.2672) < 1e-9);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -1159,47 +1155,37 @@ test('moves inside a pending query do not restart it; moving to another city can
|
||||
);
|
||||
resolve(cameraResponse([cameraNode(43, { lat: 51.495, lon: -0.13 })]));
|
||||
await second;
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
assert.deepEqual(h.shownIds(), ['alpr:43']);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('ground-centered orbits ignore horizon rectangles while sky, distant and dateline views stay bounded', async () => {
|
||||
test('the nearby box follows the ground under the view centre: horizon rectangles never matter, sky, distant and dateline views stay bounded', async () => {
|
||||
const h = cameraHarness();
|
||||
const camera = h.viewer.camera;
|
||||
const focus = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672);
|
||||
h.viewer.scene.canvas.clientWidth = 1440;
|
||||
h.viewer.scene.canvas.clientHeight = 900;
|
||||
camera.pickEllipsoid = () => focus;
|
||||
camera.positionWC = Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 1200);
|
||||
h.focus(30.2672, -97.7431, 1200);
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const entity = h.source.entities.getById('alpr:42');
|
||||
const entity = h.marker('alpr:42');
|
||||
h.click('alpr:42');
|
||||
for (const box of [null, { south: 31, west: -99, north: 36, east: -95 }]) {
|
||||
h.setBox(box);
|
||||
for (const rectangle of [
|
||||
null,
|
||||
{ south: 0.54, west: -1.73, north: 0.63, east: -1.66 },
|
||||
]) {
|
||||
h.engine.fake.script('viewRectangle', rectangle);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(h.source.entities.getById('alpr:42'), entity);
|
||||
assert.equal(h.marker('alpr:42'), entity);
|
||||
assert.equal(getSelectedEntityContext().id, 'alpr:42');
|
||||
assert.equal(h.requests.length, 1);
|
||||
}
|
||||
camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
800000,
|
||||
);
|
||||
h.focus(30.2672, -97.7431, 800000);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
camera.pickEllipsoid = () => undefined;
|
||||
assert.equal(h.shownIds().length, 0);
|
||||
h.focus(null);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
camera.pickEllipsoid = () => Cesium.Cartesian3.fromDegrees(179.999, 0);
|
||||
camera.positionWC = Cesium.Cartesian3.fromDegrees(179.999, 0, 1200);
|
||||
h.focus(0, 179.999, 1200);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().status, 'zoom-in');
|
||||
assert.equal(h.requests.length, 1);
|
||||
@@ -1210,47 +1196,52 @@ test('ground-centered orbits ignore horizon rectangles while sky, distant and da
|
||||
|
||||
test('nearby count and discovery control frame a real loaded camera without fetching or stealing tracking', async () => {
|
||||
const h = cameraHarness();
|
||||
const flights = [];
|
||||
h.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.2672,
|
||||
600,
|
||||
);
|
||||
h.viewer.camera.flyToBoundingSphere = (sphere, options) =>
|
||||
flights.push({ sphere, options });
|
||||
h.viewer.scene.globe.show = true;
|
||||
h.viewer.scene.globe.getHeight = () => 312;
|
||||
h.focus(30.2672, -97.7431, 600);
|
||||
const flights = () =>
|
||||
h.engine.fake
|
||||
.callsOf('camera.flyToSphere')
|
||||
.map(({ args: [sphere, options] }) => ({ sphere, options }));
|
||||
// A keyless basemap: no rendered tiles under the camera, the globe's terrain.
|
||||
h.engine.fake.script('terrainHeight', 312);
|
||||
try {
|
||||
assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(alprCamerasLayer.getStats().countLabel, '1 nearby');
|
||||
assert.equal(
|
||||
alprCamerasLayer.getStats().countLabel,
|
||||
'1 nearby · 1 on screen',
|
||||
);
|
||||
const controls = alprCamerasLayer.getRowControls();
|
||||
assert.equal(controls.legend[0].label, 'Camera badges');
|
||||
assert.equal(controls.chips[0].onClick(), true);
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights().length, 1);
|
||||
assert.equal(getSelectedEntityContext().id, 'alpr:42');
|
||||
const center = Cesium.Cartographic.fromCartesian(flights[0].sphere.center);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(center.latitude) - 30.2672) < 1e-9,
|
||||
);
|
||||
const [flight] = flights();
|
||||
const center = geo.cartographic.fromCartesian(flight.sphere.center);
|
||||
assert.ok(Math.abs(geo.math.toDegrees(center.latitude) - 30.2672) < 1e-9);
|
||||
assert.ok(
|
||||
Math.abs(center.height - 312) < 1e-6,
|
||||
'use the rendered globe floor on keyless basemaps',
|
||||
);
|
||||
assert.equal(flights[0].options.offset.range, 800);
|
||||
assert.equal(flight.sphere.radius, 30);
|
||||
assert.equal(flight.options.range, 800);
|
||||
assert.equal(flight.options.pitch, -Math.PI / 4);
|
||||
assert.equal(flight.options.duration, 1.2);
|
||||
assert.equal(h.requests.length, 1);
|
||||
h.viewer.trackedEntity = {};
|
||||
const follow = h.engine.camera.follow({
|
||||
position: () => geo.cartesian3.fromDegrees(-97.7, 30.2, 3000),
|
||||
viewFrom: { x: 0, y: -500, z: 300 },
|
||||
});
|
||||
assert.equal(alprCamerasLayer.getRowControls().chips[0].disabled, true);
|
||||
assert.equal(
|
||||
controls.chips[0].onClick(),
|
||||
false,
|
||||
'a stale button cannot steal the follow camera',
|
||||
);
|
||||
h.viewer.trackedEntity = undefined;
|
||||
follow.stop();
|
||||
alprCamerasLayer.disable();
|
||||
assert.equal(alprCamerasLayer.getStats().countLabel, '');
|
||||
assert.equal(controls.chips[0].onClick(), false);
|
||||
assert.equal(flights.length, 1);
|
||||
assert.equal(flights().length, 1);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
@@ -1333,8 +1324,9 @@ test('slow ALPR floors share real terrain requests across preparation, render an
|
||||
latitude: 30.261 + Math.floor(i / 15) * 0.001,
|
||||
longitude: -97.749 + (i % 15) * 0.001,
|
||||
}));
|
||||
const shown = trackedDisplay();
|
||||
const layer = createAlprCamerasLayer({
|
||||
rendererFactory: createAlprCesiumRenderer,
|
||||
rendererFactory: shown.factory,
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
@@ -1390,7 +1382,7 @@ test('slow ALPR floors share real terrain requests across preparation, render an
|
||||
requests[1].release();
|
||||
await new Promise((done) => setTimeout(done, 0));
|
||||
assert.equal(requests.length, 2, 'queued final cells are discarded');
|
||||
assert.equal(h.source.entities.values.length, 0);
|
||||
assert.equal(shown.display.markers().length, 0);
|
||||
layer.enable();
|
||||
await layer.update();
|
||||
assert.equal(
|
||||
@@ -1409,7 +1401,7 @@ test('slow ALPR floors share real terrain requests across preparation, render an
|
||||
}
|
||||
});
|
||||
|
||||
test('map-source changes reposition all ALPR entities and saved native appearances in place', async () => {
|
||||
test('map-source changes reposition every ALPR marker in place and return canvas-owned badges at the new floor', async () => {
|
||||
let floor = 180;
|
||||
let listener;
|
||||
let removed = 0;
|
||||
@@ -1419,8 +1411,9 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
||||
longitude: -97.7431,
|
||||
directionDeg: i === 0 ? null : 90,
|
||||
}));
|
||||
const shown = trackedDisplay();
|
||||
const layer = createAlprCamerasLayer({
|
||||
rendererFactory: createAlprCesiumRenderer,
|
||||
rendererFactory: shown.factory,
|
||||
source: {
|
||||
fetch: async () => ({ records, stale: false, saturated: false }),
|
||||
},
|
||||
@@ -1446,42 +1439,48 @@ test('map-source changes reposition all ALPR entities and saved native appearanc
|
||||
},
|
||||
});
|
||||
const h = cameraHarness(layer);
|
||||
h.engine.fake.script('terrainHeight', 90);
|
||||
const drawnHeight = (marker) => {
|
||||
const { positions } = h.engine.fake.moverSet('alpr-cameras').store;
|
||||
const at = marker.index * 3;
|
||||
return geo.cartographic.fromCartesian({
|
||||
x: positions[at],
|
||||
y: positions[at + 1],
|
||||
z: positions[at + 2],
|
||||
}).height;
|
||||
};
|
||||
try {
|
||||
await layer.update();
|
||||
const entities = [...h.source.entities.values];
|
||||
const saved = entities[1];
|
||||
saved.gevAlprNativeAppearance = {
|
||||
position: saved.position,
|
||||
heightReference: saved.billboard.heightReference,
|
||||
};
|
||||
saved.position = Cesium.Cartesian3.fromDegrees(-97.7431, 30.26721, 190);
|
||||
saved.gevAlprPickPosition = saved.position;
|
||||
const markers = shown.display.markers();
|
||||
// The overlay owns one badge: a pick target at its canvas anchor.
|
||||
const owned = markers[1];
|
||||
owned.gevAlprCanvasPosition = geo.cartesian3.fromDegrees(
|
||||
-97.7431,
|
||||
30.26721,
|
||||
190,
|
||||
);
|
||||
owned.canvasOwned = true;
|
||||
owned.drawn = null;
|
||||
for (const next of [150, 120, null, 180]) {
|
||||
floor = next;
|
||||
// Includes a provider switch with the same globe.show value.
|
||||
h.viewer.scene.globe.show = next !== 180;
|
||||
listener({ detail: { status: 'ready' } });
|
||||
for (const entity of entities) {
|
||||
assert.equal(h.source.entities.getById(entity.id), entity);
|
||||
h.engine.fake.frame();
|
||||
const expected = (next ?? 90) + 1.5;
|
||||
assert.deepEqual(shown.display.markers(), markers, 'no marker rebuilt');
|
||||
for (const marker of markers) {
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
Cesium.Cartographic.fromCartesian(entity.position.getValue())
|
||||
.height - (next == null ? 0 : next + 1.5),
|
||||
geo.cartographic.fromCartesian(marker.position).height - expected,
|
||||
) < 0.01,
|
||||
`${entity.id} refreshes at ${next}`,
|
||||
`${marker.id} refreshes at ${next}`,
|
||||
);
|
||||
assert.equal(
|
||||
entity.billboard.heightReference.getValue(),
|
||||
next == null
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
);
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
assert.equal(marker.clamped, next == null);
|
||||
assert.equal(marker.gevAlprCanvasPosition, null);
|
||||
}
|
||||
assert.equal(
|
||||
saved.gevAlprNativeAppearance,
|
||||
null,
|
||||
'saved appearance restores at the new floor',
|
||||
assert.equal(owned.canvasOwned, false);
|
||||
assert.ok(
|
||||
Math.abs(drawnHeight(owned) - expected) < 0.01,
|
||||
'the owned badge returns to its own place at the new floor',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -1494,7 +1493,7 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
const h = cameraHarness();
|
||||
try {
|
||||
await alprCamerasLayer.update();
|
||||
const previous = h.source.entities.getById('alpr:42');
|
||||
const previous = h.marker('alpr:42');
|
||||
const revision = alprCamerasLayer.getStats().renderRevision;
|
||||
let release;
|
||||
h.setFetch(
|
||||
@@ -1507,7 +1506,7 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
const pending = alprCamerasLayer.update();
|
||||
await alprCamerasLayer.update();
|
||||
assert.equal(
|
||||
h.source.entities.getById('alpr:42'),
|
||||
h.marker('alpr:42'),
|
||||
previous,
|
||||
'do not show an intermediate filtered snapshot while the new query prepares',
|
||||
);
|
||||
@@ -1516,10 +1515,7 @@ test('a repeated move inside an outstanding query keeps the displayed marker set
|
||||
release(cameraResponse([cameraNode(43, { lat: 31.01, lon: -97.99 })]));
|
||||
await pending;
|
||||
assert.equal(alprCamerasLayer.getStats().renderRevision, revision + 1);
|
||||
assert.deepEqual(
|
||||
h.source.entities.values.map((e) => e.id),
|
||||
['alpr:43'],
|
||||
);
|
||||
assert.deepEqual(h.shownIds(), ['alpr:43']);
|
||||
} finally {
|
||||
h.restore();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { COLOR_NEUTRAL } from '../layers/bikeshare/policy.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
_clearBikeshareSelectionForTest,
|
||||
@@ -20,7 +22,9 @@ function makeRecord() {
|
||||
isRenting: false,
|
||||
isReturning: true,
|
||||
point: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 2),
|
||||
position: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 2),
|
||||
pixelSize: 8,
|
||||
color: COLOR_NEUTRAL,
|
||||
show: true,
|
||||
},
|
||||
};
|
||||
@@ -44,7 +48,7 @@ test('selected bikeshare entry preserves source copy and protected-lane policy',
|
||||
assert.equal(entry.horizonCull, false);
|
||||
});
|
||||
|
||||
test('real station select/clear path publishes one card and creates no native label graphic', () => {
|
||||
test('real station select/clear path publishes one card and draws the highlight as a point', () => {
|
||||
const calls = [];
|
||||
const overlayHost = {
|
||||
setEntries: (...args) => calls.push(['entries', ...args]),
|
||||
@@ -53,14 +57,18 @@ test('real station select/clear path publishes one card and creates no native la
|
||||
};
|
||||
const key = 'austin-capmetro:3790';
|
||||
const record = makeRecord();
|
||||
const viewer = { entities: new Cesium.EntityCollection() };
|
||||
_setBikeshareSelectionStateForTest({ viewer, key, record, overlayHost });
|
||||
const engine = createFakeEngine();
|
||||
const highlight = () => engine.fake.moverSet('bikeshare-selected');
|
||||
_setBikeshareSelectionStateForTest({ engine, key, record, overlayHost });
|
||||
try {
|
||||
_selectBikeshareStationForTest(key);
|
||||
assert.equal(record.point.show, false);
|
||||
assert.equal(viewer.entities.values.length, 1, 'runtime guard requires a real selected entity');
|
||||
assert.equal(viewer.entities.values[0].label, undefined);
|
||||
assert.ok(viewer.entities.values[0].point, 'selected point highlight remains native');
|
||||
assert.equal(
|
||||
highlight().options.kind,
|
||||
'point',
|
||||
'the selected highlight is a native point, its label the card',
|
||||
);
|
||||
assert.equal(highlight().store.pickIds[0], key);
|
||||
|
||||
const publication = calls.find(([type]) => type === 'entries');
|
||||
assert.ok(publication);
|
||||
@@ -71,7 +79,7 @@ test('real station select/clear path publishes one card and creates no native la
|
||||
|
||||
_clearBikeshareSelectionForTest();
|
||||
assert.equal(record.point.show, true);
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(highlight().store.count, 0);
|
||||
assert.deepEqual(calls.at(-1), ['clear', 'bikeshare-selected']);
|
||||
} finally {
|
||||
_clearBikeshareSelectionForTest();
|
||||
|
||||
@@ -117,27 +117,23 @@ test('every scene click handler consults ownership before it picks', () => {
|
||||
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
|
||||
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
|
||||
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
|
||||
[
|
||||
'src/layers/alpr/render/cesiumPresentation.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
[
|
||||
'src/layers/bikeshare/render/cesium/selection.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
// ALPR and bikeshare take the engine's click with its pick.
|
||||
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
||||
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
|
||||
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
|
||||
[
|
||||
'src/layers/installations/render/cesium/selection.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
],
|
||||
['src/layers/launches/render/cesium.js', 'drillPick(movement.position'],
|
||||
['src/layers/launches/render/display.js', '[ctx.picked, ...ctx.drilled]'],
|
||||
[
|
||||
'src/layers/radio/interaction.js',
|
||||
'const stationId = pickedRadioStationAt(position);',
|
||||
],
|
||||
// Satellites take the engine dispatcher's pick too.
|
||||
[
|
||||
'src/layers/satellites/render/cesium.js',
|
||||
'viewer.scene.pick(click.position)',
|
||||
'src/layers/satellites/interaction.js',
|
||||
'getRenderer().isTrackedPick(picked',
|
||||
],
|
||||
[
|
||||
'src/layers/submarineCables/interaction.js',
|
||||
|
||||
@@ -2,6 +2,9 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createModel } from '../layers/launches/model.js';
|
||||
import rocketLaunchesLayer, {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
@@ -1095,13 +1098,40 @@ test('finds a newly launched payload in the active TLE fallback catalog', () =>
|
||||
assert.equal(typeof track?.positionAt, 'function');
|
||||
});
|
||||
|
||||
test('real mission build, select, refresh, deselect, disable, and destroy paths publish no native labels', async () => {
|
||||
// The layer on the fake engine and on the Cesium engine over a stub scene:
|
||||
// a frame, a frame start alone (preUpdate) and the end of the layer's flights.
|
||||
const MISSION_HARNESSES = [
|
||||
() => {
|
||||
const engine = createFakeEngine({ width: 1600, height: 900 });
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
frame: () => engine.fake.frame(),
|
||||
frameStart: () => engine.fake.emit('frames.preUpdate', 0),
|
||||
land: () => engine.fake.frames(400),
|
||||
};
|
||||
},
|
||||
() => {
|
||||
const { engine, viewer, frame } = createCesiumMoversEngine();
|
||||
viewer.scene.globe.getHeight = () => 0;
|
||||
// The stub scene runs no flight tweens: the layer's flights land at once.
|
||||
const fly = viewer.camera.flyTo.bind(viewer.camera);
|
||||
viewer.camera.flyTo = (options) => fly({ ...options, duration: 0 });
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
engine,
|
||||
frame,
|
||||
frameStart: () =>
|
||||
viewer.scene.preUpdate.raiseEvent(viewer.scene, viewer.clock.currentTime),
|
||||
land: () => {},
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
for (const makeHarness of MISSION_HARNESSES)
|
||||
test(`real mission build, select, refresh, deselect, disable, and destroy paths publish no native labels (${makeHarness().name})`, async () => {
|
||||
const realDocument = globalThis.document;
|
||||
const realFetch = globalThis.fetch;
|
||||
const realHtmlCanvasElement = globalThis.HTMLCanvasElement;
|
||||
const realHtmlImageElement = globalThis.HTMLImageElement;
|
||||
const realImageBitmap = globalThis.ImageBitmap;
|
||||
const realOffscreenCanvas = globalThis.OffscreenCanvas;
|
||||
const listeners = new Map();
|
||||
const context = {
|
||||
strokeStyle: '',
|
||||
@@ -1156,37 +1186,15 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
addEventListener(type, handler) { listeners.set(`document:${type}`, handler); },
|
||||
removeEventListener(type) { listeners.delete(`document:${type}`); },
|
||||
};
|
||||
const canvas = new FakeElement('canvas');
|
||||
const dataSources = [];
|
||||
const camera = {
|
||||
positionCartographic: null,
|
||||
positionWC: Cesium.Cartesian3.fromDegrees(-80.604, 28.608, 18_000_000),
|
||||
cancelFlight() {},
|
||||
lookAtTransform() {},
|
||||
};
|
||||
const scene = {
|
||||
canvas,
|
||||
camera,
|
||||
frameState: { frameNumber: 1 },
|
||||
postRender: new Cesium.Event(),
|
||||
preRender: new Cesium.Event(),
|
||||
preUpdate: new Cesium.Event(),
|
||||
primitives: { add: (primitive) => primitive, remove: () => true },
|
||||
drillPick: () => [],
|
||||
};
|
||||
const viewer = {
|
||||
camera,
|
||||
scene,
|
||||
dataSources: {
|
||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
||||
remove(dataSource) {
|
||||
const index = dataSources.indexOf(dataSource);
|
||||
if (index >= 0) dataSources.splice(index, 1);
|
||||
return index >= 0;
|
||||
},
|
||||
},
|
||||
selectedEntity: undefined,
|
||||
};
|
||||
const harness = makeHarness();
|
||||
const { engine } = harness;
|
||||
const lookFrom = (lon, lat) =>
|
||||
engine.camera.setView({ destination: geo.cartesian3.fromDegrees(lon, lat, 18_000_000) });
|
||||
lookFrom(-80.604, 28.608);
|
||||
const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null);
|
||||
const drawnPoint = (id) =>
|
||||
rocketLaunchesLayer.qaDrawn().points.find((point) => point.id === id);
|
||||
const element = (id) => rocketLaunchesLayer._missionElementForTest(id);
|
||||
const hostCalls = [];
|
||||
const host = {
|
||||
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
||||
@@ -1223,10 +1231,6 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
},
|
||||
}] });
|
||||
globalThis.document = document;
|
||||
globalThis.HTMLCanvasElement = FakeElement;
|
||||
globalThis.HTMLImageElement = class {};
|
||||
globalThis.ImageBitmap = class {};
|
||||
globalThis.OffscreenCanvas = class {};
|
||||
globalThis.fetch = async (url) => {
|
||||
if (url === '/api/celestrak/active') {
|
||||
return { ok: true, text: async () => '' };
|
||||
@@ -1237,14 +1241,17 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
_setRocketMissionOverlayHostForTest(host);
|
||||
let initialized = false;
|
||||
try {
|
||||
rocketLaunchesLayer.init(viewer);
|
||||
rocketLaunchesLayer.init(null, { engine });
|
||||
initialized = true;
|
||||
await rocketLaunchesLayer.enable();
|
||||
await rocketLaunchesLayer.update();
|
||||
// The full-globe entry flight lands before the camera moves.
|
||||
harness.land();
|
||||
|
||||
const entities = dataSources[0].entities.values;
|
||||
assert.ok(entities.length >= 7, 'runtime guard requires the populated mission build path');
|
||||
assert.ok(entities.every((entity) => entity.label === undefined));
|
||||
const drawn = rocketLaunchesLayer.qaDrawn();
|
||||
assert.ok(drawn.points.length + drawn.paths.length >= 6, 'runtime guard requires the populated mission build path');
|
||||
// Only dots, the reticle and lines: the labels are the host's.
|
||||
assert.ok(engine.movers.list().every(({ kind }) => kind === 'point' || kind === 'sprite'));
|
||||
const ambientPublication = hostCalls.findLast(([type, sourceId]) => (
|
||||
type === 'entries' && sourceId === ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID
|
||||
));
|
||||
@@ -1256,26 +1263,25 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
collisionCapacity: ROCKET_MISSION_AMBIENT_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
});
|
||||
const launchEntity = dataSources[0].entities.getById('rocket-launch:mission-runtime');
|
||||
assert.deepEqual(
|
||||
ambientPublication[2][0].position,
|
||||
launchEntity.position.getValue(Cesium.JulianDate.now()),
|
||||
'ambient host marker must reuse the entity anchor Cartesian',
|
||||
);
|
||||
const frameTime = Cesium.JulianDate.now();
|
||||
scene.preRender.raiseEvent(scene, frameTime);
|
||||
assert.equal(launchEntity.point.show.getValue(frameTime), true);
|
||||
camera.positionWC = Cesium.Cartesian3.fromDegrees(99.396, -28.608, 18_000_000);
|
||||
scene.preRender.raiseEvent(scene, frameTime);
|
||||
const launchElement = element('rocket-launch:mission-runtime');
|
||||
assert.equal(
|
||||
launchEntity.point.show.getValue(frameTime),
|
||||
ambientPublication[2][0].position,
|
||||
launchElement.position,
|
||||
'ambient host marker must reuse the element anchor Cartesian',
|
||||
);
|
||||
harness.frame();
|
||||
assert.equal(drawnPoint('rocket-launch:mission-runtime').show, true);
|
||||
lookFrom(99.396, -28.608);
|
||||
harness.frame();
|
||||
assert.equal(
|
||||
drawnPoint('rocket-launch:mission-runtime').show,
|
||||
false,
|
||||
'pre-render horizon pass must hide a rear-side depth-free mission dot before draw and pick',
|
||||
);
|
||||
camera.positionWC = Cesium.Cartesian3.fromDegrees(-80.604, 28.608, 18_000_000);
|
||||
scene.preRender.raiseEvent(scene, frameTime);
|
||||
lookFrom(-80.604, 28.608);
|
||||
harness.frame();
|
||||
assert.equal(
|
||||
launchEntity.point.show.getValue(frameTime),
|
||||
drawnPoint('rocket-launch:mission-runtime').show,
|
||||
true,
|
||||
'mission dot must return when its surface anchor faces the camera again',
|
||||
);
|
||||
@@ -1301,17 +1307,16 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
&& entry.paintLane === 'selected'
|
||||
&& entry.edgeFade === 'keyhole'
|
||||
)));
|
||||
const satelliteEntity = dataSources[0].entities.getById('rocket-satellite:mission-runtime');
|
||||
const satellitePosition = satelliteEntity.position.getValue(Cesium.JulianDate.now());
|
||||
const satellite = element('rocket-satellite:mission-runtime');
|
||||
assert.equal(
|
||||
selectedPublication[2][2].position(),
|
||||
satellitePosition,
|
||||
satellite.position(),
|
||||
'payload host getter must return the exact per-frame live-position cache',
|
||||
);
|
||||
|
||||
// Jitter regression net: live-state propagation advances at most once per
|
||||
// frameNumber, and the host getter is a pure read that never advances it.
|
||||
// Without both halves, the host label and the native dot can propagate to
|
||||
// engine frame, and the host getter is a pure read that never advances it.
|
||||
// Without both halves, the host label and the dot can propagate to
|
||||
// different wall-clock instants inside one frame — the documented
|
||||
// label-separation jitter class.
|
||||
const payloadEntry = selectedPublication[2][2];
|
||||
@@ -1319,52 +1324,55 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
try {
|
||||
let nowMs = realDateNow();
|
||||
Date.now = () => nowMs;
|
||||
scene.frameState.frameNumber = 41;
|
||||
const frameA = Cesium.Cartesian3.clone(
|
||||
satelliteEntity.position.getValue(Cesium.JulianDate.now()),
|
||||
);
|
||||
// The frame's dot reads the payload position.
|
||||
harness.frame();
|
||||
const frameA = plain(satellite.position());
|
||||
nowMs += 30_000;
|
||||
assert.deepEqual(
|
||||
Cesium.Cartesian3.clone(satelliteEntity.position.getValue(Cesium.JulianDate.now())),
|
||||
plain(satellite.position()),
|
||||
frameA,
|
||||
'a second native read in the same frame must not re-propagate',
|
||||
'a second read in the same frame must not re-propagate',
|
||||
);
|
||||
nowMs += 30_000;
|
||||
scene.frameState.frameNumber = 42;
|
||||
// A new frame that nothing has drawn yet.
|
||||
harness.frameStart();
|
||||
assert.deepEqual(
|
||||
Cesium.Cartesian3.clone(payloadEntry.position()),
|
||||
plain(payloadEntry.position()),
|
||||
frameA,
|
||||
'host getter must not advance propagation, even on a new frame',
|
||||
);
|
||||
const frameB = Cesium.Cartesian3.clone(
|
||||
satelliteEntity.position.getValue(Cesium.JulianDate.now()),
|
||||
);
|
||||
assert.notDeepEqual(frameB, frameA, 'native read on a new frame propagates (non-vacuous)');
|
||||
const frameB = plain(satellite.position());
|
||||
assert.notDeepEqual(frameB, frameA, 'a read on a new frame propagates (non-vacuous)');
|
||||
assert.deepEqual(
|
||||
Cesium.Cartesian3.clone(payloadEntry.position()),
|
||||
plain(payloadEntry.position()),
|
||||
frameB,
|
||||
'host getter reads the advanced cache after native propagation',
|
||||
'host getter reads the advanced cache after propagation',
|
||||
);
|
||||
harness.frame();
|
||||
assert.deepEqual(
|
||||
plain(drawnPoint('rocket-satellite:mission-runtime').position),
|
||||
plain(payloadEntry.position()),
|
||||
'the drawn dot and the host label share the frame position',
|
||||
);
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
scene.frameState.frameNumber = 1;
|
||||
}
|
||||
const reentryEntity = dataSources[0].entities.getById('rocket-reentry:mission-runtime:0');
|
||||
const reentry = element('rocket-reentry:mission-runtime:0');
|
||||
assert.deepEqual(
|
||||
selectedPublication[2][1].position,
|
||||
reentryEntity.polyline.positions.getValue(Cesium.JulianDate.now())[0],
|
||||
reentry.positions[0],
|
||||
're-entry host label must reuse the recovery-path interface Cartesian',
|
||||
);
|
||||
const transferEntity = dataSources[0].entities.getById('rocket-transfer:mission-runtime');
|
||||
const transfer = element('rocket-transfer:mission-runtime');
|
||||
assert.deepEqual(
|
||||
selectedPublication[2][3].position,
|
||||
transferEntity.polyline.positions.getValue(Cesium.JulianDate.now()).at(-1),
|
||||
transfer.positions.at(-1),
|
||||
'orbit host label must reuse the insertion Cartesian',
|
||||
);
|
||||
|
||||
launchName = 'Mission Refresh | Gauntlet Payload';
|
||||
await rocketLaunchesLayer.update();
|
||||
assert.ok(dataSources[0].entities.values.every((entity) => entity.label === undefined));
|
||||
assert.ok(engine.movers.list().every(({ kind }) => kind === 'point' || kind === 'sprite'));
|
||||
const refreshPublication = hostCalls.findLast(([type, sourceId]) => (
|
||||
type === 'entries' && sourceId === ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID
|
||||
));
|
||||
@@ -1385,9 +1393,10 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
['clear', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID],
|
||||
['visible', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
await rocketLaunchesLayer.destroy(viewer);
|
||||
await rocketLaunchesLayer.destroy();
|
||||
initialized = false;
|
||||
assert.equal(dataSources.length, 0);
|
||||
assert.deepEqual(engine.movers.list(), []);
|
||||
assert.deepEqual(engine.lines.list(), []);
|
||||
assert.deepEqual(hostCalls.slice(-4), [
|
||||
['clear', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID],
|
||||
['visible', ROCKET_MISSION_AMBIENT_OVERLAY_SOURCE_ID, false],
|
||||
@@ -1395,14 +1404,11 @@ test('real mission build, select, refresh, deselect, disable, and destroy paths
|
||||
['visible', ROCKET_MISSION_SELECTED_OVERLAY_SOURCE_ID, false],
|
||||
]);
|
||||
} finally {
|
||||
if (initialized) await rocketLaunchesLayer.destroy(viewer);
|
||||
if (initialized) await rocketLaunchesLayer.destroy();
|
||||
engine.destroy();
|
||||
_setRocketMissionOverlayHostForTest();
|
||||
globalThis.fetch = realFetch;
|
||||
globalThis.document = realDocument;
|
||||
globalThis.HTMLCanvasElement = realHtmlCanvasElement;
|
||||
globalThis.HTMLImageElement = realHtmlImageElement;
|
||||
globalThis.ImageBitmap = realImageBitmap;
|
||||
globalThis.OffscreenCanvas = realOffscreenCanvas;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -104,7 +104,6 @@ test('satellite getAnalystRecords: core rows beat dense extras under the cap', (
|
||||
satrec,
|
||||
entity: {},
|
||||
point,
|
||||
viewer: {},
|
||||
now: () => Date.now(),
|
||||
neighbours: [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from './satellites.js';
|
||||
import satellitesLayer from './satellites.js';
|
||||
import { createTrackedOverlayEntry } from './trackedReadout.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
@@ -99,16 +100,9 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: 0,
|
||||
};
|
||||
const viewer = {
|
||||
entities: new Cesium.EntityCollection(),
|
||||
trackedEntity: undefined,
|
||||
scene: {
|
||||
frameState: { frameNumber: 1 },
|
||||
primitives: { remove() {} },
|
||||
},
|
||||
};
|
||||
const engine = createFakeEngine();
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
engine,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
point,
|
||||
});
|
||||
@@ -117,7 +111,10 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
satellitesLayer.setParams({ selectedSatTrackingId: 25544 }),
|
||||
);
|
||||
assert.equal(satellitesLayer.getParams().selectedSatTrackingId, 25544);
|
||||
assert.equal(viewer.trackedEntity?.gevTrackedId, 'satellites:25544');
|
||||
assert.equal(
|
||||
engine.camera.following()?.native.gevTrackedId,
|
||||
'satellites:25544',
|
||||
);
|
||||
|
||||
satellitesLayer.stopTracking();
|
||||
_removeSatelliteTrackingCandidateForTest(25544);
|
||||
@@ -127,7 +124,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
);
|
||||
assert.equal(_pendingSatelliteTrackingRestoreForTest(), 25544);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
engine,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
point,
|
||||
preservePending: true,
|
||||
@@ -163,7 +160,7 @@ test('selected satellite params survive delayed arrival and yield to newer expli
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
engine,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
point,
|
||||
preservePending: true,
|
||||
@@ -196,7 +193,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
// members — never unrelated satellites that merely happen to be nearby.
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
|
||||
const engine = createFakeEngine();
|
||||
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
|
||||
// The tracked satellite moves ~7.6 km/s, so neighbours are defined RELATIVE to
|
||||
// wherever it is at scan time rather than pinned to a stale sample.
|
||||
@@ -213,7 +210,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
engine,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) }, // berthed
|
||||
@@ -226,7 +223,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
// The cluster scan is throttled, so advance past its interval before the frame
|
||||
// that must observe the cluster.
|
||||
nowMs += 2000;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
engine.fake.frame();
|
||||
_runSatellitePreRenderForTest();
|
||||
|
||||
const details = entity.gevLabelModel.details;
|
||||
@@ -254,10 +251,7 @@ test('a tracked docked cluster consolidates its companions onto one card', () =>
|
||||
test('satellite pre-render refreshes the tracked altitude on each propagated frame', () => {
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = {
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const engine = createFakeEngine();
|
||||
const epochs = [Date.UTC(2008, 8, 20, 12, 30), Date.UTC(2008, 8, 20, 13, 0)];
|
||||
let epochIndex = 0;
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
@@ -266,7 +260,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
engine,
|
||||
now: () => epochs[epochIndex],
|
||||
});
|
||||
|
||||
@@ -277,7 +271,7 @@ test('satellite pre-render refreshes the tracked altitude on each propagated fra
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
|
||||
epochIndex = 1;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
engine.fake.frame();
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
assert.equal(
|
||||
@@ -303,17 +297,10 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: 0,
|
||||
};
|
||||
const viewer = {
|
||||
entities: new Cesium.EntityCollection(),
|
||||
trackedEntity: undefined,
|
||||
scene: {
|
||||
frameState: { frameNumber: 1 },
|
||||
primitives: { remove() {} },
|
||||
},
|
||||
};
|
||||
const engine = createFakeEngine();
|
||||
let cleaned = false;
|
||||
_setSatelliteLabelLifecycleStateForTest({
|
||||
viewer,
|
||||
engine,
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
point,
|
||||
overlayHost,
|
||||
@@ -339,9 +326,9 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
const trackedEntity = _trackIssForTest();
|
||||
assert.ok(trackedEntity);
|
||||
assert.equal(
|
||||
viewer.entities.values.length,
|
||||
1,
|
||||
'runtime guard requires a real tracked entity',
|
||||
engine.camera.following()?.native,
|
||||
trackedEntity,
|
||||
'the follow is of the tracked satellite',
|
||||
);
|
||||
assert.equal(trackedEntity.label, undefined);
|
||||
assert.ok(
|
||||
@@ -372,7 +359,7 @@ test('ISS ambient and tracked lifecycle uses cached host entries with no native
|
||||
|
||||
_clearSatelliteLabelLifecycleForTest();
|
||||
cleaned = true;
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
assert.equal(engine.camera.following(), null);
|
||||
assert.equal(
|
||||
calls.filter(
|
||||
([type, sourceId]) =>
|
||||
@@ -405,17 +392,14 @@ test('the tracked satellite is propagated at the app clock time', async () => {
|
||||
const { appClock } = await import('../time/clock.js');
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = {
|
||||
camera: null,
|
||||
scene: { frameState: { frameNumber: 1 } },
|
||||
};
|
||||
const engine = createFakeEngine();
|
||||
_setTrackedSatelliteRefreshStateForTest({
|
||||
noradId: 25544,
|
||||
name: 'ISS (ZARYA)',
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
engine,
|
||||
now: undefined,
|
||||
});
|
||||
try {
|
||||
@@ -423,7 +407,7 @@ test('the tracked satellite is propagated at the app clock time', async () => {
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '353\u00a0km · NORAD 25544');
|
||||
appClock.freeze(Date.UTC(2008, 8, 20, 13, 0));
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
engine.fake.frame();
|
||||
_runSatellitePreRenderForTest();
|
||||
assert.equal(entity.gevLabelModel.details[1], '366\u00a0km · NORAD 25544');
|
||||
} finally {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
_trackedFrameCartesianForTest,
|
||||
} from './satellites.js';
|
||||
import { setLocale } from '../i18n/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
const L1 =
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927';
|
||||
@@ -18,7 +19,7 @@ function run(locale) {
|
||||
setLocale(locale);
|
||||
const entity = { gevLabelModel: { title: 'OLD', details: ['? km'] } };
|
||||
const point = { position: new Cesium.Cartesian3() };
|
||||
const viewer = { camera: null, scene: { frameState: { frameNumber: 1 } } };
|
||||
const engine = createFakeEngine();
|
||||
let nowMs = Date.UTC(2008, 8, 20, 12, 30);
|
||||
const near = (metres) => ({
|
||||
get position() {
|
||||
@@ -32,7 +33,7 @@ function run(locale) {
|
||||
satrec: twoline2satrec(L1, L2),
|
||||
entity,
|
||||
point,
|
||||
viewer,
|
||||
engine,
|
||||
now: () => nowMs,
|
||||
neighbours: [
|
||||
{ noradId: 55555, name: 'PROGRESS-MS 34', point: near(120) },
|
||||
@@ -41,7 +42,7 @@ function run(locale) {
|
||||
});
|
||||
_runSatellitePreRenderForTest();
|
||||
nowMs += 2000;
|
||||
viewer.scene.frameState.frameNumber = 2;
|
||||
engine.fake.frame();
|
||||
_runSatellitePreRenderForTest();
|
||||
const details = entity.gevLabelModel.details;
|
||||
setLocale('en');
|
||||
|
||||
@@ -15,8 +15,9 @@ import { createLineRegistry } from '../lines.js';
|
||||
const TACTICAL_TYPE = 'GevLineTactical';
|
||||
let tacticalRegistered = false;
|
||||
|
||||
// The launches' GevMissionOrbitTactical shader (layers/launches/render/
|
||||
// cesium.js), under the engine's own type.
|
||||
// The launches' selected-orbit pattern (layers/launches/policy.js
|
||||
// MISSION_ORBIT_PATTERN_GROUPS and MISSION_ORBIT_DASHES_PER_GROUP): one
|
||||
// round dot and the dashes of each group, under the engine's own type.
|
||||
function registerTactical() {
|
||||
if (tacticalRegistered) return;
|
||||
new Cesium.Material({
|
||||
|
||||
@@ -1,214 +1,9 @@
|
||||
// The overlay's labels and glyph scale; its painting over the badges is in
|
||||
// render/display.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlay } from './render/cesiumOverlay.js';
|
||||
import { normalizeAlprNode } from './records.js';
|
||||
import {
|
||||
directionWedgePositions,
|
||||
alprLabelDetails,
|
||||
markerScale,
|
||||
} from './visuals.js';
|
||||
|
||||
function harness(t, count = 2, { floor = null } = {}) {
|
||||
const previousImage = globalThis.Image;
|
||||
const project = Cesium.SceneTransforms.worldToWindowCoordinates;
|
||||
globalThis.Image = class {
|
||||
complete = true;
|
||||
naturalWidth = 128;
|
||||
};
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => ({ x: 100, y: 100 });
|
||||
const records = Array.from({ length: count }, (_, i) => ({
|
||||
id: `camera:${i}`,
|
||||
latitude: 30 + i / 10000,
|
||||
longitude: -97,
|
||||
directionDeg: 90,
|
||||
}));
|
||||
const entities = new Cesium.EntityCollection();
|
||||
for (const record of records)
|
||||
entities.add({
|
||||
id: record.id,
|
||||
billboard: { image: 'fallback', show: true },
|
||||
polyline: { show: true },
|
||||
polygon: { show: true },
|
||||
});
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
dataSource: { entities },
|
||||
viewer: {
|
||||
camera: { positionWC: Cesium.Cartesian3.fromDegrees(-97, 30, 500) },
|
||||
scene: { globe: { show: true, getHeight: () => 150 } },
|
||||
},
|
||||
};
|
||||
let paint,
|
||||
mapListener,
|
||||
active,
|
||||
removed = 0,
|
||||
paints = 0;
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
native: state,
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => floor },
|
||||
overlays: {
|
||||
registerPaintLane(_lane, painter) {
|
||||
paint = painter;
|
||||
return {
|
||||
setActive(value) {
|
||||
active = value;
|
||||
},
|
||||
requestPaint() {
|
||||
paints++;
|
||||
},
|
||||
unregister() {
|
||||
removed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
keyholeAlpha: () => 1,
|
||||
subscribeMapStack(fn) {
|
||||
mapListener = fn;
|
||||
return () => {
|
||||
mapListener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
overlay.init();
|
||||
t.after(() => {
|
||||
overlay.destroy();
|
||||
if (previousImage === undefined) delete globalThis.Image;
|
||||
else globalThis.Image = previousImage;
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates = project;
|
||||
});
|
||||
const draw = [];
|
||||
const ctx = {
|
||||
globalAlpha: 1,
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
closePath() {},
|
||||
stroke() {},
|
||||
fill() {
|
||||
draw.push('wedge');
|
||||
},
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
drawImage(image) {
|
||||
draw.push(image.src);
|
||||
},
|
||||
};
|
||||
return {
|
||||
state,
|
||||
records,
|
||||
overlay,
|
||||
entities,
|
||||
draw,
|
||||
paint: (occluder) => paint({ ctx, width: 800, height: 600, occluder }),
|
||||
mapChanged() {
|
||||
mapListener({ detail: { status: 'ready' } });
|
||||
},
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
get paints() {
|
||||
return paints;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('a selected camera without a bearing gets its badge and brackets, never a fabricated wedge', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.records[0].directionDeg = null;
|
||||
h.state.selectedId = h.records[0].id;
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.draw.length, 2);
|
||||
assert.ok(h.draw[0].endsWith('alpr-marker-selected.png'));
|
||||
assert.ok(h.draw[1].endsWith('alpr-marker-selected-brackets.png'));
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0');
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
assert.equal(
|
||||
h.entities.getById('camera:0').billboard.color.getValue().alpha,
|
||||
0.01,
|
||||
);
|
||||
assert.equal(directionWedgePositions(h.records[0]), null);
|
||||
});
|
||||
|
||||
test('the overlay bounds its cohort and promotes selection while leaving farther cameras native', (t) => {
|
||||
const h = harness(t, 70);
|
||||
h.state.selectedId = 'camera:69';
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const hidden = h.entities.values.filter((e) => e.gevAlprNativeAppearance);
|
||||
assert.equal(hidden.length, 64);
|
||||
assert.ok(hidden.some((e) => e.id === 'camera:69'));
|
||||
assert.equal(
|
||||
h.overlay.pick({ x: 100, y: 100 }),
|
||||
'camera:69',
|
||||
'selected glyph paints and picks on top',
|
||||
);
|
||||
const firstGlyph = h.draw.findIndex((value) => value !== 'wedge');
|
||||
assert.equal(firstGlyph, 64, 'all wedges paint before any glyph');
|
||||
});
|
||||
|
||||
test('surface changes invalidate cached anchors, and disable/disposal release painting and hits', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const entity = h.entities.getById('camera:0');
|
||||
const first = entity.gevAlprCanvasPosition;
|
||||
h.paint();
|
||||
assert.equal(
|
||||
entity.gevAlprCanvasPosition,
|
||||
first,
|
||||
'stable anchor across frames',
|
||||
);
|
||||
h.state.viewer.scene.globe.show = false;
|
||||
h.mapChanged();
|
||||
assert.equal(entity.gevAlprCanvasPosition, null);
|
||||
h.state.enabled = false;
|
||||
h.overlay.clear();
|
||||
assert.equal(h.active, false);
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
assert.equal(entity.billboard.show.getValue(), true);
|
||||
assert.equal(entity.billboard.color, undefined);
|
||||
assert.equal(entity.position, undefined);
|
||||
h.overlay.destroy();
|
||||
assert.equal(h.removed, 1);
|
||||
});
|
||||
|
||||
test('an unresolved surface preserves the native marker; the horizon removes overlay hits', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.state.viewer.scene.globe.getHeight = () => -15000;
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
assert.equal(h.draw.length, 0);
|
||||
h.state.viewer.scene.globe.getHeight = () => 150;
|
||||
h.paint({ isPointVisible: () => false });
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
assert.equal(h.draw.length, 0);
|
||||
});
|
||||
|
||||
test('repeated paints of a still view never rewrite marker properties', (t) => {
|
||||
// Each Entity write raises definitionChanged, so a still view must not hide
|
||||
// and re-show every overlay camera's native marker on every frame.
|
||||
const h = harness(t, 3);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
let changes = 0;
|
||||
for (const entity of h.entities.values)
|
||||
entity.definitionChanged.addEventListener(() => changes++);
|
||||
for (let i = 0; i < 5; i++) h.paint();
|
||||
assert.equal(changes, 0);
|
||||
assert.equal(h.entities.getById('camera:0').billboard.show.getValue(), true);
|
||||
});
|
||||
import { alprLabelDetails, markerScale } from './visuals.js';
|
||||
|
||||
test('adapter labels never claim a custom source is public OSM data', () => {
|
||||
assert.deepEqual(
|
||||
@@ -243,42 +38,3 @@ test('overlay glyphs and native badges shrink alike with distance', () => {
|
||||
assert.equal(markerScale(90_000), 0.45);
|
||||
assert.ok(Math.abs(markerScale(20_250) - 0.8) < 1e-9);
|
||||
});
|
||||
|
||||
test('a still view replaces floor anchors with the rendered surface, within the floor window', (t) => {
|
||||
// Floors come from ~111 m cells; on a slope a badge floated above the mesh.
|
||||
const h = harness(t, 2, { floor: 150 });
|
||||
const scene = h.state.viewer.scene;
|
||||
const camera = h.state.viewer.camera;
|
||||
camera.directionWC = Cesium.Cartesian3.UNIT_X;
|
||||
scene.sampleHeightSupported = true;
|
||||
scene.primitives = { length: 0, get: () => null };
|
||||
let samples = 0;
|
||||
scene.sampleHeight = (carto) => {
|
||||
samples++;
|
||||
return Cesium.Math.toDegrees(carto.latitude) > 30.00005 ? 400 : 172;
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
const height = (id) =>
|
||||
Cesium.Cartographic.fromCartesian(
|
||||
h.entities.getById(id).gevAlprCanvasPosition,
|
||||
).height;
|
||||
assert.equal(
|
||||
samples,
|
||||
0,
|
||||
'no depth samples while the camera may still be moving',
|
||||
);
|
||||
assert.ok(Math.abs(height('camera:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
assert.ok(
|
||||
Math.abs(height('camera:0') - 173.5) < 0.01,
|
||||
'rendered surface once still',
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(height('camera:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
const taken = samples;
|
||||
h.paint();
|
||||
assert.equal(samples, taken, 'each anchor is checked once');
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { alprCreditMarkup } from '../model.js';
|
||||
import { createAlprPresentation } from './cesiumPresentation.js';
|
||||
|
||||
/** Native scene resources; shared requests and records never own the viewer. */
|
||||
export function createAlprCesiumRenderer({ state, services, source }) {
|
||||
const native = {
|
||||
viewer: null,
|
||||
dataSource: null,
|
||||
credit: null,
|
||||
creditTimer: null,
|
||||
creditPresented: false,
|
||||
clickHandler: null,
|
||||
};
|
||||
let removeMove = null;
|
||||
let removeFrame = null;
|
||||
const presentation = createAlprPresentation({
|
||||
state,
|
||||
native,
|
||||
services,
|
||||
source,
|
||||
});
|
||||
return {
|
||||
...presentation,
|
||||
get ready() {
|
||||
return Boolean(native.viewer);
|
||||
},
|
||||
count: () => native.dataSource?.entities.values.length || 0,
|
||||
following: () => Boolean(native.viewer?.trackedEntity),
|
||||
init(viewer, { onMove }) {
|
||||
native.viewer = viewer;
|
||||
native.dataSource = new Cesium.CustomDataSource('alpr-cameras');
|
||||
const markup = alprCreditMarkup(source.attribution);
|
||||
native.credit = markup ? new Cesium.Credit(markup, true) : null;
|
||||
viewer.dataSources.add(native.dataSource);
|
||||
removeMove = viewer.camera.moveEnd.addEventListener(onMove);
|
||||
removeFrame = viewer.scene.postRender?.addEventListener(
|
||||
presentation.updateSelectedAnchor,
|
||||
);
|
||||
presentation.initOverlay();
|
||||
presentation.installInteraction(viewer);
|
||||
},
|
||||
setVisible(visible) {
|
||||
if (visible) native.creditPresented = false;
|
||||
if (native.dataSource) native.dataSource.show = visible;
|
||||
},
|
||||
destroy(options) {
|
||||
presentation.destroyOverlay();
|
||||
removeMove?.();
|
||||
removeMove = null;
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
native.clickHandler?.destroy();
|
||||
native.clickHandler = null;
|
||||
presentation.clearRendered(options);
|
||||
if (native.dataSource && native.viewer)
|
||||
native.viewer.dataSources.remove(native.dataSource, true);
|
||||
native.dataSource = null;
|
||||
native.credit = null;
|
||||
native.creditPresented = false;
|
||||
native.viewer = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createAlprOverlayCore } from '../overlay.js';
|
||||
|
||||
/** Per-layer presentation on a caller-owned overlay; Cesium remains the fallback. */
|
||||
export function createAlprOverlay({
|
||||
state,
|
||||
native,
|
||||
services,
|
||||
onSurfaceChange = () => {},
|
||||
}) {
|
||||
const scene = () => native.viewer.scene;
|
||||
const location = (record) =>
|
||||
Cesium.Cartographic.fromDegrees(record.longitude, record.latitude);
|
||||
|
||||
/** Swap a marker between its native look and a faint canvas pick target. */
|
||||
function canvasOwned(entity, owned) {
|
||||
const visible = !owned;
|
||||
const billboard = entity.billboard;
|
||||
// Keep a faint native pick target under the canvas badge so sibling layer
|
||||
// handlers recognize ALPR ownership instead of treating the click as empty.
|
||||
if (!visible && !entity.gevAlprNativeAppearance) {
|
||||
entity.gevAlprNativeAppearance = {
|
||||
position: entity.position,
|
||||
heightReference: billboard.heightReference,
|
||||
scaleByDistance: billboard.scaleByDistance,
|
||||
color: billboard.color,
|
||||
};
|
||||
entity.position = entity.gevAlprCanvasPosition;
|
||||
entity.gevAlprPickPosition = entity.position;
|
||||
billboard.heightReference = Cesium.HeightReference.NONE;
|
||||
billboard.scaleByDistance = undefined;
|
||||
billboard.color = Cesium.Color.WHITE.withAlpha(0.01);
|
||||
} else if (visible && entity.gevAlprNativeAppearance) {
|
||||
const saved = entity.gevAlprNativeAppearance;
|
||||
if (entity.position === entity.gevAlprPickPosition)
|
||||
entity.position = saved.position;
|
||||
billboard.heightReference = saved.heightReference;
|
||||
billboard.scaleByDistance = saved.scaleByDistance;
|
||||
billboard.color = saved.color;
|
||||
entity.gevAlprNativeAppearance = null;
|
||||
entity.gevAlprPickPosition = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Assign only on change: each Entity property write mints a new property
|
||||
// and raises definitionChanged.
|
||||
function setShown(entity, visible) {
|
||||
if (entity.gevAlprShown === visible) return;
|
||||
entity.gevAlprShown = visible;
|
||||
entity.billboard.show = visible;
|
||||
if (entity.polyline) entity.polyline.show = visible;
|
||||
if (entity.polygon) entity.polygon.show = visible;
|
||||
}
|
||||
|
||||
/** Whether the visible photoreal tiles (or globe) have finished streaming. */
|
||||
function surfaceSettled() {
|
||||
const { globe, primitives } = scene();
|
||||
if (globe?.show) return globe.tilesLoaded !== false;
|
||||
for (let i = 0; i < (primitives?.length || 0); i++) {
|
||||
const primitive = primitives.get(i);
|
||||
if (primitive?.show && primitive.tilesLoaded === false) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return createAlprOverlayCore({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange,
|
||||
engine: {
|
||||
marker: (id) => native.dataSource?.entities.getById(id),
|
||||
markers: () => native.dataSource.entities.values,
|
||||
canvasOwned,
|
||||
setShown,
|
||||
setDisplayPosition(entity, position) {
|
||||
entity.gevAlprDisplayPosition = position;
|
||||
},
|
||||
placeAnchor(entity, record, height) {
|
||||
entity.gevAlprCanvasPosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
},
|
||||
surfaceSupported: () => scene().sampleHeightSupported,
|
||||
// Each sample is a depth render.
|
||||
sampleHeight: (record, entity) =>
|
||||
scene().sampleHeight(location(record), [entity]),
|
||||
terrainHeight: (record) =>
|
||||
scene().globe.show
|
||||
? scene().globe.getHeight?.(location(record))
|
||||
: undefined,
|
||||
surfaceSettled,
|
||||
cameraPose: () => ({
|
||||
position: native.viewer.camera.positionWC,
|
||||
direction: native.viewer.camera.directionWC,
|
||||
}),
|
||||
cameraPosition: () => native.viewer.camera.positionWC,
|
||||
projector: ({ occluder }) => ({
|
||||
horizonVisible: (point) => !occluder || occluder.isPointVisible(point),
|
||||
project: (point) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(scene(), point),
|
||||
}),
|
||||
cullOffscreenWedges: false,
|
||||
createId: () => Cesium.createGuid(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
MAX_VIEWPORT_DEGREES,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
CREDIT_DISPLAY_MS,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from '../policy.js';
|
||||
import {
|
||||
alprEntityContext,
|
||||
alprFocusBox,
|
||||
alprLabelModel,
|
||||
alprViewRecords,
|
||||
} from '../presentation.js';
|
||||
import { createAlprFloorResolver } from '../floors.js';
|
||||
import { createAlprOverlay } from './cesiumOverlay.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
validAlprGroundHeight,
|
||||
} from '../visuals.js';
|
||||
|
||||
export function createAlprPresentation({ state, native, services, source }) {
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
|
||||
const { cachedGroundFloor } = services.groundFloor;
|
||||
const overlay = createAlprOverlay({
|
||||
state,
|
||||
native,
|
||||
services,
|
||||
onSurfaceChange: refreshMarkerPlacements,
|
||||
});
|
||||
let visibleRecords = [];
|
||||
let selectionStartedAt = 0;
|
||||
/** Markers still clamped while their floor cell resolves (id -> entity). */
|
||||
const clampedMarkers = new Map();
|
||||
let floorResolve = null;
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
|
||||
/**
|
||||
* Marker placement from the cached ground floor (validated mesh or DEM, the
|
||||
* ellipsoidal datum every layer shares). Ground clamping on photoreal tiles
|
||||
* re-samples every clamped marker whenever a tile streams in, which made a
|
||||
* whole-city view stutter while zooming, so it is only the placeholder for
|
||||
* a cell that has not resolved yet.
|
||||
* @param {{latitude:number, longitude:number}} record Camera.
|
||||
* @returns {{position: Cesium.Cartesian3, clamped: boolean}} Placement.
|
||||
*/
|
||||
function markerPlacement(record) {
|
||||
const floor = cachedGroundFloor(record.latitude, record.longitude);
|
||||
const clamped = !validAlprGroundHeight(floor);
|
||||
return {
|
||||
clamped,
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
clamped ? 0 : floor + MARKER_FLOOR_LIFT_M,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** Records the current view would display (viewport box, nearest first when capped). */
|
||||
const viewRecords = (records) =>
|
||||
alprViewRecords(records, viewportBox(native.viewer));
|
||||
|
||||
/**
|
||||
* Resolve the floor cells of the records this view will display, waiting at
|
||||
* most the ground service's bounded deadline. Never throws.
|
||||
* @param {Array<object>} records Candidate records.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function prepareFloors(records) {
|
||||
const resolve = services.groundFloor.resolveGroundFloorCells;
|
||||
if (typeof resolve !== 'function') return;
|
||||
const cold = viewRecords(records)
|
||||
.visible.filter(
|
||||
(record) =>
|
||||
!validAlprGroundHeight(
|
||||
cachedGroundFloor(record.latitude, record.longitude),
|
||||
),
|
||||
)
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
try {
|
||||
await floors.prepare(cold);
|
||||
} catch {
|
||||
/* markers fall back to clamping until their floors resolve */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded cameras that project inside the canvas and in front of the globe.
|
||||
* "Nearby" spans twice the camera-to-ground range, so a view can hold
|
||||
* cameras while none is on screen; the row then says so rather than
|
||||
* leaving the user to wait for markers. Runs once per render pass.
|
||||
* @returns {number|null} Count, or null without a scene to project into.
|
||||
*/
|
||||
function countOnScreen() {
|
||||
const scene = native.viewer?.scene;
|
||||
const canvas = scene?.canvas;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const camera = native.viewer?.camera;
|
||||
if (!scene || !width || !height || !camera?.positionWC) return null;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
camera.positionWC,
|
||||
);
|
||||
let count = 0;
|
||||
try {
|
||||
for (const entity of native.dataSource.entities.values) {
|
||||
const position = entity.position?.getValue?.(Cesium.JulianDate.now());
|
||||
if (!position || !occluder.isPointVisible(position)) continue;
|
||||
const point = Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
scene,
|
||||
position,
|
||||
);
|
||||
if (
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.x <= width &&
|
||||
point.y >= 0 &&
|
||||
point.y <= height
|
||||
)
|
||||
count += 1;
|
||||
}
|
||||
} catch {
|
||||
// A scene without a usable frame (startup, teardown) cannot answer.
|
||||
return null;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/** Write a marker position, respecting the overlay's pick-target swap. */
|
||||
function setMarkerPosition(entity, position, clamped) {
|
||||
const heightReference = clamped
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE;
|
||||
const saved = entity.gevAlprNativeAppearance;
|
||||
if (saved) {
|
||||
saved.position = position;
|
||||
saved.heightReference = heightReference;
|
||||
} else {
|
||||
entity.position = position;
|
||||
entity.billboard.heightReference = heightReference;
|
||||
}
|
||||
entity.gevAlprClamped = clamped;
|
||||
}
|
||||
|
||||
/** Refresh all native placements, including the appearances held by the canvas. */
|
||||
function refreshMarkerPlacements() {
|
||||
if (!state.enabled || !native.dataSource) return;
|
||||
clampedMarkers.clear();
|
||||
for (const entity of native.dataSource.entities.values) {
|
||||
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
||||
setMarkerPosition(entity, position, clamped);
|
||||
if (clamped) clampedMarkers.set(entity.id, entity);
|
||||
}
|
||||
resolveClampedFloors();
|
||||
governorRequestRender('alpr-surface');
|
||||
}
|
||||
|
||||
/** Resolve floors for clamped markers once, then place them in place. */
|
||||
function resolveClampedFloors() {
|
||||
const resolve = services.groundFloor.resolveGroundFloorCells;
|
||||
if (floorResolve || !clampedMarkers.size || typeof resolve !== 'function')
|
||||
return;
|
||||
const batch = [...clampedMarkers.values()];
|
||||
const request = Promise.resolve(
|
||||
floors.resolve(
|
||||
batch.map((entity) => ({
|
||||
lat: entity.gevAlprRecord.latitude,
|
||||
lon: entity.gevAlprRecord.longitude,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (floorResolve !== request) return;
|
||||
floorResolve = null;
|
||||
if (!state.enabled || !native.dataSource) return;
|
||||
let moved = false;
|
||||
for (const entity of batch) {
|
||||
if (clampedMarkers.get(entity.id) !== entity) continue;
|
||||
clampedMarkers.delete(entity.id);
|
||||
const { position, clamped } = markerPlacement(entity.gevAlprRecord);
|
||||
if (clamped) continue;
|
||||
setMarkerPosition(entity, position, false);
|
||||
moved = true;
|
||||
}
|
||||
if (moved) governorRequestRender('alpr-floor');
|
||||
// Cells that never resolved stay clamped; later renders retry them.
|
||||
});
|
||||
floorResolve = request;
|
||||
}
|
||||
|
||||
function updateAppearance(entity, selected) {
|
||||
const color = Cesium.Color.fromCssColorString(
|
||||
selected ? ALPR_SELECTED_COLOR : ALPR_COLOR,
|
||||
);
|
||||
entity.billboard.image = selected ? SELECTED_IMAGE : MARKER_IMAGE;
|
||||
entity.billboard.width = entity.billboard.height = selected
|
||||
? SELECTED_MARKER_ICON_SIZE
|
||||
: MARKER_ICON_SIZE;
|
||||
if (entity.gevLabelModel) {
|
||||
entity.gevLabelModel.accent = color.toCssColorString();
|
||||
entity.gevLabelModel.leaderAnimationStartedAt = selected
|
||||
? selectionStartedAt
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
|
||||
function markerColor() {
|
||||
return Cesium.Color.fromCssColorString(ALPR_COLOR);
|
||||
}
|
||||
|
||||
function viewportBox(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.scene.canvas;
|
||||
if (typeof camera?.pickEllipsoid === 'function' && canvas) {
|
||||
const width = canvas.clientWidth || canvas.width;
|
||||
const height = canvas.clientHeight || canvas.height;
|
||||
if (!width || !height) return null;
|
||||
const focus = camera.pickEllipsoid(
|
||||
new Cesium.Cartesian2(width / 2, height / 2),
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
if (!focus) return null;
|
||||
return alprFocusBox(
|
||||
Cesium.Cartographic.fromCartesian(focus),
|
||||
Cesium.Cartesian3.distance(camera.positionWC, focus),
|
||||
);
|
||||
}
|
||||
// Rectangle-only viewers retain the same bounded-area contract.
|
||||
const rectangle = viewer?.camera?.computeViewRectangle(
|
||||
viewer.scene.globe.ellipsoid,
|
||||
);
|
||||
if (!rectangle) return null;
|
||||
const south = Cesium.Math.toDegrees(rectangle.south);
|
||||
const north = Cesium.Math.toDegrees(rectangle.north);
|
||||
const west = Cesium.Math.toDegrees(rectangle.west);
|
||||
const east = Cesium.Math.toDegrees(rectangle.east);
|
||||
if (
|
||||
!Number.isFinite(south + north + west + east) ||
|
||||
east <= west ||
|
||||
north - south > MAX_VIEWPORT_DEGREES ||
|
||||
east - west > MAX_VIEWPORT_DEGREES
|
||||
)
|
||||
return null;
|
||||
return { south, west, north, east };
|
||||
}
|
||||
|
||||
function clearRendered({ retainContext = false } = {}) {
|
||||
hideOnMapCredit();
|
||||
floors.cancel();
|
||||
floorResolve = null;
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
clampedMarkers.clear();
|
||||
if (native.dataSource?.entities) native.dataSource.entities.removeAll();
|
||||
if (!retainContext) removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
function hideOnMapCredit() {
|
||||
services.credits?.hideOsmCredit?.(native.viewer, LAYER_ID);
|
||||
clearTimeout(native.creditTimer);
|
||||
native.creditTimer = null;
|
||||
if (native.credit)
|
||||
native.viewer?.creditDisplay?.removeStaticCredit(native.credit);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
function presentOnMapCredit() {
|
||||
if (
|
||||
state.enabled &&
|
||||
source.attribution?.name === 'OpenStreetMap' &&
|
||||
services.credits?.showOsmCredit
|
||||
) {
|
||||
services.credits.showOsmCredit(native.viewer, LAYER_ID);
|
||||
return;
|
||||
}
|
||||
if (!state.enabled || native.creditPresented || !native.credit) return;
|
||||
native.creditPresented = true;
|
||||
native.viewer.creditDisplay?.addStaticCredit(native.credit);
|
||||
native.creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
function renderRecords() {
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
// Over the render cap, keep the cameras nearest the view centre and say
|
||||
// coverage is limited rather than dropping an arbitrary subset.
|
||||
const { visible, capped } = viewRecords(state.records);
|
||||
state.renderSaturated = capped;
|
||||
visibleRecords = visible;
|
||||
// A refresh may retain its own selection, never reclaim one cleared or
|
||||
// replaced by an aircraft, another layer, or a voice action.
|
||||
if (
|
||||
selectedContext?.layerId !== LAYER_ID ||
|
||||
selectedContext.id !== state.selectedId ||
|
||||
!visible.some((record) => record.id === state.selectedId)
|
||||
) {
|
||||
state.selectedId = null;
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
}
|
||||
governorRequestRender('alpr-render');
|
||||
const visibleIds = new Set(visible.map((record) => record.id));
|
||||
let changed = false;
|
||||
native.dataSource.entities.suspendEvents();
|
||||
for (const entity of [...native.dataSource.entities.values]) {
|
||||
if (!visibleIds.has(entity.id)) {
|
||||
native.dataSource.entities.remove(entity);
|
||||
clampedMarkers.delete(entity.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: visibleIds });
|
||||
for (const record of visible) {
|
||||
const existing = native.dataSource.entities.getById(record.id);
|
||||
if (existing?.gevAlprRecord === record) {
|
||||
updateAppearance(existing, record.id === state.selectedId);
|
||||
continue;
|
||||
}
|
||||
const color = markerColor();
|
||||
const selected = record.id === state.selectedId;
|
||||
const { position, clamped } = markerPlacement(record);
|
||||
// Direction wedges are painted by the overlay for the nearest cameras.
|
||||
// Native ground-clamped wedges (a ground polyline plus a classification
|
||||
// polygon per camera) cost ~40 ms per frame for 200 downtown cameras on
|
||||
// photoreal tiles, so markers stay billboards only.
|
||||
const entityDef = {
|
||||
id: record.id,
|
||||
position,
|
||||
billboard: {
|
||||
image: selected ? SELECTED_IMAGE : MARKER_IMAGE,
|
||||
width: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
height: selected ? SELECTED_MARKER_ICON_SIZE : MARKER_ICON_SIZE,
|
||||
heightReference: clamped
|
||||
? Cesium.HeightReference.CLAMP_TO_GROUND
|
||||
: Cesium.HeightReference.NONE,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
// City-wide views hold hundreds of badges; let them shrink.
|
||||
scaleByDistance: new Cesium.NearFarScalar(
|
||||
...MARKER_SCALE_BY_DISTANCE,
|
||||
),
|
||||
},
|
||||
};
|
||||
let entity = existing;
|
||||
// Keep the Cesium entity and its ground-clamping subscription while its
|
||||
// geometry is unchanged. Updating metadata must not rebuild the marker.
|
||||
const previous = entity?.gevAlprRecord;
|
||||
if (!entity) {
|
||||
entity = native.dataSource.entities.add(entityDef);
|
||||
entity.gevAlprClamped = clamped;
|
||||
changed = true;
|
||||
} else {
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
setMarkerPosition(entity, position, clamped);
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprDisplayPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
entity.gevAlprCanvasPosition = null;
|
||||
entity.gevAlprWedge = null;
|
||||
}
|
||||
}
|
||||
entity.gevAlprRecord = record;
|
||||
entity.gevTrackedId = record.id;
|
||||
// The mapped camera datum has no elevation; it is not the clamped marker's
|
||||
// visual anchor. Only the selected marker samples the rendered surface,
|
||||
// at most once per second, through Cesium's public height APIs.
|
||||
entity.gevAlprDisplayPosition ??= null;
|
||||
entity.gevDisplayPosition = () => entity.gevAlprDisplayPosition;
|
||||
entity.gevLabelModel = alprLabelModel(record, source, {
|
||||
accent: color.toCssColorString(),
|
||||
startedAt: selected ? selectionStartedAt : 0,
|
||||
});
|
||||
updateAppearance(entity, selected);
|
||||
registerEntityContext(entity, {
|
||||
...alprEntityContext(record, source),
|
||||
dataSource: native.dataSource,
|
||||
});
|
||||
}
|
||||
native.dataSource.entities.resumeEvents();
|
||||
if (changed) state.renderRevision += 1;
|
||||
state.onScreen = countOnScreen();
|
||||
for (const entity of native.dataSource.entities.values)
|
||||
if (entity.gevAlprClamped) clampedMarkers.set(entity.id, entity);
|
||||
resolveClampedFloors();
|
||||
const selectedEntity = state.selectedId
|
||||
? native.dataSource.entities.getById(state.selectedId)
|
||||
: null;
|
||||
if (!selectedEntity) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visible);
|
||||
if (selectedEntity) services.overlays?.refreshReadout?.(selectedEntity);
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (visible.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
}
|
||||
|
||||
function focusNearest() {
|
||||
const camera = native.viewer?.camera;
|
||||
if (!state.enabled || !camera?.positionWC || native.viewer.trackedEntity)
|
||||
return false;
|
||||
let nearest = null,
|
||||
distance = Infinity;
|
||||
for (const entity of native.dataSource.entities.values) {
|
||||
const position = entity.position.getValue(Cesium.JulianDate.now());
|
||||
const candidate = Cesium.Cartesian3.distanceSquared(
|
||||
camera.positionWC,
|
||||
position,
|
||||
);
|
||||
if (candidate < distance) {
|
||||
nearest = entity;
|
||||
distance = candidate;
|
||||
}
|
||||
}
|
||||
if (!nearest) return false;
|
||||
const record = state.recordById.get(nearest.id);
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
let height;
|
||||
if (native.viewer.scene.sampleHeightSupported) {
|
||||
try {
|
||||
height = native.viewer.scene.sampleHeight(location, [nearest]);
|
||||
} catch {
|
||||
/* tiles may still be streaming */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && native.viewer.scene.globe.show)
|
||||
height = native.viewer.scene.globe.getHeight?.(location);
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
const center = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
camera.flyToBoundingSphere(new Cesium.BoundingSphere(center, 30), {
|
||||
duration: 1.2,
|
||||
offset: new Cesium.HeadingPitchRange(
|
||||
camera.heading || 0,
|
||||
-Math.PI / 4,
|
||||
800,
|
||||
),
|
||||
});
|
||||
governorRequestRender('alpr-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const entity = native.dataSource?.entities.getById(id);
|
||||
if (!entity || !state.recordById.has(id)) return false;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
|
||||
updateAppearance(entity, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
selectEntityContext(entity);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
governorRequestRender('alpr-selection');
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const entity = state.selectedId
|
||||
? native.dataSource?.entities.getById(state.selectedId)
|
||||
: null;
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (entity && record) updateAppearance(entity, false);
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
overlay.sync(visibleRecords);
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
governorRequestRender('alpr-selection');
|
||||
}
|
||||
|
||||
function updateSelectedAnchor() {
|
||||
if (!state.enabled || !state.selectedId) return;
|
||||
const selected = getSelectedEntityContext();
|
||||
if (selected?.id !== state.selectedId) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const entity = native.dataSource?.entities.getById(state.selectedId);
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (entity?.gevAlprCanvasPosition) {
|
||||
entity.gevAlprDisplayPosition = entity.gevAlprCanvasPosition;
|
||||
return;
|
||||
}
|
||||
if (!entity || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||
return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
const scene = native.viewer.scene;
|
||||
const location = Cesium.Cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
let height;
|
||||
if (scene.sampleHeightSupported) {
|
||||
try {
|
||||
height = scene.sampleHeight(location, [entity]);
|
||||
} catch {
|
||||
/* tiles may still be streaming */
|
||||
}
|
||||
}
|
||||
if (!validAlprGroundHeight(height) && scene.globe.show)
|
||||
height = scene.globe.getHeight?.(location);
|
||||
if (!validAlprGroundHeight(height))
|
||||
height = cachedGroundFloor(record.latitude, record.longitude);
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = Cesium.Cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
if (
|
||||
!entity.gevAlprDisplayPosition ||
|
||||
Cesium.Cartesian3.distance(next, entity.gevAlprDisplayPosition) > 0.1
|
||||
) {
|
||||
entity.gevAlprDisplayPosition = next;
|
||||
governorRequestRender('alpr-anchor');
|
||||
}
|
||||
}
|
||||
|
||||
function installInteraction(viewer) {
|
||||
if (native.clickHandler) return;
|
||||
native.clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
native.clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const id =
|
||||
overlay.pick(click.position) ||
|
||||
(typeof picked?.id?.id === 'string' ? picked.id.id : null);
|
||||
if (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
|
||||
)
|
||||
selectRecord(id);
|
||||
else if (state.selectedId) clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
return {
|
||||
initOverlay: () => overlay.init(),
|
||||
destroyOverlay: () => overlay.destroy(),
|
||||
markerColor,
|
||||
viewportBox: () => viewportBox(native.viewer),
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
presentOnMapCredit,
|
||||
renderRecords,
|
||||
selectRecord,
|
||||
focusNearest,
|
||||
clearSelection,
|
||||
updateSelectedAnchor,
|
||||
installInteraction,
|
||||
prepareFloors,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,769 @@
|
||||
// The ALPR camera badges on the globe contract (engine.movers, engine.input,
|
||||
// engine.camera, engine.projector, engine.surface, engine.credits), the
|
||||
// same on every engine. Each displayed camera is a plain marker handle: a
|
||||
// 38 px badge in one sprite set, or the 60 px selected badge in a set of
|
||||
// one drawn over it, shrinking with distance, always on top and culled
|
||||
// behind the globe's horizon. The overlay (../overlay.js) paints the
|
||||
// nearest cameras' artwork and bearing wedges on the canvas and keeps a
|
||||
// faint, unscaled badge under each as a pick target. Records, requests and
|
||||
// the view's camera set stay in the layer (../index.js).
|
||||
//
|
||||
// The contract has no ground clamping: a marker sits on its cached ground
|
||||
// floor cell, else on the terrain, and waits unseen until one of them is
|
||||
// known; floors still resolving are asked for once per render.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { distanceScale } from '../../../globe/cpuPicks.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { alprCreditMarkup } from '../model.js';
|
||||
import { createAlprOverlayCore } from '../overlay.js';
|
||||
import {
|
||||
alprEntityContext,
|
||||
alprFocusBox,
|
||||
alprLabelModel,
|
||||
alprViewRecords,
|
||||
} from '../presentation.js';
|
||||
import { createAlprFloorResolver } from '../floors.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
validAlprGroundHeight,
|
||||
} from '../visuals.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
MAX_RENDERED,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
CREDIT_DISPLAY_MS,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from '../policy.js';
|
||||
|
||||
export const ALPR_SELECTED_SET_ID = 'alpr-selected';
|
||||
// Under the moving contacts (src/data/spriteOrder.js), as map data; the
|
||||
// selected badge over the others.
|
||||
const MARKER_ORDER = -1;
|
||||
const SELECTED_ORDER = -0.5;
|
||||
// What a canvas-owned marker keeps of its badge: enough for a pick.
|
||||
const PICK_TARGET_ALPHA = 0.01;
|
||||
// The label accents, in Cesium's CSS form.
|
||||
const ACCENT = rgbaFromCss(ALPR_COLOR).toCssColorString();
|
||||
const SELECTED_ACCENT = rgbaFromCss(ALPR_SELECTED_COLOR).toCssColorString();
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, source: object }} options
|
||||
* The layer's state, services and camera source.
|
||||
*/
|
||||
export function createAlprDisplay(engine, { state, services, source }) {
|
||||
if (!engine) throw new TypeError('The ALPR display needs a globe engine');
|
||||
const { governorRequestRender } = services.render;
|
||||
const {
|
||||
clearSelectedEntityContextForLayer,
|
||||
getSelectedEntityContext,
|
||||
registerEntityContext,
|
||||
removeEntityContextsForLayer,
|
||||
selectEntityContext,
|
||||
} = services.context;
|
||||
const markers = new Map();
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
let normal = null,
|
||||
selected = null,
|
||||
initialized = false,
|
||||
visible = false;
|
||||
let visibleRecords = [],
|
||||
selectionStartedAt = 0,
|
||||
generation = 0,
|
||||
floorRequest = null;
|
||||
let removeMove = null,
|
||||
removeFrame = null,
|
||||
removeClick = null;
|
||||
let creditMarkup = null,
|
||||
removeCredit = null,
|
||||
creditTimer = null,
|
||||
creditPresented = false;
|
||||
let lastCamera = null,
|
||||
dirty = false;
|
||||
|
||||
const cachedFloor = (record) =>
|
||||
services.groundFloor.cachedGroundFloor(record.latitude, record.longitude);
|
||||
const location = (record) =>
|
||||
geo.cartographic.fromDegrees(record.longitude, record.latitude);
|
||||
const ownSets = () => [normal, selected].filter(Boolean);
|
||||
|
||||
// Each sample is a depth render on Cesium, a raycast on Navara.
|
||||
function sampleHeight(record) {
|
||||
if (!engine.surface.supported) return undefined;
|
||||
try {
|
||||
return engine.surface.sampleHeight(location(record), ownSets());
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
function terrainHeight(record) {
|
||||
return engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(location(record))
|
||||
: undefined;
|
||||
}
|
||||
/** The rendered surface under a camera, else the terrain, else its floor. */
|
||||
function surfaceHeight(record) {
|
||||
let height = sampleHeight(record);
|
||||
if (!validAlprGroundHeight(height)) height = terrainHeight(record);
|
||||
if (!validAlprGroundHeight(height)) height = cachedFloor(record);
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marker placement from the cached ground floor (validated mesh or DEM,
|
||||
* the ellipsoidal datum every layer shares); a cell that has not resolved
|
||||
* yet places it on the terrain until it does (`clamped`).
|
||||
*/
|
||||
function placement(record) {
|
||||
const floor = cachedFloor(record);
|
||||
const clamped = !validAlprGroundHeight(floor);
|
||||
const height = clamped ? terrainHeight(record) : floor;
|
||||
const resolved = validAlprGroundHeight(height);
|
||||
return {
|
||||
clamped,
|
||||
resolved,
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
resolved ? height + MARKER_FLOOR_LIFT_M : 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function viewportBox() {
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
const focus = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
if (!focus) return null;
|
||||
return alprFocusBox(
|
||||
geo.cartographic.fromCartesian(focus),
|
||||
geo.cartesian3.distance(engine.camera.livePosition, focus),
|
||||
);
|
||||
}
|
||||
/** Records the current view would display (nearest first when capped). */
|
||||
const viewRecords = (records) => alprViewRecords(records, viewportBox());
|
||||
|
||||
/**
|
||||
* Writes a marker's badge when it changed: its own place, or the canvas
|
||||
* anchor at full size and nearly transparent while the overlay paints it.
|
||||
*/
|
||||
function syncMarker(marker) {
|
||||
if (!initialized) return;
|
||||
const owned = marker.canvasOwned;
|
||||
const position =
|
||||
owned && marker.gevAlprCanvasPosition
|
||||
? marker.gevAlprCanvasPosition
|
||||
: marker.position;
|
||||
const isSelected = marker.id === state.selectedId;
|
||||
// The sets shrink badges with distance; a pick target keeps its size.
|
||||
const scale = owned
|
||||
? 1 /
|
||||
distanceScale(
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
geo.cartesian3.distance(engine.camera.livePosition, position),
|
||||
)
|
||||
: 1;
|
||||
const show =
|
||||
visible &&
|
||||
state.enabled &&
|
||||
marker.shown &&
|
||||
marker.resolved &&
|
||||
engine.projector.horizonVisible(position);
|
||||
const alpha = owned ? PICK_TARGET_ALPHA : 1;
|
||||
const previous = marker.drawn;
|
||||
if (
|
||||
previous &&
|
||||
previous.position === position &&
|
||||
previous.scale === scale &&
|
||||
previous.show === show &&
|
||||
previous.alpha === alpha &&
|
||||
previous.selected === isSelected
|
||||
)
|
||||
return;
|
||||
marker.drawn = { position, scale, show, alpha, selected: isSelected };
|
||||
const fields = {
|
||||
position,
|
||||
scale,
|
||||
show,
|
||||
color: [1, 1, 1, alpha],
|
||||
pickId: marker.id,
|
||||
};
|
||||
normal.set(marker.index, { ...fields, show: show && !isSelected });
|
||||
if (isSelected) {
|
||||
selected.setCount(1);
|
||||
selected.set(0, fields);
|
||||
}
|
||||
// The overlay changes ownership while it paints, after the frame's
|
||||
// render request was taken: wake the next frame, which commits.
|
||||
if (!dirty) queueMicrotask(requestDirtySprites);
|
||||
dirty = true;
|
||||
}
|
||||
function requestDirtySprites() {
|
||||
if (dirty) engine.render.request('alpr-render');
|
||||
}
|
||||
function flushSprites() {
|
||||
if (!dirty || !initialized) return;
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
dirty = false;
|
||||
}
|
||||
function dropSelectedSprite() {
|
||||
if (!initialized) return;
|
||||
selected.setCount(0);
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
const overlay = createAlprOverlayCore({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange: refreshPlacements,
|
||||
engine: {
|
||||
marker: (id) => markers.get(id),
|
||||
markers: () => markers.values(),
|
||||
canvasOwned(marker, owned) {
|
||||
if (marker.canvasOwned === owned) return;
|
||||
marker.canvasOwned = owned;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setShown(marker, shown) {
|
||||
if (marker.shown === shown) return;
|
||||
marker.shown = shown;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setDisplayPosition(marker, position) {
|
||||
marker.displayPosition = position;
|
||||
},
|
||||
placeAnchor(marker, record, height) {
|
||||
marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
marker.resolved = true;
|
||||
syncMarker(marker);
|
||||
},
|
||||
surfaceSupported: () => engine.surface.supported,
|
||||
sampleHeight: (record) => sampleHeight(record),
|
||||
terrainHeight,
|
||||
surfaceSettled: () => engine.surface.tilesLoaded(),
|
||||
cameraPose: () => engine.camera.pose(),
|
||||
cameraPosition: () => engine.camera.livePosition,
|
||||
projector: ({ width, height, viewProjectionMatrix: matrix }) => ({
|
||||
horizonVisible: (point) => engine.projector.horizonVisible(point),
|
||||
// The host captured the camera and CSS viewport for this frame;
|
||||
// every wedge vertex reuses it instead of rereading DOM geometry.
|
||||
project({ x, y, z }) {
|
||||
const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||
if (!(w > 0)) return null;
|
||||
return {
|
||||
x:
|
||||
(((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) /
|
||||
w +
|
||||
1) *
|
||||
width) /
|
||||
2,
|
||||
y:
|
||||
((1 -
|
||||
(matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) /
|
||||
w) *
|
||||
height) /
|
||||
2,
|
||||
};
|
||||
},
|
||||
}),
|
||||
cullOffscreenWedges: true,
|
||||
createId: () => globalThis.crypto.randomUUID(),
|
||||
},
|
||||
});
|
||||
|
||||
/** Places every marker again (a new map stack moved the surface). */
|
||||
function refreshPlacements() {
|
||||
if (!state.enabled || !initialized) return;
|
||||
generation++;
|
||||
floorRequest = null;
|
||||
floors.cancel();
|
||||
for (const marker of markers.values()) {
|
||||
Object.assign(marker, placement(marker.record));
|
||||
marker.drawn = null;
|
||||
syncMarker(marker);
|
||||
}
|
||||
resolveFloors();
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-surface');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the floor cells of the records this view will display, waiting
|
||||
* at most the ground service's bounded deadline. Never throws.
|
||||
*/
|
||||
async function prepareFloors(records) {
|
||||
if (typeof services.groundFloor.resolveGroundFloorCells !== 'function')
|
||||
return;
|
||||
const cold = viewRecords(records)
|
||||
.visible.filter((record) => !validAlprGroundHeight(cachedFloor(record)))
|
||||
.map((record) => ({ lat: record.latitude, lon: record.longitude }));
|
||||
if (!cold.length) return;
|
||||
try {
|
||||
await floors.prepare(cold);
|
||||
} catch {
|
||||
/* markers wait on the terrain until their floors resolve */
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the clamped markers' floors once, then places them in place. */
|
||||
function resolveFloors() {
|
||||
if (
|
||||
floorRequest ||
|
||||
typeof services.groundFloor.resolveGroundFloorCells !== 'function'
|
||||
)
|
||||
return;
|
||||
const batch = [...markers.values()].filter((marker) => marker.clamped);
|
||||
if (!batch.length) return;
|
||||
const epoch = generation;
|
||||
const request = Promise.resolve(
|
||||
floors.resolve(
|
||||
batch.map((marker) => ({
|
||||
lat: marker.record.latitude,
|
||||
lon: marker.record.longitude,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (epoch !== generation || floorRequest !== request) return;
|
||||
floorRequest = null;
|
||||
if (!state.enabled || !initialized) return;
|
||||
let moved = false;
|
||||
for (const marker of batch) {
|
||||
if (markers.get(marker.id) !== marker) continue;
|
||||
const next = placement(marker.record);
|
||||
// Cells that never resolved stay clamped; later renders retry them.
|
||||
if (next.clamped) continue;
|
||||
Object.assign(marker, next);
|
||||
syncMarker(marker);
|
||||
moved = true;
|
||||
}
|
||||
if (!moved) return;
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-floor');
|
||||
});
|
||||
floorRequest = request;
|
||||
}
|
||||
|
||||
function updateAppearance(marker, active) {
|
||||
if (marker.gevLabelModel) {
|
||||
marker.gevLabelModel.accent = active ? SELECTED_ACCENT : ACCENT;
|
||||
marker.gevLabelModel.leaderAnimationStartedAt = active
|
||||
? selectionStartedAt
|
||||
: 0;
|
||||
}
|
||||
syncMarker(marker);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loaded cameras that project inside the canvas and in front of the globe.
|
||||
* "Nearby" spans twice the camera-to-ground range, so a view can hold
|
||||
* cameras while none is on screen; the row then says so rather than
|
||||
* leaving the user to wait for markers.
|
||||
* @returns {number|null} Count, or null without a canvas to project into.
|
||||
*/
|
||||
function countOnScreen() {
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
let count = 0;
|
||||
for (const marker of markers.values()) {
|
||||
if (!engine.projector.horizonVisible(marker.position)) continue;
|
||||
const point = engine.projector.worldToWindow(marker.position);
|
||||
if (
|
||||
point &&
|
||||
point.x >= 0 &&
|
||||
point.x <= width &&
|
||||
point.y >= 0 &&
|
||||
point.y <= height
|
||||
)
|
||||
count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function hideOnMapCredit() {
|
||||
services.credits?.hideOsmCredit?.(engine, LAYER_ID);
|
||||
clearTimeout(creditTimer);
|
||||
creditTimer = null;
|
||||
removeCredit?.();
|
||||
removeCredit = null;
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
/**
|
||||
* The OSM credit inline while cameras show; another source's credit on
|
||||
* screen for a few seconds after it first displays.
|
||||
*/
|
||||
function presentOnMapCredit() {
|
||||
if (
|
||||
state.enabled &&
|
||||
source.attribution?.name === 'OpenStreetMap' &&
|
||||
services.credits?.showOsmCredit
|
||||
) {
|
||||
services.credits.showOsmCredit(engine, LAYER_ID);
|
||||
return;
|
||||
}
|
||||
if (!state.enabled || creditPresented || !creditMarkup) return;
|
||||
creditPresented = true;
|
||||
removeCredit = engine.credits.addStatic(creditMarkup, { onScreen: true });
|
||||
creditTimer = setTimeout(hideOnMapCredit, CREDIT_DISPLAY_MS);
|
||||
governorRequestRender('alpr-credit');
|
||||
}
|
||||
|
||||
function renderRecords() {
|
||||
if (!initialized) return;
|
||||
const selectedContext = getSelectedEntityContext();
|
||||
// Over the render cap, keep the cameras nearest the view centre and say
|
||||
// coverage is limited rather than dropping an arbitrary subset.
|
||||
const { visible: shown, capped } = viewRecords(state.records);
|
||||
state.renderSaturated = capped;
|
||||
visibleRecords = shown;
|
||||
// A refresh may retain its own selection, never reclaim one cleared or
|
||||
// replaced by an aircraft, another layer, or a voice action.
|
||||
if (
|
||||
selectedContext?.layerId !== LAYER_ID ||
|
||||
selectedContext.id !== state.selectedId ||
|
||||
!shown.some((record) => record.id === state.selectedId)
|
||||
) {
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
dropSelectedSprite();
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
}
|
||||
governorRequestRender('alpr-render');
|
||||
const ids = new Set(shown.map((record) => record.id));
|
||||
let changed = false;
|
||||
for (const id of [...markers.keys()])
|
||||
if (!ids.has(id)) {
|
||||
markers.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
removeEntityContextsForLayer(LAYER_ID, { retainIds: ids });
|
||||
for (const record of shown) {
|
||||
let marker = markers.get(record.id);
|
||||
const isSelected = record.id === state.selectedId;
|
||||
if (marker?.record === record) continue;
|
||||
if (!marker) {
|
||||
marker = {
|
||||
id: record.id,
|
||||
record,
|
||||
...placement(record),
|
||||
index: -1,
|
||||
shown: true,
|
||||
canvasOwned: false,
|
||||
displayPosition: null,
|
||||
gevAlprCanvasPosition: null,
|
||||
gevAlprWedge: null,
|
||||
drawn: null,
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
changed = true;
|
||||
} else {
|
||||
// The handle and its badge stay while the geometry is unchanged:
|
||||
// updating metadata must not rebuild the marker.
|
||||
const previous = marker.record;
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
Object.assign(marker, placement(record));
|
||||
marker.displayPosition = null;
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
marker.gevAlprWedge = null;
|
||||
}
|
||||
marker.record = record;
|
||||
}
|
||||
// The mapped camera datum has no elevation; it is not the marker's
|
||||
// visual anchor. Only the selected marker samples the rendered
|
||||
// surface, at most once per second.
|
||||
marker.gevTrackedId = record.id;
|
||||
marker.gevDisplayPosition = () => marker.displayPosition;
|
||||
marker.gevLabelModel = alprLabelModel(record, source, {
|
||||
accent: ACCENT,
|
||||
startedAt: isSelected ? selectionStartedAt : 0,
|
||||
});
|
||||
registerEntityContext(marker, alprEntityContext(record, source));
|
||||
}
|
||||
normal.setCount(markers.size);
|
||||
let index = 0;
|
||||
for (const marker of markers.values()) {
|
||||
marker.index = index++;
|
||||
marker.drawn = null;
|
||||
updateAppearance(marker, marker.id === state.selectedId);
|
||||
}
|
||||
flushSprites();
|
||||
if (changed) state.renderRevision += 1;
|
||||
state.onScreen = countOnScreen();
|
||||
resolveFloors();
|
||||
const chosen = state.selectedId ? markers.get(state.selectedId) : null;
|
||||
if (!chosen) state.selectedId = null;
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(shown);
|
||||
if (chosen) services.overlays?.refreshReadout?.(chosen);
|
||||
// Keep the on-map credit tied to displayed data, not loading or zoom prompts.
|
||||
if (shown.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
}
|
||||
|
||||
function clearRendered({ retainContext = false } = {}) {
|
||||
hideOnMapCredit();
|
||||
generation++;
|
||||
floors.cancel();
|
||||
floorRequest = null;
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
markers.clear();
|
||||
if (initialized) {
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
dirty = true;
|
||||
flushSprites();
|
||||
}
|
||||
if (!retainContext) removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
|
||||
function selectRecord(id) {
|
||||
const marker = markers.get(id);
|
||||
if (!marker || !state.recordById.has(id)) return false;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
selectionStartedAt = globalThis.performance?.now?.() ?? Date.now();
|
||||
updateAppearance(marker, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
selectEntityContext(marker);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
flushSprites();
|
||||
governorRequestRender('alpr-selection');
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
const marker = state.selectedId ? markers.get(state.selectedId) : null;
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
dropSelectedSprite();
|
||||
if (marker) updateAppearance(marker, false);
|
||||
overlay.sync(visibleRecords);
|
||||
flushSprites();
|
||||
clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
governorRequestRender('alpr-selection');
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected marker's readout anchor: its canvas anchor, else the
|
||||
* rendered surface under it, sampled at most once per second.
|
||||
*/
|
||||
function updateSelectedAnchor() {
|
||||
if (!state.enabled || !state.selectedId) return;
|
||||
if (getSelectedEntityContext()?.id !== state.selectedId) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const marker = markers.get(state.selectedId);
|
||||
const record = state.recordById.get(state.selectedId);
|
||||
if (marker?.gevAlprCanvasPosition) {
|
||||
marker.displayPosition = marker.gevAlprCanvasPosition;
|
||||
return;
|
||||
}
|
||||
if (!marker || !record || Date.now() - state.lastAnchorSampleAt < 1000)
|
||||
return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
const height = surfaceHeight(record);
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
if (
|
||||
!marker.displayPosition ||
|
||||
geo.cartesian3.distance(next, marker.displayPosition) > 0.1
|
||||
) {
|
||||
marker.displayPosition = next;
|
||||
governorRequestRender('alpr-anchor');
|
||||
}
|
||||
}
|
||||
|
||||
function focusNearest() {
|
||||
if (!state.enabled || !initialized || engine.camera.following())
|
||||
return false;
|
||||
let nearest = null,
|
||||
distance = Infinity;
|
||||
for (const marker of markers.values()) {
|
||||
const candidate = geo.cartesian3.distanceSquared(
|
||||
engine.camera.livePosition,
|
||||
marker.position,
|
||||
);
|
||||
if (candidate < distance) {
|
||||
nearest = marker;
|
||||
distance = candidate;
|
||||
}
|
||||
}
|
||||
if (!nearest) return false;
|
||||
const record = state.recordById.get(nearest.id);
|
||||
const height = record ? surfaceHeight(record) : undefined;
|
||||
if (!selectRecord(nearest.id)) return false;
|
||||
const center = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
engine.camera.flyToSphere(
|
||||
{ center, radius: 30 },
|
||||
{
|
||||
duration: 1.2,
|
||||
heading: engine.camera.pose().heading || 0,
|
||||
pitch: -Math.PI / 4,
|
||||
range: 800,
|
||||
},
|
||||
);
|
||||
governorRequestRender('alpr-focus');
|
||||
return true;
|
||||
}
|
||||
|
||||
function onClick({ position, picked }) {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
if (!state.enabled) return;
|
||||
const id =
|
||||
overlay.pick(position) ||
|
||||
(typeof picked?.id === 'string' ? picked.id : null);
|
||||
if (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
(id !== state.selectedId || getSelectedEntityContext()?.id !== id)
|
||||
)
|
||||
selectRecord(id);
|
||||
else if (state.selectedId) clearSelection();
|
||||
}
|
||||
|
||||
// Badges move on screen with the camera: their horizon test and a pick
|
||||
// target's size follow it.
|
||||
function frame() {
|
||||
const camera = engine.camera.livePosition;
|
||||
if (
|
||||
!lastCamera ||
|
||||
!geo.cartesian3.equalsEpsilon(camera, lastCamera, 0, 0.00001)
|
||||
) {
|
||||
lastCamera = geo.cartesian3.clone(camera);
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
}
|
||||
flushSprites();
|
||||
updateSelectedAnchor();
|
||||
}
|
||||
|
||||
function createSet(id, sizePx, capacity, image, order) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'sprite',
|
||||
capacity,
|
||||
order,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
sharpen: true,
|
||||
sprite: {
|
||||
sizePx,
|
||||
// City-wide views hold hundreds of badges; they shrink.
|
||||
scaleByDistance: MARKER_SCALE_BY_DISTANCE,
|
||||
atlas: { url: image, cells: { camera: [0, 0, 1, 1] } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
get ready() {
|
||||
return initialized;
|
||||
},
|
||||
count: () => markers.size,
|
||||
following: () => Boolean(engine.camera.following()),
|
||||
viewportBox,
|
||||
prepareFloors,
|
||||
renderRecords,
|
||||
clearRendered,
|
||||
hideOnMapCredit,
|
||||
selectRecord,
|
||||
clearSelection,
|
||||
focusNearest,
|
||||
/** The marker handles displayed now. */
|
||||
markers: () => [...markers.values()],
|
||||
init(_viewer, { onMove }) {
|
||||
if (initialized) throw new Error('ALPR renderer is already initialized');
|
||||
// A source credit that is not a public HTTPS link fails here.
|
||||
creditMarkup = alprCreditMarkup(source.attribution);
|
||||
normal = createSet(
|
||||
LAYER_ID,
|
||||
MARKER_ICON_SIZE,
|
||||
MAX_RENDERED,
|
||||
MARKER_IMAGE,
|
||||
MARKER_ORDER,
|
||||
);
|
||||
selected = createSet(
|
||||
ALPR_SELECTED_SET_ID,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
1,
|
||||
SELECTED_IMAGE,
|
||||
SELECTED_ORDER,
|
||||
);
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
initialized = true;
|
||||
removeMove = engine.camera.moveEnd.addEventListener(onMove);
|
||||
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||
overlay.init();
|
||||
},
|
||||
/** Shows the badges and takes clicks, or hides both. */
|
||||
setVisible(value) {
|
||||
const next = Boolean(value);
|
||||
if (next) creditPresented = false;
|
||||
removeClick?.();
|
||||
removeClick = next
|
||||
? engine.input.on('click', onClick, { pick: true })
|
||||
: null;
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
flushSprites();
|
||||
},
|
||||
destroy(options) {
|
||||
if (!initialized) return;
|
||||
clearRendered(options);
|
||||
overlay.destroy();
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
removeMove?.();
|
||||
removeMove = null;
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
normal.destroy();
|
||||
selected.destroy();
|
||||
normal = selected = null;
|
||||
initialized = false;
|
||||
visible = false;
|
||||
lastCamera = null;
|
||||
dirty = false;
|
||||
creditPresented = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,875 @@
|
||||
// The ALPR badges on the engine contract, on the fake engine and on the
|
||||
// Cesium engine (real Cesium billboards over a stub scene: they draw
|
||||
// nothing but hold what the engine writes): the badge sets as the layer
|
||||
// styles them, floors and the horizon, clicks, selection and its readout
|
||||
// anchor, the render cap, credits, the nearest-camera flight, the canvas
|
||||
// overlay's cohort and pick targets, and what toggles and teardown leave.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { setLocale } from '../../../i18n/index.js';
|
||||
import { shownEntityLabel } from '../../../voice/speech.js';
|
||||
import { distanceScale } from '../../../globe/cpuPicks.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { MARKER_SCALE_BY_DISTANCE } from '../policy.js';
|
||||
import { directionWedgePositions } from '../visuals.js';
|
||||
import { createAlprDisplay } from './display.js';
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const heightOf = (position) => geo.cartographic.fromCartesian(position).height;
|
||||
const SOURCE = {
|
||||
label: 'Fixture',
|
||||
attribution: {
|
||||
name: 'Fixture cameras',
|
||||
text: 'Fixture cameras',
|
||||
href: 'https://example.com/cameras',
|
||||
},
|
||||
};
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
let picked;
|
||||
engine.fake.script('pick', () => picked);
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
frame: () => engine.fake.frame(),
|
||||
click(target) {
|
||||
picked = target;
|
||||
engine.fake.input('click', { position: { x: -50, y: -50 } });
|
||||
},
|
||||
credits: () => engine.fake.credits,
|
||||
surface(height) {
|
||||
engine.fake.script('sampleHeight', height);
|
||||
},
|
||||
terrain(height) {
|
||||
engine.fake.script('terrainHeight', height);
|
||||
},
|
||||
/** A badge as drawn: set id → instance → { shown, sizePx, alpha, scale, position }. */
|
||||
badge(id, index) {
|
||||
const { store, options } = engine.fake.moverSet(id);
|
||||
const at = index * 3;
|
||||
return {
|
||||
shown: index < store.count && store.shows[index] === 1,
|
||||
sizePx: options.sprite.sizePx,
|
||||
alpha: store.colors[index * 4 + 3],
|
||||
scale: store.scales[index],
|
||||
id: store.pickIds[index],
|
||||
position: {
|
||||
x: store.positions[at],
|
||||
y: store.positions[at + 1],
|
||||
z: store.positions[at + 2],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const handlers = createFakeEventHandlers();
|
||||
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
|
||||
engineOptions: {
|
||||
createEventHandler: handlers.create,
|
||||
scheduleFrame: (run) => run(),
|
||||
},
|
||||
});
|
||||
const credits = [];
|
||||
viewer.creditDisplay = {
|
||||
addStaticCredit: (credit) =>
|
||||
credits.push({
|
||||
html: credit.html,
|
||||
onScreen: credit.showOnScreen,
|
||||
credit,
|
||||
}),
|
||||
removeStaticCredit(credit) {
|
||||
const at = credits.findIndex((entry) => entry.credit === credit);
|
||||
if (at >= 0) credits.splice(at, 1);
|
||||
},
|
||||
};
|
||||
viewer.scene.globe.getHeight = () => undefined;
|
||||
// What a camera flight needs of the scene.
|
||||
viewer.scene.tweens = new Cesium.TweenCollection();
|
||||
viewer.scene.screenSpaceCameraController = {
|
||||
minimumZoomDistance: 1,
|
||||
maximumZoomDistance: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
let native;
|
||||
viewer.scene.pick = () => native;
|
||||
viewer.scene.drillPick = () => (native ? [native] : []);
|
||||
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
viewer,
|
||||
primitive,
|
||||
frame,
|
||||
click(target) {
|
||||
native =
|
||||
target?.owner && primitive(target.owner)
|
||||
? { primitive: primitive(target.owner).get(target.instance) }
|
||||
: target
|
||||
? { id: target.id, primitive: {} }
|
||||
: undefined;
|
||||
for (const handler of handlers.handlers)
|
||||
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
|
||||
position: new Cesium.Cartesian2(-50, -50),
|
||||
});
|
||||
},
|
||||
credits: () => credits,
|
||||
surface(height) {
|
||||
viewer.scene.sampleHeightSupported = true;
|
||||
viewer.scene.sampleHeight = () => height;
|
||||
},
|
||||
terrain(height) {
|
||||
viewer.scene.globe.getHeight = () => height;
|
||||
},
|
||||
badge(id, index) {
|
||||
const billboard = primitive(id).get(index);
|
||||
return {
|
||||
shown: index < count(id) && billboard.show,
|
||||
sizePx: billboard.width,
|
||||
alpha: billboard.color.alpha,
|
||||
scale: billboard.scale,
|
||||
id: billboard.id,
|
||||
position: billboard.position,
|
||||
native: billboard,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESSES = [fakeHarness, cesiumHarness];
|
||||
|
||||
function services() {
|
||||
const contexts = new Map();
|
||||
let selected = null;
|
||||
return {
|
||||
contexts,
|
||||
get selected() {
|
||||
return selected;
|
||||
},
|
||||
otherSelection() {
|
||||
selected = { id: 'flight:1', layerId: 'flights' };
|
||||
},
|
||||
render: { governorRequestRender() {} },
|
||||
groundFloor: { cachedGroundFloor: () => 150 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selected,
|
||||
clearSelectedEntityContextForLayer(layerId) {
|
||||
if (selected?.layerId === layerId) selected = null;
|
||||
},
|
||||
removeEntityContextsForLayer(_id, { retainIds = new Set() } = {}) {
|
||||
for (const id of contexts.keys())
|
||||
if (!retainIds.has(id)) contexts.delete(id);
|
||||
},
|
||||
registerEntityContext(entity, metadata) {
|
||||
contexts.set(metadata.id, { ...metadata, entity });
|
||||
},
|
||||
selectEntityContext(entity) {
|
||||
selected = [...contexts.values()].find((c) => c.entity === entity);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const records = (count, extra = {}) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `alpr:${i}`,
|
||||
latitude: 30 + i * 0.00001,
|
||||
longitude: -97,
|
||||
osmId: i,
|
||||
...extra,
|
||||
}));
|
||||
|
||||
function setup(
|
||||
makeHarness,
|
||||
{ source = SOURCE, cameraHeight = 2000, overlays } = {},
|
||||
) {
|
||||
const h = makeHarness();
|
||||
h.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97, 30, cameraHeight),
|
||||
});
|
||||
const s = services();
|
||||
if (overlays) s.overlays = overlays;
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
lastAnchorSampleAt: 0,
|
||||
renderRevision: 0,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
};
|
||||
const display = createAlprDisplay(h.engine, { state, services: s, source });
|
||||
display.init(null, { onMove() {} });
|
||||
display.setVisible(true);
|
||||
const load = (list) => {
|
||||
state.records = list;
|
||||
state.recordById = new Map(list.map((r) => [r.id, r]));
|
||||
display.renderRecords();
|
||||
h.frame();
|
||||
};
|
||||
const marker = (id) => display.markers().find((m) => m.id === id);
|
||||
return {
|
||||
...h,
|
||||
display,
|
||||
state,
|
||||
services: s,
|
||||
load,
|
||||
marker,
|
||||
/** A click on a camera's badge. */
|
||||
clickCamera(id) {
|
||||
h.click({
|
||||
id,
|
||||
owner: 'alpr-cameras',
|
||||
instance: marker(id).index,
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
state.enabled = false;
|
||||
display.setVisible(false);
|
||||
display.destroy();
|
||||
h.engine.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the display needs a globe engine', () => {
|
||||
assert.throws(() => createAlprDisplay(null, {}), TypeError);
|
||||
});
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`badges draw on their floor, on top, shrinking with distance (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
h.load(records(2));
|
||||
await settle();
|
||||
const badge = h.badge('alpr-cameras', 0);
|
||||
assert.equal(badge.shown, true);
|
||||
assert.equal(badge.sizePx, 38);
|
||||
assert.equal(badge.id, 'alpr:0', 'a pick returns the camera id');
|
||||
assert.equal(badge.alpha, 1);
|
||||
assert.ok(Math.abs(heightOf(badge.position) - 151.5) < 0.01);
|
||||
if (h.cesium) {
|
||||
const billboard = badge.native;
|
||||
assert.ok(
|
||||
h.primitive('alpr-cameras') instanceof Cesium.BillboardCollection,
|
||||
);
|
||||
assert.equal(billboard.height, 38);
|
||||
assert.equal(
|
||||
billboard.disableDepthTestDistance,
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.NearFarScalar.equals(
|
||||
billboard.scaleByDistance,
|
||||
new Cesium.NearFarScalar(...MARKER_SCALE_BY_DISTANCE),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
for (const [id, size, url] of [
|
||||
['alpr-cameras', 38, /alpr-marker-normal\.png$/],
|
||||
['alpr-selected', 60, /alpr-marker-selected\.png$/],
|
||||
]) {
|
||||
const { options } = h.engine.fake.moverSet(id);
|
||||
assert.equal(options.kind, 'sprite');
|
||||
assert.equal(options.sprite.sizePx, size);
|
||||
assert.match(options.sprite.atlas.url, url);
|
||||
assert.equal(options.depthTest, 'always');
|
||||
assert.equal(options.pickable, true);
|
||||
assert.deepEqual(
|
||||
options.sprite.scaleByDistance,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
);
|
||||
assert.ok(options.order < 0, 'under the moving contacts');
|
||||
}
|
||||
assert.ok(
|
||||
h.engine.fake.moverSet('alpr-selected').options.order >
|
||||
h.engine.fake.moverSet('alpr-cameras').options.order,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`a click selects a camera under its 60 px badge and another clears it (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
h.load(records(2));
|
||||
await settle();
|
||||
h.clickCamera('alpr:0');
|
||||
h.frame();
|
||||
await settle();
|
||||
assert.equal(h.services.selected.id, 'alpr:0');
|
||||
assert.equal(h.badge('alpr-cameras', 0).shown, false);
|
||||
const chosen = h.badge('alpr-selected', 0);
|
||||
assert.equal(chosen.shown, true);
|
||||
assert.equal(chosen.sizePx, 60);
|
||||
assert.equal(chosen.id, 'alpr:0');
|
||||
assert.equal(
|
||||
h.marker('alpr:0').gevLabelModel.accent,
|
||||
'rgb(255,100,116)',
|
||||
'the selected accent, in Cesium’s CSS form',
|
||||
);
|
||||
h.clickCamera('alpr:1');
|
||||
h.frame();
|
||||
assert.equal(h.services.selected.id, 'alpr:1');
|
||||
assert.equal(h.badge('alpr-cameras', 0).shown, true);
|
||||
assert.equal(h.marker('alpr:0').gevLabelModel.accent, 'rgb(82,212,255)');
|
||||
h.click({ id: 'alpr:1', owner: 'alpr-selected', instance: 0 });
|
||||
h.frame();
|
||||
assert.equal(
|
||||
h.services.selected,
|
||||
null,
|
||||
'a click on the selected camera clears it',
|
||||
);
|
||||
assert.equal(h.engine.movers.list()[1].count, 0);
|
||||
h.clickCamera('alpr:1');
|
||||
h.click(undefined);
|
||||
assert.equal(h.services.selected, null, 'so does the map');
|
||||
h.clickCamera('alpr:1');
|
||||
h.click({ id: 'flight:abc', owner: 'flights-sprites-0', instance: 0 });
|
||||
assert.equal(h.services.selected, null, "and another layer's object");
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`the selected readout anchors on the rendered ground, sampled once a second (${name})`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: 1_000_000 });
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
h.surface(187);
|
||||
h.load(records(1));
|
||||
await settle();
|
||||
h.clickCamera('alpr:0');
|
||||
const anchor = h.marker('alpr:0').gevDisplayPosition();
|
||||
assert.ok(Math.abs(heightOf(anchor) - 187) < 0.001);
|
||||
h.surface(190);
|
||||
for (let i = 0; i < 30; i++) h.frame();
|
||||
assert.ok(
|
||||
Math.abs(heightOf(h.marker('alpr:0').gevDisplayPosition()) - 187) <
|
||||
0.001,
|
||||
'no per-frame raycasts',
|
||||
);
|
||||
t.mock.timers.tick(1000);
|
||||
h.frame();
|
||||
assert.ok(
|
||||
Math.abs(heightOf(h.marker('alpr:0').gevDisplayPosition()) - 190) <
|
||||
0.001,
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`markers wait on the terrain for their floor and hide with no ground known (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
h.services.groundFloor.cachedGroundFloor = () => null;
|
||||
h.terrain(320);
|
||||
h.load(records(1));
|
||||
await settle();
|
||||
assert.equal(h.marker('alpr:0').clamped, true);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(h.badge('alpr-cameras', 0).position) - 321.5) < 0.01,
|
||||
);
|
||||
h.terrain(undefined);
|
||||
h.load(records(1).map((r) => ({ ...r, latitude: 30.0001 })));
|
||||
await settle();
|
||||
assert.equal(h.badge('alpr-cameras', 0).shown, false);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`a dense view respects the render cap, batches writes and flies to the nearest camera (${name})`, (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
h.load(records(1600));
|
||||
assert.equal(h.display.count(), 1500);
|
||||
assert.equal(h.state.renderSaturated, true);
|
||||
if (!h.cesium)
|
||||
assert.ok(
|
||||
h.engine.fake.moverSet('alpr-cameras').commits < 10,
|
||||
'one buffer commit per batch, not per marker',
|
||||
);
|
||||
const credit = h.credits().find((c) => /Fixture cameras/.test(c.html));
|
||||
assert.ok(credit, 'the source credit shows with the cameras');
|
||||
assert.equal(credit.onScreen, true);
|
||||
t.mock.timers.tick(5000);
|
||||
assert.equal(
|
||||
h.credits().some((c) => /Fixture cameras/.test(c.html)),
|
||||
false,
|
||||
'for five seconds',
|
||||
);
|
||||
const follow = h.engine.camera.follow({
|
||||
position: () => geo.cartesian3.fromDegrees(-97, 30, 3000),
|
||||
viewFrom: { x: 0, y: -500, z: 300 },
|
||||
});
|
||||
assert.equal(h.display.following(), true);
|
||||
assert.equal(h.display.focusNearest(), false);
|
||||
follow.stop();
|
||||
assert.equal(h.display.focusNearest(), true);
|
||||
assert.equal(h.services.selected.id, 'alpr:0');
|
||||
if (!h.cesium) {
|
||||
const [{ args }] = h.engine.fake.callsOf('camera.flyToSphere');
|
||||
assert.equal(args[0].radius, 30);
|
||||
assert.ok(Math.abs(heightOf(args[0].center) - 0) < 0.01, 'no ground');
|
||||
assert.equal(args[1].range, 800);
|
||||
assert.equal(args[1].pitch, -Math.PI / 4);
|
||||
assert.equal(args[1].duration, 1.2);
|
||||
}
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`ten toggles clear contexts, clicks and credits without new sets (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
try {
|
||||
const sets = h.engine.movers.list().map(({ id }) => id);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
h.state.enabled = true;
|
||||
h.display.setVisible(true);
|
||||
h.load(records(3));
|
||||
h.state.enabled = false;
|
||||
h.display.setVisible(false);
|
||||
h.display.clearSelection();
|
||||
h.display.clearRendered();
|
||||
assert.equal(h.services.contexts.size, 0);
|
||||
assert.equal(
|
||||
h.credits().filter((c) => /Fixture/.test(c.html)).length,
|
||||
0,
|
||||
);
|
||||
assert.ok(h.engine.movers.list().every((set) => set.count === 0));
|
||||
}
|
||||
assert.deepEqual(
|
||||
h.engine.movers.list().map(({ id }) => id),
|
||||
sets,
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
});
|
||||
}
|
||||
|
||||
test('a badge beyond the horizon is hidden', () => {
|
||||
const h = setup(fakeHarness, { cameraHeight: 300 });
|
||||
try {
|
||||
// A low oblique view: the ground at the view centre is 55 km away.
|
||||
h.engine.fake.script(
|
||||
'pickEllipsoid',
|
||||
geo.cartesian3.fromDegrees(-97, 30.5),
|
||||
);
|
||||
h.load([
|
||||
{ id: 'alpr:near', latitude: 30, longitude: -97 },
|
||||
{ id: 'alpr:far', latitude: 31.2, longitude: -97 },
|
||||
]);
|
||||
assert.equal(
|
||||
h.badge('alpr-cameras', h.marker('alpr:near').index).shown,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
h.badge('alpr-cameras', h.marker('alpr:far').index).shown,
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('selected metadata survives a refresh; an evicted or replaced selection is released', () => {
|
||||
const h = setup(fakeHarness);
|
||||
try {
|
||||
h.load(records(2));
|
||||
h.clickCamera('alpr:0');
|
||||
assert.equal(h.services.selected.id, 'alpr:0');
|
||||
h.load(records(2).map((r) => ({ ...r, operator: 'Updated' })));
|
||||
assert.equal(h.state.selectedId, 'alpr:0');
|
||||
assert.ok(
|
||||
h.services.contexts
|
||||
.get('alpr:0')
|
||||
.entity.gevLabelModel.details.some((line) => line.includes('UPDATED')),
|
||||
);
|
||||
h.services.otherSelection();
|
||||
h.load(records(2));
|
||||
assert.equal(h.state.selectedId, null);
|
||||
assert.equal(h.services.selected.id, 'flight:1');
|
||||
h.clickCamera('alpr:1');
|
||||
h.load(records(1));
|
||||
assert.equal(h.state.selectedId, null);
|
||||
assert.equal(h.services.contexts.has('alpr:1'), false);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('the camera label stays English data in a translated interface', (t) => {
|
||||
const h = setup(fakeHarness);
|
||||
setLocale('ru');
|
||||
t.after(() => setLocale('en'));
|
||||
try {
|
||||
h.load(records(1));
|
||||
const { label } = h.services.contexts.get('alpr:0');
|
||||
assert.equal(label, 'ALPR camera');
|
||||
assert.equal(shownEntityLabel(label), 'Камера ALPR');
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('an OpenStreetMap source keeps the shared OSM credit inline while cameras show', () => {
|
||||
const shown = [];
|
||||
const h = setup(fakeHarness, {
|
||||
source: { attribution: { name: 'OpenStreetMap' } },
|
||||
});
|
||||
h.services.credits = {
|
||||
showOsmCredit: (target, owner) => shown.push(['show', target, owner]),
|
||||
hideOsmCredit: (target, owner) => shown.push(['hide', target, owner]),
|
||||
};
|
||||
try {
|
||||
h.load(records(1));
|
||||
assert.deepEqual(shown.at(-1), ['show', h.engine, 'alpr-cameras']);
|
||||
h.load([]);
|
||||
assert.deepEqual(shown.at(-1), ['hide', h.engine, 'alpr-cameras']);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('retired floor requests cannot rewrite a newer marker or a destroyed set', async () => {
|
||||
const h = setup(fakeHarness);
|
||||
const pending = [];
|
||||
let cached;
|
||||
h.services.groundFloor.cachedGroundFloor = () => cached;
|
||||
h.services.groundFloor.resolveGroundFloorCells = (_points, { signal }) =>
|
||||
new Promise((resolve) => pending.push({ resolve, signal }));
|
||||
h.load(records(1));
|
||||
assert.equal(pending.length, 1);
|
||||
h.display.clearRendered();
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
h.load([{ ...records(1)[0], latitude: 30.01 }]);
|
||||
assert.equal(pending.length, 2);
|
||||
const marker = h.services.contexts.get('alpr:0').entity;
|
||||
const position = marker.position;
|
||||
cached = 450;
|
||||
pending[0].resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
marker.position,
|
||||
position,
|
||||
'old completion cannot place the new marker',
|
||||
);
|
||||
h.display.destroy();
|
||||
assert.equal(pending[1].signal.aborted, true);
|
||||
pending[1].resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
h.frame();
|
||||
assert.equal(h.services.contexts.size, 0);
|
||||
assert.equal(h.engine.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(h.engine.frames.preRender.numberOfListeners, 0);
|
||||
assert.deepEqual(h.engine.movers.list(), []);
|
||||
h.engine.destroy();
|
||||
});
|
||||
|
||||
// The canvas overlay over the badges (../overlay.js), on the fake engine.
|
||||
function overlaySetup(t, count = 2) {
|
||||
const previousImage = globalThis.Image;
|
||||
globalThis.Image = class {
|
||||
complete = true;
|
||||
naturalWidth = 128;
|
||||
};
|
||||
t.after(() => {
|
||||
if (previousImage === undefined) delete globalThis.Image;
|
||||
else globalThis.Image = previousImage;
|
||||
});
|
||||
let paint, mapListener, active;
|
||||
let removed = 0;
|
||||
const h = setup(fakeHarness, {
|
||||
cameraHeight: 600,
|
||||
overlays: {
|
||||
registerPaintLane(_lane, painter) {
|
||||
paint = painter;
|
||||
return {
|
||||
requestPaint() {},
|
||||
setActive(value) {
|
||||
active = value;
|
||||
},
|
||||
unregister() {
|
||||
removed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
keyholeAlpha: () => 1,
|
||||
subscribeMapStack(callback) {
|
||||
mapListener = callback;
|
||||
return () => {
|
||||
mapListener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(() => h.destroy());
|
||||
const draw = [];
|
||||
const ctx = {
|
||||
globalAlpha: 1,
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
closePath() {},
|
||||
stroke() {},
|
||||
fill() {
|
||||
draw.push('wedge');
|
||||
},
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
drawImage(image) {
|
||||
draw.push(image.src);
|
||||
},
|
||||
};
|
||||
const list = records(count, { directionDeg: 90 });
|
||||
const { width, height } = h.engine.host.size();
|
||||
return {
|
||||
...h,
|
||||
list,
|
||||
load: () => h.load(list),
|
||||
draw,
|
||||
paint: (matrix = h.engine.projector.viewProjection()) =>
|
||||
paint({ ctx, width, height, viewProjectionMatrix: matrix }),
|
||||
/** Where a marker's canvas anchor lands on screen. */
|
||||
screen: (id) =>
|
||||
h.engine.projector.worldToWindow(h.marker(id).gevAlprCanvasPosition),
|
||||
mapChanged: () => mapListener({ detail: { status: 'ready' } }),
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('the overlay paints the 64 nearest cameras, the selection last, and invents no bearing', (t) => {
|
||||
const h = overlaySetup(t, 70);
|
||||
h.list[69].directionDeg = null;
|
||||
h.load();
|
||||
h.clickCamera('alpr:69');
|
||||
h.paint();
|
||||
const owned = h.display.markers().filter((m) => m.canvasOwned);
|
||||
assert.equal(owned.length, 64);
|
||||
assert.equal(h.draw.filter((x) => x === 'wedge').length, 63);
|
||||
assert.ok(h.draw.at(-1).endsWith('alpr-marker-selected-brackets.png'));
|
||||
const firstGlyph = h.draw.findIndex((value) => value !== 'wedge');
|
||||
assert.equal(firstGlyph, 63, 'all wedges paint before any glyph');
|
||||
assert.equal(h.marker('alpr:69').gevAlprWedge, null);
|
||||
assert.equal(
|
||||
h.display.markers().filter((m) => !m.canvasOwned).length,
|
||||
6,
|
||||
'farther cameras keep their native badges',
|
||||
);
|
||||
h.engine.fake.script('pick', undefined);
|
||||
h.engine.fake.input('click', { position: h.screen('alpr:69') });
|
||||
assert.equal(
|
||||
h.services.selected,
|
||||
null,
|
||||
'the selected glyph paints and picks on top: a click on it clears it',
|
||||
);
|
||||
});
|
||||
|
||||
test('a paint projects from the host frame and skips fully clipped artwork', (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.load();
|
||||
const worldToWindow = h.engine.projector.worldToWindow;
|
||||
h.engine.projector.worldToWindow = () => {
|
||||
throw new Error('A paint must not reread camera and DOM size per vertex');
|
||||
};
|
||||
try {
|
||||
h.paint();
|
||||
assert.ok(h.draw.includes('wedge'));
|
||||
// Every point far left of the canvas: clip x shifted by -1000 w.
|
||||
const matrix = [...h.engine.projector.viewProjection()];
|
||||
for (const [x, w] of [
|
||||
[0, 3],
|
||||
[4, 7],
|
||||
[8, 11],
|
||||
[12, 15],
|
||||
])
|
||||
matrix[x] -= 1000 * matrix[w];
|
||||
h.draw.length = 0;
|
||||
h.paint(matrix);
|
||||
assert.equal(
|
||||
h.draw.length,
|
||||
0,
|
||||
'fully clipped artwork allocates no gradient',
|
||||
);
|
||||
h.paint(h.engine.projector.viewProjection().map((value) => -value));
|
||||
assert.equal(h.draw.length, 0, 'points behind the camera never paint');
|
||||
} finally {
|
||||
h.engine.projector.worldToWindow = worldToWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test('a change made while the overlay paints wakes the next frame, which commits it once', async (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.load();
|
||||
h.engine.fake.resetCalls();
|
||||
const commits = h.engine.fake.moverSet('alpr-cameras').commits;
|
||||
h.paint();
|
||||
assert.equal(h.marker('alpr:0').canvasOwned, true);
|
||||
await Promise.resolve();
|
||||
assert.ok(
|
||||
h.engine.fake.callsOf('render.request').length > 0,
|
||||
'the pending change wakes a new frame',
|
||||
);
|
||||
h.frame();
|
||||
assert.equal(h.engine.fake.moverSet('alpr-cameras').commits, commits + 1);
|
||||
h.engine.fake.resetCalls();
|
||||
h.paint();
|
||||
h.frame();
|
||||
await Promise.resolve();
|
||||
assert.equal(
|
||||
h.engine.fake.moverSet('alpr-cameras').commits,
|
||||
commits + 1,
|
||||
'settled sprites do not commit again',
|
||||
);
|
||||
assert.equal(
|
||||
h.engine.fake.callsOf('render.request').length,
|
||||
0,
|
||||
'settled sprites do not wake another frame',
|
||||
);
|
||||
});
|
||||
|
||||
test('a selected camera without a bearing gets its badge and brackets, never a fabricated wedge', (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.list[0].directionDeg = null;
|
||||
h.load();
|
||||
h.clickCamera('alpr:0');
|
||||
h.paint();
|
||||
h.frame();
|
||||
assert.equal(h.draw.length, 2);
|
||||
assert.ok(h.draw[0].endsWith('alpr-marker-selected.png'));
|
||||
assert.ok(h.draw[1].endsWith('alpr-marker-selected-brackets.png'));
|
||||
assert.equal(h.marker('alpr:0').gevAlprWedge, null);
|
||||
const badge = h.badge('alpr-selected', 0);
|
||||
assert.equal(badge.shown, true, 'a faint native pick target stays');
|
||||
assert.ok(Math.abs(badge.alpha - 0.01) < 1e-6);
|
||||
assert.equal(directionWedgePositions(h.list[0]), null);
|
||||
});
|
||||
|
||||
test('an unknown surface leaves the badge native and paints nothing', (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.services.groundFloor.cachedGroundFloor = () => null;
|
||||
h.engine.fake.script('terrainHeight', -15000);
|
||||
h.engine.fake.script('sampleHeight', undefined);
|
||||
h.load();
|
||||
h.paint();
|
||||
h.frame();
|
||||
assert.equal(h.draw.length, 0);
|
||||
assert.equal(h.marker('alpr:0').canvasOwned, false);
|
||||
const badge = h.badge('alpr-cameras', 0);
|
||||
assert.equal(badge.alpha, 1);
|
||||
assert.equal(badge.shown, false, 'it waits for a known ground');
|
||||
});
|
||||
|
||||
test('a canvas-owned badge stays a faint pick target at full size', (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.load();
|
||||
h.paint();
|
||||
h.frame();
|
||||
const marker = h.marker('alpr:0');
|
||||
assert.equal(marker.canvasOwned, true);
|
||||
const anchor = marker.gevAlprCanvasPosition;
|
||||
h.paint();
|
||||
assert.equal(marker.gevAlprCanvasPosition, anchor, 'stable across frames');
|
||||
const badge = h.badge('alpr-cameras', marker.index);
|
||||
assert.equal(badge.shown, true);
|
||||
assert.ok(Math.abs(badge.alpha - 0.01) < 1e-6, 'nearly transparent');
|
||||
const distance = geo.cartesian3.distance(
|
||||
h.engine.camera.livePosition,
|
||||
marker.gevAlprCanvasPosition,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(
|
||||
badge.scale * distanceScale(MARKER_SCALE_BY_DISTANCE, distance) - 1,
|
||||
) < 1e-5,
|
||||
'the set’s distance scale is undone',
|
||||
);
|
||||
h.mapChanged();
|
||||
h.frame();
|
||||
assert.equal(marker.gevAlprCanvasPosition, null);
|
||||
assert.equal(marker.canvasOwned, false);
|
||||
assert.equal(h.badge('alpr-cameras', marker.index).alpha, 1);
|
||||
});
|
||||
|
||||
test('a still view refines two anchors per paint against the floor window, once each', (t) => {
|
||||
const h = overlaySetup(t, 2);
|
||||
let samples = 0;
|
||||
h.engine.fake.script('sampleHeight', (place) => {
|
||||
samples++;
|
||||
return geo.math.toDegrees(place.latitude) > 30.000005 ? 400 : 172;
|
||||
});
|
||||
h.load();
|
||||
h.paint();
|
||||
assert.equal(samples, 0, 'no samples while the camera may still be moving');
|
||||
const height = (id) => heightOf(h.marker(id).gevAlprCanvasPosition);
|
||||
assert.ok(Math.abs(height('alpr:0') - 151.5) < 0.01, 'floor anchor first');
|
||||
h.paint();
|
||||
assert.equal(samples, 2);
|
||||
assert.ok(Math.abs(height('alpr:0') - 173.5) < 0.01, 'rendered surface');
|
||||
assert.ok(
|
||||
Math.abs(height('alpr:1') - 151.5) < 0.01,
|
||||
'a rooftop-height sample is rejected',
|
||||
);
|
||||
h.paint();
|
||||
assert.equal(samples, 2, 'each anchor is checked once');
|
||||
});
|
||||
|
||||
test('repeated paints of a still view write no badge', (t) => {
|
||||
const h = overlaySetup(t, 3);
|
||||
h.load();
|
||||
h.paint();
|
||||
h.frame();
|
||||
const commits = h.engine.fake.moverSet('alpr-cameras').commits;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
h.paint();
|
||||
h.frame();
|
||||
}
|
||||
assert.equal(h.engine.fake.moverSet('alpr-cameras').commits, commits);
|
||||
});
|
||||
|
||||
test('the horizon, a disable and disposal remove overlay hits, painting and ownership', (t) => {
|
||||
const h = overlaySetup(t, 1);
|
||||
h.load();
|
||||
h.paint();
|
||||
const at = h.screen('alpr:0');
|
||||
h.engine.fake.script('pick', undefined);
|
||||
h.engine.fake.input('click', { position: at });
|
||||
assert.equal(h.services.selected?.id, 'alpr:0', 'the painted glyph picks');
|
||||
h.display.clearSelection();
|
||||
const horizonVisible = h.engine.projector.horizonVisible;
|
||||
h.engine.projector.horizonVisible = () => false;
|
||||
h.paint();
|
||||
h.engine.fake.input('click', { position: at });
|
||||
assert.equal(h.services.selected, null, 'nothing past the horizon picks');
|
||||
h.engine.projector.horizonVisible = horizonVisible;
|
||||
h.paint();
|
||||
h.state.enabled = false;
|
||||
h.display.clearRendered();
|
||||
assert.equal(h.active, false);
|
||||
h.state.enabled = true;
|
||||
h.engine.fake.input('click', { position: at });
|
||||
assert.equal(h.services.selected, null, 'no hits once cleared');
|
||||
h.display.destroy();
|
||||
assert.equal(h.removed, 1);
|
||||
});
|
||||
@@ -1,530 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { distanceScale } from '../../../globe/cpuPicks.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { resolvePickId } from '../../../data/pickRegistry.js';
|
||||
import { alprCreditMarkup } from '../model.js';
|
||||
import {
|
||||
alprEntityContext,
|
||||
alprFocusBox,
|
||||
alprLabelModel,
|
||||
alprViewRecords,
|
||||
} from '../presentation.js';
|
||||
import { createAlprFloorResolver } from '../floors.js';
|
||||
import { createAlprNavaraOverlay } from './navaraOverlay.js';
|
||||
import {
|
||||
MARKER_IMAGE,
|
||||
SELECTED_IMAGE,
|
||||
validAlprGroundHeight,
|
||||
} from '../visuals.js';
|
||||
import {
|
||||
LAYER_ID,
|
||||
MAX_RENDERED,
|
||||
ALPR_COLOR,
|
||||
ALPR_SELECTED_COLOR,
|
||||
MARKER_ICON_SIZE,
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
MARKER_FLOOR_LIFT_M,
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
} from '../policy.js';
|
||||
|
||||
/** Plain marker handles and bounded sprite sets; source ownership stays in the layer. */
|
||||
export function createAlprNavaraRenderer(engine, { state, services, source }) {
|
||||
const markers = new Map();
|
||||
const floors = createAlprFloorResolver(services.groundFloor);
|
||||
let normal,
|
||||
selected,
|
||||
initialized = false;
|
||||
let visibleRecords = [],
|
||||
generation = 0,
|
||||
floorRequest = null;
|
||||
let removeMove, removeFrame, removeClick, removeCredit;
|
||||
let lastCamera = null,
|
||||
selectionStartedAt = 0;
|
||||
let spritesDirty = false;
|
||||
const request = () => engine.render.request('alpr-render');
|
||||
const cachedFloor = (record) =>
|
||||
services.groundFloor.cachedGroundFloor(record.latitude, record.longitude);
|
||||
const sampleHeight = (location) => {
|
||||
if (!engine.surface.supported) return undefined;
|
||||
try {
|
||||
return engine.surface.sampleHeight(location, [normal, selected]);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
function surfaceHeight(record) {
|
||||
const location = geo.cartographic.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
);
|
||||
let height = sampleHeight(location);
|
||||
if (!validAlprGroundHeight(height) && engine.surface.terrainHeightSupported)
|
||||
height = engine.surface.terrainHeight(location);
|
||||
if (!validAlprGroundHeight(height)) height = cachedFloor(record);
|
||||
return height;
|
||||
}
|
||||
function placement(record) {
|
||||
let height = cachedFloor(record);
|
||||
const clamped = !validAlprGroundHeight(height);
|
||||
if (clamped) height = surfaceHeight(record);
|
||||
return {
|
||||
clamped,
|
||||
resolved: validAlprGroundHeight(height),
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
validAlprGroundHeight(height) ? height + MARKER_FLOOR_LIFT_M : 0,
|
||||
),
|
||||
};
|
||||
}
|
||||
function viewportBox() {
|
||||
const { width, height } = engine.host.size();
|
||||
if (!width || !height) return null;
|
||||
const focus = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
if (!focus) return null;
|
||||
return alprFocusBox(
|
||||
geo.cartographic.fromCartesian(focus),
|
||||
geo.cartesian3.distance(engine.camera.livePosition, focus),
|
||||
);
|
||||
}
|
||||
const viewRecords = (records) => alprViewRecords(records, viewportBox());
|
||||
function syncMarker(marker) {
|
||||
if (!initialized) return;
|
||||
const position =
|
||||
marker.canvasOwned && marker.gevAlprCanvasPosition
|
||||
? marker.gevAlprCanvasPosition
|
||||
: marker.position;
|
||||
const isSelected = marker.id === state.selectedId;
|
||||
const scale = marker.canvasOwned
|
||||
? 1
|
||||
: distanceScale(
|
||||
MARKER_SCALE_BY_DISTANCE,
|
||||
geo.cartesian3.distance(engine.camera.livePosition, position),
|
||||
);
|
||||
const show =
|
||||
state.enabled &&
|
||||
marker.shown &&
|
||||
marker.resolved &&
|
||||
engine.projector.horizonVisible(position);
|
||||
const alpha = marker.canvasOwned ? 0.01 : 1;
|
||||
const previous = marker.drawn;
|
||||
if (
|
||||
previous &&
|
||||
previous.position === position &&
|
||||
previous.scale === scale &&
|
||||
previous.show === show &&
|
||||
previous.alpha === alpha &&
|
||||
previous.selected === isSelected
|
||||
)
|
||||
return;
|
||||
marker.drawn = { position, scale, show, alpha, selected: isSelected };
|
||||
const fields = {
|
||||
position,
|
||||
scale,
|
||||
show,
|
||||
color: [1, 1, 1, alpha],
|
||||
cell: 'camera',
|
||||
pickId: marker.id,
|
||||
};
|
||||
normal.set(marker.index, { ...fields, show: show && !isSelected });
|
||||
if (isSelected) {
|
||||
selected.setCount(1);
|
||||
selected.set(0, fields);
|
||||
}
|
||||
if (!spritesDirty) {
|
||||
// Canvas ownership can change during postRender. Navara clears its
|
||||
// request flag after that event; wake the next frame afterwards, while
|
||||
// retaining GPU uploads in preRender before the buffers are drawn.
|
||||
queueMicrotask(requestDirtySprites);
|
||||
}
|
||||
spritesDirty = true;
|
||||
}
|
||||
function requestDirtySprites() {
|
||||
if (spritesDirty) request();
|
||||
}
|
||||
function flushSprites() {
|
||||
if (!spritesDirty) return;
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
spritesDirty = false;
|
||||
}
|
||||
const overlay = createAlprNavaraOverlay({
|
||||
engine,
|
||||
state,
|
||||
services,
|
||||
markers,
|
||||
sampleHeight,
|
||||
syncMarker,
|
||||
onSurfaceChange: refreshPlacements,
|
||||
});
|
||||
function refreshPlacements() {
|
||||
if (!state.enabled) return;
|
||||
generation++;
|
||||
floorRequest = null;
|
||||
floors.cancel();
|
||||
for (const marker of markers.values()) {
|
||||
Object.assign(marker, placement(marker.record));
|
||||
marker.drawn = null;
|
||||
syncMarker(marker);
|
||||
}
|
||||
resolveFloors();
|
||||
request();
|
||||
}
|
||||
async function prepareFloors(records) {
|
||||
const cold = viewRecords(records)
|
||||
.visible.filter((r) => !validAlprGroundHeight(cachedFloor(r)))
|
||||
.map((r) => ({ lat: r.latitude, lon: r.longitude }));
|
||||
if (!cold.length) return;
|
||||
try {
|
||||
await floors.prepare(cold);
|
||||
} catch {
|
||||
/* bounded unresolved floors */
|
||||
}
|
||||
}
|
||||
function resolveFloors() {
|
||||
if (
|
||||
floorRequest ||
|
||||
typeof services.groundFloor.resolveGroundFloorCells !== 'function'
|
||||
)
|
||||
return;
|
||||
const batch = [...markers.values()].filter((m) => m.clamped);
|
||||
if (!batch.length) return;
|
||||
const epoch = generation;
|
||||
const promise = Promise.resolve(
|
||||
floors.resolve(
|
||||
batch.map((m) => ({ lat: m.record.latitude, lon: m.record.longitude })),
|
||||
),
|
||||
)
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (epoch !== generation || floorRequest !== promise) return;
|
||||
floorRequest = null;
|
||||
if (!state.enabled) return;
|
||||
for (const marker of batch) {
|
||||
if (markers.get(marker.id) !== marker) continue;
|
||||
Object.assign(marker, placement(marker.record));
|
||||
syncMarker(marker);
|
||||
}
|
||||
});
|
||||
floorRequest = promise;
|
||||
}
|
||||
function updateAppearance(marker, active) {
|
||||
if (marker.gevLabelModel) {
|
||||
marker.gevLabelModel.accent = active ? ALPR_SELECTED_COLOR : ALPR_COLOR;
|
||||
marker.gevLabelModel.leaderAnimationStartedAt = active
|
||||
? selectionStartedAt
|
||||
: 0;
|
||||
}
|
||||
syncMarker(marker);
|
||||
}
|
||||
function clearSelection() {
|
||||
const marker = markers.get(state.selectedId);
|
||||
state.selectedId = null;
|
||||
selectionStartedAt = 0;
|
||||
if (initialized) {
|
||||
selected.setCount(0);
|
||||
selected.commit();
|
||||
}
|
||||
if (marker) updateAppearance(marker, false);
|
||||
overlay.sync(visibleRecords);
|
||||
services.context.clearSelectedEntityContextForLayer(LAYER_ID);
|
||||
request();
|
||||
}
|
||||
function updateSelectedAnchor() {
|
||||
if (!state.enabled || !state.selectedId) return;
|
||||
if (services.context.getSelectedEntityContext()?.id !== state.selectedId) {
|
||||
clearSelection();
|
||||
return;
|
||||
}
|
||||
const marker = markers.get(state.selectedId);
|
||||
if (!marker) return;
|
||||
if (marker.gevAlprCanvasPosition) {
|
||||
marker.displayPosition = marker.gevAlprCanvasPosition;
|
||||
return;
|
||||
}
|
||||
if (Date.now() - state.lastAnchorSampleAt < 1000) return;
|
||||
state.lastAnchorSampleAt = Date.now();
|
||||
const height = surfaceHeight(marker.record);
|
||||
if (!validAlprGroundHeight(height)) return;
|
||||
const next = geo.cartesian3.fromDegrees(
|
||||
marker.record.longitude,
|
||||
marker.record.latitude,
|
||||
height,
|
||||
);
|
||||
if (
|
||||
!marker.displayPosition ||
|
||||
geo.cartesian3.distance(next, marker.displayPosition) > 0.1
|
||||
) {
|
||||
marker.displayPosition = next;
|
||||
request();
|
||||
}
|
||||
}
|
||||
function selectRecord(id) {
|
||||
const marker = markers.get(id);
|
||||
if (!marker || !state.recordById.has(id)) return false;
|
||||
clearSelection();
|
||||
state.selectedId = id;
|
||||
selectionStartedAt = performance.now();
|
||||
updateAppearance(marker, true);
|
||||
state.lastAnchorSampleAt = 0;
|
||||
services.context.selectEntityContext(marker);
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visibleRecords);
|
||||
request();
|
||||
return true;
|
||||
}
|
||||
function registerContext(marker) {
|
||||
const record = marker.record;
|
||||
marker.gevTrackedId = record.id;
|
||||
marker.gevDisplayPosition = () => marker.displayPosition;
|
||||
marker.gevLabelModel = alprLabelModel(record, source, {
|
||||
accent: ALPR_COLOR,
|
||||
startedAt: record.id === state.selectedId ? selectionStartedAt : 0,
|
||||
});
|
||||
services.context.registerEntityContext(
|
||||
marker,
|
||||
alprEntityContext(record, source),
|
||||
);
|
||||
}
|
||||
function countOnScreen() {
|
||||
const { width, height } = engine.host.size();
|
||||
let count = 0;
|
||||
for (const marker of markers.values()) {
|
||||
if (!engine.projector.horizonVisible(marker.position)) continue;
|
||||
const p = engine.projector.worldToWindow(marker.position);
|
||||
if (p && p.x >= 0 && p.x <= width && p.y >= 0 && p.y <= height) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function hideOnMapCredit() {
|
||||
removeCredit?.();
|
||||
removeCredit = null;
|
||||
}
|
||||
function presentOnMapCredit() {
|
||||
if (!state.enabled || removeCredit) return;
|
||||
const markup = alprCreditMarkup(source.attribution);
|
||||
if (markup) removeCredit = engine.credits.addStatic(markup);
|
||||
}
|
||||
function renderRecords() {
|
||||
const { visible, capped } = viewRecords(state.records);
|
||||
state.renderSaturated = capped;
|
||||
visibleRecords = visible;
|
||||
const current = services.context.getSelectedEntityContext();
|
||||
if (
|
||||
current?.layerId !== LAYER_ID ||
|
||||
current.id !== state.selectedId ||
|
||||
!visible.some((r) => r.id === state.selectedId)
|
||||
)
|
||||
clearSelection();
|
||||
const ids = new Set(visible.map((r) => r.id));
|
||||
let changed = false;
|
||||
for (const id of markers.keys())
|
||||
if (!ids.has(id)) {
|
||||
markers.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
services.context.removeEntityContextsForLayer(LAYER_ID, { retainIds: ids });
|
||||
for (const record of visible) {
|
||||
let marker = markers.get(record.id);
|
||||
if (!marker) {
|
||||
marker = {
|
||||
id: record.id,
|
||||
record,
|
||||
...placement(record),
|
||||
shown: true,
|
||||
canvasOwned: false,
|
||||
displayPosition: null,
|
||||
};
|
||||
markers.set(record.id, marker);
|
||||
changed = true;
|
||||
} else if (marker.record !== record) {
|
||||
const previous = marker.record;
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude
|
||||
) {
|
||||
Object.assign(marker, placement(record));
|
||||
marker.displayPosition = null;
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
}
|
||||
if (
|
||||
previous.latitude !== record.latitude ||
|
||||
previous.longitude !== record.longitude ||
|
||||
previous.directionDeg !== record.directionDeg
|
||||
) {
|
||||
marker.gevAlprCanvasPosition = null;
|
||||
marker.gevAlprWedge = null;
|
||||
}
|
||||
marker.record = record;
|
||||
}
|
||||
registerContext(marker);
|
||||
}
|
||||
normal.setCount(markers.size);
|
||||
let index = 0;
|
||||
for (const marker of markers.values()) {
|
||||
marker.index = index++;
|
||||
marker.drawn = null;
|
||||
updateAppearance(marker, marker.id === state.selectedId);
|
||||
}
|
||||
flushSprites();
|
||||
if (changed) state.renderRevision++;
|
||||
state.onScreen = countOnScreen();
|
||||
resolveFloors();
|
||||
updateSelectedAnchor();
|
||||
overlay.sync(visible);
|
||||
const chosen = markers.get(state.selectedId);
|
||||
if (chosen) services.overlays?.refreshReadout?.(chosen);
|
||||
if (visible.length) presentOnMapCredit();
|
||||
else hideOnMapCredit();
|
||||
request();
|
||||
}
|
||||
function clearRendered({ retainContext = false } = {}) {
|
||||
hideOnMapCredit();
|
||||
generation++;
|
||||
floors.cancel();
|
||||
floorRequest = null;
|
||||
overlay.clear();
|
||||
visibleRecords = [];
|
||||
markers.clear();
|
||||
if (initialized) {
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
spritesDirty = false;
|
||||
}
|
||||
if (!retainContext) services.context.removeEntityContextsForLayer(LAYER_ID);
|
||||
}
|
||||
function frame() {
|
||||
const camera = engine.camera.livePosition;
|
||||
if (
|
||||
!lastCamera ||
|
||||
!geo.cartesian3.equalsEpsilon(camera, lastCamera, 0, 0.00001)
|
||||
) {
|
||||
lastCamera = { ...camera };
|
||||
for (const marker of markers.values()) syncMarker(marker);
|
||||
}
|
||||
flushSprites();
|
||||
updateSelectedAnchor();
|
||||
}
|
||||
return {
|
||||
get ready() {
|
||||
return initialized;
|
||||
},
|
||||
count: () => markers.size,
|
||||
following: () => engine.camera.following(),
|
||||
viewportBox,
|
||||
prepareFloors,
|
||||
renderRecords,
|
||||
clearRendered,
|
||||
clearSelection,
|
||||
hideOnMapCredit,
|
||||
init(_viewer, { onMove }) {
|
||||
if (initialized) throw new Error('ALPR renderer is already initialized');
|
||||
const create = (id, sizePx, capacity, image, order) =>
|
||||
engine.movers.createSet({
|
||||
id,
|
||||
kind: 'sprite',
|
||||
capacity,
|
||||
order,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
sharpen: true,
|
||||
sprite: {
|
||||
sizePx,
|
||||
atlas: { url: image, cells: { camera: [0, 0, 1, 1] } },
|
||||
},
|
||||
});
|
||||
normal = create(
|
||||
LAYER_ID,
|
||||
MARKER_ICON_SIZE,
|
||||
MAX_RENDERED,
|
||||
MARKER_IMAGE,
|
||||
50,
|
||||
);
|
||||
selected = create(
|
||||
'alpr-selected',
|
||||
SELECTED_MARKER_ICON_SIZE,
|
||||
1,
|
||||
SELECTED_IMAGE,
|
||||
51,
|
||||
);
|
||||
normal.setCount(0);
|
||||
selected.setCount(0);
|
||||
normal.commit();
|
||||
selected.commit();
|
||||
initialized = true;
|
||||
removeMove = engine.camera.moveEnd.addEventListener(onMove);
|
||||
removeFrame = engine.frames.preRender.addEventListener(frame);
|
||||
overlay.init();
|
||||
},
|
||||
setVisible(value) {
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
if (value)
|
||||
removeClick = selectionDispatcherFor(engine).register(LAYER_ID, {
|
||||
owns: (id) => state.recordById.has(id),
|
||||
onClick({ position, picked }) {
|
||||
if (!state.enabled) return;
|
||||
const id = overlay.pick(position) || resolvePickId(picked);
|
||||
if (
|
||||
id &&
|
||||
state.recordById.has(id) &&
|
||||
(id !== state.selectedId ||
|
||||
services.context.getSelectedEntityContext()?.id !== id)
|
||||
)
|
||||
selectRecord(id);
|
||||
else if (state.selectedId) clearSelection();
|
||||
},
|
||||
});
|
||||
},
|
||||
focusNearest() {
|
||||
if (!state.enabled || engine.camera.following()) return false;
|
||||
let nearest,
|
||||
distance = Infinity;
|
||||
for (const marker of markers.values()) {
|
||||
const next = geo.cartesian3.distanceSquared(
|
||||
engine.camera.livePosition,
|
||||
marker.position,
|
||||
);
|
||||
if (next < distance) {
|
||||
nearest = marker;
|
||||
distance = next;
|
||||
}
|
||||
}
|
||||
if (!nearest || !selectRecord(nearest.id)) return false;
|
||||
const height = surfaceHeight(nearest.record);
|
||||
const center = geo.cartesian3.fromDegrees(
|
||||
nearest.record.longitude,
|
||||
nearest.record.latitude,
|
||||
validAlprGroundHeight(height) ? height : 0,
|
||||
);
|
||||
engine.camera.flyToSphere(
|
||||
{ center, radius: 30 },
|
||||
{
|
||||
duration: 1.2,
|
||||
heading: engine.camera.pose().heading || 0,
|
||||
pitch: -Math.PI / 4,
|
||||
range: 800,
|
||||
},
|
||||
);
|
||||
request();
|
||||
return true;
|
||||
},
|
||||
destroy(options) {
|
||||
if (!initialized) return;
|
||||
clearRendered(options);
|
||||
overlay.destroy();
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
removeMove?.();
|
||||
removeMove = null;
|
||||
removeFrame?.();
|
||||
removeFrame = null;
|
||||
normal?.destroy();
|
||||
selected?.destroy();
|
||||
initialized = false;
|
||||
lastCamera = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { setLocale } from '../../../i18n/index.js';
|
||||
import { shownEntityLabel } from '../../../voice/speech.js';
|
||||
import { createAlprNavaraRenderer } from './navara.js';
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
listeners,
|
||||
addEventListener(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
fire() {
|
||||
for (const fn of listeners) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
function harness(t) {
|
||||
const sets = [],
|
||||
contexts = new Map(),
|
||||
inputs = new Map(),
|
||||
flights = [];
|
||||
const move = event(),
|
||||
frame = event();
|
||||
let commits = 0,
|
||||
selectedContext = null,
|
||||
following = false,
|
||||
credits = 0,
|
||||
renderRequested = false;
|
||||
const state = {
|
||||
enabled: true,
|
||||
selectedId: null,
|
||||
lastAnchorSampleAt: 0,
|
||||
renderRevision: 0,
|
||||
records: [],
|
||||
recordById: new Map(),
|
||||
};
|
||||
const engine = {
|
||||
host: { size: () => ({ width: 800, height: 600 }) },
|
||||
render: {
|
||||
request() {
|
||||
renderRequested = true;
|
||||
},
|
||||
},
|
||||
camera: {
|
||||
livePosition: geo.cartesian3.fromDegrees(-97, 30, 10000),
|
||||
pickEllipsoid: () => geo.cartesian3.fromDegrees(-97, 30),
|
||||
following: () => following,
|
||||
pose: () => ({ heading: 0.5 }),
|
||||
moveEnd: move,
|
||||
flyToSphere(sphere, options) {
|
||||
flights.push({ sphere, options });
|
||||
},
|
||||
},
|
||||
frames: { preRender: frame },
|
||||
input: {
|
||||
on(name, fn) {
|
||||
inputs.set(name, fn);
|
||||
return () => inputs.delete(name);
|
||||
},
|
||||
},
|
||||
projector: {
|
||||
horizonVisible: () => true,
|
||||
worldToWindow: () => ({ x: 400, y: 300 }),
|
||||
},
|
||||
surface: {
|
||||
supported: true,
|
||||
terrainHeightSupported: false,
|
||||
sampleHeight: () => 150,
|
||||
},
|
||||
credits: {
|
||||
addStatic(_markup, options) {
|
||||
assert.notEqual(
|
||||
options?.onScreen,
|
||||
true,
|
||||
'loading ALPR must not open an attribution popup over the map',
|
||||
);
|
||||
credits++;
|
||||
return () => credits--;
|
||||
},
|
||||
},
|
||||
movers: {
|
||||
createSet(options) {
|
||||
const set = {
|
||||
options,
|
||||
rows: [],
|
||||
count: 0,
|
||||
dead: false,
|
||||
setCount(n) {
|
||||
assert.ok(!this.dead);
|
||||
assert.ok(n <= options.capacity);
|
||||
this.count = n;
|
||||
},
|
||||
set(i, row) {
|
||||
assert.ok(i < this.count);
|
||||
this.rows[i] = row;
|
||||
},
|
||||
commit() {
|
||||
assert.ok(!this.dead);
|
||||
this.committedRows = this.rows.slice(0, this.count);
|
||||
commits++;
|
||||
},
|
||||
destroy() {
|
||||
assert.ok(!this.dead);
|
||||
this.dead = true;
|
||||
},
|
||||
};
|
||||
sets.push(set);
|
||||
return set;
|
||||
},
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
groundFloor: { cachedGroundFloor: () => 150 },
|
||||
context: {
|
||||
getSelectedEntityContext: () => selectedContext,
|
||||
clearSelectedEntityContextForLayer() {
|
||||
if (selectedContext?.layerId === 'alpr-cameras') selectedContext = null;
|
||||
},
|
||||
removeEntityContextsForLayer(_id, { retainIds = new Set() } = {}) {
|
||||
for (const id of contexts.keys())
|
||||
if (!retainIds.has(id)) contexts.delete(id);
|
||||
},
|
||||
registerEntityContext(entity, metadata) {
|
||||
contexts.set(metadata.id, { ...metadata, entity });
|
||||
},
|
||||
selectEntityContext(entity) {
|
||||
selectedContext = contexts.get(entity.id);
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createAlprNavaraRenderer(engine, {
|
||||
state,
|
||||
services,
|
||||
source: {
|
||||
label: 'Fixture',
|
||||
attribution: {
|
||||
text: 'Fixture cameras',
|
||||
href: 'https://example.com/cameras',
|
||||
},
|
||||
},
|
||||
});
|
||||
renderer.init(null, { onMove() {} });
|
||||
renderer.setVisible(true);
|
||||
t.after(() => {
|
||||
state.enabled = false;
|
||||
renderer.setVisible(false);
|
||||
renderer.destroy();
|
||||
});
|
||||
const load = (records) => {
|
||||
state.records = records;
|
||||
state.recordById = new Map(records.map((r) => [r.id, r]));
|
||||
renderer.renderRecords();
|
||||
frame.fire();
|
||||
};
|
||||
return {
|
||||
renderer,
|
||||
state,
|
||||
services,
|
||||
engine,
|
||||
sets,
|
||||
contexts,
|
||||
inputs,
|
||||
flights,
|
||||
move,
|
||||
frame,
|
||||
load,
|
||||
get selected() {
|
||||
return selectedContext;
|
||||
},
|
||||
get commits() {
|
||||
return commits;
|
||||
},
|
||||
get credits() {
|
||||
return credits;
|
||||
},
|
||||
get renderRequested() {
|
||||
return renderRequested;
|
||||
},
|
||||
finishFrame() {
|
||||
renderRequested = false;
|
||||
},
|
||||
follow(value) {
|
||||
following = value;
|
||||
},
|
||||
otherSelection() {
|
||||
selectedContext = { id: 'flight:1', layerId: 'flights' };
|
||||
},
|
||||
};
|
||||
}
|
||||
const records = (count) =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `alpr:${i}`,
|
||||
latitude: 30 + i * 0.00001,
|
||||
longitude: -97,
|
||||
osmId: i,
|
||||
}));
|
||||
|
||||
test('ALPR preserves a postRender update after the view clears its current frame request', async (t) => {
|
||||
const h = harness(t);
|
||||
h.load(records(1));
|
||||
h.inputs.get('click')({
|
||||
position: { x: 400, y: 300 },
|
||||
picked: { id: 'alpr:0' },
|
||||
});
|
||||
h.finishFrame();
|
||||
await Promise.resolve();
|
||||
assert.equal(h.renderRequested, true, 'the pending change wakes a new frame');
|
||||
h.frame.fire();
|
||||
assert.equal(h.sets[0].committedRows[0].show, false);
|
||||
assert.equal(h.sets[1].committedRows[0]?.pickId, 'alpr:0');
|
||||
const commits = h.commits;
|
||||
h.finishFrame();
|
||||
h.frame.fire();
|
||||
await Promise.resolve();
|
||||
assert.equal(
|
||||
h.commits,
|
||||
commits,
|
||||
'settled sprites do not keep requesting frames',
|
||||
);
|
||||
assert.equal(
|
||||
h.renderRequested,
|
||||
false,
|
||||
'settled sprites do not wake another frame',
|
||||
);
|
||||
});
|
||||
|
||||
test('retired ALPR floor requests cannot rewrite a newer marker or a destroyed set', async (t) => {
|
||||
const h = harness(t);
|
||||
const pending = [];
|
||||
let cached;
|
||||
h.services.groundFloor.cachedGroundFloor = () => cached;
|
||||
h.services.groundFloor.resolveGroundFloorCells = (_points, { signal }) =>
|
||||
new Promise((resolve) => pending.push({ resolve, signal }));
|
||||
h.load(records(1));
|
||||
assert.equal(pending.length, 1);
|
||||
h.renderer.clearRendered();
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
h.load([{ ...records(1)[0], latitude: 30.01 }]);
|
||||
assert.equal(pending.length, 2);
|
||||
const marker = h.contexts.get('alpr:0').entity;
|
||||
const position = marker.position;
|
||||
cached = 450;
|
||||
pending[0].resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
marker.position,
|
||||
position,
|
||||
'old completion cannot place the new marker',
|
||||
);
|
||||
h.renderer.destroy();
|
||||
assert.equal(pending[1].signal.aborted, true);
|
||||
const commits = h.commits;
|
||||
pending[1].resolve();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
h.frame.fire();
|
||||
assert.equal(h.commits, commits);
|
||||
assert.equal(h.contexts.size, 0);
|
||||
assert.equal(h.inputs.size, 0);
|
||||
assert.equal(h.frame.listeners.size, 0);
|
||||
assert.equal(h.move.listeners.size, 0);
|
||||
});
|
||||
|
||||
test('ALPR batches a dense viewport, respects the cap and never follows a tracked target', (t) => {
|
||||
const h = harness(t);
|
||||
const before = h.commits;
|
||||
h.load(records(1600));
|
||||
assert.equal(h.renderer.count(), 1500);
|
||||
assert.equal(h.state.renderSaturated, true);
|
||||
assert.ok(
|
||||
h.commits - before < 10,
|
||||
'one buffer commit per batch, not per marker',
|
||||
);
|
||||
assert.equal(h.credits, 1);
|
||||
h.follow(true);
|
||||
assert.equal(h.renderer.focusNearest(), false);
|
||||
assert.equal(h.flights.length, 0);
|
||||
h.follow(false);
|
||||
assert.equal(h.renderer.focusNearest(), true);
|
||||
assert.equal(h.flights[0].options.range, 800);
|
||||
assert.equal(h.flights[0].options.pitch, -Math.PI / 4);
|
||||
assert.equal(h.selected.id, 'alpr:0');
|
||||
});
|
||||
|
||||
test('ALPR preserves selected metadata and releases an evicted or replaced selection', (t) => {
|
||||
const h = harness(t);
|
||||
h.load(records(2));
|
||||
h.inputs.get('click')({
|
||||
position: { x: 400, y: 300 },
|
||||
picked: { id: 'alpr:0' },
|
||||
});
|
||||
assert.equal(h.selected.id, 'alpr:0');
|
||||
h.load(records(2).map((r) => ({ ...r, operator: 'Updated' })));
|
||||
assert.equal(h.state.selectedId, 'alpr:0');
|
||||
assert.ok(
|
||||
h.contexts
|
||||
.get('alpr:0')
|
||||
.entity.gevLabelModel.details.some((line) => line.includes('UPDATED')),
|
||||
);
|
||||
h.otherSelection();
|
||||
h.load(records(2));
|
||||
assert.equal(h.state.selectedId, null);
|
||||
assert.equal(h.selected.id, 'flight:1');
|
||||
h.inputs.get('click')({
|
||||
position: { x: 400, y: 300 },
|
||||
picked: { id: 'alpr:1' },
|
||||
});
|
||||
h.load(records(1));
|
||||
assert.equal(h.state.selectedId, null);
|
||||
assert.equal(h.contexts.has('alpr:1'), false);
|
||||
});
|
||||
|
||||
test('the camera label stays English data in a translated interface, as on Cesium', (t) => {
|
||||
const h = harness(t);
|
||||
setLocale('ru');
|
||||
t.after(() => setLocale('en'));
|
||||
h.load(records(1));
|
||||
const { label } = h.contexts.get('alpr:0');
|
||||
assert.equal(label, 'ALPR camera');
|
||||
assert.equal(shownEntityLabel(label), 'Камера ALPR');
|
||||
});
|
||||
|
||||
test('ten ALPR toggles clear contexts, picks and credits without allocating new sets', (t) => {
|
||||
const h = harness(t);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
h.state.enabled = true;
|
||||
h.renderer.setVisible(true);
|
||||
h.load(records(3));
|
||||
h.state.enabled = false;
|
||||
h.renderer.setVisible(false);
|
||||
h.renderer.clearSelection();
|
||||
h.renderer.clearRendered();
|
||||
assert.equal(h.contexts.size, 0);
|
||||
assert.equal(h.inputs.size, 0);
|
||||
assert.equal(h.credits, 0);
|
||||
assert.ok(h.sets.every((set) => set.count === 0));
|
||||
}
|
||||
assert.equal(h.sets.length, 2);
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createAlprOverlayCore } from '../overlay.js';
|
||||
|
||||
/** Canvas artwork over Navara pick sprites, with bounded surface refinement. */
|
||||
export function createAlprNavaraOverlay({
|
||||
engine,
|
||||
state,
|
||||
markers,
|
||||
sampleHeight,
|
||||
syncMarker,
|
||||
services,
|
||||
onSurfaceChange = () => {},
|
||||
}) {
|
||||
const location = (record) =>
|
||||
geo.cartographic.fromDegrees(record.longitude, record.latitude);
|
||||
return createAlprOverlayCore({
|
||||
state,
|
||||
services,
|
||||
onSurfaceChange,
|
||||
engine: {
|
||||
marker: (id) => markers.get(id),
|
||||
markers: () => markers.values(),
|
||||
canvasOwned(marker, owned) {
|
||||
if (marker.canvasOwned === owned) return;
|
||||
marker.canvasOwned = owned;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setShown(marker, visible) {
|
||||
if (marker.shown === visible) return;
|
||||
marker.shown = visible;
|
||||
syncMarker(marker);
|
||||
},
|
||||
setDisplayPosition(marker, position) {
|
||||
marker.displayPosition = position;
|
||||
},
|
||||
placeAnchor(marker, record, height) {
|
||||
marker.gevAlprCanvasPosition = geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
height,
|
||||
);
|
||||
marker.resolved = true;
|
||||
syncMarker(marker);
|
||||
},
|
||||
surfaceSupported: () => engine.surface.supported,
|
||||
// Navara raycasts the loaded surface.
|
||||
sampleHeight: (record) => sampleHeight(location(record)),
|
||||
terrainHeight: (record) =>
|
||||
engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(location(record))
|
||||
: undefined,
|
||||
surfaceSettled: () => engine.surface.tilesLoaded(),
|
||||
cameraPose: () => engine.camera.pose(),
|
||||
cameraPosition: () => engine.camera.livePosition,
|
||||
projector: ({ width, height, viewProjectionMatrix: matrix }) => ({
|
||||
horizonVisible: (point) => engine.projector.horizonVisible(point),
|
||||
// The host already captured the camera and CSS viewport for this
|
||||
// frame; reuse it for every wedge vertex instead of rereading DOM
|
||||
// geometry.
|
||||
project({ x, y, z }) {
|
||||
const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
|
||||
if (!(w > 0)) return null;
|
||||
return {
|
||||
x:
|
||||
(((matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) /
|
||||
w +
|
||||
1) *
|
||||
width) /
|
||||
2,
|
||||
y:
|
||||
((1 -
|
||||
(matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) /
|
||||
w) *
|
||||
height) /
|
||||
2,
|
||||
};
|
||||
},
|
||||
}),
|
||||
cullOffscreenWedges: true,
|
||||
createId: () => crypto.randomUUID(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createAlprNavaraOverlay } from './navaraOverlay.js';
|
||||
|
||||
function harness(t, count = 2) {
|
||||
const previousImage = globalThis.Image;
|
||||
globalThis.Image = class {
|
||||
complete = true;
|
||||
naturalWidth = 128;
|
||||
};
|
||||
const records = Array.from({ length: count }, (_, i) => ({
|
||||
id: `camera:${i}`,
|
||||
latitude: 30 + i / 10000,
|
||||
longitude: -97,
|
||||
directionDeg: 90,
|
||||
}));
|
||||
const markers = new Map(
|
||||
records.map((record) => [
|
||||
record.id,
|
||||
{
|
||||
id: record.id,
|
||||
record,
|
||||
shown: true,
|
||||
canvasOwned: false,
|
||||
position: geo.cartesian3.fromDegrees(
|
||||
record.longitude,
|
||||
record.latitude,
|
||||
151.5,
|
||||
),
|
||||
},
|
||||
]),
|
||||
);
|
||||
const state = { enabled: true, selectedId: null };
|
||||
let paint,
|
||||
mapListener,
|
||||
active,
|
||||
samples = 0,
|
||||
removed = 0;
|
||||
const pose = {
|
||||
position: geo.cartesian3.fromDegrees(-97, 30, 500),
|
||||
direction: geo.cartesian3.UNIT_X,
|
||||
};
|
||||
const engine = {
|
||||
camera: { livePosition: pose.position, pose: () => pose },
|
||||
projector: {
|
||||
worldToWindow: () => ({ x: 100, y: 100 }),
|
||||
horizonVisible: () => true,
|
||||
},
|
||||
surface: {
|
||||
supported: true,
|
||||
tilesLoaded: () => true,
|
||||
terrainHeightSupported: false,
|
||||
},
|
||||
};
|
||||
const draw = [];
|
||||
const ctx = {
|
||||
globalAlpha: 1,
|
||||
save() {},
|
||||
restore() {},
|
||||
beginPath() {},
|
||||
moveTo() {},
|
||||
lineTo() {},
|
||||
closePath() {},
|
||||
stroke() {},
|
||||
fill() {
|
||||
draw.push('wedge');
|
||||
},
|
||||
createLinearGradient: () => ({ addColorStop() {} }),
|
||||
drawImage(image) {
|
||||
draw.push(image.src);
|
||||
},
|
||||
};
|
||||
const overlay = createAlprNavaraOverlay({
|
||||
engine,
|
||||
state,
|
||||
markers,
|
||||
sampleHeight(location) {
|
||||
samples++;
|
||||
return geo.math.toDegrees(location.latitude) > 30.00005 ? 400 : 172;
|
||||
},
|
||||
syncMarker() {},
|
||||
services: {
|
||||
groundFloor: { cachedGroundFloor: () => 150 },
|
||||
overlays: {
|
||||
registerPaintLane(_name, callback) {
|
||||
paint = callback;
|
||||
return {
|
||||
requestPaint() {},
|
||||
setActive(value) {
|
||||
active = value;
|
||||
},
|
||||
unregister() {
|
||||
removed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
keyholeAlpha: () => 1,
|
||||
subscribeMapStack(callback) {
|
||||
mapListener = callback;
|
||||
return () => {
|
||||
mapListener = null;
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
overlay.init();
|
||||
t.after(() => {
|
||||
overlay.destroy();
|
||||
if (previousImage === undefined) delete globalThis.Image;
|
||||
else globalThis.Image = previousImage;
|
||||
});
|
||||
return {
|
||||
state,
|
||||
markers,
|
||||
records,
|
||||
overlay,
|
||||
engine,
|
||||
draw,
|
||||
paint: (frame = {}) =>
|
||||
paint({
|
||||
ctx,
|
||||
width: 800,
|
||||
height: 600,
|
||||
viewProjectionMatrix: [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
-0.75,
|
||||
2 / 3,
|
||||
0,
|
||||
1,
|
||||
],
|
||||
...frame,
|
||||
}),
|
||||
mapChanged: () => mapListener({ detail: { status: 'ready' } }),
|
||||
get samples() {
|
||||
return samples;
|
||||
},
|
||||
get active() {
|
||||
return active;
|
||||
},
|
||||
get removed() {
|
||||
return removed;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('ALPR overlay retains the 64-camera budget, paints selection last and invents no bearing', (t) => {
|
||||
const h = harness(t, 70);
|
||||
h.records[69].directionDeg = null;
|
||||
h.state.selectedId = 'camera:69';
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal([...h.markers.values()].filter((m) => m.canvasOwned).length, 64);
|
||||
assert.equal(h.draw.filter((x) => x === 'wedge').length, 63);
|
||||
assert.ok(h.draw.at(-1).endsWith('alpr-marker-selected-brackets.png'));
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:69');
|
||||
assert.equal(h.markers.get('camera:69').gevAlprWedge, null);
|
||||
});
|
||||
|
||||
test('ALPR projects from the host frame and skips fully clipped wedges, preserving edge strokes', (t) => {
|
||||
const h = harness(t, 1);
|
||||
h.engine.projector.worldToWindow = () => {
|
||||
throw new Error('A paint must not reread camera and DOM size per vertex');
|
||||
};
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), 'camera:0');
|
||||
const matrix = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1.005, 0, 0, 1];
|
||||
h.draw.length = 0;
|
||||
h.paint({ viewProjectionMatrix: matrix });
|
||||
assert.ok(
|
||||
h.draw.includes('wedge'),
|
||||
'a stroke within 3px of the edge still paints',
|
||||
);
|
||||
matrix[12] = -2;
|
||||
h.draw.length = 0;
|
||||
h.paint({ viewProjectionMatrix: matrix });
|
||||
assert.equal(h.draw.length, 0, 'fully clipped artwork allocates no gradient');
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
matrix[15] = -1;
|
||||
h.paint({ viewProjectionMatrix: matrix });
|
||||
assert.equal(h.draw.length, 0, 'points behind the camera never paint');
|
||||
});
|
||||
|
||||
test('ALPR overlay samples two settled anchors per paint and rejects roof-height outliers', (t) => {
|
||||
const h = harness(t);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
assert.equal(h.samples, 0);
|
||||
h.paint();
|
||||
assert.equal(h.samples, 2);
|
||||
const height = (id) =>
|
||||
geo.cartographic.fromCartesian(h.markers.get(id).gevAlprCanvasPosition)
|
||||
.height;
|
||||
assert.ok(Math.abs(height('camera:0') - 173.5) < 0.01);
|
||||
assert.ok(Math.abs(height('camera:1') - 151.5) < 0.01);
|
||||
h.paint();
|
||||
assert.equal(h.samples, 2);
|
||||
h.mapChanged();
|
||||
assert.equal(h.markers.get('camera:0').gevAlprCanvasPosition, null);
|
||||
});
|
||||
|
||||
test('ALPR overlay horizon and disposal remove hits, painting and ownership', (t) => {
|
||||
const h = harness(t);
|
||||
h.overlay.sync(h.records);
|
||||
h.paint();
|
||||
h.engine.projector.horizonVisible = () => false;
|
||||
h.paint();
|
||||
assert.equal(h.overlay.pick({ x: 100, y: 100 }), null);
|
||||
h.state.enabled = false;
|
||||
h.overlay.clear();
|
||||
assert.equal(h.active, false);
|
||||
assert.ok([...h.markers.values()].every((m) => !m.canvasOwned && m.shown));
|
||||
h.overlay.destroy();
|
||||
assert.equal(h.removed, 1);
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each Barcelona system's host, and the stations farthest from its registered
|
||||
@@ -37,6 +37,8 @@ const { computeInRangeCities } = createViewport({
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
// The range check reads no camera.
|
||||
camera: {},
|
||||
});
|
||||
|
||||
test('Bicing and AMBici are registered and readable through the proxy', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each Bike Itaú city's PBSC host, and the station farthest from its
|
||||
@@ -45,6 +45,8 @@ const { computeInRangeCities } = createViewport({
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
// The range check reads no camera.
|
||||
camera: {},
|
||||
});
|
||||
|
||||
test('the Bike Itaú systems are registered and readable through the proxy', () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createBikeshareLayer } from './index.js';
|
||||
import { createBikeshareNavaraRenderer } from './render/navara.js';
|
||||
import { createBikeshareDisplay } from './render/display.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
|
||||
// Two systems that cover one view and both number a station "1".
|
||||
@@ -68,8 +68,7 @@ function fixture() {
|
||||
clearOverlaySource: (id) => entries.delete(id),
|
||||
},
|
||||
},
|
||||
rendererFactory: (context) =>
|
||||
createBikeshareNavaraRenderer(engine, context),
|
||||
rendererFactory: (context) => createBikeshareDisplay(engine, context),
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return {
|
||||
|
||||
@@ -83,13 +83,12 @@ test('a recovered city clears its own failure', () => {
|
||||
// drive the real proximity check, status poll and lifecycle against a fake
|
||||
// GBFS source, so the failure state they read is the state the layer builds.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createRendering } from './render/cesium/rendering.js';
|
||||
import { createLifecycle } from './render/cesium/lifecycle.js';
|
||||
import { createBikeshareDisplay } from './render/display.js';
|
||||
|
||||
const TORONTO = 'toronto-bike-share';
|
||||
const BUFFALO = 'buffalo-reddy';
|
||||
@@ -101,12 +100,6 @@ const TORONTO_ONLY = { lat: 43.7, lon: -79.6 };
|
||||
const BUFFALO_ONLY = { lat: 42.5, lon: -78.0 };
|
||||
const LOW_M = 10_000;
|
||||
|
||||
// disable() detaches a keydown listener; node has no document.
|
||||
globalThis.document ??= {
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
};
|
||||
|
||||
/** A GBFS source whose per-city answers each test can change. */
|
||||
function makeSource() {
|
||||
const cityOf = (url) => {
|
||||
@@ -175,7 +168,6 @@ function makeLiveLayer() {
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource() {},
|
||||
},
|
||||
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
|
||||
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
|
||||
};
|
||||
const state = createState({ services });
|
||||
@@ -183,49 +175,36 @@ function makeLiveLayer() {
|
||||
const context = { state, services, parts, source };
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.model = createModel(context);
|
||||
parts.viewport = createViewport(context);
|
||||
parts.rendering = createRendering(context);
|
||||
// Selection owns a DOM click handler; nothing here selects a station.
|
||||
parts.selection = {
|
||||
refreshSelectedCard() {},
|
||||
_installClickHandler() {},
|
||||
_clearSelection() {},
|
||||
_onKeyDown() {},
|
||||
};
|
||||
// The display's parts on a fake globe the test moves; nothing here
|
||||
// clicks a station.
|
||||
const engine = createFakeEngine();
|
||||
// The view centre is the point under the camera.
|
||||
engine.fake.script('viewRectangle', undefined);
|
||||
const display = createBikeshareDisplay(engine, context);
|
||||
Object.assign(parts, display);
|
||||
parts.queries = { collectDetectableStations: () => [] };
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
const { methods: controls } = createControls(context);
|
||||
|
||||
const camera = { ...BETWEEN, height: LOW_M };
|
||||
const viewer = {
|
||||
camera: {
|
||||
get positionCartographic() {
|
||||
return Cesium.Cartographic.fromDegrees(
|
||||
camera.lon,
|
||||
camera.lat,
|
||||
camera.height,
|
||||
);
|
||||
},
|
||||
computeViewRectangle: () => undefined,
|
||||
changed: { addEventListener() {}, removeEventListener() {} },
|
||||
},
|
||||
scene: { globe: {}, primitives: { add() {}, remove() {} } },
|
||||
};
|
||||
parts.lifecycle.methods.init(viewer);
|
||||
const place = (where, height) =>
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(where.lon, where.lat, height),
|
||||
});
|
||||
place(BETWEEN, LOW_M);
|
||||
parts.lifecycle.methods.init();
|
||||
|
||||
return {
|
||||
state,
|
||||
source,
|
||||
stats: () => controls.getStats(),
|
||||
moveTo(place, height = LOW_M) {
|
||||
Object.assign(camera, place, { height });
|
||||
moveTo(where, height = LOW_M) {
|
||||
place(where, height);
|
||||
return parts.viewport.runProximityCheck();
|
||||
},
|
||||
async enable() {
|
||||
parts.lifecycle.methods.enable(viewer);
|
||||
parts.lifecycle.methods.enable();
|
||||
await settle();
|
||||
},
|
||||
disable: () => parts.lifecycle.methods.disable(viewer),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
poll: () => parts.ingestion.methods.update(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
|
||||
state._selectedKey = selected;
|
||||
state._cityRuntime.clear();
|
||||
state._stationRenderMap.clear();
|
||||
state._viewer = state._engine = null;
|
||||
state._engine = null;
|
||||
renderer = null;
|
||||
initialized = false;
|
||||
for (const key of ['rendering', 'selection', 'viewport', 'lifecycle'])
|
||||
@@ -103,7 +103,11 @@ export function createBikeshareLayer({ services, source, rendererFactory }) {
|
||||
{
|
||||
createBikeshareSelectedOverlayEntry,
|
||||
_setBikeshareSelectionStateForTest: (options) => {
|
||||
renderer ??= rendererFactory({ ...context, viewer: options.viewer });
|
||||
renderer ??= rendererFactory({
|
||||
...context,
|
||||
viewer: null,
|
||||
engine: options.engine,
|
||||
});
|
||||
Object.assign(parts, renderer);
|
||||
parts.testing._setBikeshareSelectionStateForTest(options);
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each system's PBSC host, the station farthest from its registered centre,
|
||||
@@ -56,6 +56,8 @@ const { computeInRangeCities } = createViewport({
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
// The range check reads no camera.
|
||||
camera: {},
|
||||
});
|
||||
|
||||
test('Bogotá and Santiago are registered and readable through the proxy', () => {
|
||||
|
||||
@@ -448,18 +448,20 @@ test('unknown GBFS counts stay unknown through the source, model and station col
|
||||
});
|
||||
|
||||
test('a station without a published capacity re-derives it from every status poll', async () => {
|
||||
const { createRendering } = await import('./render/cesium/rendering.js');
|
||||
const { createBikeshareDisplay } = await import('./render/display.js');
|
||||
const { createFakeEngine } =
|
||||
await import('../../globe/testing/fakeEngine.js');
|
||||
const { COLOR_YELLOW } = await import('./policy.js');
|
||||
const model = createModel({});
|
||||
const state = {
|
||||
_cityRuntime: new Map(),
|
||||
_stationRenderMap: new Map(),
|
||||
_pointCollection: { add: (options) => ({ ...options }) },
|
||||
};
|
||||
const rendering = createRendering({
|
||||
const parts = { model, selection: { refreshSelectedCard() {} } };
|
||||
const { rendering } = createBikeshareDisplay(createFakeEngine(), {
|
||||
state,
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts: { model, selection: { refreshSelectedCard() {} } },
|
||||
parts,
|
||||
});
|
||||
const info = model.parseStationInformation({
|
||||
data: {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createLifecycle } from './cesium/lifecycle.js';
|
||||
import { createRendering } from './cesium/rendering.js';
|
||||
import { createSelection } from './cesium/selection.js';
|
||||
import { createViewport } from './cesium/viewport.js';
|
||||
|
||||
/** Compose the existing Cesium-owned bikeshare render modules. */
|
||||
export function createBikeshareCesiumRenderer(context) {
|
||||
return {
|
||||
rendering: createRendering(context),
|
||||
selection: createSelection(context),
|
||||
viewport: createViewport(context),
|
||||
lifecycle: createLifecycle(context),
|
||||
};
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { GBFS_CITY_REGISTRY } from '../../registry.js';
|
||||
import { BIKESHARE_SELECTED_OVERLAY_SOURCE_ID } from '../../policy.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../../../data/cameraSensitivity.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
engine,
|
||||
}) {
|
||||
const {
|
||||
registerSpriteCollection,
|
||||
unregisterSpriteCollection,
|
||||
restoreSpriteOrder,
|
||||
} = services.sprites;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* Initialize the bikeshare layer. Creates the point primitive collection,
|
||||
* resets all internal state, and installs the click handler.
|
||||
* Called once during app bootstrap.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
init(viewer) {
|
||||
layerState._viewer = viewer;
|
||||
layerState._pointCollection = new Cesium.PointPrimitiveCollection({
|
||||
blendOption: Cesium.BlendOption.TRANSLUCENT,
|
||||
});
|
||||
viewer.scene.primitives.add(layerState._pointCollection);
|
||||
registerSpriteCollection('bikeshare', layerState._pointCollection);
|
||||
layerState._pointCollection.show = false;
|
||||
|
||||
layerState._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
|
||||
parts.selection._installClickHandler(viewer);
|
||||
|
||||
restoreSpriteOrder(viewer);
|
||||
|
||||
console.log(
|
||||
`[Data:Bikeshare] Initialized with ${GBFS_CITY_REGISTRY.length} cities`,
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable the bikeshare layer. Shows points, attaches the camera listener,
|
||||
* and triggers an initial proximity check.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
enable(viewer) {
|
||||
if (layerState._enabled) return;
|
||||
layerState._enabled = true;
|
||||
layerState._failedCityIds.clear();
|
||||
this.show(viewer);
|
||||
},
|
||||
show(viewer) {
|
||||
viewer = layerState._viewer;
|
||||
layerState._pointCollection.show = true;
|
||||
layerState._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
parts.selection._installClickHandler(viewer);
|
||||
// Pick-ownership (H2): station point ids are string render-map keys.
|
||||
registerPickOwner('bikeshare', (pickedId) =>
|
||||
layerState._stationRenderMap.has(pickedId),
|
||||
);
|
||||
|
||||
if (!layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.addEventListener(parts.viewport.onCameraChanged);
|
||||
// Through the shared ledger: this is a single number every layer's
|
||||
// camera.changed listener shares, and a layer leaving must not hand
|
||||
// back a coarse default while this one is still driving off it.
|
||||
claimCameraSensitivity(engine ?? viewer.camera, 'bikeshare', 0.05);
|
||||
layerState._cameraChangedAttached = true;
|
||||
}
|
||||
|
||||
void parts.viewport.runProximityCheck();
|
||||
restoreSpriteOrder(viewer);
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable the bikeshare layer. Hides points, removes event listeners,
|
||||
* aborts all pending fetches, and tears down all city data.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
disable(viewer) {
|
||||
viewer = layerState._viewer;
|
||||
if (!viewer) return;
|
||||
layerState._enabled = false;
|
||||
layerState._proximityGeneration++;
|
||||
layerState._altitudeGateEnabled = false;
|
||||
clearTimeout(layerState._cameraDebounceTimer);
|
||||
layerState._cameraDebounceTimer = null;
|
||||
parts.selection._clearSelection();
|
||||
layerState._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
|
||||
if (layerState._clickHandler) {
|
||||
layerState._clickHandler.destroy();
|
||||
layerState._clickHandler = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.selection._onKeyDown);
|
||||
unregisterPickOwner('bikeshare');
|
||||
|
||||
if (layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
|
||||
parts.ingestion.abortAllInFlight();
|
||||
parts.viewport.deactivateAllCities();
|
||||
layerState._cityRuntime.clear();
|
||||
layerState._pointCollection.show = false;
|
||||
layerState._count = 0;
|
||||
layerState._loading = false;
|
||||
layerState._loadingOps = 0;
|
||||
},
|
||||
|
||||
detach() {
|
||||
const viewer = layerState._viewer;
|
||||
parts.selection._clearSelection();
|
||||
layerState._clickHandler?.destroy();
|
||||
layerState._clickHandler = null;
|
||||
document.removeEventListener('keydown', parts.selection._onKeyDown);
|
||||
unregisterPickOwner('bikeshare');
|
||||
if (layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
unregisterSpriteCollection?.('bikeshare', layerState._pointCollection);
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
},
|
||||
|
||||
/** Permanently release primitives, handlers, and the selected host source. */
|
||||
destroy(viewer) {
|
||||
viewer = layerState._viewer;
|
||||
if (!viewer) return;
|
||||
clearTimeout(layerState._cameraDebounceTimer);
|
||||
layerState._cameraDebounceTimer = null;
|
||||
if (layerState._enabled) this.disable(viewer);
|
||||
else {
|
||||
parts.selection._clearSelection();
|
||||
layerState._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
if (layerState._clickHandler) {
|
||||
layerState._clickHandler.destroy();
|
||||
layerState._clickHandler = null;
|
||||
}
|
||||
document.removeEventListener('keydown', parts.selection._onKeyDown);
|
||||
unregisterPickOwner('bikeshare');
|
||||
}
|
||||
if (layerState._cameraChangedAttached) {
|
||||
viewer.camera.changed.removeEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
releaseCameraSensitivity(engine ?? viewer.camera, 'bikeshare');
|
||||
layerState._cameraChangedAttached = false;
|
||||
}
|
||||
parts.ingestion.abortAllInFlight();
|
||||
if (layerState._pointCollection) {
|
||||
unregisterSpriteCollection?.('bikeshare', layerState._pointCollection);
|
||||
viewer.scene.primitives.remove(layerState._pointCollection);
|
||||
layerState._pointCollection = null;
|
||||
}
|
||||
layerState._viewer = null;
|
||||
layerState._stationInfoCache.clear();
|
||||
layerState._statusCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createState } from '../../state.js';
|
||||
|
||||
test('destroy releases the original viewer and registered collection even when passed another viewer', (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = { removeEventListener() {} };
|
||||
t.after(() => {
|
||||
globalThis.document = previous;
|
||||
});
|
||||
const noop = () => {};
|
||||
const calls = [];
|
||||
const services = {
|
||||
overlays: {
|
||||
setOverlayEntries: noop,
|
||||
setOverlaySourceVisible: noop,
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
sprites: {
|
||||
registerSpriteCollection: noop,
|
||||
unregisterSpriteCollection: (...args) => calls.push(args),
|
||||
restoreSpriteOrder: noop,
|
||||
},
|
||||
picking: { registerPickOwner: noop, unregisterPickOwner: noop },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const viewer = { scene: { primitives: new Cesium.PrimitiveCollection() } };
|
||||
const other = { scene: { primitives: new Cesium.PrimitiveCollection() } };
|
||||
const lifecycle = createLifecycle({
|
||||
state,
|
||||
services,
|
||||
parts: {
|
||||
selection: {
|
||||
_installClickHandler: noop,
|
||||
_clearSelection: noop,
|
||||
_onKeyDown: noop,
|
||||
},
|
||||
ingestion: { abortAllInFlight: noop },
|
||||
},
|
||||
}).methods;
|
||||
lifecycle.init(viewer);
|
||||
const collection = state._pointCollection;
|
||||
state._stationInfoCache.set('city', new Map());
|
||||
state._statusCache.set('city', {});
|
||||
lifecycle.destroy(other);
|
||||
lifecycle.destroy(other);
|
||||
assert.equal(viewer.scene.primitives.length, 0);
|
||||
assert.equal(other.scene.primitives.length, 0);
|
||||
assert.equal(collection.isDestroyed(), true);
|
||||
assert.deepEqual(calls, [['bikeshare', collection]]);
|
||||
assert.equal(state._stationInfoCache.size, 0);
|
||||
assert.equal(state._statusCache.size, 0);
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COLOR_NEUTRAL,
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
COLOR_OUTLINE,
|
||||
} from '../../policy.js';
|
||||
import { createStationPoints } from '../../stationPoints.js';
|
||||
|
||||
export function createRendering(context) {
|
||||
const { state: layerState } = context;
|
||||
|
||||
/**
|
||||
* Create a Cartesian3 position for a station, terrain-clamped when possible.
|
||||
* Falls back to a fixed small height offset if terrain sampling is unsupported.
|
||||
* @param {Object} station - Station info object with lat/lon.
|
||||
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
|
||||
*/
|
||||
|
||||
function createStationPosition(station) {
|
||||
const lon = Number(station?.lon);
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
|
||||
let height = POINT_HEIGHT_OFFSET_M;
|
||||
// Sample terrain height so points sit on ground rather than at ellipsoid level
|
||||
if (layerState._viewer?.scene?.sampleHeightSupported) {
|
||||
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
|
||||
const sampled = layerState._viewer.scene.sampleHeight(carto);
|
||||
if (Number.isFinite(sampled)) {
|
||||
height = sampled + POINT_HEIGHT_OFFSET_M;
|
||||
}
|
||||
}
|
||||
|
||||
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
||||
}
|
||||
|
||||
return createStationPoints(context, {
|
||||
position: createStationPosition,
|
||||
// A point primitive with distance-based scale and translucency falloff
|
||||
create: (record, { position, pixelSize }) =>
|
||||
layerState._pointCollection.add({
|
||||
position,
|
||||
pixelSize,
|
||||
color: COLOR_NEUTRAL,
|
||||
outlineColor: COLOR_OUTLINE,
|
||||
outlineWidth: 1,
|
||||
scaleByDistance: new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
|
||||
translucencyByDistance: new Cesium.NearFarScalar(
|
||||
200,
|
||||
1.0,
|
||||
180000,
|
||||
0.15,
|
||||
),
|
||||
disableDepthTestDistance: 2500,
|
||||
id: record.key,
|
||||
}),
|
||||
remove: (record) => layerState._pointCollection.remove(record.point),
|
||||
});
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../../../data/inputOwnership.js';
|
||||
import { createBikeshareSelectedOverlayEntry } from '../../selectionModel.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
} from '../../policy.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/**
|
||||
* Clear the current station selection.
|
||||
* Re-shows the hidden point primitive and removes the highlight entity.
|
||||
*/
|
||||
|
||||
function _clearSelection() {
|
||||
if (layerState._selectedKey) {
|
||||
const record = layerState._stationRenderMap.get(layerState._selectedKey);
|
||||
if (record?.point) {
|
||||
record.point.show = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (layerState._selectedEntity && layerState._viewer) {
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
}
|
||||
|
||||
layerState._selectedKey = null;
|
||||
layerState._selectedEntity = null;
|
||||
layerState._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a station by key: hides the original point primitive and adds a
|
||||
* highlighted cyan entity plus a protected shared-host availability card.
|
||||
* @param {string} key - Composite "cityId:stationId" key.
|
||||
*/
|
||||
|
||||
function _selectStation(key) {
|
||||
_clearSelection();
|
||||
|
||||
const record = layerState._stationRenderMap.get(key);
|
||||
if (!record || !record.point?.position || !layerState._viewer) return;
|
||||
|
||||
layerState._selectedKey = key;
|
||||
// Hide the base point so the highlight entity replaces it visually
|
||||
record.point.show = false;
|
||||
|
||||
layerState._selectedEntity = layerState._viewer.entities.add({
|
||||
position: record.point.position,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color: Cesium.Color.CYAN,
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
refreshSelectedCard();
|
||||
}
|
||||
|
||||
function refreshSelectedCard() {
|
||||
const key = layerState._selectedKey;
|
||||
const record = layerState._stationRenderMap.get(key);
|
||||
if (!record) return;
|
||||
const entry = createBikeshareSelectedOverlayEntry(key, record);
|
||||
if (entry) {
|
||||
layerState._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a screen-space click handler for station selection/deselection.
|
||||
* Also registers a global keydown listener for Escape-to-deselect.
|
||||
* Idempotent — does nothing if a handler is already installed.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
*/
|
||||
|
||||
function _installClickHandler(viewer) {
|
||||
if (layerState._clickHandler) return;
|
||||
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
viewer.scene.canvas,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
|
||||
if (picked) {
|
||||
// Clicking selected entity itself — ignore (don't deselect).
|
||||
if (picked.id === layerState._selectedEntity) return;
|
||||
|
||||
// Check if the picked primitive or entity id matches a station key
|
||||
const primitive = picked.primitive;
|
||||
if (
|
||||
primitive &&
|
||||
typeof primitive.id === 'string' &&
|
||||
layerState._stationRenderMap.has(primitive.id)
|
||||
) {
|
||||
_selectStation(primitive.id);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeof picked.id === 'string' &&
|
||||
layerState._stationRenderMap.has(picked.id)
|
||||
) {
|
||||
_selectStation(picked.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Clicked empty space — deselect.
|
||||
if (layerState._selectedKey) _clearSelection();
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
|
||||
document.addEventListener('keydown', _onKeyDown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global keydown handler — deselects the current station on Escape.
|
||||
* @param {KeyboardEvent} e - Keyboard event.
|
||||
*/
|
||||
|
||||
function _onKeyDown(e) {
|
||||
if (e.key === 'Escape' && layerState._selectedKey) {
|
||||
_clearSelection();
|
||||
}
|
||||
}
|
||||
return {
|
||||
refreshSelectedCard,
|
||||
_clearSelection,
|
||||
_selectStation,
|
||||
_installClickHandler,
|
||||
_onKeyDown,
|
||||
};
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../../geo/index.js';
|
||||
import { createViewport as createSharedViewport } from '../../viewport.js';
|
||||
|
||||
export function createViewport(context) {
|
||||
/**
|
||||
* Get the camera's current altitude in meters above the ellipsoid.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {number} Altitude in meters, or Infinity if unavailable.
|
||||
*/
|
||||
|
||||
function getCameraAltitude(viewer) {
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
return carto && Number.isFinite(carto.height) ? carto.height : Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the lat/lon the camera is currently looking at.
|
||||
* Prefers the center of the computed view rectangle; falls back to the
|
||||
* camera's own cartographic position when the rectangle is unavailable.
|
||||
* @param {Cesium.Viewer} viewer - Cesium viewer instance.
|
||||
* @returns {{ lat: number, lon: number }|null} Center coordinates in degrees, or null.
|
||||
*/
|
||||
|
||||
function getCameraCenterLatLon(viewer) {
|
||||
// Try view rectangle center first (more accurate for tilted views)
|
||||
const rect = viewer?.camera?.computeViewRectangle?.(
|
||||
viewer.scene.globe?.ellipsoid,
|
||||
);
|
||||
if (rect) {
|
||||
const center = Cesium.Rectangle.center(rect);
|
||||
return {
|
||||
lat: geo.math.toDegrees(center.latitude),
|
||||
lon: geo.math.toDegrees(center.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: use the camera's own position
|
||||
const carto = viewer?.camera?.positionCartographic;
|
||||
if (carto) {
|
||||
return {
|
||||
lat: geo.math.toDegrees(carto.latitude),
|
||||
lon: geo.math.toDegrees(carto.longitude),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return createSharedViewport({
|
||||
...context,
|
||||
camera: { getCameraAltitude, getCameraCenterLatLon },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
// The bikeshare stations on the globe contract (engine.movers, engine.input,
|
||||
// engine.camera, engine.surface), the same on every engine:
|
||||
// - rendering: one point per station in one point set, sized by capacity
|
||||
// and coloured by availability, scaled and faded with distance, on top
|
||||
// only within 2.5 km of the camera;
|
||||
// - selection: a click on a station hides its point under a 14 px cyan
|
||||
// highlight (a one-point set drawn over everything) and publishes its
|
||||
// card; a click elsewhere or Escape clears it;
|
||||
// - viewport: the camera's altitude and view centre for the city range
|
||||
// check, rerun after camera changes;
|
||||
// - lifecycle: what init, enable, show, disable, detach and destroy own.
|
||||
// The layer (index.js) merges these into its parts.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
claimCameraSensitivity,
|
||||
releaseCameraSensitivity,
|
||||
} from '../../../data/cameraSensitivity.js';
|
||||
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||
import { viewRectangleCenter } from '../../shared/viewRectangle.js';
|
||||
import { GBFS_CITY_REGISTRY } from '../registry.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
COLOR_NEUTRAL,
|
||||
MAX_TOTAL_POINTS,
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
} from '../policy.js';
|
||||
import { createBikeshareSelectedOverlayEntry } from '../selectionModel.js';
|
||||
import { createStationPoints } from '../stationPoints.js';
|
||||
import { createViewport } from '../viewport.js';
|
||||
import {
|
||||
SELECTED_COLOR,
|
||||
SELECTED_POINT,
|
||||
SELECTED_SET_ID,
|
||||
STATION_DEPTH_TEST,
|
||||
STATION_POINT,
|
||||
STATION_SET_ID,
|
||||
} from './style.js';
|
||||
|
||||
const LAYER_ID = 'bikeshare';
|
||||
// The station points take the layer's place among the contact sprites; the
|
||||
// highlight draws over the layer's own points.
|
||||
const STATION_ORDER = SPRITE_LAYER_ORDER.indexOf(LAYER_ID);
|
||||
const SELECTED_ORDER = STATION_ORDER + 0.5;
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ state: object, services: object, parts: object, source: object }} context
|
||||
* @returns {{ rendering: object, selection: object, viewport: object, lifecycle: { methods: object } }}
|
||||
*/
|
||||
export function createBikeshareDisplay(engine, context) {
|
||||
if (!engine)
|
||||
throw new TypeError('The bikeshare display needs a globe engine');
|
||||
const { state, services, parts } = context;
|
||||
const { registerPickOwner, unregisterPickOwner } = services.picking ?? {};
|
||||
let stations = null;
|
||||
let highlight = null;
|
||||
let visible = false;
|
||||
let destroyed = false;
|
||||
let removeClick = null;
|
||||
let removeCamera = null;
|
||||
|
||||
// Both sets, made with the first station or selection.
|
||||
function ensureSets() {
|
||||
if (stations || destroyed) return Boolean(stations);
|
||||
stations = engine.movers.createSet({
|
||||
id: STATION_SET_ID,
|
||||
kind: 'point',
|
||||
capacity: MAX_TOTAL_POINTS,
|
||||
order: STATION_ORDER,
|
||||
pickable: true,
|
||||
sharpen: true,
|
||||
depthTest: STATION_DEPTH_TEST,
|
||||
point: STATION_POINT,
|
||||
});
|
||||
highlight = engine.movers.createSet({
|
||||
id: SELECTED_SET_ID,
|
||||
kind: 'point',
|
||||
capacity: 1,
|
||||
order: SELECTED_ORDER,
|
||||
pickable: true,
|
||||
sharpen: true,
|
||||
depthTest: 'always',
|
||||
point: SELECTED_POINT,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
function destroySets() {
|
||||
stations?.destroy();
|
||||
highlight?.destroy();
|
||||
stations = highlight = null;
|
||||
}
|
||||
|
||||
// A station's point as stationPoints keeps it: position, pixelSize,
|
||||
// colour and show.
|
||||
function writePoint(record) {
|
||||
if (!stations) return;
|
||||
stations.set(record.index, {
|
||||
position: record.point.position,
|
||||
color: record.point.color.rgba(),
|
||||
scale: record.point.pixelSize / STATION_POINT.sizePx,
|
||||
show: record.point.show,
|
||||
pickId: record.key,
|
||||
});
|
||||
}
|
||||
function reindex() {
|
||||
let index = 0;
|
||||
for (const record of state._stationRenderMap.values()) {
|
||||
record.index = index++;
|
||||
writePoint(record);
|
||||
}
|
||||
}
|
||||
function commitStations() {
|
||||
if (!stations) return;
|
||||
stations.setCount(visible ? state._stationRenderMap.size : 0);
|
||||
stations.commit();
|
||||
}
|
||||
function commitHighlight() {
|
||||
if (!highlight) return;
|
||||
const key = state._selectedKey;
|
||||
const record = key ? state._stationRenderMap.get(key) : null;
|
||||
const shown = Boolean(record?.point?.position);
|
||||
if (shown)
|
||||
highlight.set(0, {
|
||||
position: record.point.position,
|
||||
color: SELECTED_COLOR.rgba(),
|
||||
scale: 1,
|
||||
show: true,
|
||||
pickId: key,
|
||||
});
|
||||
highlight.setCount(shown ? 1 : 0);
|
||||
highlight.commit();
|
||||
}
|
||||
// The stations show and hide together; the highlight goes with the
|
||||
// selection.
|
||||
function setVisible(next) {
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
commitStations();
|
||||
}
|
||||
|
||||
const rendering = createStationPoints(context, {
|
||||
// On the rendered surface where it answers, a small height otherwise.
|
||||
position(station) {
|
||||
const lon = Number(station?.lon);
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
ensureSets();
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(
|
||||
geo.cartographic.fromDegrees(lon, lat),
|
||||
[stations, highlight].filter(Boolean),
|
||||
)
|
||||
: undefined;
|
||||
const height =
|
||||
(Number.isFinite(sampled) ? sampled : 0) + POINT_HEIGHT_OFFSET_M;
|
||||
return geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
},
|
||||
create(record, { position, pixelSize }) {
|
||||
ensureSets();
|
||||
record.index = state._stationRenderMap.size;
|
||||
record.point = {
|
||||
position,
|
||||
pixelSize,
|
||||
color: COLOR_NEUTRAL,
|
||||
show: true,
|
||||
};
|
||||
writePoint(record);
|
||||
return record.point;
|
||||
},
|
||||
afterAdd: commitStations,
|
||||
afterRemove() {
|
||||
reindex();
|
||||
commitStations();
|
||||
},
|
||||
update: writePoint,
|
||||
afterUpdate: commitStations,
|
||||
});
|
||||
|
||||
const selection = {
|
||||
/** Clears the selection: the station's point returns, the card goes. */
|
||||
_clearSelection() {
|
||||
const record = state._selectedKey
|
||||
? state._stationRenderMap.get(state._selectedKey)
|
||||
: null;
|
||||
if (record?.point) {
|
||||
record.point.show = true;
|
||||
writePoint(record);
|
||||
commitStations();
|
||||
}
|
||||
state._selectedKey = null;
|
||||
commitHighlight();
|
||||
state._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
|
||||
},
|
||||
/** Selects a station: its point hides under the highlight and its card. */
|
||||
_selectStation(key) {
|
||||
selection._clearSelection();
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record || !record.point?.position || !ensureSets()) return;
|
||||
state._selectedKey = key;
|
||||
record.point.show = false;
|
||||
writePoint(record);
|
||||
commitStations();
|
||||
commitHighlight();
|
||||
selection.refreshSelectedCard();
|
||||
},
|
||||
refreshSelectedCard() {
|
||||
const key = state._selectedKey;
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record) return;
|
||||
const entry = createBikeshareSelectedOverlayEntry(key, record);
|
||||
if (entry)
|
||||
state._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
},
|
||||
/**
|
||||
* A click on a station selects it, one on the highlight changes nothing
|
||||
* and any other clears the selection; Escape clears it too. Once.
|
||||
*/
|
||||
_installClickHandler() {
|
||||
if (removeClick) return;
|
||||
const remove = engine.input.on('click', selection._onClick, {
|
||||
pick: true,
|
||||
});
|
||||
globalThis.document?.addEventListener?.('keydown', selection._onKeyDown);
|
||||
removeClick = () => {
|
||||
remove();
|
||||
globalThis.document?.removeEventListener?.(
|
||||
'keydown',
|
||||
selection._onKeyDown,
|
||||
);
|
||||
};
|
||||
},
|
||||
_uninstallClickHandler() {
|
||||
removeClick?.();
|
||||
removeClick = null;
|
||||
},
|
||||
_onClick(event) {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree()) return;
|
||||
const picked = event.picked;
|
||||
if (picked) {
|
||||
if (picked.owner === SELECTED_SET_ID) return;
|
||||
if (
|
||||
typeof picked.id === 'string' &&
|
||||
state._stationRenderMap.has(picked.id)
|
||||
) {
|
||||
selection._selectStation(picked.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (state._selectedKey) selection._clearSelection();
|
||||
},
|
||||
_onKeyDown(event) {
|
||||
if (event.key === 'Escape' && state._selectedKey)
|
||||
selection._clearSelection();
|
||||
},
|
||||
};
|
||||
|
||||
const viewport = createViewport({
|
||||
...context,
|
||||
camera: {
|
||||
getCameraAltitude: () => engine.camera.height(),
|
||||
// The view rectangle's centre, else the point under the camera.
|
||||
getCameraCenterLatLon() {
|
||||
const rect = engine.camera.viewRectangle();
|
||||
if (rect) {
|
||||
const { latitude, longitude } = viewRectangleCenter(rect);
|
||||
return {
|
||||
lat: geo.math.toDegrees(latitude),
|
||||
lon: geo.math.toDegrees(longitude),
|
||||
};
|
||||
}
|
||||
const cartographic = engine.camera.pose()?.cartographic;
|
||||
return cartographic
|
||||
? {
|
||||
lat: geo.math.toDegrees(cartographic.latitude),
|
||||
lon: geo.math.toDegrees(cartographic.longitude),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// camera.changed through the shared sensitivity ledger: every layer's
|
||||
// listener shares the one number (src/data/cameraSensitivity.js).
|
||||
function attachCamera() {
|
||||
if (state._cameraChangedAttached) return;
|
||||
removeCamera = engine.camera.changed.addEventListener(
|
||||
parts.viewport.onCameraChanged,
|
||||
);
|
||||
claimCameraSensitivity(engine, LAYER_ID, 0.05);
|
||||
state._cameraChangedAttached = true;
|
||||
}
|
||||
function detachCamera() {
|
||||
if (!state._cameraChangedAttached) return;
|
||||
removeCamera?.();
|
||||
removeCamera = null;
|
||||
releaseCameraSensitivity(engine, LAYER_ID);
|
||||
state._cameraChangedAttached = false;
|
||||
}
|
||||
function releaseInput() {
|
||||
parts.selection._uninstallClickHandler();
|
||||
unregisterPickOwner?.(LAYER_ID);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/** Builds the sets, hidden until show(). */
|
||||
init() {
|
||||
if (destroyed) throw new Error('Bikeshare renderer is destroyed');
|
||||
state._engine = engine;
|
||||
ensureSets();
|
||||
setVisible(false);
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
console.log(
|
||||
`[Data:Bikeshare] Initialized with ${GBFS_CITY_REGISTRY.length} cities`,
|
||||
);
|
||||
},
|
||||
enable() {
|
||||
if (destroyed || state._enabled) return;
|
||||
state._enabled = true;
|
||||
state._failedCityIds.clear();
|
||||
methods.show();
|
||||
},
|
||||
/** Shows the points and card, takes clicks and follows the camera. */
|
||||
show() {
|
||||
setVisible(true);
|
||||
state._overlayHost.setVisible(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID, true);
|
||||
parts.selection._installClickHandler();
|
||||
// Pick ownership: station point ids are render-map keys.
|
||||
registerPickOwner?.(LAYER_ID, (pickedId) =>
|
||||
state._stationRenderMap.has(pickedId),
|
||||
);
|
||||
attachCamera();
|
||||
void parts.viewport.runProximityCheck();
|
||||
},
|
||||
/** Hides everything, stops every request and drops the cities. */
|
||||
disable() {
|
||||
if (destroyed) return;
|
||||
state._enabled = false;
|
||||
state._proximityGeneration++;
|
||||
state._altitudeGateEnabled = false;
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
parts.selection._clearSelection();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
releaseInput();
|
||||
detachCamera();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
parts.viewport.deactivateAllCities();
|
||||
state._cityRuntime.clear();
|
||||
setVisible(false);
|
||||
state._count = 0;
|
||||
state._loading = false;
|
||||
state._loadingOps = 0;
|
||||
},
|
||||
/** Leaves this globe; the caches stay for the next one. */
|
||||
detach() {
|
||||
parts.selection._clearSelection();
|
||||
releaseInput();
|
||||
detachCamera();
|
||||
destroySets();
|
||||
destroyed = true;
|
||||
},
|
||||
/** Releases the sets, listeners and caches for good. */
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
if (state._enabled) methods.disable();
|
||||
else {
|
||||
parts.selection._clearSelection();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
releaseInput();
|
||||
}
|
||||
detachCamera();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
destroySets();
|
||||
destroyed = true;
|
||||
state._engine = null;
|
||||
state._stationInfoCache.clear();
|
||||
state._statusCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return { rendering, selection, viewport, lifecycle: { methods } };
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
// The bikeshare stations on the engine contract, on the fake engine and on
|
||||
// the Cesium engine (real Cesium point collections over a stub scene: they
|
||||
// draw nothing but hold what the engine writes): the station discs and the
|
||||
// selected highlight as the layer styles them, their height on the sampled
|
||||
// surface, the click and Escape rules, the camera's change listener and
|
||||
// sensitivity claim, and what enable/disable cycles and teardown leave.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createFakeEventHandlers } from '../../../globe/cesium/testing/fakeViewer.mjs';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createBikeshareLayer } from '../index.js';
|
||||
import { capacityToPixelSize } from '../presentation.js';
|
||||
import { COLOR_GREEN, COLOR_OUTLINE, COLOR_RED } from '../policy.js';
|
||||
import { createBikeshareDisplay } from './display.js';
|
||||
|
||||
const city = 'austin-capmetro';
|
||||
const key = `${city}:central`;
|
||||
const information = {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
name: '中央 / Central',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const status = (bikes = 7) => ({
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
num_bikes_available: bikes,
|
||||
num_docks_available: 10 - bikes,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
const near = (actual, expected, label, tolerance = 1e-6) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < tolerance,
|
||||
`${label}: ${actual} vs ${expected}`,
|
||||
);
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
/** A click whose pick is `picked` (undefined: the map). */
|
||||
click(picked) {
|
||||
engine.fake.script('pick', picked);
|
||||
engine.fake.input('click', { position: { x: 100, y: 100 } });
|
||||
},
|
||||
surface(height) {
|
||||
engine.fake.script('sampleHeight', height);
|
||||
},
|
||||
point: (id, index) => {
|
||||
const { store } = engine.fake.moverSet(id);
|
||||
return {
|
||||
shown: index < store.count && store.shows[index] === 1,
|
||||
pixelSize:
|
||||
engine.fake.moverSet(id).options.point.sizePx * store.scales[index],
|
||||
color: [...store.colors.slice(index * 4, index * 4 + 4)],
|
||||
id: store.pickIds[index],
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const handlers = createFakeEventHandlers();
|
||||
const { engine, viewer, primitive } = createCesiumMoversEngine({
|
||||
engineOptions: {
|
||||
createEventHandler: handlers.create,
|
||||
scheduleFrame: (run) => run(),
|
||||
},
|
||||
});
|
||||
const count = (id) => engine.movers.list().find((set) => set.id === id).count;
|
||||
// A Cesium pick of `picked`'s set and instance: its point primitive.
|
||||
let native;
|
||||
viewer.scene.pick = () => native;
|
||||
viewer.scene.drillPick = () => (native ? [native] : []);
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
primitive,
|
||||
click(picked) {
|
||||
native = undefined;
|
||||
if (picked?.owner?.startsWith('bikeshare'))
|
||||
native = { primitive: primitive(picked.owner).get(picked.instance) };
|
||||
else if (picked) native = { id: picked.id, primitive: {} };
|
||||
for (const handler of handlers.handlers)
|
||||
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
|
||||
position: new Cesium.Cartesian2(100, 100),
|
||||
});
|
||||
},
|
||||
surface(height) {
|
||||
viewer.scene.sampleHeightSupported = true;
|
||||
viewer.scene.sampleHeight = () => height;
|
||||
},
|
||||
point: (id, index) => {
|
||||
const point = primitive(id).get(index);
|
||||
return {
|
||||
shown: index < count(id) && point.show,
|
||||
pixelSize: point.pixelSize,
|
||||
color: [
|
||||
point.color.red,
|
||||
point.color.green,
|
||||
point.color.blue,
|
||||
point.color.alpha,
|
||||
],
|
||||
id: point.id,
|
||||
native: point,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESSES = [fakeHarness, cesiumHarness];
|
||||
|
||||
function fixture(makeHarness, getStations) {
|
||||
const h = makeHarness();
|
||||
h.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-97.7431, 30.2672, 10000),
|
||||
});
|
||||
const entries = new Map();
|
||||
const owners = new Map();
|
||||
let context, display;
|
||||
let bikes = 7;
|
||||
const source = {
|
||||
getStations:
|
||||
getStations ??
|
||||
(async (url) =>
|
||||
url.includes('station_information') ? information : status(bikes)),
|
||||
};
|
||||
const layer = createBikeshareLayer({
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries: (id, value) => entries.set(id, value),
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource: (id) => entries.delete(id),
|
||||
},
|
||||
picking: {
|
||||
registerPickOwner: (id, owns) => owners.set(id, owns),
|
||||
unregisterPickOwner: (id) => owners.delete(id),
|
||||
},
|
||||
},
|
||||
rendererFactory(value) {
|
||||
context = value;
|
||||
display = createBikeshareDisplay(h.engine, value);
|
||||
return display;
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine: h.engine });
|
||||
return {
|
||||
...h,
|
||||
h,
|
||||
layer,
|
||||
entries,
|
||||
owners,
|
||||
get context() {
|
||||
return context;
|
||||
},
|
||||
get display() {
|
||||
return display;
|
||||
},
|
||||
setBikes: (value) => {
|
||||
bikes = value;
|
||||
},
|
||||
count: (id) => h.engine.movers.list().find((set) => set.id === id)?.count,
|
||||
};
|
||||
}
|
||||
|
||||
test('the display needs a globe engine', () => {
|
||||
assert.throws(() => createBikeshareDisplay(null, {}), TypeError);
|
||||
});
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`stations draw as capacity-sized discs that fade with distance (${name})`, async () => {
|
||||
const f = fixture(makeHarness);
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
assert.equal(f.count('bikeshare'), 1);
|
||||
const station = f.point('bikeshare', 0);
|
||||
assert.equal(station.shown, true);
|
||||
assert.equal(station.id, key, 'a pick returns the station key');
|
||||
near(station.pixelSize, capacityToPixelSize(10), 'pixel size');
|
||||
// 7 of 10 bikes: plenty.
|
||||
COLOR_GREEN.rgba().forEach((value, i) =>
|
||||
near(station.color[i], value, `colour ${i}`),
|
||||
);
|
||||
if (f.cesium) {
|
||||
const collection = f.primitive('bikeshare');
|
||||
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
|
||||
const point = station.native;
|
||||
assert.equal(point.outlineWidth, 1);
|
||||
assert.ok(
|
||||
point.outlineColor.equalsEpsilon(
|
||||
new Cesium.Color(...COLOR_OUTLINE.rgba()),
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.NearFarScalar.equals(
|
||||
point.scaleByDistance,
|
||||
new Cesium.NearFarScalar(200, 1.35, 130000, 0.4),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.NearFarScalar.equals(
|
||||
point.translucencyByDistance,
|
||||
new Cesium.NearFarScalar(200, 1, 180000, 0.15),
|
||||
),
|
||||
);
|
||||
assert.equal(point.disableDepthTestDistance, 2500);
|
||||
} else {
|
||||
const { options } = f.engine.fake.moverSet('bikeshare');
|
||||
assert.equal(options.pickable, true);
|
||||
assert.equal(options.sharpen, true);
|
||||
assert.equal(options.order, 3, 'its data/spriteOrder slot');
|
||||
assert.deepEqual(options.depthTest, { disableWithinM: 2500 });
|
||||
assert.equal(options.point.outlinePx, 1);
|
||||
assert.deepEqual(options.point.outlineColor, COLOR_OUTLINE.rgba());
|
||||
assert.deepEqual(
|
||||
options.point.scaleByDistance,
|
||||
[200, 1.35, 130000, 0.4],
|
||||
);
|
||||
assert.deepEqual(
|
||||
options.point.translucencyByDistance,
|
||||
[200, 1, 180000, 0.15],
|
||||
);
|
||||
}
|
||||
f.setBikes(1);
|
||||
await f.layer.update();
|
||||
COLOR_RED.rgba().forEach((value, i) =>
|
||||
near(f.point('bikeshare', 0).color[i], value, `refreshed colour ${i}`),
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`a selected station hides under a cyan highlight and keeps its card fresh (${name})`, async () => {
|
||||
const f = fixture(makeHarness);
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
f.click({ id: key, owner: 'bikeshare', instance: 0 });
|
||||
assert.equal(f.count('bikeshare-selected'), 1);
|
||||
assert.equal(f.point('bikeshare', 0).shown, false);
|
||||
const highlight = f.point('bikeshare-selected', 0);
|
||||
assert.equal(highlight.shown, true);
|
||||
near(highlight.pixelSize, 14, 'highlight size');
|
||||
assert.deepEqual(highlight.color, [0, 1, 1, 1]);
|
||||
if (f.cesium) {
|
||||
const point = highlight.native;
|
||||
assert.equal(point.outlineWidth, 2);
|
||||
assert.ok(point.outlineColor.equals(Cesium.Color.BLACK));
|
||||
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
|
||||
assert.equal(point.scaleByDistance, undefined);
|
||||
} else {
|
||||
const { options } = f.engine.fake.moverSet('bikeshare-selected');
|
||||
assert.equal(options.depthTest, 'always');
|
||||
assert.equal(options.point.scaleByDistance, null);
|
||||
assert.deepEqual(options.point.outlineColor, [0, 0, 0, 1]);
|
||||
assert.ok(
|
||||
options.order > f.engine.fake.moverSet('bikeshare').options.order,
|
||||
'over the layer’s own points',
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
f.entries.get('bikeshare-selected')[0].title,
|
||||
'中央 / Central',
|
||||
);
|
||||
f.click({ id: key, owner: 'bikeshare-selected', instance: 0 });
|
||||
assert.equal(
|
||||
f.count('bikeshare-selected'),
|
||||
1,
|
||||
'a click on the highlight keeps the selection',
|
||||
);
|
||||
f.setBikes(2);
|
||||
await f.layer.update();
|
||||
assert.match(
|
||||
f.entries.get('bikeshare-selected')[0].details[0],
|
||||
/2 avail/,
|
||||
);
|
||||
assert.equal(f.layer.getAnalystRecords()[0].bikesAvailable, 2);
|
||||
f.click(undefined);
|
||||
assert.equal(f.count('bikeshare-selected'), 0);
|
||||
assert.equal(f.point('bikeshare', 0).shown, true);
|
||||
assert.equal(f.entries.size, 0);
|
||||
f.click({ id: key, owner: 'bikeshare', instance: 0 });
|
||||
f.click({ id: 'flight:abc', owner: 'flights-sprites-0', instance: 0 });
|
||||
assert.equal(
|
||||
f.count('bikeshare-selected'),
|
||||
0,
|
||||
"another layer's object clears the selection too",
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`camera changes rerun the range check and ten cycles leave nothing behind (${name})`, async () => {
|
||||
const f = fixture(makeHarness);
|
||||
try {
|
||||
const base = f.engine.camera.percentageChanged;
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
f.layer.enable();
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
assert.equal(f.engine.camera.percentageChanged, 0.05);
|
||||
assert.equal(f.owners.get('bikeshare')(key), true);
|
||||
f.engine.camera.changed.raiseEvent(0.1);
|
||||
assert.ok(f.context.state._cameraDebounceTimer);
|
||||
f.layer.disable();
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
assert.equal(f.context.state._cameraChangedAttached, false);
|
||||
assert.equal(f.engine.camera.percentageChanged, base);
|
||||
assert.equal(f.owners.has('bikeshare'), false);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.layer.getDetectableObjects(), []);
|
||||
assert.equal(f.count('bikeshare'), 0);
|
||||
f.engine.camera.changed.raiseEvent(0.1);
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
}
|
||||
const gate = f.display.viewport.shouldActivateAtAltitude;
|
||||
assert.equal(gate(49000), false);
|
||||
assert.equal(gate(48000), true);
|
||||
assert.equal(gate(51000), true);
|
||||
assert.equal(gate(52000), false);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
f.layer.destroy();
|
||||
}
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test(`station positions sample the surface with the layer's own sets hidden (${name})`, async () => {
|
||||
const f = fixture(makeHarness);
|
||||
try {
|
||||
f.surface(175);
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
const point = f.layer.getDetectableObjects()[0].position;
|
||||
near(geo.cartographic.fromCartesian(point).height, 177, 'height');
|
||||
if (!f.cesium) {
|
||||
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
|
||||
assert.deepEqual(
|
||||
call.args[1].map((set) => set.id),
|
||||
['bikeshare', 'bikeshare-selected'],
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('Escape clears the selection while the layer shows', async (t) => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete globalThis.document;
|
||||
else globalThis.document = previous;
|
||||
});
|
||||
const f = fixture(fakeHarness);
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
f.click({ id: key, owner: 'bikeshare', instance: 0 });
|
||||
assert.equal(f.count('bikeshare-selected'), 1);
|
||||
const escape = new Event('keydown');
|
||||
escape.key = 'Escape';
|
||||
globalThis.document.dispatchEvent(escape);
|
||||
assert.equal(f.count('bikeshare-selected'), 0);
|
||||
f.click({ id: key, owner: 'bikeshare', instance: 0 });
|
||||
f.layer.disable();
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
f.click({ id: key, owner: 'bikeshare', instance: 0 });
|
||||
f.layer.disable();
|
||||
globalThis.document.dispatchEvent(escape);
|
||||
assert.equal(f.count('bikeshare-selected'), 0);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('an aborted response cannot repopulate caches or clear a newer request loading state', async () => {
|
||||
const pending = [];
|
||||
const f = fixture(
|
||||
fakeHarness,
|
||||
(url, { signal }) =>
|
||||
new Promise((resolve) => pending.push({ url, signal, resolve })),
|
||||
);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 1);
|
||||
f.layer.disable();
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[0].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().loading, true);
|
||||
f.layer.destroy();
|
||||
pending[1].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test('a station without a published capacity re-derives it from every status poll', async () => {
|
||||
const f = fixture(fakeHarness);
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
const { rendering } = f.display;
|
||||
const { model } = f.context.parts;
|
||||
const info = model.parseStationInformation({
|
||||
data: {
|
||||
stations: [
|
||||
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
|
||||
],
|
||||
},
|
||||
});
|
||||
rendering.ensureCityPoints(city, info);
|
||||
const poll = (stations) =>
|
||||
rendering.applyStatusToPoints(
|
||||
city,
|
||||
model.parseStationStatus({ data: { stations } }),
|
||||
);
|
||||
const open = f.context.state._stationRenderMap.get(`${city}:open`);
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
|
||||
]);
|
||||
assert.equal(open.capacity, 30);
|
||||
near(
|
||||
f.point('bikeshare', open.index).pixelSize,
|
||||
capacityToPixelSize(30),
|
||||
'the point grows with it',
|
||||
);
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
|
||||
]);
|
||||
assert.equal(open.capacity, 6, 'the second poll is not pinned to 30');
|
||||
assert.equal(
|
||||
f.context.state._stationRenderMap.get(key).capacity,
|
||||
10,
|
||||
'a published capacity is kept',
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { CYAN } from '../../shared/color.js';
|
||||
import { viewRectangleCenter } from '../../shared/viewRectangle.js';
|
||||
import { createViewport } from '../viewport.js';
|
||||
import {
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
COLOR_NEUTRAL,
|
||||
COLOR_OUTLINE,
|
||||
MAX_TOTAL_POINTS,
|
||||
POINT_HEIGHT_OFFSET_M,
|
||||
} from '../policy.js';
|
||||
import { capacityToPixelSize } from '../presentation.js';
|
||||
import { createBikeshareSelectedOverlayEntry } from '../selectionModel.js';
|
||||
import { createStationPoints } from '../stationPoints.js';
|
||||
|
||||
const LAYER_ID = 'bikeshare';
|
||||
|
||||
function pointSet(engine, { id, capacity, order, depthTest, point }) {
|
||||
return engine.movers.createSet({
|
||||
id,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order,
|
||||
pickable: true,
|
||||
sharpen: true,
|
||||
depthTest,
|
||||
point,
|
||||
});
|
||||
}
|
||||
|
||||
export function createBikeshareNavaraRenderer(engine, context) {
|
||||
const { state, services, parts } = context;
|
||||
const normalSet = pointSet(engine, {
|
||||
id: LAYER_ID,
|
||||
capacity: MAX_TOTAL_POINTS,
|
||||
order: 30,
|
||||
point: {
|
||||
sizePx: 8,
|
||||
outlinePx: 1,
|
||||
outlineColor: COLOR_OUTLINE.rgba(),
|
||||
scaleByDistance: [200, 1.35, 130000, 0.4],
|
||||
translucencyByDistance: [200, 1, 180000, 0.15],
|
||||
},
|
||||
depthTest: { disableWithinM: 2500 },
|
||||
});
|
||||
const selectedSet = pointSet(engine, {
|
||||
id: `${LAYER_ID}-selected`,
|
||||
capacity: 1,
|
||||
order: 31,
|
||||
point: { sizePx: 14, outlinePx: 2, outlineColor: [0, 0, 0, 1] },
|
||||
depthTest: 'always',
|
||||
});
|
||||
let selectionRemove = null;
|
||||
let cameraRemove = null;
|
||||
let destroyed = false;
|
||||
|
||||
const syncPoint = (record) => {
|
||||
const color = record.point.color;
|
||||
normalSet.set(record.index, {
|
||||
position: record.point.position,
|
||||
color: color.rgba(),
|
||||
scale: capacityToPixelSize(record.capacity) / 8,
|
||||
show: record.point.show,
|
||||
pickId: record.key,
|
||||
});
|
||||
};
|
||||
const resetNormalSet = () => {
|
||||
normalSet.setCount(state._stationRenderMap.size);
|
||||
let index = 0;
|
||||
for (const record of state._stationRenderMap.values()) {
|
||||
record.index = index++;
|
||||
syncPoint(record);
|
||||
}
|
||||
normalSet.commit();
|
||||
state._count = state._stationRenderMap.size;
|
||||
};
|
||||
const request = (reason) => services.render.governorRequestRender(reason);
|
||||
|
||||
const rendering = createStationPoints(context, {
|
||||
position(station) {
|
||||
const lon = Number(station?.lon);
|
||||
const lat = Number(station?.lat);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const sampled = engine.surface.supported
|
||||
? engine.surface.sampleHeight(geo.cartographic.fromDegrees(lon, lat), [
|
||||
normalSet,
|
||||
selectedSet,
|
||||
])
|
||||
: undefined;
|
||||
const height =
|
||||
(Number.isFinite(sampled) ? sampled : 0) + POINT_HEIGHT_OFFSET_M;
|
||||
return geo.cartesian3.fromDegrees(lon, lat, height);
|
||||
},
|
||||
create(record, { position, pixelSize }) {
|
||||
record.index = state._stationRenderMap.size;
|
||||
record.point = { position, pixelSize, color: COLOR_NEUTRAL, show: true };
|
||||
syncPoint(record);
|
||||
return record.point;
|
||||
},
|
||||
afterAdd() {
|
||||
normalSet.setCount(state._stationRenderMap.size);
|
||||
normalSet.commit();
|
||||
},
|
||||
afterRemove: resetNormalSet,
|
||||
update: syncPoint,
|
||||
afterUpdate: () => normalSet.commit(),
|
||||
});
|
||||
|
||||
const selection = {
|
||||
refreshSelectedCard() {
|
||||
const key = state._selectedKey;
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record) return;
|
||||
state._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[createBikeshareSelectedOverlayEntry(key, record)],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
},
|
||||
_clearSelection() {
|
||||
if (state._selectedKey) {
|
||||
const record = state._stationRenderMap.get(state._selectedKey);
|
||||
if (record?.point) {
|
||||
record.point.show = true;
|
||||
syncPoint(record);
|
||||
normalSet.commit();
|
||||
}
|
||||
}
|
||||
state._selectedKey = null;
|
||||
state._selectedEntity = null;
|
||||
selectedSet.setCount(0);
|
||||
selectedSet.commit();
|
||||
state._overlayHost.clearSource(BIKESHARE_SELECTED_OVERLAY_SOURCE_ID);
|
||||
request('bikeshare-selection-clear');
|
||||
},
|
||||
_selectStation(key) {
|
||||
this._clearSelection();
|
||||
const record = state._stationRenderMap.get(key);
|
||||
if (!record || !record.point?.position) return;
|
||||
state._selectedKey = key;
|
||||
record.point.show = false;
|
||||
syncPoint(record);
|
||||
normalSet.commit();
|
||||
selectedSet.setCount(1);
|
||||
selectedSet.set(0, {
|
||||
position: record.point.position,
|
||||
color: CYAN.rgba(),
|
||||
scale: 1,
|
||||
show: true,
|
||||
pickId: key,
|
||||
});
|
||||
selectedSet.commit();
|
||||
const entry = createBikeshareSelectedOverlayEntry(key, record);
|
||||
if (entry) {
|
||||
state._overlayHost.setEntries(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
[entry],
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
}
|
||||
request('bikeshare-selection');
|
||||
},
|
||||
};
|
||||
|
||||
const viewport = createViewport({
|
||||
...context,
|
||||
camera: {
|
||||
getCameraAltitude: () => engine.camera.height(),
|
||||
getCameraCenterLatLon() {
|
||||
const rect = engine.camera.viewRectangle();
|
||||
if (rect) {
|
||||
const { latitude, longitude } = viewRectangleCenter(rect);
|
||||
return {
|
||||
lat: geo.math.toDegrees(latitude),
|
||||
lon: geo.math.toDegrees(longitude),
|
||||
};
|
||||
}
|
||||
const cartographic = engine.camera.pose()?.cartographic;
|
||||
return cartographic
|
||||
? {
|
||||
lat: geo.math.toDegrees(cartographic.latitude),
|
||||
lon: geo.math.toDegrees(cartographic.longitude),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycle = {
|
||||
methods: {
|
||||
init() {
|
||||
if (destroyed) throw new Error('Bikeshare renderer is destroyed');
|
||||
state._engine = engine;
|
||||
state._viewer = null;
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
},
|
||||
enable() {
|
||||
if (destroyed || state._enabled) return;
|
||||
state._enabled = true;
|
||||
state._error = null;
|
||||
this.show();
|
||||
},
|
||||
show() {
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
true,
|
||||
);
|
||||
selectionRemove = selectionDispatcherFor(engine).register(LAYER_ID, {
|
||||
owns: (id) => state._stationRenderMap.has(id),
|
||||
onSelect: (id) => selection._selectStation(id),
|
||||
onEmptyClick: () => selection._clearSelection(),
|
||||
onEscape: () => selection._clearSelection(),
|
||||
});
|
||||
cameraRemove = engine.camera.moveEnd.addEventListener(() =>
|
||||
viewport.onCameraChanged(),
|
||||
);
|
||||
state._cameraChangedAttached = true;
|
||||
void viewport.runProximityCheck();
|
||||
},
|
||||
disable() {
|
||||
if (destroyed) return;
|
||||
state._enabled = false;
|
||||
state._proximityGeneration++;
|
||||
state._altitudeGateEnabled = false;
|
||||
clearTimeout(state._cameraDebounceTimer);
|
||||
state._cameraDebounceTimer = null;
|
||||
selection._clearSelection();
|
||||
state._overlayHost.setVisible(
|
||||
BIKESHARE_SELECTED_OVERLAY_SOURCE_ID,
|
||||
false,
|
||||
);
|
||||
selectionRemove?.();
|
||||
selectionRemove = null;
|
||||
cameraRemove?.();
|
||||
cameraRemove = null;
|
||||
state._cameraChangedAttached = false;
|
||||
parts.ingestion.abortAllInFlight();
|
||||
viewport.deactivateAllCities();
|
||||
state._cityRuntime.clear();
|
||||
normalSet.setCount(0);
|
||||
selectedSet.setCount(0);
|
||||
normalSet.commit();
|
||||
selectedSet.commit();
|
||||
state._count = 0;
|
||||
state._loading = false;
|
||||
state._loadingOps = 0;
|
||||
},
|
||||
detach() {
|
||||
selection._clearSelection();
|
||||
selectionRemove?.();
|
||||
cameraRemove?.();
|
||||
state._cameraChangedAttached = false;
|
||||
normalSet.destroy();
|
||||
selectedSet.destroy();
|
||||
destroyed = true;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
this.disable();
|
||||
parts.ingestion.abortAllInFlight();
|
||||
normalSet.destroy();
|
||||
selectedSet.destroy();
|
||||
state._stationInfoCache.clear();
|
||||
state._statusCache.clear();
|
||||
state._engine = null;
|
||||
state._viewer = null;
|
||||
destroyed = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { rendering, selection, viewport, lifecycle };
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createBikeshareLayer } from '../index.js';
|
||||
import { createBikeshareNavaraRenderer } from './navara.js';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
|
||||
const city = 'austin-capmetro';
|
||||
const key = `${city}:central`;
|
||||
const information = {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
name: '中央 / Central',
|
||||
lat: 30.2672,
|
||||
lon: -97.7431,
|
||||
capacity: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const status = (bikes = 7) => ({
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 'central',
|
||||
num_bikes_available: bikes,
|
||||
num_docks_available: 10 - bikes,
|
||||
is_installed: true,
|
||||
is_renting: true,
|
||||
is_returning: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const settle = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function fixture(getStations) {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
engine.fake.setPose({
|
||||
longitude: (-97.7431 * Math.PI) / 180,
|
||||
latitude: (30.2672 * Math.PI) / 180,
|
||||
height: 10000,
|
||||
});
|
||||
engine.fake.script('viewRectangle', null);
|
||||
const entries = new Map();
|
||||
let context, renderer;
|
||||
let bikes = 7;
|
||||
const source = {
|
||||
getStations:
|
||||
getStations ??
|
||||
(async (url) =>
|
||||
url.includes('station_information') ? information : status(bikes)),
|
||||
};
|
||||
const layer = createBikeshareLayer({
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
setOverlayEntries: (id, value) => entries.set(id, value),
|
||||
setOverlaySourceVisible() {},
|
||||
clearOverlaySource: (id) => entries.delete(id),
|
||||
},
|
||||
},
|
||||
rendererFactory(value) {
|
||||
context = value;
|
||||
renderer = createBikeshareNavaraRenderer(engine, value);
|
||||
return renderer;
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return {
|
||||
engine,
|
||||
layer,
|
||||
entries,
|
||||
context,
|
||||
renderer,
|
||||
setBikes: (value) => {
|
||||
bikes = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('Navara GBFS loads real parsed stations, picks stable IDs and refreshes a selected status card', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
const normal = f.engine.fake.moverSet('bikeshare');
|
||||
const selected = f.engine.fake.moverSet('bikeshare-selected');
|
||||
assert.equal(normal.options.sharpen, true);
|
||||
assert.equal(selected.options.sharpen, true);
|
||||
assert.equal(normal.store.pickIds[0], key);
|
||||
assert.deepEqual(
|
||||
normal.options.point.translucencyByDistance,
|
||||
[200, 1, 180000, 0.15],
|
||||
);
|
||||
assert.equal(selected.options.point.scaleByDistance, null);
|
||||
assert.deepEqual(selected.options.point.outlineColor, [0, 0, 0, 1]);
|
||||
f.engine.fake.script('pick', { id: key });
|
||||
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
|
||||
assert.equal(selected.store.count, 1);
|
||||
assert.equal(normal.store.shows[0], 0);
|
||||
assert.equal(
|
||||
f.entries.get('bikeshare-selected')[0].title,
|
||||
'中央 / Central',
|
||||
);
|
||||
f.setBikes(2);
|
||||
await f.layer.update();
|
||||
assert.match(f.entries.get('bikeshare-selected')[0].details[0], /2 avail/);
|
||||
assert.equal(f.layer.getAnalystRecords()[0].bikesAvailable, 2);
|
||||
f.engine.fake.script('pick', null);
|
||||
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
|
||||
assert.equal(selected.store.count, 0);
|
||||
assert.equal(normal.store.shows[0], 1);
|
||||
assert.equal(f.entries.size, 0);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('camera callbacks preserve hysteresis and ten enable/disable cycles leave no owned data or listeners', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
for (let cycle = 0; cycle < 10; cycle++) {
|
||||
f.layer.enable();
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
assert.equal(f.layer.getStats().count, 1);
|
||||
f.engine.fake.emit('camera.moveEnd');
|
||||
assert.ok(f.context.state._cameraDebounceTimer);
|
||||
f.layer.disable();
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
assert.equal(f.context.state._cameraChangedAttached, false);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.layer.getDetectableObjects(), []);
|
||||
assert.equal(f.engine.fake.moverSet('bikeshare').store.count, 0);
|
||||
f.engine.fake.emit('camera.moveEnd');
|
||||
assert.equal(f.context.state._cameraDebounceTimer, null);
|
||||
}
|
||||
const gate = f.renderer.viewport.shouldActivateAtAltitude;
|
||||
assert.equal(gate(49000), false);
|
||||
assert.equal(gate(48000), true);
|
||||
assert.equal(gate(51000), true);
|
||||
assert.equal(gate(52000), false);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
f.layer.destroy();
|
||||
}
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test('an aborted response cannot repopulate caches or clear a newer request loading state', async () => {
|
||||
const pending = [];
|
||||
const f = fixture(
|
||||
(url, { signal }) =>
|
||||
new Promise((resolve) => pending.push({ url, signal, resolve })),
|
||||
);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 1);
|
||||
f.layer.disable();
|
||||
assert.equal(pending[0].signal.aborted, true);
|
||||
f.layer.enable();
|
||||
assert.equal(pending.length, 2);
|
||||
pending[0].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().loading, true);
|
||||
f.layer.destroy();
|
||||
pending[1].resolve(information);
|
||||
await settle();
|
||||
assert.equal(f.context.state._stationInfoCache.size, 0);
|
||||
assert.equal(f.layer.getStats().count, 0);
|
||||
assert.deepEqual(f.engine.movers.list(), []);
|
||||
});
|
||||
|
||||
test('station positions sample the visible surface with own point sets excluded', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.engine.fake.script('sampleHeight', 175);
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
const point = f.layer.getDetectableObjects()[0].position;
|
||||
const height = geo.cartographic.fromCartesian(point).height;
|
||||
assert.ok(Math.abs(height - 177) < 1e-6);
|
||||
const call = f.engine.fake.callsOf('surface.sampleHeight')[0];
|
||||
assert.deepEqual(
|
||||
call.args[1].map((set) => set.id),
|
||||
['bikeshare', 'bikeshare-selected'],
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a Navara station without a published capacity re-derives it from every status poll', async () => {
|
||||
const f = fixture();
|
||||
try {
|
||||
f.layer.enable();
|
||||
await settle();
|
||||
const { rendering } = f.renderer;
|
||||
const { model } = f.context.parts;
|
||||
const info = model.parseStationInformation({
|
||||
data: {
|
||||
stations: [
|
||||
{ station_id: 'open', name: 'Open', lat: 30.26, lon: -97.74 },
|
||||
],
|
||||
},
|
||||
});
|
||||
rendering.ensureCityPoints(city, info);
|
||||
const poll = (stations) =>
|
||||
rendering.applyStatusToPoints(
|
||||
city,
|
||||
model.parseStationStatus({ data: { stations } }),
|
||||
);
|
||||
const open = f.context.state._stationRenderMap.get(`${city}:open`);
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 10, num_docks_available: 20 },
|
||||
]);
|
||||
assert.equal(open.capacity, 30);
|
||||
poll([
|
||||
{ station_id: 'open', num_bikes_available: 4, num_docks_available: 2 },
|
||||
]);
|
||||
assert.equal(open.capacity, 6, 'the second poll is not pinned to 30');
|
||||
assert.equal(
|
||||
f.context.state._stationRenderMap.get(key).capacity,
|
||||
10,
|
||||
'a published capacity is kept',
|
||||
);
|
||||
} finally {
|
||||
f.layer.destroy();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
// How the bikeshare stations draw (engine.movers point sets).
|
||||
|
||||
import { CYAN } from '../../shared/color.js';
|
||||
import { COLOR_OUTLINE } from '../policy.js';
|
||||
|
||||
export const STATION_SET_ID = 'bikeshare';
|
||||
export const SELECTED_SET_ID = 'bikeshare-selected';
|
||||
|
||||
/**
|
||||
* A station's disc: sizePx times the point's scale (its capacity's pixel
|
||||
* size over sizePx), a faint outline, larger and opaque up close, smaller
|
||||
* and fading out with distance.
|
||||
*/
|
||||
export const STATION_POINT = Object.freeze({
|
||||
sizePx: 8,
|
||||
outlinePx: 1,
|
||||
outlineColor: COLOR_OUTLINE.rgba(),
|
||||
scaleByDistance: Object.freeze([200, 1.35, 130000, 0.4]),
|
||||
translucencyByDistance: Object.freeze([200, 1, 180000, 0.15]),
|
||||
});
|
||||
|
||||
/** On top within this distance of the camera, depth tested beyond. */
|
||||
export const STATION_DEPTH_TEST = Object.freeze({ disableWithinM: 2500 });
|
||||
|
||||
/** The selected station's highlight: a fixed-size cyan disc. */
|
||||
export const SELECTED_POINT = Object.freeze({
|
||||
sizePx: 14,
|
||||
outlinePx: 2,
|
||||
outlineColor: [0, 0, 0, 1],
|
||||
});
|
||||
export const SELECTED_COLOR = CYAN;
|
||||
@@ -3,7 +3,7 @@ import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each system's host, its stations farthest from the registered centre in
|
||||
@@ -120,6 +120,8 @@ const { computeInRangeCities } = createViewport({
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
// The range check reads no camera.
|
||||
camera: {},
|
||||
});
|
||||
|
||||
/** Station information for `id` through the real ingestion path. */
|
||||
|
||||
@@ -15,13 +15,9 @@ export function createState({ services }) {
|
||||
// Module-level mutable state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @type {Cesium.Viewer|null} Active Cesium viewer instance. */
|
||||
/** The globe engine the stations draw on, or null. */
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
/** @type {Cesium.PointPrimitiveCollection|null} Primitive collection for station dots. */
|
||||
|
||||
layerState._pointCollection = null;
|
||||
layerState._engine = null;
|
||||
|
||||
/** Whether the bikeshare layer is currently enabled. */
|
||||
|
||||
@@ -71,18 +67,10 @@ export function createState({ services }) {
|
||||
|
||||
layerState._stationRenderMap = new Map();
|
||||
|
||||
/** @type {Cesium.ScreenSpaceEventHandler|null} Click handler for station selection. */
|
||||
|
||||
layerState._clickHandler = null;
|
||||
|
||||
/** @type {string|null} Key of the currently selected station, or null. */
|
||||
|
||||
layerState._selectedKey = null;
|
||||
|
||||
/** @type {Cesium.Entity|null} Entity used to display the selected-station point highlight. */
|
||||
|
||||
layerState._selectedEntity = null;
|
||||
|
||||
/** Total number of currently rendered station points. */
|
||||
|
||||
layerState._count = 0;
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
export function createTesting({ state: layerState, services, parts, source }) {
|
||||
/** Seed a selected-station runtime record while still exercising real select/clear paths. */
|
||||
|
||||
function _setBikeshareSelectionStateForTest({
|
||||
viewer,
|
||||
key,
|
||||
record,
|
||||
overlayHost,
|
||||
}) {
|
||||
layerState._viewer = viewer;
|
||||
function _setBikeshareSelectionStateForTest({ key, record, overlayHost }) {
|
||||
layerState._stationRenderMap = new Map([[key, record]]);
|
||||
record.index = 0;
|
||||
layerState._selectedKey = null;
|
||||
layerState._selectedEntity = null;
|
||||
layerState._overlayHost = overlayHost || layerState.DEFAULT_OVERLAY_HOST;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { CITY_BY_ID } from './registry.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createViewport } from './render/cesium/viewport.js';
|
||||
import { createViewport } from './viewport.js';
|
||||
import { isAllowedGbfsHost, isAllowedGbfsPath } from '../../data/gbfsSource.js';
|
||||
|
||||
// Each city's PBSC host, and the stations farthest from its registered centre
|
||||
@@ -41,6 +41,8 @@ const { computeInRangeCities } = createViewport({
|
||||
services: { render: { governorRequestRender() {} } },
|
||||
parts,
|
||||
source: null,
|
||||
// The range check reads no camera.
|
||||
camera: {},
|
||||
});
|
||||
|
||||
test('the added US PBSC systems are registered and readable through the proxy', () => {
|
||||
|
||||
@@ -161,14 +161,14 @@ export function createViewport({ state: layerState, services, parts, camera }) {
|
||||
if (!layerState._enabled || layerState._restoringViewport) return;
|
||||
|
||||
const generation = ++layerState._proximityGeneration;
|
||||
const altitude = getCameraAltitude(layerState._viewer);
|
||||
const altitude = getCameraAltitude();
|
||||
// Altitude gate: disable all cities when camera is too high
|
||||
if (!shouldActivateAtAltitude(altitude)) {
|
||||
deactivateAllCities();
|
||||
return;
|
||||
}
|
||||
|
||||
const center = getCameraCenterLatLon(layerState._viewer);
|
||||
const center = getCameraCenterLatLon();
|
||||
if (!center) return;
|
||||
|
||||
// Diff active set against newly computed in-range set
|
||||
|
||||
@@ -3,8 +3,9 @@ import assert from 'node:assert/strict';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { WHITE } from '../shared/color.js';
|
||||
import { createLaunchesNavaraRenderer } from './render/navara.js';
|
||||
import { createLaunchesDisplay } from './render/display.js';
|
||||
import { createState } from './state.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createPaths } from './paths.js';
|
||||
import { createCamera } from './camera.js';
|
||||
import { createSelection } from './selection.js';
|
||||
@@ -27,13 +28,13 @@ const pad = (launch, height = 0) =>
|
||||
geo.cartesian3.fromDegrees(launch.lon, launch.lat, height);
|
||||
|
||||
function rig({ tle = async () => '1 25544U' } = {}) {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.setPose({
|
||||
longitude: geo.math.toRadians(-100),
|
||||
latitude: geo.math.toRadians(30),
|
||||
height: 20_000_000,
|
||||
});
|
||||
const renderer = createLaunchesNavaraRenderer(engine);
|
||||
const renderer = createLaunchesDisplay(engine);
|
||||
const state = createState({
|
||||
services: {
|
||||
overlays: {
|
||||
@@ -43,12 +44,10 @@ function rig({ tle = async () => '1 25544U' } = {}) {
|
||||
},
|
||||
},
|
||||
});
|
||||
state._viewer = renderer.view;
|
||||
state._engine = engine;
|
||||
state._renderer = renderer;
|
||||
state._enabled = true;
|
||||
state._dataSource = renderer.createDataSource();
|
||||
state._dataSource.show = true;
|
||||
renderer.setVisible(true);
|
||||
let feed = [CAPE, VANDENBERG];
|
||||
const source = {
|
||||
getLaunches: async () => feed,
|
||||
@@ -68,35 +67,42 @@ function rig({ tle = async () => '1 25544U' } = {}) {
|
||||
},
|
||||
policyHelpers: { releaseAircraftTracking: noop },
|
||||
model: { normalizeRocketLaunches: (launches) => launches },
|
||||
rendering: {
|
||||
// The launch marker (the pad) and a replay track per mission.
|
||||
addLaunchEntity(launch) {
|
||||
renderer.addEntity(state._dataSource, {
|
||||
id: `rocket-launch:${launch.id}`,
|
||||
position: pad(launch),
|
||||
point: { pixelSize: 8, color: WHITE },
|
||||
});
|
||||
state._replayTracks.set(launch.id, {
|
||||
ascentDurationSec: 600,
|
||||
ascentPath: [pad(launch), pad(launch, 100_000)],
|
||||
animatedOrbitPath: [pad(launch, 100_000), pad(launch, 200_000)],
|
||||
beginReplayFrame: () => ({
|
||||
ascending: true,
|
||||
phaseProgress: 0.2,
|
||||
elapsedSinceStart: 10,
|
||||
}),
|
||||
getReplayState: () => null,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
state,
|
||||
services: { geometry: { getKeyholeGeometry: () => ({ radius: 300 }) } },
|
||||
services: {
|
||||
geometry: { getKeyholeGeometry: () => ({ radius: 300 }) },
|
||||
satellites: {
|
||||
getSatelliteOrbitTrack: () => null,
|
||||
findSatelliteOrbitTrackInTle: () => null,
|
||||
},
|
||||
},
|
||||
parts,
|
||||
source,
|
||||
getRenderer: () => renderer,
|
||||
};
|
||||
parts.rendering = {
|
||||
...createRendering(context),
|
||||
// The launch marker (the pad) and a replay track per mission.
|
||||
addLaunchElements(launch) {
|
||||
parts.rendering.addMissionElement({
|
||||
id: `rocket-launch:${launch.id}`,
|
||||
position: pad(launch),
|
||||
point: { pixelSize: 8, color: WHITE },
|
||||
});
|
||||
state._replayTracks.set(launch.id, {
|
||||
ascentDurationSec: 600,
|
||||
ascentPath: [pad(launch), pad(launch, 100_000)],
|
||||
animatedOrbitPath: [pad(launch, 100_000), pad(launch, 200_000)],
|
||||
beginReplayFrame: () => ({
|
||||
ascending: true,
|
||||
phaseProgress: 0.2,
|
||||
elapsedSinceStart: 10,
|
||||
}),
|
||||
getReplayState: () => null,
|
||||
});
|
||||
},
|
||||
};
|
||||
parts.paths = createPaths(context);
|
||||
parts.camera = createCamera(context);
|
||||
parts.selection = createSelection(context);
|
||||
@@ -107,8 +113,7 @@ function rig({ tle = async () => '1 25544U' } = {}) {
|
||||
state,
|
||||
parts,
|
||||
setFeed: (next) => (feed = next),
|
||||
shown: (id) =>
|
||||
state._dataSource.entities.getById(`rocket-launch:${id}`)?.show,
|
||||
shown: (id) => state._missionElements.get(`rocket-launch:${id}`)?.shown,
|
||||
cameraCommands: () =>
|
||||
engine.fake.calls.filter(({ method }) =>
|
||||
[
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
// stubbed /api/launches, so a failed first load takes the browser's path.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { DataLayerManager } from '../../data/manager.js';
|
||||
import { ContextControls } from '../../ui/contextControls.js';
|
||||
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
|
||||
@@ -15,7 +16,7 @@ import { createPanel } from './panel.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createReplay } from './replay.js';
|
||||
import { createLaunchesCesiumRenderer } from './render/cesium.js';
|
||||
import { createLaunchesDisplay } from './render/display.js';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
@@ -48,47 +49,25 @@ function plainLayer(id, name, params = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCamera() {
|
||||
const camera = {
|
||||
position: new Cesium.Cartesian3(1, 2, 3),
|
||||
heading: 0.1,
|
||||
pitch: -0.5,
|
||||
roll: 0,
|
||||
flights: 0,
|
||||
cancelled: 0,
|
||||
flight: null,
|
||||
get positionWC() {
|
||||
return camera.position;
|
||||
// The fake engine's camera, counted and steered as the tests need: flights
|
||||
// run on its frames (land() runs them to the end), a new flight cancels the
|
||||
// one in progress.
|
||||
function cameraOf(engine) {
|
||||
const calls = (method) => engine.fake.callsOf(method).length;
|
||||
return {
|
||||
get flights() {
|
||||
return calls('camera.flyTo');
|
||||
},
|
||||
// Like Cesium, a new flight cancels the one in progress.
|
||||
flyTo({ destination, complete, cancel }) {
|
||||
camera.flight?.cancel?.();
|
||||
camera.flight = { complete, cancel };
|
||||
camera.flights += 1;
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = 0;
|
||||
camera.pitch = -Math.PI / 2;
|
||||
get cancelled() {
|
||||
return calls('camera.cancelFlight');
|
||||
},
|
||||
land() {
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.complete?.();
|
||||
},
|
||||
cancelFlight() {
|
||||
camera.cancelled += 1;
|
||||
const flight = camera.flight;
|
||||
camera.flight = null;
|
||||
flight?.cancel?.();
|
||||
},
|
||||
lookAtTransform: noop,
|
||||
setView({ destination, orientation }) {
|
||||
camera.position = Cesium.Cartesian3.clone(destination);
|
||||
camera.heading = orientation.heading;
|
||||
camera.pitch = orientation.pitch;
|
||||
camera.roll = orientation.roll;
|
||||
get flying() {
|
||||
return engine.camera.flightActive();
|
||||
},
|
||||
land: () => engine.fake.frames(400),
|
||||
flyTo: (options) => engine.camera.flyTo(options),
|
||||
setView: (view) => engine.camera.setView(view),
|
||||
};
|
||||
return camera;
|
||||
}
|
||||
|
||||
async function fixture(t) {
|
||||
@@ -111,25 +90,18 @@ async function fixture(t) {
|
||||
clearOverlaySource: noop,
|
||||
},
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
renderer: {
|
||||
create: ({ viewer: view }) =>
|
||||
createLaunchesCesiumRenderer(null, { viewer: view }),
|
||||
},
|
||||
renderer: { create: ({ engine }) => createLaunchesDisplay(engine) },
|
||||
};
|
||||
const state = createState({ services });
|
||||
const camera = fakeCamera();
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
clientWidth: 1200,
|
||||
clientHeight: 800,
|
||||
const engine = createFakeEngine({ width: 1200, height: 800 });
|
||||
// An oblique view the full-globe flight changes.
|
||||
engine.fake.setPose({
|
||||
position: geo.cartesian3.fromDegrees(10, 20, 3_000_000),
|
||||
heading: 0.1,
|
||||
pitch: -0.5,
|
||||
roll: 0,
|
||||
});
|
||||
const viewer = {
|
||||
camera,
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
const camera = cameraOf(engine);
|
||||
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
|
||||
const parts = {
|
||||
overlays: {
|
||||
@@ -143,7 +115,8 @@ async function fixture(t) {
|
||||
selection: {
|
||||
focusFullGlobe(target, flight) {
|
||||
target.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(9, 9, 9),
|
||||
destination: geo.cartesian3.fromDegrees(30, 40, 20_000_000),
|
||||
duration: 2.4,
|
||||
...flight,
|
||||
});
|
||||
return true;
|
||||
@@ -164,12 +137,10 @@ async function fixture(t) {
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
addLaunchElements(record) {
|
||||
state._missionElements.set(record.id, { id: record.id });
|
||||
},
|
||||
removeMissionElements: () => state._missionElements.clear(),
|
||||
},
|
||||
};
|
||||
const getRenderer = () => state._renderer;
|
||||
@@ -190,7 +161,7 @@ async function fixture(t) {
|
||||
const rosters = [];
|
||||
parts.panel.subscribePanel((view) => rosters.push(view.roster));
|
||||
|
||||
const manager = new DataLayerManager(viewer);
|
||||
const manager = new DataLayerManager(null, { engine });
|
||||
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
|
||||
manager.register(plainLayer('satellites', 'Satellites'));
|
||||
manager.register(plainLayer('wind', 'Wind'));
|
||||
@@ -219,17 +190,19 @@ async function fixture(t) {
|
||||
controls.disconnect();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
|
||||
await manager.setEnabled(id, false);
|
||||
await missions.destroy(viewer);
|
||||
await missions.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
|
||||
const view = () => ({
|
||||
x: camera.position.x,
|
||||
y: camera.position.y,
|
||||
z: camera.position.z,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
});
|
||||
const view = () => {
|
||||
const pose = engine.camera.pose();
|
||||
return {
|
||||
position: pose.position,
|
||||
heading: pose.heading,
|
||||
pitch: pose.pitch,
|
||||
};
|
||||
};
|
||||
const settle = async () => {
|
||||
await controls._waitForContextLayerSettlement();
|
||||
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
|
||||
@@ -251,6 +224,16 @@ async function fixture(t) {
|
||||
};
|
||||
}
|
||||
|
||||
// The same view, to a millimetre and a microradian.
|
||||
function assertSameView(actual, expected, message) {
|
||||
assert.ok(
|
||||
geo.cartesian3.distance(actual.position, expected.position) < 1e-3 &&
|
||||
Math.abs(actual.heading - expected.heading) < 1e-6 &&
|
||||
Math.abs(actual.pitch - expected.pitch) < 1e-6,
|
||||
`${message ?? 'the same view'}: ${JSON.stringify(actual)} vs ${JSON.stringify(expected)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const entries = {
|
||||
direct: (f) =>
|
||||
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
|
||||
@@ -276,7 +259,7 @@ for (const [path, enter] of Object.entries(entries))
|
||||
assert.equal(f.controls.getContextModeState().mode, null);
|
||||
assert.equal(f.controls.getContextModeState().active, false);
|
||||
assert.equal(f.missions.getStats().error, 'HTTP 429');
|
||||
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
|
||||
assertSameView(f.view(), before, 'the pre-entry view comes back');
|
||||
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
|
||||
|
||||
assert.equal(
|
||||
@@ -319,7 +302,7 @@ test('a landed entry flight still gives the view back after a failed first load'
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), before);
|
||||
assertSameView(f.view(), before);
|
||||
});
|
||||
|
||||
test('a camera move after the entry flight survives a failed first load', async (t) => {
|
||||
@@ -334,7 +317,7 @@ test('a camera move after the entry flight survives a failed first load', async
|
||||
f.camera.land();
|
||||
// The user drags the globe while the mission index is still loading.
|
||||
f.camera.setView({
|
||||
destination: new Cesium.Cartesian3(7, 7, 7),
|
||||
destination: geo.cartesian3.fromDegrees(-50, 10, 5_000_000),
|
||||
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
|
||||
});
|
||||
const moved = f.view();
|
||||
@@ -342,7 +325,7 @@ test('a camera move after the entry flight survives a failed first load', async
|
||||
release();
|
||||
assert.equal(await entering, false);
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), moved);
|
||||
assertSameView(f.view(), moved);
|
||||
});
|
||||
|
||||
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
|
||||
@@ -358,7 +341,7 @@ test('a navigation during a failed first load is not cancelled by the rollback',
|
||||
f.missions.releaseCameraOwnership();
|
||||
let navigationCancelled = false;
|
||||
f.camera.flyTo({
|
||||
destination: new Cesium.Cartesian3(5, 5, 5),
|
||||
destination: geo.cartesian3.fromDegrees(5, 5, 5_000_000),
|
||||
cancel: () => {
|
||||
navigationCancelled = true;
|
||||
},
|
||||
@@ -375,7 +358,7 @@ test('a navigation during a failed first load is not cancelled by the rollback',
|
||||
'the rollback does not cancel the Search/Home flight',
|
||||
);
|
||||
assert.equal(f.camera.cancelled, cancelled);
|
||||
assert.deepEqual(f.view(), navigated);
|
||||
assertSameView(f.view(), navigated);
|
||||
});
|
||||
|
||||
test('the roster reports a failed mission index instead of loading forever', async (t) => {
|
||||
@@ -438,7 +421,7 @@ test('a refresh failure after a loaded index keeps the mode and its records', as
|
||||
// Leaving a loaded mode does not restore the pre-entry view.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.deepEqual(f.view(), framed);
|
||||
assertSameView(f.view(), framed);
|
||||
});
|
||||
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
|
||||
const f = await fixture(t);
|
||||
@@ -448,13 +431,13 @@ test('exiting during a successful entry flight cancels the mission-owned flight'
|
||||
true,
|
||||
);
|
||||
await f.settle();
|
||||
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
|
||||
assert.ok(f.camera.flying, 'the 2.4 s entry flight is still in progress');
|
||||
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
assert.equal(
|
||||
f.camera.flight,
|
||||
null,
|
||||
f.camera.flying,
|
||||
false,
|
||||
'leaving Space Missions stops the flight it started (main cancels it)',
|
||||
);
|
||||
});
|
||||
@@ -479,17 +462,24 @@ test('navigation taking the camera keeps a mission flight it asked to preserve',
|
||||
f.upstream.status = 200;
|
||||
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
|
||||
await f.settle();
|
||||
const flight = f.camera.flight;
|
||||
assert.ok(flight, 'the entry flight is in progress');
|
||||
assert.ok(f.camera.flying, 'the entry flight is in progress');
|
||||
const cancelled = f.camera.cancelled;
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
|
||||
assert.ok(f.camera.flying, 'navigation decides about the flight');
|
||||
assert.equal(f.camera.cancelled, cancelled);
|
||||
// After exit an inactive layer never cancels another owner's flight.
|
||||
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
|
||||
await f.settle();
|
||||
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
|
||||
const other = f.camera.flight;
|
||||
let otherCancelled = false;
|
||||
f.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(1, 1, 1_000_000),
|
||||
cancel: () => {
|
||||
otherCancelled = true;
|
||||
},
|
||||
});
|
||||
f.missions.releaseCameraOwnership();
|
||||
assert.equal(f.camera.flight, other);
|
||||
assert.ok(f.camera.flying);
|
||||
assert.equal(otherCancelled, false);
|
||||
});
|
||||
|
||||
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Event } from 'cesium';
|
||||
import { createLaunchHover } from './render/cesium.js';
|
||||
import { createEngineEvent } from '../../globe/contract.js';
|
||||
import { createLaunchHover } from './render/display.js';
|
||||
|
||||
// What the hover reads of an engine: its canvas, drillPick and camera motion.
|
||||
function hoverEngine(drillPick, style = { cursor: '' }) {
|
||||
return {
|
||||
host: { canvas: { style } },
|
||||
picker: { drillPick },
|
||||
camera: { moveStart: createEngineEvent(), moveEnd: createEngineEvent() },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeTimers() {
|
||||
const timers = new Map();
|
||||
@@ -31,20 +40,14 @@ function hoverRig({ overMarker }) {
|
||||
enabled = true,
|
||||
free = true;
|
||||
const timers = fakeTimers();
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick(position) {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = hoverEngine((position) => {
|
||||
picks++;
|
||||
return overMarker(position) ? [{ id: 'launch' }] : [];
|
||||
});
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
engine,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
isLaunchPick: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: { hidden: false },
|
||||
pointerFree: () => free,
|
||||
@@ -54,8 +57,8 @@ function hoverRig({ overMarker }) {
|
||||
return {
|
||||
hover,
|
||||
timers,
|
||||
viewer,
|
||||
cursor: () => viewer.scene.canvas.style.cursor,
|
||||
engine,
|
||||
cursor: () => engine.host.canvas.style.cursor,
|
||||
picks: () => picks,
|
||||
setTime: (value) => (at = value),
|
||||
setEnabled: (value) => (enabled = value),
|
||||
@@ -74,7 +77,7 @@ test('mission hover runs one trailing check for a pointer that stops on empty sp
|
||||
rig.setTime(50);
|
||||
const movement = empty();
|
||||
rig.hover.move(movement);
|
||||
// Cesium reuses its movement object; the trailing check must use a copy.
|
||||
// The engine may reuse its movement object; the trailing check must use a copy.
|
||||
movement.endPosition.x = 1;
|
||||
assert.equal(rig.picks(), 1);
|
||||
assert.deepEqual(
|
||||
@@ -110,7 +113,7 @@ test('mission hover trailing check is cancelled by reset, destroy, and camera mo
|
||||
for (const cancel of [
|
||||
(rig) => rig.hover.reset(),
|
||||
(rig) => rig.hover.destroy(),
|
||||
(rig) => rig.viewer.camera.moveStart.raiseEvent(),
|
||||
(rig) => rig.engine.camera.moveStart.raiseEvent(),
|
||||
]) {
|
||||
const rig = hoverRig({ overMarker: onMarker });
|
||||
rig.hover.move(empty());
|
||||
@@ -153,20 +156,14 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners',
|
||||
addEventListener: (_n, fn) => listeners.add(fn),
|
||||
removeEventListener: (_n, fn) => listeners.delete(fn),
|
||||
};
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: {} },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = hoverEngine(() => {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
}, {});
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
engine,
|
||||
enabled: () => enabled,
|
||||
entityLaunchId: (id) => id === 'launch',
|
||||
isLaunchPick: (id) => id === 'launch',
|
||||
now: () => at,
|
||||
documentRef: doc,
|
||||
pointerFree: () => true,
|
||||
@@ -174,59 +171,53 @@ test('mission hover throttles, clears on motion/hidden, and releases listeners',
|
||||
});
|
||||
for (at = 0; at < 120; at++) hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
assert.equal(engine.host.canvas.style.cursor, 'pointer');
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
engine.camera.moveStart.raiseEvent();
|
||||
at = 500;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 2);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
viewer.camera.moveEnd.raiseEvent();
|
||||
assert.equal(engine.host.canvas.style.cursor, '');
|
||||
engine.camera.moveEnd.raiseEvent();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
doc.hidden = true;
|
||||
for (const fn of listeners) fn();
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, '');
|
||||
assert.equal(engine.host.canvas.style.cursor, '');
|
||||
doc.hidden = false;
|
||||
enabled = false;
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(picks, 3);
|
||||
hover.destroy();
|
||||
assert.equal(listeners.size, 0);
|
||||
assert.equal(viewer.camera.moveStart.numberOfListeners, 0);
|
||||
assert.equal(viewer.camera.moveEnd.numberOfListeners, 0);
|
||||
assert.equal(engine.camera.moveStart.numberOfListeners, 0);
|
||||
assert.equal(engine.camera.moveEnd.numberOfListeners, 0);
|
||||
});
|
||||
|
||||
test('mission hover preserves the cursor of a pointer-owning tool', () => {
|
||||
let free = true,
|
||||
picks = 0;
|
||||
const viewer = {
|
||||
camera: { moveStart: new Event(), moveEnd: new Event() },
|
||||
scene: {
|
||||
canvas: { style: { cursor: '' } },
|
||||
drillPick() {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
},
|
||||
},
|
||||
};
|
||||
const engine = hoverEngine(() => {
|
||||
picks++;
|
||||
return [{ id: 'launch' }];
|
||||
});
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
engine,
|
||||
enabled: () => true,
|
||||
entityLaunchId: () => true,
|
||||
isLaunchPick: () => true,
|
||||
documentRef: null,
|
||||
pointerFree: () => free,
|
||||
});
|
||||
hover.move({ endPosition: {} });
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'pointer');
|
||||
assert.equal(engine.host.canvas.style.cursor, 'pointer');
|
||||
free = false;
|
||||
viewer.scene.canvas.style.cursor = 'crosshair';
|
||||
engine.host.canvas.style.cursor = 'crosshair';
|
||||
hover.move({ endPosition: {} });
|
||||
viewer.camera.moveStart.raiseEvent();
|
||||
engine.camera.moveStart.raiseEvent();
|
||||
hover.destroy();
|
||||
assert.equal(picks, 1);
|
||||
assert.equal(viewer.scene.canvas.style.cursor, 'crosshair');
|
||||
assert.equal(engine.host.canvas.style.cursor, 'crosshair');
|
||||
});
|
||||
@@ -22,14 +22,8 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
throw new TypeError('A launches source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const getRenderer = () => {
|
||||
if (!state._renderer || state._renderer.view !== state._viewer)
|
||||
state._renderer = services.renderer.create({
|
||||
viewer: state._viewer,
|
||||
engine: state._engine,
|
||||
});
|
||||
return state._renderer;
|
||||
};
|
||||
// The display the layer draws through (render/display.js), while attached.
|
||||
const getRenderer = () => state._renderer;
|
||||
const context = { state, services, parts, source, getRenderer };
|
||||
parts.policyHelpers = createPolicyHelpers(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
@@ -54,11 +48,9 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
parts.panel.commands,
|
||||
{
|
||||
qaDrawn() {
|
||||
if (!state._viewer || !state._dataSource) return null;
|
||||
const value = (v) =>
|
||||
typeof v?.getValue === 'function'
|
||||
? v.getValue(state._declutterTime)
|
||||
: v;
|
||||
const display = state._renderer;
|
||||
if (!display) return null;
|
||||
const elements = [...state._missionElements.values()];
|
||||
const plain = (p) => (p ? { x: p.x, y: p.y, z: p.z } : null);
|
||||
const anchors = (positions) =>
|
||||
!positions?.length
|
||||
@@ -80,37 +72,34 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
latitude: l.lat,
|
||||
orbit: l.orbit?.name,
|
||||
})),
|
||||
points: state._dataSource.entities.values
|
||||
points: elements
|
||||
.filter((e) => e.point && e.position)
|
||||
.map((e) => {
|
||||
const color = value(e.point.color);
|
||||
const color = e.pointStyle.color;
|
||||
return {
|
||||
id: e.id,
|
||||
position: plain(value(e.position)),
|
||||
show:
|
||||
state._dataSource.show &&
|
||||
e.show &&
|
||||
value(e.point.show) !== false,
|
||||
size: value(e.point.pixelSize),
|
||||
position: plain(parts.rendering.elementPosition(e)),
|
||||
show: display.visible() && e.point.show,
|
||||
size: e.pointStyle.pixelSize,
|
||||
color: color
|
||||
? [color.red, color.green, color.blue, color.alpha]
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
paths: state._dataSource.entities.values
|
||||
.filter((e) => e.polyline)
|
||||
paths: elements
|
||||
.filter((e) => e.line)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
show: state._dataSource.show && e.show,
|
||||
anchors: anchors(value(e.polyline.positions)),
|
||||
show: display.visible() && e.line.show,
|
||||
anchors: anchors(e.positions),
|
||||
})),
|
||||
rings: [...state._missionOrbitPrimitives].map(([id, path]) => ({
|
||||
id,
|
||||
show: path.primitive.show,
|
||||
show: display.visible() && path.line.show,
|
||||
anchors: anchors(path.basePositions).map((p) =>
|
||||
plain(
|
||||
geo.matrix4.multiplyByPoint(
|
||||
path.primitive.modelMatrix,
|
||||
path.modelMatrix,
|
||||
p,
|
||||
geo.cartesian3.create(),
|
||||
),
|
||||
@@ -195,6 +184,7 @@ export function createRocketLaunchesLayer({ services, source }) {
|
||||
parts.testing._setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest:
|
||||
parts.testing._setSelectedRocketMissionForTest,
|
||||
_missionElementForTest: parts.testing._missionElementForTest,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,20 +185,20 @@ export function createIngestion({
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
token !== layerState._lifecycleToken ||
|
||||
!layerState._dataSource
|
||||
!layerState._renderer
|
||||
)
|
||||
return;
|
||||
const activeTleText = layerState._activeTleText;
|
||||
if (layerState._replayCameraLaunchId) parts.replay.stopMissionReplay();
|
||||
layerState._launches = launches;
|
||||
parts.orbitRendering.removeMissionOrbitPrimitives();
|
||||
layerState._dataSource.entities.removeAll();
|
||||
parts.rendering.removeMissionElements();
|
||||
layerState._missionOverlayRecords.clear();
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
layerState._orbitMatches = 0;
|
||||
launches.forEach((launch) =>
|
||||
parts.rendering.addLaunchEntity(launch, activeTleText),
|
||||
parts.rendering.addLaunchElements(launch, activeTleText),
|
||||
);
|
||||
layerState._renderedTleText = activeTleText;
|
||||
if (layerState._renderedTleText === layerState._activeTleText)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createLaunchesCesiumRenderer } from './render/cesium.js';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createLaunchesDisplay } from './render/display.js';
|
||||
import { createLaunchSource } from './source.js';
|
||||
import { createState } from './state.js';
|
||||
import { createModel } from './model.js';
|
||||
@@ -67,23 +67,12 @@ async function fixture({ legacySource = false } = {}) {
|
||||
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
|
||||
};
|
||||
services.renderer = {
|
||||
create: ({ viewer, engine }) =>
|
||||
createLaunchesCesiumRenderer(engine, { viewer }),
|
||||
create: ({ engine }) => createLaunchesDisplay(engine),
|
||||
};
|
||||
const state = createState({ services });
|
||||
state._enabled = true;
|
||||
state._lifecycleToken = 1;
|
||||
state._dataSource = new Cesium.CustomDataSource('owned-qualification');
|
||||
const canvas = Object.assign(new EventTarget(), {
|
||||
disableRootEvents: true,
|
||||
onwheel: null,
|
||||
style: {},
|
||||
});
|
||||
const viewer = {
|
||||
dataSources: new Cesium.DataSourceCollection(),
|
||||
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
|
||||
};
|
||||
await viewer.dataSources.add(state._dataSource);
|
||||
const engine = createFakeEngine();
|
||||
// Only presentation boundaries are inert. Source, normalization, state,
|
||||
// ingestion, lifecycle, stats and feed-state policy execute unchanged.
|
||||
const parts = {
|
||||
@@ -97,6 +86,7 @@ async function fixture({ legacySource = false } = {}) {
|
||||
replay: { stopMissionReplay: noop },
|
||||
selection: {
|
||||
setSelectedMission: noop,
|
||||
showSelectedMission: noop,
|
||||
focusMission: noop,
|
||||
stopMissionZoomAnchor: noop,
|
||||
},
|
||||
@@ -117,33 +107,30 @@ async function fixture({ legacySource = false } = {}) {
|
||||
},
|
||||
rendering: {
|
||||
updateMissionFrame: noop,
|
||||
addLaunchEntity(record) {
|
||||
state._dataSource.entities.add({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
});
|
||||
addLaunchElements(record) {
|
||||
state._missionElements.set(record.id, { id: record.id });
|
||||
},
|
||||
removeMissionElements: () => state._missionElements.clear(),
|
||||
},
|
||||
};
|
||||
const renderer = createLaunchesCesiumRenderer(null, { viewer });
|
||||
const context = {
|
||||
state,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
getRenderer: () => renderer,
|
||||
getRenderer: () => state._renderer,
|
||||
};
|
||||
parts.model = createModel(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.lifecycle.methods.init(null, { engine });
|
||||
const current = () => {
|
||||
const stats = parts.controls.methods.getStats();
|
||||
return {
|
||||
stats,
|
||||
feed: layerFeedState(stats),
|
||||
rendered: state._dataSource?.entities.values.map((x) => x.id) || [],
|
||||
rendered: [...state._missionElements.keys()],
|
||||
enabled: state._enabled,
|
||||
};
|
||||
};
|
||||
@@ -152,18 +139,19 @@ async function fixture({ legacySource = false } = {}) {
|
||||
state,
|
||||
source,
|
||||
current,
|
||||
viewer,
|
||||
init: () => parts.lifecycle.methods.init(viewer),
|
||||
engine,
|
||||
init: () => parts.lifecycle.methods.init(null, { engine }),
|
||||
update: () => parts.ingestion.methods.update(),
|
||||
disable: () => parts.lifecycle.methods.disable(),
|
||||
destroy: () => parts.lifecycle.methods.destroy(viewer),
|
||||
destroy: () => parts.lifecycle.methods.destroy(),
|
||||
holdArrival: () =>
|
||||
new Promise((resolve) => {
|
||||
arrival = resolve;
|
||||
}),
|
||||
release: () => release?.(),
|
||||
async close() {
|
||||
await parts.lifecycle.methods.destroy(viewer);
|
||||
await parts.lifecycle.methods.destroy();
|
||||
engine.destroy();
|
||||
server.closeAllConnections();
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
},
|
||||
@@ -307,8 +295,8 @@ test('destroy followed by actual init starts a clean instance with owned handler
|
||||
assert.equal(value.stats.lastUpdate, null);
|
||||
assert.equal(value.stats.error, null);
|
||||
assert.equal(value.enabled, false);
|
||||
assert.equal(f.viewer.dataSources.length, 1);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 1);
|
||||
assert.equal(typeof f.state._renderer?.addPoint, 'function');
|
||||
assert.ok(f.engine.frames.preRender.numberOfListeners > 0);
|
||||
const click = f.state._clickHandler;
|
||||
const move = f.state._moveHandler;
|
||||
assert.equal(typeof click.destroy, 'function');
|
||||
@@ -316,8 +304,9 @@ test('destroy followed by actual init starts a clean instance with owned handler
|
||||
await f.destroy();
|
||||
assert.equal(f.state._clickHandler, null);
|
||||
assert.equal(f.state._moveHandler, null);
|
||||
assert.equal(f.viewer.dataSources.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0);
|
||||
assert.equal(f.state._renderer, null);
|
||||
assert.equal(f.engine.frames.preRender.numberOfListeners, 0);
|
||||
assert.equal(f.engine.frames.preUpdate.numberOfListeners, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ export function createLaunchPad({
|
||||
}
|
||||
|
||||
function removeLaunchPadZonePrimitive() {
|
||||
if (layerState._launchPadZonePrimitive)
|
||||
getRenderer().removePrimitive(layerState._launchPadZonePrimitive);
|
||||
layerState._launchPadZonePrimitive?.destroy();
|
||||
layerState._launchPadZonePrimitive = null;
|
||||
layerState._launchPadZoneLaunchId = null;
|
||||
}
|
||||
@@ -27,9 +26,9 @@ export function createLaunchPad({
|
||||
}
|
||||
|
||||
function initLaunchPadZonePrimitive() {
|
||||
if (!layerState._viewer || layerState._launchPadZoneRemover) return;
|
||||
if (!getRenderer() || layerState._launchPadZoneRemover) return;
|
||||
layerState._launchPadZoneRemover = getRenderer().on('preRender', () => {
|
||||
if (!layerState._enabled || !layerState._dataSource?.show) {
|
||||
if (!layerState._enabled || !getRenderer()?.visible()) {
|
||||
hideLaunchPadZone();
|
||||
return;
|
||||
}
|
||||
@@ -44,19 +43,19 @@ export function createLaunchPad({
|
||||
const launch = layerState._launches.find(
|
||||
(item) => item.id === layerState._selectedLaunchId,
|
||||
);
|
||||
const camera = layerState._viewer?.camera;
|
||||
const camera = layerState._engine?.camera;
|
||||
if (!launch || !camera) {
|
||||
hideLaunchPadZone();
|
||||
return;
|
||||
}
|
||||
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
const visible = parts.policyHelpers.launchPadZoneVisible({
|
||||
layerActive: Boolean(layerState._dataSource?.show),
|
||||
layerActive: Boolean(getRenderer()?.visible()),
|
||||
selectedLaunchId: layerState._selectedLaunchId,
|
||||
launchId: launch.id,
|
||||
cameraHeightM: camera.positionCartographic?.height,
|
||||
cameraHeightM: camera.height(),
|
||||
cameraDistanceM: geo.cartesian3.distance(
|
||||
camera.positionWC,
|
||||
camera.livePosition,
|
||||
launchPosition,
|
||||
),
|
||||
});
|
||||
|
||||
@@ -9,14 +9,13 @@ test('selected pad frame uses neutral geometry and the physical orbit epoch', ()
|
||||
created = 0,
|
||||
epoch;
|
||||
const state = {
|
||||
_viewer: {
|
||||
_engine: {
|
||||
camera: {
|
||||
positionWC: geo.cartesian3.fromDegrees(launch.lon, launch.lat, 500),
|
||||
positionCartographic: { height: 500 },
|
||||
livePosition: geo.cartesian3.fromDegrees(launch.lon, launch.lat, 500),
|
||||
height: () => 500,
|
||||
},
|
||||
},
|
||||
_enabled: true,
|
||||
_dataSource: { show: true },
|
||||
_selectedLaunchId: 'qa',
|
||||
_launches: [launch],
|
||||
_launchPadZoneRemover: null,
|
||||
@@ -26,6 +25,7 @@ test('selected pad frame uses neutral geometry and the physical orbit epoch', ()
|
||||
_launchPadZoneLaunchId: null,
|
||||
};
|
||||
const renderer = {
|
||||
visible: () => true,
|
||||
on: (event, fn) => {
|
||||
assert.equal(event, 'preRender');
|
||||
tick = fn;
|
||||
@@ -33,9 +33,8 @@ test('selected pad frame uses neutral geometry and the physical orbit epoch', ()
|
||||
},
|
||||
createPad: () => {
|
||||
created++;
|
||||
return { show: true };
|
||||
return { show: true, destroy() {} };
|
||||
},
|
||||
removePrimitive: () => {},
|
||||
};
|
||||
const parts = {
|
||||
orbitRendering: {
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import * as geo from '../../geo/index.js';
|
||||
|
||||
/** The camera pose to give back, in world coordinates. */
|
||||
function captureCameraView(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
if (!camera?.positionWC) return null;
|
||||
function captureCameraView(engine) {
|
||||
const pose = engine?.camera?.pose();
|
||||
if (!pose?.position) return null;
|
||||
return {
|
||||
destination: geo.cartesian3.clone(camera.positionWC),
|
||||
destination: geo.cartesian3.clone(pose.position),
|
||||
orientation: {
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
heading: pose.heading,
|
||||
pitch: pose.pitch,
|
||||
roll: pose.roll,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sameCameraView(camera, view) {
|
||||
function sameCameraView(pose, view) {
|
||||
return (
|
||||
geo.cartesian3.equalsEpsilon(camera.positionWC, view.destination, 0, 1) &&
|
||||
geo.cartesian3.equalsEpsilon(pose.position, view.destination, 0, 1) &&
|
||||
['heading', 'pitch', 'roll'].every((key) =>
|
||||
geo.math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
|
||||
geo.math.equalsEpsilon(pose[key], view.orientation[key], 0, 1e-6),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** Restore the pre-entry view if the entry flight still holds the camera. */
|
||||
function restoreCameraView(viewer, view, landedView) {
|
||||
const camera = viewer?.camera;
|
||||
function restoreCameraView(engine, view, landedView) {
|
||||
const camera = engine?.camera;
|
||||
if (!camera) return;
|
||||
if (!landedView) camera.cancelFlight?.();
|
||||
else if (!sameCameraView(camera, landedView)) return;
|
||||
if (!landedView) camera.cancelFlight();
|
||||
else if (!sameCameraView(camera.pose(), landedView)) return;
|
||||
camera.setView(view);
|
||||
}
|
||||
export function createLifecycle({
|
||||
@@ -40,65 +40,60 @@ export function createLifecycle({
|
||||
}) {
|
||||
const { holdContinuousRender, releaseContinuousRender } = services.render;
|
||||
|
||||
/** The display on `engine`, with its input and frame listeners. */
|
||||
function attachDisplay(engine) {
|
||||
layerState._engine = engine;
|
||||
layerState._renderer = services.renderer.create({ engine });
|
||||
parts.panel.createMissionPanel();
|
||||
parts.overlays.createReplayVehicleOverlay();
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
const enabled = () =>
|
||||
layerState._enabled && Boolean(layerState._renderer?.visible());
|
||||
layerState._clickHandler = getRenderer().onClick(
|
||||
(id) => {
|
||||
const launchId = parts.selection.entityLaunchId(id);
|
||||
if (!launchId) return;
|
||||
parts.selection.setSelectedMission(launchId);
|
||||
parts.selection.focusMission(
|
||||
layerState._launches.find((l) => l.id === launchId),
|
||||
);
|
||||
},
|
||||
{ enabled },
|
||||
);
|
||||
layerState._moveHandler = getRenderer().onHover({ enabled });
|
||||
// Apply horizon visibility before the engine traverses and picks the
|
||||
// scene. A postRender write is one frame late and can remain visibly
|
||||
// stale when request-on-demand rendering stops after a globe camera move.
|
||||
layerState._declutterHandler = getRenderer().on(
|
||||
'preRender',
|
||||
parts.rendering.updateMissionFrame,
|
||||
);
|
||||
parts.launchPad.initLaunchPadZonePrimitive();
|
||||
if (layerState._enabled) {
|
||||
holdContinuousRender('rocket-launches');
|
||||
getRenderer().setVisible(true);
|
||||
layerState._orbitMatches = 0;
|
||||
for (const launch of layerState._launches)
|
||||
parts.rendering.addLaunchElements(launch, layerState._renderedTleText);
|
||||
parts.selection.showSelectedMission();
|
||||
}
|
||||
}
|
||||
|
||||
const methods = {
|
||||
init(viewer, options) {
|
||||
// The layer draws through the engine (options.engine) only.
|
||||
init(_scene, options) {
|
||||
layerState._count = 0;
|
||||
layerState._lastUpdate = null;
|
||||
layerState._stale = false;
|
||||
layerState._lastError = null;
|
||||
layerState._orbitMatches = 0;
|
||||
this.attachRenderer(viewer, options);
|
||||
this.attachRenderer(null, options);
|
||||
},
|
||||
attachRenderer(
|
||||
viewer,
|
||||
_scene,
|
||||
{ engine = null, waitForCameraRestore = false } = {},
|
||||
) {
|
||||
layerState._engine = engine;
|
||||
layerState._renderer = services.renderer.create({ viewer, engine });
|
||||
layerState._viewer = layerState._renderer.view;
|
||||
layerState._declutterOccluder = getRenderer().occluder();
|
||||
parts.panel.createMissionPanel();
|
||||
parts.overlays.createReplayVehicleOverlay();
|
||||
layerState._dataSource = getRenderer().createDataSource();
|
||||
parts.overlays.clearMissionOverlaySources();
|
||||
const enabled = () => layerState._enabled && layerState._dataSource?.show;
|
||||
layerState._clickHandler = getRenderer().listenClick(
|
||||
layerState._dataSource,
|
||||
(entity) => {
|
||||
const launchId = parts.selection.entityLaunchId(entity);
|
||||
if (!launchId) return;
|
||||
parts.selection.setSelectedMission(launchId);
|
||||
parts.selection.focusMission(
|
||||
layerState._launches.find((l) => l.id === launchId),
|
||||
);
|
||||
},
|
||||
{ enabled },
|
||||
);
|
||||
layerState._moveHandler = getRenderer().listenMove(
|
||||
layerState._dataSource,
|
||||
(entity) => {
|
||||
layerState._viewer.scene.canvas.style.cursor = entity
|
||||
? 'pointer'
|
||||
: '';
|
||||
},
|
||||
{ enabled },
|
||||
);
|
||||
// Apply horizon visibility before Cesium traverses and picks the scene.
|
||||
// A postRender write is one frame late and can remain visibly stale when
|
||||
// request-on-demand rendering stops after a globe camera move.
|
||||
layerState._declutterHandler = getRenderer().on(
|
||||
'preRender',
|
||||
parts.rendering.updateMissionFrame,
|
||||
);
|
||||
parts.launchPad.initLaunchPadZonePrimitive();
|
||||
if (layerState._enabled) {
|
||||
holdContinuousRender('rocket-launches');
|
||||
layerState._dataSource.show = true;
|
||||
layerState._orbitMatches = 0;
|
||||
for (const launch of layerState._launches)
|
||||
parts.rendering.addLaunchEntity(launch, layerState._renderedTleText);
|
||||
parts.selection.showSelectedMission();
|
||||
}
|
||||
if (engine) attachDisplay(engine);
|
||||
layerState._restoringViewport = true;
|
||||
if (!waitForCameraRestore) this.resumeViewport();
|
||||
},
|
||||
@@ -148,13 +143,9 @@ export function createLifecycle({
|
||||
layerState._missionOverlayRecords.clear();
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
layerState._renderer.removeDataSource(layerState._dataSource);
|
||||
parts.rendering.removeMissionElements();
|
||||
layerState._renderer.destroy();
|
||||
layerState._dataSource =
|
||||
layerState._renderer =
|
||||
layerState._engine =
|
||||
layerState._viewer =
|
||||
null;
|
||||
layerState._renderer = layerState._engine = null;
|
||||
if (layerState._enabled) releaseContinuousRender('rocket-launches');
|
||||
},
|
||||
|
||||
@@ -182,7 +173,7 @@ export function createLifecycle({
|
||||
|
||||
async _enableBody() {
|
||||
layerState._updateDirty = false;
|
||||
if (layerState._dataSource) layerState._dataSource.show = true;
|
||||
getRenderer()?.setVisible(true);
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
// Snapshot the existing follow before the mission flight releases it.
|
||||
await parts.ingestion.captureSatelliteDependency();
|
||||
@@ -192,14 +183,14 @@ export function createLifecycle({
|
||||
parts.panel.renderMissionRoster();
|
||||
// Kept until the mission index loads, so an entry that never does
|
||||
// gives the view back. A newer flight takes the camera and drops it.
|
||||
layerState._entryCameraView = captureCameraView(layerState._viewer);
|
||||
layerState._entryCameraView = captureCameraView(layerState._engine);
|
||||
layerState._entryLandedView = null;
|
||||
}
|
||||
const token = layerState._lifecycleToken;
|
||||
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
|
||||
const flying = parts.selection.focusFullGlobe(layerState._engine, {
|
||||
complete: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
layerState._entryLandedView = captureCameraView(layerState._viewer);
|
||||
layerState._entryLandedView = captureCameraView(layerState._engine);
|
||||
},
|
||||
cancel: () => {
|
||||
if (token === layerState._lifecycleToken)
|
||||
@@ -223,7 +214,7 @@ export function createLifecycle({
|
||||
layerState._entryCameraView = null;
|
||||
if (entryView)
|
||||
restoreCameraView(
|
||||
layerState._viewer,
|
||||
layerState._engine,
|
||||
entryView,
|
||||
layerState._entryLandedView,
|
||||
);
|
||||
@@ -235,10 +226,10 @@ export function createLifecycle({
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._cameraReleased = true;
|
||||
parts.selection.stopMissionZoomAnchor();
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
getRenderer()?.setVisible(false);
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
if (layerState._viewer?.scene?.canvas)
|
||||
layerState._viewer.scene.canvas.style.cursor = '';
|
||||
const cursor = layerState._engine?.host?.canvas?.style;
|
||||
if (cursor) cursor.cursor = '';
|
||||
parts.launchPad.hideLaunchPadZone();
|
||||
parts.overlays.hideReplayVehicleOverlay();
|
||||
layerState._selectedLaunchId = null;
|
||||
@@ -249,7 +240,7 @@ export function createLifecycle({
|
||||
parts.panel.renderMissionPanel();
|
||||
},
|
||||
|
||||
async destroy(viewer) {
|
||||
async destroy() {
|
||||
layerState._resumeRenderer?.();
|
||||
layerState._waitForRenderer = layerState._resumeRenderer = null;
|
||||
layerState._sourceController.abort();
|
||||
@@ -279,9 +270,7 @@ export function createLifecycle({
|
||||
layerState._satelliteTelemetry.clear();
|
||||
layerState._replayTracks.clear();
|
||||
parts.panel.clearMissionPanel();
|
||||
if (layerState._dataSource)
|
||||
getRenderer().removeDataSource(layerState._dataSource);
|
||||
layerState._dataSource = null;
|
||||
parts.rendering.removeMissionElements();
|
||||
layerState._count = 0;
|
||||
layerState._orbitMatches = 0;
|
||||
layerState._lastUpdate = null;
|
||||
@@ -297,7 +286,6 @@ export function createLifecycle({
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = null;
|
||||
layerState._engine = null;
|
||||
layerState._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ test('missions preserve the satellite target before their focus flight releases
|
||||
let target = 25544,
|
||||
snapshot = null;
|
||||
const lifecycle = createLifecycle({
|
||||
state: { _dataSource: { show: false }, _viewer: {} },
|
||||
state: { _engine: {} },
|
||||
services: { render: {} },
|
||||
getRenderer: () => ({ setVisible() {} }),
|
||||
parts: {
|
||||
orbitRendering: { syncMissionOrbitPrimitiveVisibility() {} },
|
||||
selection: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export function createOrbitRendering({
|
||||
function missionOrbitPrimitiveVisible(launchId) {
|
||||
return Boolean(
|
||||
layerState._enabled &&
|
||||
layerState._dataSource?.show &&
|
||||
getRenderer()?.visible() &&
|
||||
(!layerState._selectedLaunchId ||
|
||||
layerState._selectedLaunchId === launchId),
|
||||
);
|
||||
@@ -18,32 +18,24 @@ export function createOrbitRendering({
|
||||
|
||||
function syncMissionOrbitPrimitiveVisibility() {
|
||||
for (const [launchId, path] of layerState._missionOrbitPrimitives) {
|
||||
if (path.primitive)
|
||||
path.primitive.show = missionOrbitPrimitiveVisible(launchId);
|
||||
if (path.line) path.line.show = missionOrbitPrimitiveVisible(launchId);
|
||||
}
|
||||
}
|
||||
|
||||
function removeMissionOrbitPrimitives() {
|
||||
for (const path of layerState._missionOrbitPrimitives.values()) {
|
||||
if (path.primitive && layerState._viewer) {
|
||||
getRenderer().removePrimitive(path.primitive);
|
||||
}
|
||||
}
|
||||
for (const path of layerState._missionOrbitPrimitives.values())
|
||||
path.line?.remove();
|
||||
layerState._missionOrbitPrimitives.clear();
|
||||
}
|
||||
|
||||
function updateMissionOrbitPrimitiveFrames(nowDate) {
|
||||
for (const [launchId, path] of layerState._missionOrbitPrimitives) {
|
||||
if (!path.primitive || !missionOrbitPrimitiveVisible(launchId)) continue;
|
||||
orbitFrameModelMatrix(
|
||||
path.gmstAtBake,
|
||||
nowDate,
|
||||
path.primitive.modelMatrix,
|
||||
);
|
||||
getRenderer().commitOrbit(path.primitive);
|
||||
if (!path.line || !missionOrbitPrimitiveVisible(launchId)) continue;
|
||||
orbitFrameModelMatrix(path.gmstAtBake, nowDate, path.modelMatrix);
|
||||
path.line.setModelMatrix(path.modelMatrix);
|
||||
if (path.labelBakePosition && path.labelPosition) {
|
||||
geo.matrix4.multiplyByPoint(
|
||||
path.primitive.modelMatrix,
|
||||
path.modelMatrix,
|
||||
path.labelBakePosition,
|
||||
path.labelPosition,
|
||||
);
|
||||
@@ -53,25 +45,29 @@ export function createOrbitRendering({
|
||||
|
||||
function addMissionOrbitPrimitive(launch, orbitPath, satelliteTrack) {
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!getRenderer() ||
|
||||
!satelliteTrack ||
|
||||
!Number.isFinite(satelliteTrack.gmstAtBake)
|
||||
)
|
||||
return false;
|
||||
const collection = getRenderer().createOrbit({
|
||||
positions: orbitPath,
|
||||
width: 3,
|
||||
color: rgbaFromCss('#22e6e6').withAlpha(0.95),
|
||||
});
|
||||
collection.show = missionOrbitPrimitiveVisible(launch.id);
|
||||
orbitFrameModelMatrix(
|
||||
const modelMatrix = orbitFrameModelMatrix(
|
||||
satelliteTrack.gmstAtBake,
|
||||
new Date(appClock.now()),
|
||||
collection.modelMatrix,
|
||||
geo.matrix4.clone(geo.matrix4.IDENTITY),
|
||||
);
|
||||
getRenderer().commitOrbit(collection);
|
||||
// The live ring is not picked (as Cesium's polyline collection).
|
||||
const line = getRenderer().addLine({
|
||||
id: `rocket-orbit-ring:${launch.id}`,
|
||||
positions: orbitPath,
|
||||
widthPx: 3,
|
||||
color: rgbaFromCss('#22e6e6').withAlpha(0.95),
|
||||
dash: 'tactical',
|
||||
modelMatrix,
|
||||
});
|
||||
line.show = missionOrbitPrimitiveVisible(launch.id);
|
||||
layerState._missionOrbitPrimitives.set(launch.id, {
|
||||
primitive: collection,
|
||||
line,
|
||||
modelMatrix,
|
||||
basePositions: orbitPath,
|
||||
gmstAtBake: satelliteTrack.gmstAtBake,
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createOverlays({
|
||||
/**
|
||||
* Resolve whether a surface mission anchor is safely on the camera-facing
|
||||
* side of Earth. The small positive limb margin prevents labels anchored just
|
||||
* beyond the horizon from leaking through the hidden Cesium globe.
|
||||
* beyond the horizon from leaking through a hidden globe.
|
||||
* @param {Cesium.Cartesian3} cameraPosition Camera world position.
|
||||
* @param {Cesium.Cartesian3} markerPosition Mission anchor world position.
|
||||
* @param {number} [limbMargin] Additional normalized horizon clearance.
|
||||
@@ -364,7 +364,7 @@ export function createOverlays({
|
||||
}
|
||||
|
||||
function syncMissionOverlayEntries() {
|
||||
if (!layerState._enabled || !layerState._dataSource?.show) {
|
||||
if (!layerState._enabled || !getRenderer()?.visible()) {
|
||||
clearMissionOverlaySources();
|
||||
return;
|
||||
}
|
||||
@@ -454,7 +454,7 @@ export function createOverlays({
|
||||
|
||||
function updateReplayVehicleOverlay(occluder) {
|
||||
if (
|
||||
!layerState._viewer ||
|
||||
!layerState._engine ||
|
||||
!layerState._replayVehicleOverlay ||
|
||||
!layerState._selectedLaunchId
|
||||
) {
|
||||
@@ -501,12 +501,12 @@ export function createOverlays({
|
||||
hideReplayVehicleOverlay();
|
||||
return null;
|
||||
}
|
||||
const canvas = layerState._viewer.scene.canvas;
|
||||
const canvas = layerState._engine.host.size();
|
||||
if (
|
||||
windowPosition.x < -80 ||
|
||||
windowPosition.y < -100 ||
|
||||
windowPosition.x > canvas.clientWidth + 80 ||
|
||||
windowPosition.y > canvas.clientHeight + 100
|
||||
windowPosition.x > canvas.width + 80 ||
|
||||
windowPosition.y > canvas.height + 100
|
||||
) {
|
||||
hideReplayVehicleOverlay();
|
||||
return null;
|
||||
@@ -526,7 +526,7 @@ export function createOverlays({
|
||||
track.lastOverlayWindowPosition = renderedWindowPosition;
|
||||
track.lastOverlayMode = mode;
|
||||
const sonar = isCyberSonarActive()
|
||||
? createCyberSonarSampler(canvas.clientWidth, canvas.clientHeight)
|
||||
? createCyberSonarSampler(canvas.width, canvas.height)
|
||||
: null;
|
||||
const factor =
|
||||
sonar?.at(renderedWindowPosition.x, renderedWindowPosition.y) ?? 1;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { appClock } from '../../time/clock.js';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { formatNumber, isolate, onLocaleChange, t } from '../../i18n/index.js';
|
||||
import {
|
||||
@@ -328,17 +327,16 @@ export function createPanel({
|
||||
if (
|
||||
!layerState._missionPanelReady ||
|
||||
!layerState._selectedLaunchId ||
|
||||
!layerState._dataSource
|
||||
!layerState._renderer
|
||||
)
|
||||
return;
|
||||
const now = performance.now();
|
||||
if (!force && now - layerState._lastPanelTelemetryMs < 250) return;
|
||||
layerState._lastPanelTelemetryMs = now;
|
||||
const satellite = layerState._dataSource.entities.getById(
|
||||
`rocket-satellite:${layerState._selectedLaunchId}`,
|
||||
);
|
||||
const position = satellite?.position?.getValue(
|
||||
getRenderer().time(appClock.now()),
|
||||
const position = parts.rendering.elementPosition(
|
||||
layerState._missionElements.get(
|
||||
`rocket-satellite:${layerState._selectedLaunchId}`,
|
||||
),
|
||||
);
|
||||
const altitudeM = position
|
||||
? geo.cartographic.fromCartesian(position)?.height
|
||||
|
||||
@@ -89,11 +89,11 @@ export const MISSION_ORBIT_DASHES_PER_GROUP = 100;
|
||||
export const LAUNCH_PAD_ZONE_RADIUS_M = 500;
|
||||
|
||||
/**
|
||||
* The pad zone's look on both engines, as a fraction of the radius: a faint
|
||||
* fill, a bright rim between rimStart and rimEnd whose colour gains the
|
||||
* emission, and a soft edge from rimEnd to the radius. The zone is classified
|
||||
* onto the surface under the pad (terrain and 3D tiles), never drawn at a
|
||||
* height.
|
||||
* The pad zone's look, as a fraction of the radius: a faint fill, a bright
|
||||
* rim between rimStart and rimEnd whose colour gains the emission, and a soft
|
||||
* edge from rimEnd to the radius. The display draws the rim (render/display.js
|
||||
* padRimColor, PAD_RIM_RADIUS_M) as a ring on the terrain under the pad, over
|
||||
* the scene; the engine contract has no surface fill for the rest.
|
||||
*/
|
||||
export const LAUNCH_PAD_ZONE_STYLE = Object.freeze({
|
||||
color: '#22e6e6',
|
||||
|
||||
@@ -225,20 +225,18 @@ export function createPolicyHelpers({
|
||||
}
|
||||
|
||||
function previewMissionFromRoster(launch) {
|
||||
if (!layerState._viewer || !launch || layerState._selectedLaunchId) return;
|
||||
const range = parts.panel.missionHoverPreviewRange(
|
||||
layerState._viewer.camera.positionCartographic?.height,
|
||||
);
|
||||
const engine = layerState._engine;
|
||||
if (!engine || !launch || layerState._selectedLaunchId) return;
|
||||
const range = parts.panel.missionHoverPreviewRange(engine.camera.height());
|
||||
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
geo.boundingSphere.create(position, 0),
|
||||
{
|
||||
offset: getRenderer().range(0, -geo.math.PI_OVER_TWO, range),
|
||||
duration: 0.8,
|
||||
easingFunction: getRenderer().easing,
|
||||
},
|
||||
);
|
||||
engine.camera.flyToSphere(geo.boundingSphere.create(position, 0), {
|
||||
heading: 0,
|
||||
pitch: -geo.math.PI_OVER_TWO,
|
||||
range,
|
||||
duration: 0.8,
|
||||
easing: 'cubicInOut',
|
||||
});
|
||||
}
|
||||
return {
|
||||
satelliteParamsForSpaceMissions,
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
// Native mission graphics stay here; shared mission logic uses plain geometry.
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import {
|
||||
MISSION_ORBIT_PATTERN_GROUPS,
|
||||
MISSION_ORBIT_DASHES_PER_GROUP,
|
||||
LAUNCH_PAD_ZONE_RADIUS_M,
|
||||
LAUNCH_PAD_ZONE_STYLE,
|
||||
} from '../policy.js';
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
/** A GLSL float literal (always with a decimal point). */
|
||||
const glslFloat = (value) => value.toFixed(4);
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
entityLaunchId,
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
if (ownsCursor && viewer.scene.canvas.style.cursor === 'pointer')
|
||||
viewer.scene.canvas.style.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
const start = () => {
|
||||
moving = true;
|
||||
reset();
|
||||
};
|
||||
const end = () => {
|
||||
moving = false;
|
||||
reset();
|
||||
};
|
||||
const visibility = () => {
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = viewer.scene
|
||||
.drillPick(position, 12)
|
||||
.some((picked) => entityLaunchId(picked?.id));
|
||||
if (found) {
|
||||
viewer.scene.canvas.style.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = viewer.camera?.moveStart?.addEventListener(start);
|
||||
const removeEnd = viewer.camera?.moveEnd?.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// Cesium reuses the movement object, so keep a copy of the position.
|
||||
pendingPosition = Cesium.Cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
reset();
|
||||
removeStart?.();
|
||||
removeEnd?.();
|
||||
documentRef?.removeEventListener?.('visibilitychange', visibility);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createLaunchesCesiumRenderer(engine, { viewer } = {}) {
|
||||
let patternRegistered = false;
|
||||
const scratchTime = new Cesium.JulianDate();
|
||||
const color = (value) =>
|
||||
value?.red !== undefined
|
||||
? new Cesium.Color(value.red, value.green, value.blue, value.alpha)
|
||||
: value;
|
||||
function ensureMissionOrbitPatternRegistered() {
|
||||
if (patternRegistered) return;
|
||||
new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'GevMissionOrbitTactical',
|
||||
uniforms: {
|
||||
color: Cesium.Color.CYAN,
|
||||
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
|
||||
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
|
||||
},
|
||||
source: `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
float groupPosition = fract(materialInput.st.s * groupCount);
|
||||
float markPosition = groupPosition * (dashCount + 1.0);
|
||||
float markIndex = floor(markPosition);
|
||||
float localPosition = fract(markPosition);
|
||||
float centerDistance = abs(localPosition - 0.5);
|
||||
float edge = max(fwidth(localPosition) * 1.35, 0.012);
|
||||
float dashAlong = 1.0 - smoothstep(0.27 - edge, 0.27 + edge, centerDistance);
|
||||
float dashAcross = 1.0 - smoothstep(0.12, 0.24, abs(materialInput.st.t - 0.5));
|
||||
float dash = dashAlong * dashAcross;
|
||||
float dotAlong = (localPosition - 0.5) / 0.32;
|
||||
float dotAcross = (materialInput.st.t - 0.5) / 0.5;
|
||||
float dot = 1.0 - smoothstep(0.78, 1.0, length(vec2(dotAlong, dotAcross)));
|
||||
float isDot = 1.0 - step(0.5, markIndex);
|
||||
float visible = mix(dash, dot, isDot);
|
||||
material.diffuse = color.rgb;
|
||||
material.emission = color.rgb * mix(0.07, 0.65, isDot);
|
||||
material.alpha = color.a * visible * mix(0.58, 1.0, isDot);
|
||||
return material;
|
||||
}`,
|
||||
},
|
||||
});
|
||||
patternRegistered = true;
|
||||
}
|
||||
|
||||
function createMissionOrbitPatternMaterial(color) {
|
||||
ensureMissionOrbitPatternRegistered();
|
||||
return Cesium.Material.fromType('GevMissionOrbitTactical', {
|
||||
color,
|
||||
groupCount: MISSION_ORBIT_PATTERN_GROUPS,
|
||||
dashCount: MISSION_ORBIT_DASHES_PER_GROUP,
|
||||
});
|
||||
}
|
||||
|
||||
function MissionOrbitPatternMaterialProperty(color) {
|
||||
ensureMissionOrbitPatternRegistered();
|
||||
this._color = color;
|
||||
this._definitionChanged = new Cesium.Event();
|
||||
}
|
||||
Object.defineProperties(MissionOrbitPatternMaterialProperty.prototype, {
|
||||
isConstant: {
|
||||
get() {
|
||||
return true;
|
||||
},
|
||||
},
|
||||
definitionChanged: {
|
||||
get() {
|
||||
return this._definitionChanged;
|
||||
},
|
||||
},
|
||||
});
|
||||
MissionOrbitPatternMaterialProperty.prototype.getType = function getType() {
|
||||
return 'GevMissionOrbitTactical';
|
||||
};
|
||||
MissionOrbitPatternMaterialProperty.prototype.getValue = function getValue(
|
||||
time,
|
||||
result,
|
||||
) {
|
||||
if (!Cesium.defined(result)) result = {};
|
||||
result.color = this._color;
|
||||
result.groupCount = MISSION_ORBIT_PATTERN_GROUPS;
|
||||
result.dashCount = MISSION_ORBIT_DASHES_PER_GROUP;
|
||||
return result;
|
||||
};
|
||||
MissionOrbitPatternMaterialProperty.prototype.equals = function equals(
|
||||
other,
|
||||
) {
|
||||
return this === other;
|
||||
};
|
||||
function createPad(launch) {
|
||||
const material = new Cesium.Material({
|
||||
fabric: {
|
||||
type: 'GevLaunchPadZone',
|
||||
uniforms: {
|
||||
color: Cesium.Color.fromCssColorString(LAUNCH_PAD_ZONE_STYLE.color),
|
||||
fillAlpha: LAUNCH_PAD_ZONE_STYLE.fillAlpha,
|
||||
rimAlpha: LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
||||
},
|
||||
source: `
|
||||
czm_material czm_getMaterial(czm_materialInput materialInput) {
|
||||
czm_material material = czm_getDefaultMaterial(materialInput);
|
||||
vec2 centered = (materialInput.st - vec2(0.5)) * 2.0;
|
||||
float radius = length(centered);
|
||||
float inside = 1.0 - smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)}, 1.0, radius);
|
||||
float rim = smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimStart)}, ${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)}, radius) * inside;
|
||||
material.diffuse = color.rgb;
|
||||
material.emission = color.rgb * rim * ${glslFloat(LAUNCH_PAD_ZONE_STYLE.emission)};
|
||||
material.alpha = color.a * inside * mix(fillAlpha, rimAlpha, rim);
|
||||
return material;
|
||||
}`,
|
||||
},
|
||||
});
|
||||
const geometry = new Cesium.EllipseGeometry({
|
||||
center: Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat),
|
||||
semiMajorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
|
||||
semiMinorAxis: LAUNCH_PAD_ZONE_RADIUS_M,
|
||||
granularity: Cesium.Math.toRadians(0.08),
|
||||
vertexFormat:
|
||||
Cesium.MaterialAppearance.MaterialSupport.TEXTURED.vertexFormat,
|
||||
});
|
||||
const primitive = viewer.scene.primitives.add(
|
||||
new Cesium.GroundPrimitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry,
|
||||
id: `rocket-pad-zone:${launch.id}`,
|
||||
}),
|
||||
appearance: new Cesium.MaterialAppearance({
|
||||
material,
|
||||
translucent: true,
|
||||
closed: false,
|
||||
faceForward: true,
|
||||
flat: true,
|
||||
// Keep the zone classified onto the photoreal surface, but bias only its
|
||||
// rasterized depth toward the camera. This avoids coplanar fragments
|
||||
// being intermittently buried by the launch-pad mesh at oblique angles
|
||||
// without adding a world-space height that would make the ring float.
|
||||
renderState: {
|
||||
depthTest: {
|
||||
enabled: true,
|
||||
},
|
||||
depthMask: false,
|
||||
polygonOffset: {
|
||||
enabled: true,
|
||||
factor: -1,
|
||||
units: -4,
|
||||
},
|
||||
blending: Cesium.BlendingState.ALPHA_BLEND,
|
||||
},
|
||||
}),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
asynchronous: true,
|
||||
show: true,
|
||||
}),
|
||||
);
|
||||
return primitive;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'cesium',
|
||||
engine,
|
||||
view: viewer,
|
||||
time(epochMs) {
|
||||
return Cesium.JulianDate.fromDate(new Date(epochMs), scratchTime);
|
||||
},
|
||||
occluder: () =>
|
||||
new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
Cesium.Cartesian3.ZERO,
|
||||
),
|
||||
callback: (fn) => new Cesium.CallbackProperty(fn, false),
|
||||
range: (heading, pitch, range) =>
|
||||
new Cesium.HeadingPitchRange(heading, pitch, range),
|
||||
easing: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
createDataSource() {
|
||||
const source = new Cesium.CustomDataSource('rocket-launches');
|
||||
source.show = false;
|
||||
viewer.dataSources.add(source);
|
||||
return source;
|
||||
},
|
||||
removeDataSource: (source) => viewer?.dataSources.remove(source, true),
|
||||
addEntity(source, spec) {
|
||||
const options = { ...spec };
|
||||
for (const key of ['point', 'billboard'])
|
||||
if (spec[key])
|
||||
options[key] = {
|
||||
...spec[key],
|
||||
...(spec[key].color ? { color: color(spec[key].color) } : {}),
|
||||
...(spec[key].outlineColor
|
||||
? { outlineColor: color(spec[key].outlineColor) }
|
||||
: {}),
|
||||
};
|
||||
if (spec.polyline) {
|
||||
const { arc, ...line } = spec.polyline;
|
||||
options.polyline = {
|
||||
...line,
|
||||
material: color(line.material),
|
||||
...(arc
|
||||
? {
|
||||
arcType:
|
||||
arc === 'none'
|
||||
? Cesium.ArcType.NONE
|
||||
: Cesium.ArcType.GEODESIC,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return source.entities.add(options);
|
||||
},
|
||||
dash: (options) =>
|
||||
new Cesium.PolylineDashMaterialProperty({
|
||||
...options,
|
||||
color: color(options.color),
|
||||
}),
|
||||
patternMaterial: (value) =>
|
||||
new MissionOrbitPatternMaterialProperty(color(value)),
|
||||
createOrbit({ positions, width, color: rgba }) {
|
||||
const collection = new Cesium.PolylineCollection();
|
||||
collection.add({
|
||||
positions,
|
||||
width,
|
||||
material: createMissionOrbitPatternMaterial(color(rgba)),
|
||||
});
|
||||
viewer.scene.primitives.add(collection);
|
||||
return collection;
|
||||
},
|
||||
commitOrbit() {},
|
||||
removePrimitive: (primitive) => viewer?.scene.primitives.remove(primitive),
|
||||
createPad,
|
||||
project: (position, result) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
position,
|
||||
result,
|
||||
),
|
||||
on: (event, listener) => viewer.scene[event].addEventListener(listener),
|
||||
listenClick(source, listener, { enabled = () => true } = {}) {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction((movement) => {
|
||||
if (!isPointerFree()) return;
|
||||
if (!enabled()) return;
|
||||
const entity = viewer.scene
|
||||
.drillPick(movement.position, 12)
|
||||
.map((p) => p?.id)
|
||||
.find((e) => e?.id?.startsWith('rocket-'));
|
||||
listener(entity);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
return { destroy: () => handler.destroy() };
|
||||
},
|
||||
listenMove(source, listener, { enabled = () => true } = {}) {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
const hover = createLaunchHover({
|
||||
viewer,
|
||||
enabled,
|
||||
entityLaunchId: (entity) => entity?.id?.startsWith('rocket-'),
|
||||
});
|
||||
handler.setInputAction(
|
||||
hover.move,
|
||||
Cesium.ScreenSpaceEventType.MOUSE_MOVE,
|
||||
);
|
||||
return {
|
||||
reset: hover.reset,
|
||||
destroy() {
|
||||
hover.destroy();
|
||||
handler.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
// The Space Missions display: what the launches layer draws, on any globe
|
||||
// engine, through the engine contract only: engine.movers (the launch-site,
|
||||
// recovery and payload dots, the hover reticle), engine.lines (trajectories,
|
||||
// recovery and re-entry paths, the transfer, the orbits in the tactical
|
||||
// pattern, the pad zone's rim), the selection dispatcher on engine.input
|
||||
// (clicks), engine.picker (the hover cursor), engine.projector and the frame
|
||||
// events. The layer keeps its own records and moves the camera itself
|
||||
// (engine.camera); the engine draws.
|
||||
//
|
||||
// The writes of a frame are committed once, at the end of the frame listener
|
||||
// that made them (display.on), or in a microtask for writes outside a frame.
|
||||
// Every handle draws only while the display is visible (the layer's own
|
||||
// switch) and its own `show` is set.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { resolvePickId } from '../../../data/pickRegistry.js';
|
||||
import { createMoverBank, writeFields } from '../../shared/render/moverBank.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
||||
|
||||
const LAYER_ID = 'rocket-launches';
|
||||
const HOVER_PICK_INTERVAL_MS = 120;
|
||||
const PICK_DEPTH = 12;
|
||||
const RETICLE_SIZE_PX = 24;
|
||||
const RETICLE_CELL = 'reticle';
|
||||
// The pad zone's rim: a closed ring through the middle of the style's rim
|
||||
// band, in the rim's colour with its emission and alpha.
|
||||
const PAD_RING_SEGMENTS = 128;
|
||||
const PAD_RING_WIDTH_PX = 2;
|
||||
|
||||
const rgbaOf = (color) =>
|
||||
Array.isArray(color)
|
||||
? [...color]
|
||||
: [color?.red ?? 1, color?.green ?? 1, color?.blue ?? 1, color?.alpha ?? 1];
|
||||
|
||||
/** The pad zone's rim colour: Cesium's rim emission over the zone colour. */
|
||||
export function padRimColor() {
|
||||
const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba();
|
||||
const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission;
|
||||
return [
|
||||
Math.min(1, red * gain),
|
||||
Math.min(1, green * gain),
|
||||
Math.min(1, blue * gain),
|
||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
||||
];
|
||||
}
|
||||
|
||||
/** The pad zone's rim radius: the middle of the style's rim band, metres. */
|
||||
export const PAD_RIM_RADIUS_M =
|
||||
(LAUNCH_PAD_ZONE_RADIUS_M *
|
||||
(LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd)) /
|
||||
2;
|
||||
|
||||
/** The pad zone's rim positions around `launch` at `heightM`. */
|
||||
export function padRingPositions(launch, heightM = 0) {
|
||||
const frame = geo.transforms.eastNorthUpToFixedFrame(
|
||||
geo.cartesian3.fromDegrees(launch.lon, launch.lat, heightM),
|
||||
geo.ellipsoid.WGS84,
|
||||
);
|
||||
const local = geo.cartesian3.create();
|
||||
return Array.from({ length: PAD_RING_SEGMENTS + 1 }, (_, index) => {
|
||||
const angle = (index / PAD_RING_SEGMENTS) * geo.math.TWO_PI;
|
||||
local.x = PAD_RIM_RADIUS_M * Math.cos(angle);
|
||||
local.y = PAD_RIM_RADIUS_M * Math.sin(angle);
|
||||
local.z = 0;
|
||||
return geo.matrix4.multiplyByPoint(frame, local, geo.cartesian3.create());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound hover picking independently of precise click selection. A move inside
|
||||
* the throttle window is not dropped: its position is kept and one trailing
|
||||
* check runs when the window ends, so a pointer that stops is still resolved.
|
||||
* The cursor is the engine canvas's; camera motion and a hidden document
|
||||
* clear it.
|
||||
*/
|
||||
export function createLaunchHover({
|
||||
engine,
|
||||
enabled,
|
||||
isLaunchPick,
|
||||
now = () => performance.now(),
|
||||
documentRef = globalThis.document,
|
||||
pointerFree = isPointerFree,
|
||||
setTimer = globalThis.setTimeout,
|
||||
clearTimer = globalThis.clearTimeout,
|
||||
}) {
|
||||
const style = () => engine.host.canvas?.style;
|
||||
let moving = false;
|
||||
let lastPick = -Infinity;
|
||||
let ownsCursor = false;
|
||||
let pendingPosition = null;
|
||||
let trailingTimer = null;
|
||||
const clearCursor = () => {
|
||||
const canvasStyle = style();
|
||||
if (ownsCursor && canvasStyle?.cursor === 'pointer')
|
||||
canvasStyle.cursor = '';
|
||||
ownsCursor = false;
|
||||
};
|
||||
const cancelTrailing = () => {
|
||||
if (trailingTimer !== null) clearTimer(trailingTimer);
|
||||
trailingTimer = null;
|
||||
pendingPosition = null;
|
||||
};
|
||||
const reset = () => {
|
||||
cancelTrailing();
|
||||
clearCursor();
|
||||
lastPick = -Infinity;
|
||||
};
|
||||
const start = () => {
|
||||
moving = true;
|
||||
reset();
|
||||
};
|
||||
const end = () => {
|
||||
moving = false;
|
||||
reset();
|
||||
};
|
||||
const visibility = () => {
|
||||
if (documentRef?.hidden || documentRef?.visibilityState === 'hidden')
|
||||
reset();
|
||||
};
|
||||
const eligible = () =>
|
||||
enabled() &&
|
||||
!moving &&
|
||||
!documentRef?.hidden &&
|
||||
documentRef?.visibilityState !== 'hidden' &&
|
||||
pointerFree();
|
||||
const pick = (position, at) => {
|
||||
lastPick = at;
|
||||
const found = engine.picker
|
||||
.drillPick(position, PICK_DEPTH)
|
||||
.some((picked) => isLaunchPick(resolvePickId(picked)));
|
||||
const canvasStyle = style();
|
||||
if (found && canvasStyle) {
|
||||
canvasStyle.cursor = 'pointer';
|
||||
ownsCursor = true;
|
||||
} else clearCursor();
|
||||
};
|
||||
const trailing = () => {
|
||||
trailingTimer = null;
|
||||
const position = pendingPosition;
|
||||
pendingPosition = null;
|
||||
if (!position) return;
|
||||
// Eligibility may have changed while the check waited.
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
pick(position, now());
|
||||
};
|
||||
const removeStart = engine.camera.moveStart.addEventListener(start);
|
||||
const removeEnd = engine.camera.moveEnd.addEventListener(end);
|
||||
documentRef?.addEventListener?.('visibilitychange', visibility);
|
||||
return {
|
||||
move(movement) {
|
||||
if (!eligible()) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const at = now();
|
||||
const wait = HOVER_PICK_INTERVAL_MS - (at - lastPick);
|
||||
if (wait > 0) {
|
||||
// The engine may reuse the movement object: keep a copy.
|
||||
pendingPosition = geo.cartesian2.clone(
|
||||
movement.endPosition,
|
||||
pendingPosition ?? undefined,
|
||||
);
|
||||
if (trailingTimer === null) trailingTimer = setTimer(trailing, wait);
|
||||
return;
|
||||
}
|
||||
cancelTrailing();
|
||||
pick(movement.endPosition, at);
|
||||
},
|
||||
reset,
|
||||
destroy() {
|
||||
reset();
|
||||
removeStart();
|
||||
removeEnd();
|
||||
documentRef?.removeEventListener?.('visibilitychange', visibility);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ layerId?: string }} [options] Names the mover sets and lines.
|
||||
*/
|
||||
export function createLaunchesDisplay(engine, { layerId = LAYER_ID } = {}) {
|
||||
if (!engine?.movers || !engine?.lines)
|
||||
throw new TypeError('The launches display needs a globe engine');
|
||||
let destroyed = false;
|
||||
let visible = false;
|
||||
let lineSeq = 0;
|
||||
let frameNumber = 0;
|
||||
const removers = [];
|
||||
|
||||
// --- commits: once per frame listener, or in a microtask -----------------
|
||||
const pending = new Set();
|
||||
let inFrame = 0;
|
||||
let microtask = false;
|
||||
const flush = () => {
|
||||
microtask = false;
|
||||
for (const bank of pending) bank.commit();
|
||||
pending.clear();
|
||||
};
|
||||
const onCommit = (bank) => {
|
||||
pending.add(bank);
|
||||
if (inFrame === 0 && !microtask) {
|
||||
microtask = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const frame = (run) => {
|
||||
inFrame++;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
inFrame--;
|
||||
if (inFrame === 0) flush();
|
||||
}
|
||||
};
|
||||
|
||||
// What the layer draws, by pick id: the selection dispatcher's owns().
|
||||
const pickIds = new Map();
|
||||
const ownPick = (id) => pickIds.set(id, (pickIds.get(id) ?? 0) + 1);
|
||||
const dropPick = (id) => {
|
||||
const count = (pickIds.get(id) ?? 0) - 1;
|
||||
if (count > 0) pickIds.set(id, count);
|
||||
else pickIds.delete(id);
|
||||
};
|
||||
const owns = (id) => typeof id === 'string' && pickIds.has(id);
|
||||
|
||||
// --- markers: points by size, the reticle sprite --------------------------
|
||||
const banks = new Map();
|
||||
const markers = new Set();
|
||||
const markerWrite = (marker, set, index) => {
|
||||
writeFields(set, index, {
|
||||
position: marker.drawnPosition,
|
||||
color: marker.rgba,
|
||||
show: marker.show,
|
||||
...(marker.cell ? { cell: marker.cell } : {}),
|
||||
});
|
||||
set.set(index, { pickId: marker.id });
|
||||
};
|
||||
const bankFor = (key, options) => {
|
||||
let bank = banks.get(key);
|
||||
if (!bank) {
|
||||
const index = banks.size;
|
||||
bank = createMoverBank(engine, {
|
||||
options: (capacity, generation) => ({
|
||||
id: `${layerId}-${index}-${generation}`,
|
||||
capacity,
|
||||
pickable: true,
|
||||
// Cesium's disableDepthTestDistance = Infinity: over the scene.
|
||||
depthTest: 'always',
|
||||
...options,
|
||||
}),
|
||||
write: markerWrite,
|
||||
onCommit,
|
||||
});
|
||||
bank.setVisible(visible);
|
||||
banks.set(key, bank);
|
||||
}
|
||||
return bank;
|
||||
};
|
||||
const pointBank = (sizePx) =>
|
||||
bankFor(`point:${sizePx}`, {
|
||||
kind: 'point',
|
||||
point: { sizePx, outlinePx: 0 },
|
||||
});
|
||||
let reticleImage = null;
|
||||
const reticleBank = (image) => {
|
||||
reticleImage ??= image;
|
||||
return bankFor('reticle', {
|
||||
kind: 'sprite',
|
||||
sprite: {
|
||||
sizePx: RETICLE_SIZE_PX,
|
||||
atlas: {
|
||||
...(typeof reticleImage === 'string'
|
||||
? { url: reticleImage }
|
||||
: { canvas: reticleImage }),
|
||||
cells: { [RETICLE_CELL]: [0, 0, 1, 1] },
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// A function position is read each frame while its marker draws.
|
||||
const readPosition = (marker) => {
|
||||
const position =
|
||||
typeof marker.position === 'function'
|
||||
? marker.position()
|
||||
: marker.position;
|
||||
if (!position) return false;
|
||||
if (geo.cartesian3.equals(position, marker.drawnPosition)) return false;
|
||||
geo.cartesian3.clone(position, marker.drawnPosition);
|
||||
return true;
|
||||
};
|
||||
const refresh = (marker) => {
|
||||
if (!visible || !marker.show || typeof marker.position !== 'function')
|
||||
return;
|
||||
if (readPosition(marker))
|
||||
marker.bank.update(marker, { position: marker.drawnPosition });
|
||||
};
|
||||
|
||||
class Marker {
|
||||
constructor(bank, { id, position, color, cell }) {
|
||||
this.bank = bank;
|
||||
this.gevBank = null;
|
||||
this.gevIndex = -1;
|
||||
this.id = id;
|
||||
this.cell = cell;
|
||||
this.position = position;
|
||||
this.drawnPosition = geo.cartesian3.clone(geo.cartesian3.ZERO);
|
||||
this.rgba = rgbaOf(color);
|
||||
this._show = true;
|
||||
if (typeof position !== 'function') readPosition(this);
|
||||
else if (visible) readPosition(this);
|
||||
ownPick(id);
|
||||
markers.add(this);
|
||||
bank.add(this);
|
||||
}
|
||||
|
||||
get show() {
|
||||
return this._show;
|
||||
}
|
||||
|
||||
set show(value) {
|
||||
const next = value !== false;
|
||||
if (next === this._show) return;
|
||||
this._show = next;
|
||||
if (next && typeof this.position === 'function' && visible)
|
||||
readPosition(this);
|
||||
this.bank.update(this, {
|
||||
show: next,
|
||||
position: this.drawnPosition,
|
||||
});
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (!markers.delete(this)) return;
|
||||
dropPick(this.id);
|
||||
this.bank.remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
// --- lines ------------------------------------------------------------------
|
||||
const lines = new Set();
|
||||
class LineHandle {
|
||||
constructor(options) {
|
||||
const { id, positions, pickId, modelMatrix } = options;
|
||||
this.id = id;
|
||||
this.pickId = pickId;
|
||||
this.positions = positions;
|
||||
this._show = true;
|
||||
this.line = engine.lines.createLine({
|
||||
id: `${layerId}-line-${++lineSeq}`,
|
||||
widthPx: options.widthPx,
|
||||
color: rgbaOf(options.color),
|
||||
arc: options.arc ?? 'none',
|
||||
...(options.dash ? { dash: options.dash } : {}),
|
||||
...(options.depthFailAlpha
|
||||
? { depthFailAlpha: options.depthFailAlpha }
|
||||
: {}),
|
||||
...(modelMatrix ? { modelMatrix: [...modelMatrix] } : {}),
|
||||
...(pickId !== undefined ? { pickId } : {}),
|
||||
});
|
||||
this.line.setPositions(positions);
|
||||
this.line.show(visible);
|
||||
if (pickId !== undefined) ownPick(pickId);
|
||||
lines.add(this);
|
||||
}
|
||||
|
||||
get show() {
|
||||
return this._show;
|
||||
}
|
||||
|
||||
set show(value) {
|
||||
const next = value !== false;
|
||||
if (next === this._show) return;
|
||||
this._show = next;
|
||||
this.line.show(visible && next);
|
||||
}
|
||||
|
||||
setModelMatrix(matrix) {
|
||||
this.line.setModelMatrix([...matrix]);
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (!lines.delete(this)) return;
|
||||
if (this.pickId !== undefined) dropPick(this.pickId);
|
||||
this.line.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// --- frames -------------------------------------------------------------------
|
||||
removers.push(
|
||||
engine.frames.preUpdate.addEventListener(() => {
|
||||
frameNumber++;
|
||||
}),
|
||||
);
|
||||
// Before the layer's own listeners: function positions of the drawn markers.
|
||||
removers.push(
|
||||
engine.frames.preRender.addEventListener(() =>
|
||||
frame(() => {
|
||||
if (!visible) return;
|
||||
for (const marker of markers) refresh(marker);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const occluder = {
|
||||
isPointVisible: (position) => engine.projector.horizonVisible(position),
|
||||
};
|
||||
|
||||
return {
|
||||
engine,
|
||||
occluder,
|
||||
/** The engine frames seen so far (preUpdate). */
|
||||
frameNumber: () => frameNumber,
|
||||
visible: () => visible && !destroyed,
|
||||
/** The layer's switch: nothing draws while it is off. */
|
||||
setVisible(next) {
|
||||
next = Boolean(next) && !destroyed;
|
||||
if (next === visible) return;
|
||||
visible = next;
|
||||
frame(() => {
|
||||
for (const bank of banks.values()) bank.setVisible(visible);
|
||||
if (visible) for (const marker of markers) refresh(marker);
|
||||
for (const handle of lines) handle.line.show(visible && handle.show);
|
||||
});
|
||||
},
|
||||
/** A frame event listener whose writes commit when it returns. */
|
||||
on(name, listener) {
|
||||
const remove = engine.frames[name].addEventListener((time) =>
|
||||
frame(() => listener(time)),
|
||||
);
|
||||
removers.push(remove);
|
||||
return remove;
|
||||
},
|
||||
/** A dot of `sizePx` CSS px over the scene; picks return `id`. */
|
||||
addPoint: ({ id, position, sizePx, color }) =>
|
||||
new Marker(pointBank(sizePx), { id, position, color }),
|
||||
/** The hover reticle (`image`: a canvas or url) over the scene. */
|
||||
addReticle: ({ id, position, image }) =>
|
||||
new Marker(reticleBank(image), {
|
||||
id,
|
||||
position,
|
||||
color: [1, 1, 1, 1],
|
||||
cell: RETICLE_CELL,
|
||||
}),
|
||||
/**
|
||||
* A line: { id, positions, widthPx, color, dash?, arc?, pickId?,
|
||||
* modelMatrix?, depthFailAlpha? } (engine.lines options).
|
||||
*/
|
||||
addLine: (options) => new LineHandle(options),
|
||||
/**
|
||||
* The pad zone of a launch: its rim on the terrain under the pad, drawn
|
||||
* over the scene. Picks pass through it, as Cesium's clicks and hover
|
||||
* passed over the zone.
|
||||
*/
|
||||
createPad(launch) {
|
||||
const terrain = engine.surface.terrainHeightSupported
|
||||
? engine.surface.terrainHeight(
|
||||
geo.cartographic.fromDegrees(launch.lon, launch.lat, 0),
|
||||
)
|
||||
: undefined;
|
||||
const heightM = Number.isFinite(terrain) ? terrain : 0;
|
||||
const ring = new LineHandle({
|
||||
id: `rocket-pad-zone:${launch.id}`,
|
||||
positions: padRingPositions(launch, heightM),
|
||||
widthPx: PAD_RING_WIDTH_PX,
|
||||
color: padRimColor(),
|
||||
depthFailAlpha: 1,
|
||||
});
|
||||
return {
|
||||
heightM,
|
||||
get show() {
|
||||
return ring.show;
|
||||
},
|
||||
set show(value) {
|
||||
ring.show = value;
|
||||
},
|
||||
destroy: () => ring.remove(),
|
||||
};
|
||||
},
|
||||
project: (position, result) =>
|
||||
engine.projector.worldToWindow(position, result),
|
||||
/**
|
||||
* Clicks through the selection dispatcher: `listener(id)` gets the
|
||||
* first of the layer's pick ids under the click, or undefined.
|
||||
*/
|
||||
onClick(listener, { enabled = () => true } = {}) {
|
||||
const remove = selectionDispatcherFor(engine).register(LAYER_ID, {
|
||||
owns,
|
||||
onClick: (ctx) => {
|
||||
if (!isPointerFree()) return;
|
||||
if (!enabled()) return;
|
||||
const id = [ctx.picked, ...ctx.drilled]
|
||||
.map((picked) => resolvePickId(picked))
|
||||
.find(owns);
|
||||
listener(id);
|
||||
},
|
||||
});
|
||||
removers.push(remove);
|
||||
return { destroy: remove };
|
||||
},
|
||||
/** The pointer cursor over the layer's picks (createLaunchHover). */
|
||||
onHover({ enabled = () => true } = {}) {
|
||||
const hover = createLaunchHover({ engine, enabled, isLaunchPick: owns });
|
||||
const removeMove = engine.input.on('move', hover.move);
|
||||
const handle = {
|
||||
reset: hover.reset,
|
||||
destroy() {
|
||||
removeMove();
|
||||
hover.destroy();
|
||||
},
|
||||
};
|
||||
removers.push(handle.destroy);
|
||||
return handle;
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
flush();
|
||||
destroyed = true;
|
||||
visible = false;
|
||||
for (const remove of removers.splice(0)) remove?.();
|
||||
for (const marker of [...markers]) marker.remove();
|
||||
for (const bank of banks.values()) bank.destroy();
|
||||
banks.clear();
|
||||
pending.clear();
|
||||
for (const handle of [...lines]) handle.remove();
|
||||
pickIds.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// The Space Missions display on the engine contract, on the fake engine and
|
||||
// on the Cesium engine (real Cesium collections and polylines over a stub
|
||||
// scene: they draw nothing but hold what the engine writes): the dots and the
|
||||
// reticle as Cesium's entity points and billboards drew them, their per-frame
|
||||
// positions, the mission paths' dashes, the tactical orbits and their model
|
||||
// matrix, the pad zone's rim, the layer's switch, clicks, the hover cursor
|
||||
// and teardown.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { dashPattern } from '../../../globe/cesium/lines.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
||||
import {
|
||||
PAD_RIM_RADIUS_M,
|
||||
createLaunchesDisplay,
|
||||
padRimColor,
|
||||
} from './display.js';
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const reticle = { width: 48, height: 48, getContext: () => null };
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine({ canvas: { style: { cursor: '' } } });
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
frame: () => engine.fake.frame(),
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const { engine, viewer, ...rest } = createCesiumMoversEngine();
|
||||
viewer.scene.globe.getHeight = () => 7;
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
viewer,
|
||||
...rest,
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESSES = [fakeHarness, cesiumHarness];
|
||||
|
||||
function setup(makeHarness) {
|
||||
const harness = makeHarness();
|
||||
harness.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(-80.6, 28.6, 2_000_000),
|
||||
});
|
||||
const display = createLaunchesDisplay(harness.engine);
|
||||
return { ...harness, display };
|
||||
}
|
||||
|
||||
const near = (actual, expected, label, tolerance = 1e-6) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < tolerance,
|
||||
`${label}: ${actual} vs ${expected}`,
|
||||
);
|
||||
const PAD = geo.cartesian3.fromDegrees(-80.6, 28.6);
|
||||
const ORBIT = [
|
||||
geo.cartesian3.fromDegrees(-80.6, 28.6, 400_000),
|
||||
geo.cartesian3.fromDegrees(-70, 30, 400_000),
|
||||
geo.cartesian3.fromDegrees(-60, 31, 400_000),
|
||||
];
|
||||
|
||||
test('the display needs a globe engine', () => {
|
||||
assert.throws(() => createLaunchesDisplay(null), TypeError);
|
||||
});
|
||||
|
||||
test("the pad zone's rim is the middle of the style's rim band in the rim's colour", () => {
|
||||
near(
|
||||
PAD_RIM_RADIUS_M,
|
||||
LAUNCH_PAD_ZONE_RADIUS_M *
|
||||
((LAUNCH_PAD_ZONE_STYLE.rimStart + LAUNCH_PAD_ZONE_STYLE.rimEnd) / 2),
|
||||
'radius',
|
||||
);
|
||||
const [red, green, blue] = rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba();
|
||||
const gain = 1 + LAUNCH_PAD_ZONE_STYLE.emission;
|
||||
assert.deepEqual(padRimColor(), [
|
||||
Math.min(1, red * gain),
|
||||
Math.min(1, green * gain),
|
||||
Math.min(1, blue * gain),
|
||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
||||
]);
|
||||
});
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`mission dots draw as Cesium's entity points: size, colour, over the scene, picked by id (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
const color = rgbaFromCss('#4cc9f0');
|
||||
const pad = display.addPoint({
|
||||
id: 'rocket-launch:a',
|
||||
position: PAD,
|
||||
sizePx: 5,
|
||||
color,
|
||||
});
|
||||
display.addPoint({
|
||||
id: 'rocket-recovery-end:a:0',
|
||||
position: geo.cartesian3.fromDegrees(-76.2, 30.1),
|
||||
sizePx: 4,
|
||||
color: rgbaFromCss('#ff66c4'),
|
||||
});
|
||||
await settle();
|
||||
// Off until the layer shows it.
|
||||
assert.deepEqual(
|
||||
engine.movers.list().map(({ count }) => count),
|
||||
[0, 0],
|
||||
);
|
||||
display.setVisible(true);
|
||||
const [five, four] = engine.movers.list();
|
||||
assert.equal(five.kind, 'point');
|
||||
assert.equal(five.count, 1);
|
||||
assert.equal(four.count, 1);
|
||||
if (h.cesium) {
|
||||
const point = h.primitive(five.id).get(0);
|
||||
assert.equal(point.pixelSize, 5);
|
||||
assert.equal(point.outlineWidth, 0);
|
||||
assert.equal(point.disableDepthTestDistance, Number.POSITIVE_INFINITY);
|
||||
assert.equal(point.id, 'rocket-launch:a');
|
||||
assert.equal(point.show, true);
|
||||
assert.ok(Cesium.Cartesian3.equals(point.position, PAD));
|
||||
near(point.color.green, color.green, 'green');
|
||||
assert.equal(h.primitive(four.id).get(0).pixelSize, 4);
|
||||
} else {
|
||||
const set = engine.fake.moverSet(five.id);
|
||||
assert.equal(set.options.point.sizePx, 5);
|
||||
assert.equal(set.options.point.outlinePx, 0);
|
||||
assert.equal(set.options.depthTest, 'always');
|
||||
assert.equal(set.options.pickable, true);
|
||||
assert.equal(set.store.pickIds[0], 'rocket-launch:a');
|
||||
near(set.store.colors[1], color.green, 'green');
|
||||
}
|
||||
pad.show = false;
|
||||
await settle();
|
||||
if (h.cesium) assert.equal(h.primitive(five.id).get(0).show, false);
|
||||
else assert.equal(engine.fake.moverSet(five.id).store.shows[0], 0);
|
||||
display.setVisible(false);
|
||||
assert.deepEqual(
|
||||
engine.movers.list().map(({ count }) => count),
|
||||
[0, 0],
|
||||
);
|
||||
display.destroy();
|
||||
assert.deepEqual(engine.movers.list(), []);
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`the hover reticle is a 24 px sprite of the reticle canvas over the scene (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
display.setVisible(true);
|
||||
const sprite = display.addReticle({
|
||||
id: 'rocket-launch:a',
|
||||
position: PAD,
|
||||
image: reticle,
|
||||
});
|
||||
sprite.show = false;
|
||||
await settle();
|
||||
const [listed] = engine.movers.list();
|
||||
assert.equal(listed.kind, 'sprite');
|
||||
if (h.cesium) {
|
||||
const billboard = h.primitive(listed.id).get(0);
|
||||
assert.equal(billboard.width, 24);
|
||||
assert.equal(billboard.height, 24);
|
||||
assert.equal(
|
||||
billboard.disableDepthTestDistance,
|
||||
Number.POSITIVE_INFINITY,
|
||||
);
|
||||
assert.equal(billboard.id, 'rocket-launch:a');
|
||||
assert.equal(billboard.show, false);
|
||||
} else {
|
||||
const set = engine.fake.moverSet(listed.id);
|
||||
assert.equal(set.options.sprite.sizePx, 24);
|
||||
assert.equal(set.options.sprite.atlas.canvas, reticle);
|
||||
assert.equal(set.options.depthTest, 'always');
|
||||
assert.equal(set.store.shows[0], 0);
|
||||
}
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`a moving dot reads its position each frame while it draws, never while hidden (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
let at = geo.cartesian3.fromDegrees(0, 0, 400_000);
|
||||
let reads = 0;
|
||||
display.addPoint({
|
||||
id: 'rocket-satellite:a',
|
||||
position: () => {
|
||||
reads++;
|
||||
return at;
|
||||
},
|
||||
sizePx: 6,
|
||||
color: rgbaFromCss('#ffd166'),
|
||||
});
|
||||
await settle();
|
||||
h.frame();
|
||||
assert.equal(reads, 0, 'not read while the layer is off');
|
||||
display.setVisible(true);
|
||||
assert.equal(reads, 1, 'read when it starts to draw');
|
||||
at = geo.cartesian3.fromDegrees(1, 0, 400_000);
|
||||
h.frame();
|
||||
assert.equal(reads, 2);
|
||||
const [listed] = engine.movers.list();
|
||||
const drawn = () => {
|
||||
if (h.cesium) return h.primitive(listed.id).get(0).position;
|
||||
const { positions } = engine.fake.moverSet(listed.id).store;
|
||||
return geo.cartesian3.create(positions[0], positions[1], positions[2]);
|
||||
};
|
||||
assert.ok(geo.cartesian3.equalsEpsilon(drawn(), at, 0, 1e-6));
|
||||
display.setVisible(false);
|
||||
if (!h.cesium) engine.fake.resetCalls();
|
||||
at = geo.cartesian3.fromDegrees(2, 0, 400_000);
|
||||
h.frame();
|
||||
h.frame();
|
||||
assert.equal(reads, 2, 'hidden motion is not read');
|
||||
if (!h.cesium)
|
||||
assert.equal(
|
||||
engine.fake.renderRequests,
|
||||
0,
|
||||
'hidden motion must not sustain rendering',
|
||||
);
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`mission paths keep Cesium's widths, colours, dashes and straight segments (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
display.setVisible(true);
|
||||
const color = rgbaFromCss('#7bed9f').withAlpha(0.8);
|
||||
const transfer = display.addLine({
|
||||
id: 'rocket-transfer:a',
|
||||
pickId: 'rocket-transfer:a',
|
||||
positions: ORBIT,
|
||||
widthPx: 1.5,
|
||||
color,
|
||||
dash: { lengthPx: 6, gapPx: 6 },
|
||||
});
|
||||
const [listed] = engine.lines.list();
|
||||
if (h.cesium) {
|
||||
const primitive = h.primitive(listed.id);
|
||||
const { geometry, id } = primitive.geometryInstances;
|
||||
assert.equal(geometry._width, 1.5);
|
||||
assert.equal(geometry._arcType, Cesium.ArcType.NONE);
|
||||
assert.equal(id, 'rocket-transfer:a');
|
||||
const { material } = primitive.appearance;
|
||||
assert.equal(material.type, 'PolylineDash');
|
||||
// Cesium's dashLength 24 px with 0xf0f0: 6 px on, 6 px off.
|
||||
assert.equal(material.uniforms.dashLength, 12);
|
||||
assert.equal(material.uniforms.dashPattern, dashPattern(6, 6));
|
||||
assert.equal(material.uniforms.dashPattern, 0xff00);
|
||||
near(material.uniforms.color.alpha, 0.8, 'alpha');
|
||||
assert.equal(primitive.depthFailAppearance, undefined);
|
||||
} else {
|
||||
const { options } = engine.fake.line(listed.id);
|
||||
assert.equal(options.widthPx, 1.5);
|
||||
assert.equal(options.arc, 'none');
|
||||
assert.equal(options.pickId, 'rocket-transfer:a');
|
||||
assert.deepEqual(options.dash, {
|
||||
kind: 'pixels',
|
||||
lengthPx: 6,
|
||||
gapPx: 6,
|
||||
});
|
||||
assert.deepEqual(options.color, color.rgba());
|
||||
assert.equal(options.depthFailAlpha, 0);
|
||||
}
|
||||
transfer.show = false;
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
transfer.show = true;
|
||||
display.setVisible(false);
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
display.setVisible(true);
|
||||
assert.equal(engine.lines.list()[0].shown, true);
|
||||
transfer.remove();
|
||||
assert.deepEqual(engine.lines.list(), []);
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`orbits draw the tactical pattern; a live ring turns by its model matrix (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
display.setVisible(true);
|
||||
const modelMatrix = geo.matrix4.clone(geo.matrix4.IDENTITY);
|
||||
const ring = display.addLine({
|
||||
id: 'rocket-orbit-ring:a',
|
||||
positions: ORBIT,
|
||||
widthPx: 3,
|
||||
color: rgbaFromCss('#22e6e6').withAlpha(0.95),
|
||||
dash: 'tactical',
|
||||
modelMatrix,
|
||||
});
|
||||
const [listed] = engine.lines.list();
|
||||
modelMatrix[12] = 42;
|
||||
ring.setModelMatrix(modelMatrix);
|
||||
if (h.cesium) {
|
||||
const primitive = h.primitive(listed.id);
|
||||
const { material } = primitive.appearance;
|
||||
assert.equal(material.type, 'GevLineTactical');
|
||||
assert.equal(material.uniforms.groupCount, 4);
|
||||
assert.equal(material.uniforms.dashCount, 100);
|
||||
near(material.uniforms.color.alpha, 0.95, 'alpha');
|
||||
assert.equal(primitive.geometryInstances.geometry._width, 3);
|
||||
assert.equal(primitive.modelMatrix[12], 42);
|
||||
// The live ring is not picked (Cesium's polyline collection).
|
||||
assert.equal(primitive.allowPicking, false);
|
||||
} else {
|
||||
const line = engine.fake.line(listed.id);
|
||||
assert.deepEqual(line.options.dash, {
|
||||
kind: 'tactical',
|
||||
groups: 4,
|
||||
dashes: 100,
|
||||
});
|
||||
assert.equal(line.modelMatrix[12], 42);
|
||||
assert.equal(line.options.pickId, undefined);
|
||||
}
|
||||
display.destroy();
|
||||
assert.deepEqual(engine.lines.list(), []);
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`the pad zone is its rim on the terrain, drawn over the scene, not picked (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
if (!h.cesium) engine.fake.script('terrainHeight', 7);
|
||||
display.setVisible(true);
|
||||
const zone = display.createPad({ id: 'a', lon: -80.6, lat: 28.6 });
|
||||
assert.equal(zone.heightM, 7);
|
||||
assert.equal(
|
||||
engine.fake?.callsOf('surface.sampleHeight').length ?? 0,
|
||||
0,
|
||||
'no tile height is sampled',
|
||||
);
|
||||
const [listed] = engine.lines.list();
|
||||
assert.equal(listed.points, 129, 'a closed ring');
|
||||
const center = geo.cartesian3.fromDegrees(-80.6, 28.6, 7);
|
||||
const ringPoint = (flat, index) =>
|
||||
geo.cartesian3.create(
|
||||
flat[index * 3],
|
||||
flat[index * 3 + 1],
|
||||
flat[index * 3 + 2],
|
||||
);
|
||||
if (h.cesium) {
|
||||
const primitive = h.primitive(listed.id);
|
||||
assert.equal(primitive.allowPicking, false);
|
||||
const positions = primitive.geometryInstances.geometry._positions;
|
||||
near(
|
||||
Cesium.Cartesian3.distance(positions[0], center),
|
||||
PAD_RIM_RADIUS_M,
|
||||
'rim radius',
|
||||
1e-3,
|
||||
);
|
||||
const { color } = primitive.appearance.material.uniforms;
|
||||
near(color.alpha, LAUNCH_PAD_ZONE_STYLE.rimAlpha, 'rim alpha');
|
||||
near(
|
||||
primitive.depthFailAppearance.material.uniforms.color.alpha,
|
||||
LAUNCH_PAD_ZONE_STYLE.rimAlpha,
|
||||
'drawn where the scene hides it',
|
||||
);
|
||||
} else {
|
||||
const line = engine.fake.line(listed.id);
|
||||
assert.equal(line.options.pickId, undefined);
|
||||
assert.equal(line.options.depthFailAlpha, 1);
|
||||
assert.deepEqual(line.options.color, padRimColor());
|
||||
near(
|
||||
geo.cartesian3.distance(ringPoint(line.positions, 0), center),
|
||||
PAD_RIM_RADIUS_M,
|
||||
'rim radius',
|
||||
1e-3,
|
||||
);
|
||||
near(
|
||||
geo.cartesian3.distance(ringPoint(line.positions, 64), center),
|
||||
PAD_RIM_RADIUS_M,
|
||||
'rim radius',
|
||||
1e-3,
|
||||
);
|
||||
}
|
||||
zone.show = false;
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
zone.show = true;
|
||||
assert.equal(engine.lines.list()[0].shown, true);
|
||||
zone.destroy();
|
||||
zone.destroy();
|
||||
assert.deepEqual(engine.lines.list(), []);
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`teardown releases every set, line and frame listener (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
const before = engine.frames.preRender.numberOfListeners;
|
||||
display.setVisible(true);
|
||||
display.addPoint({ id: 'rocket-launch:a', position: PAD, sizePx: 5 });
|
||||
display.addReticle({
|
||||
id: 'rocket-launch:a',
|
||||
position: PAD,
|
||||
image: reticle,
|
||||
});
|
||||
display.addLine({ id: 'rocket-orbit:a', positions: ORBIT, widthPx: 3 });
|
||||
display.createPad({ id: 'a', lon: -80.6, lat: 28.6 });
|
||||
let frames = 0;
|
||||
display.on('preRender', () => frames++);
|
||||
h.frame();
|
||||
assert.equal(frames, 1);
|
||||
await settle();
|
||||
display.destroy();
|
||||
display.destroy();
|
||||
assert.deepEqual(engine.movers.list(), []);
|
||||
assert.deepEqual(engine.lines.list(), []);
|
||||
h.frame();
|
||||
assert.equal(frames, 1);
|
||||
assert.equal(display.visible(), false);
|
||||
assert.equal(engine.frames.preRender.numberOfListeners, before - 1);
|
||||
h.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('a click on a mission names its element; a click elsewhere or while off names none', () => {
|
||||
const engine = createFakeEngine();
|
||||
const display = createLaunchesDisplay(engine);
|
||||
display.setVisible(true);
|
||||
display.addPoint({ id: 'rocket-launch:a', position: PAD, sizePx: 5 });
|
||||
let enabled = true;
|
||||
const clicks = [];
|
||||
const handler = display.onClick((id) => clicks.push(id), {
|
||||
enabled: () => enabled,
|
||||
});
|
||||
engine.fake.script('pick', { id: 'rocket-launch:a' });
|
||||
engine.fake.script('drillPick', [{ id: 'other' }, { id: 'rocket-launch:a' }]);
|
||||
engine.fake.input('click', { position: { x: 1, y: 2 } });
|
||||
engine.fake.script('pick', { id: 'other' });
|
||||
engine.fake.script('drillPick', [{ id: 'rocket-launch:a' }]);
|
||||
engine.fake.input('click', { position: { x: 1, y: 2 } });
|
||||
engine.fake.script('pick', undefined);
|
||||
engine.fake.script('drillPick', []);
|
||||
engine.fake.input('click', { position: { x: 1, y: 2 } });
|
||||
enabled = false;
|
||||
engine.fake.script('pick', { id: 'rocket-launch:a' });
|
||||
engine.fake.input('click', { position: { x: 1, y: 2 } });
|
||||
assert.deepEqual(clicks, ['rocket-launch:a', 'rocket-launch:a', undefined]);
|
||||
handler.destroy();
|
||||
enabled = true;
|
||||
engine.fake.input('click', { position: { x: 1, y: 2 } });
|
||||
assert.equal(clicks.length, 3);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test("the hover cursor is the engine canvas's pointer over a mission", () => {
|
||||
const canvas = { style: { cursor: '' } };
|
||||
const engine = createFakeEngine({ canvas });
|
||||
const display = createLaunchesDisplay(engine);
|
||||
display.setVisible(true);
|
||||
display.addPoint({ id: 'rocket-launch:a', position: PAD, sizePx: 5 });
|
||||
const hover = display.onHover({ enabled: () => true });
|
||||
const previousDocument = globalThis.document;
|
||||
engine.fake.script('drillPick', [{ id: 'rocket-launch:a' }]);
|
||||
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
|
||||
assert.equal(canvas.style.cursor, 'pointer');
|
||||
assert.equal(engine.fake.callsOf('picker.drillPick')[0].args[1], 12);
|
||||
hover.reset();
|
||||
assert.equal(canvas.style.cursor, '');
|
||||
hover.destroy();
|
||||
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
|
||||
assert.equal(canvas.style.cursor, '');
|
||||
assert.equal(globalThis.document, previousDocument);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,473 +0,0 @@
|
||||
// Mission points, paths and the pad zone on Navara.
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createMoverBank } from '../../shared/render/navaraMoverBank.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { registerViewerEngine } from '../../../globe/cesium/engineFromViewer.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { createPadGround } from './navaraPadGround.js';
|
||||
|
||||
const read = (v, time) =>
|
||||
typeof v?.getValue === 'function' ? v.getValue(time) : v;
|
||||
const property = (v) =>
|
||||
typeof v?.getValue === 'function' ? v : { getValue: () => v };
|
||||
const rgba = (c) => (c ? [c.red, c.green, c.blue, c.alpha] : [1, 1, 1, 1]);
|
||||
export function createLaunchesNavaraRenderer(
|
||||
engine,
|
||||
{ view: nativeView, EffectDesc, NavaraEffect } = {},
|
||||
) {
|
||||
let destroyed = false,
|
||||
sequence = 0,
|
||||
inFrame = false,
|
||||
queued = false,
|
||||
bankVisibility = false;
|
||||
const sources = new Set(),
|
||||
banks = new Map(),
|
||||
dirty = new Set(),
|
||||
primitives = new Set();
|
||||
const flush = () => {
|
||||
queued = false;
|
||||
for (const bank of dirty) bank.commit();
|
||||
dirty.clear();
|
||||
};
|
||||
const touch = (bank) => {
|
||||
dirty.add(bank);
|
||||
if (!inFrame && !queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const fields = { position: null, color: null, show: true };
|
||||
const pointBank = (record, graphic, sprite) => {
|
||||
const time = engine.clock.now(),
|
||||
size =
|
||||
read(sprite ? graphic.width : graphic.pixelSize, time) ??
|
||||
(sprite ? 24 : 5);
|
||||
const image = sprite ? read(graphic.image, time) : null;
|
||||
const outline = rgba(
|
||||
read(graphic.outlineColor, time) ?? {
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
alpha: 1,
|
||||
},
|
||||
);
|
||||
const outlineWidth = read(graphic.outlineWidth, time) ?? 0;
|
||||
const key = [
|
||||
sprite ? 'sprite' : 'point',
|
||||
size,
|
||||
outlineWidth,
|
||||
...outline,
|
||||
image ?? '',
|
||||
].join(':');
|
||||
let bank = banks.get(key);
|
||||
if (!bank) {
|
||||
const id = ++sequence;
|
||||
bank = createMoverBank(engine, {
|
||||
options: (capacity, generation) => ({
|
||||
id: `launches-${id}-${generation}`,
|
||||
kind: sprite ? 'sprite' : 'point',
|
||||
capacity,
|
||||
pickable: true,
|
||||
depthTest: 'always',
|
||||
...(sprite
|
||||
? {
|
||||
sprite: {
|
||||
sizePx: size,
|
||||
atlas: {
|
||||
...(typeof image === 'string'
|
||||
? { url: image }
|
||||
: { canvas: image }),
|
||||
cells: { reticle: [0, 0, 1, 1] },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
point: {
|
||||
sizePx: size,
|
||||
outlinePx: outlineWidth,
|
||||
outlineColor: outline,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
write: (point, set, index) =>
|
||||
set.set(index, {
|
||||
position: point.position,
|
||||
color: point.color,
|
||||
show: point.show,
|
||||
pickId: point.record.entity.id,
|
||||
...(sprite ? { cell: 'reticle' } : {}),
|
||||
}),
|
||||
onCommit: touch,
|
||||
});
|
||||
banks.set(key, bank);
|
||||
bank.setVisible(bankVisibility);
|
||||
}
|
||||
return bank;
|
||||
};
|
||||
const syncPoint = (point, graphic, record) => {
|
||||
const time = engine.clock.now();
|
||||
if (
|
||||
!record.source.show ||
|
||||
!record.entity.show ||
|
||||
read(graphic.show, time) === false
|
||||
) {
|
||||
if (point.show) {
|
||||
point.show = false;
|
||||
fields.position = point.position;
|
||||
fields.color = point.color;
|
||||
fields.show = false;
|
||||
point.gevBank.update(point, fields);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const p = read(record.entity.position, time),
|
||||
c = read(graphic.color, time);
|
||||
const shown = Boolean(p);
|
||||
const red = c?.red ?? 1,
|
||||
green = c?.green ?? 1,
|
||||
blue = c?.blue ?? 1,
|
||||
alpha = c?.alpha ?? 1;
|
||||
if (
|
||||
p &&
|
||||
(point.position.x !== p.x ||
|
||||
point.position.y !== p.y ||
|
||||
point.position.z !== p.z ||
|
||||
point.show !== shown ||
|
||||
red !== point.color[0] ||
|
||||
green !== point.color[1] ||
|
||||
blue !== point.color[2] ||
|
||||
alpha !== point.color[3])
|
||||
) {
|
||||
geo.cartesian3.clone(p, point.position);
|
||||
point.color[0] = red;
|
||||
point.color[1] = green;
|
||||
point.color[2] = blue;
|
||||
point.color[3] = alpha;
|
||||
point.show = shown;
|
||||
fields.position = point.position;
|
||||
fields.color = point.color;
|
||||
fields.show = shown;
|
||||
point.gevBank.update(point, fields);
|
||||
} else if (point.show !== shown) {
|
||||
point.show = shown;
|
||||
fields.position = point.position;
|
||||
fields.color = point.color;
|
||||
fields.show = shown;
|
||||
point.gevBank.update(point, fields);
|
||||
}
|
||||
};
|
||||
const syncEntity = (record) => {
|
||||
for (const point of record.points) syncPoint(point, point.graphic, record);
|
||||
if (record.line) {
|
||||
const show = Boolean(
|
||||
record.source.show &&
|
||||
record.entity.show &&
|
||||
read(record.entity.polyline.show, engine.clock.now()) !== false,
|
||||
);
|
||||
if (show !== record.lineVisible) {
|
||||
record.lineVisible = show;
|
||||
record.line.show(show);
|
||||
}
|
||||
}
|
||||
};
|
||||
const sync = () => {
|
||||
let visible = false;
|
||||
for (const source of sources)
|
||||
if (source.show) {
|
||||
visible = true;
|
||||
break;
|
||||
}
|
||||
if (visible !== bankVisibility) {
|
||||
bankVisibility = visible;
|
||||
for (const bank of banks.values()) bank.setVisible(visible);
|
||||
}
|
||||
for (const source of sources)
|
||||
for (const record of source.records.values()) syncEntity(record);
|
||||
};
|
||||
const frame = (run) => {
|
||||
inFrame = true;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
sync();
|
||||
inFrame = false;
|
||||
flush();
|
||||
}
|
||||
};
|
||||
const adapter = createEngineLayerView(engine, {
|
||||
layerId: 'rocket-launches',
|
||||
wrap: frame,
|
||||
isDestroyed: () => destroyed,
|
||||
});
|
||||
const view = adapter.view;
|
||||
view.container = engine.host.container;
|
||||
view.scene.canvas = engine.host.canvas;
|
||||
view.scene.preUpdate = engine.frames.preUpdate;
|
||||
view.scene.preRender = engine.frames.preRender;
|
||||
view.scene.postRender = engine.frames.postRender;
|
||||
view.scene.requestRender = () => engine.render.request('rocket-launches');
|
||||
Object.defineProperty(view, 'trackedEntity', {
|
||||
get: () => engine.camera.following(),
|
||||
set: (value) => {
|
||||
if (!value) engine.camera.following()?.stop();
|
||||
},
|
||||
});
|
||||
view.camera.cancelFlight = () => engine.camera.cancelFlight();
|
||||
view.camera.lookAt = (target, offset) => engine.camera.lookAt(target, offset);
|
||||
view.camera.lookAtTransform = (matrix, offset) =>
|
||||
engine.camera.lookAtTransform(matrix, offset);
|
||||
view.camera.setView = (options) => engine.camera.setView(options);
|
||||
view.camera.flyTo = ({ easingFunction, ...options }) =>
|
||||
engine.camera.flyTo({ ...options, easing: 'cubicInOut' });
|
||||
view.camera.flyToBoundingSphere = (
|
||||
sphere,
|
||||
{ offset, easingFunction, ...options } = {},
|
||||
) =>
|
||||
engine.camera.flyToSphere(sphere, {
|
||||
...options,
|
||||
...offset,
|
||||
easing: 'cubicInOut',
|
||||
});
|
||||
const unregister = registerViewerEngine(view, engine);
|
||||
const clearSource = (source) => {
|
||||
for (const record of source.records.values()) {
|
||||
for (const point of record.points) point.gevBank?.remove(point);
|
||||
record.line?.destroy();
|
||||
}
|
||||
source.records.clear();
|
||||
source.entitiesArray.length = 0;
|
||||
};
|
||||
const addEntity = (source, spec) => {
|
||||
const record = {
|
||||
source,
|
||||
entity: null,
|
||||
points: [],
|
||||
line: null,
|
||||
lineVisible: null,
|
||||
};
|
||||
let shown = spec.show !== false;
|
||||
const entity = {
|
||||
...spec,
|
||||
position: spec.position ? property(spec.position) : undefined,
|
||||
};
|
||||
record.entity = entity;
|
||||
Object.defineProperty(entity, 'show', {
|
||||
get: () => shown,
|
||||
set: (value) => {
|
||||
shown = Boolean(value);
|
||||
syncEntity(record);
|
||||
},
|
||||
});
|
||||
for (const name of ['point', 'billboard', 'polyline'])
|
||||
if (spec[name]) {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(spec[name]).map(([k, v]) => [k, property(v)]),
|
||||
);
|
||||
entity[name] = new Proxy(values, {
|
||||
set: (target, key, value) => {
|
||||
target[key] = property(value);
|
||||
syncEntity(record);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
source.records.set(entity.id, record);
|
||||
source.entitiesArray.push(entity);
|
||||
for (const name of ['point', 'billboard'])
|
||||
if (entity[name]) {
|
||||
const graphic = entity[name],
|
||||
p = read(entity.position, engine.clock.now());
|
||||
const point = {
|
||||
record,
|
||||
graphic,
|
||||
position: geo.cartesian3.clone(p ?? geo.cartesian3.ZERO),
|
||||
color: new Float64Array(
|
||||
rgba(read(graphic.color, engine.clock.now())),
|
||||
),
|
||||
show: false,
|
||||
gevBank: null,
|
||||
gevIndex: -1,
|
||||
};
|
||||
record.points.push(point);
|
||||
pointBank(record, graphic, name === 'billboard').add(point);
|
||||
}
|
||||
if (spec.polyline) {
|
||||
const material = read(entity.polyline.material, engine.clock.now());
|
||||
const color = material?.kind ? material.color : material;
|
||||
const dash =
|
||||
material?.kind === 'tactical'
|
||||
? 'tactical'
|
||||
: material?.kind === 'dash'
|
||||
? {
|
||||
lengthPx:
|
||||
material.dashLength /
|
||||
(material.dashPattern === 0xaaaa ? 16 : 4),
|
||||
gapPx:
|
||||
material.dashLength /
|
||||
(material.dashPattern === 0xaaaa ? 16 : 4),
|
||||
}
|
||||
: null;
|
||||
record.line = engine.lines.createLine({
|
||||
id: `launch-entity-${++sequence}`,
|
||||
pickId: entity.id,
|
||||
widthPx: read(entity.polyline.width, engine.clock.now()) ?? 2,
|
||||
color: rgba(color),
|
||||
dash,
|
||||
arc: 'none',
|
||||
});
|
||||
record.line.setPositions(
|
||||
read(entity.polyline.positions, engine.clock.now()),
|
||||
);
|
||||
}
|
||||
syncEntity(record);
|
||||
return entity;
|
||||
};
|
||||
const createDataSource = () => {
|
||||
let visible = false;
|
||||
const source = {
|
||||
records: new Map(),
|
||||
entitiesArray: [],
|
||||
get show() {
|
||||
return visible;
|
||||
},
|
||||
set show(value) {
|
||||
visible = Boolean(value);
|
||||
sync();
|
||||
},
|
||||
entities: {},
|
||||
};
|
||||
Object.defineProperty(source.entities, 'values', {
|
||||
get: () => source.entitiesArray,
|
||||
});
|
||||
source.entities.getById = (id) => source.records.get(id)?.entity;
|
||||
source.entities.removeAll = () => clearSource(source);
|
||||
sources.add(source);
|
||||
return source;
|
||||
};
|
||||
const createPad = (launch) => {
|
||||
// Classified onto the surface under the pad as Cesium's GroundPrimitive
|
||||
// (render/navaraPadGround.js); nothing is drawn at a height.
|
||||
const ground =
|
||||
nativeView?.registerEffect && EffectDesc && NavaraEffect
|
||||
? createPadGround(nativeView, { EffectDesc, NavaraEffect }, launch)
|
||||
: null;
|
||||
let dead = false,
|
||||
shown = true;
|
||||
engine.render.request('launch-pad');
|
||||
const pad = {
|
||||
/** Classified, as on Cesium: the zone has no height of its own. */
|
||||
heightM: null,
|
||||
get show() {
|
||||
return shown;
|
||||
},
|
||||
set show(value) {
|
||||
if (shown === Boolean(value)) return;
|
||||
shown = Boolean(value);
|
||||
if (ground) ground.visible = shown;
|
||||
engine.render.request('launch-pad');
|
||||
},
|
||||
destroy() {
|
||||
if (dead) return;
|
||||
dead = true;
|
||||
ground?.destroy();
|
||||
primitives.delete(pad);
|
||||
engine.render.request('launch-pad');
|
||||
},
|
||||
};
|
||||
primitives.add(pad);
|
||||
return pad;
|
||||
};
|
||||
const listenClick = (source, listener, { enabled = () => true } = {}) => {
|
||||
const remove = selectionDispatcherFor(engine).register('rocket-launches', {
|
||||
owns: (id) => source.records.has(id),
|
||||
onClick: (event) => {
|
||||
if (!enabled()) return;
|
||||
const entity = [event.picked, ...event.drilled]
|
||||
.map((p) => source.entities.getById(p?.id))
|
||||
.find(Boolean);
|
||||
listener(entity);
|
||||
},
|
||||
});
|
||||
return { destroy: remove };
|
||||
};
|
||||
return {
|
||||
kind: 'navara',
|
||||
engine,
|
||||
view,
|
||||
time: (epoch) => epoch,
|
||||
occluder: () => ({
|
||||
cameraPosition: null,
|
||||
isPointVisible: (p) => engine.projector.horizonVisible(p),
|
||||
}),
|
||||
callback: (fn) => ({ getValue: fn }),
|
||||
range: (heading, pitch, range) => ({ heading, pitch, range }),
|
||||
easing: undefined,
|
||||
createDataSource,
|
||||
addEntity,
|
||||
removeDataSource(source) {
|
||||
clearSource(source);
|
||||
sources.delete(source);
|
||||
sync();
|
||||
},
|
||||
dash: (options) => ({ kind: 'dash', ...options }),
|
||||
patternMaterial: (color) => ({ kind: 'tactical', color }),
|
||||
createOrbit({ positions, width, color }) {
|
||||
const line = engine.lines.createLine({
|
||||
id: `launch-orbit-${++sequence}`,
|
||||
widthPx: width,
|
||||
color: rgba(color),
|
||||
dash: 'tactical',
|
||||
arc: 'none',
|
||||
});
|
||||
line.setPositions(positions);
|
||||
let visible = true;
|
||||
const primitive = {
|
||||
modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY),
|
||||
get show() {
|
||||
return visible;
|
||||
},
|
||||
set show(value) {
|
||||
if (visible === Boolean(value)) return;
|
||||
visible = Boolean(value);
|
||||
line.show(visible);
|
||||
},
|
||||
destroy() {
|
||||
line.destroy();
|
||||
primitives.delete(primitive);
|
||||
},
|
||||
line,
|
||||
};
|
||||
primitives.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
commitOrbit: (primitive) =>
|
||||
primitive.line.setModelMatrix(primitive.modelMatrix),
|
||||
createPad,
|
||||
removePrimitive: (primitive) => primitive.destroy(),
|
||||
project: (p, r) => engine.projector.worldToWindow(p, r),
|
||||
on: (event, listener) => view.on(event, listener),
|
||||
listenClick,
|
||||
listenMove: (source, listener, { enabled = () => true } = {}) => ({
|
||||
destroy: engine.input.on('move', (event) => {
|
||||
if (enabled())
|
||||
listener(
|
||||
engine.picker
|
||||
.drillPick(event.endPosition, 12)
|
||||
.map((p) => source.entities.getById(p.id))
|
||||
.find(Boolean),
|
||||
);
|
||||
}),
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
for (const source of sources) clearSource(source);
|
||||
sources.clear();
|
||||
for (const primitive of [...primitives]) primitive.destroy();
|
||||
for (const bank of banks.values()) bank.destroy();
|
||||
banks.clear();
|
||||
dirty.clear();
|
||||
unregister();
|
||||
adapter.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { WHITE } from '../../shared/color.js';
|
||||
import { PerspectiveCamera, RGBADepthPacking } from 'three';
|
||||
import { createLaunchesNavaraRenderer } from './navara.js';
|
||||
import { PAD_GROUND_KEY, padGroundShader } from './navaraPadGround.js';
|
||||
import { LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
||||
|
||||
const position = geo.cartesian3.fromDegrees(0, 0, 1000);
|
||||
test('mission bank growth, callbacks and source visibility write actual mover stores', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createLaunchesNavaraRenderer(engine);
|
||||
const source = renderer.createDataSource();
|
||||
let sample = position;
|
||||
for (let i = 0; i < 1100; i++)
|
||||
renderer.addEntity(source, {
|
||||
id: `rocket-launch:${i}`,
|
||||
position: renderer.callback(() => sample),
|
||||
point: { pixelSize: 6, color: WHITE, show: true },
|
||||
});
|
||||
const values = source.entities.values;
|
||||
source.show = true;
|
||||
await Promise.resolve();
|
||||
const listed = engine.movers.list();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].count, 1100);
|
||||
const set = engine.fake.moverSet(listed[0].id);
|
||||
assert.equal(set.store.pickIds[1000], 'rocket-launch:1000');
|
||||
const remove = renderer.on('preRender', () => {});
|
||||
sample = { x: 1, y: 2, z: 3 };
|
||||
engine.fake.frame();
|
||||
assert.deepEqual([...set.store.positions.slice(0, 3)], [1, 2, 3]);
|
||||
assert.equal(source.entities.values, values);
|
||||
source.entities.getById('rocket-launch:0').point.show = false;
|
||||
await Promise.resolve();
|
||||
assert.equal(set.store.shows[0], 0);
|
||||
source.show = false;
|
||||
await Promise.resolve();
|
||||
assert.equal(engine.movers.list()[0].count, 0);
|
||||
const commits = engine.fake.moverSet(listed[0].id).commits;
|
||||
sample = { x: 7, y: 8, z: 9 };
|
||||
engine.fake.resetCalls();
|
||||
engine.fake.frames(3);
|
||||
assert.equal(engine.fake.moverSet(listed[0].id).commits, commits);
|
||||
assert.equal(
|
||||
engine.fake.renderRequests,
|
||||
0,
|
||||
'hidden motion must not sustain rendering',
|
||||
);
|
||||
source.show = true;
|
||||
await Promise.resolve();
|
||||
assert.equal(engine.movers.list()[0].count, 1100);
|
||||
source.entities.removeAll();
|
||||
await Promise.resolve();
|
||||
assert.equal(values.length, 0);
|
||||
assert.equal(engine.movers.list()[0].count, 0);
|
||||
remove();
|
||||
renderer.destroy();
|
||||
assert.equal(engine.movers.list().length, 0);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('mission lines retain dash styles and orbit matrices and release on removal', () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createLaunchesNavaraRenderer(engine);
|
||||
const source = renderer.createDataSource();
|
||||
source.show = true;
|
||||
const entity = renderer.addEntity(source, {
|
||||
id: 'rocket-transfer:x',
|
||||
polyline: {
|
||||
positions: [position, { x: 0, y: 6778137, z: 0 }],
|
||||
width: 2,
|
||||
material: renderer.dash({
|
||||
color: WHITE,
|
||||
dashLength: 24,
|
||||
dashPattern: 0xf0f0,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const first = engine.lines.list()[0];
|
||||
assert.deepEqual(engine.fake.line(first.id).options.dash, {
|
||||
kind: 'pixels',
|
||||
lengthPx: 6,
|
||||
gapPx: 6,
|
||||
});
|
||||
entity.show = false;
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
const orbit = renderer.createOrbit({
|
||||
positions: [position, { x: 0, y: 6778137, z: 0 }],
|
||||
width: 3,
|
||||
color: WHITE,
|
||||
});
|
||||
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
|
||||
orbit.modelMatrix[12] = 42;
|
||||
renderer.commitOrbit(orbit);
|
||||
assert.equal(engine.fake.line(engine.lines.list()[1].id).modelMatrix[12], 42);
|
||||
renderer.removePrimitive(orbit);
|
||||
renderer.removeDataSource(source);
|
||||
assert.equal(engine.lines.list().length, 0);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('mission hover uses engine drillPick and obeys disabled state', () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createLaunchesNavaraRenderer(engine);
|
||||
const source = renderer.createDataSource();
|
||||
const entity = renderer.addEntity(source, {
|
||||
id: 'rocket-launch:x',
|
||||
position,
|
||||
point: { pixelSize: 6, color: WHITE },
|
||||
});
|
||||
engine.fake.script('drillPick', [{ id: entity.id }]);
|
||||
let enabled = true,
|
||||
hovered = null;
|
||||
const handler = renderer.listenMove(
|
||||
source,
|
||||
(e) => {
|
||||
hovered = e;
|
||||
},
|
||||
{ enabled: () => enabled },
|
||||
);
|
||||
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
|
||||
assert.equal(hovered, entity);
|
||||
enabled = false;
|
||||
hovered = null;
|
||||
engine.fake.input('move', { endPosition: { x: 1, y: 2 } });
|
||||
assert.equal(hovered, null);
|
||||
handler.destroy();
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test("the pad zone is classified onto the surface with Cesium's fill, rim and emission", () => {
|
||||
const engine = createFakeEngine();
|
||||
engine.fake.script('sampleHeight', 125);
|
||||
const effects = [];
|
||||
let Desc;
|
||||
class EffectDesc {
|
||||
constructor(view, ctx, config) {
|
||||
this.view = view;
|
||||
this.ctx = ctx;
|
||||
this.config = config;
|
||||
}
|
||||
find() {
|
||||
return mrt;
|
||||
}
|
||||
onDestroy() {}
|
||||
}
|
||||
class NavaraEffect {
|
||||
constructor(camera, effect) {
|
||||
this.effect = effect;
|
||||
}
|
||||
}
|
||||
const mrt = {
|
||||
depthBuffer: { isTexture: true },
|
||||
depthBufferPacking: RGBADepthPacking,
|
||||
};
|
||||
const camera = new PerspectiveCamera();
|
||||
camera.position.set(6378137 + 2000, 0, 0);
|
||||
camera.updateMatrixWorld();
|
||||
const view = {
|
||||
camera: { raw: camera },
|
||||
registerEffect(key, desc) {
|
||||
assert.equal(key, PAD_GROUND_KEY);
|
||||
assert.ok(desc.insertAfter.includes('toneMapping'));
|
||||
Desc = desc;
|
||||
},
|
||||
addEffect(config) {
|
||||
const desc = new Desc(
|
||||
view,
|
||||
{ getRenderTarget: () => ({ depthTexture: {} }) },
|
||||
config,
|
||||
);
|
||||
desc.createPass();
|
||||
const record = { desc, visible: config.visible, deleted: false };
|
||||
effects.push(record);
|
||||
return {
|
||||
update: ({ visible }) => (record.visible = visible),
|
||||
delete: () => {
|
||||
record.deleted = true;
|
||||
desc.onDestroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const renderer = createLaunchesNavaraRenderer(engine, {
|
||||
view,
|
||||
EffectDesc,
|
||||
NavaraEffect,
|
||||
});
|
||||
const pad = renderer.createPad({ id: 'x', lon: 0, lat: 0 });
|
||||
assert.equal(
|
||||
engine.fake.callsOf('surface.sampleHeight').length,
|
||||
0,
|
||||
'classified onto the surface: no coarse tile height is sampled',
|
||||
);
|
||||
assert.equal(pad.heightM, null, 'no height of its own, as on Cesium');
|
||||
assert.equal(effects.length, 1);
|
||||
const { desc } = effects[0];
|
||||
desc.updateFrame();
|
||||
const u = desc.effect.uniforms;
|
||||
// The pad 2 km below the camera, the ENU axes of lon 0, lat 0.
|
||||
assert.deepEqual(u.get('uPadOffset').value.toArray(), [-2000, 0, 0]);
|
||||
assert.deepEqual(u.get('uUp').value.toArray(), [1, 0, 0]);
|
||||
assert.deepEqual(
|
||||
u
|
||||
.get('uEast')
|
||||
.value.toArray()
|
||||
.map((v) => Math.round(v) + 0),
|
||||
[0, 1, 0],
|
||||
);
|
||||
assert.deepEqual(
|
||||
u
|
||||
.get('uNorth')
|
||||
.value.toArray()
|
||||
.map((v) => Math.round(v) + 0),
|
||||
[0, 0, 1],
|
||||
);
|
||||
assert.equal(u.get('tAllDepth').value, mrt.depthBuffer);
|
||||
// Cesium's GevLaunchPadZone material, from the same style.
|
||||
const rgb = u
|
||||
.get('uPadColor')
|
||||
.value.toArray()
|
||||
.map((v) => Math.round(v * 255));
|
||||
assert.deepEqual(rgb, [0x22, 0xe6, 0xe6]);
|
||||
for (const part of [
|
||||
`smoothstep(${LAUNCH_PAD_ZONE_STYLE.rimEnd.toFixed(4)},1.0,radius)`,
|
||||
`smoothstep(${LAUNCH_PAD_ZONE_STYLE.rimStart.toFixed(4)},${LAUNCH_PAD_ZONE_STYLE.rimEnd.toFixed(4)},radius)`,
|
||||
`mix(${LAUNCH_PAD_ZONE_STYLE.fillAlpha.toFixed(4)},${LAUNCH_PAD_ZONE_STYLE.rimAlpha.toFixed(4)},rim)`,
|
||||
`(1.0+${LAUNCH_PAD_ZONE_STYLE.emission.toFixed(4)}*rim)`,
|
||||
'/500.0000',
|
||||
'if(depth<surface-2e-7) return;',
|
||||
])
|
||||
assert.ok(padGroundShader.includes(part), part);
|
||||
pad.show = false;
|
||||
assert.equal(effects[0].visible, false);
|
||||
pad.show = true;
|
||||
assert.equal(effects[0].visible, true);
|
||||
pad.destroy();
|
||||
pad.destroy();
|
||||
assert.equal(effects[0].deleted, true);
|
||||
assert.equal(effects.filter((e) => e.deleted).length, 1);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
// The launch pad zone on Navara, classified onto the surface as Cesium's
|
||||
// GroundPrimitive does (render/cesium.js): a screen-space pass reads the depth
|
||||
// of each pixel, puts it back in the world and paints the pixels of the
|
||||
// ground (terrain or 3D tiles, not a model in front of them) that lie within
|
||||
// the zone's radius of the pad, with Cesium's fill, rim and emission. Nothing
|
||||
// is drawn at a height, so the zone neither floats over nor sinks under the
|
||||
// ground, and no height is sampled.
|
||||
import { Matrix3, Uniform, Vector3 } from 'three';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import {
|
||||
groundSurfaceUniforms,
|
||||
updateGroundSurface,
|
||||
} from '../../shared/render/navaraGroundSurface.js';
|
||||
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { LAUNCH_PAD_ZONE_RADIUS_M, LAUNCH_PAD_ZONE_STYLE } from '../policy.js';
|
||||
|
||||
export const PAD_GROUND_KEY = 'gevLaunchPadGround';
|
||||
// The surface above or below the pad that the zone still classifies, as
|
||||
// Cesium's classification volume spans the terrain heights there.
|
||||
const PAD_GROUND_BAND_M = 9000;
|
||||
const glslFloat = (value) => Number(value).toFixed(4);
|
||||
|
||||
export const padGroundShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform float uLogDepthFC;
|
||||
uniform mat3 uViewToWorld;
|
||||
uniform vec3 uPadOffset;
|
||||
uniform vec3 uEast;
|
||||
uniform vec3 uNorth;
|
||||
uniform vec3 uUp;
|
||||
uniform vec3 uPadColor;
|
||||
vec3 toDisplay(vec3 c) {
|
||||
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||
}
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||
outputColor=inputColor;
|
||||
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
|
||||
if(depth>=1.0) return;
|
||||
// Only the ground: a mesh or model in front of it keeps its own colour.
|
||||
float surface=texture2D(tSurfaceDepth,uv).r;
|
||||
if(depth<surface-2e-7) return;
|
||||
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
|
||||
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
|
||||
vec3 point=ray.xyz*(distance/-ray.z);
|
||||
// Relative to the pad, from offsets small enough for single precision.
|
||||
vec3 rel=uViewToWorld*point-uPadOffset;
|
||||
if(abs(dot(rel,uUp))>${glslFloat(PAD_GROUND_BAND_M)}) return;
|
||||
float radius=length(vec2(dot(rel,uEast),dot(rel,uNorth)))/${glslFloat(LAUNCH_PAD_ZONE_RADIUS_M)};
|
||||
if(radius>=1.0) return;
|
||||
// Cesium's GevLaunchPadZone material, blended as in its framebuffer.
|
||||
float inside=1.0-smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)},1.0,radius);
|
||||
float rim=smoothstep(${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimStart)},${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimEnd)},radius)*inside;
|
||||
float alpha=inside*mix(${glslFloat(LAUNCH_PAD_ZONE_STYLE.fillAlpha)},${glslFloat(LAUNCH_PAD_ZONE_STYLE.rimAlpha)},rim);
|
||||
vec3 color=min(uPadColor*(1.0+${glslFloat(LAUNCH_PAD_ZONE_STYLE.emission)}*rim),vec3(1.0));
|
||||
outputColor=vec4(toLinear(mix(toDisplay(inputColor.rgb),color,alpha)),inputColor.a);
|
||||
}
|
||||
`;
|
||||
|
||||
/** The pad's ECEF position and its east, north and up axes. */
|
||||
function padFrame(launch) {
|
||||
const m = geo.transforms.eastNorthUpToFixedFrame(
|
||||
geo.cartesian3.fromDegrees(launch.lon, launch.lat, 0),
|
||||
geo.ellipsoid.WGS84,
|
||||
);
|
||||
return {
|
||||
origin: [m[12], m[13], m[14]],
|
||||
east: [m[0], m[1], m[2]],
|
||||
north: [m[4], m[5], m[6]],
|
||||
up: [m[8], m[9], m[10]],
|
||||
};
|
||||
}
|
||||
|
||||
const padGroundEffect = defineGroundEffect({
|
||||
key: PAD_GROUND_KEY,
|
||||
name: 'GevLaunchPadGround',
|
||||
fragmentShader: padGroundShader,
|
||||
insertAfter: [
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevRecentImagery',
|
||||
'gevFirmsGround',
|
||||
'toneMapping',
|
||||
],
|
||||
insertBefore: [
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
],
|
||||
uniforms: () => [
|
||||
...groundSurfaceUniforms(),
|
||||
['uViewToWorld', new Uniform(new Matrix3())],
|
||||
['uPadOffset', new Uniform(new Vector3())],
|
||||
['uEast', new Uniform(new Vector3())],
|
||||
['uNorth', new Uniform(new Vector3())],
|
||||
['uUp', new Uniform(new Vector3())],
|
||||
[
|
||||
'uPadColor',
|
||||
// Display (sRGB) values, as Cesium's material reads the colour.
|
||||
new Uniform(
|
||||
new Vector3(...rgbaFromCss(LAUNCH_PAD_ZONE_STYLE.color).rgba()),
|
||||
),
|
||||
],
|
||||
],
|
||||
update() {
|
||||
const frame = this.owner.frame,
|
||||
camera = this.view.camera.raw;
|
||||
if (!this.effect || !frame || !camera) return;
|
||||
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||
const u = this.effect.uniforms;
|
||||
u.get('uViewToWorld').value.setFromMatrix4(camera.matrixWorld);
|
||||
const e = camera.matrixWorld.elements;
|
||||
u.get('uPadOffset').value.set(
|
||||
frame.origin[0] - e[12],
|
||||
frame.origin[1] - e[13],
|
||||
frame.origin[2] - e[14],
|
||||
);
|
||||
u.get('uEast').value.fromArray(frame.east);
|
||||
u.get('uNorth').value.fromArray(frame.north);
|
||||
u.get('uUp').value.fromArray(frame.up);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Show one pad zone on a Navara view; `show` toggles it, `destroy` removes it.
|
||||
* @param {object} view The Navara view.
|
||||
* @param {{ EffectDesc: Function, NavaraEffect: Function }} sdk
|
||||
* @param {{ lon: number, lat: number }} launch
|
||||
*/
|
||||
export function createPadGround(view, { EffectDesc, NavaraEffect }, launch) {
|
||||
padGroundEffect.register(view, { EffectDesc, NavaraEffect });
|
||||
const owner = { frame: padFrame(launch) };
|
||||
let handle = view.addEffect({
|
||||
[PAD_GROUND_KEY]: { owner },
|
||||
visible: true,
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
set visible(value) {
|
||||
handle?.update({ visible: Boolean(value) });
|
||||
},
|
||||
destroy() {
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
owner.frame = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
+171
-107
@@ -8,6 +8,13 @@ import {
|
||||
STAGE_REENTRY_ALTITUDE_M,
|
||||
} from './policy.js';
|
||||
|
||||
// Cesium's PolylineDash patterns of the mission paths as the contract's
|
||||
// dashes: dashLength 12 px with 0xaaaa is 0.75 px on, 0.75 px off; 8 px and
|
||||
// 24 px with 0xf0f0 are 2 and 6 px on, as much off.
|
||||
const RECOVERY_DASH = Object.freeze({ lengthPx: 0.75, gapPx: 0.75 });
|
||||
const REENTRY_DASH = Object.freeze({ lengthPx: 2, gapPx: 2 });
|
||||
const TRANSFER_DASH = Object.freeze({ lengthPx: 6, gapPx: 6 });
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -18,60 +25,139 @@ export function createRendering({
|
||||
const { getSatelliteOrbitTrack, findSatelliteOrbitTrackInTle } =
|
||||
services.satellites;
|
||||
|
||||
function setGraphicVisibility(graphic, visible, time) {
|
||||
if (!graphic) return;
|
||||
const next = Boolean(visible);
|
||||
const current = graphic.show?.getValue?.(time);
|
||||
if (current !== next) graphic.show = next;
|
||||
/** An element's position now (a function position is read). */
|
||||
function elementPosition(element) {
|
||||
return typeof element?.position === 'function'
|
||||
? element.position()
|
||||
: element?.position;
|
||||
}
|
||||
|
||||
/** Draws an element's graphics as its own and its graphics' flags say. */
|
||||
function syncElement(element) {
|
||||
if (element.point) element.point.show = element.shown && element.pointShown;
|
||||
if (element.reticle)
|
||||
element.reticle.show = element.shown && element.reticleShown;
|
||||
if (element.line) element.line.show = element.shown;
|
||||
}
|
||||
|
||||
/** Selection isolation: whether the element belongs in the view. */
|
||||
function setElementShown(element, shown) {
|
||||
const next = Boolean(shown);
|
||||
if (element.shown === next) return;
|
||||
element.shown = next;
|
||||
syncElement(element);
|
||||
}
|
||||
|
||||
/**
|
||||
* One mission element (a launch site, path, payload or vehicle) the layer
|
||||
* records and the display draws: `point` { pixelSize, color },
|
||||
* `reticle` (the hover reticle), `polyline` { positions, width, color,
|
||||
* dash, arc }.
|
||||
*/
|
||||
function addMissionElement({
|
||||
id,
|
||||
position,
|
||||
point,
|
||||
reticle,
|
||||
polyline,
|
||||
properties,
|
||||
}) {
|
||||
const display = getRenderer();
|
||||
const element = {
|
||||
id,
|
||||
launchId: parts.selection.entityLaunchId(id),
|
||||
position,
|
||||
positions: polyline?.positions ?? null,
|
||||
properties,
|
||||
shown: true,
|
||||
pointShown: true,
|
||||
reticleShown: false,
|
||||
pointStyle: point ?? null,
|
||||
point: null,
|
||||
reticle: null,
|
||||
line: null,
|
||||
};
|
||||
if (point)
|
||||
element.point = display.addPoint({
|
||||
id,
|
||||
position,
|
||||
sizePx: point.pixelSize,
|
||||
color: point.color,
|
||||
});
|
||||
if (reticle)
|
||||
element.reticle = display.addReticle({
|
||||
id,
|
||||
position,
|
||||
image: parts.overlays.missionHoverReticleImage(),
|
||||
});
|
||||
if (polyline)
|
||||
element.line = display.addLine({
|
||||
id,
|
||||
pickId: id,
|
||||
positions: polyline.positions,
|
||||
widthPx: polyline.width,
|
||||
color: polyline.color,
|
||||
dash: polyline.dash,
|
||||
arc: polyline.arc,
|
||||
});
|
||||
layerState._missionElements.set(id, element);
|
||||
syncElement(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Removes every mission element and what the display draws for it. */
|
||||
function removeMissionElements() {
|
||||
for (const element of layerState._missionElements.values()) {
|
||||
element.point?.remove();
|
||||
element.reticle?.remove();
|
||||
element.line?.remove();
|
||||
}
|
||||
layerState._missionElements.clear();
|
||||
}
|
||||
|
||||
function updateMissionFrame() {
|
||||
if (
|
||||
!layerState._enabled ||
|
||||
!layerState._viewer ||
|
||||
!layerState._dataSource?.show
|
||||
) {
|
||||
const display = getRenderer();
|
||||
if (!layerState._enabled || !display?.visible()) {
|
||||
parts.overlays.hideReplayVehicleOverlay();
|
||||
return;
|
||||
}
|
||||
parts.panel.updateMissionTelemetry();
|
||||
// The app clock's now, as the JulianDate Cesium's entity properties take.
|
||||
const time = getRenderer().time(appClock.now());
|
||||
layerState._declutterTime = time;
|
||||
layerState._declutterOccluder.cameraPosition =
|
||||
layerState._viewer.scene.camera.positionWC;
|
||||
parts.overlays.updateReplayVehicleOverlay(layerState._declutterOccluder);
|
||||
const { occluder } = display;
|
||||
const cameraPosition = layerState._engine.camera.livePosition;
|
||||
parts.overlays.updateReplayVehicleOverlay(occluder);
|
||||
parts.overlays.refreshSelectedMissionOverlayText();
|
||||
for (const entity of layerState._dataSource.entities.values) {
|
||||
if (!entity.show) continue;
|
||||
if (!entity.position) continue;
|
||||
const id = parts.selection.entityLaunchId(entity);
|
||||
for (const element of layerState._missionElements.values()) {
|
||||
if (!element.shown) continue;
|
||||
if (!element.position) continue;
|
||||
const id = element.launchId;
|
||||
if (!id) continue;
|
||||
const position = entity.position.getValue(time);
|
||||
const position = elementPosition(element);
|
||||
let horizonVisible = Boolean(
|
||||
position && layerState._declutterOccluder.isPointVisible(position),
|
||||
position && occluder.isPointVisible(position),
|
||||
);
|
||||
const launchAnchor = entity.id.startsWith('rocket-launch:');
|
||||
const launchAnchor = element.id.startsWith('rocket-launch:');
|
||||
if (launchAnchor) {
|
||||
horizonVisible = parts.overlays.missionAnchorVisible(
|
||||
layerState._viewer.scene.camera.positionWC,
|
||||
cameraPosition,
|
||||
position,
|
||||
id,
|
||||
layerState._selectedLaunchId,
|
||||
);
|
||||
// Keep entity.show reserved for mission-selection isolation. Horizon
|
||||
// Keep `shown` reserved for mission-selection isolation. Horizon
|
||||
// state is applied to the individual graphics below so a rear-side
|
||||
// anchor remains eligible for reevaluation as the camera moves.
|
||||
}
|
||||
if (entity.point) {
|
||||
setGraphicVisibility(entity.point, horizonVisible, time);
|
||||
if (element.point && element.pointShown !== horizonVisible) {
|
||||
element.pointShown = horizonVisible;
|
||||
syncElement(element);
|
||||
}
|
||||
if (launchAnchor && entity.billboard) {
|
||||
setGraphicVisibility(
|
||||
entity.billboard,
|
||||
horizonVisible && id === layerState._hoveredRosterLaunchId,
|
||||
time,
|
||||
);
|
||||
if (launchAnchor && element.reticle) {
|
||||
const reticleShown =
|
||||
horizonVisible && id === layerState._hoveredRosterLaunchId;
|
||||
if (element.reticleShown !== reticleShown) {
|
||||
element.reticleShown = reticleShown;
|
||||
syncElement(element);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,7 +191,10 @@ export function createRendering({
|
||||
return solution;
|
||||
}
|
||||
|
||||
function addLaunchEntity(launch, activeTleText = layerState._activeTleText) {
|
||||
function addLaunchElements(
|
||||
launch,
|
||||
activeTleText = layerState._activeTleText,
|
||||
) {
|
||||
const position = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
const overlayRecord = {
|
||||
launch,
|
||||
@@ -130,25 +219,14 @@ export function createRendering({
|
||||
);
|
||||
});
|
||||
if (satelliteTrack) layerState._orbitMatches++;
|
||||
const entity = getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-launch:${launch.id}`,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 5,
|
||||
color: parts.model.missionMarkerColor(launch),
|
||||
outlineColor: parts.model.missionMarkerColor(launch),
|
||||
outlineWidth: 0,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
billboard: {
|
||||
image: parts.overlays.missionHoverReticleImage(),
|
||||
width: 24,
|
||||
height: 24,
|
||||
show: false,
|
||||
horizontalOrigin: 0,
|
||||
verticalOrigin: 0,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
reticle: true,
|
||||
properties: {
|
||||
launchId: launch.id,
|
||||
launchName: launch.name,
|
||||
@@ -163,10 +241,6 @@ export function createRendering({
|
||||
noradId: satelliteTrack?.noradId || '',
|
||||
},
|
||||
});
|
||||
// Publish the same anchor representation held by the active renderer.
|
||||
overlayRecord.anchorPosition = entity.position.getValue(
|
||||
getRenderer().time(appClock.now()),
|
||||
);
|
||||
const points = launch.trajectory
|
||||
.filter(
|
||||
(point) =>
|
||||
@@ -197,15 +271,14 @@ export function createRendering({
|
||||
});
|
||||
segments.forEach((segment, index) => {
|
||||
if (segment.positions.length < 2) return;
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-trajectory:${launch.id}:${index}`,
|
||||
polyline: {
|
||||
positions: parts.paths.surfaceSafePath(segment.positions),
|
||||
width: 2,
|
||||
material: rgbaFromCss(
|
||||
color: rgbaFromCss(
|
||||
TRAJECTORY_STAGE_COLORS[index % TRAJECTORY_STAGE_COLORS.length],
|
||||
).withAlpha(0.8),
|
||||
clampToGround: false,
|
||||
arc: 'none',
|
||||
},
|
||||
properties: {
|
||||
@@ -277,8 +350,8 @@ export function createRendering({
|
||||
launch,
|
||||
ascentPath,
|
||||
);
|
||||
// Cesium can evaluate CallbackProperty values multiple times while it
|
||||
// traverses one frame. Camera tracking and the HTML replay overlay must
|
||||
// A frame can read the replay position several times (the camera, the
|
||||
// overlay, the vehicle element). Camera tracking and the HTML replay overlay must
|
||||
// use the exact same replay epoch as those callbacks; sampling appClock.now()
|
||||
// independently made the vehicle advance relative to the camera and then
|
||||
// snap back on the next frame.
|
||||
@@ -295,10 +368,8 @@ export function createRendering({
|
||||
REPLAY_TILE_SETTLE_DELAY_SEC,
|
||||
layerState._replayCameraLaunchId !== launch.id,
|
||||
);
|
||||
// preUpdate runs before Cesium evaluates entity CallbackProperties. Seed
|
||||
// one state there and retain it through postRender so every consumer in
|
||||
// that traversal sees the same timestamp. scene.frameState.frameNumber is
|
||||
// advanced between some of those phases, so it is not a reliable cache key.
|
||||
// The replay camera seeds one state in preUpdate and every consumer of
|
||||
// that frame, through postRender, sees the same timestamp.
|
||||
const beginReplayFrame = () => {
|
||||
replayStateForFrame = sampleReplayState();
|
||||
return replayStateForFrame;
|
||||
@@ -329,16 +400,13 @@ export function createRendering({
|
||||
(stageIndex + 1) % TRAJECTORY_STAGE_COLORS.length
|
||||
],
|
||||
);
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-reentry-recovery:${launch.id}:${stageIndex}`,
|
||||
polyline: {
|
||||
positions: path,
|
||||
width: 2,
|
||||
material: getRenderer().dash({
|
||||
color: color.withAlpha(0.82),
|
||||
dashLength: 12,
|
||||
dashPattern: 0xaaaa,
|
||||
}),
|
||||
color: color.withAlpha(0.82),
|
||||
dash: RECOVERY_DASH,
|
||||
arc: 'none',
|
||||
},
|
||||
properties: {
|
||||
@@ -350,16 +418,13 @@ export function createRendering({
|
||||
},
|
||||
});
|
||||
if (reentryPath.length > 1) {
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-reentry:${launch.id}:${stageIndex}`,
|
||||
polyline: {
|
||||
positions: reentryPath,
|
||||
width: 2.5,
|
||||
material: getRenderer().dash({
|
||||
color: rgbaFromCss('#ffd166').withAlpha(0.9),
|
||||
dashLength: 8,
|
||||
dashPattern: 0xf0f0,
|
||||
}),
|
||||
color: rgbaFromCss('#ffd166').withAlpha(0.9),
|
||||
dash: REENTRY_DASH,
|
||||
arc: 'none',
|
||||
},
|
||||
properties: {
|
||||
@@ -382,15 +447,10 @@ export function createRendering({
|
||||
}),
|
||||
);
|
||||
}
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-recovery-end:${launch.id}:${stageIndex}`,
|
||||
position: path.at(-1),
|
||||
point: {
|
||||
pixelSize: 4,
|
||||
color,
|
||||
outlineWidth: 0,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
point: { pixelSize: 4, color },
|
||||
properties: {
|
||||
launchId: launch.id,
|
||||
stageId: stage.id,
|
||||
@@ -424,9 +484,10 @@ export function createRendering({
|
||||
let liveFrameNumber = -1;
|
||||
const fallbackPeriodSec =
|
||||
parts.paths.estimatedOrbitPeriodSeconds(orbitPath);
|
||||
// At most once per engine frame: every reader in a frame (the dot,
|
||||
// the panel, the label) sees one propagation.
|
||||
const updateLiveState = () => {
|
||||
const frameNumber =
|
||||
layerState._viewer?.scene?.frameState?.frameNumber ?? -1;
|
||||
const frameNumber = getRenderer()?.frameNumber() ?? -1;
|
||||
if (frameNumber !== -1 && frameNumber === liveFrameNumber) return;
|
||||
liveFrameNumber = frameNumber;
|
||||
const nowMs = appClock.now();
|
||||
@@ -457,17 +518,15 @@ export function createRendering({
|
||||
) || livePosition;
|
||||
}
|
||||
};
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-satellite:${launch.id}`,
|
||||
position: getRenderer().callback(() => {
|
||||
position: () => {
|
||||
updateLiveState();
|
||||
return livePosition;
|
||||
}, false),
|
||||
},
|
||||
point: {
|
||||
pixelSize: 6,
|
||||
color: rgbaFromCss(satelliteTrack ? '#7bed9f' : '#ffd166'),
|
||||
outlineWidth: 0,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
properties: {
|
||||
launchId: launch.id,
|
||||
@@ -496,25 +555,24 @@ export function createRendering({
|
||||
gapPx: 12,
|
||||
});
|
||||
});
|
||||
// Live satellite rings use a primitive collection so the core Satellite
|
||||
// GMST transform can rotate their baked ECEF geometry without rebuilding
|
||||
// it. Estimated launch-site-relative rings remain ordinary entities.
|
||||
// Live satellite rings are lines with a model matrix so the core
|
||||
// Satellite GMST transform can rotate their baked ECEF geometry without
|
||||
// rebuilding it. Estimated launch-site-relative rings are fixed.
|
||||
const primitiveOrbitAdded = parts.orbitRendering.addMissionOrbitPrimitive(
|
||||
launch,
|
||||
orbitPath,
|
||||
satelliteTrack,
|
||||
);
|
||||
if (!primitiveOrbitAdded) {
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-orbit:${launch.id}`,
|
||||
polyline: {
|
||||
positions: orbitPath,
|
||||
// The 3 px canvas gives the periodic dot room to read as round; the
|
||||
// shader masks the ten intervening dashes down to a thin center stroke.
|
||||
// pattern masks the ten intervening dashes down to a thin center stroke.
|
||||
width: 3,
|
||||
material: getRenderer().patternMaterial(
|
||||
rgbaFromCss('#c084fc').withAlpha(0.95),
|
||||
),
|
||||
color: rgbaFromCss('#c084fc').withAlpha(0.95),
|
||||
dash: 'tactical',
|
||||
arc: 'none',
|
||||
},
|
||||
properties: {
|
||||
@@ -531,7 +589,7 @@ export function createRendering({
|
||||
if (primitivePath) {
|
||||
primitivePath.labelBakePosition = orbitLabelBakePosition;
|
||||
primitivePath.labelPosition = geo.matrix4.multiplyByPoint(
|
||||
primitivePath.primitive.modelMatrix,
|
||||
primitivePath.modelMatrix,
|
||||
orbitLabelBakePosition,
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
@@ -547,13 +605,15 @@ export function createRendering({
|
||||
gapPx: 8,
|
||||
}),
|
||||
);
|
||||
const replayPosition = getRenderer().callback(() => {
|
||||
const replayPosition = () => {
|
||||
const state = getReplayState();
|
||||
return state.ascending
|
||||
? parts.paths.samplePath(ascentPath, state.phaseProgress)
|
||||
: parts.paths.samplePath(animatedOrbitPath, state.phaseProgress);
|
||||
}, false);
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
};
|
||||
// The replay vehicle draws as the screen overlay; its element has no
|
||||
// graphics.
|
||||
addMissionElement({
|
||||
id: `rocket-vehicle:${launch.id}`,
|
||||
position: replayPosition,
|
||||
properties: {
|
||||
@@ -565,20 +625,17 @@ export function createRendering({
|
||||
: 'Approximate trajectory animation',
|
||||
},
|
||||
});
|
||||
getRenderer().addEntity(layerState._dataSource, {
|
||||
addMissionElement({
|
||||
id: `rocket-transfer:${launch.id}`,
|
||||
polyline: {
|
||||
positions: ascentPath,
|
||||
width: 1.5,
|
||||
// The animated vehicle uses Cartesian interpolation between these
|
||||
// exact samples. Prevent Cesium from replacing each segment with a
|
||||
// geodesic arc, which would visually separate the dot from the line.
|
||||
// exact samples. Straight segments keep the line from separating
|
||||
// from it as geodesic arcs would.
|
||||
arc: 'none',
|
||||
material: getRenderer().dash({
|
||||
color: rgbaFromCss('#7bed9f').withAlpha(0.8),
|
||||
dashLength: 24,
|
||||
dashPattern: 0xf0f0,
|
||||
}),
|
||||
color: rgbaFromCss('#7bed9f').withAlpha(0.8),
|
||||
dash: TRANSFER_DASH,
|
||||
},
|
||||
properties: {
|
||||
launchId: launch.id,
|
||||
@@ -590,5 +647,12 @@ export function createRendering({
|
||||
});
|
||||
}
|
||||
}
|
||||
return { setGraphicVisibility, updateMissionFrame, addLaunchEntity };
|
||||
return {
|
||||
elementPosition,
|
||||
setElementShown,
|
||||
addMissionElement,
|
||||
removeMissionElements,
|
||||
updateMissionFrame,
|
||||
addLaunchElements,
|
||||
};
|
||||
}
|
||||
@@ -324,10 +324,10 @@ export function createReplay({
|
||||
// taking the camera.
|
||||
if (
|
||||
(stoppedLaunchId || !layerState._cameraReleased) &&
|
||||
layerState._viewer?.camera
|
||||
layerState._engine
|
||||
) {
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
|
||||
layerState._engine.camera.cancelFlight();
|
||||
layerState._engine.camera.release();
|
||||
}
|
||||
layerState._replayCameraLaunchId = null;
|
||||
layerState._replayPaused = false;
|
||||
@@ -344,7 +344,8 @@ export function createReplay({
|
||||
function startMissionReplay(launchId, { resuming = false } = {}) {
|
||||
const launch = layerState._launches.find((item) => item.id === launchId);
|
||||
const track = layerState._replayTracks.get(launchId);
|
||||
if (!layerState._viewer || !launch || !track) return false;
|
||||
const engine = layerState._engine;
|
||||
if (!engine || !launch || !track) return false;
|
||||
if (!resuming && layerState._replayCameraLaunchId === launchId) {
|
||||
stopMissionReplay();
|
||||
return false;
|
||||
@@ -371,8 +372,8 @@ export function createReplay({
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
if (!resuming) {
|
||||
parts.policyHelpers.releaseAircraftTracking(layerState._dataManager);
|
||||
layerState._viewer.trackedEntity = undefined;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
engine.camera.following()?.stop();
|
||||
engine.camera.cancelFlight();
|
||||
layerState._animationStarts.set(
|
||||
launchId,
|
||||
appClock.now() +
|
||||
@@ -383,10 +384,11 @@ export function createReplay({
|
||||
let lastCameraUpdateMs = null;
|
||||
|
||||
// Move the chase camera before scene traversal. Mutating it in preRender
|
||||
// makes Cesium discover a new view after 3D-tile refinement, which can cause
|
||||
// a self-sustaining refinement loop and visible stutter during slow replay.
|
||||
layerState._replayCameraRemover =
|
||||
layerState._viewer.scene.preUpdate.addEventListener(() => {
|
||||
// makes the engine discover a new view after 3D-tile refinement, which can
|
||||
// cause a self-sustaining refinement loop and visible stutter during slow
|
||||
// replay.
|
||||
layerState._replayCameraRemover = engine.frames.preUpdate.addEventListener(
|
||||
() => {
|
||||
if (
|
||||
token !== layerState._replayCameraToken ||
|
||||
layerState._replayCameraLaunchId !== launchId
|
||||
@@ -451,14 +453,11 @@ export function createReplay({
|
||||
)
|
||||
: cameraView.range;
|
||||
if (state.ascending) {
|
||||
layerState._viewer.camera.lookAt(
|
||||
cameraTarget,
|
||||
getRenderer().range(
|
||||
track.lastCameraHeading,
|
||||
cameraView.pitch,
|
||||
cameraRange,
|
||||
),
|
||||
);
|
||||
engine.camera.lookAt(cameraTarget, {
|
||||
heading: track.lastCameraHeading,
|
||||
pitch: cameraView.pitch,
|
||||
range: cameraRange,
|
||||
});
|
||||
} else {
|
||||
const tangentStep = Math.max(
|
||||
0.002,
|
||||
@@ -483,10 +482,10 @@ export function createReplay({
|
||||
);
|
||||
if (orbitPose) {
|
||||
if (!track.orbitCameraWorldFrame) {
|
||||
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
|
||||
engine.camera.release();
|
||||
track.orbitCameraWorldFrame = true;
|
||||
}
|
||||
layerState._viewer.camera.setView({
|
||||
engine.camera.setView({
|
||||
destination: orbitPose.destination,
|
||||
orientation: {
|
||||
direction: orbitPose.direction,
|
||||
@@ -501,19 +500,18 @@ export function createReplay({
|
||||
) {
|
||||
stopMissionReplay();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (resuming) return true;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
engine.camera.flyToSphere(
|
||||
geo.boundingSphere.create(track.ascentPath[0], 0),
|
||||
{
|
||||
offset: getRenderer().range(
|
||||
initialHeading,
|
||||
geo.math.toRadians(-20),
|
||||
REPLAY_INITIAL_RANGE_M,
|
||||
),
|
||||
heading: initialHeading,
|
||||
pitch: geo.math.toRadians(-20),
|
||||
range: REPLAY_INITIAL_RANGE_M,
|
||||
duration: 1.4,
|
||||
easingFunction: getRenderer().easing,
|
||||
easing: 'cubicInOut',
|
||||
complete: () => {
|
||||
cameraReady = true;
|
||||
},
|
||||
|
||||
@@ -12,12 +12,12 @@ export function createSelection({
|
||||
const { getKeyholeGeometry } = services.geometry;
|
||||
|
||||
/** @returns {boolean} Whether the flight started. */
|
||||
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
const cartographic = viewer?.camera?.positionCartographic;
|
||||
const fovy = viewer?.camera?.frustum?.fovy;
|
||||
function focusFullGlobe(engine, { duration = 2.4, complete, cancel } = {}) {
|
||||
const camera = engine?.camera;
|
||||
const { width, height } = engine?.host?.size() ?? {};
|
||||
const pose = camera?.pose();
|
||||
const cartographic = pose?.cartographic;
|
||||
const fovy = pose?.fovy;
|
||||
if (
|
||||
!(width > 0) ||
|
||||
!(height > 0) ||
|
||||
@@ -36,28 +36,29 @@ export function createSelection({
|
||||
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
|
||||
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
|
||||
layerState._cameraReleased = false;
|
||||
viewer.camera.flyTo({
|
||||
camera.flyTo({
|
||||
destination: geo.cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
altitude,
|
||||
),
|
||||
orientation: {
|
||||
heading: viewer.camera.heading,
|
||||
heading: pose.heading,
|
||||
pitch: -geo.math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
duration,
|
||||
easingFunction: getRenderer().easing,
|
||||
easing: 'cubicInOut',
|
||||
complete,
|
||||
cancel,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function entityLaunchId(entity) {
|
||||
if (!entity?.id || typeof entity.id !== 'string') return null;
|
||||
const match = entity.id.match(/^rocket-[^:]+:([^:]+)/);
|
||||
/** The mission a pick id (`rocket-<kind>:<launch id>…`) belongs to. */
|
||||
function entityLaunchId(id) {
|
||||
if (!id || typeof id !== 'string') return null;
|
||||
const match = id.match(/^rocket-[^:]+:([^:]+)/);
|
||||
return match?.[1] || null;
|
||||
}
|
||||
|
||||
@@ -78,22 +79,20 @@ export function createSelection({
|
||||
layerState._selectedLaunchId = launchId;
|
||||
layerState._explicitSelection = Boolean(launchId && isolate);
|
||||
if (launchId) layerState._animationStarts.set(launchId, appClock.now());
|
||||
if (!launchId && layerState._viewer) {
|
||||
stopMissionZoomAnchor();
|
||||
layerState._viewer.selectedEntity = undefined;
|
||||
}
|
||||
if (!launchId && layerState._engine) stopMissionZoomAnchor();
|
||||
showSelectedMission();
|
||||
}
|
||||
|
||||
function showSelectedMission() {
|
||||
if (!layerState._dataSource) return;
|
||||
if (!getRenderer()) return;
|
||||
const launchId = layerState._selectedLaunchId;
|
||||
for (const entity of layerState._dataSource.entities.values) {
|
||||
const relatedId = entityLaunchId(entity);
|
||||
entity.show = launchId
|
||||
? relatedId === launchId
|
||||
: entity.id.startsWith('rocket-launch:');
|
||||
}
|
||||
for (const element of layerState._missionElements.values())
|
||||
parts.rendering.setElementShown(
|
||||
element,
|
||||
launchId
|
||||
? element.launchId === launchId
|
||||
: element.id.startsWith('rocket-launch:'),
|
||||
);
|
||||
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
|
||||
parts.overlays.syncMissionOverlayEntries();
|
||||
parts.panel.renderMissionPanel();
|
||||
@@ -104,13 +103,12 @@ export function createSelection({
|
||||
layerState._missionZoomAnchorRemover();
|
||||
layerState._missionZoomAnchorRemover = null;
|
||||
layerState._missionZoomAnchorId = null;
|
||||
if (layerState._viewer?.camera && !layerState._replayCameraLaunchId) {
|
||||
layerState._viewer.camera.lookAtTransform(geo.matrix4.IDENTITY);
|
||||
}
|
||||
if (layerState._engine && !layerState._replayCameraLaunchId)
|
||||
layerState._engine.camera.release();
|
||||
}
|
||||
|
||||
function startMissionZoomAnchor(launch, initialRange) {
|
||||
if (!layerState._viewer || !launch || layerState._replayCameraLaunchId)
|
||||
if (!layerState._engine || !launch || layerState._replayCameraLaunchId)
|
||||
return;
|
||||
stopMissionZoomAnchor();
|
||||
const target = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
@@ -120,36 +118,27 @@ export function createSelection({
|
||||
);
|
||||
layerState._missionZoomAnchorId = launch.id;
|
||||
layerState._missionZoomRange = range;
|
||||
layerState._viewer.camera.lookAt(
|
||||
target,
|
||||
getRenderer().range(0, parts.camera.missionZoomPitch(range), range),
|
||||
);
|
||||
layerState._engine.camera.lookAt(target, {
|
||||
heading: 0,
|
||||
pitch: parts.camera.missionZoomPitch(range),
|
||||
range,
|
||||
});
|
||||
}
|
||||
|
||||
function focusMission(launch) {
|
||||
if (!layerState._viewer || !launch) return;
|
||||
const engine = layerState._engine;
|
||||
if (!engine || !launch) return;
|
||||
stopMissionZoomAnchor();
|
||||
layerState._missionFocusIntentId = launch.id;
|
||||
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
|
||||
`rocket-launch:${launch.id}`,
|
||||
);
|
||||
const orbitEntity = layerState._dataSource.entities.getById(
|
||||
const orbitPositions = layerState._missionElements.get(
|
||||
`rocket-orbit:${launch.id}`,
|
||||
);
|
||||
const orbitPositions = orbitEntity?.polyline?.positions?.getValue(
|
||||
getRenderer().time(appClock.now()),
|
||||
);
|
||||
)?.positions;
|
||||
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
let range = 18000000;
|
||||
if (orbitPositions?.length > 1) {
|
||||
const canvas = layerState._viewer.scene.canvas;
|
||||
const fovy =
|
||||
layerState._viewer.camera.frustum?.fovy || geo.math.toRadians(60);
|
||||
const aspect = Math.max(
|
||||
0.1,
|
||||
(canvas.clientWidth || canvas.width) /
|
||||
Math.max(1, canvas.clientHeight || canvas.height),
|
||||
);
|
||||
const size = engine.host.size();
|
||||
const fovy = engine.camera.pose().fovy || geo.math.toRadians(60);
|
||||
const aspect = Math.max(0.1, size.width / Math.max(1, size.height));
|
||||
const fovx = 2 * Math.atan(Math.tan(fovy * 0.5) * aspect);
|
||||
const paddedHalfAngle = Math.min(fovy, fovx) * 0.5 * 0.62;
|
||||
const orbitExtent = orbitPositions.reduce(
|
||||
@@ -193,7 +182,8 @@ export function createSelection({
|
||||
geo.cartesian3.create(),
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyTo({
|
||||
// Without an easing: the engine's own for the flight's heights.
|
||||
engine.camera.flyTo({
|
||||
destination,
|
||||
orientation: { direction, up },
|
||||
duration: 1.1,
|
||||
@@ -205,25 +195,20 @@ export function createSelection({
|
||||
}
|
||||
|
||||
function focusLaunchSite(launch) {
|
||||
if (!layerState._viewer || !launch) return;
|
||||
if (!layerState._engine || !launch) return;
|
||||
parts.replay.stopMissionReplay();
|
||||
layerState._missionFocusIntentId = null;
|
||||
stopMissionZoomAnchor();
|
||||
const launchPosition = geo.cartesian3.fromDegrees(launch.lon, launch.lat);
|
||||
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
|
||||
`rocket-launch:${launch.id}`,
|
||||
);
|
||||
layerState._cameraReleased = false;
|
||||
layerState._viewer.camera.flyToBoundingSphere(
|
||||
layerState._engine.camera.flyToSphere(
|
||||
geo.boundingSphere.create(launchPosition, 0),
|
||||
{
|
||||
offset: getRenderer().range(
|
||||
0,
|
||||
geo.math.toRadians(-42),
|
||||
MISSION_FOCUS_RANGE_M,
|
||||
),
|
||||
heading: 0,
|
||||
pitch: geo.math.toRadians(-42),
|
||||
range: MISSION_FOCUS_RANGE_M,
|
||||
duration: 1.4,
|
||||
easingFunction: getRenderer().easing,
|
||||
easing: 'cubicInOut',
|
||||
complete: () => {
|
||||
if (layerState._selectedLaunchId === launch.id) {
|
||||
startMissionZoomAnchor(launch, MISSION_FOCUS_RANGE_M);
|
||||
|
||||
@@ -11,7 +11,8 @@ export function createState({ services }) {
|
||||
clearSource: clearOverlaySource,
|
||||
});
|
||||
|
||||
state._dataSource = null;
|
||||
// The layer's records of what it draws, by pick id (rendering.js).
|
||||
state._missionElements = new Map();
|
||||
|
||||
state._count = 0;
|
||||
|
||||
@@ -36,8 +37,6 @@ export function createState({ services }) {
|
||||
|
||||
state._moveHandler = null;
|
||||
|
||||
state._viewer = null;
|
||||
|
||||
state._declutterHandler = null;
|
||||
|
||||
state._dataManager = null;
|
||||
@@ -152,10 +151,9 @@ export function createState({ services }) {
|
||||
|
||||
state._missionOrbitPrimitives = new Map();
|
||||
|
||||
// The display (render/display.js) and the engine it draws on.
|
||||
state._renderer = null;
|
||||
state._engine = null;
|
||||
state._declutterTime = null;
|
||||
state._declutterOccluder = null;
|
||||
|
||||
state._missionRingDate = new Date(0);
|
||||
|
||||
|
||||
@@ -12,8 +12,14 @@ export function createTesting({ state: layerState, services, parts, source }) {
|
||||
function _setSelectedRocketMissionForTest(launchId = null) {
|
||||
parts.selection.setSelectedMission(launchId, Boolean(launchId));
|
||||
}
|
||||
/** Test seam: the layer's record of one drawn element, by pick id. */
|
||||
|
||||
function _missionElementForTest(id) {
|
||||
return layerState._missionElements.get(id) ?? null;
|
||||
}
|
||||
return {
|
||||
_setRocketMissionOverlayHostForTest,
|
||||
_setSelectedRocketMissionForTest,
|
||||
_missionElementForTest,
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { msg } from '../../i18n/marker.js';
|
||||
import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from './model.js';
|
||||
import { createNominatimStatus } from '../../sources/nominatimStatus.js';
|
||||
import { createOsmPlacesDisplay } from './render/display.js';
|
||||
export * from './model.js';
|
||||
|
||||
const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
||||
@@ -10,43 +10,6 @@ const OVERLAY_SOURCE_ID = OSM_PLACES_LAYER_ID;
|
||||
const LABEL_LIMIT = 48;
|
||||
const COLOR = OSM_PLACE_COLOR;
|
||||
|
||||
/**
|
||||
* The places' points on a Cesium globe (the default renderer):
|
||||
* `{ setVisible, replace(records), destroy }`.
|
||||
*/
|
||||
export function createOsmPlacesCesiumRenderer({ viewer }) {
|
||||
const dataSource = new Cesium.CustomDataSource(OSM_PLACES_LAYER_ID);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
return {
|
||||
setVisible(show) {
|
||||
dataSource.show = show;
|
||||
},
|
||||
replace(records) {
|
||||
dataSource.entities.removeAll();
|
||||
const color = Cesium.Color.fromCssColorString(COLOR);
|
||||
for (const record of records)
|
||||
dataSource.entities.add({
|
||||
id: `osm-place:${record.id}`,
|
||||
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
|
||||
point: {
|
||||
pixelSize: 9,
|
||||
color,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.8),
|
||||
outlineWidth: 2,
|
||||
heightReference: Cesium.HeightReference.CLAMP_TO_GROUND,
|
||||
// On top only up close (as bikeshare stations): beyond that the
|
||||
// depth test keeps far-side points from showing through the globe.
|
||||
disableDepthTestDistance: 2500,
|
||||
},
|
||||
});
|
||||
},
|
||||
destroy() {
|
||||
viewer.dataSources.remove(dataSource, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OSM Places: the results of the last voice OpenStreetMap search ("hospitals
|
||||
* in Kathmandu"), drawn as points with name labels and queryable by
|
||||
@@ -60,7 +23,7 @@ export function createOsmPlacesLayer({
|
||||
overlayHost,
|
||||
// The same once-per-page capability probe as Nominatim's, on Overpass.
|
||||
overpassConfigured = createNominatimStatus({ url: '/api/overpass/status' }),
|
||||
createRenderer = createOsmPlacesCesiumRenderer,
|
||||
createRenderer = ({ engine }) => createOsmPlacesDisplay(engine),
|
||||
} = {}) {
|
||||
if (!overlayHost) throw new TypeError('OSM Places requires an overlay host');
|
||||
let initialized = false;
|
||||
@@ -131,7 +94,7 @@ export function createOsmPlacesLayer({
|
||||
})
|
||||
.catch(() => {});
|
||||
overlayHost.setVisible(OVERLAY_SOURCE_ID, false);
|
||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
||||
if (engine) this.attachRenderer(viewer, { engine });
|
||||
},
|
||||
|
||||
/** Draw on a globe; the results stay for the next one. */
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Run with: npm test
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createOsmPlacesLayer } from './index.js';
|
||||
|
||||
function fakeOverlayHost() {
|
||||
@@ -77,19 +78,7 @@ test('destroy and re-init expose a fresh empty session', () => {
|
||||
});
|
||||
assert.ok(layer.getStats().lastUpdate);
|
||||
layer.destroy();
|
||||
const dataSources = {
|
||||
added: [],
|
||||
removed: [],
|
||||
add(source) {
|
||||
this.added.push(source);
|
||||
return source;
|
||||
},
|
||||
remove(source, destroy) {
|
||||
this.removed.push([source, destroy]);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
layer.init({ dataSources });
|
||||
layer.init(null, { engine: createFakeEngine() });
|
||||
layer.enable();
|
||||
assert.equal(layer.getResultsMeta(), null);
|
||||
assert.deepEqual(layer.getAnalystRecords(), []);
|
||||
@@ -102,8 +91,6 @@ test('destroy and re-init expose a fresh empty session', () => {
|
||||
});
|
||||
|
||||
test('without an operator Overpass the layer shows as unavailable', async () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const off = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => false,
|
||||
@@ -113,14 +100,14 @@ test('without an operator Overpass the layer shows as unavailable', async () =>
|
||||
true,
|
||||
'unknown until asked',
|
||||
);
|
||||
off.init(viewer);
|
||||
off.init(null, { engine: createFakeEngine() });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(off.getSourceAvailability().available, false);
|
||||
const unknown = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => null,
|
||||
});
|
||||
unknown.init(viewer);
|
||||
unknown.init(null, { engine: createFakeEngine() });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(
|
||||
unknown.getSourceAvailability().available,
|
||||
@@ -130,23 +117,23 @@ test('without an operator Overpass the layer shows as unavailable', async () =>
|
||||
});
|
||||
|
||||
test('places are depth-tested past close range, so they never show through the globe', () => {
|
||||
const added = [];
|
||||
const viewer = { dataSources: { add: (ds) => added.push(ds), remove() {} } };
|
||||
const engine = createFakeEngine();
|
||||
const layer = createOsmPlacesLayer({
|
||||
overlayHost: fakeOverlayHost(),
|
||||
overpassConfigured: async () => true,
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.init(null, { engine });
|
||||
layer.enable();
|
||||
layer.setResults(RECORDS, {
|
||||
kind: 'hospital',
|
||||
label: 'hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
const point = added[0].entities.values[0].point;
|
||||
const distance = point.disableDepthTestDistance.getValue();
|
||||
const { disableWithinM } =
|
||||
engine.fake.moverSet('osm-places').options.depthTest;
|
||||
assert.ok(
|
||||
Number.isFinite(distance) && distance <= 5000,
|
||||
`depth test off only within ${distance} m`,
|
||||
Number.isFinite(disableWithinM) && disableWithinM <= 5000,
|
||||
`depth test off only within ${disableWithinM} m`,
|
||||
);
|
||||
layer.destroy();
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
// The OSM Places points on the globe contract (engine.movers,
|
||||
// engine.surface, engine.camera), the same on every engine: 9 px discs in
|
||||
// the place colour with a dark 2 px outline, on top only within 2.5 km of
|
||||
// the camera. The contract has no ground clamping, so each point sits on the
|
||||
// sampled surface; heights are read again after each camera move, and
|
||||
// shortly after a draw that found no surface under a place yet.
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { rgbaFromCss } from '../../shared/color.js';
|
||||
import { OSM_PLACE_COLOR } from '../model.js';
|
||||
import { OSM_PLACES_LAYER_ID, OSM_PLACE_COLOR } from '../model.js';
|
||||
|
||||
// Above the sampled surface, so a point does not sink into it.
|
||||
const HEIGHT_OFFSET_M = 2;
|
||||
@@ -9,15 +16,17 @@ const INITIAL_CAPACITY = 256;
|
||||
// While the surface under a place has not loaded, its height is asked again.
|
||||
const RESAMPLE_MS = 500;
|
||||
const RESAMPLE_TRIES = 20;
|
||||
// Under the moving contacts (src/data/spriteOrder.js), as map data.
|
||||
const POINT_ORDER = -1;
|
||||
|
||||
/** What a pick of a place returns. */
|
||||
export const osmPlacePickId = (record) => `osm-place:${record.id}`;
|
||||
|
||||
/**
|
||||
* The places' points on Navara, as the Cesium renderer draws them: 9 px
|
||||
* discs with a dark outline, on the surface where it has loaded, on top only
|
||||
* within 2.5 km of the camera. `{ setVisible, replace(records), destroy }`.
|
||||
* Heights are read again after each camera move, and shortly after a draw
|
||||
* that found no surface under a place yet.
|
||||
* The places' points: `{ setVisible, replace(records), destroy }`.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
*/
|
||||
export function createOsmPlacesNavaraRenderer(engine) {
|
||||
export function createOsmPlacesDisplay(engine) {
|
||||
const color = rgbaFromCss(OSM_PLACE_COLOR).rgba();
|
||||
let set = null,
|
||||
capacity = 0,
|
||||
@@ -31,10 +40,11 @@ export function createOsmPlacesNavaraRenderer(engine) {
|
||||
set?.destroy();
|
||||
capacity = Math.max(INITIAL_CAPACITY, count);
|
||||
set = engine.movers.createSet({
|
||||
id: 'osm-places',
|
||||
id: OSM_PLACES_LAYER_ID,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
order: 30,
|
||||
order: POINT_ORDER,
|
||||
pickable: true,
|
||||
depthTest: { disableWithinM: 2500 },
|
||||
point: { sizePx: 9, outlinePx: 2, outlineColor: [0, 0, 0, 0.8] },
|
||||
});
|
||||
@@ -45,9 +55,10 @@ export function createOsmPlacesNavaraRenderer(engine) {
|
||||
retry = null;
|
||||
const shown = visible ? records : [];
|
||||
ensure(shown.length);
|
||||
const sampling = engine.surface.supported;
|
||||
let unsampled = false;
|
||||
shown.forEach((record, index) => {
|
||||
const sampled = engine.surface?.supported
|
||||
const sampled = sampling
|
||||
? engine.surface.sampleHeight(
|
||||
geo.cartographic.fromDegrees(record.lon, record.lat),
|
||||
[set],
|
||||
@@ -62,15 +73,15 @@ export function createOsmPlacesNavaraRenderer(engine) {
|
||||
),
|
||||
color,
|
||||
show: true,
|
||||
pickId: osmPlacePickId(record),
|
||||
});
|
||||
});
|
||||
set.setCount(shown.length);
|
||||
set.commit();
|
||||
engine.render.request('osm-places');
|
||||
if (unsampled && engine.surface?.supported && tries++ < RESAMPLE_TRIES)
|
||||
if (unsampled && sampling && tries++ < RESAMPLE_TRIES)
|
||||
retry = setTimeout(draw, RESAMPLE_MS);
|
||||
}
|
||||
const removeMoveEnd = engine.camera?.moveEnd?.addEventListener(() => {
|
||||
const removeMoveEnd = engine.camera.moveEnd.addEventListener(() => {
|
||||
tries = 0;
|
||||
if (visible && records.length) draw();
|
||||
});
|
||||
@@ -89,7 +100,7 @@ export function createOsmPlacesNavaraRenderer(engine) {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
clearTimeout(retry);
|
||||
removeMoveEnd?.();
|
||||
removeMoveEnd();
|
||||
set?.destroy();
|
||||
set = null;
|
||||
records = [];
|
||||
@@ -0,0 +1,183 @@
|
||||
// The OSM Places points on the engine contract, on the fake engine and on
|
||||
// the Cesium engine (real Cesium point collections over a stub scene): the
|
||||
// disc's size, outline, colour and close-range depth test, its height on the
|
||||
// sampled surface, what a pick returns, and the points' life across
|
||||
// toggles, a globe switch and teardown.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { createOsmPlacesLayer, OSM_PLACE_COLOR } from '../index.js';
|
||||
import { createOsmPlacesDisplay } from './display.js';
|
||||
|
||||
const places = [
|
||||
{ id: 'node/1', name: 'Bir Hospital', lat: 27.7, lon: 85.31 },
|
||||
{ id: 'node/2', name: '', lat: 27.71, lon: 85.32 },
|
||||
];
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
/** The surface under every place answers `height` (undefined: none). */
|
||||
surface(height) {
|
||||
engine.fake.script('sampleHeight', height);
|
||||
},
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const { engine, viewer, primitive } = createCesiumMoversEngine();
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
primitive,
|
||||
surface(height) {
|
||||
viewer.scene.sampleHeightSupported = true;
|
||||
viewer.scene.sampleHeight = () => height;
|
||||
},
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESSES = [fakeHarness, cesiumHarness];
|
||||
|
||||
function layerOn(engine) {
|
||||
const overlay = new Map();
|
||||
const layer = createOsmPlacesLayer({
|
||||
overpassConfigured: async () => true,
|
||||
overlayHost: {
|
||||
setEntries: (id, entries) => overlay.set(id, entries),
|
||||
setVisible() {},
|
||||
clearSource: (id) => overlay.delete(id),
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return { layer, overlay };
|
||||
}
|
||||
|
||||
const count = (engine) =>
|
||||
engine.movers.list().find(({ id }) => id === 'osm-places')?.count;
|
||||
const heightOf = (position) => geo.cartographic.fromCartesian(position).height;
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`places draw as 9 px outlined discs, on top only up close (${name})`, () => {
|
||||
const h = makeHarness();
|
||||
const display = createOsmPlacesDisplay(h.engine);
|
||||
try {
|
||||
display.setVisible(true);
|
||||
display.replace(places);
|
||||
assert.equal(count(h.engine), 2);
|
||||
if (h.cesium) {
|
||||
const collection = h.primitive('osm-places');
|
||||
assert.ok(collection instanceof Cesium.PointPrimitiveCollection);
|
||||
const point = collection.get(0);
|
||||
assert.equal(point.pixelSize, 9);
|
||||
assert.equal(point.outlineWidth, 2);
|
||||
assert.ok(
|
||||
point.outlineColor.equalsEpsilon(
|
||||
Cesium.Color.BLACK.withAlpha(0.8),
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
point.color.equalsEpsilon(
|
||||
Cesium.Color.fromCssColorString(OSM_PLACE_COLOR),
|
||||
1e-6,
|
||||
),
|
||||
);
|
||||
assert.equal(point.disableDepthTestDistance, 2500);
|
||||
assert.equal(point.id, 'osm-place:node/1', 'a pick names the place');
|
||||
} else {
|
||||
const set = h.engine.fake.moverSet('osm-places');
|
||||
assert.equal(set.options.kind, 'point');
|
||||
assert.equal(set.options.point.sizePx, 9);
|
||||
assert.equal(set.options.point.outlinePx, 2);
|
||||
assert.deepEqual(set.options.point.outlineColor, [0, 0, 0, 0.8]);
|
||||
assert.deepEqual(set.options.depthTest, { disableWithinM: 2500 });
|
||||
assert.equal(set.options.pickable, true);
|
||||
assert.ok(set.options.order < 0, 'under the moving contacts');
|
||||
assert.equal(set.store.pickIds[1], 'osm-place:node/2');
|
||||
assert.ok(Math.abs(set.store.colors[0] - 0x7f / 255) < 1e-6);
|
||||
}
|
||||
} finally {
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`places sit 2 m over the sampled surface, read again after a move (${name})`, (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const h = makeHarness();
|
||||
const display = createOsmPlacesDisplay(h.engine);
|
||||
const position = () =>
|
||||
h.cesium
|
||||
? h.primitive('osm-places').get(0).position
|
||||
: (() => {
|
||||
const { positions } = h.engine.fake.moverSet('osm-places').store;
|
||||
return { x: positions[0], y: positions[1], z: positions[2] };
|
||||
})();
|
||||
try {
|
||||
h.surface(undefined);
|
||||
display.setVisible(true);
|
||||
display.replace(places);
|
||||
assert.ok(Math.abs(heightOf(position()) - 2) < 1e-6, 'no surface yet');
|
||||
h.surface(1340);
|
||||
t.mock.timers.tick(500);
|
||||
assert.ok(
|
||||
Math.abs(heightOf(position()) - 1342) < 1e-6,
|
||||
'asked again shortly after',
|
||||
);
|
||||
h.surface(1360);
|
||||
h.engine.camera.moveEnd.raiseEvent();
|
||||
assert.ok(
|
||||
Math.abs(heightOf(position()) - 1362) < 1e-6,
|
||||
'and after a camera move',
|
||||
);
|
||||
} finally {
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`places follow the layer across toggles and a globe switch (${name})`, () => {
|
||||
const h = makeHarness();
|
||||
const { layer, overlay } = layerOn(h.engine);
|
||||
try {
|
||||
layer.setResults(places, {
|
||||
kind: 'hospital',
|
||||
label: 'Hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
assert.equal(count(h.engine), 0, 'nothing drawn while off');
|
||||
layer.enable();
|
||||
assert.equal(count(h.engine), 2, 'one point per place');
|
||||
assert.equal(
|
||||
overlay.get('osm-places').length,
|
||||
1,
|
||||
'only named places get labels',
|
||||
);
|
||||
layer.detachRenderer();
|
||||
assert.equal(count(h.engine), undefined, 'the points go with the globe');
|
||||
const next = makeHarness();
|
||||
layer.attachRenderer(null, { engine: next.engine });
|
||||
assert.equal(count(next.engine), 2);
|
||||
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
|
||||
layer.disable();
|
||||
assert.equal(count(next.engine), 0);
|
||||
layer.destroy();
|
||||
assert.equal(count(next.engine), undefined);
|
||||
next.destroy();
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createOsmPlacesLayer } from '../index.js';
|
||||
import { createOsmPlacesNavaraRenderer } from './navara.js';
|
||||
|
||||
const places = [
|
||||
{ id: 'node/1', name: 'Bir Hospital', lat: 27.7, lon: 85.31 },
|
||||
{ id: 'node/2', name: '', lat: 27.71, lon: 85.32 },
|
||||
];
|
||||
|
||||
function layerOn(engine) {
|
||||
const overlay = new Map();
|
||||
const layer = createOsmPlacesLayer({
|
||||
overpassConfigured: async () => true,
|
||||
overlayHost: {
|
||||
setEntries: (id, entries) => overlay.set(id, entries),
|
||||
setVisible() {},
|
||||
clearSource: (id) => overlay.delete(id),
|
||||
},
|
||||
createRenderer: ({ engine: target }) =>
|
||||
createOsmPlacesNavaraRenderer(target),
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
return { layer, overlay };
|
||||
}
|
||||
|
||||
test('OSM places draw their points on Navara and keep them across a globe switch', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const { layer, overlay } = layerOn(engine);
|
||||
layer.setResults(places, {
|
||||
kind: 'hospital',
|
||||
label: 'Hospitals',
|
||||
area: 'Kathmandu',
|
||||
});
|
||||
const set = () => engine.fake.moverSet('osm-places');
|
||||
assert.equal(set().store.count, 0, 'nothing drawn while off');
|
||||
layer.enable();
|
||||
assert.equal(set().store.count, 2, 'one point per place');
|
||||
assert.equal(
|
||||
overlay.get('osm-places').length,
|
||||
1,
|
||||
'only named places get labels',
|
||||
);
|
||||
layer.detachRenderer();
|
||||
assert.equal(set(), undefined, 'the points go with the globe');
|
||||
const next = createFakeEngine({ kind: 'navara' });
|
||||
layer.attachRenderer(null, { engine: next });
|
||||
assert.equal(next.fake.moverSet('osm-places').store.count, 2);
|
||||
assert.equal(layer.getAnalystRecords().length, 2, 'the results stayed');
|
||||
layer.disable();
|
||||
assert.equal(next.fake.moverSet('osm-places').store.count, 0);
|
||||
layer.destroy();
|
||||
assert.equal(next.fake.moverSet('osm-places'), undefined);
|
||||
});
|
||||
@@ -62,7 +62,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
async function _loadDenseCatalog({ signal = null } = {}) {
|
||||
if (layerState._waitForRenderer) await layerState._waitForRenderer;
|
||||
if (!layerState._viewer || !layerState._pointCollection)
|
||||
if (!layerState._pointCollection)
|
||||
return { status: 'source-unavailable', reason: 'layer-unavailable' };
|
||||
layerState._denseLoadController?.abort();
|
||||
const resourceController = new AbortController();
|
||||
|
||||
Vendored
+1
-1
@@ -261,7 +261,7 @@ export function createControls({ state: layerState, services, parts, source }) {
|
||||
const id = Number(noradId);
|
||||
if (
|
||||
!Number.isFinite(id) ||
|
||||
!layerState._viewer ||
|
||||
!layerState._renderer ||
|
||||
!layerState._catalog.has(id) ||
|
||||
!layerState._points.has(id)
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createApplicationSatellites } from '../../app/layers/satellites.js';
|
||||
import { createSatellitesNavaraRenderer } from './render/navara.js';
|
||||
import { createSatellitesDisplay } from './render/display.js';
|
||||
import { appClock } from '../../time/clock.js';
|
||||
|
||||
// Canonical archived ISS element set (epoch 2008-09-20 ~12:25 UTC).
|
||||
@@ -65,7 +65,7 @@ test('edge:satellites: a scene switch at a stopped epoch keeps passes and coordi
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
create: ({ engine }) => createSatellitesNavaraRenderer(engine),
|
||||
create: ({ engine }) => createSatellitesDisplay(engine),
|
||||
},
|
||||
});
|
||||
const [first, second] = [createFakeEngine(), createFakeEngine()];
|
||||
|
||||
@@ -18,14 +18,8 @@ export function createSatellitesLayer({ services, source }) {
|
||||
throw new TypeError('A satellites source is required');
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const getRenderer = () => {
|
||||
if (!state._renderer || state._renderer.view !== state._viewer)
|
||||
state._renderer = services.renderer.create({
|
||||
viewer: state._viewer,
|
||||
engine: state._engine,
|
||||
});
|
||||
return state._renderer;
|
||||
};
|
||||
// The engine's display while the layer draws (render/display.js), else null.
|
||||
const getRenderer = () => state._renderer;
|
||||
const context = { state, services, parts, source, getRenderer };
|
||||
parts.controls = createControls(context);
|
||||
parts.catalog = createCatalog(context);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function createIngestion({
|
||||
layerState._pointCollection.removeAll();
|
||||
layerState._points.clear();
|
||||
for (const path of layerState._orbitPaths.values())
|
||||
getRenderer().removeOrbit(path.primitive);
|
||||
getRenderer()?.removeOrbit(path.primitive);
|
||||
layerState._orbitPaths.clear();
|
||||
layerState._catalog.clear();
|
||||
// The detection overlay caches one record per satellite and stamps its
|
||||
|
||||
@@ -16,21 +16,21 @@ export function createInteraction({
|
||||
}
|
||||
}
|
||||
|
||||
function _installClickHandler(viewer) {
|
||||
if (layerState._clickHandler) return; // already installed
|
||||
function _installClickHandler() {
|
||||
if (layerState._clickHandler || !getRenderer()) return; // already installed
|
||||
|
||||
// Cross-layer untrack (H2, mirror of flights): if ANOTHER layer (flights,
|
||||
// military, …) grabs the follow-camera, drop our tracking so the orbit ring /
|
||||
// tracked entity don't orphan — without touching viewer.trackedEntity (the
|
||||
// new owner controls it). Guarded so our OWN switch (viewer.trackedEntity
|
||||
// briefly undefined mid-_trackSatellite) doesn't self-clear.
|
||||
// tracked dot don't orphan — without releasing the follow (the new owner
|
||||
// controls it). Guarded so our OWN switch (the follow briefly null
|
||||
// mid-_trackSatellite) doesn't self-clear.
|
||||
if (!layerState._trackedEntityChangedRemove) {
|
||||
layerState._trackedEntityChangedRemove = getRenderer().follow.onChanged(
|
||||
() => {
|
||||
if (!layerState._enabled) return;
|
||||
if (
|
||||
layerState._trackedNorad &&
|
||||
layerState._viewer &&
|
||||
getRenderer() &&
|
||||
((getRenderer().follow.current() &&
|
||||
getRenderer().follow.current() !== layerState._trackedEntity) ||
|
||||
(!getRenderer().follow.current() &&
|
||||
@@ -70,7 +70,7 @@ export function createInteraction({
|
||||
|
||||
// A pick that belongs to a sibling layer (plane, vessel, station, CCTV
|
||||
// camera…) is not "empty space" — leave OUR tracking (and crucially
|
||||
// viewer.trackedEntity, which that sibling may have JUST set) alone (H2).
|
||||
// the follow, which that sibling may have JUST started) alone (H2).
|
||||
if (picked) {
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer('satellites', pickedId)) return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createEngineEvent } from '../../globe/contract.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
test('external null follow clears satellite state on Cesium; own release stays guarded', () => {
|
||||
test('an external null follow clears satellite state; own release stays guarded', () => {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
try {
|
||||
@@ -11,7 +11,6 @@ test('external null follow clears satellite state on Cesium; own release stays g
|
||||
releasing = false;
|
||||
const calls = [];
|
||||
const renderer = {
|
||||
kind: 'cesium',
|
||||
follow: {
|
||||
onChanged: (fn) => changed.addEventListener(fn),
|
||||
current: () => current,
|
||||
@@ -22,7 +21,6 @@ test('external null follow clears satellite state on Cesium; own release stays g
|
||||
const state = {
|
||||
_enabled: true,
|
||||
_trackedNorad: 25544,
|
||||
_viewer: {},
|
||||
_trackedEntity: { id: 'own' },
|
||||
};
|
||||
const interaction = createInteraction({
|
||||
@@ -31,7 +29,7 @@ test('external null follow clears satellite state on Cesium; own release stays g
|
||||
parts: { tracking: { _clearTracking: (...args) => calls.push(args) } },
|
||||
getRenderer: () => renderer,
|
||||
});
|
||||
interaction._installClickHandler(state._viewer);
|
||||
interaction._installClickHandler();
|
||||
releasing = true;
|
||||
changed.raiseEvent();
|
||||
assert.equal(calls.length, 0);
|
||||
|
||||
@@ -14,13 +14,15 @@ export function createLifecycle({
|
||||
const methods = {
|
||||
// Catalog data refresh; propagation remains preRender-owned.
|
||||
|
||||
async init(viewer, { engine = null } = {}) {
|
||||
// The display draws on `engine` (none without one: nothing is drawn).
|
||||
async init(_viewer, { engine = null } = {}) {
|
||||
parts.catalog._abortActiveUpdates();
|
||||
clearFocusTarget('satellites');
|
||||
layerState._engine = engine;
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = services.renderer.create({ viewer, engine });
|
||||
layerState._viewer = layerState._renderer.view;
|
||||
layerState._renderer = engine
|
||||
? (services.renderer.create({ engine }) ?? null)
|
||||
: null;
|
||||
layerState._catalog = new Map();
|
||||
layerState._points = new Map();
|
||||
layerState._detectionObjects = new Map();
|
||||
@@ -50,16 +52,19 @@ export function createLifecycle({
|
||||
layerState._denseError = null;
|
||||
layerState._catalogRevision++;
|
||||
|
||||
// Point primitives for satellite dots
|
||||
layerState._pointCollection = getRenderer().createPoints();
|
||||
const renderer = getRenderer();
|
||||
if (renderer) {
|
||||
// Point primitives for satellite dots
|
||||
layerState._pointCollection = renderer.createPoints();
|
||||
|
||||
parts.interaction._installClickHandler(viewer);
|
||||
parts.interaction._installClickHandler();
|
||||
|
||||
// Pre-render listener for real-time position updates
|
||||
// (fleet propagation + tracked per-frame dot + orbit ring GMST rotation)
|
||||
layerState._preRenderListener = getRenderer().onFrame(
|
||||
parts.rendering._preRenderTick,
|
||||
);
|
||||
// Pre-render listener for real-time position updates
|
||||
// (fleet propagation + tracked per-frame dot + orbit ring GMST rotation)
|
||||
layerState._preRenderListener = renderer.onFrame(
|
||||
parts.rendering._preRenderTick,
|
||||
);
|
||||
}
|
||||
|
||||
console.log('[Data:Satellites] Initialized');
|
||||
},
|
||||
@@ -123,7 +128,6 @@ export function createLifecycle({
|
||||
renderer.removePoints(layerState._pointCollection);
|
||||
renderer.destroy();
|
||||
layerState._engine =
|
||||
layerState._viewer =
|
||||
layerState._renderer =
|
||||
layerState._pointCollection =
|
||||
null;
|
||||
@@ -132,13 +136,17 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
attachRenderer(
|
||||
viewer,
|
||||
_viewer,
|
||||
{ engine = null, waitForCameraRestore = false } = {},
|
||||
) {
|
||||
layerState._engine = engine;
|
||||
const renderer = services.renderer.create({ viewer, engine });
|
||||
const renderer = engine
|
||||
? (services.renderer.create({ engine }) ?? null)
|
||||
: null;
|
||||
layerState._renderer = renderer;
|
||||
layerState._viewer = renderer.view;
|
||||
if (!renderer) return;
|
||||
// The new engine counts its own frames.
|
||||
layerState._trackedFrameNumber = -1;
|
||||
layerState._pointCollection = renderer.createPoints();
|
||||
for (const [id, spec] of layerState._points)
|
||||
layerState._points.set(id, layerState._pointCollection.add(spec));
|
||||
@@ -171,13 +179,13 @@ export function createLifecycle({
|
||||
origin: layerState._handoffOrigin,
|
||||
resuming: true,
|
||||
});
|
||||
this.enable(layerState._viewer);
|
||||
this.enable();
|
||||
}
|
||||
layerState._resumeRenderer?.();
|
||||
layerState._waitForRenderer = layerState._resumeRenderer = null;
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
enable() {
|
||||
layerState._enabled = true;
|
||||
holdContinuousRender('satellites'); // per-frame animator (perf wave 2)
|
||||
if (layerState._pointCollection)
|
||||
@@ -194,14 +202,14 @@ export function createLifecycle({
|
||||
);
|
||||
parts.labels._syncIssOverlay();
|
||||
// Re-attach input handlers and preRender propagation
|
||||
parts.interaction._installClickHandler(viewer);
|
||||
if (layerState._renderer) parts.interaction._installClickHandler();
|
||||
// Pick-ownership (H2): satellite dot ids are numeric NORAD catalog numbers;
|
||||
// the registry hands predicates String()-coerced ids, so match via Number().
|
||||
registerPickOwner('satellites', (pickedId) => {
|
||||
const norad = Number(pickedId);
|
||||
return Number.isFinite(norad) && layerState._points.has(norad);
|
||||
});
|
||||
if (!layerState._preRenderListener && layerState._viewer) {
|
||||
if (!layerState._preRenderListener && layerState._renderer) {
|
||||
layerState._preRenderListener = getRenderer().onFrame(
|
||||
parts.rendering._preRenderTick,
|
||||
);
|
||||
@@ -209,7 +217,7 @@ export function createLifecycle({
|
||||
parts.tracking._applyPendingTrackingRestore();
|
||||
},
|
||||
|
||||
disable(viewer) {
|
||||
disable() {
|
||||
parts.catalog._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
layerState._enabled = false;
|
||||
@@ -236,7 +244,7 @@ export function createLifecycle({
|
||||
}
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
destroy() {
|
||||
layerState._resumeRenderer?.();
|
||||
layerState._waitForRenderer = layerState._resumeRenderer = null;
|
||||
parts.catalog._abortActiveUpdates();
|
||||
@@ -266,7 +274,7 @@ export function createLifecycle({
|
||||
}
|
||||
// Orbit ring primitives are removed (and destroyed) here
|
||||
for (const path of layerState._orbitPaths.values()) {
|
||||
if (path.primitive) getRenderer().removeOrbit(path.primitive);
|
||||
if (path.primitive) getRenderer()?.removeOrbit(path.primitive);
|
||||
}
|
||||
layerState._points.clear();
|
||||
layerState._detectionObjects.clear();
|
||||
@@ -295,7 +303,6 @@ export function createLifecycle({
|
||||
layerState._renderer?.destroy();
|
||||
layerState._renderer = null;
|
||||
layerState._engine = null;
|
||||
layerState._viewer = null;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Satellite drawing and guarded native follow.
|
||||
import * as Cesium from 'cesium';
|
||||
import { isPointerFree } from '../../../data/inputOwnership.js';
|
||||
|
||||
export function createSatellitesCesiumRenderer(engine, { viewer } = {}) {
|
||||
let releasing = false;
|
||||
let starting = null;
|
||||
return {
|
||||
kind: 'cesium',
|
||||
engine,
|
||||
view: viewer,
|
||||
createPoints() {
|
||||
const points = new Cesium.PointPrimitiveCollection();
|
||||
viewer.scene.primitives.add(points);
|
||||
return points;
|
||||
},
|
||||
removePoints(points) {
|
||||
viewer?.scene.primitives.remove(points);
|
||||
},
|
||||
onFrame: (listener) => viewer?.scene.preRender.addEventListener(listener),
|
||||
project: (position, result) =>
|
||||
Cesium.SceneTransforms.worldToWindowCoordinates(
|
||||
viewer.scene,
|
||||
position,
|
||||
result,
|
||||
),
|
||||
createOrbit({ positions, width, color }) {
|
||||
const primitive = new Cesium.Primitive({
|
||||
geometryInstances: new Cesium.GeometryInstance({
|
||||
geometry: new Cesium.PolylineGeometry({
|
||||
positions,
|
||||
width,
|
||||
vertexFormat: Cesium.PolylineColorAppearance.VERTEX_FORMAT,
|
||||
}),
|
||||
attributes: {
|
||||
color: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color.withAlpha(0.6),
|
||||
),
|
||||
depthFailColor: Cesium.ColorGeometryInstanceAttribute.fromColor(
|
||||
color.withAlpha(0.35),
|
||||
),
|
||||
},
|
||||
}),
|
||||
appearance: new Cesium.PolylineColorAppearance({ translucent: true }),
|
||||
depthFailAppearance: new Cesium.PolylineColorAppearance({
|
||||
translucent: true,
|
||||
}),
|
||||
asynchronous: false,
|
||||
allowPicking: false,
|
||||
});
|
||||
viewer.scene.primitives.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
commitOrbit() {},
|
||||
removeOrbit: (primitive) => viewer?.scene.primitives.remove(primitive),
|
||||
createTracked({ position, viewFrom }) {
|
||||
return viewer.entities.add({
|
||||
position: new Cesium.CallbackProperty(position, false),
|
||||
viewFrom: new Cesium.Cartesian3(viewFrom.x, viewFrom.y, viewFrom.z),
|
||||
trackingReferenceFrame: Cesium.TrackingReferenceFrame.ENU,
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color: Cesium.Color.YELLOW,
|
||||
outlineColor: Cesium.Color.WHITE,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
},
|
||||
trackedPosition: (entity) =>
|
||||
entity?.position?.getValue(viewer.clock.currentTime),
|
||||
removeTracked: (entity) => viewer?.entities.remove(entity),
|
||||
follow: {
|
||||
start: (entity) => {
|
||||
starting = entity;
|
||||
try {
|
||||
if (engine) {
|
||||
engine.camera.follow({
|
||||
native: entity,
|
||||
position: () =>
|
||||
entity.position.getValue(viewer.clock.currentTime),
|
||||
viewFrom: () =>
|
||||
entity.viewFrom.getValue(viewer.clock.currentTime),
|
||||
});
|
||||
} else {
|
||||
viewer.camera?.cancelFlight();
|
||||
viewer.trackedEntity = entity;
|
||||
}
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
},
|
||||
release: () => {
|
||||
releasing = true;
|
||||
try {
|
||||
const current = engine?.camera.following();
|
||||
if (current && current.native === viewer?.trackedEntity)
|
||||
current.stop();
|
||||
else if (viewer) viewer.trackedEntity = undefined;
|
||||
} finally {
|
||||
releasing = false;
|
||||
}
|
||||
},
|
||||
current: () => starting ?? viewer?.trackedEntity,
|
||||
releasing: () => releasing,
|
||||
onChanged: (listener) =>
|
||||
viewer.trackedEntityChanged.addEventListener(listener),
|
||||
},
|
||||
listenClick(listener, { enabled = () => true } = {}) {
|
||||
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
|
||||
handler.setInputAction((click) => {
|
||||
if (!isPointerFree()) return;
|
||||
if (!enabled()) return;
|
||||
listener({
|
||||
picked: viewer.scene.pick(click.position),
|
||||
position: click.position,
|
||||
});
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
return { destroy: () => handler.destroy() };
|
||||
},
|
||||
pickedNorad: (picked) => Number(picked?.primitive?.id),
|
||||
isTrackedPick: (picked, entity) => Boolean(entity && picked?.id === entity),
|
||||
destroy() {},
|
||||
};
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createSatellitesCesiumRenderer } from './cesium.js';
|
||||
|
||||
test('native satellite viewFrom fills the scratch vector used by EntityView', () => {
|
||||
const viewer = { entities: new Cesium.EntityCollection() };
|
||||
const renderer = createSatellitesCesiumRenderer(null, { viewer });
|
||||
const viewFrom = geo.cartesian3.create(-450000, -450000, 350000);
|
||||
const entity = renderer.createTracked({
|
||||
position: () => geo.cartesian3.fromDegrees(0, 0, 420000),
|
||||
viewFrom,
|
||||
});
|
||||
assert.equal(
|
||||
entity.trackingReferenceFrame,
|
||||
Cesium.TrackingReferenceFrame.ENU,
|
||||
);
|
||||
const scratch = new Cesium.Cartesian3();
|
||||
const value = entity.viewFrom.getValue(Cesium.JulianDate.now(), scratch);
|
||||
assert.equal(value, scratch);
|
||||
assert.deepEqual(
|
||||
[scratch.x, scratch.y, scratch.z],
|
||||
[viewFrom.x, viewFrom.y, viewFrom.z],
|
||||
);
|
||||
renderer.removeTracked(entity);
|
||||
assert.equal(viewer.entities.values.length, 0);
|
||||
});
|
||||
|
||||
test('native satellite follow goes through the shared camera contract', () => {
|
||||
let options;
|
||||
const viewer = {
|
||||
entities: new Cesium.EntityCollection(),
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
};
|
||||
const engine = {
|
||||
camera: {
|
||||
follow: (spec) => {
|
||||
options = spec;
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createSatellitesCesiumRenderer(engine, { viewer });
|
||||
const position = geo.cartesian3.fromDegrees(0, 0, 20200000);
|
||||
const offset = geo.cartesian3.create(-1800000, -1800000, 1400000);
|
||||
const entity = renderer.createTracked({
|
||||
position: () => position,
|
||||
viewFrom: offset,
|
||||
});
|
||||
renderer.follow.start(entity);
|
||||
assert.equal(options.native, entity);
|
||||
assert.equal(options.position(), position);
|
||||
assert.deepEqual({ ...options.viewFrom() }, { ...offset });
|
||||
});
|
||||
|
||||
test('native own follow release is guarded without hiding later external release', () => {
|
||||
const event = new Cesium.Event();
|
||||
let entity = { id: 'satellite' };
|
||||
const viewer = {
|
||||
trackedEntityChanged: event,
|
||||
get trackedEntity() {
|
||||
return entity;
|
||||
},
|
||||
set trackedEntity(value) {
|
||||
entity = value;
|
||||
event.raiseEvent(value);
|
||||
},
|
||||
};
|
||||
const renderer = createSatellitesCesiumRenderer(null, { viewer });
|
||||
const guards = [];
|
||||
renderer.follow.onChanged(() => guards.push(renderer.follow.releasing()));
|
||||
renderer.follow.release();
|
||||
viewer.trackedEntity = { id: 'other' };
|
||||
viewer.trackedEntity = undefined;
|
||||
assert.deepEqual(guards, [true, false, false]);
|
||||
assert.equal(renderer.follow.releasing(), false);
|
||||
});
|
||||
|
||||
test('native follow takeover reports its starting entity through an intermediate null', () => {
|
||||
const event = new Cesium.Event();
|
||||
let current = { id: 'foreign' };
|
||||
const viewer = {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
trackedEntityChanged: event,
|
||||
get trackedEntity() {
|
||||
return current;
|
||||
},
|
||||
set trackedEntity(v) {
|
||||
current = v;
|
||||
event.raiseEvent();
|
||||
},
|
||||
};
|
||||
const engine = {
|
||||
camera: {
|
||||
follow: ({ native }) => {
|
||||
viewer.trackedEntity = undefined;
|
||||
viewer.trackedEntity = native;
|
||||
},
|
||||
},
|
||||
};
|
||||
const renderer = createSatellitesCesiumRenderer(engine, { viewer });
|
||||
const entity = {
|
||||
id: 'own',
|
||||
position: {
|
||||
getValue() {
|
||||
return geo.cartesian3.ZERO;
|
||||
},
|
||||
},
|
||||
viewFrom: {
|
||||
getValue() {
|
||||
return geo.cartesian3.ZERO;
|
||||
},
|
||||
},
|
||||
};
|
||||
const seen = [];
|
||||
renderer.follow.onChanged(() => seen.push(renderer.follow.current()));
|
||||
renderer.follow.start(entity);
|
||||
assert.deepEqual(seen, [entity, entity]);
|
||||
assert.equal(renderer.follow.current(), entity);
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
// The satellites layer's display, the same on every globe engine, through
|
||||
// the engine contract only: the fleet's dots are engine.movers point banks
|
||||
// (one per look: size, outline, distance scale, depth mode), the tracked
|
||||
// satellite's dot is its own bank drawn over them, the orbit rings are
|
||||
// engine.lines turned by their model matrix (GMST), the follow is
|
||||
// engine.camera.follow and clicks come through the engine's selection
|
||||
// dispatcher. The view the logic reads (camera, scene, frame number) is the
|
||||
// shared engine layer view.
|
||||
//
|
||||
// The logic holds handles with Cesium's PointPrimitive field vocabulary
|
||||
// (position, show, pixelSize, color, outlineColor, outlineWidth,
|
||||
// scaleByDistance, disableDepthTestDistance, id); a change of look moves the
|
||||
// dot to that look's bank. The writes of a frame are committed once, at the
|
||||
// end of the frame listener that made them (or in a microtask for writes
|
||||
// outside a frame).
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createMoverBank } from '../../shared/render/moverBank.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { Rgba, WHITE, constantRgba } from '../../shared/color.js';
|
||||
|
||||
const LAYER_ID = 'satellites';
|
||||
const YELLOW = constantRgba(1, 1, 0);
|
||||
// The tracked dot: 14 px yellow with a 2 px white outline, never depth-tested.
|
||||
const TRACKED_POINT = Object.freeze({
|
||||
pixelSize: 14,
|
||||
color: YELLOW,
|
||||
outlineColor: WHITE,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
// An orbit ring's alpha in front of the scene, and where the scene hides it.
|
||||
const ORBIT_ALPHA = 0.6;
|
||||
const ORBIT_OCCLUDED_ALPHA = 0.35;
|
||||
// The tracked dot draws over the fleet.
|
||||
const TRACKED_ORDER = 100;
|
||||
|
||||
const copyColor = (color) =>
|
||||
new Rgba(
|
||||
color?.red ?? 1,
|
||||
color?.green ?? 1,
|
||||
color?.blue ?? 1,
|
||||
color?.alpha ?? 1,
|
||||
);
|
||||
|
||||
const depthTestOf = (distance) =>
|
||||
distance === Number.POSITIVE_INFINITY
|
||||
? 'always'
|
||||
: distance > 0
|
||||
? { disableWithinM: distance }
|
||||
: 'normal';
|
||||
|
||||
/**
|
||||
* Registry factory (src/app/layers/renderers.js): `(engine)`.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
*/
|
||||
export function createSatellitesDisplay(engine) {
|
||||
if (!engine?.movers || !engine?.lines)
|
||||
throw new TypeError('The satellites display needs a globe engine');
|
||||
const banks = new Map();
|
||||
const points = new Map();
|
||||
const tracked = new Set();
|
||||
const orbits = new Set();
|
||||
const dirty = new Set();
|
||||
let destroyed = false;
|
||||
let inFrame = 0;
|
||||
let queued = false;
|
||||
let visible = true;
|
||||
let sequence = 0;
|
||||
let releasing = false;
|
||||
let starting = null;
|
||||
|
||||
// --- commits: once per frame listener, or in a microtask -----------------
|
||||
const flush = () => {
|
||||
queued = false;
|
||||
for (const bank of dirty) bank.commit();
|
||||
dirty.clear();
|
||||
};
|
||||
const onCommit = (bank) => {
|
||||
dirty.add(bank);
|
||||
if (inFrame === 0 && !queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const inFrameDo = (run) => {
|
||||
inFrame++;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
inFrame--;
|
||||
if (inFrame === 0) flush();
|
||||
}
|
||||
};
|
||||
const layerView = createEngineLayerView(engine, {
|
||||
layerId: LAYER_ID,
|
||||
wrap: inFrameDo,
|
||||
isDestroyed: () => destroyed,
|
||||
});
|
||||
|
||||
// --- the dots ----------------------------------------------------------------
|
||||
const rgba = new Float64Array(4);
|
||||
const writeColor = (color) => {
|
||||
rgba[0] = color.red;
|
||||
rgba[1] = color.green;
|
||||
rgba[2] = color.blue;
|
||||
rgba[3] = color.alpha;
|
||||
};
|
||||
const fields = {
|
||||
position: null,
|
||||
color: rgba,
|
||||
show: true,
|
||||
pickId: null,
|
||||
};
|
||||
const positionField = { position: null };
|
||||
const colorField = { color: rgba };
|
||||
const showField = { show: true };
|
||||
const write = (point, set, index) => {
|
||||
writeColor(point.values.color);
|
||||
fields.position = point.values.position;
|
||||
fields.show = point.values.show;
|
||||
fields.pickId = point.id;
|
||||
set.set(index, fields);
|
||||
};
|
||||
// One bank per family (fleet, tracked) and look.
|
||||
const bankFor = (point) => {
|
||||
const v = point.values;
|
||||
const scale = v.scaleByDistance;
|
||||
const outline = v.outlineColor;
|
||||
const key = [
|
||||
point.family,
|
||||
v.pixelSize,
|
||||
v.outlineWidth,
|
||||
outline.red,
|
||||
outline.green,
|
||||
outline.blue,
|
||||
outline.alpha,
|
||||
...(scale
|
||||
? [scale.near, scale.nearValue, scale.far, scale.farValue]
|
||||
: ['none']),
|
||||
v.disableDepthTestDistance,
|
||||
].join(':');
|
||||
let entry = banks.get(key);
|
||||
if (!entry) {
|
||||
const id = ++sequence;
|
||||
const bank = createMoverBank(engine, {
|
||||
options: (capacity, generation) => ({
|
||||
id: `${LAYER_ID}-points-${id}-${generation}`,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
pickable: true,
|
||||
depthTest: depthTestOf(v.disableDepthTestDistance),
|
||||
order: point.family === 'tracked' ? TRACKED_ORDER : 0,
|
||||
point: {
|
||||
sizePx: v.pixelSize,
|
||||
outlinePx: v.outlineWidth,
|
||||
outlineColor: [
|
||||
outline.red,
|
||||
outline.green,
|
||||
outline.blue,
|
||||
outline.alpha,
|
||||
],
|
||||
scaleByDistance: scale
|
||||
? [scale.near, scale.nearValue, scale.far, scale.farValue]
|
||||
: undefined,
|
||||
},
|
||||
}),
|
||||
write,
|
||||
onCommit,
|
||||
});
|
||||
entry = { bank, family: point.family };
|
||||
banks.set(key, entry);
|
||||
bank.setVisible(point.family === 'tracked' || visible);
|
||||
}
|
||||
return entry.bank;
|
||||
};
|
||||
const LOOK = [
|
||||
'pixelSize',
|
||||
'outlineWidth',
|
||||
'outlineColor',
|
||||
'scaleByDistance',
|
||||
'disableDepthTestDistance',
|
||||
];
|
||||
const createPoint = (spec, family = 'fleet') => {
|
||||
const point = {
|
||||
id: spec.id,
|
||||
family,
|
||||
gevBank: null,
|
||||
gevIndex: -1,
|
||||
values: {
|
||||
position: geo.cartesian3.clone(spec.position ?? geo.cartesian3.ZERO),
|
||||
color: copyColor(spec.color ?? WHITE),
|
||||
show: spec.show !== false,
|
||||
pixelSize: spec.pixelSize ?? 1,
|
||||
outlineColor: copyColor(spec.outlineColor ?? WHITE),
|
||||
outlineWidth: spec.outlineWidth ?? 0,
|
||||
scaleByDistance: spec.scaleByDistance ?? null,
|
||||
disableDepthTestDistance: spec.disableDepthTestDistance ?? 0,
|
||||
},
|
||||
};
|
||||
Object.defineProperties(point, {
|
||||
position: {
|
||||
get: () => point.values.position,
|
||||
set(value) {
|
||||
geo.cartesian3.clone(value, point.values.position);
|
||||
positionField.position = point.values.position;
|
||||
point.gevBank?.update(point, positionField);
|
||||
},
|
||||
},
|
||||
color: {
|
||||
get: () => point.values.color,
|
||||
set(value) {
|
||||
point.values.color = copyColor(value);
|
||||
writeColor(point.values.color);
|
||||
point.gevBank?.update(point, colorField);
|
||||
},
|
||||
},
|
||||
show: {
|
||||
get: () => point.values.show,
|
||||
set(value) {
|
||||
if (point.values.show === Boolean(value)) return;
|
||||
point.values.show = Boolean(value);
|
||||
showField.show = point.values.show;
|
||||
point.gevBank?.update(point, showField);
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const key of LOOK)
|
||||
Object.defineProperty(point, key, {
|
||||
get: () => point.values[key],
|
||||
set(value) {
|
||||
const next = key === 'outlineColor' ? copyColor(value) : value;
|
||||
const current = point.values[key];
|
||||
if (
|
||||
current === value ||
|
||||
(key === 'outlineColor' && current.equals(next))
|
||||
)
|
||||
return;
|
||||
point.values[key] = next;
|
||||
// Another look is another bank.
|
||||
if (point.gevBank) bankFor(point).add(point);
|
||||
},
|
||||
});
|
||||
bankFor(point).add(point);
|
||||
return point;
|
||||
};
|
||||
const collection = {
|
||||
get show() {
|
||||
return visible;
|
||||
},
|
||||
set show(value) {
|
||||
visible = Boolean(value);
|
||||
for (const entry of banks.values())
|
||||
if (entry.family === 'fleet') entry.bank.setVisible(visible);
|
||||
},
|
||||
add(spec) {
|
||||
const point = createPoint(spec);
|
||||
points.set(point.id, point);
|
||||
return point;
|
||||
},
|
||||
remove(point) {
|
||||
if (!point || points.get(point.id) !== point) return false;
|
||||
points.delete(point.id);
|
||||
point.gevBank?.remove(point);
|
||||
return true;
|
||||
},
|
||||
removeAll() {
|
||||
for (const point of points.values()) point.gevBank?.remove(point);
|
||||
points.clear();
|
||||
},
|
||||
};
|
||||
|
||||
// --- follow ------------------------------------------------------------------
|
||||
const follow = {
|
||||
start(handle) {
|
||||
const outer = starting;
|
||||
starting = handle;
|
||||
try {
|
||||
handle.gevFollowHandle = engine.camera.follow({
|
||||
native: handle,
|
||||
position: handle.sample,
|
||||
viewFrom: () => handle.viewFrom,
|
||||
// The tracked readout's card as plain data (the handle's
|
||||
// annotation fields, read when the card paints).
|
||||
label:
|
||||
typeof handle.gevTrackedId === 'string'
|
||||
? {
|
||||
id: handle.gevTrackedId,
|
||||
model: () => handle.gevLabelModel,
|
||||
position: () =>
|
||||
handle.gevDisplayPosition?.() ?? handle.sample(),
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} finally {
|
||||
starting = outer;
|
||||
}
|
||||
},
|
||||
release() {
|
||||
releasing = true;
|
||||
try {
|
||||
engine.camera.following()?.stop();
|
||||
} finally {
|
||||
releasing = false;
|
||||
}
|
||||
},
|
||||
releasing: () => releasing,
|
||||
// What is followed: this layer's tracked handle, else what the follow is
|
||||
// of (another layer's handle or entity), else the follow itself.
|
||||
current() {
|
||||
if (starting) return starting;
|
||||
const current = engine.camera.following();
|
||||
if (!current) return null;
|
||||
for (const handle of tracked)
|
||||
if (handle.gevFollowHandle === current) return handle;
|
||||
return current.native ?? current;
|
||||
},
|
||||
onChanged: (listener) =>
|
||||
engine.camera.followChanged.addEventListener(listener),
|
||||
};
|
||||
|
||||
return {
|
||||
engine,
|
||||
view: layerView.view,
|
||||
createPoints: () => collection,
|
||||
removePoints: () => collection.removeAll(),
|
||||
project: (position, result) =>
|
||||
engine.projector.worldToWindow(position, result),
|
||||
// Each frame (before drawing): the listener, then the tracked dots read
|
||||
// their position (the frame's sample).
|
||||
onFrame: (listener) =>
|
||||
layerView.view.on('preRender', () => {
|
||||
listener();
|
||||
for (const handle of tracked) {
|
||||
const position = handle.sample();
|
||||
if (position) handle.point.position = position;
|
||||
}
|
||||
}),
|
||||
/** An orbit ring at identity; `commitOrbit` applies its modelMatrix. */
|
||||
createOrbit({ positions, width, color }) {
|
||||
const line = engine.lines.createLine({
|
||||
id: `${LAYER_ID}-orbit-${++sequence}`,
|
||||
widthPx: width,
|
||||
color: [color.red, color.green, color.blue, ORBIT_ALPHA],
|
||||
depthFailAlpha: ORBIT_OCCLUDED_ALPHA / ORBIT_ALPHA,
|
||||
arc: 'geodesic',
|
||||
});
|
||||
line.setPositions(positions);
|
||||
let shown = true;
|
||||
const orbit = {
|
||||
line,
|
||||
modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY),
|
||||
get show() {
|
||||
return shown;
|
||||
},
|
||||
set show(value) {
|
||||
shown = Boolean(value);
|
||||
line.show(shown);
|
||||
},
|
||||
};
|
||||
orbits.add(orbit);
|
||||
return orbit;
|
||||
},
|
||||
commitOrbit(orbit) {
|
||||
if (orbits.has(orbit)) orbit.line.setModelMatrix(orbit.modelMatrix);
|
||||
},
|
||||
removeOrbit(orbit) {
|
||||
if (!orbit || !orbits.delete(orbit)) return;
|
||||
orbit.line.destroy();
|
||||
},
|
||||
/**
|
||||
* The tracked satellite: its dot over the fleet, followed by
|
||||
* follow.start. `position()` is read each frame.
|
||||
*/
|
||||
createTracked({ id, position, viewFrom }) {
|
||||
const handle = {
|
||||
id: `${LAYER_ID}:${id}`,
|
||||
sample: position,
|
||||
viewFrom,
|
||||
gevFollowHandle: null,
|
||||
position: { getValue: () => position() },
|
||||
point: createPoint(
|
||||
{ id, position: position(), ...TRACKED_POINT },
|
||||
'tracked',
|
||||
),
|
||||
};
|
||||
tracked.add(handle);
|
||||
return handle;
|
||||
},
|
||||
trackedPosition: (handle) => handle?.sample() ?? undefined,
|
||||
removeTracked(handle) {
|
||||
if (!handle || !tracked.delete(handle)) return;
|
||||
handle.point.gevBank?.remove(handle.point);
|
||||
if (
|
||||
handle.gevFollowHandle &&
|
||||
engine.camera.following() === handle.gevFollowHandle
|
||||
)
|
||||
follow.release();
|
||||
},
|
||||
follow,
|
||||
/** Every click with its top pick (the old LEFT_CLICK rule). */
|
||||
listenClick(listener) {
|
||||
const dispatcher = selectionDispatcherFor(engine);
|
||||
const remove = dispatcher.register(LAYER_ID, {
|
||||
owns: (id) => points.has(Number(id)),
|
||||
onClick: listener,
|
||||
});
|
||||
return { destroy: remove };
|
||||
},
|
||||
pickedNorad: (picked) => Number(picked?.id ?? picked?.primitive?.id),
|
||||
isTrackedPick: (picked, handle) =>
|
||||
Boolean(
|
||||
handle &&
|
||||
Number(picked?.id ?? picked?.primitive?.id) === handle.point.id,
|
||||
),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
flush();
|
||||
destroyed = true;
|
||||
for (const orbit of orbits) orbit.line.destroy();
|
||||
orbits.clear();
|
||||
for (const entry of banks.values()) entry.bank.destroy();
|
||||
banks.clear();
|
||||
dirty.clear();
|
||||
points.clear();
|
||||
tracked.clear();
|
||||
layerView.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
// The satellites display on the engine contract, on the fake engine and on
|
||||
// the Cesium engine (real Cesium point collections and polylines over a stub
|
||||
// scene: they draw nothing but hold what the engine writes): the fleet's
|
||||
// point banks and their looks, writes committed once per frame, the tracked
|
||||
// dot over the fleet, the follow (its target, offset, readout label and
|
||||
// release guard), the orbit rings turned by their model matrix, clicks and
|
||||
// teardown.
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { CYAN, WHITE, nearFarScale } from '../../shared/color.js';
|
||||
import { createSatellitesDisplay } from './display.js';
|
||||
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const position = geo.cartesian3.fromDegrees(0, 0, 420000);
|
||||
const SCALE = nearFarScale(1e6, 1.5, 2e7, 0.6);
|
||||
const spec = (id, overrides = {}) => ({
|
||||
id,
|
||||
position,
|
||||
color: WHITE,
|
||||
pixelSize: 12,
|
||||
outlineColor: WHITE.withAlpha(0.3),
|
||||
outlineWidth: 1,
|
||||
scaleByDistance: SCALE,
|
||||
...overrides,
|
||||
});
|
||||
const near = (actual, expected, label, tolerance = 1e-6) =>
|
||||
assert.ok(
|
||||
Math.abs(actual - expected) < tolerance,
|
||||
`${label}: ${actual} vs ${expected}`,
|
||||
);
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
frame: () => engine.fake.frame(),
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const { engine, ...rest } = createCesiumMoversEngine();
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
cesium: true,
|
||||
engine,
|
||||
...rest,
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
const HARNESSES = [fakeHarness, cesiumHarness];
|
||||
|
||||
function setup(makeHarness) {
|
||||
const harness = makeHarness();
|
||||
harness.engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(0, 0, 2e7),
|
||||
});
|
||||
return { ...harness, display: createSatellitesDisplay(harness.engine) };
|
||||
}
|
||||
|
||||
const setsOf = (engine) =>
|
||||
engine.movers.list().filter(({ id }) => id.startsWith('satellites-points'));
|
||||
|
||||
/** What a set's instance draws: its size, outline, depth mode and colour. */
|
||||
function drawn(h, setId, index) {
|
||||
if (h.cesium) {
|
||||
const item = h.primitive(setId).get(index);
|
||||
return {
|
||||
position: item.position,
|
||||
pixelSize: item.pixelSize,
|
||||
outlineWidth: item.outlineWidth,
|
||||
outline: item.outlineColor,
|
||||
depth: item.disableDepthTestDistance,
|
||||
scale: item.scaleByDistance,
|
||||
color: item.color,
|
||||
show: item.show,
|
||||
id: item.id,
|
||||
};
|
||||
}
|
||||
const { options, store } = h.engine.fake.moverSet(setId);
|
||||
const [r, g, b, a] = store.colors.slice(index * 4, index * 4 + 4);
|
||||
const [nearM, nearValue, farM, farValue] =
|
||||
options.point.scaleByDistance ?? [];
|
||||
return {
|
||||
position: {
|
||||
x: store.positions[index * 3],
|
||||
y: store.positions[index * 3 + 1],
|
||||
z: store.positions[index * 3 + 2],
|
||||
},
|
||||
pixelSize: options.point.sizePx * store.scales[index],
|
||||
outlineWidth: options.point.outlinePx,
|
||||
outline: {
|
||||
red: options.point.outlineColor[0],
|
||||
alpha: options.point.outlineColor[3],
|
||||
},
|
||||
depth:
|
||||
options.depthTest === 'always'
|
||||
? Number.POSITIVE_INFINITY
|
||||
: options.depthTest === 'normal'
|
||||
? 0
|
||||
: options.depthTest.disableWithinM,
|
||||
scale: { near: nearM, nearValue, far: farM, farValue },
|
||||
color: { red: r, green: g, blue: b, alpha: a },
|
||||
show: store.shows[index] === 1,
|
||||
id: store.pickIds[index],
|
||||
};
|
||||
}
|
||||
|
||||
test('the display needs a globe engine', () => {
|
||||
assert.throws(() => createSatellitesDisplay(null), TypeError);
|
||||
});
|
||||
|
||||
for (const makeHarness of HARNESSES) {
|
||||
const { name } = makeHarness();
|
||||
|
||||
test(`fleet dots draw as the layer asked, in banks that survive growth, written once per frame (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
const points = display.createPoints();
|
||||
const all = Array.from({ length: 1500 }, (_, i) => points.add(spec(i)));
|
||||
await settle();
|
||||
const [listed] = setsOf(engine);
|
||||
assert.equal(setsOf(engine).length, 1);
|
||||
assert.equal(listed.count, 1500);
|
||||
let dot = drawn(h, listed.id, 699);
|
||||
assert.equal(dot.id, 699);
|
||||
assert.equal(dot.pixelSize, 12);
|
||||
assert.equal(dot.outlineWidth, 1);
|
||||
near(dot.outline.alpha, 0.3, 'outline alpha');
|
||||
assert.equal(dot.depth, 0, 'fleet dots are depth-tested');
|
||||
assert.equal(dot.scale.near, 1e6);
|
||||
assert.equal(dot.scale.nearValue, 1.5);
|
||||
assert.equal(dot.scale.far, 2e7);
|
||||
assert.equal(dot.scale.farValue, 0.6);
|
||||
assert.equal(dot.show, true);
|
||||
// Reads are the display's own copies.
|
||||
assert.notEqual(all[0].color, WHITE);
|
||||
assert.equal(all[0].color.alpha, 1);
|
||||
const commits = h.cesium
|
||||
? null
|
||||
: () => engine.fake.moverSet(listed.id).commits;
|
||||
const before = commits?.();
|
||||
const remove = display.onFrame(() => {
|
||||
all[0].position = { x: 1, y: 2, z: 3 };
|
||||
all[0].show = false;
|
||||
all[0].color = CYAN.withAlpha(0.25);
|
||||
});
|
||||
h.frame();
|
||||
remove();
|
||||
if (commits) assert.equal(commits(), before + 1, 'one commit per frame');
|
||||
dot = drawn(h, listed.id, 0);
|
||||
assert.deepEqual(
|
||||
[dot.position.x, dot.position.y, dot.position.z],
|
||||
[1, 2, 3],
|
||||
);
|
||||
assert.equal(dot.show, false);
|
||||
near(dot.color.alpha, 0.25, 'alpha');
|
||||
assert.equal(all[0].color.alpha, 0.25);
|
||||
// Another look is another bank.
|
||||
all[1].pixelSize = 14;
|
||||
await settle();
|
||||
assert.equal(setsOf(engine).length, 2);
|
||||
assert.equal(setsOf(engine)[0].count, 1499);
|
||||
assert.equal(all[1].pixelSize, 14);
|
||||
// A removed dot leaves; the look it had no longer matters.
|
||||
assert.equal(points.remove(all[2]), true);
|
||||
assert.equal(points.remove(all[2]), false);
|
||||
all[2].pixelSize = 16;
|
||||
await settle();
|
||||
assert.equal(setsOf(engine).length, 2);
|
||||
// Hiding the fleet hides its banks.
|
||||
points.show = false;
|
||||
await settle();
|
||||
assert.equal(points.show, false);
|
||||
assert.ok(setsOf(engine).every(({ count }) => count === 0));
|
||||
points.show = true;
|
||||
points.removeAll();
|
||||
await settle();
|
||||
assert.ok(setsOf(engine).every(({ count }) => count === 0));
|
||||
display.destroy();
|
||||
assert.equal(engine.movers.list().length, 0, 'no set left behind');
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`the tracked dot draws over the fleet at its follow's sample, whatever the fleet's visibility (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
const points = display.createPoints();
|
||||
const fleetDot = points.add(spec(25544));
|
||||
fleetDot.show = false;
|
||||
let sample = position;
|
||||
const handle = display.createTracked({
|
||||
id: 25544,
|
||||
position: () => sample,
|
||||
viewFrom: { x: -450000, y: -450000, z: 350000 },
|
||||
});
|
||||
assert.equal(handle.id, 'satellites:25544');
|
||||
points.show = false;
|
||||
const remove = display.onFrame(() => {});
|
||||
sample = { x: position.x + 7600, y: 100, z: 0 };
|
||||
h.frame();
|
||||
remove();
|
||||
await settle();
|
||||
assert.deepEqual({ ...handle.point.position }, sample);
|
||||
assert.deepEqual({ ...display.trackedPosition(handle) }, sample);
|
||||
const tracked = setsOf(engine).find(
|
||||
({ id, count }) => count > 0 && drawn(h, id, 0).pixelSize === 14,
|
||||
);
|
||||
assert.ok(tracked, 'the tracked bank');
|
||||
const dot = drawn(h, tracked.id, 0);
|
||||
assert.equal(dot.show, true);
|
||||
assert.equal(dot.pixelSize, 14);
|
||||
assert.equal(dot.outlineWidth, 2);
|
||||
assert.equal(dot.outline.red, 1);
|
||||
assert.equal(dot.depth, Number.POSITIVE_INFINITY, 'never depth-tested');
|
||||
assert.deepEqual(
|
||||
[dot.color.red, dot.color.green, dot.color.blue],
|
||||
[1, 1, 0],
|
||||
'yellow',
|
||||
);
|
||||
assert.equal(dot.id, 25544);
|
||||
if (!h.cesium)
|
||||
assert.equal(engine.fake.moverSet(tracked.id).options.order, 100);
|
||||
assert.ok(
|
||||
setsOf(engine)
|
||||
.filter(({ id }) => id !== tracked.id)
|
||||
.every(({ count }) => count === 0),
|
||||
'the fleet is hidden',
|
||||
);
|
||||
display.removeTracked(handle);
|
||||
await settle();
|
||||
assert.equal(
|
||||
engine.movers.list().find(({ id }) => id === tracked.id).count,
|
||||
0,
|
||||
);
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`the follow centres the tracked satellite from its offset in the east-north-up frame and carries its readout label (${name})`, async () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
display.createPoints().add(spec(25544));
|
||||
const target = geo.cartesian3.fromDegrees(0, 0, 20200000);
|
||||
const offset = geo.cartesian3.create(-1800000, -1800000, 1400000);
|
||||
const handle = display.createTracked({
|
||||
id: 25544,
|
||||
position: () => target,
|
||||
viewFrom: offset,
|
||||
});
|
||||
handle.gevTrackedId = 'satellites:25544';
|
||||
handle.gevSelectionOrigin = 'user';
|
||||
handle.gevLabelModel = { title: 'ISS', details: [] };
|
||||
handle.gevDisplayPosition = () => target;
|
||||
const seen = [];
|
||||
const removeListener = display.follow.onChanged(() =>
|
||||
seen.push(display.follow.current()),
|
||||
);
|
||||
display.follow.start(handle);
|
||||
// Every change the start raises reads the satellite being followed.
|
||||
assert.ok(seen.length >= 1);
|
||||
assert.ok(seen.every((current) => current === handle));
|
||||
const following = engine.camera.following();
|
||||
assert.equal(following, handle.gevFollowHandle);
|
||||
assert.equal(following.native, handle);
|
||||
assert.equal(following.label.id, 'satellites:25544');
|
||||
assert.equal(following.label.model(), handle.gevLabelModel);
|
||||
assert.equal(following.label.position(), target);
|
||||
assert.equal(display.follow.current(), handle);
|
||||
assert.equal(following.target(), target);
|
||||
if (h.cesium) {
|
||||
const proxy = h.viewer.trackedEntity;
|
||||
assert.ok(proxy instanceof Cesium.Entity);
|
||||
assert.equal(
|
||||
proxy.trackingReferenceFrame,
|
||||
Cesium.TrackingReferenceFrame.ENU,
|
||||
);
|
||||
assert.equal(proxy.gevTrackedId, 'satellites:25544');
|
||||
assert.equal(proxy.gevSelectionOrigin, 'user');
|
||||
const scratch = new Cesium.Cartesian3();
|
||||
const value = proxy.viewFrom.getValue(
|
||||
h.viewer.clock.currentTime,
|
||||
scratch,
|
||||
);
|
||||
assert.deepEqual(
|
||||
[value.x, value.y, value.z],
|
||||
[offset.x, offset.y, offset.z],
|
||||
);
|
||||
}
|
||||
// The display's own release is guarded; a later external change is not.
|
||||
seen.length = 0;
|
||||
const guards = [];
|
||||
const removeGuard = display.follow.onChanged(() =>
|
||||
guards.push(display.follow.releasing()),
|
||||
);
|
||||
display.follow.release();
|
||||
assert.equal(engine.camera.following(), null);
|
||||
assert.equal(display.follow.current(), null);
|
||||
assert.ok(guards.length >= 1);
|
||||
assert.ok(guards.every(Boolean), 'own release');
|
||||
guards.length = 0;
|
||||
const other = { id: 'other', gevSelectionOrigin: 'programmatic' };
|
||||
const foreign = engine.camera.follow({
|
||||
native: other,
|
||||
position: () => target,
|
||||
viewFrom: { x: 0, y: -100, z: 100 },
|
||||
});
|
||||
assert.equal(display.follow.current(), other, "another layer's target");
|
||||
foreign.stop();
|
||||
assert.ok(guards.length >= 1);
|
||||
assert.ok(
|
||||
guards.every((guard) => guard === false),
|
||||
'external changes',
|
||||
);
|
||||
assert.equal(display.follow.releasing(), false);
|
||||
removeGuard();
|
||||
removeListener();
|
||||
// Removing the followed satellite ends its follow.
|
||||
display.follow.start(handle);
|
||||
display.removeTracked(handle);
|
||||
assert.equal(engine.camera.following(), null);
|
||||
if (h.cesium) assert.equal(h.viewer.trackedEntity, undefined);
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`orbit rings start at identity, turn by their model matrix without a rebuild, dim behind the globe and leave (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
const { engine, display } = h;
|
||||
const ring = [position, { x: 0, y: 6778137, z: 0 }];
|
||||
const orbit = display.createOrbit({
|
||||
positions: ring,
|
||||
width: 2.5,
|
||||
color: CYAN,
|
||||
});
|
||||
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
|
||||
const [before] = engine.lines.list();
|
||||
assert.equal(before.points, 2);
|
||||
if (h.cesium) {
|
||||
const primitive = h.primitive(before.id);
|
||||
assert.equal(primitive.allowPicking, false, 'rings are not picked');
|
||||
assert.equal(
|
||||
primitive.geometryInstances.geometry._arcType,
|
||||
Cesium.ArcType.GEODESIC,
|
||||
);
|
||||
assert.equal(primitive.geometryInstances.geometry._width, 2.5);
|
||||
near(primitive.appearance.material.uniforms.color.alpha, 0.6, 'alpha');
|
||||
near(
|
||||
primitive.depthFailAppearance.material.uniforms.color.alpha,
|
||||
0.35,
|
||||
'occluded alpha',
|
||||
);
|
||||
assert.equal(primitive.appearance.material.uniforms.color.red, 0);
|
||||
} else {
|
||||
const { options } = engine.fake.line(before.id);
|
||||
assert.equal(options.widthPx, 2.5);
|
||||
assert.equal(options.arc, 'geodesic');
|
||||
assert.equal(options.pickId, undefined, 'rings are not picked');
|
||||
near(options.color[3], 0.6, 'alpha');
|
||||
near(options.color[3] * options.depthFailAlpha, 0.35, 'occluded alpha');
|
||||
}
|
||||
orbit.modelMatrix[12] = 123;
|
||||
display.commitOrbit(orbit);
|
||||
assert.equal(engine.lines.list()[0].id, before.id, 'the same line');
|
||||
if (h.cesium) assert.equal(h.primitive(before.id).modelMatrix[12], 123);
|
||||
else assert.equal(engine.fake.line(before.id).modelMatrix[12], 123);
|
||||
orbit.show = false;
|
||||
assert.equal(orbit.show, false);
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
// A ring that is not the display's is left alone.
|
||||
display.commitOrbit({ show: true, modelMatrix: geo.matrix4.create() });
|
||||
display.removeOrbit({ show: true });
|
||||
assert.equal(engine.lines.list().length, 1);
|
||||
display.removeOrbit(orbit);
|
||||
assert.equal(engine.lines.list().length, 0);
|
||||
display.createOrbit({ positions: ring, width: 2, color: CYAN });
|
||||
display.destroy();
|
||||
assert.equal(engine.lines.list().length, 0, 'the display releases rings');
|
||||
h.destroy();
|
||||
});
|
||||
|
||||
test(`the view reads the engine's camera and frames (${name})`, () => {
|
||||
const h = setup(makeHarness);
|
||||
const { display } = h;
|
||||
const frame = display.view.frameNumber();
|
||||
h.frame();
|
||||
assert.equal(display.view.frameNumber(), frame + 1);
|
||||
near(display.view.camera.positionCartographic.height, 2e7, 'height', 1);
|
||||
const screen = display.project(geo.cartesian3.fromDegrees(0, 0, 0));
|
||||
assert.ok(Number.isFinite(screen.x) && Number.isFinite(screen.y));
|
||||
display.destroy();
|
||||
h.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
test('a click comes with its top pick; picks name the NORAD id and the tracked dot (the fake engine)', async () => {
|
||||
const previousDocument = globalThis.document;
|
||||
globalThis.document = new EventTarget();
|
||||
try {
|
||||
const engine = createFakeEngine();
|
||||
const display = createSatellitesDisplay(engine);
|
||||
display.createPoints().add(spec(25544));
|
||||
const clicks = [];
|
||||
const handler = display.listenClick((ctx) => clicks.push(ctx));
|
||||
engine.fake.script('pick', () => ({ id: 25544 }));
|
||||
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||
assert.equal(clicks.length, 1);
|
||||
assert.equal(display.pickedNorad(clicks[0].picked), 25544);
|
||||
const handle = display.createTracked({
|
||||
id: 25544,
|
||||
position: () => position,
|
||||
viewFrom: { x: 0, y: 0, z: 1 },
|
||||
});
|
||||
assert.equal(display.isTrackedPick(clicks[0].picked, handle), true);
|
||||
assert.equal(display.isTrackedPick({ id: 1 }, handle), false);
|
||||
assert.equal(display.isTrackedPick(clicks[0].picked, null), false);
|
||||
assert.equal(Number.isNaN(display.pickedNorad({ id: { id: 'x' } })), true);
|
||||
handler.destroy();
|
||||
engine.fake.input('click', { position: { x: 10, y: 10 } });
|
||||
assert.equal(clicks.length, 1);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
} finally {
|
||||
if (previousDocument === undefined) delete globalThis.document;
|
||||
else globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -1,355 +0,0 @@
|
||||
// Satellite points, rigid orbit rings and follow through the globe contract.
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createMoverBank } from '../../shared/render/navaraMoverBank.js';
|
||||
import { createEngineLayerView } from '../../shared/render/engineView.js';
|
||||
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
|
||||
import { WHITE, constantRgba } from '../../shared/color.js';
|
||||
|
||||
const YELLOW = constantRgba(1, 1, 0);
|
||||
export function createSatellitesNavaraRenderer(engine) {
|
||||
const banks = new Map();
|
||||
const points = new Map();
|
||||
const tracked = new Set();
|
||||
const orbits = new Set();
|
||||
const dirty = new Set();
|
||||
let destroyed = false;
|
||||
let inFrame = false;
|
||||
let queued = false;
|
||||
let visible = true;
|
||||
let sequence = 0;
|
||||
let releasing = false;
|
||||
let starting = null;
|
||||
const rgba = new Float64Array(4);
|
||||
const fields = {
|
||||
position: null,
|
||||
color: rgba,
|
||||
scale: 1,
|
||||
show: true,
|
||||
pickId: null,
|
||||
};
|
||||
const positionField = { position: null };
|
||||
const colorField = { color: rgba };
|
||||
const showField = { show: true };
|
||||
const flush = () => {
|
||||
queued = false;
|
||||
for (const bank of dirty) bank.commit();
|
||||
dirty.clear();
|
||||
};
|
||||
const onCommit = (bank) => {
|
||||
dirty.add(bank);
|
||||
if (!inFrame && !queued) {
|
||||
queued = true;
|
||||
queueMicrotask(flush);
|
||||
}
|
||||
};
|
||||
const frame = (run) => {
|
||||
inFrame = true;
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
inFrame = false;
|
||||
flush();
|
||||
}
|
||||
};
|
||||
const layerView = createEngineLayerView(engine, {
|
||||
layerId: 'satellites',
|
||||
wrap: frame,
|
||||
isDestroyed: () => destroyed,
|
||||
});
|
||||
const copyColor = (color) => {
|
||||
rgba[0] = color.red;
|
||||
rgba[1] = color.green;
|
||||
rgba[2] = color.blue;
|
||||
rgba[3] = color.alpha;
|
||||
};
|
||||
const write = (point, set, index) => {
|
||||
copyColor(point.values.color);
|
||||
fields.position = point.values.position;
|
||||
fields.show = point.values.show;
|
||||
fields.pickId = point.id;
|
||||
set.set(index, fields);
|
||||
};
|
||||
const bankFor = (point) => {
|
||||
const v = point.values;
|
||||
const scale = v.scaleByDistance;
|
||||
const outline = v.outlineColor;
|
||||
const depth =
|
||||
v.disableDepthTestDistance === Infinity
|
||||
? 'always'
|
||||
: v.disableDepthTestDistance > 0
|
||||
? { disableWithinM: v.disableDepthTestDistance }
|
||||
: 'normal';
|
||||
const key = [
|
||||
point.family,
|
||||
v.pixelSize,
|
||||
v.outlineWidth,
|
||||
outline.red,
|
||||
outline.green,
|
||||
outline.blue,
|
||||
outline.alpha,
|
||||
...Object.values(scale ?? {}),
|
||||
v.disableDepthTestDistance,
|
||||
].join(':');
|
||||
let entry = banks.get(key);
|
||||
if (!entry) {
|
||||
const id = ++sequence;
|
||||
const bank = createMoverBank(engine, {
|
||||
options: (capacity, generation) => ({
|
||||
id: `satellites-points-${id}-${generation}`,
|
||||
kind: 'point',
|
||||
capacity,
|
||||
pickable: true,
|
||||
depthTest: depth,
|
||||
order: point.family === 'tracked' ? 100 : 0,
|
||||
point: {
|
||||
sizePx: v.pixelSize,
|
||||
outlinePx: v.outlineWidth,
|
||||
outlineColor: [
|
||||
outline.red,
|
||||
outline.green,
|
||||
outline.blue,
|
||||
outline.alpha,
|
||||
],
|
||||
scaleByDistance: scale
|
||||
? [scale.near, scale.nearValue, scale.far, scale.farValue]
|
||||
: null,
|
||||
},
|
||||
}),
|
||||
write,
|
||||
onCommit,
|
||||
});
|
||||
entry = { bank, family: point.family };
|
||||
banks.set(key, entry);
|
||||
bank.setVisible(point.family === 'tracked' || visible);
|
||||
}
|
||||
return entry.bank;
|
||||
};
|
||||
const createPoint = (spec, family = 'fleet') => {
|
||||
const point = {
|
||||
id: spec.id,
|
||||
family,
|
||||
gevBank: null,
|
||||
gevIndex: -1,
|
||||
values: {
|
||||
position: geo.cartesian3.clone(spec.position),
|
||||
color: spec.color ?? WHITE,
|
||||
show: spec.show !== false,
|
||||
pixelSize: spec.pixelSize ?? 1,
|
||||
outlineColor: spec.outlineColor ?? WHITE,
|
||||
outlineWidth: spec.outlineWidth ?? 0,
|
||||
scaleByDistance: spec.scaleByDistance ?? null,
|
||||
disableDepthTestDistance: spec.disableDepthTestDistance ?? 0,
|
||||
},
|
||||
};
|
||||
Object.defineProperties(point, {
|
||||
position: {
|
||||
get: () => point.values.position,
|
||||
set(value) {
|
||||
geo.cartesian3.clone(value, point.values.position);
|
||||
positionField.position = point.values.position;
|
||||
point.gevBank?.update(point, positionField);
|
||||
},
|
||||
},
|
||||
color: {
|
||||
get: () => point.values.color,
|
||||
set(value) {
|
||||
point.values.color = value;
|
||||
copyColor(value);
|
||||
point.gevBank?.update(point, colorField);
|
||||
},
|
||||
},
|
||||
show: {
|
||||
get: () => point.values.show,
|
||||
set(value) {
|
||||
if (point.values.show === Boolean(value)) return;
|
||||
point.values.show = Boolean(value);
|
||||
showField.show = point.values.show;
|
||||
point.gevBank?.update(point, showField);
|
||||
},
|
||||
},
|
||||
});
|
||||
for (const key of [
|
||||
'pixelSize',
|
||||
'outlineWidth',
|
||||
'outlineColor',
|
||||
'scaleByDistance',
|
||||
'disableDepthTestDistance',
|
||||
])
|
||||
Object.defineProperty(point, key, {
|
||||
get: () => point.values[key],
|
||||
set(value) {
|
||||
if (point.values[key] === value) return;
|
||||
point.values[key] = value;
|
||||
bankFor(point).add(point);
|
||||
},
|
||||
});
|
||||
bankFor(point).add(point);
|
||||
return point;
|
||||
};
|
||||
const collection = {
|
||||
get show() {
|
||||
return visible;
|
||||
},
|
||||
set show(value) {
|
||||
visible = Boolean(value);
|
||||
for (const entry of banks.values())
|
||||
if (entry.family === 'fleet') entry.bank.setVisible(visible);
|
||||
},
|
||||
add(spec) {
|
||||
const point = createPoint(spec);
|
||||
points.set(point.id, point);
|
||||
return point;
|
||||
},
|
||||
remove(point) {
|
||||
if (!points.delete(point.id)) return false;
|
||||
point.gevBank?.remove(point);
|
||||
return true;
|
||||
},
|
||||
removeAll() {
|
||||
for (const point of points.values()) point.gevBank?.remove(point);
|
||||
points.clear();
|
||||
},
|
||||
};
|
||||
const follow = {
|
||||
start(entity) {
|
||||
starting = entity;
|
||||
try {
|
||||
const followLabel = {
|
||||
id: entity.gevTrackedId,
|
||||
model: () => entity.gevLabelModel,
|
||||
position: () => entity.gevDisplayPosition?.() ?? entity.sample(),
|
||||
};
|
||||
entity.follow = engine.camera.follow({
|
||||
position: entity.sample,
|
||||
viewFrom: () => entity.viewFrom,
|
||||
label: followLabel,
|
||||
});
|
||||
} finally {
|
||||
starting = null;
|
||||
}
|
||||
},
|
||||
release() {
|
||||
releasing = true;
|
||||
try {
|
||||
engine.camera.following()?.stop();
|
||||
} finally {
|
||||
releasing = false;
|
||||
}
|
||||
},
|
||||
releasing: () => releasing,
|
||||
current() {
|
||||
if (starting) return starting;
|
||||
const current = engine.camera.following();
|
||||
for (const entity of tracked)
|
||||
if (entity.follow === current && current) return entity;
|
||||
return current;
|
||||
},
|
||||
onChanged: (listener) =>
|
||||
engine.camera.followChanged.addEventListener(listener),
|
||||
};
|
||||
return {
|
||||
kind: 'navara',
|
||||
engine,
|
||||
view: layerView.view,
|
||||
createPoints: () => collection,
|
||||
removePoints: () => collection.removeAll(),
|
||||
project: (position, result) =>
|
||||
engine.projector.worldToWindow(position, result),
|
||||
onFrame: (listener) =>
|
||||
layerView.view.on('preRender', () => {
|
||||
listener();
|
||||
for (const entity of tracked) {
|
||||
const position = entity.sample();
|
||||
if (position) entity.point.position = position;
|
||||
}
|
||||
}),
|
||||
createOrbit({ positions, width, color }) {
|
||||
const line = engine.lines.createLine({
|
||||
id: `satellites-orbit-${++sequence}`,
|
||||
widthPx: width,
|
||||
color: [color.red, color.green, color.blue, 0.6],
|
||||
depthFailAlpha: 0.35 / 0.6,
|
||||
arc: 'geodesic',
|
||||
});
|
||||
line.setPositions(positions);
|
||||
const primitive = {
|
||||
line,
|
||||
modelMatrix: geo.matrix4.clone(geo.matrix4.IDENTITY),
|
||||
visible: true,
|
||||
get show() {
|
||||
return this.visible;
|
||||
},
|
||||
set show(value) {
|
||||
this.visible = Boolean(value);
|
||||
line.show(this.visible);
|
||||
},
|
||||
};
|
||||
orbits.add(primitive);
|
||||
return primitive;
|
||||
},
|
||||
commitOrbit: (primitive) =>
|
||||
primitive.line.setModelMatrix(primitive.modelMatrix),
|
||||
removeOrbit(primitive) {
|
||||
primitive.line.destroy();
|
||||
orbits.delete(primitive);
|
||||
},
|
||||
createTracked({ id, position, viewFrom }) {
|
||||
const entity = {
|
||||
id: `satellites:${id}`,
|
||||
sample: position,
|
||||
viewFrom,
|
||||
follow: null,
|
||||
position: { getValue: position },
|
||||
point: createPoint(
|
||||
{
|
||||
id,
|
||||
position: position(),
|
||||
pixelSize: 14,
|
||||
color: YELLOW,
|
||||
outlineColor: WHITE,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Infinity,
|
||||
},
|
||||
'tracked',
|
||||
),
|
||||
};
|
||||
tracked.add(entity);
|
||||
return entity;
|
||||
},
|
||||
trackedPosition: (entity) => entity?.point.position,
|
||||
removeTracked(entity) {
|
||||
entity.point.gevBank?.remove(entity.point);
|
||||
tracked.delete(entity);
|
||||
if (entity.follow && engine.camera.following() === entity.follow)
|
||||
follow.release();
|
||||
},
|
||||
follow,
|
||||
listenClick(listener) {
|
||||
const dispatcher = selectionDispatcherFor(engine);
|
||||
if (!dispatcher)
|
||||
throw new Error('Satellite selection dispatcher unavailable');
|
||||
const remove = dispatcher.register('satellites', {
|
||||
owns: (id) => points.has(Number(id)),
|
||||
onClick: listener,
|
||||
});
|
||||
return { destroy: remove };
|
||||
},
|
||||
pickedNorad: (picked) => Number(picked?.id ?? picked?.primitive?.id),
|
||||
isTrackedPick: (picked, entity) =>
|
||||
Boolean(
|
||||
entity &&
|
||||
Number(picked?.id ?? picked?.primitive?.id) === entity.point.id,
|
||||
),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
for (const orbit of orbits) orbit.line.destroy();
|
||||
orbits.clear();
|
||||
for (const entry of banks.values()) entry.bank.destroy();
|
||||
banks.clear();
|
||||
dirty.clear();
|
||||
points.clear();
|
||||
tracked.clear();
|
||||
layerView.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { WHITE, nearFarScale } from '../../shared/color.js';
|
||||
import { createSatellitesNavaraRenderer } from './navara.js';
|
||||
|
||||
const position = geo.cartesian3.fromDegrees(0, 0, 420000);
|
||||
const spec = (id) => ({
|
||||
id,
|
||||
position,
|
||||
color: WHITE,
|
||||
pixelSize: 12,
|
||||
outlineColor: WHITE.withAlpha(0.3),
|
||||
outlineWidth: 1,
|
||||
scaleByDistance: nearFarScale(1e6, 1.5, 2e7, 0.6),
|
||||
});
|
||||
|
||||
test('satellite banks retain styles, survive growth and update the real store', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createSatellitesNavaraRenderer(engine);
|
||||
const points = renderer.createPoints();
|
||||
const all = Array.from({ length: 1500 }, (_, i) => points.add(spec(i)));
|
||||
await Promise.resolve();
|
||||
const [listed] = engine.movers.list();
|
||||
const set = engine.fake.moverSet(listed.id);
|
||||
assert.equal(engine.movers.list().length, 1);
|
||||
assert.equal(set.store.count, 1500);
|
||||
assert.equal(set.store.pickIds[699], 699);
|
||||
assert.deepEqual(set.options.point.outlineColor, [1, 1, 1, 0.3]);
|
||||
const before = set.commits;
|
||||
const remove = renderer.onFrame(() => {
|
||||
all[0].position = { x: 1, y: 2, z: 3 };
|
||||
all[0].show = false;
|
||||
});
|
||||
engine.fake.frame();
|
||||
remove();
|
||||
assert.equal(engine.fake.moverSet(listed.id).commits, before + 1);
|
||||
assert.deepEqual([...set.store.positions.slice(0, 3)], [1, 2, 3]);
|
||||
assert.equal(set.store.shows[0], 0);
|
||||
all[1].pixelSize = 14;
|
||||
await Promise.resolve();
|
||||
assert.equal(set.store.count, 1499);
|
||||
assert.equal(engine.movers.list().length, 2);
|
||||
points.removeAll();
|
||||
await Promise.resolve();
|
||||
assert.ok(engine.movers.list().every((s) => s.count === 0));
|
||||
renderer.destroy();
|
||||
assert.equal(engine.movers.list().length, 0);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('tracked glyph uses the follow sample and stays visible independently of fleet', async () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createSatellitesNavaraRenderer(engine);
|
||||
const points = renderer.createPoints();
|
||||
points.add(spec(25544));
|
||||
let sample = position;
|
||||
const entity = renderer.createTracked({
|
||||
id: 25544,
|
||||
position: () => sample,
|
||||
viewFrom: { x: -450000, y: -450000, z: 350000 },
|
||||
});
|
||||
entity.gevTrackedId = '25544';
|
||||
renderer.follow.start(entity);
|
||||
points.show = false;
|
||||
const remove = renderer.onFrame(() => {});
|
||||
sample = { x: position.x + 7600, y: 100, z: 0 };
|
||||
engine.fake.frame();
|
||||
remove();
|
||||
assert.deepEqual({ ...renderer.trackedPosition(entity) }, sample);
|
||||
assert.equal(renderer.follow.current(), entity);
|
||||
const sets = engine.movers.list().map((s) => engine.fake.moverSet(s.id));
|
||||
assert.equal(sets.find((s) => s.options.order === 100).store.shows[0], 1);
|
||||
assert.equal(sets.find((s) => s.options.order === 0).store.count, 0);
|
||||
renderer.removeTracked(entity);
|
||||
assert.equal(renderer.follow.current(), null);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('orbit starts at identity, rotates without rebuilding and releases lines', () => {
|
||||
const engine = createFakeEngine();
|
||||
const renderer = createSatellitesNavaraRenderer(engine);
|
||||
const orbit = renderer.createOrbit({
|
||||
positions: [position, { x: 0, y: 6778137, z: 0 }],
|
||||
width: 2.5,
|
||||
color: WHITE,
|
||||
});
|
||||
assert.deepEqual(orbit.modelMatrix, geo.matrix4.IDENTITY);
|
||||
const before = engine.lines.list()[0];
|
||||
orbit.modelMatrix[12] = 123;
|
||||
renderer.commitOrbit(orbit);
|
||||
assert.equal(engine.lines.list()[0].id, before.id);
|
||||
orbit.show = false;
|
||||
assert.equal(engine.lines.list()[0].shown, false);
|
||||
renderer.removeOrbit(orbit);
|
||||
assert.equal(engine.lines.list().length, 0);
|
||||
renderer.destroy();
|
||||
engine.destroy();
|
||||
});
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user