From 54fe0aea406c1a8636c7f0aedf71f7ddd5b4fcc7 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 7 Nov 2026 06:20:48 +0400 Subject: [PATCH] Draw nine layers' fills through engine.ground, one display each Earthquakes, air quality, GNSS Integrity, fire perimeters, Prohibited & Restricted Airspace, Active Fires, S2 GhostMaps, cyclone advisories and submarine cables each have one render/display.js on the globe contract; their render/cesium* and render/navara* files go. Their discs, cells, polygons and corridors are engine.ground sets, so on Navara they come from the one ground screen pass with exact colours instead of nine hand-written classification passes and lit SDK vector layers. Cesium's look stays the reference: - Earthquake and air-quality discs have no outline (Cesium drew none on clamped ellipses); earthquake discs can now be picked on Navara too. - Perimeters, drone zones and GhostMaps polygons outline their outer ring only, as Cesium did. - GNSS cells stay out of the pick pass; cyclone cones, tracks and centres are culled by a port of Cesium's horizon occluder. - Submarine cables are one ground corridor set, as Cesium batched them; landing points, tips and stems are movers and lines. - FIRMS glows are a sprite set and its cells a ground set. The far-zoom heat raster, which no primitive draws yet, sits behind one seam, render/heatField.js, with a Cesium and a Navara backend. The shared Navara ground helpers that only these layers used go (navaraGroundCells, the cell and ground geometry, the zone renderers), as does the ground prototype script. Coupling: layer renderer lines fall from 1636/6953 to 1011/4832 (Cesium/Navara), Navara ground passes from 7 to 5, Cesium-importing files from 65 to 53, engine.extensions reads from 26 to 19. --- docs/CURRENT-STATE.md | 15 +- scripts/check-import-directions.mjs | 4 + scripts/engine-coupling-baseline.json | 20 +- scripts/format-scope.json | 46 +- scripts/package-boundaries.json | 114 +- scripts/qa-ground-prototypes.mjs | 313 --- src/app/layers/airQuality.js | 12 +- src/app/layers/cyclones.js | 15 +- src/app/layers/droneAirspace.js | 25 +- src/app/layers/earthquakes.js | 12 +- src/app/layers/firms.js | 27 +- src/app/layers/ghostMaps.js | 9 +- src/app/layers/gnss.js | 13 +- src/app/layers/perimeters.js | 21 +- src/app/layers/renderers.js | 32 +- src/app/layers/renderers.test.mjs | 14 + src/app/layers/submarineCables.js | 40 +- src/app/navaraMode.js | 90 +- src/cameraHandoff.test.mjs | 518 +++-- src/data/earthquakes.test.mjs | 158 +- src/data/firmsCards.test.mjs | 133 +- src/data/firmsHorizonCull.test.mjs | 560 ++++-- src/data/firmsInteraction.test.mjs | 313 ++- src/data/inputOwnership.test.mjs | 7 +- src/data/spriteOrder.test.mjs | 69 +- src/data/telegeographySubmarineCables.js | 7 +- .../telegeographySubmarineCables.test.mjs | 1760 +++-------------- src/i18n/layerStatusText.test.mjs | 2 +- .../airQuality/airQualityLocale.test.mjs | 6 +- src/layers/airQuality/index.js | 10 +- src/layers/airQuality/ownership.test.mjs | 42 +- src/layers/airQuality/render/cesium.js | 56 - src/layers/airQuality/render/display.js | 55 + src/layers/airQuality/render/display.test.mjs | 180 ++ src/layers/airQuality/render/navara.js | 62 - src/layers/cyclones/coverage.test.mjs | 3 +- src/layers/cyclones/index.js | 76 +- src/layers/cyclones/index.test.mjs | 250 +-- src/layers/cyclones/render/display.js | 408 ++++ src/layers/cyclones/render/display.test.mjs | 613 ++++++ src/layers/cyclones/render/navara.js | 209 -- src/layers/cyclones/rendering.js | 307 +-- src/layers/cyclones/rendering.test.mjs | 669 ------- src/layers/droneAirspace/index.js | 30 +- src/layers/droneAirspace/ownership.test.mjs | 118 +- src/layers/droneAirspace/render/display.js | 84 + .../droneAirspace/render/display.test.mjs | 272 +++ src/layers/droneAirspace/render/navara.js | 14 - src/layers/earthquakes/index.js | 49 +- src/layers/earthquakes/ownership.test.mjs | 47 +- src/layers/earthquakes/render/cesium.js | 83 - src/layers/earthquakes/render/cesium.test.mjs | 162 -- src/layers/earthquakes/render/display.js | 50 + .../earthquakes/render/display.test.mjs | 249 +++ src/layers/earthquakes/render/ground.js | 69 - src/layers/earthquakes/render/navara.js | 309 --- src/layers/earthquakes/render/navara.test.mjs | 148 -- .../earthquakes/render/navaraGeometry.js | 84 - .../render/navaraGeometry.test.mjs | 237 --- src/layers/earthquakes/render/navaraGrid.js | 5 - .../earthquakes/render/navaraGrid.test.mjs | 38 - src/layers/firms/cards.js | 12 +- src/layers/firms/edgeCases.test.mjs | 9 +- src/layers/firms/lifecycle.js | 51 +- src/layers/firms/queries.js | 16 +- src/layers/firms/render/cesium.js | 147 -- src/layers/firms/render/cesium.test.mjs | 137 -- src/layers/firms/render/cesiumHeat.js | 20 + src/layers/firms/render/display.js | 173 ++ src/layers/firms/render/display.test.mjs | 618 ++++++ src/layers/firms/render/heatField.js | 68 + src/layers/firms/render/navara.js | 151 -- src/layers/firms/render/navara.test.mjs | 188 -- .../firms/render/navaraGeometry.test.mjs | 120 -- .../firms/render/navaraSprites.test.mjs | 177 -- .../render/{navaraSprites.js => sprites.js} | 76 +- src/layers/firms/rendering.js | 21 +- src/layers/firms/selection.js | 25 +- src/layers/firms/viewport.js | 8 +- src/layers/ghostMaps/index.js | 21 +- src/layers/ghostMaps/lifecycle.test.mjs | 181 +- src/layers/ghostMaps/render/cesium.js | 157 -- src/layers/ghostMaps/render/display.js | 388 ++++ src/layers/ghostMaps/render/display.test.mjs | 515 +++++ src/layers/ghostMaps/render/navara.js | 145 -- src/layers/gnss/index.js | 11 +- src/layers/gnss/ownership.test.mjs | 207 +- src/layers/gnss/render/cesium.js | 99 - src/layers/gnss/render/display.js | 89 + src/layers/gnss/render/display.test.mjs | 173 ++ src/layers/gnss/render/navara.js | 40 - src/layers/gnss/sourceErrorLocale.test.mjs | 10 +- src/layers/perimeters/index.js | 17 +- src/layers/perimeters/ownership.test.mjs | 272 ++- src/layers/perimeters/render/cesium.js | 20 - src/layers/perimeters/render/display.js | 83 + src/layers/perimeters/render/display.test.mjs | 269 +++ src/layers/perimeters/render/ground.js | 46 - src/layers/perimeters/render/navara.js | 13 - src/layers/shared/render/cesiumZones.js | 77 - .../shared/render/navaraCellGeometry.js | 69 - src/layers/shared/render/navaraGroundCells.js | 310 --- .../shared/render/navaraGroundGeometry.js | 62 - .../render/navaraGroundGeometry.test.mjs | 29 - src/layers/shared/render/navaraGroundGrid.js | 69 - .../shared/render/navaraGroundOrder.test.mjs | 6 - src/layers/shared/render/navaraZones.js | 90 - src/layers/submarineCables/edgeCases.test.mjs | 143 -- src/layers/submarineCables/geometry.js | 43 - src/layers/submarineCables/index.js | 44 +- src/layers/submarineCables/ingestion.js | 237 --- src/layers/submarineCables/interaction.js | 78 - src/layers/submarineCables/lifecycle.js | 156 -- src/layers/submarineCables/overlay.js | 40 +- src/layers/submarineCables/policy.js | 25 +- src/layers/submarineCables/render/display.js | 488 +++++ .../submarineCables/render/display.test.mjs | 763 +++++++ src/layers/submarineCables/render/navara.js | 271 --- src/layers/submarineCables/rendering.js | 274 --- src/layers/submarineCables/session.js | 42 +- src/layers/submarineCables/session.test.mjs | 121 +- src/layers/submarineCables/state.js | 107 - src/layers/submarineCables/surface.js | 139 +- src/testSupport/groundDisplayEngines.mjs | 184 ++ 124 files changed, 8407 insertions(+), 9576 deletions(-) delete mode 100644 scripts/qa-ground-prototypes.mjs delete mode 100644 src/layers/airQuality/render/cesium.js create mode 100644 src/layers/airQuality/render/display.js create mode 100644 src/layers/airQuality/render/display.test.mjs delete mode 100644 src/layers/airQuality/render/navara.js create mode 100644 src/layers/cyclones/render/display.js create mode 100644 src/layers/cyclones/render/display.test.mjs delete mode 100644 src/layers/cyclones/render/navara.js delete mode 100644 src/layers/cyclones/rendering.test.mjs create mode 100644 src/layers/droneAirspace/render/display.js create mode 100644 src/layers/droneAirspace/render/display.test.mjs delete mode 100644 src/layers/droneAirspace/render/navara.js delete mode 100644 src/layers/earthquakes/render/cesium.js delete mode 100644 src/layers/earthquakes/render/cesium.test.mjs create mode 100644 src/layers/earthquakes/render/display.js create mode 100644 src/layers/earthquakes/render/display.test.mjs delete mode 100644 src/layers/earthquakes/render/ground.js delete mode 100644 src/layers/earthquakes/render/navara.js delete mode 100644 src/layers/earthquakes/render/navara.test.mjs delete mode 100644 src/layers/earthquakes/render/navaraGeometry.js delete mode 100644 src/layers/earthquakes/render/navaraGeometry.test.mjs delete mode 100644 src/layers/earthquakes/render/navaraGrid.js delete mode 100644 src/layers/earthquakes/render/navaraGrid.test.mjs delete mode 100644 src/layers/firms/render/cesium.js delete mode 100644 src/layers/firms/render/cesium.test.mjs create mode 100644 src/layers/firms/render/cesiumHeat.js create mode 100644 src/layers/firms/render/display.js create mode 100644 src/layers/firms/render/display.test.mjs create mode 100644 src/layers/firms/render/heatField.js delete mode 100644 src/layers/firms/render/navara.js delete mode 100644 src/layers/firms/render/navara.test.mjs delete mode 100644 src/layers/firms/render/navaraGeometry.test.mjs delete mode 100644 src/layers/firms/render/navaraSprites.test.mjs rename src/layers/firms/render/{navaraSprites.js => sprites.js} (66%) delete mode 100644 src/layers/ghostMaps/render/cesium.js create mode 100644 src/layers/ghostMaps/render/display.js create mode 100644 src/layers/ghostMaps/render/display.test.mjs delete mode 100644 src/layers/ghostMaps/render/navara.js delete mode 100644 src/layers/gnss/render/cesium.js create mode 100644 src/layers/gnss/render/display.js create mode 100644 src/layers/gnss/render/display.test.mjs delete mode 100644 src/layers/gnss/render/navara.js delete mode 100644 src/layers/perimeters/render/cesium.js create mode 100644 src/layers/perimeters/render/display.js create mode 100644 src/layers/perimeters/render/display.test.mjs delete mode 100644 src/layers/perimeters/render/ground.js delete mode 100644 src/layers/perimeters/render/navara.js delete mode 100644 src/layers/shared/render/cesiumZones.js delete mode 100644 src/layers/shared/render/navaraCellGeometry.js delete mode 100644 src/layers/shared/render/navaraGroundCells.js delete mode 100644 src/layers/shared/render/navaraGroundGeometry.js delete mode 100644 src/layers/shared/render/navaraGroundGeometry.test.mjs delete mode 100644 src/layers/shared/render/navaraGroundGrid.js delete mode 100644 src/layers/shared/render/navaraZones.js delete mode 100644 src/layers/submarineCables/edgeCases.test.mjs delete mode 100644 src/layers/submarineCables/ingestion.js delete mode 100644 src/layers/submarineCables/interaction.js delete mode 100644 src/layers/submarineCables/lifecycle.js create mode 100644 src/layers/submarineCables/render/display.js create mode 100644 src/layers/submarineCables/render/display.test.mjs delete mode 100644 src/layers/submarineCables/render/navara.js delete mode 100644 src/layers/submarineCables/rendering.js delete mode 100644 src/layers/submarineCables/state.js create mode 100644 src/testSupport/groundDisplayEngines.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fe2b57075..f11006d2c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -223,8 +223,15 @@ one application, and only the globe-bound parts move to the next engine. engine over the globe contract: the aircraft family (flights, military and local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places, - Wildlife Sightings, radio, transit and CCTV through their own - `render/display.js`. CCTV's monitor plane and viewshed volume, which the + Wildlife Sightings, radio, transit, CCTV, earthquakes, air quality, GNSS + Integrity, fire perimeters, Prohibited & Restricted Airspace, Active Fires, + S2 GhostMaps, cyclone advisories and submarine cables through their own + `render/display.js`; the fills of the last nine are `engine.ground` sets. + The Active Fires far-zoom heatmap, a raster draped on the ground that the + contract has no primitive for yet, draws behind one seam, + `src/layers/firms/render/heatField.js` (`render/cesiumHeat.js`, an imagery + layer, on Cesium; `render/navaraHeat.js`, a surface pass that the Navara + mode loads and passes in). CCTV's monitor plane and viewshed volume, which the contract has no primitive for yet, draw behind one seam, `src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium; `render/navaraMeshes.js`, which the Navara mode loads and passes in). Cesium @@ -863,7 +870,7 @@ forecast lead-hour points and cones render only when all source parts match the status advisory; a newer status shows its position with geometry pending rather than relabeling older geometry. The cone represents forecast center uncertainty, not storm size or the full hazard area. Successful empty and unavailable states -are distinct. One owned native Cesium data source preserves polygon holes and +are distinct. The display draws centres and forecast points as point movers and each storm's track and cone as ground sets, keeping polygon holes and geographic seams and releases on disable; forecast animation is not implied. NHC movement speed is converted from miles per hour to rounded knots by the proxy; advisory wind intensity is already in knots and is passed through. @@ -2492,7 +2499,7 @@ retaining rooftop and below-sea-level elevations. A hidden globe does not constrain photoreal geometry, and retry work remains bounded. Already sampled nearby markers follow higher settled terrain on existing bounded walks, without additional GPU samples or timers, when close-up tile detail refines the floor. -The submarine-cable renderer is unchanged. These limits bound active stem work; +Submarine cables draw through `render/display.js` on every engine. These limits bound active stem work; they do not reduce the materialized entity count or establish an FPS gain. ## Keyboard interaction and focus diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index f0f594f75..e8ff26de3 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -34,6 +34,10 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // cesium only in render/cesium*.js, three and Navara only in // render/navara*.js. The list grows with each layer moved onto renderers. const RENDERED_LAYER_DIRS = [ + 'src/layers/droneAirspace/', + 'src/layers/cyclones/', + 'src/layers/ghostMaps/', + 'src/layers/submarineCables/', 'src/layers/alpr/', 'src/layers/cctv/', 'src/layers/transit/', diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index f99112d0a..6c4427255 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -1,16 +1,16 @@ { "metrics": { - "cesiumLayerRendererLines": 1636, - "navaraLayerRendererLines": 6953, - "cesiumImportFiles": 65, - "viewerLines": 1901, - "navaraKindChecks": 12, + "cesiumLayerRendererLines": 1011, + "navaraLayerRendererLines": 4832, + "cesiumImportFiles": 53, + "viewerLines": 1745, + "navaraKindChecks": 9, "engineSniffCalls": 14, - "engineExtensionReads": 26, - "groundEffectPasses": 7, + "engineExtensionReads": 19, + "groundEffectPasses": 5, "cesiumSdkLoads": 6, - "screenSpaceHandlerFiles": 10, - "engineNameLines": 133 + "screenSpaceHandlerFiles": 8, + "engineNameLines": 108 }, - "measuredAt": "after the radio, transit and CCTV displays; one engine.extensions read is CCTV's meshes seam, 2026-11-07" + "measuredAt": "after the ground layers (earthquakes, air quality, GNSS, perimeters, drone airspace, FIRMS, GhostMaps, cyclones, cables), 2026-11-07" } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index cf0d738b8..562b7909c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -625,13 +625,8 @@ "src/layers/submarineCables/bundledSource.js", "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", - "src/layers/submarineCables/ingestion.js", - "src/layers/submarineCables/interaction.js", - "src/layers/submarineCables/lifecycle.js", "src/layers/submarineCables/overlay.js", "src/layers/submarineCables/policy.js", - "src/layers/submarineCables/rendering.js", - "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js", "src/data/telegeographySubmarineCables.js", "src/data/telegeographySubmarineCables.test.mjs", @@ -908,7 +903,6 @@ "src/layers/cyclones/rendering.js", "src/layers/cyclones/labels.js", "src/layers/cyclones/source.js", - "src/layers/cyclones/rendering.test.mjs", "src/layers/cyclones/labels.test.mjs", "src/layers/cyclones/index.test.mjs", "src/layers/cyclones/source.test.mjs", @@ -1403,23 +1397,10 @@ "src/layers/localAdsb/qaDrawn.test.mjs", "src/tooling/w1RuntimeEvidence.test.mjs", "scripts/fixtures/w2/earthquakes.mjs", - "src/layers/earthquakes/render/cesium.js", - "src/layers/earthquakes/render/cesium.test.mjs", - "src/layers/earthquakes/render/navara.js", - "src/layers/earthquakes/render/navaraGeometry.js", - "src/layers/earthquakes/render/navaraGeometry.test.mjs", - "src/layers/earthquakes/render/navaraGrid.js", - "src/layers/earthquakes/render/navaraGrid.test.mjs", - "src/layers/earthquakes/render/navara.test.mjs", - "src/layers/firms/render/cesium.js", - "src/layers/firms/render/cesium.test.mjs", "src/layers/firms/rectangle.js", "src/layers/firms/rectangle.test.mjs", "src/layers/firms/viewport.test.mjs", "scripts/fixtures/w2/firms.mjs", - "src/layers/firms/render/navara.test.mjs", - "src/layers/firms/render/navaraGeometry.test.mjs", - "src/layers/firms/render/navaraSprites.test.mjs", "src/layers/firms/lifecycle.test.mjs", "scripts/fixtures/w2/radio.mjs", "scripts/qa-map-policy.mjs", @@ -1620,11 +1601,30 @@ "src/globe/navara/ground/pack.test.mjs", "src/globe/navara/ground/pass.js", "src/globe/navara/ground/vector.js", - "src/layers/earthquakes/render/ground.js", - "src/layers/perimeters/render/ground.js", "scripts/qa-ground-suite.mjs", "scripts/qa-ground-bench.mjs", - "scripts/qa-ground-prototypes.mjs", "scripts/qa-ground-switch.mjs", - "docs/GLOBE-PRIMITIVES.md" + "docs/GLOBE-PRIMITIVES.md", + "src/layers/airQuality/render/display.js", + "src/layers/airQuality/render/display.test.mjs", + "src/layers/cyclones/render/display.js", + "src/layers/cyclones/render/display.test.mjs", + "src/layers/droneAirspace/render/display.js", + "src/layers/droneAirspace/render/display.test.mjs", + "src/layers/earthquakes/render/display.js", + "src/layers/earthquakes/render/display.test.mjs", + "src/layers/firms/render/cesiumHeat.js", + "src/layers/firms/render/display.js", + "src/layers/firms/render/display.test.mjs", + "src/layers/firms/render/heatField.js", + "src/layers/firms/render/sprites.js", + "src/layers/ghostMaps/render/display.js", + "src/layers/ghostMaps/render/display.test.mjs", + "src/layers/gnss/render/display.js", + "src/layers/gnss/render/display.test.mjs", + "src/layers/perimeters/render/display.js", + "src/layers/perimeters/render/display.test.mjs", + "src/layers/submarineCables/render/display.js", + "src/layers/submarineCables/render/display.test.mjs", + "src/testSupport/groundDisplayEngines.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 504b49f86..c3e0fcb58 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1583,7 +1583,8 @@ "src/layers/earthquakes/source.js", "src/layers/earthquakes/records.js", "src/geo/", - "src/layers/shared/color.js" + "src/layers/shared/color.js", + "src/layers/earthquakes/render/display.js" ], "external": [] }, @@ -2180,13 +2181,8 @@ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/session.js", - "src/layers/submarineCables/ingestion.js", - "src/layers/submarineCables/interaction.js", - "src/layers/submarineCables/lifecycle.js", "src/layers/submarineCables/overlay.js", "src/layers/submarineCables/policy.js", - "src/layers/submarineCables/rendering.js", - "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js", "src/data/inputOwnership.js", "src/i18n/index.js", @@ -2202,7 +2198,10 @@ "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", - "src/geo/" + "src/geo/", + "src/sources/requestDeadline.js", + "src/layers/shared/color.js", + "src/layers/submarineCables/render/display.js" ], "external": ["cesium"] }, @@ -2779,13 +2778,8 @@ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/session.js", - "src/layers/submarineCables/ingestion.js", - "src/layers/submarineCables/interaction.js", - "src/layers/submarineCables/lifecycle.js", "src/layers/submarineCables/overlay.js", "src/layers/submarineCables/policy.js", - "src/layers/submarineCables/rendering.js", - "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js", "src/layers/traffic/animation.js", "src/layers/traffic/controls.js", @@ -3058,20 +3052,16 @@ "src/layers/earthquakes/records.js", "src/app/layers/perimeters.js", "src/layers/perimeters/index.js", - "src/layers/perimeters/render/cesium.js", - "src/layers/shared/render/cesiumZones.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", "src/app/layers/gnss.js", "src/layers/gnss/index.js", - "src/layers/gnss/render/cesium.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", "src/app/layers/ghostMaps.js", "src/layers/ghostMaps/index.js", - "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", @@ -3299,7 +3289,6 @@ "src/data/geoImportImages.js", "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", - "src/layers/airQuality/render/cesium.js", "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", "src/ui/statusText.js", @@ -3477,23 +3466,13 @@ "src/layers/shared/render/engineView.js", "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", - "src/layers/earthquakes/render/cesium.js", - "src/layers/earthquakes/render/navara.js", - "src/layers/airQuality/render/navara.js", - "src/layers/gnss/render/navara.js", - "src/layers/perimeters/render/navara.js", "src/layers/shared/render/navaraPolygons.js", - "src/layers/shared/render/navaraZones.js", - "src/layers/droneAirspace/render/navara.js", - "src/layers/earthquakes/render/navaraGeometry.js", - "src/layers/earthquakes/render/navaraGrid.js", "src/layers/installations/render/navara.js", "src/layers/awareness/render/navara.js", "src/layers/awareness/render/navaraPulses.js", "src/layers/infrastructure/render/navara.js", "src/layers/shared/render/navaraStems.js", "src/layers/shared/render/navaraSceneDepth.js", - "src/layers/submarineCables/render/navara.js", "src/layers/directions/render/navara.js", "src/layers/traffic/render/navara.js", "src/layers/streetLevel/render/navara.js", @@ -3505,9 +3484,7 @@ "src/layers/weather/render/navaraSurface.js", "src/layers/wind/render/navara.js", "src/layers/wind/render/flowMesh.js", - "src/layers/ghostMaps/render/navara.js", "src/layers/shared/render/navaraGroundPicks.js", - "src/layers/cyclones/render/navara.js", "src/layers/shared/render/groundRing.js", "src/layers/bikeshare/stationPoints.js", "src/layers/shared/viewRectangle.js", @@ -3515,8 +3492,6 @@ "src/layers/installations/render/navaraOutlineBatch.js", "src/layers/installations/render/navaraStencilBatch.js", "src/layers/firms/rectangle.js", - "src/layers/firms/render/cesium.js", - "src/layers/firms/render/navara.js", "src/layers/shared/render/navaraMoverBank.js", "src/layers/shared/render/moverBank.js", "src/layers/recentImagery/render/navara.js", @@ -3526,13 +3501,8 @@ "src/layers/alpr/overlay.js", "src/layers/alpr/presentation.js", "src/layers/radio/screenClusters.js", - "src/layers/shared/render/navaraCellGeometry.js", - "src/layers/shared/render/navaraGroundCells.js", - "src/layers/firms/render/navaraSprites.js", "src/layers/shared/render/imageAtlas.js", "src/layers/firms/render/navaraHeat.js", - "src/layers/shared/render/navaraGroundGeometry.js", - "src/layers/shared/render/navaraGroundGrid.js", "src/layers/shared/render/navaraGroundSurface.js", "src/layers/shared/render/navaraGroundEffect.js", "src/annotations/outlineRungs.js", @@ -3602,7 +3572,19 @@ "src/layers/cctv/render/cesiumMeshes.js", "src/layers/cctv/render/meshes.js", "src/layers/cctv/render/display.js", - "src/layers/transit/render/display.js" + "src/layers/transit/render/display.js", + "src/layers/earthquakes/render/display.js", + "src/layers/airQuality/render/display.js", + "src/layers/submarineCables/render/display.js", + "src/layers/firms/render/sprites.js", + "src/layers/cyclones/render/display.js", + "src/layers/droneAirspace/render/display.js", + "src/layers/ghostMaps/render/display.js", + "src/layers/perimeters/render/display.js", + "src/layers/gnss/render/display.js", + "src/layers/firms/render/cesiumHeat.js", + "src/layers/firms/render/heatField.js", + "src/layers/firms/render/display.js" ], "external": [ "hls.js", @@ -3935,13 +3917,8 @@ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", "src/layers/submarineCables/session.js", - "src/layers/submarineCables/ingestion.js", - "src/layers/submarineCables/interaction.js", - "src/layers/submarineCables/lifecycle.js", "src/layers/submarineCables/overlay.js", "src/layers/submarineCables/policy.js", - "src/layers/submarineCables/rendering.js", - "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js", "src/layers/traffic/animation.js", "src/layers/traffic/controls.js", @@ -4031,20 +4008,16 @@ "src/layers/earthquakes/records.js", "src/app/layers/perimeters.js", "src/layers/perimeters/index.js", - "src/layers/perimeters/render/cesium.js", - "src/layers/shared/render/cesiumZones.js", "src/layers/perimeters/source.js", "src/layers/perimeters/records.js", "src/layers/perimeters/cards.js", "src/layers/perimeters/inciweb.js", "src/app/layers/gnss.js", "src/layers/gnss/index.js", - "src/layers/gnss/render/cesium.js", "src/layers/gnss/source.js", "src/layers/gnss/records.js", "src/app/layers/ghostMaps.js", "src/layers/ghostMaps/index.js", - "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", @@ -4211,7 +4184,6 @@ "src/data/cameraFraming.js", "src/app/layers/airQuality.js", "src/layers/airQuality/index.js", - "src/layers/airQuality/render/cesium.js", "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", "src/ui/statusText.js", @@ -4282,9 +4254,7 @@ "src/celestialRing/render/cesium.js", "src/celestialRing/view.js", "src/layers/shared/render/engineView.js", - "src/layers/earthquakes/render/cesium.js", "src/layers/firms/rectangle.js", - "src/layers/firms/render/cesium.js", "src/annotations/outlineRungs.js", "src/sources/openFreeMapOutlines.js", "src/sources/nominatimOutlines.js", @@ -4337,7 +4307,20 @@ "src/layers/cctv/render/cesiumMeshes.js", "src/layers/cctv/render/meshes.js", "src/layers/cctv/render/display.js", - "src/layers/transit/render/display.js" + "src/layers/transit/render/display.js", + "src/layers/earthquakes/render/display.js", + "src/layers/airQuality/render/display.js", + "src/layers/cyclones/render/display.js", + "src/layers/gnss/render/display.js", + "src/layers/firms/render/sprites.js", + "src/layers/submarineCables/render/display.js", + "src/layers/perimeters/render/display.js", + "src/layers/droneAirspace/render/display.js", + "src/globe/ground.js", + "src/layers/ghostMaps/render/display.js", + "src/layers/firms/render/cesiumHeat.js", + "src/layers/firms/render/heatField.js", + "src/layers/firms/render/display.js" ], "external": [ "hls.js", @@ -5185,8 +5168,6 @@ "src/geo/cartesian3.js", "src/geo/math.js", "src/layers/perimeters/index.js", - "src/layers/perimeters/render/cesium.js", - "src/layers/shared/render/cesiumZones.js", "src/sources/httpBody.js", "src/i18n/marker.js", "src/layers/perimeters/source.js", @@ -5205,7 +5186,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/layers/shared/color.js", + "src/layers/perimeters/render/display.js" ], "external": ["cesium"] }, @@ -5213,7 +5196,6 @@ "exports": ["./layers/gnss"], "modules": [ "src/layers/gnss/index.js", - "src/layers/gnss/render/cesium.js", "src/sources/httpBody.js", "src/i18n/marker.js", "src/layers/gnss/source.js", @@ -5232,7 +5214,18 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", - "src/ui/layerNames.js" + "src/ui/layerNames.js", + "src/layers/shared/viewRectangle.js", + "src/layers/shared/color.js", + "src/geo/math.js", + "src/geo/cartesian2.js", + "src/geo/matrix3.js", + "src/geo/cartesian3.js", + "src/geo/matrix4.js", + "src/geo/quaternion.js", + "src/geo/boundingSphere.js", + "src/geo/", + "src/layers/gnss/render/display.js" ], "external": ["cesium"] }, @@ -5279,7 +5272,6 @@ "exports": ["./layers/ghostmaps"], "modules": [ "src/layers/ghostMaps/index.js", - "src/layers/ghostMaps/render/cesium.js", "src/layers/ghostMaps/catalog.js", "src/layers/ghostMaps/records.js", "src/layers/ghostMaps/kml.js", @@ -5335,7 +5327,6 @@ "modules": [ "src/layers/droneAirspace/index.js", "src/geo/", - "src/layers/shared/render/cesiumZones.js", "src/sources/httpBody.js", "src/layers/droneAirspace/source.js", "src/layers/droneAirspace/records.js", @@ -5353,7 +5344,9 @@ "src/i18n/locales/ru.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/layers/shared/color.js", + "src/layers/droneAirspace/render/display.js" ], "external": ["cesium"] }, @@ -5538,7 +5531,6 @@ "src/geo/cartesian3.js", "src/geo/math.js", "src/layers/airQuality/index.js", - "src/layers/airQuality/render/cesium.js", "src/layers/airQuality/model.js", "src/layers/airQuality/source.js", "src/sources/httpBody.js", @@ -5557,7 +5549,9 @@ "src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-Hant.json", "src/ui/statusText.js", - "src/ui/layerNames.js" + "src/ui/layerNames.js", + "src/layers/shared/color.js", + "src/layers/airQuality/render/display.js" ], "external": ["cesium"] }, diff --git a/scripts/qa-ground-prototypes.mjs b/scripts/qa-ground-prototypes.mjs deleted file mode 100644 index 97d9acc41..000000000 --- a/scripts/qa-ground-prototypes.mjs +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env node -/** - * The engine.ground prototypes of earthquakes and fire perimeters - * (src/layers/earthquakes/render/ground.js, - * src/layers/perimeters/render/ground.js) against what each engine draws - * today, on fixed data over California in daylight. Per engine: the canvas - * without the layers, with today's renderers, with the ground prototypes; - * the pixels each drawing changed (a channel off by more than 24), and - * their overlap (intersection over union) with today's Cesium drawing; a - * pick of a quake and of a perimeter through engine.picker. Writes the - * images and a side-by-side to --out. - * - * GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4271 - * node scripts/qa-ground-prototypes.mjs http://localhost:4271 --out=DIR - */ -import fs from 'node:fs'; -import path from 'node:path'; -import puppeteer from 'puppeteer'; -import sharp from 'sharp'; - -const BLOCKED_HOSTS = - /(^|\.)(googleapis\.com|google\.com|gstatic\.com|cesium\.com)$/; -const args = process.argv.slice(2); -const origin = args.find((arg) => !arg.startsWith('--')); -const option = (name) => - args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3); -const out = option('out') ?? 'qa-ground-prototypes'; -if (!origin) { - console.error( - 'Usage: node scripts/qa-ground-prototypes.mjs --out=DIR', - ); - process.exit(2); -} -fs.mkdirSync(out, { recursive: true }); -const VIEW = { lon: -119.6, lat: 37.2, alt: 700000 }; - -async function capture(page, kind) { - const ui = await page.evaluate( - async (kind, view) => { - const { engine } = window.__godsEyeView; - const frame = () => - new Promise((resolve) => { - const remove = engine.frames.postRender.addEventListener(() => { - remove(); - resolve(); - }); - engine.render.request('ground-prototypes'); - }); - const settle = async () => { - let loaded = 0; - const started = performance.now(); - while (loaded < 20 && performance.now() - started < 60000) { - await frame(); - loaded = engine.surface.tilesLoaded() ? loaded + 1 : 0; - } - }; - const shot = async () => { - await settle(); - const canvas = engine.host.canvas; - const copy = document.createElement('canvas'); - copy.width = canvas.clientWidth; - copy.height = canvas.clientHeight; - copy.getContext('2d').drawImage(canvas, 0, 0, copy.width, copy.height); - return copy.toDataURL('image/png'); - }; - const geo = await import('/src/geo/index.js'); - const { depthColor } = await import('/src/layers/earthquakes/model.js'); - // Fixed quakes and perimeters (one with a hole) in the view. - const quakes = [ - [-121.8, 36.6, 3.1, 8], - [-120.6, 35.9, 4.6, 12], - [-118.4, 37.6, 5.4, 9], - [-117.6, 36.2, 2.8, 120], - [-121.0, 38.4, 3.9, 400], - [-119.0, 38.9, 6.0, 15], - ].map(([lon, lat, mag, depthKm], index) => ({ - stableId: `q${index}`, - usgsId: `q${index}`, - position: geo.cartesian3.fromDegrees(lon, lat), - baseRadius: 2 ** mag * 1000, - color: depthColor(depthKm), - isSignificant: mag >= 5, - fillAlpha: mag >= 5 ? 0.4 : 0.3, - outlineAlpha: mag >= 5 ? 1 : 0.8, - mag, - place: `fixture ${index}`, - time: 0, - depthKm, - })); - const blob = (lon, lat, rx, ry, count = 40) => { - const ring = []; - for (let k = 0; k < count; k++) { - const angle = (k / count) * 2 * Math.PI; - const wobble = 1 + 0.18 * Math.sin(angle * 5); - ring.push([ - lon + rx * wobble * Math.cos(angle), - lat + ry * wobble * Math.sin(angle), - ]); - } - ring.push(ring[0]); - return ring; - }; - const perimeters = [ - { - stableId: 'p0', - containedPct: 10, - polygons: [ - [blob(-120.2, 37.9, 0.35, 0.25), blob(-120.2, 37.9, 0.12, 0.08)], - ], - }, - { - stableId: 'p1', - containedPct: 60, - polygons: [[blob(-118.9, 36.6, 0.25, 0.3)]], - }, - { - stableId: 'p2', - containedPct: 100, - polygons: [ - [blob(-121.4, 37.4, 0.15, 0.12)], - [blob(-121.1, 37.1, 0.1, 0.1)], - ], - }, - ]; - // A restored view settles after the loading screen: take the camera. - await new Promise((resolve) => setTimeout(resolve, 3000)); - engine.camera.setView({ - destination: geo.cartesian3.fromDegrees(view.lon, view.lat, view.alt), - }); - const images = { base: await shot() }; - // Today's renderers on this engine. - let quakeRenderer; - let perimeterRenderer; - if (kind === 'cesium') { - const { viewer } = engine.extensions.cesium; - const { createEarthquakesCesiumRenderer } = - await import('/src/layers/earthquakes/render/cesium.js'); - const { createPerimetersCesiumRenderer } = - await import('/src/layers/perimeters/render/cesium.js'); - quakeRenderer = createEarthquakesCesiumRenderer(engine, { viewer }); - perimeterRenderer = createPerimetersCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - }); - } else { - const navara = engine.extensions.navara; - const { createEarthquakesNavaraRenderer } = - await import('/src/layers/earthquakes/render/navara.js'); - const { createPerimetersNavaraRenderer } = - await import('/src/layers/perimeters/render/navara.js'); - quakeRenderer = createEarthquakesNavaraRenderer(engine, navara); - perimeterRenderer = createPerimetersNavaraRenderer(engine, navara); - } - quakeRenderer.replace(quakes); - quakeRenderer.setVisible(true); - perimeterRenderer.replace(perimeters); - perimeterRenderer.setVisible(true); - for (let index = 0; index < 60; index++) await frame(); - images.today = await shot(); - quakeRenderer.destroy(); - perimeterRenderer.destroy(); - // The prototypes on engine.ground. - const { createEarthquakesGroundRenderer } = - await import('/src/layers/earthquakes/render/ground.js'); - const { createPerimetersGroundRenderer } = - await import('/src/layers/perimeters/render/ground.js'); - const quakesOnGround = createEarthquakesGroundRenderer(engine); - const perimetersOnGround = createPerimetersGroundRenderer(engine); - quakesOnGround.replace(quakes); - quakesOnGround.setVisible(true); - perimetersOnGround.replace(perimeters); - perimetersOnGround.setVisible(true); - const ready = () => - engine.ground.list().every(({ id }) => id) && - ['earthquakes', 'fire-perimeters'].every((id) => - engine.ground.list().some((entry) => entry.id === id), - ); - while (!ready()) await frame(); - for (let index = 0; index < 60; index++) await frame(); - images.ground = await shot(); - const screen = (lon, lat) => - engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat)); - const picks = { - quake: engine.picker.pick(screen(-118.4, 37.6))?.id ?? null, - perimeter: engine.picker.pick(screen(-118.9, 36.6))?.id ?? null, - hole: engine.picker.pick(screen(-120.2, 37.9))?.id ?? null, - records: quakesOnGround.records(10).length, - }; - quakesOnGround.destroy(); - perimetersOnGround.destroy(); - return { images, picks }; - }, - kind, - VIEW, - ); - return ui; -} - -async function openEngine(browser, kind) { - const page = await browser.newPage(); - await page.setViewport({ width: 1280, height: 800 }); - page.on('pageerror', (error) => console.log(' page error:', error.message)); - await page.setRequestInterception(true); - page.on('request', (request) => { - if (BLOCKED_HOSTS.test(new URL(request.url()).hostname)) request.abort(); - else request.continue(); - }); - await page.goto( - `${origin}/?welcome=0&engine=${kind}#v=2&lat=${VIEW.lat}&lon=${VIEW.lon}&alt=${VIEW.alt}&heading=0&pitch=-90&map=osm&l=`, - ); - await page.waitForFunction( - (expected) => - window.__godsEyeView?.engine?.kind === expected && - document.getElementById('loading-screen')?.classList.contains('hidden'), - { timeout: 300_000, polling: 250 }, - kind, - ); - // Daylight over California (Navara lights the scene by the app clock). - await page.evaluate(async () => { - const { appClock } = await import('/src/time/clock.js'); - appClock.freeze(Date.UTC(2026, 10, 7, 20, 0)); - }); - return page; -} - -const decode = async (dataUrl) => { - const { data, info } = await sharp( - Buffer.from(dataUrl.split(',')[1], 'base64'), - ) - .raw() - .ensureAlpha() - .toBuffer({ resolveWithObject: true }); - return { data, width: info.width, height: info.height }; -}; -// Pixels where `image` differs from `base` by more than 24 on a channel. -const changed = (base, image) => { - const mask = new Uint8Array(base.width * base.height); - for (let i = 0; i < mask.length; i++) - for (let c = 0; c < 3; c++) - if (Math.abs(base.data[i * 4 + c] - image.data[i * 4 + c]) > 24) { - mask[i] = 1; - break; - } - return mask; -}; -const iou = (a, b) => { - let both = 0; - let either = 0; - for (let i = 0; i < a.length; i++) { - both += a[i] & b[i]; - either += a[i] | b[i]; - } - return either ? both / either : 1; -}; -const count = (mask) => mask.reduce((total, value) => total + value, 0); - -const browser = await puppeteer.launch({ headless: true }); -const results = {}; -try { - for (const kind of ['cesium', 'navara']) { - const page = await openEngine(browser, kind); - results[kind] = await capture(page, kind); - await page.close(); - } -} finally { - await browser.close(); -} -const masks = {}; -for (const kind of ['cesium', 'navara']) { - const { images, picks } = results[kind]; - const base = await decode(images.base); - masks[kind] = { - today: changed(base, await decode(images.today)), - ground: changed(base, await decode(images.ground)), - }; - for (const name of ['base', 'today', 'ground']) - fs.writeFileSync( - path.join(out, `${kind}-${name}.png`), - Buffer.from(images[name].split(',')[1], 'base64'), - ); - console.log( - `${kind}: today changed ${count(masks[kind].today)} px, ground ${count(masks[kind].ground)} px; picks ${JSON.stringify(picks)}`, - ); -} -const reference = masks.cesium.today; -console.log( - `overlap with today's Cesium drawing (IoU of changed pixels): Cesium ground ${iou(reference, masks.cesium.ground).toFixed(3)}, Navara ground ${iou(reference, masks.navara.ground).toFixed(3)}, Navara today ${iou(reference, masks.navara.today).toFixed(3)}`, -); -// Side by side: Cesium today, Cesium ground, Navara today, Navara ground. -const tiles = await Promise.all( - ['cesium-today', 'cesium-ground', 'navara-today', 'navara-ground'].map( - (name) => - sharp(path.join(out, `${name}.png`)) - .resize(640, 400) - .png() - .toBuffer(), - ), -); -await sharp({ - create: { width: 1280, height: 800, channels: 3, background: '#000' }, -}) - .composite( - tiles.map((input, index) => ({ - input, - left: (index % 2) * 640, - top: Math.floor(index / 2) * 400, - })), - ) - .png() - .toFile(path.join(out, 'side-by-side.png')); diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js index 5d1cfbc43..171836404 100644 --- a/src/app/layers/airQuality.js +++ b/src/app/layers/airQuality.js @@ -1,15 +1,15 @@ import { rendererFor } from './renderers.js'; -import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js'; import { createAirQualityLayer } from '../../layers/airQuality/index.js'; import { overlayHost } from './overlayHost.js'; -/** Wire air-quality readings to the application overlay host. */ +/** + * Wire air-quality readings to the application overlay host; the station + * discs are the engine's renderer from the registry (render/display.js on + * every engine). + */ export function createApplicationAirQuality(options) { return createAirQualityLayer({ overlayHost, - createRenderer: ({ viewer, engine }) => - engine - ? rendererFor('air-quality', engine, { viewer }) - : createAirQualityCesiumRenderer({ viewer }), + createRenderer: ({ engine }) => rendererFor('air-quality', engine), ...options, }); } diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index e0cd508b0..27f090e9a 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -1,10 +1,21 @@ import { createCyclonesLayer } from '../../layers/cyclones/index.js'; import * as context from '../../data/contextStore.js'; +import { rendererFor } from './renderers.js'; -/** Wire cyclone selection to the application shared context. */ -export function createApplicationCyclones(options) { +/** + * Wire cyclone selection to the application shared context; the layer draws + * with the engine's renderer from the registry (render/display.js on every + * engine). The catalog passes the same factory as `createNativeRendering`. + */ +export function createApplicationCyclones({ + createNativeRendering, + createRendering = createNativeRendering ?? + ((engine, options) => rendererFor('weather-cyclones', engine, options)), + ...options +} = {}) { return createCyclonesLayer({ context: typeof window !== 'undefined' ? context : null, + createRendering, ...options, }); } diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js index 4fd942a30..a2cede31a 100644 --- a/src/app/layers/droneAirspace.js +++ b/src/app/layers/droneAirspace.js @@ -1,14 +1,14 @@ -import * as Cesium from 'cesium'; -import { - createDroneAirspaceLayer, - createDroneAirspaceCesiumRenderer, -} from '../../layers/droneAirspace/index.js'; +import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js'; import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Wire FAA special-use airspace into the application catalog. */ +/** + * Wire FAA special-use airspace into the application catalog; the zones are + * the engine's renderer from the registry (render/display.js on every + * engine). + */ export function createApplicationDroneAirspace(options) { return createDroneAirspaceLayer({ overlayHost: { @@ -17,18 +17,7 @@ export function createApplicationDroneAirspace(options) { clearSource: overlays.clearOverlaySource, hitTest: overlays.hitTestWorldOverlay, }, - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), - createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) => - engine - ? rendererFor('drone-airspace', engine, { - viewer, - screenSpaceEventHandlerFactory, - }) - : createDroneAirspaceCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, - }), + createRenderer: ({ engine }) => rendererFor('drone-airspace', engine), picking, pointer: { isPointerFree }, ...options, diff --git a/src/app/layers/earthquakes.js b/src/app/layers/earthquakes.js index 41b35bb32..9c43aa024 100644 --- a/src/app/layers/earthquakes.js +++ b/src/app/layers/earthquakes.js @@ -1,14 +1,14 @@ import { rendererFor } from './renderers.js'; -import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js'; import { createEarthquakesLayer } from '../../layers/earthquakes/index.js'; import { overlayHost } from './overlayHost.js'; -/** Wire earthquake observations to the application overlay host. */ +/** + * Wire earthquake observations to the application overlay host; the discs + * are the engine's renderer from the registry (render/display.js on every + * engine). + */ export function createApplicationEarthquakes({ renderer = { - create: ({ engine = null, ...options }) => - engine - ? rendererFor('earthquakes', engine, options) - : createEarthquakesCesiumRenderer(null, options), + create: ({ engine }) => rendererFor('earthquakes', engine), }, ...options } = {}) { diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 631b4e152..6c78eeeff 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -1,28 +1,23 @@ -import * as Cesium from 'cesium'; import { rendererFor } from './renderers.js'; -import { - createFirmsCesiumRenderer, - projectFirmsWindow, - firmsHorizonOccluder, - FIRMS_LEFT_CLICK, -} from '../../layers/firms/render/cesium.js'; import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; import * as render from '../../renderGovernor.js'; -import * as sprites from '../../data/spriteOrder.js'; import * as context from '../../data/contextStore.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import * as focus from '../../worldFocus.js'; export const firmsServices = { render, - sprites, context, picking, overlays, focus, }; -/** Bind a supplied fire feed to the application scene service owners. */ +/** + * Bind a supplied fire feed to the application scene service owners; it + * draws with the engine's renderer from the registry (render/display.js on + * every engine). + */ export function createApplicationFirms({ surface, ...options }) { return createLayer({ ...options, @@ -31,22 +26,14 @@ export function createApplicationFirms({ surface, ...options }) { feed: options.feed, services: { ...firmsServices, anchors: surface.anchors }, renderer: options.renderer ?? { - create: ({ engine = null, ...renderOptions }) => - engine - ? rendererFor('local-firms', engine, renderOptions) - : createFirmsCesiumRenderer(null, renderOptions), + create: ({ engine, ...renderOptions }) => + rendererFor('local-firms', engine, renderOptions), }, - projectToWindow: options.projectToWindow ?? projectFirmsWindow, - horizonOccluder: options.horizonOccluder ?? firmsHorizonOccluder, - leftClickEventType: options.leftClickEventType ?? FIRMS_LEFT_CLICK, overlayHost: options.overlayHost ?? { setEntries: overlays.setOverlayEntries, setVisible: overlays.setOverlaySourceVisible, clearSource: overlays.clearOverlaySource, hitTest: overlays.hitTestWorldOverlay, }, - screenSpaceEventHandlerFactory: - options.screenSpaceEventHandlerFactory ?? - ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), }); } diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index 6adbb0552..fd6306710 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -1,11 +1,14 @@ -import * as Cesium from 'cesium'; import { rendererFor } from './renderers.js'; import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Wire the S2 Underground GhostMaps layer into the application catalog. */ +/** + * Wire the S2 Underground GhostMaps layer into the application catalog; it + * draws with the engine's renderer from the registry (render/display.js on + * every engine). + */ export function createApplicationGhostMaps(options) { return createGhostMapsLayer({ overlayHost: { @@ -15,8 +18,6 @@ export function createApplicationGhostMaps(options) { hitTest: overlays.hitTestWorldOverlay, }, openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), picking, pointer: { isPointerFree }, createRenderer: (engine) => rendererFor('s2-ghostmaps', engine), diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 0b0bb435d..30a4515c1 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -1,14 +1,15 @@ import { rendererFor } from './renderers.js'; -import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js'; import { createGnssIntegrityLayer } from '../../layers/gnss/index.js'; -/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */ +/** + * Wire the adsb.lol GNSS navigation-integrity layer into the application + * catalog; the cells are the engine's renderer from the registry + * (render/display.js on every engine). + */ export function createApplicationGnssIntegrity(options) { return createGnssIntegrityLayer({ - createRenderer: ({ viewer, engine, viewAnchor }) => - engine - ? rendererFor('gnss-integrity', engine, { viewer, viewAnchor }) - : createGnssCesiumRenderer({ viewer, viewAnchor }), + createRenderer: ({ engine, viewAnchor }) => + rendererFor('gnss-integrity', engine, { viewAnchor }), ...options, }); } diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index 9ca24571f..9bac7a407 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -1,6 +1,4 @@ import { rendererFor } from './renderers.js'; -import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js'; -import * as Cesium from 'cesium'; import { createFirePerimetersLayer, createInciwebIndexSource, @@ -10,7 +8,11 @@ import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import { isPointerFree } from '../../data/inputOwnership.js'; -/** Wire WFIGS fire perimeters into the application catalog. */ +/** + * Wire WFIGS fire perimeters into the application catalog; the perimeters + * are the engine's renderer from the registry (render/display.js on every + * engine). + */ export function createApplicationFirePerimeters(options) { return createFirePerimetersLayer({ overlayHost: { @@ -22,18 +24,7 @@ export function createApplicationFirePerimeters(options) { inciwebSource: createInciwebIndexSource(), inciwebPublications: createInciwebPublicationSource(), openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'), - screenSpaceEventHandlerFactory: (viewer) => - new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas), - createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) => - engine - ? rendererFor('fire-perimeters', engine, { - viewer, - screenSpaceEventHandlerFactory, - }) - : createPerimetersCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, - }), + createRenderer: ({ engine }) => rendererFor('fire-perimeters', engine), picking, pointer: { isPointerFree }, ...options, diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 0d393b8d6..744c9ba2b 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -1,9 +1,3 @@ -import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js'; -import { createDroneAirspaceCesiumRenderer } from '../../layers/droneAirspace/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'; // Which engine draws which layer: the layer renderer registry and the layer // availability API built on it. // @@ -43,6 +37,15 @@ import { createBikeshareDisplay } from '../../layers/bikeshare/render/display.js import { createRadioDisplay } from '../../layers/radio/render/display.js'; import { createTransitDisplay } from '../../layers/transit/render/display.js'; import { createCctvDisplay } from '../../layers/cctv/render/display.js'; +import { createEarthquakesDisplay } from '../../layers/earthquakes/render/display.js'; +import { createAirQualityDisplay } from '../../layers/airQuality/render/display.js'; +import { createGnssDisplay } from '../../layers/gnss/render/display.js'; +import { createPerimetersDisplay } from '../../layers/perimeters/render/display.js'; +import { createDroneAirspaceDisplay } from '../../layers/droneAirspace/render/display.js'; +import { createFirmsDisplay } from '../../layers/firms/render/display.js'; +import { createGhostMapsDisplay } from '../../layers/ghostMaps/render/display.js'; +import { createCyclonesDisplay } from '../../layers/cyclones/render/display.js'; +import { createCablesDisplay } from '../../layers/submarineCables/render/display.js'; const ENGINE_KINDS = new Set(['cesium', 'navara']); @@ -102,19 +105,20 @@ export const layerAvailableOn = (layerId, engineKind) => /** Cesium renderers extracted from their layers (render/cesium.js). */ export const CESIUM_LAYER_RENDERERS = Object.freeze({ - earthquakes: createEarthquakesCesiumRenderer, - 'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options), - 'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options), - 'fire-perimeters': (_engine, options) => - createPerimetersCesiumRenderer(options), - 'drone-airspace': (_engine, options) => - createDroneAirspaceCesiumRenderer(options), + earthquakes: createEarthquakesDisplay, + 'air-quality': createAirQualityDisplay, + 'gnss-integrity': createGnssDisplay, + 'fire-perimeters': createPerimetersDisplay, + 'drone-airspace': createDroneAirspaceDisplay, 'osm-places': createOsmPlacesDisplay, 'alpr-cameras': createAlprDisplay, bikeshare: createBikeshareDisplay, radio: createRadioDisplay, transit: createTransitDisplay, - 'local-firms': createFirmsCesiumRenderer, + 'local-firms': createFirmsDisplay, + 's2-ghostmaps': createGhostMapsDisplay, + 'weather-cyclones': createCyclonesDisplay, + 'telegeography-submarine-cables': createCablesDisplay, 'rocket-launches': createLaunchesDisplay, satellites: createSatellitesDisplay, flights: createFlightsDisplay, diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 8f77dd0fd..e7e5c9f22 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -71,6 +71,9 @@ test('the layers with extracted renderers', () => { 'radio', 'transit', 'local-firms', + 's2-ghostmaps', + 'weather-cyclones', + 'telegeography-submarine-cables', 'rocket-launches', 'satellites', 'flights', @@ -94,6 +97,17 @@ test('the layers drawn through one display get the same factory on every engine' bikeshare: ['bikeshare', 'createBikeshareDisplay'], radio: ['radio', 'createRadioDisplay'], transit: ['transit', 'createTransitDisplay'], + earthquakes: ['earthquakes', 'createEarthquakesDisplay'], + 'air-quality': ['airQuality', 'createAirQualityDisplay'], + 'gnss-integrity': ['gnss', 'createGnssDisplay'], + 'fire-perimeters': ['perimeters', 'createPerimetersDisplay'], + 'drone-airspace': ['droneAirspace', 'createDroneAirspaceDisplay'], + 's2-ghostmaps': ['ghostMaps', 'createGhostMapsDisplay'], + 'weather-cyclones': ['cyclones', 'createCyclonesDisplay'], + 'telegeography-submarine-cables': [ + 'submarineCables', + 'createCablesDisplay', + ], }; // The Navara mode registers the same modules' factories. const navaraMode = readFileSync( diff --git a/src/app/layers/submarineCables.js b/src/app/layers/submarineCables.js index 6e1e60956..0e8ca3600 100644 --- a/src/app/layers/submarineCables.js +++ b/src/app/layers/submarineCables.js @@ -1,24 +1,24 @@ -import * as Cesium from 'cesium'; import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js'; -import { createCableSessionLayer } from '../../layers/submarineCables/session.js'; +import { CABLE_OVERLAY_SOURCE_ID } from '../../layers/submarineCables/policy.js'; import { rendererFor } from './renderers.js'; -import { overlayHost } from './overlayHost.js'; -/** Wire cable source geometry to the application overlay host. */ -export function createApplicationCables({ session = false, ...options }) { - const createCesium = (source) => - createSubmarineCableLayer({ +import { overlayHost as applicationOverlayHost } from './overlayHost.js'; + +/** + * Wire cable source geometry to the application overlay host and map-stack + * events; it draws with the engine's renderer from the registry + * (render/display.js on every engine). + */ +export function createApplicationCables({ + source, + overlayHost = applicationOverlayHost, + mapStackEventTarget = typeof window !== 'undefined' ? window : null, + sweepClock, + createDisplay = (engine) => + rendererFor(CABLE_OVERLAY_SOURCE_ID, engine, { overlayHost, - screenSpaceEventHandlerFactory: (canvas) => - new Cesium.ScreenSpaceEventHandler(canvas), - mapStackEventTarget: typeof window !== 'undefined' ? window : null, - ...options, - source, - }); - if (!session) return createCesium(options.source); - return createCableSessionLayer({ - source: options.source, - createCesium, - createNavara: (engine) => - rendererFor('telegeography-submarine-cables', engine, { overlayHost }), - }); + mapStackEventTarget, + ...(sweepClock ? { sweepClock } : {}), + }), +}) { + return createSubmarineCableLayer({ source, createDisplay }); } diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 615c9fe4c..1b8f30e75 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -170,28 +170,18 @@ export async function registerNavaraLayerRenderers() { return (engine) => createDirectionsNavaraRenderer(engine, engine.extensions.navara); }, - 'telegeography-submarine-cables': async () => { - const { createNavaraCables } = - await import('../layers/submarineCables/render/navara.js'); - return (engine, { overlayHost }) => - createNavaraCables(engine, engine.extensions.navara, overlayHost); - }, + 'telegeography-submarine-cables': async () => + (await import('../layers/submarineCables/render/display.js')) + .createCablesDisplay, wind: async () => { const { createWindNavaraRenderer } = await import('../layers/wind/render/navara.js'); return (engine) => createWindNavaraRenderer(engine, engine.extensions.navara); }, - 'weather-cyclones': async () => { - const { createCyclonesNavaraRenderer } = - await import('../layers/cyclones/render/navara.js'); - return (engine, options) => - createCyclonesNavaraRenderer( - engine, - engine.extensions.navara.view, - options, - ); - }, + 'weather-cyclones': async () => + (await import('../layers/cyclones/render/display.js')) + .createCyclonesDisplay, ...Object.fromEntries( ['local-datacenters', 'local-dams'].map((id) => [ id, @@ -208,12 +198,9 @@ export async function registerNavaraLayerRenderers() { }, ]), ), - 's2-ghostmaps': async () => { - const { createGhostMapsNavaraRenderer } = - await import('../layers/ghostMaps/render/navara.js'); - return (engine) => - createGhostMapsNavaraRenderer(engine, engine.extensions.navara.view); - }, + 's2-ghostmaps': async () => + (await import('../layers/ghostMaps/render/display.js')) + .createGhostMapsDisplay, 'military-awareness': async () => { const { createAwarenessNavaraRenderer } = await import('../layers/awareness/render/navara.js'); @@ -229,13 +216,17 @@ export async function registerNavaraLayerRenderers() { ...engine.extensions.navara, }); }, + // The FIRMS display, with the Navara backend of its heatmap. 'local-firms': async () => { - const { createFirmsNavaraRenderer } = - await import('../layers/firms/render/navara.js'); + const [{ createFirmsDisplay }, { createFirmsHeatDrape }] = + await Promise.all([ + import('../layers/firms/render/display.js'), + import('../layers/firms/render/navaraHeat.js'), + ]); return (engine, options) => - createFirmsNavaraRenderer(engine, { + createFirmsDisplay(engine, { ...options, - ...engine.extensions.navara, + navaraHeat: createFirmsHeatDrape, }); }, 'osm-places': async () => @@ -243,39 +234,20 @@ export async function registerNavaraLayerRenderers() { .createOsmPlacesDisplay, // Draws itself through the globe contract (engine.movers, engine.lines). 'wildlife-sightings': async () => () => null, - 'drone-airspace': async () => { - const { createDroneAirspaceNavaraRenderer } = - await import('../layers/droneAirspace/render/navara.js'); - return (engine) => - createDroneAirspaceNavaraRenderer(engine, engine.extensions.navara); - }, - 'fire-perimeters': async () => { - const { createPerimetersNavaraRenderer } = - await import('../layers/perimeters/render/navara.js'); - return (engine) => - createPerimetersNavaraRenderer(engine, engine.extensions.navara); - }, - 'gnss-integrity': async () => { - const { createGnssNavaraRenderer } = - await import('../layers/gnss/render/navara.js'); - return (engine) => - createGnssNavaraRenderer(engine, engine.extensions.navara); - }, - 'air-quality': async () => { - const { createAirQualityNavaraRenderer } = - await import('../layers/airQuality/render/navara.js'); - return (engine) => - createAirQualityNavaraRenderer(engine, engine.extensions.navara); - }, - earthquakes: async () => { - const { createEarthquakesNavaraRenderer } = - await import('../layers/earthquakes/render/navara.js'); - return (engine, options) => - createEarthquakesNavaraRenderer(engine, { - ...options, - ...engine.extensions.navara, - }); - }, + 'drone-airspace': async () => + (await import('../layers/droneAirspace/render/display.js')) + .createDroneAirspaceDisplay, + 'fire-perimeters': async () => + (await import('../layers/perimeters/render/display.js')) + .createPerimetersDisplay, + 'gnss-integrity': async () => + (await import('../layers/gnss/render/display.js')).createGnssDisplay, + 'air-quality': async () => + (await import('../layers/airQuality/render/display.js')) + .createAirQualityDisplay, + earthquakes: async () => + (await import('../layers/earthquakes/render/display.js')) + .createEarthquakesDisplay, // The same displays as on Cesium (renderers.js). 'rocket-launches': async () => (await import('../layers/launches/render/display.js')) diff --git a/src/cameraHandoff.test.mjs b/src/cameraHandoff.test.mjs index acec145cc..b723604f0 100644 --- a/src/cameraHandoff.test.mjs +++ b/src/cameraHandoff.test.mjs @@ -1,9 +1,13 @@ import { LayerBindings } from './ui/layerBindings.js'; -import { ScreenSpaceEventType } from 'cesium'; -import { FIRMS_LEFT_CLICK } from './layers/firms/render/cesium.js'; -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; -import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js'; +import { + enter as cockpitEnter, + navigateContext, +} from './ui/cockpitTrackingController.js'; import { CockpitViewController } from './ui/cockpitController.js'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -13,11 +17,24 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ui = readShellSource(); -const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js')); -const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js')); -const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8'); -const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8'); -const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8'); +const firms = readLayerSource( + path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'), +); +const vessels = readLayerSource( + path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'), +); +const voice = fs.readFileSync( + path.join(ROOT, 'src', 'voice', 'gevActions.js'), + 'utf8', +); +const cameraVerbs = fs.readFileSync( + path.join(ROOT, 'src', 'cameraVerbs.js'), + 'utf8', +); +const cockpitTracking = fs.readFileSync( + path.join(ROOT, 'src', 'cockpitTracking.js'), + 'utf8', +); function body(source, pattern, label) { const name = pattern.source.match(/^(\w+)/)?.[1]; @@ -33,8 +50,14 @@ function body(source, pattern, label) { function ordered(source, needles, label) { let previous = -1; for (const needle of needles) { - const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null; - const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1); + const match = + needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null; + const index = + needle instanceof RegExp + ? match + ? previous + 1 + match.index + : -1 + : source.indexOf(needle, previous + 1); assert.ok(index >= 0, `${label}: missing ${needle}`); assert.ok(index > previous, `${label}: ${needle} is out of order`); previous = index; @@ -43,12 +66,16 @@ function ordered(source, needles, label) { test('Cockpit takeover invalidates deferred work before camera cancellation', () => { const enter = cockpitEnter.toString(); - ordered(enter, [ - 'if (!info || !entity) return false;', - 'this.onCameraTakeover?.();', - 'this.host.camera.cancelFlight();', - 'this.host.release();', - ], 'Cockpit takeover'); + ordered( + enter, + [ + 'if (!info || !entity) return false;', + 'this.onCameraTakeover?.();', + 'this.host.camera.cancelFlight();', + 'this.host.release();', + ], + 'Cockpit takeover', + ); assert.match( ui, /onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/, @@ -82,11 +109,23 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res ); assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/); assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); - assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/); + assert.match( + stamp, + /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/, + ); assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/); - assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/, + ); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/, + ); + assert.match( + stamp, + /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/, + ); }); test('voice Cockpit entry reaches the camera only through stamping seams', () => { @@ -106,10 +145,14 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () => /export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/, 'Cockpit entry transaction', ); - ordered(transaction, [ - /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, - 'if (!entryError) entered = Boolean(cockpitView.enter());', - ], 'Cockpit entry transaction'); + ordered( + transaction, + [ + /if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/, + 'if (!entryError) entered = Boolean(cockpitView.enter());', + ], + 'Cockpit entry transaction', + ); // No direct camera control: every mutation goes through a layer tracker or // the cockpit controller, both of which stamp. assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/); @@ -148,27 +191,41 @@ test('voice Cockpit next/previous shares the manual Context navigation path', () /this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/, ); const funnel = navigateContext.toString(); - assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/); - ordered(funnel, [ - "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", - 'const navigationOptions = wasActive', - 'militaryAwarenessLayer?.[method]?.(navigationOptions)', - 'this._adoptTrackedEntity(performance.now());', - ], 'Cockpit Context navigation funnel'); + assert.match( + funnel, + /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/, + ); + ordered( + funnel, + [ + "const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';", + 'const navigationOptions = wasActive', + 'militaryAwarenessLayer?.[method]?.(navigationOptions)', + 'this._adoptTrackedEntity(performance.now());', + ], + 'Cockpit Context navigation funnel', + ); const navigate = body( ui, /if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/, 'controlCockpit navigation branch', ); - ordered(navigate, [ - 'this.cockpitView.navigateContext(', - "normalized === 'next' ? 1 : -1,", - ], 'controlCockpit navigation branch'); + ordered( + navigate, + ['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"], + 'controlCockpit navigation branch', + ); // No private camera route around the awareness layer. - assert.doesNotMatch(navigate, /flyTo|camera|trackById|_runExplicitNavigation/); + assert.doesNotMatch( + navigate, + /flyTo|camera|trackById|_runExplicitNavigation/, + ); // An exhausted cohort is an honest failure, not a silent success. assert.match(navigate, /ok: changed,/); - assert.match(navigate, /error: changed \? null : 'No further context target was available',/); + assert.match( + navigate, + /error: changed \? null : 'No further context target was available',/, + ); }); test('accepted navigation releases through PR15-aware ownership before flight', () => { @@ -177,37 +234,69 @@ test('accepted navigation releases through PR15-aware ownership before flight', /_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/, 'explicit navigation', ); - ordered(run, [ - 'cockpitActive: this.isCockpitActive()', - 'stamp: () => this._stampNavigation()', - 'release: () => this._releaseFollowCamera(releaseOptions)', - 'navigate,', - ], 'explicit navigation'); + ordered( + run, + [ + 'cockpitActive: this.isCockpitActive()', + 'stamp: () => this._stampNavigation()', + 'release: () => this._releaseFollowCamera(releaseOptions)', + 'navigate,', + ], + 'explicit navigation', + ); const release = body( ui, /_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'follow release', ); - ordered(release, [ - 'origin: trackingOrigin', - 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', - 'rocketLaunchesLayer.releaseCameraOwnership?.()', - 'if (this.viewer) this.viewer.trackedEntity = undefined;', - "interruptCameraMotion('explicit-navigation')", - 'camera?.cancelFlight();', - 'camera?.release();', - ], 'follow release'); - assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); - assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/); + ordered( + release, + [ + 'origin: trackingOrigin', + 'satellitesLayer.stopTracking?.({ origin: trackingOrigin })', + 'rocketLaunchesLayer.releaseCameraOwnership?.()', + 'if (this.viewer) this.viewer.trackedEntity = undefined;', + "interruptCameraMotion('explicit-navigation')", + 'camera?.cancelFlight();', + 'camera?.release();', + ], + 'follow release', + ); + assert.match( + release, + /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, + ); + assert.match( + release, + /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/, + ); }); test('validated voice camera destinations share the UI navigation authority facade', () => { - assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/); - assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/); + assert.match( + ui, + /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/, + ); + assert.match( + voice, + /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/, + ); const trackedVoice = voice.slice( voice.indexOf('async function trackEntity'), voice.indexOf('async function frameOverhead'), @@ -224,19 +313,25 @@ test('validated voice camera destinations share the UI navigation authority faca /export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/, 'move camera', ); - ordered(move, [ - 'if (argsError) return argsError;', - 'const start = () => {', - /const preserveCameraFlight =\s*motion === 'orbit'/, - 'runNavigation(start, { preserveCameraFlight })', - ], 'move validation before handoff'); + ordered( + move, + [ + 'if (argsError) return argsError;', + 'const start = () => {', + /const preserveCameraFlight =\s*motion === 'orbit'/, + 'runNavigation(start, { preserveCameraFlight })', + ], + 'move validation before handoff', + ); const moveArgs = body( cameraVerbs, /export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/, 'move camera argument checks', ); assert.ok( - moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"), + moveArgs.includes( + "if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", + ), 'unknown motions are refused before handoff', ); @@ -245,35 +340,64 @@ test('validated voice camera destinations share the UI navigation authority faca /export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,\s*engine = null,?\s*\) \{([\s\S]*?)\n\}/, 'fly route', ); - ordered(route, [ - 'if (!routes.length)', - 'const pts = route.path.map', - 'const start = () => {', - /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/, - ], 'route validation before handoff'); + ordered( + route, + [ + 'if (!routes.length)', + 'const pts = route.path.map', + 'const start = () => {', + /return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/, + ], + 'route validation before handoff', + ); // The corridor warm is injected the same way the floor READ is — the dolly // never reaches into the data layer itself, and the voice dispatch is the one // place that binds both. - assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/); + assert.match( + voice, + /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/, + ); }); test('deferred search releases only after its final authority check', () => { - const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); - ordered(search, [ - 'const authority = this.begin();', - 'this.onStart?.(authority);', - 'await this.search(query', - 'beforeFly: () => current() && this.beforeFly(authority)', - 'if (!current() || controller.signal.aborted) return;', - 'destination?.cancelled', - 'finally', - 'this.onSettled?.(authority)', - ], 'deferred search component'); - assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/); - assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/); - assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/); - assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/); - assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/); + const search = fs.readFileSync( + path.join(ROOT, 'src', 'ui', 'locationSearch.js'), + 'utf8', + ); + ordered( + search, + [ + 'const authority = this.begin();', + 'this.onStart?.(authority);', + 'await this.search(query', + 'beforeFly: () => current() && this.beforeFly(authority)', + 'if (!current() || controller.signal.aborted) return;', + 'destination?.cancelled', + 'finally', + 'this.onSettled?.(authority)', + ], + 'deferred search component', + ); + assert.match( + ui, + /begin: \(\) => this\._beginDeferredNavigation\('location'\)/, + ); + assert.match( + ui, + /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/, + ); + assert.match( + ui, + /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/, + ); + assert.match( + ui, + /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/, + ); + assert.doesNotMatch( + search.slice(0, search.indexOf('await this.search')), + /_releaseFollowCamera/, + ); }); test('a direct globe gesture retires delayed camera and selection restore only', () => { @@ -298,87 +422,141 @@ test('a direct globe gesture retires delayed camera and selection restore only', /_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/, 'navigation stamp', ); - assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/); + assert.match( + stamp, + /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/, + ); assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/); }); test('newer navigation, reset, Cockpit, and teardown share one generation', () => { assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1); const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset'); - ordered(reset, [ - 'if (this._globeResetPromise) return this._globeResetPromise;', - 'this._stampNavigation();', - "interruptCameraMotion('reset-globe')", - ], 'reset supersession'); + ordered( + reset, + [ + 'if (this._globeResetPromise) return this._globeResetPromise;', + 'this._stampNavigation();', + "interruptCameraMotion('reset-globe')", + ], + 'reset supersession', + ); const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); - ordered(dispose, [ - 'this._disposed = true;', - 'await this.detachScene(', - 'this._navigation.stop();', - ], 'dispose invalidation'); + ordered( + dispose, + [ + 'this._disposed = true;', + 'await this.detachScene(', + 'this._navigation.stop();', + ], + 'dispose invalidation', + ); // An engine switch retires the outgoing globe's navigation the same way. - const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach'); - ordered(detach, [ - 'this._navigation.setScene(', - 'this._layerBindings.stop();', - 'await this._contextControls.restoreForDisposal();', - ], 'detach invalidation'); - assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/); + const detach = body( + ui, + /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, + 'detach', + ); + ordered( + detach, + [ + 'this._navigation.setScene(', + 'this._layerBindings.stop();', + 'await this._contextControls.restoreForDisposal();', + ], + 'detach invalidation', + ); + assert.match( + shellMethod('destroy').toString(), + /this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/, + ); }); test('teardown synchronously closes immediate camera entry points', () => { const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose'); - ordered(dispose, [ - 'this._disposed = true;', - 'await this.detachScene(', - ], 'synchronous teardown barrier'); - const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach'); - ordered(detach, [ - 'this._layerBindings.stop();', - 'await this._contextControls.restoreForDisposal();', - ], 'synchronous detach barrier'); - ordered(LayerBindings.prototype.stop.toString(), [ - 'this._disposed = true;', - 'this._removeCctvRequestFocusListener?.();', - 'this._removeWorldRequestFocusListener?.();', - 'this._navigationOwnerChangedRemover?.();', - ], 'camera bindings close synchronously'); + ordered( + dispose, + ['this._disposed = true;', 'await this.detachScene('], + 'synchronous teardown barrier', + ); + const detach = body( + ui, + /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, + 'detach', + ); + ordered( + detach, + [ + 'this._layerBindings.stop();', + 'await this._contextControls.restoreForDisposal();', + ], + 'synchronous detach barrier', + ); + ordered( + LayerBindings.prototype.stop.toString(), + [ + 'this._disposed = true;', + 'this._removeCctvRequestFocusListener?.();', + 'this._removeWorldRequestFocusListener?.();', + 'this._navigationOwnerChangedRemover?.();', + ], + 'camera bindings close synchronously', + ); const navigation = body( ui, /_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/, 'explicit navigation', ); - ordered(navigation, [ - 'disposed: this._disposed', - 'release: () => this._releaseFollowCamera(releaseOptions)', - 'navigate,', - ], 'disposed navigation guard'); + ordered( + navigation, + [ + 'disposed: this._disposed', + 'release: () => this._releaseFollowCamera(releaseOptions)', + 'navigate,', + ], + 'disposed navigation guard', + ); const cctvFocus = body( ui, /_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/, 'explicit CCTV focus', ); - ordered(cctvFocus, [ - 'if (this._disposed) return false;', - 'const cameraId = activate();', - ], 'disposed CCTV activation guard'); + ordered( + cctvFocus, + ['if (this._disposed) return false;', 'const cameraId = activate();'], + 'disposed CCTV activation guard', + ); }); test('teardown refuses deferred location work before geocoding begins', () => { - const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation'); + const deferred = body( + ui, + /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, + 'deferred navigation', + ); assert.match(deferred, /disposed: this\._disposed/); - const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8'); - ordered(search, [ - 'if (!query || this.destroyed) return;', - 'const authority = this.begin();', - 'if (authority === false)', - 'this.input?.blur();', - 'this.onStart?.(authority);', - 'await this.search(query', - ], 'disposed search refusal'); - assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/); + const search = fs.readFileSync( + path.join(ROOT, 'src', 'ui', 'locationSearch.js'), + 'utf8', + ); + ordered( + search, + [ + 'if (!query || this.destroyed) return;', + 'const authority = this.begin();', + 'if (authority === false)', + 'this.input?.blur();', + 'this.onStart?.(authority);', + 'await this.search(query', + ], + 'disposed search refusal', + ); + assert.match( + search, + /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/, + ); }); test('refused canned destinations commit no location or POI state', () => { @@ -387,11 +565,15 @@ test('refused canned destinations commit no location or POI state', () => { ['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/], ]) { const handler = body(ui, pattern, name); - ordered(handler, [ - 'this._flyWithTransition(', - 'if (result === false) return;', - 'this._setActiveLocation(cityId);', - ], name); + ordered( + handler, + [ + 'this._flyWithTransition(', + 'if (result === false) return;', + 'this._setActiveLocation(cityId);', + ], + name, + ); } }); @@ -415,27 +597,39 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () => /function handleClick\(click\) \{([\s\S]*?)\n \}/, 'vessel click', ); - ordered(vesselClick, [ - "isOwnedByOtherLayer('ais-live-vessels', pickedId)", - '_vesselOverlayHost.hitTest?.(', - 'selectAndFocusVessel(record)', - ], 'vessel sibling ownership'); + ordered( + vesselClick, + [ + "isOwnedByOtherLayer('ais-live-vessels', pickedId)", + '_vesselOverlayHost.hitTest?.(', + 'selectAndFocusVessel(record)', + ], + 'vessel sibling ownership', + ); const vesselFocus = body( vessels, /function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/, 'vessel focus helper', ); assert.match(vesselFocus, /requestWorldFocus\(\{/); - assert.match(firms, /const clickType\s*=\s*layerState\._renderer\?\.clickType\s*\?\?\s*config\.leftClickEventType/); - assert.equal(FIRMS_LEFT_CLICK, ScreenSpaceEventType.LEFT_CLICK); + // Fire clicks come from the layer's renderer (the engine's selection + // dispatcher), with the pick under the click. + assert.match( + firms, + /layerState\._clickHandler = layerState\._renderer\.onClick\(\(click\) => \{/, + ); const fireClick = body( firms, - /_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/, + /_renderer\.onClick\(\(click\) => \{([\s\S]*?)\n \}\);/, 'fire click', ); - ordered(fireClick, [ - 'isOwnedByOtherLayer(id, pickedId)', - 'overlayHost.hitTest?.(', - 'selectAndFocusFire(carded)', - ], 'fire sibling ownership'); + ordered( + fireClick, + [ + 'isOwnedByOtherLayer(id, pickedId)', + 'overlayHost.hitTest?.(', + 'selectAndFocusFire(carded)', + ], + 'fire sibling ownership', + ); }); diff --git a/src/data/earthquakes.test.mjs b/src/data/earthquakes.test.mjs index 49641bed1..c92681677 100644 --- a/src/data/earthquakes.test.mjs +++ b/src/data/earthquakes.test.mjs @@ -5,6 +5,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import * as Cesium from 'cesium'; +import { createFakeGroundHarness } from '../testSupport/groundDisplayEngines.mjs'; import { EARTHQUAKE_OVERLAY_COHORT_LIMIT, EARTHQUAKE_OVERLAY_COLLISION_CAPACITY, @@ -92,25 +93,15 @@ test('earthquake overlay copy keeps source-side magnitude formatting and bounded assert.equal(cohort.at(-1).id, 'quake-020'); }); -test('real earthquake lifecycle publishes host labels while runtime entities carry no label graphic', async () => { +test('real earthquake lifecycle draws ground discs and publishes their labels to the host', async () => { const originalFetch = globalThis.fetch; const hostCalls = []; - const dataSources = []; + const ground = createFakeGroundHarness(); const overlayHost = { setEntries: (...args) => hostCalls.push(['entries', ...args]), setVisible: (...args) => hostCalls.push(['visible', ...args]), clearSource: (...args) => hostCalls.push(['clear', ...args]), }; - const viewer = { - 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; - }, - }, - }; globalThis.fetch = async () => ({ ok: true, json: async () => ({ @@ -130,13 +121,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car }); const layer = createEarthquakesLayer({ overlayHost }); try { - layer.init(viewer); - layer.enable(viewer); - await layer.update(viewer); + layer.init(null, { engine: ground.engine }); + layer.enable(); + await layer.update(); - const entities = dataSources[0].entities.values; - assert.equal(entities.length, 2, 'runtime guard requires populated real source entities'); - assert.ok(entities.every((entity) => entity.label === undefined)); + const { items } = ground.set('earthquakes'); + assert.equal(items.length, 2, 'runtime guard requires populated ground discs'); + assert.ok(items.every((item) => Object.keys(item).every((key) => + ['center', 'radiusM', 'fill', 'pickId'].includes(key))), 'a disc carries no label'); const publication = hostCalls.find(([type]) => type === 'entries'); assert.ok(publication, 'real update path must publish the overlay source'); assert.deepEqual(publication[2].map(({ title }) => title), ['M5.2', 'M3.0']); @@ -146,14 +138,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car moving: false, }); - layer.disable(viewer); - assert.equal(dataSources[0].show, false); + layer.disable(); + assert.equal(ground.set('earthquakes').shown, false); assert.deepEqual(hostCalls.slice(-2), [ ['clear', 'earthquakes'], ['visible', 'earthquakes', false], ]); - layer.destroy(viewer); - assert.equal(dataSources.length, 0); + layer.destroy(); + assert.equal(ground.sets(), 0); assert.deepEqual(hostCalls.slice(-2), [ ['clear', 'earthquakes'], ['visible', 'earthquakes', false], @@ -164,20 +156,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car }); // ── Perf pin: the 2026-08-20 earthquakes frame-rate cliff ──────────────────── -// Every disc is a CLAMP_TO_GROUND ellipse. When its axes were a -// `CallbackProperty`, Cesium re-tessellated all 58 ground primitives on EVERY -// frame: 32.4 ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with -// the layer off. Static axes restored 60 fps. Both halves of the fix are -// pinned — the runtime property shape, and the source-level guards. -test('quake disc axes are STATIC — a per-frame callback re-tessellates ground geometry', async () => { +// Every disc is ground geometry. When its axes were a per-frame callback, +// Cesium re-tessellated all 58 ground primitives on EVERY frame: 32.4 +// ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with the layer +// off. Static radii restored 60 fps. Both halves of the fix are pinned — one +// commit per refresh with a plain radius, and the source-level guards. +test('quake disc radii are STATIC — a per-frame rebuild re-tessellates ground geometry', async () => { const originalFetch = globalThis.fetch; - const dataSources = []; - const viewer = { - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove() { return true; }, - }, - }; + const ground = createFakeGroundHarness(); const layer = createEarthquakesLayer({ overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, }); @@ -192,26 +178,25 @@ test('quake disc axes are STATIC — a per-frame callback re-tessellates ground }), }); try { - layer.init(viewer); - layer.enable(viewer); - await layer.update(viewer); + layer.init(null, { engine: ground.engine }); + layer.enable(); + await layer.update(); - const [entity] = dataSources[0].entities.values; - assert.ok(entity.ellipse, 'the disc is an ellipse graphic'); - for (const axis of ['semiMajorAxis', 'semiMinorAxis']) { - const property = entity.ellipse[axis]; - assert.equal( - property instanceof Cesium.CallbackProperty, - false, - `${axis} must not be a CallbackProperty — it rebuilds the ground primitive every frame`, - ); - assert.equal(property.isConstant, true, `${axis} must be a constant property`); - // Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse. - assert.equal(property.getValue(Cesium.JulianDate.now()), Math.pow(2, 5.5) * 1000); - } + const set = ground.set('earthquakes'); + const [disc] = set.items; + assert.equal(set.options.kind, 'disc', 'the disc is a ground disc'); + // Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse. + assert.equal(disc.radiusM, Math.pow(2, 5.5) * 1000); + ground.engine.fake.frames(30); + assert.equal( + ground.set('earthquakes').commits, + set.commits, + 'frames must not rebuild the ground discs', + ); } finally { globalThis.fetch = originalFetch; - layer.destroy(viewer); + layer.destroy(); + ground.destroy(); } }); @@ -223,13 +208,9 @@ test('quake disc axes are STATIC — a per-frame callback re-tessellates ground test('a quake poll still reaches the screen with the render loop idle', async () => { const originalFetch = globalThis.fetch; const renderRequests = []; - const dataSources = []; + const ground = createFakeGroundHarness(); const viewer = { scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) }, - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove() { return true; }, - }, }; const layer = createEarthquakesLayer({ overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, @@ -251,7 +232,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () // frame. Drop that from the baseline or the enable assertion below can pass on // the install alone, without the manager ever requesting anything. renderRequests.length = 0; - const manager = new DataLayerManager(viewer); + const manager = new DataLayerManager(viewer, { engine: ground.engine }); // updateInterval -1 keeps the poll loop unarmed; we drive one tick by hand. manager.register({ ...layer, updateInterval: -1 }); try { @@ -264,7 +245,7 @@ test('a quake poll still reaches the screen with the render loop idle', async () // ...and the poll that populated the discs must still have asked for a frame, // because in idle mode nothing repaints on its own. - assert.ok(dataSources[0].entities.values.length > 0, 'the enable poll produced discs'); + assert.ok(ground.set('earthquakes').items.length > 0, 'the enable poll produced discs'); assert.ok(renderRequests.length > 0, 'enabling the layer must request a render in idle mode'); // Named, not merely counted: the frame has to come from the manager's // visibility request, not from some incidental repaint. @@ -290,11 +271,12 @@ test('a quake poll still reaches the screen with the render loop idle', async () globalThis.fetch = originalFetch; await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {}); _resetRenderGovernorForTest(); + ground.destroy(); } }); test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => { - const source = ['index', 'model', 'source', 'render/cesium'].map(name => + const source = ['index', 'model', 'source', 'render/display'].map(name => readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n'); assert.doesNotMatch( source, @@ -310,43 +292,36 @@ test('the earthquakes layer installs no per-frame callback and no continuous-ren test('earthquake refresh reports failure and clears it only after a successful response', async () => { const originalFetch = globalThis.fetch; - const dataSources = []; - const viewer = { - dataSources: { - add(dataSource) { dataSources.push(dataSource); return dataSource; }, - remove() { return true; }, - }, - }; + const ground = createFakeGroundHarness(); const layer = createEarthquakesLayer({ overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, }); try { - layer.init(viewer); - layer.enable(viewer); + layer.init(null, { engine: ground.engine }); + layer.enable(); globalThis.fetch = async () => ({ ok: false, status: 503 }); - assert.equal(await layer.update(viewer), false); + assert.equal(await layer.update(), false); assert.equal(layer.getStats().error, 'USGS HTTP 503'); globalThis.fetch = async () => ({ ok: true, json: async () => ({ features: [] }), }); - assert.equal(await layer.update(viewer), true); + assert.equal(await layer.update(), true); assert.equal(layer.getStats().error, null); assert.ok(Number.isFinite(layer.getStats().lastUpdate)); } finally { globalThis.fetch = originalFetch; - layer.destroy(viewer); + layer.destroy(); + ground.destroy(); } }); -test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => { +test('malformed earthquake refresh preserves discs, overlays, count and timestamp', async () => { const originalFetch = globalThis.fetch; - const dataSources = []; + const ground = createFakeGroundHarness(); + const discs = () => ground.set('earthquakes').items; const publications = []; - const viewer = { dataSources: { - add(source) { dataSources.push(source); }, remove() { return true; }, - } }; const layer = createEarthquakesLayer({ overlayHost: { setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {}, } }); @@ -354,19 +329,18 @@ test('malformed earthquake refresh preserves entities, overlays, count and times properties: { mag: 4, time: 100, place: 'Fixture' } }; const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); }; try { - layer.init(viewer); - layer.enable(viewer); + layer.init(null, { engine: ground.engine }); + layer.enable(); respond([good]); - assert.equal(await layer.update(viewer), true); - const entity = dataSources[0].entities.values[0]; + assert.equal(await layer.update(), true); + const drawn = discs(); const stats = layer.getStats(); for (const properties of ['invalid', 123, [], true]) { const bad = { ...good, id: 'bad-properties', properties }; for (const features of [[bad], [good, bad]]) { respond(features); - assert.equal(await layer.update(viewer), false); - assert.equal(dataSources[0].entities.values.length, 1); - assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(await layer.update(), false); + assert.equal(discs(), drawn); assert.equal(layer.getStats().count, stats.count); assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); assert.equal(layer.getStats().error, 'Malformed USGS response'); @@ -381,24 +355,24 @@ test('malformed earthquake refresh preserves entities, overlays, count and times { ...good, properties: { mag: '4' } }, { ...good, properties: { mag: Infinity } }, good]) { respond([good, bad]); - assert.equal(await layer.update(viewer), false); - assert.equal(dataSources[0].entities.values.length, 1); - assert.equal(dataSources[0].entities.values[0], entity); + assert.equal(await layer.update(), false); + assert.equal(discs(), drawn); assert.equal(layer.getStats().count, stats.count); assert.equal(layer.getStats().lastUpdate, stats.lastUpdate); assert.equal(layer.getStats().error, 'Malformed USGS response'); assert.equal(publications.length, 1); } respond([{ ...good, properties: { mag: null } }]); - assert.equal(await layer.update(viewer), true); - assert.equal(dataSources[0].entities.values.length, 0); + assert.equal(await layer.update(), true); + assert.equal(discs().length, 0); assert.equal(layer.getStats().error, null); assert.equal(publications.at(-1)[1].length, 0); respond([good]); - assert.equal(await layer.update(viewer), true); - assert.equal(dataSources[0].entities.values.length, 1); + assert.equal(await layer.update(), true); + assert.equal(discs().length, 1); } finally { globalThis.fetch = originalFetch; - layer.destroy(viewer); + layer.destroy(); + ground.destroy(); } }); diff --git a/src/data/firmsCards.test.mjs b/src/data/firmsCards.test.mjs index 9d6157324..86c7d2ce0 100644 --- a/src/data/firmsCards.test.mjs +++ b/src/data/firmsCards.test.mjs @@ -16,6 +16,8 @@ import { import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; import { getLocale, setLocale } from '../i18n/index.js'; import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js'; +import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs'; +import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs'; const NOW = Date.UTC(2026, 6, 17, 4, 0); const H = 3600000; @@ -40,13 +42,24 @@ function fire(overrides = {}) { } test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () => { - const card = buildFireCard({ fire: fire(), position: { x: 1, y: 2, z: 3 } }, NOW); + const card = buildFireCard( + { fire: fire(), position: { x: 1, y: 2, z: 3 } }, + NOW, + ); assert.equal(card.title, '▲ 1520 MW'); assert.equal(card.details.length, 1); assert.equal(card.details[0], 'high · 2h · N20'); assert.equal(card.selected, false); - assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot'); - assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched'); + assert.equal( + card.accent, + accentForSeverity('white-hot'), + 'FRP 1520 is white-hot', + ); + assert.deepEqual( + card.position, + { x: 1, y: 2, z: 3 }, + 'uses the candidate position untouched', + ); }); test('buildFireCard: missing acquisition time omits the age segment', () => { @@ -60,17 +73,17 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => { NOW, ); assert.equal(crossed.title, '▲ 10 MW'); - const below = buildFireCard( - { fire: fire({ frp: 9.94 }), position: {} }, - NOW, - ); + const below = buildFireCard({ fire: fire({ frp: 9.94 }), position: {} }, NOW); assert.equal(below.title, '▲ 9.9 MW'); const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW); assert.equal(selected.title, 'FIRE · 10 MW'); }); test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => { - const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW); + const card = buildFireCard( + { fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, + NOW, + ); assert.match(card.details[0], /SNPP$/); assert.equal(card.title, '▲ 0.8 MW'); assert.equal(card.accent, accentForSeverity('ember')); @@ -87,7 +100,10 @@ test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100 assert.equal(accentAt(300), accentForSeverity('white-hot')); // Confidence never moves the color: a low-confidence 864 MW fire stays red. assert.equal( - buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent, + buildFireCard( + { fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, + NOW, + ).accent, accentForSeverity('white-hot'), ); }); @@ -149,7 +165,10 @@ test('fire anchor: cold floor renders at 0, then re-grounds when the floor warms const f = fire({ lat: 55.501, lon: -120.501 }); const before = buildSelectedFireCard(f, NOW); const cartoBefore = Cesium.Cartographic.fromCartesian(before.position); - assert.ok(Math.abs(cartoBefore.height) < 0.5, `cold floor anchors at ellipsoid 0, got ${cartoBefore.height}`); + assert.ok( + Math.abs(cartoBefore.height) < 0.5, + `cold floor anchors at ellipsoid 0, got ${cartoBefore.height}`, + ); setMeshFloorPreferred(true); reportMeshFloorCell(55.501, -120.501, 900); @@ -157,23 +176,37 @@ test('fire anchor: cold floor renders at 0, then re-grounds when the floor warms const cartoAfter = Cesium.Cartographic.fromCartesian(after.position); assert.ok( Math.abs(cartoAfter.height - (900 + FIRE_ANCHOR_LIFT_M)) < 0.5, - `warm floor re-anchors at floor + lift, got ${cartoAfter.height}` + `warm floor re-anchors at floor + lift, got ${cartoAfter.height}`, ); }); test('buildCellCard: singular noun and missing-age omission', () => { - const candidate = { cell: { count: 1, maxFrp: 9.9, newestAcqMs: 0 }, position: {}, accent: undefined }; + const candidate = { + cell: { count: 1, maxFrp: 9.9, newestAcqMs: 0 }, + position: {}, + accent: undefined, + }; const card = buildCellCard(candidate, NOW); assert.equal(card.title, '1 FIRE'); assert.equal(card.details[0], 'max 9.9 MW'); - assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop'); + assert.equal( + card.accent, + accentForSeverity('ember'), + 'missing accent defaults to the weakest stop', + ); }); test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => { - const ambient = applyFirmsOverlayPolicy(buildFireCard({ - fire: fire(), - position: new Cesium.Cartesian3(1, 2, 3), - }, NOW), 4_500_000); + const ambient = applyFirmsOverlayPolicy( + buildFireCard( + { + fire: fire(), + position: new Cesium.Cartesian3(1, 2, 3), + }, + NOW, + ), + 4_500_000, + ); assert.equal(ambient.variant, 'card'); assert.equal(ambient.protected, false); assert.equal(ambient.collisionGroup, 'ambient-card'); @@ -182,56 +215,38 @@ test('FIRMS host policy keeps ambient cards bounded and selected cards protected assert.equal(ambient.edgeFade, 'keyhole'); assert.equal(ambient.cardStyle, 'tactical'); assert.equal(ambient.verticalOnly, true); - assert.equal(ambient.gapPx, Math.max(12, buildFireCard({ fire: fire(), position: {} }, NOW).gapPx + 8)); + assert.equal( + ambient.gapPx, + Math.max(12, buildFireCard({ fire: fire(), position: {} }, NOW).gapPx + 8), + ); assert.equal(ambient.leaderOffsetPx, ambient.gapPx - 6); - const selected = applyFirmsOverlayPolicy(buildSelectedFireCard(fire(), NOW), 4_500_000); + const selected = applyFirmsOverlayPolicy( + buildSelectedFireCard(fire(), NOW), + 4_500_000, + ); assert.equal(selected.variant, 'selected'); assert.equal(selected.protected, true); assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY); }); -class MockEvent { - constructor() { this.listeners = new Set(); } - - addEventListener(listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } -} - test('real FIRMS lifecycle clears host entries on disable and destroy', async () => { const originalFetch = globalThis.fetch; const originalWindow = globalThis.window; const calls = []; - const preRender = new MockEvent(); - const moveEnd = new MockEvent(); globalThis.fetch = async () => ({ ok: false, status: 503, json: async () => ({ error: 'no_key' }), }); globalThis.window = new EventTarget(); - const dataSources = []; - const viewer = { - dataSources: { - add(value) { dataSources.push(value); return value; }, - remove(value) { - const index = dataSources.indexOf(value); - if (index >= 0) dataSources.splice(index, 1); - }, - }, - camera: { - moveEnd, - positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000), - positionCartographic: { height: 1_000_000 }, - }, - scene: { - canvas: { clientWidth: 800, clientHeight: 600 }, - preRender, - primitives: { contains() { return false; } }, - }, - }; + const { engine, viewer } = createCesiumMoversEngine({ + engineOptions: { createEventHandler: createFakeEventHandlers().create }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + engine.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000), + }); const layer = createFirmsHeatmapLayer({ id: 'firms', name: 'FIRMS', @@ -240,29 +255,33 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async () setVisible: (...args) => calls.push(['visible', ...args]), clearSource: (...args) => calls.push(['clear', ...args]), }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), }); try { - layer.init(viewer); - await layer.enable(viewer); + layer.init(null, { engine }); + await layer.enable(); assert.deepEqual(calls.at(-1), ['visible', FIRMS_OVERLAY_SOURCE_ID, true]); layer.disable(); assert.deepEqual(calls.slice(-2), [ ['clear', FIRMS_OVERLAY_SOURCE_ID], ['visible', FIRMS_OVERLAY_SOURCE_ID, false], ]); - await layer.enable(viewer); - layer.destroy(viewer); + await layer.enable(); + layer.destroy(); assert.ok(calls.some(([type]) => type === 'clear')); assert.deepEqual(calls.slice(-2), [ ['clear', FIRMS_OVERLAY_SOURCE_ID], ['visible', FIRMS_OVERLAY_SOURCE_ID, false], ]); const callCount = calls.length; - await layer.enable(viewer); - assert.equal(calls.length, callCount, 'destroyed FIRMS layers reject late enable work'); + await layer.enable(); + assert.equal( + calls.length, + callCount, + 'destroyed FIRMS layers reject late enable work', + ); assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18); } finally { + engine.destroy(); globalThis.fetch = originalFetch; if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; diff --git a/src/data/firmsHorizonCull.test.mjs b/src/data/firmsHorizonCull.test.mjs index e7fe3afaf..ad18582a3 100644 --- a/src/data/firmsHorizonCull.test.mjs +++ b/src/data/firmsHorizonCull.test.mjs @@ -7,14 +7,22 @@ import { fireCullPosition, } from './firmsHeatmap.js'; import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js'; -import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js'; +import { + FIRMS_AMBIENT_COHORT_LIMIT, + FIRMS_OVERLAY_SOURCE_ID, +} from './firmsLabels.js'; import { _clearMeshFloorCellsForTest, reportMeshFloorCell, setMeshFloorPreferred, } from './groundFloor.js'; -import { unregisterSpriteCollection } from './spriteOrder.js'; -import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js'; +import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs'; +import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs'; +import { createFirmsDisplay } from '../layers/firms/render/display.js'; +import { + isOverlayPointVisible, + normalizeOverlayEntry, +} from '../overlays/worldOverlay.js'; const AUSTIN = { lon: -97.7, lat: 30.2 }; /** Antipode of Austin — as far behind the limb as a point on Earth can be. */ @@ -38,8 +46,13 @@ function billboard(position, show = true) { position, _show: show, writes: 0, - get show() { return this._show; }, - set show(value) { this._show = value; this.writes += 1; }, + get show() { + return this._show; + }, + set show(value) { + this._show = value; + this.writes += 1; + }, }; } @@ -62,17 +75,36 @@ function occluderOverAustin(heightM) { test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0)); - const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0)); - const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0)); - const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0)); + const near = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), + ); + const nearby = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0), + ); + const far = billboard( + Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), + ); + const otherSide = billboard( + Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0), + ); - const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder); + const visible = applyHorizonCull( + collection([near, nearby, far, otherSide]), + occluder, + ); assert.equal(near.show, true, 'the fire under the camera renders'); assert.equal(nearby.show, true, 'a fire well inside the horizon renders'); - assert.equal(far.show, false, 'the antipodal fire must not shine through the planet'); - assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm'); + assert.equal( + far.show, + false, + 'the antipodal fire must not shine through the planet', + ); + assert.equal( + otherSide.show, + false, + 'Tokyo is beyond the limb from Austin at 1.5 Mm', + ); assert.equal(visible, 2, 'returns the surviving count'); }); @@ -81,15 +113,26 @@ test('applyHorizonCull: raising the camera reveals fires the low camera hid', () const low = paris(); applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M)); - assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera'); + assert.equal( + low.show, + false, + 'Paris is over the horizon from a 1.5 Mm Austin camera', + ); const high = paris(); applyHorizonCull(collection([high]), occluderOverAustin(35_000_000)); - assert.equal(high.show, true, 'from geostationary height the same fire is in view'); + assert.equal( + high.show, + true, + 'from geostationary height the same fire is in view', + ); }); test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => { - const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false); + const revealed = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), + false, + ); applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M)); assert.equal(revealed.show, true, 'the cull un-hides as well as hides'); assert.equal(revealed.writes, 1); @@ -97,25 +140,56 @@ test('applyHorizonCull: a hidden fire is restored when it swings back into view' test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true); - const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true); + const stays = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), + true, + ); + const flips = billboard( + Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), + true, + ); applyHorizonCull(collection([stays, flips]), occluder); applyHorizonCull(collection([stays, flips]), occluder); - assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned'); - assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass'); + assert.equal( + stays.writes, + 0, + 'an already-visible billboard is never re-assigned', + ); + assert.equal( + flips.writes, + 1, + 'a hidden billboard is written once, not once per pass', + ); }); test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => { assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0); - assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape'); - const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0)); - assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone'); - assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible'); + assert.equal( + applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), + 0, + 'non-collection shape', + ); + const item = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), + ); + assert.equal( + applyHorizonCull(collection([item]), null), + 0, + 'no occluder → leave the layer alone', + ); + assert.equal( + applyHorizonCull(collection([item]), {}), + 0, + 'occluder without isPointVisible', + ); assert.equal(item.writes, 0, 'a skipped pass must not touch show'); assert.equal( - applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)), + applyHorizonCull( + collection([undefined, item]), + occluderOverAustin(REPRO_HEIGHT_M), + ), 1, 'sparse slots are skipped, surviving neighbours still counted', ); @@ -127,22 +201,42 @@ test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22)); - const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12); + const sunken = billboard( + Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22), + ); + const lifted = Cesium.Cartesian3.fromDegrees( + AUSTIN.lon + LIMB_DLON, + AUSTIN.lat, + 12, + ); applyHorizonCull(collection([sunken]), occluder); - assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb'); + assert.equal( + sunken.show, + false, + 'baseline: the raw sub-ellipsoid anchor false-hides at the limb', + ); applyHorizonCull(collection([sunken]), occluder, [lifted]); - assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen'); + assert.equal( + sunken.show, + true, + 'the lifted cull point keeps the near-limb fire on screen', + ); }); test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => { const occluder = occluderOverAustin(REPRO_HEIGHT_M); - const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22)); + const far = billboard( + Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22), + ); const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12); applyHorizonCull(collection([far]), occluder, [lifted]); - assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull'); + assert.equal( + far.show, + false, + '12 m of lift must not defeat the actual horizon cull', + ); }); test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => { @@ -160,15 +254,34 @@ test('fireCullPosition: negative-geoid anchor renders at the datum but culls lif `render anchor stays datum-correct at ${-27 + FIRE_ANCHOR_LIFT_M} m, got ${renderCarto.height}`, ); - assert.notEqual(cull, render, 'a sub-ellipsoid anchor gets a separate cull point'); + assert.notEqual( + cull, + render, + 'a sub-ellipsoid anchor gets a separate cull point', + ); const cullCarto = Cesium.Cartographic.fromCartesian(cull); - assert.ok(Math.abs(cullCarto.height - 12) < 0.5, `cull point lifted to 12 m, got ${cullCarto.height}`); - assert.equal(fireCullPosition(fire), cull, 'the lifted point is cached, not re-allocated per call'); + assert.ok( + Math.abs(cullCarto.height - 12) < 0.5, + `cull point lifted to 12 m, got ${cullCarto.height}`, + ); + assert.equal( + fireCullPosition(fire), + cull, + 'the lifted point is cached, not re-allocated per call', + ); // The whole point: the datum-correct point would be culled here, the lifted one is not. const occluder = occluderOverAustin(REPRO_HEIGHT_M); - assert.equal(occluder.isPointVisible(render), false, 'baseline defect: −22 m reads beyond the horizon'); - assert.equal(occluder.isPointVisible(cull), true, 'lifted cull point reads correctly as in view'); + assert.equal( + occluder.isPointVisible(render), + false, + 'baseline defect: −22 m reads beyond the horizon', + ); + assert.equal( + occluder.isPointVisible(cull), + true, + 'lifted cull point reads correctly as in view', + ); } finally { _clearMeshFloorCellsForTest(); setMeshFloorPreferred(false); @@ -183,7 +296,11 @@ test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point', try { const fire = { index: 2, lat, lon, frp: 10, position: null }; const cull = fireCullPosition(fire); - assert.equal(cull, fire.position, 'no second Cartesian is allocated for the common case'); + assert.equal( + cull, + fire.position, + 'no second Cartesian is allocated for the common case', + ); } finally { _clearMeshFloorCellsForTest(); setMeshFloorPreferred(false); @@ -197,23 +314,50 @@ test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point', test('isOverlayPointVisible: horizon test prefers entry.cullPosition when present', () => { const viewport = { width: 800, height: 600 }; const screen = { x: 400, y: 300 }; - const render = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22); - const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12); + const render = Cesium.Cartesian3.fromDegrees( + AUSTIN.lon + LIMB_DLON, + AUSTIN.lat, + -22, + ); + const lifted = Cesium.Cartesian3.fromDegrees( + AUSTIN.lon + LIMB_DLON, + AUSTIN.lat, + 12, + ); const occluder = occluderOverAustin(REPRO_HEIGHT_M); assert.equal( - isOverlayPointVisible({ horizonCull: true }, render, screen, viewport, occluder), + isOverlayPointVisible( + { horizonCull: true }, + render, + screen, + viewport, + occluder, + ), false, 'baseline: the render anchor alone false-hides the card', ); assert.equal( - isOverlayPointVisible({ horizonCull: true, cullPosition: lifted }, render, screen, viewport, occluder), + isOverlayPointVisible( + { horizonCull: true, cullPosition: lifted }, + render, + screen, + viewport, + occluder, + ), true, 'the supplied cull position is what the horizon test uses', ); assert.equal( isOverlayPointVisible( - { horizonCull: true, cullPosition: Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12) }, + { + horizonCull: true, + cullPosition: Cesium.Cartesian3.fromDegrees( + ANTIPODE.lon, + ANTIPODE.lat, + 12, + ), + }, render, screen, viewport, @@ -227,37 +371,80 @@ test('isOverlayPointVisible: horizon test prefers entry.cullPosition when presen test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => { const position = { x: 1, y: 2, z: 3 }; const source = { x: 4, y: 5, z: 6 }; - const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: source }); - assert.notEqual(kept.cullPosition, source, 'stored as a host-owned copy (a geo Cartesian3)'); - assert.ok(!(kept.cullPosition instanceof Cesium.Cartesian3), 'not a Cesium value'); + const kept = normalizeOverlayEntry('firms', { + id: 'a', + position, + cullPosition: source, + }); + assert.notEqual( + kept.cullPosition, + source, + 'stored as a host-owned copy (a geo Cartesian3)', + ); + assert.ok( + !(kept.cullPosition instanceof Cesium.Cartesian3), + 'not a Cesium value', + ); assert.deepEqual( [kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z], [4, 5, 6], ); - const dropped = normalizeOverlayEntry('firms', { id: 'b', position, cullPosition: { x: NaN, y: 5, z: 6 } }); - assert.equal(dropped.cullPosition, null, 'a malformed cull point falls back to the render position'); - const notAnObject = normalizeOverlayEntry('firms', { id: 'b2', position, cullPosition: 42 }); - assert.equal(notAnObject.cullPosition, null, 'a non-object cull point is rejected'); + const dropped = normalizeOverlayEntry('firms', { + id: 'b', + position, + cullPosition: { x: NaN, y: 5, z: 6 }, + }); + assert.equal( + dropped.cullPosition, + null, + 'a malformed cull point falls back to the render position', + ); + const notAnObject = normalizeOverlayEntry('firms', { + id: 'b2', + position, + cullPosition: 42, + }); + assert.equal( + notAnObject.cullPosition, + null, + 'a non-object cull point is rejected', + ); // Non-adopting sources keep their existing BEHAVIOUR and steady-frame // allocation profile; the normalized record does gain an enumerable // `cullPosition: null` field, which is why this asserts null rather than // absence. const absent = normalizeOverlayEntry('firms', { id: 'c', position }); - assert.equal(absent.cullPosition, null, 'sources that do not opt in resolve to no cull anchor'); + assert.equal( + absent.cullPosition, + null, + 'sources that do not opt in resolve to no cull anchor', + ); }); test('normalizeOverlayEntry: the stored cull anchor is a snapshot, not the caller object', () => { const position = { x: 1, y: 2, z: 3 }; const caller = new Cesium.Cartesian3(4, 5, 6); - const normalized = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: caller }); - assert.notEqual(normalized.cullPosition, caller, 'the host must not retain the caller reference'); + const normalized = normalizeOverlayEntry('firms', { + id: 'a', + position, + cullPosition: caller, + }); + assert.notEqual( + normalized.cullPosition, + caller, + 'the host must not retain the caller reference', + ); // Sources legitimately recycle scratch vectors between publishes; a mutation // after normalization must never reach the per-frame occluder. caller.x = NaN; caller.y = 999; assert.deepEqual( - [normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z], + [ + normalized.cullPosition.x, + normalized.cullPosition.y, + normalized.cullPosition.z, + ], [4, 5, 6], 'post-normalize mutation of the source vector has no effect', ); @@ -277,7 +464,11 @@ test('normalizeOverlayEntry: an accessor-backed cullPosition is read exactly onc const normalized = normalizeOverlayEntry('firms', entry); assert.equal(reads, 1, 'one property read — not one per validated component'); assert.deepEqual( - [normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z], + [ + normalized.cullPosition.x, + normalized.cullPosition.y, + normalized.cullPosition.z, + ], [4, 5, 6], ); }); @@ -288,11 +479,21 @@ test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort nor id: 'a', position, title: 'still normalized', - get cullPosition() { throw new Error('hostile accessor'); }, + get cullPosition() { + throw new Error('hostile accessor'); + }, }; const normalized = normalizeOverlayEntry('firms', entry); - assert.equal(normalized.cullPosition, null, 'the bad anchor degrades to null'); - assert.equal(normalized.title, 'still normalized', 'the rest of the entry survives'); + assert.equal( + normalized.cullPosition, + null, + 'the bad anchor degrades to null', + ); + assert.equal( + normalized.title, + 'still normalized', + 'the rest of the entry survives', + ); assert.equal(normalized.id, 'a'); }); @@ -300,12 +501,6 @@ test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort nor // Layer integration: the three production hooks // --------------------------------------------------------------------------- -class MockEvent { - constructor() { this.listeners = new Set(); } - addEventListener(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); } - emit() { for (const listener of [...this.listeners]) listener(); } -} - /** Canvas stub good enough for the pre-baked glow sprites. */ function canvasStub() { return { @@ -337,9 +532,10 @@ function rawFire(lat, lon, frp) { } /** - * Boots the REAL layer against a headless viewer stub with a REAL - * Cesium.BillboardCollection, so the production hooks (not just the pure - * predicate) are what the assertions exercise. + * Boots the REAL layer on the Cesium engine over a stub scene with a REAL + * Cesium.Camera and the engine's REAL Cesium.BillboardCollection, so the + * production hooks (not just the pure predicate) are what the assertions + * exercise. */ function createHarness(rawFires) { const originalFetch = globalThis.fetch; @@ -357,9 +553,14 @@ function createHarness(rawFires) { // Deterministic projection: every distinct world position gets its own // 200 px grid slot (well clear of LABEL_MIN_SEP_PX) inside a 2000×2000 - // canvas, so declutter geometry never decides these assertions. + // canvas, so declutter geometry never decides these assertions. The + // engine's projector reads it when the engine is made. const slots = new Map(); - Cesium.SceneTransforms.worldToWindowCoordinates = (scene, position, result) => { + Cesium.SceneTransforms.worldToWindowCoordinates = ( + scene, + position, + result, + ) => { if (!slots.has(position)) slots.set(position, slots.size); const slot = slots.get(position); const out = result || new Cesium.Cartesian2(); @@ -368,30 +569,21 @@ function createHarness(rawFires) { return out; }; - const preRender = new MockEvent(); - const moveEnd = new MockEvent(); - const primitives = []; + const { engine, viewer, primitive, frame } = createCesiumMoversEngine({ + engineOptions: { createEventHandler: createFakeEventHandlers().create }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + Object.assign(viewer.scene.canvas, { clientWidth: 2000, clientHeight: 2000 }); + // No view rectangle (Cesium's answer for a view of the sky or the whole + // globe): the layer draws the globally strongest detections, so far-side + // fires are drawn and only the horizon pass decides what shows. + viewer.camera.computeViewRectangle = () => undefined; + const moveCameraTo = (lon, lat, height = REPRO_HEIGHT_M) => + engine.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(lon, lat, height), + }); + moveCameraTo(AUSTIN.lon, AUSTIN.lat); const entryCalls = []; - const viewer = { - dataSources: { add: (value) => value, remove: () => {} }, - camera: { - moveEnd, - positionWC: Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, REPRO_HEIGHT_M), - directionWC: new Cesium.Cartesian3(0, 0, -1), - positionCartographic: { height: REPRO_HEIGHT_M }, - }, - scene: { - canvas: { clientWidth: 2000, clientHeight: 2000 }, - preRender, - primitives: { - add: (value) => { primitives.push(value); return value; }, - remove: () => {}, - contains: () => false, - }, - frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() }, - mapProjection: new Cesium.GeographicProjection(), - }, - }; const layer = createFirmsHeatmapLayer({ id: 'firms', @@ -401,36 +593,58 @@ function createHarness(rawFires) { setVisible: () => {}, clearSource: () => {}, }, - screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }), + // The display the registry gives every engine, with image seams. + renderer: { + create: ({ engine: target, ...options }) => + createFirmsDisplay(target, { + ...options, + loadImage: async () => ({ width: 16, height: 16 }), + createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }), + }), + }, }); + const settle = async () => { + for (let i = 0; i < 4; i++) + await new Promise((resolve) => setImmediate(resolve)); + }; + const drawnCount = () => + engine.movers.list().find((set) => set.id === 'firms:firms')?.count ?? 0; return { layer, - viewer, - preRender, - moveEnd, - /** The real BillboardCollection the layer created. */ - billboards: () => primitives[0], + engine, + /** Attaches the layer to the engine and enables it; markers drawn. */ + async start() { + layer.init(null, { engine }); + await layer.enable(); + await settle(); + }, + settle, + /** The real BillboardCollection the engine drew the markers with. */ + billboards: () => primitive('firms:firms'), + drawnCount, /** Latest entry list published to the overlay host. */ latestEntries: () => entryCalls[entryCalls.length - 1] || [], - /** `show` flags keyed by billboard pick id. */ + /** `show` flags of the drawn markers, keyed by billboard pick id. */ showById() { - const bb = primitives[0]; + const bb = primitive('firms:firms'); const out = new Map(); - for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show); + for (let i = 0; i < drawnCount(); i += 1) + out.set(bb.get(i).id, bb.get(i).show); return out; }, - moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) { - viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, height); - viewer.camera.positionCartographic = { height }; - }, + moveCameraTo, + moveEnd: () => viewer.camera.moveEnd.raiseEvent(), + preRender: () => frame(), cleanup() { - layer.destroy(viewer); - unregisterSpriteCollection('firms'); + layer.destroy(); + engine.destroy(); Cesium.SceneTransforms.worldToWindowCoordinates = originalProject; globalThis.fetch = originalFetch; - if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow; - if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument; + if (originalWindow === undefined) delete globalThis.window; + else globalThis.window = originalWindow; + if (originalDocument === undefined) delete globalThis.document; + else globalThis.document = originalDocument; }, }; } @@ -442,14 +656,25 @@ test('layer: a detection rebuild culls its own freshly-added sprites', async () rawFire(TOKYO.lat, TOKYO.lon, 800), ]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); + await harness.start(); // No camera event has fired — only renderDetections ran. const show = harness.showById(); assert.equal(show.size, 3, 'all three detections were rendered'); - assert.equal(show.get('firms-0'), true, 'the Austin fire under the camera is visible'); - assert.equal(show.get('firms-1'), false, 'the antipodal fire is culled at build time'); - assert.equal(show.get('firms-2'), false, 'the Tokyo fire is culled at build time'); + assert.equal( + show.get('firms-0'), + true, + 'the Austin fire under the camera is visible', + ); + assert.equal( + show.get('firms-1'), + false, + 'the antipodal fire is culled at build time', + ); + assert.equal( + show.get('firms-2'), + false, + 'the Tokyo fire is culled at build time', + ); } finally { harness.cleanup(); } @@ -461,15 +686,23 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => { rawFire(TOKYO.lat, TOKYO.lon, 800), ]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); - assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin'); + await harness.start(); + assert.equal( + harness.showById().get('firms-1'), + false, + 'precondition: Tokyo hidden from Austin', + ); harness.moveCameraTo(TOKYO.lon, TOKYO.lat); - harness.moveEnd.emit(); + harness.moveEnd(); + await harness.settle(); const show = harness.showById(); - assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it'); + assert.equal( + show.get('firms-1'), + true, + 'Tokyo becomes visible once the camera is over it', + ); assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb'); } finally { harness.cleanup(); @@ -482,20 +715,30 @@ test('layer: the throttled preRender watcher re-culls on a camera move', async ( rawFire(TOKYO.lat, TOKYO.lon, 800), ]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); + await harness.start(); harness.moveCameraTo(TOKYO.lon, TOKYO.lat); // The watcher is throttled to LOD_CHECK_MS (650 ms) against // performance.now(), whose value at this point depends on process // start-up — so emit, wait out the full throttle window, and emit again. // Whichever tick passes the gate, one of them must run the pass. - harness.preRender.emit(); - await new Promise((resolve) => { setTimeout(resolve, 700); }); - harness.preRender.emit(); + harness.preRender(); + await new Promise((resolve) => { + setTimeout(resolve, 700); + }); + harness.preRender(); + await harness.settle(); const show = harness.showById(); - assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass'); - assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass'); + assert.equal( + show.get('firms-1'), + true, + 'Tokyo revealed by the preRender pass', + ); + assert.equal( + show.get('firms-0'), + false, + 'Austin culled by the preRender pass', + ); } finally { harness.cleanup(); } @@ -507,23 +750,35 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s rawFire(TOKYO.lat, TOKYO.lon, 800), ]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); - assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin'); + await harness.start(); + assert.equal( + harness.showById().get('firms-1'), + false, + 'precondition: Tokyo hidden from Austin', + ); harness.layer.disable(); // The camera moves while the layer is OFF: moveEnd is not being listened // to, and the preRender watcher is inert. Nothing re-culls on its own. harness.moveCameraTo(TOKYO.lon, TOKYO.lat); - await harness.layer.enable(harness.viewer); + await harness.layer.enable(); + await harness.settle(); // Assert with NO camera event fired and NO refetch (fires are already // loaded, so enable() does not reload): the enable path itself must have // re-culled before the collection went visible. const show = harness.showById(); - assert.equal(harness.billboards().show, true, 'the collection is visible again'); - assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time'); - assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet'); + assert.equal(harness.drawnCount(), 2, 'the markers are drawn again'); + assert.equal( + show.get('firms-1'), + true, + 'the Tokyo fire is revealed at enable time', + ); + assert.equal( + show.get('firms-0'), + false, + 'the Austin fire is NOT left shining through the planet', + ); } finally { harness.cleanup(); } @@ -541,8 +796,7 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat rawFire(lat, lon, 90), ]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); + await harness.start(); const bb = harness.billboards(); const sunken = bb.get(1); @@ -551,8 +805,16 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5, `the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`, ); - assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)'); - assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected'); + assert.equal( + sunken.show, + true, + 'the near-limb fire stays on screen (lifted cull anchor)', + ); + assert.equal( + harness.showById().get('firms-0'), + true, + 'the camera-nadir fire is unaffected', + ); } finally { harness.cleanup(); _clearMeshFloorCellsForTest(); @@ -562,25 +824,37 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat test('layer: far-side candidates never displace near-side ambient cards', async () => { const near = []; - for (let i = 0; i < 6; i += 1) near.push(rawFire(AUSTIN.lat + i * 0.4, AUSTIN.lon + i * 0.4, 100 - i)); + for (let i = 0; i < 6; i += 1) + near.push(rawFire(AUSTIN.lat + i * 0.4, AUSTIN.lon + i * 0.4, 100 - i)); const far = []; - for (let i = 0; i < 20; i += 1) far.push(rawFire(ANTIPODE.lat + i * 0.4, ANTIPODE.lon + i * 0.4, 900 - i)); + for (let i = 0; i < 20; i += 1) + far.push(rawFire(ANTIPODE.lat + i * 0.4, ANTIPODE.lon + i * 0.4, 900 - i)); // Far-side fires are the STRONGEST, so they lead the FRP-ranked candidate // walk and would eat all 18 ambient slots without the horizon skip. const harness = createHarness([...near, ...far]); try { - harness.layer.init(harness.viewer); - await harness.layer.enable(harness.viewer); + await harness.start(); const ids = harness.latestEntries().map((entry) => entry.id); assert.ok(ids.length > 0, 'cards were published'); - assert.ok(ids.length <= FIRMS_AMBIENT_COHORT_LIMIT, 'the ambient cap still holds'); + assert.ok( + ids.length <= FIRMS_AMBIENT_COHORT_LIMIT, + 'the ambient cap still holds', + ); // Card ids are `fire:firms::::` (the refetch-stable // detection key), so a card is matched back to its seed by coordinate. - const nearCoords = new Set(near.map((fire) => `${fire.lat.toFixed(4)}:${fire.lon.toFixed(4)}`)); - const nearIds = ids.filter((id) => nearCoords.has(id.split(':').slice(2, 4).join(':'))); + const nearCoords = new Set( + near.map((fire) => `${fire.lat.toFixed(4)}:${fire.lon.toFixed(4)}`), + ); + const nearIds = ids.filter((id) => + nearCoords.has(id.split(':').slice(2, 4).join(':')), + ); assert.equal(nearIds.length, 6, 'every in-view fire gets a card'); - assert.equal(new Set(nearIds).size, 6, 'six distinct near-side detections, not one repeated'); + assert.equal( + new Set(nearIds).size, + 6, + 'six distinct near-side detections, not one repeated', + ); assert.equal(ids.length, 6, 'no slot is spent on a fire behind the planet'); assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms'); } finally { diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index cdf1e4384..611947fe3 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -1,11 +1,19 @@ // FIRMS click routes: sprite AND card must both select for detail and hand the // camera over (pre-launch defect #4 + P1-3). Driven through the production -// click handler and the production card-render path; only the viewer, the -// event handler, the overlay host and the world→window projection are stubbed. +// click handler and the production card-render path, on the Cesium engine +// over a stub scene; only the scene pick, the event handler, the overlay +// host and the world→window projection are stubbed. import { test } from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js'; +import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs'; +import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs'; +import { createFirmsDisplay } from '../layers/firms/render/display.js'; +import { + createFirmsHeatmapLayer, + applyFirmsOverlayPolicy, + buildCellCard, +} from './firmsHeatmap.js'; import { fireDetectionKey } from './firmsLabels.js'; import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js'; import { getSelectedEntityContext } from './contextStore.js'; @@ -43,8 +51,11 @@ function makeFire(overrides = {}) { * actionable card presents. */ function harness({ - fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null, - withDataSource = false, feed = null, + fires = [makeFire()], + picked = null, + cardHit = null, + cameraPosition = null, + feed = null, } = {}) { const hadWindow = Object.hasOwn(globalThis, 'window'); const priorWindow = globalThis.window; @@ -52,15 +63,29 @@ function harness({ const windowTarget = new EventTarget(); globalThis.window = windowTarget; // Spread projections so the greedy screen-separation declutter accepts every - // seeded detection; co-located fires would otherwise collide by design. + // seeded detection; co-located fires would otherwise collide by design. The + // engine's projector reads this when the engine is made. let projected = 0; Cesium.SceneTransforms.worldToWindowCoordinates = () => { projected += 1; return { x: 200 * projected, y: 300 }; }; + const handlers = createFakeEventHandlers(); + const { engine, viewer, primitive, frame } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + viewer.scene.pick = () => picked ?? undefined; + viewer.scene.drillPick = () => (picked ? [picked] : []); + engine.camera.setView({ + destination: + cameraPosition ?? Cesium.Cartesian3.fromDegrees(-98, 30, 2_000_000), + }); const published = []; - let handler = null; const layer = createFirmsHeatmapLayer({ ...(feed ? { feed } : {}), id: 'firms-test', @@ -71,41 +96,48 @@ function harness({ clearSource() {}, hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null), }, - screenSpaceEventHandlerFactory: () => { - handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} }; - return handler; + // The display the registry gives every engine, with image seams. + renderer: { + create: ({ engine: target, ...options }) => + createFirmsDisplay(target, { + ...options, + loadImage: async () => ({ width: 16, height: 16 }), + createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }), + }), }, }); - - const viewer = { - scene: { - pick: () => picked, - frameState: { mode: Cesium.SceneMode.SCENE3D }, - primitives: new Cesium.PrimitiveCollection(), - canvas: { clientWidth: 1280, clientHeight: 800 }, - camera: cameraPosition ? { positionWC: cameraPosition } : undefined, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, - dataSources: { add() {}, remove() {} }, - }; - // The LOD rebuild (and with it the context-registration sweep) is inert - // without a data source, so tests that drive a real refresh need one. - if (withDataSource) layer.init(viewer); - layer._bindInteractionForTest(viewer, fires); + layer._bindInteractionForTest(engine, fires); const requests = []; - windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) => requests.push(e.detail)); + windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) => + requests.push(e.detail), + ); return { layer, + engine, viewer, + frame, + /** The glow billboards drawn (the engine's sprite set), or null. */ + markers: () => primitive('firms:firms-test') ?? null, windowTarget, requests, published, fires, - click: () => handler.click({ position: { x: 400, y: 300 } }), + click() { + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(400, 300), + }); + }, + setHeight(height) { + engine.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees(-98, 30, height), + }); + }, cleanup() { layer.destroy(); + engine.destroy(); Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection; if (hadWindow) globalThis.window = priorWindow; else delete globalThis.window; @@ -113,12 +145,26 @@ function harness({ }; } +const settle = async () => { + for (let i = 0; i < 4; i++) + await new Promise((resolve) => setImmediate(resolve)); +}; + test('fire detection key survives a refetch that renumbers the index', () => { const fire = makeFire(); - assert.equal(fireDetectionKey(fire), fireDetectionKey({ ...fire, index: 999 })); + assert.equal( + fireDetectionKey(fire), + fireDetectionKey({ ...fire, index: 999 }), + ); // Different pass over the same pixel is a different detection. - assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 })); - assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, lat: 31.0 })); + assert.notEqual( + fireDetectionKey(fire), + fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }), + ); + assert.notEqual( + fireDetectionKey(fire), + fireDetectionKey({ ...fire, lat: 31.0 }), + ); assert.match(fireDetectionKey({}), /^firms:x:x:0:x$/); }); @@ -133,11 +179,20 @@ test('two satellites over the same pixel at the same time stay distinct', () => assert.notEqual(fireDetectionKey(base), fireDetectionKey(suomi)); assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi)); // Satellite naming variants normalize to one identity, not three. - assert.equal(fireDetectionKey(base), fireDetectionKey(makeFire({ satellite: 'NOAA-20' }))); - assert.equal(fireDetectionKey(suomi), fireDetectionKey(makeFire({ satellite: 'Suomi NPP' }))); + assert.equal( + fireDetectionKey(base), + fireDetectionKey(makeFire({ satellite: 'NOAA-20' })), + ); + assert.equal( + fireDetectionKey(suomi), + fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })), + ); // A record with no satellite falls back to its sensor, not to a shared blank. const sensorOnly = makeFire({ satellite: '', sensor: 'MODIS' }); - assert.notEqual(fireDetectionKey(sensorOnly), fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' }))); + assert.notEqual( + fireDetectionKey(sensorOnly), + fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' })), + ); }); test('duplicate-coordinate detections get their own cards and focus targets', () => { @@ -149,11 +204,21 @@ test('duplicate-coordinate detections get their own cards and focus targets', () cardHit: `fire:${fireDetectionKey(n21)}`, }); try { - const cardIds = h.published.filter((e) => e.id.startsWith('fire:')).map((e) => e.id); - assert.equal(new Set(cardIds).size, 2, 'co-located detections must not collapse to one card'); + const cardIds = h.published + .filter((e) => e.id.startsWith('fire:')) + .map((e) => e.id); + assert.equal( + new Set(cardIds).size, + 2, + 'co-located detections must not collapse to one card', + ); h.click(); assert.equal(h.requests.length, 1); - assert.equal(h.requests[0].id, fireDetectionKey(n21), 'the clicked card wins, not its twin'); + assert.equal( + h.requests[0].id, + fireDetectionKey(n21), + 'the clicked card wins, not its twin', + ); } finally { h.cleanup(); } @@ -164,7 +229,9 @@ test('selection restoration uses the same key the cards and focus use', () => { // detections from different satellites — the selection could come back on // the wrong record. Reachable only through the 30-minute refetch, so this is // a source contract; the key's own behavior is proven above. - const match = LAYER_SOURCE.match(/function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/); + const match = LAYER_SOURCE.match( + /function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/, + ); assert.ok(match, 'findMatchingFire is missing'); assert.match(match[1], /fireDetectionKey\(previous\)/); assert.match(match[1], /fireDetectionKey\(fire\) === key/); @@ -177,7 +244,11 @@ test('painted detection cards are keyed by the stable detection key', () => { const card = h.published.find((entry) => entry.id.startsWith('fire:')); assert.ok(card, 'a detection card was painted'); assert.equal(card.id, `fire:${fireDetectionKey(h.fires[0])}`); - assert.equal(card.interactive, true, 'a detection card must publish a hit rect'); + assert.equal( + card.interactive, + true, + 'a detection card must publish a hit rect', + ); assert.match(card.accessibilityLabel, /^Focus fire detection /); assert.equal(card.activate(), true); assert.equal(h.requests.length, 1); @@ -189,7 +260,16 @@ test('painted detection cards are keyed by the stable detection key', () => { test('an aggregated cell card is never actionable — it is not one fire', () => { const cell = buildCellCard( - { cell: { count: 14, maxFrp: 210, newestAcqMs: 0, latCell: 30, lonCell: -98 }, position: {} }, + { + cell: { + count: 14, + maxFrp: 210, + newestAcqMs: 0, + latCell: 30, + lonCell: -98, + }, + position: {}, + }, Date.now(), ); assert.equal(applyFirmsOverlayPolicy(cell, 1e6).interactive, false); @@ -198,10 +278,16 @@ test('an aggregated cell card is never actionable — it is not one fire', () => test('far-side detections cannot consume the bounded ambient-card cohort', () => { const front = makeFire({ index: 1, lat: 30.51, lon: -98.21, frp: 100 }); const rear = makeFire({ index: 2, lat: -30.51, lon: 81.79, frp: 1000 }); - const cameraPosition = Cesium.Cartesian3.fromDegrees(front.lon, front.lat, 10_000_000); + const cameraPosition = Cesium.Cartesian3.fromDegrees( + front.lon, + front.lat, + 10_000_000, + ); const h = harness({ fires: [rear, front], cameraPosition }); try { - const cardIds = h.published.filter((entry) => entry.id.startsWith('fire:')).map((entry) => entry.id); + const cardIds = h.published + .filter((entry) => entry.id.startsWith('fire:')) + .map((entry) => entry.id); assert.deepEqual(cardIds, [`fire:${fireDetectionKey(front)}`]); } finally { h.cleanup(); @@ -278,7 +364,11 @@ test('a click on neither sprite nor card clears without moving the camera', () = const SIBLING_PICKS = [ ['flights', 'a1b2c3', { id: 'a1b2c3' }], ['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }], - ['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }], + [ + 'ais-live-vessels', + '353136000', + { id: { mmsi: '353136000', name: 'EVER GIVEN' } }, + ], ['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }], ['satellites', '25544', { id: 25544 }], ]; @@ -294,7 +384,11 @@ for (const [layerId, ownedId, picked] of SIBLING_PICKS) { }); try { h.click(); - assert.equal(h.requests.length, 0, `the ${layerId} layer is already acting on this click`); + assert.equal( + h.requests.length, + 0, + `the ${layerId} layer is already acting on this click`, + ); } finally { h.cleanup(); unregisterPickOwner(layerId); @@ -321,15 +415,20 @@ test('a refresh that drops the selected fire emits an eviction the readout can a // record first and the clear then fails its ownership guard silently. const kept = makeFire({ index: 0 }); const dropped = makeFire({ - index: 1, lat: 31.77, lon: -99.02, acqMs: 1_753_600_100_000, satellite: 'N20', + index: 1, + lat: 31.77, + lon: -99.02, + acqMs: 1_753_600_100_000, + satellite: 'N20', }); const h = harness({ fires: [dropped, kept], cardHit: `fire:${fireDetectionKey(dropped)}`, - withDataSource: true, }); const cleared = []; - h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => cleared.push(e.detail)); + h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => + cleared.push(e.detail), + ); const priorFetch = globalThis.fetch; // The far band keeps the markers built under the heatmap for the hand-off // fade; their glow sprites need a canvas. @@ -340,7 +439,11 @@ test('a refresh that drops the selected fire emits an eviction the readout can a try { h.click(); - assert.equal(h.requests.length, 1, 'the click must select the fire we are about to drop'); + assert.equal( + h.requests.length, + 1, + 'the click must select the fire we are about to drop', + ); assert.equal(h.requests[0].id, fireDetectionKey(dropped)); assert.deepEqual(cleared, [], 'selecting must not clear anything yet'); @@ -351,18 +454,20 @@ test('a refresh that drops the selected fire emits an eviction the readout can a json: async () => ({ fetchedAt: Date.now(), stale: false, - fires: [{ - lat: kept.lat, - lon: kept.lon, - frp: kept.frp, - confidence: 'h', - brightness: 330, - daynight: 'D', - acqDate: '2025-07-27', - acqTime: '0412', - instrument: 'VIIRS', - satellite: kept.satellite, - }], + fires: [ + { + lat: kept.lat, + lon: kept.lon, + frp: kept.frp, + confidence: 'h', + brightness: 330, + daynight: 'D', + acqDate: '2025-07-27', + acqTime: '0412', + instrument: 'VIIRS', + satellite: kept.satellite, + }, + ], }), }); await h.layer.update(); @@ -394,9 +499,16 @@ test('a refresh that drops the selected fire emits an eviction the readout can a test('disable cancels a pending source and a late response cannot repopulate the layer', async () => { let resolve, signal; - const h = harness({ withDataSource: true, feed: { getSnapshot(options) { - signal = options.signal; return new Promise(done => { resolve = done; }); - } } }); + const h = harness({ + feed: { + getSnapshot(options) { + signal = options.signal; + return new Promise((done) => { + resolve = done; + }); + }, + }, + }); try { const before = h.layer.getStats(); const pending = h.layer.update(); @@ -407,16 +519,20 @@ test('disable cancels a pending source and a late response cannot repopulate the assert.equal(h.layer.getStats().count, before.count); assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate); assert.equal(h.layer.getStats().loading, false); - } finally { h.layer.destroy(h.viewer); h.cleanup(); } + } finally { + h.cleanup(); + } }); -test('the heatmap is preloaded under the markers before the first zoom-out', (t) => { +test('the heatmap is preloaded under the markers before the first zoom-out', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const priorDocument = globalThis.document; globalThis.document = { createElement: () => ({ getContext: () => ({ - createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }), + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), putImageData() {}, createRadialGradient: () => ({ addColorStop() {} }), fillRect() {}, @@ -425,12 +541,8 @@ test('the heatmap is preloaded under the markers before the first zoom-out', (t) }), }; const h = harness({ - withDataSource: true, cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000), }); - h.viewer.camera = { positionCartographic: { height: 1_000_000 } }; - h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true }; - h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; const added = []; try { h.layer.attachShellServices({ @@ -454,14 +566,13 @@ test('the heatmap is preloaded under the markers before the first zoom-out', (t) assert.equal(added[0].alpha, 0, 'invisible until the hand-off'); assert.equal(added[0].show, true, 'but mapped onto the globe tiles'); } finally { - h.layer.destroy(h.viewer); h.cleanup(); if (priorDocument === undefined) delete globalThis.document; else globalThis.document = priorDocument; } }); -test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => { +test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', async () => { // A canvas stub for the heat drape and the detection glow sprites. const priorDocument = globalThis.document; const restoreDocument = () => { @@ -483,26 +594,18 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke }; const h = harness({ fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })], - withDataSource: true, cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000), }); const added = []; const removed = []; - let markers = null; - // The fade reads the camera height; the cull reads scene.camera. - const setHeight = (height) => { - h.viewer.camera = { positionCartographic: { height } }; + // The markers as drawn: how many the sprite set draws, and their alphas. + const drawn = () => { + const count = + h.engine.movers.list().find((set) => set.id === 'firms:firms-test') + ?.count ?? 0; + const markers = h.markers(); + return Array.from({ length: count }, (_, i) => markers.get(i).color.alpha); }; - setHeight(12_000_000); - h.viewer.scene.primitives = { - add(primitive) { - markers = primitive; - }, - remove() {}, - raiseToTop() {}, - contains: () => true, - }; - h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D }; try { const collection = { addImageryProvider: (provider) => { @@ -517,39 +620,53 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke imageryHost: () => ({ kind: 'globe', collection }), }); h.layer._renderLodForTest('global'); + await settle(); assert.equal(added.length, 1, 'one heat imagery layer'); assert.equal(added[0].show, true); assert.equal(added[0].alpha, 1); - assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities'); + assert.equal(h.layer._cellCountForTest(), 0, 'no ground cells'); assert.ok(h.published.length > 0, 'the far band still paints cards'); - assert.equal(markers.length, 2, 'the markers are built under the heatmap'); - assert.equal(markers.show, false, 'and hidden while the heatmap is whole'); + assert.deepEqual(drawn(), [], 'no marker drawn while the heatmap is whole'); + // The markers are built under it: the hand-off shows them without a rebuild. + h.setHeight(4_500_000); + h.layer._applyHandoffFadeForTest(); + await settle(); + assert.deepEqual( + drawn(), + [0.5, 0.5], + 'the markers are built under the heatmap', + ); + h.setHeight(12_000_000); + h.layer._applyHandoffFadeForTest(); + await settle(); + assert.deepEqual(drawn(), []); h.layer._renderLodForTest('regional'); assert.equal(added.length, 1, 'switching far bands reuses the raster'); - setHeight(1_000_000); + h.setHeight(1_000_000); h.layer._renderLodForTest('close'); + await settle(); assert.equal(added.length, 1, 'the markers band keeps the same heat layer'); assert.equal(added[0].show, true, 'still mapped onto the globe tiles'); assert.equal(added[0].alpha, 0, 'faded out under the markers'); - assert.equal(markers.show, true); - assert.equal(markers.get(0).color.alpha, 1); + assert.deepEqual(drawn(), [1, 1]); // Mid hand-off both are drawn, half each, in either band. - setHeight(4_500_000); + h.setHeight(4_500_000); h.layer._applyHandoffFadeForTest(); + await settle(); assert.equal(added[0].alpha, 0.5); - assert.equal(markers.show, true); - assert.equal(markers.get(0).color.alpha, 0.5); + assert.deepEqual(drawn(), [0.5, 0.5]); h.layer._renderLodForTest('regional'); + await settle(); assert.equal(added[0].alpha, 0.5); - assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade'); + assert.deepEqual(drawn(), [0.5, 0.5], 'rebuilt markers get the fade'); h.layer.disable(); assert.equal(added[0].show, false, 'disable hides the heatmap'); - h.layer.destroy(h.viewer); + h.layer.destroy(); assert.deepEqual( removed.map((entry) => [entry.layer, entry.destroy]), [[added[0], true]], diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 5e60dce4d..d4449eb19 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -120,7 +120,8 @@ test('every scene click handler consults ownership before it picks', () => { // 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)'], + // FIRMS takes the engine dispatcher's click with its pick. + ['src/layers/firms/selection.js', 'const picked = click.picked;'], [ 'src/layers/installations/render/cesium/selection.js', 'viewer.scene.pick(click.position)', @@ -136,8 +137,8 @@ test('every scene click handler consults ownership before it picks', () => { 'getRenderer().isTrackedPick(picked', ], [ - 'src/layers/submarineCables/interaction.js', - 'viewer.scene.pick(click.position)', + 'src/layers/submarineCables/render/display.js', + 'const picked = event.picked;', ], // Vessels take the engine dispatcher's pick: the guard // comes before the click rule reads it. diff --git a/src/data/spriteOrder.test.mjs b/src/data/spriteOrder.test.mjs index c61ea9cf8..f9e6c8717 100644 --- a/src/data/spriteOrder.test.mjs +++ b/src/data/spriteOrder.test.mjs @@ -16,7 +16,9 @@ function makePrimitives(initial = []) { return { items: [...initial], calls: [], - contains(collection) { return this.items.includes(collection); }, + contains(collection) { + return this.items.includes(collection); + }, raiseToTop(collection) { this.calls.push(collection.id); const index = this.items.indexOf(collection); @@ -31,10 +33,15 @@ function makeCollection(id, destroyed = false) { } test('restoreSpriteOrder raises live collections bottom-to-top and skips destroyed entries', () => { - const collections = Object.fromEntries(ORDER.map((id) => [id, makeCollection(id)])); + const collections = Object.fromEntries( + ORDER.map((id) => [id, makeCollection(id)]), + ); const destroyedFirms = makeCollection('firms', true); for (const id of ORDER) { - registerSpriteCollection(id, id === 'firms' ? destroyedFirms : collections[id]); + registerSpriteCollection( + id, + id === 'firms' ? destroyedFirms : collections[id], + ); } const primitives = makePrimitives([ collections.flights, @@ -46,10 +53,17 @@ test('restoreSpriteOrder raises live collections bottom-to-top and skips destroy restoreSpriteOrder({ scene: { primitives } }); - assert.deepEqual(primitives.calls, ['cctv', 'bikeshare', 'ais', 'military', 'flights']); - assert.deepEqual(primitives.items.map((item) => item.id), [ - 'cctv', 'bikeshare', 'ais', 'military', 'flights', + assert.deepEqual(primitives.calls, [ + 'cctv', + 'bikeshare', + 'ais', + 'military', + 'flights', ]); + assert.deepEqual( + primitives.items.map((item) => item.id), + ['cctv', 'bikeshare', 'ais', 'military', 'flights'], + ); for (const id of ORDER) unregisterSpriteCollection(id); }); @@ -69,7 +83,10 @@ test('late CCTV registration still restores flights above the ambient collection restoreSpriteOrder(viewer); assert.deepEqual(primitives.calls, ['cctv', 'flights']); - assert.deepEqual(primitives.items.map((item) => item.id), ['cctv', 'flights']); + assert.deepEqual( + primitives.items.map((item) => item.id), + ['cctv', 'flights'], + ); unregisterSpriteCollection('cctv', cctv); unregisterSpriteCollection('flights', flights); @@ -81,7 +98,9 @@ test('restoreSpriteOrder is inert for destroyed viewers and primitive collection registerSpriteCollection('flights', flights); restoreSpriteOrder({ isDestroyed: () => true, scene: { primitives } }); - restoreSpriteOrder({ scene: { primitives: { ...primitives, isDestroyed: () => true } } }); + restoreSpriteOrder({ + scene: { primitives: { ...primitives, isDestroyed: () => true } }, + }); assert.deepEqual(primitives.calls, []); unregisterSpriteCollection('flights', flights); @@ -112,32 +131,32 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r // Flights draws through its display: enable asks the fleet to restore its // order; the display's sprite sets take the flights' slot as their draw // order on every engine (aircraftLayerDisplay). - assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/); assert.match( - readFileSync(new URL('../layers/aircraft/render/display.js', import.meta.url), 'utf8'), + flightsLayer.enable.toString(), + /fleet\.restoreOrderOnEnable\(\)/, + ); + assert.match( + readFileSync( + new URL('../layers/aircraft/render/display.js', import.meta.url), + 'utf8', + ), /order: SPRITE_LAYER_ORDER\.indexOf\(layerId\)/, ); assert.match( - readFileSync(new URL('../layers/flights/render/display.js', import.meta.url), 'utf8'), + readFileSync( + new URL('../layers/flights/render/display.js', import.meta.url), + 'utf8', + ), /aircraftLayerDisplay\(\s*'flights',/, ); - assert.match( - firmsLayer.enable.toString(), - /renderer\.onEnable\(\)/, - ); + // FIRMS draws through its display: its glow sprite set takes the 'firms' + // slot as its draw order on every engine. + assert.doesNotMatch(firmsLayer.enable.toString(), /onEnable/); assert.match( readFileSync( - new URL('../layers/firms/render/cesium.js', import.meta.url), + new URL('../layers/firms/render/sprites.js', import.meta.url), 'utf8', ), - /restoreSpriteOrderOnEnable\('firms', viewer\)/, - ); - assert.match( - readFileSync( - new URL('../layers/firms/render/cesium.js', import.meta.url), - 'utf8', - ), - /registerSpriteCollection\('firms', sprites\);\s*spriteServices\.restoreSpriteOrder\(viewer\);/, - 'lazy FIRMS registration must restore order immediately', + /FIRMS_SPRITE_ORDER = SPRITE_LAYER_ORDER\.indexOf\('firms'\)[\s\S]*order: FIRMS_SPRITE_ORDER/, ); }); diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 6bee7c56d..2c823a127 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -22,9 +22,8 @@ export { selectCableReferenceLabelWinners, cableReferencePriority, createCableOverlayEntry, - cableClassificationTypeForStack, - cableClassificationTypeForScene, - applyTranslucentMarkerBlend, + cableDrapeForStack, + cableDrapeForEngine, createCableReferenceSweepGate, - updateCableReferenceStem, + updateCableReferenceTip, } from '../layers/submarineCables/index.js'; diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 110f65f18..a4cc69c06 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -1,7 +1,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import * as Cesium from 'cesium'; +import * as geo from '../geo/index.js'; import { MAP_STACKS } from '../mapStackController.js'; import { CABLE_LABEL_DEPTH_DECISION, @@ -11,17 +12,18 @@ import { CABLE_STEM_TIP_EPSILON_M, CABLE_SWEEP_MOTION_EPSILON_M, CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS, - applyTranslucentMarkerBlend, - cableClassificationTypeForScene, - cableClassificationTypeForStack, + cableDrapeForEngine, + cableDrapeForStack, cableReferencePriority, createCableOverlayEntry, createCableOverlayPublisher, createCableReferenceSweepGate, createTeleGeographySubmarineCableLayer, selectCableReferenceLabelWinners, - updateCableReferenceStem, + updateCableReferenceTip, } from './telegeographySubmarineCables.js'; +import { createCablesDisplay } from '../layers/submarineCables/render/display.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; test('cable reference winners remain nearest-first and hard-capped', () => { const records = Array.from({ length: 300 }, (_, index) => ({ @@ -181,240 +183,50 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle', ]); }); -test('cable ground lines classify against exactly the active surface on every stack', () => { - // Photorealistic tiles render with the globe hidden — 3D-tile pass only. - assert.equal( - cableClassificationTypeForStack('photoreal'), - Cesium.ClassificationType.CESIUM_3D_TILE, - ); - // Every globe stack renders imagery on the shown globe — terrain pass only. +test('cable paths drape on exactly the active surface on every stack', () => { + // Photorealistic tiles render with the globe hidden: 3D tiles only. + assert.equal(cableDrapeForStack('photoreal'), 'tiles'); + // Every globe stack renders imagery on the shown globe: terrain only. for (const stackId of ['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']) { assert.equal( - cableClassificationTypeForStack(stackId), - Cesium.ClassificationType.TERRAIN, - `${stackId} must classify against terrain so cables stay visible there`, + cableDrapeForStack(stackId), + 'terrain', + `${stackId} must drape on terrain so cables stay visible there`, ); } - // Unknown stack → BOTH: visible on every surface, the shipped fallback. + // Unknown stack → both: visible on every surface, the shipped fallback. // A TRUTHY unknown id must reach it too — "not photoreal" is not the same - // claim as "renders on the globe", and asserting TERRAIN for an id this + // claim as "renders on the globe", and asserting terrain for an id this // module has never heard of would hide the cables on a future 3D-tile // stack instead of degrading to the documented safe behavior. - assert.equal( - cableClassificationTypeForStack('some-future-stack'), - Cesium.ClassificationType.BOTH, - ); - assert.equal( - cableClassificationTypeForStack('photoreal-v2'), - Cesium.ClassificationType.BOTH, - ); - assert.equal( - cableClassificationTypeForStack(''), - Cesium.ClassificationType.BOTH, - ); - assert.equal( - cableClassificationTypeForStack(undefined), - Cesium.ClassificationType.BOTH, - ); - assert.equal( - cableClassificationTypeForStack(null), - Cesium.ClassificationType.BOTH, - ); + for (const unknown of [ + 'some-future-stack', + 'photoreal-v2', + '', + undefined, + null, + ]) + assert.equal(cableDrapeForStack(unknown), 'both', String(unknown)); // Every id that actually ships must be a KNOWN id: a stack added to // MAP_STACKS without a mapping here silently loses its halved command set, // so the omission fails loudly instead of degrading quietly. for (const stack of MAP_STACKS) { assert.notEqual( - cableClassificationTypeForStack(stack.id), - Cesium.ClassificationType.BOTH, - `MAP_STACKS id '${stack.id}' has no cable classification mapping`, + cableDrapeForStack(stack.id), + 'both', + `MAP_STACKS id '${stack.id}' has no cable drape mapping`, ); } - // Boot fires no stack event; the initial value derives from live scene - // state exactly like the height-datum listeners: photoreal ⇔ globe hidden. - assert.equal( - cableClassificationTypeForScene({ globe: { show: false } }), - Cesium.ClassificationType.CESIUM_3D_TILE, - ); - assert.equal( - cableClassificationTypeForScene({ globe: { show: true } }), - Cesium.ClassificationType.TERRAIN, - ); - assert.equal( - cableClassificationTypeForScene(null), - Cesium.ClassificationType.BOTH, - ); -}); - -test('a map-stack change re-classifies every cable line once, and destroy detaches the listener', async () => { - const listeners = new Map(); - const eventTarget = { - addEventListener: (name, fn) => listeners.set(name, fn), - removeEventListener: (name, fn) => { - if (listeners.get(name) === fn) listeners.delete(name); - }, - }; - const env = await createRealCableLayerHarness({ - mapStackEventTarget: eventTarget, + // Boot fires no stack event; the first drape comes from the engine: + // photoreal tiles shown ⇔ the globe hidden. + const engine = (photoreal) => ({ + maps: { photorealActive: () => photoreal }, }); - const cableSource = env.dataSources.find((ds) => - /Submarine Cables/.test(ds.name || ''), - ); - const classificationOf = (entity) => - entity.polyline.classificationType.getValue(); - - // The harness scene has no globe → BOTH at init (safe unknown fallback). - const cableEntity = cableSource.entities.values[0]; - assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.BOTH); - const listener = listeners.get('gev:map-stack-changed'); - assert.equal( - typeof listener, - 'function', - 'init must subscribe to the stack event', - ); - - listener({ detail: { activeId: 'photoreal', status: 'ready' } }); - assert.equal( - classificationOf(cableEntity), - Cesium.ClassificationType.CESIUM_3D_TILE, - ); - - listener({ detail: { activeId: 'osm', status: 'ready' } }); - assert.equal( - classificationOf(cableEntity), - Cesium.ClassificationType.TERRAIN, - ); - - env.layer.destroy(env.viewer); - assert.equal( - listeners.has('gev:map-stack-changed'), - false, - 'destroy must detach the stack listener', - ); -}); - -test('marker collections blend in a single translucent pass behind a loud shape invariant', async () => { - // Helper contract on a REAL Cesium cluster shape. - const ds = new Cesium.CustomDataSource('blend-probe'); - let result = applyTranslucentMarkerBlend(ds); - assert.deepEqual( - result, - { applied: 0, pending: 2, invariantFailed: false }, - 'pre-visualizer clusters are pending, never a shape failure', - ); - - ds.clustering._billboardCollection = new Cesium.BillboardCollection(); - ds.clustering._pointCollection = new Cesium.PointPrimitiveCollection(); - result = applyTranslucentMarkerBlend(ds); - assert.deepEqual(result, { applied: 2, pending: 0, invariantFailed: false }); - assert.equal( - ds.clustering._billboardCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - ); - assert.equal( - ds.clustering._pointCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - ); - - // Shape drift fails LOUDLY-detectably instead of applying blindly: a - // renamed field or a foreign object type must never be touched. - assert.equal( - applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed, - true, - 'missing private fields mean Cesium changed shape', - ); - const wrongType = new Cesium.CustomDataSource('wrong-type'); - wrongType.clustering._billboardCollection = { blendOption: 0 }; - const wrongResult = applyTranslucentMarkerBlend(wrongType); - assert.equal(wrongResult.invariantFailed, true); - assert.equal( - wrongType.clustering._billboardCollection.blendOption, - 0, - 'foreign objects are left untouched', - ); - assert.equal(applyTranslucentMarkerBlend(null).invariantFailed, true); - - // ATOMICITY: a shape failure on the SECOND collection must not leave the - // FIRST one already forced. Validation of every field/type completes before - // the first assignment, so a failed probe applies nothing at all — that is - // what "leaves Cesium's default blend untouched" has to mean. - const partial = new Cesium.CustomDataSource('partial-shape'); - partial.clustering._billboardCollection = new Cesium.BillboardCollection(); - partial.clustering._pointCollection = { blendOption: 0 }; // renamed/reshaped - const partialResult = applyTranslucentMarkerBlend(partial); - assert.equal(partialResult.invariantFailed, true); - assert.equal( - partialResult.applied, - 0, - 'a failed probe reports zero applications', - ); - assert.equal( - partial.clustering._billboardCollection.blendOption, - Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT, - "the valid first collection keeps Cesium's default blend when the second fails", - ); - assert.equal( - partial.clustering._pointCollection.blendOption, - 0, - 'the malformed collection is never touched', - ); - - // Real-layer wiring: once the visualizers have created the collections, - // the next sweep applies the single-pass blend to both marker sources. - const env = await createRealCableLayerHarness(); - env.raiseSweep(); - const landingSource = env.dataSources.find((source) => - /Landing Points/.test(source.name || ''), - ); - const referenceSource = env.dataSources.find((source) => - /References/.test(source.name || ''), - ); - landingSource.clustering._billboardCollection = - new Cesium.BillboardCollection(); - landingSource.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - referenceSource.clustering._billboardCollection = - new Cesium.BillboardCollection(); - referenceSource.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - assert.equal( - landingSource.clustering._billboardCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - 'landing billboards blend in one pass after the sweep', - ); - assert.equal( - referenceSource.clustering._pointCollection.blendOption, - Cesium.BlendOption.TRANSLUCENT, - 'reference points blend in one pass after the sweep', - ); - env.layer.destroy(env.viewer); - - // The same atomicity holds ACROSS the two marker sources: the layer probes - // both before committing either, so a reshaped reference source can never - // leave the landing source already forced to TRANSLUCENT. - const failEnv = await createRealCableLayerHarness(); - const failLanding = failEnv.dataSources.find((source) => - /Landing Points/.test(source.name || ''), - ); - const failReference = failEnv.dataSources.find((source) => - /References/.test(source.name || ''), - ); - failLanding.clustering._billboardCollection = - new Cesium.BillboardCollection(); - failLanding.clustering._pointCollection = - new Cesium.PointPrimitiveCollection(); - delete failReference.clustering._pointCollection; // Cesium reshaped - failEnv.raiseSweep(); - assert.equal( - failLanding.clustering._billboardCollection.blendOption, - Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT, - 'a reference-source shape failure leaves the landing source on the default blend', - ); - failEnv.layer.destroy(failEnv.viewer); + assert.equal(cableDrapeForEngine(engine(true)), 'tiles'); + assert.equal(cableDrapeForEngine(engine(false)), 'terrain'); + assert.equal(cableDrapeForEngine(null), 'both'); }); test('cable reference sweep gate is dirty-only — no timer path', () => { @@ -532,91 +344,61 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov ); }); -function makeStemRecord(lon = -40, lat = 35) { - const base = Cesium.Cartesian3.fromDegrees(lon, lat, 0); +test('reference tips move only on real changes, sized for the stem target and clamped', () => { + const lon = -40; + const lat = 35; const tip = Cesium.Cartesian3.fromDegrees(lon, lat, 2500); - const stemPositionBuffers = [ - [base, tip], - [base, tip], - ]; - const setCalls = { position: 0, polyline: 0, polylineArrays: [] }; - return { - record: { - entity: { - position: { - setValue: () => { - setCalls.position++; - }, - }, - polyline: { - positions: { - setValue: (value) => { - setCalls.polyline++; - setCalls.polylineArrays.push(value); - }, - }, - }, - }, - base, - tip, - nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, - reference: { lon, lat }, - }, - setCalls, - stemPositionBuffers, + const record = { + base: Cesium.Cartesian3.fromDegrees(lon, lat, 0), + tip, + nextTip: Cesium.Cartesian3.clone(tip), + reference: { lon, lat }, }; -} - -test('staticized stems redefine constants only on real tip changes, alternating buffers', () => { - const { record, setCalls, stemPositionBuffers } = makeStemRecord(); const canvasHeight = 900; const fov = Math.PI / 3; const camera = Cesium.Cartesian3.fromDegrees(-40, 35, 500_000); assert.equal( - updateCableReferenceStem(record, camera, canvasHeight, fov), + updateCableReferenceTip(record, camera, canvasHeight, fov), true, ); - assert.equal(setCalls.position, 1); - assert.equal(setCalls.polyline, 1); - assert.equal( - setCalls.polylineArrays[0], - stemPositionBuffers[1], - 'first real update selects the alternate buffer', + assert.equal(record.tip, tip, 'the tip is moved in place'); + const height = Cesium.Cartographic.fromCartesian(record.tip).height; + // ~66 px at 500 km: 500 km × 2·tan(30°)/900 × 66. + assert.ok( + Math.abs(height - (500_000 * 2 * Math.tan(fov / 2) * 66) / 900) < 1, ); - // Sub-epsilon camera noise must not redefine anything. + // Sub-epsilon camera noise moves nothing. const jitter = Cesium.Cartesian3.add( camera, new Cesium.Cartesian3(CABLE_STEM_TIP_EPSILON_M / 10, 0, 0), new Cesium.Cartesian3(), ); assert.equal( - updateCableReferenceStem(record, jitter, canvasHeight, fov), + updateCableReferenceTip(record, jitter, canvasHeight, fov), false, ); - assert.equal(setCalls.position, 1); - assert.equal(setCalls.polyline, 1); - // A real camera move flips back to the first buffer: steady state allocates - // no arrays beyond the two preallocated ones. + // A real camera move does. const moved = Cesium.Cartesian3.add( camera, new Cesium.Cartesian3(0, 0, 400_000), new Cesium.Cartesian3(), ); - assert.equal( - updateCableReferenceStem(record, moved, canvasHeight, fov), - true, - ); - assert.equal(setCalls.polylineArrays[1], stemPositionBuffers[0]); - assert.equal(new Set(setCalls.polylineArrays).size, 2); + assert.equal(updateCableReferenceTip(record, moved, canvasHeight, fov), true); - // Stem height honors the shipped 700 m..85 km clamp. - const tipCarto = Cesium.Cartographic.fromCartesian(record.tip); - assert.ok(tipCarto.height >= 700 && tipCarto.height <= 85_000); + // Tip height honors the shipped 700 m..85 km clamp. + const far = Cesium.Cartesian3.fromDegrees(-40, 35, 20_000_000); + updateCableReferenceTip(record, far, canvasHeight, fov); + assert.ok( + Math.abs(Cesium.Cartographic.fromCartesian(record.tip).height - 85_000) < 1, + ); + const close = Cesium.Cartesian3.fromDegrees(-40, 35, 100); + updateCableReferenceTip(record, close, canvasHeight, fov); + assert.ok( + Math.abs(Cesium.Cartographic.fromCartesian(record.tip).height - 700) < 1, + ); }); test('the depth decision is the dated Option-2 host migration, superseding Option 1', () => { @@ -628,50 +410,30 @@ test('the depth decision is the dated Option-2 host migration, superseding Optio }); }); -test('cables create no native labels and no per-frame geometry callbacks', () => { - const source = [ - '../../data/telegeographySubmarineCables.js', - '../../app/layers/submarineCables.js', - '../../app/layers/overlayHost.js', - 'rendering.js', - 'overlay.js', - 'surface.js', - 'ingestion.js', - 'interaction.js', - 'geometry.js', - 'lifecycle.js', - ] - .map((file) => - readFileSync( - new URL('../layers/submarineCables/' + file, import.meta.url), - 'utf8', - ), - ) - .join('\n'); +test('cables create no native labels, no per-frame callbacks and no engine imports', () => { + const directory = new URL('../layers/submarineCables/', import.meta.url); + const files = [ + ...readdirSync(directory) + .filter((file) => file.endsWith('.js')) + .map((file) => new URL(file, directory)), + ...readdirSync(new URL('render/', directory)) + .filter((file) => file.endsWith('.js')) + .map((file) => new URL(`render/${file}`, directory)), + new URL('../app/layers/submarineCables.js', import.meta.url), + new URL('../app/layers/overlayHost.js', import.meta.url), + new URL('./telegeographySubmarineCables.js', import.meta.url), + ]; + const source = files.map((file) => readFileSync(file, 'utf8')).join('\n'); + assert.doesNotMatch(source, /from 'cesium'|from 'three'|@navaramap/); assert.doesNotMatch( source, - /new Cesium\.LabelGraphics/, - 'reverting to native labels must fail this pin', - ); - assert.doesNotMatch( - source, - /new Cesium\.CallbackProperty/, - 'reverting to per-frame stem callbacks must fail this pin', + /\bnew \w+\.(?:LabelGraphics|CallbackProperty)\b/, ); assert.match( source, /setOverlayEntries/, 'labels must flow through the shared world-overlay host', ); - assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/); - assert.match( - source, - /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/, - ); - assert.match( - source, - /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/, - ); }); const CABLE_FIXTURE = { @@ -691,756 +453,107 @@ const CABLE_FIXTURE = { }, ], }; -// Node has no `document`, so Point features (whose GeoJSON default marker -// needs a canvas PinBuilder) cannot load here. Landing references only need -// `properties.coordinates`, so a line geometry exercises the same path. const LANDING_FIXTURE = { type: 'FeatureCollection', features: [ { type: 'Feature', id: 'lisbon', - properties: { - id: 'lisbon', - name: 'Lisbon, Portugal', - coordinates: [-9.1, 38.7], - }, - geometry: { - type: 'LineString', - coordinates: [ - [-9.1, 38.7], - [-9.05, 38.72], - ], - }, + properties: { id: 'lisbon', name: 'Lisbon, Portugal' }, + geometry: { type: 'Point', coordinates: [-9.1, 38.7] }, }, ], }; - -function fixtureForUrl(url) { - return String(url).includes('landing-point') - ? LANDING_FIXTURE - : CABLE_FIXTURE; -} - -function makeStubViewer( - listeners, - dataSources, - addCalls = { count: 0 }, - addControl = null, -) { - return { - dataSources: { - // Mirror Cesium's real DataSourceCollection.add(): the returned promise - // resolves — and the collection mutates — on a DEFERRED tick, never in - // the caller's synchronous frame. A remove() issued before the deferred - // add settles therefore removes NOTHING and the add still materializes - // afterwards (empirically verified against Cesium). Tests may hold the - // settle window open via addControl to abort inside it, and may mark an - // add rejected-AFTER-push: Cesium pushes into the collection BEFORE - // raising dataSourceAdded, so a throwing listener rejects the promise - // after the mutation already landed. - add: (source) => { - const callIndex = addCalls.count; - addCalls.count += 1; - return new Promise((resolve, reject) => { - const settle = () => { - dataSources.push(source); - if (addControl?.rejectAfterPush?.has(callIndex)) { - reject( - new Error( - 'dataSourceAdded listener threw after the push landed', - ), - ); - return; - } - resolve(source); - }; - if (addControl?.held) addControl.pending.push(settle); - else Promise.resolve().then(settle); - }); - }, - remove: (source) => { - const index = dataSources.indexOf(source); - if (index >= 0) dataSources.splice(index, 1); - return index >= 0; - }, - }, - camera: { - positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000), - frustum: { fov: Math.PI / 3 }, - moveEnd: { - addEventListener: (fn) => { - listeners.moveEnd.add(fn); - return () => listeners.moveEnd.delete(fn); - }, - }, - heading: 0, - cancelFlight() {}, - flyTo() {}, - }, - scene: { - canvas: { clientHeight: 900 }, - requestRender() {}, - pick() { - return null; - }, - preRender: { - addEventListener: (fn) => { - listeners.preRender.add(fn); - return () => listeners.preRender.delete(fn); - }, - }, +const fixtureSource = (label = 'Test cable directory', payload) => { + const source = { + label, + reads: 0, + async fetch(signal) { + signal.throwIfAborted(); + source.reads += 1; + return ( + payload ?? { cables: CABLE_FIXTURE, landingPoints: LANDING_FIXTURE } + ); }, }; -} + return source; +}; /** - * Harness whose fetches stay PENDING until the test releases them, honoring - * the abort signal like real fetch (reject with AbortError on abort). Load - * lifecycle races are sequenced deterministically through it. + * The facade's layer on the fake engine, enabled and loaded, the camera + * over the Atlantic; `sweep()` runs one preRender. */ -function createDeferredCableLayerHarness() { +async function createLayerHarness({ source = fixtureSource() } = {}) { const hostCalls = []; - const listeners = { preRender: new Set(), moveEnd: new Set() }; - const dataSources = []; - const addCalls = { count: 0 }; - const addControl = { held: false, pending: [], rejectAfterPush: new Set() }; - const pendingFetches = []; - const abortError = () => - Object.assign(new Error('The operation was aborted'), { - name: 'AbortError', + const overlayHost = { + setVisible: (...args) => hostCalls.push(['visible', ...args]), + setEntries: (...args) => hostCalls.push(['entries', ...args]), + clearSource: (...args) => hostCalls.push(['clear', ...args]), + }; + const engine = createFakeEngine(); + // What the paths set was given at its last commit. + let paths = null; + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const replace = set.replace; + set.replace = (items) => { + replace(items); + paths = { options, items }; + }; + return set; + }; + const flights = []; + engine.camera.flyTo = (options) => flights.push(options); + const look = (lon, lat, height) => + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, height), }); - const originalFetch = globalThis.fetch; - globalThis.fetch = (url, options = {}) => - new Promise((resolve, reject) => { - const entry = { - url: String(url), - settled: false, - release() { - if (entry.settled) return; - entry.settled = true; - resolve({ ok: true, json: async () => fixtureForUrl(url) }); - }, - }; - const signal = options?.signal; - if (signal) { - if (signal.aborted) { - entry.settled = true; - reject(abortError()); - return; - } - signal.addEventListener( - 'abort', - () => { - if (entry.settled) return; - entry.settled = true; - reject(abortError()); - }, - { once: true }, - ); - } - pendingFetches.push(entry); - }); - + look(-40, 35, 4_500_000); const layer = createTeleGeographySubmarineCableLayer({ - overlayHost: { - setVisible: (...args) => hostCalls.push(['visible', ...args]), - setEntries: (...args) => hostCalls.push(['entries', ...args]), - clearSource: (...args) => hostCalls.push(['clear', ...args]), - }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), + source, + createDisplay: (engine) => + createCablesDisplay(engine, { overlayHost, sweepClock: () => 0 }), + }); + layer.init(null, { engine }); + await layer.enable(); + assert.equal(layer.getStats().error, null, 'harness load must succeed'); + const drawn = () => ({ + ground: engine.ground.list().length, + movers: engine.movers.list().length, + lines: engine.lines.list().length, }); - const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl); - layer.init(viewer); - return { + engine, + look, + flights, + paths: () => (engine.ground.list().length ? paths : undefined), + moveEnd: () => engine.fake.emit('camera.moveEnd'), + /** A click on the paths set's item `instance`. */ + clickPath(instance) { + engine.fake.script('pick', { + id: paths.items[instance].pickId, + owner: paths.options.id, + instance, + }); + engine.fake.input('click', { position: { x: 100, y: 100 } }); + engine.fake.unscript('pick'); + }, layer, - viewer, + source, hostCalls, - dataSources, - addCalls, - listeners, - pendingFetches, - raiseSweep() { - for (const fn of listeners.preRender) fn(); - }, - releaseAll() { - for (const entry of [...pendingFetches]) entry.release(); - pendingFetches.length = 0; - }, - /** Park every subsequent dataSources.add in its deferred settle window. */ - holdAdds() { - addControl.held = true; - }, - /** Make the Nth add (0-based) push into the collection, THEN reject. */ - failAddAfterPush(callIndex) { - addControl.rejectAfterPush.add(callIndex); - }, - /** Let the held adds mutate the collection (the deferred settle lands). */ - releaseHeldAdds() { - addControl.held = false; - const pending = addControl.pending.splice(0); - for (const settle of pending) settle(); - }, - async settle() { - // Drain the microtask/timer interleave the load path spans. - for (let i = 0; i < 8; i++) - await new Promise((resolve) => setTimeout(resolve, 0)); - }, - cleanup() { - globalThis.fetch = originalFetch; - }, + drawn, + sweep: () => engine.fake.emit('frames.preRender', 0), + publishCount: () => hostCalls.filter(([type]) => type === 'entries').length, }; } -/** - * Gate Cesium's GeoJsonDataSource.load: each call runs the REAL load, then - * parks its resolution behind a test-owned gate. Opening gate N and aborting - * in the same synchronous frame lands the abort exactly at the caller's - * post-await ownership check for that boundary. - */ -function installGeoJsonLoadGate() { - const original = Cesium.GeoJsonDataSource.load; - const calls = []; - const gates = []; - Cesium.GeoJsonDataSource.load = function gatedLoad(...args) { - calls.push(args); - let open; - const opened = new Promise((resolve) => { - open = resolve; - }); - gates.push({ open }); - return original - .apply(Cesium.GeoJsonDataSource, args) - .then((result) => opened.then(() => result)); - }; - return { - calls, - open(index) { - gates[index]?.open(); - }, - /** - * Open every gate, including ones created by continuations the opening - * itself unblocks (a load calls the landing build only after the cable - * gate opens), until no new gates appear. - */ - async drainOpen(settle) { - let opened = 0; - while (opened < gates.length) { - for (; opened < gates.length; opened++) gates[opened].open(); - await settle(); - } - }, - restore() { - Cesium.GeoJsonDataSource.load = original; - }, - }; -} - -test('an abort landing at the post-fetch boundary stops the stale load before GeoJson work', async () => { - const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); - try { - env.layer.enable(env.viewer); - // Release A's fetches and abort in the SAME frame: A's continuation runs - // with ownership already lost, and the post-fetch owns() check is the guard. - env.releaseAll(); - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); - assert.equal( - geojson.calls.length, - 0, - 'stale load A must not reach GeoJsonDataSource.load', - ); - - env.releaseAll(); - await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal( - geojson.calls.length, - 2, - 'load B alone performs the two GeoJson loads', - ); - assert.equal(env.addCalls.count, 3); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - geojson.restore(); - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('an abort landing at the post-cable-GeoJson boundary stops the stale load before the landing build', async () => { - const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - assert.equal(env.pendingFetches.length, 0); - assert.equal( - geojson.calls.length, - 1, - 'load A is parked on the gated cable GeoJson build', - ); - - // Open the cable gate and abort in the same frame: A resumes at the - // post-cable owns() check with ownership lost. - geojson.open(0); - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); - // A already cached the fetched JSON, so load B skips the network and goes - // straight to its own CABLE build; the only new GeoJson call must be that - // one — a landing build here would mean stale A ran on past its check. - assert.equal( - env.pendingFetches.length, - 0, - 'load B rebuilds from the cached JSON without refetching', - ); - assert.equal( - geojson.calls.length, - 2, - 'stale load A must not start the landing GeoJson build', - ); - assert.ok( - Cesium.Color.equals( - geojson.calls[1][1].stroke, - geojson.calls[0][1].stroke, - ), - "the second GeoJson call is load B's cable build, not A's landing build", - ); - - env.releaseAll(); - await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal( - geojson.calls.length, - 3, - 'load B adds its own two GeoJson builds', - ); - assert.equal(env.addCalls.count, 3); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - geojson.restore(); - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('an abort landing at the pre-add boundary stops the stale load before any dataSources.add', async () => { - const env = createDeferredCableLayerHarness(); - const geojson = installGeoJsonLoadGate(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - geojson.open(0); - await env.settle(); - assert.equal( - geojson.calls.length, - 2, - 'load A is parked on the gated landing GeoJson build', - ); - - // Open the landing gate and abort in the same frame: A resumes at the - // pre-add owns() check with ownership lost. - geojson.open(1); - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); - assert.equal( - env.addCalls.count, - 0, - 'stale load A must never call dataSources.add', - ); - - env.releaseAll(); - await env.settle(); - await geojson.drainOpen(() => env.settle()); - assert.equal(env.addCalls.count, 3, 'load B alone adds the trio'); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - geojson.restore(); - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('a destroy inside the deferred add window leaves zero post-destroy data sources', async () => { - const env = createDeferredCableLayerHarness(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - env.holdAdds(); - // Let the load run until all three adds have been CALLED but none has - // settled — Cesium mutates the collection on a deferred tick, so this - // window is real, and a remove() issued inside it removes nothing. - const deadline = Date.now() + 2_000; - while (env.addCalls.count < 3 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.equal( - env.addCalls.count, - 3, - 'all three adds are in their deferred window', - ); - assert.equal( - env.dataSources.length, - 0, - 'nothing has settled into the collection yet', - ); - - // Destroy lands in the window: it can remove nothing, so the stale - // load's own post-add ownership check must compensate once the deferred - // adds settle. - env.layer.destroy(env.viewer); - env.releaseHeldAdds(); - await env.settle(); - assert.equal( - env.dataSources.length, - 0, - 'the deferred adds settled post-destroy and must be rolled back by their own load', - ); - - // The layer still honors its re-init contract afterwards. - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - assert.equal( - env.dataSources.length, - 3, - 're-enable after the raced destroy loads cleanly', - ); - assert.equal(env.layer.getStats().count, 2); - } finally { - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('a destroy in the deferred window with a rejected-after-push add still leaves zero sources', async () => { - const env = createDeferredCableLayerHarness(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - env.holdAdds(); - // Cesium pushes the source into the collection BEFORE raising - // dataSourceAdded, so a throwing listener rejects the add promise AFTER - // its mutation landed. Make add #1 exactly that: pushed, then rejected; - // #2/#3 fulfill normally. - env.failAddAfterPush(0); - const deadline = Date.now() + 2_000; - while (env.addCalls.count < 3 && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - assert.equal( - env.addCalls.count, - 3, - 'all three adds are in their deferred window', - ); - assert.equal( - env.dataSources.length, - 0, - 'nothing has settled into the collection yet', - ); - - // Destroy lands in the window; the adds then settle as - // [rejected-after-push, fulfilled, fulfilled]. Compensation that removes - // only FULFILLED adds would strand the rejected-but-pushed source. - env.layer.destroy(env.viewer); - env.releaseHeldAdds(); - await env.settle(); - assert.equal( - env.dataSources.length, - 0, - 'a rejected add still landed its push, so compensation must remove it too', - ); - } finally { - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('a direct destroy of a published layer clears and hides its host source', async () => { - const env = createDeferredCableLayerHarness(); - try { - env.layer.enable(env.viewer); - env.releaseAll(); - await env.settle(); - assert.equal(env.dataSources.length, 3, 'layer is fully loaded'); - // Drive the sweep through the layer's real preRender listener so the - // host actually holds published cable entries. - env.raiseSweep(); - assert.ok( - env.hostCalls.some(([type]) => type === 'entries'), - 'sweep must publish before the direct destroy', - ); - env.hostCalls.length = 0; - - // Destroy DIRECTLY — no disable() first. The publisher teardown must - // still clear the published source and hide it; skipping it would leave - // a visible overlay source with orphan labels. - env.layer.destroy(env.viewer); - assert.ok( - env.hostCalls.some( - ([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID, - ), - 'direct destroy must clear the published host source', - ); - assert.deepEqual( - env.hostCalls.at(-1), - ['visible', CABLE_OVERLAY_SOURCE_ID, false], - 'direct destroy must hide the host source after clearing it', - ); - } finally { - env.cleanup(); - } -}); - -test("a stale aborted load never clobbers the next load's lifecycle (A/B/C toggle race)", async () => { - const env = createDeferredCableLayerHarness(); - try { - // A: enable starts a load whose fetches stay pending. - env.layer.enable(env.viewer); - assert.equal( - env.pendingFetches.length, - 2, - 'load A has two fetches in flight', - ); - - // Abort A and immediately start B before A\'s rejection lands. - env.layer.disable(); - env.layer.enable(env.viewer); - await env.settle(); // A\'s stale catch/finally runs here, after B began. - - // C: with A\'s stale completion having cleared nothing it does not own, - // the layer still reports a load in flight, so update() must not start a - // third load. - void env.layer.update(env.viewer); - await env.settle(); - const inFlight = env.pendingFetches.filter( - (entry) => !entry.settled, - ).length; - assert.equal( - inFlight, - 2, - "only load B's two fetches remain in flight — no duplicate load C", - ); - - // Releasing everything must produce exactly one set of scene adds. - env.releaseAll(); - await env.settle(); - assert.equal( - env.addCalls.count, - 3, - 'exactly one cable/landing/reference data-source trio is added', - ); - assert.equal(env.dataSources.length, 3); - const stats = env.layer.getStats(); - assert.equal(stats.error, null); - assert.equal(stats.count, 2, 'one cable + one landing point loaded once'); - } finally { - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -test('destroy mid-load stays clean and a re-enabled layer reloads exactly once', async () => { - const env = createDeferredCableLayerHarness(); - try { - // Destroy while load A\'s fetches are still in flight. - env.layer.enable(env.viewer); - assert.equal(env.pendingFetches.length, 2); - env.layer.destroy(env.viewer); - - // Legacy contract: the layer supports re-init after destroy. Enable D - // before A\'s aborted rejection lands. - env.layer.enable(env.viewer); - await env.settle(); // A\'s stale finally runs here. - - // A\'s stale completion must not have cleared D\'s in-flight lifecycle: - // update() must not start a duplicate load E. - void env.layer.update(env.viewer); - await env.settle(); - const inFlight = env.pendingFetches.filter( - (entry) => !entry.settled, - ).length; - assert.equal(inFlight, 2, "only load D's two fetches remain in flight"); - - env.releaseAll(); - await env.settle(); - assert.equal( - env.addCalls.count, - 3, - 'the destroyed load added nothing; the re-enable added once', - ); - assert.equal(env.dataSources.length, 3); - assert.equal(env.layer.getStats().count, 2); - } finally { - env.layer.destroy(env.viewer); - env.cleanup(); - } -}); - -async function createRealCableLayerHarness({ - source, - mapStackEventTarget = null, -} = {}) { - const hostCalls = []; - // The sweep gate's motion-fallback clock is FROZEN here and advanced only by - // the tests that exercise it, so every other harness test observes the pure - // event-dirty behavior with no wall-clock coupling. - let clockMs = 0; - const layer = createTeleGeographySubmarineCableLayer({ - overlayHost: { - setVisible: (...args) => hostCalls.push(['visible', ...args]), - setEntries: (...args) => hostCalls.push(['entries', ...args]), - clearSource: (...args) => hostCalls.push(['clear', ...args]), - }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - ...(source ? { source } : {}), - mapStackEventTarget, - sweepClock: () => clockMs, - }); - - const listeners = { preRender: new Set(), moveEnd: new Set() }; - const dataSources = []; - const viewer = { - dataSources: { - add: (source) => { - dataSources.push(source); - return Promise.resolve(source); - }, - remove: (source) => { - const index = dataSources.indexOf(source); - if (index >= 0) dataSources.splice(index, 1); - return true; - }, - }, - camera: { - positionWC: Cesium.Cartesian3.fromDegrees(-40, 35, 4_500_000), - frustum: { fov: Math.PI / 3 }, - moveEnd: { - addEventListener: (fn) => { - listeners.moveEnd.add(fn); - return () => listeners.moveEnd.delete(fn); - }, - }, - heading: 0, - cancelFlight() {}, - flyTo() {}, - }, - scene: { - canvas: { clientHeight: 900 }, - requestRender() {}, - pick() { - return null; - }, - preRender: { - addEventListener: (fn) => { - listeners.preRender.add(fn); - return () => listeners.preRender.delete(fn); - }, - }, - }, - }; - - const originalFetch = globalThis.fetch; - // Node has no `document`, so Point features (whose GeoJSON default marker - // needs a canvas PinBuilder) cannot load here. Landing references only need - // `properties.coordinates`, so a line geometry exercises the same path. - globalThis.fetch = async (url) => ({ - ok: true, - json: async () => - String(url).includes('landing-point') - ? { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'lisbon', - properties: { - id: 'lisbon', - name: 'Lisbon, Portugal', - coordinates: [-9.1, 38.7], - }, - geometry: { - type: 'LineString', - coordinates: [ - [-9.1, 38.7], - [-9.05, 38.72], - ], - }, - }, - ], - } - : { - type: 'FeatureCollection', - features: [ - { - type: 'Feature', - id: 'ac1', - properties: { id: 'ac1', name: 'Atlantic Crossing-1' }, - geometry: { - type: 'LineString', - coordinates: [ - [-40, 35], - [-30, 40], - ], - }, - }, - ], - }, - }); - try { - layer.init(viewer); - layer.enable(viewer); - // enable() fires load() without awaiting it (legacy contract); poll the - // layer's own stats until the load settles. - const deadline = Date.now() + 2_000; - while (Date.now() < deadline) { - const stats = layer.getStats(); - if (stats.count > 0 || stats.error) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.equal(layer.getStats().error, null, 'harness load must succeed'); - } finally { - globalThis.fetch = originalFetch; - } - const raiseSweep = () => { - for (const fn of listeners.preRender) fn(); - }; - const advanceClock = (ms) => { - clockMs += ms; - }; - return { - layer, - viewer, - hostCalls, - dataSources, - raiseSweep, - listeners, - advanceClock, - }; -} - -test('a real enabled cable layer publishes host entries and has zero native labels', async () => { - const env = await createRealCableLayerHarness(); - env.raiseSweep(); +test('a real enabled cable layer publishes host entries and draws through the engine', async () => { + const env = await createLayerHarness(); + env.sweep(); const publishes = env.hostCalls.filter(([type]) => type === 'entries'); - assert.ok(publishes.length > 0, 'sweep must publish to the shared host'); + assert.equal(publishes.length, 1, 'sweep must publish to the shared host'); const [, sourceId, entries, options] = publishes.at(-1); assert.equal(sourceId, CABLE_OVERLAY_SOURCE_ID); assert.equal(entries.length, 2, 'one cable + one landing point reference'); @@ -1450,28 +563,10 @@ test('a real enabled cable layer publishes host entries and has zero native labe collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY, moving: false, }); - - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - assert.ok(referenceSource, 'reference data source must exist'); - const referenceEntities = referenceSource.entities.values; - assert.ok(referenceEntities.length > 0); - assert.ok( - referenceEntities.every((entity) => entity.label === undefined), - 'no reference entity may carry a native LabelGraphics', - ); - assert.ok( - referenceEntities.every( - (entity) => - entity.position?.isConstant === true && - entity.polyline?.positions?.isConstant === true, - ), - 'stem properties must be constants, never per-frame callbacks', - ); - + assert.deepEqual(env.drawn(), { ground: 1, movers: 3, lines: 2 }); const stats = env.layer.getStats(); assert.equal(stats.referenceLabelCount, 2); + assert.equal(stats.count, 2); env.layer.disable(); assert.ok( @@ -1485,273 +580,78 @@ test('a real enabled cable layer publishes host entries and has zero native labe ['visible', CABLE_OVERLAY_SOURCE_ID, false], 'disable must hide the host source after clearing it', ); - - env.layer.destroy(env.viewer); - assert.equal( - env.dataSources.length, - 0, - 'destroy must remove every data source', - ); + assert.deepEqual(env.drawn(), { ground: 0, movers: 0, lines: 0 }); + env.layer.destroy(); }); -test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async () => { - const env = await createRealCableLayerHarness(); - env.raiseSweep(); // initial sizing at the parked camera - - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - const entity = referenceSource.entities.values[0]; - let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind( - entity.polyline.positions, - ); - entity.polyline.positions.setValue = (...args) => { - stemRebuilds++; - return originalSet(...args); - }; - - // Mid-drag: the camera moves every tick and frames keep rendering, but no - // moveEnd fires. However long this goes on, the stem constants must never - // be redefined — each redefinition rebuilt the whole batched stem - // primitive, and its unready replacement frames were the felt hitch. - const camera = env.viewer.camera.positionWC; - for (let tick = 0; tick < 30; tick++) { - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - camera, - new Cesium.Cartesian3(0, 0, 100_000 * (tick + 1)), - new Cesium.Cartesian3(), - ); - env.raiseSweep(); // preRender fires every frame during a drag - await new Promise((resolve) => setTimeout(resolve, 25)); - } - assert.equal( - stemRebuilds, - 0, - 'render ticks without moveEnd must never redefine a stem', - ); - - // The gesture ends close over the cable's reference point (inside the - // 700 m..85 km stem clamp band, so the re-size is a real tip change): - // one moveEnd, one sweep, exactly one redefinition. - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - -35, - 37.5, - 400_000, - ); - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - assert.equal(stemRebuilds, 1, 'moveEnd re-sizes the stem exactly once'); - env.raiseSweep(); - assert.equal(stemRebuilds, 1, 'the next frame does not re-run the sweep'); - - env.layer.destroy(env.viewer); -}); - -test('a tracked camera that never emits moveEnd is swept by the motion fallback', async () => { - const env = await createRealCableLayerHarness(); - // Tracking hands over a mid-altitude follow camera. 400 km keeps the stem - // inside the 700 m..85 km clamp band, so a sweep is observable as a real - // tip re-size (at the harness's 4,500 km parking altitude every stem sits - // pinned at the 85 km clamp and no sweep could be distinguished). - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - -35, - 37.5, - 400_000, - ); - env.raiseSweep(); // initial sizing at the tracking handoff - - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - const entity = referenceSource.entities.values[0]; - let stemRebuilds = 0; - const originalSet = entity.polyline.positions.setValue.bind( - entity.polyline.positions, - ); - entity.polyline.positions.setValue = (...args) => { - stemRebuilds++; - return originalSet(...args); - }; - - // Entity tracking / orbit: the follow camera advances every frame and - // moveEnd NEVER fires. Frames are simulated at 60 Hz on the harness clock. - const origin = env.viewer.camera.positionWC; - const frameMs = 1000 / 60; - const trackFrames = (from, to) => { - for (let frame = from; frame < to; frame++) { - env.advanceClock(frameMs); - env.viewer.camera.positionWC = Cesium.Cartesian3.add( - origin, - new Cesium.Cartesian3(0, 0, 200 * (frame + 1)), - new Cesium.Cartesian3(), - ); - env.raiseSweep(); - } - }; - - // Inside the first probe window the sweep stays event-dirty-only, so the - // batched stem primitive is not rebuilt mid-motion (the shipped tradeoff). - trackFrames(0, 60); - assert.equal( - stemRebuilds, - 0, - 'inside the probe window a tracked camera still costs no rebuilds', - ); - - // Past the window the fallback fires — exactly once, not per frame. - trackFrames(60, 130); - assert.equal( - stemRebuilds, - 1, - 'the tracked camera earns exactly one fallback sweep per window', - ); - - // Tracking stops but frames keep rendering. The motion accumulated since - // the last fallback sweep settles in exactly ONE further sweep... - const parkFrames = (count) => { - for (let frame = 0; frame < count; frame++) { - env.advanceClock(frameMs); - env.raiseSweep(); - } - }; - parkFrames(240); // 4 s parked: two full probe windows - assert.equal( - stemRebuilds, - 2, - 'a stopped camera settles its residual motion in one further sweep', - ); - - // ...and the parked camera then costs zero sweeps for as long as it sits. - parkFrames(3600); // 60 s parked - assert.equal( - stemRebuilds, - 2, - 'a settled parked camera never spends another sweep', - ); - - env.layer.destroy(env.viewer); +test('a direct destroy of a published layer clears and hides its host source', async () => { + const env = await createLayerHarness(); + env.sweep(); + assert.equal(env.publishCount(), 1); + env.hostCalls.length = 0; + // Destroy DIRECTLY — no disable() first. + env.layer.destroy(); + assert.deepEqual(env.hostCalls, [ + ['clear', CABLE_OVERLAY_SOURCE_ID], + ['visible', CABLE_OVERLAY_SOURCE_ID, false], + ]); + assert.deepEqual(env.drawn(), { ground: 0, movers: 0, lines: 0 }); }); test('an unchanged cohort never republishes, so a parked camera stays governor-idle', async () => { - const env = await createRealCableLayerHarness(); - const publishCount = () => - env.hostCalls.filter(([type]) => type === 'entries').length; - - env.raiseSweep(); - assert.equal(publishCount(), 1, 'first sweep publishes the cohort'); + const env = await createLayerHarness(); + env.sweep(); + assert.equal(env.publishCount(), 1, 'first sweep publishes the cohort'); // moveEnd with an unchanged camera re-arms the sweep; the identical winner // set must NOT reach the host again (each publish costs a requested render). - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - assert.equal(publishCount(), 1, 'identical cohorts are skipped'); + env.moveEnd(); + env.sweep(); + env.moveEnd(); + env.sweep(); + assert.equal(env.publishCount(), 1, 'identical cohorts are skipped'); - // A real camera move changes distances beyond the 50 km priority quantum, - // so the next sweep publishes again. - env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees( - -40, - 35, - 6_500_000, - ); - for (const fn of env.listeners.moveEnd) fn(); - env.raiseSweep(); - assert.equal(publishCount(), 2, 'a changed cohort publishes'); + // A real camera move changes distances beyond the 50 km priority quantum. + env.look(-40, 35, 6_500_000); + env.moveEnd(); + env.sweep(); + assert.equal(env.publishCount(), 2, 'a changed cohort publishes'); - // disable → enable must force a republish even when the cohort is identical - // (the hide cleared the host source). + // disable → enable republishes an identical cohort: the hide cleared the + // host source. env.layer.disable(); - env.layer.enable(env.viewer); - await new Promise((resolve) => setTimeout(resolve, 10)); - env.raiseSweep(); - assert.equal( - publishCount(), - 3, - 're-enable republishes after the hide cleared the source', - ); - - env.layer.destroy(env.viewer); + await env.layer.enable(); + env.sweep(); + assert.equal(env.publishCount(), 3, 're-enable republishes'); + env.layer.destroy(); }); -test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => { - const env = await createRealCableLayerHarness(); - assert.equal( - env.dataSources.length, - 3, - 'cable + landing + reference sources while on', - ); - const before = env.layer.getStats().count; - assert.equal(before, 2); - - // Any fetch after this point is a bug: the rebuild must come from the cache. - const originalFetch = globalThis.fetch; - let fetches = 0; - globalThis.fetch = async () => { - fetches += 1; - throw new Error('unexpected refetch'); - }; - try { - env.layer.disable(); - assert.equal( - env.dataSources.length, - 0, - 'hidden entities are not free; disable must remove the sources', - ); - - env.layer.enable(env.viewer); - const deadline = Date.now() + 2_000; - while (Date.now() < deadline && env.dataSources.length < 3) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - assert.equal(fetches, 0, 'the rebuild must not touch the network'); - assert.equal(env.layer.getStats().error, null); - assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio'); - assert.equal(env.layer.getStats().count, before); - const referenceSource = env.dataSources.find((ds) => - /References/.test(ds.name || ''), - ); - assert.ok( - referenceSource?.entities.values.length > 0, - 'reference stems are rebuilt', - ); - - env.layer.destroy(env.viewer); - assert.equal(env.dataSources.length, 0); - } finally { - globalThis.fetch = originalFetch; - } +test('a toggle-off releases everything drawn and a toggle-on rebuilds it without refetching', async () => { + const env = await createLayerHarness(); + assert.deepEqual(env.drawn(), { ground: 1, movers: 3, lines: 0 }); + env.layer.disable(); + assert.deepEqual(env.drawn(), { ground: 0, movers: 0, lines: 0 }); + await env.layer.enable(); + assert.equal(env.source.reads, 1, 'the rebuild must not touch the network'); + assert.equal(env.layer.getStats().error, null); + assert.deepEqual(env.drawn(), { ground: 1, movers: 3, lines: 0 }); + assert.equal(env.layer.getStats().count, 2); + env.layer.destroy(); + assert.deepEqual(env.drawn(), { ground: 0, movers: 0, lines: 0 }); }); test('another GeoJSON source renders with its own label and rebuilds from accepted cache', async () => { - let reads = 0; - const source = { - label: 'Test cable directory', - async fetch(signal) { - signal.throwIfAborted(); - reads += 1; - return { cables: CABLE_FIXTURE, landingPoints: LANDING_FIXTURE }; - }, - }; - const env = await createRealCableLayerHarness({ source }); + const env = await createLayerHarness({ source: fixtureSource() }); try { - assert.equal(env.layer.source, source.label); - assert.equal(reads, 1); - assert.equal(env.dataSources.length, 3); - assert.ok( - env.dataSources.every((item) => item.name.startsWith(source.label)), - ); + assert.equal(env.layer.source, 'Test cable directory'); + assert.equal(env.source.reads, 1); env.layer.disable(); - assert.equal(env.dataSources.length, 0); - env.layer.enable(env.viewer); - for (let i = 0; i < 8; i++) - await new Promise((resolve) => setTimeout(resolve, 0)); + await env.layer.enable(); assert.equal(env.layer.getStats().count, 2); - assert.equal(env.dataSources.length, 3); - assert.equal(reads, 1); + assert.equal(env.paths().items.length, 1); + assert.equal(env.source.reads, 1); } finally { - env.layer.destroy(env.viewer); + env.layer.destroy(); } }); @@ -1802,19 +702,70 @@ function multipartCableFixture() { ]; } -function geometrySource(cables, landingPoints = []) { - return { - label: 'Multipart test source', - async fetch() { - return { - cables: { type: 'FeatureCollection', features: cables }, - landingPoints: { type: 'FeatureCollection', features: landingPoints }, - }; - }, - }; -} +const geometrySource = (cables, landingPoints = []) => + fixtureSource('Multipart test source', { + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landingPoints }, + }); -test('multipart association accepts frozen provider collections without modifying properties', async () => { +const near = (actual, expected, label) => + actual.forEach((value, index) => + assert.ok(Math.abs(value - expected[index]) < 1e-6, label), + ); +const cssRgba = (css, alpha) => { + const color = Cesium.Color.fromCssColorString(css); + return [color.red, color.green, color.blue, alpha]; +}; + +test('multipart cables keep their own colour, width and pick on every segment, one reference per feature', async () => { + const features = multipartCableFixture(); + const before = JSON.stringify(features); + const env = await createLayerHarness({ source: geometrySource(features) }); + try { + const items = env.paths().items; + assert.equal(items.length, 4); + for (const [index, item] of items.entries()) { + const feature = features[index < 3 ? 0 : 1]; + near(item.color, cssRgba(feature.properties.color, 0.92), feature.id); + assert.equal(item.widthPx, 2.5); + } + assert.deepEqual( + items.map((item) => item.pickId), + [ + 'cable-reference-0-alpha', + 'cable-reference-0-alpha', + 'cable-reference-0-alpha', + 'cable-reference-1-beta', + ], + ); + // Every segment flies to its own feature's reference. + for (const instance of [0, 2, 3]) env.clickPath(instance); + const references = env.flights.map((flight) => { + const place = geo.cartographic.fromCartesian(flight.destination); + return [ + Math.round(geo.math.toDegrees(place.longitude)), + Math.round(geo.math.toDegrees(place.latitude)), + ]; + }); + assert.deepEqual(references, [ + [-40, 35], + [-40, 35], + [140, -35], + ]); + assert.equal( + env.engine.movers + .list() + .find(({ id }) => id === `${CABLE_OVERLAY_SOURCE_ID}-cable-tips`).count, + 2, + 'one reference per feature', + ); + assert.equal(JSON.stringify(features), before, 'features stay unchanged'); + } finally { + env.layer.destroy(); + } +}); + +test('frozen provider collections are drawn without being modified', async () => { const payload = { cables: { type: 'FeatureCollection', features: multipartCableFixture() }, landingPoints: { @@ -1822,8 +773,6 @@ test('multipart association accepts frozen provider collections without modifyin features: multipartCableFixture(), }, }; - payload.cables.features[0].properties.__gevSourceFeatureIndex = - 'provider value'; const before = structuredClone(payload); const freeze = (value) => { if (value && typeof value === 'object') { @@ -1833,74 +782,19 @@ test('multipart association accepts frozen provider collections without modifyin return value; }; freeze(payload); - const env = await createRealCableLayerHarness({ - source: { label: 'Frozen multipart source', fetch: async () => payload }, + const env = await createLayerHarness({ + source: fixtureSource('Frozen multipart source', payload), }); try { - for (const dataSource of env.dataSources.slice(0, 2)) { - assert.deepEqual( - dataSource.entities.values.map( - (entity) => entity.__gevTeleGeography?.label, - ), - ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], - ); - } - assert.equal(env.dataSources[2].entities.values.length, 4); + // Landing lines draw as paths too (the GeoJSON stroke): 4 + 4. + assert.equal(env.paths().items.length, 8); assert.deepEqual(payload, before); - const entity = env.dataSources[0].entities.values[0]; - assert.equal(entity.properties.__gevSourceFeatureIndex.getValue(), 0); - assert.match(entity.description.getValue(), /__gevSourceFeatureIndex/); } finally { - env.layer.destroy(env.viewer); + env.layer.destroy(); } }); -test('multipart cables retain their own styling, picking and culling on every segment', async () => { - const features = multipartCableFixture(); - const before = JSON.stringify(features); - const env = await createRealCableLayerHarness({ - source: geometrySource(features), - }); - try { - const entities = env.dataSources[0].entities.values; - assert.equal(entities.length, 4); - for (const [index, entity] of entities.entries()) { - const feature = features[index < 3 ? 0 : 1]; - const info = entity.__gevTeleGeography; - assert.equal(info?.label, feature.properties.name, entity.id); - assert.deepEqual(info.reference, { - lon: feature.properties.coordinates[0], - lat: feature.properties.coordinates[1], - }); - assert.deepEqual( - entity.polyline.material.color.getValue(), - Cesium.Color.fromCssColorString(feature.properties.color).withAlpha( - 0.92, - ), - ); - assert.equal(entity.polyline.width.getValue(), 2.5); - } - env.raiseSweep(); - assert.deepEqual( - entities.map((entity) => entity.show), - [true, true, true, false], - ); - assert.equal( - env.dataSources[2].entities.values.length, - 2, - 'one reference per feature', - ); - assert.equal( - JSON.stringify(features), - before, - 'source features stay unchanged', - ); - } finally { - env.layer.destroy(env.viewer); - } -}); - -test('multipart association supports missing and repeated IDs without guessing Cesium suffixes', async () => { +test('missing and repeated IDs still give every feature its own reference', async () => { const features = multipartCableFixture(); features[1].id = 'alpha_2'; delete features[1].properties.id; @@ -1912,94 +806,50 @@ test('multipart association supports missing and repeated IDs without guessing C repeated.properties.id = 'alpha'; repeated.properties.name = 'Repeated ID cable'; features.push(repeated); - const env = await createRealCableLayerHarness({ - source: geometrySource(features), - }); + const env = await createLayerHarness({ source: geometrySource(features) }); try { - const entities = env.dataSources[0].entities.values; + const items = env.paths().items; assert.deepEqual( - entities.map((entity) => entity.id), - ['alpha', 'alpha_2', 'alpha_3', 'alpha_2_2', 'cable-2', 'alpha_4'], - ); - assert.deepEqual( - entities.map((entity) => entity.__gevTeleGeography?.label), + [...new Set(items.map((item) => item.pickId))], [ - 'Alpha cable', - 'Alpha cable', - 'Alpha cable', - 'Beta cable', - 'Generated cable', - 'Repeated ID cable', + 'cable-reference-0-alpha', + 'cable-reference-1-alpha_2', + 'cable-reference-2-cable-2', + 'cable-reference-3-alpha', ], + 'the repeated provider ID keeps its own reference', ); - assert.deepEqual( - env.dataSources[2].entities.values.map( - (entity) => entity.__gevTeleGeography.featureId, - ), - ['alpha', 'alpha_2', 'cable-2', 'alpha'], - ); + env.look(140, -35, 4_500_000); + env.moveEnd(); + env.sweep(); + const [, , entries] = env.hostCalls + .filter(([type]) => type === 'entries') + .at(-1); + assert.deepEqual(entries.map((entry) => entry.title).sort(), [ + 'Beta cable', + 'Generated cable', + 'Repeated ID cable', + ]); } finally { - env.layer.destroy(env.viewer); + env.layer.destroy(); } }); test('multipart landing geometry keeps every pick with one reference per feature', async () => { const features = multipartCableFixture(); - const env = await createRealCableLayerHarness({ + const env = await createLayerHarness({ source: geometrySource([], features), }); try { - const entities = env.dataSources[1].entities.values; - assert.deepEqual( - entities.map((entity) => entity.__gevTeleGeography?.label), - ['Alpha cable', 'Alpha cable', 'Alpha cable', 'Beta cable'], - ); + const items = env.paths().items; + assert.equal(items.length, 4); assert.ok( - entities.every( - (entity) => entity.__gevTeleGeography.kind === 'landing-point', - ), + items.every((item) => item.pickId.startsWith('landing-point-reference-')), ); - assert.equal(env.dataSources[2].entities.values.length, 2); + assert.equal(new Set(items.map((item) => item.pickId)).size, 2); + near(items[0].color, cssRgba('#8fffd2', 0.9), 'the GeoJSON landing stroke'); + assert.equal(items[0].widthPx, 2); } finally { - env.layer.destroy(env.viewer); - } -}); - -test('every bundled cable segment is associated with its source feature', async () => { - const { features } = JSON.parse( - readFileSync( - new URL( - './local_data/telegeography_submarine_cables/cable-geo.json', - import.meta.url, - ), - 'utf8', - ), - ); - // Some bundled features share a cable ID but have different references. - const expandedFeatures = features.flatMap((feature) => - feature.geometry.coordinates.map(() => feature), - ); - assert.ok(expandedFeatures.length > features.length); - const env = await createRealCableLayerHarness({ - source: geometrySource(features), - }); - try { - const entities = env.dataSources[0].entities.values; - assert.equal(entities.length, expandedFeatures.length); - for (const [index, entity] of entities.entries()) { - const feature = expandedFeatures[index]; - assert.equal( - entity.__gevTeleGeography?.label, - feature.properties.name, - entity.id, - ); - assert.deepEqual(entity.__gevTeleGeography.reference, { - lon: feature.properties.coordinates[0], - lat: feature.properties.coordinates[1], - }); - } - assert.equal(env.dataSources[2].entities.values.length, features.length); - } finally { - env.layer.destroy(env.viewer); + env.layer.destroy(); } }); diff --git a/src/i18n/layerStatusText.test.mjs b/src/i18n/layerStatusText.test.mjs index 0f9186442..2897d0722 100644 --- a/src/i18n/layerStatusText.test.mjs +++ b/src/i18n/layerStatusText.test.mjs @@ -24,7 +24,7 @@ const SITES = [ ['../layers/recentImagery/index.js', 'Imagery catalog unavailable'], ['../layers/installations/ingestion.js', 'Installation context unavailable'], ['../layers/earthquakes/index.js', 'Earthquake source unavailable'], - ['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'], + ['../layers/submarineCables/session.js', 'TeleGeography load failed'], ['../layers/vessels/ingestion.js', 'AIS live load failed'], ['../layers/vessels/lifecycle.js', 'AIS live load failed'], ['../layers/wind/index.js', 'Wind unavailable'], diff --git a/src/layers/airQuality/airQualityLocale.test.mjs b/src/layers/airQuality/airQualityLocale.test.mjs index 66f4942f4..91bde7923 100644 --- a/src/layers/airQuality/airQualityLocale.test.mjs +++ b/src/layers/airQuality/airQualityLocale.test.mjs @@ -6,6 +6,7 @@ import { setLocale, t } from '../../i18n/index.js'; import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js'; import { LayerPanel } from '../../ui/layerPanel.js'; import { translateFeedError } from '../../ui/statusText.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createAirQualityLayer } from './index.js'; function inLocale(locale, run) { @@ -22,9 +23,8 @@ function harness(source) { source, overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, }); - const viewer = { dataSources: { add() {}, remove() {} } }; - layer.init(viewer); - layer.enable(viewer); + layer.init(null, { engine: createFakeEngine() }); + layer.enable(); return layer; } diff --git a/src/layers/airQuality/index.js b/src/layers/airQuality/index.js index 44fb3bff8..540337f25 100644 --- a/src/layers/airQuality/index.js +++ b/src/layers/airQuality/index.js @@ -1,6 +1,6 @@ import { fromDegrees } from '../../geo/cartesian3.js'; import { toDegrees } from '../../geo/math.js'; -import { createAirQualityCesiumRenderer } from './render/cesium.js'; +import { createAirQualityDisplay } from './render/display.js'; import { msg, t, tn } from '../../i18n/index.js'; import { translateFeedError } from '../../ui/statusText.js'; import { @@ -28,7 +28,7 @@ const READOUT_LIMIT = 5; export function createAirQualityLayer({ source, overlayHost, - createRenderer = createAirQualityCesiumRenderer, + createRenderer = ({ engine }) => createAirQualityDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Air quality requires a snapshot source'); @@ -69,7 +69,7 @@ export function createAirQualityLayer({ throw new Error('Air quality layer is already initialized'); initialized = true; try { - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer(viewer, { engine }); } catch (error) { this.destroy(); throw error; @@ -79,7 +79,7 @@ export function createAirQualityLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid air quality scene attachment'); - renderer = createRenderer({ viewer, engine }); + renderer = createRenderer({ engine }); renderer.setVisible(_enabled); overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled); if (_readings.length) this._render(); @@ -139,7 +139,7 @@ export function createAirQualityLayer({ } }, - /** Rebuild entities and overlay labels from the current readings. */ + /** Redraw the station discs and overlay labels from the current readings. */ _render() { if (!renderer) return; renderer.replace(_readings, STATION_MARKER_RADIUS_M); diff --git a/src/layers/airQuality/ownership.test.mjs b/src/layers/airQuality/ownership.test.mjs index 74d69b757..bbfdfb5f6 100644 --- a/src/layers/airQuality/ownership.test.mjs +++ b/src/layers/airQuality/ownership.test.mjs @@ -1,22 +1,14 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; import { createAirQualityLayer } from './index.js'; import { layerFeedState } from '../../data/feedState.js'; import { STALE_AFTER_MS } from './model.js'; function harness(source) { - const sources = []; const events = []; - const viewer = { - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); - }, - }, - }; + const ground = createFakeGroundHarness(); + const { engine } = ground; const layer = createAirQualityLayer({ source, overlayHost: { @@ -27,9 +19,9 @@ function harness(source) { clearSource() {}, }, }); - layer.init(viewer); - layer.enable(viewer); - return { layer, viewer, sources, events }; + layer.init(null, { engine }); + layer.enable(); + return { layer, engine, ground, events }; } const reading = (id, value, overrides = {}) => ({ @@ -61,12 +53,12 @@ test('a layer requires both a snapshot source and an overlay host', () => { ); }); -test('initialization is single-use and owns exactly one data source', () => { - const { layer, viewer, sources } = harness(staticSource([])); - assert.equal(sources.length, 1); - assert.throws(() => layer.init(viewer), /already initialized/); - layer.destroy(viewer); - assert.equal(sources.length, 0); +test('initialization is single-use and owns exactly one ground set', () => { + const { layer, engine, ground } = harness(staticSource([])); + assert.equal(ground.sets(), 1); + assert.throws(() => layer.init(null, { engine }), /already initialized/); + layer.destroy(); + assert.equal(ground.sets(), 0); }); test('a refresh renders readings and publishes an overlay cohort', async () => { @@ -188,14 +180,14 @@ test('the readout reports an error rather than a silent empty card', async () => }); test('row-control listeners are notified on refresh and teardown-safe', async () => { - const { layer, viewer } = harness(staticSource([reading('A', 2)])); + const { layer } = harness(staticSource([reading('A', 2)])); let calls = 0; layer.setRowControlsListener(() => { calls += 1; }); await layer.update(); assert.ok(calls >= 1, 'a refresh notifies the card'); - layer.destroy(viewer); + layer.destroy(); const after = calls; layer.setRowControlsListener(() => { calls += 1; @@ -284,13 +276,13 @@ test('station data stays queryable and attaches to a fresh scene without polling }); try { h.layer.detachRenderer(); - assert.equal(h.sources.length, 0); + assert.equal(h.ground.sets(), 0); assert.equal(await h.layer.update(), true); assert.equal(h.events.length, 0); assert.equal(h.layer.getAnalystRecords()[0].value, 7); - h.layer.attachRenderer(h.viewer); + h.layer.attachRenderer(null, { engine: h.engine }); assert.equal(h.events.at(-1)[0], 'air-quality'); - assert.equal(h.sources[0].entities.values.length, 1); + assert.equal(h.ground.set('air-quality').items.length, 1); assert.equal(polls, 1); } finally { h.layer.destroy(); diff --git a/src/layers/airQuality/render/cesium.js b/src/layers/airQuality/render/cesium.js deleted file mode 100644 index c2b78e32a..000000000 --- a/src/layers/airQuality/render/cesium.js +++ /dev/null @@ -1,56 +0,0 @@ -import * as Cesium from 'cesium'; - -export function createAirQualityCesiumRenderer({ viewer }) { - const dataSource = new Cesium.CustomDataSource('air-quality'); - dataSource.show = false; - viewer.dataSources.add(dataSource); - return { - viewRectangle: () => viewer.camera?.computeViewRectangle?.(), - setVisible(visible) { - dataSource.show = visible; - }, - replace(readings, radius) { - dataSource.entities.removeAll(); - for (const reading of readings) { - const position = Cesium.Cartesian3.fromDegrees( - reading.lon, - reading.lat, - ); - const color = Cesium.Color.fromCssColorString(reading.color); - dataSource.entities.add( - new Cesium.Entity({ - id: `air-quality:${reading.id}`, - position, - ellipse: { - // Static axes, as in the earthquake layer: a per-frame axis - // re-tessellates the clamped ground primitive every frame. - semiMajorAxis: radius, - semiMinorAxis: radius, - material: new Cesium.ColorMaterialProperty(color.withAlpha(0.35)), - outline: true, - outlineColor: color.withAlpha(0.9), - outlineWidth: 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - properties: { - provider: reading.provider, - scale: reading.scale, - value: reading.value, - band: reading.band, - name: reading.name, - zone: reading.zone, - risk: reading.risk, - observedMs: reading.observedMs, - note: reading.note, - lat: reading.lat, - lon: reading.lon, - }, - }), - ); - } - }, - destroy() { - viewer.dataSources.remove(dataSource, true); - }, - }; -} diff --git a/src/layers/airQuality/render/display.js b/src/layers/airQuality/render/display.js new file mode 100644 index 000000000..70a93ff47 --- /dev/null +++ b/src/layers/airQuality/render/display.js @@ -0,0 +1,55 @@ +// The air-quality stations on the globe contract (engine.ground, +// engine.camera), the same on every engine: one ground disc per reading, +// of the layer's marker radius, filled in its band colour at 0.35, picked +// as `air-quality:${id}`. No outline: Cesium draws none on a ground-clamped +// ellipse, so none showed on either engine. + +import { rgbaFromCss } from '../../shared/color.js'; + +const SET_ID = 'air-quality'; +const FILL_ALPHA = 0.35; + +/** What a pick of a station disc returns. */ +export const airQualityPickId = (reading) => `air-quality:${reading.id}`; + +/** + * The station discs: `{ viewRectangle, setVisible, replace(readings, + * radiusM), destroy }`. viewRectangle() is the camera's (radians), or + * undefined when the globe is out of view. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createAirQualityDisplay(engine) { + if (!engine?.ground) + throw new TypeError('The air-quality display needs a globe engine'); + const set = engine.ground.createSet({ + id: SET_ID, + kind: 'disc', + pickable: true, + }); + set.show(false); + let destroyed = false; + return { + viewRectangle: () => + destroyed ? undefined : engine.camera.viewRectangle(), + setVisible(visible) { + if (!destroyed) set.show(visible); + }, + replace(readings, radiusM) { + if (destroyed) return; + set.replace( + readings.map((reading) => ({ + center: [reading.lon, reading.lat], + radiusM, + fill: rgbaFromCss(reading.color).withAlpha(FILL_ALPHA).rgba(), + pickId: airQualityPickId(reading), + })), + ); + set.commit(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + set.destroy(); + }, + }; +} diff --git a/src/layers/airQuality/render/display.test.mjs b/src/layers/airQuality/render/display.test.mjs new file mode 100644 index 000000000..55b4d7e33 --- /dev/null +++ b/src/layers/airQuality/render/display.test.mjs @@ -0,0 +1,180 @@ +// The air-quality stations on the engine contract, on the fake engine and +// on the Cesium engine (its ground batches over fake primitives): a 6 km +// disc per reading in its band colour at 0.35, no outline, picks, the view +// rectangle the readout reads, visibility through the layer and teardown. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { + GROUND_HARNESSES, + createFakeGroundHarness, + instanceColor, +} from '../../../testSupport/groundDisplayEngines.mjs'; +import { rgbaFromCss } from '../../shared/color.js'; +import { createAirQualityLayer } from '../index.js'; +import { createAirQualityDisplay } from './display.js'; + +const reading = (id, value, lon, lat, color) => ({ + id: `eccc:${id}`, + provider: 'eccc', + scale: 'AQHI', + value, + band: 'low', + stationId: id, + name: id, + zone: 'ont', + lat, + lon, + observedMs: Date.now() - 10 * 60_000, + color, + label: String(value), + risk: 'Low health risk', + note: null, +}); +const readings = [ + reading('Toronto', 2, -79.38, 43.65, '#4cc9f0'), + reading('Hamilton', 8, -79.87, 43.26, '#ff6b35'), +]; +const RADIUS_M = 6_000; + +function fixture(makeHarness) { + const h = makeHarness(); + const entries = []; + const layer = createAirQualityLayer({ + source: { getSnapshot: async () => readings }, + overlayHost: { + setEntries: (...args) => entries.push(args), + setVisible() {}, + clearSource() {}, + }, + }); + layer.init(null, { engine: h.engine }); + layer.enable(); + return { ...h, layer, entries }; +} + +const near = (actual, expected, label, tolerance = 1e-9) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +test('the display needs a globe engine', () => { + assert.throws(() => createAirQualityDisplay(null), TypeError); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`each reading is a 6 km ground disc in its band colour at 0.35, without an outline (${name})`, async () => { + const f = fixture(makeHarness); + try { + assert.equal(await f.layer.update(), true); + const set = f.set('air-quality'); + assert.equal(set.options.kind, 'disc'); + assert.equal(set.options.pickable, true); + assert.equal(set.shown, true); + assert.deepEqual( + set.items.map(({ center, radiusM, pickId, outline }) => ({ + center, + radiusM, + pickId, + outline, + })), + readings.map((row) => ({ + center: [row.lon, row.lat], + radiusM: RADIUS_M, + pickId: `air-quality:${row.id}`, + outline: undefined, + })), + ); + set.items.forEach(({ fill }, index) => { + const expected = rgbaFromCss(readings[index].color) + .withAlpha(0.35) + .rgba(); + fill.forEach((channel, at) => + near(channel, expected[at], `fill ${index}.${at}`), + ); + }); + assert.equal(f.entries.at(-1)[0], 'air-quality', 'labels published'); + if (f.cesium) { + const { fills, lines } = f.primitives('air-quality'); + assert.equal(fills.length, 1, 'one ground batch'); + assert.equal(lines.length, 0, 'no outline batch'); + assert.equal( + fills[0].options.classificationType, + Cesium.ClassificationType.BOTH, + ); + fills[0].options.geometryInstances.forEach((instance, index) => { + assert.ok(instance.geometry instanceof Cesium.EllipseGeometry); + assert.equal(instance.geometry._semiMajorAxis, RADIUS_M); + const expected = rgbaFromCss(readings[index].color) + .withAlpha(0.35) + .rgba(); + instanceColor(instance).forEach((channel, at) => + near(channel, expected[at], `colour ${index}.${at}`, 0.5 / 255), + ); + }); + } + f.layer.disable(); + assert.equal(f.set('air-quality').shown, false); + if (f.cesium) assert.equal(f.collectionShown('air-quality'), false); + f.layer.enable(); + assert.equal(f.set('air-quality').shown, true); + } finally { + f.layer.destroy(); + } + assert.equal(f.sets(), 0); + f.destroy(); + }); + + test(`the readout lists the readings in the camera's view (${name})`, async () => { + const f = fixture(makeHarness); + try { + f.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-79.38, 43.65, 30_000), + }); + await f.layer.update(); + assert.deepEqual( + f.layer.getRowControls().list.items.map((item) => item.text), + ['Toronto · Low health risk'], + 'Hamilton is out of a 30 km high view over Toronto', + ); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); +} + +test('a pick of a station names its reading (the fake engine)', async () => { + const f = fixture(createFakeGroundHarness); + try { + await f.layer.update(); + f.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-79.87, 43.26, 50_000), + }); + const { width, height } = f.engine.host.size(); + const picked = f.engine.picker.pick({ x: width / 2, y: height / 2 }); + assert.equal(picked.id, 'air-quality:eccc:Hamilton'); + assert.equal(picked.owner, 'air-quality'); + } finally { + f.layer.destroy(); + f.destroy(); + } +}); + +test('the display ignores calls after destroy and destroys once', () => { + const h = createFakeGroundHarness(); + const display = createAirQualityDisplay(h.engine); + assert.equal(h.set('air-quality').shown, false, 'hidden until shown'); + display.destroy(); + display.destroy(); + assert.equal(h.sets(), 0); + display.setVisible(true); + display.replace(readings, RADIUS_M); + assert.equal(display.viewRectangle(), undefined); + h.destroy(); +}); diff --git a/src/layers/airQuality/render/navara.js b/src/layers/airQuality/render/navara.js deleted file mode 100644 index 29ab1b9e1..000000000 --- a/src/layers/airQuality/render/navara.js +++ /dev/null @@ -1,62 +0,0 @@ -import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js'; -import * as geo from '../../../geo/index.js'; - -function stationFeature(reading, radius) { - const origin = geo.cartesian3.fromDegrees(reading.lon, reading.lat); - const frame = geo.transforms.eastNorthUpToFixedFrame(origin); - const ring = []; - for (let i = 0; i < 48; i++) { - const angle = (i * 2 * Math.PI) / 48; - const local = geo.cartesian3.create( - radius * Math.cos(angle), - radius * Math.sin(angle), - 0, - ); - const world = geo.matrix4.multiplyByPoint( - frame, - local, - geo.cartesian3.create(), - ); - const point = geo.cartographic.fromCartesian(world); - ring.push([ - geo.math.toDegrees(point.longitude), - geo.math.toDegrees(point.latitude), - ]); - } - ring.push(ring[0]); - return { - type: 'Feature', - id: `air-quality:${reading.id}`, - properties: { ...reading }, - geometry: { type: 'Polygon', coordinates: [ring] }, - }; -} - -/** One native GeoJSON layer per AQHI colour, independent of station count. */ -export function createAirQualityNavaraRenderer(engine, { view }) { - const display = createNavaraPolygonDisplay(engine, view, (color) => ({ - polygon: { - color, - transparent: true, - opacity: 0.35, - clampToGround: true, - outline: true, - outlineShow: true, - outlineColor: color, - outlineWidth: 2, - }, - })); - return { - viewRectangle: () => engine.camera.viewRectangle(), - setVisible: display.setVisible, - destroy: display.destroy, - replace(rows, radius) { - const groups = new Map(); - for (const row of rows) { - if (!groups.has(row.color)) groups.set(row.color, []); - groups.get(row.color).push(stationFeature(row, radius)); - } - display.replace(groups); - }, - }; -} diff --git a/src/layers/cyclones/coverage.test.mjs b/src/layers/cyclones/coverage.test.mjs index b2e722123..fc6c953d4 100644 --- a/src/layers/cyclones/coverage.test.mjs +++ b/src/layers/cyclones/coverage.test.mjs @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createCyclonesLayer } from './index.js'; import { setLocale, t } from '../../i18n/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; // The exact string the server sends (COVERAGE in server/providers/cyclones.js). const SERVER_COVERAGE = @@ -52,7 +53,7 @@ function harness(feed) { matchMedia: () => ({ matches: false }), openLink: () => {}, }); - layer.init({ camera: { flyToBoundingSphere() {} } }); + layer.init(null, { engine: createFakeEngine() }); layer.attachShellServices({ runNavigation: (fn) => fn() }); return layer; } diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 0e7b574bf..d36de6be6 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js'; import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { isPointerFree } from '../../data/inputOwnership.js'; @@ -6,10 +5,7 @@ import { registerPickOwner, unregisterPickOwner, } from '../../data/pickRegistry.js'; -import { - createCycloneRendering, - coherentCycloneGeometry, -} from './rendering.js'; +import { coherentCycloneGeometry } from './rendering.js'; import { CYCLONE_OVERLAY_SOURCE_ID, cycloneStormIdFromEntryId, @@ -62,23 +58,24 @@ const classificationName = (code) => const number = (value, unit) => value === null ? t('Unavailable') : formatQuantity(value, unit); -/** Advisory status and coherent forecast geometry; selected through the shared row list. */ +/** + * Advisory status and coherent forecast geometry; selected through the + * shared row list. `createRendering(engine, { overlayHost })` makes the + * engine's display (render/display.js through the renderer registry). + */ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, context = null, - cesium = null, - createRendering = createCycloneRendering, - createNativeRendering = null, + createRendering = null, matchMedia = globalThis.matchMedia?.bind(globalThis), openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'), } = {}) { if (typeof feed?.getSnapshot !== 'function') throw new TypeError('Cyclones require a snapshot source'); let engine = null; - let viewer = null, - rendering = null, + let rendering = null, snapshot = null, request = null, listener = null, @@ -157,27 +154,14 @@ export function createCyclonesLayer({ selectedId = id; rendering?.setSelection(id); } - // The SDK, on Cesium only: the production build's Cesium global loads with - // the first Cesium globe, so a Navara page has none. - const sdk = () => cesium ?? (engine?.kind === 'navara' ? null : Cesium); - // Photorealistic 3D Tiles pick as tileset content without an entity id; - // that is empty map, the same as no pick at all on the globe. + // Photorealistic 3D Tiles pick as tile content without an id; that is + // empty map, the same as no pick at all on the globe. const isSurfacePick = (picked) => - !picked || - (picked.id === undefined && - (picked.content !== undefined || - (typeof sdk()?.Cesium3DTileset === 'function' && - picked.primitive instanceof sdk().Cesium3DTileset))); + !picked || (picked.id === undefined && picked.content !== undefined); function installSelection() { - const canvas = engine?.host.canvas ?? viewer?.scene?.canvas; - const native = engine?.kind === 'navara'; - if ( - clickHandler || - !canvas || - (!native && typeof sdk()?.ScreenSpaceEventHandler !== 'function') - ) - return; - const owner = native ? {} : new (sdk().ScreenSpaceEventHandler)(canvas); + const canvas = engine?.host.canvas; + if (clickHandler || !canvas) return; + const owner = {}; clickHandler = owner; let capturedHit = null; const overlayHit = (x, y) => { @@ -246,21 +230,19 @@ export function createCyclonesLayer({ if (id) layer.setParams({ stormId: id }); return; } - const picked = native ? click.picked : viewer.scene.pick(click.position); + const { picked } = click; const id = rendering?.pickStorm(picked); if (id) layer.setParams({ stormId: id }); else if (isSurfacePick(picked)) layer.setParams({ clear: true }); }; - if (native) - owner.destroy = engine.input.on('click', onClick, { pick: true }); - else owner.setInputAction(onClick, sdk().ScreenSpaceEventType.LEFT_CLICK); + owner.destroy = engine.input.on('click', onClick, { pick: true }); } function removeSelection() { const owner = clickHandler; clickHandler = null; removeClickCapture?.(); removeClickCapture = null; - if (owner && !owner.isDestroyed?.()) owner.destroy(); + owner?.destroy(); } const layer = { id: 'weather-cyclones', @@ -268,20 +250,18 @@ export function createCyclonesLayer({ icon: '◉', source: 'NOAA NHC / CPHC', updateInterval: 300_000, - init(nextViewer, options = {}) { - if (nextViewer || options.engine) - return layer.attachRenderer(nextViewer, options); + init(_viewer, options = {}) { + if (options.engine) return layer.attachRenderer(_viewer, options); }, - attachRenderer(nextViewer, options = {}) { - viewer = nextViewer; + attachRenderer(_viewer, options = {}) { engine = options.engine ?? null; rendering = - engine?.kind === 'navara' - ? createNativeRendering(engine, { overlayHost }) - : createRendering({ viewer, cesium: sdk(), overlayHost }); + engine && createRendering + ? createRendering(engine, { overlayHost }) + : null; if (!enabled) return; installSelection(); - if (snapshot && !snapshot.unavailable) { + if (rendering && snapshot && !snapshot.unavailable) { const active = rendering; return Promise.resolve(active.setSnapshot(snapshot)).then(() => { if (active === rendering) active.setSelection(selectedId); @@ -293,7 +273,6 @@ export function createCyclonesLayer({ ++navigationGeneration; rendering?.destroy(); rendering = null; - viewer = null; engine = null; runNavigation = null; }, @@ -424,7 +403,7 @@ export function createCyclonesLayer({ notify(); } const storm = selected(); - if (params.focus === true && storm && runNavigation) { + if (params.focus === true && storm && runNavigation && engine) { const sphere = rendering?.getFocusSphere(storm.id); if (sphere) { const generation = ++navigationGeneration; @@ -436,10 +415,7 @@ export function createCyclonesLayer({ selectedId !== storm.id ) return; - const fly = engine - ? engine.camera.flyToSphere.bind(engine.camera) - : viewer.camera.flyToBoundingSphere.bind(viewer.camera); - return fly(sphere, { + return engine.camera.flyToSphere(sphere, { duration: matchMedia?.('(prefers-reduced-motion: reduce)') ?.matches ? 0 diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index 1d10cea28..5ac248189 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -47,73 +47,61 @@ const deferred = () => { return { promise, resolve }; }; +// A fake engine whose canvas takes the layer's release capture and whose +// picks the test scripts; `clicks()` counts click subscriptions. +function clickEngine() { + const canvas = new EventTarget(); + canvas.getBoundingClientRect = () => ({ left: 100, top: 200 }); + const engine = createFakeEngine({ canvas }); + let picked; + let picks = 0; + engine.fake.script('pick', () => { + picks++; + return picked; + }); + return { + engine, + canvas, + click: (click) => engine.fake.input('click', click), + setPicked(value) { + picked = value; + }, + get picks() { + return picks; + }, + subscriptions: () => engine.fake.callsOf('input.on').length, + }; +} + test('map selection yields to pointer owners and owns only its enabled handler', async () => { - const handlers = [], - pickedEntity = {}, - foreignEntity = {}; - let picked = { id: pickedEntity }, - overlayHit = null, + const pickedStorm = { id: 'cyclone:ep162026:center', owner: 'cyclones' }, + foreignPick = { id: 'aircraft:abc', owner: 'flights' }; + let overlayHit = null, overlayTests = 0, - picks = 0, notices = 0; const rendering = { setSnapshot: async () => true, setSelection() {}, clear() {}, destroy() {}, - pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null), + pickStorm: (value) => (value === pickedStorm ? 'ep162026' : null), ownsPickId: (id) => id === 'cyclone:ep162026:center', getDiagnostics: () => ({}), }; - const cesium = { - ScreenSpaceEventType: { LEFT_CLICK: 'left' }, - ScreenSpaceEventHandler: class { - constructor(canvas) { - assert.ok(canvas); - handlers.push(this); - this.destroyed = false; - } - setInputAction(callback, type) { - assert.equal(type, 'left'); - this.click = callback; - } - destroy() { - this.destroyed = true; - } - isDestroyed() { - return this.destroyed; - } - }, - }; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, - cesium, createRendering: () => rendering, hitTestOverlay: () => { overlayTests++; return overlayHit; }, }); - const canvas = new EventTarget(); - canvas.getBoundingClientRect = () => ({ left: 100, top: 200 }); - const viewer = { - scene: { - canvas, - pick() { - picks++; - return picked; - }, - }, - trackedEntity: null, - camera: { - flyToBoundingSphere() { - assert.fail('Click must not move the camera'); - }, - }, - }; - layer.init(viewer); + const h = clickEngine(); + h.engine.camera.flyToSphere = () => + assert.fail('Click must not move the camera'); + layer.init(null, { engine: h.engine }); layer.setRowControlsListener(() => notices++); - assert.equal(handlers.length, 0, 'disabled module installs no handler'); + assert.equal(h.subscriptions(), 0, 'disabled module installs no handler'); layer.enable(); layer.enable(); assert.equal( @@ -127,62 +115,64 @@ test('map selection yields to pointer owners and owns only its enabled handler', false, 'registration recognizes exact current IDs, not a prefix', ); - assert.equal(handlers.length, 1, 'enable is idempotent'); + assert.equal(h.subscriptions(), 1, 'enable is idempotent'); await layer.update(); const click = { position: { x: 10, y: 20 } }; + h.setPicked(pickedStorm); const owner = claimPointer('director'); assert.ok(owner); try { - handlers[0].click(click); - assert.equal(picks, 0, 'claimed pointer is checked before picking'); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); } finally { releasePointer(owner); } - picked = { id: foreignEntity }; - handlers[0].click(click); + h.setPicked(foreignPick); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); - picked = { primitive: {} }; - handlers[0].click(click); - assert.equal(layer.getDiagnostics().selectionIntent, 'auto'); - picked = { id: pickedEntity }; - overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; - const beforeCard = picks; - handlers[0].click(click); + h.setPicked({ object: {} }); + h.click(click); assert.equal( - picks, - beforeCard, - 'foreground AIS card wins before scene picking', + layer.getDiagnostics().selectionIntent, + 'auto', + 'an unnamed pick without tile content is not empty map', + ); + h.setPicked(pickedStorm); + overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; + h.click(click); + assert.equal( + layer.getDiagnostics().selectedId, + 'ep152026', + 'foreground AIS card wins before the scene pick', ); - assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // A native capture runs before an earlier sibling handler clears hit rects. const up = new Event('pointerup'); Object.assign(up, { clientX: 110, clientY: 220 }); - canvas.dispatchEvent(up); + h.canvas.dispatchEvent(up); overlayHit = null; - handlers[0].click(click); + h.click(click); assert.equal( layer.getDiagnostics().selectedId, 'ep152026', 'captured AIS hit survives synchronous sibling overlay rebuild', ); // A captured background hit remains background even if a sibling adds a card. - canvas.dispatchEvent(up); + h.canvas.dispatchEvent(up); overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); layer.setParams({ stormId: 'ep152026' }); // A new gesture clears an unconsumed release snapshot. - canvas.dispatchEvent(up); - canvas.dispatchEvent(new Event('pointerdown')); + h.canvas.dispatchEvent(up); + h.canvas.dispatchEvent(new Event('pointerdown')); overlayHit = null; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); layer.setParams({ stormId: 'ep152026' }); overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' }; - canvas.dispatchEvent(up); + h.canvas.dispatchEvent(up); overlayHit = null; - handlers[0].click({ position: { x: 15, y: 25 } }); + h.click({ position: { x: 15, y: 25 } }); assert.equal( layer.getDiagnostics().selectedId, 'ep162026', @@ -191,32 +181,32 @@ test('map selection yields to pointer owners and owns only its enabled handler', layer.setParams({ stormId: 'ep152026' }); overlayHit = { sourceId: 'cctv', entryId: 'camera:1' }; const before = notices; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); assert.match(layer.getRowControls().summary.detail, /Another system/); assert.equal(layer.getRowControls().list.items[1].active, true); assert.ok(notices > before); - assert.equal(viewer.trackedEntity, null); - picked = undefined; + assert.equal(h.engine.camera.following(), null); + h.setPicked(undefined); overlayHit = { sourceId: 'ais-live-vessels' }; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); overlayHit = null; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, null); assert.equal(layer.getDiagnostics().selectionIntent, 'cleared'); - picked = { id: pickedEntity }; - handlers[0].click(click); + h.setPicked(pickedStorm); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); - picked = { id: 'flight:abc', primitive: {} }; - handlers[0].click(click); + h.setPicked({ id: 'flight:abc', owner: 'flights' }); + h.click(click); assert.equal( layer.getDiagnostics().selectedId, 'ep162026', "another layer's pick leaves the selection alone", ); - picked = { content: {}, primitive: {} }; - handlers[0].click(click); + h.setPicked({ id: undefined, content: {} }); + h.click(click); assert.equal( layer.getDiagnostics().selectedId, null, @@ -235,56 +225,45 @@ test('map selection yields to pointer owners and owns only its enabled handler', isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), false, ); - assert.equal(handlers[0].destroyed, true); assert.equal(layer.getDiagnostics().selectionActive, false); - const afterDisable = picks; + const afterDisable = h.picks; const afterDisableOverlay = overlayTests; - canvas.dispatchEvent(up); + h.canvas.dispatchEvent(up); assert.equal( overlayTests, afterDisableOverlay, 'disable removes native capture listeners', ); - handlers[0].click(click); - assert.equal(picks, afterDisable, 'queued disabled callback is inert'); + h.click(click); + assert.equal(h.picks, afterDisable, 'disable removes the click listener'); layer.enable(); await layer.update(); assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true); - handlers[0].click(click); + h.click(click); assert.equal( - picks, - afterDisable, - 'superseded handler remains inert after re-enable', + h.picks, + afterDisable + 1, + 're-enable listens once; the removed listener stays inert', ); - assert.equal(handlers.length, 2); + assert.equal(h.subscriptions(), 2); layer.destroy(); assert.equal( isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), false, ); - assert.equal(handlers[1].destroyed, true); + const afterDestroy = h.picks; + h.click(click); + assert.equal(h.picks, afterDestroy, 'destroy removes the click listener'); }); -test('a click on a storm card or lead-hour label selects that storm without picking', async () => { - const handlers = []; - let picks = 0, - overlayHit = null, - hostArgument; +test('a click on a storm card or lead-hour label selects that storm whatever lies under it', async () => { + let overlayHit = null, + hostArgument, + engineArgument; const layer = createCyclonesLayer({ feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) }, - cesium: { - ScreenSpaceEventType: { LEFT_CLICK: 'left' }, - ScreenSpaceEventHandler: class { - constructor() { - handlers.push(this); - } - setInputAction(callback) { - this.click = callback; - } - destroy() {} - }, - }, overlayHost: 'host', - createRendering: ({ overlayHost }) => { + createRendering: (engine, { overlayHost }) => { + engineArgument = engine; hostArgument = overlayHost; return { setSnapshot: async () => true, @@ -298,44 +277,36 @@ test('a click on a storm card or lead-hour label selects that storm without pick }, hitTestOverlay: () => overlayHit, }); - const canvas = new EventTarget(); - canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); - layer.init({ - scene: { - canvas, - pick() { - picks++; - return { content: {} }; - }, - }, - }); + const h = clickEngine(); + h.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 }); + h.setPicked({ id: undefined, content: {} }); + layer.init(null, { engine: h.engine }); assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer'); + assert.equal(engineArgument, h.engine, 'the renderer draws on the engine'); layer.enable(); await layer.update(); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); const click = { position: { x: 10, y: 20 } }; overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' }; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); assert.equal(layer.getDiagnostics().selectionIntent, 'user'); overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' }; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // A card from a superseded advisory is not empty map. overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' }; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); // The native release snapshot carries the card identity too. const up = new Event('pointerup'); Object.assign(up, { clientX: 10, clientY: 20 }); overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' }; - canvas.dispatchEvent(up); + h.canvas.dispatchEvent(up); overlayHit = null; - handlers[0].click(click); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, 'ep162026'); - assert.equal(picks, 0, 'card clicks never fall through to the scene'); - handlers[0].click(click); - assert.equal(picks, 1); + h.click(click); assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears'); layer.destroy(); }); @@ -376,13 +347,10 @@ function harness( matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), }); - const viewer = { - camera: { - flyToBoundingSphere: (sphere, options) => - navigation.push({ sphere, options }), - }, - }; - layer.init(viewer); + const engine = createFakeEngine(); + engine.camera.flyToSphere = (sphere, options) => + navigation.push({ sphere, options }); + layer.init(null, { engine }); layer.attachShellServices({ runNavigation: (fn) => { navigation.push('claimed'); @@ -705,7 +673,7 @@ test('clearing while a refreshed data source is staging cannot reselect a storm' destroy() {}, }), }); - layer.init({}); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); await layer.update(); assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); @@ -941,7 +909,7 @@ test('advisories delivered between scenes keep a deliberate selection without an destroy() {}, }), }); - layer.init({}); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); await layer.update(); layer.setParams({ stormId: 'ep152026' }); @@ -950,7 +918,7 @@ test('advisories delivered between scenes keep a deliberate selection without an pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }])); await refresh; assert.equal(layer.getDiagnostics().selectedId, 'ep152026'); - await layer.attachRenderer({}); + await layer.attachRenderer(null, { engine: createFakeEngine() }); assert.deepEqual(drawn, [['10'], ['11']]); assert.equal(reads, 2); layer.destroy(); diff --git a/src/layers/cyclones/render/display.js b/src/layers/cyclones/render/display.js new file mode 100644 index 000000000..17211d042 --- /dev/null +++ b/src/layers/cyclones/render/display.js @@ -0,0 +1,408 @@ +// The cyclone advisories on the globe contract, the same on every engine: +// storm centres and forecast points are point movers drawn over the scene, +// each storm's forecast track is a ground corridor set and its cone a ground +// polygon set (fill and an outline on every ring). Points beyond the +// ellipsoid horizon are hidden, and a storm's track and cone are hidden +// while an inscribed WGS84 sphere hides their bounding sphere, checked on +// the frames the camera moved. Storm names and lead hours go to the shared +// overlay host at the anchors the points use (labels.js). + +import * as geo from '../../../geo/index.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { coherentCycloneGeometry } from '../rendering.js'; +import { + CYCLONE_ACCENT, + CYCLONE_MARKER_PX, + CYCLONE_SELECTED_ACCENT, + createCycloneLabels, +} from '../labels.js'; + +const LAYER = 'weather-cyclones'; +const [R, G, B] = rgbaFromCss(CYCLONE_ACCENT).rgba(); +const BLUE = Object.freeze([R, G, B, 1]); +const GOLD = Object.freeze(rgbaFromCss(CYCLONE_SELECTED_ACCENT).rgba()); +const WHITE = Object.freeze([1, 1, 1, 1]); +const BLACK = Object.freeze([0, 0, 0, 1]); +const TRACK_WIDTH_PX = 2.5; +const CONE_FILL = Object.freeze([R, G, B, 0.16]); +const CONE_OUTLINE = Object.freeze({ color: [R, G, B, 0.55], widthPx: 1 }); +// A status-only point still has a useful regional camera destination. +const MIN_FOCUS_RADIUS_M = 500_000; +const OCCLUDER_RADIUS = geo.ellipsoid.WGS84.minimumRadius; +// The source validates at most 32 storms and 500 forecast fixes. +const CENTER_CAPACITY = 32; +const FORECAST_CAPACITY = 500; + +const zeroCounts = () => ({ + storms: 0, + tracks: 0, + cones: 0, + forecastPoints: 0, +}); + +const position = ({ longitude, latitude }) => + geo.cartesian3.fromDegrees(longitude, latitude, 0); +const coordinate = (pair) => + position({ longitude: pair[0], latitude: pair[1] }); + +/** + * Cesium's Occluder.isBoundingSphereVisible for an occluder sphere at the + * Earth's centre of the WGS84 minimum radius, seen from `camera`. It only + * hides a sphere wholly behind the planet, so a partly visible track or + * cone stays shown; from inside the occluder nothing is visible. + */ +export function cycloneSphereVisible(sphere, camera) { + const cameraSq = geo.cartesian3.magnitudeSquared(camera); + const occluderSq = OCCLUDER_RADIUS * OCCLUDER_RADIUS; + if (!(cameraSq > occluderSq)) return false; + const horizon = Math.sqrt(cameraSq - occluderSq); + const radius = sphere.radius; + const toCamera = geo.cartesian3.distanceSquared(sphere.center, camera); + let temp = OCCLUDER_RADIUS - radius; + temp = geo.cartesian3.magnitudeSquared(sphere.center) - temp * temp; + if (radius < OCCLUDER_RADIUS) { + if (!(temp > 0)) return false; + temp = Math.sqrt(temp) + horizon; + return temp * temp + radius * radius > toCamera; + } + if (!(temp > 0)) return true; + const radiusSq = radius * radius; + if ((horizon * horizon + occluderSq) * radiusSq > toCamera * occluderSq) + return true; + temp = Math.sqrt(temp) + horizon; + return temp * temp + radiusSq > toCamera; +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ overlayHost?: object }} [options] The overlay host the storm + * cards and lead-hour labels publish to (labels.js has the default). + */ +export function createCyclonesDisplay(engine, { overlayHost } = {}) { + if (!engine?.movers || !engine?.ground) + throw new TypeError('The cyclone display needs a globe engine'); + const labels = createCycloneLabels( + overlayHost ? { host: overlayHost } : undefined, + ); + // Mover set ids stay unique when a set is rebuilt larger. + let setSeq = 0; + const pointSet = (role, capacity, outlinePx, sizePx, order) => + engine.movers.createSet({ + id: `${LAYER}-${role}-${++setSeq}`, + kind: 'point', + capacity, + order, + pickable: true, + // Cesium: disableDepthTestDistance Infinity. + depthTest: 'always', + point: { sizePx, outlinePx, outlineColor: BLACK }, + }); + const centerSet = (capacity) => + pointSet('centers', capacity, 2, CYCLONE_MARKER_PX.storm, 1); + const forecastSet = (capacity) => + pointSet('forecasts', capacity, 1, CYCLONE_MARKER_PX.forecast, 0); + let centers = centerSet(CENTER_CAPACITY); + let forecasts = forecastSet(FORECAST_CAPACITY); + let centerRows = []; + let forecastRows = []; + // storm id → { sphere, tracks?, cones?, shown } + let storms = new Map(); + // pickId → storm id, for the ground and mover sets drawn now. + let pickIds = new Map(); + let owners = new Set(); + let counts = zeroCounts(); + let selected = null; + let destroyed = false; + let removePreRender = null; + const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0); + + const render = () => engine.render.request(LAYER); + + function paintCenters() { + centerRows.forEach((row, index) => { + const highlight = row.stormId === selected; + centers.set(index, { + color: highlight ? GOLD : BLUE, + scale: highlight + ? CYCLONE_MARKER_PX.selected / CYCLONE_MARKER_PX.storm + : 1, + }); + }); + centers.commit(); + } + + function cull(force = false) { + const camera = engine.camera.livePosition; + if (!force && geo.cartesian3.equals(camera, lastCamera)) return; + geo.cartesian3.clone(camera, lastCamera); + for (const [set, rows] of [ + [centers, centerRows], + [forecasts, forecastRows], + ]) { + let changed = false; + rows.forEach((row, index) => { + const show = engine.projector.horizonVisible(row.position); + if (show === row.show) return; + row.show = show; + set.set(index, { show }); + changed = true; + }); + if (changed) set.commit(); + } + for (const storm of storms.values()) { + if (!storm.tracks && !storm.cones) continue; + const show = cycloneSphereVisible(storm.sphere, camera); + if (show === storm.shown) continue; + storm.shown = show; + storm.tracks?.show(show); + storm.cones?.show(show); + } + } + + function syncHorizonListener() { + if (!storms.size) { + removePreRender?.(); + removePreRender = null; + return; + } + removePreRender ??= engine.frames.preRender.addEventListener(() => cull()); + } + + function destroySets(record) { + record.tracks?.destroy(); + record.cones?.destroy(); + } + + function groundSet(record, key, kind, order, items) { + if (!items.length) { + record[key]?.destroy(); + record[key] = null; + return; + } + record[key] ??= engine.ground.createSet({ + id: `${LAYER}-${key}:${record.id}`, + kind, + order, + pickable: true, + }); + record[key].replace(items); + record[key].commit(); + owners.add(record[key].id); + } + + function ensureCapacity(needed, current, make) { + if (needed <= current.capacity) return current; + current.set.destroy(); + let capacity = current.capacity; + while (capacity < needed) capacity *= 2; + return { set: make(capacity), capacity }; + } + let centerCapacity = CENTER_CAPACITY; + let forecastCapacity = FORECAST_CAPACITY; + + function clear() { + removePreRender?.(); + removePreRender = null; + for (const record of storms.values()) destroySets(record); + storms = new Map(); + pickIds = new Map(); + owners = new Set(); + centerRows = []; + forecastRows = []; + if (!destroyed) { + centers.setCount(0); + centers.commit(); + forecasts.setCount(0); + forecasts.commit(); + } + labels.clear(); + selected = null; + counts = zeroCounts(); + render(); + } + + return { + /** + * Draws a snapshot in place of the last one; false when it was aborted + * or the display is destroyed (nothing of it is drawn then). + */ + setSnapshot(snapshot, { signal } = {}) { + signal?.throwIfAborted(); + if (destroyed) return false; + const nextStorms = new Map(); + const nextPickIds = new Map(); + const nextCenters = []; + const nextForecasts = []; + const nextLabels = []; + const nextCounts = zeroCounts(); + const builds = []; + for (const storm of snapshot.storms) { + const prefix = `cyclone:${storm.id}:`; + const center = position(storm.position); + const extent = [center]; + const own = (id) => { + nextPickIds.set(id, storm.id); + return id; + }; + nextCenters.push({ + id: own(`${prefix}center`), + stormId: storm.id, + position: center, + show: null, + }); + const label = { + id: storm.id, + name: storm.name, + classification: storm.classification, + windKt: storm.windKt, + position: center, + forecasts: [], + }; + nextLabels.push(label); + nextCounts.storms++; + const tracks = []; + const cones = []; + if (coherentCycloneGeometry(storm)) { + const lines = + storm.track?.type === 'LineString' + ? [storm.track.coordinates] + : storm.track?.coordinates || []; + lines.forEach((line, index) => { + extent.push(...line.map(coordinate)); + tracks.push({ + positions: line, + color: BLUE, + widthPx: TRACK_WIDTH_PX, + pickId: own(`${prefix}track:${index}`), + }); + nextCounts.tracks++; + }); + const polygons = + storm.cone?.type === 'Polygon' + ? [storm.cone.coordinates] + : storm.cone?.coordinates || []; + polygons.forEach((rings, index) => { + // Each exterior keeps its own holes; edges are great circles, + // across the antimeridian too. + extent.push(...rings[0].map(coordinate)); + cones.push({ + rings, + fill: CONE_FILL, + outline: CONE_OUTLINE, + pickId: own(`${prefix}cone:${index}`), + }); + nextCounts.cones++; + }); + storm.forecastPoints.forEach((point, index) => { + if (point.tauHours === 0) return; + const at = position(point.position); + extent.push(at); + nextForecasts.push({ + id: own(`${prefix}forecast:${index}`), + stormId: storm.id, + position: at, + show: null, + }); + label.forecasts.push({ tauHours: point.tauHours, position: at }); + nextCounts.forecastPoints++; + }); + } + const sphere = geo.boundingSphere.fromPoints(extent); + sphere.radius = Math.max(sphere.radius, MIN_FOCUS_RADIUS_M); + nextStorms.set(storm.id, { + id: storm.id, + sphere, + tracks: storms.get(storm.id)?.tracks ?? null, + cones: storms.get(storm.id)?.cones ?? null, + shown: null, + }); + builds.push({ id: storm.id, tracks, cones }); + } + // Sets of storms that left go; the others are rebuilt in place. + for (const [id, record] of storms) + if (!nextStorms.has(id)) destroySets(record); + owners = new Set(); + for (const { id, tracks, cones } of builds) { + const record = nextStorms.get(id); + // Cones under every track. + groundSet(record, 'cones', 'polygon', 0, cones); + groundSet(record, 'tracks', 'corridor', 1, tracks); + } + const grownCenters = ensureCapacity( + nextCenters.length, + { set: centers, capacity: centerCapacity }, + centerSet, + ); + centers = grownCenters.set; + centerCapacity = grownCenters.capacity; + const grownForecasts = ensureCapacity( + nextForecasts.length, + { set: forecasts, capacity: forecastCapacity }, + forecastSet, + ); + forecasts = grownForecasts.set; + forecastCapacity = grownForecasts.capacity; + nextCenters.forEach((row, index) => + centers.set(index, { + position: row.position, + pickId: row.id, + show: true, + }), + ); + centers.setCount(nextCenters.length); + nextForecasts.forEach((row, index) => + forecasts.set(index, { + position: row.position, + pickId: row.id, + color: WHITE, + show: true, + }), + ); + forecasts.setCount(nextForecasts.length); + forecasts.commit(); + owners.add(centers.id).add(forecasts.id); + storms = nextStorms; + pickIds = nextPickIds; + centerRows = nextCenters; + forecastRows = nextForecasts; + counts = nextCounts; + paintCenters(); + cull(true); + syncHorizonListener(); + labels.setSnapshot(nextLabels, selected); + render(); + return true; + }, + setSelection(id) { + selected = id; + if (!destroyed) paintCenters(); + labels.setSelection(id); + render(); + }, + /** Whether a pick id is one of the current advisory's drawn things. */ + ownsPickId: (id) => typeof id === 'string' && pickIds.has(id), + /** The storm a pick of this display names, else null. */ + pickStorm(picked) { + if (!picked || !owners.has(picked.owner)) return null; + return pickIds.get(picked.id) ?? null; + }, + getFocusSphere: (id) => storms.get(id)?.sphere ?? null, + clear, + destroy() { + if (destroyed) return; + clear(); + destroyed = true; + centers.destroy(); + forecasts.destroy(); + }, + getDiagnostics() { + return { + ...counts, + dataSources: Number(storms.size > 0), + entities: pickIds.size, + selectedId: selected, + timerActive: false, + }; + }, + }; +} diff --git a/src/layers/cyclones/render/display.test.mjs b/src/layers/cyclones/render/display.test.mjs new file mode 100644 index 000000000..dec0b0c7e --- /dev/null +++ b/src/layers/cyclones/render/display.test.mjs @@ -0,0 +1,613 @@ +// The cyclone display, each case on the fake engine and on a headless Cesium +// engine (src/testSupport/cesiumMoversEngine.mjs). +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 { rgbaFromCss } from '../../shared/color.js'; +import { createCyclonesDisplay, cycloneSphereVisible } from './display.js'; +import { + CYCLONE_ACCENT, + CYCLONE_OVERLAY_SOURCE_ID, + CYCLONE_SELECTED_ACCENT, +} from '../labels.js'; + +// Node has no GL context: GroundPolylinePrimitive checks its render state's +// line width against these limits when it is made. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 1; + +const BLUE = rgbaFromCss(CYCLONE_ACCENT).rgba(); +const GOLD = rgbaFromCss(CYCLONE_SELECTED_ACCENT).rgba(); + +/** Records what the display publishes to the shared overlay host. */ +function overlayRecorder() { + const calls = []; + return { + calls, + host: { + setEntries: (source, entries, options) => + calls.push({ kind: 'entries', source, entries, options }), + setVisible: (source, visible) => + calls.push({ kind: 'visible', source, visible }), + clearSource: (source) => calls.push({ kind: 'clear', source }), + }, + get publishes() { + return calls.filter(({ kind }) => kind === 'entries').length; + }, + published() { + return calls.findLast(({ kind }) => kind === 'entries')?.entries || []; + }, + entry(id) { + return this.published().find((entry) => entry.id === id) || null; + }, + }; +} + +// The items each ground set was last given, by set id. +function spyGround(engine) { + const items = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const replace = set.replace.bind(set); + set.replace = (next) => { + replace(next); + items.set(options.id, next); + }; + return set; + }; + return items; +} + +const ENGINES = { + fake() { + const engine = createFakeEngine({ longitude: 0, latitude: 0 }); + return { + engine, + frame: () => engine.fake.frame(), + points(prefix) { + const id = engine.movers + .list() + .find((set) => set.id.startsWith(prefix))?.id; + const { store, commits } = engine.fake.moverSet(id); + return { + id, + commits, + count: store.count, + outlinePx: engine.fake.moverSet(id).options.point.outlinePx, + depthTest: engine.fake.moverSet(id).options.depthTest, + at: (index) => ({ + color: [...store.colors.subarray(index * 4, index * 4 + 4)], + pixelSize: Math.fround( + engine.fake.moverSet(id).options.point.sizePx * + store.scales[index], + ), + show: store.shows[index] === 1, + pickId: store.pickIds[index], + }), + }; + }, + preRenderListeners: null, + }; + }, + cesium() { + const harness = createCesiumMoversEngine(); + harness.viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + const { engine, primitive, viewer } = harness; + return { + engine, + viewer, + frame: harness.frame, + points(prefix) { + const id = engine.movers + .list() + .find((set) => set.id.startsWith(prefix))?.id; + const set = engine.movers.list().find((entry) => entry.id === id); + const collection = primitive(id); + return { + id, + count: set.count, + outlinePx: collection.get(0)?.outlineWidth, + depthTest: + collection.get(0)?.disableDepthTestDistance === Infinity + ? 'always' + : 'normal', + at: (index) => { + const item = collection.get(index); + return { + color: [ + item.color.red, + item.color.green, + item.color.blue, + item.color.alpha, + ], + pixelSize: item.pixelSize, + show: item.show, + pickId: item.id, + }; + }, + }; + }, + preRenderListeners: () => viewer.scene.preRender.numberOfListeners, + }; + }, +}; + +function bothEngines(name, body) { + for (const [kind, make] of Object.entries(ENGINES)) + test(`${name} (${kind})`, async () => { + const harness = make(); + const ground = spyGround(harness.engine); + const overlay = overlayRecorder(); + const display = createCyclonesDisplay(harness.engine, { + overlayHost: overlay.host, + }); + try { + await body({ ...harness, kind, ground, overlay, display }); + } finally { + display.destroy(); + harness.engine.destroy(); + } + }); +} + +const close = (actual, expected, label) => + expected.forEach((value, index) => + assert.ok( + Math.abs(actual[index] - value) < 1e-6, + `${label}: ${actual} vs ${expected}`, + ), + ); + +const storm = () => ({ + id: 'ep152026', + name: 'Fifteen-E', + advisoryNumber: '10', + geometryAdvisoryNumber: '10', + geometryStatus: 'current', + position: { longitude: 179, latitude: 15 }, + forecastPoints: [ + { position: { longitude: 179, latitude: 15 }, tauHours: 0 }, + { position: { longitude: -179, latitude: 16 }, tauHours: 12 }, + ], + track: { + type: 'MultiLineString', + coordinates: [ + [ + [179, 15], + [-179, 16], + ], + ], + }, + cone: { + type: 'MultiPolygon', + coordinates: [ + [ + [ + [178, 10], + [-178, 10], + [-178, 20], + [178, 10], + ], + [ + [179, 12], + [-179, 12], + [-179, 14], + [179, 12], + ], + ], + [ + [ + [170, 1], + [171, 1], + [171, 2], + [170, 1], + ], + ], + ], + }, +}); + +bothEngines( + 'points, tracks and cones draw in the Cesium styles and keep cone parts, holes and seam coordinates', + async ({ engine, kind, viewer, ground, display, points }) => { + assert.equal(await display.setSnapshot({ storms: [storm()] }), true); + const centers = points('weather-cyclones-centers'); + assert.equal(centers.count, 1); + assert.equal(centers.outlinePx, 2); + assert.equal(centers.depthTest, 'always'); + close(centers.at(0).color, BLUE, 'centre'); + assert.equal(centers.at(0).pixelSize, 9); + assert.equal(centers.at(0).pickId, 'cyclone:ep152026:center'); + const forecasts = points('weather-cyclones-forecasts'); + assert.equal(forecasts.count, 1, 'the tau-0 fix is the centre'); + assert.equal(forecasts.outlinePx, 1); + assert.equal(forecasts.depthTest, 'always'); + close(forecasts.at(0).color, [1, 1, 1, 1], 'forecast'); + assert.equal(forecasts.at(0).pixelSize, 5); + assert.equal(forecasts.at(0).pickId, 'cyclone:ep152026:forecast:1'); + + const tracks = ground.get('weather-cyclones-tracks:ep152026'); + assert.equal(tracks.length, 1); + assert.deepEqual(tracks[0].positions, storm().track.coordinates[0]); + assert.equal(tracks[0].widthPx, 2.5); + close(tracks[0].color, BLUE, 'track'); + assert.equal(tracks[0].pickId, 'cyclone:ep152026:track:0'); + const cones = ground.get('weather-cyclones-cones:ep152026'); + assert.equal(cones.length, 2); + assert.deepEqual( + cones.map((cone) => cone.rings), + storm().cone.coordinates, + 'every exterior keeps its own holes, longitudes as given', + ); + for (const cone of cones) { + close(cone.fill, [...BLUE.slice(0, 3), 0.16], 'cone fill'); + close(cone.outline.color, [...BLUE.slice(0, 3), 0.55], 'cone outline'); + assert.equal(cone.outline.widthPx, 1); + } + assert.deepEqual( + engine.ground + .list() + .map(({ id, kind: shape, count, order }) => [id, shape, count, order]), + [ + ['weather-cyclones-cones:ep152026', 'polygon', 2, 0], + ['weather-cyclones-tracks:ep152026', 'corridor', 1, 1], + ], + 'cones under the tracks, draped on terrain and tiles', + ); + if (kind === 'cesium') { + const slots = viewer.scene.groundPrimitives.get(0); + const drawn = []; + for (let index = 0; index < slots.length; index++) { + const slot = slots.get(index); + for (let at = 0; at < slot.length; at++) drawn.push(slot.get(at)); + } + const cone = drawn.filter( + (p) => p.gevGroundSet === 'weather-cyclones-cones:ep152026', + ); + assert.equal(cone.length, 2, 'one fill batch and one outline batch'); + assert.equal(cone[0].geometryInstances.length, 2); + assert.equal(cone[1].geometryInstances.length, 3, 'one per ring'); + assert.equal(cone[1].geometryInstances[0].geometry.width, 1); + assert.equal(cone[0].classificationType, Cesium.ClassificationType.BOTH); + const track = drawn.find( + (p) => p.gevGroundSet === 'weather-cyclones-tracks:ep152026', + ); + assert.equal(track.geometryInstances[0].geometry.width, 2.5); + } + assert.deepEqual(display.getDiagnostics(), { + storms: 1, + tracks: 1, + cones: 2, + forecastPoints: 1, + dataSources: 1, + entities: 5, + selectedId: null, + timerActive: false, + }); + const sphere = display.getFocusSphere('ep152026'); + assert.ok(sphere.radius >= 500_000); + assert.equal(display.getFocusSphere('unknown'), null); + }, +); + +bothEngines( + 'pending or mismatched advisory geometry never draws even when supplied', + async ({ engine, display, points }) => { + for (const changed of [ + { geometryStatus: 'pending' }, + { geometryAdvisoryNumber: '9' }, + ]) { + await display.setSnapshot({ storms: [{ ...storm(), ...changed }] }); + assert.deepEqual(engine.ground.list(), []); + assert.equal(points('weather-cyclones-centers').count, 1); + assert.equal(points('weather-cyclones-forecasts').count, 0); + assert.equal(display.getDiagnostics().tracks, 0); + assert.equal(display.getDiagnostics().entities, 1); + } + }, +); + +bothEngines( + 'selection paints the centre gold and larger, publishes lead-hour labels at the point anchors and is quiet when unchanged', + async ({ display, overlay, points }) => { + assert.equal(overlay.calls.length, 0, 'nothing publishes before a commit'); + await display.setSnapshot({ + storms: [storm(), { ...storm(), id: 'near', name: 'Near' }], + }); + assert.deepEqual( + overlay.calls + .slice(0, 2) + .map(({ kind, source, visible }) => [kind, source, visible]), + [ + ['visible', CYCLONE_OVERLAY_SOURCE_ID, true], + ['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined], + ], + ); + assert.deepEqual( + overlay.published().map((entry) => entry.id), + ['storm:ep152026', 'storm:near'], + 'no selection, no lead-hour labels', + ); + display.setSelection('near'); + const centers = points('weather-cyclones-centers'); + close(centers.at(1).color, GOLD, 'selected'); + assert.ok(Math.abs(centers.at(1).pixelSize - 12) < 1e-5); + close(centers.at(0).color, BLUE, 'other'); + assert.equal(centers.at(0).pixelSize, 9); + assert.equal(overlay.entry('storm:near').variant, 'selected'); + assert.equal(overlay.entry('storm:ep152026').variant, 'card'); + const lead = overlay.entry('lead:near:12'); + assert.ok(lead); + assert.ok( + geo.cartesian3.equalsEpsilon( + lead.position, + geo.cartesian3.fromDegrees(-179, 16, 0), + 0, + 1e-6, + ), + ); + const publishes = overlay.publishes; + display.setSelection('near'); + assert.equal(overlay.publishes, publishes, 'unchanged selection is quiet'); + assert.equal(display.getDiagnostics().selectedId, 'near'); + + // A refresh republishes the new anchors with the retained selection. + await display.setSnapshot({ + storms: [{ ...storm(), id: 'near', name: 'Near' }], + }); + assert.deepEqual( + overlay.published().map((entry) => [entry.id, entry.variant]), + [ + ['storm:near', 'selected'], + ['lead:near:12', 'card'], + ], + ); + close(points('weather-cyclones-centers').at(0).color, GOLD, 'retained'); + + display.clear(); + assert.deepEqual(overlay.calls.slice(-2), [ + { kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID }, + { kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false }, + ]); + assert.equal(display.getDiagnostics().selectedId, null); + }, +); + +// A storm at `longitude` on the equator with a short track and a cone. +const at = (id, longitude) => ({ + ...storm(), + id, + position: { longitude, latitude: 0 }, + forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }], + track: { + type: 'LineString', + coordinates: [ + [longitude, 0], + [longitude, 1], + ], + }, + cone: { + type: 'Polygon', + coordinates: [ + [ + [longitude, 0], + [longitude + 1, 0], + [longitude, 1], + [longitude, 0], + ], + ], + }, +}); + +bothEngines( + 'horizon culling hides far points and shapes, keeps partly visible extents and writes only changes', + async ({ engine, frame, display, overlay, points, preRenderListeners }) => { + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(0, 0, 350), + }); + const listeners = preRenderListeners?.(); + await display.setSnapshot({ + storms: [at('near', 0), at('far', 180), at('limb', 5)], + }); + if (preRenderListeners) + assert.equal(preRenderListeners(), listeners + 1, 'one listener'); + display.setSelection('near'); + frame(); + const centers = points('weather-cyclones-centers'); + const forecasts = points('weather-cyclones-forecasts'); + assert.deepEqual( + [0, 1, 2].map((index) => centers.at(index).show), + [true, false, false], + 'near shown; far and limb centres beyond the horizon', + ); + assert.deepEqual( + [0, 1, 2].map((index) => forecasts.at(index).show), + [true, false, false], + ); + const shown = Object.fromEntries( + engine.ground.list().map(({ id, shown: visible }) => [id, visible]), + ); + assert.deepEqual(shown, { + 'weather-cyclones-cones:near': true, + 'weather-cyclones-tracks:near': true, + 'weather-cyclones-cones:far': false, + 'weather-cyclones-tracks:far': false, + 'weather-cyclones-cones:limb': true, + 'weather-cyclones-tracks:limb': true, + }); + assert.equal(overlay.entry('storm:near').horizonCull, true); + assert.equal(overlay.entry('lead:near:24').maxDistance, 4_000_000); + const publishes = overlay.publishes; + const commits = centers.commits; + frame(); + assert.equal(overlay.publishes, publishes, 'culling never republishes'); + if (commits !== undefined) + assert.equal( + points('weather-cyclones-centers').commits, + commits, + 'a still camera writes nothing', + ); + + // The camera turns to the far side. + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(180, 0, 350), + }); + frame(); + assert.equal(points('weather-cyclones-centers').at(0).show, false); + assert.equal(points('weather-cyclones-centers').at(1).show, true); + assert.equal( + engine.ground.list().find(({ id }) => id.endsWith(':far')).shown, + true, + ); + assert.equal( + engine.ground.list().find(({ id }) => id.endsWith(':near')).shown, + false, + ); + + // The listener follows nonempty snapshots, clear and destroy. + await display.setSnapshot({ storms: [] }); + if (preRenderListeners) assert.equal(preRenderListeners(), listeners); + await display.setSnapshot({ storms: [at('near', 0)] }); + display.clear(); + if (preRenderListeners) assert.equal(preRenderListeners(), listeners); + await display.setSnapshot({ storms: [at('near', 0)] }); + display.destroy(); + if (preRenderListeners) assert.equal(preRenderListeners(), listeners); + }, +); + +test('the inscribed-sphere test matches Cesium Occluder', () => { + const occluder = new Cesium.Occluder( + new Cesium.BoundingSphere( + Cesium.Cartesian3.ZERO, + Cesium.Ellipsoid.WGS84.minimumRadius, + ), + Cesium.Cartesian3.fromDegrees(0, 0, 350), + ); + const cameras = [ + [0, 0, 350], + [30, 10, 2_000_000], + [120, -40, 20_000_000], + [0, 0, -30_000], + ]; + const spheres = []; + for (let lon = -180; lon < 180; lon += 15) + for (const radius of [1000, 500_000, 3_000_000, 7_000_000]) + spheres.push({ + center: Cesium.Cartesian3.fromDegrees(lon, lon / 4, 0), + radius, + }); + for (const [lon, lat, height] of cameras) { + const camera = Cesium.Cartesian3.fromDegrees(lon, lat, height); + occluder.cameraPosition = camera; + for (const sphere of spheres) + assert.equal( + cycloneSphereVisible(sphere, camera), + occluder.isBoundingSphereVisible( + new Cesium.BoundingSphere(sphere.center, sphere.radius), + ), + `${lon},${lat},${height} ${sphere.radius}`, + ); + } +}); + +bothEngines( + 'picks resolve current owned ids only, never foreign owners, unknown ids or replaced storms', + async ({ engine, kind, display }) => { + await display.setSnapshot({ storms: [storm(), at('other', 0)] }); + const owner = (prefix) => + engine.movers.list().find(({ id }) => id.startsWith(prefix)).id; + const ids = [ + ['cyclone:ep152026:center', owner('weather-cyclones-centers')], + ['cyclone:ep152026:forecast:1', owner('weather-cyclones-forecasts')], + ['cyclone:ep152026:track:0', 'weather-cyclones-tracks:ep152026'], + ['cyclone:ep152026:cone:0', 'weather-cyclones-cones:ep152026'], + ['cyclone:ep152026:cone:1', 'weather-cyclones-cones:ep152026'], + ]; + for (const [id, set] of ids) { + assert.equal(display.ownsPickId(id), true, id); + assert.equal(display.pickStorm({ id, owner: set }), 'ep152026', id); + assert.equal(display.pickStorm({ id, owner: 'flights' }), null, id); + } + assert.equal(display.ownsPickId('cyclone:ep152026'), false, 'no prefix'); + assert.equal(display.ownsPickId('cyclone:ep152026:unknown'), false); + assert.equal(display.pickStorm(undefined), null); + if (kind === 'fake') { + // A real pick of the drawn centre, as a click would get it. + const screen = engine.projector.worldToWindow( + geo.cartesian3.fromDegrees(0, 0, 0), + ); + assert.equal(display.pickStorm(engine.picker.pick(screen)), 'other'); + } + // The next advisory without the storm: its ids no longer resolve. + await display.setSnapshot({ storms: [at('other', 0)] }); + for (const [id, set] of ids) { + assert.equal(display.ownsPickId(id), false, id); + assert.equal(display.pickStorm({ id, owner: set }), null, id); + } + assert.equal( + engine.ground.list().some(({ id }) => id.endsWith(':ep152026')), + false, + "the storm's ground sets go", + ); + display.clear(); + assert.equal(display.ownsPickId('cyclone:other:center'), false); + assert.deepEqual(engine.ground.list(), []); + assert.deepEqual(display.getDiagnostics(), { + storms: 0, + tracks: 0, + cones: 0, + forecastPoints: 0, + dataSources: 0, + entities: 0, + selectedId: null, + timerActive: false, + }); + }, +); + +bothEngines( + 'an aborted snapshot draws nothing and keeps the prior one; a destroyed display draws nothing', + async ({ engine, display, overlay }) => { + await display.setSnapshot({ storms: [storm()] }); + const publishes = overlay.publishes; + assert.throws( + () => + display.setSnapshot({ storms: [] }, { signal: AbortSignal.abort() }), + { name: 'AbortError' }, + ); + assert.equal(display.getDiagnostics().storms, 1); + assert.equal(overlay.publishes, publishes); + display.destroy(); + display.destroy(); + assert.deepEqual(engine.ground.list(), []); + assert.deepEqual( + engine.movers.list().filter(({ id }) => id.startsWith('weather-')), + [], + ); + assert.equal(display.setSnapshot({ storms: [storm()] }), false); + assert.deepEqual(engine.ground.list(), []); + assert.equal(display.getDiagnostics().entities, 0); + }, +); + +bothEngines( + 'more storms than the first capacity rebuild the centre set larger', + async ({ display, points }) => { + const storms = Array.from({ length: 40 }, (_, index) => ({ + ...storm(), + id: `s${index}`, + geometryStatus: 'pending', + })); + await display.setSnapshot({ storms }); + assert.equal(points('weather-cyclones-centers').count, 40); + assert.equal( + points('weather-cyclones-centers').at(39).pickId, + 'cyclone:s39:center', + ); + }, +); diff --git a/src/layers/cyclones/render/navara.js b/src/layers/cyclones/render/navara.js deleted file mode 100644 index 649e47302..000000000 --- a/src/layers/cyclones/render/navara.js +++ /dev/null @@ -1,209 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { coherentCycloneGeometry } from '../rendering.js'; -import { - createCycloneLabels, - CYCLONE_ACCENT, - CYCLONE_SELECTED_ACCENT, - CYCLONE_MARKER_PX, -} from '../labels.js'; -import { createNavaraPolygonDisplay } from '../../shared/render/navaraPolygons.js'; -import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; -import { rgbaFromCss } from '../../shared/color.js'; - -const LAYER = 'weather-cyclones'; -const BLUE = rgbaFromCss(CYCLONE_ACCENT).rgba(); -const GOLD = rgbaFromCss(CYCLONE_SELECTED_ACCENT).rgba(); -const zeroCounts = () => ({ - storms: 0, - tracks: 0, - cones: 0, - forecastPoints: 0, -}); -const world = ([lon, lat]) => geo.cartesian3.fromDegrees(lon, lat); - -export function createCyclonesNavaraRenderer(engine, view, { overlayHost }) { - const display = createNavaraPolygonDisplay(engine, view, (color) => ({ - polyline: { color, width: 2.5, clampToGround: true }, - polygon: { - color, - opacity: 0.16, - transparent: true, - clampToGround: true, - outline: true, - outlineColor: color, - outlineWidth: 1, - }, - })); - const labels = createCycloneLabels({ host: overlayHost }); - // The source validates at most 32 storms and 500 forecast fixes. - const points = engine.movers.createSet({ - id: LAYER, - kind: 'point', - capacity: 532, - order: 1, - pickable: true, - depthTest: 'always', - point: { - sizePx: CYCLONE_MARKER_PX.storm, - outlinePx: 1, - outlineColor: [0, 0, 0, 1], - }, - }); - let counts = zeroCounts(), - rows = [], - spheres = new Map(), - stormById = new Map(), - selected = null, - unbind = null; - function paintPoints() { - rows.forEach((row, i) => { - const highlight = row.center && row.stormId === selected; - const px = row.center - ? highlight - ? CYCLONE_MARKER_PX.selected - : CYCLONE_MARKER_PX.storm - : CYCLONE_MARKER_PX.forecast; - points.set(i, { - position: row.position, - pickId: row.id, - color: highlight ? GOLD : row.center ? BLUE : [1, 1, 1, 1], - scale: px / CYCLONE_MARKER_PX.storm, - show: true, - }); - }); - points.setCount(rows.length); - points.commit(); - engine.render.request(LAYER); - } - function clear() { - unbind?.(); - unbind = null; - display.destroy(); - labels.clear(); - rows = []; - spheres.clear(); - stormById.clear(); - selected = null; - counts = zeroCounts(); - points.setCount(0); - points.commit(); - } - return { - setSnapshot(snapshot, { signal } = {}) { - signal?.throwIfAborted(); - const shapes = [], - features = [], - markers = [], - anchors = [], - nextSpheres = new Map(), - ids = new Map(), - nextCounts = zeroCounts(); - for (const storm of snapshot.storms) { - const prefix = 'cyclone:' + storm.id + ':'; - const center = world([ - storm.position.longitude, - storm.position.latitude, - ]), - extent = [center]; - const anchor = { ...storm, position: center, forecasts: [] }; - anchors.push(anchor); - const mark = (id, position, central) => { - markers.push({ id, stormId: storm.id, position, center: central }); - ids.set(id, storm.id); - }; - mark(prefix + 'center', center, true); - nextCounts.storms++; - if (coherentCycloneGeometry(storm)) { - const shape = (id, geometry, kind, coords) => { - ids.set(id, storm.id); - shapes.push({ id, geometry, style: { lineWidth: 2.5 } }); - features.push({ - type: 'Feature', - id, - properties: {}, - geometry: { type: kind, coordinates: coords }, - }); - }; - const lines = - storm.track?.type === 'LineString' - ? [storm.track.coordinates] - : (storm.track?.coordinates ?? []); - lines.forEach((line, i) => { - shape( - prefix + 'track:' + i, - { type: 'line', positions: line }, - 'LineString', - line, - ); - for (const pair of line) extent.push(world(pair)); - nextCounts.tracks++; - }); - const polygons = - storm.cone?.type === 'Polygon' - ? [storm.cone.coordinates] - : (storm.cone?.coordinates ?? []); - polygons.forEach((rings, i) => { - shape( - prefix + 'cone:' + i, - { type: 'polygon', outer: rings[0], holes: rings.slice(1) }, - 'Polygon', - rings, - ); - for (const pair of rings[0]) extent.push(world(pair)); - nextCounts.cones++; - }); - storm.forecastPoints.forEach((point, i) => { - if (!point.tauHours) return; - const pos = world([ - point.position.longitude, - point.position.latitude, - ]); - extent.push(pos); - mark(prefix + 'forecast:' + i, pos, false); - anchor.forecasts.push({ tauHours: point.tauHours, position: pos }); - nextCounts.forecastPoints++; - }); - } - const sphere = geo.boundingSphere.fromPoints(extent); - sphere.radius = Math.max(500000, sphere.radius); - nextSpheres.set(storm.id, sphere); - } - display.setVisible(true); - display.replace( - features.length ? new Map([[CYCLONE_ACCENT, features]]) : new Map(), - ); - unbind?.(); - unbind = shapes.length - ? bindGroundShapePicks(engine, LAYER, shapes) - : null; - counts = nextCounts; - rows = markers; - stormById = ids; - spheres = nextSpheres; - paintPoints(); - labels.setSnapshot(anchors, selected); - return true; - }, - setSelection(id) { - selected = id; - paintPoints(); - labels.setSelection(id); - }, - ownsPickId: (id) => stormById.has(id), - pickStorm: (picked) => - picked?.owner === LAYER ? (stormById.get(picked.id) ?? null) : null, - getFocusSphere: (id) => spheres.get(id) ?? null, - clear, - destroy() { - clear(); - points.destroy(); - }, - getDiagnostics: () => ({ - ...counts, - selectedId: selected, - entities: stormById.size, - dataSources: Number(stormById.size > 0), - timerActive: false, - }), - }; -} diff --git a/src/layers/cyclones/rendering.js b/src/layers/cyclones/rendering.js index 4803cc1da..9912e150f 100644 --- a/src/layers/cyclones/rendering.js +++ b/src/layers/cyclones/rendering.js @@ -1,9 +1,4 @@ -import { - CYCLONE_ACCENT, - CYCLONE_MARKER_PX, - CYCLONE_SELECTED_ACCENT, - createCycloneLabels, -} from './labels.js'; +// Which advisory geometry the cyclone display draws (render/display.js). /** Only geometry from the displayed status advisory is eligible for rendering. */ export function coherentCycloneGeometry(storm) { @@ -12,303 +7,3 @@ export function coherentCycloneGeometry(storm) { storm.geometryAdvisoryNumber === storm.advisoryNumber ); } - -/** - * Static Cesium entities with owned horizon culling; no timers or clock. - * Storm names and lead hours are published to the shared overlay host at the - * same anchors the points use. - */ -export function createCycloneRendering({ viewer, cesium: C, overlayHost }) { - const labels = createCycloneLabels({ host: overlayHost }); - let source = null, - generation = 0, - selected = null, - destroyed = false; - let centers = new Map(), - spheres = new Map(); - let entityStorms = new WeakMap(); - let entityIds = new Set(); - let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 }; - let horizonStorms = [], - removePreRender = null, - pointOccluder = null, - sphereOccluder = null; - const blue = C.Color.fromCssColorString(CYCLONE_ACCENT); - const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT); - const white = C.Color.WHITE; - const render = () => { - if (!viewer.isDestroyed?.()) viewer.scene.requestRender(); - }; - function cullHorizon() { - pointOccluder.cameraPosition = viewer.camera.positionWC; - sphereOccluder.cameraPosition = viewer.camera.positionWC; - let changed = false; - for (let i = 0; i < horizonStorms.length; i++) { - const storm = horizonStorms[i]; - for (let j = 0; j < storm.points.length; j++) { - const { entity, position } = storm.points[j]; - const show = pointOccluder.isPointVisible(position); - if (entity.show !== show) { - entity.show = show; - changed = true; - } - } - if (!storm.shapes.length) continue; - const show = sphereOccluder.isBoundingSphereVisible(storm.sphere); - for (let j = 0; j < storm.shapes.length; j++) { - const entity = storm.shapes[j]; - if (entity.show !== show) { - entity.show = show; - changed = true; - } - } - } - if (changed) render(); - } - function syncHorizonListener() { - if (!horizonStorms.length) { - removePreRender?.(); - removePreRender = null; - return; - } - if (removePreRender) return; - pointOccluder ||= new C.EllipsoidalOccluder( - C.Ellipsoid.WGS84, - viewer.camera.positionWC, - ); - // EllipsoidalOccluder only tests points. An inscribed WGS84 sphere - // conservatively culls extents, retaining partially visible tracks/cones. - sphereOccluder ||= new C.Occluder( - new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius), - viewer.camera.positionWC, - ); - removePreRender = viewer.scene.preRender.addEventListener(cullHorizon); - } - function remove(value) { - if (!value) return; - if (!viewer.dataSources.isDestroyed?.()) - viewer.dataSources.remove(value, true); - value.entities.removeAll(); - } - function select(id) { - selected = id; - for (const [stormId, entity] of centers) { - entity.point.color = stormId === id ? gold : blue; - entity.point.pixelSize = - stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm; - } - labels.setSelection(id); - render(); - } - return { - async setSnapshot(snapshot, { signal } = {}) { - signal?.throwIfAborted(); - if (destroyed) return false; - const owner = ++generation; - const next = new C.CustomDataSource('weather-cyclones'); - const nextCenters = new Map(), - nextSpheres = new Map(); - const nextEntityStorms = new WeakMap(); - const nextEntityIds = new Set(); - const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 }; - const nextHorizonStorms = []; - const nextLabels = []; - const position = ({ longitude, latitude }) => - C.Cartesian3.fromDegrees(longitude, latitude, 0); - const coordinate = (pair) => - position({ longitude: pair[0], latitude: pair[1] }); - try { - for (const storm of snapshot.storms) { - const horizon = { points: [], shapes: [], sphere: null }; - const addEntity = (options) => { - const entity = next.entities.add(options); - if (options.position) - horizon.points.push({ entity, position: options.position }); - else horizon.shapes.push(entity); - nextEntityStorms.set(entity, storm.id); - nextEntityIds.add(entity.id); - return entity; - }; - const center = position(storm.position), - extent = [center]; - const entity = addEntity({ - id: `cyclone:${storm.id}:center`, - name: storm.name, - position: center, - point: { - heightReference: C.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - pixelSize: CYCLONE_MARKER_PX.storm, - color: blue, - outlineColor: C.Color.BLACK, - outlineWidth: 2, - }, - }); - nextCenters.set(storm.id, entity); - const label = { - id: storm.id, - name: storm.name, - classification: storm.classification, - windKt: storm.windKt, - position: center, - forecasts: [], - }; - nextLabels.push(label); - nextCounts.storms++; - if (coherentCycloneGeometry(storm)) { - const lines = - storm.track?.type === 'LineString' - ? [storm.track.coordinates] - : storm.track?.coordinates || []; - lines.forEach((line, index) => { - const positions = line.map(coordinate); - extent.push(...positions); - addEntity({ - id: `cyclone:${storm.id}:track:${index}`, - polyline: { - clampToGround: true, - classificationType: C.ClassificationType.BOTH, - positions, - width: 2.5, - material: blue, - arcType: C.ArcType.GEODESIC, - }, - }); - nextCounts.tracks++; - }); - const polygons = - storm.cone?.type === 'Polygon' - ? [storm.cone.coordinates] - : storm.cone?.coordinates || []; - polygons.forEach((rings, index) => { - // Each exterior retains its own interior holes. Native geographic - // tessellation handles the antimeridian; never flatten rings. - const exterior = rings[0].map(coordinate); - extent.push(...exterior); - const holes = rings - .slice(1) - .map((ring) => new C.PolygonHierarchy(ring.map(coordinate))); - addEntity({ - id: `cyclone:${storm.id}:cone:${index}`, - polygon: { - hierarchy: new C.PolygonHierarchy(exterior, holes), - classificationType: C.ClassificationType.BOTH, - material: blue.withAlpha(0.16), - arcType: C.ArcType.GEODESIC, - }, - }); - [exterior, ...holes.map((hole) => hole.positions)].forEach( - (positions, ringIndex) => { - addEntity({ - id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`, - polyline: { - positions, - width: 1, - material: blue.withAlpha(0.55), - arcType: C.ArcType.GEODESIC, - clampToGround: true, - classificationType: C.ClassificationType.BOTH, - }, - }); - }, - ); - nextCounts.cones++; - }); - for (const [index, point] of storm.forecastPoints.entries()) { - if (point.tauHours === 0) continue; - const p = position(point.position); - extent.push(p); - addEntity({ - id: `cyclone:${storm.id}:forecast:${index}`, - position: p, - point: { - heightReference: C.HeightReference.CLAMP_TO_GROUND, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - pixelSize: CYCLONE_MARKER_PX.forecast, - color: white, - outlineColor: C.Color.BLACK, - outlineWidth: 1, - }, - }); - label.forecasts.push({ tauHours: point.tauHours, position: p }); - nextCounts.forecastPoints++; - } - } - const sphere = C.BoundingSphere.fromPoints(extent); - // A status-only point still has a useful regional camera destination. - sphere.radius = Math.max(sphere.radius, 500_000); - nextSpheres.set(storm.id, sphere); - horizon.sphere = sphere; - nextHorizonStorms.push(horizon); - } - await viewer.dataSources.add(next); - if (destroyed || generation !== owner || signal?.aborted) { - remove(next); - return false; - } - remove(source); - source = next; - centers = nextCenters; - spheres = nextSpheres; - entityStorms = nextEntityStorms; - entityIds = nextEntityIds; - counts = nextCounts; - horizonStorms = nextHorizonStorms; - syncHorizonListener(); - labels.setSnapshot(nextLabels, selected); - select(selected); - return true; - } catch (error) { - remove(next); - if (signal?.aborted || generation !== owner || destroyed) return false; - throw error; - } - }, - setSelection: select, - ownsPickId(id) { - return source !== null && typeof id === 'string' && entityIds.has(id); - }, - pickStorm(picked) { - // Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone - // cannot establish ownership, especially after an advisory replacement. - const entity = picked?.id; - return source && entity && typeof entity === 'object' - ? entityStorms.get(entity) || null - : null; - }, - getFocusSphere(id) { - return spheres.get(id) || null; - }, - clear() { - ++generation; - horizonStorms = []; - syncHorizonListener(); - pointOccluder = null; - sphereOccluder = null; - remove(source); - source = null; - labels.clear(); - centers.clear(); - spheres.clear(); - entityStorms = new WeakMap(); - entityIds.clear(); - selected = null; - counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 }; - render(); - }, - destroy() { - if (destroyed) return; - this.clear(); - destroyed = true; - }, - getDiagnostics() { - return { - ...counts, - dataSources: Number(!!source), - entities: source?.entities.values.length || 0, - selectedId: selected, - timerActive: false, - }; - }, - }; -} diff --git a/src/layers/cyclones/rendering.test.mjs b/src/layers/cyclones/rendering.test.mjs deleted file mode 100644 index 65ac78601..000000000 --- a/src/layers/cyclones/rendering.test.mjs +++ /dev/null @@ -1,669 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { createCycloneRendering } from './rendering.js'; -import { CYCLONE_OVERLAY_SOURCE_ID } from './labels.js'; - -/** Records what the renderer publishes to the shared overlay host. */ -function overlayRecorder() { - const calls = []; - return { - calls, - host: { - setEntries: (source, entries, options) => - calls.push({ kind: 'entries', source, entries, options }), - setVisible: (source, visible) => - calls.push({ kind: 'visible', source, visible }), - clearSource: (source) => calls.push({ kind: 'clear', source }), - }, - get publishes() { - return calls.filter(({ kind }) => kind === 'entries').length; - }, - published() { - return calls.findLast(({ kind }) => kind === 'entries')?.entries || []; - }, - entry(id) { - return this.published().find((entry) => entry.id === id) || null; - }, - }; -} - -function harness({ deferred = false } = {}) { - const sources = [], - completions = [], - pointOccluders = [], - sphereOccluders = []; - const listeners = new Set(); - const visibility = { - points: new Map(), - spheres: new Map(), - pointCalls: [], - sphereCalls: [], - writes: 0, - }; - const color = (value) => ({ - value, - withAlpha: (alpha) => ({ value, alpha }), - }); - const cesium = { - Color: { - fromCssColorString: color, - WHITE: color('white'), - BLACK: color('black'), - }, - Cartesian2: class { - constructor(x, y) { - this.x = x; - this.y = y; - } - }, - Cartesian3: { - ZERO: { x: 0, y: 0, z: 0 }, - fromDegrees: (lon, lat, height) => ({ lon, lat, height }), - }, - Ellipsoid: { WGS84: { minimumRadius: 6356752 } }, - EllipsoidalOccluder: class { - constructor(ellipsoid, cameraPosition) { - this.ellipsoid = ellipsoid; - this.cameraPosition = cameraPosition; - pointOccluders.push(this); - } - isPointVisible(position) { - visibility.pointCalls.push(position); - return visibility.points.get(position) ?? true; - } - }, - Occluder: class { - constructor(sphere, cameraPosition) { - this.sphere = sphere; - this.cameraPosition = cameraPosition; - sphereOccluders.push(this); - } - isBoundingSphereVisible(sphere) { - visibility.sphereCalls.push(sphere); - return visibility.spheres.get(sphere) ?? true; - } - }, - PolygonHierarchy: class { - constructor(positions, holes = []) { - this.positions = positions; - this.holes = holes; - } - }, - BoundingSphere: class { - constructor(center, radius) { - this.center = center; - this.radius = radius; - } - static fromPoints(points) { - return { points, radius: 10 }; - } - }, - LabelStyle: { FILL_AND_OUTLINE: 1 }, - HorizontalOrigin: { LEFT: 1 }, - ArcType: { GEODESIC: 1 }, - HeightReference: Cesium.HeightReference, - ClassificationType: Cesium.ClassificationType, - DistanceDisplayCondition: Cesium.DistanceDisplayCondition, - CustomDataSource: class { - constructor() { - const values = []; - this.entities = { - values, - add: (value) => { - let show = true; - Object.defineProperty(value, 'show', { - get: () => show, - set: (next) => { - show = next; - visibility.writes++; - }, - }); - values.push(value); - return value; - }, - removeAll: () => { - values.length = 0; - }, - }; - } - }, - }; - let renders = 0; - const overlay = overlayRecorder(); - const viewer = { - camera: { positionWC: { x: 6378487, y: 0, z: 0 } }, - scene: { - requestRender: () => renders++, - preRender: { - addEventListener(listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }, - }, - dataSources: { - add(value) { - if (deferred) - return new Promise((resolve) => - completions.push(() => { - sources.push(value); - resolve(value); - }), - ); - sources.push(value); - return Promise.resolve(value); - }, - remove(value) { - const i = sources.indexOf(value); - if (i >= 0) sources.splice(i, 1); - }, - }, - }; - return { - rendering: createCycloneRendering({ - viewer, - cesium, - overlayHost: overlay.host, - }), - overlay, - sources, - completions, - viewer, - cesium, - visibility, - pointOccluders, - sphereOccluders, - listeners, - frame() { - for (const listener of listeners) listener(); - }, - get renders() { - return renders; - }, - }; -} -const storm = () => ({ - id: 'ep152026', - name: 'Fifteen-E', - advisoryNumber: '10', - geometryAdvisoryNumber: '10', - geometryStatus: 'current', - position: { longitude: 179, latitude: 15 }, - forecastPoints: [ - { position: { longitude: -179, latitude: 16 }, tauHours: 12 }, - ], - track: { - type: 'MultiLineString', - coordinates: [ - [ - [179, 15], - [-179, 16], - ], - ], - }, - cone: { - type: 'MultiPolygon', - coordinates: [ - [ - [ - [178, 10], - [-178, 10], - [-178, 20], - [178, 10], - ], - [ - [179, 12], - [-179, 12], - [-179, 14], - [179, 12], - ], - ], - [ - [ - [170, 1], - [171, 1], - [171, 2], - [170, 1], - ], - ], - ], - }, -}); - -test('horizon culling updates only changed entities and keeps selection independent', async () => { - const h = harness(); - await h.rendering.setSnapshot({ - storms: [storm(), { ...storm(), id: 'near' }], - }); - const entities = h.sources[0].entities.values; - const far = entities.filter((e) => e.id.startsWith('cyclone:ep152026:')); - const near = entities.filter((e) => e.id.startsWith('cyclone:near:')); - const forecast = far.find((e) => e.id.endsWith('forecast:0')); - const sphere = h.rendering.getFocusSphere('ep152026'); - for (const entity of far) - if (entity.position) h.visibility.points.set(entity.position, false); - h.visibility.spheres.set(sphere, false); - h.rendering.setSelection('ep152026'); - const publishes = h.overlay.publishes; - const before = h.renders; - h.frame(); - assert.ok(far.every((e) => e.show === false)); - assert.ok(near.every((e) => e.show === true)); - assert.equal( - h.overlay.entry('lead:ep152026:12').position, - forecast.position, - 'the host culls labels at the same anchors the points use', - ); - assert.equal(h.overlay.publishes, publishes, 'culling never republishes'); - assert.equal(h.renders, before + 1); - assert.equal(h.visibility.writes, far.length); - assert.equal(h.visibility.pointCalls.length, 4); - assert.deepEqual(h.visibility.sphereCalls, [ - sphere, - h.rendering.getFocusSphere('near'), - ]); - h.frame(); - assert.equal(h.renders, before + 1); - assert.equal(h.visibility.writes, far.length); - - // A visible portion of the extent keeps all tracks/cones shown even when - // the centre and an individual forecast point remain beyond the horizon. - h.visibility.spheres.set(sphere, true); - h.viewer.camera.positionWC = { x: 0, y: 6378487, z: 0 }; - h.frame(); - assert.ok(far.filter((e) => !e.position).every((e) => e.show)); - assert.ok(far.filter((e) => e.position).every((e) => !e.show)); - assert.equal(h.renders, before + 2); - assert.equal(h.pointOccluders.length, 1); - assert.equal(h.sphereOccluders.length, 1); - assert.equal(h.pointOccluders[0].ellipsoid, h.cesium.Ellipsoid.WGS84); - assert.equal(h.pointOccluders[0].cameraPosition, h.viewer.camera.positionWC); - assert.equal(h.sphereOccluders[0].cameraPosition, h.viewer.camera.positionWC); - - h.rendering.setSelection('near'); - assert.equal(h.overlay.entry('lead:ep152026:12'), null); - assert.equal( - h.overlay.entry('lead:near:12').position, - near.find((e) => e.id.endsWith('forecast:0')).position, - ); - h.visibility.points.set(forecast.position, true); - const beforeReveal = h.renders; - const revealPublishes = h.overlay.publishes; - h.frame(); - assert.equal(forecast.show, true); - assert.equal(h.overlay.entry('lead:ep152026:12'), null); - assert.equal(h.overlay.publishes, revealPublishes); - assert.equal(far[0].show, false); - assert.equal(h.renders, beforeReveal + 1); - h.rendering.destroy(); -}); - -test('horizon listener follows committed nonempty snapshots and clear/destroy', async () => { - const h = harness(); - assert.equal(h.listeners.size, 0); - await h.rendering.setSnapshot({ storms: [] }); - assert.equal(h.listeners.size, 0); - await h.rendering.setSnapshot({ storms: [storm()] }); - assert.equal(h.listeners.size, 1); - const old = [...h.sources[0].entities.values]; - await h.rendering.setSnapshot({ storms: [storm()] }); - assert.equal(h.listeners.size, 1); - h.frame(); - assert.ok( - h.visibility.pointCalls.every((p) => !old.some((e) => e.position === p)), - ); - await h.rendering.setSnapshot({ storms: [] }); - assert.equal(h.listeners.size, 0); - await h.rendering.setSnapshot({ storms: [storm()] }); - h.rendering.clear(); - assert.equal(h.listeners.size, 0); - const before = h.renders; - h.frame(); - assert.equal(h.renders, before); - await h.rendering.setSnapshot({ storms: [storm()] }); - assert.equal(h.listeners.size, 1); - h.rendering.destroy(); - h.rendering.destroy(); - assert.equal(h.listeners.size, 0); - assert.equal(await h.rendering.setSnapshot({ storms: [storm()] }), false); - assert.equal(h.listeners.size, 0); -}); - -test('late asynchronous additions never restore a cleared or destroyed horizon listener', async () => { - for (const method of ['clear', 'destroy']) { - const h = harness({ deferred: true }); - const pending = h.rendering.setSnapshot({ storms: [storm()] }); - assert.equal(h.listeners.size, 0); - h.rendering[method](); - h.completions.shift()(); - assert.equal(await pending, false); - assert.equal(h.listeners.size, 0); - } -}); - -test('real Cesium culls far storms and retains partially visible extents with either globe visibility', async () => { - for (const globeShow of [true, false]) { - const sources = []; - const viewer = { - camera: { positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 350) }, - scene: { - globe: { show: globeShow }, - preRender: new Cesium.Event(), - requestRender() {}, - }, - dataSources: { - async add(source) { - sources.push(source); - }, - remove() {}, - }, - }; - const overlay = overlayRecorder(); - const rendering = createCycloneRendering({ - viewer, - cesium: Cesium, - overlayHost: overlay.host, - }); - const at = (id, longitude) => ({ - ...storm(), - id, - position: { longitude, latitude: 0 }, - forecastPoints: [{ position: { longitude, latitude: 0 }, tauHours: 24 }], - track: { - type: 'LineString', - coordinates: [ - [longitude, 0], - [longitude, 1], - ], - }, - cone: { - type: 'Polygon', - coordinates: [ - [ - [longitude, 0], - [longitude + 1, 0], - [longitude, 1], - [longitude, 0], - ], - ], - }, - }); - await rendering.setSnapshot({ - storms: [at('near', 0), at('far', 180), at('limb', 5)], - }); - rendering.setSelection('near'); - viewer.scene.preRender.raiseEvent(); - const entities = sources[0].entities.values; - for (const entity of entities.filter((e) => e.position)) { - assert.equal( - entity.point.heightReference.getValue(), - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.equal(entity.point.disableDepthTestDistance.getValue(), Infinity); - assert.equal(entity.label, undefined, 'no Cesium label graphics'); - const published = overlay.entry( - entity.id.includes(':forecast:') - ? `lead:${entity.id.split(':')[1]}:24` - : `storm:${entity.id.split(':')[1]}`, - ); - if (entity.id.startsWith('cyclone:near:')) { - assert.ok( - Cesium.Cartesian3.equals( - published.position, - entity.position.getValue(), - ), - ); - assert.equal(published.horizonCull, true); - } - if (entity.id === 'cyclone:near:forecast:0') - assert.equal(published.maxDistance, 4_000_000); - } - assert.ok( - entities - .filter((e) => e.id.startsWith('cyclone:near:')) - .every((e) => e.show), - ); - assert.ok( - entities - .filter((e) => e.id.startsWith('cyclone:far:')) - .every((e) => !e.show), - ); - const limb = entities.filter((e) => e.id.startsWith('cyclone:limb:')); - assert.ok(limb.filter((e) => e.position).every((e) => !e.show)); - assert.ok(limb.filter((e) => !e.position).every((e) => e.show)); - rendering.destroy(); - assert.equal(viewer.scene.preRender.numberOfListeners, 0); - } -}); - -test('picking accepts exact current owned entities, never prefixes or superseded identities', async () => { - const h = harness(); - await h.rendering.setSnapshot({ storms: [storm()] }); - const oldEntities = [...h.sources[0].entities.values]; - assert.equal(oldEntities.length, 8); - for (const entity of oldEntities) { - assert.equal(h.rendering.pickStorm({ id: entity }), 'ep152026', entity.id); - assert.equal(h.rendering.ownsPickId(entity.id), true); - assert.equal( - h.rendering.pickStorm({ id: { ...entity } }), - null, - 'copied ID is not ownership', - ); - assert.equal( - h.rendering.pickStorm({ id: entity.id }), - null, - 'string prefix is not ownership', - ); - } - assert.equal(h.rendering.pickStorm(undefined), null); - assert.equal(h.rendering.ownsPickId('cyclone:ep152026:unknown'), false); - await h.rendering.setSnapshot({ storms: [storm()] }); - for (const entity of oldEntities) - assert.equal(h.rendering.pickStorm({ id: entity }), null); - const current = h.sources[0].entities.values[0]; - assert.equal(h.rendering.pickStorm({ id: current }), 'ep152026'); - h.rendering.clear(); - assert.equal(h.rendering.pickStorm({ id: current }), null); - assert.equal(h.rendering.ownsPickId(current.id), false); - h.rendering.destroy(); -}); - -test('registry IDs switch only when the next coherent data source commits', async () => { - const h = harness({ deferred: true }); - const first = h.rendering.setSnapshot({ storms: [storm()] }); - assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false); - h.completions.shift()(); - await first; - assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true); - const next = h.rendering.setSnapshot({ - storms: [{ ...storm(), id: 'ep162026' }], - }); - assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), true); - assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false); - h.completions.shift()(); - await next; - assert.equal(h.rendering.ownsPickId('cyclone:ep152026:center'), false); - assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), true); - h.rendering.destroy(); - assert.equal(h.rendering.ownsPickId('cyclone:ep162026:center'), false); -}); -test('static entities preserve polygon parts, holes and geographic seam coordinates', async () => { - const h = harness(); - await h.rendering.setSnapshot({ storms: [storm()] }); - const entities = h.sources[0].entities.values; - const cones = entities.filter((e) => e.polygon); - assert.equal(cones.length, 2); - assert.equal(cones[0].polygon.hierarchy.holes.length, 1); - assert.equal(cones[0].polygon.hierarchy.positions[1].lon, -178); - for (const cone of cones) { - assert.equal( - cone.polygon.classificationType, - h.cesium.ClassificationType.BOTH, - ); - assert.equal(cone.polygon.height, undefined); - assert.equal(cone.polygon.extrudedHeight, undefined); - assert.equal(cone.polygon.outline, undefined); - } - const outlines = entities.filter((e) => e.id.includes(':outline:')); - assert.equal(outlines.length, 3); - assert.deepEqual( - outlines.map((e) => e.polyline.positions.map((p) => [p.lon, p.lat])), - storm().cone.coordinates.flat(), - ); - for (const outline of outlines) { - assert.deepEqual(outline.polyline.material, { - value: '#7fe6ed', - alpha: 0.55, - }); - } - for (const { polyline } of entities.filter((e) => e.polyline)) { - assert.equal(polyline.clampToGround, true); - assert.equal(polyline.classificationType, h.cesium.ClassificationType.BOTH); - assert.ok(polyline.positions.every((p) => p.height === 0)); - } - assert.equal( - entities.find((e) => e.polyline).polyline.positions[1].lon, - -179, - ); - assert.deepEqual(h.rendering.getDiagnostics(), { - storms: 1, - tracks: 1, - cones: 2, - forecastPoints: 1, - dataSources: 1, - entities: 8, - selectedId: null, - timerActive: false, - }); - h.rendering.setSelection('ep152026'); - assert.equal( - h.overlay.entry('lead:ep152026:12').position, - entities.find((e) => e.id.endsWith('forecast:0')).position, - ); - assert.ok(entities.every((e) => e.label === undefined)); - assert.ok(h.rendering.getFocusSphere('ep152026').radius >= 500000); - h.rendering.destroy(); - assert.equal(h.sources.length, 0); - assert.equal(h.rendering.getDiagnostics().entities, 0); -}); -test('pending or mismatched advisory geometry never renders even when supplied', async () => { - for (const changed of [ - { geometryStatus: 'pending' }, - { geometryAdvisoryNumber: '9' }, - ]) { - const h = harness(); - await h.rendering.setSnapshot({ storms: [{ ...storm(), ...changed }] }); - assert.equal(h.sources[0].entities.values.length, 1); - assert.equal(h.rendering.getDiagnostics().tracks, 0); - h.rendering.destroy(); - } -}); -test('a data-source add settling after disable is removed, without a new owner', async () => { - const h = harness({ deferred: true }); - const pending = h.rendering.setSnapshot({ storms: [storm()] }); - h.rendering.clear(); - h.completions.shift()(); - assert.equal(await pending, false); - assert.equal(h.sources.length, 0); - assert.equal(h.rendering.getDiagnostics().dataSources, 0); -}); -test('superseded asynchronous additions cannot replace newer geometry', async () => { - const h = harness({ deferred: true }); - const old = h.rendering.setSnapshot({ storms: [storm()] }); - const latest = h.rendering.setSnapshot({ storms: [] }); - h.completions[1](); - assert.equal(await latest, true); - h.completions[0](); - assert.equal(await old, false); - assert.equal(h.sources.length, 1); - assert.equal(h.rendering.getDiagnostics().storms, 0); - h.rendering.destroy(); -}); -test('aborting a pending add retains the prior complete source', async () => { - const h = harness({ deferred: true }); - const initial = h.rendering.setSnapshot({ storms: [storm()] }); - h.completions.shift()(); - await initial; - const controller = new AbortController(); - const update = h.rendering.setSnapshot( - { storms: [] }, - { signal: controller.signal }, - ); - controller.abort(); - h.completions.shift()(); - assert.equal(await update, false); - assert.equal(h.sources.length, 1); - assert.equal(h.rendering.getDiagnostics().storms, 1); - h.rendering.destroy(); -}); - -test('labels publish committed anchors to the shared overlay and follow selection and clear', async () => { - const h = harness({ deferred: true }); - const pending = h.rendering.setSnapshot({ - storms: [storm(), { ...storm(), id: 'near', name: 'Near' }], - }); - assert.equal(h.overlay.calls.length, 0, 'nothing publishes before commit'); - h.completions.shift()(); - assert.equal(await pending, true); - const entities = h.sources[0].entities.values; - const center = (id) => entities.find((e) => e.id === `cyclone:${id}:center`); - assert.deepEqual( - h.overlay.calls - .slice(0, 2) - .map(({ kind, source, visible }) => [kind, source, visible]), - [ - ['visible', CYCLONE_OVERLAY_SOURCE_ID, true], - ['entries', CYCLONE_OVERLAY_SOURCE_ID, undefined], - ], - ); - assert.deepEqual( - h.overlay.published().map((entry) => entry.id), - ['storm:ep152026', 'storm:near'], - 'no selection, no lead-hour labels', - ); - assert.equal(h.overlay.entry('storm:near').title, 'Near'); - assert.equal(h.overlay.entry('storm:near').position, center('near').position); - - h.rendering.setSelection('near'); - assert.equal(center('near').point.pixelSize, 12); - assert.equal(center('ep152026').point.pixelSize, 9); - assert.equal(h.overlay.entry('storm:near').variant, 'selected'); - assert.equal(h.overlay.entry('storm:ep152026').variant, 'card'); - assert.ok(h.overlay.entry('lead:near:12')); - const publishes = h.overlay.publishes; - h.rendering.setSelection('near'); - assert.equal(h.overlay.publishes, publishes, 'unchanged selection is quiet'); - - // A refresh republishes the new anchors with the retained selection. - const refresh = h.rendering.setSnapshot({ - storms: [{ ...storm(), id: 'near', name: 'Near' }], - }); - h.completions.shift()(); - await refresh; - assert.deepEqual( - h.overlay.published().map((entry) => [entry.id, entry.variant]), - [ - ['storm:near', 'selected'], - ['lead:near:12', 'card'], - ], - ); - assert.equal( - h.overlay.entry('storm:near').position, - h.sources[0].entities.values[0].position, - ); - - // A superseded addition never publishes, and clear hides the source. - const stale = h.rendering.setSnapshot({ storms: [storm()] }); - h.rendering.clear(); - const afterClear = h.overlay.calls.slice(-2); - assert.deepEqual(afterClear, [ - { kind: 'clear', source: CYCLONE_OVERLAY_SOURCE_ID }, - { kind: 'visible', source: CYCLONE_OVERLAY_SOURCE_ID, visible: false }, - ]); - h.completions.shift()(); - assert.equal(await stale, false); - assert.equal(h.overlay.calls.at(-1), afterClear[1]); - h.rendering.destroy(); - assert.equal(h.overlay.calls.at(-1), afterClear[1]); -}); diff --git a/src/layers/droneAirspace/index.js b/src/layers/droneAirspace/index.js index 835ef1a23..262a87125 100644 --- a/src/layers/droneAirspace/index.js +++ b/src/layers/droneAirspace/index.js @@ -1,6 +1,6 @@ import * as geo from '../../geo/index.js'; import { msg, t } from '../../i18n/index.js'; -import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js'; +import { createDroneAirspaceDisplay } from './render/display.js'; import { DRONE_AIRSPACE_OVERLAY_SOURCE_ID, droneAirspaceAnchorDegrees, @@ -11,20 +11,6 @@ export { normalizeDroneAirspaceSnapshot } from './records.js'; export { createDroneAirspaceSource } from './source.js'; export * from './cards.js'; -/** The zones on a Cesium globe (the default renderer). */ -export function createDroneAirspaceCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, -}) { - return createZonesCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, - name: 'drone-airspace', - idPrefix: 'drone-airspace', - colorOf: (row) => droneAirspaceAccent(row.typeCode), - }); -} - /** * FNV-1a over a zone's rings: a short, deterministic fingerprint so a * boundary change with unchanged attributes still alters the refresh signature. @@ -50,10 +36,9 @@ const CARD_HOST_OPTIONS = Object.freeze({ export function createDroneAirspaceLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, picking = null, pointer = null, - createRenderer = createDroneAirspaceCesiumRenderer, + createRenderer = ({ engine }) => createDroneAirspaceDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Drone airspace requires a snapshot source'); @@ -74,8 +59,7 @@ export function createDroneAirspaceLayer({ /** @type {Map} */ const _rowById = new Map(); - const canSelect = () => - overlayHost && screenSpaceEventHandlerFactory && picking; + const canSelect = () => overlayHost && picking; function publishSelectedCard() { if (!canSelect() || !renderer) return; @@ -172,7 +156,7 @@ export function createDroneAirspaceLayer({ throw new Error('Drone airspace layer is already initialized'); initialized = true; try { - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer(viewer, { engine }); } catch (error) { this.destroy(); throw error; @@ -184,11 +168,7 @@ export function createDroneAirspaceLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid drone airspace scene attachment'); - renderer = createRenderer({ - viewer, - engine, - screenSpaceEventHandlerFactory, - }); + renderer = createRenderer({ engine }); renderer.setVisible(_enabled); if (_rows.length) renderer.replace(_rows); if (_enabled) { diff --git a/src/layers/droneAirspace/ownership.test.mjs b/src/layers/droneAirspace/ownership.test.mjs index 724842ed4..bc6c6951f 100644 --- a/src/layers/droneAirspace/ownership.test.mjs +++ b/src/layers/droneAirspace/ownership.test.mjs @@ -1,24 +1,17 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; import { createDroneAirspaceLayer } from './index.js'; import { layerFeedState } from '../../data/feedState.js'; function harness(source, { pick = () => null, cardHit = () => null } = {}) { - const sources = []; const overlay = { entries: new Map(), visible: null }; - const clicks = { handler: null, destroyed: 0 }; const owners = new Map(); - const viewer = { - scene: { pick }, - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); - }, - }, - }; + const ground = createFakeGroundHarness(); + const { engine } = ground; + // What a click picks: `scene.pick()` (null for nothing). + const scene = { pick }; + engine.fake.script('pick', () => scene.pick() ?? undefined); const layer = createDroneAirspaceLayer({ source, overlayHost: { @@ -33,15 +26,6 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) { }, hitTest: (x, y, options) => cardHit(x, y, options), }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction(callback) { - clicks.handler = callback; - }, - destroy() { - clicks.destroyed += 1; - clicks.handler = null; - }, - }), picking: { resolvePickId: (picked) => picked?.id ?? null, isOwnedByOtherLayer: (layerId, pickedId) => @@ -51,9 +35,20 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) { }, pointer: { isPointerFree: () => true }, }); - layer.init(viewer); - layer.enable(viewer); - return { layer, viewer, sources, overlay, clicks, owners }; + layer.init(null, { engine }); + layer.enable(); + return { + layer, + engine, + ground, + scene, + overlay, + owners, + /** The zones' ground set (undefined once released). */ + zones: () => ground.set('drone-airspace'), + /** A click through the engine's input, picking `scene.pick()`. */ + click: (event) => engine.fake.input('click', event), + }; } const ring = [ @@ -76,19 +71,27 @@ const row = { polygons: [[ring]], }; -test('each zone polygon renders as one filled entity with its outline', async () => { +test('each zone polygon renders as one fill with its outline', async () => { const h = harness({ getSnapshot: async () => [ row, { ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] }, ], }); - assert.equal(await h.layer.update(h.viewer), true); - const entities = h.sources[0].entities.values; - assert.equal(entities.length, 3); - assert.equal(entities[0].id, 'drone-airspace:310:0'); - assert.ok(entities[0].polygon, 'zone fill missing'); - assert.ok(entities[0].polyline, 'zone outline missing'); + assert.equal(await h.layer.update(), true); + const { items } = h.zones(); + const ids = [ + 'drone-airspace:310:0', + 'drone-airspace:400:0', + 'drone-airspace:400:1', + ]; + assert.equal(items.length, 6, 'a fill and an outline per polygon'); + assert.deepEqual( + items.map(({ pickId }) => pickId), + [...ids, ...ids], + ); + assert.ok(items.slice(0, 3).every((item) => item.fill && !item.outline)); + assert.ok(items.slice(3).every((item) => item.outline && !item.fill)); assert.equal(h.layer.getStats().count, 2); }); @@ -98,12 +101,12 @@ test('clicking a zone publishes its card; empty space clears it', async () => { { getSnapshot: async () => [row] }, { pick: () => pickResult }, ); - await h.layer.update(h.viewer); - assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + await h.layer.update(); + assert.equal(h.ground.listeners(), 1, 'click handler missing'); assert.equal(h.owners.size, 0); pickResult = { id: 'drone-airspace:310:0' }; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); const entries = h.overlay.entries.get('drone-airspace'); assert.equal(entries.length, 1); assert.equal(entries[0].id, 'drone-airspace-card:310'); @@ -111,7 +114,7 @@ test('clicking a zone publishes its card; empty space clears it', async () => { assert.ok(entries[0].position, 'card must carry a world anchor'); pickResult = { id: 'other-layer:aircraft-1' }; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); assert.equal( h.overlay.entries.get('drone-airspace')?.length, 1, @@ -119,7 +122,7 @@ test('clicking a zone publishes its card; empty space clears it', async () => { ); pickResult = null; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0); }); @@ -134,14 +137,14 @@ test('late refresh cannot publish after disable or destroy', async () => { }); }, }); - const pending = h.layer.update(h.viewer); - h.layer[action](h.viewer); + const pending = h.layer.update(); + h.layer[action](); assert.equal(signal.aborted, true); - if (action === 'disable') h.layer.enable(h.viewer); + if (action === 'disable') h.layer.enable(); resolve([row]); assert.equal(await pending, false); assert.equal(h.layer.getStats().count, 0); - h.layer.destroy(h.viewer); + h.layer.destroy(); } }); @@ -152,7 +155,7 @@ test('the row legend names prohibited and restricted with the disclaimer', async { ...row, stableId: '400', typeCode: 'R', type: 'Restricted' }, ], }); - await h.layer.update(h.viewer); + await h.layer.update(); const legend = h.layer.getRowControls().legend; assert.equal(legend[0].label, 'Prohibited (P)'); assert.equal(legend[1].label, 'Restricted (R)'); @@ -168,12 +171,12 @@ test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clea getSnapshot: async () => snapshot.rows, getSnapshotDetail: async () => snapshot, }); - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, true); assert.equal(h.layer.getStats().lastUpdate, fetchedAt); assert.equal(layerFeedState(h.layer.getStats()), 'stale'); snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, false); assert.ok(h.layer.getStats().lastUpdate > fetchedAt); assert.equal(layerFeedState(h.layer.getStats()), 'nominal'); @@ -188,10 +191,10 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale', return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; }, }); - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(layerFeedState(h.layer.getStats()), 'stale'); fail = true; - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, false); assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502'); assert.equal(layerFeedState(h.layer.getStats()), 'degraded'); @@ -201,19 +204,21 @@ test('a boundary change alone redraws the zone; identical geometry does not', as const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]); let rows = [row]; const h = harness({ getSnapshot: async () => rows }); - await h.layer.update(h.viewer); - const [first] = h.sources[0].entities.values; + await h.layer.update(); + const first = h.zones(); rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }]; - await h.layer.update(h.viewer); - assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept'); + await h.layer.update(); + assert.equal(h.zones().commits, first.commits, 'same geometry kept'); + assert.equal(h.zones().items, first.items); rows = [{ ...row, polygons: [[moved]] }]; - await h.layer.update(h.viewer); - const [redrawn] = h.sources[0].entities.values; - assert.notEqual(redrawn, first, 'moved boundary must redraw'); - assert.notDeepEqual( - redrawn.polyline.positions.getValue(), - first.polyline.positions.getValue(), + await h.layer.update(); + const redrawn = h.zones(); + assert.equal( + redrawn.commits, + first.commits + 1, + 'moved boundary must redraw', ); + assert.notDeepEqual(redrawn.items[1].rings, first.items[1].rings); assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6); }); @@ -232,7 +237,6 @@ test('an engine switch keeps the zones and the selected card for the next globe' }, hitTest: () => null, }, - screenSpaceEventHandlerFactory: () => null, picking: { resolvePickId: (picked) => picked?.id ?? null, isOwnedByOtherLayer: () => false, diff --git a/src/layers/droneAirspace/render/display.js b/src/layers/droneAirspace/render/display.js new file mode 100644 index 000000000..ebe95f6b4 --- /dev/null +++ b/src/layers/droneAirspace/render/display.js @@ -0,0 +1,84 @@ +// The FAA special-use airspace zones on the globe contract (engine.ground, +// engine.input), the same on every engine, drawn as fire perimeters are: +// each row's polygons (holes kept) as a 0.25 fill in its type's colour, +// under a 2 px ground outline at 0.9 along the outer ring only, every +// polygon and its outline picked as `drone-airspace:${stableId}:${index}`. +// All fills come before all outlines in the set, so an outline is never +// covered by a later fill (on Cesium the outlines are one batch over the +// fills). + +import { rgbaFromCss } from '../../shared/color.js'; +import { droneAirspaceAccent } from '../cards.js'; + +const SET_ID = 'drone-airspace'; +const FILL_ALPHA = 0.25; +const OUTLINE_ALPHA = 0.9; +const OUTLINE_PX = 2; + +/** What a pick of a zone's polygon returns. */ +export const droneAirspacePickId = (row, index) => + `drone-airspace:${row.stableId}:${index}`; + +/** + * The zones: `{ setVisible, replace(rows), onClick(listener) → remove, + * pick(click), destroy }`. A click listener gets the engine's click with its + * pick; pick(click) is that pick (null for none). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createDroneAirspaceDisplay(engine) { + if (!engine?.ground) + throw new TypeError('The drone airspace display needs a globe engine'); + const set = engine.ground.createSet({ + id: SET_ID, + kind: 'polygon', + pickable: true, + }); + set.show(false); + let destroyed = false; + const removers = new Set(); + return { + setVisible(show) { + if (!destroyed) set.show(show); + }, + replace(rows) { + if (destroyed) return; + const fills = []; + const outlines = []; + for (const row of rows) { + const { red, green, blue } = rgbaFromCss( + droneAirspaceAccent(row.typeCode), + ); + for (const [index, rings] of row.polygons.entries()) { + const pickId = droneAirspacePickId(row, index); + fills.push({ rings, fill: [red, green, blue, FILL_ALPHA], pickId }); + outlines.push({ + rings: rings.slice(0, 1), + outline: { + color: [red, green, blue, OUTLINE_ALPHA], + widthPx: OUTLINE_PX, + }, + pickId, + }); + } + } + set.replace([...fills, ...outlines]); + set.commit(); + }, + onClick(listener) { + if (destroyed) return () => {}; + const remove = engine.input.on('click', listener, { pick: true }); + const off = () => { + if (removers.delete(off)) remove(); + }; + removers.add(off); + return off; + }, + pick: (click) => click?.picked ?? null, + destroy() { + if (destroyed) return; + destroyed = true; + for (const off of [...removers]) off(); + set.destroy(); + }, + }; +} diff --git a/src/layers/droneAirspace/render/display.test.mjs b/src/layers/droneAirspace/render/display.test.mjs new file mode 100644 index 000000000..31c21b618 --- /dev/null +++ b/src/layers/droneAirspace/render/display.test.mjs @@ -0,0 +1,272 @@ +// The FAA airspace zones on the engine contract, on the fake engine and on +// the Cesium engine (its ground batches over fake primitives): fills with +// their holes at 0.25 and a 2 px outline at 0.9 along the outer ring only, +// in the zone type's colour; clicks through the engine's input selecting a +// zone's card; visibility and teardown. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { + GROUND_HARNESSES, + createFakeGroundHarness, + instanceColor, +} from '../../../testSupport/groundDisplayEngines.mjs'; +import { rgbaFromCss } from '../../shared/color.js'; +import { droneAirspaceAccent } from '../cards.js'; +import { createDroneAirspaceLayer } from '../index.js'; +import { createDroneAirspaceDisplay } from './display.js'; + +const outer = [ + [-77.2, 38.8], + [-76.9, 38.8], + [-76.9, 39.1], + [-77.2, 39.1], + [-77.2, 38.8], +]; +const hole = [ + [-77.1, 38.9], + [-77.0, 38.9], + [-77.0, 39.0], + [-77.1, 38.9], +]; +const second = outer.map(([lon, lat]) => [lon + 1, lat]); +const row = { + stableId: '310', + name: 'P-56', + typeCode: 'P', + type: 'Prohibited', + lowerAltitude: 'SFC', + upperAltitude: '18,000 ft MSL', + timesOfUse: 'CONTINUOUS', + agency: null, + city: 'WASHINGTON', + state: 'DC', + polygons: [[outer, hole]], +}; +const other = { + ...row, + stableId: '400', + name: 'R-4001', + typeCode: 'R', + type: 'Restricted', + polygons: [[second], [second.map(([lon, lat]) => [lon, lat + 1])]], +}; +const PICKS = [ + 'drone-airspace:310:0', + 'drone-airspace:400:0', + 'drone-airspace:400:1', +]; +const colorOf = (zone) => rgbaFromCss(droneAirspaceAccent(zone.typeCode)); +// Per polygon: its incident's colour. +const COLORS = [colorOf(row), colorOf(other), colorOf(other)]; + +function fixture(makeHarness) { + const h = makeHarness(); + const entries = new Map(); + const layer = createDroneAirspaceLayer({ + source: { getSnapshot: async () => [row, other] }, + overlayHost: { + setEntries: (id, value) => entries.set(id, value), + setVisible() {}, + clearSource: (id) => entries.delete(id), + hitTest: () => null, + }, + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (_layer, id) => id.startsWith('other-layer:'), + }, + pointer: { isPointerFree: () => true }, + }); + layer.init(null, { engine: h.engine }); + layer.enable(); + return { ...h, layer, entries, card: () => entries.get('drone-airspace') }; +} + +const near = (actual, expected, label, tolerance = 1e-9) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +test('the display needs a globe engine', () => { + assert.throws(() => createDroneAirspaceDisplay(null), TypeError); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`each polygon is a 0.25 fill with its holes under a 2 px outline at 0.9 on its outer ring (${name})`, async () => { + const f = fixture(makeHarness); + try { + assert.equal(await f.layer.update(), true); + const set = f.set('drone-airspace'); + assert.equal(set.options.kind, 'polygon'); + assert.equal(set.options.pickable, true); + assert.equal(set.shown, true); + const fills = set.items.slice(0, 3); + const lines = set.items.slice(3); + assert.equal(set.items.length, 6, 'every fill, then every outline'); + assert.deepEqual( + fills.map(({ rings, pickId, outline }) => ({ rings, pickId, outline })), + [ + { rings: [outer, hole], pickId: PICKS[0], outline: undefined }, + { rings: [second], pickId: PICKS[1], outline: undefined }, + { + rings: other.polygons[1], + pickId: PICKS[2], + outline: undefined, + }, + ], + ); + assert.deepEqual( + lines.map(({ rings, pickId, fill, outline }) => ({ + rings, + pickId, + fill, + widthPx: outline.widthPx, + })), + [ + { rings: [outer], pickId: PICKS[0], fill: undefined, widthPx: 2 }, + { rings: [second], pickId: PICKS[1], fill: undefined, widthPx: 2 }, + { + rings: other.polygons[1], + pickId: PICKS[2], + fill: undefined, + widthPx: 2, + }, + ], + ); + fills.forEach(({ fill }, index) => + assert.deepEqual(fill, COLORS[index].withAlpha(0.25).rgba()), + ); + lines.forEach(({ outline }, index) => + assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()), + ); + if (f.cesium) { + const primitives = f.primitives('drone-airspace'); + assert.equal(primitives.fills.length, 1, 'one fill batch'); + assert.equal(primitives.lines.length, 1, 'one outline batch'); + const fillInstances = primitives.fills[0].options.geometryInstances; + const lineInstances = primitives.lines[0].options.geometryInstances; + assert.equal(fillInstances.length, 3); + assert.equal(lineInstances.length, 3, 'no line along a hole'); + assert.equal( + primitives.fills[0].options.classificationType, + Cesium.ClassificationType.BOTH, + ); + assert.equal(primitives.fills[0].options.allowPicking, true); + assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry); + assert.equal( + fillInstances[0].geometry._polygonHierarchy.holes.length, + 1, + ); + fillInstances.forEach((instance, index) => + instanceColor(instance).forEach((channel, at) => + near( + channel, + COLORS[index].withAlpha(0.25).rgba()[at], + `fill ${index}.${at}`, + 0.5 / 255, + ), + ), + ); + lineInstances.forEach((instance, index) => { + assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry); + assert.equal(instance.geometry.width, 2); + assert.equal(instance.geometry.loop, true); + instanceColor(instance).forEach((channel, at) => + near( + channel, + COLORS[index].withAlpha(0.9).rgba()[at], + `line ${index}.${at}`, + 0.5 / 255, + ), + ); + }); + } + f.layer.disable(); + assert.equal(f.set('drone-airspace').shown, false); + if (f.cesium) assert.equal(f.collectionShown('drone-airspace'), false); + } finally { + f.layer.destroy(); + } + assert.equal(f.sets(), 0); + f.destroy(); + }); + + test(`a click on a zone or its outline selects the zone; empty space clears it (${name})`, async () => { + const f = fixture(makeHarness); + try { + await f.layer.update(); + assert.equal(f.listeners(), 1, 'one click listener while enabled'); + f.click({ set: 'drone-airspace', instance: 0 }); + assert.equal(f.card()[0].id, 'drone-airspace-card:310'); + // The other zone's second outline. + f.click({ set: 'drone-airspace', instance: 5 }); + assert.equal(f.card()[0].id, 'drone-airspace-card:400'); + f.click({ id: 'other-layer:aircraft-1' }); + assert.equal( + f.card()[0].id, + 'drone-airspace-card:400', + 'a sibling layer pick keeps the selection', + ); + f.click(undefined); + assert.deepEqual(f.card(), []); + f.layer.disable(); + assert.equal(f.listeners(), 0, 'disable removes the click listener'); + f.layer.enable(); + assert.equal(f.listeners(), 1); + f.layer.detachRenderer(); + assert.equal(f.listeners(), 0, 'detach removes it too'); + assert.equal(f.sets(), 0); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); +} + +test('a click inside a zone picks it on the ground, not in its hole (the fake engine)', async () => { + const f = fixture(createFakeGroundHarness); + try { + await f.layer.update(); + const clickAt = (lon, lat) => { + f.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, 30_000), + }); + const { width, height } = f.engine.host.size(); + f.engine.fake.input('click', { + position: { x: width / 2, y: height / 2 }, + }); + }; + clickAt(-77.15, 39.05); + assert.equal(f.card()[0].id, 'drone-airspace-card:310'); + clickAt(-77.03, 38.92); + assert.deepEqual(f.card(), [], 'the hole is empty space'); + } finally { + f.layer.destroy(); + f.destroy(); + } +}); + +test('the display ignores calls after destroy and destroys once', () => { + const h = createFakeGroundHarness(); + const display = createDroneAirspaceDisplay(h.engine); + assert.equal(h.set('drone-airspace').shown, false, 'hidden until shown'); + const off = display.onClick(() => {}); + assert.equal(h.listeners(), 1); + display.destroy(); + display.destroy(); + assert.equal(h.listeners(), 0, 'destroy removes its click listeners'); + off(); + assert.equal(h.sets(), 0); + display.setVisible(true); + display.replace([row]); + assert.equal(h.listeners(), 0); + display.onClick(() => {})(); + assert.equal(display.pick({}), null); + assert.equal(display.pick({ picked: { id: 'x' } }).id, 'x'); + h.destroy(); +}); diff --git a/src/layers/droneAirspace/render/navara.js b/src/layers/droneAirspace/render/navara.js deleted file mode 100644 index 8b57a9b0b..000000000 --- a/src/layers/droneAirspace/render/navara.js +++ /dev/null @@ -1,14 +0,0 @@ -import { droneAirspaceAccent } from '../cards.js'; -import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js'; - -/** The FAA zones on Navara, as fire perimeters draw. */ -export function createDroneAirspaceNavaraRenderer(engine, { view }) { - return createZonesNavaraRenderer( - engine, - { view }, - { - idPrefix: 'drone-airspace', - colorOf: (row) => droneAirspaceAccent(row.typeCode), - }, - ); -} diff --git a/src/layers/earthquakes/index.js b/src/layers/earthquakes/index.js index 88a188fbb..c90c088e3 100644 --- a/src/layers/earthquakes/index.js +++ b/src/layers/earthquakes/index.js @@ -10,17 +10,22 @@ import { selectEarthquakeOverlayCohort, mapAnalystRecord, } from './model.js'; +import { createEarthquakesDisplay } from './render/display.js'; export * from './model.js'; export { createUsgsEarthquakeSource } from './source.js'; /** * Own one earthquake display and its refresh lifecycle. - * renderer.create({viewer, engine}) owns drawing and returns: - * setVisible(boolean), replace(discs), destroy(). - * Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep - * source order. The layer retains observations for queries while detached. + * renderer.create({ engine }) owns drawing (render/display.js by default) + * and returns: setVisible(boolean), replace(discs), destroy(). + * Discs are degrees, metre radii and Rgba colours; renderers keep source + * order. The layer retains observations for queries while detached. */ -export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { +export function createEarthquakesLayer({ + source, + overlayHost, + renderer = { create: ({ engine }) => createEarthquakesDisplay(engine) }, +} = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Earthquakes require a snapshot source'); if (!overlayHost) throw new TypeError('Earthquakes require an overlay host'); @@ -57,7 +62,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { throw new Error('Earthquake layer is already initialized'); initialized = true; try { - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer(viewer, { engine }); } catch (error) { this.destroy(); throw error; @@ -68,7 +73,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { attachRenderer(viewer, { engine = null } = {}) { if (!initialized || _renderer) throw new Error('Invalid earthquake scene attachment'); - _renderer = renderer.create({ viewer, engine }); + _renderer = renderer.create({ engine }); if (!_renderer) throw new Error('Earthquake renderer unavailable'); _renderer.setVisible(_enabled); if (discs.length) _renderer.replace(discs); @@ -117,38 +122,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) { let count = 0; const overlayEntries = []; - for (const { - stableId, - usgsId, - lon, - lat, - depthKm, - mag, - place, - time, - } of rows) { + for (const { stableId, lon, lat, depthKm, mag } of rows) { count++; const baseRadius = Math.pow(2, mag) * 1000; const color = depthColor(depthKm || 0); - const isSignificant = mag >= 5.0; - const fillAlpha = isSignificant ? 0.4 : 0.3; - const outlineAlpha = isSignificant ? 1.0 : 0.8; + const fillAlpha = mag >= 5.0 ? 0.4 : 0.3; const position = geo.cartesian3.fromDegrees(lon, lat); - nextDiscs.push({ - stableId, - usgsId, - position, - baseRadius, - color, - isSignificant, - fillAlpha, - outlineAlpha, - mag, - place, - time, - depthKm, - }); + nextDiscs.push({ stableId, lon, lat, baseRadius, color, fillAlpha }); overlayEntries.push( createEarthquakeOverlayEntry({ id: String(stableId), diff --git a/src/layers/earthquakes/ownership.test.mjs b/src/layers/earthquakes/ownership.test.mjs index 4079f7406..938031997 100644 --- a/src/layers/earthquakes/ownership.test.mjs +++ b/src/layers/earthquakes/ownership.test.mjs @@ -1,26 +1,13 @@ -import { createEarthquakesCesiumRenderer } from './render/cesium.js'; import assert from 'node:assert/strict'; import test from 'node:test'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; import { createEarthquakesLayer } from './index.js'; import { createUsgsEarthquakeSource } from './source.js'; function harness(source) { - const sources = []; const events = []; - const viewer = { - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); - }, - }, - }; + const ground = createFakeGroundHarness(); const layer = createEarthquakesLayer({ source, - renderer: { - create: ({ viewer }) => createEarthquakesCesiumRenderer(null, { viewer }), - }, overlayHost: { setEntries(...args) { events.push(args); @@ -29,9 +16,9 @@ function harness(source) { clearSource() {}, }, }); - layer.init(viewer); - layer.enable(viewer); - return { layer, viewer, sources, events }; + layer.init(null, { engine: ground.engine }); + layer.enable(); + return { layer, ground, events }; } const row = { stableId: 'event-a', @@ -54,27 +41,27 @@ test('late refresh cannot publish after disable, re-enable, or destroy', async ( }); }, }); - const pending = h.layer.update(h.viewer); - h.layer[action](h.viewer); + const pending = h.layer.update(); + h.layer[action](); assert.equal(signal.aborted, true); - if (action === 'disable') h.layer.enable(h.viewer); + if (action === 'disable') h.layer.enable(); resolve([row]); assert.equal(await pending, false); assert.equal(h.layer.getStats().count, 0); assert.equal(h.events.length, 0); - h.layer.destroy(h.viewer); + h.layer.destroy(); } }); -test('two displays own separate data sources and destruction', async () => { +test('two displays own separate ground sets and destruction', async () => { const a = harness({ getSnapshot: async () => [row] }); const b = harness({ getSnapshot: async () => [] }); - await a.layer.update(a.viewer); - await b.layer.update(b.viewer); + await a.layer.update(); + await b.layer.update(); assert.equal(a.layer.getStats().count, 1); assert.equal(b.layer.getStats().count, 0); a.layer.destroy(); - assert.equal(a.sources.length, 0); - assert.equal(b.sources.length, 1); + assert.equal(a.ground.sets(), 0); + assert.equal(b.ground.sets(), 1); b.layer.destroy(); }); test('USGS body completion honors cancellation even with an uncooperative transport', async () => { @@ -107,15 +94,15 @@ test('an observation received between scenes is queryable and draws without a se try { const pending = h.layer.update(); h.layer.detachRenderer(); - assert.equal(h.sources.length, 0); + assert.equal(h.ground.sets(), 0); resolve([row]); assert.equal(await pending, true); const [record] = h.layer.getAnalystRecords(); assert.equal(record.id, row.usgsId); assert.equal(record.lat, row.lat); assert.equal(h.events.length, 0); - h.layer.attachRenderer(h.viewer); - assert.equal(h.sources[0].entities.values.length, 1); + h.layer.attachRenderer(null, { engine: h.ground.engine }); + assert.equal(h.ground.set('earthquakes').items.length, 1); assert.equal(h.events.length, 1); assert.equal(polls, 1); } finally { diff --git a/src/layers/earthquakes/render/cesium.js b/src/layers/earthquakes/render/cesium.js deleted file mode 100644 index 66bf9c0f5..000000000 --- a/src/layers/earthquakes/render/cesium.js +++ /dev/null @@ -1,83 +0,0 @@ -// Native earthquake entities: static CLAMP_TO_GROUND ellipses with the -// layer's properties and analyst snapshot semantics. -import * as Cesium from 'cesium'; - -export function createEarthquakesCesiumRenderer(_engine, { viewer }) { - if (!viewer?.dataSources) - throw new TypeError('Earthquakes require a native viewer'); - const dataSource = new Cesium.CustomDataSource('earthquakes'); - dataSource.show = false; - viewer.dataSources.add(dataSource); - let destroyed = false; - return { - kind: 'cesium', - setVisible(show) { - if (!destroyed) dataSource.show = show !== false; - }, - replace(discs) { - if (destroyed) return; - const entities = discs.map( - ({ - stableId, - usgsId, - position, - baseRadius, - color, - isSignificant, - fillAlpha, - outlineAlpha, - mag, - place, - time, - depthKm, - }) => - new Cesium.Entity({ - id: `earthquake:${stableId}`, - position, - ellipse: { - semiMajorAxis: baseRadius, - semiMinorAxis: baseRadius, - material: new Cesium.ColorMaterialProperty( - color.withAlpha(fillAlpha), - ), - outline: true, - outlineColor: color.withAlpha(outlineAlpha), - outlineWidth: isSignificant ? 3 : 2, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - properties: { usgsId, mag, place, time, depth: depthKm }, - }), - ); - dataSource.entities.removeAll(); - for (const entity of entities) dataSource.entities.add(entity); - }, - records(limit) { - if (destroyed || !dataSource.show) return []; - const now = Cesium.JulianDate.now(); - const rows = []; - for (const entity of dataSource.entities.values) { - if (rows.length >= limit) break; - const position = entity.position?.getValue(now); - const carto = position - ? Cesium.Cartographic.fromCartesian(position) - : null; - const p = entity.properties; - rows.push({ - id: p?.usgsId?.getValue(now) ?? null, - mag: p?.mag?.getValue(now), - place: p?.place?.getValue(now), - time: p?.time?.getValue(now), - depth: p?.depth?.getValue(now), - lat: carto ? Cesium.Math.toDegrees(carto.latitude) : null, - lon: carto ? Cesium.Math.toDegrees(carto.longitude) : null, - }); - } - return rows; - }, - destroy() { - if (destroyed) return; - destroyed = true; - viewer.dataSources.remove(dataSource, true); - }, - }; -} diff --git a/src/layers/earthquakes/render/cesium.test.mjs b/src/layers/earthquakes/render/cesium.test.mjs deleted file mode 100644 index 7bd6c8971..000000000 --- a/src/layers/earthquakes/render/cesium.test.mjs +++ /dev/null @@ -1,162 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; -import * as Cesium from 'cesium'; -import * as geo from '../../../geo/index.js'; -import { createEarthquakesLayer } from '../index.js'; -import { createEarthquakesCesiumRenderer } from './cesium.js'; - -const rows = [ - { - stableId: 'a', - usgsId: 'us-a', - lon: -122.42, - lat: 37.77, - depthKm: 8, - mag: 2.5, - place: 'A', - time: 1000, - }, - { - stableId: 'b', - usgsId: 'us-b', - lon: 30, - lat: 20, - depthKm: 70, - mag: 5, - place: 'B', - time: 2000, - }, - { - stableId: 'c', - usgsId: null, - lon: 150, - lat: -30, - depthKm: 300, - mag: 6, - place: 'C', - time: 3000, - }, -]; -function harness() { - const sources = new Set(); - const viewer = { - dataSources: { - add(source) { - sources.add(source); - }, - remove(source, destroy) { - assert.equal(destroy, true); - return sources.delete(source); - }, - }, - }; - let snapshot = rows; - const layer = createEarthquakesLayer({ - source: { - async getSnapshot() { - if (snapshot instanceof Error) throw snapshot; - return snapshot; - }, - }, - overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, - renderer: { - create: (options) => createEarthquakesCesiumRenderer(null, options), - }, - }); - layer.init(viewer); - layer.enable(); - return { - viewer, - sources, - layer, - setSnapshot(value) { - snapshot = value; - }, - }; -} - -test('extracted native ground discs preserve static axes, depth palette and analyst identity', async () => { - const h = harness(); - try { - assert.equal(await h.layer.update(), true); - const dataSource = [...h.sources][0]; - const now = Cesium.JulianDate.now(); - const colors = [Cesium.Color.RED, Cesium.Color.ORANGE, Cesium.Color.YELLOW]; - dataSource.entities.values.forEach((entity, index) => { - const row = rows[index]; - const ellipse = entity.ellipse; - assert.equal(entity.id, `earthquake:${row.stableId}`); - assert.equal(ellipse.semiMajorAxis.isConstant, true); - assert.equal(ellipse.semiMinorAxis.getValue(now), 2 ** row.mag * 1000); - assert.equal(ellipse.semiMajorAxis.getValue(now), 2 ** row.mag * 1000); - assert.equal( - ellipse.heightReference.getValue(now), - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - assert.equal(ellipse.outlineWidth.getValue(now), row.mag >= 5 ? 3 : 2); - assert.deepEqual( - { ...ellipse.material.color.getValue(now) }, - { ...colors[index].withAlpha(row.mag >= 5 ? 0.4 : 0.3) }, - ); - const expected = Cesium.Cartesian3.fromDegrees(row.lon, row.lat); - assert.deepEqual(entity.position.getValue(now), expected); - assert.deepEqual( - { ...geo.cartesian3.fromDegrees(row.lon, row.lat) }, - { - x: expected.x, - y: expected.y, - z: expected.z, - }, - ); - }); - const records = h.layer.getAnalystRecords(); - assert.deepEqual( - records.map((r) => r.id), - ['us-a', 'us-b', 'QUAKE-0002'], - ); - assert.equal(h.layer.getAnalystRecords(1).length, 1); - assert.equal(h.layer.getAnalystRecords(0).length, 1); - assert.equal(h.layer.getAnalystRecords(Infinity).length, 3); - h.layer.disable(); - assert.deepEqual(h.layer.getAnalystRecords(), []); - assert.equal(dataSource.show, false); - } finally { - h.layer.destroy(); - } - assert.equal(h.sources.size, 0); -}); - -test('failed native refresh retains the last complete geometry and analyst snapshot', async () => { - const h = harness(); - try { - await h.layer.update(); - const source = [...h.sources][0]; - const before = [...source.entities.values]; - const records = h.layer.getAnalystRecords(); - h.setSnapshot(new Error('fixture source unavailable')); - assert.equal(await h.layer.update(), false); - assert.deepEqual(source.entities.values, before); - assert.deepEqual(h.layer.getAnalystRecords(), records); - assert.equal(h.layer.getStats().error, 'fixture source unavailable'); - h.setSnapshot([]); - assert.equal(await h.layer.update(), true); - assert.equal(source.entities.values.length, 0); - assert.equal(h.layer.getStats().error, null); - } finally { - h.layer.destroy(); - } -}); - -test('renderer destroys its owner even when passed another viewer and repeated destroy is safe', () => { - const h = harness(); - const alien = { - dataSources: { - remove() { - assert.fail('must not remove from another viewer'); - }, - }, - }; - h.layer.destroy(alien); - h.layer.destroy(alien); - assert.equal(h.sources.size, 0); -}); diff --git a/src/layers/earthquakes/render/display.js b/src/layers/earthquakes/render/display.js new file mode 100644 index 000000000..e1be4db43 --- /dev/null +++ b/src/layers/earthquakes/render/display.js @@ -0,0 +1,50 @@ +// The earthquake discs on the globe contract (engine.ground), the same on +// every engine: one ground disc per event, of its radius in metres, filled +// in its depth colour at its alpha, picked as `earthquake:${stableId}`. No +// outline: Cesium draws none on a ground-clamped ellipse, so none showed +// on either engine. + +const SET_ID = 'earthquakes'; + +/** What a pick of an earthquake disc returns. */ +export const earthquakePickId = (disc) => `earthquake:${disc.stableId}`; + +/** + * The earthquake discs: `{ setVisible, replace(discs), destroy }`. A disc is + * `{ stableId, lon, lat, baseRadius, color (Rgba), fillAlpha }`; later discs + * draw on top. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createEarthquakesDisplay(engine) { + if (!engine?.ground) + throw new TypeError('The earthquakes display needs a globe engine'); + const set = engine.ground.createSet({ + id: SET_ID, + kind: 'disc', + pickable: true, + }); + set.show(false); + let destroyed = false; + return { + setVisible(show) { + if (!destroyed) set.show(show !== false); + }, + replace(discs) { + if (destroyed) return; + set.replace( + discs.map((disc) => ({ + center: [disc.lon, disc.lat], + radiusM: disc.baseRadius, + fill: disc.color.withAlpha(disc.fillAlpha).rgba(), + pickId: earthquakePickId(disc), + })), + ); + set.commit(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + set.destroy(); + }, + }; +} diff --git a/src/layers/earthquakes/render/display.test.mjs b/src/layers/earthquakes/render/display.test.mjs new file mode 100644 index 000000000..8e5c9bccf --- /dev/null +++ b/src/layers/earthquakes/render/display.test.mjs @@ -0,0 +1,249 @@ +// The earthquake discs on the engine contract, on the fake engine and on the +// Cesium engine (its ground batches over fake primitives): radii, depth +// colours and alphas, no outline, picks, visibility through the layer, +// retained discs across a scene handoff and a failed refresh, and teardown. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { + GROUND_HARNESSES, + createFakeGroundHarness, + instanceColor, +} from '../../../testSupport/groundDisplayEngines.mjs'; +import { createEarthquakesLayer } from '../index.js'; +import { createEarthquakesDisplay } from './display.js'; + +const rows = [ + { + stableId: 'a', + usgsId: 'us-a', + lon: -122.42, + lat: 37.77, + depthKm: 8, + mag: 2.5, + place: 'A', + time: 1000, + }, + { + stableId: 'b', + usgsId: 'us-b', + lon: 30, + lat: 20, + depthKm: 70, + mag: 5, + place: 'B', + time: 2000, + }, + { + stableId: 'c', + usgsId: null, + lon: 150, + lat: -30, + depthKm: 300, + mag: 6, + place: 'C', + time: 3000, + }, +]; +// Red, orange, yellow by depth; 0.4 from magnitude 5, else 0.3. +const FILLS = [ + [1, 0, 0, 0.3], + [1, 165 / 255, 0, 0.4], + [1, 1, 0, 0.4], +]; + +function fixture(makeHarness) { + const h = makeHarness(); + let snapshot = rows; + const layer = createEarthquakesLayer({ + source: { + async getSnapshot() { + if (snapshot instanceof Error) throw snapshot; + return snapshot; + }, + }, + overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} }, + }); + layer.init(null, { engine: h.engine }); + layer.enable(); + return { + ...h, + layer, + setSnapshot(value) { + snapshot = value; + }, + }; +} + +const near = (actual, expected, label, tolerance = 1e-9) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +test('the display needs a globe engine', () => { + assert.throws(() => createEarthquakesDisplay(null), TypeError); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`each event is a ground disc of 2^mag km in its depth colour, without an outline (${name})`, async () => { + const f = fixture(makeHarness); + try { + assert.equal(await f.layer.update(), true); + const set = f.set('earthquakes'); + assert.equal(set.options.kind, 'disc'); + assert.equal(set.options.pickable, true); + assert.equal(set.shown, true); + assert.deepEqual( + set.items.map(({ center, radiusM, pickId, outline }) => ({ + center, + radiusM, + pickId, + outline, + })), + rows.map((row) => ({ + center: [row.lon, row.lat], + radiusM: 2 ** row.mag * 1000, + pickId: `earthquake:${row.stableId}`, + outline: undefined, + })), + ); + set.items.forEach(({ fill }, index) => + fill.forEach((channel, at) => + near(channel, FILLS[index][at], `fill ${index}.${at}`), + ), + ); + if (f.cesium) { + const { fills, lines } = f.primitives('earthquakes'); + assert.equal(fills.length, 1, 'one ground batch'); + assert.equal(lines.length, 0, 'no outline batch'); + const { geometryInstances, classificationType, allowPicking } = + fills[0].options; + assert.equal(classificationType, Cesium.ClassificationType.BOTH); + assert.equal(allowPicking, true); + geometryInstances.forEach((instance, index) => { + const row = rows[index]; + assert.ok(instance.geometry instanceof Cesium.EllipseGeometry); + assert.equal(instance.geometry._semiMajorAxis, 2 ** row.mag * 1000); + assert.equal(instance.geometry._semiMinorAxis, 2 ** row.mag * 1000); + const center = Cesium.Cartesian3.fromDegrees(row.lon, row.lat); + near(instance.geometry._center.x, center.x, 'centre x', 1e-6); + near(instance.geometry._center.y, center.y, 'centre y', 1e-6); + near(instance.geometry._center.z, center.z, 'centre z', 1e-6); + instanceColor(instance).forEach((channel, at) => + near(channel, FILLS[index][at], `colour ${index}.${at}`, 0.5 / 255), + ); + }); + } + assert.deepEqual( + f.layer.getAnalystRecords().map((r) => r.id), + ['us-a', 'us-b', 'QUAKE-0002'], + ); + assert.equal(f.layer.getAnalystRecords(1).length, 1); + assert.equal(f.layer.getAnalystRecords(0).length, 1); + assert.equal(f.layer.getAnalystRecords(Infinity).length, 3); + f.layer.disable(); + assert.deepEqual(f.layer.getAnalystRecords(), []); + assert.equal(f.set('earthquakes').shown, false); + if (f.cesium) assert.equal(f.collectionShown('earthquakes'), false); + } finally { + f.layer.destroy(); + } + assert.equal(f.sets(), 0); + f.destroy(); + }); + + test(`a failed refresh keeps the last discs and records (${name})`, async () => { + const f = fixture(makeHarness); + try { + await f.layer.update(); + const before = f.set('earthquakes'); + const records = f.layer.getAnalystRecords(); + f.setSnapshot(new Error('fixture source unavailable')); + assert.equal(await f.layer.update(), false); + const after = f.set('earthquakes'); + assert.equal(after.commits, before.commits); + assert.equal(after.items, before.items); + assert.deepEqual(f.layer.getAnalystRecords(), records); + assert.equal(f.layer.getStats().error, 'fixture source unavailable'); + f.setSnapshot([]); + assert.equal(await f.layer.update(), true); + assert.equal(f.set('earthquakes').items.length, 0); + if (f.cesium) assert.equal(f.primitives('earthquakes').fills.length, 0); + assert.equal(f.layer.getStats().error, null); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); + + test(`a pick of a disc names its event (${name})`, async () => { + const f = fixture(makeHarness); + try { + await f.layer.update(); + let picked; + if (f.cesium) { + const [fills] = f.primitives('earthquakes').fills; + f.viewer.scene.pick = () => ({ + id: fills.options.geometryInstances[1].id, + primitive: fills, + }); + picked = f.engine.picker.pick({ x: 1, y: 1 }); + } else { + // The fake picks its ground items on the CPU, under the camera. + f.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(30, 20, 200_000), + }); + const { width, height } = f.engine.host.size(); + picked = f.engine.picker.pick({ x: width / 2, y: height / 2 }); + } + assert.equal(picked.id, 'earthquake:b'); + assert.equal(picked.owner, 'earthquakes'); + assert.equal(picked.instance, 1); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); + + test(`discs fetched between scenes draw on the next one without a second fetch (${name})`, async () => { + const f = fixture(makeHarness); + try { + await f.layer.update(); + f.layer.detachRenderer(); + assert.equal(f.sets(), 0); + const next = makeHarness(); + f.layer.attachRenderer(null, { engine: next.engine }); + assert.equal(next.set('earthquakes').items.length, 3); + assert.equal(next.set('earthquakes').shown, true); + f.layer.destroy(); + assert.equal(next.sets(), 0); + next.destroy(); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); +} + +test('the display ignores calls after destroy and destroys once', () => { + const h = createFakeGroundHarness(); + const display = createEarthquakesDisplay(h.engine); + assert.equal(h.set('earthquakes').shown, false, 'hidden until shown'); + display.setVisible(true); + assert.equal(h.set('earthquakes').shown, true); + display.setVisible(false); + assert.equal(h.set('earthquakes').shown, false); + display.destroy(); + display.destroy(); + assert.equal(h.sets(), 0); + display.setVisible(true); + display.replace([]); + // A new display may take the set id again. + createEarthquakesDisplay(h.engine).destroy(); + h.destroy(); +}); diff --git a/src/layers/earthquakes/render/ground.js b/src/layers/earthquakes/render/ground.js deleted file mode 100644 index efef02e20..000000000 --- a/src/layers/earthquakes/render/ground.js +++ /dev/null @@ -1,69 +0,0 @@ -// A prototype earthquake renderer on engine.ground, for every engine: each -// disc a ground disc of its radius in the Cesium renderer's fill, picked as -// `earthquake:${stableId}`. Without the outline cesium.js asks for: Cesium -// draws no outline on a ground-clamped ellipse, nor does navara.js, so none -// shows today. Not wired into the layer: scripts/qa-ground-prototypes.mjs -// draws it next to cesium.js to compare. -import * as geo from '../../../geo/index.js'; - -const rgba = (color, alpha) => [color.red, color.green, color.blue, alpha]; - -export function createEarthquakesGroundRenderer(engine) { - const set = engine.ground.createSet({ - id: 'earthquakes', - kind: 'disc', - pickable: true, - }); - set.show(false); - let visible = false; - let rows = []; - let destroyed = false; - return { - kind: 'ground', - setVisible(show) { - if (destroyed) return; - visible = show !== false; - set.show(visible); - }, - replace(discs) { - if (destroyed) return; - const items = []; - const nextRows = []; - for (const disc of discs) { - const place = geo.cartographic.fromCartesian(disc.position); - if (!place) continue; - const lon = geo.math.toDegrees(place.longitude); - const lat = geo.math.toDegrees(place.latitude); - items.push({ - center: [lon, lat], - radiusM: disc.baseRadius, - fill: rgba(disc.color, disc.fillAlpha), - pickId: `earthquake:${disc.stableId}`, - }); - nextRows.push({ - id: disc.usgsId ?? null, - mag: disc.mag, - place: disc.place, - time: disc.time, - depth: disc.depthKm, - lat, - lon, - }); - } - set.replace(items); - set.commit(); - rows = nextRows; - }, - records(limit) { - return !destroyed && visible - ? rows.slice(0, limit).map((row) => ({ ...row })) - : []; - }, - destroy() { - if (destroyed) return; - destroyed = true; - rows = []; - set.destroy(); - }, - }; -} diff --git a/src/layers/earthquakes/render/navara.js b/src/layers/earthquakes/render/navara.js deleted file mode 100644 index ab0c752ed..000000000 --- a/src/layers/earthquakes/render/navara.js +++ /dev/null @@ -1,309 +0,0 @@ -// Classify the real visible terrain/Google-tiles surface in a public -// Navara post-effect. Native ground colour blends in display space; doing -// it explicitly preserves translucent colours and overlap order. -import { Uniform } from 'three'; -import { - createGroundTexture as texture, - createGroundStorage as storage, - groundSurfaceUniforms, - updateGroundSurface, -} from '../../shared/render/navaraGroundSurface.js'; -import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js'; -import * as geo from '../../../geo/index.js'; -import { - DISC_EDGES, - discFrame, - viewFrame, - discClipBounds, -} from './navaraGeometry.js'; -import { createDiscGrid, GRID_COLS, GRID_ROWS } from './navaraGrid.js'; -const KEY = 'gevEarthquakeGround'; -const fragmentShader = /* glsl */ ` -uniform sampler2D tAllDepth; -uniform sampler2D tSurfaceDepth; -uniform sampler2D tEdges; -uniform sampler2D tFrames; -uniform sampler2D tGrid; -uniform sampler2D tCandidates; -uniform vec2 uEdgeSize; -uniform vec2 uFrameSize; -uniform vec2 uCandidateSize; -uniform mat4 uInverseProjection; -uniform float uLogDepthFC; -vec4 atSlot(sampler2D image,vec2 size,float slot) { - return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size); -} -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((c+0.055)/1.055,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)); - 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); - // Derivatives must be evaluated before divergent cell/feature branches. - // Inside the candidate loop adjacent pixels may be testing different IDs. - vec3 pointDx=dFdx(point),pointDy=dFdy(point); - if(depth>=1.0) return; - vec2 cell=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0)); - vec2 range=texture2D(tGrid,(cell+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy; - if(range.y<0.5) return; - float surface=texture2D(tSurfaceDepth,uv).r; - // Public MRT depth excludes forward custom meshes, which are not ground. - if(depthnorth.w) continue; - float coverage=1.0; - // A convex boundary intersects a ray from its centre on exactly one - // edge. For large discs, binary-search that edge and its two neighbours; - // all other edges lie beyond the one-pixel AA band. Small/subpixel discs - // retain the full supporting-plane coverage calculation. - bool large=center.w>128.0*max(length(pointDx),length(pointDy)); - int first=0; - if(large) { - float angle=mod(atan(local.x,-local.y)-up.w+6.28318530718,6.28318530718); - int low=0,high=${DISC_EDGES - 1}; - for(int step=0;step<6;step++) { - int middle=(low+high+1)/2; - float start=atSlot(tEdges,uEdgeSize,id*${DISC_EDGES}.0+float(middle)).w; - if(angle>=start) low=middle;else high=middle-1; - } - first=low-1; - } - int count=large?3:${DISC_EDGES}; - for(int k=0;k edges.data.set(frame.edges, i * DISC_EDGES * 4)); - const state = { - count: discs.length, - edges, - packed, - gridTexture, - get candidates() { - return candidates; - }, - update(camera, ctx) { - const input = ctx.getInputBuffer(); - const view = camera.matrixWorldInverse.elements; - const projection = camera.projectionMatrix.elements; - let changed = input.width !== lastWidth || input.height !== lastHeight; - for (let i = 0; i < 16 && !changed; i++) - changed = - view[i] !== lastView[i] || projection[i] !== lastProjection[i]; - // All features are static. Depth still refreshes each frame, but their - // screen index/coordinates only change with the camera or viewport. - if (!changed) return; - lastView.set(view); - lastProjection.set(projection); - lastWidth = input.width; - lastHeight = input.height; - frames.forEach((frame, i) => { - viewFrame(frame, camera.matrixWorldInverse, scratch); - discClipBounds(frame, scratch, camera.projectionMatrix, bounds); - if (bounds[0] <= 1) { - bounds[0] -= 2 / Math.max(1, input.width); - bounds[2] += 2 / Math.max(1, input.width); - bounds[1] -= 2 / Math.max(1, input.height); - bounds[3] += 2 / Math.max(1, input.height); - } - grid.bounds.set(bounds, i * 4); - const offset = i * 20; - packed.data.set( - [scratch[0], scratch[1], scratch[2], frame.radius], - offset, - ); - packed.data.set( - [scratch[3], scratch[4], scratch[5], frame.floor], - offset + 4, - ); - packed.data.set( - [scratch[6], scratch[7], scratch[8], frame.ceiling], - offset + 8, - ); - packed.data.set( - [scratch[9], scratch[10], scratch[11], frame.phase], - offset + 12, - ); - const d = discs[i]; - packed.data.set( - [d.color.red, d.color.green, d.color.blue, d.fillAlpha], - offset + 16, - ); - }); - const total = grid.pack(); - if (candidates.data.length / 4 < total) { - candidates.texture.dispose(); - candidates = storage(Math.max(total, (2 * candidates.data.length) / 4)); - } - for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i]; - for (let i = 0; i < grid.counts.length; i++) { - gridData[i * 4] = grid.offsets[i]; - gridData[i * 4 + 1] = grid.counts[i]; - } - packed.texture.needsUpdate = true; - gridTexture.needsUpdate = true; - candidates.texture.needsUpdate = true; - }, - dispose() { - edges.texture.dispose(); - packed.texture.dispose(); - gridTexture.dispose(); - candidates.texture.dispose(); - }, - }; - return state; -} - -const groundEffect = defineGroundEffect({ - key: KEY, - name: 'GevEarthquakeGround', - fragmentShader, - insertAfter: [ - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'toneMapping', - ], - insertBefore: [ - 'gevFirmsGround', - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ], - uniforms: () => [ - ...groundSurfaceUniforms(), - ['tEdges', new Uniform(null)], - ['tFrames', new Uniform(null)], - ['tGrid', new Uniform(null)], - ['tCandidates', new Uniform(null)], - ['uEdgeSize', new Uniform([1, 1])], - ['uFrameSize', new Uniform([1, 1])], - ['uCandidateSize', new Uniform([1, 1])], - ], - update() { - const state = this.owner.state, - camera = this.view.camera.raw, - mrt = this.find('mrt'); - if (!this.effect || !camera || !state) return; - updateGroundSurface(this.effect, camera, this.ctx, mrt); - state.update(camera, this.ctx); - const u = this.effect.uniforms; - u.get('tEdges').value = state.edges.texture; - u.get('uEdgeSize').value = [state.edges.width, state.edges.height]; - u.get('tFrames').value = state.packed.texture; - u.get('uFrameSize').value = [state.packed.width, state.packed.height]; - u.get('tGrid').value = state.gridTexture; - u.get('tCandidates').value = state.candidates.texture; - u.get('uCandidateSize').value = [ - state.candidates.width, - state.candidates.height, - ]; - }, -}); - -export function createEarthquakesNavaraRenderer( - engine, - { view, EffectDesc, NavaraEffect } = {}, -) { - groundEffect.register(view, { EffectDesc, NavaraEffect }); - let destroyed = false, - visible = false, - rows = [], - handle = null; - const owner = { state: buildState([]) }; - const sync = () => { - const show = visible && owner.state.count > 0; - if (show && !handle) - handle = view.addEffect({ [KEY]: { owner }, visible: true }); - else handle?.update({ visible: show }); - }; - return { - kind: 'navara', - setVisible(show) { - if (destroyed) return; - visible = show !== false; - sync(); - engine.render.request('earthquake-visibility'); - }, - replace(discs) { - if (destroyed) return; - const nextRows = discs.map((d) => { - const place = geo.cartographic.fromCartesian(d.position); - return { - id: d.usgsId ?? null, - mag: d.mag, - place: d.place, - time: d.time, - depth: d.depthKm, - lat: place ? geo.math.toDegrees(place.latitude) : null, - lon: place ? geo.math.toDegrees(place.longitude) : null, - }; - }); - const next = buildState(discs), - previous = owner.state; - owner.state = next; - rows = nextRows; - sync(); - previous.dispose(); - engine.render.request('earthquake-refresh'); - }, - records(limit) { - return !destroyed && visible - ? rows.slice(0, limit).map((row) => ({ ...row })) - : []; - }, - destroy() { - if (destroyed) return; - destroyed = true; - visible = false; - rows = []; - handle?.delete(); - owner.state.dispose(); - engine.render.request('earthquake-destroy'); - }, - }; -} diff --git a/src/layers/earthquakes/render/navara.test.mjs b/src/layers/earthquakes/render/navara.test.mjs deleted file mode 100644 index 32841f4d6..000000000 --- a/src/layers/earthquakes/render/navara.test.mjs +++ /dev/null @@ -1,148 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as geo from '../../../geo/index.js'; -import { PerspectiveCamera } from 'three'; -import { constantRgba } from '../../shared/color.js'; -import { createEarthquakesNavaraRenderer } from './navara.js'; - -test('Navara earthquake effect is lazy, releases replaced textures and detaches on repeated destroy', () => { - const registered = [], - effects = [], - requests = []; - const view = { - camera: {}, - registerEffect(key, desc) { - registered.push([key, desc]); - }, - addEffect(config) { - const h = { - config, - updates: [], - deleted: 0, - update(value) { - this.updates.push(value); - }, - delete() { - this.deleted++; - }, - }; - effects.push(h); - return h; - }, - }; - const engine = { - render: { - request(reason) { - requests.push(reason); - }, - }, - }; - const renderer = createEarthquakesNavaraRenderer(engine, { - view, - EffectDesc: class {}, - NavaraEffect: class {}, - }); - assert.equal(effects.length, 0); - renderer.setVisible(true); - assert.equal(effects.length, 0); - const disc = { - position: geo.cartesian3.fromDegrees(30, 20), - baseRadius: 32000, - color: constantRgba(1, 0, 0), - fillAlpha: 0.4, - usgsId: 'us-fixture', - mag: 5, - place: 'Fixture', - time: 1000, - depthKm: 8, - }; - renderer.replace([disc]); - assert.equal(effects.length, 1); - const owner = effects[0].config.gevEarthquakeGround.owner; - const old = owner.state; - let disposed = 0; - for (const t of [ - old.edges.texture, - old.packed.texture, - old.gridTexture, - old.candidates.texture, - ]) - t.addEventListener('dispose', () => disposed++); - const record = renderer.records(1)[0]; - assert.equal(record.id, 'us-fixture'); - record.id = 'mutated'; - assert.equal(renderer.records(1)[0].id, 'us-fixture'); - renderer.replace([disc]); - assert.equal(disposed, 4); - assert.notEqual(owner.state, old); - renderer.setVisible(false); - assert.deepEqual(renderer.records(1), []); - assert.deepEqual(effects[0].updates.at(-1), { visible: false }); - renderer.destroy(); - renderer.destroy(); - assert.equal(effects[0].deleted, 1); - renderer.replace([disc]); - assert.equal(effects.length, 1); - const second = createEarthquakesNavaraRenderer(engine, { - view, - EffectDesc: class {}, - NavaraEffect: class {}, - }); - second.destroy(); - assert.equal(registered.length, 1); -}); - -test('static screen data uploads once and invalidates for camera, projection and viewport changes', () => { - let owner; - const view = { - camera: {}, - registerEffect() {}, - addEffect(config) { - owner = config.gevEarthquakeGround.owner; - return { update() {}, delete() {} }; - }, - }; - const renderer = createEarthquakesNavaraRenderer( - { render: { request() {} } }, - { view, EffectDesc: class {}, NavaraEffect: class {} }, - ); - renderer.setVisible(true); - const position = geo.cartesian3.fromDegrees(30, 20); - renderer.replace([ - { - position, - baseRadius: 32000, - color: constantRgba(1, 0, 0), - fillAlpha: 0.4, - }, - ]); - const camera = new PerspectiveCamera(60, 1.6, 1, 30000000); - camera.position.set(position.x * 1.1, position.y * 1.1, position.z * 1.1); - camera.lookAt(position.x, position.y, position.z); - camera.updateMatrixWorld(true); - const viewport = { width: 1280, height: 800 }; - const context = { getInputBuffer: () => viewport }; - const version = () => owner.state.packed.texture.version; - owner.state.update(camera, context); - let previous = version(); - assert.ok( - owner.state.candidates.width > 1, - 'positive visible feature occupies grid cells', - ); - for (let i = 0; i < 100; i++) owner.state.update(camera, context); - assert.equal(version(), previous, 'unchanged frames do not upload geometry'); - camera.position.x += 10; - camera.updateMatrixWorld(true); - owner.state.update(camera, context); - assert.equal(version(), ++previous); - camera.fov = 45; - camera.updateProjectionMatrix(); - owner.state.update(camera, context); - assert.equal(version(), ++previous); - viewport.width = 1600; - owner.state.update(camera, context); - assert.equal(version(), ++previous); - owner.state.update(camera, context); - assert.equal(version(), previous); - renderer.destroy(); -}); diff --git a/src/layers/earthquakes/render/navaraGeometry.js b/src/layers/earthquakes/render/navaraGeometry.js deleted file mode 100644 index 61e4f0490..000000000 --- a/src/layers/earthquakes/render/navaraGeometry.js +++ /dev/null @@ -1,84 +0,0 @@ -// Native default circles have 48 boundary samples (1-degree granularity, -// multiplied by eight by EllipseGeometryLibrary). Reproduce the boundary -// mathematically, then classify the visible surface inside its convex sides. -import * as geo from '../../../geo/index.js'; - -export const DISC_EDGES = 48; -const ellipsoid = geo.ellipsoid.WGS84; -export function discFrame(disc) { - const center = { ...disc.position }; - const magnitude = Math.hypot(center.x, center.y, center.z); - const unit = geo.cartesian3.normalize(center, geo.cartesian3.create()); - const east = geo.cartesian3.create(-center.y, center.x, 0); - if (geo.cartesian3.magnitude(east) === 0) east.x = 1; - geo.cartesian3.normalize(east, east); - const sphericalNorth = geo.cartesian3.cross( - unit, - east, - geo.cartesian3.create(), - ); - const up = ellipsoid.geodeticSurfaceNormal(center, geo.cartesian3.create()); - const north = geo.cartesian3.cross(up, east, geo.cartesian3.create()); - const angle = disc.baseRadius / magnitude; - const cosine = Math.cos(angle), - sine = Math.sin(angle); - const boundary = []; - for (let i = 0; i < DISC_EDGES; i++) { - const theta = (i * 2 * Math.PI) / DISC_EDGES; - const a = Math.sin(theta) * sine * magnitude; - const b = -Math.cos(theta) * sine * magnitude; - const p = geo.cartesian3.create( - center.x * cosine + east.x * a + sphericalNorth.x * b, - center.y * cosine + east.y * a + sphericalNorth.y * b, - center.z * cosine + east.z * a + sphericalNorth.z * b, - ); - ellipsoid.scaleToGeodeticSurface(p, p); - const dx = p.x - center.x, - dy = p.y - center.y, - dz = p.z - center.z; - boundary.push({ - x: dx * east.x + dy * east.y + dz * east.z, - y: dx * north.x + dy * north.y + dz * north.z, - world: p, - }); - } - const phase = Math.atan2(boundary[0].x, -boundary[0].y); - const edges = new Float32Array(DISC_EDGES * 4); - for (let i = 0; i < DISC_EDGES; i++) { - const a = boundary[i], - b = boundary[(i + 1) % DISC_EDGES]; - const length = Math.hypot(b.x - a.x, b.y - a.y); - let nx = (b.y - a.y) / length, - ny = -(b.x - a.x) / length; - let offset = nx * a.x + ny * a.y; - if (offset < 0) { - nx = -nx; - ny = -ny; - offset = -offset; - } - let angle = i === 0 ? 0 : Math.atan2(a.x, -a.y) - phase; - if (angle < 0) angle += 2 * Math.PI; - edges.set([nx, ny, offset, angle], i * 4); - } - const r = disc.baseRadius; - return { - center, - phase, - east, - north, - up, - edges, - boundary, - radius: r, - // At orbital LOD the rendered globe mesh can lie well below the - // ellipsoid (measured -26km over Alaska). Native GroundPrimitive uses - // -100km as its conservative default floor; do not clip that surface. - floor: -100000 - ((r * r) / (2 * ellipsoid.minimumRadius)) * 1.1, - ceiling: 12000, - }; -} - -export { - viewFrame, - groundClipBounds as discClipBounds, -} from '../../shared/render/navaraGroundGeometry.js'; diff --git a/src/layers/earthquakes/render/navaraGeometry.test.mjs b/src/layers/earthquakes/render/navaraGeometry.test.mjs deleted file mode 100644 index 032ca2c58..000000000 --- a/src/layers/earthquakes/render/navaraGeometry.test.mjs +++ /dev/null @@ -1,237 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import * as Cesium from 'cesium'; -import { PerspectiveCamera, Vector3 } from 'three'; -import * as geo from '../../../geo/index.js'; -import { - DISC_EDGES, - discFrame, - viewFrame, - discClipBounds, -} from './navaraGeometry.js'; - -test('Navara classifier boundary matches public native ground-circle geometry around the world', () => { - for (const [lon, lat] of [ - [-122.42, 37.77], - [0, 0], - [179.95, 85], - [-179.95, -85], - [23, 90], - ]) { - for (const mag of [2.5, 5, 7, 9, 10]) { - const position = geo.cartesian3.fromDegrees(lon, lat); - const radius = 2 ** mag * 1000; - const frame = discFrame({ position, baseRadius: radius }); - const native = Cesium.EllipseOutlineGeometry.createGeometry( - new Cesium.EllipseOutlineGeometry({ - center: Cesium.Cartesian3.fromDegrees(lon, lat), - semiMajorAxis: radius, - semiMinorAxis: radius, - }), - ).attributes.position.values; - assert.equal(native.length, DISC_EDGES * 3); - for (const p of frame.boundary) { - let best = Infinity; - for (let i = 0; i < native.length; i += 3) - best = Math.min( - best, - Math.hypot( - p.world.x - native[i], - p.world.y - native[i + 1], - p.world.z - native[i + 2], - ), - ); - assert.ok(best < 1e-7, `boundary ${lon}/${lat}/M${mag}: ${best}m`); - for (let i = 0; i < DISC_EDGES; i++) - assert.ok(frame.edges[i * 4 + 2] > 0); - } - } - } -}); - -test('disc screen bounds contain every volume corner and cover a camera crossing the volume', () => { - const position = geo.cartesian3.fromDegrees(-122.42, 37.77); - const frame = discFrame({ position, baseRadius: 32000 }); - const camera = new PerspectiveCamera(60, 1.6, 1, 30000000); - camera.position.copy( - new Vector3(position.x, position.y, position.z).multiplyScalar(1.1), - ); - camera.lookAt(position.x, position.y, position.z); - camera.updateMatrixWorld(true); - const view = viewFrame( - frame, - camera.matrixWorldInverse, - new Float64Array(12), - ); - const bounds = discClipBounds( - frame, - view, - camera.projectionMatrix, - new Float64Array(4), - ); - assert.ok(bounds[0] < bounds[2] && bounds[1] < bounds[3]); - for (let i = 0; i < 8; i++) { - const p = new Vector3(position.x, position.y, position.z); - p.addScaledVector( - new Vector3(frame.east.x, frame.east.y, frame.east.z), - (i & 1 ? 1 : -1) * frame.radius, - ); - p.addScaledVector( - new Vector3(frame.north.x, frame.north.y, frame.north.z), - (i & 2 ? 1 : -1) * frame.radius, - ); - p.addScaledVector( - new Vector3(frame.up.x, frame.up.y, frame.up.z), - i & 4 ? frame.ceiling : frame.floor, - ); - p.project(camera); - assert.ok( - p.x >= bounds[0] - 1e-12 && - p.x <= bounds[2] + 1e-12 && - p.y >= bounds[1] - 1e-12 && - p.y <= bounds[3] + 1e-12, - ); - } - const inside = new Float64Array([0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]); - assert.deepEqual( - [ - ...discClipBounds( - frame, - inside, - camera.projectionMatrix, - new Float64Array(4), - ), - ], - [-1, -1, 1, 1], - ); - inside[2] = 1000000; - assert.deepEqual( - [ - ...discClipBounds( - frame, - inside, - camera.projectionMatrix, - new Float64Array(4), - ), - ], - [2, 2, 2, 2], - ); -}); - -test('classification volume includes the recorded far-LOD Alaska surface and excludes the far side of Earth', () => { - const position = geo.cartesian3.fromDegrees(-149.92, 61); - const frame = discFrame({ position, baseRadius: 2 ** 5.5 * 1000 }); - const surface = { - x: -2674232.841211094, - y: -1508471.7568704002, - z: 5542920.923233327, - }; - const height = - (surface.x - position.x) * frame.up.x + - (surface.y - position.y) * frame.up.y + - (surface.z - position.z) * frame.up.z; - assert.ok( - height < -20000, - 'recorded public picker sample is below the previous floor', - ); - assert.ok(height >= frame.floor && height <= frame.ceiling); - const farSideHeight = - -2 * - (position.x * frame.up.x + - position.y * frame.up.y + - position.z * frame.up.z); - assert.ok( - farSideHeight < frame.floor, - 'opposite hemisphere remains outside the volume', - ); -}); - -test('large-disc sector search preserves full 48-plane pixel coverage, including corners', () => { - const coverage = (frame, point, dx, dy, indices) => { - let result = 1; - for (const i of indices) { - const e = frame.edges.subarray(i * 4, i * 4 + 3); - const width = - Math.abs(e[0] * dx[0] + e[1] * dx[1]) + - Math.abs(e[0] * dy[0] + e[1] * dy[1]); - const distance = e[2] - e[0] * point[0] - e[1] * point[1]; - result = Math.min( - result, - Math.max(0, Math.min(1, distance / Math.max(width, 0.001) + 0.5)), - ); - } - return result; - }; - const all = Array.from({ length: DISC_EDGES }, (_, i) => i); - for (const [lon, lat] of [ - [-122.42, 37.77], - [0, 0], - [179.95, 85], - [-179.95, -85], - [23, 90], - ]) { - for (const mag of [2.5, 5, 7, 9, 10]) { - const frame = discFrame({ - position: geo.cartesian3.fromDegrees(lon, lat), - baseRadius: 2 ** mag * 1000, - }); - const angles = [ - ...frame.boundary.map((p) => Math.atan2(p.x, -p.y)), - ...Array.from({ length: 128 }, (_, i) => (i * Math.PI * 2) / 128), - ]; - for (let i = 1; i < DISC_EDGES; i++) - assert.ok(frame.edges[i * 4 + 3] > frame.edges[(i - 1) * 4 + 3]); - for (const angle of angles) { - const direction = [Math.sin(angle), -Math.cos(angle)]; - let boundary = Infinity; - for (const i of all) { - const dot = - frame.edges[i * 4] * direction[0] + - frame.edges[i * 4 + 1] * direction[1]; - if (dot > 0) - boundary = Math.min(boundary, frame.edges[i * 4 + 2] / dot); - } - const theta = - (((angle - frame.phase) % (2 * Math.PI)) + 2 * Math.PI) % - (2 * Math.PI); - let low = 0, - high = DISC_EDGES - 1; - for (let step = 0; step < 6; step++) { - const middle = Math.floor((low + high + 1) / 2); - if (theta >= frame.edges[middle * 4 + 3]) low = middle; - else high = middle - 1; - } - const adjacent = [(low + 47) % 48, low, (low + 1) % 48]; - for (const divisor of [128.01, 512]) { - const pixel = frame.radius / divisor; - for (const offset of [-1.2, -0.4, 0, 0.4, 1.2]) { - const point = direction.map( - (value) => value * (boundary + offset * pixel), - ); - for (const [dx, dy] of [ - [ - [pixel, 0], - [0, pixel], - ], - [ - [pixel * 0.7, pixel * 0.7], - [-pixel * 0.7, pixel * 0.7], - ], - [ - [pixel, 0], - [pixel * 0.2, pixel * 0.1], - ], - ]) { - const expected = coverage(frame, point, dx, dy, all); - const actual = coverage(frame, point, dx, dy, adjacent); - assert.ok( - Math.abs(actual - expected) < 1e-7, - `coverage ${lon}/${lat}/M${mag}: ${actual} != ${expected}`, - ); - } - } - } - } - } - } -}); diff --git a/src/layers/earthquakes/render/navaraGrid.js b/src/layers/earthquakes/render/navaraGrid.js deleted file mode 100644 index eee3fc328..000000000 --- a/src/layers/earthquakes/render/navaraGrid.js +++ /dev/null @@ -1,5 +0,0 @@ -export { - GRID_COLS, - GRID_ROWS, - createGroundGrid as createDiscGrid, -} from '../../shared/render/navaraGroundGrid.js'; diff --git a/src/layers/earthquakes/render/navaraGrid.test.mjs b/src/layers/earthquakes/render/navaraGrid.test.mjs deleted file mode 100644 index b9c240541..000000000 --- a/src/layers/earthquakes/render/navaraGrid.test.mjs +++ /dev/null @@ -1,38 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createDiscGrid, GRID_COLS, GRID_ROWS } from './navaraGrid.js'; - -test('screen grid retains every overlapping feature in source order without a cell cap', () => { - const grid = createDiscGrid(513); - for (let i = 0; i < 513; i++) grid.bounds.set([-1, -1, 1, 1], i * 4); - assert.equal(grid.pack(), 513 * GRID_COLS * GRID_ROWS); - for (let i = 0; i < grid.counts.length; i++) { - assert.equal(grid.counts[i], 513); - assert.deepEqual( - [...grid.indices.subarray(grid.offsets[i], grid.offsets[i] + 513)], - Array.from({ length: 513 }, (_, i) => i), - ); - } - grid.bounds.fill(2); - assert.equal(grid.pack(), 0); - assert.equal( - grid.counts.reduce((a, b) => a + b, 0), - 0, - ); -}); - -test('screen grid excludes off-screen/behind volumes and conservatively includes border cells', () => { - const grid = createDiscGrid(4); - grid.bounds.set([ - -1, -1, -0.9, -0.9, 2, 2, 2, 2, -2, -2, -1.1, -1.1, 0, 0, 1, 1, - ]); - grid.pack(); - assert.equal(grid.counts[0], 1); - assert.equal(grid.indices[grid.offsets[0]], 0); - const last = grid.counts.length - 1; - assert.equal(grid.counts[last], 1); - assert.equal(grid.indices[grid.offsets[last]], 3); - for (let i = 0; i < grid.counts.length; i++) - for (let k = 0; k < grid.counts[i]; k++) - assert.ok([0, 3].includes(grid.indices[grid.offsets[i] + k])); -}); diff --git a/src/layers/firms/cards.js b/src/layers/firms/cards.js index 05f3066d8..50faa9fc8 100644 --- a/src/layers/firms/cards.js +++ b/src/layers/firms/cards.js @@ -18,8 +18,8 @@ export function createCards({ * Rebuild the card entries for the shared world-overlay host: the * selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS * ambient cards picked by a greedy screen-space declutter — walk the - * priority-ranked candidates, project each with - * SceneTransforms.worldToWindowCoordinates, and accept only candidates + * priority-ranked candidates, project each through the renderer + * (engine.projector.worldToWindow), and accept only candidates * ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose * projection fails are dropped silently. SELECTION runs here, on LOD/ * viewport rebuilds and camera moveEnd — never per frame; RENDERING is @@ -28,17 +28,15 @@ export function createCards({ */ function rebuildAmbientLabels() { - if (!layerState._viewer) return; + if (!layerState._renderer) return; const scene = layerState._viewer.scene; const project = (position, result) => - layerState._renderer - ? layerState._renderer.project(position, result) - : config.projectToWindow(scene, position, result); + layerState._renderer.project(position, result); const now = Date.now(); const entries = []; /** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */ const accepted = []; - // worldToWindowCoordinates happily projects points on the FAR side of the + // The window projection happily projects points on the FAR side of the // planet, so without this the ≤18 ambient slots get spent on fires the // overlay host will then horizon-cull at paint time (`horizonCull: true` // in applyFirmsOverlayPolicy) — near-side fires silently lose their cards. diff --git a/src/layers/firms/edgeCases.test.mjs b/src/layers/firms/edgeCases.test.mjs index adfd39584..be8449d15 100644 --- a/src/layers/firms/edgeCases.test.mjs +++ b/src/layers/firms/edgeCases.test.mjs @@ -3,8 +3,8 @@ import assert from 'node:assert/strict'; import * as geo from '../../geo/index.js'; import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createFirmsHeatmapLayer } from './index.js'; -import { createFirmsNavaraRenderer } from './render/navara.js'; -import { unregisterSpriteCollection } from '../../data/spriteOrder.js'; +import { createFirmsDisplay } from './render/display.js'; +import { createFirmsHeatDrape } from './render/navaraHeat.js'; const settle = async () => { for (let i = 0; i < 5; i++) @@ -88,6 +88,8 @@ function fixture() { EffectDesc: class {}, NavaraEffect: class {}, }; + // The heatmap draws through the Navara backend on this view. + engine.extensions = { navara: native }; const noop = () => {}; const layer = createFirmsHeatmapLayer({ id: 'local-firms', @@ -116,7 +118,7 @@ function fixture() { overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop }, renderer: { create: ({ engine: target, id }) => - createFirmsNavaraRenderer(target, { ...native, id }), + createFirmsDisplay(target, { id, navaraHeat: createFirmsHeatDrape }), }, }); layer.init(null, { engine }); @@ -156,7 +158,6 @@ function fixture() { }, cleanup() { layer.destroy(); - unregisterSpriteCollection('local-firms'); performance.now = realNow; restore(); }, diff --git a/src/layers/firms/lifecycle.js b/src/layers/firms/lifecycle.js index 7caf13171..ebd9904cb 100644 --- a/src/layers/firms/lifecycle.js +++ b/src/layers/firms/lifecycle.js @@ -11,8 +11,8 @@ export function createLifecycle({ const { clearSelectedEntityContextForLayer } = services.context; const { id, overlayHost } = config; - // The heatmap is draped on the renderer's globe (Cesium only); it goes - // with the renderer and is built again on the next one that can drape it. + // The heatmap is draped on the renderer's globe; it goes with the renderer + // and is built again on the next one. function releaseHeat() { clearTimeout(layerState._heatRebuildTimer); layerState._heatRebuildTimer = null; @@ -26,7 +26,8 @@ export function createLifecycle({ /** * Shell services: `imageryHost()` resolves where draped imagery goes on * the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The - * heatmap moves with it; without a host it drapes on viewer.imageryLayers. + * heatmap moves with it; without a host it drapes on the globe's own + * imagery (render/heatField.js). * @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services */ attachShellServices(services) { @@ -40,36 +41,33 @@ export function createLifecycle({ init(viewer, options) { this.attachRenderer(viewer, options); }, + /** Draws on `engine` (the scene's globe engine); without one, nothing. */ attachRenderer( - viewer, + _viewer, { engine = null, waitForCameraRestore = false } = {}, ) { if (layerState._destroyed) return; - if (layerState._renderer) return; - layerState._renderer = config.renderer.create({ - viewer, - engine, - id, - services, - screenSpaceEventHandlerFactory: config.screenSpaceEventHandlerFactory, - }); + if (layerState._renderer || !engine) return; + layerState._renderer = config.renderer.create({ engine, id, services }); + if (!layerState._renderer) return; layerState._viewer = layerState._renderer.view; layerState._dataSource = layerState._renderer.dataSource; layerState._renderer.setVisible(false); layerState._restoringViewport = waitForCameraRestore; - if (layerState._enabled && !waitForCameraRestore) { - this.showRenderer(); - layerState._renderer.onEnable(); - } + if (layerState._enabled && !waitForCameraRestore) this.showRenderer(); + // Enabled before a renderer: the load enable() could not start. + if ( + layerState._enabled && + !layerState._fires.length && + !layerState._loading + ) + void components.ingestion.loadHeatmap(); }, resumeViewport() { if (!layerState._restoringViewport) return; layerState._restoringViewport = false; - if (layerState._enabled && layerState._renderer) { - this.showRenderer(); - layerState._renderer.onEnable(); - } + if (layerState._enabled && layerState._renderer) this.showRenderer(); }, detachRenderer() { @@ -77,7 +75,7 @@ export function createLifecycle({ components.viewport.removeMoveEndWatcher(); components.viewport.removeLodWatcher(); unregisterPickOwner(id); - components.rendering.removeDetectionCollections(layerState._viewer); + components.rendering.removeDetectionCollections(); releaseHeat(); layerState._renderer?.destroy(); layerState._renderer = layerState._viewer = layerState._dataSource = null; @@ -112,16 +110,13 @@ export function createLifecycle({ components.rendering.renderCurrentLod(true); }, - async enable(viewer) { + async enable() { if (layerState._destroyed) return; - if (!layerState._dataSource) this.init(viewer); layerState._enabled = true; - const renderer = layerState._renderer; + if (!layerState._renderer) return; this.showRenderer(); if (!layerState._fires.length && !layerState._loading) await components.ingestion.loadHeatmap(); - if (layerState._enabled && layerState._renderer === renderer) - renderer.onEnable(); }, disable() { @@ -141,7 +136,7 @@ export function createLifecycle({ components.viewport.removeLodWatcher(); }, - destroy(viewer = layerState._viewer) { + destroy() { layerState.request?.abort(); layerState.request = null; layerState._loading = false; @@ -152,7 +147,7 @@ export function createLifecycle({ components.viewport.removeMoveEndWatcher(); components.selection.removeClickHandler(); unregisterPickOwner(id); - components.rendering.removeDetectionCollections(viewer); + components.rendering.removeDetectionCollections(); layerState._renderer?.destroy(); layerState._renderer = null; layerState._viewer = null; diff --git a/src/layers/firms/queries.js b/src/layers/firms/queries.js index 34040ea43..5c3fa51af 100644 --- a/src/layers/firms/queries.js +++ b/src/layers/firms/queries.js @@ -322,14 +322,18 @@ export function createQueries({ components.rendering.applyHandoffFade(); }, - /** Test seam: entities drawn by the layer's data source. */ - _entityCountForTest() { - return layerState._dataSource?.entities?.values?.length ?? 0; + /** Test seam: ground cells the renderer drew. */ + _cellCountForTest() { + return layerState._renderer?.cellCount?.() ?? 0; }, - /** Test seam that binds the production click path and indexes. */ - _bindInteractionForTest(viewer, fires = []) { - layerState._viewer = viewer; + /** + * Test seam that binds the production click path and indexes, on the + * renderer of `engine` (attached here when there is none yet). + */ + _bindInteractionForTest(engine, fires = []) { + if (!layerState._renderer) this.attachRenderer(null, { engine }); + layerState._renderer.setVisible(true); layerState._enabled = true; layerState._allFires = fires; layerState._allFiresByFrp = [...fires]; diff --git a/src/layers/firms/render/cesium.js b/src/layers/firms/render/cesium.js deleted file mode 100644 index 979138825..000000000 --- a/src/layers/firms/render/cesium.js +++ /dev/null @@ -1,147 +0,0 @@ -import * as Cesium from 'cesium'; -import { - worldToWindowCoordinates, - horizonOccluder, -} from '../../../data/render/cesiumProjection.js'; -import { createHeatImagery } from '../heatImagery.js'; - -// Cesium ScreenSpaceEventType.LEFT_CLICK; available before the SDK loads. -export const FIRMS_LEFT_CLICK = 2; -export const projectFirmsWindow = worldToWindowCoordinates; -export const firmsHorizonOccluder = horizonOccluder; - -/** Own the native ground rectangles, translucent sprites and their scene. */ -export function createFirmsCesiumRenderer( - engine, - { viewer, id, services, screenSpaceEventHandlerFactory } = {}, -) { - if (!viewer?.dataSources) - throw new TypeError('FIRMS requires a native viewer'); - const dataSource = new Cesium.CustomDataSource(id); - dataSource.show = false; - viewer.dataSources.add(dataSource); - let sprites = null, - visible = false, - destroyed = false; - const fadeColor = new Cesium.Color(); - const spriteServices = services.sprites; - function removeSprites() { - if (!sprites) return; - spriteServices.unregisterSpriteCollection?.('firms', sprites); - if (!viewer.scene?.isDestroyed?.()) { - try { - viewer.scene?.primitives?.remove(sprites); - } catch { - /* scene already removed it */ - } - } - sprites = null; - } - return { - kind: 'cesium', - view: viewer, - dataSource, - clickType: FIRMS_LEFT_CLICK, - project(position, result) { - return projectFirmsWindow(viewer.scene, position, result); - }, - viewRectangle(result) { - return viewer.camera.computeViewRectangle( - viewer.scene?.globe?.ellipsoid, - result, - ); - }, - horizon() { - const camera = viewer.camera?.positionWC - ? viewer.camera - : viewer.scene?.camera; - return camera?.positionWC ? horizonOccluder(camera) : null; - }, - createInputHandler() { - return screenSpaceEventHandlerFactory - ? screenSpaceEventHandlerFactory(viewer) - : new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas); - }, - pick(position) { - return viewer.scene.pick(position); - }, - setVisible(show) { - if (destroyed) return; - visible = show !== false; - dataSource.show = visible; - if (sprites) sprites.show = visible; - }, - clearCells() { - if (!destroyed) dataSource.entities.removeAll(); - }, - addCell({ west, south, east, north, color, position, properties }) { - if (destroyed) return null; - return dataSource.entities.add({ - rectangle: { - coordinates: Cesium.Rectangle.fromDegrees(west, south, east, north), - material: new Cesium.ColorMaterialProperty( - new Cesium.Color(color.red, color.green, color.blue, color.alpha), - ), - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - }, - position, - properties, - }); - }, - ensureSprites() { - if (destroyed) return null; - if (!sprites) { - sprites = new Cesium.BillboardCollection({ - scene: viewer.scene, - blendOption: Cesium.BlendOption.TRANSLUCENT, - }); - sprites.show = visible; - viewer.scene.primitives.add(sprites); - spriteServices.registerSpriteCollection('firms', sprites); - spriteServices.restoreSpriteOrder(viewer); - } - return sprites; - }, - addSprite(description) { - if (destroyed) return null; - return sprites.add({ - ...description, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }); - }, - removeSprites, - /** - * The far-zoom heatmap draped on this globe: on the active map stack's - * imagery host (`imageryHost()`), else on the viewer's imagery layers. - */ - createHeatImagery(imageryHost) { - return createHeatImagery({ - cesium: Cesium, - getHost: () => - imageryHost?.() ?? { - collection: viewer.imageryLayers ?? null, - kind: 'globe', - }, - }); - }, - /** The hand-off fade: every marker at `alpha`, shown while `show`. */ - fadeSprites(alpha, show) { - if (destroyed || !sprites) return; - sprites.show = show; - const color = Cesium.Color.WHITE.withAlpha(alpha, fadeColor); - for (let i = 0; i < sprites.length; i += 1) sprites.get(i).color = color; - }, - onEnable() { - if (!destroyed) - spriteServices.restoreSpriteOrderOnEnable('firms', viewer); - }, - destroy() { - if (destroyed) return; - destroyed = true; - removeSprites(); - viewer.dataSources.remove(dataSource, true); - }, - }; -} diff --git a/src/layers/firms/render/cesium.test.mjs b/src/layers/firms/render/cesium.test.mjs deleted file mode 100644 index 8a5ff6f1d..000000000 --- a/src/layers/firms/render/cesium.test.mjs +++ /dev/null @@ -1,137 +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 { constantRgba } from '../../shared/color.js'; -import { createFirmsCesiumRenderer } from './cesium.js'; - -function harness() { - const sources = [], - primitives = new Cesium.PrimitiveCollection(), - registrations = [], - unregistered = [], - restored = []; - const viewer = { - scene: { - primitives, - frameState: { mode: Cesium.SceneMode.SCENE3D }, - isDestroyed: () => false, - }, - dataSources: { - add(source) { - sources.push(source); - }, - remove(source, destroy) { - const i = sources.indexOf(source); - assert.ok(i >= 0); - sources.splice(i, 1); - assert.equal(destroy, true); - }, - }, - }; - const services = { - sprites: { - registerSpriteCollection: (...args) => registrations.push(args), - unregisterSpriteCollection: (...args) => unregistered.push(args), - restoreSpriteOrder: (value) => restored.push(value), - restoreSpriteOrderOnEnable: (id, value) => { - assert.equal(id, 'firms'); - restored.push(value); - }, - }, - }; - const renderer = createFirmsCesiumRenderer(null, { - viewer, - id: 'firms-fixture', - services, - }); - return { - viewer, - renderer, - sources, - primitives, - registrations, - unregistered, - restored, - }; -} - -test('native FIRMS renderer retains exact ground rectangle, color, metadata and translucent sprite policy', () => { - const h = harness(), - r = h.renderer, - epoch = Cesium.JulianDate.fromDate(new Date(0)); - assert.equal(r.dataSource.show, false); - r.setVisible(true); - const entity = r.addCell({ - west: 179, - south: 40, - east: 180, - north: 42, - color: constantRgba(1, 165 / 255, 0, 0.66), - position: geo.cartesian3.fromDegrees(179.5, 41), - properties: { count: 7, intensity: 12.5, maxFrp: 999, night: 3 }, - }); - const rectangle = entity.rectangle.coordinates.getValue(epoch), - color = entity.rectangle.material.color.getValue(epoch); - assert.deepEqual( - [rectangle.west, rectangle.south, rectangle.east, rectangle.north], - [179, 40, 180, 42].map(Cesium.Math.toRadians), - ); - assert.deepEqual( - [color.red, color.green, color.blue, color.alpha], - [1, 165 / 255, 0, 0.66], - ); - assert.equal( - entity.rectangle.heightReference.getValue(epoch), - Cesium.HeightReference.CLAMP_TO_GROUND, - ); - for (const [key, value] of Object.entries({ - count: 7, - intensity: 12.5, - maxFrp: 999, - night: 3, - })) - assert.equal(entity.properties[key].getValue(epoch), value); - const sprites = r.ensureSprites(); - assert.equal(sprites, r.ensureSprites()); - const sprite = r.addSprite({ - id: 'firms-7', - position: geo.cartesian3.fromDegrees(179.5, 41, 105), - image: 'fixture.png', - }); - assert.equal(sprite.id, 'firms-7'); - assert.equal(sprite.disableDepthTestDistance, Infinity); - assert.equal(sprite.verticalOrigin, Cesium.VerticalOrigin.CENTER); - assert.equal(sprite.horizontalOrigin, Cesium.HorizontalOrigin.CENTER); - assert.equal(sprites.blendOption, Cesium.BlendOption.TRANSLUCENT); - assert.equal(h.registrations.length, 1); - r.setVisible(false); - assert.equal(r.dataSource.show, false); - assert.equal(sprites.show, false); - r.destroy(); -}); - -test('native FIRMS destroys and unregisters only its original scene resources, once', () => { - const h = harness(), - r = h.renderer, - sprites = r.ensureSprites(); - const foreign = { - dataSources: { - remove() { - assert.fail('foreign scene must not own FIRMS teardown'); - }, - }, - }; - r.onEnable(); - assert.ok(h.restored.every((viewer) => viewer === h.viewer)); - r.destroy(foreign); - r.destroy(); - assert.equal(h.sources.length, 0); - assert.equal(h.primitives.length, 0); - assert.equal(sprites.isDestroyed(), true); - assert.deepEqual(h.unregistered, [['firms', sprites]]); - assert.equal(r.ensureSprites(), null); - r.onEnable(); - assert.equal(h.primitives.length, 0); - assert.equal(h.registrations.length, 1); -}); diff --git a/src/layers/firms/render/cesiumHeat.js b/src/layers/firms/render/cesiumHeat.js new file mode 100644 index 000000000..559d6d756 --- /dev/null +++ b/src/layers/firms/render/cesiumHeat.js @@ -0,0 +1,20 @@ +// The heatmap's Cesium backend (render/heatField.js): one full-globe imagery +// tile on the active map stack's imagery host, else on the viewer's imagery +// layers (heatImagery.js). +import * as Cesium from 'cesium'; +import { createHeatImagery } from '../heatImagery.js'; + +/** + * @param {{ viewer: object }} cesium The engine's Cesium extension. + * @param {() => ({ collection: object, kind: string } | null)} [imageryHost] + */ +export function createFirmsCesiumHeat({ viewer }, imageryHost) { + return createHeatImagery({ + cesium: Cesium, + getHost: () => + imageryHost?.() ?? { + collection: viewer.imageryLayers ?? null, + kind: 'globe', + }, + }); +} diff --git a/src/layers/firms/render/display.js b/src/layers/firms/render/display.js new file mode 100644 index 000000000..742d902e5 --- /dev/null +++ b/src/layers/firms/render/display.js @@ -0,0 +1,173 @@ +// The Active Fires drawing on the globe contract, the same on every engine: +// - the detection glows: one sprite mover set over an atlas of the layer's +// glow PNGs (render/sprites.js); +// - the aggregated cells: one ground cell set, filled in each cell's colour, +// without an outline, not pickable; +// - clicks: the engine's selection dispatcher, with the pick under the click; +// - the camera, projection, view rectangle and horizon the layer reads: the +// engine's (engine.camera, engine.projector). +// The far-zoom heatmap is a draped raster, which the contract has no +// primitive for; it sits behind render/heatField.js. + +import { createEngineLayerView } from '../../shared/render/engineView.js'; +import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; +import { LOD_LEVELS } from '../policy.js'; +import { createFirmsSpriteBank } from './sprites.js'; +import { createFirmsHeatField } from './heatField.js'; + +const rgbaOf = (color) => [ + color?.red ?? 1, + color?.green ?? 1, + color?.blue ?? 1, + color?.alpha ?? 1, +]; + +/** + * The renderer the fire layer draws through (lifecycle.js, rendering.js). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ id?: string, navaraHeat?: Function, loadImage?: Function, + * createCanvas?: Function }} [options] `navaraHeat` is the heatmap's Navara + * backend (render/navaraHeat.js), which the Navara mode passes in; + * `loadImage` and `createCanvas` are the sprite atlas's test seams. + */ +export function createFirmsDisplay( + engine, + { id = 'local-firms', navaraHeat, loadImage, createCanvas } = {}, +) { + if (!engine?.movers || !engine?.ground) + throw new TypeError('The FIRMS display needs a globe engine'); + let destroyed = false, + visible = false, + cellItems = [], + cellsQueued = false, + sprites = null; + // The layer's visibility as the context store reads a data source's. + const dataSource = { show: false }; + const clickRemovers = new Set(); + const cells = engine.ground.createSet({ id: `${id}:cells`, kind: 'cell' }); + cells.show(false); + const layerView = createEngineLayerView(engine, { + layerId: id, + isDestroyed: () => destroyed, + }); + // The camera and scene events the layer's watchers listen to. + layerView.view.camera.moveEnd = engine.camera.moveEnd; + layerView.view.scene.preRender = engine.frames.preRender; + // Cells arrive one addCell at a time: one commit per batch. + const scheduleCells = () => { + if (cellsQueued || destroyed) return; + cellsQueued = true; + queueMicrotask(() => { + cellsQueued = false; + if (destroyed) return; + cells.replace(cellItems); + cells.commit(); + }); + }; + const removeSprites = () => { + sprites?.destroy(); + sprites = null; + }; + return { + view: layerView.view, + dataSource, + project: (position, result) => + engine.projector.worldToWindow(position, result), + viewRectangle(result) { + const rect = engine.camera.viewRectangle(); + return rect ? Object.assign(result ?? {}, rect) : null; + }, + horizon: () => ({ + isPointVisible: (position) => engine.projector.horizonVisible(position), + }), + /** + * Calls `handler({ position, picked })` for every click while the layer + * is shown, with the pick under it; returns the remover. + */ + onClick(handler) { + if (destroyed) return () => {}; + const remove = selectionDispatcherFor(engine).register(id, { + owns: (pickId) => sprites?.owns(pickId) === true, + onClick: (event) => { + if (!destroyed && visible) handler(event); + }, + }); + const release = () => { + if (!clickRemovers.delete(release)) return; + remove(); + }; + clickRemovers.add(release); + return release; + }, + setVisible(show) { + if (destroyed) return; + const next = show !== false; + if (visible === next) return; + visible = next; + dataSource.show = next; + sprites?.setVisible(next); + cells.show(next); + }, + clearCells() { + if (destroyed) return; + cellItems = []; + scheduleCells(); + }, + addCell({ west, south, east, north, color }) { + if (destroyed) return null; + const item = { west, south, east, north, fill: rgbaOf(color) }; + cellItems.push(item); + scheduleCells(); + return item; + }, + /** Ground cells drawn by the last commit. */ + cellCount: () => (destroyed ? 0 : cells.count()), + ensureSprites() { + if (destroyed) return null; + if (!sprites) { + sprites = createFirmsSpriteBank(engine, { + id: `firms:${id}`, + capacity: Math.max( + ...LOD_LEVELS.map((lod) => lod.maxDetections ?? 0), + ), + ...(loadImage ? { loadImage } : {}), + ...(createCanvas ? { createCanvas } : {}), + }); + sprites.setVisible(visible); + } + return sprites.collection; + }, + addSprite(description) { + return destroyed || !sprites ? null : sprites.add(description); + }, + removeSprites, + /** The mover set drawing the glows, for tests and QA (null before any). */ + spriteSet: () => sprites?.set ?? null, + /** + * The far-zoom heatmap draped on this globe (render/heatField.js): on + * Cesium on the active map stack's imagery host (`imageryHost()`), else + * on the viewer's imagery layers. + */ + createHeatImagery(imageryHost) { + return destroyed + ? null + : createFirmsHeatField(engine, { imageryHost, navaraHeat }); + }, + /** The hand-off fade: every marker at `alpha`, shown while `show`. */ + fadeSprites(alpha, show) { + if (!destroyed) sprites?.setFade(alpha, show); + }, + destroy() { + if (destroyed) return; + destroyed = true; + visible = false; + dataSource.show = false; + cellItems = []; + for (const release of [...clickRemovers]) release(); + removeSprites(); + cells.destroy(); + layerView.destroy(); + engine.render.request('firms-destroy'); + }, + }; +} diff --git a/src/layers/firms/render/display.test.mjs b/src/layers/firms/render/display.test.mjs new file mode 100644 index 000000000..1aaa3cc23 --- /dev/null +++ b/src/layers/firms/render/display.test.mjs @@ -0,0 +1,618 @@ +// The FIRMS display on the fake engine and on the Cesium engine over a stub +// scene: the same calls, the same drawing, read back from each engine's own +// store (the fake's mover and ground sets, Cesium's billboards and ground +// primitives). +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 { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; +import { constantRgba } from '../../shared/color.js'; +import { createFirmsDisplay } from './display.js'; +import { FIRMS_SPRITE_ORDER } from './sprites.js'; + +const settle = async () => { + for (let i = 0; i < 4; i++) await new Promise((r) => setImmediate(r)); +}; +const FIRE = { lon: -98.2, lat: 30.5 }; +const at = (lon, lat, height = 0) => + geo.cartesian3.fromDegrees(lon, lat, height); +// Glow PNGs as their size: `glow-`. +const loadImage = async (url) => { + const side = Number(url.split('-')[1]); + return { width: side, height: side }; +}; +const createCanvas = () => ({ + width: 0, + height: 0, + getContext: () => ({ drawImage() {} }), +}); + +// What each ground set was given, by id. +function watchGround(engine) { + const items = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const { replace } = set; + set.replace = (next) => { + replace(next); + items.set(options.id, next); + }; + return set; + }; + return { + items: (id) => items.get(id) ?? [], + listed: (id) => engine.ground.list().find((entry) => entry.id === id), + }; +} + +function fakeHarness() { + const engine = createFakeEngine(); + engine.fake.setPose({ + longitude: geo.math.toRadians(FIRE.lon), + latitude: geo.math.toRadians(FIRE.lat), + height: 2_000_000, + }); + const ground = watchGround(engine); + return { + name: 'the fake engine', + engine, + ground, + /** The sprite set as drawn: its options and shown instances. */ + sprites(id) { + const set = engine.fake.moverSet(id); + if (!set) return null; + const { store, options } = set; + return { + options, + count: store.count, + instances: Array.from({ length: store.count }, (_, i) => ({ + show: store.shows[i] === 1, + scale: store.scales[i], + alpha: store.colors[i * 4 + 3], + pickId: store.pickIds[i], + })), + }; + }, + click(picked) { + engine.fake.script('pick', picked); + engine.fake.script('drillPick', picked ? [picked] : []); + engine.fake.input('click', { position: { x: 10, y: 20 } }); + engine.fake.unscript('pick'); + engine.fake.unscript('drillPick'); + }, + pickOf: (setId, instance, pickId) => ({ + id: pickId, + owner: setId, + instance, + }), + destroy: () => engine.destroy(), + }; +} + +function cesiumHarness() { + const handlers = createFakeEventHandlers(); + const { engine, viewer, primitive } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + engine.camera.setView({ destination: at(FIRE.lon, FIRE.lat, 2_000_000) }); + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); + const ground = watchGround(engine); + return { + name: 'the Cesium engine', + engine, + viewer, + ground, + sprites(id) { + const collection = primitive(id); + if (!collection) return null; + const listed = engine.movers.list().find((entry) => entry.id === id); + const count = listed?.count ?? 0; + return { + collection, + count, + instances: Array.from({ length: count }, (_, i) => { + const billboard = collection.get(i); + return { + show: billboard.show, + scale: billboard.scale, + alpha: billboard.color.alpha, + pickId: billboard.id, + billboard, + }; + }), + }; + }, + /** The Cesium ground primitives of a set. */ + groundPrimitives(id) { + const root = viewer.scene.groundPrimitives.get(0); + for (let i = 0; i < (root?.length ?? 0); i++) { + const slot = root.get(i); + const values = Array.from({ length: slot.length }, (_, j) => + slot.get(j), + ); + if (values.some((value) => value.gevGroundSet === id)) + return { slot, values }; + } + return null; + }, + click(picked) { + native = picked; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(10, 20), + }); + native = undefined; + }, + // A Cesium pick of the instance's billboard. + pickOf(setId, instance) { + const billboard = primitive(setId).get(instance); + return { primitive: billboard, id: billboard.id }; + }, + destroy: () => engine.destroy(), + }; +} + +const HARNESSES = [fakeHarness, cesiumHarness]; +const display = (h, options = {}) => + createFirmsDisplay(h.engine, { + id: 'firms-fixture', + loadImage, + createCanvas, + ...options, + }); + +for (const make of HARNESSES) { + const name = make().name; + + test(`${name}: cells are one ground cell set in each cell's colour, committed once per batch`, async () => { + const h = make(); + try { + const r = display(h); + const id = 'firms-fixture:cells'; + assert.equal(h.ground.listed(id).shown, false, 'hidden until shown'); + r.setVisible(true); + assert.equal(h.ground.listed(id).shown, true); + r.clearCells(); + r.addCell({ + west: 179, + south: 40, + east: 180, + north: 42, + color: constantRgba(1, 165 / 255, 0, 0.66), + position: at(179.5, 41), + properties: { count: 7 }, + }); + r.addCell({ + west: 0, + south: 0, + east: 1, + north: 1, + color: constantRgba(1, 0, 0, 0.3), + }); + assert.equal(r.cellCount(), 0, 'drawn on the next microtask'); + await settle(); + assert.equal(r.cellCount(), 2); + assert.deepEqual(h.ground.items(id)[0], { + west: 179, + south: 40, + east: 180, + north: 42, + fill: [1, 165 / 255, 0, 0.66], + }); + if (h.groundPrimitives) { + const { values } = h.groundPrimitives(id); + assert.equal(values.length, 1, 'fills only: no outline'); + const [fills] = values; + assert.equal(fills.classificationType, Cesium.ClassificationType.BOTH); + assert.equal(fills.gevPickable, false, 'cells stay out of picks'); + const rectangle = fills.geometryInstances[0].geometry._rectangle; + assert.ok( + Math.abs(rectangle.west - Cesium.Math.toRadians(179)) < 1e-12, + ); + assert.ok( + Math.abs(rectangle.north - Cesium.Math.toRadians(42)) < 1e-12, + ); + assert.deepEqual( + Array.from(fills.geometryInstances[0].attributes.color.value), + [255, 165, 0, Math.round(0.66 * 255)], + ); + } + r.setVisible(false); + assert.equal(h.ground.listed(id).shown, false); + assert.equal(r.dataSource.show, false); + r.clearCells(); + await settle(); + assert.equal(r.cellCount(), 0); + r.destroy(); + assert.equal(h.ground.listed(id), undefined); + } finally { + h.destroy(); + } + }); + + test(`${name}: glows are one sprite set at their own size, over the scene, in the firms sprite slot`, async () => { + const h = make(); + try { + const r = display(h); + r.setVisible(true); + const collection = r.ensureSprites(); + assert.equal(collection, r.ensureSprites()); + const small = r.addSprite({ + id: 'firms-0', + position: at(FIRE.lon, FIRE.lat), + image: 'glow-16', + }); + r.addSprite({ + id: 'firms-1', + position: at(FIRE.lon + 1, FIRE.lat), + image: 'glow-56', + }); + assert.equal(collection.length, 2); + await settle(); + const drawn = h.sprites('firms:firms-fixture'); + assert.equal(drawn.count, 2); + assert.ok(Math.abs(drawn.instances[0].scale - 16 / 56) < 1e-7); + assert.equal(drawn.instances[1].scale, 1); + assert.deepEqual( + drawn.instances.map((i) => i.pickId), + ['firms-0', 'firms-1'], + ); + assert.deepEqual( + drawn.instances.map((i) => i.alpha), + [1, 1], + ); + assert.equal(FIRMS_SPRITE_ORDER, SPRITE_LAYER_ORDER.indexOf('firms')); + if (drawn.options) { + assert.equal(drawn.options.order, FIRMS_SPRITE_ORDER); + assert.equal(drawn.options.depthTest, 'always'); + assert.equal(drawn.options.pickable, true); + assert.equal(drawn.options.sharpen, true); + assert.equal(drawn.options.sprite.sizePx, 56); + } else { + const { billboard } = drawn.instances[0]; + assert.equal(billboard.disableDepthTestDistance, Infinity); + assert.equal(billboard.width, 56); + assert.equal(billboard.verticalOrigin, Cesium.VerticalOrigin.CENTER); + assert.equal( + billboard.horizontalOrigin, + Cesium.HorizontalOrigin.CENTER, + ); + } + // The horizon cull flips one marker: only that instance is rewritten. + small.show = false; + await settle(); + assert.deepEqual( + h.sprites('firms:firms-fixture').instances.map((i) => i.show), + [false, true], + ); + r.setVisible(false); + await settle(); + assert.equal( + h.sprites('firms:firms-fixture').count, + 0, + 'hidden: none drawn', + ); + r.setVisible(true); + await settle(); + assert.deepEqual( + h.sprites('firms:firms-fixture').instances.map((i) => i.show), + [false, true], + ); + collection.removeAll(); + await settle(); + assert.equal(h.sprites('firms:firms-fixture').count, 0); + r.removeSprites(); + assert.equal(h.sprites('firms:firms-fixture'), null); + assert.equal(r.addSprite({ id: 'late', image: 'glow-16' }), null); + r.destroy(); + } finally { + h.destroy(); + } + }); + + test(`${name}: the hand-off fades every marker and hides them all once the heatmap is whole`, async () => { + const h = make(); + try { + const r = display(h); + r.setVisible(true); + r.ensureSprites(); + r.addSprite({ + id: 'firms-0', + position: at(FIRE.lon, FIRE.lat), + image: 'glow-56', + }); + r.addSprite({ + id: 'firms-1', + position: at(FIRE.lon, FIRE.lat + 1), + image: 'glow-56', + }); + await settle(); + r.fadeSprites(0.25, true); + await settle(); + assert.deepEqual( + h.sprites('firms:firms-fixture').instances.map((i) => i.alpha), + [0.25, 0.25], + ); + r.fadeSprites(0, false); + await settle(); + assert.equal( + h.sprites('firms:firms-fixture').count, + 0, + 'the heatmap alone: no marker', + ); + r.fadeSprites(1, true); + await settle(); + assert.deepEqual( + h.sprites('firms:firms-fixture').instances.map((i) => i.alpha), + [1, 1], + ); + r.destroy(); + } finally { + h.destroy(); + } + }); + + test(`${name}: clicks reach the layer with the pick under them while it shows, and release on destroy`, async () => { + const h = make(); + try { + const r = display(h); + r.ensureSprites(); + r.addSprite({ + id: 'firms-7', + position: at(FIRE.lon, FIRE.lat), + image: 'glow-56', + }); + r.setVisible(true); + await settle(); + const clicks = []; + const remove = r.onClick((event) => clicks.push(event)); + h.click(h.pickOf('firms:firms-fixture', 0, 'firms-7')); + assert.equal(clicks.length, 1); + assert.equal(clicks[0].picked.id, 'firms-7'); + assert.deepEqual( + { x: clicks[0].position.x, y: clicks[0].position.y }, + { x: 10, y: 20 }, + ); + h.click(undefined); + assert.equal(clicks.length, 2, 'a click on nothing is the layer’s too'); + assert.equal(clicks[1].picked, undefined); + r.setVisible(false); + h.click(undefined); + assert.equal(clicks.length, 2, 'a hidden layer takes no click'); + r.setVisible(true); + remove(); + remove(); + h.click(undefined); + assert.equal(clicks.length, 2, 'removed'); + r.onClick((event) => clicks.push(event)); + r.destroy(); + h.click(undefined); + assert.equal(clicks.length, 2, 'destroy releases every subscription'); + } finally { + h.destroy(); + } + }); + + test(`${name}: the camera, projection, view rectangle and horizon are the engine's`, () => { + const h = make(); + try { + const r = display(h); + const pose = h.engine.camera.pose(); + assert.ok( + Math.abs( + r.view.camera.positionCartographic.height - pose.cartographic.height, + ) < 1e-6, + ); + assert.ok( + geo.cartesian3.equalsEpsilon( + r.view.camera.positionWC, + pose.position, + 0, + 1e-6, + ), + ); + assert.equal(r.view.camera.moveEnd, h.engine.camera.moveEnd); + assert.equal(r.view.scene.preRender, h.engine.frames.preRender); + const near = at(FIRE.lon, FIRE.lat); + assert.deepEqual( + r.project(near, {}), + h.engine.projector.worldToWindow(near, {}), + ); + assert.deepEqual(r.viewRectangle({}), { + ...h.engine.camera.viewRectangle(), + }); + const far = at(FIRE.lon + 180, -FIRE.lat); + assert.equal(r.horizon().isPointVisible(near), true); + assert.equal(r.horizon().isPointVisible(far), false); + r.destroy(); + } finally { + h.destroy(); + } + }); + + test(`${name}: an image that decodes after hide or destroy draws nothing`, async () => { + const h = make(); + try { + let finish; + const r = display(h, { + loadImage: () => new Promise((resolve) => (finish = resolve)), + }); + r.ensureSprites(); + r.addSprite({ + id: 'firms-0', + position: at(FIRE.lon, FIRE.lat), + image: 'glow-16', + }); + r.setVisible(true); + await settle(); + r.setVisible(false); + finish({ width: 16, height: 16 }); + await settle(); + assert.equal(h.sprites('firms:firms-fixture'), null); + r.setVisible(true); + await settle(); + assert.equal( + h.sprites('firms:firms-fixture').count, + 1, + 'the decoded image draws once shown', + ); + r.destroy(); + r.destroy(); + assert.equal(h.sprites('firms:firms-fixture'), null); + assert.equal(h.engine.movers.list().length, 0); + assert.equal(h.engine.ground.list().length, 0); + assert.equal(r.ensureSprites(), null); + assert.equal(r.createHeatImagery(), null); + } finally { + h.destroy(); + } + }); +} + +test('the fake engine: clearing rows during a decode commits only the replacement', async () => { + const h = fakeHarness(); + try { + const complete = new Map(); + const r = display(h, { + loadImage: (url) => new Promise((resolve) => complete.set(url, resolve)), + }); + r.setVisible(true); + const sprites = r.ensureSprites(); + r.addSprite({ id: 'old', position: at(0, 0), image: 'glow-16' }); + await settle(); + sprites.removeAll(); + r.addSprite({ id: 'new', position: at(0, 1), image: 'glow-56' }); + await settle(); + complete.get('glow-16')({ width: 16, height: 16 }); + await settle(); + assert.equal(h.sprites('firms:firms-fixture'), null); + complete.get('glow-56')({ width: 56, height: 56 }); + await settle(); + const drawn = h.engine.fake.moverSet('firms:firms-fixture'); + assert.equal(drawn.store.count, 1); + assert.equal(drawn.store.pickIds[0], 'new'); + assert.equal(drawn.commits, 1); + r.destroy(); + } finally { + h.destroy(); + } +}); + +test('the heatmap: the Cesium engine drapes one imagery layer on the viewer, the host when there is one', () => { + const priorDocument = globalThis.document; + globalThis.document = { + createElement: () => ({ + getContext: () => ({ + createImageData: (w, hh) => ({ + data: new Uint8ClampedArray(w * hh * 4), + }), + putImageData() {}, + }), + toDataURL: () => 'data:image/png;base64,AAAA', + }), + }; + const h = cesiumHarness(); + try { + const layers = (added) => ({ + addImageryProvider(provider) { + const layer = { provider, show: true, alpha: 1 }; + added.push(layer); + return layer; + }, + remove: (layer) => added.splice(added.indexOf(layer), 1), + raiseToTop() {}, + }); + const onViewer = []; + h.viewer.imageryLayers = layers(onViewer); + const onHost = []; + let host = null; + const r = display(h); + const heat = r.createHeatImagery(() => host); + const raster = { width: 2, height: 1, rgba: new Uint8ClampedArray(8) }; + heat.setRaster(raster); + assert.equal(heat.hasRaster(), true); + assert.equal(onViewer.length, 1, 'no host: the viewer’s imagery layers'); + assert.ok(onViewer[0].provider instanceof Cesium.SingleTileImageryProvider); + heat.setOpacity(0.5); + heat.setShow(false); + assert.deepEqual([onViewer[0].alpha, onViewer[0].show], [0.5, false]); + host = { kind: 'tiles', collection: layers(onHost) }; + heat.rehome(); + assert.deepEqual( + [onViewer.length, onHost.length], + [0, 1], + 'it follows the host', + ); + assert.deepEqual([onHost[0].alpha, onHost[0].show], [0.5, false]); + heat.destroy(); + assert.equal(onHost.length, 0); + r.destroy(); + } finally { + h.destroy(); + if (priorDocument === undefined) delete globalThis.document; + else globalThis.document = priorDocument; + } +}); + +test('the heatmap: Navara draws through the backend the mode passes in; an engine without one keeps the state', () => { + const h = fakeHarness(); + try { + const first = display(h); + const plain = first.createHeatImagery(); + plain.setRaster({ width: 1, height: 1, rgba: new Uint8ClampedArray(4) }); + plain.setOpacity(2); + plain.setShow(false); + plain.rehome(); + assert.equal(plain.hasRaster(), true); + assert.deepEqual( + { ...plain.state(), raster: Boolean(plain.state().raster) }, + { raster: true, show: false, opacity: 1, drawn: false }, + ); + plain.destroy(); + first.destroy(); + + const native = { view: {} }; + h.engine.extensions = { navara: native }; + const calls = []; + const navaraHeat = (engine, navara) => { + calls.push(['create', engine === h.engine, navara === native]); + return { + setRaster: (raster) => calls.push(['raster', raster.width]), + setShow: (show) => calls.push(['show', show]), + setOpacity: (opacity) => calls.push(['opacity', opacity]), + rehome: () => calls.push(['rehome']), + destroy: () => calls.push(['destroy']), + }; + }; + const second = display(h, { navaraHeat }); + const heat = second.createHeatImagery(); + heat.setRaster({ width: 3, height: 1, rgba: new Uint8ClampedArray(12) }); + heat.setOpacity(0.25); + heat.setShow(true); + heat.rehome(); + heat.destroy(); + heat.setShow(false); + assert.deepEqual(calls, [ + ['create', true, true], + ['raster', 3], + ['opacity', 0.25], + ['show', true], + ['rehome'], + ['destroy'], + ]); + second.destroy(); + } finally { + h.destroy(); + } +}); diff --git a/src/layers/firms/render/heatField.js b/src/layers/firms/render/heatField.js new file mode 100644 index 000000000..1e4afc432 --- /dev/null +++ b/src/layers/firms/render/heatField.js @@ -0,0 +1,68 @@ +// The Active Fires far-zoom heatmap behind one seam: one equirectangular +// RGBA raster over the whole globe (data/firmsHeatRaster.js), draped on the +// terrain and 3D tiles and blended between texels. The globe contract has no +// primitive for an image draped on the ground yet (engine.ground draws flat +// colours per item), so each engine draws it through its own backend: +// render/cesiumHeat.js (one imagery layer on the active imagery host) and +// render/navaraHeat.js (a surface pass in Navara's view), which the Navara +// mode loads and passes in, as Navara modules reach the app only through its +// import(). This is the only FIRMS module that reads engine.extensions. +// +// The field keeps what the layer asked for, so an engine with no backend +// (the fake engine) draws nothing and still answers. + +import { createFirmsCesiumHeat } from './cesiumHeat.js'; + +/** + * The heatmap with heatImagery.js's interface: setRaster, hasRaster, + * setShow, setOpacity (the hand-off fade, 0..1), rehome (follow the active + * imagery host) and destroy. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ imageryHost?: () => ({ collection: object, kind: string } | null), + * navaraHeat?: (engine: object, navara: object) => object }} [options] + * `imageryHost` is the shell's imagery host (Cesium); `navaraHeat` the + * Navara backend's factory (render/navaraHeat.js). + */ +export function createFirmsHeatField(engine, { imageryHost, navaraHeat } = {}) { + const cesium = engine.extensions?.cesium; + const navara = engine.extensions?.navara; + const backend = cesium + ? createFirmsCesiumHeat(cesium, imageryHost) + : navara && navaraHeat + ? navaraHeat(engine, navara) + : null; + let raster = null, + show = true, + opacity = 1, + destroyed = false; + return { + setRaster(next) { + if (destroyed) return; + raster = next; + backend?.setRaster(next); + }, + /** True once a raster was handed over. */ + hasRaster: () => raster !== null, + setShow(value) { + if (destroyed) return; + show = Boolean(value); + backend?.setShow(show); + }, + setOpacity(value) { + if (destroyed) return; + opacity = Math.min(1, Math.max(0, Number(value) || 0)); + backend?.setOpacity(opacity); + }, + rehome() { + if (!destroyed) backend?.rehome(); + }, + /** What the layer asked for: { raster, show, opacity, drawn }. */ + state: () => ({ raster, show, opacity, drawn: backend !== null }), + destroy() { + if (destroyed) return; + destroyed = true; + backend?.destroy(); + raster = null; + }, + }; +} diff --git a/src/layers/firms/render/navara.js b/src/layers/firms/render/navara.js deleted file mode 100644 index c495294da..000000000 --- a/src/layers/firms/render/navara.js +++ /dev/null @@ -1,151 +0,0 @@ -// FIRMS terrain/tiles classification and actual engine mover glows. -import { createEngineLayerView } from '../../shared/render/engineView.js'; -import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js'; -import { LOD_LEVELS } from '../policy.js'; -import { createFirmsSpriteBank } from './navaraSprites.js'; -import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js'; -import { createFirmsHeatDrape } from './navaraHeat.js'; -export function createFirmsNavaraRenderer( - engine, - { view: nativeView, EffectDesc, NavaraEffect, id = 'local-firms' } = {}, -) { - const display = createGroundCellDisplay( - engine, - { view: nativeView, EffectDesc, NavaraEffect }, - 1, - ); - let destroyed = false, - visible = false, - cells = [], - queued = false, - sprites = null; - const dataSource = { show: false }, - handlers = new Set(); - const adapter = createEngineLayerView(engine, { - layerId: id, - isDestroyed: () => destroyed, - }); - adapter.view.camera.moveEnd = engine.camera.moveEnd; - adapter.view.scene.preRender = engine.frames.preRender; - const scheduleCells = () => { - if (queued || destroyed) return; - queued = true; - queueMicrotask(() => { - queued = false; - if (destroyed) return; - display.replace(cells); - }); - }; - const removeSprites = () => { - sprites?.destroy(); - sprites = null; - }; - return { - kind: 'navara', - view: adapter.view, - dataSource, - clickType: 'click', - project: (position, result) => - engine.projector.worldToWindow(position, result), - viewRectangle(result) { - const rect = engine.camera.viewRectangle(); - return rect ? Object.assign(result ?? {}, rect) : null; - }, - horizon: () => ({ - isPointVisible: (position) => engine.projector.horizonVisible(position), - }), - createInputHandler() { - let remove = null; - const handler = { - setInputAction(callback) { - remove?.(); - remove = selectionDispatcherFor(engine).register(id, { - owns: (pickId) => { - const collection = sprites?.collection; - if (!collection) return false; - for (let i = 0; i < collection.length; i++) - if (collection.get(i).id === pickId) return true; - return false; - }, - onClick: (event) => { - if (!destroyed && visible) callback(event); - }, - }); - }, - destroy() { - remove?.(); - remove = null; - handlers.delete(handler); - }, - }; - handlers.add(handler); - return handler; - }, - pick: (position, event) => event?.picked, - setVisible(show) { - if (destroyed) return; - const next = show !== false; - if (visible === next) return; - visible = next; - dataSource.show = next; - sprites?.setVisible(next); - display.setVisible(visible); - engine.render.request('firms-visibility'); - }, - clearCells() { - if (destroyed) return; - cells = []; - scheduleCells(); - }, - addCell(cell) { - if (destroyed) return null; - cells.push(cell); - scheduleCells(); - return cell; - }, - ensureSprites() { - if (destroyed) return null; - if (!sprites) { - sprites = createFirmsSpriteBank(engine, { - id: `firms:${id}`, - capacity: Math.max( - ...LOD_LEVELS.map((lod) => lod.maxDetections ?? 0), - ), - }); - sprites.setVisible(visible); - } - return sprites.collection; - }, - addSprite(description) { - return destroyed ? null : sprites.add(description); - }, - removeSprites, - /** The far-zoom heatmap, draped on this view's surface. */ - createHeatImagery() { - return destroyed - ? null - : createFirmsHeatDrape(engine, { - view: nativeView, - EffectDesc, - NavaraEffect, - }); - }, - /** The hand-off fade: every marker at `alpha`, shown while `show`. */ - fadeSprites(alpha, show) { - if (!destroyed) sprites?.setFade(alpha, show); - }, - onEnable() {}, - destroy() { - if (destroyed) return; - destroyed = true; - visible = false; - dataSource.show = false; - cells = []; - for (const handler of [...handlers]) handler.destroy(); - removeSprites(); - display.destroy(); - adapter.destroy(); - engine.render.request('firms-destroy'); - }, - }; -} diff --git a/src/layers/firms/render/navara.test.mjs b/src/layers/firms/render/navara.test.mjs deleted file mode 100644 index a127bf9c9..000000000 --- a/src/layers/firms/render/navara.test.mjs +++ /dev/null @@ -1,188 +0,0 @@ -import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js'; -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createEngineEvent } from '../../../globe/contract.js'; -import * as geo from '../../../geo/index.js'; -import { createFirmsNavaraRenderer } from './navara.js'; -const settle = () => new Promise((resolve) => setImmediate(resolve)); -function harness() { - const effects = [], - registered = [], - requests = [], - input = new Map(); - const frames = { - preUpdate: createEngineEvent(), - preRender: createEngineEvent(), - }; - const position = geo.cartesian3.fromDegrees(0, 0, 4000000); - const engine = { - frames, - host: { size: () => ({ width: 1280, height: 800 }) }, - movers: { - createSet() { - throw new Error('cells do not allocate sprite movers'); - }, - }, - render: { request: (reason) => requests.push(reason) }, - camera: { - livePosition: position, - moveEnd: createEngineEvent(), - viewRectangle: () => ({ west: -1, south: -0.5, east: 1, north: 0.5 }), - pose: () => ({ - position, - cartographic: { longitude: 0, latitude: 0, height: 4000000 }, - direction: { x: -1, y: 0, z: 0 }, - up: { x: 0, y: 0, z: 1 }, - right: { x: 0, y: 1, z: 0 }, - heading: 0, - pitch: -Math.PI / 2, - roll: 0, - fovy: 1, - }), - }, - projector: { - worldToWindow: (point, result) => - Object.assign(result ?? {}, { x: 10, y: 20 }), - horizonVisible: () => true, - }, - input: { - on(type, listener) { - input.set(type, listener); - return () => input.delete(type); - }, - }, - }; - const view = { - camera: {}, - registerEffect: (key, desc) => registered.push([key, desc]), - addEffect(config) { - const handle = { - config, - updates: [], - deleted: 0, - update(value) { - this.updates.push(value); - }, - delete() { - this.deleted++; - }, - }; - effects.push(handle); - return handle; - }, - }; - const options = { view, EffectDesc: class {}, NavaraEffect: class {} }; - return { - engine, - options, - effects, - registered, - requests, - input, - frames, - renderer: createFirmsNavaraRenderer(engine, options), - }; -} -const cell = { - west: 0, - south: 0, - east: 1, - north: 1, - color: { red: 1, green: 0, blue: 0, alpha: 0.3 }, -}; -test('ground changes are atomic, lazy and dispose replaced and destroyed GPU textures once', async () => { - const h = harness(), - r = h.renderer; - r.setVisible(true); - assert.equal(h.effects.length, 0); - r.clearCells(); - r.addCell(cell); - r.addCell({ ...cell, west: 1, east: 2 }); - assert.equal(h.effects.length, 0); - await settle(); - assert.equal(h.effects.length, 1); - const owner = h.effects[0].config.gevFirmsGround.owner; - assert.equal(owner.state.count, 2); - let disposed = 0; - for (const t of [ - owner.state.packed.texture, - owner.state.gridTexture, - owner.state.candidates.texture, - ]) - t.addEventListener('dispose', () => disposed++); - r.clearCells(); - r.addCell(cell); - await settle(); - assert.equal(owner.state.count, 1); - assert.equal(disposed, 3); - r.setVisible(false); - assert.equal(r.dataSource.show, false); - assert.deepEqual(h.effects[0].updates.at(-1), { visible: false }); - r.clearCells(); - r.addCell(cell); - r.destroy(); - r.destroy(); - const before = h.requests.length; - await settle(); - assert.equal(h.requests.length, before); - assert.equal(h.effects[0].deleted, 1); - assert.equal(h.frames.preUpdate.numberOfListeners, 0); - const second = createFirmsNavaraRenderer(h.engine, h.options); - second.destroy(); - assert.equal(h.registered.length, 1); -}); -test('GNSS and FIRMS share one ground pass without hiding or destroying one another', async () => { - const h = harness(); - const gnss = createGroundCellDisplay(h.engine, h.options, 0); - const fire = h.renderer; - try { - fire.setVisible(true); - fire.addCell(cell); - await settle(); - gnss.replace([cell, cell]); - gnss.setVisible(true); - const owner = h.effects[0].config.gevFirmsGround.owner; - assert.equal(h.effects.length, 1); - assert.equal(owner.state.count, 3); - gnss.setVisible(false); - assert.equal(owner.state.count, 1); - gnss.setVisible(true); - fire.destroy(); - assert.equal(owner.state.count, 2); - assert.equal(h.effects[0].deleted, 0); - } finally { - fire.destroy(); - gnss.destroy(); - } - assert.equal(h.effects[0].deleted, 1); -}); - -test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => { - const h = harness(), - r = h.renderer; - r.setVisible(true); - const handler = r.createInputHandler(); - let event; - handler.setInputAction((value) => (event = value), 'click'); - assert.equal(h.input.size, 4); - const picked = { id: 'sibling-contact' }; - h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] }); - assert.equal(r.pick(event.position, event), picked); - assert.equal(r.view.camera.moveEnd, h.engine.camera.moveEnd); - assert.equal(r.view.scene.preRender, h.engine.frames.preRender); - assert.deepEqual(r.viewRectangle({}), { - west: -1, - south: -0.5, - east: 1, - north: 0.5, - }); - assert.deepEqual(r.project({}, {}), { x: 10, y: 20 }); - event = null; - r.setVisible(false); - h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] }); - assert.equal(event, null); - handler.destroy(); - handler.destroy(); - assert.equal(h.input.size, 0); - r.destroy(); -}); diff --git a/src/layers/firms/render/navaraGeometry.test.mjs b/src/layers/firms/render/navaraGeometry.test.mjs deleted file mode 100644 index 57e4e38b4..000000000 --- a/src/layers/firms/render/navaraGeometry.test.mjs +++ /dev/null @@ -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 { - rectangleBoundaries, - rectangleDistances, - rectangleFrame, -} from '../../shared/render/navaraCellGeometry.js'; -const cases = []; -for (const lat of [-90, -80, -45, 0, 45, 80, 88]) - for (const west of [-180, -123, 0, 178]) - for (const width of [1, 2]) - cases.push({ - west, - south: lat, - east: west + width, - north: Math.min(90, lat + width), - }); -test('geodetic cell sides contain public native rectangle vertices and retain exact parallels at every height', () => { - let vertices = 0, - heights = 0; - for (const rect of cases) { - const bounds = rectangleBoundaries(rect); - const native = Cesium.RectangleGeometry.createGeometry( - new Cesium.RectangleGeometry({ - rectangle: Cesium.Rectangle.fromDegrees( - rect.west, - rect.south, - rect.east, - rect.north, - ), - vertexFormat: Cesium.VertexFormat.POSITION_ONLY, - }), - ); - const positions = native.attributes.position.values; - for (let i = 0; i < positions.length; i += 3) { - const distance = rectangleDistances(bounds, { - x: positions[i], - y: positions[i + 1], - z: positions[i + 2], - }); - assert.ok(Math.min(...distance) > -1e-6, JSON.stringify(rect)); - vertices++; - } - for (const h of [-100000, 0, 200, 12000]) - for (const t of [0.1, 0.5, 0.9]) { - const lon = rect.west + (rect.east - rect.west) * t, - lat = rect.south + (rect.north - rect.south) * t; - for (const [x, y] of [ - [lon, rect.south], - [lon, rect.north], - [rect.west, lat], - [rect.east, lat], - ]) { - const d = rectangleDistances( - bounds, - geo.cartesian3.fromDegrees(x, y, h), - ); - assert.ok( - Math.abs(Math.min(...d)) < 1e-6, - `${JSON.stringify(rect)} height ${h}`, - ); - heights++; - } - assert.ok( - Math.min( - ...rectangleDistances( - bounds, - geo.cartesian3.fromDegrees(lon, lat, h), - ), - ) > 0, - ); - assert.ok( - Math.min( - ...rectangleDistances( - bounds, - geo.cartesian3.fromDegrees(rect.west - 0.01, lat, h), - ), - ) < 0, - ); - assert.ok( - Math.min( - ...rectangleDistances( - bounds, - geo.cartesian3.fromDegrees(rect.east + 0.01, lat, h), - ), - ) < 0, - ); - } - } - assert.equal(vertices, 562); - assert.equal(heights, 2688); -}); -test('candidate volumes enclose geodetic cells through their conservative terrain/roof height window', () => { - for (const rect of cases) { - const frame = rectangleFrame(rect); - for (const lat of [rect.south, (rect.south + rect.north) / 2, rect.north]) - for (const lon of [rect.west, (rect.west + rect.east) / 2, rect.east]) - for (const h of [-100000, 0, 12000]) { - const p = geo.cartesian3.fromDegrees(lon, lat, h), - delta = { - x: p.x - frame.center.x, - y: p.y - frame.center.y, - z: p.z - frame.center.z, - }; - const x = geo.cartesian3.dot(delta, frame.east), - y = geo.cartesian3.dot(delta, frame.north), - z = geo.cartesian3.dot(delta, frame.up); - assert.ok( - Math.abs(x) <= frame.radius && Math.abs(y) <= frame.radius, - JSON.stringify(rect), - ); - assert.ok( - z >= frame.floor - 1e-6 && z <= frame.ceiling + 1e-6, - `${JSON.stringify(rect)} ${z}`, - ); - } - } -}); diff --git a/src/layers/firms/render/navaraSprites.test.mjs b/src/layers/firms/render/navaraSprites.test.mjs deleted file mode 100644 index ed6ddca6d..000000000 --- a/src/layers/firms/render/navaraSprites.test.mjs +++ /dev/null @@ -1,177 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createMoverRegistry } from '../../../globe/movers.js'; -import { createFirmsSpriteBank } from './navaraSprites.js'; -const settle = () => new Promise((resolve) => setImmediate(resolve)); -function harness(loadImage) { - let commits = 0, - disposals = 0; - const draws = [], - canvases = []; - const registry = createMoverRegistry((options, store) => ({ - ready: () => true, - commit() { - commits++; - }, - destroy() { - disposals++; - }, - })); - const bank = createFirmsSpriteBank( - { movers: registry.movers }, - { - id: 'firms-test', - capacity: 3, - loadImage, - createCanvas() { - const canvas = { - getContext: () => ({ drawImage: (...args) => draws.push(args) }), - }; - canvases.push(canvas); - return canvas; - }, - }, - ); - return { - bank, - registry, - draws, - canvases, - get commits() { - return commits; - }, - get disposals() { - return disposals; - }, - }; -} -test('actual image dimensions set both the atlas cell and visible/pickable quad, with batched horizon flips', async () => { - const h = harness(async (url) => ({ - width: url === 'small' ? 16 : 56, - height: url === 'small' ? 16 : 56, - })); - const first = h.bank.add({ - id: 'firms-0', - position: { x: 1, y: 2, z: 3 }, - image: 'small', - }); - const second = h.bank.add({ - id: 'firms-1', - position: { x: 4, y: 5, z: 6 }, - image: 'large', - }); - h.bank.setVisible(true); - await settle(); - const entry = h.registry.sets.get('firms-test'); - assert.equal(h.registry.sets.size, 1); - assert.equal(entry.options.sharpen, true); - assert.equal(entry.options.depthTest, 'always'); - assert.equal(entry.options.pickable, true); - assert.equal(entry.store.count, 2); - assert.ok(Math.abs(entry.store.scales[0] - 16 / 56) < 1e-7); - assert.equal(entry.store.scales[1], 1); - assert.deepEqual(entry.store.pickIds.slice(0, 2), ['firms-0', 'firms-1']); - assert.equal(h.draws.length, 2); - assert.ok( - h.draws.every((args) => args.length === 3), - 'atlas does not resize source PNGs', - ); - const rect = entry.options.sprite.atlas.cells['0']; - assert.ok(Math.abs((rect[2] - rect[0]) * h.canvases[0].width - 16) < 1e-10); - assert.ok( - rect[0] > 0 && rect[1] > 0, - 'transparent neighbor padding is retained', - ); - const before = h.commits; - first.show = false; - second.show = false; - await settle(); - assert.equal(h.commits, before + 1); - assert.equal(h.canvases.length, 1); - assert.deepEqual([...entry.store.shows.slice(0, 2)], [0, 0]); - h.bank.setVisible(false); - assert.equal(entry.store.count, 0); - const hidden = h.commits; - await settle(); - assert.equal(h.commits, hidden); - h.bank.destroy(); - h.bank.destroy(); - assert.equal(h.registry.sets.size, 0); - assert.equal(h.disposals, 1); -}); -test('uncooperative image completion after disable or destroy cannot create movers or request a frame', async () => { - let finish; - const h = harness(() => new Promise((resolve) => (finish = resolve))); - h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'late' }); - h.bank.setVisible(true); - await settle(); - h.bank.setVisible(false); - finish({ width: 16, height: 16 }); - await settle(); - assert.equal(h.registry.sets.size, 0); - assert.equal(h.commits, 0); - h.bank.collection.removeAll(); - h.bank.add({ id: 'firms-1', position: { x: 1, y: 2, z: 3 }, image: 'later' }); - h.bank.setVisible(true); - await settle(); - h.bank.destroy(); - finish({ width: 56, height: 56 }); - await settle(); - assert.equal(h.registry.sets.size, 0); - assert.equal(h.commits, 0); - assert.equal(h.bank.collection.length, 0); -}); -test('clearing rows during decode preserves the new generation and commits only the replacement', async () => { - const complete = new Map(), - h = harness((url) => new Promise((resolve) => complete.set(url, resolve))); - h.bank.setVisible(true); - h.bank.add({ id: 'old', position: { x: 1, y: 2, z: 3 }, image: 'old' }); - await settle(); - h.bank.collection.removeAll(); - h.bank.add({ id: 'new', position: { x: 4, y: 5, z: 6 }, image: 'new' }); - await settle(); - complete.get('old')({ width: 16, height: 16 }); - await settle(); - assert.equal(h.registry.sets.size, 0); - complete.get('new')({ width: 56, height: 56 }); - await settle(); - const entry = h.registry.sets.get('firms-test'); - assert.equal(entry.store.count, 1); - assert.equal(entry.store.pickIds[0], 'new'); - assert.equal(h.commits, 1); - h.bank.destroy(); -}); - -test('the heatmap hand-off fades every marker and hides them all once it completes', async () => { - const h = harness(async () => ({ width: 56, height: 56 })); - h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'a' }); - h.bank.add({ id: 'firms-1', position: { x: 4, y: 5, z: 6 }, image: 'a' }); - h.bank.setVisible(true); - await settle(); - await settle(); - const entry = h.registry.sets.get('firms-test'); - assert.equal(entry.store.count, 2); - assert.deepEqual( - [...entry.store.colors.slice(0, 8)], - [1, 1, 1, 1, 1, 1, 1, 1], - ); - h.bank.setFade(0.25, true); - await settle(); - await settle(); - assert.deepEqual( - [...entry.store.colors.slice(0, 8)], - [1, 1, 1, 0.25, 1, 1, 1, 0.25], - ); - h.bank.setFade(0, false); - await settle(); - await settle(); - assert.equal(entry.store.count, 0, 'the heatmap alone: no marker drawn'); - h.bank.setFade(1, true); - await settle(); - await settle(); - assert.equal(entry.store.count, 2); - assert.deepEqual( - [...entry.store.colors.slice(0, 8)], - [1, 1, 1, 1, 1, 1, 1, 1], - ); -}); diff --git a/src/layers/firms/render/navaraSprites.js b/src/layers/firms/render/sprites.js similarity index 66% rename from src/layers/firms/render/navaraSprites.js rename to src/layers/firms/render/sprites.js index 80e3ddb3f..a33a08d93 100644 --- a/src/layers/firms/render/navaraSprites.js +++ b/src/layers/firms/render/sprites.js @@ -1,10 +1,17 @@ -// The real FIRMS PNGs in one engine mover atlas. +// The FIRMS glow PNGs as one engine.movers sprite set over an atlas of the +// decoded images, the same on every engine. Each glow is drawn at its own +// pixel size (a scale of its side over BASE_SIZE), centred on its anchor, +// over the scene (depth test off), in the sprite order's 'firms' slot. import { packImageAtlas } from '../../shared/render/imageAtlas.js'; +import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js'; const WHITE = Object.freeze([1, 1, 1, 1]); const CELL = 60, PAD = 2, BASE_SIZE = 56; +/** The glows' draw order among the sprite layers (data/spriteOrder.js). */ +export const FIRMS_SPRITE_ORDER = SPRITE_LAYER_ORDER.indexOf('firms'); + const imageFromUrl = (url) => new Promise((resolve, reject) => { const image = new Image(); @@ -18,6 +25,19 @@ const imageFromUrl = (url) => }; image.src = url; }); + +/** + * A bank of glow sprites: `collection` is billboard-collection shaped + * ({ length, get(i), removeAll() }) and each row's `show` is writable, as + * the layer's horizon cull expects. Writes are drawn on the next microtask, + * once the images they name are decoded; a show flip rewrites only that + * instance. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ id: string, capacity: number, + * loadImage?: (url: string) => Promise, + * createCanvas?: () => object }} options `loadImage` and `createCanvas` + * are test seams (Node has neither Image nor canvas). + */ export function createFirmsSpriteBank( engine, { @@ -35,8 +55,11 @@ export function createFirmsSpriteBank( destroyed = false, generation = 0, queued = false, + // Every row is rewritten on the next flush (else only `showDirty`). + full = true, set = null, atlas = null; + const showDirty = new Set(); const images = new Map(); const loaded = new Map(); const getImage = (url) => { @@ -57,6 +80,7 @@ export function createFirmsSpriteBank( return task; }; const hide = () => { + full = true; if (set && set.count() !== 0) { set.setCount(0); set.commit(); @@ -73,12 +97,12 @@ export function createFirmsSpriteBank( lookup.set(url, { key, scale: width / BASE_SIZE }); return { key, image, width, height }; }); - const atlas = packImageAtlas(entries, { + const packed = packImageAtlas(entries, { canvas: createCanvas(), cell: CELL, pad: PAD, }); - return { ...atlas, lookup }; + return { ...packed, lookup }; } async function flush() { if (destroyed) return; @@ -98,6 +122,7 @@ export function createFirmsSpriteBank( id, kind: 'sprite', capacity, + order: FIRMS_SPRITE_ORDER, depthTest: 'always', pickable: true, sharpen: true, @@ -107,19 +132,27 @@ export function createFirmsSpriteBank( }, }); atlas = next; + full = true; } - snapshot.forEach((row, index) => { - const cell = atlas.lookup.get(row.image); - set.set(index, { - position: row.position, - cell: cell.key, - scale: cell.scale, - color: fade, - show: row.show, - pickId: row.id, + if (full || set.count() !== snapshot.length) { + snapshot.forEach((row, index) => { + const cell = atlas.lookup.get(row.image); + set.set(index, { + position: row.position, + cell: cell.key, + scale: cell.scale, + color: fade, + show: row.show, + pickId: row.id, + }); }); - }); - set.setCount(snapshot.length); + set.setCount(snapshot.length); + } else + for (const index of showDirty) + if (index < snapshot.length) + set.set(index, { show: snapshot[index].show }); + full = false; + showDirty.clear(); set.commit(); } function schedule() { @@ -145,6 +178,7 @@ export function createFirmsSpriteBank( removeAll() { if (destroyed) return; rows = []; + full = true; schedule(); }, }; @@ -154,6 +188,7 @@ export function createFirmsSpriteBank( if (destroyed) return null; if (rows.length >= capacity) throw new RangeError('FIRMS sprite capacity exceeded'); + const index = rows.length; let show = description.show !== false; const row = { ...description, @@ -164,13 +199,20 @@ export function createFirmsSpriteBank( const next = Boolean(value); if (next === show || destroyed) return; show = next; + showDirty.add(index); schedule(); }, }; rows.push(row); + full = true; schedule(); return row; }, + /** Whether a pick id names one of the bank's rows. */ + owns(pickId) { + for (const row of rows) if (row.id === pickId) return true; + return false; + }, /** The hand-off fade: every marker at `alpha`, drawn while `show`. */ setFade(alpha, show) { if (destroyed) return; @@ -178,6 +220,7 @@ export function createFirmsSpriteBank( if (next[3] === fade[3] && Boolean(show) === fadeShown) return; fade = next; fadeShown = Boolean(show); + full = true; schedule(); }, setVisible(value) { @@ -186,11 +229,16 @@ export function createFirmsSpriteBank( if (!visible) hide(); schedule(); }, + /** The mover set drawing the glows (null before the first flush). */ + get set() { + return set; + }, destroy() { if (destroyed) return; destroyed = true; generation++; rows = []; + showDirty.clear(); images.clear(); loaded.clear(); set?.destroy(); diff --git a/src/layers/firms/rendering.js b/src/layers/firms/rendering.js index a516c2dc8..f6b6168b2 100644 --- a/src/layers/firms/rendering.js +++ b/src/layers/firms/rendering.js @@ -372,12 +372,12 @@ export function createRendering({ } function ensureDetectionCollections() { - if (!layerState._viewer || layerState._billboards) return; + if (!layerState._renderer || layerState._billboards) return; layerState._billboards = layerState._renderer.ensureSprites(); overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled); } - function removeDetectionCollections(viewer) { + function removeDetectionCollections() { layerState._renderer?.removeSprites(); layerState._billboards = null; overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID); @@ -385,21 +385,14 @@ export function createRendering({ } /** - * Shared horizon occluder positioned at the current camera, or null when - * there is no usable camera yet (init order / headless stubs). - * `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium; - * both are probed so a partial stub (either shape) still gets a real - * occluder rather than silently degrading to "nothing is ever occluded". - * @returns {?Cesium.EllipsoidalOccluder} + * The ellipsoid horizon at the current camera (the engine's + * projector.horizonVisible, Cesium's EllipsoidalOccluder test), or null + * without a renderer. + * @returns {?{isPointVisible: function(Object): boolean}} */ function fireHorizonOccluder() { - if (layerState._renderer) return layerState._renderer.horizon(); - const camera = layerState._viewer?.camera?.positionWC - ? layerState._viewer.camera - : layerState._viewer?.scene?.camera; - if (!camera?.positionWC) return null; - return config.horizonOccluder(camera); + return layerState._renderer?.horizon() ?? null; } /** diff --git a/src/layers/firms/selection.js b/src/layers/firms/selection.js index 8c23140b6..3108b9a42 100644 --- a/src/layers/firms/selection.js +++ b/src/layers/firms/selection.js @@ -21,28 +21,21 @@ export function createSelection({ getContextStore, registerEntityContext, } = services.context; - const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config; + const { id, name, overlayHost } = config; /** - * Install the LEFT_CLICK handler for fire selection (enable-time only, + * Install the click handler for fire selection (enable-time only, * mirroring the flight layers). Clicking a fire sprite selects it and * surfaces it in the shared context store; clicking empty space clears * the selection. Picks owned by sibling layers are left alone. */ function installClickHandler() { - if (layerState._clickHandler || !layerState._viewer) return; - layerState._clickHandler = layerState._renderer - ? layerState._renderer.createInputHandler() - : screenSpaceEventHandlerFactory(layerState._viewer); - const clickType = - layerState._renderer?.clickType ?? config.leftClickEventType; - layerState._clickHandler.setInputAction((click) => { + if (layerState._clickHandler || !layerState._renderer) return; + layerState._clickHandler = layerState._renderer.onClick((click) => { // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; - const picked = layerState._renderer - ? layerState._renderer.pick(click.position, click) - : layerState._viewer.scene.pick(click.position); + const picked = click.picked; const fire = pickedFire(picked); if (fire) { selectAndFocusFire(fire); @@ -67,14 +60,12 @@ export function createSelection({ return; } clearFireSelection(); - }, clickType); + }); } function removeClickHandler() { - if (layerState._clickHandler) { - layerState._clickHandler.destroy(); - layerState._clickHandler = null; - } + layerState._clickHandler?.(); + layerState._clickHandler = null; } /** Select one stable detection and request one UI-owned camera transfer. */ diff --git a/src/layers/firms/viewport.js b/src/layers/firms/viewport.js index 81b1e846c..8202131b9 100644 --- a/src/layers/firms/viewport.js +++ b/src/layers/firms/viewport.js @@ -98,13 +98,9 @@ export function createViewport({ function computeViewRect() { try { - if (layerState._renderer) - return layerState._renderer.viewRectangle(layerState.scratchViewRect); - const rect = layerState._viewer?.camera?.computeViewRectangle( - layerState._viewer?.scene?.globe?.ellipsoid, - layerState.scratchViewRect, + return ( + layerState._renderer?.viewRectangle(layerState.scratchViewRect) ?? null ); - return rect || null; } catch { return null; } diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index b55e410af..cc47146d2 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -1,5 +1,4 @@ import { fromDegrees } from '../../geo/cartesian3.js'; -import { createGhostMapsCesiumRenderer } from './render/cesium.js'; import { decodeXml, kmzImageResolver, @@ -149,7 +148,6 @@ function anchorOf(geometry) { export function createGhostMapsLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, createRenderer = null, picking = null, pointer = null, @@ -187,10 +185,7 @@ export function createGhostMapsLayer({ const notifyRow = () => _rowListener?.(); const render = () => _display?.requestRender(); - const canSelect = () => - overlayHost && - picking && - (screenSpaceEventHandlerFactory || createRenderer); + const canSelect = () => overlayHost && picking && createRenderer; function wanted(product) { return _selection ? _selection.has(product.id) : product.defaultOn; @@ -597,18 +592,14 @@ export function createGhostMapsLayer({ updateInterval: 300000, init(viewer, options = {}) { - if (viewer || options.engine) return this.attachRenderer(viewer, options); + if (options.engine) return this.attachRenderer(viewer, options); }, - attachRenderer(viewer, { engine = null, signal = null } = {}) { + attachRenderer(_viewer, { engine = null, signal = null } = {}) { if (_display) throw new Error('GhostMaps display is already attached'); - _display = viewer - ? createGhostMapsCesiumRenderer( - viewer, - screenSpaceEventHandlerFactory, - markerImage, - ) - : createRenderer(engine); + if (typeof createRenderer !== 'function') + throw new TypeError('GhostMaps needs a renderer factory'); + _display = createRenderer(engine); if (!_enabled) return; installClickHandler(); publishCard(); diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index 9da2a9311..f6dcde5b8 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { createGhostMapsLayer } from './index.js'; +import { createGhostMapsDisplay } from './render/display.js'; import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js'; function element(localName, children = [], value = '', attributes = {}) { @@ -87,6 +88,30 @@ function fixtureKmz() { return new Uint8Array(Buffer.concat([...local, directory, end])); } +// The editions drawn on the engine: their ground and placemark sets carry +// `s2-ghostmaps::` in their ids. +function drawnEditions(engine) { + const ids = [ + ...engine.ground.list().map(({ id }) => id), + ...engine.movers.list().map(({ id }) => id), + ]; + return new Set(ids.map((id) => id.split(':').slice(0, 3).join(':'))); +} + +// The pick ids drawn on the engine (placemarks and ground items), in order. +function drawnIds(engine, ground) { + const ids = []; + for (const { id, count } of engine.ground.list()) + ids.push(...(ground.get(id) ?? []).slice(0, count).map((i) => i.pickId)); + for (const { id } of engine.movers.list()) { + const { store } = engine.fake.moverSet(id); + ids.push( + ...Array.from({ length: store.count }, (_, i) => store.pickIds[i]), + ); + } + return ids; +} + function fixture( t, { @@ -118,22 +143,28 @@ function fixture( }); t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url)); t.mock.method(console, 'warn', () => {}); - const mounted = new Set(); - const viewer = { - scene: { requestRender() {} }, - dataSources: { - async add(dataSource) { - mounted.add(dataSource); - return dataSource; - }, - remove(dataSource) { - return mounted.delete(dataSource); - }, - }, + const engine = createFakeEngine(); + // What each ground set was last given, by set id. + const ground = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const replace = set.replace.bind(set); + set.replace = (items) => { + ground.set(options.id, items); + replace(items); + }; + return set; }; + const display = () => + createGhostMapsDisplay(engine, { + loadImage: async () => ({ naturalWidth: 32, naturalHeight: 32 }), + createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }), + }); const layer = createGhostMapsLayer({ storage: null, now, + createRenderer: display, ...controls, source: { getCatalog: async () => ({ @@ -147,12 +178,36 @@ function fixture( }, }, }); - layer.init(viewer); + layer.init(null, { engine }); layer.enable(); t.after(() => layer.destroy()); - return { layer, viewer, mounted, created, revoked }; + const mounted = { + get size() { + return drawnEditions(engine).size; + }, + }; + const ids = () => drawnIds(engine, ground); + // A click at the window centre on what the engine picks there. + const click = (pickId) => { + engine.fake.script('pick', pickId ? { id: pickId } : undefined); + engine.fake.input('click', { position: { x: 512, y: 384 } }); + }; + return { + layer, + viewer: null, + engine, + mounted, + ids, + editions: () => drawnEditions(engine), + click, + created, + revoked, + }; } +// The layer's picking service, as the application's resolves a pick. +const picking = { resolvePickId: (picked) => picked?.id ?? null }; + test('cancelling a file read does not allocate packed-image URLs', async (t) => { const { layer, viewer, mounted, created, revoked } = fixture(t, { beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()), @@ -187,7 +242,7 @@ test('a KMZ delivered between planets is reused by the new scene and releases it ['Test report'], ); assert.equal(h.mounted.size, 0); - await layer.attachRenderer(h.viewer); + await layer.attachRenderer(null, { engine: h.engine }); assert.equal(h.mounted.size, 1); assert.equal(h.created.length, 1); assert.equal(h.revoked.length, 0); @@ -196,9 +251,9 @@ test('a KMZ delivered between planets is reused by the new scene and releases it assert.equal(h.mounted.size, 0); }); -test('entity construction failure releases parsed image URLs', async (t) => { - const { layer, viewer, mounted, created, revoked } = fixture(t); - t.mock.method(Cesium.EntityCollection.prototype, 'add', () => { +test('a drawing failure releases parsed image URLs', async (t) => { + const { layer, viewer, engine, mounted, created, revoked } = fixture(t); + t.mock.method(engine.ground, 'createSet', () => { throw new Error('fixture entity failure'); }); await layer.update(viewer); @@ -386,7 +441,7 @@ test('age filtering and All markers apply to every conflict-map product, includi }); test('default products filter old event dates while keeping recent, future and undated markers', async (t) => { - const { layer, viewer, mounted } = fixture(t, { + const { layer, viewer, ids } = fixture(t, { now: () => Date.UTC(2026, 10, 3, 12), reports: [ dated('Old event', '10/20/2026'), @@ -420,21 +475,17 @@ test('default products filter old event dates while keeping recent, future and u ], ); assert.equal(layer.getStats().count, 7); - assert.equal([...mounted][0].entities.values.length, 7); + assert.equal(ids().length, 7); // Filtering cannot shift pick ids onto a different report. - assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0')); - assert.equal( - [...mounted][0].entities.getById('ghostmaps:cip:0:0'), - undefined, - ); + assert.ok(ids().includes('ghostmaps:cip:1:0')); + assert.equal(ids().includes('ghostmaps:cip:0:0'), false); }); test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => { t.mock.timers.enable({ apis: ['setInterval'] }); let at = Date.UTC(2026, 10, 3, 12); - let click; const cards = []; - const { layer, viewer, mounted } = fixture(t, { + const { layer, viewer, mounted, ids, editions, click } = fixture(t, { now: () => at, reports: [ dated('Expiring', '10/27/2026 12:00:00 PM'), @@ -442,31 +493,27 @@ test('unchanged cached products age out on the timer, clear selected cards and k ], controls: { overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction: (fn) => { - click = fn; - }, - destroy() {}, - }), - picking: { resolvePickId: (picked) => picked }, + picking, }, }); await layer.update(viewer); - const ds = [...mounted][0]; - viewer.scene.pick = () => 'ghostmaps:cip:0:0'; - click({ position: { x: 0, y: 0 } }); + const [edition] = editions(); + click('ghostmaps:cip:0:0'); assert.equal(cards.at(-1).length, 1); at += 60_000; t.mock.timers.tick(60_000); assert.equal(layer.getStats().count, 1); - assert.equal(ds.entities.values.length, 1); - assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined); - assert.ok(ds.entities.getById('ghostmaps:cip:1:0')); + // Dropped in place: the edition's drawing stays for the survivor. + assert.deepEqual(ids(), ['ghostmaps:cip:1:0']); + assert.deepEqual([...editions()], [edition]); assert.deepEqual(cards.at(-1), []); await layer.update(viewer); - assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded'); - viewer.scene.pick = () => 'ghostmaps:cip:1:0'; - click({ position: { x: 0, y: 0 } }); + assert.deepEqual( + [...editions()], + [edition], + 'same-version KMZ is not reloaded', + ); + click('ghostmaps:cip:1:0'); assert.match(cards.at(-1)[0].title, /Surviving/); layer.disable(); at += 14 * 86400_000; @@ -487,8 +534,13 @@ test('expiry removes every geometry and polygon outline belonging to an old repo element('coordinates', [], '12,34 13,34 13,35 12,34'), ]), ]), + element('innerBoundaryIs', [ + element('LinearRing', [ + element('coordinates', [], '12.6,34.2 12.8,34.2 12.8,34.4 12.6,34.2'), + ]), + ]), ]); - const { layer, viewer, mounted } = fixture(t, { + const { layer, viewer, ids } = fixture(t, { now: () => at, reports: [ { @@ -502,11 +554,16 @@ test('expiry removes every geometry and polygon outline belonging to an old repo ], }); await layer.update(viewer); - const ds = [...mounted][0]; - assert.equal(ds.entities.values.length, 4); + // The placemark, the line, the polygon and its outer outline. + assert.deepEqual(ids().sort(), [ + 'ghostmaps:cip:0:0', + 'ghostmaps:cip:0:1', + 'ghostmaps:cip:0:2', + 'ghostmaps:cip:0:2:outline', + ]); at += 60_000; t.mock.timers.tick(60_000); - assert.equal(ds.entities.values.length, 0); + assert.deepEqual(ids(), []); assert.equal(layer.getStats().count, 0); }); @@ -541,27 +598,19 @@ test('two quick clicks, 1d then 7d, restore the report', async (t) => { }); test('narrowing the window keeps the open card of a surviving marker', async (t) => { - let click; const cards = []; - const { layer, viewer } = fixture(t, { + const { layer, viewer, click } = fixture(t, { now: () => Date.UTC(2026, 10, 3, 12), reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')], controls: { overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction: (fn) => { - click = fn; - }, - destroy() {}, - }), - picking: { resolvePickId: (picked) => picked }, + picking, }, }); await layer.update(viewer); await ageChip(layer, 30).onClick(); assert.equal(layer.getStats().count, 2); - viewer.scene.pick = () => 'ghostmaps:cip:0:0'; - click({ position: { x: 0, y: 0 } }); + click('ghostmaps:cip:0:0'); assert.equal(cards.at(-1).length, 1); await ageChip(layer, 3).onClick(); assert.equal(layer.getStats().count, 1); @@ -583,26 +632,18 @@ test('a report dated yesterday is shown by 1d one hour into today (UTC)', async }); test('a widened reload that returns a different report at the selected index closes its card', async (t) => { - let click; const cards = []; const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')]; - const { layer, viewer } = fixture(t, { + const { layer, viewer, click } = fixture(t, { now: () => Date.UTC(2026, 10, 3, 12), reports, controls: { overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction: (fn) => { - click = fn; - }, - destroy() {}, - }), - picking: { resolvePickId: (picked) => picked }, + picking, }, }); await layer.update(viewer); - viewer.scene.pick = () => 'ghostmaps:cip:0:0'; - click({ position: { x: 0, y: 0 } }); + click('ghostmaps:cip:0:0'); assert.equal(cards.at(-1).length, 1); // The server answers the pinned version with a newer edition's bytes. reports[0] = dated('Replacement', '11/2/2026'); diff --git a/src/layers/ghostMaps/render/cesium.js b/src/layers/ghostMaps/render/cesium.js deleted file mode 100644 index a986090bc..000000000 --- a/src/layers/ghostMaps/render/cesium.js +++ /dev/null @@ -1,157 +0,0 @@ -import * as Cesium from 'cesium'; -import { createHorizonCull } from '../../streetLevel/view.js'; - -const cesiumColor = (rgba, fallback, alpha) => - rgba - ? new Cesium.Color(rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]) - : Cesium.Color.fromCssColorString(fallback).withAlpha(alpha ?? 1); - -export function createGhostMapsCesiumRenderer( - viewer, - createHandler, - markerImage, -) { - const allPins = new Set(); - const horizon = createHorizonCull({ - getViewer: () => viewer, - items: () => allPins, - onChange: () => viewer.scene.requestRender?.(), - }); - return { - requestRender: () => viewer.scene.requestRender?.(), - onClick(run) { - const handler = createHandler(viewer); - handler.setInputAction( - (click) => run(click, () => viewer.scene.pick(click.position)), - Cesium.ScreenSpaceEventType.LEFT_CLICK, - ); - return () => handler.destroy(); - }, - draw(productId, features, color) { - const dataSource = new Cesium.CustomDataSource( - `s2-ghostmaps:${productId}`, - ); - const pins = []; - dataSource.entities.suspendEvents(); - features.forEach((feature, index) => { - const key = `${productId}:${index}`; - const { style } = feature; - feature.geometries.forEach((geometry, part) => { - const id = `ghostmaps:${key}:${part}`; - if (geometry.type === 'point') { - const position = Cesium.Cartesian3.fromDegrees( - ...geometry.position, - ); - const entity = dataSource.entities.add({ - id, - position, - billboard: { - image: style.icon || markerImage(color), - scale: Math.min( - 1.5, - Math.max(0.35, (style.iconScale ?? 1) * 0.7), - ), - color: - style.icon && style.iconColor - ? cesiumColor(style.iconColor) - : undefined, - heightReference: Cesium.HeightReference.CLAMP_TO_GROUND, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - // Ground-clamped icons would sink into terrain and 3D tiles; the - // horizon cull hides the far side instead (see `_horizon`). - disableDepthTestDistance: Number.POSITIVE_INFINITY, - }, - }); - pins.push({ - index, - position, - get show() { - return entity.show; - }, - set show(value) { - entity.show = value; - }, - }); - } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), - ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) - dataSource.entities.add({ - id: `${id}:outline`, - polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } - }); - }); - dataSource.entities.resumeEvents(); - for (const pin of pins) allPins.add(pin); - horizon.update(pins); - let disposed = false; - const ready = Promise.resolve(viewer.dataSources.add(dataSource)).finally( - () => { - if (disposed) viewer.dataSources.remove(dataSource, true); - }, - ); - return { - ready, - // Take one report off in place: an expired event leaves its edition's - // data source, which stays mounted for the reports that remain. - drop(index) { - const prefix = `ghostmaps:${productId}:${index}:`; - for (const entity of [...dataSource.entities.values]) - if (entity.id.startsWith(prefix)) - dataSource.entities.remove(entity); - for (let i = pins.length - 1; i >= 0; i -= 1) - if (pins[i].index === index) { - allPins.delete(pins[i]); - pins.splice(i, 1); - } - return true; - }, - destroy() { - disposed = true; - for (const pin of pins) allPins.delete(pin); - viewer.dataSources.remove(dataSource, true); - }, - }; - }, - destroy: horizon.stop, - }; -} diff --git a/src/layers/ghostMaps/render/display.js b/src/layers/ghostMaps/render/display.js new file mode 100644 index 000000000..4d06e522b --- /dev/null +++ b/src/layers/ghostMaps/render/display.js @@ -0,0 +1,388 @@ +// The GhostMaps drawing on the globe contract, the same on every engine. +// Each drawn product edition owns three sets: +// - a ground 'polygon' set: KML polygons, their fill and their outline; +// - a ground 'corridor' set: KML lines, in pixels; +// - a sprite mover set: placemark icons, from an atlas of the edition's +// decoded images. +// Picks return `ghostmaps:${productId}:${index}:${part}` (a polygon's +// outline adds `:outline`), which the layer resolves to its report. +// Placemarks draw over the scene (depth test off) and are hidden beyond the +// horizon instead, as they would sink into terrain and 3D tiles otherwise. + +import * as geo from '../../../geo/index.js'; +import { normalizeGroundItem } from '../../../globe/ground.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { imageAtlasLayout } from '../../shared/render/imageAtlas.js'; + +const LAYER_ID = 's2-ghostmaps'; +const TRANSPARENT = Object.freeze([0, 0, 0, 0]); +const WHITE = Object.freeze([1, 1, 1, 1]); +// Polygons under lines; placemarks are movers, over every ground fill. +const POLYGON_ORDER = 0; +const LINE_ORDER = 1; +// Atlas cells hold an icon at its own size up to this side; a larger icon +// keeps its drawn size, from a smaller texture. +const MAX_CELL_PX = 128; +const CELL_PAD_PX = 2; +// The horizon test lifts each placemark by this much: to first order the +// same margin as an occluder 1000 m under WGS84, since ground can lie below +// the ellipsoid (NYC −22 m). +const HORIZON_LIFT_M = 2000; + +const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); + +/** A KML colour (rgba in 0..1), else the product colour, at `alpha` when given. */ +function colorOf(rgba, fallback, alpha) { + if (rgba) return [rgba[0], rgba[1], rgba[2], alpha ?? rgba[3]]; + const { red, green, blue } = rgbaFromCss(fallback); + return [red, green, blue, alpha ?? 1]; +} + +/** Placemark scale: the KML icon scale at 0.7, within 0.35..1.5. */ +export const ghostMapsIconScale = (style) => + clamp((style.iconScale ?? 1) * 0.7, 0.35, 1.5); +/** Line width in pixels, within 1.5..8. */ +export const ghostMapsLineWidth = (style) => + clamp(style.lineWidth ?? 2, 1.5, 8); +/** Polygon outline width in pixels, within 1.5..6. */ +export const ghostMapsOutlineWidth = (style) => + clamp(style.lineWidth ?? 2, 1.5, 6); + +const imageFromUrl = async (url) => { + const image = new Image(); + image.src = url; + await image.decode(); + return image; +}; + +// An item the contract accepts, or null (a degenerate KML ring or line). +function checked(kind, item) { + try { + normalizeGroundItem(kind, item); + return item; + } catch { + return null; + } +} + +/** + * The items of one edition's features, by kind; each carries the feature + * index it belongs to (`report`), so a report can be dropped in place. + */ +function editionItems(productId, features, color, markerImage) { + const polygons = []; + const lines = []; + const pins = []; + for (const [index, feature] of features.entries()) { + const { style } = feature; + feature.geometries.forEach((geometry, part) => { + const id = `ghostmaps:${productId}:${index}:${part}`; + if (geometry.type === 'point') { + const url = style.icon || markerImage(color); + if (!url) return; + const [lon, lat] = geometry.position; + pins.push({ + report: index, + id, + url, + // On the ellipsoid: movers have no ground clamp. + position: geo.cartesian3.fromDegrees(lon, lat), + horizon: geo.cartesian3.fromDegrees(lon, lat, HORIZON_LIFT_M), + scale: ghostMapsIconScale(style), + color: style.icon && style.iconColor ? [...style.iconColor] : WHITE, + show: true, + }); + } else if (geometry.type === 'line') { + const item = checked('corridor', { + positions: geometry.positions, + color: colorOf(style.lineColor, color), + widthPx: ghostMapsLineWidth(style), + pickId: id, + }); + if (item) lines.push({ report: index, item }); + } else { + const fill = + style.fill === false + ? TRANSPARENT + : colorOf( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ); + const outline = + style.outline === false + ? null + : { + color: colorOf(style.lineColor, color), + widthPx: ghostMapsOutlineWidth(style), + }; + const area = checked('polygon', { + rings: [geometry.outer, ...geometry.holes], + fill, + // The outline runs on the outer ring only: a polygon with holes + // gets it as an item of its own. + ...(outline && !geometry.holes.length ? { outline } : {}), + pickId: id, + }); + if (!area) return; + polygons.push({ report: index, item: area }); + if (outline && geometry.holes.length) + polygons.push({ + report: index, + item: { + rings: [geometry.outer], + outline, + pickId: `${id}:outline`, + }, + }); + } + }); + } + return { polygons, lines, pins }; +} + +/** + * The atlas of `images` (url → decoded image): one square cell per image, + * the image centred in it. Returns the atlas and, per url, its cell and + * drawn side in pixels. + */ +function packIcons(images, createCanvas) { + const entries = [...images].map(([url, image], index) => { + const width = image.naturalWidth || image.width || 1; + const height = image.naturalHeight || image.height || 1; + const side = Math.max(width, height); + const cell = Math.min(MAX_CELL_PX, side); + return { url, image, width, height, side, key: String(index), cell }; + }); + const cell = Math.max(...entries.map((entry) => entry.cell)); + const layout = imageAtlasLayout( + entries.map(({ key, cell: size }) => ({ key, width: size, height: size })), + { cell: cell + 2 * CELL_PAD_PX, pad: CELL_PAD_PX }, + ); + const canvas = createCanvas(); + canvas.width = layout.width; + canvas.height = layout.height; + const context = canvas.getContext('2d'); + if (!context) throw new Error('GhostMaps icons need a canvas'); + const lookup = new Map(); + entries.forEach((entry, index) => { + const { x, y } = layout.rects[index]; + const fit = entry.cell / entry.side; + const width = entry.width * fit; + const height = entry.height * fit; + context.drawImage( + entry.image, + x + (entry.cell - width) / 2, + y + (entry.cell - height) / 2, + width, + height, + ); + lookup.set(entry.url, { key: entry.key, side: entry.side }); + }); + return { atlas: { canvas, cells: layout.cells }, lookup }; +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ loadImage?: (url: string) => Promise, + * createCanvas?: () => object }} [options] Test seams: icon decoding and + * the atlas canvas. + */ +export function createGhostMapsDisplay( + engine, + { + loadImage = imageFromUrl, + createCanvas = () => document.createElement('canvas'), + } = {}, +) { + if (!engine?.ground || !engine?.movers) + throw new TypeError('The GhostMaps display needs a globe engine'); + const paints = new Set(); + let sequence = 0; + let destroyed = false; + let removeCull = null; + const lastCamera = geo.cartesian3.create(Number.NaN, 0, 0); + const request = () => engine.render.request(LAYER_ID); + + // Hides the placemarks beyond the horizon; true when any show changed. + function cull(paint) { + let changed = false; + paint.pins.forEach((pin, index) => { + const show = engine.projector.horizonVisible(pin.horizon); + if (show === pin.show) return; + pin.show = show; + paint.sprites?.set(index, { show }); + changed = true; + }); + if (changed) paint.sprites?.commit(); + return changed; + } + function cullAll() { + let changed = false; + for (const paint of paints) changed = cull(paint) || changed; + if (changed) request(); + } + // Culls again whenever the camera moved, while any placemark is drawn. + function syncCull() { + const wanted = [...paints].some((paint) => paint.pins.length); + if (!wanted) { + removeCull?.(); + removeCull = null; + return; + } + removeCull ??= engine.frames.preRender.addEventListener(() => { + const live = engine.camera.livePosition; + if (geo.cartesian3.equals(live, lastCamera)) return; + geo.cartesian3.clone(live, lastCamera); + cullAll(); + }); + } + + function writePins(paint) { + const { sprites, pins, lookup } = paint; + if (!sprites) return; + pins.forEach((pin, index) => { + const cell = lookup.get(pin.url); + sprites.set(index, { + position: pin.position, + cell: cell.key, + scale: (pin.scale * cell.side) / paint.sizePx, + color: pin.color, + show: pin.show, + pickId: pin.id, + }); + }); + sprites.setCount(pins.length); + sprites.commit(); + } + + function commitGround(paint) { + paint.polygonSet.replace(paint.polygons.map(({ item }) => item)); + paint.polygonSet.commit(); + paint.lineSet.replace(paint.lines.map(({ item }) => item)); + paint.lineSet.commit(); + } + + async function drawPins(paint) { + const urls = [...new Set(paint.pins.map((pin) => pin.url))]; + const decoded = await Promise.allSettled(urls.map((url) => loadImage(url))); + if (paint.disposed) return; + const images = new Map(); + decoded.forEach((result, index) => { + if (result.status === 'fulfilled' && result.value) + images.set(urls[index], result.value); + }); + // An icon that does not decode draws nothing, as a billboard without + // an image. + paint.pins = paint.pins.filter((pin) => images.has(pin.url)); + if (!paint.pins.length) return; + const { atlas, lookup } = packIcons(images, createCanvas); + paint.lookup = lookup; + paint.sizePx = Math.max(...[...lookup.values()].map(({ side }) => side)); + paint.sprites = engine.movers.createSet({ + id: `${paint.prefix}:pins`, + kind: 'sprite', + capacity: paint.pins.length, + depthTest: 'always', + pickable: true, + sprite: { atlas, sizePx: paint.sizePx }, + }); + for (const pin of paint.pins) + pin.show = engine.projector.horizonVisible(pin.horizon); + writePins(paint); + syncCull(); + } + + return { + requestRender: request, + onClick: (run) => + engine.input.on('click', (event) => run(event, () => event.picked), { + pick: true, + }), + draw(productId, features, color, markerImage) { + if (destroyed) throw new Error('The GhostMaps display was destroyed'); + const prefix = `${LAYER_ID}:${productId}:${++sequence}`; + const { polygons, lines, pins } = editionItems( + productId, + features, + color, + markerImage, + ); + const paint = { + prefix, + polygons, + lines, + pins, + lookup: new Map(), + sizePx: 1, + sprites: null, + disposed: false, + polygonSet: engine.ground.createSet({ + id: `${prefix}:polygons`, + kind: 'polygon', + order: POLYGON_ORDER, + pickable: true, + }), + lineSet: engine.ground.createSet({ + id: `${prefix}:lines`, + kind: 'corridor', + order: LINE_ORDER, + pickable: true, + }), + }; + paints.add(paint); + const dispose = () => { + if (paint.disposed) return; + paint.disposed = true; + paints.delete(paint); + paint.polygonSet.destroy(); + paint.lineSet.destroy(); + paint.sprites?.destroy(); + paint.sprites = null; + paint.pins = []; + syncCull(); + request(); + }; + let ready; + try { + commitGround(paint); + ready = drawPins(paint).then(() => { + if (!paint.disposed) request(); + }); + } catch (error) { + dispose(); + throw error; + } + return { + ready, + // Take one report off in place: an expired event leaves its + // edition's drawing, which stays for the reports that remain. + drop(index) { + if (paint.disposed) return true; + const keep = ({ report }) => report !== index; + const before = paint.polygons.length + paint.lines.length; + paint.polygons = paint.polygons.filter(keep); + paint.lines = paint.lines.filter(keep); + if (paint.polygons.length + paint.lines.length !== before) + commitGround(paint); + const pins = paint.pins.filter(keep); + if (pins.length !== paint.pins.length) { + paint.pins = pins; + writePins(paint); + syncCull(); + } + request(); + return true; + }, + destroy: dispose, + }; + }, + destroy() { + if (destroyed) return; + destroyed = true; + removeCull?.(); + removeCull = null; + paints.clear(); + }, + }; +} diff --git a/src/layers/ghostMaps/render/display.test.mjs b/src/layers/ghostMaps/render/display.test.mjs new file mode 100644 index 000000000..e1ce42d0d --- /dev/null +++ b/src/layers/ghostMaps/render/display.test.mjs @@ -0,0 +1,515 @@ +// The GhostMaps display on the globe contract, on the fake engine and on the +// Cesium engine over a stub scene: KML polygons, lines and placemarks keep +// the styles the Cesium entities had (colours, alphas, widths, scales and +// order), picks name the report part, a report drops in place, placemarks +// beyond the horizon hide, and teardown leaves nothing drawn. +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 { rgbaFromCss } from '../../shared/color.js'; +import { createGhostMapsDisplay } from './display.js'; + +const PRODUCT = '#ff5a36'; +const product = rgbaFromCss(PRODUCT); +const near = (actual, expected, label) => + assert.ok( + actual.length === expected.length && + actual.every((value, index) => Math.abs(value - expected[index]) < 1e-6), + `${label}: ${[...actual]} vs ${expected}`, + ); + +// Records what the display hands each ground set, on any engine. +function recordGround(engine) { + const sets = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const entry = { options, items: [] }; + sets.set(options.id, entry); + const replace = set.replace.bind(set); + set.replace = (items) => { + entry.items = items; + replace(items); + }; + return set; + }; + return sets; +} + +function fakeHarness() { + const engine = createFakeEngine(); + return { + name: 'the fake engine', + engine, + frame: () => engine.fake.frame(), + click(picked) { + engine.fake.script('pick', picked); + engine.fake.input('click', { position: { x: 100, y: 100 } }); + }, + pin(id, index) { + const { store, options } = engine.fake.moverSet(id); + return { + shown: index < store.count && store.shows[index] === 1, + color: [...store.colors.slice(index * 4, index * 4 + 4)], + sizePx: options.sprite.sizePx * store.scales[index], + id: store.pickIds[index], + }; + }, + count: (id) => engine.fake.moverSet(id).store.count, + }; +} + +// Cesium's ground primitives as built, without a WebGL context: the +// options (instances, appearance, classification) the engine hands them. +const groundFactory = (type) => (options) => ({ + type, + ...options, + ready: true, + destroy() {}, + isDestroyed: () => false, +}); + +function cesiumHarness() { + const handlers = createFakeEventHandlers(); + const { engine, viewer, frame, primitive } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + groundFactories: { + fills: groundFactory('fills'), + lines: groundFactory('lines'), + }, + }, + }); + // The stub scene has no ground collection of its own. + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); + return { + name: 'the Cesium engine', + cesium: true, + engine, + viewer, + frame, + click(picked) { + native = !picked + ? undefined + : picked.ground + ? { id: { gevGroundSet: picked.owner, instance: picked.instance } } + : { primitive: primitive(picked.owner).get(picked.instance) }; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(100, 100), + }); + }, + pin(id, index) { + const item = primitive(id).get(index); + const count = engine.movers.list().find((set) => set.id === id).count; + return { + shown: index < count && item.show, + color: [ + item.color.red, + item.color.green, + item.color.blue, + item.color.alpha, + ], + sizePx: item.width * item.scale, + id: item.id, + }; + }, + count: (id) => engine.movers.list().find((set) => set.id === id).count, + // The ground primitives Cesium builds for a set. + groundPrimitives(id) { + const found = []; + const visit = (collection) => { + for (let index = 0; index < collection.length; index++) { + const child = collection.get(index); + if (child.gevGroundSet === id) found.push(child); + else if (child instanceof Cesium.PrimitiveCollection) visit(child); + } + }; + visit(viewer.scene.groundPrimitives); + return found; + }, + }; +} + +const HARNESSES = [fakeHarness, cesiumHarness]; + +// Seams for Node: decoded icons by URL and a canvas that records draws. +function iconSeams(sizes = {}) { + const draws = []; + return { + draws, + loadImage: async (url) => { + if (url === 'blob:broken') throw new Error('decode failed'); + const side = sizes[url] ?? 20; + return { naturalWidth: side, naturalHeight: side }; + }, + createCanvas: () => ({ + width: 0, + height: 0, + getContext: () => ({ drawImage: (...args) => draws.push(args) }), + }), + }; +} + +const square = (lon, lat, size = 1) => [ + [lon, lat], + [lon + size, lat], + [lon + size, lat + size], + [lon, lat + size], +]; + +/** Reports at the view centre (0°, 0°): a pin, a line, polygons. */ +function features() { + return new Map([ + [ + 0, + { + style: { icon: 'blob:icon-0', iconScale: 1, iconColor: [1, 0, 0, 0.5] }, + geometries: [{ type: 'point', position: [0, 0, 120] }], + }, + ], + [ + 1, + { + style: { lineWidth: 12, lineColor: [0, 1, 0, 0.75] }, + geometries: [ + { + type: 'line', + positions: [ + [-1, 0], + [1, 0], + ], + }, + // A placemark without its own icon takes the product marker. + { type: 'point', position: [0.5, 0.5] }, + ], + }, + ], + [ + 2, + { + style: { lineWidth: 1 }, + geometries: [ + { type: 'polygon', outer: square(-0.5, -0.5), holes: [] }, + { + type: 'polygon', + outer: square(2, 2, 2), + holes: [square(2.5, 2.5)], + }, + ], + }, + ], + [ + 3, + { + style: { + fill: false, + outline: false, + polyColor: [0, 0, 1, 0.6], + iconScale: 4, + }, + geometries: [ + { type: 'polygon', outer: square(5, 5), holes: [] }, + { type: 'point', position: [5, 5] }, + ], + }, + ], + [ + 4, + { + style: { polyColor: [0, 0, 1, 0.6], lineWidth: 9 }, + geometries: [{ type: 'polygon', outer: square(8, 8), holes: [] }], + }, + ], + ]); +} + +const markerImage = (color) => `data:marker-${color}`; + +async function drawn(harness, { sizes, list = features() } = {}) { + const ground = recordGround(harness.engine); + const seams = iconSeams(sizes); + harness.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(0, 0, 1e7), + }); + const display = createGhostMapsDisplay(harness.engine, seams); + const paint = display.draw('kinetic-30d', list, PRODUCT, markerImage); + await paint.ready; + const prefix = 's2-ghostmaps:kinetic-30d:1'; + return { + display, + paint, + ground, + seams, + polygons: () => ground.get(`${prefix}:polygons`), + lines: () => ground.get(`${prefix}:lines`), + pins: `${prefix}:pins`, + }; +} + +for (const makeHarness of HARNESSES) { + test(`polygons, lines and placemarks keep the Cesium styles (${makeHarness().name})`, async () => { + const harness = makeHarness(); + const { polygons, lines, pins, display } = await drawn(harness, { + sizes: { 'blob:icon-0': 32 }, + }); + const productAt = (alpha) => [ + product.red, + product.green, + product.blue, + alpha, + ]; + + // Polygons: under the lines, pickable; the product colour at 0.25 or the + // KML colour with its own alpha; outlines clamped to 1.5..6 px. + assert.equal(polygons().options.kind, 'polygon'); + assert.equal(polygons().options.order, 0); + assert.equal(polygons().options.pickable, true); + const areas = polygons().items; + assert.deepEqual( + areas.map(({ pickId }) => pickId), + [ + 'ghostmaps:kinetic-30d:2:0', + 'ghostmaps:kinetic-30d:2:1', + 'ghostmaps:kinetic-30d:2:1:outline', + 'ghostmaps:kinetic-30d:3:0', + 'ghostmaps:kinetic-30d:4:0', + ], + ); + near(areas[0].fill, productAt(0.25), 'product fill'); + near(areas[0].outline.color, productAt(1), 'product outline'); + assert.equal(areas[0].outline.widthPx, 1.5); + // With holes, the fill keeps them and the outline runs on the outer + // ring only. + assert.equal(areas[1].rings.length, 2); + assert.equal(areas[1].outline, undefined); + assert.equal(areas[2].rings.length, 1); + assert.equal(areas[2].fill, undefined); + assert.equal(areas[2].outline.widthPx, 1.5); + // fill: false stays clickable, drawn transparent; outline: false has none. + near(areas[3].fill, [0, 0, 0, 0], 'no fill'); + assert.equal(areas[3].outline, undefined); + near(areas[4].fill, [0, 0, 1, 0.6], 'KML fill'); + assert.equal(areas[4].outline.widthPx, 6); + + // Lines: ground corridors over the polygons, clamped to 1.5..8 px. + assert.equal(lines().options.kind, 'corridor'); + assert.equal(lines().options.order, 1); + assert.equal(lines().options.pickable, true); + assert.equal(lines().items.length, 1); + assert.equal(lines().items[0].widthPx, 8); + near(lines().items[0].color, [0, 1, 0, 0.75], 'KML line colour'); + assert.equal(lines().items[0].pickId, 'ghostmaps:kinetic-30d:1:0'); + + // Placemarks: over the scene, at the icon's own size times the KML + // scale at 0.7 (0.35..1.5); the KML colour only on the report's own icon. + assert.equal(harness.count(pins), 3); + const own = harness.pin(pins, 0); + assert.equal(own.id, 'ghostmaps:kinetic-30d:0:0'); + near(own.color, [1, 0, 0, 0.5], 'icon colour'); + assert.ok(Math.abs(own.sizePx - 32 * 0.7) < 1e-6, `${own.sizePx}`); + const marker = harness.pin(pins, 1); + assert.equal(marker.id, 'ghostmaps:kinetic-30d:1:1'); + near(marker.color, [1, 1, 1, 1], 'marker untinted'); + assert.ok(Math.abs(marker.sizePx - 20 * 0.7) < 1e-6); + const big = harness.pin(pins, 2); + assert.ok(Math.abs(big.sizePx - 20 * 1.5) < 1e-6, 'scale capped at 1.5'); + const set = harness.engine.movers.list().find(({ id }) => id === pins); + assert.equal(set.kind, 'sprite'); + if (harness.cesium) { + // The per-instance colours and widths Cesium batches. + const [fills, outlines] = harness.groundPrimitives(polygons().options.id); + assert.deepEqual( + [fills.type, outlines.type], + ['fills', 'lines'], + 'one fill and one outline batch', + ); + assert.equal(fills.classificationType, Cesium.ClassificationType.BOTH); + assert.equal(fills.allowPicking, true); + const colour = fills.geometryInstances[0].attributes.color.value; + assert.deepEqual([...colour], [255, 90, 54, 64]); + assert.equal(outlines.geometryInstances[0].geometry.width, 1.5); + const [corridors] = harness.groundPrimitives(lines().options.id); + assert.equal(corridors.geometryInstances[0].geometry.width, 8); + } else { + const { options } = harness.engine.fake.moverSet(pins); + assert.equal(options.depthTest, 'always'); + assert.equal(options.pickable, true); + } + display.destroy(); + }); + + test(`a click picks the report part under it (${makeHarness().name})`, async () => { + const harness = makeHarness(); + const { display, polygons, pins } = await drawn(harness); + const seen = []; + const remove = display.onClick((click, pick) => + seen.push({ position: click.position, picked: pick() }), + ); + harness.click({ + ground: true, + owner: polygons().options.id, + instance: 2, + id: 'ghostmaps:kinetic-30d:2:1:outline', + }); + harness.click({ + owner: pins, + instance: 1, + id: 'ghostmaps:kinetic-30d:1:1', + }); + harness.click(undefined); + assert.deepEqual( + seen.map(({ picked }) => picked?.id), + [ + 'ghostmaps:kinetic-30d:2:1:outline', + 'ghostmaps:kinetic-30d:1:1', + undefined, + ], + ); + assert.ok(seen.every(({ position }) => position)); + remove(); + harness.click(undefined); + assert.equal(seen.length, 3, 'removed listener hears nothing'); + display.destroy(); + }); + + test(`drop takes one report off in place; destroy leaves nothing (${makeHarness().name})`, async () => { + const harness = makeHarness(); + const { display, paint, polygons, lines, pins } = await drawn(harness); + const other = display.draw( + 'cip', + new Map([ + [ + 0, + { + style: {}, + geometries: [{ type: 'polygon', outer: square(0, 0), holes: [] }], + }, + ], + ]), + '#c8ff00', + markerImage, + ); + await other.ready; + assert.equal(paint.drop(1), true); + assert.equal(lines().items.length, 0, "the report's line goes"); + assert.equal(harness.count(pins), 2, 'and its placemark'); + assert.deepEqual( + [0, 1].map((index) => harness.pin(pins, index).id), + ['ghostmaps:kinetic-30d:0:0', 'ghostmaps:kinetic-30d:3:1'], + ); + assert.equal(paint.drop(2), true); + assert.deepEqual( + polygons().items.map(({ pickId }) => pickId), + ['ghostmaps:kinetic-30d:3:0', 'ghostmaps:kinetic-30d:4:0'], + 'every polygon and outline of the report goes', + ); + const committed = (id) => + harness.engine.ground.list().find((set) => set.id === id).count; + assert.equal(committed(polygons().options.id), 2); + assert.equal(paint.drop(9), true, 'an unknown report changes nothing'); + paint.destroy(); + assert.deepEqual( + harness.engine.ground.list().map(({ id }) => id), + ['s2-ghostmaps:cip:2:polygons', 's2-ghostmaps:cip:2:lines'], + 'the other edition stays', + ); + assert.equal( + harness.engine.movers.list().some(({ id }) => id === pins), + false, + ); + other.destroy(); + display.destroy(); + assert.deepEqual(harness.engine.ground.list(), []); + assert.deepEqual(harness.engine.movers.list(), []); + }); + + test(`placemarks beyond the horizon hide and come back with the camera (${makeHarness().name})`, async () => { + const harness = makeHarness(); + const list = new Map([ + [0, { style: {}, geometries: [{ type: 'point', position: [0, 0] }] }], + [1, { style: {}, geometries: [{ type: 'point', position: [180, 0] }] }], + ]); + const { display, pins } = await drawn(harness, { list }); + assert.deepEqual( + [0, 1].map((index) => harness.pin(pins, index).shown), + [true, false], + ); + harness.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(180, 0, 1e7), + }); + harness.frame(); + assert.deepEqual( + [0, 1].map((index) => harness.pin(pins, index).shown), + [false, true], + ); + display.destroy(); + }); + + test(`icons that do not decode and degenerate shapes draw nothing (${makeHarness().name})`, async () => { + const harness = makeHarness(); + const list = new Map([ + [ + 0, + { + style: { icon: 'blob:broken' }, + geometries: [ + { type: 'point', position: [0, 0] }, + { + type: 'polygon', + outer: [ + [0, 0], + [0, 0], + [0, 0], + ], + holes: [], + }, + { type: 'line', positions: [[1, 1]] }, + ], + }, + ], + [1, { style: {}, geometries: [{ type: 'point', position: [0, 0] }] }], + ]); + const { display, pins, polygons, lines, seams } = await drawn(harness, { + list, + }); + assert.equal(harness.count(pins), 1); + assert.equal(harness.pin(pins, 0).id, 'ghostmaps:kinetic-30d:1:0'); + assert.equal(seams.draws.length, 1, 'one icon in the atlas'); + assert.deepEqual(polygons().items, []); + assert.deepEqual(lines().items, []); + display.destroy(); + }); +} + +test('an edition destroyed while its icons decode draws no placemarks', async () => { + const engine = createFakeEngine(); + const pending = []; + const release = () => + pending.forEach((resolve) => + resolve({ naturalWidth: 20, naturalHeight: 20 }), + ); + const display = createGhostMapsDisplay(engine, { + ...iconSeams(), + loadImage: () => new Promise((resolve) => pending.push(resolve)), + }); + const paint = display.draw('cip', features(), PRODUCT, markerImage); + paint.destroy(); + release(); + await paint.ready; + assert.deepEqual(engine.movers.list(), []); + assert.deepEqual(engine.ground.list(), []); + display.destroy(); + assert.throws( + () => display.draw('cip', features(), PRODUCT, markerImage), + /destroyed/, + ); +}); diff --git a/src/layers/ghostMaps/render/navara.js b/src/layers/ghostMaps/render/navara.js deleted file mode 100644 index 21c735ac8..000000000 --- a/src/layers/ghostMaps/render/navara.js +++ /dev/null @@ -1,145 +0,0 @@ -import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; - -const clamp = (value, low, high) => Math.max(low, Math.min(high, value)); - -function tint(rgba, fallback) { - if (!rgba) return parseInt(fallback.slice(1), 16); - return ( - (Math.round(rgba[0] * 255) << 16) | - (Math.round(rgba[1] * 255) << 8) | - Math.round(rgba[2] * 255) - ); -} - -function closedRing(ring) { - const last = ring.at(-1); - return last[0] === ring[0][0] && last[1] === ring[0][1] - ? ring - : [...ring, ring[0]]; -} - -/** Native vector batches preserve each KML style and carry the source feature ID in picks. */ -export function createGhostMapsNavaraRenderer(engine, view) { - return { - requestRender: () => engine.render.request('s2-ghostmaps'), - onClick: (run) => - engine.input.on('click', (event) => run(event, () => event.picked), { - pick: true, - }), - draw(productId, features, fallback, markerImage) { - const groups = new Map(); - const shapes = []; - function add(style, geom, id) { - const key = JSON.stringify(style); - if (!groups.has(key)) groups.set(key, { style, features: [] }); - groups.get(key).features.push({ - type: 'Feature', - geometry: geom, - properties: { gevId: id }, - }); - } - for (const [index, feature] of features.entries()) { - const { style } = feature; - const line = { - color: tint(style.lineColor, fallback), - opacity: style.lineColor?.[3] ?? 1, - width: clamp(style.lineWidth ?? 2, 1.5, 8), - transparent: true, - clampToGround: true, - }; - for (const [part, geometry] of feature.geometries.entries()) { - const id = `ghostmaps:${productId}:${index}:${part}`; - shapes.push({ - id, - geometry, - style: { - ...style, - pointRadius: 10 * clamp((style.iconScale ?? 1) * 0.7, 0.35, 1.5), - }, - }); - if (geometry.type === 'point') { - const color = - style.iconColor && style.icon ? style.iconColor : null; - const billboard = { - url: style.icon || markerImage(fallback), - sizeInMeters: false, - size: 20 * clamp((style.iconScale ?? 1) * 0.7, 0.35, 1.5), - color: tint(color, '#ffffff'), - opacity: color?.[3] ?? 1, - clampToGround: true, - declutter: false, - offsetDepth: true, - }; - add( - { billboard }, - { type: 'Point', coordinates: geometry.position }, - id, - ); - } else if (geometry.type === 'line') { - add( - { polyline: line }, - { type: 'LineString', coordinates: geometry.positions }, - id, - ); - } else { - const outer = closedRing(geometry.outer); - if (style.fill !== false) - add( - { - polygon: { - color: tint(style.polyColor, fallback), - opacity: style.polyColor?.[3] ?? 0.25, - transparent: true, - clampToGround: true, - }, - }, - { - type: 'Polygon', - coordinates: [outer, ...geometry.holes.map(closedRing)], - }, - id, - ); - if (style.outline !== false) - add( - { polyline: { ...line, width: Math.min(6, line.width) } }, - { type: 'LineString', coordinates: outer }, - id, - ); - } - } - } - const batches = []; - let unbind; - const destroy = () => { - unbind?.(); - for (const batch of batches.splice(0)) { - batch.layer?.delete(); - batch.source.delete(); - } - engine.render.request('ghostmaps-release'); - }; - try { - for (const group of groups.values()) { - const source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features: group.features }, - }); - const batch = { source }; - batches.push(batch); - batch.layer = view.addLayer({ - type: 'vector', - source, - ...group.style, - }); - } - unbind = bindGroundShapePicks(engine, 's2-ghostmaps', shapes); - } catch (error) { - destroy(); - throw error; - } - engine.render.request('ghostmaps-data'); - return { destroy }; - }, - destroy() {}, - }; -} diff --git a/src/layers/gnss/index.js b/src/layers/gnss/index.js index 0146cc5cc..cb0f90944 100644 --- a/src/layers/gnss/index.js +++ b/src/layers/gnss/index.js @@ -1,5 +1,5 @@ -import { createGnssCesiumRenderer } from './render/cesium.js'; -export { defaultGnssViewAnchor } from './render/cesium.js'; +import { createGnssDisplay } from './render/display.js'; +export { defaultGnssViewAnchor } from './render/display.js'; import { formatNumber, msg, t } from '../../i18n/index.js'; import { translateFeedError } from '../../ui/statusText.js'; import { @@ -30,7 +30,8 @@ const LEVEL_LABELS = Object.freeze({ export function createGnssIntegrityLayer({ source, viewAnchor, - createRenderer = createGnssCesiumRenderer, + createRenderer = ({ engine, viewAnchor: anchor }) => + createGnssDisplay(engine, { viewAnchor: anchor }), now = () => Date.now(), monotonicNow = () => performance.now(), windowMs: requestedWindowMs, @@ -160,7 +161,7 @@ export function createGnssIntegrityLayer({ throw new Error('GNSS integrity layer is already initialized'); initialized = true; try { - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer(viewer, { engine }); } catch (error) { this.destroy(); throw error; @@ -170,7 +171,7 @@ export function createGnssIntegrityLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid GNSS scene attachment'); - renderer = createRenderer({ viewer, engine, viewAnchor }); + renderer = createRenderer({ engine, viewAnchor }); _removeCameraEnd = renderer.camera?.moveEnd?.addEventListener( scheduleCameraRefresh, ); diff --git a/src/layers/gnss/ownership.test.mjs b/src/layers/gnss/ownership.test.mjs index 97c0c3369..a03412f5f 100644 --- a/src/layers/gnss/ownership.test.mjs +++ b/src/layers/gnss/ownership.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import * as geo from '../../geo/index.js'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; +import { rgbaFromCss } from '../shared/color.js'; import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js'; import { LayerLifecycle } from '../../data/lifecycle.js'; import { layerFeedState } from '../../data/feedState.js'; @@ -23,22 +25,10 @@ function harness( } = {}, ) { let anchor = initialAnchor; - const sources = []; - const viewer = { - camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, - scene: { - globe: { show: true }, - groundPrimitives: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); - value.destroy(); - }, - }, - }, - }; + const ground = createFakeGroundHarness(); + const { engine } = ground; + // What the manager's lifecycle reads of a scene besides the engine. + const viewer = { scene: { globe: { show: true } } }; let clock = 1_000; const anchors = []; const layer = createGnssIntegrityLayer({ @@ -56,10 +46,16 @@ function harness( now: () => clock, monotonicNow: () => clock, }); - if (initialize) layer.init(viewer); + if (initialize) layer.init(null, { engine }); return { layer, - sources, + ground, + engine, + camera: engine.camera, + /** The drawn cells' ground set (undefined once released). */ + cells: () => ground.set('gnss-integrity'), + /** How many cells are drawn. */ + drawn: () => ground.set('gnss-integrity')?.items.length ?? 0, anchors, viewer, advance(ms) { @@ -83,16 +79,13 @@ const flush = () => new Promise((resolve) => setImmediate(resolve)); test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => { const anchor = defaultGnssViewAnchor({ - scene: { - canvas: { clientWidth: 1000, clientHeight: 800 }, - globe: { ellipsoid: Cesium.Ellipsoid.WGS84 }, - }, + host: { size: () => ({ width: 1000, height: 800 }) }, camera: { pickEllipsoid(pixel) { - assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400)); - return Cesium.Cartesian3.fromDegrees(-97, 30); + assert.deepEqual(pixel, { x: 500, y: 400 }); + return geo.cartesian3.fromDegrees(-97, 30); }, - computeViewRectangle: () => { + viewRectangle: () => { throw new Error('centre hit must not compute a rectangle'); }, }, @@ -111,16 +104,16 @@ test('pan and zoom refresh through the manager without toggling; nearby changes }, { initialize: false }, ); - const manager = new LayerLifecycle(h.viewer); + const manager = new LayerLifecycle(h.viewer, { engine: h.engine }); manager.register(h.layer); h.layer.attachDataManager(manager); const changes = []; manager.subscribe((change) => changes.push(change.type)); await manager.setEnabled(h.layer.id, true); h.setAnchor({ latitude: 54, longitude: 19 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.setAnchor({ latitude: 55, longitude: 20 }); - h.viewer.camera.moveEnd.raiseEvent(); + h.camera.moveEnd.raiseEvent(); h.advance(2500); t.mock.timers.tick(2500); await flush(); @@ -137,14 +130,14 @@ test('pan and zoom refresh through the manager without toggling; nearby changes }); assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/); h.setAnchor({ latitude: 55.1, longitude: 20.1 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.advance(5000); t.mock.timers.tick(5000); await flush(); assert.equal(calls, 2, 'nearby views reuse the same query region'); await manager.destroyAll(); - assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0); - assert.equal(h.viewer.camera.changed.numberOfListeners, 0); + assert.equal(h.camera.moveEnd.numberOfListeners, 0); + assert.equal(h.camera.changed.numberOfListeners, 0); }); test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => { @@ -162,12 +155,12 @@ test('movement during a pending refresh follows up at the newest view; disable c h.layer.enable(); await h.layer.update(); h.setAnchor({ latitude: 54, longitude: 19 }); - h.viewer.camera.moveEnd.raiseEvent(); + h.camera.moveEnd.raiseEvent(); h.advance(2500); t.mock.timers.tick(2500); await flush(); h.setAnchor({ latitude: 35, longitude: 140 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.advance(2500); t.mock.timers.tick(2500); await flush(); @@ -180,7 +173,7 @@ test('movement during a pending refresh follows up at the newest view; disable c assert.equal(calls.length, 3); assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 }); h.setAnchor({ latitude: 10, longitude: 10 }); - h.viewer.camera.moveEnd.raiseEvent(); + h.camera.moveEnd.raiseEvent(); h.layer.disable(); h.advance(5000); t.mock.timers.tick(5000); @@ -192,11 +185,11 @@ test('movement during a pending refresh follows up at the newest view; disable c test('the layer requires a snapshot source and cannot be initialized twice', () => { assert.throws(() => createGnssIntegrityLayer({}), TypeError); const { layer } = harness(async () => ({ rows: [] })); - assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/); + assert.throws(() => layer.init(null, { engine: {} }), /already/); }); test('an enabled refresh bins the snapshot into coloured ground cells', async () => { - const { layer, sources, anchors } = harness(async (anchor, { signal }) => { + const { layer, cells, anchors } = harness(async (anchor, { signal }) => { assert.deepEqual(anchor, { latitude: 50, longitude: 30 }); assert.ok(signal instanceof AbortSignal); return { @@ -213,13 +206,21 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async () assert.equal(await layer.update(), false, 'disabled layers do not fetch'); assert.equal(anchors.length, 0); layer.enable(); - assert.equal(sources[0].show, true); + assert.equal(cells().shown, true); assert.equal(await layer.update(), true); - const entities = sources[0].get(0).geometryInstances; - assert.equal(entities.length, 1, 'the one-aircraft cell is withheld'); - assert.equal(entities[0].id, 'gnss-integrity:280:420'); - assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry); - assert.equal(sources[0].get(0).allowPicking, false); + const { items, options } = cells(); + assert.equal(items.length, 1, 'the one-aircraft cell is withheld'); + // Cell 280:420, half a degree from 50° N, 30° E, at the high level. + assert.deepEqual( + { ...items[0], fill: undefined }, + { west: 30, south: 50, east: 30.5, north: 50.5, fill: undefined }, + ); + assert.deepEqual( + items[0].fill, + rgbaFromCss('#f85149').withAlpha(0.55).rgba(), + ); + assert.equal(options.kind, 'cell'); + assert.equal(options.pickable, false); const stats = layer.getStats(); assert.equal(stats.count, 1); assert.equal(stats.error, null); @@ -272,7 +273,7 @@ test('the visible layer leads with low navigation accuracy and exposes its prove test('a failed refresh keeps the last cells and reports the error', async () => { let fail = false; - const { layer, sources } = harness(async () => { + const { layer, drawn } = harness(async () => { if (fail) throw new Error('adsb.lol HTTP 502'); return { rows: [ @@ -288,14 +289,14 @@ test('a failed refresh keeps the last cells and reports the error', async () => assert.equal(layer.getStats().stale, true); fail = true; assert.equal(await layer.update(), true); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + assert.equal(drawn(), 1); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); assert.equal(layer.getStats().count, 1); }); test('observations expire with the window, and disable aborts the request in flight', async () => { let pending; - const { layer, sources, advance } = harness( + const { layer, drawn, cells, advance } = harness( (anchor, { signal }) => new Promise((resolve, reject) => { pending = { resolve }; @@ -315,17 +316,17 @@ test('observations expire with the window, and disable aborts the request in fli pending.resolve({ rows: [] }); assert.equal(await result, true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(drawn(), 0); result = layer.update(); layer.disable(); assert.equal(await result, false); - assert.equal(sources[0].show, false); + assert.equal(cells().shown, false); }); -test('no view anchor means no request, and destroy releases the data source', async () => { +test('no view anchor means no request, and destroy releases the ground set', async () => { let calls = 0; - const { layer, sources } = harness( + const { layer, ground } = harness( async () => { calls += 1; return { rows: [] }; @@ -341,13 +342,13 @@ test('no view anchor means no request, and destroy releases the data source', as assert.equal(calls, 0); assert.equal(layer.getStats().error, null); layer.destroy(); - assert.equal(sources.length, 0); + assert.equal(ground.sets(), 0); assert.equal(layer.getStats().count, 0); }); test('a replayed stale snapshot keeps its observation age and expires on time', async () => { // The proxy keeps answering with the snapshot it observed at t=1000. - const { layer, sources, advance, now } = harness(async () => ({ + const { layer, drawn, advance, now } = harness(async () => ({ rows: threeHealthy(), ageMs: now() - 1_000, stale: true, @@ -369,12 +370,12 @@ test('a replayed stale snapshot keeps its observation age and expires on time', 0, 'the replay did not restart the window', ); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(drawn(), 0); }); test('cells expire during prolonged fetch failures', async () => { let fail = false; - const { layer, sources, advance } = harness(async () => { + const { layer, drawn, advance } = harness(async () => { if (fail) throw new Error('adsb.lol HTTP 502'); return { rows: threeHealthy() }; }); @@ -387,13 +388,13 @@ test('cells expire during prolonged fetch failures', async () => { advance(11 * MIN); assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(drawn(), 0); assert.equal(layer.getStats().error, 'adsb.lol HTTP 502'); }); test('cells expire while no view anchor is available', async () => { let calls = 0; - const { layer, sources, advance, setAnchor } = harness(async () => { + const { layer, drawn, advance, setAnchor } = harness(async () => { calls += 1; return { rows: threeHealthy() }; }); @@ -407,36 +408,37 @@ test('cells expire while no view anchor is available', async () => { assert.equal(await layer.update(), true); assert.equal(calls, 1, 'no anchor means no request'); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(drawn(), 0); assert.equal(layer.getStats().error, null); }); test('re-enabling after a long disable does not show cells past the window', async () => { - const { layer, sources, advance } = harness(async () => ({ + const { layer, drawn, advance } = harness(async () => ({ rows: threeHealthy(), })); layer.enable(); await layer.update(); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); + assert.equal(drawn(), 1); layer.disable(); advance(31 * MIN); layer.enable(); assert.equal(layer.getStats().count, 0); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0); + assert.equal(drawn(), 0); }); const threeCells = () => ({ rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)], }); -test('an identical snapshot leaves the drawn entities untouched', async () => { - const { layer, sources } = harness(async () => threeCells()); +test('an identical snapshot leaves the drawn cells untouched', async () => { + const { layer, drawn, cells } = harness(async () => threeCells()); layer.enable(); await layer.update(); - const first = sources[0].get(0); + const first = cells(); await layer.update(); - assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1); - assert.equal(sources[0].get(0), first); + assert.equal(drawn(), 1); + assert.equal(cells().commits, first.commits); + assert.equal(cells().items, first.items); }); test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => { @@ -450,7 +452,7 @@ test('an initial feed failure stays enabled, reports unavailable, and recovers a }, { initialize: false }, ); - const manager = new LayerLifecycle(h.viewer); + const manager = new LayerLifecycle(h.viewer, { engine: h.engine }); manager.register(h.layer); h.layer.attachDataManager(manager); const changes = []; @@ -496,7 +498,7 @@ test('provider cooldown is preserved across movement and toggling; disable cance h.layer.enable(); await h.layer.update(); h.setAnchor({ latitude: 10, longitude: 20 }); - h.viewer.camera.moveEnd.raiseEvent(); + h.camera.moveEnd.raiseEvent(); h.advance(30000); t.mock.timers.tick(30000); await flush(); @@ -583,7 +585,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => h.layer.enable(); await h.layer.update(); h.setAnchor({ latitude: 51, longitude: 31 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.advance(2499); t.mock.timers.tick(2499); await flush(); @@ -594,7 +596,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) => assert.equal(calls, 2); h.advance(3000); h.setAnchor({ latitude: 52, longitude: 32 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.advance(599); t.mock.timers.tick(599); await flush(); @@ -619,7 +621,7 @@ test('camera movement during initial activation waits for acquisition before fol h.layer.enable(); const initial = h.layer.update(); h.setAnchor({ latitude: 51, longitude: 31 }); - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); h.advance(2500); t.mock.timers.tick(2500); await flush(); @@ -652,13 +654,13 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async ( now: () => wall, monotonicNow: () => mono, }); - layer.init(h.viewer); + layer.init(null, { engine: h.engine }); layer.enable(); await layer.update(); wall -= 3600000; mono = 3000; anchor = { latitude: 51, longitude: 31 }; - h.viewer.camera.changed.raiseEvent(); + h.camera.changed.raiseEvent(); mono += 600; t.mock.timers.tick(600); await flush(); @@ -666,37 +668,58 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async ( layer.destroy(); }); -test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => { +test('the cells stay out of picks', async () => { const h = harness(async () => ({ rows: threeHealthy() })); h.layer.enable(); await h.layer.update(); - const fill = h.sources[0].get(0); - let updates = 0; - t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++); - fill.update({ passes: { pick: true } }); - assert.equal(updates, 0); - fill.update({ passes: { render: true, pick: false } }); - assert.equal(updates, 1); + assert.equal(h.cells().options.pickable, false); + // A pick at the drawn cell (1° N, 30.2° E) finds nothing of the layer. + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(30.2, 1.2, 100_000), + }); + const { width, height } = h.engine.host.size(); + assert.equal( + h.engine.picker.pick({ x: width / 2, y: height / 2 }), + undefined, + ); h.layer.destroy(); - assert.equal(fill.isDestroyed(), true); + assert.equal(h.ground.sets(), 0); }); test('view anchor falls back to rectangle, then nadir, then no camera', () => { assert.equal(defaultGnssViewAnchor({}), null); - const viewer = { + const radians = geo.math.toRadians; + const engine = { + host: { size: () => ({ width: 1000, height: 800 }) }, camera: { - computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42), + pickEllipsoid: () => undefined, + viewRectangle: () => ({ + west: radians(20), + south: radians(40), + east: radians(22), + north: radians(42), + }), + pose: () => assert.fail('a rectangle must not read the pose'), }, }; - const rectangleAnchor = defaultGnssViewAnchor(viewer); + const rectangleAnchor = defaultGnssViewAnchor(engine); assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10); assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10); - viewer.camera = { - positionCartographic: Cesium.Cartographic.fromDegrees(21, 41), + engine.camera = { + pickEllipsoid: () => assert.fail('an empty canvas has no centre'), + viewRectangle: () => undefined, + pose: () => ({ + cartographic: { longitude: radians(21), latitude: radians(41) }, + }), }; - const nadirAnchor = defaultGnssViewAnchor(viewer); + engine.host = { size: () => ({ width: 0, height: 0 }) }; + const nadirAnchor = defaultGnssViewAnchor(engine); assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10); assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10); + engine.camera.pose = () => ({ + cartographic: { longitude: Number.NaN, latitude: 0 }, + }); + assert.equal(defaultGnssViewAnchor(engine), null); }); test('an in-flight regional response supplies the next renderer without a new request', async () => { @@ -712,15 +735,15 @@ test('an in-flight regional response supplies the next renderer without a new re h.layer.enable(); const pending = h.layer.update(); h.layer.detachRenderer(); - assert.equal(h.viewer.camera.changed.numberOfListeners, 0); + assert.equal(h.camera.changed.numberOfListeners, 0); resolve({ rows: threeHealthy() }); assert.equal(await pending, true); - assert.equal(h.sources.length, 0); + assert.equal(h.ground.sets(), 0); assert.equal(h.layer.getStats().levels.low, 1); - h.layer.attachRenderer(h.viewer); - assert.equal(h.sources[0].length, 1); + h.layer.attachRenderer(null, { engine: h.engine }); + assert.equal(h.drawn(), 1); assert.equal(calls, 1); - assert.equal(h.viewer.camera.changed.numberOfListeners, 1); + assert.equal(h.camera.changed.numberOfListeners, 1); } finally { h.layer.destroy(); } diff --git a/src/layers/gnss/render/cesium.js b/src/layers/gnss/render/cesium.js deleted file mode 100644 index 8e1e3c554..000000000 --- a/src/layers/gnss/render/cesium.js +++ /dev/null @@ -1,99 +0,0 @@ -import * as Cesium from 'cesium'; -import { GNSS_LEVEL_COLORS } from '../records.js'; - -/** Degrees at the centre of the current view, or null before the camera settles. */ -export function defaultGnssViewAnchor(viewer) { - const canvas = viewer?.scene?.canvas; - const ellipsoid = viewer?.scene?.globe?.ellipsoid; - // At globe scale the visible rectangle can be the whole world, whose - // midpoint is unrelated to the place under the screen centre. - const centre = - canvas?.clientWidth > 0 && - canvas.clientHeight > 0 && - viewer.camera?.pickEllipsoid?.( - new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2), - ellipsoid, - ); - const rectangle = - !centre && viewer?.camera?.computeViewRectangle?.(ellipsoid); - const carto = centre - ? (ellipsoid || Cesium.Ellipsoid.WGS84).cartesianToCartographic(centre) - : rectangle - ? Cesium.Rectangle.center(rectangle) - : viewer?.camera?.positionCartographic; - if (!carto) return null; - const latitude = Cesium.Math.toDegrees(carto.latitude); - const longitude = Cesium.Math.toDegrees(carto.longitude); - return Number.isFinite(latitude) && Number.isFinite(longitude) - ? { latitude, longitude } - : null; -} - -export function createGnssCesiumRenderer({ - viewer, - viewAnchor = defaultGnssViewAnchor, -}) { - // GroundPrimitive allowPicking:false still submits stencil commands in - // the pick pass. Keep this fill from erasing overlapping geometry's picks. - class GnssGroundFill extends Cesium.GroundPrimitive { - update(frameState) { - if (!frameState.passes.pick) super.update(frameState); - } - } - - const primitives = new Cesium.PrimitiveCollection(); - primitives.id = 'gnss-integrity'; - primitives.show = false; - viewer.scene.groundPrimitives.add(primitives); - return { - camera: viewer.camera, - anchor: () => viewAnchor(viewer), - setVisible(show) { - primitives.show = show; - }, - replace(cells, alpha) { - primitives.removeAll(); - if (cells.length) { - primitives.add( - new GnssGroundFill({ - geometryInstances: cells.map( - (cell) => - new Cesium.GeometryInstance({ - id: `gnss-integrity:${cell.id}`, - geometry: new Cesium.RectangleGeometry({ - rectangle: Cesium.Rectangle.fromDegrees( - cell.west, - cell.south, - cell.east, - cell.north, - ), - vertexFormat: - Cesium.PerInstanceColorAppearance.VERTEX_FORMAT, - }), - attributes: { - color: Cesium.ColorGeometryInstanceAttribute.fromColor( - Cesium.Color.fromCssColorString( - GNSS_LEVEL_COLORS[cell.level], - ).withAlpha(alpha[cell.level]), - ), - }, - }), - ), - appearance: new Cesium.PerInstanceColorAppearance({ - flat: true, - translucent: true, - }), - classificationType: Cesium.ClassificationType.BOTH, - allowPicking: false, - // Retain the compact rectangle descriptors for inspection, not Entity visualizers. - releaseGeometryInstances: false, - }), - ); - } - viewer?.scene?.requestRender?.(); - }, - destroy() { - viewer.scene.groundPrimitives.remove(primitives); - }, - }; -} diff --git a/src/layers/gnss/render/display.js b/src/layers/gnss/render/display.js new file mode 100644 index 000000000..5fc1e1040 --- /dev/null +++ b/src/layers/gnss/render/display.js @@ -0,0 +1,89 @@ +// The GNSS integrity cells on the globe contract (engine.ground, +// engine.camera), the same on every engine: one ground cell per binned +// cell, filled in its level's colour at the layer's alpha for that level, +// without an outline. The set is not pickable, so the cells never hide +// what lies under them from a pick. + +import * as geo from '../../../geo/index.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { viewRectangleCenter } from '../../shared/viewRectangle.js'; +import { GNSS_LEVEL_COLORS } from '../records.js'; + +const SET_ID = 'gnss-integrity'; + +/** + * Degrees at the centre of the current view: the ellipsoid under the screen + * centre, else the view rectangle's centre (at globe scale the rectangle can + * be the whole world, whose midpoint is unrelated to the place under the + * screen centre), else under the camera; null when none is finite. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @returns {{ latitude: number, longitude: number } | null} + */ +export function defaultGnssViewAnchor(engine) { + const camera = engine?.camera; + if (!camera) return null; + const { width, height } = engine.host?.size?.() ?? {}; + const centre = + width > 0 && + height > 0 && + camera.pickEllipsoid({ x: width / 2, y: height / 2 }); + let place = centre ? geo.cartographic.fromCartesian(centre) : undefined; + if (!place) { + const rectangle = camera.viewRectangle(); + place = rectangle + ? viewRectangleCenter(rectangle) + : camera.pose()?.cartographic; + } + if (!place) return null; + const latitude = geo.math.toDegrees(place.latitude); + const longitude = geo.math.toDegrees(place.longitude); + return Number.isFinite(latitude) && Number.isFinite(longitude) + ? { latitude, longitude } + : null; +} + +/** + * The cells: `{ camera, anchor, setVisible, replace(cells, alpha), destroy }`. + * `camera` is the engine's (its moveEnd and changed events); `anchor()` is + * `viewAnchor(engine)`. A cell is `{ west, south, east, north, level }` in + * degrees; `alpha` maps a level to its fill alpha. + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ viewAnchor?: (engine: object) => ({ latitude: number, longitude: number } | null) }} [options] + */ +export function createGnssDisplay( + engine, + { viewAnchor = defaultGnssViewAnchor } = {}, +) { + if (!engine?.ground) + throw new TypeError('The GNSS display needs a globe engine'); + const set = engine.ground.createSet({ id: SET_ID, kind: 'cell' }); + set.show(false); + let destroyed = false; + return { + camera: engine.camera, + anchor: () => viewAnchor(engine), + setVisible(show) { + if (!destroyed) set.show(show); + }, + replace(cells, alpha) { + if (destroyed) return; + set.replace( + cells.map(({ west, south, east, north, level }) => ({ + west, + south, + east, + north, + fill: rgbaFromCss(GNSS_LEVEL_COLORS[level]) + .withAlpha(alpha[level]) + .rgba(), + })), + ); + set.commit(); + }, + destroy() { + if (destroyed) return; + destroyed = true; + set.destroy(); + }, + }; +} diff --git a/src/layers/gnss/render/display.test.mjs b/src/layers/gnss/render/display.test.mjs new file mode 100644 index 000000000..3fb29dad9 --- /dev/null +++ b/src/layers/gnss/render/display.test.mjs @@ -0,0 +1,173 @@ +// The GNSS integrity cells on the engine contract, on the fake engine and +// on the Cesium engine (its ground batches over fake primitives, and once +// over Cesium's own GroundPrimitive): one unpicked cell per bin in its +// level's colour and alpha, no outline, the camera and view anchor the +// layer reads, visibility and teardown. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { + GROUND_HARNESSES, + createCesiumGroundHarness, + createFakeGroundHarness, + instanceColor, +} from '../../../testSupport/groundDisplayEngines.mjs'; +import { rgbaFromCss } from '../../shared/color.js'; +import { GNSS_LEVEL_COLORS } from '../records.js'; +import { createGnssDisplay, defaultGnssViewAnchor } from './display.js'; + +const ALPHA = Object.freeze({ low: 0.3, medium: 0.42, high: 0.55 }); +const cells = [ + { id: '280:420', west: 30, south: 50, east: 30.5, north: 50.5, level: 'low' }, + { + id: '280:421', + west: 30.5, + south: 50, + east: 31, + north: 50.5, + level: 'medium', + }, + { + id: '281:420', + west: 30, + south: 50.5, + east: 30.5, + north: 51, + level: 'high', + }, +]; +const fillOf = (level) => + rgbaFromCss(GNSS_LEVEL_COLORS[level]).withAlpha(ALPHA[level]).rgba(); + +const near = (actual, expected, label, tolerance = 1e-9) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +test('the display needs a globe engine', () => { + assert.throws(() => createGnssDisplay(null), TypeError); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`each cell is an unpicked ground cell in its level's colour and alpha (${name})`, () => { + const h = makeHarness(); + const display = createGnssDisplay(h.engine); + try { + assert.equal(h.set('gnss-integrity').shown, false, 'hidden until shown'); + display.setVisible(true); + display.replace(cells, ALPHA); + const set = h.set('gnss-integrity'); + assert.equal(set.options.kind, 'cell'); + assert.equal(set.options.pickable, false); + assert.equal(set.shown, true); + assert.deepEqual( + set.items, + cells.map(({ west, south, east, north, level }) => ({ + west, + south, + east, + north, + fill: fillOf(level), + })), + ); + if (h.cesium) { + const { fills, lines } = h.primitives('gnss-integrity'); + assert.equal(fills.length, 1, 'one ground batch'); + assert.equal(lines.length, 0, 'no outline batch'); + const { geometryInstances, classificationType, allowPicking } = + fills[0].options; + assert.equal(classificationType, Cesium.ClassificationType.BOTH); + assert.equal(allowPicking, false); + geometryInstances.forEach((instance, index) => { + const cell = cells[index]; + assert.ok(instance.geometry instanceof Cesium.RectangleGeometry); + const rectangle = instance.geometry._rectangle; + near(rectangle.west, geo.math.toRadians(cell.west), 'west'); + near(rectangle.south, geo.math.toRadians(cell.south), 'south'); + near(rectangle.east, geo.math.toRadians(cell.east), 'east'); + near(rectangle.north, geo.math.toRadians(cell.north), 'north'); + instanceColor(instance).forEach((channel, at) => + near( + channel, + fillOf(cell.level)[at], + `colour ${index}.${at}`, + 0.5 / 255, + ), + ); + }); + } + display.setVisible(false); + assert.equal(h.set('gnss-integrity').shown, false); + if (h.cesium) assert.equal(h.collectionShown('gnss-integrity'), false); + display.replace([], ALPHA); + assert.equal(h.set('gnss-integrity').items.length, 0); + if (h.cesium) + assert.equal(h.primitives('gnss-integrity').fills.length, 0); + } finally { + display.destroy(); + display.destroy(); + } + assert.equal(h.sets(), 0); + display.replace(cells, ALPHA); + display.setVisible(true); + h.destroy(); + }); + + test(`the camera and the view anchor are the engine's (${name})`, () => { + const h = makeHarness(); + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(30.2, 50.2, 400_000), + }); + const display = createGnssDisplay(h.engine); + assert.equal(display.camera, h.engine.camera); + const anchor = display.anchor(); + near(anchor.latitude, 50.2, 'latitude', 0.01); + near(anchor.longitude, 30.2, 'longitude', 0.01); + display.destroy(); + const seen = []; + const custom = createGnssDisplay(h.engine, { + viewAnchor: (engine) => { + seen.push(engine); + return { latitude: 1, longitude: 2 }; + }, + }); + assert.deepEqual(custom.anchor(), { latitude: 1, longitude: 2 }); + assert.deepEqual(seen, [h.engine]); + custom.destroy(); + h.destroy(); + }); +} + +test('the anchor under a globe-wide view is the screen centre (the fake engine)', () => { + const h = createFakeGroundHarness(); + h.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(-97, 30, 20_000_000), + }); + const anchor = defaultGnssViewAnchor(h.engine); + near(anchor.latitude, 30, 'latitude', 0.5); + near(anchor.longitude, -97, 'longitude', 0.01); + h.destroy(); +}); + +test("Cesium's ground fill emits no pick-pass commands but keeps normal rendering", (t) => { + const h = createCesiumGroundHarness({ realFills: true }); + const display = createGnssDisplay(h.engine); + display.setVisible(true); + display.replace(cells, ALPHA); + const [fill] = h.primitives('gnss-integrity').fills; + assert.ok(fill instanceof Cesium.GroundPrimitive); + let updates = 0; + t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++); + fill.update({ passes: { pick: true } }); + assert.equal(updates, 0); + fill.update({ passes: { render: true, pick: false } }); + assert.equal(updates, 1); + display.destroy(); + assert.equal(fill.isDestroyed(), true); + h.destroy(); +}); diff --git a/src/layers/gnss/render/navara.js b/src/layers/gnss/render/navara.js deleted file mode 100644 index 7e5d3bc20..000000000 --- a/src/layers/gnss/render/navara.js +++ /dev/null @@ -1,40 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { GNSS_LEVEL_COLORS } from '../records.js'; -import { rgbaFromCss } from '../../shared/color.js'; -import { viewRectangleCenter } from '../../shared/viewRectangle.js'; -import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js'; - -export function createGnssNavaraRenderer(engine, options) { - const cells = createGroundCellDisplay(engine, options, 0); - const { camera } = engine; - function anchor() { - const { width, height } = engine.host.size(); - const centre = - width && height && camera.pickEllipsoid({ x: width / 2, y: height / 2 }); - let position = centre && geo.cartographic.fromCartesian(centre); - if (!position) { - const rect = camera.viewRectangle(); - position = rect ? viewRectangleCenter(rect) : camera.pose().cartographic; - } - return { - latitude: geo.math.toDegrees(position.latitude), - longitude: geo.math.toDegrees(position.longitude), - }; - } - return { - camera, - anchor, - setVisible: cells.setVisible, - destroy: cells.destroy, - replace(rows, alpha) { - cells.replace( - rows.map((row) => ({ - ...row, - color: rgbaFromCss(GNSS_LEVEL_COLORS[row.level]).withAlpha( - alpha[row.level], - ), - })), - ); - }, - }; -} diff --git a/src/layers/gnss/sourceErrorLocale.test.mjs b/src/layers/gnss/sourceErrorLocale.test.mjs index a09019164..03a715e3d 100644 --- a/src/layers/gnss/sourceErrorLocale.test.mjs +++ b/src/layers/gnss/sourceErrorLocale.test.mjs @@ -2,7 +2,7 @@ // card's info line shows them in the UI language, the HTTP form included. import assert from 'node:assert/strict'; import test from 'node:test'; -import * as Cesium from 'cesium'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; import { setLocale } from '../../i18n/index.js'; import { createGnssIntegrityLayer } from './index.js'; @@ -20,13 +20,7 @@ async function failedLayer(message) { now: () => 1_000, monotonicNow: () => 1_000, }); - layer.init({ - camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() }, - scene: { - globe: { show: true }, - groundPrimitives: { add() {}, remove() {} }, - }, - }); + layer.init(null, { engine: createFakeEngine() }); layer.enable(); await layer.update(); return layer; diff --git a/src/layers/perimeters/index.js b/src/layers/perimeters/index.js index 40677feb0..0d02432dc 100644 --- a/src/layers/perimeters/index.js +++ b/src/layers/perimeters/index.js @@ -1,6 +1,5 @@ import { fromDegrees } from '../../geo/cartesian3.js'; -import { createPerimetersCesiumRenderer } from './render/cesium.js'; -export { containmentColor } from './render/cesium.js'; +import { createPerimetersDisplay } from './render/display.js'; import { PERIMETER_OVERLAY_SOURCE_ID, perimeterAnchorDegrees, @@ -51,13 +50,12 @@ function samePolygons(left, right) { export function createFirePerimetersLayer({ source, overlayHost = null, - screenSpaceEventHandlerFactory = null, picking = null, pointer = null, inciwebSource = null, inciwebPublications = null, openExternal = null, - createRenderer = createPerimetersCesiumRenderer, + createRenderer = ({ engine }) => createPerimetersDisplay(engine), } = {}) { if (typeof source?.getSnapshot !== 'function') throw new TypeError('Fire perimeters require a snapshot source'); @@ -90,8 +88,7 @@ export function createFirePerimetersLayer({ _linkRequest = null; } - const canSelect = () => - overlayHost && screenSpaceEventHandlerFactory && picking; + const canSelect = () => overlayHost && picking; /** * Refresh the InciWeb catalog off the render path: a hanging or failed @@ -273,7 +270,7 @@ export function createFirePerimetersLayer({ throw new Error('Fire perimeter layer is already initialized'); initialized = true; try { - if (viewer || engine) this.attachRenderer(viewer, { engine }); + if (engine) this.attachRenderer(viewer, { engine }); } catch (error) { this.destroy(); throw error; @@ -283,11 +280,7 @@ export function createFirePerimetersLayer({ attachRenderer(viewer, { engine = null } = {}) { if (!initialized || renderer) throw new Error('Invalid fire perimeter scene attachment'); - renderer = createRenderer({ - viewer, - engine, - screenSpaceEventHandlerFactory, - }); + renderer = createRenderer({ engine }); renderer.setVisible(_enabled); if (geometry.size) renderer.replace([...geometry.values()]); if (_enabled) { diff --git a/src/layers/perimeters/ownership.test.mjs b/src/layers/perimeters/ownership.test.mjs index b855dcf8f..90f098d8c 100644 --- a/src/layers/perimeters/ownership.test.mjs +++ b/src/layers/perimeters/ownership.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs'; import { createFirePerimetersLayer } from './index.js'; function harness( @@ -14,22 +16,14 @@ function harness( }), } = {}, ) { - const sources = []; const overlay = { entries: new Map(), visible: null }; - const clicks = { handler: null, destroyed: 0 }; const owners = new Map(); const opened = []; - const viewer = { - scene: { pick }, - dataSources: { - add(value) { - sources.push(value); - }, - remove(value) { - sources.splice(sources.indexOf(value), 1); - }, - }, - }; + const ground = createFakeGroundHarness(); + const { engine } = ground; + // What a click picks: `scene.pick()` (null for nothing). + const scene = { pick }; + engine.fake.script('pick', () => scene.pick() ?? undefined); const layer = createFirePerimetersLayer({ source, overlayHost: { @@ -44,15 +38,6 @@ function harness( }, hitTest: (x, y, options) => cardHit(x, y, options), }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction(callback) { - clicks.handler = callback; - }, - destroy() { - clicks.destroyed += 1; - clicks.handler = null; - }, - }), picking: { resolvePickId: (picked) => picked?.id ?? null, isOwnedByOtherLayer: (layerId, pickedId) => @@ -67,9 +52,45 @@ function harness( inciwebPublications: { getPublication: publication }, openExternal: (url) => opened.push(url), }); - layer.init(viewer); - layer.enable(viewer); - return { layer, viewer, sources, overlay, clicks, owners, opened }; + layer.init(null, { engine }); + layer.enable(); + return { + layer, + engine, + ground, + scene, + overlay, + owners, + opened, + /** The perimeters' ground set (undefined once released). */ + zones: () => ground.set('fire-perimeters'), + /** A click through the engine's input, picking `scene.pick()`. */ + click: (event) => engine.fake.input('click', event), + }; +} + +// A layer on its own fake engine, for tests that only refresh. +function plainLayer(options) { + const layer = createFirePerimetersLayer({ + overlayHost: { + setEntries() {}, + setVisible() {}, + clearSource() {}, + hitTest: () => null, + }, + picking: { + resolvePickId: () => null, + isOwnedByOtherLayer: () => false, + registerPickOwner() {}, + unregisterPickOwner() {}, + }, + pointer: { isPointerFree: () => true }, + openExternal: () => {}, + ...options, + }); + layer.init(null, { engine: createFakeEngine() }); + layer.enable(); + return layer; } const ring = [ @@ -97,21 +118,27 @@ const row = { polygons: [[ring]], }; -test('each perimeter polygon renders as one filled entity with its fire line', async () => { +test('each perimeter polygon renders as one fill with its fire line', async () => { const h = harness({ getSnapshot: async () => [ row, { ...row, stableId: 'other', polygons: [[ring], [ring]] }, ], }); - assert.equal(await h.layer.update(h.viewer), true); - const entities = h.sources[0].entities.values; - assert.equal(entities.length, 3); - assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0'); - assert.ok(entities[0].polygon, 'perimeter fill missing'); - assert.ok(entities[0].polyline, 'fire line missing'); - assert.equal(entities[1].id, 'fire-perimeter:other:0'); - assert.equal(entities[2].id, 'fire-perimeter:other:1'); + assert.equal(await h.layer.update(), true); + const { items } = h.zones(); + const ids = [ + 'fire-perimeter:2026-NMGNF-000123:0', + 'fire-perimeter:other:0', + 'fire-perimeter:other:1', + ]; + assert.equal(items.length, 6, 'a fill and a fire line per polygon'); + assert.deepEqual( + items.map(({ pickId }) => pickId), + [...ids, ...ids], + ); + assert.ok(items.slice(0, 3).every((item) => item.fill && !item.outline)); + assert.ok(items.slice(3).every((item) => item.outline && !item.fill)); assert.equal(h.layer.getStats().count, 2); }); @@ -126,14 +153,14 @@ test('late refresh cannot publish after disable or destroy', async () => { }); }, }); - const pending = h.layer.update(h.viewer); - h.layer[action](h.viewer); + const pending = h.layer.update(); + h.layer[action](); assert.equal(signal.aborted, true); - if (action === 'disable') h.layer.enable(h.viewer); + if (action === 'disable') h.layer.enable(); resolve([row]); assert.equal(await pending, false); assert.equal(h.layer.getStats().count, 0); - h.layer.destroy(h.viewer); + h.layer.destroy(); } }); @@ -145,8 +172,8 @@ test('clicking a perimeter publishes its incident card; empty space clears it', pick: () => pickResult, }, ); - await h.layer.update(h.viewer); - assert.equal(typeof h.clicks.handler, 'function', 'click handler missing'); + await h.layer.update(); + assert.equal(h.ground.listeners(), 1, 'click handler missing'); // Deliberately NOT registered as a pick owner: sibling layers' click // handlers yield to owned picks before their own card hit-tests, so // claiming these large ground polygons would make FIRMS/vessel/CCTV @@ -154,7 +181,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it', assert.equal(h.owners.size, 0); pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' }; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); const entries = h.overlay.entries.get('fire-perimeters'); assert.equal(entries.length, 1); assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123'); @@ -162,7 +189,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it', assert.ok(entries[0].position, 'card must carry a world anchor'); pickResult = { id: 'other-layer:aircraft-1' }; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); assert.equal( h.overlay.entries.get('fire-perimeters')?.length, 1, @@ -170,7 +197,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it', ); pickResult = null; - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0); }); @@ -179,10 +206,10 @@ test('disable removes the click handler, pick ownership, and any card', async () { getSnapshot: async () => [row] }, { pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) }, ); - await h.layer.update(h.viewer); - h.clicks.handler({ position: { x: 10, y: 10 } }); - h.layer.disable(h.viewer); - assert.equal(h.clicks.destroyed, 1); + await h.layer.update(); + h.click({ position: { x: 10, y: 10 } }); + h.layer.disable(); + assert.equal(h.ground.listeners(), 0); assert.equal(h.owners.has('fire-perimeters'), false); assert.equal(h.overlay.entries.has('fire-perimeters'), false); }); @@ -193,11 +220,11 @@ test('a refresh that drops the selected incident also drops its card', async () { getSnapshot: async () => rows }, { pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) }, ); - await h.layer.update(h.viewer); - h.clicks.handler({ position: { x: 10, y: 10 } }); + await h.layer.update(); + h.click({ position: { x: 10, y: 10 } }); assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); rows = [{ ...row, stableId: 'different' }]; - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0); }); @@ -218,12 +245,12 @@ test('a matched incident card carries the InciWeb line and click-through', async cardHit: () => cardHitResult, }, ); - await h.layer.update(h.viewer); + await h.layer.update(); // Select via a perimeter pick. - h.viewer.scene.pick = () => ({ + h.scene.pick = () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0', }); - h.clicks.handler({ position: { x: 10, y: 10 } }); + h.click({ position: { x: 10, y: 10 } }); // The card publishes immediately; the link line lands once the // publication's currency is validated. assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); @@ -233,8 +260,8 @@ test('a matched incident card carries the InciWeb line and click-through', async // Clicking the card itself opens the InciWeb page. cardHitResult = { entryId: entries[0].id }; - h.viewer.scene.pick = () => null; - h.clicks.handler({ position: { x: 12, y: 12 } }); + h.scene.pick = () => null; + h.click({ position: { x: 12, y: 12 } }); assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']); assert.equal( h.overlay.entries.get('fire-perimeters').length, @@ -253,16 +280,16 @@ test('an unmatched incident renders no InciWeb line and card clicks stay inert', cardHit: () => cardHitResult, }, ); - await h.layer.update(h.viewer); - h.clicks.handler({ position: { x: 10, y: 10 } }); + await h.layer.update(); + h.click({ position: { x: 10, y: 10 } }); const entries = h.overlay.entries.get('fire-perimeters'); assert.equal( entries[0].details.some((line) => line.includes('InciWeb')), false, ); cardHitResult = { entryId: entries[0].id }; - h.viewer.scene.pick = () => null; - h.clicks.handler({ position: { x: 12, y: 12 } }); + h.scene.pick = () => null; + h.click({ position: { x: 12, y: 12 } }); assert.deepEqual(h.opened, []); assert.equal(h.overlay.entries.get('fire-perimeters').length, 1); }); @@ -283,8 +310,8 @@ test('a complex member resolves to its complex page from the catalog', async () ], }, ); - await h.layer.update(h.viewer); - h.clicks.handler({ position: { x: 10, y: 10 } }); + await h.layer.update(); + h.click({ position: { x: 10, y: 10 } }); await new Promise((done) => setTimeout(done, 0)); const entries = h.overlay.entries.get('fire-perimeters'); assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open'); @@ -317,8 +344,8 @@ test('a stale or unverifiable publication yields no link', async () => { publication, }, ); - await h.layer.update(h.viewer); - h.clicks.handler({ position: { x: 10, y: 10 } }); + await h.layer.update(); + h.click({ position: { x: 10, y: 10 } }); await new Promise((done) => setTimeout(done, 0)); const entries = h.overlay.entries.get('fire-perimeters'); assert.equal(entries.length, 1); @@ -327,99 +354,47 @@ test('a stale or unverifiable publication yields no link', async () => { false, ); assert.equal(entries[0].interactive, false); - h.layer.destroy(h.viewer); + h.layer.destroy(); } }); test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => { const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null }); // Replace the index source with one that never resolves. - h.layer.destroy(h.viewer); + h.layer.destroy(); const hanging = harness({ getSnapshot: async () => [row] }); - hanging.layer.destroy(hanging.viewer); - const sources = []; - const viewer = { - scene: { pick: () => null }, - dataSources: { - add: (v) => sources.push(v), - remove: (v) => sources.splice(sources.indexOf(v), 1), - }, - }; - const layer = createFirePerimetersLayer({ + hanging.layer.destroy(); + const layer = plainLayer({ source: { getSnapshot: async () => [row] }, - overlayHost: { - setEntries() {}, - setVisible() {}, - clearSource() {}, - hitTest: () => null, - }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - picking: { - resolvePickId: () => null, - isOwnedByOtherLayer: () => false, - registerPickOwner() {}, - unregisterPickOwner() {}, - }, - pointer: { isPointerFree: () => true }, inciwebSource: { getIndex: () => new Promise(() => {}) }, inciwebPublications: { getPublication: async () => ({ createdMs: 1, changedMs: 1 }), }, - openExternal: () => {}, }); - layer.init(viewer); - layer.enable(viewer); - assert.equal(await layer.update(viewer), true); + assert.equal(await layer.update(), true); assert.equal(layer.getStats().count, 1); - layer.destroy(viewer); + layer.destroy(); }); test('an InciWeb outage never breaks the perimeter refresh', async () => { const h = harness({ getSnapshot: async () => [row] }); - const failing = createFirePerimetersLayer({ + const failing = plainLayer({ source: { getSnapshot: async () => [row] }, - overlayHost: { - setEntries() {}, - setVisible() {}, - clearSource() {}, - hitTest: () => null, - }, - screenSpaceEventHandlerFactory: () => ({ - setInputAction() {}, - destroy() {}, - }), - picking: { - resolvePickId: () => null, - isOwnedByOtherLayer: () => false, - registerPickOwner() {}, - unregisterPickOwner() {}, - }, - pointer: { isPointerFree: () => true }, inciwebSource: { getIndex: async () => { throw new Error('InciWeb HTTP 503'); }, }, - openExternal: () => {}, }); - const viewer = { - scene: { pick: () => null }, - dataSources: { add() {}, remove() {} }, - }; - failing.init(viewer); - failing.enable(viewer); - assert.equal(await failing.update(viewer), true); + assert.equal(await failing.update(), true); assert.equal(failing.getStats().count, 1); - failing.destroy(viewer); - h.layer.destroy(h.viewer); + failing.destroy(); + h.layer.destroy(); }); test('analyst records expose incident facts without geometry payloads', async () => { const h = harness({ getSnapshot: async () => [row] }); - await h.layer.update(h.viewer); + await h.layer.update(); const records = h.layer.getAnalystRecords(); assert.equal(records.length, 1); assert.equal(records[0].name, 'Fixture Fire'); @@ -438,40 +413,34 @@ test('analyst records expose incident facts without geometry payloads', async () assert.equal('polygons' in records[0], false); }); -test('unchanged snapshots retain entity identity even if source ordering changes', async () => { +test('unchanged snapshots keep the drawn perimeters even if source ordering changes', async () => { let rows = [row, { ...row, stableId: 'other' }]; const h = harness({ getSnapshot: async () => rows }); await h.layer.update(); - const original = [...h.sources[0].entities.values]; - let removals = 0; - const entities = h.sources[0].entities; - const removeAll = entities.removeAll.bind(entities); - entities.removeAll = () => { - removals++; - removeAll(); - }; + const original = h.zones(); + const redraws = () => h.zones().commits - original.commits; rows = [...rows].reverse(); await h.layer.update(); - assert.equal(removals, 0); - assert.equal(entities.values[0], original[0]); + assert.equal(redraws(), 0); + assert.equal(h.zones().items, original.items); rows = rows.map((value) => ({ ...value, updatedTime: value.updatedTime + 1, })); await h.layer.update(); - assert.equal(removals, 1); - assert.notEqual(entities.values[0], original[0]); + assert.equal(redraws(), 1); + assert.notEqual(h.zones().items, original.items); rows = rows.map((value) => ({ ...value, containedPct: 100 })); await h.layer.update(); assert.equal( - removals, + redraws(), 2, 'incident facts can change without a new polygon timestamp', ); assert.equal(h.layer.getRowControls().legend[3].count, 2); rows = []; await h.layer.update(); - assert.equal(entities.values.length, 0); + assert.equal(h.zones().items.length, 0); h.layer.destroy(); }); @@ -538,11 +507,11 @@ for (const action of ['disable', 'destroy', 'selection']) { }, ); await h.layer.update(); - h.clicks.handler({ position: { x: 1, y: 1 } }); + h.click({ position: { x: 1, y: 1 } }); assert.equal(signal.aborted, false); if (action === 'selection') { pick = { id: 'fire-perimeter:other:0' }; - h.clicks.handler({ position: { x: 1, y: 1 } }); + h.click({ position: { x: 1, y: 1 } }); } else h.layer[action](); assert.equal(signal.aborted, true); const entries = h.overlay.entries.get('fire-perimeters'); @@ -560,11 +529,11 @@ test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clea getSnapshot: async () => snapshot.rows, getSnapshotDetail: async () => snapshot, }); - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, true); assert.equal(h.layer.getStats().lastUpdate, fetchedAt); snapshot = { rows: [row], stale: false, fetchedAt: Date.now() }; - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, false); assert.ok(h.layer.getStats().lastUpdate > fetchedAt); }); @@ -578,10 +547,10 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale', return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 }; }, }); - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, true); fail = true; - await h.layer.update(h.viewer); + await h.layer.update(); assert.equal(h.layer.getStats().stale, false); assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502'); }); @@ -603,19 +572,20 @@ test('a selected fire retains its card and changed polygon through a scene hando ); try { await h.layer.update(); - h.clicks.handler({ position: { x: 0, y: 0 } }); + h.click({ position: { x: 0, y: 0 } }); assert.equal( h.overlay.entries.get('fire-perimeters')[0].id, 'fire-perimeter-card:incident:one', ); h.layer.detachRenderer(); - assert.equal(h.sources.length, 0); + assert.equal(h.ground.sets(), 0); geometry = [[ring.map(([lon, lat]) => [lon + 0.1, lat])]]; assert.equal(await h.layer.update(), true); const record = h.layer.getAnalystRecords()[0]; assert.equal(record.id, 'incident:one'); - h.layer.attachRenderer(h.viewer); - assert.equal(h.sources[0].entities.values.length, 1); + h.layer.attachRenderer(null, { engine: h.engine }); + assert.equal(h.zones().items.length, 2, 'the fill and its fire line'); + assert.equal(h.zones().items[0].rings[0][0][0], ring[0][0] + 0.1); assert.equal( h.overlay.entries.get('fire-perimeters')[0].id, 'fire-perimeter-card:incident:one', diff --git a/src/layers/perimeters/render/cesium.js b/src/layers/perimeters/render/cesium.js deleted file mode 100644 index fa411e1f9..000000000 --- a/src/layers/perimeters/render/cesium.js +++ /dev/null @@ -1,20 +0,0 @@ -import * as Cesium from 'cesium'; -import { containmentAccent } from '../cards.js'; -import { createZonesCesiumRenderer } from '../../shared/render/cesiumZones.js'; -/** Fill/line color for a perimeter by containment progress (same ramp as the card accent). */ -export function containmentColor(containedPct) { - return Cesium.Color.fromCssColorString(containmentAccent(containedPct)); -} - -export function createPerimetersCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, -}) { - return createZonesCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, - name: 'fire-perimeters', - idPrefix: 'fire-perimeter', - colorOf: (row) => containmentAccent(row.containedPct), - }); -} diff --git a/src/layers/perimeters/render/display.js b/src/layers/perimeters/render/display.js new file mode 100644 index 000000000..8eb57e028 --- /dev/null +++ b/src/layers/perimeters/render/display.js @@ -0,0 +1,83 @@ +// The fire perimeters on the globe contract (engine.ground, engine.input), +// the same on every engine: each row's polygons (holes kept) as a 0.25 +// fill in its containment colour, under a 2 px ground outline at 0.9 along +// the outer ring only, every polygon and its outline picked as +// `fire-perimeter:${stableId}:${index}`. All fills come before all outlines +// in the set, so an outline is never covered by a later fill (on Cesium the +// outlines are one batch over the fills). + +import { rgbaFromCss } from '../../shared/color.js'; +import { containmentAccent } from '../cards.js'; + +const SET_ID = 'fire-perimeters'; +const FILL_ALPHA = 0.25; +const OUTLINE_ALPHA = 0.9; +const OUTLINE_PX = 2; + +/** What a pick of a perimeter's polygon returns. */ +export const perimeterPickId = (row, index) => + `fire-perimeter:${row.stableId}:${index}`; + +/** + * The perimeters: `{ setVisible, replace(rows), onClick(listener) → remove, + * pick(click), destroy }`. A click listener gets the engine's click with its + * pick; pick(click) is that pick (null for none). + * @param {import('../../../globe/contract.js').GlobeEngine} engine + */ +export function createPerimetersDisplay(engine) { + if (!engine?.ground) + throw new TypeError('The fire perimeters display needs a globe engine'); + const set = engine.ground.createSet({ + id: SET_ID, + kind: 'polygon', + pickable: true, + }); + set.show(false); + let destroyed = false; + const removers = new Set(); + return { + setVisible(show) { + if (!destroyed) set.show(show); + }, + replace(rows) { + if (destroyed) return; + const fills = []; + const outlines = []; + for (const row of rows) { + const { red, green, blue } = rgbaFromCss( + containmentAccent(row.containedPct), + ); + for (const [index, rings] of row.polygons.entries()) { + const pickId = perimeterPickId(row, index); + fills.push({ rings, fill: [red, green, blue, FILL_ALPHA], pickId }); + outlines.push({ + rings: rings.slice(0, 1), + outline: { + color: [red, green, blue, OUTLINE_ALPHA], + widthPx: OUTLINE_PX, + }, + pickId, + }); + } + } + set.replace([...fills, ...outlines]); + set.commit(); + }, + onClick(listener) { + if (destroyed) return () => {}; + const remove = engine.input.on('click', listener, { pick: true }); + const off = () => { + if (removers.delete(off)) remove(); + }; + removers.add(off); + return off; + }, + pick: (click) => click?.picked ?? null, + destroy() { + if (destroyed) return; + destroyed = true; + for (const off of [...removers]) off(); + set.destroy(); + }, + }; +} diff --git a/src/layers/perimeters/render/display.test.mjs b/src/layers/perimeters/render/display.test.mjs new file mode 100644 index 000000000..4b28d3954 --- /dev/null +++ b/src/layers/perimeters/render/display.test.mjs @@ -0,0 +1,269 @@ +// The fire perimeters on the engine contract, on the fake engine and on the +// Cesium engine (its ground batches over fake primitives): fills with their +// holes at 0.25 and a 2 px fire line at 0.9 along the outer ring only, in +// the containment colour; clicks through the engine's input selecting an +// incident's card; visibility and teardown. + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import * as Cesium from 'cesium'; +import * as geo from '../../../geo/index.js'; +import { + GROUND_HARNESSES, + createFakeGroundHarness, + instanceColor, +} from '../../../testSupport/groundDisplayEngines.mjs'; +import { rgbaFromCss } from '../../shared/color.js'; +import { containmentAccent } from '../cards.js'; +import { createFirePerimetersLayer } from '../index.js'; +import { createPerimetersDisplay } from './display.js'; + +const outer = [ + [-108.2, 35.1], + [-107.9, 35.1], + [-107.9, 35.4], + [-108.2, 35.4], + [-108.2, 35.1], +]; +const hole = [ + [-108.1, 35.2], + [-108.0, 35.2], + [-108.0, 35.3], + [-108.1, 35.2], +]; +const second = outer.map(([lon, lat]) => [lon + 1, lat]); +const row = { + stableId: '2026-NMGNF-000123', + name: 'Fixture Fire', + acres: 512.5, + containedPct: 40, + state: 'US-NM', + discoveredTime: 1757900000000, + updatedTime: 1757950000000, + polygons: [[outer, hole]], +}; +const other = { + ...row, + stableId: 'other', + name: 'Other Fire', + containedPct: 100, + polygons: [[second], [second.map(([lon, lat]) => [lon, lat + 1])]], +}; +const PICKS = [ + 'fire-perimeter:2026-NMGNF-000123:0', + 'fire-perimeter:other:0', + 'fire-perimeter:other:1', +]; +const colorOf = (incident) => + rgbaFromCss(containmentAccent(incident.containedPct)); +// Per polygon: its incident's colour. +const COLORS = [colorOf(row), colorOf(other), colorOf(other)]; + +function fixture(makeHarness) { + const h = makeHarness(); + const entries = new Map(); + const layer = createFirePerimetersLayer({ + source: { getSnapshot: async () => [row, other] }, + overlayHost: { + setEntries: (id, value) => entries.set(id, value), + setVisible() {}, + clearSource: (id) => entries.delete(id), + hitTest: () => null, + }, + picking: { + resolvePickId: (picked) => picked?.id ?? null, + isOwnedByOtherLayer: (_layer, id) => id.startsWith('other-layer:'), + }, + pointer: { isPointerFree: () => true }, + }); + layer.init(null, { engine: h.engine }); + layer.enable(); + return { ...h, layer, entries, card: () => entries.get('fire-perimeters') }; +} + +const near = (actual, expected, label, tolerance = 1e-9) => + assert.ok( + Math.abs(actual - expected) < tolerance, + `${label}: ${actual} vs ${expected}`, + ); + +test('the display needs a globe engine', () => { + assert.throws(() => createPerimetersDisplay(null), TypeError); +}); + +for (const makeHarness of GROUND_HARNESSES) { + const { name } = makeHarness(); + + test(`each polygon is a 0.25 fill with its holes under a 2 px fire line at 0.9 on its outer ring (${name})`, async () => { + const f = fixture(makeHarness); + try { + assert.equal(await f.layer.update(), true); + const set = f.set('fire-perimeters'); + assert.equal(set.options.kind, 'polygon'); + assert.equal(set.options.pickable, true); + assert.equal(set.shown, true); + const fills = set.items.slice(0, 3); + const lines = set.items.slice(3); + assert.equal(set.items.length, 6, 'every fill, then every fire line'); + assert.deepEqual( + fills.map(({ rings, pickId, outline }) => ({ rings, pickId, outline })), + [ + { rings: [outer, hole], pickId: PICKS[0], outline: undefined }, + { rings: [second], pickId: PICKS[1], outline: undefined }, + { + rings: other.polygons[1], + pickId: PICKS[2], + outline: undefined, + }, + ], + ); + assert.deepEqual( + lines.map(({ rings, pickId, fill, outline }) => ({ + rings, + pickId, + fill, + widthPx: outline.widthPx, + })), + [ + { rings: [outer], pickId: PICKS[0], fill: undefined, widthPx: 2 }, + { rings: [second], pickId: PICKS[1], fill: undefined, widthPx: 2 }, + { + rings: other.polygons[1], + pickId: PICKS[2], + fill: undefined, + widthPx: 2, + }, + ], + ); + fills.forEach(({ fill }, index) => + assert.deepEqual(fill, COLORS[index].withAlpha(0.25).rgba()), + ); + lines.forEach(({ outline }, index) => + assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()), + ); + if (f.cesium) { + const primitives = f.primitives('fire-perimeters'); + assert.equal(primitives.fills.length, 1, 'one fill batch'); + assert.equal(primitives.lines.length, 1, 'one fire-line batch'); + const fillInstances = primitives.fills[0].options.geometryInstances; + const lineInstances = primitives.lines[0].options.geometryInstances; + assert.equal(fillInstances.length, 3); + assert.equal(lineInstances.length, 3, 'no line along a hole'); + assert.equal( + primitives.fills[0].options.classificationType, + Cesium.ClassificationType.BOTH, + ); + assert.equal(primitives.fills[0].options.allowPicking, true); + assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry); + assert.equal( + fillInstances[0].geometry._polygonHierarchy.holes.length, + 1, + ); + fillInstances.forEach((instance, index) => + instanceColor(instance).forEach((channel, at) => + near( + channel, + COLORS[index].withAlpha(0.25).rgba()[at], + `fill ${index}.${at}`, + 0.5 / 255, + ), + ), + ); + lineInstances.forEach((instance, index) => { + assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry); + assert.equal(instance.geometry.width, 2); + assert.equal(instance.geometry.loop, true); + instanceColor(instance).forEach((channel, at) => + near( + channel, + COLORS[index].withAlpha(0.9).rgba()[at], + `line ${index}.${at}`, + 0.5 / 255, + ), + ); + }); + } + f.layer.disable(); + assert.equal(f.set('fire-perimeters').shown, false); + if (f.cesium) assert.equal(f.collectionShown('fire-perimeters'), false); + } finally { + f.layer.destroy(); + } + assert.equal(f.sets(), 0); + f.destroy(); + }); + + test(`a click on a perimeter or its fire line selects the incident; empty space clears it (${name})`, async () => { + const f = fixture(makeHarness); + try { + await f.layer.update(); + assert.equal(f.listeners(), 1, 'one click listener while enabled'); + f.click({ set: 'fire-perimeters', instance: 0 }); + assert.equal(f.card()[0].id, 'fire-perimeter-card:2026-NMGNF-000123'); + // The other incident's second fire line. + f.click({ set: 'fire-perimeters', instance: 5 }); + assert.equal(f.card()[0].id, 'fire-perimeter-card:other'); + f.click({ id: 'other-layer:aircraft-1' }); + assert.equal( + f.card()[0].id, + 'fire-perimeter-card:other', + 'a sibling layer pick keeps the selection', + ); + f.click(undefined); + assert.deepEqual(f.card(), []); + f.layer.disable(); + assert.equal(f.listeners(), 0, 'disable removes the click listener'); + f.layer.enable(); + assert.equal(f.listeners(), 1); + f.layer.detachRenderer(); + assert.equal(f.listeners(), 0, 'detach removes it too'); + assert.equal(f.sets(), 0); + } finally { + f.layer.destroy(); + f.destroy(); + } + }); +} + +test('a click inside a perimeter picks it on the ground, not in its hole (the fake engine)', async () => { + const f = fixture(createFakeGroundHarness); + try { + await f.layer.update(); + const clickAt = (lon, lat) => { + f.engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, 30_000), + }); + const { width, height } = f.engine.host.size(); + f.engine.fake.input('click', { + position: { x: width / 2, y: height / 2 }, + }); + }; + clickAt(-108.15, 35.35); + assert.equal(f.card()[0].id, 'fire-perimeter-card:2026-NMGNF-000123'); + clickAt(-108.03, 35.22); + assert.deepEqual(f.card(), [], 'the unburned hole is empty space'); + } finally { + f.layer.destroy(); + f.destroy(); + } +}); + +test('the display ignores calls after destroy and destroys once', () => { + const h = createFakeGroundHarness(); + const display = createPerimetersDisplay(h.engine); + assert.equal(h.set('fire-perimeters').shown, false, 'hidden until shown'); + const off = display.onClick(() => {}); + assert.equal(h.listeners(), 1); + display.destroy(); + display.destroy(); + assert.equal(h.listeners(), 0, 'destroy removes its click listeners'); + off(); + assert.equal(h.sets(), 0); + display.setVisible(true); + display.replace([row]); + assert.equal(h.listeners(), 0); + display.onClick(() => {})(); + assert.equal(display.pick({}), null); + assert.equal(display.pick({ picked: { id: 'x' } }).id, 'x'); + h.destroy(); +}); diff --git a/src/layers/perimeters/render/ground.js b/src/layers/perimeters/render/ground.js deleted file mode 100644 index 54222ab27..000000000 --- a/src/layers/perimeters/render/ground.js +++ /dev/null @@ -1,46 +0,0 @@ -// A prototype fire-perimeter renderer on engine.ground, for every engine: -// each row's polygons (holes kept) as a 0.25 fill under a 2 px outline at -// 0.9 in its containment colour, as the zones draw on Cesium, picked as -// `fire-perimeter:${stableId}:${index}`. Not wired into the layer: -// scripts/qa-ground-prototypes.mjs draws it next to cesium.js to compare. -import { rgbaFromCss } from '../../shared/color.js'; -import { containmentAccent } from '../cards.js'; - -export function createPerimetersGroundRenderer(engine) { - const set = engine.ground.createSet({ - id: 'fire-perimeters', - kind: 'polygon', - pickable: true, - }); - set.show(false); - return { - setVisible(show) { - set.show(show); - }, - replace(rows) { - const items = []; - for (const row of rows) { - const { red, green, blue } = rgbaFromCss( - containmentAccent(row.containedPct), - ); - for (const [index, rings] of row.polygons.entries()) - items.push({ - rings, - fill: [red, green, blue, 0.25], - outline: { color: [red, green, blue, 0.9], widthPx: 2 }, - pickId: `fire-perimeter:${row.stableId}:${index}`, - }); - } - set.replace(items); - set.commit(); - }, - pick(event) { - return event.picked?.owner === 'fire-perimeters' - ? { id: event.picked.id } - : null; - }, - destroy() { - set.destroy(); - }, - }; -} diff --git a/src/layers/perimeters/render/navara.js b/src/layers/perimeters/render/navara.js deleted file mode 100644 index 7730bba90..000000000 --- a/src/layers/perimeters/render/navara.js +++ /dev/null @@ -1,13 +0,0 @@ -import { containmentAccent } from '../cards.js'; -import { createZonesNavaraRenderer } from '../../shared/render/navaraZones.js'; - -export function createPerimetersNavaraRenderer(engine, { view }) { - return createZonesNavaraRenderer( - engine, - { view }, - { - idPrefix: 'fire-perimeter', - colorOf: (row) => containmentAccent(row.containedPct), - }, - ); -} diff --git a/src/layers/shared/render/cesiumZones.js b/src/layers/shared/render/cesiumZones.js deleted file mode 100644 index e7ae65cdb..000000000 --- a/src/layers/shared/render/cesiumZones.js +++ /dev/null @@ -1,77 +0,0 @@ -import * as Cesium from 'cesium'; - -const ringPositions = (ring) => - ring.map(([lon, lat]) => Cesium.Cartesian3.fromDegrees(lon, lat)); - -/** - * Ground zones on a Cesium globe: each row's polygons (GeoJSON-style rings, - * outline first) as a translucent fill under a ground-clamped outline, in - * the row's CSS colour (`colorOf(row)`). Entity ids are - * `${idPrefix}:${row.stableId}:${index}`. Fire perimeters and drone airspace - * draw this way. - */ -export function createZonesCesiumRenderer({ - viewer, - screenSpaceEventHandlerFactory, - name, - idPrefix, - colorOf, -}) { - const dataSource = new Cesium.CustomDataSource(name); - dataSource.show = false; - viewer.dataSources.add(dataSource); - return { - setVisible(show) { - dataSource.show = show; - }, - pick(click) { - return viewer.scene.pick(click.position); - }, - onClick(callback) { - const handler = screenSpaceEventHandlerFactory(viewer); - handler.setInputAction(callback, Cesium.ScreenSpaceEventType.LEFT_CLICK); - return () => handler.destroy(); - }, - replace(rows) { - const nextEntities = []; - for (const row of rows) { - const color = Cesium.Color.fromCssColorString(colorOf(row)); - for (const [index, rings] of row.polygons.entries()) { - const [outer, ...holes] = rings; - const outerPositions = ringPositions(outer); - nextEntities.push( - new Cesium.Entity({ - id: `${idPrefix}:${row.stableId}:${index}`, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - outerPositions, - holes.map( - (hole) => new Cesium.PolygonHierarchy(ringPositions(hole)), - ), - ), - material: new Cesium.ColorMaterialProperty( - color.withAlpha(0.25), - ), - }, - // Ground-clamped outline (entity polygons cannot outline - // clamped geometry themselves). - polyline: { - positions: outerPositions, - clampToGround: true, - width: 2, - material: new Cesium.ColorMaterialProperty( - color.withAlpha(0.9), - ), - }, - }), - ); - } - } - dataSource.entities.removeAll(); - for (const entity of nextEntities) dataSource.entities.add(entity); - }, - destroy() { - viewer.dataSources.remove(dataSource, true); - }, - }; -} diff --git a/src/layers/shared/render/navaraCellGeometry.js b/src/layers/shared/render/navaraCellGeometry.js deleted file mode 100644 index 56fb0a9cf..000000000 --- a/src/layers/shared/render/navaraCellGeometry.js +++ /dev/null @@ -1,69 +0,0 @@ -// Exact geodetic rectangle sides, including latitude parallels at any height. -import * as geo from '../../../geo/index.js'; -const ellipsoid = geo.ellipsoid.WGS84; -const A = ellipsoid.radii.x, - B = ellipsoid.radii.z; -const E2 = 1 - (B * B) / (A * A); -function latitudeBoundary(degrees) { - const angle = geo.math.toRadians(degrees), - sin = Math.sin(angle), - cos = Math.cos(angle); - return [sin, cos, ((E2 * A) / Math.sqrt(1 - E2 * sin * sin)) * sin * cos, 0]; -} -export function rectangleBoundaries({ west, south, east, north }) { - const w = geo.math.toRadians(west), - e = geo.math.toRadians(east); - return { - longitude: [Math.sin(w), Math.cos(w), Math.sin(e), Math.cos(e)], - south: latitudeBoundary(south), - north: latitudeBoundary(north), - }; -} -export function rectangleDistances(bounds, point) { - const { x, y, z } = point, - rho = Math.hypot(x, y), - l = bounds.longitude; - return [ - y * l[1] - x * l[0], - x * l[2] - y * l[3], - z * bounds.south[1] - rho * bounds.south[0] + bounds.south[2], - rho * bounds.north[0] - z * bounds.north[1] - bounds.north[2], - ]; -} -export function rectangleFrame(cell) { - let eastBound = cell.east; - if (eastBound < cell.west) eastBound += 360; - const lon = (cell.west + eastBound) / 2, - lat = (cell.south + cell.north) / 2; - const center = geo.cartesian3.fromDegrees(lon, lat); - const up = ellipsoid.geodeticSurfaceNormal(center, geo.cartesian3.create()); - const angle = geo.math.toRadians(lon); - const east = geo.cartesian3.create(-Math.sin(angle), Math.cos(angle), 0); - const north = geo.cartesian3.cross(up, east, geo.cartesian3.create()); - let radius = 0; - for (const latitude of [cell.south, lat, cell.north]) - for (const longitude of [cell.west, lon, eastBound]) { - const p = geo.cartesian3.fromDegrees(longitude, latitude); - const dx = p.x - center.x, - dy = p.y - center.y, - dz = p.z - center.z; - radius = Math.max( - radius, - Math.hypot( - dx * east.x + dy * east.y + dz * east.z, - dx * north.x + dy * north.y + dz * north.z, - ), - ); - } - radius = radius * (1 + 101000 / ellipsoid.minimumRadius) + 1; - return { - center, - east, - north, - up, - radius, - floor: -100000 - ((radius * radius) / (2 * ellipsoid.minimumRadius)) * 1.1, - ceiling: 12000, - bounds: rectangleBoundaries(cell), - }; -} diff --git a/src/layers/shared/render/navaraGroundCells.js b/src/layers/shared/render/navaraGroundCells.js deleted file mode 100644 index 68b59a7a1..000000000 --- a/src/layers/shared/render/navaraGroundCells.js +++ /dev/null @@ -1,310 +0,0 @@ -// Classify visible terrain and 3D tiles. Multiple cell layers share one non-pickable pass. -import { Matrix4, Uniform } from 'three'; -import { rectangleFrame } from './navaraCellGeometry.js'; -import { defineGroundEffect } from './navaraGroundEffect.js'; -import { viewFrame, groundClipBounds } from './navaraGroundGeometry.js'; -import { createGroundGrid, GRID_COLS, GRID_ROWS } from './navaraGroundGrid.js'; -import { - createGroundTexture, - createGroundStorage, - groundSurfaceUniforms, - updateGroundSurface, -} from './navaraGroundSurface.js'; -const KEY = 'gevFirmsGround'; -const fragmentShader = /* glsl */ ` -uniform sampler2D tAllDepth; -uniform sampler2D tSurfaceDepth; -uniform sampler2D tCells; -uniform sampler2D tGrid; -uniform sampler2D tCandidates; -uniform vec2 uCellSize; -uniform vec2 uCandidateSize; -uniform mat4 uInverseProjection; -uniform mat4 uInverseView; -uniform float uLogDepthFC; -vec4 atSlot(sampler2D image,vec2 size,float slot) { - return texture2D(image,(vec2(mod(slot,size.x),floor(slot/size.x))+0.5)/size); -} -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)); -} -vec3 pixelPlane(float distance,vec3 normal,vec3 dx,vec3 dy) { - return vec3(distance,dot(normal,dx),dot(normal,dy)); -} -float rectangleCoverage(vec3 west,vec3 east,vec3 south,vec3 north) { - vec3 edges[4]; - edges[0]=west;edges[1]=east;edges[2]=south;edges[3]=north; - float coverage=1.0; - int partial=0; - for(int i=0;i<4;i++) { - float width=abs(edges[i].y)+abs(edges[i].z); - coverage=min(coverage,clamp(edges[i].x/max(width,0.001)+0.5,0.0,1.0)); - if(coverage<=0.0) return 0.0; - if(edges[i].x<=width*0.5) partial++; - } - if(partial<2) return coverage; - // Intersect the SAME pixel square with all sides. Minimum soft-edge - // coverage creates long miter halos at acute/polar rectangle corners. - vec2 polygon[8],clipped[8]; - polygon[0]=vec2(-0.5,-0.5);polygon[1]=vec2(0.5,-0.5); - polygon[2]=vec2(0.5,0.5);polygon[3]=vec2(-0.5,0.5); - int count=4; - for(int side=0;side<4;side++) { - int nextCount=0; - vec2 previous=polygon[count-1]; - float before=edges[side].x+dot(edges[side].yz,previous); - for(int i=0;i<8;i++) { - if(i>=count) break; - vec2 current=polygon[i]; - float after=edges[side].x+dot(edges[side].yz,current); - if((before>0.0&&after<0.0)||(before<0.0&&after>0.0)) - clipped[nextCount++]=mix(previous,current,before/(before-after)); - if(after>=0.0) clipped[nextCount++]=current; - previous=current;before=after; - } - count=nextCount; - if(count<3) return 0.0; - for(int i=0;i<8;i++) { if(i>=count) break;polygon[i]=clipped[i]; } - } - float area=0.0; - vec2 previous=polygon[count-1]; - for(int i=0;i<8;i++) { - if(i>=count) break; - area+=previous.x*polygon[i].y-previous.y*polygon[i].x; - previous=polygon[i]; - } - return clamp(abs(area)*0.5,0.0,1.0); -} -void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) { - outputColor=inputColor; - float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv)); - 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); - // Evaluate derivatives before divergent candidate branches, as in quakes. - vec3 world=(uInverseView*vec4(point,1.0)).xyz; - vec3 dx=(uInverseView*vec4(dFdx(point),0.0)).xyz; - vec3 dy=(uInverseView*vec4(dFdy(point),0.0)).xyz; - // A quad crossing the sky has no surface derivative. Keep exact halfspaces - // there; otherwise its far-plane lane spreads cell AA along the globe limb. - float onSurface=1.0-step(1.0,depth); - float derivativeMask=1.0-step(0.5,abs(dFdx(onSurface))+abs(dFdy(onSurface))); - dx*=derivativeMask; - dy*=derivativeMask; - if(depth>=1.0) return; - vec2 tile=clamp(floor(uv*vec2(${GRID_COLS}.0,${GRID_ROWS}.0)),vec2(0.0),vec2(${GRID_COLS - 1}.0,${GRID_ROWS - 1}.0)); - vec2 range=texture2D(tGrid,(tile+0.5)/vec2(${GRID_COLS}.0,${GRID_ROWS}.0)).xy; - if(range.y<0.5) return; - float surface=texture2D(tSurfaceDepth,uv).r; - if(depth { - const offset = index * 16, - color = cells[index].color; - packed.data.set(frame.bounds.longitude, offset); - packed.data.set(frame.bounds.south, offset + 4); - packed.data.set(frame.bounds.north, offset + 8); - packed.data.set( - [color.red, color.green, color.blue, color.alpha], - offset + 12, - ); - }); - return { - count: cells.length, - packed, - gridTexture, - get candidates() { - return candidates; - }, - update(camera, context) { - const input = context.getInputBuffer(), - view = camera.matrixWorldInverse.elements, - projection = camera.projectionMatrix.elements; - let changed = input.width !== lastWidth || input.height !== lastHeight; - for (let i = 0; i < 16 && !changed; i++) - changed = - view[i] !== lastView[i] || projection[i] !== lastProjection[i]; - if (!changed) return; - lastView.set(view); - lastProjection.set(projection); - lastWidth = input.width; - lastHeight = input.height; - frames.forEach((frame, index) => { - viewFrame(frame, camera.matrixWorldInverse, scratch); - groundClipBounds(frame, scratch, camera.projectionMatrix, bounds); - if (bounds[0] <= 1) { - bounds[0] -= 2 / Math.max(1, input.width); - bounds[2] += 2 / Math.max(1, input.width); - bounds[1] -= 2 / Math.max(1, input.height); - bounds[3] += 2 / Math.max(1, input.height); - } - grid.bounds.set(bounds, index * 4); - }); - const total = grid.pack(); - if (candidates.data.length / 4 < total) { - candidates.texture.dispose(); - candidates = createGroundStorage( - Math.max(total, candidates.data.length / 2), - ); - } - for (let i = 0; i < total; i++) candidates.data[i * 4] = grid.indices[i]; - for (let i = 0; i < grid.counts.length; i++) { - gridData[i * 4] = grid.offsets[i]; - gridData[i * 4 + 1] = grid.counts[i]; - } - gridTexture.needsUpdate = true; - candidates.texture.needsUpdate = true; - }, - dispose() { - packed.texture.dispose(); - gridTexture.dispose(); - candidates.texture.dispose(); - }, - }; -} -const groundEffect = defineGroundEffect({ - key: KEY, - name: 'GevFirmsGround', - fragmentShader, - insertAfter: [ - 'gevEarthquakeGround', - 'gevObservedWeather', - 'gevNepalEvent', - 'gevRecentImagery', - 'toneMapping', - ], - insertBefore: [ - 'gevPreSharpenOverlay', - 'gevSharpen', - 'smaa', - 'fxaa', - 'gevOverlay', - 'final', - ], - uniforms: () => [ - ...groundSurfaceUniforms(), - ['tCells', new Uniform(null)], - ['tGrid', new Uniform(null)], - ['tCandidates', new Uniform(null)], - ['uCellSize', new Uniform([1, 1])], - ['uCandidateSize', new Uniform([1, 1])], - ['uInverseView', new Uniform(new Matrix4())], - ], - update() { - const state = this.owner.state, - camera = this.view.camera.raw; - if (!this.effect || !state || !camera) return; - updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt')); - state.update(camera, this.ctx); - const u = this.effect.uniforms; - u.get('tCells').value = state.packed.texture; - u.get('uCellSize').value[0] = state.packed.width; - u.get('uCellSize').value[1] = state.packed.height; - u.get('tGrid').value = state.gridTexture; - u.get('tCandidates').value = state.candidates.texture; - u.get('uCandidateSize').value[0] = state.candidates.width; - u.get('uCandidateSize').value[1] = state.candidates.height; - u.get('uInverseView').value.copy(camera.matrixWorld); - }, -}); -// The SDK allows one active effect per key. Compose enabled datasets in -// catalog order so both can be drawn without replacing one another. -const views = new WeakMap(); -export function createGroundCellDisplay( - engine, - { view, EffectDesc, NavaraEffect }, - order, -) { - groundEffect.register(view, { EffectDesc, NavaraEffect }); - let pass = views.get(view); - if (!pass) { - pass = { - inputs: new Set(), - owner: { state: buildState([]) }, - handle: null, - }; - views.set(view, pass); - } - const input = { cells: [], visible: false, order }; - pass.inputs.add(input); - function sync() { - const cells = [...pass.inputs] - .filter((item) => item.visible) - .sort((a, b) => a.order - b.order) - .flatMap((item) => item.cells); - const previous = pass.owner.state; - pass.owner.state = buildState(cells); - try { - if (!pass.handle && cells.length) { - pass.handle = view.addEffect({ - [KEY]: { owner: pass.owner }, - visible: true, - }); - } else { - pass.handle?.update({ visible: cells.length > 0 }); - } - } finally { - previous.dispose(); - } - engine.render.request('ground-cells'); - } - return { - replace(cells) { - input.cells = cells; - if (input.visible) sync(); - }, - setVisible(show) { - if (input.visible === show) return; - input.visible = show; - sync(); - }, - destroy() { - if (!pass.inputs.delete(input)) return; - if (pass.inputs.size) { - if (input.visible) sync(); - } else { - pass.handle?.delete(); - pass.owner.state.dispose(); - views.delete(view); - engine.render.request('ground-cells-destroy'); - } - }, - }; -} diff --git a/src/layers/shared/render/navaraGroundGeometry.js b/src/layers/shared/render/navaraGroundGeometry.js deleted file mode 100644 index ef16c2087..000000000 --- a/src/layers/shared/render/navaraGroundGeometry.js +++ /dev/null @@ -1,62 +0,0 @@ -/** Camera-relative transforms in JS float64; only nearby metres enter GLSL. */ -export function viewFrame(frame, matrix, out) { - const m = matrix.elements; - const transform = (p, offset, translate) => { - out[offset] = - m[0] * p.x + m[4] * p.y + m[8] * p.z + (translate ? m[12] : 0); - out[offset + 1] = - m[1] * p.x + m[5] * p.y + m[9] * p.z + (translate ? m[13] : 0); - out[offset + 2] = - m[2] * p.x + m[6] * p.y + m[10] * p.z + (translate ? m[14] : 0); - }; - transform(frame.center, 0, true); - transform(frame.east, 3, false); - transform(frame.north, 6, false); - transform(frame.up, 9, false); - return out; -} - -/** Conservative clip bounds, including a camera inside/crossing the volume. */ -export function groundClipBounds(frame, view, projection, out) { - const p = projection.elements; - let x0 = Infinity, - y0 = Infinity, - x1 = -Infinity, - y1 = -Infinity, - front = 0, - behind = 0; - for (let i = 0; i < 8; i++) { - const x = (i & 1 ? 1 : -1) * frame.radius; - const y = (i & 2 ? 1 : -1) * frame.radius; - const z = i & 4 ? frame.ceiling : frame.floor; - const vx = view[0] + view[3] * x + view[6] * y + view[9] * z; - const vy = view[1] + view[4] * x + view[7] * y + view[10] * z; - const vz = view[2] + view[5] * x + view[8] * y + view[11] * z; - const w = p[3] * vx + p[7] * vy + p[11] * vz + p[15]; - if (w <= 0) { - behind++; - continue; - } - front++; - const cx = (p[0] * vx + p[4] * vy + p[8] * vz + p[12]) / w; - const cy = (p[1] * vx + p[5] * vy + p[9] * vz + p[13]) / w; - x0 = Math.min(x0, cx); - y0 = Math.min(y0, cy); - x1 = Math.max(x1, cx); - y1 = Math.max(y1, cy); - } - if (front && behind) { - x0 = -1; - y0 = -1; - x1 = 1; - y1 = 1; - } - if (!front || x1 < -1 || y1 < -1 || x0 > 1 || y0 > 1) out.fill(2); - else { - out[0] = Math.max(-1, x0); - out[1] = Math.max(-1, y0); - out[2] = Math.min(1, x1); - out[3] = Math.min(1, y1); - } - return out; -} diff --git a/src/layers/shared/render/navaraGroundGeometry.test.mjs b/src/layers/shared/render/navaraGroundGeometry.test.mjs deleted file mode 100644 index 37dba30f7..000000000 --- a/src/layers/shared/render/navaraGroundGeometry.test.mjs +++ /dev/null @@ -1,29 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { PerspectiveCamera } from 'three'; -import { groundClipBounds } from './navaraGroundGeometry.js'; - -test('ground volumes wholly outside a screen edge do not enter the candidate grid', () => { - const camera = new PerspectiveCamera(60, 1.6, 1, 1000); - const frame = { radius: 1, floor: -1, ceiling: 1 }; - for (const [x, y] of [ - [100, 0], - [-100, 0], - [0, 100], - [0, -100], - ]) { - const view = new Float64Array([x, y, -10, 1, 0, 0, 0, 1, 0, 0, 0, 1]); - assert.deepEqual( - [ - ...groundClipBounds( - frame, - view, - camera.projectionMatrix, - new Float64Array(4), - ), - ], - [2, 2, 2, 2], - `offscreen volume at ${x}, ${y}`, - ); - } -}); diff --git a/src/layers/shared/render/navaraGroundGrid.js b/src/layers/shared/render/navaraGroundGrid.js deleted file mode 100644 index 8f306edd0..000000000 --- a/src/layers/shared/render/navaraGroundGrid.js +++ /dev/null @@ -1,69 +0,0 @@ -// A screen grid of conservative projected volumes. Prefix-sum storage has -// no feature/cell cap: overlapping shapes retain their source order. -export const GRID_COLS = 64; -export const GRID_ROWS = 40; -export function createGroundGrid(count) { - const counts = new Uint32Array(GRID_COLS * GRID_ROWS); - const offsets = new Uint32Array(counts.length); - const cursor = new Uint32Array(counts.length); - const bounds = new Float64Array(count * 4); - const ranges = new Int32Array(count * 4); - let indices = new Uint32Array(0); - const clamp = (value, max) => Math.min(max - 1, Math.max(0, value)); - return { - bounds, - counts, - offsets, - get indices() { - return indices; - }, - pack() { - counts.fill(0); - for (let i = 0; i < count; i++) { - const b = i * 4; - const valid = - bounds[b] <= bounds[b + 2] && - bounds[b + 1] <= bounds[b + 3] && - bounds[b] <= 1 && - bounds[b + 1] <= 1 && - bounds[b + 2] >= -1 && - bounds[b + 3] >= -1; - ranges[b] = valid - ? clamp(Math.floor((bounds[b] * 0.5 + 0.5) * GRID_COLS), GRID_COLS) - : -1; - ranges[b + 1] = clamp( - Math.floor((bounds[b + 1] * 0.5 + 0.5) * GRID_ROWS), - GRID_ROWS, - ); - ranges[b + 2] = clamp( - Math.floor((bounds[b + 2] * 0.5 + 0.5) * GRID_COLS), - GRID_COLS, - ); - ranges[b + 3] = clamp( - Math.floor((bounds[b + 3] * 0.5 + 0.5) * GRID_ROWS), - GRID_ROWS, - ); - if (!valid) continue; - for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++) - for (let x = ranges[b]; x <= ranges[b + 2]; x++) - counts[y * GRID_COLS + x]++; - } - let total = 0; - for (let i = 0; i < counts.length; i++) { - offsets[i] = total; - total += counts[i]; - cursor[i] = offsets[i]; - } - if (indices.length < total) - indices = new Uint32Array(Math.max(total, indices.length * 2)); - for (let i = 0; i < count; i++) { - const b = i * 4; - if (ranges[b] < 0) continue; - for (let y = ranges[b + 1]; y <= ranges[b + 3]; y++) - for (let x = ranges[b]; x <= ranges[b + 2]; x++) - indices[cursor[y * GRID_COLS + x]++] = i; - } - return total; - }, - }; -} diff --git a/src/layers/shared/render/navaraGroundOrder.test.mjs b/src/layers/shared/render/navaraGroundOrder.test.mjs index 65dea1160..cf6e1dabe 100644 --- a/src/layers/shared/render/navaraGroundOrder.test.mjs +++ b/src/layers/shared/render/navaraGroundOrder.test.mjs @@ -2,8 +2,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import { createImageryGround } from '../../recentImagery/render/navaraGround.js'; -import { createEarthquakesNavaraRenderer } from '../../earthquakes/render/navara.js'; -import { createGroundCellDisplay } from './navaraGroundCells.js'; // EffectDesc gives insertAfter priority, then tries insertBefore. Check the // actual renderer declarations under every activation order, including the @@ -12,8 +10,6 @@ const declarations = [ 'gevRecentImagery', 'gevNepalEvent', 'gevObservedWeather', - 'gevEarthquakeGround', - 'gevFirmsGround', 'gevPreSharpenOverlay', ]; // The descriptor a renderer registers; its setup stops there. @@ -49,8 +45,6 @@ const specs = [ registered((sdk) => createImageryGround({}, sdk, {})), registered((sdk) => createImageryGround({}, sdk, {}, 'gevNepalEvent')), await declared('gevObservedWeather', '../../weather/render/navaraSurface.js'), - registered((sdk) => createEarthquakesNavaraRenderer({ render: {} }, sdk)), - registered((sdk) => createGroundCellDisplay({}, sdk, 0)), await declared( 'gevPreSharpenOverlay', '../../../globe/navara/overlay.js', diff --git a/src/layers/shared/render/navaraZones.js b/src/layers/shared/render/navaraZones.js deleted file mode 100644 index c93840c63..000000000 --- a/src/layers/shared/render/navaraZones.js +++ /dev/null @@ -1,90 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { createNavaraPolygonDisplay } from './navaraPolygons.js'; -import { containsGroundPoint } from './groundRing.js'; - -/** - * Ground zones on Navara, as cesiumZones.js draws them on Cesium: each row's - * polygons as a translucent fill under a ground-clamped outline in the row's - * CSS colour (`colorOf(row)`), picked by a ground point inside the outline - * and outside its holes (`{ id: '${idPrefix}:${stableId}:${index}' }`). - */ -export function createZonesNavaraRenderer( - engine, - { view }, - { idPrefix, colorOf }, -) { - let polygons = []; - const display = createNavaraPolygonDisplay(engine, view, (color) => ({ - polygon: { - color, - clampToGround: true, - transparent: true, - opacity: 0.25, - }, - polyline: { - color, - clampToGround: true, - width: 2, - transparent: true, - opacity: 0.9, - }, - })); - return { - setVisible: display.setVisible, - replace(rows) { - const groups = new Map(), - next = []; - for (const row of rows) { - const color = colorOf(row); - if (!groups.has(color)) groups.set(color, []); - for (const [index, rings] of row.polygons.entries()) { - const id = `${idPrefix}:${row.stableId}:${index}`; - const properties = { zone: row.stableId }; - groups.get(color).push( - { - type: 'Feature', - id, - properties, - geometry: { type: 'Polygon', coordinates: rings }, - }, - { - type: 'Feature', - id: id + '-outline', - properties, - geometry: { type: 'LineString', coordinates: rings[0] }, - }, - ); - next.push({ id, rings }); - } - } - display.replace(groups); - polygons = next; - }, - onClick(callback) { - // Zones never claim a sibling pick. The input port shares the native - // click result with other consumers of the same gesture. - return engine.input.on('click', callback, { pick: true }); - }, - pick(event) { - if (event.picked) return event.picked; - const point = engine.picker.pickGlobe(event.position); - if (!point) return null; - const pos = geo.cartographic.fromCartesian(point); - const lon = geo.math.toDegrees(pos.longitude), - lat = geo.math.toDegrees(pos.latitude); - for (let i = polygons.length - 1; i >= 0; i--) { - const { id, rings } = polygons[i]; - if ( - containsGroundPoint(rings[0], lon, lat) && - !rings.slice(1).some((ring) => containsGroundPoint(ring, lon, lat)) - ) - return { id }; - } - return null; - }, - destroy() { - polygons = []; - display.destroy(); - }, - }; -} diff --git a/src/layers/submarineCables/edgeCases.test.mjs b/src/layers/submarineCables/edgeCases.test.mjs deleted file mode 100644 index bb64fd22d..000000000 --- a/src/layers/submarineCables/edgeCases.test.mjs +++ /dev/null @@ -1,143 +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 { createCpuPicker } from '../../globe/cpuPicks.js'; -import { createNavaraCables } from './render/navara.js'; -import { CABLE_OVERLAY_SOURCE_ID } from './policy.js'; - -const FIJI = [178.44, -18.12]; -// Cable A leaves Fiji eastwards across the antimeridian as ONE unsplit path -// and has no reference coordinates, so its reference comes from its geometry. -const cableA = { - type: 'Feature', - properties: { id: 'cable-a', name: 'Cable A' }, - geometry: { - type: 'LineString', - coordinates: [FIJI, [-170, -10]], - }, -}; -// Cable B ends at the same landing point and runs south-west. -const cableB = { - type: 'Feature', - properties: { id: 'cable-b', name: 'Cable B', coordinates: [174, -24] }, - geometry: { - type: 'LineString', - coordinates: [FIJI, [170, -30]], - }, -}; -const landing = { - type: 'Feature', - properties: { id: 'suva', name: 'Suva' }, - geometry: { type: 'Point', coordinates: FIJI }, -}; - -function fixture() { - const engine = createFakeEngine({ kind: 'navara' }); - engine.fake.setPose({ - longitude: geo.math.toRadians(FIJI[0]), - latitude: geo.math.toRadians(FIJI[1]), - height: 400000, - }); - // The fake has no mesh pickers: arbitrate them as Navara's picking does. - const meshPickers = new Set(); - engine.picker.registerMeshPicker = (pick) => { - meshPickers.add(pick); - return () => meshPickers.delete(pick); - }; - const drill = createCpuPicker({ - projector: engine.projector, - camera: engine.camera, - movers: () => [], - lines: () => [], - meshes: (at) => [...meshPickers].flatMap((pick) => pick(at)), - }); - engine.fake.script('pick', (at) => drill(at, 1)[0]); - const sources = []; - const native = { - view: { - addSource(options) { - sources.push(options); - return { delete() {} }; - }, - addLayer() { - return { on() {}, forceUpdate() {}, delete() {} }; - }, - registerMesh() {}, - addMesh: () => ({ delete() {} }), - }, - MeshDesc: class {}, - NavaraColor: class { - setStyle() { - return this; - } - }, - }; - const host = { setVisible() {}, setEntries() {}, clearSource() {} }; - const cables = createNavaraCables(engine, native, host); - cables.show({ - cables: { type: 'FeatureCollection', features: [cableA, cableB] }, - landingPoints: { type: 'FeatureCollection', features: [landing] }, - }); - return { engine, cables, sources }; -} - -const screen = (engine, [lon, lat]) => - engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat), {}); - -test('edge:telegeography-submarine-cables: an antimeridian crossing draws no segment across the whole map', () => { - const { cables, sources } = fixture(); - try { - const drawn = sources.at(-1).data.features; - const paths = drawn.flatMap(({ geometry }) => - geometry.type === 'MultiLineString' - ? geometry.coordinates - : [geometry.coordinates], - ); - assert.ok(paths.length >= 3, 'the crossing cable is split in two'); - for (const path of paths) - for (let index = 1; index < path.length; index++) - assert.ok( - Math.abs(path[index][0] - path[index - 1][0]) <= 180, - `segment ${JSON.stringify([path[index - 1], path[index]])} spans the map`, - ); - // Both halves meet at the antimeridian at the same latitude. - const [west, east] = drawn[0].geometry.coordinates; - assert.equal(Math.abs(west.at(-1)[0]), 180); - assert.equal(Math.abs(east[0][0]), 180); - assert.equal(west.at(-1)[1], east[0][1]); - } finally { - cables.destroy(); - } -}); - -test('edge:telegeography-submarine-cables: a click on a cable beside its landing point returns that cable and flies to its own reference', () => { - const { engine, cables } = fixture(); - try { - const edgeLat = -18.12 + ((180 - 178.44) / (190 - 178.44)) * (-10 + 18.12); - const from = screen(engine, FIJI); - const to = screen(engine, [180, edgeLat]); - // Halfway along cable A's first drawn segment, well clear of the landing point. - const at = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; - assert.ok(Math.hypot(at.x - from.x, at.y - from.y) > 20); - const picked = engine.picker.pick(at); - assert.equal(picked?.id, `${CABLE_OVERLAY_SOURCE_ID}:cable:0`); - // The landing point itself still answers as the landing point. - assert.equal( - engine.picker.pick(from)?.id, - `${CABLE_OVERLAY_SOURCE_ID}:landing-point:0`, - ); - engine.fake.resetCalls(); - engine.fake.input('click', { position: at }); - const [flight] = engine.fake.callsOf('camera.flyTo'); - assert.ok(flight, 'the click flies to the cable'); - const target = geo.cartographic.fromCartesian(flight.args[0].destination); - // The reference of a cable across the antimeridian stays beside it, not at 0°. - assert.ok( - Math.abs(geo.math.toDegrees(target.longitude)) > 170, - `reference at ${geo.math.toDegrees(target.longitude)}°`, - ); - } finally { - cables.destroy(); - } -}); diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js index 1859a1c83..a80ba8b6d 100644 --- a/src/layers/submarineCables/geometry.js +++ b/src/layers/submarineCables/geometry.js @@ -37,49 +37,6 @@ export function featureReference(feature) { }; } -/** - * Split a [lon, lat] path where a segment crosses the antimeridian, so no - * drawn segment runs the long way across the whole map. - */ -function splitAtAntimeridian(path) { - const parts = []; - let current = []; - for (let index = 0; index < path.length; index++) { - const point = path[index]; - const previous = path[index - 1]; - if (previous && Math.abs(point[0] - previous[0]) > 180) { - const edge = previous[0] > point[0] ? 180 : -180; - const unwrapped = point[0] + 2 * edge; - const t = (edge - previous[0]) / (unwrapped - previous[0]); - const lat = previous[1] + t * (point[1] - previous[1]); - current.push([edge, lat]); - parts.push(current); - current = [[-edge, lat]]; - } - current.push(point); - } - parts.push(current); - return parts.filter((part) => part.length > 1); -} - -/** A line or multi-line feature whose paths are split at the antimeridian. */ -export function splitFeatureAtAntimeridian(feature) { - const geometry = feature?.geometry; - if (geometry?.type !== 'LineString' && geometry?.type !== 'MultiLineString') - return feature; - const paths = - geometry.type === 'LineString' - ? [geometry.coordinates] - : geometry.coordinates; - return { - ...feature, - geometry: { - type: 'MultiLineString', - coordinates: paths.flatMap((path) => splitAtAntimeridian(path)), - }, - }; -} - function coordsFromProperty(value) { if (!Array.isArray(value) || value.length < 2) return null; const lon = Number(value[0]); diff --git a/src/layers/submarineCables/index.js b/src/layers/submarineCables/index.js index 5de4fe6e2..672115c78 100644 --- a/src/layers/submarineCables/index.js +++ b/src/layers/submarineCables/index.js @@ -1,47 +1,41 @@ -import { createState } from './state.js'; -import { createLifecycle } from './lifecycle.js'; +import { createCableSessionLayer } from './session.js'; +import { createCablesDisplay } from './render/display.js'; import { defaultSweepClock } from './policy.js'; -import { createRendering } from './rendering.js'; -import { createIngestion } from './ingestion.js'; -import { createInteraction } from './interaction.js'; + +/** + * The submarine cable layer on any globe engine: the dataset is read once + * per activation (session.js) and drawn by `createDisplay(engine)`, by + * default the contract display (render/display.js). + */ export function createSubmarineCableLayer({ source, overlayHost, - screenSpaceEventHandlerFactory, mapStackEventTarget = null, sweepClock = defaultSweepClock, + createDisplay = (engine) => + createCablesDisplay(engine, { + overlayHost, + mapStackEventTarget, + sweepClock, + }), }) { if (!source?.fetch || !source.label) throw new TypeError( 'A cable source with a label and fetch(signal) is required', ); - const state = createState({ overlayHost, sweepClock }); - const parts = {}; - const context = { - state, - parts, - source, - screenSpaceEventHandlerFactory, - mapStackEventTarget, - }; - parts.rendering = createRendering(context); - parts.ingestion = createIngestion(context); - parts.interaction = createInteraction(context); - return createLifecycle(context); + return createCableSessionLayer({ source, createDisplay }); } +export { createCableSessionLayer } from './session.js'; +export { createCablesDisplay } from './render/display.js'; export { selectCableReferenceLabelWinners, cableReferencePriority, createCableOverlayEntry, createCableOverlayPublisher, createCableReferenceSweepGate, - updateCableReferenceStem, + updateCableReferenceTip, } from './overlay.js'; -export { - cableClassificationTypeForStack, - cableClassificationTypeForScene, - applyTranslucentMarkerBlend, -} from './surface.js'; +export { cableDrapeForStack, cableDrapeForEngine } from './surface.js'; export { CABLE_REFERENCE_LABEL_WINNER_CAP, CABLE_OVERLAY_SOURCE_ID, diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js deleted file mode 100644 index 714b8753b..000000000 --- a/src/layers/submarineCables/ingestion.js +++ /dev/null @@ -1,237 +0,0 @@ -import { msg, t } from '../../i18n/index.js'; -import { - featureLabel, - featureReference, - normalizeFeatures, -} from './geometry.js'; -import * as Cesium from 'cesium'; - -const SOURCE_FEATURE_INDEX = '__gevSourceFeatureIndex'; - -function indexedCollection(features) { - return { - type: 'FeatureCollection', - features: features.map((feature, index) => ({ - ...feature, - properties: { - ...feature.properties, - [SOURCE_FEATURE_INDEX]: index, - }, - })), - }; -} - -export function createIngestion({ state, parts, source }) { - async function load(viewer) { - if (state._loading || state._loaded) return; - - state._loading = true; - state._error = null; - state._loadingLabel = t('loading...'); - // Ownership token (the militaryAwareness activationId pattern): a - // disable/abort followed by a fresh enable starts a NEWER load while this - // one is still settling. The stale load must bail after every await and - // must never touch shared lifecycle state or the viewer it no longer - // owns — clearing `_loading`/`_abort` for the successor is how duplicate - // or post-destroy data sources got added. - const generation = ++state._loadGeneration; - const abort = new AbortController(); - state._abort = abort; - const owns = () => - generation === state._loadGeneration && !abort.signal.aborted; - - try { - let cableJson = state._cachedCableJson; - let landingJson = state._cachedLandingJson; - if (!cableJson || !landingJson) { - ({ cables: cableJson, landingPoints: landingJson } = await source.fetch( - abort.signal, - )); - if (!owns()) return; - if ( - !Array.isArray(cableJson?.features) || - !Array.isArray(landingJson?.features) - ) { - throw new TypeError( - 'A cable source must return cables and landingPoints GeoJSON collections', - ); - } - state._cachedCableJson = cableJson; - state._cachedLandingJson = landingJson; - } - - const cableFeatures = normalizeFeatures(cableJson, 'cable'); - const landingFeatures = normalizeFeatures(landingJson, 'landing'); - - const cableDataSource = await Cesium.GeoJsonDataSource.load( - indexedCollection(cableFeatures), - { - clampToGround: true, - stroke: state.cableColor.withAlpha(0.95), - fill: state.cableColor.withAlpha(0.18), - strokeWidth: 2, - markerColor: state.cableColor, - markerSize: 6, - }, - ); - if (!owns()) return; - const landingDataSource = await Cesium.GeoJsonDataSource.load( - indexedCollection(landingFeatures), - { - clampToGround: true, - stroke: state.landingColor.withAlpha(0.9), - fill: state.landingColor.withAlpha(0.35), - strokeWidth: 2, - markerColor: state.landingColor, - markerSize: 6, - }, - ); - if (!owns()) return; - - cableDataSource.name = `${source.label} Submarine Cables`; - landingDataSource.name = `${source.label} Landing Points`; - const referenceDataSource = new Cesium.CustomDataSource( - `${source.label} Cable References`, - ); - const addedSources = [ - cableDataSource, - landingDataSource, - referenceDataSource, - ]; - - // dataSources.add() is itself an await point: Cesium mutates the - // collection on a DEFERRED tick, so a disable/destroy racing this - // window finds nothing to remove and the adds still materialize - // afterwards. Await every add to settlement (allSettled so a partial - // failure cannot leave an unawaited add materializing later), then - // re-check ownership. Pinned teardown mechanism: disable/destroy never - // wait for in-flight adds — the stale generation's own post-await - // check here guarantees cleanup by removing exactly the sources THIS - // generation added. - const addResults = await Promise.allSettled([ - viewer.dataSources.add(cableDataSource), - viewer.dataSources.add(landingDataSource), - viewer.dataSources.add(referenceDataSource), - ]); - const rejectedAdd = addResults.find( - (result) => result.status === 'rejected', - ); - if (!owns() || rejectedAdd) { - // Compensate: every add has settled by now, so removal is effective - // (a remove inside the deferred window would have been a no-op). - // Remove EVERY generation-local source regardless of settle status: - // Cesium's add() pushes into the collection BEFORE raising - // dataSourceAdded, so a REJECTED add may still have landed its - // mutation. remove() of a never-added source is a harmless no-op. - for (const source of addedSources) { - try { - viewer.dataSources.remove(source, true); - } catch { - /* collection gone */ - } - } - if (owns() && rejectedAdd) throw rejectedAdd.reason; - return; - } - - // Await-free commit: the viewer accepted all three sources and this - // load still owns the lifecycle. - state._cableDataSource = cableDataSource; - state._landingDataSource = landingDataSource; - state._referenceDataSource = referenceDataSource; - - const cableEntities = state._cableDataSource.entities.values; - const landingEntities = state._landingDataSource.entities.values; - state._pickByEntity = new WeakMap(); - state._referenceRecords = []; - state._surfaceRecords = []; - - // Cesium expands multipart geometry into several entities and suffixes - // colliding IDs. Its copied properties retain this source index even - // when a provider has no ID, so every segment keeps the right owner. - const cableReferences = new Set(); - cableEntities.forEach((entity) => { - const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); - const feature = cableFeatures[index]; - const reference = featureReference(feature); - if (!reference) return; - - parts.rendering.styleCableEntity(entity, feature); - parts.interaction.registerPickEntity(entity, { - kind: 'cable', - reference, - label: featureLabel(feature), - }); - state._surfaceRecords.push({ - entity, - base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), - }); - if (cableReferences.has(index)) return; - cableReferences.add(index); - parts.rendering.addReferenceStem({ - reference, - label: featureLabel(feature), - kind: 'cable', - color: state.cableColor, - feature, - }); - }); - - const landingReferences = new Set(); - landingEntities.forEach((entity) => { - const index = entity.properties?.[SOURCE_FEATURE_INDEX]?.getValue(); - const feature = landingFeatures[index]; - const reference = featureReference(feature); - if (!reference) return; - - parts.rendering.styleLandingEntity(entity, feature); - parts.interaction.registerPickEntity(entity, { - kind: 'landing-point', - reference, - label: featureLabel(feature), - }); - state._surfaceRecords.push({ - entity, - base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0), - }); - if (landingReferences.has(index)) return; - landingReferences.add(index); - parts.rendering.addReferenceStem({ - reference, - label: featureLabel(feature), - kind: 'landing-point', - color: state.landingColor, - feature, - }); - }); - - state._count = cableFeatures.length + landingFeatures.length; - state._loaded = true; - state._lastUpdate = Date.now(); - state._loadingLabel = ''; - parts.rendering.updateVisibility(); - state._referenceSweepGate.markDirty(); - viewer.scene.requestRender?.(); - } catch (error) { - // A stale or aborted load reports nothing: its failure belongs to a - // lifecycle the user already left. - if (owns() && error?.name !== 'AbortError') { - state._error = error?.message || msg('TeleGeography load failed'); - console.warn( - '[Data:telegeography-submarine-cables]', - state._error, - error, - ); - } - } finally { - // Only the OWNING load may clear the shared lifecycle. A stale load - // clearing `_loading`/`_abort` would hand a later enable/update a - // duplicate load while the real one is still in flight. - if (generation === state._loadGeneration) { - state._loading = false; - if (state._abort === abort) state._abort = null; - } - } - } - return { load }; -} diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js deleted file mode 100644 index aaa4f8aad..000000000 --- a/src/layers/submarineCables/interaction.js +++ /dev/null @@ -1,78 +0,0 @@ -import * as Cesium from 'cesium'; -import { isPointerFree } from '../../data/inputOwnership.js'; - -export function createInteraction({ state, screenSpaceEventHandlerFactory }) { - function registerPickEntity(entity, info) { - entity.__gevTeleGeography = info; - state._pickByEntity.set(entity, info); - } - - function beginInteraction(viewer) { - if (state._clickHandler) return; - state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas); - state._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 record = resolvePickRecord(picked); - if (!record?.reference) return; - flyToReference(viewer, record.reference); - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); - } - - function resolvePickRecord(picked) { - if (!picked) return null; - const primitive = picked.primitive; - if (primitive) { - const primitiveInfo = state._pickByEntity.get(primitive); - if (primitiveInfo) return primitiveInfo; - if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography; - if ( - primitive.id && - typeof primitive.id === 'object' && - primitive.id.reference - ) { - return primitive.id; - } - } - - const entity = picked.id; - if (entity) { - const entityInfo = state._pickByEntity.get(entity); - if (entityInfo) return entityInfo; - if (entity.__gevTeleGeography) return entity.__gevTeleGeography; - if (entity.id && typeof entity.id === 'object' && entity.id.reference) { - return entity.id; - } - } - - return null; - } - - function flyToReference(viewer, reference) { - if (!viewer || !reference) return; - const destination = Cesium.Cartesian3.fromDegrees( - reference.lon, - reference.lat, - 6500, - ); - viewer.camera.cancelFlight(); - viewer.camera.flyTo({ - destination, - orientation: { - heading: viewer.camera.heading || 0, - pitch: Cesium.Math.toRadians(-52), - roll: 0, - }, - duration: 1.35, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, - }); - } - return { - registerPickEntity, - beginInteraction, - resolvePickRecord, - flyToReference, - }; -} diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js deleted file mode 100644 index 233bb248d..000000000 --- a/src/layers/submarineCables/lifecycle.js +++ /dev/null @@ -1,156 +0,0 @@ -import { - cableClassificationTypeForScene, - cableClassificationTypeForStack, -} from './surface.js'; - -export function createLifecycle({ state, parts, source, mapStackEventTarget }) { - return { - id: 'telegeography-submarine-cables', - name: 'Submarine Cables', - icon: '≋', - source: source.label, - updateInterval: 0, - statsRefreshInterval: 500, - - init(viewer) { - state._viewer = viewer; - state._classificationType = cableClassificationTypeForScene( - viewer?.scene, - ); - if (!state._mapStackListener && mapStackEventTarget?.addEventListener) { - state._mapStackListener = (event) => { - parts.rendering.applyCableClassification( - event?.detail?.activeId - ? cableClassificationTypeForStack(event.detail.activeId) - : cableClassificationTypeForScene(state._viewer?.scene), - ); - }; - mapStackEventTarget.addEventListener( - 'gev:map-stack-changed', - state._mapStackListener, - ); - } - parts.interaction.beginInteraction(viewer); - if (!state._preRenderRemover) { - state._preRenderRemover = viewer.scene.preRender.addEventListener( - () => { - // The camera is handed to the gate so tracked/orbit cameras — which - // never emit moveEnd — reach the motion fallback. No render is - // requested for a fallback sweep: it only fires while the camera is - // already moving, so frames are flowing by construction. - if ( - !state._enabled || - !state._loaded || - !state._referenceSweepGate.shouldRun(viewer.camera) - ) - return; - parts.rendering.updateReferenceVisibility(); - }, - ); - } - if (!state._moveEndRemover) { - state._moveEndRemover = viewer.camera.moveEnd.addEventListener(() => { - if (!state._enabled) return; - state._referenceSweepGate.markDirty(); - viewer.scene.requestRender?.(); - }); - } - }, - - enable(viewer) { - state._enabled = true; - parts.rendering.updateVisibility(); - state._overlayPublisher.show(); - // A hide() cleared the host source, so an identical cohort must still - // republish on the next sweep. - parts.rendering.resetPublishSignature(); - state._referenceSweepGate.markDirty(); - // The gate is dirty-only; ask for the frame its sweep needs in case the - // camera is parked and nothing else is rendering. - (viewer || state._viewer)?.scene?.requestRender?.(); - return parts.ingestion.load(viewer || state._viewer); - }, - - disable() { - state._enabled = false; - state._overlayPublisher.hide(); - parts.rendering.resetPublishSignature(); - if (state._loading && state._abort) { - state._abort.abort(); - state._loading = false; - state._loadingLabel = ''; - } - // Free the entities rather than hide them; enable() rebuilds from the - // cached JSON. An in-flight load's own post-await ownership check - // removes whatever that stale generation adds later. - parts.rendering.releaseDataSources(state._viewer); - }, - - update(viewer) { - return parts.ingestion.load(viewer || state._viewer); - }, - - destroy(viewer) { - if (state._abort) state._abort.abort(); - parts.rendering.releaseDataSources(viewer || state._viewer); - state._cachedCableJson = null; - state._cachedLandingJson = null; - // hide() clears every published host entry and goes invisible while - // keeping the publisher reusable — the legacy layer supported re-init - // after destroy, and hidden publishers already drop late publishes. - // Pinned by the direct-destroy host test: removing this line leaves a - // visible overlay source with orphan labels. - state._overlayPublisher.hide(); - if (state._clickHandler) { - state._clickHandler.destroy(); - state._clickHandler = null; - } - if (state._preRenderRemover) { - state._preRenderRemover(); - state._preRenderRemover = null; - } - if (state._moveEndRemover) { - state._moveEndRemover(); - state._moveEndRemover = null; - } - if (state._mapStackListener && mapStackEventTarget?.removeEventListener) { - mapStackEventTarget.removeEventListener( - 'gev:map-stack-changed', - state._mapStackListener, - ); - state._mapStackListener = null; - } - state._viewer = null; - state._enabled = false; - state._loading = false; - state._loaded = false; - state._count = 0; - state._error = null; - state._lastUpdate = null; - state._loadingLabel = ''; - state._cableDataSource = null; - state._landingDataSource = null; - state._referenceDataSource = null; - state._referenceRecords = []; - state._surfaceRecords = []; - state._pickByEntity = new WeakMap(); - state._referenceLabelCount = 0; - state._publishScratch.length = 0; - state._markerBlendDone = false; - state._markerBlendInvariantWarned = false; - parts.rendering.resetPublishSignature(); - state._referenceSweepGate.reset(); - }, - - getStats() { - return { - count: state._count, - lastUpdate: state._lastUpdate, - loading: state._loading, - loadingLabel: state._loadingLabel, - error: state._error, - referenceLabelCount: state._referenceLabelCount, - }; - }, - }; -} diff --git a/src/layers/submarineCables/overlay.js b/src/layers/submarineCables/overlay.js index 9c9b5dbc1..40f496e56 100644 --- a/src/layers/submarineCables/overlay.js +++ b/src/layers/submarineCables/overlay.js @@ -41,7 +41,9 @@ export function selectCableReferenceLabelWinners( .sort( (a, b) => a.distanceM - b.distanceM || - String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')), + String(a.id ?? a.entity?.id ?? '').localeCompare( + String(b.id ?? b.entity?.id ?? ''), + ), ) .slice(0, cap); } @@ -66,7 +68,7 @@ export function cableReferencePriority(distanceM) { * The entry stays attached to the record's mutable stem-tip Cartesian and is * created once per record; only `priority` is refreshed per sweep. * `interactive: false` follows the infrastructure precedent — the depth-tested - * native point/stem/line remains the click surface, and a non-interactive + * point, stem and line on the globe remain the click surface, and a non-interactive * label keeps the host's per-frame accessibility/hit sync allocation-free. * @param {object} record Reference record ({ id, kind, label, tip }). * @returns {object} @@ -212,7 +214,7 @@ export function createCableReferenceSweepGate({ dirty = true; }, /** - * @param {Cesium.Camera} [camera] Live camera; omit to disable the motion + * @param {{ positionWC: object }} [camera] Live camera; omit to disable the motion * fallback entirely (pure dirty-only gate). * @returns {boolean} */ @@ -248,16 +250,14 @@ export function createCableReferenceSweepGate({ } /** - * Recompute one staticized stem on the sweep cadence. Replaces the former - * per-frame `CallbackProperty` pair: constant properties are redefined only - * when the camera-scaled tip moved beyond the settle epsilon, and the two - * preallocated position buffers alternate so each real change raises exactly - * one geometry notification (the localGeojson double-buffer pattern). - * @param {object} record Reference record with entity/base/tip/nextTip/buffers. - * @param {Cesium.Cartesian3} cameraPositionWC + * Size one reference's tip on the sweep cadence: high enough above its base + * for a ~STEM_TARGET_PX stem at the camera's distance (700 m..85 km). The + * tip moves only when it changed by more than the settle epsilon. + * @param {object} record Reference record with base/tip/nextTip/reference. + * @param {{x: number, y: number, z: number}} cameraPositionWC * @param {number} canvasHeight CSS-pixel canvas height. * @param {number} fov Camera frustum field of view (radians). - * @returns {boolean} True when the stem geometry was redefined. + * @returns {boolean} True when the tip moved. */ export function updateCableReferenceTip( @@ -292,24 +292,6 @@ export function updateCableReferenceTip( return true; } -export function updateCableReferenceStem( - record, - cameraPositionWC, - canvasHeight, - fov, -) { - if (!updateCableReferenceTip(record, cameraPositionWC, canvasHeight, fov)) - return false; - record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex; - const stemPositions = - record.stemPositionBuffers[record.stemPositionBufferIndex]; - stemPositions[0] = record.base; - stemPositions[1] = record.tip; - record.entity.position.setValue(record.tip); - record.entity.polyline.positions.setValue(stemPositions); - return true; -} - export function clampLabel(value, maxLength = 34) { const text = String(value || '') .replace(/\s+/g, ' ') diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js index 9fb0c4341..ffa99936e 100644 --- a/src/layers/submarineCables/policy.js +++ b/src/layers/submarineCables/policy.js @@ -1,5 +1,3 @@ -import * as Cesium from 'cesium'; - export const BASE_CABLE_COLOR = '#39d5ff'; export const BASE_LANDING_COLOR = '#8fffd2'; @@ -53,8 +51,8 @@ export const CABLE_SWEEP_MOTION_EPSILON_M = 250; * layer alone cost ~9.5 ms/frame during camera motion. The depth cue this * trades away (photoreal tiles occluding label TEXT at low, city-level * cameras) matches the sibling dams/datacenters sources, which shipped - * host-composited under the same ruling; the anchor points/stems remain - * Cesium-native and depth-tested. See + * host-composited under the same ruling; the anchor points and stems stay + * depth-tested on the globe. See * the world-overlay consolidation design notes ("Depth-testing * decision") for both dated decisions. */ @@ -67,7 +65,7 @@ export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({ }); /** - * `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion + * `MAP_STACKS` ids that render imagery on the SHOWN globe (the ion * Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that * is not photoreal": an id this module has never heard of is UNKNOWN, and * unknown must reach the documented BOTH fallback rather than being asserted @@ -81,23 +79,6 @@ export const CABLE_GLOBE_STACK_IDS = Object.freeze( new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm', 'sentinel2']), ); -/** EntityCluster's private marker collections and their required types. */ - -export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([ - Object.freeze({ - key: '_billboardCollection', - get type() { - return Cesium.BillboardCollection; - }, - }), - Object.freeze({ - key: '_pointCollection', - get type() { - return Cesium.PointPrimitiveCollection; - }, - }), -]); - /** * Monotonic clock for the sweep gate's motion probe. A hoisted declaration: * the module's `export default createTeleGeographySubmarineCableLayer()` diff --git a/src/layers/submarineCables/render/display.js b/src/layers/submarineCables/render/display.js new file mode 100644 index 000000000..64ca04b83 --- /dev/null +++ b/src/layers/submarineCables/render/display.js @@ -0,0 +1,488 @@ +// The submarine cables on the globe contract, the same on every engine: +// - cable paths: one engine.ground corridor set (2.5 px, each cable's own +// colour at 0.92), draped on the active map stack's surface; +// - landing points: a point mover set at the ground (the 6 px GeoJSON pin's +// width and colour); +// - references: per cable and landing point, a tip point mover high enough +// for a ~66 px stem, sized on the sweep cadence, with its label on the +// shared world-overlay host; +// - stems: engine.lines from the base to the tip, for the label winners +// (at most CABLE_REFERENCE_LABEL_WINNER_CAP). +// Clicks on any of them fly to the reference. + +import * as geo from '../../../geo/index.js'; +import { isPointerFree } from '../../../data/inputOwnership.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { + featureLabel, + featureReference, + normalizeFeatures, +} from '../geometry.js'; +import { + BASE_CABLE_COLOR, + BASE_LANDING_COLOR, + CABLE_OVERLAY_SOURCE_ID as ID, + defaultSweepClock, +} from '../policy.js'; +import { + cableReferencePriority, + clampLabel, + createCableOverlayEntry, + createCableOverlayPublisher, + createCableReferenceSweepGate, + selectCableReferenceLabelWinners, + updateCableReferenceTip, +} from '../overlay.js'; +import { cableDrapeForEngine, cableDrapeForStack } from '../surface.js'; + +const TIP_HEIGHT_M = 2500; +const OUTLINE = [0, 0, 0, 0.65]; +const color = (css, alpha) => { + const rgba = rgbaFromCss(css); + return [rgba.red, rgba.green, rgba.blue, alpha]; +}; +// A feature's own CSS colour, else the base cable colour. +const featureColor = (feature, alpha) => { + const css = feature?.properties?.color; + if (css) + try { + return color(String(css), alpha); + } catch { + /* not a hex colour: the base colour */ + } + return color(BASE_CABLE_COLOR, alpha); +}; + +// What each kind draws, as Cesium drew the GeoJSON entities and references: +// a cable path at its feature colour (0.92, 2.5 px) and a landing point's +// path at the GeoJSON stroke (0.9, 2 px); point geometry as the 6 px GeoJSON +// pin, here a disc of the pin's ~4 px width in the marker colour; the +// reference tip and stem at the kind's colour. +const KINDS = Object.freeze({ + cable: { + path: (feature) => ({ color: featureColor(feature, 0.92), widthPx: 2.5 }), + marker: color(BASE_CABLE_COLOR, 1), + tip: { sizePx: 7, color: color(BASE_CABLE_COLOR, 0.84) }, + stem: { widthPx: 2, color: color(BASE_CABLE_COLOR, 0.58) }, + }, + 'landing-point': { + path: () => ({ color: color(BASE_LANDING_COLOR, 0.9), widthPx: 2 }), + marker: color(BASE_LANDING_COLOR, 1), + tip: { sizePx: 8, color: color(BASE_LANDING_COLOR, 0.94) }, + stem: { widthPx: 2.4, color: color(BASE_LANDING_COLOR, 0.68) }, + }, +}); +const MARKER_PX = 4; + +const finitePair = (pair) => + Array.isArray(pair) && + Number.isFinite(Number(pair[0])) && + Number.isFinite(Number(pair[1])) && + Math.abs(Number(pair[1])) <= 90; + +const lonLat = ([lon, lat]) => [Number(lon), Number(lat)]; + +/** A geometry's paths (lines) and points, as [lon, lat] number pairs. */ +function shapesOf(geometry) { + const paths = []; + const points = []; + const add = (type, coordinates) => { + if (!Array.isArray(coordinates)) return; + if (type === 'Point') { + if (finitePair(coordinates)) points.push(lonLat(coordinates)); + } else if (type === 'MultiPoint') { + for (const point of coordinates) + if (finitePair(point)) points.push(lonLat(point)); + } else if (type === 'LineString') { + const path = coordinates.filter(finitePair).map(lonLat); + if (path.length > 1) paths.push(path); + } else if (type === 'MultiLineString') { + for (const line of coordinates) add('LineString', line); + } + }; + if (geometry?.type === 'GeometryCollection') + for (const part of geometry.geometries ?? []) + add(part?.type, part?.coordinates); + else add(geometry?.type, geometry?.coordinates); + return { paths, points }; +} + +/** + * Cesium's PerspectiveFrustum.fov: the horizontal angle on a wide canvas, + * the vertical one on a tall canvas (what the stem sizing reads). + */ +function frustumFov({ fovy, aspectRatio }) { + const aspect = aspectRatio ?? 1; + return aspect > 1 ? 2 * Math.atan(Math.tan(fovy / 2) * aspect) : fovy; +} + +/** + * @param {import('../../../globe/contract.js').GlobeEngine} engine + * @param {{ overlayHost: object, mapStackEventTarget?: EventTarget | null, + * sweepClock?: () => number }} options + * `mapStackEventTarget` raises 'gev:map-stack-changed' (the window). + * @returns {{ show: (dataset: object) => void, hide: () => void, + * destroy: () => void, stats: () => { referenceLabelCount: number } }} + */ +export function createCablesDisplay( + engine, + { + overlayHost, + mapStackEventTarget = null, + sweepClock = defaultSweepClock, + } = {}, +) { + if (!engine?.ground || !engine?.movers || !engine?.lines) + throw new TypeError('The cables display needs a globe engine'); + const publisher = createCableOverlayPublisher({ host: overlayHost }); + const gate = createCableReferenceSweepGate({ now: sweepClock }); + // Reference records by id (their pickId), in creation order. + const records = new Map(); + // Drawn object ids (sets and lines) whose picks are cables. + const owners = new Set(); + // record id → its stem, while the record is a label winner. + const stems = new Map(); + const tipSets = new Map(); + const removers = []; + let dataset = null; + let paths = null; + let pathItems = []; + let markers = null; + let markerRows = []; + let drape = cableDrapeForEngine(engine); + let destroyed = false; + let referenceLabelCount = 0; + let lastIds = []; + let lastPriorities = []; + let lastCount = -1; + const scratchCamera = { positionWC: null }; + + const wake = () => engine.render.request(ID); + + function resetSignature() { + lastIds = []; + lastPriorities = []; + lastCount = -1; + } + + function buildPaths() { + paths?.destroy(); + owners.delete(`${ID}-paths`); + paths = null; + if (!pathItems.length) return; + paths = engine.ground.createSet({ + id: `${ID}-paths`, + kind: 'corridor', + pickable: true, + drape, + }); + owners.add(paths.id); + paths.replace(pathItems); + paths.commit(); + } + + function setDrape(next) { + if (next === drape) return; + drape = next; + if (dataset) buildPaths(); + } + + // A winner's stem: its line and the tip it was last drawn to. + function stemFor(record) { + let stem = stems.get(record.id); + if (!stem) { + const { widthPx, color: stemColor } = KINDS[record.kind].stem; + const id = `${ID}-stem-${record.id}`; + stem = { + id, + line: engine.lines.createLine({ + id, + widthPx, + color: stemColor, + pickId: record.id, + }), + tip: null, + }; + owners.add(id); + stems.set(record.id, stem); + } + return stem; + } + + function dropStem(recordId) { + const stem = stems.get(recordId); + if (!stem) return; + stem.line.destroy(); + owners.delete(stem.id); + stems.delete(recordId); + } + + // The reference sweep, on the gate's cadence: horizon visibility, + // distances, tip heights, the label cohort and the winners' stems. + function sweep() { + if (!dataset) return; + const position = engine.camera.livePosition; + scratchCamera.positionWC = position; + if (!gate.shouldRun(scratchCamera)) return; + const height = engine.host.size().height || 1080; + const fov = frustumFov(engine.camera.pose()); + const tipIndex = new Map(); + for (const record of records.values()) { + const visible = engine.projector.horizonVisible(record.base); + record.visible = visible; + record.distanceM = geo.cartesian3.distance(position, record.base); + // Hidden tips keep their last height; the sweep that shows them again + // re-sizes them. + if (visible) updateCableReferenceTip(record, position, height, fov); + const set = tipSets.get(record.kind); + const index = tipIndex.get(record.kind) ?? 0; + tipIndex.set(record.kind, index + 1); + set.set(index, { position: record.tip, show: visible }); + } + for (const set of tipSets.values()) set.commit(); + if (markers) { + markerRows.forEach((row, index) => + markers.set(index, { + show: engine.projector.horizonVisible(row.base), + }), + ); + markers.commit(); + } + const winners = selectCableReferenceLabelWinners([...records.values()]); + referenceLabelCount = winners.length; + const winning = new Set(winners.map((record) => record.id)); + for (const id of [...stems.keys()]) if (!winning.has(id)) dropStem(id); + for (const record of winners) { + const stem = stemFor(record); + if (stem.tip && geo.cartesian3.equals(stem.tip, record.tip)) continue; + stem.line.setPositions([record.base, record.tip]); + stem.tip = geo.cartesian3.clone(record.tip, stem.tip ?? undefined); + } + let changed = winners.length !== lastCount; + const entries = winners.map((record, index) => { + const priority = cableReferencePriority(record.distanceM); + record.entry.priority = priority; + if ( + !changed && + (lastIds[index] !== record.entry.id || + lastPriorities[index] !== priority) + ) + changed = true; + return record.entry; + }); + if (!changed) return; + publisher.publish(entries); + lastCount = entries.length; + lastIds = entries.map((entry) => entry.id); + lastPriorities = entries.map((entry) => entry.priority); + } + + function flyTo(reference) { + engine.camera.flyTo({ + destination: geo.cartesian3.fromDegrees( + reference.lon, + reference.lat, + 6500, + ), + orientation: { + heading: engine.camera.pose().heading || 0, + pitch: geo.math.toRadians(-52), + roll: 0, + }, + duration: 1.35, + easing: 'cubicInOut', + }); + } + + function onClick(event) { + // A tool owns the pointer (src/data/inputOwnership.js): yield the click. + if (!isPointerFree()) return; + const picked = event.picked; + if (!dataset || !picked || !owners.has(picked.owner)) return; + const record = records.get(picked.id); + if (record?.reference) flyTo(record.reference); + } + + function hide() { + for (const remove of removers.splice(0)) remove(); + paths?.destroy(); + paths = null; + markers?.destroy(); + markers = null; + for (const set of tipSets.values()) set.destroy(); + tipSets.clear(); + for (const id of [...stems.keys()]) dropStem(id); + owners.clear(); + records.clear(); + pathItems = []; + markerRows = []; + dataset = null; + referenceLabelCount = 0; + resetSignature(); + gate.reset(); + publisher.hide(); + wake(); + } + + function build(next) { + const byKind = [ + ['cable', normalizeFeatures(next.cables, 'cable')], + ['landing-point', normalizeFeatures(next.landingPoints, 'landing')], + ]; + const items = []; + const rows = []; + for (const [kind, features] of byKind) { + const style = KINDS[kind]; + for (const feature of features) { + const reference = featureReference(feature); + if (!reference) continue; + const featureId = feature?.id || feature?.properties?.id || 'feature'; + const id = `${kind}-reference-${records.size}-${featureId}`; + const base = geo.cartesian3.fromDegrees(reference.lon, reference.lat); + const tip = geo.cartesian3.fromDegrees( + reference.lon, + reference.lat, + TIP_HEIGHT_M, + ); + const record = { + id, + kind, + reference, + label: clampLabel(featureLabel(feature)), + featureId, + base, + tip, + nextTip: geo.cartesian3.clone(tip), + visible: false, + distanceM: Infinity, + entry: null, + }; + record.entry = createCableOverlayEntry(record); + records.set(id, record); + const shapes = shapesOf(feature.geometry); + for (const positions of shapes.paths) + items.push({ positions, ...style.path(feature), pickId: id }); + for (const point of shapes.points) + rows.push({ + position: geo.cartesian3.fromDegrees(point[0], point[1]), + base, + color: style.marker, + pickId: id, + }); + } + } + pathItems = items; + buildPaths(); + markerRows = rows; + if (rows.length) { + markers = engine.movers.createSet({ + id: `${ID}-markers`, + kind: 'point', + capacity: rows.length, + depthTest: 'normal', + pickable: true, + point: { sizePx: MARKER_PX, outlinePx: 0 }, + }); + owners.add(`${ID}-markers`); + rows.forEach((row, index) => + markers.set(index, { + position: row.position, + color: row.color, + show: true, + pickId: row.pickId, + }), + ); + markers.setCount(rows.length); + markers.commit(); + } + const counts = new Map(); + for (const record of records.values()) + counts.set(record.kind, (counts.get(record.kind) ?? 0) + 1); + for (const [kind, count] of counts) { + const { tip } = KINDS[kind]; + const setId = `${ID}-${kind}-tips`; + const set = engine.movers.createSet({ + id: setId, + kind: 'point', + capacity: count, + depthTest: 'normal', + pickable: true, + point: { sizePx: tip.sizePx, outlinePx: 1, outlineColor: OUTLINE }, + }); + owners.add(setId); + tipSets.set(kind, set); + } + // Tips draw from the first sweep on: until then, hidden. + const tipIndex = new Map(); + for (const record of records.values()) { + const index = tipIndex.get(record.kind) ?? 0; + tipIndex.set(record.kind, index + 1); + tipSets.get(record.kind).set(index, { + position: record.tip, + color: KINDS[record.kind].tip.color, + show: false, + pickId: record.id, + }); + } + for (const [kind, set] of tipSets) { + set.setCount(tipIndex.get(kind) ?? 0); + set.commit(); + } + } + + // The active map stack decides the surface the paths drape on: 3D tiles + // on photoreal, terrain on the globe stacks, both on an unknown stack. + const onStackChanged = (event) => + setDrape( + event?.detail?.activeId + ? cableDrapeForStack(event.detail.activeId) + : cableDrapeForEngine(engine), + ); + mapStackEventTarget?.addEventListener?.( + 'gev:map-stack-changed', + onStackChanged, + ); + const removeStackListener = () => + mapStackEventTarget?.removeEventListener?.( + 'gev:map-stack-changed', + onStackChanged, + ); + + return { + /** Draws a dataset ({ cables, landingPoints } GeoJSON collections). */ + show(next) { + if (destroyed || next === dataset) return; + if (dataset) hide(); + try { + build(next); + dataset = next; + removers.push( + engine.input.on('click', onClick, { pick: true }), + engine.camera.moveEnd.addEventListener(() => { + gate.markDirty(); + wake(); + }), + engine.frames.preRender.addEventListener(sweep), + ); + publisher.show(); + // A hide cleared the host source: an identical cohort republishes. + resetSignature(); + gate.markDirty(); + wake(); + } catch (error) { + hide(); + throw error; + } + }, + hide, + destroy() { + if (destroyed) return; + hide(); + destroyed = true; + removeStackListener(); + }, + stats: () => ({ referenceLabelCount }), + /** The surface the cable paths drape on ('both', 'terrain', 'tiles'). */ + drape: () => drape, + }; +} diff --git a/src/layers/submarineCables/render/display.test.mjs b/src/layers/submarineCables/render/display.test.mjs new file mode 100644 index 000000000..a322eb512 --- /dev/null +++ b/src/layers/submarineCables/render/display.test.mjs @@ -0,0 +1,763 @@ +// The cables display on the engine contract, on the fake engine and on the +// Cesium engine: cable paths and their colours, widths, picks and drape per +// map stack; landing markers; the reference sweep (tips, horizon, labels, +// the publish skip, the winners' stems, the moveEnd and motion cadence); +// clicks that fly to a reference; hide, show and destroy; and the +// antimeridian edge cases. Each harness records what the ground sets were +// given and the flights asked for, drives preRender and moveEnd by hand, and +// clicks with a pick of a ground item, a mover instance or a line. + +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +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 { claimPointer, releasePointer } from '../../../data/inputOwnership.js'; +import { rgbaFromCss } from '../../shared/color.js'; +import { + BASE_CABLE_COLOR, + BASE_LANDING_COLOR, + CABLE_OVERLAY_COLLISION_CAPACITY, + CABLE_OVERLAY_SOURCE_ID, + CABLE_REFERENCE_LABEL_WINNER_CAP, + CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS, +} from '../policy.js'; +import { createCablesDisplay } from './display.js'; + +// GroundPolylinePrimitive checks its render state's line width against the +// context's limits, which a stub scene has none of. +Cesium.ContextLimits._minimumAliasedLineWidth = 1; +Cesium.ContextLimits._maximumAliasedLineWidth = 10; + +function watch(engine) { + const sets = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const record = { options, pending: [], items: [], commits: 0 }; + const { replace, commit } = set; + set.replace = (items) => { + replace(items); + record.pending = items; + }; + set.commit = () => { + commit(); + record.items = record.pending; + record.commits++; + }; + sets.set(options.id, record); + return set; + }; + const positionsSet = new Map(); + const createLine = engine.lines.createLine.bind(engine.lines); + engine.lines.createLine = (options) => { + const line = createLine(options); + const { setPositions } = line; + positionsSet.set(options.id, 0); + line.setPositions = (positions) => { + positionsSet.set(options.id, positionsSet.get(options.id) + 1); + setPositions(positions); + }; + return line; + }; + const flights = []; + engine.camera.flyTo = (options) => flights.push(options); + return { + /** A live ground set: its options and last committed items. */ + groundSet(id) { + const listed = engine.ground.list().find((entry) => entry.id === id); + return listed ? sets.get(id) : undefined; + }, + /** How often a line's positions were set. */ + positionsSet: (id) => positionsSet.get(id) ?? 0, + flights, + lineIds: () => engine.lines.list().map(({ id }) => id), + look(lon, lat, height) { + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(lon, lat, height), + }); + }, + }; +} + +function fakeHarness() { + const engine = createFakeEngine(); + const watched = watch(engine); + return { + name: 'the fake engine', + engine, + ...watched, + preRender: () => engine.fake.emit('frames.preRender', 0), + moveEnd: () => engine.fake.emit('camera.moveEnd'), + /** A mover set's instance: shown, colour, size and pickId. */ + point(id, index) { + const entry = engine.fake.moverSet(id); + if (!entry || index >= entry.store.count) return undefined; + const { store, options } = entry; + return { + shown: store.shows[index] === 1, + color: [...store.colors.slice(index * 4, index * 4 + 4)], + sizePx: options.point.sizePx, + outlinePx: options.point.outlinePx, + id: store.pickIds[index], + position: { + x: store.positions[index * 3], + y: store.positions[index * 3 + 1], + z: store.positions[index * 3 + 2], + }, + }; + }, + line(id) { + const line = engine.fake.line(id); + return line && { positions: line.positions, color: line.color }; + }, + click(target) { + const picked = + target?.ground !== undefined + ? { + id: watched.groundSet(target.ground).items[target.instance] + .pickId, + owner: target.ground, + instance: target.instance, + } + : target?.mover !== undefined + ? { + id: engine.fake.moverSet(target.mover).store.pickIds[ + target.instance + ], + owner: target.mover, + instance: target.instance, + } + : target?.line !== undefined + ? { + id: engine.fake.line(target.line).options.pickId, + owner: target.line, + } + : target; + engine.fake.script('pick', picked); + engine.fake.input('click', { position: { x: 100, y: 100 } }); + engine.fake.unscript('pick'); + }, + }; +} + +function cesiumHarness() { + const handlers = createFakeEventHandlers(); + const { engine, viewer, primitive } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); + const watched = watch(engine); + const count = (id) => engine.movers.list().find((set) => set.id === id).count; + return { + name: 'the Cesium engine', + cesium: true, + engine, + viewer, + ...watched, + preRender: () => viewer.scene.preRender.raiseEvent(viewer.scene), + moveEnd: () => viewer.camera.moveEnd.raiseEvent(), + /** The Cesium ground primitives a set drew, in its slot. */ + groundPrimitives(id) { + const root = viewer.scene.groundPrimitives.get(0); + const found = []; + for (let index = 0; index < (root?.length ?? 0); index++) { + const slot = root.get(index); + for (let at = 0; at < slot.length; at++) + if (slot.get(at).gevGroundSet === id) found.push(slot.get(at)); + } + return found; + }, + point(id, index) { + const collection = primitive(id); + if (!collection || index >= count(id)) return undefined; + const item = collection.get(index); + return { + shown: item.show, + color: [ + item.color.red, + item.color.green, + item.color.blue, + item.color.alpha, + ], + sizePx: item.pixelSize, + outlinePx: item.outlineWidth, + outlineColor: item.outlineColor, + id: item.id, + position: item.position, + native: item, + }; + }, + line(id) { + return primitive(id) && { primitive: primitive(id) }; + }, + click(target) { + native = + target?.ground !== undefined + ? { id: { gevGroundSet: target.ground, instance: target.instance } } + : target?.mover !== undefined + ? { primitive: primitive(target.mover).get(target.instance) } + : target?.line !== undefined + ? { primitive: primitive(target.line) } + : target; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(100, 100), + }); + native = undefined; + }, + }; +} + +const HARNESSES = [fakeHarness, cesiumHarness]; + +const PATHS = `${CABLE_OVERLAY_SOURCE_ID}-paths`; +const MARKERS = `${CABLE_OVERLAY_SOURCE_ID}-markers`; +const CABLE_TIPS = `${CABLE_OVERLAY_SOURCE_ID}-cable-tips`; +const LANDING_TIPS = `${CABLE_OVERLAY_SOURCE_ID}-landing-point-tips`; +const rgba = (css, alpha) => [...rgbaFromCss(css).rgba().slice(0, 3), alpha]; +const near = (actual, expected, label) => + actual.forEach((value, index) => + assert.ok( + Math.abs(value - expected[index]) < 1e-6, + `${label}: ${actual} vs ${expected}`, + ), + ); + +const alpha = { + type: 'Feature', + properties: { + id: 'alpha', + name: 'Alpha cable', + color: '#ff0000', + coordinates: [-40, 35], + }, + geometry: { + type: 'MultiLineString', + coordinates: [ + [ + [-40, 35], + [-39, 36], + ], + [ + [-38, 37], + [-37, 38], + ], + ], + }, +}; +// No colour of its own and no reference coordinates: the base colour and a +// reference averaged from its path. +const beta = { + type: 'Feature', + properties: { id: 'beta', name: 'Beta cable' }, + geometry: { + type: 'LineString', + coordinates: [ + [140, -35], + [141, -34], + ], + }, +}; +const lisbon = { + type: 'Feature', + properties: { id: 'lisbon', name: 'Lisbon, Portugal', is_tbd: false }, + geometry: { type: 'Point', coordinates: [-9.1, 38.7] }, +}; +const dataset = (cables = [alpha, beta], landings = [lisbon]) => ({ + cables: { type: 'FeatureCollection', features: cables }, + landingPoints: { type: 'FeatureCollection', features: landings }, +}); + +function stackTarget() { + const listeners = new Map(); + return { + listeners, + addEventListener: (name, fn) => listeners.set(name, fn), + removeEventListener: (name, fn) => { + if (listeners.get(name) === fn) listeners.delete(name); + }, + }; +} + +function fixture(t, makeHarness, options = {}) { + const harness = makeHarness(); + const hostCalls = []; + let clock = 0; + const display = createCablesDisplay(harness.engine, { + overlayHost: { + setVisible: (...args) => hostCalls.push(['visible', ...args]), + setEntries: (...args) => hostCalls.push(['entries', ...args]), + clearSource: (...args) => hostCalls.push(['clear', ...args]), + }, + sweepClock: () => clock, + ...options, + }); + t.after(() => { + display.destroy(); + harness.engine.destroy(); + }); + return { + ...harness, + display, + hostCalls, + publishes: () => hostCalls.filter(([type]) => type === 'entries'), + advance(ms) { + clock += ms; + }, + }; +} + +for (const makeHarness of HARNESSES) { + const { name } = makeHarness(); + + test(`cable paths draw as one pixel corridor set in their colours, one item per path (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.display.show(dataset()); + const set = f.groundSet(PATHS); + assert.equal(set.options.kind, 'corridor'); + assert.equal(set.options.pickable, true); + assert.equal(set.options.drape, 'terrain', 'no photoreal tiles: terrain'); + assert.equal(set.items.length, 3, 'every path of a multipart cable'); + assert.deepEqual( + set.items.map((item) => item.widthPx), + [2.5, 2.5, 2.5], + ); + near(set.items[0].color, rgba('#ff0000', 0.92), 'own colour at 0.92'); + near(set.items[1].color, rgba('#ff0000', 0.92), 'every segment'); + near(set.items[2].color, rgba(BASE_CABLE_COLOR, 0.92), 'base colour'); + assert.equal(set.items[0].pickId, set.items[1].pickId); + assert.notEqual(set.items[0].pickId, set.items[2].pickId); + assert.deepEqual(set.items[0].positions, alpha.geometry.coordinates[0]); + if (f.cesium) { + const [batch] = f.groundPrimitives(PATHS); + assert.ok(batch instanceof Cesium.GroundPolylinePrimitive); + assert.equal(batch.geometryInstances.length, 3); + assert.equal(batch.geometryInstances[0].geometry.width, 2.5); + assert.equal(batch.classificationType, Cesium.ClassificationType.TERRAIN); + assert.deepEqual( + [...batch.geometryInstances[0].attributes.color.value], + [255, 0, 0, Math.round(0.92 * 255)], + ); + } + }); + + test(`the paths drape on the active map stack's surface and follow its changes (${name})`, (t) => { + const target = stackTarget(); + const f = fixture(t, makeHarness, { mapStackEventTarget: target }); + f.display.show(dataset()); + const listener = target.listeners.get('gev:map-stack-changed'); + assert.equal(typeof listener, 'function'); + listener({ detail: { activeId: 'photoreal', status: 'ready' } }); + assert.equal(f.groundSet(PATHS).options.drape, 'tiles'); + assert.equal(f.groundSet(PATHS).items.length, 3, 'rebuilt whole'); + if (f.cesium) + assert.equal( + f.groundPrimitives(PATHS)[0].classificationType, + Cesium.ClassificationType.CESIUM_3D_TILE, + ); + listener({ detail: { activeId: 'osm', status: 'ready' } }); + assert.equal(f.groundSet(PATHS).options.drape, 'terrain'); + listener({ detail: { activeId: 'some-future-stack' } }); + assert.equal(f.groundSet(PATHS).options.drape, 'both'); + assert.equal( + f.engine.ground.list().length, + 1, + 'one set at a time: the old one goes', + ); + f.display.hide(); + listener({ detail: { activeId: 'photoreal' } }); + assert.equal(f.display.drape(), 'tiles', 'kept for the next show'); + f.display.show(dataset()); + assert.equal(f.groundSet(PATHS).options.drape, 'tiles'); + f.display.destroy(); + assert.equal( + target.listeners.has('gev:map-stack-changed'), + false, + 'destroy detaches the stack listener', + ); + }); + + test(`landing points are small ground markers, horizon-culled on the sweep (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.display.show(dataset()); + const marker = f.point(MARKERS, 0); + assert.equal(marker.sizePx, 4, "the 6 px GeoJSON pin's width"); + assert.equal(marker.outlinePx, 0); + near(marker.color, rgba(BASE_LANDING_COLOR, 1), 'the marker colour'); + assert.equal(marker.shown, true); + assert.equal(marker.id, `landing-point-reference-2-lisbon`); + // The far side of the planet from Lisbon. + f.look(170.9, -38.7, 4_500_000); + f.preRender(); + assert.equal(f.point(MARKERS, 0).shown, false); + f.look(-9.1, 38.7, 4_500_000); + f.moveEnd(); + f.preRender(); + assert.equal(f.point(MARKERS, 0).shown, true); + }); + + test(`the sweep shows visible tips, publishes their labels once and stems the winners (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(-40, 35, 4_500_000); + f.display.show(dataset()); + assert.equal(f.point(CABLE_TIPS, 0).shown, false, 'hidden until a sweep'); + f.preRender(); + const alphaTip = f.point(CABLE_TIPS, 0); + assert.equal(alphaTip.shown, true); + assert.equal(alphaTip.sizePx, 7); + assert.equal(alphaTip.outlinePx, 1); + near(alphaTip.color, rgba(BASE_CABLE_COLOR, 0.84), 'cable tip'); + assert.equal(alphaTip.id, 'cable-reference-0-alpha'); + assert.equal(f.point(CABLE_TIPS, 1).shown, false, 'beyond the horizon'); + const landingTip = f.point(LANDING_TIPS, 0); + assert.equal(landingTip.sizePx, 8); + near(landingTip.color, rgba(BASE_LANDING_COLOR, 0.94), 'landing tip'); + if (f.cesium) { + assert.equal(alphaTip.native.disableDepthTestDistance, 0, 'depth-tested'); + near( + [ + alphaTip.outlineColor.red, + alphaTip.outlineColor.green, + alphaTip.outlineColor.blue, + alphaTip.outlineColor.alpha, + ], + [0, 0, 0, 0.65], + 'tip outline', + ); + } + const tipHeight = geo.cartographic.fromCartesian(alphaTip.position).height; + assert.ok(tipHeight >= 700 && tipHeight <= 85_000, `tip ${tipHeight} m`); + + const [publish] = f.publishes(); + assert.equal(f.publishes().length, 1); + const [, sourceId, entries, options] = publish; + assert.equal(sourceId, CABLE_OVERLAY_SOURCE_ID); + assert.deepEqual( + entries.map((entry) => entry.title), + ['Alpha cable', 'Lisbon, Portugal'], + 'the visible references, nearest first', + ); + assert.ok( + geo.cartesian3.equalsEpsilon( + entries[0].position, + alphaTip.position, + 0, + 1e-3, + ), + 'the label sits at the tip', + ); + assert.deepEqual(options, { + cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP, + collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY, + moving: false, + }); + assert.equal(f.display.stats().referenceLabelCount, 2); + + const stems = f.lineIds(); + assert.deepEqual(stems, [ + `${CABLE_OVERLAY_SOURCE_ID}-stem-cable-reference-0-alpha`, + `${CABLE_OVERLAY_SOURCE_ID}-stem-landing-point-reference-2-lisbon`, + ]); + assert.ok( + stems.every((id) => f.line(id)), + 'each stem is drawn', + ); + if (!f.cesium) { + const cableStem = f.engine.fake.line(stems[0]); + assert.equal(cableStem.options.widthPx, 2); + near(cableStem.color, rgba(BASE_CABLE_COLOR, 0.58), 'cable stem'); + assert.equal(cableStem.positions.length, 6, 'base and tip'); + const landingStem = f.engine.fake.line(stems[1]); + assert.equal(landingStem.options.widthPx, 2.4); + near(landingStem.color, rgba(BASE_LANDING_COLOR, 0.68), 'landing stem'); + } + + // A moveEnd at an unchanged camera re-runs the sweep; the identical + // cohort is not published again. + f.moveEnd(); + f.preRender(); + f.preRender(); + assert.equal(f.publishes().length, 1, 'identical cohorts are skipped'); + // A move past the 50 km priority quantum publishes. + f.look(-40, 35, 6_500_000); + f.moveEnd(); + f.preRender(); + assert.equal(f.publishes().length, 2, 'a changed cohort publishes'); + }); + + test(`stems re-size only on moveEnd or the motion probe, never mid-drag (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(-35, 37.5, 400_000); + f.display.show(dataset()); + f.preRender(); + const stem = `${CABLE_OVERLAY_SOURCE_ID}-stem-cable-reference-0-alpha`; + assert.equal(f.positionsSet(stem), 1); + // A drag: the camera moves every frame, no moveEnd, the probe window + // stays closed. + for (let tick = 0; tick < 30; tick++) { + f.look(-35, 37.5, 400_000 + 2_000 * (tick + 1)); + f.advance(10); + f.preRender(); + } + assert.equal(f.positionsSet(stem), 1, 'no re-size mid-drag'); + f.moveEnd(); + f.preRender(); + assert.equal(f.positionsSet(stem), 2, 'moveEnd re-sizes once'); + f.preRender(); + assert.equal(f.positionsSet(stem), 2, 'the next frame does not'); + // A follow camera never raises moveEnd: past the probe window, moved + // past the motion epsilon, one sweep. + f.look(-35, 37.5, 300_000); + f.advance(CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS + 1); + f.preRender(); + assert.equal(f.positionsSet(stem), 3, 'the motion fallback sweeps'); + f.advance(CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS + 1); + f.preRender(); + assert.equal(f.positionsSet(stem), 3, 'a parked camera never sweeps'); + }); + + test(`a click on a path, marker, tip or stem flies to its reference (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(-40, 35, 4_500_000); + f.display.show(dataset()); + f.preRender(); + const stem = `${CABLE_OVERLAY_SOURCE_ID}-stem-landing-point-reference-2-lisbon`; + for (const target of [ + { ground: PATHS, instance: 1 }, + { mover: MARKERS, instance: 0 }, + { mover: CABLE_TIPS, instance: 0 }, + { line: stem }, + ]) + f.click(target); + assert.equal(f.flights.length, 4); + const [path, marker, tip, stemFlight] = f.flights; + const at = (flight) => { + const place = geo.cartographic.fromCartesian(flight.destination); + return [ + geo.math.toDegrees(place.longitude), + geo.math.toDegrees(place.latitude), + place.height, + ]; + }; + near(at(path), [-40, 35, 6500], 'the cable reference'); + near(at(marker), [-9.1, 38.7, 6500], 'the landing point'); + near(at(tip), [-40, 35, 6500], 'the tip'); + near(at(stemFlight), [-9.1, 38.7, 6500], 'the stem'); + assert.equal(path.duration, 1.35); + assert.equal(path.easing, 'cubicInOut'); + assert.ok( + Math.abs(path.orientation.pitch - geo.math.toRadians(-52)) < 1e-12, + ); + assert.equal(path.orientation.roll, 0); + assert.ok( + Math.abs(path.orientation.heading - f.engine.camera.pose().heading) < + 1e-9, + 'keeps the heading', + ); + + // A tool owns the pointer: the click is the tool's. + const lease = claimPointer('draw'); + try { + f.click({ ground: PATHS, instance: 0 }); + } finally { + releasePointer(lease); + } + // Nothing, the map, or another layer's object. + f.click(undefined); + f.click({ id: 'cable-reference-0-alpha', owner: 'someone-else' }); + assert.equal(f.flights.length, 4); + f.display.hide(); + if (!f.cesium) { + f.engine.fake.script('pick', { + id: 'cable-reference-0-alpha', + owner: PATHS, + }); + f.engine.fake.input('click', { position: { x: 1, y: 1 } }); + f.engine.fake.unscript('pick'); + assert.equal(f.flights.length, 4, 'a hidden display takes no clicks'); + } + }); + + test(`hide clears what the display drew and its labels; show draws it again (${name})`, (t) => { + const f = fixture(t, makeHarness); + f.look(-40, 35, 4_500_000); + f.display.show(dataset()); + f.preRender(); + assert.equal(f.engine.ground.list().length, 1); + assert.equal(f.engine.movers.list().length, 3); + assert.equal(f.engine.lines.list().length, 2); + f.hostCalls.length = 0; + f.display.hide(); + assert.deepEqual(f.hostCalls, [ + ['clear', CABLE_OVERLAY_SOURCE_ID], + ['visible', CABLE_OVERLAY_SOURCE_ID, false], + ]); + assert.equal(f.engine.ground.list().length, 0); + assert.equal(f.engine.movers.list().length, 0); + assert.equal(f.engine.lines.list().length, 0); + assert.equal(f.display.stats().referenceLabelCount, 0); + f.preRender(); + assert.equal(f.publishes().length, 0, 'no sweep while hidden'); + // An identical cohort publishes again after the hide cleared the host. + const same = dataset(); + f.display.show(same); + f.preRender(); + assert.equal(f.publishes().length, 1); + f.display.show(same); + assert.equal(f.engine.ground.list().length, 1, 'the same dataset stays'); + f.display.destroy(); + assert.equal(f.engine.ground.list().length, 0); + assert.equal(f.engine.movers.list().length, 0); + f.display.show(same); + assert.equal(f.engine.ground.list().length, 0, 'destroyed: draws nothing'); + }); +} + +test('every bundled cable path draws with its own feature, one reference per feature', (t) => { + const read = (file) => + JSON.parse( + readFileSync( + new URL( + `../../../data/local_data/telegeography_submarine_cables/${file}`, + import.meta.url, + ), + 'utf8', + ), + ); + const cables = read('cable-geo.json'); + const landings = read('landing-point-geo.json'); + const [makeFake] = HARNESSES; + const f = fixture(t, makeFake); + f.display.show({ cables, landingPoints: landings }); + const items = f.groundSet(PATHS).items; + const expanded = cables.features.flatMap((feature) => + feature.geometry.coordinates.map(() => feature), + ); + assert.equal(items.length, expanded.length); + const ids = new Map(); + items.forEach((item, index) => { + const feature = expanded[index]; + near(item.color, rgba(feature.properties.color, 0.92), feature.id); + const owner = ids.get(item.pickId); + if (owner) assert.equal(owner, feature, 'one reference per feature'); + ids.set(item.pickId, feature); + }); + assert.equal(ids.size, cables.features.length); + assert.equal( + f.engine.movers.list().find(({ id }) => id === CABLE_TIPS).count, + cables.features.length, + ); + assert.equal( + f.engine.movers.list().find(({ id }) => id === MARKERS).count, + landings.features.length, + ); +}); + +test('the display draws on the contract only', () => { + const source = readFileSync(new URL('./display.js', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /from 'cesium'|from 'three'|@navaramap/); + assert.doesNotMatch(source, /engine\.kind|extensions|defineGroundEffect/); + assert.doesNotMatch(source, /ScreenSpaceEventHandler/); +}); + +const FIJI = [178.44, -18.12]; +// Cable A leaves Fiji eastwards across the antimeridian as ONE unsplit path +// and has no reference coordinates, so its reference comes from its geometry. +const cableA = { + type: 'Feature', + properties: { id: 'cable-a', name: 'Cable A' }, + geometry: { + type: 'LineString', + coordinates: [FIJI, [-170, -10]], + }, +}; +// Cable B ends at the same landing point and runs south-west. +const cableB = { + type: 'Feature', + properties: { id: 'cable-b', name: 'Cable B', coordinates: [174, -24] }, + geometry: { + type: 'LineString', + coordinates: [FIJI, [170, -30]], + }, +}; +const landing = { + type: 'Feature', + properties: { id: 'suva', name: 'Suva' }, + geometry: { type: 'Point', coordinates: FIJI }, +}; + +function fijiFixture(t) { + const harness = fakeHarness(); + const { engine } = harness; + engine.fake.setPose({ + longitude: geo.math.toRadians(FIJI[0]), + latitude: geo.math.toRadians(FIJI[1]), + height: 400000, + }); + const host = { setVisible() {}, setEntries() {}, clearSource() {} }; + const cables = createCablesDisplay(engine, { overlayHost: host }); + cables.show({ + cables: { type: 'FeatureCollection', features: [cableA, cableB] }, + landingPoints: { type: 'FeatureCollection', features: [landing] }, + }); + harness.preRender(); + t.after(() => { + cables.destroy(); + engine.destroy(); + }); + return harness; +} + +const fijiScreen = (engine, [lon, lat]) => + engine.projector.worldToWindow(geo.cartesian3.fromDegrees(lon, lat), {}); + +test('edge:telegeography-submarine-cables: an antimeridian crossing draws no segment across the whole map', (t) => { + const { engine, groundSet } = fijiFixture(t); + // The path keeps its own two points: the ground set follows the short + // great circle across 180°, as Cesium's ground polylines do. + const [first] = groundSet(PATHS).items; + assert.deepEqual(first.positions, cableA.geometry.coordinates); + // Picks find it along the short way and nowhere on the long way round. + const edgeLat = -18.12 + ((180 - 178.44) / (190 - 178.44)) * (-10 + 18.12); + const beyond = fijiScreen(engine, [180.5, edgeLat + 0.35]); + assert.equal(engine.picker.pick(beyond)?.id, 'cable-reference-0-cable-a'); + for (const lon of [170, 160, 150]) + assert.notEqual( + engine.picker.pick(fijiScreen(engine, [lon, -14]))?.owner, + PATHS, + `no cable at ${lon}°`, + ); +}); + +test('edge:telegeography-submarine-cables: a click on a cable beside its landing point returns that cable and flies to its own reference', (t) => { + const { engine, flights } = fijiFixture(t); + const edgeLat = -18.12 + ((180 - 178.44) / (190 - 178.44)) * (-10 + 18.12); + const from = fijiScreen(engine, FIJI); + const to = fijiScreen(engine, [180, edgeLat]); + // Halfway along cable A's first stretch, well clear of the landing point. + const at = { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 }; + assert.ok(Math.hypot(at.x - from.x, at.y - from.y) > 20); + const picked = engine.picker.pick(at); + assert.equal(picked?.id, 'cable-reference-0-cable-a'); + // The landing point itself still answers as the landing point. + assert.equal(engine.picker.pick(from)?.id, 'landing-point-reference-2-suva'); + engine.fake.input('click', { position: at }); + const [flight] = flights; + assert.ok(flight, 'the click flies to the cable'); + const target = geo.cartographic.fromCartesian(flight.destination); + // The reference of a cable across the antimeridian stays beside it, not at 0°. + assert.ok( + Math.abs(geo.math.toDegrees(target.longitude)) > 170, + `reference at ${geo.math.toDegrees(target.longitude)}°`, + ); +}); diff --git a/src/layers/submarineCables/render/navara.js b/src/layers/submarineCables/render/navara.js deleted file mode 100644 index a5827da5e..000000000 --- a/src/layers/submarineCables/render/navara.js +++ /dev/null @@ -1,271 +0,0 @@ -import * as geo from '../../../geo/index.js'; -import { isPointerFree } from '../../../data/inputOwnership.js'; -import { - featureLabel, - featureReference, - normalizeFeatures, - splitFeatureAtAntimeridian, -} from '../geometry.js'; -import { - BASE_CABLE_COLOR, - BASE_LANDING_COLOR, - CABLE_OVERLAY_SOURCE_ID as id, -} from '../policy.js'; -import { - cableReferencePriority, - clampLabel, - createCableOverlayEntry, - createCableOverlayPublisher, - createCableReferenceSweepGate, - selectCableReferenceLabelWinners, - updateCableReferenceTip, -} from '../overlay.js'; -import { createStemBatch } from '../../shared/render/navaraStems.js'; -import { bindGroundShapePicks } from '../../shared/render/navaraGroundPicks.js'; -import { rgbaFromCss } from '../../shared/color.js'; -import { distanceToSegment } from '../../../globe/cpuPicks.js'; - -export function createNavaraCables(engine, native, host) { - const { view } = native; - const publisher = createCableOverlayPublisher({ host }), - gate = createCableReferenceSweepGate(); - const records = new Map(), - banks = [], - removers = []; - let source, - layer, - dataset, - lastLabels = null, - referenceLabelCount = 0; - const wake = () => engine.render.request(id); - function sweep() { - const position = engine.camera.livePosition; - if (!position || !gate.shouldRun({ positionWC: position })) return; - const { height } = engine.host.size(), - { fovy } = engine.camera.pose(); - for (const bank of banks) { - const visible = []; - for (const row of bank.rows) { - row.visible = engine.projector.horizonVisible(row.base); - if (!row.visible) continue; - row.distanceM = geo.cartesian3.distance(position, row.base); - updateCableReferenceTip(row, position, height, fovy); - bank.points.set(visible.length, { - position: row.tip, - pickId: row.id, - show: true, - color: bank.color, - }); - visible.push(row); - } - bank.points.setCount(visible.length); - bank.points.commit(); - bank.stems.set(visible, position); - } - const winners = selectCableReferenceLabelWinners([...records.values()]); - referenceLabelCount = winners.length; - const signature = winners - .map((r) => r.id + '/' + cableReferencePriority(r.distanceM)) - .join('|'); - if (signature !== lastLabels) { - lastLabels = signature; - publisher.publish( - winners.map((r) => { - r.entry.priority = cableReferencePriority(r.distanceM); - return r.entry; - }), - ); - } - } - function hide() { - for (const fn of removers.splice(0)) fn(); - for (const bank of banks.splice(0)) { - bank.points?.destroy(); - bank.stems?.destroy(); - bank.surface?.destroy(); - } - layer?.delete(); - source?.delete(); - source = layer = dataset = null; - records.clear(); - gate.reset(); - lastLabels = null; - referenceLabelCount = 0; - publisher.hide(); - wake(); - } - function show(next) { - if (next === dataset) return; - if (dataset) hide(); - try { - const cables = normalizeFeatures(next.cables, 'cable').map( - splitFeatureAtAntimeridian, - ), - landings = normalizeFeatures(next.landingPoints, 'landing'); - const shapes = [], - colors = new Map(); - for (const [kind, features, css] of [ - ['cable', cables, BASE_CABLE_COLOR], - ['landing-point', landings, BASE_LANDING_COLOR], - ]) { - const rows = [], - color = rgbaFromCss(css).rgba(); - for (const f of features) { - const ref = featureReference(f); - if (!ref) continue; - const key = id + ':' + kind + ':' + rows.length; - const base = geo.cartesian3.fromDegrees(ref.lon, ref.lat); - const row = { - id: key, - reference: ref, - label: clampLabel(featureLabel(f)), - kind, - base, - tip: geo.cartesian3.fromDegrees(ref.lon, ref.lat, 2500), - nextTip: geo.cartesian3.create(), - }; - row.entry = createCableOverlayEntry(row); - records.set(key, row); - rows.push(row); - const geom = f.geometry; - if (geom.type === 'Point') - shapes.push({ - id: key, - geometry: { type: 'point', position: geom.coordinates }, - style: { pointRadius: 5 }, - }); - else - for (const path of geom.type === 'MultiLineString' - ? geom.coordinates - : [geom.coordinates]) - if (path.length > 1) - shapes.push({ - id: key, - geometry: { type: 'line', positions: path }, - style: { lineWidth: 2.5 }, - }); - } - if (!rows.length) continue; - const bank = { rows, color }; - banks.push(bank); - const point = { - sizePx: kind === 'cable' ? 7 : 8, - outlinePx: 1, - outlineColor: [0, 0, 0, 0.65], - }; - const make = (role) => - engine.movers.createSet({ - id: id + '-' + kind + role, - kind: 'point', - capacity: rows.length, - depthTest: 'normal', - pickable: true, - point, - }); - bank.points = make('-tip'); - bank.stems = createStemBatch( - native, - css, - rows.length, - kind === 'cable' ? 2 : 2.4, - kind === 'cable' ? 0.58 : 0.68, - ); - if (kind === 'landing-point') { - bank.surface = make('-base'); - rows.forEach((row, i) => - bank.surface.set(i, { - position: row.base, - show: true, - pickId: row.id, - color, - }), - ); - bank.surface.setCount(rows.length); - bank.surface.commit(); - } - } - source = view.addSource({ - type: 'geojson', - data: { type: 'FeatureCollection', features: cables }, - }); - layer = view.addLayer({ - type: 'vector', - source, - polyline: { - clampToGround: true, - width: 2.5, - lit: false, - color: 0xffffff, - opacity: 0.92, - transparent: true, - }, - }); - layer.on('featureUpdated', ({ evaluator }) => - evaluator.evaluate(({ properties }) => { - const css = properties?.color ?? BASE_CABLE_COLOR; - if (!colors.has(css)) - colors.set(css, new native.NavaraColor().setStyle(css)); - return { color: colors.get(css) }; - }), - ); - layer.forceUpdate(); - removers.push(bindGroundShapePicks(engine, id, shapes)); - removers.push( - engine.picker.registerMeshPicker((at) => { - const hits = []; - for (const r of records.values()) { - if (!r.visible) continue; - const a = engine.projector.worldToWindow(r.base, {}), - b = engine.projector.worldToWindow(r.tip, {}); - if (a && b && distanceToSegment(at, a, b) <= 2) - hits.push({ - onTop: 0, - order: 0, - depth: r.distanceM, - result: { id: r.id, owner: id }, - }); - } - return hits; - }), - ); - removers.push( - engine.input.on( - 'click', - (e) => { - if (!isPointerFree()) return; - const row = records.get(e.picked?.id); - if (!row) return; - engine.camera.flyTo({ - destination: geo.cartesian3.fromDegrees( - row.reference.lon, - row.reference.lat, - 6500, - ), - orientation: { - heading: engine.camera.pose().heading, - pitch: geo.math.toRadians(-52), - roll: 0, - }, - duration: 1.35, - }); - }, - { pick: true }, - ), - engine.camera.moveEnd.addEventListener(() => { - gate.markDirty(); - wake(); - }), - engine.frames.preRender.addEventListener(sweep), - ); - dataset = next; - publisher.show(); - gate.markDirty(); - sweep(); - wake(); - } catch (e) { - hide(); - throw e; - } - } - return { show, hide, destroy: hide, stats: () => ({ referenceLabelCount }) }; -} diff --git a/src/layers/submarineCables/rendering.js b/src/layers/submarineCables/rendering.js deleted file mode 100644 index 91936573f..000000000 --- a/src/layers/submarineCables/rendering.js +++ /dev/null @@ -1,274 +0,0 @@ -import * as Cesium from 'cesium'; -import { - probeTranslucentMarkerBlend, - commitTranslucentMarkerBlend, -} from './surface.js'; -import { - clampLabel, - createCableOverlayEntry, - updateCableReferenceStem, - selectCableReferenceLabelWinners, - cableReferencePriority, -} from './overlay.js'; - -export function createRendering({ state }) { - function resetPublishSignature() { - state._lastPublishedIds.length = 0; - state._lastPublishedPriorities.length = 0; - state._lastPublishedCount = -1; - } - - /** - * Remove the three data sources from the viewer and forget the built - * entities. Hiding them (`show = false`) is NOT enough: Cesium's - * DataSourceDisplay still walks every visualizer of a hidden source on - * every frame, and the thousands of hidden polylines/billboards keep - * costing CPU and GC pressure long after the user toggled the layer off — - * that is the "everything gets sluggish after I turn layers off" report. - * The fetched JSON stays cached, so the next enable rebuilds without a - * network round trip or a re-parse. - */ - - function releaseDataSources(viewer) { - const sources = [ - state._cableDataSource, - state._landingDataSource, - state._referenceDataSource, - ]; - state._cableDataSource = null; - state._landingDataSource = null; - state._referenceDataSource = null; - state._referenceRecords = []; - state._surfaceRecords = []; - state._pickByEntity = new WeakMap(); - state._referenceLabelCount = 0; - state._publishScratch.length = 0; - // The blend pass targets the collections being dropped; the rebuilt - // sources need their own pass. - state._markerBlendDone = false; - state._loaded = false; - state._referenceSweepGate.reset(); - for (const source of sources) { - if (!source) continue; - try { - viewer?.dataSources?.remove(source, true); - } catch { - /* collection gone */ - } - } - } - - function updateVisibility() { - if (state._cableDataSource) state._cableDataSource.show = state._enabled; - if (state._landingDataSource) - state._landingDataSource.show = state._enabled; - if (state._referenceDataSource) - state._referenceDataSource.show = state._enabled; - } - - function styleCableEntity(entity, feature) { - if (!entity?.polyline) return; - const color = feature?.properties?.color - ? Cesium.Color.fromCssColorString(String(feature.properties.color)) - : state.cableColor; - entity.polyline.material = color.withAlpha(0.92); - entity.polyline.width = 2.5; - entity.polyline.clampToGround = true; - entity.polyline.classificationType = state._classificationType; - entity.show = true; - } - - /** - * Apply the single translucent blend pass to the landing-billboard and - * reference-point collections. Landing entities carry only billboards and - * reference entities only points, so completion is one applied collection - * per source; a failed shape probe disables the optimization permanently - * for this session and says so loudly in dev builds. - * Both sources are probed BEFORE either is committed, so the helper's - * "defaults untouched on shape drift" fallback holds across the pair too — - * a reference-source failure can never leave the landing source already - * forced to TRANSLUCENT. - */ - - function applyMarkerBlendOnce() { - const landing = probeTranslucentMarkerBlend(state._landingDataSource); - const reference = probeTranslucentMarkerBlend(state._referenceDataSource); - if (landing.invariantFailed || reference.invariantFailed) { - state._markerBlendDone = true; - if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) { - state._markerBlendInvariantWarned = true; - console.error( - '[Data:telegeography-submarine-cables] EntityCluster marker-collection ' + - 'shape changed — single-pass translucent blend skipped; markers fall ' + - "back to Cesium's default two-pass blend.", - ); - } - return; - } - const landingBlend = commitTranslucentMarkerBlend(landing); - const referenceBlend = commitTranslucentMarkerBlend(reference); - if (landingBlend.applied > 0 && referenceBlend.applied > 0) - state._markerBlendDone = true; - } - - /** - * Re-classify every cable ground line for the active surface. One batched - * ground-primitive rebuild per stack switch — never per frame. - * @param {Cesium.ClassificationType} next - */ - - function applyCableClassification(next) { - if (next === undefined || next === state._classificationType) return; - state._classificationType = next; - if (!state._cableDataSource) return; - const entities = state._cableDataSource.entities.values; - for (let i = 0; i < entities.length; i++) { - const polyline = entities[i].polyline; - if (polyline) polyline.classificationType = next; - } - state._viewer?.scene?.requestRender?.(); - } - - function styleLandingEntity(entity, feature) { - if (!entity?.point) return; - entity.point.color = state.landingColor.withAlpha(0.92); - entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7; - entity.point.outlineColor = state.cableOutline; - entity.point.outlineWidth = 1; - entity.point.disableDepthTestDistance = 0; - entity.show = true; - } - - function addReferenceStem({ reference, label, kind, color, feature }) { - if (!state._referenceDataSource || !reference) return; - - const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0); - const tip = Cesium.Cartesian3.fromDegrees( - reference.lon, - reference.lat, - 2500, - ); - const info = { - kind, - reference, - label, - featureId: feature?.id || feature?.properties?.id || null, - }; - const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94); - const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68); - // Constant properties on the sweep cadence, never per-frame callbacks: - // the sweep redefines them through the alternating buffers below. - const stemPositionBuffers = [ - [base, tip], - [base, tip], - ]; - - const entity = state._referenceDataSource.entities.add({ - id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`, - position: tip, - polyline: { - positions: stemPositionBuffers[0], - width: kind === 'cable' ? 2 : 2.4, - material: new Cesium.ColorMaterialProperty(stemColor), - }, - point: { - pixelSize: kind === 'cable' ? 7 : 8, - color: pointColor, - outlineColor: Cesium.Color.BLACK.withAlpha(0.65), - outlineWidth: 1, - disableDepthTestDistance: 0, - }, - }); - - entity.__gevTeleGeography = info; - state._pickByEntity.set(entity, info); - const record = { - id: entity.id, - entity, - base, - tip, - nextTip: Cesium.Cartesian3.clone(tip), - stemPositionBuffers, - stemPositionBufferIndex: 0, - reference, - kind, - label: clampLabel(label), - visible: false, - distanceM: Infinity, - entry: null, - }; - record.entry = createCableOverlayEntry(record); - state._referenceRecords.push(record); - } - - function updateReferenceVisibility() { - if (!state._enabled || !state._viewer?.camera) return; - const cameraPos = state._viewer.camera.positionWC; - if (!cameraPos) return; - - const canvasHeight = state._viewer.scene?.canvas?.clientHeight || 1080; - const fov = state._viewer.camera.frustum?.fov || Math.PI / 3; - const occluder = new Cesium.EllipsoidalOccluder( - Cesium.Ellipsoid.WGS84, - cameraPos, - ); - for (const record of state._referenceRecords) { - const visible = occluder.isPointVisible(record.base); - record.visible = visible; - record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base); - if (record.entity.show !== visible) { - record.entity.show = visible; - } - // Hidden stems keep their last geometry; this same sweep refreshes them - // in the pass where they turn visible again. - if (visible) - updateCableReferenceStem(record, cameraPos, canvasHeight, fov); - } - for (const record of state._surfaceRecords) { - const visible = occluder.isPointVisible(record.base); - if (record.entity.show !== visible) { - record.entity.show = visible; - } - } - const winners = selectCableReferenceLabelWinners(state._referenceRecords); - let changed = winners.length !== state._lastPublishedCount; - for (let i = 0; i < winners.length; i++) { - const record = winners[i]; - const priority = cableReferencePriority(record.distanceM); - record.entry.priority = priority; - state._publishScratch[i] = record.entry; - if ( - !changed && - (state._lastPublishedIds[i] !== record.entry.id || - state._lastPublishedPriorities[i] !== priority) - ) { - changed = true; - } - } - state._publishScratch.length = winners.length; - state._referenceLabelCount = winners.length; - // The marker collections are created lazily by the visualizers, so the - // single-pass blend is applied on the sweep cadence until both landed. - if (!state._markerBlendDone) applyMarkerBlendOnce(); - if (!changed) return; - state._overlayPublisher.publish(state._publishScratch); - state._lastPublishedCount = winners.length; - for (let i = 0; i < winners.length; i++) { - state._lastPublishedIds[i] = state._publishScratch[i].id; - state._lastPublishedPriorities[i] = state._publishScratch[i].priority; - } - state._lastPublishedIds.length = winners.length; - state._lastPublishedPriorities.length = winners.length; - } - return { - resetPublishSignature, - releaseDataSources, - updateVisibility, - styleCableEntity, - applyMarkerBlendOnce, - applyCableClassification, - styleLandingEntity, - addReferenceStem, - updateReferenceVisibility, - }; -} diff --git a/src/layers/submarineCables/session.js b/src/layers/submarineCables/session.js index 62a9aab20..9cedaa82c 100644 --- a/src/layers/submarineCables/session.js +++ b/src/layers/submarineCables/session.js @@ -1,10 +1,12 @@ +import { msg } from '../../i18n/index.js'; import { waitForSignal } from '../../sources/requestDeadline.js'; -/** The dataset is acquired once per activation, independently of scene attachment. */ -export function createCableSessionLayer({ - source, - createCesium, - createNavara, -}) { + +/** + * The dataset is acquired once per activation, independently of scene + * attachment; `createDisplay(engine)` draws it on each attached engine + * (show(dataset), hide(), destroy(), stats()). + */ +export function createCableSessionLayer({ source, createDisplay }) { const id = 'telegeography-submarine-cables'; let data, request, @@ -58,7 +60,7 @@ export function createCableSessionLayer({ owner === display && e?.name !== 'AbortError' ) - error = e.message; + error = e?.message || msg('TeleGeography load failed'); } })(); rendering = result; @@ -73,29 +75,11 @@ export function createCableSessionLayer({ source: source.label, updateInterval: 0, statsRefreshInterval: 500, - init(viewer, ctx) { - return this.attachRenderer(viewer, ctx); + init(_viewer, ctx) { + return this.attachRenderer(_viewer, ctx); }, - attachRenderer(viewer, { engine, signal = null } = {}) { - if (viewer) { - const legacy = createCesium({ label: source.label, fetch: read }); - legacy.init(viewer); - let shown = false; - display = { - async show() { - if (!shown) { - await legacy.enable(viewer); - shown = !legacy.getStats().error; - } else if (!legacy.getStats().count) await legacy.update(viewer); - }, - hide() { - shown = false; - legacy.disable(); - }, - destroy: () => legacy.destroy(viewer), - stats: () => legacy.getStats(), - }; - } else display = createNavara(engine); + attachRenderer(_viewer, { engine = null, signal = null } = {}) { + display = engine ? createDisplay(engine) : null; // The cable data load belongs to the session and keeps running; only // this scene's wait for it ends when the scene is released. show() // draws nothing into a display that was released meanwhile. diff --git a/src/layers/submarineCables/session.test.mjs b/src/layers/submarineCables/session.test.mjs index 890ac13b7..95eba1af9 100644 --- a/src/layers/submarineCables/session.test.mjs +++ b/src/layers/submarineCables/session.test.mjs @@ -16,7 +16,7 @@ test('cables arriving during handoff belong to the incoming display', async () = }); }, }, - createNavara: (engine) => ({ + createDisplay: (engine) => ({ show: (dataset) => shown.push([engine, dataset]), destroy() {}, hide() {}, @@ -45,7 +45,7 @@ test('a released scene stops waiting for a stalled cable load, which stays in th resolve = done; }), }, - createNavara: (engine) => ({ + createDisplay: (engine) => ({ show: (dataset) => shown.push([engine, dataset]), destroy() {}, hide() {}, @@ -70,3 +70,120 @@ test('a released scene stops waiting for a stalled cable load, which stays in th assert.deepEqual(shown, [['next', dataset]]); layer.destroy(); }); + +function deferredSource() { + const fetches = []; + return { + fetches, + source: { + label: 'fixture', + fetch(signal) { + return new Promise((resolve, reject) => { + const entry = { signal, resolve }; + fetches.push(entry); + signal.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }); + }, + }, + }; +} + +function recordingDisplay(log) { + return (engine) => ({ + show: (dataset) => log.push(['show', engine, dataset]), + hide: () => log.push(['hide', engine]), + destroy: () => log.push(['destroy', engine]), + stats: () => ({ referenceLabelCount: 7 }), + }); +} + +const DATASET = { + cables: { features: [{}, {}] }, + landingPoints: { features: [{}] }, +}; + +test('a disable during a load aborts it; the next enable loads once and shows once', async () => { + const { source, fetches } = deferredSource(); + const log = []; + const layer = createCableSessionLayer({ + source, + createDisplay: recordingDisplay(log), + }); + layer.init(null, { engine: 'globe' }); + const first = layer.enable(); + layer.disable(); + assert.equal(fetches[0].signal.aborted, true); + const second = layer.enable(); + // The aborted load's late answer changes nothing. + fetches[0].resolve(DATASET); + await first; + assert.equal(fetches.length, 2); + void layer.update(); + assert.equal(fetches.length, 2, 'an update joins the load in flight'); + fetches[1].resolve(DATASET); + await second; + assert.deepEqual(log, [ + ['hide', 'globe'], + ['show', 'globe', DATASET], + ]); + assert.equal(layer.getStats().error, null); + layer.destroy(); +}); + +test('a toggle-off hides the display and a toggle-on shows the dataset again without refetching', async () => { + const { source, fetches } = deferredSource(); + const log = []; + const layer = createCableSessionLayer({ + source, + createDisplay: recordingDisplay(log), + }); + layer.init(null, { engine: 'globe' }); + const loading = layer.enable(); + assert.equal(layer.getStats().loading, true); + fetches[0].resolve(DATASET); + await loading; + const stats = layer.getStats(); + assert.equal(stats.count, 3); + assert.equal(stats.referenceLabelCount, 7, "the display's stats"); + assert.equal(stats.loading, false); + layer.disable(); + assert.equal(layer.getStats().count, 0); + await layer.enable(); + assert.equal(fetches.length, 1, 'the dataset is kept for the session'); + assert.deepEqual(log, [ + ['show', 'globe', DATASET], + ['hide', 'globe'], + ['show', 'globe', DATASET], + ]); + layer.destroy(); + assert.deepEqual(log.slice(-2), [ + ['hide', 'globe'], + ['destroy', 'globe'], + ]); +}); + +test('a failed load reports its error, and a dataset without both collections is refused', async () => { + const layer = createCableSessionLayer({ + source: { label: 'fixture', fetch: async () => ({ cables: {} }) }, + createDisplay: recordingDisplay([]), + }); + layer.init(null, { engine: 'globe' }); + await layer.enable(); + assert.equal(layer.getStats().error, 'Invalid submarine cable dataset'); + layer.destroy(); + const silent = createCableSessionLayer({ + source: { + label: 'fixture', + fetch: async () => { + throw new Error(''); + }, + }, + createDisplay: recordingDisplay([]), + }); + silent.init(null, { engine: 'globe' }); + await silent.enable(); + assert.equal(silent.getStats().error, 'TeleGeography load failed'); + silent.destroy(); +}); diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js deleted file mode 100644 index 0761688aa..000000000 --- a/src/layers/submarineCables/state.js +++ /dev/null @@ -1,107 +0,0 @@ -import * as Cesium from 'cesium'; -import { - createCableReferenceSweepGate, - createCableOverlayPublisher, -} from './overlay.js'; -import { BASE_CABLE_COLOR, BASE_LANDING_COLOR } from './policy.js'; - -export function createState({ sweepClock, overlayHost }) { - const state = {}; - - state._viewer = null; - - state._enabled = false; - - state._loading = false; - - state._loaded = false; - - state._count = 0; - - state._error = null; - - state._lastUpdate = null; - - state._loadingLabel = ''; - - state._abort = null; - - /** Monotonic load-ownership token; every load() start claims a new one. */ - - state._loadGeneration = 0; - - state._cableDataSource = null; - - state._landingDataSource = null; - - state._referenceDataSource = null; - - /** - * Fetched cable/landing JSON kept across disable/enable. A disable RELEASES - * the three data sources (see releaseDataSources) instead of hiding them, - * so a re-enable has to rebuild the entities; the network/parse half of - * that rebuild is the expensive part users would notice, so it is cached. - */ - - state._cachedCableJson = null; - - state._cachedLandingJson = null; - - state._referenceRecords = []; - - state._surfaceRecords = []; - - state._clickHandler = null; - - state._preRenderRemover = null; - - state._moveEndRemover = null; - - state._mapStackListener = null; - - /** Active ground-line classification; refined from live scene state at init. */ - - state._classificationType = Cesium.ClassificationType.BOTH; - - /** True once both marker collections blend translucent (or the shape probe failed). */ - - state._markerBlendDone = false; - - state._markerBlendInvariantWarned = false; - - state._pickByEntity = new WeakMap(); - - state._referenceLabelCount = 0; - - state._referenceSweepGate = createCableReferenceSweepGate({ - now: sweepClock, - }); - - state._overlayPublisher = createCableOverlayPublisher({ host: overlayHost }); - - /** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */ - - state._publishScratch = []; - - /** - * Last published cohort signature (ids + quantized priorities). A parked - * camera reproduces the same winners every sweep; skipping the identical - * republish keeps the host from requesting a render per publish, so the - * layer stays render-governor idle exactly like its native predecessor. - * Tip positions need no republish either: the host reprojects the same - * mutable Cartesians on every painted frame. - */ - - state._lastPublishedIds = []; - - state._lastPublishedPriorities = []; - - state._lastPublishedCount = -1; - - state.cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR); - - state.cableOutline = Cesium.Color.BLACK.withAlpha(0.45); - - state.landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR); - return state; -} diff --git a/src/layers/submarineCables/surface.js b/src/layers/submarineCables/surface.js index 36556bbb3..c70ded11c 100644 --- a/src/layers/submarineCables/surface.js +++ b/src/layers/submarineCables/surface.js @@ -1,126 +1,31 @@ -import * as Cesium from 'cesium'; -import { - CABLE_GLOBE_STACK_IDS, - MARKER_COLLECTION_EXPECTATIONS, -} from './policy.js'; +import { CABLE_GLOBE_STACK_IDS } from './policy.js'; /** - * Ground-line classification for one map stack. The photoreal stack renders - * Google 3D tiles with the Cesium globe HIDDEN, so cable ground lines only - * need the 3D-tile classification pass there; every other known stack (the - * bing stacks and osm) renders imagery on the shown globe, so only the - * terrain pass applies. - * Classifying against just the active surface halves the batched - * GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback - * for an unknown stack — it renders on every surface, exactly the shipped - * pre-optimization behavior. + * The surface cable paths drape on for one map stack (a ground set's + * `drape`). The photoreal stack renders Google 3D tiles with the globe + * hidden, so the paths need only the 3D tiles there; every other known + * stack (the Bing stacks, Esri, OSM, Sentinel-2) renders imagery on the + * shown globe, so only the terrain. 'both' is the fallback for an unknown + * stack: it draws on every surface. Cesium classifies a ground batch + * against only the surface asked for, which halves its draw commands; + * Navara drapes on whatever surface it draws. * @param {string|null|undefined} activeId MapStackController stack id. - * @returns {Cesium.ClassificationType} + * @returns {'tiles'|'terrain'|'both'} */ - -export function cableClassificationTypeForStack(activeId) { - if (activeId === 'photoreal') return Cesium.ClassificationType.CESIUM_3D_TILE; - if (CABLE_GLOBE_STACK_IDS.has(activeId)) - return Cesium.ClassificationType.TERRAIN; - return Cesium.ClassificationType.BOTH; +export function cableDrapeForStack(activeId) { + if (activeId === 'photoreal') return 'tiles'; + if (CABLE_GLOBE_STACK_IDS.has(activeId)) return 'terrain'; + return 'both'; } /** - * Derive the active surface from live scene state. The boot-time - * `setStack(..., { silent: true })` fires no 'gev:map-stack-changed' event, - * so the initial classification reads the scene the way the height-datum - * listeners do: the photoreal regime is exactly "globe hidden". - * @param {Cesium.Scene|null|undefined} scene - * @returns {Cesium.ClassificationType} + * The surface from the engine's own state, for the start (the boot-time + * stack change raises no event): photoreal tiles shown → 'tiles', else + * 'terrain'; 'both' without an engine. + * @param {import('../../globe/contract.js').GlobeEngine|null|undefined} engine + * @returns {'tiles'|'terrain'|'both'} */ - -export function cableClassificationTypeForScene(scene) { - if (!scene?.globe) return Cesium.ClassificationType.BOTH; - return scene.globe.show === false - ? Cesium.ClassificationType.CESIUM_3D_TILE - : Cesium.ClassificationType.TERRAIN; -} - -/** - * Validate one data source's marker collections WITHOUT mutating anything. - * Splitting validation from mutation is what makes the fallback honest: a - * shape failure on the SECOND collection (or the second data source) must - * not leave the first one already forced to TRANSLUCENT. - * @param {Cesium.DataSource|null|undefined} dataSource - * @returns {{ready:object[],pending:number,invariantFailed:boolean}} - */ - -export function probeTranslucentMarkerBlend(dataSource) { - const cluster = dataSource?.clustering; - if (!cluster) return { ready: [], pending: 0, invariantFailed: true }; - const ready = []; - let pending = 0; - let invariantFailed = false; - for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) { - if (!(key in cluster)) { - invariantFailed = true; - continue; - } - const collection = cluster[key]; - if (collection === undefined || collection === null) { - pending++; - continue; - } - if (!(collection instanceof type) || !('blendOption' in collection)) { - invariantFailed = true; - continue; - } - ready.push(collection); - } - // Any failure discards every collected target: nothing is mutated at all. - if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true }; - return { ready, pending, invariantFailed: false }; -} - -/** - * Commit a clean probe. Only ever called once every probe in the batch has - * passed, so this cannot land a partial application. - * @param {{ready:object[],pending:number}} probe - * @returns {{applied:number,pending:number,invariantFailed:boolean}} - */ - -export function commitTranslucentMarkerBlend(probe) { - for (const collection of probe.ready) { - if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) { - collection.blendOption = Cesium.BlendOption.TRANSLUCENT; - } - } - return { - applied: probe.ready.length, - pending: probe.pending, - invariantFailed: false, - }; -} - -/** - * Force one translucent draw pass for a data source's marker collections. - * The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per - * collection, and every cable marker is translucent (alpha < 1), so the - * opaque pass is pure overhead across 1,917 landing billboards + 2,629 - * reference points. Cesium has no public accessor for the entity-backed - * collections, so this reaches through EntityCluster's private fields under - * a SHAPE INVARIANT the caller must honor loudly: the constructor - * pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a - * visualizer lazily creates them), so a missing PROPERTY means Cesium's - * shape changed — report `invariantFailed` and never touch anything — - * while a present-but-undefined value only means "not created yet" - * (`pending`). Wrong instance types also fail the invariant. The cables - * sources never enable clustering, so created collections persist. - * VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first - * assignment, so a failure anywhere leaves BOTH collections on Cesium's - * default blend — the fallback this helper promises. - * @param {Cesium.DataSource|null|undefined} dataSource - * @returns {{applied:number,pending:number,invariantFailed:boolean}} - */ - -export function applyTranslucentMarkerBlend(dataSource) { - const probe = probeTranslucentMarkerBlend(dataSource); - if (probe.invariantFailed) - return { applied: 0, pending: 0, invariantFailed: true }; - return commitTranslucentMarkerBlend(probe); +export function cableDrapeForEngine(engine) { + if (typeof engine?.maps?.photorealActive !== 'function') return 'both'; + return engine.maps.photorealActive() ? 'tiles' : 'terrain'; } diff --git a/src/testSupport/groundDisplayEngines.mjs b/src/testSupport/groundDisplayEngines.mjs new file mode 100644 index 000000000..981b46610 --- /dev/null +++ b/src/testSupport/groundDisplayEngines.mjs @@ -0,0 +1,184 @@ +// Ground displays under test, on the fake engine and on the Cesium engine. +// Both harnesses record what each ground set was given (the items of its +// last commit, its commits) and count the live input listeners. The Cesium +// one is the engine over a real Cesium camera on a stub scene +// (cesiumMoversEngine.mjs) with fake GroundPrimitive and +// GroundPolylinePrimitive factories: the engine's own geometry instances, +// colours and options reach them, and they draw nothing. A click goes +// through the engine's input with a pick of a ground item (`{ set, +// instance }`), of another object (`{ id }`) or of nothing (undefined). + +import * as Cesium from 'cesium'; +import { normalizeGroundSetOptions } from '../globe/ground.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; +import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs'; +import { createCesiumMoversEngine } from './cesiumMoversEngine.mjs'; + +const CLICK_AT = { x: 100, y: 100 }; + +// What the engine's ground sets were given, and its live input listeners. +function watch(engine) { + const sets = new Map(); + const createSet = engine.ground.createSet.bind(engine.ground); + engine.ground.createSet = (options) => { + const set = createSet(options); + const record = { + // With the engine's defaults. + options: normalizeGroundSetOptions(options), + pending: [], + items: [], + commits: 0, + }; + const { replace, commit } = set; + set.replace = (items) => { + replace(items); + record.pending = items; + }; + set.commit = () => { + commit(); + record.items = record.pending; + record.commits++; + }; + sets.set(options.id, record); + return set; + }; + let listeners = 0; + const on = engine.input.on.bind(engine.input); + engine.input.on = (type, listener, options) => { + const remove = on(type, listener, options); + let live = true; + listeners++; + return () => { + if (live) listeners--; + live = false; + remove(); + }; + }; + return { + /** A live set: its options, committed items, commits and visibility. */ + set(id) { + const listed = engine.ground.list().find((entry) => entry.id === id); + const record = sets.get(id); + return listed && record ? { ...record, shown: listed.shown } : undefined; + }, + /** How many ground sets are live. */ + sets: () => engine.ground.list().length, + listeners: () => listeners, + }; +} + +/** The fake engine, with picks scripted per click. */ +export function createFakeGroundHarness(options = {}) { + const engine = createFakeEngine(options); + const watched = watch(engine); + return { + name: 'the fake engine', + cesium: false, + engine, + ...watched, + click(target, position = CLICK_AT) { + let picked = target; + if (target?.set !== undefined) { + const item = watched.set(target.set)?.items[target.instance]; + picked = { + id: item?.pickId, + owner: target.set, + instance: target.instance, + }; + } + engine.fake.script('pick', picked); + engine.fake.input('click', { position }); + engine.fake.unscript('pick'); + }, + destroy: () => engine.destroy(), + }; +} + +/** + * The Cesium engine over a stub scene, with fake ground primitives; with + * `realFills`, Cesium's own GroundPrimitive draws the fills (it builds in + * Node; GroundPolylinePrimitive does not). + */ +export function createCesiumGroundHarness({ realFills = false } = {}) { + const handlers = createFakeEventHandlers(); + const primitive = (type) => (options) => { + const value = { + type, + options, + ready: false, + show: true, + destroyed: false, + destroy() { + value.destroyed = true; + }, + isDestroyed: () => value.destroyed, + }; + return value; + }; + const { engine, viewer } = createCesiumMoversEngine({ + engineOptions: { + createEventHandler: handlers.create, + scheduleFrame: (run) => run(), + groundFactories: { + ...(realFills ? {} : { fills: primitive('fills') }), + lines: primitive('lines'), + }, + }, + }); + viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection(); + let native; + viewer.scene.pick = () => native; + viewer.scene.drillPick = () => (native ? [native] : []); + const children = (collection) => + Array.from({ length: collection?.length ?? 0 }, (_, index) => + collection.get(index), + ); + // The engine's collection of sets, each set's collection of primitives. + const slots = () => children(viewer.scene.groundPrimitives.get(0)); + const slotOf = (id) => + slots().find((slot) => + children(slot).some((value) => value.gevGroundSet === id), + ); + const live = (id) => children(slotOf(id)); + const typeOf = (value) => + value.type ?? (value instanceof Cesium.GroundPrimitive ? 'fills' : 'lines'); + return { + name: 'the Cesium engine', + cesium: true, + engine, + viewer, + ...watch(engine), + /** The live Cesium primitives of a set: fills and outlines. */ + primitives: (id) => ({ + fills: live(id).filter((value) => typeOf(value) === 'fills'), + lines: live(id).filter((value) => typeOf(value) === 'lines'), + }), + /** Whether the set's Cesium collection is shown (undefined: none). */ + collectionShown: (id) => slotOf(id)?.show, + click(target, position = CLICK_AT) { + native = undefined; + if (target?.set !== undefined) { + const [first] = live(target.set); + native = { + id: { gevGroundSet: target.set, instance: target.instance }, + primitive: first, + }; + } else if (target) native = { id: target.id, primitive: {} }; + for (const handler of handlers.handlers) + handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, { + position: new Cesium.Cartesian2(position.x, position.y), + }); + native = undefined; + }, + destroy: () => engine.destroy(), + }; +} + +export const GROUND_HARNESSES = [ + createFakeGroundHarness, + createCesiumGroundHarness, +]; + +/** A Cesium colour attribute's value as rgba in 0..1. */ +export const instanceColor = (instance) => + Array.from(instance.attributes.color.value, (byte) => byte / 255);