mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 22:32: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
|
engine over the globe contract: the aircraft family (flights, military and
|
||||||
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
|
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
|
||||||
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places,
|
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places,
|
||||||
Wildlife Sightings, radio, transit and CCTV through their own
|
Wildlife Sightings, radio, transit, CCTV, earthquakes, air quality, GNSS
|
||||||
`render/display.js`. CCTV's monitor plane and viewshed volume, which the
|
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,
|
contract has no primitive for yet, draw behind one seam,
|
||||||
`src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium;
|
`src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium;
|
||||||
`render/navaraMeshes.js`, which the Navara mode loads and passes in). 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
|
status advisory; a newer status shows its position with geometry pending rather
|
||||||
than relabeling older geometry. The cone represents forecast center uncertainty,
|
than relabeling older geometry. The cone represents forecast center uncertainty,
|
||||||
not storm size or the full hazard area. Successful empty and unavailable states
|
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.
|
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
|
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.
|
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
|
constrain photoreal geometry, and retry work remains bounded. Already sampled
|
||||||
nearby markers follow higher settled terrain on existing bounded walks, without
|
nearby markers follow higher settled terrain on existing bounded walks, without
|
||||||
additional GPU samples or timers, when close-up tile detail refines the floor.
|
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.
|
they do not reduce the materialized entity count or establish an FPS gain.
|
||||||
|
|
||||||
## Keyboard interaction and focus
|
## Keyboard interaction and focus
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
|
|||||||
// cesium only in render/cesium*.js, three and Navara only in
|
// cesium only in render/cesium*.js, three and Navara only in
|
||||||
// render/navara*.js. The list grows with each layer moved onto renderers.
|
// render/navara*.js. The list grows with each layer moved onto renderers.
|
||||||
const RENDERED_LAYER_DIRS = [
|
const RENDERED_LAYER_DIRS = [
|
||||||
|
'src/layers/droneAirspace/',
|
||||||
|
'src/layers/cyclones/',
|
||||||
|
'src/layers/ghostMaps/',
|
||||||
|
'src/layers/submarineCables/',
|
||||||
'src/layers/alpr/',
|
'src/layers/alpr/',
|
||||||
'src/layers/cctv/',
|
'src/layers/cctv/',
|
||||||
'src/layers/transit/',
|
'src/layers/transit/',
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
{
|
{
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"cesiumLayerRendererLines": 1636,
|
"cesiumLayerRendererLines": 1011,
|
||||||
"navaraLayerRendererLines": 6953,
|
"navaraLayerRendererLines": 4832,
|
||||||
"cesiumImportFiles": 65,
|
"cesiumImportFiles": 53,
|
||||||
"viewerLines": 1901,
|
"viewerLines": 1745,
|
||||||
"navaraKindChecks": 12,
|
"navaraKindChecks": 9,
|
||||||
"engineSniffCalls": 14,
|
"engineSniffCalls": 14,
|
||||||
"engineExtensionReads": 26,
|
"engineExtensionReads": 19,
|
||||||
"groundEffectPasses": 7,
|
"groundEffectPasses": 5,
|
||||||
"cesiumSdkLoads": 6,
|
"cesiumSdkLoads": 6,
|
||||||
"screenSpaceHandlerFiles": 10,
|
"screenSpaceHandlerFiles": 8,
|
||||||
"engineNameLines": 133
|
"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/bundledSource.js",
|
||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.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/overlay.js",
|
||||||
"src/layers/submarineCables/policy.js",
|
"src/layers/submarineCables/policy.js",
|
||||||
"src/layers/submarineCables/rendering.js",
|
|
||||||
"src/layers/submarineCables/state.js",
|
|
||||||
"src/layers/submarineCables/surface.js",
|
"src/layers/submarineCables/surface.js",
|
||||||
"src/data/telegeographySubmarineCables.js",
|
"src/data/telegeographySubmarineCables.js",
|
||||||
"src/data/telegeographySubmarineCables.test.mjs",
|
"src/data/telegeographySubmarineCables.test.mjs",
|
||||||
@@ -908,7 +903,6 @@
|
|||||||
"src/layers/cyclones/rendering.js",
|
"src/layers/cyclones/rendering.js",
|
||||||
"src/layers/cyclones/labels.js",
|
"src/layers/cyclones/labels.js",
|
||||||
"src/layers/cyclones/source.js",
|
"src/layers/cyclones/source.js",
|
||||||
"src/layers/cyclones/rendering.test.mjs",
|
|
||||||
"src/layers/cyclones/labels.test.mjs",
|
"src/layers/cyclones/labels.test.mjs",
|
||||||
"src/layers/cyclones/index.test.mjs",
|
"src/layers/cyclones/index.test.mjs",
|
||||||
"src/layers/cyclones/source.test.mjs",
|
"src/layers/cyclones/source.test.mjs",
|
||||||
@@ -1403,23 +1397,10 @@
|
|||||||
"src/layers/localAdsb/qaDrawn.test.mjs",
|
"src/layers/localAdsb/qaDrawn.test.mjs",
|
||||||
"src/tooling/w1RuntimeEvidence.test.mjs",
|
"src/tooling/w1RuntimeEvidence.test.mjs",
|
||||||
"scripts/fixtures/w2/earthquakes.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.js",
|
||||||
"src/layers/firms/rectangle.test.mjs",
|
"src/layers/firms/rectangle.test.mjs",
|
||||||
"src/layers/firms/viewport.test.mjs",
|
"src/layers/firms/viewport.test.mjs",
|
||||||
"scripts/fixtures/w2/firms.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",
|
"src/layers/firms/lifecycle.test.mjs",
|
||||||
"scripts/fixtures/w2/radio.mjs",
|
"scripts/fixtures/w2/radio.mjs",
|
||||||
"scripts/qa-map-policy.mjs",
|
"scripts/qa-map-policy.mjs",
|
||||||
@@ -1620,11 +1601,30 @@
|
|||||||
"src/globe/navara/ground/pack.test.mjs",
|
"src/globe/navara/ground/pack.test.mjs",
|
||||||
"src/globe/navara/ground/pass.js",
|
"src/globe/navara/ground/pass.js",
|
||||||
"src/globe/navara/ground/vector.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-suite.mjs",
|
||||||
"scripts/qa-ground-bench.mjs",
|
"scripts/qa-ground-bench.mjs",
|
||||||
"scripts/qa-ground-prototypes.mjs",
|
|
||||||
"scripts/qa-ground-switch.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/source.js",
|
||||||
"src/layers/earthquakes/records.js",
|
"src/layers/earthquakes/records.js",
|
||||||
"src/geo/",
|
"src/geo/",
|
||||||
"src/layers/shared/color.js"
|
"src/layers/shared/color.js",
|
||||||
|
"src/layers/earthquakes/render/display.js"
|
||||||
],
|
],
|
||||||
"external": []
|
"external": []
|
||||||
},
|
},
|
||||||
@@ -2180,13 +2181,8 @@
|
|||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.js",
|
"src/layers/submarineCables/index.js",
|
||||||
"src/layers/submarineCables/session.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/overlay.js",
|
||||||
"src/layers/submarineCables/policy.js",
|
"src/layers/submarineCables/policy.js",
|
||||||
"src/layers/submarineCables/rendering.js",
|
|
||||||
"src/layers/submarineCables/state.js",
|
|
||||||
"src/layers/submarineCables/surface.js",
|
"src/layers/submarineCables/surface.js",
|
||||||
"src/data/inputOwnership.js",
|
"src/data/inputOwnership.js",
|
||||||
"src/i18n/index.js",
|
"src/i18n/index.js",
|
||||||
@@ -2202,7 +2198,10 @@
|
|||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -2779,13 +2778,8 @@
|
|||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.js",
|
"src/layers/submarineCables/index.js",
|
||||||
"src/layers/submarineCables/session.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/overlay.js",
|
||||||
"src/layers/submarineCables/policy.js",
|
"src/layers/submarineCables/policy.js",
|
||||||
"src/layers/submarineCables/rendering.js",
|
|
||||||
"src/layers/submarineCables/state.js",
|
|
||||||
"src/layers/submarineCables/surface.js",
|
"src/layers/submarineCables/surface.js",
|
||||||
"src/layers/traffic/animation.js",
|
"src/layers/traffic/animation.js",
|
||||||
"src/layers/traffic/controls.js",
|
"src/layers/traffic/controls.js",
|
||||||
@@ -3058,20 +3052,16 @@
|
|||||||
"src/layers/earthquakes/records.js",
|
"src/layers/earthquakes/records.js",
|
||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
"src/layers/perimeters/index.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/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/perimeters/cards.js",
|
"src/layers/perimeters/cards.js",
|
||||||
"src/layers/perimeters/inciweb.js",
|
"src/layers/perimeters/inciweb.js",
|
||||||
"src/app/layers/gnss.js",
|
"src/app/layers/gnss.js",
|
||||||
"src/layers/gnss/index.js",
|
"src/layers/gnss/index.js",
|
||||||
"src/layers/gnss/render/cesium.js",
|
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
"src/layers/gnss/records.js",
|
"src/layers/gnss/records.js",
|
||||||
"src/app/layers/ghostMaps.js",
|
"src/app/layers/ghostMaps.js",
|
||||||
"src/layers/ghostMaps/index.js",
|
"src/layers/ghostMaps/index.js",
|
||||||
"src/layers/ghostMaps/render/cesium.js",
|
|
||||||
"src/layers/ghostMaps/catalog.js",
|
"src/layers/ghostMaps/catalog.js",
|
||||||
"src/layers/ghostMaps/records.js",
|
"src/layers/ghostMaps/records.js",
|
||||||
"src/layers/ghostMaps/kml.js",
|
"src/layers/ghostMaps/kml.js",
|
||||||
@@ -3299,7 +3289,6 @@
|
|||||||
"src/data/geoImportImages.js",
|
"src/data/geoImportImages.js",
|
||||||
"src/app/layers/airQuality.js",
|
"src/app/layers/airQuality.js",
|
||||||
"src/layers/airQuality/index.js",
|
"src/layers/airQuality/index.js",
|
||||||
"src/layers/airQuality/render/cesium.js",
|
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/ui/statusText.js",
|
"src/ui/statusText.js",
|
||||||
@@ -3477,23 +3466,13 @@
|
|||||||
"src/layers/shared/render/engineView.js",
|
"src/layers/shared/render/engineView.js",
|
||||||
"src/celestialRing/render/cesium.js",
|
"src/celestialRing/render/cesium.js",
|
||||||
"src/celestialRing/view.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/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/installations/render/navara.js",
|
||||||
"src/layers/awareness/render/navara.js",
|
"src/layers/awareness/render/navara.js",
|
||||||
"src/layers/awareness/render/navaraPulses.js",
|
"src/layers/awareness/render/navaraPulses.js",
|
||||||
"src/layers/infrastructure/render/navara.js",
|
"src/layers/infrastructure/render/navara.js",
|
||||||
"src/layers/shared/render/navaraStems.js",
|
"src/layers/shared/render/navaraStems.js",
|
||||||
"src/layers/shared/render/navaraSceneDepth.js",
|
"src/layers/shared/render/navaraSceneDepth.js",
|
||||||
"src/layers/submarineCables/render/navara.js",
|
|
||||||
"src/layers/directions/render/navara.js",
|
"src/layers/directions/render/navara.js",
|
||||||
"src/layers/traffic/render/navara.js",
|
"src/layers/traffic/render/navara.js",
|
||||||
"src/layers/streetLevel/render/navara.js",
|
"src/layers/streetLevel/render/navara.js",
|
||||||
@@ -3505,9 +3484,7 @@
|
|||||||
"src/layers/weather/render/navaraSurface.js",
|
"src/layers/weather/render/navaraSurface.js",
|
||||||
"src/layers/wind/render/navara.js",
|
"src/layers/wind/render/navara.js",
|
||||||
"src/layers/wind/render/flowMesh.js",
|
"src/layers/wind/render/flowMesh.js",
|
||||||
"src/layers/ghostMaps/render/navara.js",
|
|
||||||
"src/layers/shared/render/navaraGroundPicks.js",
|
"src/layers/shared/render/navaraGroundPicks.js",
|
||||||
"src/layers/cyclones/render/navara.js",
|
|
||||||
"src/layers/shared/render/groundRing.js",
|
"src/layers/shared/render/groundRing.js",
|
||||||
"src/layers/bikeshare/stationPoints.js",
|
"src/layers/bikeshare/stationPoints.js",
|
||||||
"src/layers/shared/viewRectangle.js",
|
"src/layers/shared/viewRectangle.js",
|
||||||
@@ -3515,8 +3492,6 @@
|
|||||||
"src/layers/installations/render/navaraOutlineBatch.js",
|
"src/layers/installations/render/navaraOutlineBatch.js",
|
||||||
"src/layers/installations/render/navaraStencilBatch.js",
|
"src/layers/installations/render/navaraStencilBatch.js",
|
||||||
"src/layers/firms/rectangle.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/navaraMoverBank.js",
|
||||||
"src/layers/shared/render/moverBank.js",
|
"src/layers/shared/render/moverBank.js",
|
||||||
"src/layers/recentImagery/render/navara.js",
|
"src/layers/recentImagery/render/navara.js",
|
||||||
@@ -3526,13 +3501,8 @@
|
|||||||
"src/layers/alpr/overlay.js",
|
"src/layers/alpr/overlay.js",
|
||||||
"src/layers/alpr/presentation.js",
|
"src/layers/alpr/presentation.js",
|
||||||
"src/layers/radio/screenClusters.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/shared/render/imageAtlas.js",
|
||||||
"src/layers/firms/render/navaraHeat.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/navaraGroundSurface.js",
|
||||||
"src/layers/shared/render/navaraGroundEffect.js",
|
"src/layers/shared/render/navaraGroundEffect.js",
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
@@ -3602,7 +3572,19 @@
|
|||||||
"src/layers/cctv/render/cesiumMeshes.js",
|
"src/layers/cctv/render/cesiumMeshes.js",
|
||||||
"src/layers/cctv/render/meshes.js",
|
"src/layers/cctv/render/meshes.js",
|
||||||
"src/layers/cctv/render/display.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -3935,13 +3917,8 @@
|
|||||||
"src/layers/submarineCables/geometry.js",
|
"src/layers/submarineCables/geometry.js",
|
||||||
"src/layers/submarineCables/index.js",
|
"src/layers/submarineCables/index.js",
|
||||||
"src/layers/submarineCables/session.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/overlay.js",
|
||||||
"src/layers/submarineCables/policy.js",
|
"src/layers/submarineCables/policy.js",
|
||||||
"src/layers/submarineCables/rendering.js",
|
|
||||||
"src/layers/submarineCables/state.js",
|
|
||||||
"src/layers/submarineCables/surface.js",
|
"src/layers/submarineCables/surface.js",
|
||||||
"src/layers/traffic/animation.js",
|
"src/layers/traffic/animation.js",
|
||||||
"src/layers/traffic/controls.js",
|
"src/layers/traffic/controls.js",
|
||||||
@@ -4031,20 +4008,16 @@
|
|||||||
"src/layers/earthquakes/records.js",
|
"src/layers/earthquakes/records.js",
|
||||||
"src/app/layers/perimeters.js",
|
"src/app/layers/perimeters.js",
|
||||||
"src/layers/perimeters/index.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/source.js",
|
||||||
"src/layers/perimeters/records.js",
|
"src/layers/perimeters/records.js",
|
||||||
"src/layers/perimeters/cards.js",
|
"src/layers/perimeters/cards.js",
|
||||||
"src/layers/perimeters/inciweb.js",
|
"src/layers/perimeters/inciweb.js",
|
||||||
"src/app/layers/gnss.js",
|
"src/app/layers/gnss.js",
|
||||||
"src/layers/gnss/index.js",
|
"src/layers/gnss/index.js",
|
||||||
"src/layers/gnss/render/cesium.js",
|
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
"src/layers/gnss/records.js",
|
"src/layers/gnss/records.js",
|
||||||
"src/app/layers/ghostMaps.js",
|
"src/app/layers/ghostMaps.js",
|
||||||
"src/layers/ghostMaps/index.js",
|
"src/layers/ghostMaps/index.js",
|
||||||
"src/layers/ghostMaps/render/cesium.js",
|
|
||||||
"src/layers/ghostMaps/catalog.js",
|
"src/layers/ghostMaps/catalog.js",
|
||||||
"src/layers/ghostMaps/records.js",
|
"src/layers/ghostMaps/records.js",
|
||||||
"src/layers/ghostMaps/kml.js",
|
"src/layers/ghostMaps/kml.js",
|
||||||
@@ -4211,7 +4184,6 @@
|
|||||||
"src/data/cameraFraming.js",
|
"src/data/cameraFraming.js",
|
||||||
"src/app/layers/airQuality.js",
|
"src/app/layers/airQuality.js",
|
||||||
"src/layers/airQuality/index.js",
|
"src/layers/airQuality/index.js",
|
||||||
"src/layers/airQuality/render/cesium.js",
|
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/ui/statusText.js",
|
"src/ui/statusText.js",
|
||||||
@@ -4282,9 +4254,7 @@
|
|||||||
"src/celestialRing/render/cesium.js",
|
"src/celestialRing/render/cesium.js",
|
||||||
"src/celestialRing/view.js",
|
"src/celestialRing/view.js",
|
||||||
"src/layers/shared/render/engineView.js",
|
"src/layers/shared/render/engineView.js",
|
||||||
"src/layers/earthquakes/render/cesium.js",
|
|
||||||
"src/layers/firms/rectangle.js",
|
"src/layers/firms/rectangle.js",
|
||||||
"src/layers/firms/render/cesium.js",
|
|
||||||
"src/annotations/outlineRungs.js",
|
"src/annotations/outlineRungs.js",
|
||||||
"src/sources/openFreeMapOutlines.js",
|
"src/sources/openFreeMapOutlines.js",
|
||||||
"src/sources/nominatimOutlines.js",
|
"src/sources/nominatimOutlines.js",
|
||||||
@@ -4337,7 +4307,20 @@
|
|||||||
"src/layers/cctv/render/cesiumMeshes.js",
|
"src/layers/cctv/render/cesiumMeshes.js",
|
||||||
"src/layers/cctv/render/meshes.js",
|
"src/layers/cctv/render/meshes.js",
|
||||||
"src/layers/cctv/render/display.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
@@ -5185,8 +5168,6 @@
|
|||||||
"src/geo/cartesian3.js",
|
"src/geo/cartesian3.js",
|
||||||
"src/geo/math.js",
|
"src/geo/math.js",
|
||||||
"src/layers/perimeters/index.js",
|
"src/layers/perimeters/index.js",
|
||||||
"src/layers/perimeters/render/cesium.js",
|
|
||||||
"src/layers/shared/render/cesiumZones.js",
|
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/layers/perimeters/source.js",
|
"src/layers/perimeters/source.js",
|
||||||
@@ -5205,7 +5186,9 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -5213,7 +5196,6 @@
|
|||||||
"exports": ["./layers/gnss"],
|
"exports": ["./layers/gnss"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/gnss/index.js",
|
"src/layers/gnss/index.js",
|
||||||
"src/layers/gnss/render/cesium.js",
|
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/i18n/marker.js",
|
"src/i18n/marker.js",
|
||||||
"src/layers/gnss/source.js",
|
"src/layers/gnss/source.js",
|
||||||
@@ -5232,7 +5214,18 @@
|
|||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/statusText.js",
|
"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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -5279,7 +5272,6 @@
|
|||||||
"exports": ["./layers/ghostmaps"],
|
"exports": ["./layers/ghostmaps"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/ghostMaps/index.js",
|
"src/layers/ghostMaps/index.js",
|
||||||
"src/layers/ghostMaps/render/cesium.js",
|
|
||||||
"src/layers/ghostMaps/catalog.js",
|
"src/layers/ghostMaps/catalog.js",
|
||||||
"src/layers/ghostMaps/records.js",
|
"src/layers/ghostMaps/records.js",
|
||||||
"src/layers/ghostMaps/kml.js",
|
"src/layers/ghostMaps/kml.js",
|
||||||
@@ -5335,7 +5327,6 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/layers/droneAirspace/index.js",
|
"src/layers/droneAirspace/index.js",
|
||||||
"src/geo/",
|
"src/geo/",
|
||||||
"src/layers/shared/render/cesiumZones.js",
|
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
"src/layers/droneAirspace/source.js",
|
"src/layers/droneAirspace/source.js",
|
||||||
"src/layers/droneAirspace/records.js",
|
"src/layers/droneAirspace/records.js",
|
||||||
@@ -5353,7 +5344,9 @@
|
|||||||
"src/i18n/locales/ru.json",
|
"src/i18n/locales/ru.json",
|
||||||
"src/i18n/locales/uk.json",
|
"src/i18n/locales/uk.json",
|
||||||
"src/i18n/locales/zh-CN.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"]
|
"external": ["cesium"]
|
||||||
},
|
},
|
||||||
@@ -5538,7 +5531,6 @@
|
|||||||
"src/geo/cartesian3.js",
|
"src/geo/cartesian3.js",
|
||||||
"src/geo/math.js",
|
"src/geo/math.js",
|
||||||
"src/layers/airQuality/index.js",
|
"src/layers/airQuality/index.js",
|
||||||
"src/layers/airQuality/render/cesium.js",
|
|
||||||
"src/layers/airQuality/model.js",
|
"src/layers/airQuality/model.js",
|
||||||
"src/layers/airQuality/source.js",
|
"src/layers/airQuality/source.js",
|
||||||
"src/sources/httpBody.js",
|
"src/sources/httpBody.js",
|
||||||
@@ -5557,7 +5549,9 @@
|
|||||||
"src/i18n/locales/zh-CN.json",
|
"src/i18n/locales/zh-CN.json",
|
||||||
"src/i18n/locales/zh-Hant.json",
|
"src/i18n/locales/zh-Hant.json",
|
||||||
"src/ui/statusText.js",
|
"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"]
|
"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 { rendererFor } from './renderers.js';
|
||||||
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
|
|
||||||
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
||||||
import { overlayHost } from './overlayHost.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) {
|
export function createApplicationAirQuality(options) {
|
||||||
return createAirQualityLayer({
|
return createAirQualityLayer({
|
||||||
overlayHost,
|
overlayHost,
|
||||||
createRenderer: ({ viewer, engine }) =>
|
createRenderer: ({ engine }) => rendererFor('air-quality', engine),
|
||||||
engine
|
|
||||||
? rendererFor('air-quality', engine, { viewer })
|
|
||||||
: createAirQualityCesiumRenderer({ viewer }),
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||||
import * as context from '../../data/contextStore.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({
|
return createCyclonesLayer({
|
||||||
context: typeof window !== 'undefined' ? context : null,
|
context: typeof window !== 'undefined' ? context : null,
|
||||||
|
createRendering,
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as Cesium from 'cesium';
|
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
|
||||||
import {
|
|
||||||
createDroneAirspaceLayer,
|
|
||||||
createDroneAirspaceCesiumRenderer,
|
|
||||||
} from '../../layers/droneAirspace/index.js';
|
|
||||||
import { rendererFor } from './renderers.js';
|
import { rendererFor } from './renderers.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.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) {
|
export function createApplicationDroneAirspace(options) {
|
||||||
return createDroneAirspaceLayer({
|
return createDroneAirspaceLayer({
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -17,18 +17,7 @@ export function createApplicationDroneAirspace(options) {
|
|||||||
clearSource: overlays.clearOverlaySource,
|
clearSource: overlays.clearOverlaySource,
|
||||||
hitTest: overlays.hitTestWorldOverlay,
|
hitTest: overlays.hitTestWorldOverlay,
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
createRenderer: ({ engine }) => rendererFor('drone-airspace', engine),
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
|
||||||
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
|
|
||||||
engine
|
|
||||||
? rendererFor('drone-airspace', engine, {
|
|
||||||
viewer,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
})
|
|
||||||
: createDroneAirspaceCesiumRenderer({
|
|
||||||
viewer,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
}),
|
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
pointer: { isPointerFree },
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { rendererFor } from './renderers.js';
|
import { rendererFor } from './renderers.js';
|
||||||
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
|
|
||||||
import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
|
import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
|
||||||
import { overlayHost } from './overlayHost.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({
|
export function createApplicationEarthquakes({
|
||||||
renderer = {
|
renderer = {
|
||||||
create: ({ engine = null, ...options }) =>
|
create: ({ engine }) => rendererFor('earthquakes', engine),
|
||||||
engine
|
|
||||||
? rendererFor('earthquakes', engine, options)
|
|
||||||
: createEarthquakesCesiumRenderer(null, options),
|
|
||||||
},
|
},
|
||||||
...options
|
...options
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
|||||||
+7
-20
@@ -1,28 +1,23 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { rendererFor } from './renderers.js';
|
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 { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
import * as sprites from '../../data/spriteOrder.js';
|
|
||||||
import * as context from '../../data/contextStore.js';
|
import * as context from '../../data/contextStore.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import * as focus from '../../worldFocus.js';
|
import * as focus from '../../worldFocus.js';
|
||||||
export const firmsServices = {
|
export const firmsServices = {
|
||||||
render,
|
render,
|
||||||
sprites,
|
|
||||||
context,
|
context,
|
||||||
picking,
|
picking,
|
||||||
overlays,
|
overlays,
|
||||||
focus,
|
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 }) {
|
export function createApplicationFirms({ surface, ...options }) {
|
||||||
return createLayer({
|
return createLayer({
|
||||||
...options,
|
...options,
|
||||||
@@ -31,22 +26,14 @@ export function createApplicationFirms({ surface, ...options }) {
|
|||||||
feed: options.feed,
|
feed: options.feed,
|
||||||
services: { ...firmsServices, anchors: surface.anchors },
|
services: { ...firmsServices, anchors: surface.anchors },
|
||||||
renderer: options.renderer ?? {
|
renderer: options.renderer ?? {
|
||||||
create: ({ engine = null, ...renderOptions }) =>
|
create: ({ engine, ...renderOptions }) =>
|
||||||
engine
|
rendererFor('local-firms', engine, renderOptions),
|
||||||
? rendererFor('local-firms', engine, renderOptions)
|
|
||||||
: createFirmsCesiumRenderer(null, renderOptions),
|
|
||||||
},
|
},
|
||||||
projectToWindow: options.projectToWindow ?? projectFirmsWindow,
|
|
||||||
horizonOccluder: options.horizonOccluder ?? firmsHorizonOccluder,
|
|
||||||
leftClickEventType: options.leftClickEventType ?? FIRMS_LEFT_CLICK,
|
|
||||||
overlayHost: options.overlayHost ?? {
|
overlayHost: options.overlayHost ?? {
|
||||||
setEntries: overlays.setOverlayEntries,
|
setEntries: overlays.setOverlayEntries,
|
||||||
setVisible: overlays.setOverlaySourceVisible,
|
setVisible: overlays.setOverlaySourceVisible,
|
||||||
clearSource: overlays.clearOverlaySource,
|
clearSource: overlays.clearOverlaySource,
|
||||||
hitTest: overlays.hitTestWorldOverlay,
|
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 { rendererFor } from './renderers.js';
|
||||||
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.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) {
|
export function createApplicationGhostMaps(options) {
|
||||||
return createGhostMapsLayer({
|
return createGhostMapsLayer({
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -15,8 +18,6 @@ export function createApplicationGhostMaps(options) {
|
|||||||
hitTest: overlays.hitTestWorldOverlay,
|
hitTest: overlays.hitTestWorldOverlay,
|
||||||
},
|
},
|
||||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
pointer: { isPointerFree },
|
||||||
createRenderer: (engine) => rendererFor('s2-ghostmaps', engine),
|
createRenderer: (engine) => rendererFor('s2-ghostmaps', engine),
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { rendererFor } from './renderers.js';
|
import { rendererFor } from './renderers.js';
|
||||||
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
|
|
||||||
import { createGnssIntegrityLayer } from '../../layers/gnss/index.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) {
|
export function createApplicationGnssIntegrity(options) {
|
||||||
return createGnssIntegrityLayer({
|
return createGnssIntegrityLayer({
|
||||||
createRenderer: ({ viewer, engine, viewAnchor }) =>
|
createRenderer: ({ engine, viewAnchor }) =>
|
||||||
engine
|
rendererFor('gnss-integrity', engine, { viewAnchor }),
|
||||||
? rendererFor('gnss-integrity', engine, { viewer, viewAnchor })
|
|
||||||
: createGnssCesiumRenderer({ viewer, viewAnchor }),
|
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
import { rendererFor } from './renderers.js';
|
import { rendererFor } from './renderers.js';
|
||||||
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
|
|
||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
import {
|
||||||
createFirePerimetersLayer,
|
createFirePerimetersLayer,
|
||||||
createInciwebIndexSource,
|
createInciwebIndexSource,
|
||||||
@@ -10,7 +8,11 @@ import * as picking from '../../data/pickRegistry.js';
|
|||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.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) {
|
export function createApplicationFirePerimeters(options) {
|
||||||
return createFirePerimetersLayer({
|
return createFirePerimetersLayer({
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -22,18 +24,7 @@ export function createApplicationFirePerimeters(options) {
|
|||||||
inciwebSource: createInciwebIndexSource(),
|
inciwebSource: createInciwebIndexSource(),
|
||||||
inciwebPublications: createInciwebPublicationSource(),
|
inciwebPublications: createInciwebPublicationSource(),
|
||||||
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
|
||||||
screenSpaceEventHandlerFactory: (viewer) =>
|
createRenderer: ({ engine }) => rendererFor('fire-perimeters', engine),
|
||||||
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
|
|
||||||
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
|
|
||||||
engine
|
|
||||||
? rendererFor('fire-perimeters', engine, {
|
|
||||||
viewer,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
})
|
|
||||||
: createPerimetersCesiumRenderer({
|
|
||||||
viewer,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
}),
|
|
||||||
picking,
|
picking,
|
||||||
pointer: { isPointerFree },
|
pointer: { isPointerFree },
|
||||||
...options,
|
...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
|
// Which engine draws which layer: the layer renderer registry and the layer
|
||||||
// availability API built on it.
|
// 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 { createRadioDisplay } from '../../layers/radio/render/display.js';
|
||||||
import { createTransitDisplay } from '../../layers/transit/render/display.js';
|
import { createTransitDisplay } from '../../layers/transit/render/display.js';
|
||||||
import { createCctvDisplay } from '../../layers/cctv/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']);
|
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). */
|
/** Cesium renderers extracted from their layers (render/cesium.js). */
|
||||||
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||||
earthquakes: createEarthquakesCesiumRenderer,
|
earthquakes: createEarthquakesDisplay,
|
||||||
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
|
'air-quality': createAirQualityDisplay,
|
||||||
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
|
'gnss-integrity': createGnssDisplay,
|
||||||
'fire-perimeters': (_engine, options) =>
|
'fire-perimeters': createPerimetersDisplay,
|
||||||
createPerimetersCesiumRenderer(options),
|
'drone-airspace': createDroneAirspaceDisplay,
|
||||||
'drone-airspace': (_engine, options) =>
|
|
||||||
createDroneAirspaceCesiumRenderer(options),
|
|
||||||
'osm-places': createOsmPlacesDisplay,
|
'osm-places': createOsmPlacesDisplay,
|
||||||
'alpr-cameras': createAlprDisplay,
|
'alpr-cameras': createAlprDisplay,
|
||||||
bikeshare: createBikeshareDisplay,
|
bikeshare: createBikeshareDisplay,
|
||||||
radio: createRadioDisplay,
|
radio: createRadioDisplay,
|
||||||
transit: createTransitDisplay,
|
transit: createTransitDisplay,
|
||||||
'local-firms': createFirmsCesiumRenderer,
|
'local-firms': createFirmsDisplay,
|
||||||
|
's2-ghostmaps': createGhostMapsDisplay,
|
||||||
|
'weather-cyclones': createCyclonesDisplay,
|
||||||
|
'telegeography-submarine-cables': createCablesDisplay,
|
||||||
'rocket-launches': createLaunchesDisplay,
|
'rocket-launches': createLaunchesDisplay,
|
||||||
satellites: createSatellitesDisplay,
|
satellites: createSatellitesDisplay,
|
||||||
flights: createFlightsDisplay,
|
flights: createFlightsDisplay,
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ test('the layers with extracted renderers', () => {
|
|||||||
'radio',
|
'radio',
|
||||||
'transit',
|
'transit',
|
||||||
'local-firms',
|
'local-firms',
|
||||||
|
's2-ghostmaps',
|
||||||
|
'weather-cyclones',
|
||||||
|
'telegeography-submarine-cables',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
'satellites',
|
'satellites',
|
||||||
'flights',
|
'flights',
|
||||||
@@ -94,6 +97,17 @@ test('the layers drawn through one display get the same factory on every engine'
|
|||||||
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
|
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
|
||||||
radio: ['radio', 'createRadioDisplay'],
|
radio: ['radio', 'createRadioDisplay'],
|
||||||
transit: ['transit', 'createTransitDisplay'],
|
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.
|
// The Navara mode registers the same modules' factories.
|
||||||
const navaraMode = readFileSync(
|
const navaraMode = readFileSync(
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
|
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 { rendererFor } from './renderers.js';
|
||||||
import { overlayHost } from './overlayHost.js';
|
import { overlayHost as applicationOverlayHost } from './overlayHost.js';
|
||||||
/** Wire cable source geometry to the application overlay host. */
|
|
||||||
export function createApplicationCables({ session = false, ...options }) {
|
/**
|
||||||
const createCesium = (source) =>
|
* Wire cable source geometry to the application overlay host and map-stack
|
||||||
createSubmarineCableLayer({
|
* 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,
|
overlayHost,
|
||||||
screenSpaceEventHandlerFactory: (canvas) =>
|
mapStackEventTarget,
|
||||||
new Cesium.ScreenSpaceEventHandler(canvas),
|
...(sweepClock ? { sweepClock } : {}),
|
||||||
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
|
}),
|
||||||
...options,
|
}) {
|
||||||
source,
|
return createSubmarineCableLayer({ source, createDisplay });
|
||||||
});
|
|
||||||
if (!session) return createCesium(options.source);
|
|
||||||
return createCableSessionLayer({
|
|
||||||
source: options.source,
|
|
||||||
createCesium,
|
|
||||||
createNavara: (engine) =>
|
|
||||||
rendererFor('telegeography-submarine-cables', engine, { overlayHost }),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
+31
-59
@@ -170,28 +170,18 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
return (engine) =>
|
return (engine) =>
|
||||||
createDirectionsNavaraRenderer(engine, engine.extensions.navara);
|
createDirectionsNavaraRenderer(engine, engine.extensions.navara);
|
||||||
},
|
},
|
||||||
'telegeography-submarine-cables': async () => {
|
'telegeography-submarine-cables': async () =>
|
||||||
const { createNavaraCables } =
|
(await import('../layers/submarineCables/render/display.js'))
|
||||||
await import('../layers/submarineCables/render/navara.js');
|
.createCablesDisplay,
|
||||||
return (engine, { overlayHost }) =>
|
|
||||||
createNavaraCables(engine, engine.extensions.navara, overlayHost);
|
|
||||||
},
|
|
||||||
wind: async () => {
|
wind: async () => {
|
||||||
const { createWindNavaraRenderer } =
|
const { createWindNavaraRenderer } =
|
||||||
await import('../layers/wind/render/navara.js');
|
await import('../layers/wind/render/navara.js');
|
||||||
return (engine) =>
|
return (engine) =>
|
||||||
createWindNavaraRenderer(engine, engine.extensions.navara);
|
createWindNavaraRenderer(engine, engine.extensions.navara);
|
||||||
},
|
},
|
||||||
'weather-cyclones': async () => {
|
'weather-cyclones': async () =>
|
||||||
const { createCyclonesNavaraRenderer } =
|
(await import('../layers/cyclones/render/display.js'))
|
||||||
await import('../layers/cyclones/render/navara.js');
|
.createCyclonesDisplay,
|
||||||
return (engine, options) =>
|
|
||||||
createCyclonesNavaraRenderer(
|
|
||||||
engine,
|
|
||||||
engine.extensions.navara.view,
|
|
||||||
options,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
['local-datacenters', 'local-dams'].map((id) => [
|
['local-datacenters', 'local-dams'].map((id) => [
|
||||||
id,
|
id,
|
||||||
@@ -208,12 +198,9 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
's2-ghostmaps': async () => {
|
's2-ghostmaps': async () =>
|
||||||
const { createGhostMapsNavaraRenderer } =
|
(await import('../layers/ghostMaps/render/display.js'))
|
||||||
await import('../layers/ghostMaps/render/navara.js');
|
.createGhostMapsDisplay,
|
||||||
return (engine) =>
|
|
||||||
createGhostMapsNavaraRenderer(engine, engine.extensions.navara.view);
|
|
||||||
},
|
|
||||||
'military-awareness': async () => {
|
'military-awareness': async () => {
|
||||||
const { createAwarenessNavaraRenderer } =
|
const { createAwarenessNavaraRenderer } =
|
||||||
await import('../layers/awareness/render/navara.js');
|
await import('../layers/awareness/render/navara.js');
|
||||||
@@ -229,13 +216,17 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
...engine.extensions.navara,
|
...engine.extensions.navara,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// The FIRMS display, with the Navara backend of its heatmap.
|
||||||
'local-firms': async () => {
|
'local-firms': async () => {
|
||||||
const { createFirmsNavaraRenderer } =
|
const [{ createFirmsDisplay }, { createFirmsHeatDrape }] =
|
||||||
await import('../layers/firms/render/navara.js');
|
await Promise.all([
|
||||||
|
import('../layers/firms/render/display.js'),
|
||||||
|
import('../layers/firms/render/navaraHeat.js'),
|
||||||
|
]);
|
||||||
return (engine, options) =>
|
return (engine, options) =>
|
||||||
createFirmsNavaraRenderer(engine, {
|
createFirmsDisplay(engine, {
|
||||||
...options,
|
...options,
|
||||||
...engine.extensions.navara,
|
navaraHeat: createFirmsHeatDrape,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'osm-places': async () =>
|
'osm-places': async () =>
|
||||||
@@ -243,39 +234,20 @@ export async function registerNavaraLayerRenderers() {
|
|||||||
.createOsmPlacesDisplay,
|
.createOsmPlacesDisplay,
|
||||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||||
'wildlife-sightings': async () => () => null,
|
'wildlife-sightings': async () => () => null,
|
||||||
'drone-airspace': async () => {
|
'drone-airspace': async () =>
|
||||||
const { createDroneAirspaceNavaraRenderer } =
|
(await import('../layers/droneAirspace/render/display.js'))
|
||||||
await import('../layers/droneAirspace/render/navara.js');
|
.createDroneAirspaceDisplay,
|
||||||
return (engine) =>
|
'fire-perimeters': async () =>
|
||||||
createDroneAirspaceNavaraRenderer(engine, engine.extensions.navara);
|
(await import('../layers/perimeters/render/display.js'))
|
||||||
},
|
.createPerimetersDisplay,
|
||||||
'fire-perimeters': async () => {
|
'gnss-integrity': async () =>
|
||||||
const { createPerimetersNavaraRenderer } =
|
(await import('../layers/gnss/render/display.js')).createGnssDisplay,
|
||||||
await import('../layers/perimeters/render/navara.js');
|
'air-quality': async () =>
|
||||||
return (engine) =>
|
(await import('../layers/airQuality/render/display.js'))
|
||||||
createPerimetersNavaraRenderer(engine, engine.extensions.navara);
|
.createAirQualityDisplay,
|
||||||
},
|
earthquakes: async () =>
|
||||||
'gnss-integrity': async () => {
|
(await import('../layers/earthquakes/render/display.js'))
|
||||||
const { createGnssNavaraRenderer } =
|
.createEarthquakesDisplay,
|
||||||
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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
// The same displays as on Cesium (renderers.js).
|
// The same displays as on Cesium (renderers.js).
|
||||||
'rocket-launches': async () =>
|
'rocket-launches': async () =>
|
||||||
(await import('../layers/launches/render/display.js'))
|
(await import('../layers/launches/render/display.js'))
|
||||||
|
|||||||
+356
-162
@@ -1,9 +1,13 @@
|
|||||||
import { LayerBindings } from './ui/layerBindings.js';
|
import { LayerBindings } from './ui/layerBindings.js';
|
||||||
import { ScreenSpaceEventType } from 'cesium';
|
import {
|
||||||
import { FIRMS_LEFT_CLICK } from './layers/firms/render/cesium.js';
|
readShellSource,
|
||||||
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
|
shellMethod,
|
||||||
|
} from './testSupport/readShellSource.mjs';
|
||||||
import { readLayerSource } from './testSupport/readLayerSource.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 { CockpitViewController } from './ui/cockpitController.js';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import fs from 'node:fs';
|
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 ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
const ui = readShellSource();
|
const ui = readShellSource();
|
||||||
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
|
const firms = readLayerSource(
|
||||||
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
|
path.join(ROOT, 'src', 'data', 'firmsHeatmap.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 vessels = readLayerSource(
|
||||||
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
|
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) {
|
function body(source, pattern, label) {
|
||||||
const name = pattern.source.match(/^(\w+)/)?.[1];
|
const name = pattern.source.match(/^(\w+)/)?.[1];
|
||||||
@@ -33,8 +50,14 @@ function body(source, pattern, label) {
|
|||||||
function ordered(source, needles, label) {
|
function ordered(source, needles, label) {
|
||||||
let previous = -1;
|
let previous = -1;
|
||||||
for (const needle of needles) {
|
for (const needle of needles) {
|
||||||
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
|
const match =
|
||||||
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
|
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 >= 0, `${label}: missing ${needle}`);
|
||||||
assert.ok(index > previous, `${label}: ${needle} is out of order`);
|
assert.ok(index > previous, `${label}: ${needle} is out of order`);
|
||||||
previous = index;
|
previous = index;
|
||||||
@@ -43,12 +66,16 @@ function ordered(source, needles, label) {
|
|||||||
|
|
||||||
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
|
||||||
const enter = cockpitEnter.toString();
|
const enter = cockpitEnter.toString();
|
||||||
ordered(enter, [
|
ordered(
|
||||||
'if (!info || !entity) return false;',
|
enter,
|
||||||
'this.onCameraTakeover?.();',
|
[
|
||||||
'this.host.camera.cancelFlight();',
|
'if (!info || !entity) return false;',
|
||||||
'this.host.release();',
|
'this.onCameraTakeover?.();',
|
||||||
], 'Cockpit takeover');
|
'this.host.camera.cancelFlight();',
|
||||||
|
'this.host.release();',
|
||||||
|
],
|
||||||
|
'Cockpit takeover',
|
||||||
|
);
|
||||||
assert.match(
|
assert.match(
|
||||||
ui,
|
ui,
|
||||||
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
|
/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.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
|
||||||
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||||
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
assert.match(
|
||||||
|
stamp,
|
||||||
|
/militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/,
|
||||||
|
);
|
||||||
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
|
||||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
|
assert.match(
|
||||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
|
stamp,
|
||||||
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
|
/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', () => {
|
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/,
|
/export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/,
|
||||||
'Cockpit entry transaction',
|
'Cockpit entry transaction',
|
||||||
);
|
);
|
||||||
ordered(transaction, [
|
ordered(
|
||||||
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
|
transaction,
|
||||||
'if (!entryError) entered = Boolean(cockpitView.enter());',
|
[
|
||||||
], 'Cockpit entry 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
|
// No direct camera control: every mutation goes through a layer tracker or
|
||||||
// the cockpit controller, both of which stamp.
|
// the cockpit controller, both of which stamp.
|
||||||
assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/);
|
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*\);/,
|
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
|
||||||
);
|
);
|
||||||
const funnel = navigateContext.toString();
|
const funnel = navigateContext.toString();
|
||||||
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
|
assert.match(
|
||||||
ordered(funnel, [
|
funnel,
|
||||||
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
|
/const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/,
|
||||||
'const navigationOptions = wasActive',
|
);
|
||||||
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
|
ordered(
|
||||||
'this._adoptTrackedEntity(performance.now());',
|
funnel,
|
||||||
], 'Cockpit Context navigation funnel');
|
[
|
||||||
|
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
|
||||||
|
'const navigationOptions = wasActive',
|
||||||
|
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
|
||||||
|
'this._adoptTrackedEntity(performance.now());',
|
||||||
|
],
|
||||||
|
'Cockpit Context navigation funnel',
|
||||||
|
);
|
||||||
const navigate = body(
|
const navigate = body(
|
||||||
ui,
|
ui,
|
||||||
/if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/,
|
/if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/,
|
||||||
'controlCockpit navigation branch',
|
'controlCockpit navigation branch',
|
||||||
);
|
);
|
||||||
ordered(navigate, [
|
ordered(
|
||||||
'this.cockpitView.navigateContext(',
|
navigate,
|
||||||
"normalized === 'next' ? 1 : -1,",
|
['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"],
|
||||||
], 'controlCockpit navigation branch');
|
'controlCockpit navigation branch',
|
||||||
|
);
|
||||||
// No private camera route around the awareness layer.
|
// 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.
|
// An exhausted cohort is an honest failure, not a silent success.
|
||||||
assert.match(navigate, /ok: changed,/);
|
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', () => {
|
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 \}/,
|
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
|
||||||
'explicit navigation',
|
'explicit navigation',
|
||||||
);
|
);
|
||||||
ordered(run, [
|
ordered(
|
||||||
'cockpitActive: this.isCockpitActive()',
|
run,
|
||||||
'stamp: () => this._stampNavigation()',
|
[
|
||||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
'cockpitActive: this.isCockpitActive()',
|
||||||
'navigate,',
|
'stamp: () => this._stampNavigation()',
|
||||||
], 'explicit navigation');
|
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||||
|
'navigate,',
|
||||||
|
],
|
||||||
|
'explicit navigation',
|
||||||
|
);
|
||||||
const release = body(
|
const release = body(
|
||||||
ui,
|
ui,
|
||||||
/_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
|
/_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||||
'follow release',
|
'follow release',
|
||||||
);
|
);
|
||||||
ordered(release, [
|
ordered(
|
||||||
'origin: trackingOrigin',
|
release,
|
||||||
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
[
|
||||||
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
'origin: trackingOrigin',
|
||||||
'if (this.viewer) this.viewer.trackedEntity = undefined;',
|
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
|
||||||
"interruptCameraMotion('explicit-navigation')",
|
'rocketLaunchesLayer.releaseCameraOwnership?.()',
|
||||||
'camera?.cancelFlight();',
|
'if (this.viewer) this.viewer.trackedEntity = undefined;',
|
||||||
'camera?.release();',
|
"interruptCameraMotion('explicit-navigation')",
|
||||||
], 'follow release');
|
'camera?.cancelFlight();',
|
||||||
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
'camera?.release();',
|
||||||
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
|
],
|
||||||
|
'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', () => {
|
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(
|
||||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
|
ui,
|
||||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
|
/runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/,
|
||||||
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(
|
||||||
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
|
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(
|
const trackedVoice = voice.slice(
|
||||||
voice.indexOf('async function trackEntity'),
|
voice.indexOf('async function trackEntity'),
|
||||||
voice.indexOf('async function frameOverhead'),
|
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\}/,
|
/export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/,
|
||||||
'move camera',
|
'move camera',
|
||||||
);
|
);
|
||||||
ordered(move, [
|
ordered(
|
||||||
'if (argsError) return argsError;',
|
move,
|
||||||
'const start = () => {',
|
[
|
||||||
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
'if (argsError) return argsError;',
|
||||||
'runNavigation(start, { preserveCameraFlight })',
|
'const start = () => {',
|
||||||
], 'move validation before handoff');
|
/const preserveCameraFlight =\s*motion === 'orbit'/,
|
||||||
|
'runNavigation(start, { preserveCameraFlight })',
|
||||||
|
],
|
||||||
|
'move validation before handoff',
|
||||||
|
);
|
||||||
const moveArgs = body(
|
const moveArgs = body(
|
||||||
cameraVerbs,
|
cameraVerbs,
|
||||||
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
|
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
|
||||||
'move camera argument checks',
|
'move camera argument checks',
|
||||||
);
|
);
|
||||||
assert.ok(
|
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',
|
'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\}/,
|
/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',
|
'fly route',
|
||||||
);
|
);
|
||||||
ordered(route, [
|
ordered(
|
||||||
'if (!routes.length)',
|
route,
|
||||||
'const pts = route.path.map',
|
[
|
||||||
'const start = () => {',
|
'if (!routes.length)',
|
||||||
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
|
'const pts = route.path.map',
|
||||||
], 'route validation before handoff');
|
'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
|
// 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
|
// never reaches into the data layer itself, and the voice dispatch is the one
|
||||||
// place that binds both.
|
// 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', () => {
|
test('deferred search releases only after its final authority check', () => {
|
||||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
const search = fs.readFileSync(
|
||||||
ordered(search, [
|
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
|
||||||
'const authority = this.begin();',
|
'utf8',
|
||||||
'this.onStart?.(authority);',
|
);
|
||||||
'await this.search(query',
|
ordered(
|
||||||
'beforeFly: () => current() && this.beforeFly(authority)',
|
search,
|
||||||
'if (!current() || controller.signal.aborted) return;',
|
[
|
||||||
'destination?.cancelled',
|
'const authority = this.begin();',
|
||||||
'finally',
|
'this.onStart?.(authority);',
|
||||||
'this.onSettled?.(authority)',
|
'await this.search(query',
|
||||||
], 'deferred search component');
|
'beforeFly: () => current() && this.beforeFly(authority)',
|
||||||
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
|
'if (!current() || controller.signal.aborted) return;',
|
||||||
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
|
'destination?.cancelled',
|
||||||
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
|
'finally',
|
||||||
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
|
'this.onSettled?.(authority)',
|
||||||
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
|
],
|
||||||
|
'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', () => {
|
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 \}/,
|
/_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||||
'navigation stamp',
|
'navigation stamp',
|
||||||
);
|
);
|
||||||
assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/);
|
assert.match(
|
||||||
|
stamp,
|
||||||
|
/if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/,
|
||||||
|
);
|
||||||
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
|
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('newer navigation, reset, Cockpit, and teardown share one generation', () => {
|
test('newer navigation, reset, Cockpit, and teardown share one generation', () => {
|
||||||
assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1);
|
assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1);
|
||||||
const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset');
|
const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset');
|
||||||
ordered(reset, [
|
ordered(
|
||||||
'if (this._globeResetPromise) return this._globeResetPromise;',
|
reset,
|
||||||
'this._stampNavigation();',
|
[
|
||||||
"interruptCameraMotion('reset-globe')",
|
'if (this._globeResetPromise) return this._globeResetPromise;',
|
||||||
], 'reset supersession');
|
'this._stampNavigation();',
|
||||||
|
"interruptCameraMotion('reset-globe')",
|
||||||
|
],
|
||||||
|
'reset supersession',
|
||||||
|
);
|
||||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||||
ordered(dispose, [
|
ordered(
|
||||||
'this._disposed = true;',
|
dispose,
|
||||||
'await this.detachScene(',
|
[
|
||||||
'this._navigation.stop();',
|
'this._disposed = true;',
|
||||||
], 'dispose invalidation');
|
'await this.detachScene(',
|
||||||
|
'this._navigation.stop();',
|
||||||
|
],
|
||||||
|
'dispose invalidation',
|
||||||
|
);
|
||||||
// An engine switch retires the outgoing globe's navigation the same way.
|
// An engine switch retires the outgoing globe's navigation the same way.
|
||||||
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
const detach = body(
|
||||||
ordered(detach, [
|
ui,
|
||||||
'this._navigation.setScene(',
|
/async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||||
'this._layerBindings.stop();',
|
'detach',
|
||||||
'await this._contextControls.restoreForDisposal();',
|
);
|
||||||
], 'detach invalidation');
|
ordered(
|
||||||
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/);
|
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', () => {
|
test('teardown synchronously closes immediate camera entry points', () => {
|
||||||
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
|
||||||
ordered(dispose, [
|
ordered(
|
||||||
'this._disposed = true;',
|
dispose,
|
||||||
'await this.detachScene(',
|
['this._disposed = true;', 'await this.detachScene('],
|
||||||
], 'synchronous teardown barrier');
|
'synchronous teardown barrier',
|
||||||
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
|
);
|
||||||
ordered(detach, [
|
const detach = body(
|
||||||
'this._layerBindings.stop();',
|
ui,
|
||||||
'await this._contextControls.restoreForDisposal();',
|
/async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
|
||||||
], 'synchronous detach barrier');
|
'detach',
|
||||||
ordered(LayerBindings.prototype.stop.toString(), [
|
);
|
||||||
'this._disposed = true;',
|
ordered(
|
||||||
'this._removeCctvRequestFocusListener?.();',
|
detach,
|
||||||
'this._removeWorldRequestFocusListener?.();',
|
[
|
||||||
'this._navigationOwnerChangedRemover?.();',
|
'this._layerBindings.stop();',
|
||||||
], 'camera bindings close synchronously');
|
'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(
|
const navigation = body(
|
||||||
ui,
|
ui,
|
||||||
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
|
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
|
||||||
'explicit navigation',
|
'explicit navigation',
|
||||||
);
|
);
|
||||||
ordered(navigation, [
|
ordered(
|
||||||
'disposed: this._disposed',
|
navigation,
|
||||||
'release: () => this._releaseFollowCamera(releaseOptions)',
|
[
|
||||||
'navigate,',
|
'disposed: this._disposed',
|
||||||
], 'disposed navigation guard');
|
'release: () => this._releaseFollowCamera(releaseOptions)',
|
||||||
|
'navigate,',
|
||||||
|
],
|
||||||
|
'disposed navigation guard',
|
||||||
|
);
|
||||||
|
|
||||||
const cctvFocus = body(
|
const cctvFocus = body(
|
||||||
ui,
|
ui,
|
||||||
/_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/,
|
/_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/,
|
||||||
'explicit CCTV focus',
|
'explicit CCTV focus',
|
||||||
);
|
);
|
||||||
ordered(cctvFocus, [
|
ordered(
|
||||||
'if (this._disposed) return false;',
|
cctvFocus,
|
||||||
'const cameraId = activate();',
|
['if (this._disposed) return false;', 'const cameraId = activate();'],
|
||||||
], 'disposed CCTV activation guard');
|
'disposed CCTV activation guard',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('teardown refuses deferred location work before geocoding begins', () => {
|
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/);
|
assert.match(deferred, /disposed: this\._disposed/);
|
||||||
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
|
const search = fs.readFileSync(
|
||||||
ordered(search, [
|
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
|
||||||
'if (!query || this.destroyed) return;',
|
'utf8',
|
||||||
'const authority = this.begin();',
|
);
|
||||||
'if (authority === false)',
|
ordered(
|
||||||
'this.input?.blur();',
|
search,
|
||||||
'this.onStart?.(authority);',
|
[
|
||||||
'await this.search(query',
|
'if (!query || this.destroyed) return;',
|
||||||
], 'disposed search refusal');
|
'const authority = this.begin();',
|
||||||
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
|
'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', () => {
|
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 \}/],
|
['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/],
|
||||||
]) {
|
]) {
|
||||||
const handler = body(ui, pattern, name);
|
const handler = body(ui, pattern, name);
|
||||||
ordered(handler, [
|
ordered(
|
||||||
'this._flyWithTransition(',
|
handler,
|
||||||
'if (result === false) return;',
|
[
|
||||||
'this._setActiveLocation(cityId);',
|
'this._flyWithTransition(',
|
||||||
], name);
|
'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 \}/,
|
/function handleClick\(click\) \{([\s\S]*?)\n \}/,
|
||||||
'vessel click',
|
'vessel click',
|
||||||
);
|
);
|
||||||
ordered(vesselClick, [
|
ordered(
|
||||||
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
|
vesselClick,
|
||||||
'_vesselOverlayHost.hitTest?.(',
|
[
|
||||||
'selectAndFocusVessel(record)',
|
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
|
||||||
], 'vessel sibling ownership');
|
'_vesselOverlayHost.hitTest?.(',
|
||||||
|
'selectAndFocusVessel(record)',
|
||||||
|
],
|
||||||
|
'vessel sibling ownership',
|
||||||
|
);
|
||||||
const vesselFocus = body(
|
const vesselFocus = body(
|
||||||
vessels,
|
vessels,
|
||||||
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
|
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
|
||||||
'vessel focus helper',
|
'vessel focus helper',
|
||||||
);
|
);
|
||||||
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
assert.match(vesselFocus, /requestWorldFocus\(\{/);
|
||||||
assert.match(firms, /const clickType\s*=\s*layerState\._renderer\?\.clickType\s*\?\?\s*config\.leftClickEventType/);
|
// Fire clicks come from the layer's renderer (the engine's selection
|
||||||
assert.equal(FIRMS_LEFT_CLICK, ScreenSpaceEventType.LEFT_CLICK);
|
// dispatcher), with the pick under the click.
|
||||||
|
assert.match(
|
||||||
|
firms,
|
||||||
|
/layerState\._clickHandler = layerState\._renderer\.onClick\(\(click\) => \{/,
|
||||||
|
);
|
||||||
const fireClick = body(
|
const fireClick = body(
|
||||||
firms,
|
firms,
|
||||||
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/,
|
/_renderer\.onClick\(\(click\) => \{([\s\S]*?)\n \}\);/,
|
||||||
'fire click',
|
'fire click',
|
||||||
);
|
);
|
||||||
ordered(fireClick, [
|
ordered(
|
||||||
'isOwnedByOtherLayer(id, pickedId)',
|
fireClick,
|
||||||
'overlayHost.hitTest?.(',
|
[
|
||||||
'selectAndFocusFire(carded)',
|
'isOwnedByOtherLayer(id, pickedId)',
|
||||||
], 'fire sibling ownership');
|
'overlayHost.hitTest?.(',
|
||||||
|
'selectAndFocusFire(carded)',
|
||||||
|
],
|
||||||
|
'fire sibling ownership',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ import { test } from 'node:test';
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
|
import { createFakeGroundHarness } from '../testSupport/groundDisplayEngines.mjs';
|
||||||
import {
|
import {
|
||||||
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
|
||||||
EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
|
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');
|
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 originalFetch = globalThis.fetch;
|
||||||
const hostCalls = [];
|
const hostCalls = [];
|
||||||
const dataSources = [];
|
const ground = createFakeGroundHarness();
|
||||||
const overlayHost = {
|
const overlayHost = {
|
||||||
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
setEntries: (...args) => hostCalls.push(['entries', ...args]),
|
||||||
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
setVisible: (...args) => hostCalls.push(['visible', ...args]),
|
||||||
clearSource: (...args) => hostCalls.push(['clear', ...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 () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
@@ -130,13 +121,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car
|
|||||||
});
|
});
|
||||||
const layer = createEarthquakesLayer({ overlayHost });
|
const layer = createEarthquakesLayer({ overlayHost });
|
||||||
try {
|
try {
|
||||||
layer.init(viewer);
|
layer.init(null, { engine: ground.engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
await layer.update(viewer);
|
await layer.update();
|
||||||
|
|
||||||
const entities = dataSources[0].entities.values;
|
const { items } = ground.set('earthquakes');
|
||||||
assert.equal(entities.length, 2, 'runtime guard requires populated real source entities');
|
assert.equal(items.length, 2, 'runtime guard requires populated ground discs');
|
||||||
assert.ok(entities.every((entity) => entity.label === undefined));
|
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');
|
const publication = hostCalls.find(([type]) => type === 'entries');
|
||||||
assert.ok(publication, 'real update path must publish the overlay source');
|
assert.ok(publication, 'real update path must publish the overlay source');
|
||||||
assert.deepEqual(publication[2].map(({ title }) => title), ['M5.2', 'M3.0']);
|
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,
|
moving: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
layer.disable(viewer);
|
layer.disable();
|
||||||
assert.equal(dataSources[0].show, false);
|
assert.equal(ground.set('earthquakes').shown, false);
|
||||||
assert.deepEqual(hostCalls.slice(-2), [
|
assert.deepEqual(hostCalls.slice(-2), [
|
||||||
['clear', 'earthquakes'],
|
['clear', 'earthquakes'],
|
||||||
['visible', 'earthquakes', false],
|
['visible', 'earthquakes', false],
|
||||||
]);
|
]);
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
assert.equal(dataSources.length, 0);
|
assert.equal(ground.sets(), 0);
|
||||||
assert.deepEqual(hostCalls.slice(-2), [
|
assert.deepEqual(hostCalls.slice(-2), [
|
||||||
['clear', 'earthquakes'],
|
['clear', 'earthquakes'],
|
||||||
['visible', 'earthquakes', false],
|
['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 ────────────────────
|
// ── Perf pin: the 2026-08-20 earthquakes frame-rate cliff ────────────────────
|
||||||
// Every disc is a CLAMP_TO_GROUND ellipse. When its axes were a
|
// Every disc is ground geometry. When its axes were a per-frame callback,
|
||||||
// `CallbackProperty`, Cesium re-tessellated all 58 ground primitives on EVERY
|
// Cesium re-tessellated all 58 ground primitives on EVERY frame: 32.4
|
||||||
// frame: 32.4 ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with
|
// ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with the layer
|
||||||
// the layer off. Static axes restored 60 fps. Both halves of the fix are
|
// off. Static radii restored 60 fps. Both halves of the fix are pinned — one
|
||||||
// pinned — the runtime property shape, and the source-level guards.
|
// commit per refresh with a plain radius, and the source-level guards.
|
||||||
test('quake disc axes are STATIC — a per-frame callback re-tessellates ground geometry', async () => {
|
test('quake disc radii are STATIC — a per-frame rebuild re-tessellates ground geometry', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const dataSources = [];
|
const ground = createFakeGroundHarness();
|
||||||
const viewer = {
|
|
||||||
dataSources: {
|
|
||||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
|
||||||
remove() { return true; },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createEarthquakesLayer({
|
const layer = createEarthquakesLayer({
|
||||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
});
|
});
|
||||||
@@ -192,26 +178,25 @@ test('quake disc axes are STATIC — a per-frame callback re-tessellates ground
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
layer.init(viewer);
|
layer.init(null, { engine: ground.engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
await layer.update(viewer);
|
await layer.update();
|
||||||
|
|
||||||
const [entity] = dataSources[0].entities.values;
|
const set = ground.set('earthquakes');
|
||||||
assert.ok(entity.ellipse, 'the disc is an ellipse graphic');
|
const [disc] = set.items;
|
||||||
for (const axis of ['semiMajorAxis', 'semiMinorAxis']) {
|
assert.equal(set.options.kind, 'disc', 'the disc is a ground disc');
|
||||||
const property = entity.ellipse[axis];
|
// Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse.
|
||||||
assert.equal(
|
assert.equal(disc.radiusM, Math.pow(2, 5.5) * 1000);
|
||||||
property instanceof Cesium.CallbackProperty,
|
ground.engine.fake.frames(30);
|
||||||
false,
|
assert.equal(
|
||||||
`${axis} must not be a CallbackProperty — it rebuilds the ground primitive every frame`,
|
ground.set('earthquakes').commits,
|
||||||
);
|
set.commits,
|
||||||
assert.equal(property.isConstant, true, `${axis} must be a constant property`);
|
'frames must not rebuild the ground discs',
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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 () => {
|
test('a quake poll still reaches the screen with the render loop idle', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const renderRequests = [];
|
const renderRequests = [];
|
||||||
const dataSources = [];
|
const ground = createFakeGroundHarness();
|
||||||
const viewer = {
|
const viewer = {
|
||||||
scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) },
|
scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) },
|
||||||
dataSources: {
|
|
||||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
|
||||||
remove() { return true; },
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
const layer = createEarthquakesLayer({
|
const layer = createEarthquakesLayer({
|
||||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
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
|
// frame. Drop that from the baseline or the enable assertion below can pass on
|
||||||
// the install alone, without the manager ever requesting anything.
|
// the install alone, without the manager ever requesting anything.
|
||||||
renderRequests.length = 0;
|
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.
|
// updateInterval -1 keeps the poll loop unarmed; we drive one tick by hand.
|
||||||
manager.register({ ...layer, updateInterval: -1 });
|
manager.register({ ...layer, updateInterval: -1 });
|
||||||
try {
|
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,
|
// ...and the poll that populated the discs must still have asked for a frame,
|
||||||
// because in idle mode nothing repaints on its own.
|
// 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');
|
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
|
// Named, not merely counted: the frame has to come from the manager's
|
||||||
// visibility request, not from some incidental repaint.
|
// 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;
|
globalThis.fetch = originalFetch;
|
||||||
await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {});
|
await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {});
|
||||||
_resetRenderGovernorForTest();
|
_resetRenderGovernorForTest();
|
||||||
|
ground.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
|
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');
|
readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
source,
|
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 () => {
|
test('earthquake refresh reports failure and clears it only after a successful response', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const dataSources = [];
|
const ground = createFakeGroundHarness();
|
||||||
const viewer = {
|
|
||||||
dataSources: {
|
|
||||||
add(dataSource) { dataSources.push(dataSource); return dataSource; },
|
|
||||||
remove() { return true; },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createEarthquakesLayer({
|
const layer = createEarthquakesLayer({
|
||||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
layer.init(viewer);
|
layer.init(null, { engine: ground.engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
globalThis.fetch = async () => ({ ok: false, status: 503 });
|
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');
|
assert.equal(layer.getStats().error, 'USGS HTTP 503');
|
||||||
|
|
||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ features: [] }),
|
json: async () => ({ features: [] }),
|
||||||
});
|
});
|
||||||
assert.equal(await layer.update(viewer), true);
|
assert.equal(await layer.update(), true);
|
||||||
assert.equal(layer.getStats().error, null);
|
assert.equal(layer.getStats().error, null);
|
||||||
assert.ok(Number.isFinite(layer.getStats().lastUpdate));
|
assert.ok(Number.isFinite(layer.getStats().lastUpdate));
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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 originalFetch = globalThis.fetch;
|
||||||
const dataSources = [];
|
const ground = createFakeGroundHarness();
|
||||||
|
const discs = () => ground.set('earthquakes').items;
|
||||||
const publications = [];
|
const publications = [];
|
||||||
const viewer = { dataSources: {
|
|
||||||
add(source) { dataSources.push(source); }, remove() { return true; },
|
|
||||||
} };
|
|
||||||
const layer = createEarthquakesLayer({ overlayHost: {
|
const layer = createEarthquakesLayer({ overlayHost: {
|
||||||
setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {},
|
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' } };
|
properties: { mag: 4, time: 100, place: 'Fixture' } };
|
||||||
const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
|
const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
|
||||||
try {
|
try {
|
||||||
layer.init(viewer);
|
layer.init(null, { engine: ground.engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
respond([good]);
|
respond([good]);
|
||||||
assert.equal(await layer.update(viewer), true);
|
assert.equal(await layer.update(), true);
|
||||||
const entity = dataSources[0].entities.values[0];
|
const drawn = discs();
|
||||||
const stats = layer.getStats();
|
const stats = layer.getStats();
|
||||||
for (const properties of ['invalid', 123, [], true]) {
|
for (const properties of ['invalid', 123, [], true]) {
|
||||||
const bad = { ...good, id: 'bad-properties', properties };
|
const bad = { ...good, id: 'bad-properties', properties };
|
||||||
for (const features of [[bad], [good, bad]]) {
|
for (const features of [[bad], [good, bad]]) {
|
||||||
respond(features);
|
respond(features);
|
||||||
assert.equal(await layer.update(viewer), false);
|
assert.equal(await layer.update(), false);
|
||||||
assert.equal(dataSources[0].entities.values.length, 1);
|
assert.equal(discs(), drawn);
|
||||||
assert.equal(dataSources[0].entities.values[0], entity);
|
|
||||||
assert.equal(layer.getStats().count, stats.count);
|
assert.equal(layer.getStats().count, stats.count);
|
||||||
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
||||||
assert.equal(layer.getStats().error, 'Malformed USGS response');
|
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: '4' } },
|
||||||
{ ...good, properties: { mag: Infinity } }, good]) {
|
{ ...good, properties: { mag: Infinity } }, good]) {
|
||||||
respond([good, bad]);
|
respond([good, bad]);
|
||||||
assert.equal(await layer.update(viewer), false);
|
assert.equal(await layer.update(), false);
|
||||||
assert.equal(dataSources[0].entities.values.length, 1);
|
assert.equal(discs(), drawn);
|
||||||
assert.equal(dataSources[0].entities.values[0], entity);
|
|
||||||
assert.equal(layer.getStats().count, stats.count);
|
assert.equal(layer.getStats().count, stats.count);
|
||||||
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
|
||||||
assert.equal(layer.getStats().error, 'Malformed USGS response');
|
assert.equal(layer.getStats().error, 'Malformed USGS response');
|
||||||
assert.equal(publications.length, 1);
|
assert.equal(publications.length, 1);
|
||||||
}
|
}
|
||||||
respond([{ ...good, properties: { mag: null } }]);
|
respond([{ ...good, properties: { mag: null } }]);
|
||||||
assert.equal(await layer.update(viewer), true);
|
assert.equal(await layer.update(), true);
|
||||||
assert.equal(dataSources[0].entities.values.length, 0);
|
assert.equal(discs().length, 0);
|
||||||
assert.equal(layer.getStats().error, null);
|
assert.equal(layer.getStats().error, null);
|
||||||
assert.equal(publications.at(-1)[1].length, 0);
|
assert.equal(publications.at(-1)[1].length, 0);
|
||||||
respond([good]);
|
respond([good]);
|
||||||
assert.equal(await layer.update(viewer), true);
|
assert.equal(await layer.update(), true);
|
||||||
assert.equal(dataSources[0].entities.values.length, 1);
|
assert.equal(discs().length, 1);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
|
ground.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
|
||||||
import { getLocale, setLocale } from '../i18n/index.js';
|
import { getLocale, setLocale } from '../i18n/index.js';
|
||||||
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.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 NOW = Date.UTC(2026, 6, 17, 4, 0);
|
||||||
const H = 3600000;
|
const H = 3600000;
|
||||||
@@ -40,13 +42,24 @@ function fire(overrides = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () => {
|
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.title, '▲ 1520 MW');
|
||||||
assert.equal(card.details.length, 1);
|
assert.equal(card.details.length, 1);
|
||||||
assert.equal(card.details[0], 'high · 2h · N20');
|
assert.equal(card.details[0], 'high · 2h · N20');
|
||||||
assert.equal(card.selected, false);
|
assert.equal(card.selected, false);
|
||||||
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
|
assert.equal(
|
||||||
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
|
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', () => {
|
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,
|
NOW,
|
||||||
);
|
);
|
||||||
assert.equal(crossed.title, '▲ 10 MW');
|
assert.equal(crossed.title, '▲ 10 MW');
|
||||||
const below = buildFireCard(
|
const below = buildFireCard({ fire: fire({ frp: 9.94 }), position: {} }, NOW);
|
||||||
{ fire: fire({ frp: 9.94 }), position: {} },
|
|
||||||
NOW,
|
|
||||||
);
|
|
||||||
assert.equal(below.title, '▲ 9.9 MW');
|
assert.equal(below.title, '▲ 9.9 MW');
|
||||||
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
|
||||||
assert.equal(selected.title, 'FIRE · 10 MW');
|
assert.equal(selected.title, 'FIRE · 10 MW');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
|
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.match(card.details[0], /SNPP$/);
|
||||||
assert.equal(card.title, '▲ 0.8 MW');
|
assert.equal(card.title, '▲ 0.8 MW');
|
||||||
assert.equal(card.accent, accentForSeverity('ember'));
|
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'));
|
assert.equal(accentAt(300), accentForSeverity('white-hot'));
|
||||||
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
|
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
|
||||||
assert.equal(
|
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'),
|
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 f = fire({ lat: 55.501, lon: -120.501 });
|
||||||
const before = buildSelectedFireCard(f, NOW);
|
const before = buildSelectedFireCard(f, NOW);
|
||||||
const cartoBefore = Cesium.Cartographic.fromCartesian(before.position);
|
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);
|
setMeshFloorPreferred(true);
|
||||||
reportMeshFloorCell(55.501, -120.501, 900);
|
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);
|
const cartoAfter = Cesium.Cartographic.fromCartesian(after.position);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
Math.abs(cartoAfter.height - (900 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
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', () => {
|
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);
|
const card = buildCellCard(candidate, NOW);
|
||||||
assert.equal(card.title, '1 FIRE');
|
assert.equal(card.title, '1 FIRE');
|
||||||
assert.equal(card.details[0], 'max 9.9 MW');
|
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', () => {
|
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
|
||||||
const ambient = applyFirmsOverlayPolicy(buildFireCard({
|
const ambient = applyFirmsOverlayPolicy(
|
||||||
fire: fire(),
|
buildFireCard(
|
||||||
position: new Cesium.Cartesian3(1, 2, 3),
|
{
|
||||||
}, NOW), 4_500_000);
|
fire: fire(),
|
||||||
|
position: new Cesium.Cartesian3(1, 2, 3),
|
||||||
|
},
|
||||||
|
NOW,
|
||||||
|
),
|
||||||
|
4_500_000,
|
||||||
|
);
|
||||||
assert.equal(ambient.variant, 'card');
|
assert.equal(ambient.variant, 'card');
|
||||||
assert.equal(ambient.protected, false);
|
assert.equal(ambient.protected, false);
|
||||||
assert.equal(ambient.collisionGroup, 'ambient-card');
|
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.edgeFade, 'keyhole');
|
||||||
assert.equal(ambient.cardStyle, 'tactical');
|
assert.equal(ambient.cardStyle, 'tactical');
|
||||||
assert.equal(ambient.verticalOnly, true);
|
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);
|
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.variant, 'selected');
|
||||||
assert.equal(selected.protected, true);
|
assert.equal(selected.protected, true);
|
||||||
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
|
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 () => {
|
test('real FIRMS lifecycle clears host entries on disable and destroy', async () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
const originalWindow = globalThis.window;
|
const originalWindow = globalThis.window;
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const preRender = new MockEvent();
|
|
||||||
const moveEnd = new MockEvent();
|
|
||||||
globalThis.fetch = async () => ({
|
globalThis.fetch = async () => ({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 503,
|
status: 503,
|
||||||
json: async () => ({ error: 'no_key' }),
|
json: async () => ({ error: 'no_key' }),
|
||||||
});
|
});
|
||||||
globalThis.window = new EventTarget();
|
globalThis.window = new EventTarget();
|
||||||
const dataSources = [];
|
const { engine, viewer } = createCesiumMoversEngine({
|
||||||
const viewer = {
|
engineOptions: { createEventHandler: createFakeEventHandlers().create },
|
||||||
dataSources: {
|
});
|
||||||
add(value) { dataSources.push(value); return value; },
|
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||||
remove(value) {
|
engine.camera.setView({
|
||||||
const index = dataSources.indexOf(value);
|
destination: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000),
|
||||||
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 layer = createFirmsHeatmapLayer({
|
const layer = createFirmsHeatmapLayer({
|
||||||
id: 'firms',
|
id: 'firms',
|
||||||
name: '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]),
|
setVisible: (...args) => calls.push(['visible', ...args]),
|
||||||
clearSource: (...args) => calls.push(['clear', ...args]),
|
clearSource: (...args) => calls.push(['clear', ...args]),
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
|
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
layer.init(viewer);
|
layer.init(null, { engine });
|
||||||
await layer.enable(viewer);
|
await layer.enable();
|
||||||
assert.deepEqual(calls.at(-1), ['visible', FIRMS_OVERLAY_SOURCE_ID, true]);
|
assert.deepEqual(calls.at(-1), ['visible', FIRMS_OVERLAY_SOURCE_ID, true]);
|
||||||
layer.disable();
|
layer.disable();
|
||||||
assert.deepEqual(calls.slice(-2), [
|
assert.deepEqual(calls.slice(-2), [
|
||||||
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
||||||
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
||||||
]);
|
]);
|
||||||
await layer.enable(viewer);
|
await layer.enable();
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
assert.ok(calls.some(([type]) => type === 'clear'));
|
assert.ok(calls.some(([type]) => type === 'clear'));
|
||||||
assert.deepEqual(calls.slice(-2), [
|
assert.deepEqual(calls.slice(-2), [
|
||||||
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
['clear', FIRMS_OVERLAY_SOURCE_ID],
|
||||||
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
|
||||||
]);
|
]);
|
||||||
const callCount = calls.length;
|
const callCount = calls.length;
|
||||||
await layer.enable(viewer);
|
await layer.enable();
|
||||||
assert.equal(calls.length, callCount, 'destroyed FIRMS layers reject late enable work');
|
assert.equal(
|
||||||
|
calls.length,
|
||||||
|
callCount,
|
||||||
|
'destroyed FIRMS layers reject late enable work',
|
||||||
|
);
|
||||||
assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18);
|
assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18);
|
||||||
} finally {
|
} finally {
|
||||||
|
engine.destroy();
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
if (originalWindow === undefined) delete globalThis.window;
|
if (originalWindow === undefined) delete globalThis.window;
|
||||||
else globalThis.window = originalWindow;
|
else globalThis.window = originalWindow;
|
||||||
|
|||||||
+417
-143
@@ -7,14 +7,22 @@ import {
|
|||||||
fireCullPosition,
|
fireCullPosition,
|
||||||
} from './firmsHeatmap.js';
|
} from './firmsHeatmap.js';
|
||||||
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.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 {
|
import {
|
||||||
_clearMeshFloorCellsForTest,
|
_clearMeshFloorCellsForTest,
|
||||||
reportMeshFloorCell,
|
reportMeshFloorCell,
|
||||||
setMeshFloorPreferred,
|
setMeshFloorPreferred,
|
||||||
} from './groundFloor.js';
|
} from './groundFloor.js';
|
||||||
import { unregisterSpriteCollection } from './spriteOrder.js';
|
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
|
||||||
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
|
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 };
|
const AUSTIN = { lon: -97.7, lat: 30.2 };
|
||||||
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
|
/** 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,
|
position,
|
||||||
_show: show,
|
_show: show,
|
||||||
writes: 0,
|
writes: 0,
|
||||||
get show() { return this._show; },
|
get show() {
|
||||||
set show(value) { this._show = value; this.writes += 1; },
|
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', () => {
|
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => {
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
const near = billboard(
|
||||||
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0));
|
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
|
||||||
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0));
|
);
|
||||||
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.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(near.show, true, 'the fire under the camera renders');
|
||||||
assert.equal(nearby.show, true, 'a fire well inside the horizon 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(
|
||||||
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
|
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');
|
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();
|
const low = paris();
|
||||||
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M));
|
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();
|
const high = paris();
|
||||||
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
|
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', () => {
|
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));
|
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
|
||||||
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
|
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
|
||||||
assert.equal(revealed.writes, 1);
|
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)', () => {
|
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true);
|
const stays = billboard(
|
||||||
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true);
|
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);
|
||||||
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(
|
||||||
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
|
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', () => {
|
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => {
|
||||||
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
|
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
|
||||||
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
|
assert.equal(
|
||||||
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
|
applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)),
|
||||||
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
|
0,
|
||||||
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
|
'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(item.writes, 0, 'a skipped pass must not touch show');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
|
applyHorizonCull(
|
||||||
|
collection([undefined, item]),
|
||||||
|
occluderOverAustin(REPRO_HEIGHT_M),
|
||||||
|
),
|
||||||
1,
|
1,
|
||||||
'sparse slots are skipped, surviving neighbours still counted',
|
'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', () => {
|
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
|
const sunken = billboard(
|
||||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
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);
|
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]);
|
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', () => {
|
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => {
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
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);
|
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12);
|
||||||
applyHorizonCull(collection([far]), occluder, [lifted]);
|
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', () => {
|
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}`,
|
`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);
|
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.ok(
|
||||||
assert.equal(fireCullPosition(fire), cull, 'the lifted point is cached, not re-allocated per call');
|
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.
|
// The whole point: the datum-correct point would be culled here, the lifted one is not.
|
||||||
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
assert.equal(occluder.isPointVisible(render), false, 'baseline defect: −22 m reads beyond the horizon');
|
assert.equal(
|
||||||
assert.equal(occluder.isPointVisible(cull), true, 'lifted cull point reads correctly as in view');
|
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 {
|
} finally {
|
||||||
_clearMeshFloorCellsForTest();
|
_clearMeshFloorCellsForTest();
|
||||||
setMeshFloorPreferred(false);
|
setMeshFloorPreferred(false);
|
||||||
@@ -183,7 +296,11 @@ test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point',
|
|||||||
try {
|
try {
|
||||||
const fire = { index: 2, lat, lon, frp: 10, position: null };
|
const fire = { index: 2, lat, lon, frp: 10, position: null };
|
||||||
const cull = fireCullPosition(fire);
|
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 {
|
} finally {
|
||||||
_clearMeshFloorCellsForTest();
|
_clearMeshFloorCellsForTest();
|
||||||
setMeshFloorPreferred(false);
|
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', () => {
|
test('isOverlayPointVisible: horizon test prefers entry.cullPosition when present', () => {
|
||||||
const viewport = { width: 800, height: 600 };
|
const viewport = { width: 800, height: 600 };
|
||||||
const screen = { x: 400, y: 300 };
|
const screen = { x: 400, y: 300 };
|
||||||
const render = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22);
|
const render = Cesium.Cartesian3.fromDegrees(
|
||||||
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
|
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);
|
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isOverlayPointVisible({ horizonCull: true }, render, screen, viewport, occluder),
|
isOverlayPointVisible(
|
||||||
|
{ horizonCull: true },
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
viewport,
|
||||||
|
occluder,
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
'baseline: the render anchor alone false-hides the card',
|
'baseline: the render anchor alone false-hides the card',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isOverlayPointVisible({ horizonCull: true, cullPosition: lifted }, render, screen, viewport, occluder),
|
isOverlayPointVisible(
|
||||||
|
{ horizonCull: true, cullPosition: lifted },
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
viewport,
|
||||||
|
occluder,
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
'the supplied cull position is what the horizon test uses',
|
'the supplied cull position is what the horizon test uses',
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isOverlayPointVisible(
|
isOverlayPointVisible(
|
||||||
{ horizonCull: true, cullPosition: Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12) },
|
{
|
||||||
|
horizonCull: true,
|
||||||
|
cullPosition: Cesium.Cartesian3.fromDegrees(
|
||||||
|
ANTIPODE.lon,
|
||||||
|
ANTIPODE.lat,
|
||||||
|
12,
|
||||||
|
),
|
||||||
|
},
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
viewport,
|
viewport,
|
||||||
@@ -227,37 +371,80 @@ test('isOverlayPointVisible: horizon test prefers entry.cullPosition when presen
|
|||||||
test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => {
|
test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => {
|
||||||
const position = { x: 1, y: 2, z: 3 };
|
const position = { x: 1, y: 2, z: 3 };
|
||||||
const source = { x: 4, y: 5, z: 6 };
|
const source = { x: 4, y: 5, z: 6 };
|
||||||
const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: source });
|
const kept = normalizeOverlayEntry('firms', {
|
||||||
assert.notEqual(kept.cullPosition, source, 'stored as a host-owned copy (a geo Cartesian3)');
|
id: 'a',
|
||||||
assert.ok(!(kept.cullPosition instanceof Cesium.Cartesian3), 'not a Cesium value');
|
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(
|
assert.deepEqual(
|
||||||
[kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z],
|
[kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z],
|
||||||
[4, 5, 6],
|
[4, 5, 6],
|
||||||
);
|
);
|
||||||
const dropped = normalizeOverlayEntry('firms', { id: 'b', position, cullPosition: { x: NaN, y: 5, z: 6 } });
|
const dropped = normalizeOverlayEntry('firms', {
|
||||||
assert.equal(dropped.cullPosition, null, 'a malformed cull point falls back to the render position');
|
id: 'b',
|
||||||
const notAnObject = normalizeOverlayEntry('firms', { id: 'b2', position, cullPosition: 42 });
|
position,
|
||||||
assert.equal(notAnObject.cullPosition, null, 'a non-object cull point is rejected');
|
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
|
// Non-adopting sources keep their existing BEHAVIOUR and steady-frame
|
||||||
// allocation profile; the normalized record does gain an enumerable
|
// allocation profile; the normalized record does gain an enumerable
|
||||||
// `cullPosition: null` field, which is why this asserts null rather than
|
// `cullPosition: null` field, which is why this asserts null rather than
|
||||||
// absence.
|
// absence.
|
||||||
const absent = normalizeOverlayEntry('firms', { id: 'c', position });
|
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', () => {
|
test('normalizeOverlayEntry: the stored cull anchor is a snapshot, not the caller object', () => {
|
||||||
const position = { x: 1, y: 2, z: 3 };
|
const position = { x: 1, y: 2, z: 3 };
|
||||||
const caller = new Cesium.Cartesian3(4, 5, 6);
|
const caller = new Cesium.Cartesian3(4, 5, 6);
|
||||||
const normalized = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: caller });
|
const normalized = normalizeOverlayEntry('firms', {
|
||||||
assert.notEqual(normalized.cullPosition, caller, 'the host must not retain the caller reference');
|
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
|
// Sources legitimately recycle scratch vectors between publishes; a mutation
|
||||||
// after normalization must never reach the per-frame occluder.
|
// after normalization must never reach the per-frame occluder.
|
||||||
caller.x = NaN;
|
caller.x = NaN;
|
||||||
caller.y = 999;
|
caller.y = 999;
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
|
[
|
||||||
|
normalized.cullPosition.x,
|
||||||
|
normalized.cullPosition.y,
|
||||||
|
normalized.cullPosition.z,
|
||||||
|
],
|
||||||
[4, 5, 6],
|
[4, 5, 6],
|
||||||
'post-normalize mutation of the source vector has no effect',
|
'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);
|
const normalized = normalizeOverlayEntry('firms', entry);
|
||||||
assert.equal(reads, 1, 'one property read — not one per validated component');
|
assert.equal(reads, 1, 'one property read — not one per validated component');
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
|
[
|
||||||
|
normalized.cullPosition.x,
|
||||||
|
normalized.cullPosition.y,
|
||||||
|
normalized.cullPosition.z,
|
||||||
|
],
|
||||||
[4, 5, 6],
|
[4, 5, 6],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -288,11 +479,21 @@ test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort nor
|
|||||||
id: 'a',
|
id: 'a',
|
||||||
position,
|
position,
|
||||||
title: 'still normalized',
|
title: 'still normalized',
|
||||||
get cullPosition() { throw new Error('hostile accessor'); },
|
get cullPosition() {
|
||||||
|
throw new Error('hostile accessor');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const normalized = normalizeOverlayEntry('firms', entry);
|
const normalized = normalizeOverlayEntry('firms', entry);
|
||||||
assert.equal(normalized.cullPosition, null, 'the bad anchor degrades to null');
|
assert.equal(
|
||||||
assert.equal(normalized.title, 'still normalized', 'the rest of the entry survives');
|
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');
|
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
|
// 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. */
|
/** Canvas stub good enough for the pre-baked glow sprites. */
|
||||||
function canvasStub() {
|
function canvasStub() {
|
||||||
return {
|
return {
|
||||||
@@ -337,9 +532,10 @@ function rawFire(lat, lon, frp) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boots the REAL layer against a headless viewer stub with a REAL
|
* Boots the REAL layer on the Cesium engine over a stub scene with a REAL
|
||||||
* Cesium.BillboardCollection, so the production hooks (not just the pure
|
* Cesium.Camera and the engine's REAL Cesium.BillboardCollection, so the
|
||||||
* predicate) are what the assertions exercise.
|
* production hooks (not just the pure predicate) are what the assertions
|
||||||
|
* exercise.
|
||||||
*/
|
*/
|
||||||
function createHarness(rawFires) {
|
function createHarness(rawFires) {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -357,9 +553,14 @@ function createHarness(rawFires) {
|
|||||||
|
|
||||||
// Deterministic projection: every distinct world position gets its own
|
// Deterministic projection: every distinct world position gets its own
|
||||||
// 200 px grid slot (well clear of LABEL_MIN_SEP_PX) inside a 2000×2000
|
// 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();
|
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);
|
if (!slots.has(position)) slots.set(position, slots.size);
|
||||||
const slot = slots.get(position);
|
const slot = slots.get(position);
|
||||||
const out = result || new Cesium.Cartesian2();
|
const out = result || new Cesium.Cartesian2();
|
||||||
@@ -368,30 +569,21 @@ function createHarness(rawFires) {
|
|||||||
return out;
|
return out;
|
||||||
};
|
};
|
||||||
|
|
||||||
const preRender = new MockEvent();
|
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
|
||||||
const moveEnd = new MockEvent();
|
engineOptions: { createEventHandler: createFakeEventHandlers().create },
|
||||||
const primitives = [];
|
});
|
||||||
|
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 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({
|
const layer = createFirmsHeatmapLayer({
|
||||||
id: 'firms',
|
id: 'firms',
|
||||||
@@ -401,36 +593,58 @@ function createHarness(rawFires) {
|
|||||||
setVisible: () => {},
|
setVisible: () => {},
|
||||||
clearSource: () => {},
|
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 {
|
return {
|
||||||
layer,
|
layer,
|
||||||
viewer,
|
engine,
|
||||||
preRender,
|
/** Attaches the layer to the engine and enables it; markers drawn. */
|
||||||
moveEnd,
|
async start() {
|
||||||
/** The real BillboardCollection the layer created. */
|
layer.init(null, { engine });
|
||||||
billboards: () => primitives[0],
|
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. */
|
/** Latest entry list published to the overlay host. */
|
||||||
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
|
||||||
/** `show` flags keyed by billboard pick id. */
|
/** `show` flags of the drawn markers, keyed by billboard pick id. */
|
||||||
showById() {
|
showById() {
|
||||||
const bb = primitives[0];
|
const bb = primitive('firms:firms');
|
||||||
const out = new Map();
|
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;
|
return out;
|
||||||
},
|
},
|
||||||
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
|
moveCameraTo,
|
||||||
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, height);
|
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
|
||||||
viewer.camera.positionCartographic = { height };
|
preRender: () => frame(),
|
||||||
},
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
unregisterSpriteCollection('firms');
|
engine.destroy();
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = originalProject;
|
Cesium.SceneTransforms.worldToWindowCoordinates = originalProject;
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow;
|
if (originalWindow === undefined) delete globalThis.window;
|
||||||
if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument;
|
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),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
|
||||||
// No camera event has fired — only renderDetections ran.
|
// No camera event has fired — only renderDetections ran.
|
||||||
const show = harness.showById();
|
const show = harness.showById();
|
||||||
assert.equal(show.size, 3, 'all three detections were rendered');
|
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(
|
||||||
assert.equal(show.get('firms-1'), false, 'the antipodal fire is culled at build time');
|
show.get('firms-0'),
|
||||||
assert.equal(show.get('firms-2'), false, 'the Tokyo fire is culled at build time');
|
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 {
|
} finally {
|
||||||
harness.cleanup();
|
harness.cleanup();
|
||||||
}
|
}
|
||||||
@@ -461,15 +686,23 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
|
|||||||
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
assert.equal(
|
||||||
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
|
harness.showById().get('firms-1'),
|
||||||
|
false,
|
||||||
|
'precondition: Tokyo hidden from Austin',
|
||||||
|
);
|
||||||
|
|
||||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||||
harness.moveEnd.emit();
|
harness.moveEnd();
|
||||||
|
await harness.settle();
|
||||||
|
|
||||||
const show = harness.showById();
|
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');
|
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
|
||||||
} finally {
|
} finally {
|
||||||
harness.cleanup();
|
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),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
|
||||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
||||||
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
|
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
|
||||||
// performance.now(), whose value at this point depends on process
|
// performance.now(), whose value at this point depends on process
|
||||||
// start-up — so emit, wait out the full throttle window, and emit again.
|
// 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.
|
// Whichever tick passes the gate, one of them must run the pass.
|
||||||
harness.preRender.emit();
|
harness.preRender();
|
||||||
await new Promise((resolve) => { setTimeout(resolve, 700); });
|
await new Promise((resolve) => {
|
||||||
harness.preRender.emit();
|
setTimeout(resolve, 700);
|
||||||
|
});
|
||||||
|
harness.preRender();
|
||||||
|
await harness.settle();
|
||||||
|
|
||||||
const show = harness.showById();
|
const show = harness.showById();
|
||||||
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass');
|
assert.equal(
|
||||||
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
|
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 {
|
} finally {
|
||||||
harness.cleanup();
|
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),
|
rawFire(TOKYO.lat, TOKYO.lon, 800),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
assert.equal(
|
||||||
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
|
harness.showById().get('firms-1'),
|
||||||
|
false,
|
||||||
|
'precondition: Tokyo hidden from Austin',
|
||||||
|
);
|
||||||
|
|
||||||
harness.layer.disable();
|
harness.layer.disable();
|
||||||
// The camera moves while the layer is OFF: moveEnd is not being listened
|
// 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.
|
// to, and the preRender watcher is inert. Nothing re-culls on its own.
|
||||||
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
|
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
|
// Assert with NO camera event fired and NO refetch (fires are already
|
||||||
// loaded, so enable() does not reload): the enable path itself must have
|
// loaded, so enable() does not reload): the enable path itself must have
|
||||||
// re-culled before the collection went visible.
|
// re-culled before the collection went visible.
|
||||||
const show = harness.showById();
|
const show = harness.showById();
|
||||||
assert.equal(harness.billboards().show, true, 'the collection is visible again');
|
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(
|
||||||
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
|
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 {
|
} finally {
|
||||||
harness.cleanup();
|
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),
|
rawFire(lat, lon, 90),
|
||||||
]);
|
]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
|
||||||
|
|
||||||
const bb = harness.billboards();
|
const bb = harness.billboards();
|
||||||
const sunken = bb.get(1);
|
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,
|
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
|
||||||
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
|
`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(
|
||||||
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
|
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 {
|
} finally {
|
||||||
harness.cleanup();
|
harness.cleanup();
|
||||||
_clearMeshFloorCellsForTest();
|
_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 () => {
|
test('layer: far-side candidates never displace near-side ambient cards', async () => {
|
||||||
const near = [];
|
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 = [];
|
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
|
// 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.
|
// walk and would eat all 18 ambient slots without the horizon skip.
|
||||||
const harness = createHarness([...near, ...far]);
|
const harness = createHarness([...near, ...far]);
|
||||||
try {
|
try {
|
||||||
harness.layer.init(harness.viewer);
|
await harness.start();
|
||||||
await harness.layer.enable(harness.viewer);
|
|
||||||
|
|
||||||
const ids = harness.latestEntries().map((entry) => entry.id);
|
const ids = harness.latestEntries().map((entry) => entry.id);
|
||||||
assert.ok(ids.length > 0, 'cards were published');
|
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
|
// 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.
|
// 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 nearCoords = new Set(
|
||||||
const nearIds = ids.filter((id) => nearCoords.has(id.split(':').slice(2, 4).join(':')));
|
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(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(ids.length, 6, 'no slot is spent on a fire behind the planet');
|
||||||
assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms');
|
assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
// FIRMS click routes: sprite AND card must both select for detail and hand the
|
// 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
|
// camera over (pre-launch defect #4 + P1-3). Driven through the production
|
||||||
// click handler and the production card-render path; only the viewer, the
|
// click handler and the production card-render path, on the Cesium engine
|
||||||
// event handler, the overlay host and the world→window projection are stubbed.
|
// 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 { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import * as Cesium from 'cesium';
|
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 { fireDetectionKey } from './firmsLabels.js';
|
||||||
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
|
||||||
import { getSelectedEntityContext } from './contextStore.js';
|
import { getSelectedEntityContext } from './contextStore.js';
|
||||||
@@ -43,8 +51,11 @@ function makeFire(overrides = {}) {
|
|||||||
* actionable card presents.
|
* actionable card presents.
|
||||||
*/
|
*/
|
||||||
function harness({
|
function harness({
|
||||||
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
|
fires = [makeFire()],
|
||||||
withDataSource = false, feed = null,
|
picked = null,
|
||||||
|
cardHit = null,
|
||||||
|
cameraPosition = null,
|
||||||
|
feed = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const hadWindow = Object.hasOwn(globalThis, 'window');
|
const hadWindow = Object.hasOwn(globalThis, 'window');
|
||||||
const priorWindow = globalThis.window;
|
const priorWindow = globalThis.window;
|
||||||
@@ -52,15 +63,29 @@ function harness({
|
|||||||
const windowTarget = new EventTarget();
|
const windowTarget = new EventTarget();
|
||||||
globalThis.window = windowTarget;
|
globalThis.window = windowTarget;
|
||||||
// Spread projections so the greedy screen-separation declutter accepts every
|
// 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;
|
let projected = 0;
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = () => {
|
Cesium.SceneTransforms.worldToWindowCoordinates = () => {
|
||||||
projected += 1;
|
projected += 1;
|
||||||
return { x: 200 * projected, y: 300 };
|
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 = [];
|
const published = [];
|
||||||
let handler = null;
|
|
||||||
const layer = createFirmsHeatmapLayer({
|
const layer = createFirmsHeatmapLayer({
|
||||||
...(feed ? { feed } : {}),
|
...(feed ? { feed } : {}),
|
||||||
id: 'firms-test',
|
id: 'firms-test',
|
||||||
@@ -71,41 +96,48 @@ function harness({
|
|||||||
clearSource() {},
|
clearSource() {},
|
||||||
hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null),
|
hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null),
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: () => {
|
// The display the registry gives every engine, with image seams.
|
||||||
handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} };
|
renderer: {
|
||||||
return handler;
|
create: ({ engine: target, ...options }) =>
|
||||||
|
createFirmsDisplay(target, {
|
||||||
|
...options,
|
||||||
|
loadImage: async () => ({ width: 16, height: 16 }),
|
||||||
|
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
layer._bindInteractionForTest(engine, fires);
|
||||||
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);
|
|
||||||
|
|
||||||
const requests = [];
|
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 {
|
return {
|
||||||
layer,
|
layer,
|
||||||
|
engine,
|
||||||
viewer,
|
viewer,
|
||||||
|
frame,
|
||||||
|
/** The glow billboards drawn (the engine's sprite set), or null. */
|
||||||
|
markers: () => primitive('firms:firms-test') ?? null,
|
||||||
windowTarget,
|
windowTarget,
|
||||||
requests,
|
requests,
|
||||||
published,
|
published,
|
||||||
fires,
|
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() {
|
cleanup() {
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
|
engine.destroy();
|
||||||
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
|
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
|
||||||
if (hadWindow) globalThis.window = priorWindow;
|
if (hadWindow) globalThis.window = priorWindow;
|
||||||
else delete globalThis.window;
|
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', () => {
|
test('fire detection key survives a refetch that renumbers the index', () => {
|
||||||
const fire = makeFire();
|
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.
|
// Different pass over the same pixel is a different detection.
|
||||||
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }));
|
assert.notEqual(
|
||||||
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, lat: 31.0 }));
|
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$/);
|
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(base), fireDetectionKey(suomi));
|
||||||
assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi));
|
assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi));
|
||||||
// Satellite naming variants normalize to one identity, not three.
|
// Satellite naming variants normalize to one identity, not three.
|
||||||
assert.equal(fireDetectionKey(base), fireDetectionKey(makeFire({ satellite: 'NOAA-20' })));
|
assert.equal(
|
||||||
assert.equal(fireDetectionKey(suomi), fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })));
|
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.
|
// A record with no satellite falls back to its sensor, not to a shared blank.
|
||||||
const sensorOnly = makeFire({ satellite: '', sensor: 'MODIS' });
|
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', () => {
|
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)}`,
|
cardHit: `fire:${fireDetectionKey(n21)}`,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const cardIds = h.published.filter((e) => e.id.startsWith('fire:')).map((e) => e.id);
|
const cardIds = h.published
|
||||||
assert.equal(new Set(cardIds).size, 2, 'co-located detections must not collapse to one card');
|
.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();
|
h.click();
|
||||||
assert.equal(h.requests.length, 1);
|
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 {
|
} finally {
|
||||||
h.cleanup();
|
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
|
// detections from different satellites — the selection could come back on
|
||||||
// the wrong record. Reachable only through the 30-minute refetch, so this is
|
// the wrong record. Reachable only through the 30-minute refetch, so this is
|
||||||
// a source contract; the key's own behavior is proven above.
|
// 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.ok(match, 'findMatchingFire is missing');
|
||||||
assert.match(match[1], /fireDetectionKey\(previous\)/);
|
assert.match(match[1], /fireDetectionKey\(previous\)/);
|
||||||
assert.match(match[1], /fireDetectionKey\(fire\) === key/);
|
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:'));
|
const card = h.published.find((entry) => entry.id.startsWith('fire:'));
|
||||||
assert.ok(card, 'a detection card was painted');
|
assert.ok(card, 'a detection card was painted');
|
||||||
assert.equal(card.id, `fire:${fireDetectionKey(h.fires[0])}`);
|
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.match(card.accessibilityLabel, /^Focus fire detection /);
|
||||||
assert.equal(card.activate(), true);
|
assert.equal(card.activate(), true);
|
||||||
assert.equal(h.requests.length, 1);
|
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', () => {
|
test('an aggregated cell card is never actionable — it is not one fire', () => {
|
||||||
const cell = buildCellCard(
|
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(),
|
Date.now(),
|
||||||
);
|
);
|
||||||
assert.equal(applyFirmsOverlayPolicy(cell, 1e6).interactive, false);
|
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', () => {
|
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 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 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 });
|
const h = harness({ fires: [rear, front], cameraPosition });
|
||||||
try {
|
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)}`]);
|
assert.deepEqual(cardIds, [`fire:${fireDetectionKey(front)}`]);
|
||||||
} finally {
|
} finally {
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
@@ -278,7 +364,11 @@ test('a click on neither sprite nor card clears without moving the camera', () =
|
|||||||
const SIBLING_PICKS = [
|
const SIBLING_PICKS = [
|
||||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||||
['flights', 'a1b2c3', { primitive: { 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' } }],
|
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||||
['satellites', '25544', { id: 25544 }],
|
['satellites', '25544', { id: 25544 }],
|
||||||
];
|
];
|
||||||
@@ -294,7 +384,11 @@ for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
h.click();
|
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 {
|
} finally {
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
unregisterPickOwner(layerId);
|
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.
|
// record first and the clear then fails its ownership guard silently.
|
||||||
const kept = makeFire({ index: 0 });
|
const kept = makeFire({ index: 0 });
|
||||||
const dropped = makeFire({
|
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({
|
const h = harness({
|
||||||
fires: [dropped, kept],
|
fires: [dropped, kept],
|
||||||
cardHit: `fire:${fireDetectionKey(dropped)}`,
|
cardHit: `fire:${fireDetectionKey(dropped)}`,
|
||||||
withDataSource: true,
|
|
||||||
});
|
});
|
||||||
const cleared = [];
|
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;
|
const priorFetch = globalThis.fetch;
|
||||||
// The far band keeps the markers built under the heatmap for the hand-off
|
// The far band keeps the markers built under the heatmap for the hand-off
|
||||||
// fade; their glow sprites need a canvas.
|
// 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 {
|
try {
|
||||||
h.click();
|
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.equal(h.requests[0].id, fireDetectionKey(dropped));
|
||||||
assert.deepEqual(cleared, [], 'selecting must not clear anything yet');
|
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 () => ({
|
json: async () => ({
|
||||||
fetchedAt: Date.now(),
|
fetchedAt: Date.now(),
|
||||||
stale: false,
|
stale: false,
|
||||||
fires: [{
|
fires: [
|
||||||
lat: kept.lat,
|
{
|
||||||
lon: kept.lon,
|
lat: kept.lat,
|
||||||
frp: kept.frp,
|
lon: kept.lon,
|
||||||
confidence: 'h',
|
frp: kept.frp,
|
||||||
brightness: 330,
|
confidence: 'h',
|
||||||
daynight: 'D',
|
brightness: 330,
|
||||||
acqDate: '2025-07-27',
|
daynight: 'D',
|
||||||
acqTime: '0412',
|
acqDate: '2025-07-27',
|
||||||
instrument: 'VIIRS',
|
acqTime: '0412',
|
||||||
satellite: kept.satellite,
|
instrument: 'VIIRS',
|
||||||
}],
|
satellite: kept.satellite,
|
||||||
|
},
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
await h.layer.update();
|
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 () => {
|
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
|
||||||
let resolve, signal;
|
let resolve, signal;
|
||||||
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
|
const h = harness({
|
||||||
signal = options.signal; return new Promise(done => { resolve = done; });
|
feed: {
|
||||||
} } });
|
getSnapshot(options) {
|
||||||
|
signal = options.signal;
|
||||||
|
return new Promise((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const before = h.layer.getStats();
|
const before = h.layer.getStats();
|
||||||
const pending = h.layer.update();
|
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().count, before.count);
|
||||||
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
|
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
|
||||||
assert.equal(h.layer.getStats().loading, false);
|
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'] });
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
const priorDocument = globalThis.document;
|
const priorDocument = globalThis.document;
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
createElement: () => ({
|
createElement: () => ({
|
||||||
getContext: () => ({
|
getContext: () => ({
|
||||||
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
|
createImageData: (w, hh) => ({
|
||||||
|
data: new Uint8ClampedArray(w * hh * 4),
|
||||||
|
}),
|
||||||
putImageData() {},
|
putImageData() {},
|
||||||
createRadialGradient: () => ({ addColorStop() {} }),
|
createRadialGradient: () => ({ addColorStop() {} }),
|
||||||
fillRect() {},
|
fillRect() {},
|
||||||
@@ -425,12 +541,8 @@ test('the heatmap is preloaded under the markers before the first zoom-out', (t)
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
const h = harness({
|
const h = harness({
|
||||||
withDataSource: true,
|
|
||||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
|
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 = [];
|
const added = [];
|
||||||
try {
|
try {
|
||||||
h.layer.attachShellServices({
|
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].alpha, 0, 'invisible until the hand-off');
|
||||||
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
|
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
|
||||||
} finally {
|
} finally {
|
||||||
h.layer.destroy(h.viewer);
|
|
||||||
h.cleanup();
|
h.cleanup();
|
||||||
if (priorDocument === undefined) delete globalThis.document;
|
if (priorDocument === undefined) delete globalThis.document;
|
||||||
else globalThis.document = priorDocument;
|
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.
|
// A canvas stub for the heat drape and the detection glow sprites.
|
||||||
const priorDocument = globalThis.document;
|
const priorDocument = globalThis.document;
|
||||||
const restoreDocument = () => {
|
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({
|
const h = harness({
|
||||||
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
|
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
|
||||||
withDataSource: true,
|
|
||||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
|
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
|
||||||
});
|
});
|
||||||
const added = [];
|
const added = [];
|
||||||
const removed = [];
|
const removed = [];
|
||||||
let markers = null;
|
// The markers as drawn: how many the sprite set draws, and their alphas.
|
||||||
// The fade reads the camera height; the cull reads scene.camera.
|
const drawn = () => {
|
||||||
const setHeight = (height) => {
|
const count =
|
||||||
h.viewer.camera = { positionCartographic: { height } };
|
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 {
|
try {
|
||||||
const collection = {
|
const collection = {
|
||||||
addImageryProvider: (provider) => {
|
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 }),
|
imageryHost: () => ({ kind: 'globe', collection }),
|
||||||
});
|
});
|
||||||
h.layer._renderLodForTest('global');
|
h.layer._renderLodForTest('global');
|
||||||
|
await settle();
|
||||||
assert.equal(added.length, 1, 'one heat imagery layer');
|
assert.equal(added.length, 1, 'one heat imagery layer');
|
||||||
assert.equal(added[0].show, true);
|
assert.equal(added[0].show, true);
|
||||||
assert.equal(added[0].alpha, 1);
|
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.ok(h.published.length > 0, 'the far band still paints cards');
|
||||||
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
|
assert.deepEqual(drawn(), [], 'no marker drawn while the heatmap is whole');
|
||||||
assert.equal(markers.show, false, 'and hidden 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');
|
h.layer._renderLodForTest('regional');
|
||||||
assert.equal(added.length, 1, 'switching far bands reuses the raster');
|
assert.equal(added.length, 1, 'switching far bands reuses the raster');
|
||||||
|
|
||||||
setHeight(1_000_000);
|
h.setHeight(1_000_000);
|
||||||
h.layer._renderLodForTest('close');
|
h.layer._renderLodForTest('close');
|
||||||
|
await settle();
|
||||||
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
|
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].show, true, 'still mapped onto the globe tiles');
|
||||||
assert.equal(added[0].alpha, 0, 'faded out under the markers');
|
assert.equal(added[0].alpha, 0, 'faded out under the markers');
|
||||||
assert.equal(markers.show, true);
|
assert.deepEqual(drawn(), [1, 1]);
|
||||||
assert.equal(markers.get(0).color.alpha, 1);
|
|
||||||
|
|
||||||
// Mid hand-off both are drawn, half each, in either band.
|
// Mid hand-off both are drawn, half each, in either band.
|
||||||
setHeight(4_500_000);
|
h.setHeight(4_500_000);
|
||||||
h.layer._applyHandoffFadeForTest();
|
h.layer._applyHandoffFadeForTest();
|
||||||
|
await settle();
|
||||||
assert.equal(added[0].alpha, 0.5);
|
assert.equal(added[0].alpha, 0.5);
|
||||||
assert.equal(markers.show, true);
|
assert.deepEqual(drawn(), [0.5, 0.5]);
|
||||||
assert.equal(markers.get(0).color.alpha, 0.5);
|
|
||||||
h.layer._renderLodForTest('regional');
|
h.layer._renderLodForTest('regional');
|
||||||
|
await settle();
|
||||||
assert.equal(added[0].alpha, 0.5);
|
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();
|
h.layer.disable();
|
||||||
assert.equal(added[0].show, false, 'disable hides the heatmap');
|
assert.equal(added[0].show, false, 'disable hides the heatmap');
|
||||||
|
|
||||||
h.layer.destroy(h.viewer);
|
h.layer.destroy();
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
removed.map((entry) => [entry.layer, entry.destroy]),
|
removed.map((entry) => [entry.layer, entry.destroy]),
|
||||||
[[added[0], true]],
|
[[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.
|
// ALPR and bikeshare take the engine's click with its pick.
|
||||||
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
|
||||||
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
|
['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',
|
'src/layers/installations/render/cesium/selection.js',
|
||||||
'viewer.scene.pick(click.position)',
|
'viewer.scene.pick(click.position)',
|
||||||
@@ -136,8 +137,8 @@ test('every scene click handler consults ownership before it picks', () => {
|
|||||||
'getRenderer().isTrackedPick(picked',
|
'getRenderer().isTrackedPick(picked',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'src/layers/submarineCables/interaction.js',
|
'src/layers/submarineCables/render/display.js',
|
||||||
'viewer.scene.pick(click.position)',
|
'const picked = event.picked;',
|
||||||
],
|
],
|
||||||
// Vessels take the engine dispatcher's pick: the guard
|
// Vessels take the engine dispatcher's pick: the guard
|
||||||
// comes before the click rule reads it.
|
// comes before the click rule reads it.
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ function makePrimitives(initial = []) {
|
|||||||
return {
|
return {
|
||||||
items: [...initial],
|
items: [...initial],
|
||||||
calls: [],
|
calls: [],
|
||||||
contains(collection) { return this.items.includes(collection); },
|
contains(collection) {
|
||||||
|
return this.items.includes(collection);
|
||||||
|
},
|
||||||
raiseToTop(collection) {
|
raiseToTop(collection) {
|
||||||
this.calls.push(collection.id);
|
this.calls.push(collection.id);
|
||||||
const index = this.items.indexOf(collection);
|
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', () => {
|
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);
|
const destroyedFirms = makeCollection('firms', true);
|
||||||
for (const id of ORDER) {
|
for (const id of ORDER) {
|
||||||
registerSpriteCollection(id, id === 'firms' ? destroyedFirms : collections[id]);
|
registerSpriteCollection(
|
||||||
|
id,
|
||||||
|
id === 'firms' ? destroyedFirms : collections[id],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const primitives = makePrimitives([
|
const primitives = makePrimitives([
|
||||||
collections.flights,
|
collections.flights,
|
||||||
@@ -46,10 +53,17 @@ test('restoreSpriteOrder raises live collections bottom-to-top and skips destroy
|
|||||||
|
|
||||||
restoreSpriteOrder({ scene: { primitives } });
|
restoreSpriteOrder({ scene: { primitives } });
|
||||||
|
|
||||||
assert.deepEqual(primitives.calls, ['cctv', 'bikeshare', 'ais', 'military', 'flights']);
|
assert.deepEqual(primitives.calls, [
|
||||||
assert.deepEqual(primitives.items.map((item) => item.id), [
|
'cctv',
|
||||||
'cctv', 'bikeshare', 'ais', 'military', 'flights',
|
'bikeshare',
|
||||||
|
'ais',
|
||||||
|
'military',
|
||||||
|
'flights',
|
||||||
]);
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
primitives.items.map((item) => item.id),
|
||||||
|
['cctv', 'bikeshare', 'ais', 'military', 'flights'],
|
||||||
|
);
|
||||||
|
|
||||||
for (const id of ORDER) unregisterSpriteCollection(id);
|
for (const id of ORDER) unregisterSpriteCollection(id);
|
||||||
});
|
});
|
||||||
@@ -69,7 +83,10 @@ test('late CCTV registration still restores flights above the ambient collection
|
|||||||
restoreSpriteOrder(viewer);
|
restoreSpriteOrder(viewer);
|
||||||
|
|
||||||
assert.deepEqual(primitives.calls, ['cctv', 'flights']);
|
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('cctv', cctv);
|
||||||
unregisterSpriteCollection('flights', flights);
|
unregisterSpriteCollection('flights', flights);
|
||||||
@@ -81,7 +98,9 @@ test('restoreSpriteOrder is inert for destroyed viewers and primitive collection
|
|||||||
registerSpriteCollection('flights', flights);
|
registerSpriteCollection('flights', flights);
|
||||||
|
|
||||||
restoreSpriteOrder({ isDestroyed: () => true, scene: { primitives } });
|
restoreSpriteOrder({ isDestroyed: () => true, scene: { primitives } });
|
||||||
restoreSpriteOrder({ scene: { primitives: { ...primitives, isDestroyed: () => true } } });
|
restoreSpriteOrder({
|
||||||
|
scene: { primitives: { ...primitives, isDestroyed: () => true } },
|
||||||
|
});
|
||||||
|
|
||||||
assert.deepEqual(primitives.calls, []);
|
assert.deepEqual(primitives.calls, []);
|
||||||
unregisterSpriteCollection('flights', flights);
|
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
|
// 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; the display's sprite sets take the flights' slot as their draw
|
||||||
// order on every engine (aircraftLayerDisplay).
|
// order on every engine (aircraftLayerDisplay).
|
||||||
assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/);
|
|
||||||
assert.match(
|
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\)/,
|
/order: SPRITE_LAYER_ORDER\.indexOf\(layerId\)/,
|
||||||
);
|
);
|
||||||
assert.match(
|
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',/,
|
/aircraftLayerDisplay\(\s*'flights',/,
|
||||||
);
|
);
|
||||||
assert.match(
|
// FIRMS draws through its display: its glow sprite set takes the 'firms'
|
||||||
firmsLayer.enable.toString(),
|
// slot as its draw order on every engine.
|
||||||
/renderer\.onEnable\(\)/,
|
assert.doesNotMatch(firmsLayer.enable.toString(), /onEnable/);
|
||||||
);
|
|
||||||
assert.match(
|
assert.match(
|
||||||
readFileSync(
|
readFileSync(
|
||||||
new URL('../layers/firms/render/cesium.js', import.meta.url),
|
new URL('../layers/firms/render/sprites.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
),
|
),
|
||||||
/restoreSpriteOrderOnEnable\('firms', viewer\)/,
|
/FIRMS_SPRITE_ORDER = SPRITE_LAYER_ORDER\.indexOf\('firms'\)[\s\S]*order: FIRMS_SPRITE_ORDER/,
|
||||||
);
|
|
||||||
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',
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -22,9 +22,8 @@ export {
|
|||||||
selectCableReferenceLabelWinners,
|
selectCableReferenceLabelWinners,
|
||||||
cableReferencePriority,
|
cableReferencePriority,
|
||||||
createCableOverlayEntry,
|
createCableOverlayEntry,
|
||||||
cableClassificationTypeForStack,
|
cableDrapeForStack,
|
||||||
cableClassificationTypeForScene,
|
cableDrapeForEngine,
|
||||||
applyTranslucentMarkerBlend,
|
|
||||||
createCableReferenceSweepGate,
|
createCableReferenceSweepGate,
|
||||||
updateCableReferenceStem,
|
updateCableReferenceTip,
|
||||||
} from '../layers/submarineCables/index.js';
|
} 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/recentImagery/index.js', 'Imagery catalog unavailable'],
|
||||||
['../layers/installations/ingestion.js', 'Installation context unavailable'],
|
['../layers/installations/ingestion.js', 'Installation context unavailable'],
|
||||||
['../layers/earthquakes/index.js', 'Earthquake source 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/ingestion.js', 'AIS live load failed'],
|
||||||
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
|
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
|
||||||
['../layers/wind/index.js', 'Wind unavailable'],
|
['../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 { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
|
||||||
import { LayerPanel } from '../../ui/layerPanel.js';
|
import { LayerPanel } from '../../ui/layerPanel.js';
|
||||||
import { translateFeedError } from '../../ui/statusText.js';
|
import { translateFeedError } from '../../ui/statusText.js';
|
||||||
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
import { createAirQualityLayer } from './index.js';
|
import { createAirQualityLayer } from './index.js';
|
||||||
|
|
||||||
function inLocale(locale, run) {
|
function inLocale(locale, run) {
|
||||||
@@ -22,9 +23,8 @@ function harness(source) {
|
|||||||
source,
|
source,
|
||||||
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
|
||||||
});
|
});
|
||||||
const viewer = { dataSources: { add() {}, remove() {} } };
|
layer.init(null, { engine: createFakeEngine() });
|
||||||
layer.init(viewer);
|
layer.enable();
|
||||||
layer.enable(viewer);
|
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { fromDegrees } from '../../geo/cartesian3.js';
|
import { fromDegrees } from '../../geo/cartesian3.js';
|
||||||
import { toDegrees } from '../../geo/math.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 { msg, t, tn } from '../../i18n/index.js';
|
||||||
import { translateFeedError } from '../../ui/statusText.js';
|
import { translateFeedError } from '../../ui/statusText.js';
|
||||||
import {
|
import {
|
||||||
@@ -28,7 +28,7 @@ const READOUT_LIMIT = 5;
|
|||||||
export function createAirQualityLayer({
|
export function createAirQualityLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost,
|
overlayHost,
|
||||||
createRenderer = createAirQualityCesiumRenderer,
|
createRenderer = ({ engine }) => createAirQualityDisplay(engine),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Air quality requires a snapshot source');
|
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');
|
throw new Error('Air quality layer is already initialized');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
try {
|
||||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
if (engine) this.attachRenderer(viewer, { engine });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -79,7 +79,7 @@ export function createAirQualityLayer({
|
|||||||
attachRenderer(viewer, { engine = null } = {}) {
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
if (!initialized || renderer)
|
if (!initialized || renderer)
|
||||||
throw new Error('Invalid air quality scene attachment');
|
throw new Error('Invalid air quality scene attachment');
|
||||||
renderer = createRenderer({ viewer, engine });
|
renderer = createRenderer({ engine });
|
||||||
renderer.setVisible(_enabled);
|
renderer.setVisible(_enabled);
|
||||||
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled);
|
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled);
|
||||||
if (_readings.length) this._render();
|
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() {
|
_render() {
|
||||||
if (!renderer) return;
|
if (!renderer) return;
|
||||||
renderer.replace(_readings, STATION_MARKER_RADIUS_M);
|
renderer.replace(_readings, STATION_MARKER_RADIUS_M);
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||||
import { createAirQualityLayer } from './index.js';
|
import { createAirQualityLayer } from './index.js';
|
||||||
import { layerFeedState } from '../../data/feedState.js';
|
import { layerFeedState } from '../../data/feedState.js';
|
||||||
import { STALE_AFTER_MS } from './model.js';
|
import { STALE_AFTER_MS } from './model.js';
|
||||||
|
|
||||||
function harness(source) {
|
function harness(source) {
|
||||||
const sources = [];
|
|
||||||
const events = [];
|
const events = [];
|
||||||
const viewer = {
|
const ground = createFakeGroundHarness();
|
||||||
dataSources: {
|
const { engine } = ground;
|
||||||
add(value) {
|
|
||||||
sources.push(value);
|
|
||||||
},
|
|
||||||
remove(value) {
|
|
||||||
sources.splice(sources.indexOf(value), 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createAirQualityLayer({
|
const layer = createAirQualityLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -27,9 +19,9 @@ function harness(source) {
|
|||||||
clearSource() {},
|
clearSource() {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(null, { engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
return { layer, viewer, sources, events };
|
return { layer, engine, ground, events };
|
||||||
}
|
}
|
||||||
|
|
||||||
const reading = (id, value, overrides = {}) => ({
|
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', () => {
|
test('initialization is single-use and owns exactly one ground set', () => {
|
||||||
const { layer, viewer, sources } = harness(staticSource([]));
|
const { layer, engine, ground } = harness(staticSource([]));
|
||||||
assert.equal(sources.length, 1);
|
assert.equal(ground.sets(), 1);
|
||||||
assert.throws(() => layer.init(viewer), /already initialized/);
|
assert.throws(() => layer.init(null, { engine }), /already initialized/);
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
assert.equal(sources.length, 0);
|
assert.equal(ground.sets(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a refresh renders readings and publishes an overlay cohort', async () => {
|
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 () => {
|
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;
|
let calls = 0;
|
||||||
layer.setRowControlsListener(() => {
|
layer.setRowControlsListener(() => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
});
|
});
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.ok(calls >= 1, 'a refresh notifies the card');
|
assert.ok(calls >= 1, 'a refresh notifies the card');
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
const after = calls;
|
const after = calls;
|
||||||
layer.setRowControlsListener(() => {
|
layer.setRowControlsListener(() => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
@@ -284,13 +276,13 @@ test('station data stays queryable and attaches to a fresh scene without polling
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
h.layer.detachRenderer();
|
h.layer.detachRenderer();
|
||||||
assert.equal(h.sources.length, 0);
|
assert.equal(h.ground.sets(), 0);
|
||||||
assert.equal(await h.layer.update(), true);
|
assert.equal(await h.layer.update(), true);
|
||||||
assert.equal(h.events.length, 0);
|
assert.equal(h.events.length, 0);
|
||||||
assert.equal(h.layer.getAnalystRecords()[0].value, 7);
|
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.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);
|
assert.equal(polls, 1);
|
||||||
} finally {
|
} finally {
|
||||||
h.layer.destroy();
|
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 assert from 'node:assert/strict';
|
||||||
import { createCyclonesLayer } from './index.js';
|
import { createCyclonesLayer } from './index.js';
|
||||||
import { setLocale, t } from '../../i18n/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).
|
// The exact string the server sends (COVERAGE in server/providers/cyclones.js).
|
||||||
const SERVER_COVERAGE =
|
const SERVER_COVERAGE =
|
||||||
@@ -52,7 +53,7 @@ function harness(feed) {
|
|||||||
matchMedia: () => ({ matches: false }),
|
matchMedia: () => ({ matches: false }),
|
||||||
openLink: () => {},
|
openLink: () => {},
|
||||||
});
|
});
|
||||||
layer.init({ camera: { flyToBoundingSphere() {} } });
|
layer.init(null, { engine: createFakeEngine() });
|
||||||
layer.attachShellServices({ runNavigation: (fn) => fn() });
|
layer.attachShellServices({ runNavigation: (fn) => fn() });
|
||||||
return layer;
|
return layer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
|
||||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
@@ -6,10 +5,7 @@ import {
|
|||||||
registerPickOwner,
|
registerPickOwner,
|
||||||
unregisterPickOwner,
|
unregisterPickOwner,
|
||||||
} from '../../data/pickRegistry.js';
|
} from '../../data/pickRegistry.js';
|
||||||
import {
|
import { coherentCycloneGeometry } from './rendering.js';
|
||||||
createCycloneRendering,
|
|
||||||
coherentCycloneGeometry,
|
|
||||||
} from './rendering.js';
|
|
||||||
import {
|
import {
|
||||||
CYCLONE_OVERLAY_SOURCE_ID,
|
CYCLONE_OVERLAY_SOURCE_ID,
|
||||||
cycloneStormIdFromEntryId,
|
cycloneStormIdFromEntryId,
|
||||||
@@ -62,23 +58,24 @@ const classificationName = (code) =>
|
|||||||
const number = (value, unit) =>
|
const number = (value, unit) =>
|
||||||
value === null ? t('Unavailable') : formatQuantity(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({
|
export function createCyclonesLayer({
|
||||||
feed,
|
feed,
|
||||||
hitTestOverlay = hitTestWorldOverlay,
|
hitTestOverlay = hitTestWorldOverlay,
|
||||||
overlayHost,
|
overlayHost,
|
||||||
context = null,
|
context = null,
|
||||||
cesium = null,
|
createRendering = null,
|
||||||
createRendering = createCycloneRendering,
|
|
||||||
createNativeRendering = null,
|
|
||||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||||
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof feed?.getSnapshot !== 'function')
|
if (typeof feed?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Cyclones require a snapshot source');
|
throw new TypeError('Cyclones require a snapshot source');
|
||||||
let engine = null;
|
let engine = null;
|
||||||
let viewer = null,
|
let rendering = null,
|
||||||
rendering = null,
|
|
||||||
snapshot = null,
|
snapshot = null,
|
||||||
request = null,
|
request = null,
|
||||||
listener = null,
|
listener = null,
|
||||||
@@ -157,27 +154,14 @@ export function createCyclonesLayer({
|
|||||||
selectedId = id;
|
selectedId = id;
|
||||||
rendering?.setSelection(id);
|
rendering?.setSelection(id);
|
||||||
}
|
}
|
||||||
// The SDK, on Cesium only: the production build's Cesium global loads with
|
// Photorealistic 3D Tiles pick as tile content without an id; that is
|
||||||
// the first Cesium globe, so a Navara page has none.
|
// empty map, the same as no pick at all on the globe.
|
||||||
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.
|
|
||||||
const isSurfacePick = (picked) =>
|
const isSurfacePick = (picked) =>
|
||||||
!picked ||
|
!picked || (picked.id === undefined && picked.content !== undefined);
|
||||||
(picked.id === undefined &&
|
|
||||||
(picked.content !== undefined ||
|
|
||||||
(typeof sdk()?.Cesium3DTileset === 'function' &&
|
|
||||||
picked.primitive instanceof sdk().Cesium3DTileset)));
|
|
||||||
function installSelection() {
|
function installSelection() {
|
||||||
const canvas = engine?.host.canvas ?? viewer?.scene?.canvas;
|
const canvas = engine?.host.canvas;
|
||||||
const native = engine?.kind === 'navara';
|
if (clickHandler || !canvas) return;
|
||||||
if (
|
const owner = {};
|
||||||
clickHandler ||
|
|
||||||
!canvas ||
|
|
||||||
(!native && typeof sdk()?.ScreenSpaceEventHandler !== 'function')
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
const owner = native ? {} : new (sdk().ScreenSpaceEventHandler)(canvas);
|
|
||||||
clickHandler = owner;
|
clickHandler = owner;
|
||||||
let capturedHit = null;
|
let capturedHit = null;
|
||||||
const overlayHit = (x, y) => {
|
const overlayHit = (x, y) => {
|
||||||
@@ -246,21 +230,19 @@ export function createCyclonesLayer({
|
|||||||
if (id) layer.setParams({ stormId: id });
|
if (id) layer.setParams({ stormId: id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const picked = native ? click.picked : viewer.scene.pick(click.position);
|
const { picked } = click;
|
||||||
const id = rendering?.pickStorm(picked);
|
const id = rendering?.pickStorm(picked);
|
||||||
if (id) layer.setParams({ stormId: id });
|
if (id) layer.setParams({ stormId: id });
|
||||||
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
|
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
|
||||||
};
|
};
|
||||||
if (native)
|
owner.destroy = engine.input.on('click', onClick, { pick: true });
|
||||||
owner.destroy = engine.input.on('click', onClick, { pick: true });
|
|
||||||
else owner.setInputAction(onClick, sdk().ScreenSpaceEventType.LEFT_CLICK);
|
|
||||||
}
|
}
|
||||||
function removeSelection() {
|
function removeSelection() {
|
||||||
const owner = clickHandler;
|
const owner = clickHandler;
|
||||||
clickHandler = null;
|
clickHandler = null;
|
||||||
removeClickCapture?.();
|
removeClickCapture?.();
|
||||||
removeClickCapture = null;
|
removeClickCapture = null;
|
||||||
if (owner && !owner.isDestroyed?.()) owner.destroy();
|
owner?.destroy();
|
||||||
}
|
}
|
||||||
const layer = {
|
const layer = {
|
||||||
id: 'weather-cyclones',
|
id: 'weather-cyclones',
|
||||||
@@ -268,20 +250,18 @@ export function createCyclonesLayer({
|
|||||||
icon: '◉',
|
icon: '◉',
|
||||||
source: 'NOAA NHC / CPHC',
|
source: 'NOAA NHC / CPHC',
|
||||||
updateInterval: 300_000,
|
updateInterval: 300_000,
|
||||||
init(nextViewer, options = {}) {
|
init(_viewer, options = {}) {
|
||||||
if (nextViewer || options.engine)
|
if (options.engine) return layer.attachRenderer(_viewer, options);
|
||||||
return layer.attachRenderer(nextViewer, options);
|
|
||||||
},
|
},
|
||||||
attachRenderer(nextViewer, options = {}) {
|
attachRenderer(_viewer, options = {}) {
|
||||||
viewer = nextViewer;
|
|
||||||
engine = options.engine ?? null;
|
engine = options.engine ?? null;
|
||||||
rendering =
|
rendering =
|
||||||
engine?.kind === 'navara'
|
engine && createRendering
|
||||||
? createNativeRendering(engine, { overlayHost })
|
? createRendering(engine, { overlayHost })
|
||||||
: createRendering({ viewer, cesium: sdk(), overlayHost });
|
: null;
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
installSelection();
|
installSelection();
|
||||||
if (snapshot && !snapshot.unavailable) {
|
if (rendering && snapshot && !snapshot.unavailable) {
|
||||||
const active = rendering;
|
const active = rendering;
|
||||||
return Promise.resolve(active.setSnapshot(snapshot)).then(() => {
|
return Promise.resolve(active.setSnapshot(snapshot)).then(() => {
|
||||||
if (active === rendering) active.setSelection(selectedId);
|
if (active === rendering) active.setSelection(selectedId);
|
||||||
@@ -293,7 +273,6 @@ export function createCyclonesLayer({
|
|||||||
++navigationGeneration;
|
++navigationGeneration;
|
||||||
rendering?.destroy();
|
rendering?.destroy();
|
||||||
rendering = null;
|
rendering = null;
|
||||||
viewer = null;
|
|
||||||
engine = null;
|
engine = null;
|
||||||
runNavigation = null;
|
runNavigation = null;
|
||||||
},
|
},
|
||||||
@@ -424,7 +403,7 @@ export function createCyclonesLayer({
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
const storm = selected();
|
const storm = selected();
|
||||||
if (params.focus === true && storm && runNavigation) {
|
if (params.focus === true && storm && runNavigation && engine) {
|
||||||
const sphere = rendering?.getFocusSphere(storm.id);
|
const sphere = rendering?.getFocusSphere(storm.id);
|
||||||
if (sphere) {
|
if (sphere) {
|
||||||
const generation = ++navigationGeneration;
|
const generation = ++navigationGeneration;
|
||||||
@@ -436,10 +415,7 @@ export function createCyclonesLayer({
|
|||||||
selectedId !== storm.id
|
selectedId !== storm.id
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
const fly = engine
|
return engine.camera.flyToSphere(sphere, {
|
||||||
? engine.camera.flyToSphere.bind(engine.camera)
|
|
||||||
: viewer.camera.flyToBoundingSphere.bind(viewer.camera);
|
|
||||||
return fly(sphere, {
|
|
||||||
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
|
||||||
?.matches
|
?.matches
|
||||||
? 0
|
? 0
|
||||||
|
|||||||
+109
-141
@@ -47,73 +47,61 @@ const deferred = () => {
|
|||||||
return { promise, resolve };
|
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 () => {
|
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
|
||||||
const handlers = [],
|
const pickedStorm = { id: 'cyclone:ep162026:center', owner: 'cyclones' },
|
||||||
pickedEntity = {},
|
foreignPick = { id: 'aircraft:abc', owner: 'flights' };
|
||||||
foreignEntity = {};
|
let overlayHit = null,
|
||||||
let picked = { id: pickedEntity },
|
|
||||||
overlayHit = null,
|
|
||||||
overlayTests = 0,
|
overlayTests = 0,
|
||||||
picks = 0,
|
|
||||||
notices = 0;
|
notices = 0;
|
||||||
const rendering = {
|
const rendering = {
|
||||||
setSnapshot: async () => true,
|
setSnapshot: async () => true,
|
||||||
setSelection() {},
|
setSelection() {},
|
||||||
clear() {},
|
clear() {},
|
||||||
destroy() {},
|
destroy() {},
|
||||||
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
|
pickStorm: (value) => (value === pickedStorm ? 'ep162026' : null),
|
||||||
ownsPickId: (id) => id === 'cyclone:ep162026:center',
|
ownsPickId: (id) => id === 'cyclone:ep162026:center',
|
||||||
getDiagnostics: () => ({}),
|
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({
|
const layer = createCyclonesLayer({
|
||||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
||||||
cesium,
|
|
||||||
createRendering: () => rendering,
|
createRendering: () => rendering,
|
||||||
hitTestOverlay: () => {
|
hitTestOverlay: () => {
|
||||||
overlayTests++;
|
overlayTests++;
|
||||||
return overlayHit;
|
return overlayHit;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const canvas = new EventTarget();
|
const h = clickEngine();
|
||||||
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
|
h.engine.camera.flyToSphere = () =>
|
||||||
const viewer = {
|
assert.fail('Click must not move the camera');
|
||||||
scene: {
|
layer.init(null, { engine: h.engine });
|
||||||
canvas,
|
|
||||||
pick() {
|
|
||||||
picks++;
|
|
||||||
return picked;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
trackedEntity: null,
|
|
||||||
camera: {
|
|
||||||
flyToBoundingSphere() {
|
|
||||||
assert.fail('Click must not move the camera');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
layer.init(viewer);
|
|
||||||
layer.setRowControlsListener(() => notices++);
|
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();
|
||||||
layer.enable();
|
layer.enable();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -127,62 +115,64 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
|||||||
false,
|
false,
|
||||||
'registration recognizes exact current IDs, not a prefix',
|
'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();
|
await layer.update();
|
||||||
const click = { position: { x: 10, y: 20 } };
|
const click = { position: { x: 10, y: 20 } };
|
||||||
|
h.setPicked(pickedStorm);
|
||||||
const owner = claimPointer('director');
|
const owner = claimPointer('director');
|
||||||
assert.ok(owner);
|
assert.ok(owner);
|
||||||
try {
|
try {
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(picks, 0, 'claimed pointer is checked before picking');
|
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
} finally {
|
} finally {
|
||||||
releasePointer(owner);
|
releasePointer(owner);
|
||||||
}
|
}
|
||||||
picked = { id: foreignEntity };
|
h.setPicked(foreignPick);
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
picked = { primitive: {} };
|
h.setPicked({ object: {} });
|
||||||
handlers[0].click(click);
|
h.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);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
picks,
|
layer.getDiagnostics().selectionIntent,
|
||||||
beforeCard,
|
'auto',
|
||||||
'foreground AIS card wins before scene picking',
|
'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.
|
// A native capture runs before an earlier sibling handler clears hit rects.
|
||||||
const up = new Event('pointerup');
|
const up = new Event('pointerup');
|
||||||
Object.assign(up, { clientX: 110, clientY: 220 });
|
Object.assign(up, { clientX: 110, clientY: 220 });
|
||||||
canvas.dispatchEvent(up);
|
h.canvas.dispatchEvent(up);
|
||||||
overlayHit = null;
|
overlayHit = null;
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layer.getDiagnostics().selectedId,
|
layer.getDiagnostics().selectedId,
|
||||||
'ep152026',
|
'ep152026',
|
||||||
'captured AIS hit survives synchronous sibling overlay rebuild',
|
'captured AIS hit survives synchronous sibling overlay rebuild',
|
||||||
);
|
);
|
||||||
// A captured background hit remains background even if a sibling adds a card.
|
// 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' };
|
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
layer.setParams({ stormId: 'ep152026' });
|
layer.setParams({ stormId: 'ep152026' });
|
||||||
// A new gesture clears an unconsumed release snapshot.
|
// A new gesture clears an unconsumed release snapshot.
|
||||||
canvas.dispatchEvent(up);
|
h.canvas.dispatchEvent(up);
|
||||||
canvas.dispatchEvent(new Event('pointerdown'));
|
h.canvas.dispatchEvent(new Event('pointerdown'));
|
||||||
overlayHit = null;
|
overlayHit = null;
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
layer.setParams({ stormId: 'ep152026' });
|
layer.setParams({ stormId: 'ep152026' });
|
||||||
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
|
||||||
canvas.dispatchEvent(up);
|
h.canvas.dispatchEvent(up);
|
||||||
overlayHit = null;
|
overlayHit = null;
|
||||||
handlers[0].click({ position: { x: 15, y: 25 } });
|
h.click({ position: { x: 15, y: 25 } });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layer.getDiagnostics().selectedId,
|
layer.getDiagnostics().selectedId,
|
||||||
'ep162026',
|
'ep162026',
|
||||||
@@ -191,32 +181,32 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
|||||||
layer.setParams({ stormId: 'ep152026' });
|
layer.setParams({ stormId: 'ep152026' });
|
||||||
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
|
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
|
||||||
const before = notices;
|
const before = notices;
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
assert.match(layer.getRowControls().summary.detail, /Another system/);
|
assert.match(layer.getRowControls().summary.detail, /Another system/);
|
||||||
assert.equal(layer.getRowControls().list.items[1].active, true);
|
assert.equal(layer.getRowControls().list.items[1].active, true);
|
||||||
assert.ok(notices > before);
|
assert.ok(notices > before);
|
||||||
assert.equal(viewer.trackedEntity, null);
|
assert.equal(h.engine.camera.following(), null);
|
||||||
picked = undefined;
|
h.setPicked(undefined);
|
||||||
overlayHit = { sourceId: 'ais-live-vessels' };
|
overlayHit = { sourceId: 'ais-live-vessels' };
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
overlayHit = null;
|
overlayHit = null;
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, null);
|
assert.equal(layer.getDiagnostics().selectedId, null);
|
||||||
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
|
||||||
picked = { id: pickedEntity };
|
h.setPicked(pickedStorm);
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
picked = { id: 'flight:abc', primitive: {} };
|
h.setPicked({ id: 'flight:abc', owner: 'flights' });
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layer.getDiagnostics().selectedId,
|
layer.getDiagnostics().selectedId,
|
||||||
'ep162026',
|
'ep162026',
|
||||||
"another layer's pick leaves the selection alone",
|
"another layer's pick leaves the selection alone",
|
||||||
);
|
);
|
||||||
picked = { content: {}, primitive: {} };
|
h.setPicked({ id: undefined, content: {} });
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
layer.getDiagnostics().selectedId,
|
layer.getDiagnostics().selectedId,
|
||||||
null,
|
null,
|
||||||
@@ -235,56 +225,45 @@ test('map selection yields to pointer owners and owns only its enabled handler',
|
|||||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(handlers[0].destroyed, true);
|
|
||||||
assert.equal(layer.getDiagnostics().selectionActive, false);
|
assert.equal(layer.getDiagnostics().selectionActive, false);
|
||||||
const afterDisable = picks;
|
const afterDisable = h.picks;
|
||||||
const afterDisableOverlay = overlayTests;
|
const afterDisableOverlay = overlayTests;
|
||||||
canvas.dispatchEvent(up);
|
h.canvas.dispatchEvent(up);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
overlayTests,
|
overlayTests,
|
||||||
afterDisableOverlay,
|
afterDisableOverlay,
|
||||||
'disable removes native capture listeners',
|
'disable removes native capture listeners',
|
||||||
);
|
);
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
|
assert.equal(h.picks, afterDisable, 'disable removes the click listener');
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
|
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
picks,
|
h.picks,
|
||||||
afterDisable,
|
afterDisable + 1,
|
||||||
'superseded handler remains inert after re-enable',
|
're-enable listens once; the removed listener stays inert',
|
||||||
);
|
);
|
||||||
assert.equal(handlers.length, 2);
|
assert.equal(h.subscriptions(), 2);
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
|
||||||
false,
|
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 () => {
|
test('a click on a storm card or lead-hour label selects that storm whatever lies under it', async () => {
|
||||||
const handlers = [];
|
let overlayHit = null,
|
||||||
let picks = 0,
|
hostArgument,
|
||||||
overlayHit = null,
|
engineArgument;
|
||||||
hostArgument;
|
|
||||||
const layer = createCyclonesLayer({
|
const layer = createCyclonesLayer({
|
||||||
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
|
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',
|
overlayHost: 'host',
|
||||||
createRendering: ({ overlayHost }) => {
|
createRendering: (engine, { overlayHost }) => {
|
||||||
|
engineArgument = engine;
|
||||||
hostArgument = overlayHost;
|
hostArgument = overlayHost;
|
||||||
return {
|
return {
|
||||||
setSnapshot: async () => true,
|
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,
|
hitTestOverlay: () => overlayHit,
|
||||||
});
|
});
|
||||||
const canvas = new EventTarget();
|
const h = clickEngine();
|
||||||
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
h.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
|
||||||
layer.init({
|
h.setPicked({ id: undefined, content: {} });
|
||||||
scene: {
|
layer.init(null, { engine: h.engine });
|
||||||
canvas,
|
|
||||||
pick() {
|
|
||||||
picks++;
|
|
||||||
return { content: {} };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
|
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
|
||||||
|
assert.equal(engineArgument, h.engine, 'the renderer draws on the engine');
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
const click = { position: { x: 10, y: 20 } };
|
const click = { position: { x: 10, y: 20 } };
|
||||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
|
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
|
||||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
|
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
// A card from a superseded advisory is not empty map.
|
// A card from a superseded advisory is not empty map.
|
||||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
|
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
// The native release snapshot carries the card identity too.
|
// The native release snapshot carries the card identity too.
|
||||||
const up = new Event('pointerup');
|
const up = new Event('pointerup');
|
||||||
Object.assign(up, { clientX: 10, clientY: 20 });
|
Object.assign(up, { clientX: 10, clientY: 20 });
|
||||||
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
|
||||||
canvas.dispatchEvent(up);
|
h.canvas.dispatchEvent(up);
|
||||||
overlayHit = null;
|
overlayHit = null;
|
||||||
handlers[0].click(click);
|
h.click(click);
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
|
||||||
assert.equal(picks, 0, 'card clicks never fall through to the scene');
|
h.click(click);
|
||||||
handlers[0].click(click);
|
|
||||||
assert.equal(picks, 1);
|
|
||||||
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
|
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
});
|
});
|
||||||
@@ -376,13 +347,10 @@ function harness(
|
|||||||
matchMedia: () => ({ matches: reducedMotion }),
|
matchMedia: () => ({ matches: reducedMotion }),
|
||||||
openLink: (url) => opened.push(url),
|
openLink: (url) => opened.push(url),
|
||||||
});
|
});
|
||||||
const viewer = {
|
const engine = createFakeEngine();
|
||||||
camera: {
|
engine.camera.flyToSphere = (sphere, options) =>
|
||||||
flyToBoundingSphere: (sphere, options) =>
|
navigation.push({ sphere, options });
|
||||||
navigation.push({ sphere, options }),
|
layer.init(null, { engine });
|
||||||
},
|
|
||||||
};
|
|
||||||
layer.init(viewer);
|
|
||||||
layer.attachShellServices({
|
layer.attachShellServices({
|
||||||
runNavigation: (fn) => {
|
runNavigation: (fn) => {
|
||||||
navigation.push('claimed');
|
navigation.push('claimed');
|
||||||
@@ -705,7 +673,7 @@ test('clearing while a refreshed data source is staging cannot reselect a storm'
|
|||||||
destroy() {},
|
destroy() {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
layer.init({});
|
layer.init(null, { engine: createFakeEngine() });
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
@@ -941,7 +909,7 @@ test('advisories delivered between scenes keep a deliberate selection without an
|
|||||||
destroy() {},
|
destroy() {},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
layer.init({});
|
layer.init(null, { engine: createFakeEngine() });
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
layer.setParams({ stormId: 'ep152026' });
|
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' }]));
|
pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }]));
|
||||||
await refresh;
|
await refresh;
|
||||||
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
|
||||||
await layer.attachRenderer({});
|
await layer.attachRenderer(null, { engine: createFakeEngine() });
|
||||||
assert.deepEqual(drawn, [['10'], ['11']]);
|
assert.deepEqual(drawn, [['10'], ['11']]);
|
||||||
assert.equal(reads, 2);
|
assert.equal(reads, 2);
|
||||||
layer.destroy();
|
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 {
|
// Which advisory geometry the cyclone display draws (render/display.js).
|
||||||
CYCLONE_ACCENT,
|
|
||||||
CYCLONE_MARKER_PX,
|
|
||||||
CYCLONE_SELECTED_ACCENT,
|
|
||||||
createCycloneLabels,
|
|
||||||
} from './labels.js';
|
|
||||||
|
|
||||||
/** Only geometry from the displayed status advisory is eligible for rendering. */
|
/** Only geometry from the displayed status advisory is eligible for rendering. */
|
||||||
export function coherentCycloneGeometry(storm) {
|
export function coherentCycloneGeometry(storm) {
|
||||||
@@ -12,303 +7,3 @@ export function coherentCycloneGeometry(storm) {
|
|||||||
storm.geometryAdvisoryNumber === storm.advisoryNumber
|
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 * as geo from '../../geo/index.js';
|
||||||
import { msg, t } from '../../i18n/index.js';
|
import { msg, t } from '../../i18n/index.js';
|
||||||
import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js';
|
import { createDroneAirspaceDisplay } from './render/display.js';
|
||||||
import {
|
import {
|
||||||
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
|
||||||
droneAirspaceAnchorDegrees,
|
droneAirspaceAnchorDegrees,
|
||||||
@@ -11,20 +11,6 @@ export { normalizeDroneAirspaceSnapshot } from './records.js';
|
|||||||
export { createDroneAirspaceSource } from './source.js';
|
export { createDroneAirspaceSource } from './source.js';
|
||||||
export * from './cards.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
|
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
|
||||||
* boundary change with unchanged attributes still alters the refresh signature.
|
* boundary change with unchanged attributes still alters the refresh signature.
|
||||||
@@ -50,10 +36,9 @@ const CARD_HOST_OPTIONS = Object.freeze({
|
|||||||
export function createDroneAirspaceLayer({
|
export function createDroneAirspaceLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost = null,
|
overlayHost = null,
|
||||||
screenSpaceEventHandlerFactory = null,
|
|
||||||
picking = null,
|
picking = null,
|
||||||
pointer = null,
|
pointer = null,
|
||||||
createRenderer = createDroneAirspaceCesiumRenderer,
|
createRenderer = ({ engine }) => createDroneAirspaceDisplay(engine),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Drone airspace requires a snapshot source');
|
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}}>} */
|
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
|
||||||
const _rowById = new Map();
|
const _rowById = new Map();
|
||||||
|
|
||||||
const canSelect = () =>
|
const canSelect = () => overlayHost && picking;
|
||||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
|
||||||
|
|
||||||
function publishSelectedCard() {
|
function publishSelectedCard() {
|
||||||
if (!canSelect() || !renderer) return;
|
if (!canSelect() || !renderer) return;
|
||||||
@@ -172,7 +156,7 @@ export function createDroneAirspaceLayer({
|
|||||||
throw new Error('Drone airspace layer is already initialized');
|
throw new Error('Drone airspace layer is already initialized');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
try {
|
||||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
if (engine) this.attachRenderer(viewer, { engine });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -184,11 +168,7 @@ export function createDroneAirspaceLayer({
|
|||||||
attachRenderer(viewer, { engine = null } = {}) {
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
if (!initialized || renderer)
|
if (!initialized || renderer)
|
||||||
throw new Error('Invalid drone airspace scene attachment');
|
throw new Error('Invalid drone airspace scene attachment');
|
||||||
renderer = createRenderer({
|
renderer = createRenderer({ engine });
|
||||||
viewer,
|
|
||||||
engine,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
});
|
|
||||||
renderer.setVisible(_enabled);
|
renderer.setVisible(_enabled);
|
||||||
if (_rows.length) renderer.replace(_rows);
|
if (_rows.length) renderer.replace(_rows);
|
||||||
if (_enabled) {
|
if (_enabled) {
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||||
import { createDroneAirspaceLayer } from './index.js';
|
import { createDroneAirspaceLayer } from './index.js';
|
||||||
import { layerFeedState } from '../../data/feedState.js';
|
import { layerFeedState } from '../../data/feedState.js';
|
||||||
|
|
||||||
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
||||||
const sources = [];
|
|
||||||
const overlay = { entries: new Map(), visible: null };
|
const overlay = { entries: new Map(), visible: null };
|
||||||
const clicks = { handler: null, destroyed: 0 };
|
|
||||||
const owners = new Map();
|
const owners = new Map();
|
||||||
const viewer = {
|
const ground = createFakeGroundHarness();
|
||||||
scene: { pick },
|
const { engine } = ground;
|
||||||
dataSources: {
|
// What a click picks: `scene.pick()` (null for nothing).
|
||||||
add(value) {
|
const scene = { pick };
|
||||||
sources.push(value);
|
engine.fake.script('pick', () => scene.pick() ?? undefined);
|
||||||
},
|
|
||||||
remove(value) {
|
|
||||||
sources.splice(sources.indexOf(value), 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createDroneAirspaceLayer({
|
const layer = createDroneAirspaceLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -33,15 +26,6 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
|||||||
},
|
},
|
||||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
|
||||||
setInputAction(callback) {
|
|
||||||
clicks.handler = callback;
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
clicks.destroyed += 1;
|
|
||||||
clicks.handler = null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
picking: {
|
picking: {
|
||||||
resolvePickId: (picked) => picked?.id ?? null,
|
resolvePickId: (picked) => picked?.id ?? null,
|
||||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||||
@@ -51,9 +35,20 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) {
|
|||||||
},
|
},
|
||||||
pointer: { isPointerFree: () => true },
|
pointer: { isPointerFree: () => true },
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(null, { engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
return { layer, viewer, sources, overlay, clicks, owners };
|
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 = [
|
const ring = [
|
||||||
@@ -76,19 +71,27 @@ const row = {
|
|||||||
polygons: [[ring]],
|
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({
|
const h = harness({
|
||||||
getSnapshot: async () => [
|
getSnapshot: async () => [
|
||||||
row,
|
row,
|
||||||
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
|
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
assert.equal(await h.layer.update(h.viewer), true);
|
assert.equal(await h.layer.update(), true);
|
||||||
const entities = h.sources[0].entities.values;
|
const { items } = h.zones();
|
||||||
assert.equal(entities.length, 3);
|
const ids = [
|
||||||
assert.equal(entities[0].id, 'drone-airspace:310:0');
|
'drone-airspace:310:0',
|
||||||
assert.ok(entities[0].polygon, 'zone fill missing');
|
'drone-airspace:400:0',
|
||||||
assert.ok(entities[0].polyline, 'zone outline missing');
|
'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);
|
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] },
|
{ getSnapshot: async () => [row] },
|
||||||
{ pick: () => pickResult },
|
{ pick: () => pickResult },
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
assert.equal(h.ground.listeners(), 1, 'click handler missing');
|
||||||
assert.equal(h.owners.size, 0);
|
assert.equal(h.owners.size, 0);
|
||||||
|
|
||||||
pickResult = { id: 'drone-airspace:310: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');
|
const entries = h.overlay.entries.get('drone-airspace');
|
||||||
assert.equal(entries.length, 1);
|
assert.equal(entries.length, 1);
|
||||||
assert.equal(entries[0].id, 'drone-airspace-card:310');
|
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');
|
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||||
|
|
||||||
pickResult = { id: 'other-layer:aircraft-1' };
|
pickResult = { id: 'other-layer:aircraft-1' };
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.overlay.entries.get('drone-airspace')?.length,
|
h.overlay.entries.get('drone-airspace')?.length,
|
||||||
1,
|
1,
|
||||||
@@ -119,7 +122,7 @@ test('clicking a zone publishes its card; empty space clears it', async () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
pickResult = null;
|
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);
|
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);
|
const pending = h.layer.update();
|
||||||
h.layer[action](h.viewer);
|
h.layer[action]();
|
||||||
assert.equal(signal.aborted, true);
|
assert.equal(signal.aborted, true);
|
||||||
if (action === 'disable') h.layer.enable(h.viewer);
|
if (action === 'disable') h.layer.enable();
|
||||||
resolve([row]);
|
resolve([row]);
|
||||||
assert.equal(await pending, false);
|
assert.equal(await pending, false);
|
||||||
assert.equal(h.layer.getStats().count, 0);
|
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' },
|
{ ...row, stableId: '400', typeCode: 'R', type: 'Restricted' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
const legend = h.layer.getRowControls().legend;
|
const legend = h.layer.getRowControls().legend;
|
||||||
assert.equal(legend[0].label, 'Prohibited (P)');
|
assert.equal(legend[0].label, 'Prohibited (P)');
|
||||||
assert.equal(legend[1].label, 'Restricted (R)');
|
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,
|
getSnapshot: async () => snapshot.rows,
|
||||||
getSnapshotDetail: async () => snapshot,
|
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().stale, true);
|
||||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||||
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
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.equal(h.layer.getStats().stale, false);
|
||||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
||||||
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
|
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 };
|
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');
|
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
|
||||||
fail = 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().stale, false);
|
||||||
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
|
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
|
||||||
assert.equal(layerFeedState(h.layer.getStats()), 'degraded');
|
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]);
|
const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]);
|
||||||
let rows = [row];
|
let rows = [row];
|
||||||
const h = harness({ getSnapshot: async () => rows });
|
const h = harness({ getSnapshot: async () => rows });
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
const [first] = h.sources[0].entities.values;
|
const first = h.zones();
|
||||||
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
|
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept');
|
assert.equal(h.zones().commits, first.commits, 'same geometry kept');
|
||||||
|
assert.equal(h.zones().items, first.items);
|
||||||
rows = [{ ...row, polygons: [[moved]] }];
|
rows = [{ ...row, polygons: [[moved]] }];
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
const [redrawn] = h.sources[0].entities.values;
|
const redrawn = h.zones();
|
||||||
assert.notEqual(redrawn, first, 'moved boundary must redraw');
|
assert.equal(
|
||||||
assert.notDeepEqual(
|
redrawn.commits,
|
||||||
redrawn.polyline.positions.getValue(),
|
first.commits + 1,
|
||||||
first.polyline.positions.getValue(),
|
'moved boundary must redraw',
|
||||||
);
|
);
|
||||||
|
assert.notDeepEqual(redrawn.items[1].rings, first.items[1].rings);
|
||||||
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
|
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,
|
hitTest: () => null,
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: () => null,
|
|
||||||
picking: {
|
picking: {
|
||||||
resolvePickId: (picked) => picked?.id ?? null,
|
resolvePickId: (picked) => picked?.id ?? null,
|
||||||
isOwnedByOtherLayer: () => false,
|
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,
|
selectEarthquakeOverlayCohort,
|
||||||
mapAnalystRecord,
|
mapAnalystRecord,
|
||||||
} from './model.js';
|
} from './model.js';
|
||||||
|
import { createEarthquakesDisplay } from './render/display.js';
|
||||||
export * from './model.js';
|
export * from './model.js';
|
||||||
export { createUsgsEarthquakeSource } from './source.js';
|
export { createUsgsEarthquakeSource } from './source.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Own one earthquake display and its refresh lifecycle.
|
* Own one earthquake display and its refresh lifecycle.
|
||||||
* renderer.create({viewer, engine}) owns drawing and returns:
|
* renderer.create({ engine }) owns drawing (render/display.js by default)
|
||||||
* setVisible(boolean), replace(discs), destroy().
|
* and returns: setVisible(boolean), replace(discs), destroy().
|
||||||
* Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep
|
* Discs are degrees, metre radii and Rgba colours; renderers keep source
|
||||||
* source order. The layer retains observations for queries while detached.
|
* 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')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Earthquakes require a snapshot source');
|
throw new TypeError('Earthquakes require a snapshot source');
|
||||||
if (!overlayHost) throw new TypeError('Earthquakes require an overlay host');
|
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');
|
throw new Error('Earthquake layer is already initialized');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
try {
|
||||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
if (engine) this.attachRenderer(viewer, { engine });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -68,7 +73,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
|
|||||||
attachRenderer(viewer, { engine = null } = {}) {
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
if (!initialized || _renderer)
|
if (!initialized || _renderer)
|
||||||
throw new Error('Invalid earthquake scene attachment');
|
throw new Error('Invalid earthquake scene attachment');
|
||||||
_renderer = renderer.create({ viewer, engine });
|
_renderer = renderer.create({ engine });
|
||||||
if (!_renderer) throw new Error('Earthquake renderer unavailable');
|
if (!_renderer) throw new Error('Earthquake renderer unavailable');
|
||||||
_renderer.setVisible(_enabled);
|
_renderer.setVisible(_enabled);
|
||||||
if (discs.length) _renderer.replace(discs);
|
if (discs.length) _renderer.replace(discs);
|
||||||
@@ -117,38 +122,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
|
|||||||
let count = 0;
|
let count = 0;
|
||||||
const overlayEntries = [];
|
const overlayEntries = [];
|
||||||
|
|
||||||
for (const {
|
for (const { stableId, lon, lat, depthKm, mag } of rows) {
|
||||||
stableId,
|
|
||||||
usgsId,
|
|
||||||
lon,
|
|
||||||
lat,
|
|
||||||
depthKm,
|
|
||||||
mag,
|
|
||||||
place,
|
|
||||||
time,
|
|
||||||
} of rows) {
|
|
||||||
count++;
|
count++;
|
||||||
const baseRadius = Math.pow(2, mag) * 1000;
|
const baseRadius = Math.pow(2, mag) * 1000;
|
||||||
const color = depthColor(depthKm || 0);
|
const color = depthColor(depthKm || 0);
|
||||||
const isSignificant = mag >= 5.0;
|
const fillAlpha = mag >= 5.0 ? 0.4 : 0.3;
|
||||||
const fillAlpha = isSignificant ? 0.4 : 0.3;
|
|
||||||
const outlineAlpha = isSignificant ? 1.0 : 0.8;
|
|
||||||
|
|
||||||
const position = geo.cartesian3.fromDegrees(lon, lat);
|
const position = geo.cartesian3.fromDegrees(lon, lat);
|
||||||
nextDiscs.push({
|
nextDiscs.push({ stableId, lon, lat, baseRadius, color, fillAlpha });
|
||||||
stableId,
|
|
||||||
usgsId,
|
|
||||||
position,
|
|
||||||
baseRadius,
|
|
||||||
color,
|
|
||||||
isSignificant,
|
|
||||||
fillAlpha,
|
|
||||||
outlineAlpha,
|
|
||||||
mag,
|
|
||||||
place,
|
|
||||||
time,
|
|
||||||
depthKm,
|
|
||||||
});
|
|
||||||
overlayEntries.push(
|
overlayEntries.push(
|
||||||
createEarthquakeOverlayEntry({
|
createEarthquakeOverlayEntry({
|
||||||
id: String(stableId),
|
id: String(stableId),
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
import { createEarthquakesCesiumRenderer } from './render/cesium.js';
|
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||||
import { createEarthquakesLayer } from './index.js';
|
import { createEarthquakesLayer } from './index.js';
|
||||||
import { createUsgsEarthquakeSource } from './source.js';
|
import { createUsgsEarthquakeSource } from './source.js';
|
||||||
function harness(source) {
|
function harness(source) {
|
||||||
const sources = [];
|
|
||||||
const events = [];
|
const events = [];
|
||||||
const viewer = {
|
const ground = createFakeGroundHarness();
|
||||||
dataSources: {
|
|
||||||
add(value) {
|
|
||||||
sources.push(value);
|
|
||||||
},
|
|
||||||
remove(value) {
|
|
||||||
sources.splice(sources.indexOf(value), 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createEarthquakesLayer({
|
const layer = createEarthquakesLayer({
|
||||||
source,
|
source,
|
||||||
renderer: {
|
|
||||||
create: ({ viewer }) => createEarthquakesCesiumRenderer(null, { viewer }),
|
|
||||||
},
|
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
setEntries(...args) {
|
setEntries(...args) {
|
||||||
events.push(args);
|
events.push(args);
|
||||||
@@ -29,9 +16,9 @@ function harness(source) {
|
|||||||
clearSource() {},
|
clearSource() {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(null, { engine: ground.engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
return { layer, viewer, sources, events };
|
return { layer, ground, events };
|
||||||
}
|
}
|
||||||
const row = {
|
const row = {
|
||||||
stableId: 'event-a',
|
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);
|
const pending = h.layer.update();
|
||||||
h.layer[action](h.viewer);
|
h.layer[action]();
|
||||||
assert.equal(signal.aborted, true);
|
assert.equal(signal.aborted, true);
|
||||||
if (action === 'disable') h.layer.enable(h.viewer);
|
if (action === 'disable') h.layer.enable();
|
||||||
resolve([row]);
|
resolve([row]);
|
||||||
assert.equal(await pending, false);
|
assert.equal(await pending, false);
|
||||||
assert.equal(h.layer.getStats().count, 0);
|
assert.equal(h.layer.getStats().count, 0);
|
||||||
assert.equal(h.events.length, 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 a = harness({ getSnapshot: async () => [row] });
|
||||||
const b = harness({ getSnapshot: async () => [] });
|
const b = harness({ getSnapshot: async () => [] });
|
||||||
await a.layer.update(a.viewer);
|
await a.layer.update();
|
||||||
await b.layer.update(b.viewer);
|
await b.layer.update();
|
||||||
assert.equal(a.layer.getStats().count, 1);
|
assert.equal(a.layer.getStats().count, 1);
|
||||||
assert.equal(b.layer.getStats().count, 0);
|
assert.equal(b.layer.getStats().count, 0);
|
||||||
a.layer.destroy();
|
a.layer.destroy();
|
||||||
assert.equal(a.sources.length, 0);
|
assert.equal(a.ground.sets(), 0);
|
||||||
assert.equal(b.sources.length, 1);
|
assert.equal(b.ground.sets(), 1);
|
||||||
b.layer.destroy();
|
b.layer.destroy();
|
||||||
});
|
});
|
||||||
test('USGS body completion honors cancellation even with an uncooperative transport', async () => {
|
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 {
|
try {
|
||||||
const pending = h.layer.update();
|
const pending = h.layer.update();
|
||||||
h.layer.detachRenderer();
|
h.layer.detachRenderer();
|
||||||
assert.equal(h.sources.length, 0);
|
assert.equal(h.ground.sets(), 0);
|
||||||
resolve([row]);
|
resolve([row]);
|
||||||
assert.equal(await pending, true);
|
assert.equal(await pending, true);
|
||||||
const [record] = h.layer.getAnalystRecords();
|
const [record] = h.layer.getAnalystRecords();
|
||||||
assert.equal(record.id, row.usgsId);
|
assert.equal(record.id, row.usgsId);
|
||||||
assert.equal(record.lat, row.lat);
|
assert.equal(record.lat, row.lat);
|
||||||
assert.equal(h.events.length, 0);
|
assert.equal(h.events.length, 0);
|
||||||
h.layer.attachRenderer(h.viewer);
|
h.layer.attachRenderer(null, { engine: h.ground.engine });
|
||||||
assert.equal(h.sources[0].entities.values.length, 1);
|
assert.equal(h.ground.set('earthquakes').items.length, 1);
|
||||||
assert.equal(h.events.length, 1);
|
assert.equal(h.events.length, 1);
|
||||||
assert.equal(polls, 1);
|
assert.equal(polls, 1);
|
||||||
} finally {
|
} 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
|
* Rebuild the card entries for the shared world-overlay host: the
|
||||||
* selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS
|
* selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS
|
||||||
* ambient cards picked by a greedy screen-space declutter — walk the
|
* ambient cards picked by a greedy screen-space declutter — walk the
|
||||||
* priority-ranked candidates, project each with
|
* priority-ranked candidates, project each through the renderer
|
||||||
* SceneTransforms.worldToWindowCoordinates, and accept only candidates
|
* (engine.projector.worldToWindow), and accept only candidates
|
||||||
* ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose
|
* ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose
|
||||||
* projection fails are dropped silently. SELECTION runs here, on LOD/
|
* projection fails are dropped silently. SELECTION runs here, on LOD/
|
||||||
* viewport rebuilds and camera moveEnd — never per frame; RENDERING is
|
* viewport rebuilds and camera moveEnd — never per frame; RENDERING is
|
||||||
@@ -28,17 +28,15 @@ export function createCards({
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
function rebuildAmbientLabels() {
|
function rebuildAmbientLabels() {
|
||||||
if (!layerState._viewer) return;
|
if (!layerState._renderer) return;
|
||||||
const scene = layerState._viewer.scene;
|
const scene = layerState._viewer.scene;
|
||||||
const project = (position, result) =>
|
const project = (position, result) =>
|
||||||
layerState._renderer
|
layerState._renderer.project(position, result);
|
||||||
? layerState._renderer.project(position, result)
|
|
||||||
: config.projectToWindow(scene, position, result);
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const entries = [];
|
const entries = [];
|
||||||
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
|
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
|
||||||
const accepted = [];
|
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
|
// planet, so without this the ≤18 ambient slots get spent on fires the
|
||||||
// overlay host will then horizon-cull at paint time (`horizonCull: true`
|
// overlay host will then horizon-cull at paint time (`horizonCull: true`
|
||||||
// in applyFirmsOverlayPolicy) — near-side fires silently lose their cards.
|
// 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 * as geo from '../../geo/index.js';
|
||||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
import { createFirmsHeatmapLayer } from './index.js';
|
import { createFirmsHeatmapLayer } from './index.js';
|
||||||
import { createFirmsNavaraRenderer } from './render/navara.js';
|
import { createFirmsDisplay } from './render/display.js';
|
||||||
import { unregisterSpriteCollection } from '../../data/spriteOrder.js';
|
import { createFirmsHeatDrape } from './render/navaraHeat.js';
|
||||||
|
|
||||||
const settle = async () => {
|
const settle = async () => {
|
||||||
for (let i = 0; i < 5; i++)
|
for (let i = 0; i < 5; i++)
|
||||||
@@ -88,6 +88,8 @@ function fixture() {
|
|||||||
EffectDesc: class {},
|
EffectDesc: class {},
|
||||||
NavaraEffect: class {},
|
NavaraEffect: class {},
|
||||||
};
|
};
|
||||||
|
// The heatmap draws through the Navara backend on this view.
|
||||||
|
engine.extensions = { navara: native };
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const layer = createFirmsHeatmapLayer({
|
const layer = createFirmsHeatmapLayer({
|
||||||
id: 'local-firms',
|
id: 'local-firms',
|
||||||
@@ -116,7 +118,7 @@ function fixture() {
|
|||||||
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
||||||
renderer: {
|
renderer: {
|
||||||
create: ({ engine: target, id }) =>
|
create: ({ engine: target, id }) =>
|
||||||
createFirmsNavaraRenderer(target, { ...native, id }),
|
createFirmsDisplay(target, { id, navaraHeat: createFirmsHeatDrape }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
layer.init(null, { engine });
|
layer.init(null, { engine });
|
||||||
@@ -156,7 +158,6 @@ function fixture() {
|
|||||||
},
|
},
|
||||||
cleanup() {
|
cleanup() {
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
unregisterSpriteCollection('local-firms');
|
|
||||||
performance.now = realNow;
|
performance.now = realNow;
|
||||||
restore();
|
restore();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export function createLifecycle({
|
|||||||
const { clearSelectedEntityContextForLayer } = services.context;
|
const { clearSelectedEntityContextForLayer } = services.context;
|
||||||
const { id, overlayHost } = config;
|
const { id, overlayHost } = config;
|
||||||
|
|
||||||
// The heatmap is draped on the renderer's globe (Cesium only); it goes
|
// The heatmap is draped on the renderer's globe; it goes with the renderer
|
||||||
// with the renderer and is built again on the next one that can drape it.
|
// and is built again on the next one.
|
||||||
function releaseHeat() {
|
function releaseHeat() {
|
||||||
clearTimeout(layerState._heatRebuildTimer);
|
clearTimeout(layerState._heatRebuildTimer);
|
||||||
layerState._heatRebuildTimer = null;
|
layerState._heatRebuildTimer = null;
|
||||||
@@ -26,7 +26,8 @@ export function createLifecycle({
|
|||||||
/**
|
/**
|
||||||
* Shell services: `imageryHost()` resolves where draped imagery goes on
|
* Shell services: `imageryHost()` resolves where draped imagery goes on
|
||||||
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
|
* 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
|
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
|
||||||
*/
|
*/
|
||||||
attachShellServices(services) {
|
attachShellServices(services) {
|
||||||
@@ -40,36 +41,33 @@ export function createLifecycle({
|
|||||||
init(viewer, options) {
|
init(viewer, options) {
|
||||||
this.attachRenderer(viewer, options);
|
this.attachRenderer(viewer, options);
|
||||||
},
|
},
|
||||||
|
/** Draws on `engine` (the scene's globe engine); without one, nothing. */
|
||||||
attachRenderer(
|
attachRenderer(
|
||||||
viewer,
|
_viewer,
|
||||||
{ engine = null, waitForCameraRestore = false } = {},
|
{ engine = null, waitForCameraRestore = false } = {},
|
||||||
) {
|
) {
|
||||||
if (layerState._destroyed) return;
|
if (layerState._destroyed) return;
|
||||||
if (layerState._renderer) return;
|
if (layerState._renderer || !engine) return;
|
||||||
layerState._renderer = config.renderer.create({
|
layerState._renderer = config.renderer.create({ engine, id, services });
|
||||||
viewer,
|
if (!layerState._renderer) return;
|
||||||
engine,
|
|
||||||
id,
|
|
||||||
services,
|
|
||||||
screenSpaceEventHandlerFactory: config.screenSpaceEventHandlerFactory,
|
|
||||||
});
|
|
||||||
layerState._viewer = layerState._renderer.view;
|
layerState._viewer = layerState._renderer.view;
|
||||||
layerState._dataSource = layerState._renderer.dataSource;
|
layerState._dataSource = layerState._renderer.dataSource;
|
||||||
layerState._renderer.setVisible(false);
|
layerState._renderer.setVisible(false);
|
||||||
layerState._restoringViewport = waitForCameraRestore;
|
layerState._restoringViewport = waitForCameraRestore;
|
||||||
if (layerState._enabled && !waitForCameraRestore) {
|
if (layerState._enabled && !waitForCameraRestore) this.showRenderer();
|
||||||
this.showRenderer();
|
// Enabled before a renderer: the load enable() could not start.
|
||||||
layerState._renderer.onEnable();
|
if (
|
||||||
}
|
layerState._enabled &&
|
||||||
|
!layerState._fires.length &&
|
||||||
|
!layerState._loading
|
||||||
|
)
|
||||||
|
void components.ingestion.loadHeatmap();
|
||||||
},
|
},
|
||||||
|
|
||||||
resumeViewport() {
|
resumeViewport() {
|
||||||
if (!layerState._restoringViewport) return;
|
if (!layerState._restoringViewport) return;
|
||||||
layerState._restoringViewport = false;
|
layerState._restoringViewport = false;
|
||||||
if (layerState._enabled && layerState._renderer) {
|
if (layerState._enabled && layerState._renderer) this.showRenderer();
|
||||||
this.showRenderer();
|
|
||||||
layerState._renderer.onEnable();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
detachRenderer() {
|
detachRenderer() {
|
||||||
@@ -77,7 +75,7 @@ export function createLifecycle({
|
|||||||
components.viewport.removeMoveEndWatcher();
|
components.viewport.removeMoveEndWatcher();
|
||||||
components.viewport.removeLodWatcher();
|
components.viewport.removeLodWatcher();
|
||||||
unregisterPickOwner(id);
|
unregisterPickOwner(id);
|
||||||
components.rendering.removeDetectionCollections(layerState._viewer);
|
components.rendering.removeDetectionCollections();
|
||||||
releaseHeat();
|
releaseHeat();
|
||||||
layerState._renderer?.destroy();
|
layerState._renderer?.destroy();
|
||||||
layerState._renderer = layerState._viewer = layerState._dataSource = null;
|
layerState._renderer = layerState._viewer = layerState._dataSource = null;
|
||||||
@@ -112,16 +110,13 @@ export function createLifecycle({
|
|||||||
components.rendering.renderCurrentLod(true);
|
components.rendering.renderCurrentLod(true);
|
||||||
},
|
},
|
||||||
|
|
||||||
async enable(viewer) {
|
async enable() {
|
||||||
if (layerState._destroyed) return;
|
if (layerState._destroyed) return;
|
||||||
if (!layerState._dataSource) this.init(viewer);
|
|
||||||
layerState._enabled = true;
|
layerState._enabled = true;
|
||||||
const renderer = layerState._renderer;
|
if (!layerState._renderer) return;
|
||||||
this.showRenderer();
|
this.showRenderer();
|
||||||
if (!layerState._fires.length && !layerState._loading)
|
if (!layerState._fires.length && !layerState._loading)
|
||||||
await components.ingestion.loadHeatmap();
|
await components.ingestion.loadHeatmap();
|
||||||
if (layerState._enabled && layerState._renderer === renderer)
|
|
||||||
renderer.onEnable();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
@@ -141,7 +136,7 @@ export function createLifecycle({
|
|||||||
components.viewport.removeLodWatcher();
|
components.viewport.removeLodWatcher();
|
||||||
},
|
},
|
||||||
|
|
||||||
destroy(viewer = layerState._viewer) {
|
destroy() {
|
||||||
layerState.request?.abort();
|
layerState.request?.abort();
|
||||||
layerState.request = null;
|
layerState.request = null;
|
||||||
layerState._loading = false;
|
layerState._loading = false;
|
||||||
@@ -152,7 +147,7 @@ export function createLifecycle({
|
|||||||
components.viewport.removeMoveEndWatcher();
|
components.viewport.removeMoveEndWatcher();
|
||||||
components.selection.removeClickHandler();
|
components.selection.removeClickHandler();
|
||||||
unregisterPickOwner(id);
|
unregisterPickOwner(id);
|
||||||
components.rendering.removeDetectionCollections(viewer);
|
components.rendering.removeDetectionCollections();
|
||||||
layerState._renderer?.destroy();
|
layerState._renderer?.destroy();
|
||||||
layerState._renderer = null;
|
layerState._renderer = null;
|
||||||
layerState._viewer = null;
|
layerState._viewer = null;
|
||||||
|
|||||||
@@ -322,14 +322,18 @@ export function createQueries({
|
|||||||
components.rendering.applyHandoffFade();
|
components.rendering.applyHandoffFade();
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Test seam: entities drawn by the layer's data source. */
|
/** Test seam: ground cells the renderer drew. */
|
||||||
_entityCountForTest() {
|
_cellCountForTest() {
|
||||||
return layerState._dataSource?.entities?.values?.length ?? 0;
|
return layerState._renderer?.cellCount?.() ?? 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Test seam that binds the production click path and indexes. */
|
/**
|
||||||
_bindInteractionForTest(viewer, fires = []) {
|
* Test seam that binds the production click path and indexes, on the
|
||||||
layerState._viewer = viewer;
|
* 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._enabled = true;
|
||||||
layerState._allFires = fires;
|
layerState._allFires = fires;
|
||||||
layerState._allFiresByFrp = [...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 { packImageAtlas } from '../../shared/render/imageAtlas.js';
|
||||||
|
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
|
||||||
|
|
||||||
const WHITE = Object.freeze([1, 1, 1, 1]);
|
const WHITE = Object.freeze([1, 1, 1, 1]);
|
||||||
const CELL = 60,
|
const CELL = 60,
|
||||||
PAD = 2,
|
PAD = 2,
|
||||||
BASE_SIZE = 56;
|
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) =>
|
const imageFromUrl = (url) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
@@ -18,6 +25,19 @@ const imageFromUrl = (url) =>
|
|||||||
};
|
};
|
||||||
image.src = 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(
|
export function createFirmsSpriteBank(
|
||||||
engine,
|
engine,
|
||||||
{
|
{
|
||||||
@@ -35,8 +55,11 @@ export function createFirmsSpriteBank(
|
|||||||
destroyed = false,
|
destroyed = false,
|
||||||
generation = 0,
|
generation = 0,
|
||||||
queued = false,
|
queued = false,
|
||||||
|
// Every row is rewritten on the next flush (else only `showDirty`).
|
||||||
|
full = true,
|
||||||
set = null,
|
set = null,
|
||||||
atlas = null;
|
atlas = null;
|
||||||
|
const showDirty = new Set();
|
||||||
const images = new Map();
|
const images = new Map();
|
||||||
const loaded = new Map();
|
const loaded = new Map();
|
||||||
const getImage = (url) => {
|
const getImage = (url) => {
|
||||||
@@ -57,6 +80,7 @@ export function createFirmsSpriteBank(
|
|||||||
return task;
|
return task;
|
||||||
};
|
};
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
|
full = true;
|
||||||
if (set && set.count() !== 0) {
|
if (set && set.count() !== 0) {
|
||||||
set.setCount(0);
|
set.setCount(0);
|
||||||
set.commit();
|
set.commit();
|
||||||
@@ -73,12 +97,12 @@ export function createFirmsSpriteBank(
|
|||||||
lookup.set(url, { key, scale: width / BASE_SIZE });
|
lookup.set(url, { key, scale: width / BASE_SIZE });
|
||||||
return { key, image, width, height };
|
return { key, image, width, height };
|
||||||
});
|
});
|
||||||
const atlas = packImageAtlas(entries, {
|
const packed = packImageAtlas(entries, {
|
||||||
canvas: createCanvas(),
|
canvas: createCanvas(),
|
||||||
cell: CELL,
|
cell: CELL,
|
||||||
pad: PAD,
|
pad: PAD,
|
||||||
});
|
});
|
||||||
return { ...atlas, lookup };
|
return { ...packed, lookup };
|
||||||
}
|
}
|
||||||
async function flush() {
|
async function flush() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
@@ -98,6 +122,7 @@ export function createFirmsSpriteBank(
|
|||||||
id,
|
id,
|
||||||
kind: 'sprite',
|
kind: 'sprite',
|
||||||
capacity,
|
capacity,
|
||||||
|
order: FIRMS_SPRITE_ORDER,
|
||||||
depthTest: 'always',
|
depthTest: 'always',
|
||||||
pickable: true,
|
pickable: true,
|
||||||
sharpen: true,
|
sharpen: true,
|
||||||
@@ -107,19 +132,27 @@ export function createFirmsSpriteBank(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
atlas = next;
|
atlas = next;
|
||||||
|
full = true;
|
||||||
}
|
}
|
||||||
snapshot.forEach((row, index) => {
|
if (full || set.count() !== snapshot.length) {
|
||||||
const cell = atlas.lookup.get(row.image);
|
snapshot.forEach((row, index) => {
|
||||||
set.set(index, {
|
const cell = atlas.lookup.get(row.image);
|
||||||
position: row.position,
|
set.set(index, {
|
||||||
cell: cell.key,
|
position: row.position,
|
||||||
scale: cell.scale,
|
cell: cell.key,
|
||||||
color: fade,
|
scale: cell.scale,
|
||||||
show: row.show,
|
color: fade,
|
||||||
pickId: row.id,
|
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();
|
set.commit();
|
||||||
}
|
}
|
||||||
function schedule() {
|
function schedule() {
|
||||||
@@ -145,6 +178,7 @@ export function createFirmsSpriteBank(
|
|||||||
removeAll() {
|
removeAll() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
rows = [];
|
rows = [];
|
||||||
|
full = true;
|
||||||
schedule();
|
schedule();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -154,6 +188,7 @@ export function createFirmsSpriteBank(
|
|||||||
if (destroyed) return null;
|
if (destroyed) return null;
|
||||||
if (rows.length >= capacity)
|
if (rows.length >= capacity)
|
||||||
throw new RangeError('FIRMS sprite capacity exceeded');
|
throw new RangeError('FIRMS sprite capacity exceeded');
|
||||||
|
const index = rows.length;
|
||||||
let show = description.show !== false;
|
let show = description.show !== false;
|
||||||
const row = {
|
const row = {
|
||||||
...description,
|
...description,
|
||||||
@@ -164,13 +199,20 @@ export function createFirmsSpriteBank(
|
|||||||
const next = Boolean(value);
|
const next = Boolean(value);
|
||||||
if (next === show || destroyed) return;
|
if (next === show || destroyed) return;
|
||||||
show = next;
|
show = next;
|
||||||
|
showDirty.add(index);
|
||||||
schedule();
|
schedule();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
rows.push(row);
|
rows.push(row);
|
||||||
|
full = true;
|
||||||
schedule();
|
schedule();
|
||||||
return row;
|
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`. */
|
/** The hand-off fade: every marker at `alpha`, drawn while `show`. */
|
||||||
setFade(alpha, show) {
|
setFade(alpha, show) {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
@@ -178,6 +220,7 @@ export function createFirmsSpriteBank(
|
|||||||
if (next[3] === fade[3] && Boolean(show) === fadeShown) return;
|
if (next[3] === fade[3] && Boolean(show) === fadeShown) return;
|
||||||
fade = next;
|
fade = next;
|
||||||
fadeShown = Boolean(show);
|
fadeShown = Boolean(show);
|
||||||
|
full = true;
|
||||||
schedule();
|
schedule();
|
||||||
},
|
},
|
||||||
setVisible(value) {
|
setVisible(value) {
|
||||||
@@ -186,11 +229,16 @@ export function createFirmsSpriteBank(
|
|||||||
if (!visible) hide();
|
if (!visible) hide();
|
||||||
schedule();
|
schedule();
|
||||||
},
|
},
|
||||||
|
/** The mover set drawing the glows (null before the first flush). */
|
||||||
|
get set() {
|
||||||
|
return set;
|
||||||
|
},
|
||||||
destroy() {
|
destroy() {
|
||||||
if (destroyed) return;
|
if (destroyed) return;
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
generation++;
|
generation++;
|
||||||
rows = [];
|
rows = [];
|
||||||
|
showDirty.clear();
|
||||||
images.clear();
|
images.clear();
|
||||||
loaded.clear();
|
loaded.clear();
|
||||||
set?.destroy();
|
set?.destroy();
|
||||||
@@ -372,12 +372,12 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureDetectionCollections() {
|
function ensureDetectionCollections() {
|
||||||
if (!layerState._viewer || layerState._billboards) return;
|
if (!layerState._renderer || layerState._billboards) return;
|
||||||
layerState._billboards = layerState._renderer.ensureSprites();
|
layerState._billboards = layerState._renderer.ensureSprites();
|
||||||
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
|
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeDetectionCollections(viewer) {
|
function removeDetectionCollections() {
|
||||||
layerState._renderer?.removeSprites();
|
layerState._renderer?.removeSprites();
|
||||||
layerState._billboards = null;
|
layerState._billboards = null;
|
||||||
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
|
||||||
@@ -385,21 +385,14 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared horizon occluder positioned at the current camera, or null when
|
* The ellipsoid horizon at the current camera (the engine's
|
||||||
* there is no usable camera yet (init order / headless stubs).
|
* projector.horizonVisible, Cesium's EllipsoidalOccluder test), or null
|
||||||
* `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium;
|
* without a renderer.
|
||||||
* both are probed so a partial stub (either shape) still gets a real
|
* @returns {?{isPointVisible: function(Object): boolean}}
|
||||||
* occluder rather than silently degrading to "nothing is ever occluded".
|
|
||||||
* @returns {?Cesium.EllipsoidalOccluder}
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function fireHorizonOccluder() {
|
function fireHorizonOccluder() {
|
||||||
if (layerState._renderer) return layerState._renderer.horizon();
|
return layerState._renderer?.horizon() ?? null;
|
||||||
const camera = layerState._viewer?.camera?.positionWC
|
|
||||||
? layerState._viewer.camera
|
|
||||||
: layerState._viewer?.scene?.camera;
|
|
||||||
if (!camera?.positionWC) return null;
|
|
||||||
return config.horizonOccluder(camera);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,28 +21,21 @@ export function createSelection({
|
|||||||
getContextStore,
|
getContextStore,
|
||||||
registerEntityContext,
|
registerEntityContext,
|
||||||
} = services.context;
|
} = 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
|
* mirroring the flight layers). Clicking a fire sprite selects it and
|
||||||
* surfaces it in the shared context store; clicking empty space clears
|
* surfaces it in the shared context store; clicking empty space clears
|
||||||
* the selection. Picks owned by sibling layers are left alone.
|
* the selection. Picks owned by sibling layers are left alone.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function installClickHandler() {
|
function installClickHandler() {
|
||||||
if (layerState._clickHandler || !layerState._viewer) return;
|
if (layerState._clickHandler || !layerState._renderer) return;
|
||||||
layerState._clickHandler = layerState._renderer
|
layerState._clickHandler = layerState._renderer.onClick((click) => {
|
||||||
? layerState._renderer.createInputHandler()
|
|
||||||
: screenSpaceEventHandlerFactory(layerState._viewer);
|
|
||||||
const clickType =
|
|
||||||
layerState._renderer?.clickType ?? config.leftClickEventType;
|
|
||||||
layerState._clickHandler.setInputAction((click) => {
|
|
||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||||
if (!isPointerFree()) return;
|
if (!isPointerFree()) return;
|
||||||
const picked = layerState._renderer
|
const picked = click.picked;
|
||||||
? layerState._renderer.pick(click.position, click)
|
|
||||||
: layerState._viewer.scene.pick(click.position);
|
|
||||||
const fire = pickedFire(picked);
|
const fire = pickedFire(picked);
|
||||||
if (fire) {
|
if (fire) {
|
||||||
selectAndFocusFire(fire);
|
selectAndFocusFire(fire);
|
||||||
@@ -67,14 +60,12 @@ export function createSelection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearFireSelection();
|
clearFireSelection();
|
||||||
}, clickType);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeClickHandler() {
|
function removeClickHandler() {
|
||||||
if (layerState._clickHandler) {
|
layerState._clickHandler?.();
|
||||||
layerState._clickHandler.destroy();
|
layerState._clickHandler = null;
|
||||||
layerState._clickHandler = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Select one stable detection and request one UI-owned camera transfer. */
|
/** Select one stable detection and request one UI-owned camera transfer. */
|
||||||
|
|||||||
@@ -98,13 +98,9 @@ export function createViewport({
|
|||||||
|
|
||||||
function computeViewRect() {
|
function computeViewRect() {
|
||||||
try {
|
try {
|
||||||
if (layerState._renderer)
|
return (
|
||||||
return layerState._renderer.viewRectangle(layerState.scratchViewRect);
|
layerState._renderer?.viewRectangle(layerState.scratchViewRect) ?? null
|
||||||
const rect = layerState._viewer?.camera?.computeViewRectangle(
|
|
||||||
layerState._viewer?.scene?.globe?.ellipsoid,
|
|
||||||
layerState.scratchViewRect,
|
|
||||||
);
|
);
|
||||||
return rect || null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { fromDegrees } from '../../geo/cartesian3.js';
|
import { fromDegrees } from '../../geo/cartesian3.js';
|
||||||
import { createGhostMapsCesiumRenderer } from './render/cesium.js';
|
|
||||||
import {
|
import {
|
||||||
decodeXml,
|
decodeXml,
|
||||||
kmzImageResolver,
|
kmzImageResolver,
|
||||||
@@ -149,7 +148,6 @@ function anchorOf(geometry) {
|
|||||||
export function createGhostMapsLayer({
|
export function createGhostMapsLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost = null,
|
overlayHost = null,
|
||||||
screenSpaceEventHandlerFactory = null,
|
|
||||||
createRenderer = null,
|
createRenderer = null,
|
||||||
picking = null,
|
picking = null,
|
||||||
pointer = null,
|
pointer = null,
|
||||||
@@ -187,10 +185,7 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
const notifyRow = () => _rowListener?.();
|
const notifyRow = () => _rowListener?.();
|
||||||
const render = () => _display?.requestRender();
|
const render = () => _display?.requestRender();
|
||||||
const canSelect = () =>
|
const canSelect = () => overlayHost && picking && createRenderer;
|
||||||
overlayHost &&
|
|
||||||
picking &&
|
|
||||||
(screenSpaceEventHandlerFactory || createRenderer);
|
|
||||||
|
|
||||||
function wanted(product) {
|
function wanted(product) {
|
||||||
return _selection ? _selection.has(product.id) : product.defaultOn;
|
return _selection ? _selection.has(product.id) : product.defaultOn;
|
||||||
@@ -597,18 +592,14 @@ export function createGhostMapsLayer({
|
|||||||
updateInterval: 300000,
|
updateInterval: 300000,
|
||||||
|
|
||||||
init(viewer, options = {}) {
|
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');
|
if (_display) throw new Error('GhostMaps display is already attached');
|
||||||
_display = viewer
|
if (typeof createRenderer !== 'function')
|
||||||
? createGhostMapsCesiumRenderer(
|
throw new TypeError('GhostMaps needs a renderer factory');
|
||||||
viewer,
|
_display = createRenderer(engine);
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
markerImage,
|
|
||||||
)
|
|
||||||
: createRenderer(engine);
|
|
||||||
if (!_enabled) return;
|
if (!_enabled) return;
|
||||||
installClickHandler();
|
installClickHandler();
|
||||||
publishCard();
|
publishCard();
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
import { createGhostMapsLayer } from './index.js';
|
import { createGhostMapsLayer } from './index.js';
|
||||||
|
import { createGhostMapsDisplay } from './render/display.js';
|
||||||
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||||
|
|
||||||
function element(localName, children = [], value = '', attributes = {}) {
|
function element(localName, children = [], value = '', attributes = {}) {
|
||||||
@@ -87,6 +88,30 @@ function fixtureKmz() {
|
|||||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
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(
|
function fixture(
|
||||||
t,
|
t,
|
||||||
{
|
{
|
||||||
@@ -118,22 +143,28 @@ function fixture(
|
|||||||
});
|
});
|
||||||
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
|
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
|
||||||
t.mock.method(console, 'warn', () => {});
|
t.mock.method(console, 'warn', () => {});
|
||||||
const mounted = new Set();
|
const engine = createFakeEngine();
|
||||||
const viewer = {
|
// What each ground set was last given, by set id.
|
||||||
scene: { requestRender() {} },
|
const ground = new Map();
|
||||||
dataSources: {
|
const createSet = engine.ground.createSet.bind(engine.ground);
|
||||||
async add(dataSource) {
|
engine.ground.createSet = (options) => {
|
||||||
mounted.add(dataSource);
|
const set = createSet(options);
|
||||||
return dataSource;
|
const replace = set.replace.bind(set);
|
||||||
},
|
set.replace = (items) => {
|
||||||
remove(dataSource) {
|
ground.set(options.id, items);
|
||||||
return mounted.delete(dataSource);
|
replace(items);
|
||||||
},
|
};
|
||||||
},
|
return set;
|
||||||
};
|
};
|
||||||
|
const display = () =>
|
||||||
|
createGhostMapsDisplay(engine, {
|
||||||
|
loadImage: async () => ({ naturalWidth: 32, naturalHeight: 32 }),
|
||||||
|
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
|
||||||
|
});
|
||||||
const layer = createGhostMapsLayer({
|
const layer = createGhostMapsLayer({
|
||||||
storage: null,
|
storage: null,
|
||||||
now,
|
now,
|
||||||
|
createRenderer: display,
|
||||||
...controls,
|
...controls,
|
||||||
source: {
|
source: {
|
||||||
getCatalog: async () => ({
|
getCatalog: async () => ({
|
||||||
@@ -147,12 +178,36 @@ function fixture(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(null, { engine });
|
||||||
layer.enable();
|
layer.enable();
|
||||||
t.after(() => layer.destroy());
|
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) => {
|
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
|
||||||
const { layer, viewer, mounted, created, revoked } = fixture(t, {
|
const { layer, viewer, mounted, created, revoked } = fixture(t, {
|
||||||
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
|
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'],
|
['Test report'],
|
||||||
);
|
);
|
||||||
assert.equal(h.mounted.size, 0);
|
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.mounted.size, 1);
|
||||||
assert.equal(h.created.length, 1);
|
assert.equal(h.created.length, 1);
|
||||||
assert.equal(h.revoked.length, 0);
|
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);
|
assert.equal(h.mounted.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('entity construction failure releases parsed image URLs', async (t) => {
|
test('a drawing failure releases parsed image URLs', async (t) => {
|
||||||
const { layer, viewer, mounted, created, revoked } = fixture(t);
|
const { layer, viewer, engine, mounted, created, revoked } = fixture(t);
|
||||||
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
|
t.mock.method(engine.ground, 'createSet', () => {
|
||||||
throw new Error('fixture entity failure');
|
throw new Error('fixture entity failure');
|
||||||
});
|
});
|
||||||
await layer.update(viewer);
|
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) => {
|
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),
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
reports: [
|
reports: [
|
||||||
dated('Old event', '10/20/2026'),
|
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(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.
|
// Filtering cannot shift pick ids onto a different report.
|
||||||
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
|
assert.ok(ids().includes('ghostmaps:cip:1:0'));
|
||||||
assert.equal(
|
assert.equal(ids().includes('ghostmaps:cip:0:0'), false);
|
||||||
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
|
||||||
t.mock.timers.enable({ apis: ['setInterval'] });
|
t.mock.timers.enable({ apis: ['setInterval'] });
|
||||||
let at = Date.UTC(2026, 10, 3, 12);
|
let at = Date.UTC(2026, 10, 3, 12);
|
||||||
let click;
|
|
||||||
const cards = [];
|
const cards = [];
|
||||||
const { layer, viewer, mounted } = fixture(t, {
|
const { layer, viewer, mounted, ids, editions, click } = fixture(t, {
|
||||||
now: () => at,
|
now: () => at,
|
||||||
reports: [
|
reports: [
|
||||||
dated('Expiring', '10/27/2026 12:00:00 PM'),
|
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: {
|
controls: {
|
||||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
picking,
|
||||||
setInputAction: (fn) => {
|
|
||||||
click = fn;
|
|
||||||
},
|
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
picking: { resolvePickId: (picked) => picked },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await layer.update(viewer);
|
await layer.update(viewer);
|
||||||
const ds = [...mounted][0];
|
const [edition] = editions();
|
||||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
click('ghostmaps:cip:0:0');
|
||||||
click({ position: { x: 0, y: 0 } });
|
|
||||||
assert.equal(cards.at(-1).length, 1);
|
assert.equal(cards.at(-1).length, 1);
|
||||||
at += 60_000;
|
at += 60_000;
|
||||||
t.mock.timers.tick(60_000);
|
t.mock.timers.tick(60_000);
|
||||||
assert.equal(layer.getStats().count, 1);
|
assert.equal(layer.getStats().count, 1);
|
||||||
assert.equal(ds.entities.values.length, 1);
|
// Dropped in place: the edition's drawing stays for the survivor.
|
||||||
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
|
assert.deepEqual(ids(), ['ghostmaps:cip:1:0']);
|
||||||
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
|
assert.deepEqual([...editions()], [edition]);
|
||||||
assert.deepEqual(cards.at(-1), []);
|
assert.deepEqual(cards.at(-1), []);
|
||||||
await layer.update(viewer);
|
await layer.update(viewer);
|
||||||
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
|
assert.deepEqual(
|
||||||
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
|
[...editions()],
|
||||||
click({ position: { x: 0, y: 0 } });
|
[edition],
|
||||||
|
'same-version KMZ is not reloaded',
|
||||||
|
);
|
||||||
|
click('ghostmaps:cip:1:0');
|
||||||
assert.match(cards.at(-1)[0].title, /Surviving/);
|
assert.match(cards.at(-1)[0].title, /Surviving/);
|
||||||
layer.disable();
|
layer.disable();
|
||||||
at += 14 * 86400_000;
|
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('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,
|
now: () => at,
|
||||||
reports: [
|
reports: [
|
||||||
{
|
{
|
||||||
@@ -502,11 +554,16 @@ test('expiry removes every geometry and polygon outline belonging to an old repo
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
await layer.update(viewer);
|
await layer.update(viewer);
|
||||||
const ds = [...mounted][0];
|
// The placemark, the line, the polygon and its outer outline.
|
||||||
assert.equal(ds.entities.values.length, 4);
|
assert.deepEqual(ids().sort(), [
|
||||||
|
'ghostmaps:cip:0:0',
|
||||||
|
'ghostmaps:cip:0:1',
|
||||||
|
'ghostmaps:cip:0:2',
|
||||||
|
'ghostmaps:cip:0:2:outline',
|
||||||
|
]);
|
||||||
at += 60_000;
|
at += 60_000;
|
||||||
t.mock.timers.tick(60_000);
|
t.mock.timers.tick(60_000);
|
||||||
assert.equal(ds.entities.values.length, 0);
|
assert.deepEqual(ids(), []);
|
||||||
assert.equal(layer.getStats().count, 0);
|
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) => {
|
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
|
||||||
let click;
|
|
||||||
const cards = [];
|
const cards = [];
|
||||||
const { layer, viewer } = fixture(t, {
|
const { layer, viewer, click } = fixture(t, {
|
||||||
now: () => Date.UTC(2026, 10, 3, 12),
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
|
||||||
controls: {
|
controls: {
|
||||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
picking,
|
||||||
setInputAction: (fn) => {
|
|
||||||
click = fn;
|
|
||||||
},
|
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
picking: { resolvePickId: (picked) => picked },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await layer.update(viewer);
|
await layer.update(viewer);
|
||||||
await ageChip(layer, 30).onClick();
|
await ageChip(layer, 30).onClick();
|
||||||
assert.equal(layer.getStats().count, 2);
|
assert.equal(layer.getStats().count, 2);
|
||||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
click('ghostmaps:cip:0:0');
|
||||||
click({ position: { x: 0, y: 0 } });
|
|
||||||
assert.equal(cards.at(-1).length, 1);
|
assert.equal(cards.at(-1).length, 1);
|
||||||
await ageChip(layer, 3).onClick();
|
await ageChip(layer, 3).onClick();
|
||||||
assert.equal(layer.getStats().count, 1);
|
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) => {
|
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
|
||||||
let click;
|
|
||||||
const cards = [];
|
const cards = [];
|
||||||
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
|
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),
|
now: () => Date.UTC(2026, 10, 3, 12),
|
||||||
reports,
|
reports,
|
||||||
controls: {
|
controls: {
|
||||||
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
picking,
|
||||||
setInputAction: (fn) => {
|
|
||||||
click = fn;
|
|
||||||
},
|
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
picking: { resolvePickId: (picked) => picked },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await layer.update(viewer);
|
await layer.update(viewer);
|
||||||
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
|
click('ghostmaps:cip:0:0');
|
||||||
click({ position: { x: 0, y: 0 } });
|
|
||||||
assert.equal(cards.at(-1).length, 1);
|
assert.equal(cards.at(-1).length, 1);
|
||||||
// The server answers the pinned version with a newer edition's bytes.
|
// The server answers the pinned version with a newer edition's bytes.
|
||||||
reports[0] = dated('Replacement', '11/2/2026');
|
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';
|
import { createGnssDisplay } from './render/display.js';
|
||||||
export { defaultGnssViewAnchor } from './render/cesium.js';
|
export { defaultGnssViewAnchor } from './render/display.js';
|
||||||
import { formatNumber, msg, t } from '../../i18n/index.js';
|
import { formatNumber, msg, t } from '../../i18n/index.js';
|
||||||
import { translateFeedError } from '../../ui/statusText.js';
|
import { translateFeedError } from '../../ui/statusText.js';
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +30,8 @@ const LEVEL_LABELS = Object.freeze({
|
|||||||
export function createGnssIntegrityLayer({
|
export function createGnssIntegrityLayer({
|
||||||
source,
|
source,
|
||||||
viewAnchor,
|
viewAnchor,
|
||||||
createRenderer = createGnssCesiumRenderer,
|
createRenderer = ({ engine, viewAnchor: anchor }) =>
|
||||||
|
createGnssDisplay(engine, { viewAnchor: anchor }),
|
||||||
now = () => Date.now(),
|
now = () => Date.now(),
|
||||||
monotonicNow = () => performance.now(),
|
monotonicNow = () => performance.now(),
|
||||||
windowMs: requestedWindowMs,
|
windowMs: requestedWindowMs,
|
||||||
@@ -160,7 +161,7 @@ export function createGnssIntegrityLayer({
|
|||||||
throw new Error('GNSS integrity layer is already initialized');
|
throw new Error('GNSS integrity layer is already initialized');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
try {
|
||||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
if (engine) this.attachRenderer(viewer, { engine });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -170,7 +171,7 @@ export function createGnssIntegrityLayer({
|
|||||||
attachRenderer(viewer, { engine = null } = {}) {
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
if (!initialized || renderer)
|
if (!initialized || renderer)
|
||||||
throw new Error('Invalid GNSS scene attachment');
|
throw new Error('Invalid GNSS scene attachment');
|
||||||
renderer = createRenderer({ viewer, engine, viewAnchor });
|
renderer = createRenderer({ engine, viewAnchor });
|
||||||
_removeCameraEnd = renderer.camera?.moveEnd?.addEventListener(
|
_removeCameraEnd = renderer.camera?.moveEnd?.addEventListener(
|
||||||
scheduleCameraRefresh,
|
scheduleCameraRefresh,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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 { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
|
||||||
import { LayerLifecycle } from '../../data/lifecycle.js';
|
import { LayerLifecycle } from '../../data/lifecycle.js';
|
||||||
import { layerFeedState } from '../../data/feedState.js';
|
import { layerFeedState } from '../../data/feedState.js';
|
||||||
@@ -23,22 +25,10 @@ function harness(
|
|||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
let anchor = initialAnchor;
|
let anchor = initialAnchor;
|
||||||
const sources = [];
|
const ground = createFakeGroundHarness();
|
||||||
const viewer = {
|
const { engine } = ground;
|
||||||
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
|
// What the manager's lifecycle reads of a scene besides the engine.
|
||||||
scene: {
|
const viewer = { scene: { globe: { show: true } } };
|
||||||
globe: { show: true },
|
|
||||||
groundPrimitives: {
|
|
||||||
add(value) {
|
|
||||||
sources.push(value);
|
|
||||||
},
|
|
||||||
remove(value) {
|
|
||||||
sources.splice(sources.indexOf(value), 1);
|
|
||||||
value.destroy();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
let clock = 1_000;
|
let clock = 1_000;
|
||||||
const anchors = [];
|
const anchors = [];
|
||||||
const layer = createGnssIntegrityLayer({
|
const layer = createGnssIntegrityLayer({
|
||||||
@@ -56,10 +46,16 @@ function harness(
|
|||||||
now: () => clock,
|
now: () => clock,
|
||||||
monotonicNow: () => clock,
|
monotonicNow: () => clock,
|
||||||
});
|
});
|
||||||
if (initialize) layer.init(viewer);
|
if (initialize) layer.init(null, { engine });
|
||||||
return {
|
return {
|
||||||
layer,
|
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,
|
anchors,
|
||||||
viewer,
|
viewer,
|
||||||
advance(ms) {
|
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', () => {
|
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
|
||||||
const anchor = defaultGnssViewAnchor({
|
const anchor = defaultGnssViewAnchor({
|
||||||
scene: {
|
host: { size: () => ({ width: 1000, height: 800 }) },
|
||||||
canvas: { clientWidth: 1000, clientHeight: 800 },
|
|
||||||
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
|
|
||||||
},
|
|
||||||
camera: {
|
camera: {
|
||||||
pickEllipsoid(pixel) {
|
pickEllipsoid(pixel) {
|
||||||
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
|
assert.deepEqual(pixel, { x: 500, y: 400 });
|
||||||
return Cesium.Cartesian3.fromDegrees(-97, 30);
|
return geo.cartesian3.fromDegrees(-97, 30);
|
||||||
},
|
},
|
||||||
computeViewRectangle: () => {
|
viewRectangle: () => {
|
||||||
throw new Error('centre hit must not compute a rectangle');
|
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 },
|
{ initialize: false },
|
||||||
);
|
);
|
||||||
const manager = new LayerLifecycle(h.viewer);
|
const manager = new LayerLifecycle(h.viewer, { engine: h.engine });
|
||||||
manager.register(h.layer);
|
manager.register(h.layer);
|
||||||
h.layer.attachDataManager(manager);
|
h.layer.attachDataManager(manager);
|
||||||
const changes = [];
|
const changes = [];
|
||||||
manager.subscribe((change) => changes.push(change.type));
|
manager.subscribe((change) => changes.push(change.type));
|
||||||
await manager.setEnabled(h.layer.id, true);
|
await manager.setEnabled(h.layer.id, true);
|
||||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.setAnchor({ latitude: 55, longitude: 20 });
|
h.setAnchor({ latitude: 55, longitude: 20 });
|
||||||
h.viewer.camera.moveEnd.raiseEvent();
|
h.camera.moveEnd.raiseEvent();
|
||||||
h.advance(2500);
|
h.advance(2500);
|
||||||
t.mock.timers.tick(2500);
|
t.mock.timers.tick(2500);
|
||||||
await flush();
|
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/);
|
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
|
||||||
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
|
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.advance(5000);
|
h.advance(5000);
|
||||||
t.mock.timers.tick(5000);
|
t.mock.timers.tick(5000);
|
||||||
await flush();
|
await flush();
|
||||||
assert.equal(calls, 2, 'nearby views reuse the same query region');
|
assert.equal(calls, 2, 'nearby views reuse the same query region');
|
||||||
await manager.destroyAll();
|
await manager.destroyAll();
|
||||||
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
|
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
|
||||||
assert.equal(h.viewer.camera.changed.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) => {
|
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();
|
h.layer.enable();
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
h.setAnchor({ latitude: 54, longitude: 19 });
|
h.setAnchor({ latitude: 54, longitude: 19 });
|
||||||
h.viewer.camera.moveEnd.raiseEvent();
|
h.camera.moveEnd.raiseEvent();
|
||||||
h.advance(2500);
|
h.advance(2500);
|
||||||
t.mock.timers.tick(2500);
|
t.mock.timers.tick(2500);
|
||||||
await flush();
|
await flush();
|
||||||
h.setAnchor({ latitude: 35, longitude: 140 });
|
h.setAnchor({ latitude: 35, longitude: 140 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.advance(2500);
|
h.advance(2500);
|
||||||
t.mock.timers.tick(2500);
|
t.mock.timers.tick(2500);
|
||||||
await flush();
|
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.equal(calls.length, 3);
|
||||||
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
|
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
|
||||||
h.setAnchor({ latitude: 10, longitude: 10 });
|
h.setAnchor({ latitude: 10, longitude: 10 });
|
||||||
h.viewer.camera.moveEnd.raiseEvent();
|
h.camera.moveEnd.raiseEvent();
|
||||||
h.layer.disable();
|
h.layer.disable();
|
||||||
h.advance(5000);
|
h.advance(5000);
|
||||||
t.mock.timers.tick(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', () => {
|
test('the layer requires a snapshot source and cannot be initialized twice', () => {
|
||||||
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
|
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
|
||||||
const { layer } = harness(async () => ({ rows: [] }));
|
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 () => {
|
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.deepEqual(anchor, { latitude: 50, longitude: 30 });
|
||||||
assert.ok(signal instanceof AbortSignal);
|
assert.ok(signal instanceof AbortSignal);
|
||||||
return {
|
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(await layer.update(), false, 'disabled layers do not fetch');
|
||||||
assert.equal(anchors.length, 0);
|
assert.equal(anchors.length, 0);
|
||||||
layer.enable();
|
layer.enable();
|
||||||
assert.equal(sources[0].show, true);
|
assert.equal(cells().shown, true);
|
||||||
assert.equal(await layer.update(), true);
|
assert.equal(await layer.update(), true);
|
||||||
const entities = sources[0].get(0).geometryInstances;
|
const { items, options } = cells();
|
||||||
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
|
assert.equal(items.length, 1, 'the one-aircraft cell is withheld');
|
||||||
assert.equal(entities[0].id, 'gnss-integrity:280:420');
|
// Cell 280:420, half a degree from 50° N, 30° E, at the high level.
|
||||||
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
|
assert.deepEqual(
|
||||||
assert.equal(sources[0].get(0).allowPicking, false);
|
{ ...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();
|
const stats = layer.getStats();
|
||||||
assert.equal(stats.count, 1);
|
assert.equal(stats.count, 1);
|
||||||
assert.equal(stats.error, null);
|
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 () => {
|
test('a failed refresh keeps the last cells and reports the error', async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const { layer, sources } = harness(async () => {
|
const { layer, drawn } = harness(async () => {
|
||||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||||
return {
|
return {
|
||||||
rows: [
|
rows: [
|
||||||
@@ -288,14 +289,14 @@ test('a failed refresh keeps the last cells and reports the error', async () =>
|
|||||||
assert.equal(layer.getStats().stale, true);
|
assert.equal(layer.getStats().stale, true);
|
||||||
fail = true;
|
fail = true;
|
||||||
assert.equal(await layer.update(), 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().error, 'adsb.lol HTTP 502');
|
||||||
assert.equal(layer.getStats().count, 1);
|
assert.equal(layer.getStats().count, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('observations expire with the window, and disable aborts the request in flight', async () => {
|
test('observations expire with the window, and disable aborts the request in flight', async () => {
|
||||||
let pending;
|
let pending;
|
||||||
const { layer, sources, advance } = harness(
|
const { layer, drawn, cells, advance } = harness(
|
||||||
(anchor, { signal }) =>
|
(anchor, { signal }) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
pending = { resolve };
|
pending = { resolve };
|
||||||
@@ -315,17 +316,17 @@ test('observations expire with the window, and disable aborts the request in fli
|
|||||||
pending.resolve({ rows: [] });
|
pending.resolve({ rows: [] });
|
||||||
assert.equal(await result, true);
|
assert.equal(await result, true);
|
||||||
assert.equal(layer.getStats().count, 0);
|
assert.equal(layer.getStats().count, 0);
|
||||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
assert.equal(drawn(), 0);
|
||||||
|
|
||||||
result = layer.update();
|
result = layer.update();
|
||||||
layer.disable();
|
layer.disable();
|
||||||
assert.equal(await result, false);
|
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;
|
let calls = 0;
|
||||||
const { layer, sources } = harness(
|
const { layer, ground } = harness(
|
||||||
async () => {
|
async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return { rows: [] };
|
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(calls, 0);
|
||||||
assert.equal(layer.getStats().error, null);
|
assert.equal(layer.getStats().error, null);
|
||||||
layer.destroy();
|
layer.destroy();
|
||||||
assert.equal(sources.length, 0);
|
assert.equal(ground.sets(), 0);
|
||||||
assert.equal(layer.getStats().count, 0);
|
assert.equal(layer.getStats().count, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
|
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.
|
// 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(),
|
rows: threeHealthy(),
|
||||||
ageMs: now() - 1_000,
|
ageMs: now() - 1_000,
|
||||||
stale: true,
|
stale: true,
|
||||||
@@ -369,12 +370,12 @@ test('a replayed stale snapshot keeps its observation age and expires on time',
|
|||||||
0,
|
0,
|
||||||
'the replay did not restart the window',
|
'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 () => {
|
test('cells expire during prolonged fetch failures', async () => {
|
||||||
let fail = false;
|
let fail = false;
|
||||||
const { layer, sources, advance } = harness(async () => {
|
const { layer, drawn, advance } = harness(async () => {
|
||||||
if (fail) throw new Error('adsb.lol HTTP 502');
|
if (fail) throw new Error('adsb.lol HTTP 502');
|
||||||
return { rows: threeHealthy() };
|
return { rows: threeHealthy() };
|
||||||
});
|
});
|
||||||
@@ -387,13 +388,13 @@ test('cells expire during prolonged fetch failures', async () => {
|
|||||||
advance(11 * MIN);
|
advance(11 * MIN);
|
||||||
assert.equal(await layer.update(), true);
|
assert.equal(await layer.update(), true);
|
||||||
assert.equal(layer.getStats().count, 0);
|
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');
|
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('cells expire while no view anchor is available', async () => {
|
test('cells expire while no view anchor is available', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const { layer, sources, advance, setAnchor } = harness(async () => {
|
const { layer, drawn, advance, setAnchor } = harness(async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
return { rows: threeHealthy() };
|
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(await layer.update(), true);
|
||||||
assert.equal(calls, 1, 'no anchor means no request');
|
assert.equal(calls, 1, 'no anchor means no request');
|
||||||
assert.equal(layer.getStats().count, 0);
|
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);
|
assert.equal(layer.getStats().error, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('re-enabling after a long disable does not show cells past the window', async () => {
|
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(),
|
rows: threeHealthy(),
|
||||||
}));
|
}));
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
assert.equal(drawn(), 1);
|
||||||
layer.disable();
|
layer.disable();
|
||||||
advance(31 * MIN);
|
advance(31 * MIN);
|
||||||
layer.enable();
|
layer.enable();
|
||||||
assert.equal(layer.getStats().count, 0);
|
assert.equal(layer.getStats().count, 0);
|
||||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
|
assert.equal(drawn(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
const threeCells = () => ({
|
const threeCells = () => ({
|
||||||
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
|
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 () => {
|
test('an identical snapshot leaves the drawn cells untouched', async () => {
|
||||||
const { layer, sources } = harness(async () => threeCells());
|
const { layer, drawn, cells } = harness(async () => threeCells());
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
const first = sources[0].get(0);
|
const first = cells();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
|
assert.equal(drawn(), 1);
|
||||||
assert.equal(sources[0].get(0), first);
|
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) => {
|
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 },
|
{ initialize: false },
|
||||||
);
|
);
|
||||||
const manager = new LayerLifecycle(h.viewer);
|
const manager = new LayerLifecycle(h.viewer, { engine: h.engine });
|
||||||
manager.register(h.layer);
|
manager.register(h.layer);
|
||||||
h.layer.attachDataManager(manager);
|
h.layer.attachDataManager(manager);
|
||||||
const changes = [];
|
const changes = [];
|
||||||
@@ -496,7 +498,7 @@ test('provider cooldown is preserved across movement and toggling; disable cance
|
|||||||
h.layer.enable();
|
h.layer.enable();
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
h.setAnchor({ latitude: 10, longitude: 20 });
|
h.setAnchor({ latitude: 10, longitude: 20 });
|
||||||
h.viewer.camera.moveEnd.raiseEvent();
|
h.camera.moveEnd.raiseEvent();
|
||||||
h.advance(30000);
|
h.advance(30000);
|
||||||
t.mock.timers.tick(30000);
|
t.mock.timers.tick(30000);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -583,7 +585,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) =>
|
|||||||
h.layer.enable();
|
h.layer.enable();
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.advance(2499);
|
h.advance(2499);
|
||||||
t.mock.timers.tick(2499);
|
t.mock.timers.tick(2499);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -594,7 +596,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) =>
|
|||||||
assert.equal(calls, 2);
|
assert.equal(calls, 2);
|
||||||
h.advance(3000);
|
h.advance(3000);
|
||||||
h.setAnchor({ latitude: 52, longitude: 32 });
|
h.setAnchor({ latitude: 52, longitude: 32 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.advance(599);
|
h.advance(599);
|
||||||
t.mock.timers.tick(599);
|
t.mock.timers.tick(599);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -619,7 +621,7 @@ test('camera movement during initial activation waits for acquisition before fol
|
|||||||
h.layer.enable();
|
h.layer.enable();
|
||||||
const initial = h.layer.update();
|
const initial = h.layer.update();
|
||||||
h.setAnchor({ latitude: 51, longitude: 31 });
|
h.setAnchor({ latitude: 51, longitude: 31 });
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
h.advance(2500);
|
h.advance(2500);
|
||||||
t.mock.timers.tick(2500);
|
t.mock.timers.tick(2500);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -652,13 +654,13 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async (
|
|||||||
now: () => wall,
|
now: () => wall,
|
||||||
monotonicNow: () => mono,
|
monotonicNow: () => mono,
|
||||||
});
|
});
|
||||||
layer.init(h.viewer);
|
layer.init(null, { engine: h.engine });
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
wall -= 3600000;
|
wall -= 3600000;
|
||||||
mono = 3000;
|
mono = 3000;
|
||||||
anchor = { latitude: 51, longitude: 31 };
|
anchor = { latitude: 51, longitude: 31 };
|
||||||
h.viewer.camera.changed.raiseEvent();
|
h.camera.changed.raiseEvent();
|
||||||
mono += 600;
|
mono += 600;
|
||||||
t.mock.timers.tick(600);
|
t.mock.timers.tick(600);
|
||||||
await flush();
|
await flush();
|
||||||
@@ -666,37 +668,58 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async (
|
|||||||
layer.destroy();
|
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() }));
|
const h = harness(async () => ({ rows: threeHealthy() }));
|
||||||
h.layer.enable();
|
h.layer.enable();
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
const fill = h.sources[0].get(0);
|
assert.equal(h.cells().options.pickable, false);
|
||||||
let updates = 0;
|
// A pick at the drawn cell (1° N, 30.2° E) finds nothing of the layer.
|
||||||
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
|
h.engine.camera.setView({
|
||||||
fill.update({ passes: { pick: true } });
|
destination: geo.cartesian3.fromDegrees(30.2, 1.2, 100_000),
|
||||||
assert.equal(updates, 0);
|
});
|
||||||
fill.update({ passes: { render: true, pick: false } });
|
const { width, height } = h.engine.host.size();
|
||||||
assert.equal(updates, 1);
|
assert.equal(
|
||||||
|
h.engine.picker.pick({ x: width / 2, y: height / 2 }),
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
h.layer.destroy();
|
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', () => {
|
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
|
||||||
assert.equal(defaultGnssViewAnchor({}), null);
|
assert.equal(defaultGnssViewAnchor({}), null);
|
||||||
const viewer = {
|
const radians = geo.math.toRadians;
|
||||||
|
const engine = {
|
||||||
|
host: { size: () => ({ width: 1000, height: 800 }) },
|
||||||
camera: {
|
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.latitude - 41) < 1e-10);
|
||||||
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
|
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
|
||||||
viewer.camera = {
|
engine.camera = {
|
||||||
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
|
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.latitude - 41) < 1e-10);
|
||||||
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 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 () => {
|
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();
|
h.layer.enable();
|
||||||
const pending = h.layer.update();
|
const pending = h.layer.update();
|
||||||
h.layer.detachRenderer();
|
h.layer.detachRenderer();
|
||||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
|
assert.equal(h.camera.changed.numberOfListeners, 0);
|
||||||
resolve({ rows: threeHealthy() });
|
resolve({ rows: threeHealthy() });
|
||||||
assert.equal(await pending, true);
|
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);
|
assert.equal(h.layer.getStats().levels.low, 1);
|
||||||
h.layer.attachRenderer(h.viewer);
|
h.layer.attachRenderer(null, { engine: h.engine });
|
||||||
assert.equal(h.sources[0].length, 1);
|
assert.equal(h.drawn(), 1);
|
||||||
assert.equal(calls, 1);
|
assert.equal(calls, 1);
|
||||||
assert.equal(h.viewer.camera.changed.numberOfListeners, 1);
|
assert.equal(h.camera.changed.numberOfListeners, 1);
|
||||||
} finally {
|
} finally {
|
||||||
h.layer.destroy();
|
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.
|
// card's info line shows them in the UI language, the HTTP form included.
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
import { setLocale } from '../../i18n/index.js';
|
import { setLocale } from '../../i18n/index.js';
|
||||||
import { createGnssIntegrityLayer } from './index.js';
|
import { createGnssIntegrityLayer } from './index.js';
|
||||||
|
|
||||||
@@ -20,13 +20,7 @@ async function failedLayer(message) {
|
|||||||
now: () => 1_000,
|
now: () => 1_000,
|
||||||
monotonicNow: () => 1_000,
|
monotonicNow: () => 1_000,
|
||||||
});
|
});
|
||||||
layer.init({
|
layer.init(null, { engine: createFakeEngine() });
|
||||||
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
|
|
||||||
scene: {
|
|
||||||
globe: { show: true },
|
|
||||||
groundPrimitives: { add() {}, remove() {} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
layer.enable();
|
layer.enable();
|
||||||
await layer.update();
|
await layer.update();
|
||||||
return layer;
|
return layer;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { fromDegrees } from '../../geo/cartesian3.js';
|
import { fromDegrees } from '../../geo/cartesian3.js';
|
||||||
import { createPerimetersCesiumRenderer } from './render/cesium.js';
|
import { createPerimetersDisplay } from './render/display.js';
|
||||||
export { containmentColor } from './render/cesium.js';
|
|
||||||
import {
|
import {
|
||||||
PERIMETER_OVERLAY_SOURCE_ID,
|
PERIMETER_OVERLAY_SOURCE_ID,
|
||||||
perimeterAnchorDegrees,
|
perimeterAnchorDegrees,
|
||||||
@@ -51,13 +50,12 @@ function samePolygons(left, right) {
|
|||||||
export function createFirePerimetersLayer({
|
export function createFirePerimetersLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost = null,
|
overlayHost = null,
|
||||||
screenSpaceEventHandlerFactory = null,
|
|
||||||
picking = null,
|
picking = null,
|
||||||
pointer = null,
|
pointer = null,
|
||||||
inciwebSource = null,
|
inciwebSource = null,
|
||||||
inciwebPublications = null,
|
inciwebPublications = null,
|
||||||
openExternal = null,
|
openExternal = null,
|
||||||
createRenderer = createPerimetersCesiumRenderer,
|
createRenderer = ({ engine }) => createPerimetersDisplay(engine),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getSnapshot !== 'function')
|
if (typeof source?.getSnapshot !== 'function')
|
||||||
throw new TypeError('Fire perimeters require a snapshot source');
|
throw new TypeError('Fire perimeters require a snapshot source');
|
||||||
@@ -90,8 +88,7 @@ export function createFirePerimetersLayer({
|
|||||||
_linkRequest = null;
|
_linkRequest = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSelect = () =>
|
const canSelect = () => overlayHost && picking;
|
||||||
overlayHost && screenSpaceEventHandlerFactory && picking;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refresh the InciWeb catalog off the render path: a hanging or failed
|
* 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');
|
throw new Error('Fire perimeter layer is already initialized');
|
||||||
initialized = true;
|
initialized = true;
|
||||||
try {
|
try {
|
||||||
if (viewer || engine) this.attachRenderer(viewer, { engine });
|
if (engine) this.attachRenderer(viewer, { engine });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.destroy();
|
this.destroy();
|
||||||
throw error;
|
throw error;
|
||||||
@@ -283,11 +280,7 @@ export function createFirePerimetersLayer({
|
|||||||
attachRenderer(viewer, { engine = null } = {}) {
|
attachRenderer(viewer, { engine = null } = {}) {
|
||||||
if (!initialized || renderer)
|
if (!initialized || renderer)
|
||||||
throw new Error('Invalid fire perimeter scene attachment');
|
throw new Error('Invalid fire perimeter scene attachment');
|
||||||
renderer = createRenderer({
|
renderer = createRenderer({ engine });
|
||||||
viewer,
|
|
||||||
engine,
|
|
||||||
screenSpaceEventHandlerFactory,
|
|
||||||
});
|
|
||||||
renderer.setVisible(_enabled);
|
renderer.setVisible(_enabled);
|
||||||
if (geometry.size) renderer.replace([...geometry.values()]);
|
if (geometry.size) renderer.replace([...geometry.values()]);
|
||||||
if (_enabled) {
|
if (_enabled) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||||
|
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
|
||||||
import { createFirePerimetersLayer } from './index.js';
|
import { createFirePerimetersLayer } from './index.js';
|
||||||
|
|
||||||
function harness(
|
function harness(
|
||||||
@@ -14,22 +16,14 @@ function harness(
|
|||||||
}),
|
}),
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
const sources = [];
|
|
||||||
const overlay = { entries: new Map(), visible: null };
|
const overlay = { entries: new Map(), visible: null };
|
||||||
const clicks = { handler: null, destroyed: 0 };
|
|
||||||
const owners = new Map();
|
const owners = new Map();
|
||||||
const opened = [];
|
const opened = [];
|
||||||
const viewer = {
|
const ground = createFakeGroundHarness();
|
||||||
scene: { pick },
|
const { engine } = ground;
|
||||||
dataSources: {
|
// What a click picks: `scene.pick()` (null for nothing).
|
||||||
add(value) {
|
const scene = { pick };
|
||||||
sources.push(value);
|
engine.fake.script('pick', () => scene.pick() ?? undefined);
|
||||||
},
|
|
||||||
remove(value) {
|
|
||||||
sources.splice(sources.indexOf(value), 1);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createFirePerimetersLayer({
|
const layer = createFirePerimetersLayer({
|
||||||
source,
|
source,
|
||||||
overlayHost: {
|
overlayHost: {
|
||||||
@@ -44,15 +38,6 @@ function harness(
|
|||||||
},
|
},
|
||||||
hitTest: (x, y, options) => cardHit(x, y, options),
|
hitTest: (x, y, options) => cardHit(x, y, options),
|
||||||
},
|
},
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
|
||||||
setInputAction(callback) {
|
|
||||||
clicks.handler = callback;
|
|
||||||
},
|
|
||||||
destroy() {
|
|
||||||
clicks.destroyed += 1;
|
|
||||||
clicks.handler = null;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
picking: {
|
picking: {
|
||||||
resolvePickId: (picked) => picked?.id ?? null,
|
resolvePickId: (picked) => picked?.id ?? null,
|
||||||
isOwnedByOtherLayer: (layerId, pickedId) =>
|
isOwnedByOtherLayer: (layerId, pickedId) =>
|
||||||
@@ -67,9 +52,45 @@ function harness(
|
|||||||
inciwebPublications: { getPublication: publication },
|
inciwebPublications: { getPublication: publication },
|
||||||
openExternal: (url) => opened.push(url),
|
openExternal: (url) => opened.push(url),
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
layer.init(null, { engine });
|
||||||
layer.enable(viewer);
|
layer.enable();
|
||||||
return { layer, viewer, sources, overlay, clicks, owners, opened };
|
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 = [
|
const ring = [
|
||||||
@@ -97,21 +118,27 @@ const row = {
|
|||||||
polygons: [[ring]],
|
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({
|
const h = harness({
|
||||||
getSnapshot: async () => [
|
getSnapshot: async () => [
|
||||||
row,
|
row,
|
||||||
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
|
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
assert.equal(await h.layer.update(h.viewer), true);
|
assert.equal(await h.layer.update(), true);
|
||||||
const entities = h.sources[0].entities.values;
|
const { items } = h.zones();
|
||||||
assert.equal(entities.length, 3);
|
const ids = [
|
||||||
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
|
'fire-perimeter:2026-NMGNF-000123:0',
|
||||||
assert.ok(entities[0].polygon, 'perimeter fill missing');
|
'fire-perimeter:other:0',
|
||||||
assert.ok(entities[0].polyline, 'fire line missing');
|
'fire-perimeter:other:1',
|
||||||
assert.equal(entities[1].id, 'fire-perimeter:other:0');
|
];
|
||||||
assert.equal(entities[2].id, '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);
|
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);
|
const pending = h.layer.update();
|
||||||
h.layer[action](h.viewer);
|
h.layer[action]();
|
||||||
assert.equal(signal.aborted, true);
|
assert.equal(signal.aborted, true);
|
||||||
if (action === 'disable') h.layer.enable(h.viewer);
|
if (action === 'disable') h.layer.enable();
|
||||||
resolve([row]);
|
resolve([row]);
|
||||||
assert.equal(await pending, false);
|
assert.equal(await pending, false);
|
||||||
assert.equal(h.layer.getStats().count, 0);
|
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,
|
pick: () => pickResult,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
|
assert.equal(h.ground.listeners(), 1, 'click handler missing');
|
||||||
// Deliberately NOT registered as a pick owner: sibling layers' click
|
// Deliberately NOT registered as a pick owner: sibling layers' click
|
||||||
// handlers yield to owned picks before their own card hit-tests, so
|
// handlers yield to owned picks before their own card hit-tests, so
|
||||||
// claiming these large ground polygons would make FIRMS/vessel/CCTV
|
// 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);
|
assert.equal(h.owners.size, 0);
|
||||||
|
|
||||||
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123: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');
|
const entries = h.overlay.entries.get('fire-perimeters');
|
||||||
assert.equal(entries.length, 1);
|
assert.equal(entries.length, 1);
|
||||||
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
|
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');
|
assert.ok(entries[0].position, 'card must carry a world anchor');
|
||||||
|
|
||||||
pickResult = { id: 'other-layer:aircraft-1' };
|
pickResult = { id: 'other-layer:aircraft-1' };
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.overlay.entries.get('fire-perimeters')?.length,
|
h.overlay.entries.get('fire-perimeters')?.length,
|
||||||
1,
|
1,
|
||||||
@@ -170,7 +197,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it',
|
|||||||
);
|
);
|
||||||
|
|
||||||
pickResult = null;
|
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);
|
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] },
|
{ getSnapshot: async () => [row] },
|
||||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
h.layer.disable(h.viewer);
|
h.layer.disable();
|
||||||
assert.equal(h.clicks.destroyed, 1);
|
assert.equal(h.ground.listeners(), 0);
|
||||||
assert.equal(h.owners.has('fire-perimeters'), false);
|
assert.equal(h.owners.has('fire-perimeters'), false);
|
||||||
assert.equal(h.overlay.entries.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 },
|
{ getSnapshot: async () => rows },
|
||||||
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
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);
|
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
||||||
rows = [{ ...row, stableId: 'different' }];
|
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);
|
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,
|
cardHit: () => cardHitResult,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
// Select via a perimeter pick.
|
// Select via a perimeter pick.
|
||||||
h.viewer.scene.pick = () => ({
|
h.scene.pick = () => ({
|
||||||
id: 'fire-perimeter:2026-NMGNF-000123:0',
|
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
|
// The card publishes immediately; the link line lands once the
|
||||||
// publication's currency is validated.
|
// publication's currency is validated.
|
||||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
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.
|
// Clicking the card itself opens the InciWeb page.
|
||||||
cardHitResult = { entryId: entries[0].id };
|
cardHitResult = { entryId: entries[0].id };
|
||||||
h.viewer.scene.pick = () => null;
|
h.scene.pick = () => null;
|
||||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
h.click({ position: { x: 12, y: 12 } });
|
||||||
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
|
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.overlay.entries.get('fire-perimeters').length,
|
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,
|
cardHit: () => cardHitResult,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
const entries = h.overlay.entries.get('fire-perimeters');
|
const entries = h.overlay.entries.get('fire-perimeters');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
entries[0].details.some((line) => line.includes('InciWeb')),
|
entries[0].details.some((line) => line.includes('InciWeb')),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
cardHitResult = { entryId: entries[0].id };
|
cardHitResult = { entryId: entries[0].id };
|
||||||
h.viewer.scene.pick = () => null;
|
h.scene.pick = () => null;
|
||||||
h.clicks.handler({ position: { x: 12, y: 12 } });
|
h.click({ position: { x: 12, y: 12 } });
|
||||||
assert.deepEqual(h.opened, []);
|
assert.deepEqual(h.opened, []);
|
||||||
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
|
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);
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
await new Promise((done) => setTimeout(done, 0));
|
await new Promise((done) => setTimeout(done, 0));
|
||||||
const entries = h.overlay.entries.get('fire-perimeters');
|
const entries = h.overlay.entries.get('fire-perimeters');
|
||||||
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
|
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,
|
publication,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 10, y: 10 } });
|
h.click({ position: { x: 10, y: 10 } });
|
||||||
await new Promise((done) => setTimeout(done, 0));
|
await new Promise((done) => setTimeout(done, 0));
|
||||||
const entries = h.overlay.entries.get('fire-perimeters');
|
const entries = h.overlay.entries.get('fire-perimeters');
|
||||||
assert.equal(entries.length, 1);
|
assert.equal(entries.length, 1);
|
||||||
@@ -327,99 +354,47 @@ test('a stale or unverifiable publication yields no link', async () => {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.equal(entries[0].interactive, 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 () => {
|
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
|
||||||
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
|
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
|
||||||
// Replace the index source with one that never resolves.
|
// Replace the index source with one that never resolves.
|
||||||
h.layer.destroy(h.viewer);
|
h.layer.destroy();
|
||||||
const hanging = harness({ getSnapshot: async () => [row] });
|
const hanging = harness({ getSnapshot: async () => [row] });
|
||||||
hanging.layer.destroy(hanging.viewer);
|
hanging.layer.destroy();
|
||||||
const sources = [];
|
const layer = plainLayer({
|
||||||
const viewer = {
|
|
||||||
scene: { pick: () => null },
|
|
||||||
dataSources: {
|
|
||||||
add: (v) => sources.push(v),
|
|
||||||
remove: (v) => sources.splice(sources.indexOf(v), 1),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const layer = createFirePerimetersLayer({
|
|
||||||
source: { getSnapshot: async () => [row] },
|
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(() => {}) },
|
inciwebSource: { getIndex: () => new Promise(() => {}) },
|
||||||
inciwebPublications: {
|
inciwebPublications: {
|
||||||
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
|
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
|
||||||
},
|
},
|
||||||
openExternal: () => {},
|
|
||||||
});
|
});
|
||||||
layer.init(viewer);
|
assert.equal(await layer.update(), true);
|
||||||
layer.enable(viewer);
|
|
||||||
assert.equal(await layer.update(viewer), true);
|
|
||||||
assert.equal(layer.getStats().count, 1);
|
assert.equal(layer.getStats().count, 1);
|
||||||
layer.destroy(viewer);
|
layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an InciWeb outage never breaks the perimeter refresh', async () => {
|
test('an InciWeb outage never breaks the perimeter refresh', async () => {
|
||||||
const h = harness({ getSnapshot: async () => [row] });
|
const h = harness({ getSnapshot: async () => [row] });
|
||||||
const failing = createFirePerimetersLayer({
|
const failing = plainLayer({
|
||||||
source: { getSnapshot: async () => [row] },
|
source: { getSnapshot: async () => [row] },
|
||||||
overlayHost: {
|
|
||||||
setEntries() {},
|
|
||||||
setVisible() {},
|
|
||||||
clearSource() {},
|
|
||||||
hitTest: () => null,
|
|
||||||
},
|
|
||||||
screenSpaceEventHandlerFactory: () => ({
|
|
||||||
setInputAction() {},
|
|
||||||
destroy() {},
|
|
||||||
}),
|
|
||||||
picking: {
|
|
||||||
resolvePickId: () => null,
|
|
||||||
isOwnedByOtherLayer: () => false,
|
|
||||||
registerPickOwner() {},
|
|
||||||
unregisterPickOwner() {},
|
|
||||||
},
|
|
||||||
pointer: { isPointerFree: () => true },
|
|
||||||
inciwebSource: {
|
inciwebSource: {
|
||||||
getIndex: async () => {
|
getIndex: async () => {
|
||||||
throw new Error('InciWeb HTTP 503');
|
throw new Error('InciWeb HTTP 503');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
openExternal: () => {},
|
|
||||||
});
|
});
|
||||||
const viewer = {
|
assert.equal(await failing.update(), true);
|
||||||
scene: { pick: () => null },
|
|
||||||
dataSources: { add() {}, remove() {} },
|
|
||||||
};
|
|
||||||
failing.init(viewer);
|
|
||||||
failing.enable(viewer);
|
|
||||||
assert.equal(await failing.update(viewer), true);
|
|
||||||
assert.equal(failing.getStats().count, 1);
|
assert.equal(failing.getStats().count, 1);
|
||||||
failing.destroy(viewer);
|
failing.destroy();
|
||||||
h.layer.destroy(h.viewer);
|
h.layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('analyst records expose incident facts without geometry payloads', async () => {
|
test('analyst records expose incident facts without geometry payloads', async () => {
|
||||||
const h = harness({ getSnapshot: async () => [row] });
|
const h = harness({ getSnapshot: async () => [row] });
|
||||||
await h.layer.update(h.viewer);
|
await h.layer.update();
|
||||||
const records = h.layer.getAnalystRecords();
|
const records = h.layer.getAnalystRecords();
|
||||||
assert.equal(records.length, 1);
|
assert.equal(records.length, 1);
|
||||||
assert.equal(records[0].name, 'Fixture Fire');
|
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);
|
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' }];
|
let rows = [row, { ...row, stableId: 'other' }];
|
||||||
const h = harness({ getSnapshot: async () => rows });
|
const h = harness({ getSnapshot: async () => rows });
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
const original = [...h.sources[0].entities.values];
|
const original = h.zones();
|
||||||
let removals = 0;
|
const redraws = () => h.zones().commits - original.commits;
|
||||||
const entities = h.sources[0].entities;
|
|
||||||
const removeAll = entities.removeAll.bind(entities);
|
|
||||||
entities.removeAll = () => {
|
|
||||||
removals++;
|
|
||||||
removeAll();
|
|
||||||
};
|
|
||||||
rows = [...rows].reverse();
|
rows = [...rows].reverse();
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
assert.equal(removals, 0);
|
assert.equal(redraws(), 0);
|
||||||
assert.equal(entities.values[0], original[0]);
|
assert.equal(h.zones().items, original.items);
|
||||||
rows = rows.map((value) => ({
|
rows = rows.map((value) => ({
|
||||||
...value,
|
...value,
|
||||||
updatedTime: value.updatedTime + 1,
|
updatedTime: value.updatedTime + 1,
|
||||||
}));
|
}));
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
assert.equal(removals, 1);
|
assert.equal(redraws(), 1);
|
||||||
assert.notEqual(entities.values[0], original[0]);
|
assert.notEqual(h.zones().items, original.items);
|
||||||
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
|
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
removals,
|
redraws(),
|
||||||
2,
|
2,
|
||||||
'incident facts can change without a new polygon timestamp',
|
'incident facts can change without a new polygon timestamp',
|
||||||
);
|
);
|
||||||
assert.equal(h.layer.getRowControls().legend[3].count, 2);
|
assert.equal(h.layer.getRowControls().legend[3].count, 2);
|
||||||
rows = [];
|
rows = [];
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
assert.equal(entities.values.length, 0);
|
assert.equal(h.zones().items.length, 0);
|
||||||
h.layer.destroy();
|
h.layer.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -538,11 +507,11 @@ for (const action of ['disable', 'destroy', 'selection']) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 1, y: 1 } });
|
h.click({ position: { x: 1, y: 1 } });
|
||||||
assert.equal(signal.aborted, false);
|
assert.equal(signal.aborted, false);
|
||||||
if (action === 'selection') {
|
if (action === 'selection') {
|
||||||
pick = { id: 'fire-perimeter:other:0' };
|
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]();
|
} else h.layer[action]();
|
||||||
assert.equal(signal.aborted, true);
|
assert.equal(signal.aborted, true);
|
||||||
const entries = h.overlay.entries.get('fire-perimeters');
|
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,
|
getSnapshot: async () => snapshot.rows,
|
||||||
getSnapshotDetail: async () => snapshot,
|
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().stale, true);
|
||||||
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
|
||||||
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
|
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.equal(h.layer.getStats().stale, false);
|
||||||
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
|
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 };
|
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);
|
assert.equal(h.layer.getStats().stale, true);
|
||||||
fail = 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().stale, false);
|
||||||
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
|
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 {
|
try {
|
||||||
await h.layer.update();
|
await h.layer.update();
|
||||||
h.clicks.handler({ position: { x: 0, y: 0 } });
|
h.click({ position: { x: 0, y: 0 } });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.overlay.entries.get('fire-perimeters')[0].id,
|
h.overlay.entries.get('fire-perimeters')[0].id,
|
||||||
'fire-perimeter-card:incident:one',
|
'fire-perimeter-card:incident:one',
|
||||||
);
|
);
|
||||||
h.layer.detachRenderer();
|
h.layer.detachRenderer();
|
||||||
assert.equal(h.sources.length, 0);
|
assert.equal(h.ground.sets(), 0);
|
||||||
geometry = [[ring.map(([lon, lat]) => [lon + 0.1, lat])]];
|
geometry = [[ring.map(([lon, lat]) => [lon + 0.1, lat])]];
|
||||||
assert.equal(await h.layer.update(), true);
|
assert.equal(await h.layer.update(), true);
|
||||||
const record = h.layer.getAnalystRecords()[0];
|
const record = h.layer.getAnalystRecords()[0];
|
||||||
assert.equal(record.id, 'incident:one');
|
assert.equal(record.id, 'incident:one');
|
||||||
h.layer.attachRenderer(h.viewer);
|
h.layer.attachRenderer(null, { engine: h.engine });
|
||||||
assert.equal(h.sources[0].entities.values.length, 1);
|
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(
|
assert.equal(
|
||||||
h.overlay.entries.get('fire-perimeters')[0].id,
|
h.overlay.entries.get('fire-perimeters')[0].id,
|
||||||
'fire-perimeter-card:incident:one',
|
'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