mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
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.
This commit is contained in:
1 parent
974a0b6361
commit
54fe0aea40
124 files changed
+8407
-9576
No files matched your search
+11
-4
@@ -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
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+23
-23
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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 <origin> --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'));
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
} = {}) {
|
||||
|
||||
+7
-20
@@ -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)),
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
+18
-14
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
+31
-59
@@ -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'))
|
||||
|
||||
+356
-162
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
+417
-143
@@ -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:<lat>:<lon>:<acqMs>:<sat>` (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 {
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -22,9 +22,8 @@ export {
|
||||
selectCableReferenceLabelWinners,
|
||||
cableReferencePriority,
|
||||
createCableOverlayEntry,
|
||||
cableClassificationTypeForStack,
|
||||
cableClassificationTypeForScene,
|
||||
applyTranslucentMarkerBlend,
|
||||
cableDrapeForStack,
|
||||
cableDrapeForEngine,
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
updateCableReferenceTip,
|
||||
} from '../layers/submarineCables/index.js';
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+109
-141
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(depth<surface-2e-7) return;
|
||||
vec3 color=toDisplay(inputColor.rgb);
|
||||
for(int k=0;k<int(range.y);k++) {
|
||||
float id=atSlot(tCandidates,uCandidateSize,range.x+float(k)).x;
|
||||
vec4 center=atSlot(tFrames,uFrameSize,id*5.0);
|
||||
vec4 east=atSlot(tFrames,uFrameSize,id*5.0+1.0);
|
||||
vec4 north=atSlot(tFrames,uFrameSize,id*5.0+2.0);
|
||||
vec4 up=atSlot(tFrames,uFrameSize,id*5.0+3.0);
|
||||
vec3 delta=point-center.xyz;
|
||||
vec3 local=vec3(dot(delta,east.xyz),dot(delta,north.xyz),dot(delta,up.xyz));
|
||||
vec2 localDx=vec2(dot(pointDx,east.xyz),dot(pointDx,north.xyz));
|
||||
vec2 localDy=vec2(dot(pointDy,east.xyz),dot(pointDy,north.xyz));
|
||||
if(local.z<east.w || local.z>north.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<count;k++) {
|
||||
int i=(first+k+${DISC_EDGES})%${DISC_EDGES};
|
||||
vec3 edge=atSlot(tEdges,uEdgeSize,id*${DISC_EDGES}.0+float(i)).xyz;
|
||||
float d=edge.z-dot(edge.xy,local.xy);
|
||||
float pixel=abs(dot(edge.xy,localDx))+abs(dot(edge.xy,localDy));
|
||||
coverage=min(coverage,clamp(d/max(pixel,0.001)+0.5,0.0,1.0));
|
||||
if(coverage<=0.0) break;
|
||||
}
|
||||
if(coverage<=0.0) continue;
|
||||
vec4 fill=atSlot(tFrames,uFrameSize,id*5.0+4.0);
|
||||
color=mix(color,fill.rgb,fill.a*coverage);
|
||||
}
|
||||
outputColor=vec4(toLinear(color),inputColor.a);
|
||||
}
|
||||
`;
|
||||
function buildState(discs) {
|
||||
const frames = discs.map(discFrame);
|
||||
const edges = storage(discs.length * DISC_EDGES);
|
||||
const packed = storage(discs.length * 5);
|
||||
const grid = createDiscGrid(discs.length);
|
||||
const gridData = new Float32Array(GRID_COLS * GRID_ROWS * 4);
|
||||
const gridTexture = texture(gridData, GRID_COLS, GRID_ROWS);
|
||||
let candidates = storage(1);
|
||||
const scratch = new Float64Array(12),
|
||||
bounds = new Float64Array(4);
|
||||
const lastView = new Float64Array(16).fill(Number.NaN);
|
||||
const lastProjection = new Float64Array(16).fill(Number.NaN);
|
||||
let lastWidth = 0,
|
||||
lastHeight = 0;
|
||||
frames.forEach((frame, i) => 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
GRID_COLS,
|
||||
GRID_ROWS,
|
||||
createGroundGrid as createDiscGrid,
|
||||
} from '../../shared/render/navaraGroundGrid.js';
|
||||
@@ -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]));
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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-<side>`.
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
});
|
||||
@@ -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<object>,
|
||||
* 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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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:<product>:<draw>` 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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<object>,
|
||||
* 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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() {},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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],
|
||||
),
|
||||
})),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user