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:
German Khokhlov committed 2026-11-07 06:20:48 +04:00
1 parent 974a0b6361
commit 54fe0aea40
124 files changed
+8407 -9576

No files matched your search

+11 -4
View File
@@ -223,8 +223,15 @@ one application, and only the globe-bound parts move to the next engine.
engine over the globe contract: the aircraft family (flights, military and
local ADS-B) through `src/layers/aircraft/render/display.js`, and AIS
vessels, satellites, Space Missions, ALPR, bikeshare, OSM Places,
Wildlife Sightings, radio, transit and CCTV through their own
`render/display.js`. CCTV's monitor plane and viewshed volume, which the
Wildlife Sightings, radio, transit, CCTV, earthquakes, air quality, GNSS
Integrity, fire perimeters, Prohibited & Restricted Airspace, Active Fires,
S2 GhostMaps, cyclone advisories and submarine cables through their own
`render/display.js`; the fills of the last nine are `engine.ground` sets.
The Active Fires far-zoom heatmap, a raster draped on the ground that the
contract has no primitive for yet, draws behind one seam,
`src/layers/firms/render/heatField.js` (`render/cesiumHeat.js`, an imagery
layer, on Cesium; `render/navaraHeat.js`, a surface pass that the Navara
mode loads and passes in). CCTV's monitor plane and viewshed volume, which the
contract has no primitive for yet, draw behind one seam,
`src/layers/cctv/render/meshes.js` (`render/cesiumMeshes.js` on Cesium;
`render/navaraMeshes.js`, which the Navara mode loads and passes in). Cesium
@@ -863,7 +870,7 @@ forecast lead-hour points and cones render only when all source parts match the
status advisory; a newer status shows its position with geometry pending rather
than relabeling older geometry. The cone represents forecast center uncertainty,
not storm size or the full hazard area. Successful empty and unavailable states
are distinct. One owned native Cesium data source preserves polygon holes and
are distinct. The display draws centres and forecast points as point movers and each storm's track and cone as ground sets, keeping polygon holes and
geographic seams and releases on disable; forecast animation is not implied.
NHC movement speed is converted from miles per hour to rounded knots by the
proxy; advisory wind intensity is already in knots and is passed through.
@@ -2492,7 +2499,7 @@ retaining rooftop and below-sea-level elevations. A hidden globe does not
constrain photoreal geometry, and retry work remains bounded. Already sampled
nearby markers follow higher settled terrain on existing bounded walks, without
additional GPU samples or timers, when close-up tile detail refines the floor.
The submarine-cable renderer is unchanged. These limits bound active stem work;
Submarine cables draw through `render/display.js` on every engine. These limits bound active stem work;
they do not reduce the materialized entity count or establish an FPS gain.
## Keyboard interaction and focus
+4
View File
@@ -34,6 +34,10 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// cesium only in render/cesium*.js, three and Navara only in
// render/navara*.js. The list grows with each layer moved onto renderers.
const RENDERED_LAYER_DIRS = [
'src/layers/droneAirspace/',
'src/layers/cyclones/',
'src/layers/ghostMaps/',
'src/layers/submarineCables/',
'src/layers/alpr/',
'src/layers/cctv/',
'src/layers/transit/',
+10 -10
View File
@@ -1,16 +1,16 @@
{
"metrics": {
"cesiumLayerRendererLines": 1636,
"navaraLayerRendererLines": 6953,
"cesiumImportFiles": 65,
"viewerLines": 1901,
"navaraKindChecks": 12,
"cesiumLayerRendererLines": 1011,
"navaraLayerRendererLines": 4832,
"cesiumImportFiles": 53,
"viewerLines": 1745,
"navaraKindChecks": 9,
"engineSniffCalls": 14,
"engineExtensionReads": 26,
"groundEffectPasses": 7,
"engineExtensionReads": 19,
"groundEffectPasses": 5,
"cesiumSdkLoads": 6,
"screenSpaceHandlerFiles": 10,
"engineNameLines": 133
"screenSpaceHandlerFiles": 8,
"engineNameLines": 108
},
"measuredAt": "after the radio, transit and CCTV displays; one engine.extensions read is CCTV's meshes seam, 2026-11-07"
"measuredAt": "after the ground layers (earthquakes, air quality, GNSS, perimeters, drone airspace, FIRMS, GhostMaps, cyclones, cables), 2026-11-07"
}
+23 -23
View File
@@ -625,13 +625,8 @@
"src/layers/submarineCables/bundledSource.js",
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/data/telegeographySubmarineCables.js",
"src/data/telegeographySubmarineCables.test.mjs",
@@ -908,7 +903,6 @@
"src/layers/cyclones/rendering.js",
"src/layers/cyclones/labels.js",
"src/layers/cyclones/source.js",
"src/layers/cyclones/rendering.test.mjs",
"src/layers/cyclones/labels.test.mjs",
"src/layers/cyclones/index.test.mjs",
"src/layers/cyclones/source.test.mjs",
@@ -1403,23 +1397,10 @@
"src/layers/localAdsb/qaDrawn.test.mjs",
"src/tooling/w1RuntimeEvidence.test.mjs",
"scripts/fixtures/w2/earthquakes.mjs",
"src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/cesium.test.mjs",
"src/layers/earthquakes/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGeometry.test.mjs",
"src/layers/earthquakes/render/navaraGrid.js",
"src/layers/earthquakes/render/navaraGrid.test.mjs",
"src/layers/earthquakes/render/navara.test.mjs",
"src/layers/firms/render/cesium.js",
"src/layers/firms/render/cesium.test.mjs",
"src/layers/firms/rectangle.js",
"src/layers/firms/rectangle.test.mjs",
"src/layers/firms/viewport.test.mjs",
"scripts/fixtures/w2/firms.mjs",
"src/layers/firms/render/navara.test.mjs",
"src/layers/firms/render/navaraGeometry.test.mjs",
"src/layers/firms/render/navaraSprites.test.mjs",
"src/layers/firms/lifecycle.test.mjs",
"scripts/fixtures/w2/radio.mjs",
"scripts/qa-map-policy.mjs",
@@ -1620,11 +1601,30 @@
"src/globe/navara/ground/pack.test.mjs",
"src/globe/navara/ground/pass.js",
"src/globe/navara/ground/vector.js",
"src/layers/earthquakes/render/ground.js",
"src/layers/perimeters/render/ground.js",
"scripts/qa-ground-suite.mjs",
"scripts/qa-ground-bench.mjs",
"scripts/qa-ground-prototypes.mjs",
"scripts/qa-ground-switch.mjs",
"docs/GLOBE-PRIMITIVES.md"
"docs/GLOBE-PRIMITIVES.md",
"src/layers/airQuality/render/display.js",
"src/layers/airQuality/render/display.test.mjs",
"src/layers/cyclones/render/display.js",
"src/layers/cyclones/render/display.test.mjs",
"src/layers/droneAirspace/render/display.js",
"src/layers/droneAirspace/render/display.test.mjs",
"src/layers/earthquakes/render/display.js",
"src/layers/earthquakes/render/display.test.mjs",
"src/layers/firms/render/cesiumHeat.js",
"src/layers/firms/render/display.js",
"src/layers/firms/render/display.test.mjs",
"src/layers/firms/render/heatField.js",
"src/layers/firms/render/sprites.js",
"src/layers/ghostMaps/render/display.js",
"src/layers/ghostMaps/render/display.test.mjs",
"src/layers/gnss/render/display.js",
"src/layers/gnss/render/display.test.mjs",
"src/layers/perimeters/render/display.js",
"src/layers/perimeters/render/display.test.mjs",
"src/layers/submarineCables/render/display.js",
"src/layers/submarineCables/render/display.test.mjs",
"src/testSupport/groundDisplayEngines.mjs"
]
+54 -60
View File
@@ -1583,7 +1583,8 @@
"src/layers/earthquakes/source.js",
"src/layers/earthquakes/records.js",
"src/geo/",
"src/layers/shared/color.js"
"src/layers/shared/color.js",
"src/layers/earthquakes/render/display.js"
],
"external": []
},
@@ -2180,13 +2181,8 @@
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/session.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/data/inputOwnership.js",
"src/i18n/index.js",
@@ -2202,7 +2198,10 @@
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/geo/"
"src/geo/",
"src/sources/requestDeadline.js",
"src/layers/shared/color.js",
"src/layers/submarineCables/render/display.js"
],
"external": ["cesium"]
},
@@ -2779,13 +2778,8 @@
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/session.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/controls.js",
@@ -3058,20 +3052,16 @@
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
@@ -3299,7 +3289,6 @@
"src/data/geoImportImages.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/render/cesium.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
@@ -3477,23 +3466,13 @@
"src/layers/shared/render/engineView.js",
"src/celestialRing/render/cesium.js",
"src/celestialRing/view.js",
"src/layers/earthquakes/render/cesium.js",
"src/layers/earthquakes/render/navara.js",
"src/layers/airQuality/render/navara.js",
"src/layers/gnss/render/navara.js",
"src/layers/perimeters/render/navara.js",
"src/layers/shared/render/navaraPolygons.js",
"src/layers/shared/render/navaraZones.js",
"src/layers/droneAirspace/render/navara.js",
"src/layers/earthquakes/render/navaraGeometry.js",
"src/layers/earthquakes/render/navaraGrid.js",
"src/layers/installations/render/navara.js",
"src/layers/awareness/render/navara.js",
"src/layers/awareness/render/navaraPulses.js",
"src/layers/infrastructure/render/navara.js",
"src/layers/shared/render/navaraStems.js",
"src/layers/shared/render/navaraSceneDepth.js",
"src/layers/submarineCables/render/navara.js",
"src/layers/directions/render/navara.js",
"src/layers/traffic/render/navara.js",
"src/layers/streetLevel/render/navara.js",
@@ -3505,9 +3484,7 @@
"src/layers/weather/render/navaraSurface.js",
"src/layers/wind/render/navara.js",
"src/layers/wind/render/flowMesh.js",
"src/layers/ghostMaps/render/navara.js",
"src/layers/shared/render/navaraGroundPicks.js",
"src/layers/cyclones/render/navara.js",
"src/layers/shared/render/groundRing.js",
"src/layers/bikeshare/stationPoints.js",
"src/layers/shared/viewRectangle.js",
@@ -3515,8 +3492,6 @@
"src/layers/installations/render/navaraOutlineBatch.js",
"src/layers/installations/render/navaraStencilBatch.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/shared/render/navaraMoverBank.js",
"src/layers/shared/render/moverBank.js",
"src/layers/recentImagery/render/navara.js",
@@ -3526,13 +3501,8 @@
"src/layers/alpr/overlay.js",
"src/layers/alpr/presentation.js",
"src/layers/radio/screenClusters.js",
"src/layers/shared/render/navaraCellGeometry.js",
"src/layers/shared/render/navaraGroundCells.js",
"src/layers/firms/render/navaraSprites.js",
"src/layers/shared/render/imageAtlas.js",
"src/layers/firms/render/navaraHeat.js",
"src/layers/shared/render/navaraGroundGeometry.js",
"src/layers/shared/render/navaraGroundGrid.js",
"src/layers/shared/render/navaraGroundSurface.js",
"src/layers/shared/render/navaraGroundEffect.js",
"src/annotations/outlineRungs.js",
@@ -3602,7 +3572,19 @@
"src/layers/cctv/render/cesiumMeshes.js",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js",
"src/layers/transit/render/display.js"
"src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
"src/layers/submarineCables/render/display.js",
"src/layers/firms/render/sprites.js",
"src/layers/cyclones/render/display.js",
"src/layers/droneAirspace/render/display.js",
"src/layers/ghostMaps/render/display.js",
"src/layers/perimeters/render/display.js",
"src/layers/gnss/render/display.js",
"src/layers/firms/render/cesiumHeat.js",
"src/layers/firms/render/heatField.js",
"src/layers/firms/render/display.js"
],
"external": [
"hls.js",
@@ -3935,13 +3917,8 @@
"src/layers/submarineCables/geometry.js",
"src/layers/submarineCables/index.js",
"src/layers/submarineCables/session.js",
"src/layers/submarineCables/ingestion.js",
"src/layers/submarineCables/interaction.js",
"src/layers/submarineCables/lifecycle.js",
"src/layers/submarineCables/overlay.js",
"src/layers/submarineCables/policy.js",
"src/layers/submarineCables/rendering.js",
"src/layers/submarineCables/state.js",
"src/layers/submarineCables/surface.js",
"src/layers/traffic/animation.js",
"src/layers/traffic/controls.js",
@@ -4031,20 +4008,16 @@
"src/layers/earthquakes/records.js",
"src/app/layers/perimeters.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/layers/perimeters/source.js",
"src/layers/perimeters/records.js",
"src/layers/perimeters/cards.js",
"src/layers/perimeters/inciweb.js",
"src/app/layers/gnss.js",
"src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/layers/gnss/source.js",
"src/layers/gnss/records.js",
"src/app/layers/ghostMaps.js",
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
@@ -4211,7 +4184,6 @@
"src/data/cameraFraming.js",
"src/app/layers/airQuality.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/render/cesium.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/ui/statusText.js",
@@ -4282,9 +4254,7 @@
"src/celestialRing/render/cesium.js",
"src/celestialRing/view.js",
"src/layers/shared/render/engineView.js",
"src/layers/earthquakes/render/cesium.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js",
"src/annotations/outlineRungs.js",
"src/sources/openFreeMapOutlines.js",
"src/sources/nominatimOutlines.js",
@@ -4337,7 +4307,20 @@
"src/layers/cctv/render/cesiumMeshes.js",
"src/layers/cctv/render/meshes.js",
"src/layers/cctv/render/display.js",
"src/layers/transit/render/display.js"
"src/layers/transit/render/display.js",
"src/layers/earthquakes/render/display.js",
"src/layers/airQuality/render/display.js",
"src/layers/cyclones/render/display.js",
"src/layers/gnss/render/display.js",
"src/layers/firms/render/sprites.js",
"src/layers/submarineCables/render/display.js",
"src/layers/perimeters/render/display.js",
"src/layers/droneAirspace/render/display.js",
"src/globe/ground.js",
"src/layers/ghostMaps/render/display.js",
"src/layers/firms/render/cesiumHeat.js",
"src/layers/firms/render/heatField.js",
"src/layers/firms/render/display.js"
],
"external": [
"hls.js",
@@ -5185,8 +5168,6 @@
"src/geo/cartesian3.js",
"src/geo/math.js",
"src/layers/perimeters/index.js",
"src/layers/perimeters/render/cesium.js",
"src/layers/shared/render/cesiumZones.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/perimeters/source.js",
@@ -5205,7 +5186,9 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/color.js",
"src/layers/perimeters/render/display.js"
],
"external": ["cesium"]
},
@@ -5213,7 +5196,6 @@
"exports": ["./layers/gnss"],
"modules": [
"src/layers/gnss/index.js",
"src/layers/gnss/render/cesium.js",
"src/sources/httpBody.js",
"src/i18n/marker.js",
"src/layers/gnss/source.js",
@@ -5232,7 +5214,18 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
"src/ui/layerNames.js",
"src/layers/shared/viewRectangle.js",
"src/layers/shared/color.js",
"src/geo/math.js",
"src/geo/cartesian2.js",
"src/geo/matrix3.js",
"src/geo/cartesian3.js",
"src/geo/matrix4.js",
"src/geo/quaternion.js",
"src/geo/boundingSphere.js",
"src/geo/",
"src/layers/gnss/render/display.js"
],
"external": ["cesium"]
},
@@ -5279,7 +5272,6 @@
"exports": ["./layers/ghostmaps"],
"modules": [
"src/layers/ghostMaps/index.js",
"src/layers/ghostMaps/render/cesium.js",
"src/layers/ghostMaps/catalog.js",
"src/layers/ghostMaps/records.js",
"src/layers/ghostMaps/kml.js",
@@ -5335,7 +5327,6 @@
"modules": [
"src/layers/droneAirspace/index.js",
"src/geo/",
"src/layers/shared/render/cesiumZones.js",
"src/sources/httpBody.js",
"src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js",
@@ -5353,7 +5344,9 @@
"src/i18n/locales/ru.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
"src/i18n/locales/zh-Hant.json",
"src/layers/shared/color.js",
"src/layers/droneAirspace/render/display.js"
],
"external": ["cesium"]
},
@@ -5538,7 +5531,6 @@
"src/geo/cartesian3.js",
"src/geo/math.js",
"src/layers/airQuality/index.js",
"src/layers/airQuality/render/cesium.js",
"src/layers/airQuality/model.js",
"src/layers/airQuality/source.js",
"src/sources/httpBody.js",
@@ -5557,7 +5549,9 @@
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/ui/statusText.js",
"src/ui/layerNames.js"
"src/ui/layerNames.js",
"src/layers/shared/color.js",
"src/layers/airQuality/render/display.js"
],
"external": ["cesium"]
},
-313
View File
@@ -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'));
+6 -6
View File
@@ -1,15 +1,15 @@
import { rendererFor } from './renderers.js';
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
import { createAirQualityLayer } from '../../layers/airQuality/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire air-quality readings to the application overlay host. */
/**
* Wire air-quality readings to the application overlay host; the station
* discs are the engine's renderer from the registry (render/display.js on
* every engine).
*/
export function createApplicationAirQuality(options) {
return createAirQualityLayer({
overlayHost,
createRenderer: ({ viewer, engine }) =>
engine
? rendererFor('air-quality', engine, { viewer })
: createAirQualityCesiumRenderer({ viewer }),
createRenderer: ({ engine }) => rendererFor('air-quality', engine),
...options,
});
}
+13 -2
View File
@@ -1,10 +1,21 @@
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
import * as context from '../../data/contextStore.js';
import { rendererFor } from './renderers.js';
/** Wire cyclone selection to the application shared context. */
export function createApplicationCyclones(options) {
/**
* Wire cyclone selection to the application shared context; the layer draws
* with the engine's renderer from the registry (render/display.js on every
* engine). The catalog passes the same factory as `createNativeRendering`.
*/
export function createApplicationCyclones({
createNativeRendering,
createRendering = createNativeRendering ??
((engine, options) => rendererFor('weather-cyclones', engine, options)),
...options
} = {}) {
return createCyclonesLayer({
context: typeof window !== 'undefined' ? context : null,
createRendering,
...options,
});
}
+7 -18
View File
@@ -1,14 +1,14 @@
import * as Cesium from 'cesium';
import {
createDroneAirspaceLayer,
createDroneAirspaceCesiumRenderer,
} from '../../layers/droneAirspace/index.js';
import { createDroneAirspaceLayer } from '../../layers/droneAirspace/index.js';
import { rendererFor } from './renderers.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire FAA special-use airspace into the application catalog. */
/**
* Wire FAA special-use airspace into the application catalog; the zones are
* the engine's renderer from the registry (render/display.js on every
* engine).
*/
export function createApplicationDroneAirspace(options) {
return createDroneAirspaceLayer({
overlayHost: {
@@ -17,18 +17,7 @@ export function createApplicationDroneAirspace(options) {
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
engine
? rendererFor('drone-airspace', engine, {
viewer,
screenSpaceEventHandlerFactory,
})
: createDroneAirspaceCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}),
createRenderer: ({ engine }) => rendererFor('drone-airspace', engine),
picking,
pointer: { isPointerFree },
...options,
+6 -6
View File
@@ -1,14 +1,14 @@
import { rendererFor } from './renderers.js';
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
import { overlayHost } from './overlayHost.js';
/** Wire earthquake observations to the application overlay host. */
/**
* Wire earthquake observations to the application overlay host; the discs
* are the engine's renderer from the registry (render/display.js on every
* engine).
*/
export function createApplicationEarthquakes({
renderer = {
create: ({ engine = null, ...options }) =>
engine
? rendererFor('earthquakes', engine, options)
: createEarthquakesCesiumRenderer(null, options),
create: ({ engine }) => rendererFor('earthquakes', engine),
},
...options
} = {}) {
+7 -20
View File
@@ -1,28 +1,23 @@
import * as Cesium from 'cesium';
import { rendererFor } from './renderers.js';
import {
createFirmsCesiumRenderer,
projectFirmsWindow,
firmsHorizonOccluder,
FIRMS_LEFT_CLICK,
} from '../../layers/firms/render/cesium.js';
import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js';
import * as render from '../../renderGovernor.js';
import * as sprites from '../../data/spriteOrder.js';
import * as context from '../../data/contextStore.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as focus from '../../worldFocus.js';
export const firmsServices = {
render,
sprites,
context,
picking,
overlays,
focus,
};
/** Bind a supplied fire feed to the application scene service owners. */
/**
* Bind a supplied fire feed to the application scene service owners; it
* draws with the engine's renderer from the registry (render/display.js on
* every engine).
*/
export function createApplicationFirms({ surface, ...options }) {
return createLayer({
...options,
@@ -31,22 +26,14 @@ export function createApplicationFirms({ surface, ...options }) {
feed: options.feed,
services: { ...firmsServices, anchors: surface.anchors },
renderer: options.renderer ?? {
create: ({ engine = null, ...renderOptions }) =>
engine
? rendererFor('local-firms', engine, renderOptions)
: createFirmsCesiumRenderer(null, renderOptions),
create: ({ engine, ...renderOptions }) =>
rendererFor('local-firms', engine, renderOptions),
},
projectToWindow: options.projectToWindow ?? projectFirmsWindow,
horizonOccluder: options.horizonOccluder ?? firmsHorizonOccluder,
leftClickEventType: options.leftClickEventType ?? FIRMS_LEFT_CLICK,
overlayHost: options.overlayHost ?? {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory:
options.screenSpaceEventHandlerFactory ??
((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)),
});
}
+5 -4
View File
@@ -1,11 +1,14 @@
import * as Cesium from 'cesium';
import { rendererFor } from './renderers.js';
import { createGhostMapsLayer } from '../../layers/ghostMaps/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire the S2 Underground GhostMaps layer into the application catalog. */
/**
* Wire the S2 Underground GhostMaps layer into the application catalog; it
* draws with the engine's renderer from the registry (render/display.js on
* every engine).
*/
export function createApplicationGhostMaps(options) {
return createGhostMapsLayer({
overlayHost: {
@@ -15,8 +18,6 @@ export function createApplicationGhostMaps(options) {
hitTest: overlays.hitTestWorldOverlay,
},
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
createRenderer: (engine) => rendererFor('s2-ghostmaps', engine),
+7 -6
View File
@@ -1,14 +1,15 @@
import { rendererFor } from './renderers.js';
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
/** Wire the adsb.lol GNSS navigation-integrity layer into the application catalog. */
/**
* Wire the adsb.lol GNSS navigation-integrity layer into the application
* catalog; the cells are the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationGnssIntegrity(options) {
return createGnssIntegrityLayer({
createRenderer: ({ viewer, engine, viewAnchor }) =>
engine
? rendererFor('gnss-integrity', engine, { viewer, viewAnchor })
: createGnssCesiumRenderer({ viewer, viewAnchor }),
createRenderer: ({ engine, viewAnchor }) =>
rendererFor('gnss-integrity', engine, { viewAnchor }),
...options,
});
}
+6 -15
View File
@@ -1,6 +1,4 @@
import { rendererFor } from './renderers.js';
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
import * as Cesium from 'cesium';
import {
createFirePerimetersLayer,
createInciwebIndexSource,
@@ -10,7 +8,11 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Wire WFIGS fire perimeters into the application catalog. */
/**
* Wire WFIGS fire perimeters into the application catalog; the perimeters
* are the engine's renderer from the registry (render/display.js on every
* engine).
*/
export function createApplicationFirePerimeters(options) {
return createFirePerimetersLayer({
overlayHost: {
@@ -22,18 +24,7 @@ export function createApplicationFirePerimeters(options) {
inciwebSource: createInciwebIndexSource(),
inciwebPublications: createInciwebPublicationSource(),
openExternal: (url) => window.open(url, '_blank', 'noopener,noreferrer'),
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
createRenderer: ({ viewer, engine, screenSpaceEventHandlerFactory }) =>
engine
? rendererFor('fire-perimeters', engine, {
viewer,
screenSpaceEventHandlerFactory,
})
: createPerimetersCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}),
createRenderer: ({ engine }) => rendererFor('fire-perimeters', engine),
picking,
pointer: { isPointerFree },
...options,
+18 -14
View File
@@ -1,9 +1,3 @@
import { createPerimetersCesiumRenderer } from '../../layers/perimeters/render/cesium.js';
import { createDroneAirspaceCesiumRenderer } from '../../layers/droneAirspace/index.js';
import { createGnssCesiumRenderer } from '../../layers/gnss/render/cesium.js';
import { createAirQualityCesiumRenderer } from '../../layers/airQuality/render/cesium.js';
import { createFirmsCesiumRenderer } from '../../layers/firms/render/cesium.js';
import { createEarthquakesCesiumRenderer } from '../../layers/earthquakes/render/cesium.js';
// Which engine draws which layer: the layer renderer registry and the layer
// availability API built on it.
//
@@ -43,6 +37,15 @@ import { createBikeshareDisplay } from '../../layers/bikeshare/render/display.js
import { createRadioDisplay } from '../../layers/radio/render/display.js';
import { createTransitDisplay } from '../../layers/transit/render/display.js';
import { createCctvDisplay } from '../../layers/cctv/render/display.js';
import { createEarthquakesDisplay } from '../../layers/earthquakes/render/display.js';
import { createAirQualityDisplay } from '../../layers/airQuality/render/display.js';
import { createGnssDisplay } from '../../layers/gnss/render/display.js';
import { createPerimetersDisplay } from '../../layers/perimeters/render/display.js';
import { createDroneAirspaceDisplay } from '../../layers/droneAirspace/render/display.js';
import { createFirmsDisplay } from '../../layers/firms/render/display.js';
import { createGhostMapsDisplay } from '../../layers/ghostMaps/render/display.js';
import { createCyclonesDisplay } from '../../layers/cyclones/render/display.js';
import { createCablesDisplay } from '../../layers/submarineCables/render/display.js';
const ENGINE_KINDS = new Set(['cesium', 'navara']);
@@ -102,19 +105,20 @@ export const layerAvailableOn = (layerId, engineKind) =>
/** Cesium renderers extracted from their layers (render/cesium.js). */
export const CESIUM_LAYER_RENDERERS = Object.freeze({
earthquakes: createEarthquakesCesiumRenderer,
'air-quality': (_engine, options) => createAirQualityCesiumRenderer(options),
'gnss-integrity': (_engine, options) => createGnssCesiumRenderer(options),
'fire-perimeters': (_engine, options) =>
createPerimetersCesiumRenderer(options),
'drone-airspace': (_engine, options) =>
createDroneAirspaceCesiumRenderer(options),
earthquakes: createEarthquakesDisplay,
'air-quality': createAirQualityDisplay,
'gnss-integrity': createGnssDisplay,
'fire-perimeters': createPerimetersDisplay,
'drone-airspace': createDroneAirspaceDisplay,
'osm-places': createOsmPlacesDisplay,
'alpr-cameras': createAlprDisplay,
bikeshare: createBikeshareDisplay,
radio: createRadioDisplay,
transit: createTransitDisplay,
'local-firms': createFirmsCesiumRenderer,
'local-firms': createFirmsDisplay,
's2-ghostmaps': createGhostMapsDisplay,
'weather-cyclones': createCyclonesDisplay,
'telegeography-submarine-cables': createCablesDisplay,
'rocket-launches': createLaunchesDisplay,
satellites: createSatellitesDisplay,
flights: createFlightsDisplay,
+14
View File
@@ -71,6 +71,9 @@ test('the layers with extracted renderers', () => {
'radio',
'transit',
'local-firms',
's2-ghostmaps',
'weather-cyclones',
'telegeography-submarine-cables',
'rocket-launches',
'satellites',
'flights',
@@ -94,6 +97,17 @@ test('the layers drawn through one display get the same factory on every engine'
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
radio: ['radio', 'createRadioDisplay'],
transit: ['transit', 'createTransitDisplay'],
earthquakes: ['earthquakes', 'createEarthquakesDisplay'],
'air-quality': ['airQuality', 'createAirQualityDisplay'],
'gnss-integrity': ['gnss', 'createGnssDisplay'],
'fire-perimeters': ['perimeters', 'createPerimetersDisplay'],
'drone-airspace': ['droneAirspace', 'createDroneAirspaceDisplay'],
's2-ghostmaps': ['ghostMaps', 'createGhostMapsDisplay'],
'weather-cyclones': ['cyclones', 'createCyclonesDisplay'],
'telegeography-submarine-cables': [
'submarineCables',
'createCablesDisplay',
],
};
// The Navara mode registers the same modules' factories.
const navaraMode = readFileSync(
+20 -20
View File
@@ -1,24 +1,24 @@
import * as Cesium from 'cesium';
import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js';
import { createCableSessionLayer } from '../../layers/submarineCables/session.js';
import { CABLE_OVERLAY_SOURCE_ID } from '../../layers/submarineCables/policy.js';
import { rendererFor } from './renderers.js';
import { overlayHost } from './overlayHost.js';
/** Wire cable source geometry to the application overlay host. */
export function createApplicationCables({ session = false, ...options }) {
const createCesium = (source) =>
createSubmarineCableLayer({
import { overlayHost as applicationOverlayHost } from './overlayHost.js';
/**
* Wire cable source geometry to the application overlay host and map-stack
* events; it draws with the engine's renderer from the registry
* (render/display.js on every engine).
*/
export function createApplicationCables({
source,
overlayHost = applicationOverlayHost,
mapStackEventTarget = typeof window !== 'undefined' ? window : null,
sweepClock,
createDisplay = (engine) =>
rendererFor(CABLE_OVERLAY_SOURCE_ID, engine, {
overlayHost,
screenSpaceEventHandlerFactory: (canvas) =>
new Cesium.ScreenSpaceEventHandler(canvas),
mapStackEventTarget: typeof window !== 'undefined' ? window : null,
...options,
source,
});
if (!session) return createCesium(options.source);
return createCableSessionLayer({
source: options.source,
createCesium,
createNavara: (engine) =>
rendererFor('telegeography-submarine-cables', engine, { overlayHost }),
});
mapStackEventTarget,
...(sweepClock ? { sweepClock } : {}),
}),
}) {
return createSubmarineCableLayer({ source, createDisplay });
}
+31 -59
View File
@@ -170,28 +170,18 @@ export async function registerNavaraLayerRenderers() {
return (engine) =>
createDirectionsNavaraRenderer(engine, engine.extensions.navara);
},
'telegeography-submarine-cables': async () => {
const { createNavaraCables } =
await import('../layers/submarineCables/render/navara.js');
return (engine, { overlayHost }) =>
createNavaraCables(engine, engine.extensions.navara, overlayHost);
},
'telegeography-submarine-cables': async () =>
(await import('../layers/submarineCables/render/display.js'))
.createCablesDisplay,
wind: async () => {
const { createWindNavaraRenderer } =
await import('../layers/wind/render/navara.js');
return (engine) =>
createWindNavaraRenderer(engine, engine.extensions.navara);
},
'weather-cyclones': async () => {
const { createCyclonesNavaraRenderer } =
await import('../layers/cyclones/render/navara.js');
return (engine, options) =>
createCyclonesNavaraRenderer(
engine,
engine.extensions.navara.view,
options,
);
},
'weather-cyclones': async () =>
(await import('../layers/cyclones/render/display.js'))
.createCyclonesDisplay,
...Object.fromEntries(
['local-datacenters', 'local-dams'].map((id) => [
id,
@@ -208,12 +198,9 @@ export async function registerNavaraLayerRenderers() {
},
]),
),
's2-ghostmaps': async () => {
const { createGhostMapsNavaraRenderer } =
await import('../layers/ghostMaps/render/navara.js');
return (engine) =>
createGhostMapsNavaraRenderer(engine, engine.extensions.navara.view);
},
's2-ghostmaps': async () =>
(await import('../layers/ghostMaps/render/display.js'))
.createGhostMapsDisplay,
'military-awareness': async () => {
const { createAwarenessNavaraRenderer } =
await import('../layers/awareness/render/navara.js');
@@ -229,13 +216,17 @@ export async function registerNavaraLayerRenderers() {
...engine.extensions.navara,
});
},
// The FIRMS display, with the Navara backend of its heatmap.
'local-firms': async () => {
const { createFirmsNavaraRenderer } =
await import('../layers/firms/render/navara.js');
const [{ createFirmsDisplay }, { createFirmsHeatDrape }] =
await Promise.all([
import('../layers/firms/render/display.js'),
import('../layers/firms/render/navaraHeat.js'),
]);
return (engine, options) =>
createFirmsNavaraRenderer(engine, {
createFirmsDisplay(engine, {
...options,
...engine.extensions.navara,
navaraHeat: createFirmsHeatDrape,
});
},
'osm-places': async () =>
@@ -243,39 +234,20 @@ export async function registerNavaraLayerRenderers() {
.createOsmPlacesDisplay,
// Draws itself through the globe contract (engine.movers, engine.lines).
'wildlife-sightings': async () => () => null,
'drone-airspace': async () => {
const { createDroneAirspaceNavaraRenderer } =
await import('../layers/droneAirspace/render/navara.js');
return (engine) =>
createDroneAirspaceNavaraRenderer(engine, engine.extensions.navara);
},
'fire-perimeters': async () => {
const { createPerimetersNavaraRenderer } =
await import('../layers/perimeters/render/navara.js');
return (engine) =>
createPerimetersNavaraRenderer(engine, engine.extensions.navara);
},
'gnss-integrity': async () => {
const { createGnssNavaraRenderer } =
await import('../layers/gnss/render/navara.js');
return (engine) =>
createGnssNavaraRenderer(engine, engine.extensions.navara);
},
'air-quality': async () => {
const { createAirQualityNavaraRenderer } =
await import('../layers/airQuality/render/navara.js');
return (engine) =>
createAirQualityNavaraRenderer(engine, engine.extensions.navara);
},
earthquakes: async () => {
const { createEarthquakesNavaraRenderer } =
await import('../layers/earthquakes/render/navara.js');
return (engine, options) =>
createEarthquakesNavaraRenderer(engine, {
...options,
...engine.extensions.navara,
});
},
'drone-airspace': async () =>
(await import('../layers/droneAirspace/render/display.js'))
.createDroneAirspaceDisplay,
'fire-perimeters': async () =>
(await import('../layers/perimeters/render/display.js'))
.createPerimetersDisplay,
'gnss-integrity': async () =>
(await import('../layers/gnss/render/display.js')).createGnssDisplay,
'air-quality': async () =>
(await import('../layers/airQuality/render/display.js'))
.createAirQualityDisplay,
earthquakes: async () =>
(await import('../layers/earthquakes/render/display.js'))
.createEarthquakesDisplay,
// The same displays as on Cesium (renderers.js).
'rocket-launches': async () =>
(await import('../layers/launches/render/display.js'))
+356 -162
View File
@@ -1,9 +1,13 @@
import { LayerBindings } from './ui/layerBindings.js';
import { ScreenSpaceEventType } from 'cesium';
import { FIRMS_LEFT_CLICK } from './layers/firms/render/cesium.js';
import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs';
import {
readShellSource,
shellMethod,
} from './testSupport/readShellSource.mjs';
import { readLayerSource } from './testSupport/readLayerSource.mjs';
import { enter as cockpitEnter, navigateContext } from './ui/cockpitTrackingController.js';
import {
enter as cockpitEnter,
navigateContext,
} from './ui/cockpitTrackingController.js';
import { CockpitViewController } from './ui/cockpitController.js';
import assert from 'node:assert/strict';
import fs from 'node:fs';
@@ -13,11 +17,24 @@ import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ui = readShellSource();
const firms = readLayerSource(path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'));
const vessels = readLayerSource(path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'));
const voice = fs.readFileSync(path.join(ROOT, 'src', 'voice', 'gevActions.js'), 'utf8');
const cameraVerbs = fs.readFileSync(path.join(ROOT, 'src', 'cameraVerbs.js'), 'utf8');
const cockpitTracking = fs.readFileSync(path.join(ROOT, 'src', 'cockpitTracking.js'), 'utf8');
const firms = readLayerSource(
path.join(ROOT, 'src', 'data', 'firmsHeatmap.js'),
);
const vessels = readLayerSource(
path.join(ROOT, 'src', 'data', 'aisLiveVessels.js'),
);
const voice = fs.readFileSync(
path.join(ROOT, 'src', 'voice', 'gevActions.js'),
'utf8',
);
const cameraVerbs = fs.readFileSync(
path.join(ROOT, 'src', 'cameraVerbs.js'),
'utf8',
);
const cockpitTracking = fs.readFileSync(
path.join(ROOT, 'src', 'cockpitTracking.js'),
'utf8',
);
function body(source, pattern, label) {
const name = pattern.source.match(/^(\w+)/)?.[1];
@@ -33,8 +50,14 @@ function body(source, pattern, label) {
function ordered(source, needles, label) {
let previous = -1;
for (const needle of needles) {
const match = needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index = needle instanceof RegExp ? (match ? previous + 1 + match.index : -1) : source.indexOf(needle, previous + 1);
const match =
needle instanceof RegExp ? needle.exec(source.slice(previous + 1)) : null;
const index =
needle instanceof RegExp
? match
? previous + 1 + match.index
: -1
: source.indexOf(needle, previous + 1);
assert.ok(index >= 0, `${label}: missing ${needle}`);
assert.ok(index > previous, `${label}: ${needle} is out of order`);
previous = index;
@@ -43,12 +66,16 @@ function ordered(source, needles, label) {
test('Cockpit takeover invalidates deferred work before camera cancellation', () => {
const enter = cockpitEnter.toString();
ordered(enter, [
'if (!info || !entity) return false;',
'this.onCameraTakeover?.();',
'this.host.camera.cancelFlight();',
'this.host.release();',
], 'Cockpit takeover');
ordered(
enter,
[
'if (!info || !entity) return false;',
'this.onCameraTakeover?.();',
'this.host.camera.cancelFlight();',
'this.host.release();',
],
'Cockpit takeover',
);
assert.match(
ui,
/onCameraTakeover: \(\) =>\s*this\._stampNavigation\(\{\s*cancelPendingSelection: false,?\s*\}\),/,
@@ -82,11 +109,23 @@ test('navigation clears dormant tracker IDs without aborting unrelated layer res
);
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
assert.match(stamp, /flightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(
stamp,
/militaryFlightsLayer\.cancelPendingTrackingRestore\?\.\(\)/,
);
assert.match(stamp, /satellitesLayer\.cancelPendingTrackingRestore\?\.\(\)/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/);
assert.match(stamp, /if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!flightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedFlightsTrackingId: null/,
);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!militaryFlightsLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedMilitaryTrackingId: null/,
);
assert.match(
stamp,
/if \(\s*!passivelyClearedShareSelection\s*&&\s*!satellitesLayer\.getTrackedInfo\?\.\(\)\s*\)[\s\S]*?selectedSatTrackingId: null/,
);
});
test('voice Cockpit entry reaches the camera only through stamping seams', () => {
@@ -106,10 +145,14 @@ test('voice Cockpit entry reaches the camera only through stamping seams', () =>
/export function enterCockpitWithTracking\(\{[\s\S]*?\n\}\) \{([\s\S]*?)\n\}\n/,
'Cockpit entry transaction',
);
ordered(transaction, [
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
], 'Cockpit entry transaction');
ordered(
transaction,
[
/if \(\s*!selectedLayer\.trackById\?\.\(selectedTarget\.id, \{\s*origin: selectionOrigin,?\s*\}\)\s*\) \{/,
'if (!entryError) entered = Boolean(cockpitView.enter());',
],
'Cockpit entry transaction',
);
// No direct camera control: every mutation goes through a layer tracker or
// the cockpit controller, both of which stamp.
assert.doesNotMatch(transaction, /\b(?:viewer\.)?camera\s*(?:\.|\[|=)/);
@@ -148,27 +191,41 @@ test('voice Cockpit next/previous shares the manual Context navigation path', ()
/this\._listen\(this\.contextNext, 'click', \(\) =>\s*this\.navigateContext\(1, \{ origin: 'user' \}\),?\s*\);/,
);
const funnel = navigateContext.toString();
assert.match(funnel, /const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/);
ordered(funnel, [
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
], 'Cockpit Context navigation funnel');
assert.match(
funnel,
/const navigationOptions = wasActive\s*\? \{ \.\.\.options, aircraftOnly: true \}\s*: options;/,
);
ordered(
funnel,
[
"const method = direction < 0 ? 'navigatePrevious' : 'navigateNext';",
'const navigationOptions = wasActive',
'militaryAwarenessLayer?.[method]?.(navigationOptions)',
'this._adoptTrackedEntity(performance.now());',
],
'Cockpit Context navigation funnel',
);
const navigate = body(
ui,
/if \(normalized === 'next' \|\| normalized === 'previous'\) \{([\s\S]*?)\n \}/,
'controlCockpit navigation branch',
);
ordered(navigate, [
'this.cockpitView.navigateContext(',
"normalized === 'next' ? 1 : -1,",
], 'controlCockpit navigation branch');
ordered(
navigate,
['this.cockpitView.navigateContext(', "normalized === 'next' ? 1 : -1,"],
'controlCockpit navigation branch',
);
// No private camera route around the awareness layer.
assert.doesNotMatch(navigate, /flyTo|camera|trackById|_runExplicitNavigation/);
assert.doesNotMatch(
navigate,
/flyTo|camera|trackById|_runExplicitNavigation/,
);
// An exhausted cohort is an honest failure, not a silent success.
assert.match(navigate, /ok: changed,/);
assert.match(navigate, /error: changed \? null : 'No further context target was available',/);
assert.match(
navigate,
/error: changed \? null : 'No further context target was available',/,
);
});
test('accepted navigation releases through PR15-aware ownership before flight', () => {
@@ -177,37 +234,69 @@ test('accepted navigation releases through PR15-aware ownership before flight',
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
'explicit navigation',
);
ordered(run, [
'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
], 'explicit navigation');
ordered(
run,
[
'cockpitActive: this.isCockpitActive()',
'stamp: () => this._stampNavigation()',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
],
'explicit navigation',
);
const release = body(
ui,
/_releaseFollowCamera\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
'follow release',
);
ordered(release, [
'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()',
'if (this.viewer) this.viewer.trackedEntity = undefined;',
"interruptCameraMotion('explicit-navigation')",
'camera?.cancelFlight();',
'camera?.release();',
], 'follow release');
assert.match(release, /flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
assert.match(release, /militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/);
ordered(
release,
[
'origin: trackingOrigin',
'satellitesLayer.stopTracking?.({ origin: trackingOrigin })',
'rocketLaunchesLayer.releaseCameraOwnership?.()',
'if (this.viewer) this.viewer.trackedEntity = undefined;',
"interruptCameraMotion('explicit-navigation')",
'camera?.cancelFlight();',
'camera?.release();',
],
'follow release',
);
assert.match(
release,
/flightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
);
assert.match(
release,
/militaryFlightsLayer\.stopTracking\?\.\(\{ origin: trackingOrigin \}\)/,
);
});
test('validated voice camera destinations share the UI navigation authority facade', () => {
assert.match(ui, /runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/);
assert.match(voice, /runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/);
assert.match(
ui,
/runImmediateNavigation\(noun, navigate, releaseOptions = undefined\) \{\s*return this\._runExplicitNavigation\(noun, navigate, releaseOptions\);/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'camera',\s*'move_camera',\s*navigate,\s*releaseOptions/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'route',\s*'fly_route',\s*navigate/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'fire',\s*'track_entity'/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*family\.kind,\s*'track_entity'/,
);
assert.match(
voice,
/runManagedVoiceNavigation\(\s*styleManager,\s*'frame',\s*'frame_overhead'/,
);
const trackedVoice = voice.slice(
voice.indexOf('async function trackEntity'),
voice.indexOf('async function frameOverhead'),
@@ -224,19 +313,25 @@ test('validated voice camera destinations share the UI navigation authority faca
/export function moveCamera\(args = \{\}, runNavigation = null\) \{([\s\S]*?)\n\}/,
'move camera',
);
ordered(move, [
'if (argsError) return argsError;',
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff');
ordered(
move,
[
'if (argsError) return argsError;',
'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })',
],
'move validation before handoff',
);
const moveArgs = body(
cameraVerbs,
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
'move camera argument checks',
);
assert.ok(
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
moveArgs.includes(
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))",
),
'unknown motions are refused before handoff',
);
@@ -245,35 +340,64 @@ test('validated voice camera destinations share the UI navigation authority faca
/export function flyRoute\(\s*annoList,\s*args = \{\},\s*floorFn = null,\s*runNavigation = null,\s*warmFn = null,\s*engine = null,?\s*\) \{([\s\S]*?)\n\}/,
'fly route',
);
ordered(route, [
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
], 'route validation before handoff');
ordered(
route,
[
'if (!routes.length)',
'const pts = route.path.map',
'const start = () => {',
/return typeof runNavigation === 'function'\s*\? runNavigation\(start\)\s*: start\(\);/,
],
'route validation before handoff',
);
// The corridor warm is injected the same way the floor READ is — the dolly
// never reaches into the data layer itself, and the voice dispatch is the one
// place that binds both.
assert.match(voice, /\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/);
assert.match(
voice,
/\(lat, lon\) => floorServices\.cachedGroundFloor\(lat, lon\),[\s\S]{0,200}?\(cells\) => floorServices\.warmGroundFloor\(cells\),/,
);
});
test('deferred search releases only after its final authority check', () => {
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'const authority = this.begin();',
'this.onStart?.(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this.onSettled?.(authority)',
], 'deferred search component');
assert.match(ui, /begin: \(\) => this\._beginDeferredNavigation\('location'\)/);
assert.match(ui, /beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/);
assert.match(ui, /isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/);
assert.match(ui, /change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/);
assert.doesNotMatch(search.slice(0, search.indexOf('await this.search')), /_releaseFollowCamera/);
const search = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
'utf8',
);
ordered(
search,
[
'const authority = this.begin();',
'this.onStart?.(authority);',
'await this.search(query',
'beforeFly: () => current() && this.beforeFly(authority)',
'if (!current() || controller.signal.aborted) return;',
'destination?.cancelled',
'finally',
'this.onSettled?.(authority)',
],
'deferred search component',
);
assert.match(
ui,
/begin: \(\) => this\._beginDeferredNavigation\('location'\)/,
);
assert.match(
ui,
/beforeFly: \(generation\) => this\._reassertNavigationHandoff\(generation\)/,
);
assert.match(
ui,
/isCurrent: \(generation\) =>\s*!this\._disposed && generation === this\._navigationGeneration/,
);
assert.match(
ui,
/change\.type === 'settled'\)\s*this\._settleLocationSearchUi\(change\.generation, \{\s*keepQuery: state\.status === 'missing' \|\| state\.status === 'failed',?\s*\}\)/,
);
assert.doesNotMatch(
search.slice(0, search.indexOf('await this.search')),
/_releaseFollowCamera/,
);
});
test('a direct globe gesture retires delayed camera and selection restore only', () => {
@@ -298,87 +422,141 @@ test('a direct globe gesture retires delayed camera and selection restore only',
/_stampNavigation\(\{ cancelPendingSelection = true[^)]*\} = \{\}\) \{([\s\S]*?)\n \}/,
'navigation stamp',
);
assert.match(stamp, /if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/);
assert.match(
stamp,
/if \(cancelPendingSelection\) \{[\s\S]*?cancelPendingTrackingRestore/,
);
assert.doesNotMatch(stamp, /cancelPendingRestores\(\)/);
});
test('newer navigation, reset, Cockpit, and teardown share one generation', () => {
assert.equal((ui.match(/_navigationGeneration \+= 1/g) || []).length, 1);
const reset = body(ui, /resetToGlobeView\(\) \{([\s\S]*?)\n \}/, 'reset');
ordered(reset, [
'if (this._globeResetPromise) return this._globeResetPromise;',
'this._stampNavigation();',
"interruptCameraMotion('reset-globe')",
], 'reset supersession');
ordered(
reset,
[
'if (this._globeResetPromise) return this._globeResetPromise;',
'this._stampNavigation();',
"interruptCameraMotion('reset-globe')",
],
'reset supersession',
);
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'await this.detachScene(',
'this._navigation.stop();',
], 'dispose invalidation');
ordered(
dispose,
[
'this._disposed = true;',
'await this.detachScene(',
'this._navigation.stop();',
],
'dispose invalidation',
);
// An engine switch retires the outgoing globe's navigation the same way.
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
ordered(detach, [
'this._navigation.setScene(',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'detach invalidation');
assert.match(shellMethod('destroy').toString(), /this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/);
const detach = body(
ui,
/async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
'detach',
);
ordered(
detach,
[
'this._navigation.setScene(',
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
],
'detach invalidation',
);
assert.match(
shellMethod('destroy').toString(),
/this\._stampNavigation\(\{\s*cancelPendingSelection: !retainScene\s*\}\)/,
);
});
test('teardown synchronously closes immediate camera entry points', () => {
const dispose = body(ui, /async dispose\(\) \{([\s\S]*?)\n \}/, 'dispose');
ordered(dispose, [
'this._disposed = true;',
'await this.detachScene(',
], 'synchronous teardown barrier');
const detach = body(ui, /async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/, 'detach');
ordered(detach, [
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
], 'synchronous detach barrier');
ordered(LayerBindings.prototype.stop.toString(), [
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
], 'camera bindings close synchronously');
ordered(
dispose,
['this._disposed = true;', 'await this.detachScene('],
'synchronous teardown barrier',
);
const detach = body(
ui,
/async _detachScene\(\{[\s\S]*?\} = \{\}\) \{([\s\S]*?)\n \}/,
'detach',
);
ordered(
detach,
[
'this._layerBindings.stop();',
'await this._contextControls.restoreForDisposal();',
],
'synchronous detach barrier',
);
ordered(
LayerBindings.prototype.stop.toString(),
[
'this._disposed = true;',
'this._removeCctvRequestFocusListener?.();',
'this._removeWorldRequestFocusListener?.();',
'this._navigationOwnerChangedRemover?.();',
],
'camera bindings close synchronously',
);
const navigation = body(
ui,
/_runExplicitNavigation\(noun, navigate, releaseOptions = undefined\) \{([\s\S]*?)\n \}/,
'explicit navigation',
);
ordered(navigation, [
'disposed: this._disposed',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
], 'disposed navigation guard');
ordered(
navigation,
[
'disposed: this._disposed',
'release: () => this._releaseFollowCamera(releaseOptions)',
'navigate,',
],
'disposed navigation guard',
);
const cctvFocus = body(
ui,
/_runExplicitCctvFocus\(activate, focus\) \{([\s\S]*?)\n \}/,
'explicit CCTV focus',
);
ordered(cctvFocus, [
'if (this._disposed) return false;',
'const cameraId = activate();',
], 'disposed CCTV activation guard');
ordered(
cctvFocus,
['if (this._disposed) return false;', 'const cameraId = activate();'],
'disposed CCTV activation guard',
);
});
test('teardown refuses deferred location work before geocoding begins', () => {
const deferred = body(ui, /_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/, 'deferred navigation');
const deferred = body(
ui,
/_beginDeferredNavigation\(noun = 'location', \{ cancelPendingSelection = true \} = \{\}\) \{([\s\S]*?)\n \}/,
'deferred navigation',
);
assert.match(deferred, /disposed: this\._disposed/);
const search = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'locationSearch.js'), 'utf8');
ordered(search, [
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
], 'disposed search refusal');
assert.match(search, /if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/);
const search = fs.readFileSync(
path.join(ROOT, 'src', 'ui', 'locationSearch.js'),
'utf8',
);
ordered(
search,
[
'if (!query || this.destroyed) return;',
'const authority = this.begin();',
'if (authority === false)',
'this.input?.blur();',
'this.onStart?.(authority);',
'await this.search(query',
],
'disposed search refusal',
);
assert.match(
search,
/if \(authority === false\) \{[\s\S]*?return;[\s\S]*?\}\s*this\.controller/,
);
});
test('refused canned destinations commit no location or POI state', () => {
@@ -387,11 +565,15 @@ test('refused canned destinations commit no location or POI state', () => {
['poi', /_onPoiClick\(cityId, poiIndex\) \{([\s\S]*?)\n \}/],
]) {
const handler = body(ui, pattern, name);
ordered(handler, [
'this._flyWithTransition(',
'if (result === false) return;',
'this._setActiveLocation(cityId);',
], name);
ordered(
handler,
[
'this._flyWithTransition(',
'if (result === false) return;',
'this._setActiveLocation(cityId);',
],
name,
);
}
});
@@ -415,27 +597,39 @@ test('vessel and fire layers announce valid clicks and never fly cameras', () =>
/function handleClick\(click\) \{([\s\S]*?)\n \}/,
'vessel click',
);
ordered(vesselClick, [
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
'_vesselOverlayHost.hitTest?.(',
'selectAndFocusVessel(record)',
], 'vessel sibling ownership');
ordered(
vesselClick,
[
"isOwnedByOtherLayer('ais-live-vessels', pickedId)",
'_vesselOverlayHost.hitTest?.(',
'selectAndFocusVessel(record)',
],
'vessel sibling ownership',
);
const vesselFocus = body(
vessels,
/function selectAndFocusVessel\(record\) \{([\s\S]*?)\n \}/,
'vessel focus helper',
);
assert.match(vesselFocus, /requestWorldFocus\(\{/);
assert.match(firms, /const clickType\s*=\s*layerState\._renderer\?\.clickType\s*\?\?\s*config\.leftClickEventType/);
assert.equal(FIRMS_LEFT_CLICK, ScreenSpaceEventType.LEFT_CLICK);
// Fire clicks come from the layer's renderer (the engine's selection
// dispatcher), with the pick under the click.
assert.match(
firms,
/layerState\._clickHandler = layerState\._renderer\.onClick\(\(click\) => \{/,
);
const fireClick = body(
firms,
/_clickHandler\.setInputAction\(\(click\) => \{([\s\S]*?)\n \}, clickType\);/,
/_renderer\.onClick\(\(click\) => \{([\s\S]*?)\n \}\);/,
'fire click',
);
ordered(fireClick, [
'isOwnedByOtherLayer(id, pickedId)',
'overlayHost.hitTest?.(',
'selectAndFocusFire(carded)',
], 'fire sibling ownership');
ordered(
fireClick,
[
'isOwnedByOtherLayer(id, pickedId)',
'overlayHost.hitTest?.(',
'selectAndFocusFire(carded)',
],
'fire sibling ownership',
);
});
+66 -92
View File
@@ -5,6 +5,7 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import * as Cesium from 'cesium';
import { createFakeGroundHarness } from '../testSupport/groundDisplayEngines.mjs';
import {
EARTHQUAKE_OVERLAY_COHORT_LIMIT,
EARTHQUAKE_OVERLAY_COLLISION_CAPACITY,
@@ -92,25 +93,15 @@ test('earthquake overlay copy keeps source-side magnitude formatting and bounded
assert.equal(cohort.at(-1).id, 'quake-020');
});
test('real earthquake lifecycle publishes host labels while runtime entities carry no label graphic', async () => {
test('real earthquake lifecycle draws ground discs and publishes their labels to the host', async () => {
const originalFetch = globalThis.fetch;
const hostCalls = [];
const dataSources = [];
const ground = createFakeGroundHarness();
const overlayHost = {
setEntries: (...args) => hostCalls.push(['entries', ...args]),
setVisible: (...args) => hostCalls.push(['visible', ...args]),
clearSource: (...args) => hostCalls.push(['clear', ...args]),
};
const viewer = {
dataSources: {
add(dataSource) { dataSources.push(dataSource); return dataSource; },
remove(dataSource) {
const index = dataSources.indexOf(dataSource);
if (index >= 0) dataSources.splice(index, 1);
return index >= 0;
},
},
};
globalThis.fetch = async () => ({
ok: true,
json: async () => ({
@@ -130,13 +121,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car
});
const layer = createEarthquakesLayer({ overlayHost });
try {
layer.init(viewer);
layer.enable(viewer);
await layer.update(viewer);
layer.init(null, { engine: ground.engine });
layer.enable();
await layer.update();
const entities = dataSources[0].entities.values;
assert.equal(entities.length, 2, 'runtime guard requires populated real source entities');
assert.ok(entities.every((entity) => entity.label === undefined));
const { items } = ground.set('earthquakes');
assert.equal(items.length, 2, 'runtime guard requires populated ground discs');
assert.ok(items.every((item) => Object.keys(item).every((key) =>
['center', 'radiusM', 'fill', 'pickId'].includes(key))), 'a disc carries no label');
const publication = hostCalls.find(([type]) => type === 'entries');
assert.ok(publication, 'real update path must publish the overlay source');
assert.deepEqual(publication[2].map(({ title }) => title), ['M5.2', 'M3.0']);
@@ -146,14 +138,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car
moving: false,
});
layer.disable(viewer);
assert.equal(dataSources[0].show, false);
layer.disable();
assert.equal(ground.set('earthquakes').shown, false);
assert.deepEqual(hostCalls.slice(-2), [
['clear', 'earthquakes'],
['visible', 'earthquakes', false],
]);
layer.destroy(viewer);
assert.equal(dataSources.length, 0);
layer.destroy();
assert.equal(ground.sets(), 0);
assert.deepEqual(hostCalls.slice(-2), [
['clear', 'earthquakes'],
['visible', 'earthquakes', false],
@@ -164,20 +156,14 @@ test('real earthquake lifecycle publishes host labels while runtime entities car
});
// ── Perf pin: the 2026-08-20 earthquakes frame-rate cliff ────────────────────
// Every disc is a CLAMP_TO_GROUND ellipse. When its axes were a
// `CallbackProperty`, Cesium re-tessellated all 58 ground primitives on EVERY
// frame: 32.4 ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with
// the layer off. Static axes restored 60 fps. Both halves of the fix are
// pinned — the runtime property shape, and the source-level guards.
test('quake disc axes are STATIC — a per-frame callback re-tessellates ground geometry', async () => {
// Every disc is ground geometry. When its axes were a per-frame callback,
// Cesium re-tessellated all 58 ground primitives on EVERY frame: 32.4
// ms/frame and 30 fps for 58 contacts, against 1.4 ms/60 fps with the layer
// off. Static radii restored 60 fps. Both halves of the fix are pinned — one
// commit per refresh with a plain radius, and the source-level guards.
test('quake disc radii are STATIC — a per-frame rebuild re-tessellates ground geometry', async () => {
const originalFetch = globalThis.fetch;
const dataSources = [];
const viewer = {
dataSources: {
add(dataSource) { dataSources.push(dataSource); return dataSource; },
remove() { return true; },
},
};
const ground = createFakeGroundHarness();
const layer = createEarthquakesLayer({
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
@@ -192,26 +178,25 @@ test('quake disc axes are STATIC — a per-frame callback re-tessellates ground
}),
});
try {
layer.init(viewer);
layer.enable(viewer);
await layer.update(viewer);
layer.init(null, { engine: ground.engine });
layer.enable();
await layer.update();
const [entity] = dataSources[0].entities.values;
assert.ok(entity.ellipse, 'the disc is an ellipse graphic');
for (const axis of ['semiMajorAxis', 'semiMinorAxis']) {
const property = entity.ellipse[axis];
assert.equal(
property instanceof Cesium.CallbackProperty,
false,
`${axis} must not be a CallbackProperty — it rebuilds the ground primitive every frame`,
);
assert.equal(property.isConstant, true, `${axis} must be a constant property`);
// Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse.
assert.equal(property.getValue(Cesium.JulianDate.now()), Math.pow(2, 5.5) * 1000);
}
const set = ground.set('earthquakes');
const [disc] = set.items;
assert.equal(set.options.kind, 'disc', 'the disc is a ground disc');
// Magnitude 5.5 → 2^5.5 * 1000 m, unchanged by the dropped pulse.
assert.equal(disc.radiusM, Math.pow(2, 5.5) * 1000);
ground.engine.fake.frames(30);
assert.equal(
ground.set('earthquakes').commits,
set.commits,
'frames must not rebuild the ground discs',
);
} finally {
globalThis.fetch = originalFetch;
layer.destroy(viewer);
layer.destroy();
ground.destroy();
}
});
@@ -223,13 +208,9 @@ test('quake disc axes are STATIC — a per-frame callback re-tessellates ground
test('a quake poll still reaches the screen with the render loop idle', async () => {
const originalFetch = globalThis.fetch;
const renderRequests = [];
const dataSources = [];
const ground = createFakeGroundHarness();
const viewer = {
scene: { requestRenderMode: false, requestRender: () => renderRequests.push(Date.now()) },
dataSources: {
add(dataSource) { dataSources.push(dataSource); return dataSource; },
remove() { return true; },
},
};
const layer = createEarthquakesLayer({
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
@@ -251,7 +232,7 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
// frame. Drop that from the baseline or the enable assertion below can pass on
// the install alone, without the manager ever requesting anything.
renderRequests.length = 0;
const manager = new DataLayerManager(viewer);
const manager = new DataLayerManager(viewer, { engine: ground.engine });
// updateInterval -1 keeps the poll loop unarmed; we drive one tick by hand.
manager.register({ ...layer, updateInterval: -1 });
try {
@@ -264,7 +245,7 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
// ...and the poll that populated the discs must still have asked for a frame,
// because in idle mode nothing repaints on its own.
assert.ok(dataSources[0].entities.values.length > 0, 'the enable poll produced discs');
assert.ok(ground.set('earthquakes').items.length > 0, 'the enable poll produced discs');
assert.ok(renderRequests.length > 0, 'enabling the layer must request a render in idle mode');
// Named, not merely counted: the frame has to come from the manager's
// visibility request, not from some incidental repaint.
@@ -290,11 +271,12 @@ test('a quake poll still reaches the screen with the render loop idle', async ()
globalThis.fetch = originalFetch;
await manager.setEnabled('earthquakes', false, { origin: 'test' }).catch(() => {});
_resetRenderGovernorForTest();
ground.destroy();
}
});
test('the earthquakes layer installs no per-frame callback and no continuous-render hold', () => {
const source = ['index', 'model', 'source', 'render/cesium'].map(name =>
const source = ['index', 'model', 'source', 'render/display'].map(name =>
readFileSync(new URL(`../layers/earthquakes/${name}.js`, import.meta.url), 'utf8')).join('\n');
assert.doesNotMatch(
source,
@@ -310,43 +292,36 @@ test('the earthquakes layer installs no per-frame callback and no continuous-ren
test('earthquake refresh reports failure and clears it only after a successful response', async () => {
const originalFetch = globalThis.fetch;
const dataSources = [];
const viewer = {
dataSources: {
add(dataSource) { dataSources.push(dataSource); return dataSource; },
remove() { return true; },
},
};
const ground = createFakeGroundHarness();
const layer = createEarthquakesLayer({
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
try {
layer.init(viewer);
layer.enable(viewer);
layer.init(null, { engine: ground.engine });
layer.enable();
globalThis.fetch = async () => ({ ok: false, status: 503 });
assert.equal(await layer.update(viewer), false);
assert.equal(await layer.update(), false);
assert.equal(layer.getStats().error, 'USGS HTTP 503');
globalThis.fetch = async () => ({
ok: true,
json: async () => ({ features: [] }),
});
assert.equal(await layer.update(viewer), true);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().error, null);
assert.ok(Number.isFinite(layer.getStats().lastUpdate));
} finally {
globalThis.fetch = originalFetch;
layer.destroy(viewer);
layer.destroy();
ground.destroy();
}
});
test('malformed earthquake refresh preserves entities, overlays, count and timestamp', async () => {
test('malformed earthquake refresh preserves discs, overlays, count and timestamp', async () => {
const originalFetch = globalThis.fetch;
const dataSources = [];
const ground = createFakeGroundHarness();
const discs = () => ground.set('earthquakes').items;
const publications = [];
const viewer = { dataSources: {
add(source) { dataSources.push(source); }, remove() { return true; },
} };
const layer = createEarthquakesLayer({ overlayHost: {
setEntries(...args) { publications.push(args); }, setVisible() {}, clearSource() {},
} });
@@ -354,19 +329,18 @@ test('malformed earthquake refresh preserves entities, overlays, count and times
properties: { mag: 4, time: 100, place: 'Fixture' } };
const respond = (features) => { globalThis.fetch = async () => ({ ok: true, json: async () => ({ features }) }); };
try {
layer.init(viewer);
layer.enable(viewer);
layer.init(null, { engine: ground.engine });
layer.enable();
respond([good]);
assert.equal(await layer.update(viewer), true);
const entity = dataSources[0].entities.values[0];
assert.equal(await layer.update(), true);
const drawn = discs();
const stats = layer.getStats();
for (const properties of ['invalid', 123, [], true]) {
const bad = { ...good, id: 'bad-properties', properties };
for (const features of [[bad], [good, bad]]) {
respond(features);
assert.equal(await layer.update(viewer), false);
assert.equal(dataSources[0].entities.values.length, 1);
assert.equal(dataSources[0].entities.values[0], entity);
assert.equal(await layer.update(), false);
assert.equal(discs(), drawn);
assert.equal(layer.getStats().count, stats.count);
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
assert.equal(layer.getStats().error, 'Malformed USGS response');
@@ -381,24 +355,24 @@ test('malformed earthquake refresh preserves entities, overlays, count and times
{ ...good, properties: { mag: '4' } },
{ ...good, properties: { mag: Infinity } }, good]) {
respond([good, bad]);
assert.equal(await layer.update(viewer), false);
assert.equal(dataSources[0].entities.values.length, 1);
assert.equal(dataSources[0].entities.values[0], entity);
assert.equal(await layer.update(), false);
assert.equal(discs(), drawn);
assert.equal(layer.getStats().count, stats.count);
assert.equal(layer.getStats().lastUpdate, stats.lastUpdate);
assert.equal(layer.getStats().error, 'Malformed USGS response');
assert.equal(publications.length, 1);
}
respond([{ ...good, properties: { mag: null } }]);
assert.equal(await layer.update(viewer), true);
assert.equal(dataSources[0].entities.values.length, 0);
assert.equal(await layer.update(), true);
assert.equal(discs().length, 0);
assert.equal(layer.getStats().error, null);
assert.equal(publications.at(-1)[1].length, 0);
respond([good]);
assert.equal(await layer.update(viewer), true);
assert.equal(dataSources[0].entities.values.length, 1);
assert.equal(await layer.update(), true);
assert.equal(discs().length, 1);
} finally {
globalThis.fetch = originalFetch;
layer.destroy(viewer);
layer.destroy();
ground.destroy();
}
});
+76 -57
View File
@@ -16,6 +16,8 @@ import {
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { getLocale, setLocale } from '../i18n/index.js';
import { reportMeshFloorCell, setMeshFloorPreferred } from './groundFloor.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
const NOW = Date.UTC(2026, 6, 17, 4, 0);
const H = 3600000;
@@ -40,13 +42,24 @@ function fire(overrides = {}) {
}
test('buildFireCard: title carries FRP, detail carries conf/age/satellite', () => {
const card = buildFireCard({ fire: fire(), position: { x: 1, y: 2, z: 3 } }, NOW);
const card = buildFireCard(
{ fire: fire(), position: { x: 1, y: 2, z: 3 } },
NOW,
);
assert.equal(card.title, '▲ 1520 MW');
assert.equal(card.details.length, 1);
assert.equal(card.details[0], 'high · 2h · N20');
assert.equal(card.selected, false);
assert.equal(card.accent, accentForSeverity('white-hot'), 'FRP 1520 is white-hot');
assert.deepEqual(card.position, { x: 1, y: 2, z: 3 }, 'uses the candidate position untouched');
assert.equal(
card.accent,
accentForSeverity('white-hot'),
'FRP 1520 is white-hot',
);
assert.deepEqual(
card.position,
{ x: 1, y: 2, z: 3 },
'uses the candidate position untouched',
);
});
test('buildFireCard: missing acquisition time omits the age segment', () => {
@@ -60,17 +73,17 @@ test('buildFireCard: power that rounds to 10 MW drops the decimal', () => {
NOW,
);
assert.equal(crossed.title, '▲ 10 MW');
const below = buildFireCard(
{ fire: fire({ frp: 9.94 }), position: {} },
NOW,
);
const below = buildFireCard({ fire: fire({ frp: 9.94 }), position: {} }, NOW);
assert.equal(below.title, '▲ 9.9 MW');
const selected = buildSelectedFireCard(fire({ frp: 9.96 }), NOW);
assert.equal(selected.title, 'FIRE · 10 MW');
});
test('buildFireCard: SNPP satellite code renders as SNPP, weak fire is an ember', () => {
const card = buildFireCard({ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} }, NOW);
const card = buildFireCard(
{ fire: fire({ satellite: 'N', frp: 0.8, confidence: 0.3 }), position: {} },
NOW,
);
assert.match(card.details[0], /SNPP$/);
assert.equal(card.title, '▲ 0.8 MW');
assert.equal(card.accent, accentForSeverity('ember'));
@@ -87,7 +100,10 @@ test('buildFireCard: accent steps through the FRP ramp at 5 · 20 · 50 · 100
assert.equal(accentAt(300), accentForSeverity('white-hot'));
// Confidence never moves the color: a low-confidence 864 MW fire stays red.
assert.equal(
buildFireCard({ fire: fire({ frp: 864, confidence: 0.3 }), position: {} }, NOW).accent,
buildFireCard(
{ fire: fire({ frp: 864, confidence: 0.3 }), position: {} },
NOW,
).accent,
accentForSeverity('white-hot'),
);
});
@@ -149,7 +165,10 @@ test('fire anchor: cold floor renders at 0, then re-grounds when the floor warms
const f = fire({ lat: 55.501, lon: -120.501 });
const before = buildSelectedFireCard(f, NOW);
const cartoBefore = Cesium.Cartographic.fromCartesian(before.position);
assert.ok(Math.abs(cartoBefore.height) < 0.5, `cold floor anchors at ellipsoid 0, got ${cartoBefore.height}`);
assert.ok(
Math.abs(cartoBefore.height) < 0.5,
`cold floor anchors at ellipsoid 0, got ${cartoBefore.height}`,
);
setMeshFloorPreferred(true);
reportMeshFloorCell(55.501, -120.501, 900);
@@ -157,23 +176,37 @@ test('fire anchor: cold floor renders at 0, then re-grounds when the floor warms
const cartoAfter = Cesium.Cartographic.fromCartesian(after.position);
assert.ok(
Math.abs(cartoAfter.height - (900 + FIRE_ANCHOR_LIFT_M)) < 0.5,
`warm floor re-anchors at floor + lift, got ${cartoAfter.height}`
`warm floor re-anchors at floor + lift, got ${cartoAfter.height}`,
);
});
test('buildCellCard: singular noun and missing-age omission', () => {
const candidate = { cell: { count: 1, maxFrp: 9.9, newestAcqMs: 0 }, position: {}, accent: undefined };
const candidate = {
cell: { count: 1, maxFrp: 9.9, newestAcqMs: 0 },
position: {},
accent: undefined,
};
const card = buildCellCard(candidate, NOW);
assert.equal(card.title, '1 FIRE');
assert.equal(card.details[0], 'max 9.9 MW');
assert.equal(card.accent, accentForSeverity('ember'), 'missing accent defaults to the weakest stop');
assert.equal(
card.accent,
accentForSeverity('ember'),
'missing accent defaults to the weakest stop',
);
});
test('FIRMS host policy keeps ambient cards bounded and selected cards protected', () => {
const ambient = applyFirmsOverlayPolicy(buildFireCard({
fire: fire(),
position: new Cesium.Cartesian3(1, 2, 3),
}, NOW), 4_500_000);
const ambient = applyFirmsOverlayPolicy(
buildFireCard(
{
fire: fire(),
position: new Cesium.Cartesian3(1, 2, 3),
},
NOW,
),
4_500_000,
);
assert.equal(ambient.variant, 'card');
assert.equal(ambient.protected, false);
assert.equal(ambient.collisionGroup, 'ambient-card');
@@ -182,56 +215,38 @@ test('FIRMS host policy keeps ambient cards bounded and selected cards protected
assert.equal(ambient.edgeFade, 'keyhole');
assert.equal(ambient.cardStyle, 'tactical');
assert.equal(ambient.verticalOnly, true);
assert.equal(ambient.gapPx, Math.max(12, buildFireCard({ fire: fire(), position: {} }, NOW).gapPx + 8));
assert.equal(
ambient.gapPx,
Math.max(12, buildFireCard({ fire: fire(), position: {} }, NOW).gapPx + 8),
);
assert.equal(ambient.leaderOffsetPx, ambient.gapPx - 6);
const selected = applyFirmsOverlayPolicy(buildSelectedFireCard(fire(), NOW), 4_500_000);
const selected = applyFirmsOverlayPolicy(
buildSelectedFireCard(fire(), NOW),
4_500_000,
);
assert.equal(selected.variant, 'selected');
assert.equal(selected.protected, true);
assert.equal(selected.maxDistance, Number.POSITIVE_INFINITY);
});
class MockEvent {
constructor() { this.listeners = new Set(); }
addEventListener(listener) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
}
test('real FIRMS lifecycle clears host entries on disable and destroy', async () => {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
const calls = [];
const preRender = new MockEvent();
const moveEnd = new MockEvent();
globalThis.fetch = async () => ({
ok: false,
status: 503,
json: async () => ({ error: 'no_key' }),
});
globalThis.window = new EventTarget();
const dataSources = [];
const viewer = {
dataSources: {
add(value) { dataSources.push(value); return value; },
remove(value) {
const index = dataSources.indexOf(value);
if (index >= 0) dataSources.splice(index, 1);
},
},
camera: {
moveEnd,
positionWC: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000),
positionCartographic: { height: 1_000_000 },
},
scene: {
canvas: { clientWidth: 800, clientHeight: 600 },
preRender,
primitives: { contains() { return false; } },
},
};
const { engine, viewer } = createCesiumMoversEngine({
engineOptions: { createEventHandler: createFakeEventHandlers().create },
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
engine.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 1_000_000),
});
const layer = createFirmsHeatmapLayer({
id: 'firms',
name: 'FIRMS',
@@ -240,29 +255,33 @@ test('real FIRMS lifecycle clears host entries on disable and destroy', async ()
setVisible: (...args) => calls.push(['visible', ...args]),
clearSource: (...args) => calls.push(['clear', ...args]),
},
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
});
try {
layer.init(viewer);
await layer.enable(viewer);
layer.init(null, { engine });
await layer.enable();
assert.deepEqual(calls.at(-1), ['visible', FIRMS_OVERLAY_SOURCE_ID, true]);
layer.disable();
assert.deepEqual(calls.slice(-2), [
['clear', FIRMS_OVERLAY_SOURCE_ID],
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
]);
await layer.enable(viewer);
layer.destroy(viewer);
await layer.enable();
layer.destroy();
assert.ok(calls.some(([type]) => type === 'clear'));
assert.deepEqual(calls.slice(-2), [
['clear', FIRMS_OVERLAY_SOURCE_ID],
['visible', FIRMS_OVERLAY_SOURCE_ID, false],
]);
const callCount = calls.length;
await layer.enable(viewer);
assert.equal(calls.length, callCount, 'destroyed FIRMS layers reject late enable work');
await layer.enable();
assert.equal(
calls.length,
callCount,
'destroyed FIRMS layers reject late enable work',
);
assert.equal(FIRMS_AMBIENT_COHORT_LIMIT, 18);
} finally {
engine.destroy();
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
+417 -143
View File
@@ -7,14 +7,22 @@ import {
fireCullPosition,
} from './firmsHeatmap.js';
import { FIRE_ANCHOR_LIFT_M } from './fireAnchors.js';
import { FIRMS_AMBIENT_COHORT_LIMIT, FIRMS_OVERLAY_SOURCE_ID } from './firmsLabels.js';
import {
FIRMS_AMBIENT_COHORT_LIMIT,
FIRMS_OVERLAY_SOURCE_ID,
} from './firmsLabels.js';
import {
_clearMeshFloorCellsForTest,
reportMeshFloorCell,
setMeshFloorPreferred,
} from './groundFloor.js';
import { unregisterSpriteCollection } from './spriteOrder.js';
import { isOverlayPointVisible, normalizeOverlayEntry } from '../overlays/worldOverlay.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createFirmsDisplay } from '../layers/firms/render/display.js';
import {
isOverlayPointVisible,
normalizeOverlayEntry,
} from '../overlays/worldOverlay.js';
const AUSTIN = { lon: -97.7, lat: 30.2 };
/** Antipode of Austin — as far behind the limb as a point on Earth can be. */
@@ -38,8 +46,13 @@ function billboard(position, show = true) {
position,
_show: show,
writes: 0,
get show() { return this._show; },
set show(value) { this._show = value; this.writes += 1; },
get show() {
return this._show;
},
set show(value) {
this._show = value;
this.writes += 1;
},
};
}
@@ -62,17 +75,36 @@ function occluderOverAustin(heightM) {
test('applyHorizonCull: far-side fires are hidden, near-side fires stay visible', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const near = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
const nearby = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0));
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0));
const otherSide = billboard(Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0));
const near = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
);
const nearby = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon + 6, AUSTIN.lat + 4, 0),
);
const far = billboard(
Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0),
);
const otherSide = billboard(
Cesium.Cartesian3.fromDegrees(TOKYO.lon, TOKYO.lat, 0),
);
const visible = applyHorizonCull(collection([near, nearby, far, otherSide]), occluder);
const visible = applyHorizonCull(
collection([near, nearby, far, otherSide]),
occluder,
);
assert.equal(near.show, true, 'the fire under the camera renders');
assert.equal(nearby.show, true, 'a fire well inside the horizon renders');
assert.equal(far.show, false, 'the antipodal fire must not shine through the planet');
assert.equal(otherSide.show, false, 'Tokyo is beyond the limb from Austin at 1.5 Mm');
assert.equal(
far.show,
false,
'the antipodal fire must not shine through the planet',
);
assert.equal(
otherSide.show,
false,
'Tokyo is beyond the limb from Austin at 1.5 Mm',
);
assert.equal(visible, 2, 'returns the surviving count');
});
@@ -81,15 +113,26 @@ test('applyHorizonCull: raising the camera reveals fires the low camera hid', ()
const low = paris();
applyHorizonCull(collection([low]), occluderOverAustin(REPRO_HEIGHT_M));
assert.equal(low.show, false, 'Paris is over the horizon from a 1.5 Mm Austin camera');
assert.equal(
low.show,
false,
'Paris is over the horizon from a 1.5 Mm Austin camera',
);
const high = paris();
applyHorizonCull(collection([high]), occluderOverAustin(35_000_000));
assert.equal(high.show, true, 'from geostationary height the same fire is in view');
assert.equal(
high.show,
true,
'from geostationary height the same fire is in view',
);
});
test('applyHorizonCull: a hidden fire is restored when it swings back into view', () => {
const revealed = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), false);
const revealed = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
false,
);
applyHorizonCull(collection([revealed]), occluderOverAustin(REPRO_HEIGHT_M));
assert.equal(revealed.show, true, 'the cull un-hides as well as hides');
assert.equal(revealed.writes, 1);
@@ -97,25 +140,56 @@ test('applyHorizonCull: a hidden fire is restored when it swings back into view'
test('applyHorizonCull: only flipped billboards are written (vertex-buffer churn)', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const stays = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0), true);
const flips = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0), true);
const stays = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
true,
);
const flips = billboard(
Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 0),
true,
);
applyHorizonCull(collection([stays, flips]), occluder);
applyHorizonCull(collection([stays, flips]), occluder);
assert.equal(stays.writes, 0, 'an already-visible billboard is never re-assigned');
assert.equal(flips.writes, 1, 'a hidden billboard is written once, not once per pass');
assert.equal(
stays.writes,
0,
'an already-visible billboard is never re-assigned',
);
assert.equal(
flips.writes,
1,
'a hidden billboard is written once, not once per pass',
);
});
test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws', () => {
assert.equal(applyHorizonCull(null, occluderOverAustin(REPRO_HEIGHT_M)), 0);
assert.equal(applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)), 0, 'non-collection shape');
const item = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0));
assert.equal(applyHorizonCull(collection([item]), null), 0, 'no occluder → leave the layer alone');
assert.equal(applyHorizonCull(collection([item]), {}), 0, 'occluder without isPointVisible');
assert.equal(
applyHorizonCull({ length: 3 }, occluderOverAustin(REPRO_HEIGHT_M)),
0,
'non-collection shape',
);
const item = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, 0),
);
assert.equal(
applyHorizonCull(collection([item]), null),
0,
'no occluder → leave the layer alone',
);
assert.equal(
applyHorizonCull(collection([item]), {}),
0,
'occluder without isPointVisible',
);
assert.equal(item.writes, 0, 'a skipped pass must not touch show');
assert.equal(
applyHorizonCull(collection([undefined, item]), occluderOverAustin(REPRO_HEIGHT_M)),
applyHorizonCull(
collection([undefined, item]),
occluderOverAustin(REPRO_HEIGHT_M),
),
1,
'sparse slots are skipped, surviving neighbours still counted',
);
@@ -127,22 +201,42 @@ test('applyHorizonCull: missing collection/occluder/holes are no-ops, not throws
test('applyHorizonCull: an index-aligned cull position overrides the render anchor', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const sunken = billboard(Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22));
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
const sunken = billboard(
Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22),
);
const lifted = Cesium.Cartesian3.fromDegrees(
AUSTIN.lon + LIMB_DLON,
AUSTIN.lat,
12,
);
applyHorizonCull(collection([sunken]), occluder);
assert.equal(sunken.show, false, 'baseline: the raw sub-ellipsoid anchor false-hides at the limb');
assert.equal(
sunken.show,
false,
'baseline: the raw sub-ellipsoid anchor false-hides at the limb',
);
applyHorizonCull(collection([sunken]), occluder, [lifted]);
assert.equal(sunken.show, true, 'the lifted cull point keeps the near-limb fire on screen');
assert.equal(
sunken.show,
true,
'the lifted cull point keeps the near-limb fire on screen',
);
});
test('applyHorizonCull: a lifted cull point does NOT resurrect a genuinely far-side fire', () => {
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
const far = billboard(Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22));
const far = billboard(
Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, -22),
);
const lifted = Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12);
applyHorizonCull(collection([far]), occluder, [lifted]);
assert.equal(far.show, false, '12 m of lift must not defeat the actual horizon cull');
assert.equal(
far.show,
false,
'12 m of lift must not defeat the actual horizon cull',
);
});
test('fireCullPosition: negative-geoid anchor renders at the datum but culls lifted', () => {
@@ -160,15 +254,34 @@ test('fireCullPosition: negative-geoid anchor renders at the datum but culls lif
`render anchor stays datum-correct at ${-27 + FIRE_ANCHOR_LIFT_M} m, got ${renderCarto.height}`,
);
assert.notEqual(cull, render, 'a sub-ellipsoid anchor gets a separate cull point');
assert.notEqual(
cull,
render,
'a sub-ellipsoid anchor gets a separate cull point',
);
const cullCarto = Cesium.Cartographic.fromCartesian(cull);
assert.ok(Math.abs(cullCarto.height - 12) < 0.5, `cull point lifted to 12 m, got ${cullCarto.height}`);
assert.equal(fireCullPosition(fire), cull, 'the lifted point is cached, not re-allocated per call');
assert.ok(
Math.abs(cullCarto.height - 12) < 0.5,
`cull point lifted to 12 m, got ${cullCarto.height}`,
);
assert.equal(
fireCullPosition(fire),
cull,
'the lifted point is cached, not re-allocated per call',
);
// The whole point: the datum-correct point would be culled here, the lifted one is not.
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
assert.equal(occluder.isPointVisible(render), false, 'baseline defect: −22 m reads beyond the horizon');
assert.equal(occluder.isPointVisible(cull), true, 'lifted cull point reads correctly as in view');
assert.equal(
occluder.isPointVisible(render),
false,
'baseline defect: −22 m reads beyond the horizon',
);
assert.equal(
occluder.isPointVisible(cull),
true,
'lifted cull point reads correctly as in view',
);
} finally {
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(false);
@@ -183,7 +296,11 @@ test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point',
try {
const fire = { index: 2, lat, lon, frp: 10, position: null };
const cull = fireCullPosition(fire);
assert.equal(cull, fire.position, 'no second Cartesian is allocated for the common case');
assert.equal(
cull,
fire.position,
'no second Cartesian is allocated for the common case',
);
} finally {
_clearMeshFloorCellsForTest();
setMeshFloorPreferred(false);
@@ -197,23 +314,50 @@ test('fireCullPosition: a healthy above-ellipsoid anchor is its own cull point',
test('isOverlayPointVisible: horizon test prefers entry.cullPosition when present', () => {
const viewport = { width: 800, height: 600 };
const screen = { x: 400, y: 300 };
const render = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, -22);
const lifted = Cesium.Cartesian3.fromDegrees(AUSTIN.lon + LIMB_DLON, AUSTIN.lat, 12);
const render = Cesium.Cartesian3.fromDegrees(
AUSTIN.lon + LIMB_DLON,
AUSTIN.lat,
-22,
);
const lifted = Cesium.Cartesian3.fromDegrees(
AUSTIN.lon + LIMB_DLON,
AUSTIN.lat,
12,
);
const occluder = occluderOverAustin(REPRO_HEIGHT_M);
assert.equal(
isOverlayPointVisible({ horizonCull: true }, render, screen, viewport, occluder),
isOverlayPointVisible(
{ horizonCull: true },
render,
screen,
viewport,
occluder,
),
false,
'baseline: the render anchor alone false-hides the card',
);
assert.equal(
isOverlayPointVisible({ horizonCull: true, cullPosition: lifted }, render, screen, viewport, occluder),
isOverlayPointVisible(
{ horizonCull: true, cullPosition: lifted },
render,
screen,
viewport,
occluder,
),
true,
'the supplied cull position is what the horizon test uses',
);
assert.equal(
isOverlayPointVisible(
{ horizonCull: true, cullPosition: Cesium.Cartesian3.fromDegrees(ANTIPODE.lon, ANTIPODE.lat, 12) },
{
horizonCull: true,
cullPosition: Cesium.Cartesian3.fromDegrees(
ANTIPODE.lon,
ANTIPODE.lat,
12,
),
},
render,
screen,
viewport,
@@ -227,37 +371,80 @@ test('isOverlayPointVisible: horizon test prefers entry.cullPosition when presen
test('normalizeOverlayEntry: carries a valid cullPosition, nulls a junk one', () => {
const position = { x: 1, y: 2, z: 3 };
const source = { x: 4, y: 5, z: 6 };
const kept = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: source });
assert.notEqual(kept.cullPosition, source, 'stored as a host-owned copy (a geo Cartesian3)');
assert.ok(!(kept.cullPosition instanceof Cesium.Cartesian3), 'not a Cesium value');
const kept = normalizeOverlayEntry('firms', {
id: 'a',
position,
cullPosition: source,
});
assert.notEqual(
kept.cullPosition,
source,
'stored as a host-owned copy (a geo Cartesian3)',
);
assert.ok(
!(kept.cullPosition instanceof Cesium.Cartesian3),
'not a Cesium value',
);
assert.deepEqual(
[kept.cullPosition.x, kept.cullPosition.y, kept.cullPosition.z],
[4, 5, 6],
);
const dropped = normalizeOverlayEntry('firms', { id: 'b', position, cullPosition: { x: NaN, y: 5, z: 6 } });
assert.equal(dropped.cullPosition, null, 'a malformed cull point falls back to the render position');
const notAnObject = normalizeOverlayEntry('firms', { id: 'b2', position, cullPosition: 42 });
assert.equal(notAnObject.cullPosition, null, 'a non-object cull point is rejected');
const dropped = normalizeOverlayEntry('firms', {
id: 'b',
position,
cullPosition: { x: NaN, y: 5, z: 6 },
});
assert.equal(
dropped.cullPosition,
null,
'a malformed cull point falls back to the render position',
);
const notAnObject = normalizeOverlayEntry('firms', {
id: 'b2',
position,
cullPosition: 42,
});
assert.equal(
notAnObject.cullPosition,
null,
'a non-object cull point is rejected',
);
// Non-adopting sources keep their existing BEHAVIOUR and steady-frame
// allocation profile; the normalized record does gain an enumerable
// `cullPosition: null` field, which is why this asserts null rather than
// absence.
const absent = normalizeOverlayEntry('firms', { id: 'c', position });
assert.equal(absent.cullPosition, null, 'sources that do not opt in resolve to no cull anchor');
assert.equal(
absent.cullPosition,
null,
'sources that do not opt in resolve to no cull anchor',
);
});
test('normalizeOverlayEntry: the stored cull anchor is a snapshot, not the caller object', () => {
const position = { x: 1, y: 2, z: 3 };
const caller = new Cesium.Cartesian3(4, 5, 6);
const normalized = normalizeOverlayEntry('firms', { id: 'a', position, cullPosition: caller });
assert.notEqual(normalized.cullPosition, caller, 'the host must not retain the caller reference');
const normalized = normalizeOverlayEntry('firms', {
id: 'a',
position,
cullPosition: caller,
});
assert.notEqual(
normalized.cullPosition,
caller,
'the host must not retain the caller reference',
);
// Sources legitimately recycle scratch vectors between publishes; a mutation
// after normalization must never reach the per-frame occluder.
caller.x = NaN;
caller.y = 999;
assert.deepEqual(
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
[
normalized.cullPosition.x,
normalized.cullPosition.y,
normalized.cullPosition.z,
],
[4, 5, 6],
'post-normalize mutation of the source vector has no effect',
);
@@ -277,7 +464,11 @@ test('normalizeOverlayEntry: an accessor-backed cullPosition is read exactly onc
const normalized = normalizeOverlayEntry('firms', entry);
assert.equal(reads, 1, 'one property read — not one per validated component');
assert.deepEqual(
[normalized.cullPosition.x, normalized.cullPosition.y, normalized.cullPosition.z],
[
normalized.cullPosition.x,
normalized.cullPosition.y,
normalized.cullPosition.z,
],
[4, 5, 6],
);
});
@@ -288,11 +479,21 @@ test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort nor
id: 'a',
position,
title: 'still normalized',
get cullPosition() { throw new Error('hostile accessor'); },
get cullPosition() {
throw new Error('hostile accessor');
},
};
const normalized = normalizeOverlayEntry('firms', entry);
assert.equal(normalized.cullPosition, null, 'the bad anchor degrades to null');
assert.equal(normalized.title, 'still normalized', 'the rest of the entry survives');
assert.equal(
normalized.cullPosition,
null,
'the bad anchor degrades to null',
);
assert.equal(
normalized.title,
'still normalized',
'the rest of the entry survives',
);
assert.equal(normalized.id, 'a');
});
@@ -300,12 +501,6 @@ test('normalizeOverlayEntry: a throwing cullPosition accessor does not abort nor
// Layer integration: the three production hooks
// ---------------------------------------------------------------------------
class MockEvent {
constructor() { this.listeners = new Set(); }
addEventListener(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
emit() { for (const listener of [...this.listeners]) listener(); }
}
/** Canvas stub good enough for the pre-baked glow sprites. */
function canvasStub() {
return {
@@ -337,9 +532,10 @@ function rawFire(lat, lon, frp) {
}
/**
* Boots the REAL layer against a headless viewer stub with a REAL
* Cesium.BillboardCollection, so the production hooks (not just the pure
* predicate) are what the assertions exercise.
* Boots the REAL layer on the Cesium engine over a stub scene with a REAL
* Cesium.Camera and the engine's REAL Cesium.BillboardCollection, so the
* production hooks (not just the pure predicate) are what the assertions
* exercise.
*/
function createHarness(rawFires) {
const originalFetch = globalThis.fetch;
@@ -357,9 +553,14 @@ function createHarness(rawFires) {
// Deterministic projection: every distinct world position gets its own
// 200 px grid slot (well clear of LABEL_MIN_SEP_PX) inside a 2000×2000
// canvas, so declutter geometry never decides these assertions.
// canvas, so declutter geometry never decides these assertions. The
// engine's projector reads it when the engine is made.
const slots = new Map();
Cesium.SceneTransforms.worldToWindowCoordinates = (scene, position, result) => {
Cesium.SceneTransforms.worldToWindowCoordinates = (
scene,
position,
result,
) => {
if (!slots.has(position)) slots.set(position, slots.size);
const slot = slots.get(position);
const out = result || new Cesium.Cartesian2();
@@ -368,30 +569,21 @@ function createHarness(rawFires) {
return out;
};
const preRender = new MockEvent();
const moveEnd = new MockEvent();
const primitives = [];
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
engineOptions: { createEventHandler: createFakeEventHandlers().create },
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
Object.assign(viewer.scene.canvas, { clientWidth: 2000, clientHeight: 2000 });
// No view rectangle (Cesium's answer for a view of the sky or the whole
// globe): the layer draws the globally strongest detections, so far-side
// fires are drawn and only the horizon pass decides what shows.
viewer.camera.computeViewRectangle = () => undefined;
const moveCameraTo = (lon, lat, height = REPRO_HEIGHT_M) =>
engine.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, height),
});
moveCameraTo(AUSTIN.lon, AUSTIN.lat);
const entryCalls = [];
const viewer = {
dataSources: { add: (value) => value, remove: () => {} },
camera: {
moveEnd,
positionWC: Cesium.Cartesian3.fromDegrees(AUSTIN.lon, AUSTIN.lat, REPRO_HEIGHT_M),
directionWC: new Cesium.Cartesian3(0, 0, -1),
positionCartographic: { height: REPRO_HEIGHT_M },
},
scene: {
canvas: { clientWidth: 2000, clientHeight: 2000 },
preRender,
primitives: {
add: (value) => { primitives.push(value); return value; },
remove: () => {},
contains: () => false,
},
frameState: { mode: Cesium.SceneMode.SCENE3D, mapProjection: new Cesium.GeographicProjection() },
mapProjection: new Cesium.GeographicProjection(),
},
};
const layer = createFirmsHeatmapLayer({
id: 'firms',
@@ -401,36 +593,58 @@ function createHarness(rawFires) {
setVisible: () => {},
clearSource: () => {},
},
screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
// The display the registry gives every engine, with image seams.
renderer: {
create: ({ engine: target, ...options }) =>
createFirmsDisplay(target, {
...options,
loadImage: async () => ({ width: 16, height: 16 }),
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
}),
},
});
const settle = async () => {
for (let i = 0; i < 4; i++)
await new Promise((resolve) => setImmediate(resolve));
};
const drawnCount = () =>
engine.movers.list().find((set) => set.id === 'firms:firms')?.count ?? 0;
return {
layer,
viewer,
preRender,
moveEnd,
/** The real BillboardCollection the layer created. */
billboards: () => primitives[0],
engine,
/** Attaches the layer to the engine and enables it; markers drawn. */
async start() {
layer.init(null, { engine });
await layer.enable();
await settle();
},
settle,
/** The real BillboardCollection the engine drew the markers with. */
billboards: () => primitive('firms:firms'),
drawnCount,
/** Latest entry list published to the overlay host. */
latestEntries: () => entryCalls[entryCalls.length - 1] || [],
/** `show` flags keyed by billboard pick id. */
/** `show` flags of the drawn markers, keyed by billboard pick id. */
showById() {
const bb = primitives[0];
const bb = primitive('firms:firms');
const out = new Map();
for (let i = 0; i < bb.length; i += 1) out.set(bb.get(i).id, bb.get(i).show);
for (let i = 0; i < drawnCount(); i += 1)
out.set(bb.get(i).id, bb.get(i).show);
return out;
},
moveCameraTo(lon, lat, height = REPRO_HEIGHT_M) {
viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(lon, lat, height);
viewer.camera.positionCartographic = { height };
},
moveCameraTo,
moveEnd: () => viewer.camera.moveEnd.raiseEvent(),
preRender: () => frame(),
cleanup() {
layer.destroy(viewer);
unregisterSpriteCollection('firms');
layer.destroy();
engine.destroy();
Cesium.SceneTransforms.worldToWindowCoordinates = originalProject;
globalThis.fetch = originalFetch;
if (originalWindow === undefined) delete globalThis.window; else globalThis.window = originalWindow;
if (originalDocument === undefined) delete globalThis.document; else globalThis.document = originalDocument;
if (originalWindow === undefined) delete globalThis.window;
else globalThis.window = originalWindow;
if (originalDocument === undefined) delete globalThis.document;
else globalThis.document = originalDocument;
},
};
}
@@ -442,14 +656,25 @@ test('layer: a detection rebuild culls its own freshly-added sprites', async ()
rawFire(TOKYO.lat, TOKYO.lon, 800),
]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
await harness.start();
// No camera event has fired — only renderDetections ran.
const show = harness.showById();
assert.equal(show.size, 3, 'all three detections were rendered');
assert.equal(show.get('firms-0'), true, 'the Austin fire under the camera is visible');
assert.equal(show.get('firms-1'), false, 'the antipodal fire is culled at build time');
assert.equal(show.get('firms-2'), false, 'the Tokyo fire is culled at build time');
assert.equal(
show.get('firms-0'),
true,
'the Austin fire under the camera is visible',
);
assert.equal(
show.get('firms-1'),
false,
'the antipodal fire is culled at build time',
);
assert.equal(
show.get('firms-2'),
false,
'the Tokyo fire is culled at build time',
);
} finally {
harness.cleanup();
}
@@ -461,15 +686,23 @@ test('layer: camera moveEnd re-culls without a rebuild', async () => {
rawFire(TOKYO.lat, TOKYO.lon, 800),
]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
await harness.start();
assert.equal(
harness.showById().get('firms-1'),
false,
'precondition: Tokyo hidden from Austin',
);
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
harness.moveEnd.emit();
harness.moveEnd();
await harness.settle();
const show = harness.showById();
assert.equal(show.get('firms-1'), true, 'Tokyo becomes visible once the camera is over it');
assert.equal(
show.get('firms-1'),
true,
'Tokyo becomes visible once the camera is over it',
);
assert.equal(show.get('firms-0'), false, 'Austin drops behind the limb');
} finally {
harness.cleanup();
@@ -482,20 +715,30 @@ test('layer: the throttled preRender watcher re-culls on a camera move', async (
rawFire(TOKYO.lat, TOKYO.lon, 800),
]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
await harness.start();
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
// The watcher is throttled to LOD_CHECK_MS (650 ms) against
// performance.now(), whose value at this point depends on process
// start-up — so emit, wait out the full throttle window, and emit again.
// Whichever tick passes the gate, one of them must run the pass.
harness.preRender.emit();
await new Promise((resolve) => { setTimeout(resolve, 700); });
harness.preRender.emit();
harness.preRender();
await new Promise((resolve) => {
setTimeout(resolve, 700);
});
harness.preRender();
await harness.settle();
const show = harness.showById();
assert.equal(show.get('firms-1'), true, 'Tokyo revealed by the preRender pass');
assert.equal(show.get('firms-0'), false, 'Austin culled by the preRender pass');
assert.equal(
show.get('firms-1'),
true,
'Tokyo revealed by the preRender pass',
);
assert.equal(
show.get('firms-0'),
false,
'Austin culled by the preRender pass',
);
} finally {
harness.cleanup();
}
@@ -507,23 +750,35 @@ test('layer: re-enabling after the camera moved does not expose stale far-side s
rawFire(TOKYO.lat, TOKYO.lon, 800),
]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
assert.equal(harness.showById().get('firms-1'), false, 'precondition: Tokyo hidden from Austin');
await harness.start();
assert.equal(
harness.showById().get('firms-1'),
false,
'precondition: Tokyo hidden from Austin',
);
harness.layer.disable();
// The camera moves while the layer is OFF: moveEnd is not being listened
// to, and the preRender watcher is inert. Nothing re-culls on its own.
harness.moveCameraTo(TOKYO.lon, TOKYO.lat);
await harness.layer.enable(harness.viewer);
await harness.layer.enable();
await harness.settle();
// Assert with NO camera event fired and NO refetch (fires are already
// loaded, so enable() does not reload): the enable path itself must have
// re-culled before the collection went visible.
const show = harness.showById();
assert.equal(harness.billboards().show, true, 'the collection is visible again');
assert.equal(show.get('firms-1'), true, 'the Tokyo fire is revealed at enable time');
assert.equal(show.get('firms-0'), false, 'the Austin fire is NOT left shining through the planet');
assert.equal(harness.drawnCount(), 2, 'the markers are drawn again');
assert.equal(
show.get('firms-1'),
true,
'the Tokyo fire is revealed at enable time',
);
assert.equal(
show.get('firms-0'),
false,
'the Austin fire is NOT left shining through the planet',
);
} finally {
harness.cleanup();
}
@@ -541,8 +796,7 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
rawFire(lat, lon, 90),
]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
await harness.start();
const bb = harness.billboards();
const sunken = bb.get(1);
@@ -551,8 +805,16 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
Math.abs(carto.height - (-27 + FIRE_ANCHOR_LIFT_M)) < 0.5,
`the sprite still RENDERS at the datum-correct anchor, got ${carto.height}`,
);
assert.equal(sunken.show, true, 'the near-limb fire stays on screen (lifted cull anchor)');
assert.equal(harness.showById().get('firms-0'), true, 'the camera-nadir fire is unaffected');
assert.equal(
sunken.show,
true,
'the near-limb fire stays on screen (lifted cull anchor)',
);
assert.equal(
harness.showById().get('firms-0'),
true,
'the camera-nadir fire is unaffected',
);
} finally {
harness.cleanup();
_clearMeshFloorCellsForTest();
@@ -562,25 +824,37 @@ test('layer: a near-limb fire on a sub-ellipsoid anchor is not culled by its dat
test('layer: far-side candidates never displace near-side ambient cards', async () => {
const near = [];
for (let i = 0; i < 6; i += 1) near.push(rawFire(AUSTIN.lat + i * 0.4, AUSTIN.lon + i * 0.4, 100 - i));
for (let i = 0; i < 6; i += 1)
near.push(rawFire(AUSTIN.lat + i * 0.4, AUSTIN.lon + i * 0.4, 100 - i));
const far = [];
for (let i = 0; i < 20; i += 1) far.push(rawFire(ANTIPODE.lat + i * 0.4, ANTIPODE.lon + i * 0.4, 900 - i));
for (let i = 0; i < 20; i += 1)
far.push(rawFire(ANTIPODE.lat + i * 0.4, ANTIPODE.lon + i * 0.4, 900 - i));
// Far-side fires are the STRONGEST, so they lead the FRP-ranked candidate
// walk and would eat all 18 ambient slots without the horizon skip.
const harness = createHarness([...near, ...far]);
try {
harness.layer.init(harness.viewer);
await harness.layer.enable(harness.viewer);
await harness.start();
const ids = harness.latestEntries().map((entry) => entry.id);
assert.ok(ids.length > 0, 'cards were published');
assert.ok(ids.length <= FIRMS_AMBIENT_COHORT_LIMIT, 'the ambient cap still holds');
assert.ok(
ids.length <= FIRMS_AMBIENT_COHORT_LIMIT,
'the ambient cap still holds',
);
// Card ids are `fire:firms:<lat>:<lon>:<acqMs>:<sat>` (the refetch-stable
// detection key), so a card is matched back to its seed by coordinate.
const nearCoords = new Set(near.map((fire) => `${fire.lat.toFixed(4)}:${fire.lon.toFixed(4)}`));
const nearIds = ids.filter((id) => nearCoords.has(id.split(':').slice(2, 4).join(':')));
const nearCoords = new Set(
near.map((fire) => `${fire.lat.toFixed(4)}:${fire.lon.toFixed(4)}`),
);
const nearIds = ids.filter((id) =>
nearCoords.has(id.split(':').slice(2, 4).join(':')),
);
assert.equal(nearIds.length, 6, 'every in-view fire gets a card');
assert.equal(new Set(nearIds).size, 6, 'six distinct near-side detections, not one repeated');
assert.equal(
new Set(nearIds).size,
6,
'six distinct near-side detections, not one repeated',
);
assert.equal(ids.length, 6, 'no slot is spent on a fire behind the planet');
assert.equal(FIRMS_OVERLAY_SOURCE_ID, 'firms');
} finally {
+215 -98
View File
@@ -1,11 +1,19 @@
// FIRMS click routes: sprite AND card must both select for detail and hand the
// camera over (pre-launch defect #4 + P1-3). Driven through the production
// click handler and the production card-render path; only the viewer, the
// event handler, the overlay host and the world→window projection are stubbed.
// click handler and the production card-render path, on the Cesium engine
// over a stub scene; only the scene pick, the event handler, the overlay
// host and the world→window projection are stubbed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFirmsHeatmapLayer, applyFirmsOverlayPolicy, buildCellCard } from './firmsHeatmap.js';
import { createCesiumMoversEngine } from '../testSupport/cesiumMoversEngine.mjs';
import { createFakeEventHandlers } from '../globe/cesium/testing/fakeViewer.mjs';
import { createFirmsDisplay } from '../layers/firms/render/display.js';
import {
createFirmsHeatmapLayer,
applyFirmsOverlayPolicy,
buildCellCard,
} from './firmsHeatmap.js';
import { fireDetectionKey } from './firmsLabels.js';
import { registerPickOwner, unregisterPickOwner } from './pickRegistry.js';
import { getSelectedEntityContext } from './contextStore.js';
@@ -43,8 +51,11 @@ function makeFire(overrides = {}) {
* actionable card presents.
*/
function harness({
fires = [makeFire()], picked = null, cardHit = null, cameraPosition = null,
withDataSource = false, feed = null,
fires = [makeFire()],
picked = null,
cardHit = null,
cameraPosition = null,
feed = null,
} = {}) {
const hadWindow = Object.hasOwn(globalThis, 'window');
const priorWindow = globalThis.window;
@@ -52,15 +63,29 @@ function harness({
const windowTarget = new EventTarget();
globalThis.window = windowTarget;
// Spread projections so the greedy screen-separation declutter accepts every
// seeded detection; co-located fires would otherwise collide by design.
// seeded detection; co-located fires would otherwise collide by design. The
// engine's projector reads this when the engine is made.
let projected = 0;
Cesium.SceneTransforms.worldToWindowCoordinates = () => {
projected += 1;
return { x: 200 * projected, y: 300 };
};
const handlers = createFakeEventHandlers();
const { engine, viewer, primitive, frame } = createCesiumMoversEngine({
engineOptions: {
createEventHandler: handlers.create,
scheduleFrame: (run) => run(),
},
});
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
viewer.scene.pick = () => picked ?? undefined;
viewer.scene.drillPick = () => (picked ? [picked] : []);
engine.camera.setView({
destination:
cameraPosition ?? Cesium.Cartesian3.fromDegrees(-98, 30, 2_000_000),
});
const published = [];
let handler = null;
const layer = createFirmsHeatmapLayer({
...(feed ? { feed } : {}),
id: 'firms-test',
@@ -71,41 +96,48 @@ function harness({
clearSource() {},
hitTest: () => (cardHit ? { sourceId: 'firms', entryId: cardHit } : null),
},
screenSpaceEventHandlerFactory: () => {
handler = { click: null, setInputAction(cb) { this.click = cb; }, destroy() {} };
return handler;
// The display the registry gives every engine, with image seams.
renderer: {
create: ({ engine: target, ...options }) =>
createFirmsDisplay(target, {
...options,
loadImage: async () => ({ width: 16, height: 16 }),
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
}),
},
});
const viewer = {
scene: {
pick: () => picked,
frameState: { mode: Cesium.SceneMode.SCENE3D },
primitives: new Cesium.PrimitiveCollection(),
canvas: { clientWidth: 1280, clientHeight: 800 },
camera: cameraPosition ? { positionWC: cameraPosition } : undefined,
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
dataSources: { add() {}, remove() {} },
};
// The LOD rebuild (and with it the context-registration sweep) is inert
// without a data source, so tests that drive a real refresh need one.
if (withDataSource) layer.init(viewer);
layer._bindInteractionForTest(viewer, fires);
layer._bindInteractionForTest(engine, fires);
const requests = [];
windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) => requests.push(e.detail));
windowTarget.addEventListener(WORLD_FOCUS_REQUEST_EVENT, (e) =>
requests.push(e.detail),
);
return {
layer,
engine,
viewer,
frame,
/** The glow billboards drawn (the engine's sprite set), or null. */
markers: () => primitive('firms:firms-test') ?? null,
windowTarget,
requests,
published,
fires,
click: () => handler.click({ position: { x: 400, y: 300 } }),
click() {
for (const handler of handlers.handlers)
handler.fire(Cesium.ScreenSpaceEventType.LEFT_CLICK, {
position: new Cesium.Cartesian2(400, 300),
});
},
setHeight(height) {
engine.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-98, 30, height),
});
},
cleanup() {
layer.destroy();
engine.destroy();
Cesium.SceneTransforms.worldToWindowCoordinates = priorProjection;
if (hadWindow) globalThis.window = priorWindow;
else delete globalThis.window;
@@ -113,12 +145,26 @@ function harness({
};
}
const settle = async () => {
for (let i = 0; i < 4; i++)
await new Promise((resolve) => setImmediate(resolve));
};
test('fire detection key survives a refetch that renumbers the index', () => {
const fire = makeFire();
assert.equal(fireDetectionKey(fire), fireDetectionKey({ ...fire, index: 999 }));
assert.equal(
fireDetectionKey(fire),
fireDetectionKey({ ...fire, index: 999 }),
);
// Different pass over the same pixel is a different detection.
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }));
assert.notEqual(fireDetectionKey(fire), fireDetectionKey({ ...fire, lat: 31.0 }));
assert.notEqual(
fireDetectionKey(fire),
fireDetectionKey({ ...fire, acqMs: fire.acqMs + 1 }),
);
assert.notEqual(
fireDetectionKey(fire),
fireDetectionKey({ ...fire, lat: 31.0 }),
);
assert.match(fireDetectionKey({}), /^firms:x:x:0:x$/);
});
@@ -133,11 +179,20 @@ test('two satellites over the same pixel at the same time stay distinct', () =>
assert.notEqual(fireDetectionKey(base), fireDetectionKey(suomi));
assert.notEqual(fireDetectionKey(twin), fireDetectionKey(suomi));
// Satellite naming variants normalize to one identity, not three.
assert.equal(fireDetectionKey(base), fireDetectionKey(makeFire({ satellite: 'NOAA-20' })));
assert.equal(fireDetectionKey(suomi), fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })));
assert.equal(
fireDetectionKey(base),
fireDetectionKey(makeFire({ satellite: 'NOAA-20' })),
);
assert.equal(
fireDetectionKey(suomi),
fireDetectionKey(makeFire({ satellite: 'Suomi NPP' })),
);
// A record with no satellite falls back to its sensor, not to a shared blank.
const sensorOnly = makeFire({ satellite: '', sensor: 'MODIS' });
assert.notEqual(fireDetectionKey(sensorOnly), fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' })));
assert.notEqual(
fireDetectionKey(sensorOnly),
fireDetectionKey(makeFire({ satellite: '', sensor: 'VIIRS' })),
);
});
test('duplicate-coordinate detections get their own cards and focus targets', () => {
@@ -149,11 +204,21 @@ test('duplicate-coordinate detections get their own cards and focus targets', ()
cardHit: `fire:${fireDetectionKey(n21)}`,
});
try {
const cardIds = h.published.filter((e) => e.id.startsWith('fire:')).map((e) => e.id);
assert.equal(new Set(cardIds).size, 2, 'co-located detections must not collapse to one card');
const cardIds = h.published
.filter((e) => e.id.startsWith('fire:'))
.map((e) => e.id);
assert.equal(
new Set(cardIds).size,
2,
'co-located detections must not collapse to one card',
);
h.click();
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].id, fireDetectionKey(n21), 'the clicked card wins, not its twin');
assert.equal(
h.requests[0].id,
fireDetectionKey(n21),
'the clicked card wins, not its twin',
);
} finally {
h.cleanup();
}
@@ -164,7 +229,9 @@ test('selection restoration uses the same key the cards and focus use', () => {
// detections from different satellites — the selection could come back on
// the wrong record. Reachable only through the 30-minute refetch, so this is
// a source contract; the key's own behavior is proven above.
const match = LAYER_SOURCE.match(/function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/);
const match = LAYER_SOURCE.match(
/function findMatchingFire\(previous\) \{([\s\S]*?)\n \}\n/,
);
assert.ok(match, 'findMatchingFire is missing');
assert.match(match[1], /fireDetectionKey\(previous\)/);
assert.match(match[1], /fireDetectionKey\(fire\) === key/);
@@ -177,7 +244,11 @@ test('painted detection cards are keyed by the stable detection key', () => {
const card = h.published.find((entry) => entry.id.startsWith('fire:'));
assert.ok(card, 'a detection card was painted');
assert.equal(card.id, `fire:${fireDetectionKey(h.fires[0])}`);
assert.equal(card.interactive, true, 'a detection card must publish a hit rect');
assert.equal(
card.interactive,
true,
'a detection card must publish a hit rect',
);
assert.match(card.accessibilityLabel, /^Focus fire detection /);
assert.equal(card.activate(), true);
assert.equal(h.requests.length, 1);
@@ -189,7 +260,16 @@ test('painted detection cards are keyed by the stable detection key', () => {
test('an aggregated cell card is never actionable — it is not one fire', () => {
const cell = buildCellCard(
{ cell: { count: 14, maxFrp: 210, newestAcqMs: 0, latCell: 30, lonCell: -98 }, position: {} },
{
cell: {
count: 14,
maxFrp: 210,
newestAcqMs: 0,
latCell: 30,
lonCell: -98,
},
position: {},
},
Date.now(),
);
assert.equal(applyFirmsOverlayPolicy(cell, 1e6).interactive, false);
@@ -198,10 +278,16 @@ test('an aggregated cell card is never actionable — it is not one fire', () =>
test('far-side detections cannot consume the bounded ambient-card cohort', () => {
const front = makeFire({ index: 1, lat: 30.51, lon: -98.21, frp: 100 });
const rear = makeFire({ index: 2, lat: -30.51, lon: 81.79, frp: 1000 });
const cameraPosition = Cesium.Cartesian3.fromDegrees(front.lon, front.lat, 10_000_000);
const cameraPosition = Cesium.Cartesian3.fromDegrees(
front.lon,
front.lat,
10_000_000,
);
const h = harness({ fires: [rear, front], cameraPosition });
try {
const cardIds = h.published.filter((entry) => entry.id.startsWith('fire:')).map((entry) => entry.id);
const cardIds = h.published
.filter((entry) => entry.id.startsWith('fire:'))
.map((entry) => entry.id);
assert.deepEqual(cardIds, [`fire:${fireDetectionKey(front)}`]);
} finally {
h.cleanup();
@@ -278,7 +364,11 @@ test('a click on neither sprite nor card clears without moving the camera', () =
const SIBLING_PICKS = [
['flights', 'a1b2c3', { id: 'a1b2c3' }],
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
[
'ais-live-vessels',
'353136000',
{ id: { mmsi: '353136000', name: 'EVER GIVEN' } },
],
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
['satellites', '25544', { id: 25544 }],
];
@@ -294,7 +384,11 @@ for (const [layerId, ownedId, picked] of SIBLING_PICKS) {
});
try {
h.click();
assert.equal(h.requests.length, 0, `the ${layerId} layer is already acting on this click`);
assert.equal(
h.requests.length,
0,
`the ${layerId} layer is already acting on this click`,
);
} finally {
h.cleanup();
unregisterPickOwner(layerId);
@@ -321,15 +415,20 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
// record first and the clear then fails its ownership guard silently.
const kept = makeFire({ index: 0 });
const dropped = makeFire({
index: 1, lat: 31.77, lon: -99.02, acqMs: 1_753_600_100_000, satellite: 'N20',
index: 1,
lat: 31.77,
lon: -99.02,
acqMs: 1_753_600_100_000,
satellite: 'N20',
});
const h = harness({
fires: [dropped, kept],
cardHit: `fire:${fireDetectionKey(dropped)}`,
withDataSource: true,
});
const cleared = [];
h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) => cleared.push(e.detail));
h.windowTarget.addEventListener('gev:entity-selection-cleared', (e) =>
cleared.push(e.detail),
);
const priorFetch = globalThis.fetch;
// The far band keeps the markers built under the heatmap for the hand-off
// fade; their glow sprites need a canvas.
@@ -340,7 +439,11 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
try {
h.click();
assert.equal(h.requests.length, 1, 'the click must select the fire we are about to drop');
assert.equal(
h.requests.length,
1,
'the click must select the fire we are about to drop',
);
assert.equal(h.requests[0].id, fireDetectionKey(dropped));
assert.deepEqual(cleared, [], 'selecting must not clear anything yet');
@@ -351,18 +454,20 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
json: async () => ({
fetchedAt: Date.now(),
stale: false,
fires: [{
lat: kept.lat,
lon: kept.lon,
frp: kept.frp,
confidence: 'h',
brightness: 330,
daynight: 'D',
acqDate: '2025-07-27',
acqTime: '0412',
instrument: 'VIIRS',
satellite: kept.satellite,
}],
fires: [
{
lat: kept.lat,
lon: kept.lon,
frp: kept.frp,
confidence: 'h',
brightness: 330,
daynight: 'D',
acqDate: '2025-07-27',
acqTime: '0412',
instrument: 'VIIRS',
satellite: kept.satellite,
},
],
}),
});
await h.layer.update();
@@ -394,9 +499,16 @@ test('a refresh that drops the selected fire emits an eviction the readout can a
test('disable cancels a pending source and a late response cannot repopulate the layer', async () => {
let resolve, signal;
const h = harness({ withDataSource: true, feed: { getSnapshot(options) {
signal = options.signal; return new Promise(done => { resolve = done; });
} } });
const h = harness({
feed: {
getSnapshot(options) {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
},
},
});
try {
const before = h.layer.getStats();
const pending = h.layer.update();
@@ -407,16 +519,20 @@ test('disable cancels a pending source and a late response cannot repopulate the
assert.equal(h.layer.getStats().count, before.count);
assert.equal(h.layer.getStats().lastUpdate, before.lastUpdate);
assert.equal(h.layer.getStats().loading, false);
} finally { h.layer.destroy(h.viewer); h.cleanup(); }
} finally {
h.cleanup();
}
});
test('the heatmap is preloaded under the markers before the first zoom-out', (t) => {
test('the heatmap is preloaded under the markers before the first zoom-out', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const priorDocument = globalThis.document;
globalThis.document = {
createElement: () => ({
getContext: () => ({
createImageData: (w, hh) => ({ data: new Uint8ClampedArray(w * hh * 4) }),
createImageData: (w, hh) => ({
data: new Uint8ClampedArray(w * hh * 4),
}),
putImageData() {},
createRadialGradient: () => ({ addColorStop() {} }),
fillRect() {},
@@ -425,12 +541,8 @@ test('the heatmap is preloaded under the markers before the first zoom-out', (t)
}),
};
const h = harness({
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
});
h.viewer.camera = { positionCartographic: { height: 1_000_000 } };
h.viewer.scene.primitives = { add() {}, remove() {}, raiseToTop() {}, contains: () => true };
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
const added = [];
try {
h.layer.attachShellServices({
@@ -454,14 +566,13 @@ test('the heatmap is preloaded under the markers before the first zoom-out', (t)
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
} finally {
h.layer.destroy(h.viewer);
h.cleanup();
if (priorDocument === undefined) delete globalThis.document;
else globalThis.document = priorDocument;
}
});
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', () => {
test('far zoom drapes one heatmap and the hand-off cross-fades it with the markers', async () => {
// A canvas stub for the heat drape and the detection glow sprites.
const priorDocument = globalThis.document;
const restoreDocument = () => {
@@ -483,26 +594,18 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke
};
const h = harness({
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
withDataSource: true,
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
});
const added = [];
const removed = [];
let markers = null;
// The fade reads the camera height; the cull reads scene.camera.
const setHeight = (height) => {
h.viewer.camera = { positionCartographic: { height } };
// The markers as drawn: how many the sprite set draws, and their alphas.
const drawn = () => {
const count =
h.engine.movers.list().find((set) => set.id === 'firms:firms-test')
?.count ?? 0;
const markers = h.markers();
return Array.from({ length: count }, (_, i) => markers.get(i).color.alpha);
};
setHeight(12_000_000);
h.viewer.scene.primitives = {
add(primitive) {
markers = primitive;
},
remove() {},
raiseToTop() {},
contains: () => true,
};
h.viewer.scene.frameState = { mode: Cesium.SceneMode.SCENE3D };
try {
const collection = {
addImageryProvider: (provider) => {
@@ -517,39 +620,53 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke
imageryHost: () => ({ kind: 'globe', collection }),
});
h.layer._renderLodForTest('global');
await settle();
assert.equal(added.length, 1, 'one heat imagery layer');
assert.equal(added[0].show, true);
assert.equal(added[0].alpha, 1);
assert.equal(h.layer._entityCountForTest(), 0, 'no rectangle entities');
assert.equal(h.layer._cellCountForTest(), 0, 'no ground cells');
assert.ok(h.published.length > 0, 'the far band still paints cards');
assert.equal(markers.length, 2, 'the markers are built under the heatmap');
assert.equal(markers.show, false, 'and hidden while the heatmap is whole');
assert.deepEqual(drawn(), [], 'no marker drawn while the heatmap is whole');
// The markers are built under it: the hand-off shows them without a rebuild.
h.setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
await settle();
assert.deepEqual(
drawn(),
[0.5, 0.5],
'the markers are built under the heatmap',
);
h.setHeight(12_000_000);
h.layer._applyHandoffFadeForTest();
await settle();
assert.deepEqual(drawn(), []);
h.layer._renderLodForTest('regional');
assert.equal(added.length, 1, 'switching far bands reuses the raster');
setHeight(1_000_000);
h.setHeight(1_000_000);
h.layer._renderLodForTest('close');
await settle();
assert.equal(added.length, 1, 'the markers band keeps the same heat layer');
assert.equal(added[0].show, true, 'still mapped onto the globe tiles');
assert.equal(added[0].alpha, 0, 'faded out under the markers');
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 1);
assert.deepEqual(drawn(), [1, 1]);
// Mid hand-off both are drawn, half each, in either band.
setHeight(4_500_000);
h.setHeight(4_500_000);
h.layer._applyHandoffFadeForTest();
await settle();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.show, true);
assert.equal(markers.get(0).color.alpha, 0.5);
assert.deepEqual(drawn(), [0.5, 0.5]);
h.layer._renderLodForTest('regional');
await settle();
assert.equal(added[0].alpha, 0.5);
assert.equal(markers.get(1).color.alpha, 0.5, 'rebuilt markers get the fade');
assert.deepEqual(drawn(), [0.5, 0.5], 'rebuilt markers get the fade');
h.layer.disable();
assert.equal(added[0].show, false, 'disable hides the heatmap');
h.layer.destroy(h.viewer);
h.layer.destroy();
assert.deepEqual(
removed.map((entry) => [entry.layer, entry.destroy]),
[[added[0], true]],
+4 -3
View File
@@ -120,7 +120,8 @@ test('every scene click handler consults ownership before it picks', () => {
// ALPR and bikeshare take the engine's click with its pick.
['src/layers/alpr/render/display.js', 'overlay.pick(position) ||'],
['src/layers/bikeshare/render/display.js', 'const picked = event.picked;'],
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
// FIRMS takes the engine dispatcher's click with its pick.
['src/layers/firms/selection.js', 'const picked = click.picked;'],
[
'src/layers/installations/render/cesium/selection.js',
'viewer.scene.pick(click.position)',
@@ -136,8 +137,8 @@ test('every scene click handler consults ownership before it picks', () => {
'getRenderer().isTrackedPick(picked',
],
[
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
'src/layers/submarineCables/render/display.js',
'const picked = event.picked;',
],
// Vessels take the engine dispatcher's pick: the guard
// comes before the click rule reads it.
+44 -25
View File
@@ -16,7 +16,9 @@ function makePrimitives(initial = []) {
return {
items: [...initial],
calls: [],
contains(collection) { return this.items.includes(collection); },
contains(collection) {
return this.items.includes(collection);
},
raiseToTop(collection) {
this.calls.push(collection.id);
const index = this.items.indexOf(collection);
@@ -31,10 +33,15 @@ function makeCollection(id, destroyed = false) {
}
test('restoreSpriteOrder raises live collections bottom-to-top and skips destroyed entries', () => {
const collections = Object.fromEntries(ORDER.map((id) => [id, makeCollection(id)]));
const collections = Object.fromEntries(
ORDER.map((id) => [id, makeCollection(id)]),
);
const destroyedFirms = makeCollection('firms', true);
for (const id of ORDER) {
registerSpriteCollection(id, id === 'firms' ? destroyedFirms : collections[id]);
registerSpriteCollection(
id,
id === 'firms' ? destroyedFirms : collections[id],
);
}
const primitives = makePrimitives([
collections.flights,
@@ -46,10 +53,17 @@ test('restoreSpriteOrder raises live collections bottom-to-top and skips destroy
restoreSpriteOrder({ scene: { primitives } });
assert.deepEqual(primitives.calls, ['cctv', 'bikeshare', 'ais', 'military', 'flights']);
assert.deepEqual(primitives.items.map((item) => item.id), [
'cctv', 'bikeshare', 'ais', 'military', 'flights',
assert.deepEqual(primitives.calls, [
'cctv',
'bikeshare',
'ais',
'military',
'flights',
]);
assert.deepEqual(
primitives.items.map((item) => item.id),
['cctv', 'bikeshare', 'ais', 'military', 'flights'],
);
for (const id of ORDER) unregisterSpriteCollection(id);
});
@@ -69,7 +83,10 @@ test('late CCTV registration still restores flights above the ambient collection
restoreSpriteOrder(viewer);
assert.deepEqual(primitives.calls, ['cctv', 'flights']);
assert.deepEqual(primitives.items.map((item) => item.id), ['cctv', 'flights']);
assert.deepEqual(
primitives.items.map((item) => item.id),
['cctv', 'flights'],
);
unregisterSpriteCollection('cctv', cctv);
unregisterSpriteCollection('flights', flights);
@@ -81,7 +98,9 @@ test('restoreSpriteOrder is inert for destroyed viewers and primitive collection
registerSpriteCollection('flights', flights);
restoreSpriteOrder({ isDestroyed: () => true, scene: { primitives } });
restoreSpriteOrder({ scene: { primitives: { ...primitives, isDestroyed: () => true } } });
restoreSpriteOrder({
scene: { primitives: { ...primitives, isDestroyed: () => true } },
});
assert.deepEqual(primitives.calls, []);
unregisterSpriteCollection('flights', flights);
@@ -112,32 +131,32 @@ test('flights, AIS, and FIRMS enable paths are wired through the shared sprite r
// Flights draws through its display: enable asks the fleet to restore its
// order; the display's sprite sets take the flights' slot as their draw
// order on every engine (aircraftLayerDisplay).
assert.match(flightsLayer.enable.toString(), /fleet\.restoreOrderOnEnable\(\)/);
assert.match(
readFileSync(new URL('../layers/aircraft/render/display.js', import.meta.url), 'utf8'),
flightsLayer.enable.toString(),
/fleet\.restoreOrderOnEnable\(\)/,
);
assert.match(
readFileSync(
new URL('../layers/aircraft/render/display.js', import.meta.url),
'utf8',
),
/order: SPRITE_LAYER_ORDER\.indexOf\(layerId\)/,
);
assert.match(
readFileSync(new URL('../layers/flights/render/display.js', import.meta.url), 'utf8'),
readFileSync(
new URL('../layers/flights/render/display.js', import.meta.url),
'utf8',
),
/aircraftLayerDisplay\(\s*'flights',/,
);
assert.match(
firmsLayer.enable.toString(),
/renderer\.onEnable\(\)/,
);
// FIRMS draws through its display: its glow sprite set takes the 'firms'
// slot as its draw order on every engine.
assert.doesNotMatch(firmsLayer.enable.toString(), /onEnable/);
assert.match(
readFileSync(
new URL('../layers/firms/render/cesium.js', import.meta.url),
new URL('../layers/firms/render/sprites.js', import.meta.url),
'utf8',
),
/restoreSpriteOrderOnEnable\('firms', viewer\)/,
);
assert.match(
readFileSync(
new URL('../layers/firms/render/cesium.js', import.meta.url),
'utf8',
),
/registerSpriteCollection\('firms', sprites\);\s*spriteServices\.restoreSpriteOrder\(viewer\);/,
'lazy FIRMS registration must restore order immediately',
/FIRMS_SPRITE_ORDER = SPRITE_LAYER_ORDER\.indexOf\('firms'\)[\s\S]*order: FIRMS_SPRITE_ORDER/,
);
});
+3 -4
View File
@@ -22,9 +22,8 @@ export {
selectCableReferenceLabelWinners,
cableReferencePriority,
createCableOverlayEntry,
cableClassificationTypeForStack,
cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
cableDrapeForStack,
cableDrapeForEngine,
createCableReferenceSweepGate,
updateCableReferenceStem,
updateCableReferenceTip,
} from '../layers/submarineCables/index.js';
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -24,7 +24,7 @@ const SITES = [
['../layers/recentImagery/index.js', 'Imagery catalog unavailable'],
['../layers/installations/ingestion.js', 'Installation context unavailable'],
['../layers/earthquakes/index.js', 'Earthquake source unavailable'],
['../layers/submarineCables/ingestion.js', 'TeleGeography load failed'],
['../layers/submarineCables/session.js', 'TeleGeography load failed'],
['../layers/vessels/ingestion.js', 'AIS live load failed'],
['../layers/vessels/lifecycle.js', 'AIS live load failed'],
['../layers/wind/index.js', 'Wind unavailable'],
@@ -6,6 +6,7 @@ import { setLocale, t } from '../../i18n/index.js';
import { layerNameForId, localizeLayerName } from '../../ui/layerNames.js';
import { LayerPanel } from '../../ui/layerPanel.js';
import { translateFeedError } from '../../ui/statusText.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createAirQualityLayer } from './index.js';
function inLocale(locale, run) {
@@ -22,9 +23,8 @@ function harness(source) {
source,
overlayHost: { setEntries() {}, setVisible() {}, clearSource() {} },
});
const viewer = { dataSources: { add() {}, remove() {} } };
layer.init(viewer);
layer.enable(viewer);
layer.init(null, { engine: createFakeEngine() });
layer.enable();
return layer;
}
+5 -5
View File
@@ -1,6 +1,6 @@
import { fromDegrees } from '../../geo/cartesian3.js';
import { toDegrees } from '../../geo/math.js';
import { createAirQualityCesiumRenderer } from './render/cesium.js';
import { createAirQualityDisplay } from './render/display.js';
import { msg, t, tn } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
@@ -28,7 +28,7 @@ const READOUT_LIMIT = 5;
export function createAirQualityLayer({
source,
overlayHost,
createRenderer = createAirQualityCesiumRenderer,
createRenderer = ({ engine }) => createAirQualityDisplay(engine),
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Air quality requires a snapshot source');
@@ -69,7 +69,7 @@ export function createAirQualityLayer({
throw new Error('Air quality layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
@@ -79,7 +79,7 @@ export function createAirQualityLayer({
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid air quality scene attachment');
renderer = createRenderer({ viewer, engine });
renderer = createRenderer({ engine });
renderer.setVisible(_enabled);
overlayHost.setVisible(AIR_QUALITY_OVERLAY_SOURCE_ID, _enabled);
if (_readings.length) this._render();
@@ -139,7 +139,7 @@ export function createAirQualityLayer({
}
},
/** Rebuild entities and overlay labels from the current readings. */
/** Redraw the station discs and overlay labels from the current readings. */
_render() {
if (!renderer) return;
renderer.replace(_readings, STATION_MARKER_RADIUS_M);
+17 -25
View File
@@ -1,22 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createAirQualityLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
import { STALE_AFTER_MS } from './model.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const ground = createFakeGroundHarness();
const { engine } = ground;
const layer = createAirQualityLayer({
source,
overlayHost: {
@@ -27,9 +19,9 @@ function harness(source) {
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
layer.init(null, { engine });
layer.enable();
return { layer, engine, ground, events };
}
const reading = (id, value, overrides = {}) => ({
@@ -61,12 +53,12 @@ test('a layer requires both a snapshot source and an overlay host', () => {
);
});
test('initialization is single-use and owns exactly one data source', () => {
const { layer, viewer, sources } = harness(staticSource([]));
assert.equal(sources.length, 1);
assert.throws(() => layer.init(viewer), /already initialized/);
layer.destroy(viewer);
assert.equal(sources.length, 0);
test('initialization is single-use and owns exactly one ground set', () => {
const { layer, engine, ground } = harness(staticSource([]));
assert.equal(ground.sets(), 1);
assert.throws(() => layer.init(null, { engine }), /already initialized/);
layer.destroy();
assert.equal(ground.sets(), 0);
});
test('a refresh renders readings and publishes an overlay cohort', async () => {
@@ -188,14 +180,14 @@ test('the readout reports an error rather than a silent empty card', async () =>
});
test('row-control listeners are notified on refresh and teardown-safe', async () => {
const { layer, viewer } = harness(staticSource([reading('A', 2)]));
const { layer } = harness(staticSource([reading('A', 2)]));
let calls = 0;
layer.setRowControlsListener(() => {
calls += 1;
});
await layer.update();
assert.ok(calls >= 1, 'a refresh notifies the card');
layer.destroy(viewer);
layer.destroy();
const after = calls;
layer.setRowControlsListener(() => {
calls += 1;
@@ -284,13 +276,13 @@ test('station data stays queryable and attaches to a fresh scene without polling
});
try {
h.layer.detachRenderer();
assert.equal(h.sources.length, 0);
assert.equal(h.ground.sets(), 0);
assert.equal(await h.layer.update(), true);
assert.equal(h.events.length, 0);
assert.equal(h.layer.getAnalystRecords()[0].value, 7);
h.layer.attachRenderer(h.viewer);
h.layer.attachRenderer(null, { engine: h.engine });
assert.equal(h.events.at(-1)[0], 'air-quality');
assert.equal(h.sources[0].entities.values.length, 1);
assert.equal(h.ground.set('air-quality').items.length, 1);
assert.equal(polls, 1);
} finally {
h.layer.destroy();
-56
View File
@@ -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);
},
};
}
+55
View File
@@ -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();
});
-62
View File
@@ -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 -1
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createCyclonesLayer } from './index.js';
import { setLocale, t } from '../../i18n/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
// The exact string the server sends (COVERAGE in server/providers/cyclones.js).
const SERVER_COVERAGE =
@@ -52,7 +53,7 @@ function harness(feed) {
matchMedia: () => ({ matches: false }),
openLink: () => {},
});
layer.init({ camera: { flyToBoundingSphere() {} } });
layer.init(null, { engine: createFakeEngine() });
layer.attachShellServices({ runNavigation: (fn) => fn() });
return layer;
}
+26 -50
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { hitTestWorldOverlay } from '../../overlays/worldOverlay.js';
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import { isPointerFree } from '../../data/inputOwnership.js';
@@ -6,10 +5,7 @@ import {
registerPickOwner,
unregisterPickOwner,
} from '../../data/pickRegistry.js';
import {
createCycloneRendering,
coherentCycloneGeometry,
} from './rendering.js';
import { coherentCycloneGeometry } from './rendering.js';
import {
CYCLONE_OVERLAY_SOURCE_ID,
cycloneStormIdFromEntryId,
@@ -62,23 +58,24 @@ const classificationName = (code) =>
const number = (value, unit) =>
value === null ? t('Unavailable') : formatQuantity(value, unit);
/** Advisory status and coherent forecast geometry; selected through the shared row list. */
/**
* Advisory status and coherent forecast geometry; selected through the
* shared row list. `createRendering(engine, { overlayHost })` makes the
* engine's display (render/display.js through the renderer registry).
*/
export function createCyclonesLayer({
feed,
hitTestOverlay = hitTestWorldOverlay,
overlayHost,
context = null,
cesium = null,
createRendering = createCycloneRendering,
createNativeRendering = null,
createRendering = null,
matchMedia = globalThis.matchMedia?.bind(globalThis),
openLink = (url) => globalThis.open?.(url, '_blank', 'noopener,noreferrer'),
} = {}) {
if (typeof feed?.getSnapshot !== 'function')
throw new TypeError('Cyclones require a snapshot source');
let engine = null;
let viewer = null,
rendering = null,
let rendering = null,
snapshot = null,
request = null,
listener = null,
@@ -157,27 +154,14 @@ export function createCyclonesLayer({
selectedId = id;
rendering?.setSelection(id);
}
// The SDK, on Cesium only: the production build's Cesium global loads with
// the first Cesium globe, so a Navara page has none.
const sdk = () => cesium ?? (engine?.kind === 'navara' ? null : Cesium);
// Photorealistic 3D Tiles pick as tileset content without an entity id;
// that is empty map, the same as no pick at all on the globe.
// Photorealistic 3D Tiles pick as tile content without an id; that is
// empty map, the same as no pick at all on the globe.
const isSurfacePick = (picked) =>
!picked ||
(picked.id === undefined &&
(picked.content !== undefined ||
(typeof sdk()?.Cesium3DTileset === 'function' &&
picked.primitive instanceof sdk().Cesium3DTileset)));
!picked || (picked.id === undefined && picked.content !== undefined);
function installSelection() {
const canvas = engine?.host.canvas ?? viewer?.scene?.canvas;
const native = engine?.kind === 'navara';
if (
clickHandler ||
!canvas ||
(!native && typeof sdk()?.ScreenSpaceEventHandler !== 'function')
)
return;
const owner = native ? {} : new (sdk().ScreenSpaceEventHandler)(canvas);
const canvas = engine?.host.canvas;
if (clickHandler || !canvas) return;
const owner = {};
clickHandler = owner;
let capturedHit = null;
const overlayHit = (x, y) => {
@@ -246,21 +230,19 @@ export function createCyclonesLayer({
if (id) layer.setParams({ stormId: id });
return;
}
const picked = native ? click.picked : viewer.scene.pick(click.position);
const { picked } = click;
const id = rendering?.pickStorm(picked);
if (id) layer.setParams({ stormId: id });
else if (isSurfacePick(picked)) layer.setParams({ clear: true });
};
if (native)
owner.destroy = engine.input.on('click', onClick, { pick: true });
else owner.setInputAction(onClick, sdk().ScreenSpaceEventType.LEFT_CLICK);
owner.destroy = engine.input.on('click', onClick, { pick: true });
}
function removeSelection() {
const owner = clickHandler;
clickHandler = null;
removeClickCapture?.();
removeClickCapture = null;
if (owner && !owner.isDestroyed?.()) owner.destroy();
owner?.destroy();
}
const layer = {
id: 'weather-cyclones',
@@ -268,20 +250,18 @@ export function createCyclonesLayer({
icon: '◉',
source: 'NOAA NHC / CPHC',
updateInterval: 300_000,
init(nextViewer, options = {}) {
if (nextViewer || options.engine)
return layer.attachRenderer(nextViewer, options);
init(_viewer, options = {}) {
if (options.engine) return layer.attachRenderer(_viewer, options);
},
attachRenderer(nextViewer, options = {}) {
viewer = nextViewer;
attachRenderer(_viewer, options = {}) {
engine = options.engine ?? null;
rendering =
engine?.kind === 'navara'
? createNativeRendering(engine, { overlayHost })
: createRendering({ viewer, cesium: sdk(), overlayHost });
engine && createRendering
? createRendering(engine, { overlayHost })
: null;
if (!enabled) return;
installSelection();
if (snapshot && !snapshot.unavailable) {
if (rendering && snapshot && !snapshot.unavailable) {
const active = rendering;
return Promise.resolve(active.setSnapshot(snapshot)).then(() => {
if (active === rendering) active.setSelection(selectedId);
@@ -293,7 +273,6 @@ export function createCyclonesLayer({
++navigationGeneration;
rendering?.destroy();
rendering = null;
viewer = null;
engine = null;
runNavigation = null;
},
@@ -424,7 +403,7 @@ export function createCyclonesLayer({
notify();
}
const storm = selected();
if (params.focus === true && storm && runNavigation) {
if (params.focus === true && storm && runNavigation && engine) {
const sphere = rendering?.getFocusSphere(storm.id);
if (sphere) {
const generation = ++navigationGeneration;
@@ -436,10 +415,7 @@ export function createCyclonesLayer({
selectedId !== storm.id
)
return;
const fly = engine
? engine.camera.flyToSphere.bind(engine.camera)
: viewer.camera.flyToBoundingSphere.bind(viewer.camera);
return fly(sphere, {
return engine.camera.flyToSphere(sphere, {
duration: matchMedia?.('(prefers-reduced-motion: reduce)')
?.matches
? 0
+109 -141
View File
@@ -47,73 +47,61 @@ const deferred = () => {
return { promise, resolve };
};
// A fake engine whose canvas takes the layer's release capture and whose
// picks the test scripts; `clicks()` counts click subscriptions.
function clickEngine() {
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const engine = createFakeEngine({ canvas });
let picked;
let picks = 0;
engine.fake.script('pick', () => {
picks++;
return picked;
});
return {
engine,
canvas,
click: (click) => engine.fake.input('click', click),
setPicked(value) {
picked = value;
},
get picks() {
return picks;
},
subscriptions: () => engine.fake.callsOf('input.on').length,
};
}
test('map selection yields to pointer owners and owns only its enabled handler', async () => {
const handlers = [],
pickedEntity = {},
foreignEntity = {};
let picked = { id: pickedEntity },
overlayHit = null,
const pickedStorm = { id: 'cyclone:ep162026:center', owner: 'cyclones' },
foreignPick = { id: 'aircraft:abc', owner: 'flights' };
let overlayHit = null,
overlayTests = 0,
picks = 0,
notices = 0;
const rendering = {
setSnapshot: async () => true,
setSelection() {},
clear() {},
destroy() {},
pickStorm: (value) => (value?.id === pickedEntity ? 'ep162026' : null),
pickStorm: (value) => (value === pickedStorm ? 'ep162026' : null),
ownsPickId: (id) => id === 'cyclone:ep162026:center',
getDiagnostics: () => ({}),
};
const cesium = {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor(canvas) {
assert.ok(canvas);
handlers.push(this);
this.destroyed = false;
}
setInputAction(callback, type) {
assert.equal(type, 'left');
this.click = callback;
}
destroy() {
this.destroyed = true;
}
isDestroyed() {
return this.destroyed;
}
},
};
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium,
createRendering: () => rendering,
hitTestOverlay: () => {
overlayTests++;
return overlayHit;
},
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 100, top: 200 });
const viewer = {
scene: {
canvas,
pick() {
picks++;
return picked;
},
},
trackedEntity: null,
camera: {
flyToBoundingSphere() {
assert.fail('Click must not move the camera');
},
},
};
layer.init(viewer);
const h = clickEngine();
h.engine.camera.flyToSphere = () =>
assert.fail('Click must not move the camera');
layer.init(null, { engine: h.engine });
layer.setRowControlsListener(() => notices++);
assert.equal(handlers.length, 0, 'disabled module installs no handler');
assert.equal(h.subscriptions(), 0, 'disabled module installs no handler');
layer.enable();
layer.enable();
assert.equal(
@@ -127,62 +115,64 @@ test('map selection yields to pointer owners and owns only its enabled handler',
false,
'registration recognizes exact current IDs, not a prefix',
);
assert.equal(handlers.length, 1, 'enable is idempotent');
assert.equal(h.subscriptions(), 1, 'enable is idempotent');
await layer.update();
const click = { position: { x: 10, y: 20 } };
h.setPicked(pickedStorm);
const owner = claimPointer('director');
assert.ok(owner);
try {
handlers[0].click(click);
assert.equal(picks, 0, 'claimed pointer is checked before picking');
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
} finally {
releasePointer(owner);
}
picked = { id: foreignEntity };
handlers[0].click(click);
h.setPicked(foreignPick);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
picked = { primitive: {} };
handlers[0].click(click);
assert.equal(layer.getDiagnostics().selectionIntent, 'auto');
picked = { id: pickedEntity };
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
const beforeCard = picks;
handlers[0].click(click);
h.setPicked({ object: {} });
h.click(click);
assert.equal(
picks,
beforeCard,
'foreground AIS card wins before scene picking',
layer.getDiagnostics().selectionIntent,
'auto',
'an unnamed pick without tile content is not empty map',
);
h.setPicked(pickedStorm);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
h.click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'foreground AIS card wins before the scene pick',
);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A native capture runs before an earlier sibling handler clears hit rects.
const up = new Event('pointerup');
Object.assign(up, { clientX: 110, clientY: 220 });
canvas.dispatchEvent(up);
h.canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
h.click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep152026',
'captured AIS hit survives synchronous sibling overlay rebuild',
);
// A captured background hit remains background even if a sibling adds a card.
canvas.dispatchEvent(up);
h.canvas.dispatchEvent(up);
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
// A new gesture clears an unconsumed release snapshot.
canvas.dispatchEvent(up);
canvas.dispatchEvent(new Event('pointerdown'));
h.canvas.dispatchEvent(up);
h.canvas.dispatchEvent(new Event('pointerdown'));
overlayHit = null;
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'ais-live-vessels', entryId: 'vessel:123' };
canvas.dispatchEvent(up);
h.canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click({ position: { x: 15, y: 25 } });
h.click({ position: { x: 15, y: 25 } });
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
@@ -191,32 +181,32 @@ test('map selection yields to pointer owners and owns only its enabled handler',
layer.setParams({ stormId: 'ep152026' });
overlayHit = { sourceId: 'cctv', entryId: 'camera:1' };
const before = notices;
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.match(layer.getRowControls().summary.detail, /Another system/);
assert.equal(layer.getRowControls().list.items[1].active, true);
assert.ok(notices > before);
assert.equal(viewer.trackedEntity, null);
picked = undefined;
assert.equal(h.engine.camera.following(), null);
h.setPicked(undefined);
overlayHit = { sourceId: 'ais-live-vessels' };
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
overlayHit = null;
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, null);
assert.equal(layer.getDiagnostics().selectionIntent, 'cleared');
picked = { id: pickedEntity };
handlers[0].click(click);
h.setPicked(pickedStorm);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
picked = { id: 'flight:abc', primitive: {} };
handlers[0].click(click);
h.setPicked({ id: 'flight:abc', owner: 'flights' });
h.click(click);
assert.equal(
layer.getDiagnostics().selectedId,
'ep162026',
"another layer's pick leaves the selection alone",
);
picked = { content: {}, primitive: {} };
handlers[0].click(click);
h.setPicked({ id: undefined, content: {} });
h.click(click);
assert.equal(
layer.getDiagnostics().selectedId,
null,
@@ -235,56 +225,45 @@ test('map selection yields to pointer owners and owns only its enabled handler',
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[0].destroyed, true);
assert.equal(layer.getDiagnostics().selectionActive, false);
const afterDisable = picks;
const afterDisable = h.picks;
const afterDisableOverlay = overlayTests;
canvas.dispatchEvent(up);
h.canvas.dispatchEvent(up);
assert.equal(
overlayTests,
afterDisableOverlay,
'disable removes native capture listeners',
);
handlers[0].click(click);
assert.equal(picks, afterDisable, 'queued disabled callback is inert');
h.click(click);
assert.equal(h.picks, afterDisable, 'disable removes the click listener');
layer.enable();
await layer.update();
assert.equal(isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'), true);
handlers[0].click(click);
h.click(click);
assert.equal(
picks,
afterDisable,
'superseded handler remains inert after re-enable',
h.picks,
afterDisable + 1,
're-enable listens once; the removed listener stays inert',
);
assert.equal(handlers.length, 2);
assert.equal(h.subscriptions(), 2);
layer.destroy();
assert.equal(
isOwnedByOtherLayer('flights', 'cyclone:ep162026:center'),
false,
);
assert.equal(handlers[1].destroyed, true);
const afterDestroy = h.picks;
h.click(click);
assert.equal(h.picks, afterDestroy, 'destroy removes the click listener');
});
test('a click on a storm card or lead-hour label selects that storm without picking', async () => {
const handlers = [];
let picks = 0,
overlayHit = null,
hostArgument;
test('a click on a storm card or lead-hour label selects that storm whatever lies under it', async () => {
let overlayHit = null,
hostArgument,
engineArgument;
const layer = createCyclonesLayer({
feed: { getSnapshot: async () => snapshot([storm(), storm('ep162026')]) },
cesium: {
ScreenSpaceEventType: { LEFT_CLICK: 'left' },
ScreenSpaceEventHandler: class {
constructor() {
handlers.push(this);
}
setInputAction(callback) {
this.click = callback;
}
destroy() {}
},
},
overlayHost: 'host',
createRendering: ({ overlayHost }) => {
createRendering: (engine, { overlayHost }) => {
engineArgument = engine;
hostArgument = overlayHost;
return {
setSnapshot: async () => true,
@@ -298,44 +277,36 @@ test('a click on a storm card or lead-hour label selects that storm without pick
},
hitTestOverlay: () => overlayHit,
});
const canvas = new EventTarget();
canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
layer.init({
scene: {
canvas,
pick() {
picks++;
return { content: {} };
},
},
});
const h = clickEngine();
h.canvas.getBoundingClientRect = () => ({ left: 0, top: 0 });
h.setPicked({ id: undefined, content: {} });
layer.init(null, { engine: h.engine });
assert.equal(hostArgument, 'host', 'the overlay host reaches the renderer');
assert.equal(engineArgument, h.engine, 'the renderer draws on the engine');
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
const click = { position: { x: 10, y: 20 } };
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(layer.getDiagnostics().selectionIntent, 'user');
overlayHit = { sourceId: 'weather-cyclones', entryId: 'lead:ep152026:24' };
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// A card from a superseded advisory is not empty map.
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:al019999' };
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
// The native release snapshot carries the card identity too.
const up = new Event('pointerup');
Object.assign(up, { clientX: 10, clientY: 20 });
overlayHit = { sourceId: 'weather-cyclones', entryId: 'storm:ep162026' };
canvas.dispatchEvent(up);
h.canvas.dispatchEvent(up);
overlayHit = null;
handlers[0].click(click);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, 'ep162026');
assert.equal(picks, 0, 'card clicks never fall through to the scene');
handlers[0].click(click);
assert.equal(picks, 1);
h.click(click);
assert.equal(layer.getDiagnostics().selectedId, null, 'empty map clears');
layer.destroy();
});
@@ -376,13 +347,10 @@ function harness(
matchMedia: () => ({ matches: reducedMotion }),
openLink: (url) => opened.push(url),
});
const viewer = {
camera: {
flyToBoundingSphere: (sphere, options) =>
navigation.push({ sphere, options }),
},
};
layer.init(viewer);
const engine = createFakeEngine();
engine.camera.flyToSphere = (sphere, options) =>
navigation.push({ sphere, options });
layer.init(null, { engine });
layer.attachShellServices({
runNavigation: (fn) => {
navigation.push('claimed');
@@ -705,7 +673,7 @@ test('clearing while a refreshed data source is staging cannot reselect a storm'
destroy() {},
}),
});
layer.init({});
layer.init(null, { engine: createFakeEngine() });
layer.enable();
await layer.update();
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
@@ -941,7 +909,7 @@ test('advisories delivered between scenes keep a deliberate selection without an
destroy() {},
}),
});
layer.init({});
layer.init(null, { engine: createFakeEngine() });
layer.enable();
await layer.update();
layer.setParams({ stormId: 'ep152026' });
@@ -950,7 +918,7 @@ test('advisories delivered between scenes keep a deliberate selection without an
pending.resolve(snapshot([{ ...storm(), advisoryNumber: '11' }]));
await refresh;
assert.equal(layer.getDiagnostics().selectedId, 'ep152026');
await layer.attachRenderer({});
await layer.attachRenderer(null, { engine: createFakeEngine() });
assert.deepEqual(drawn, [['10'], ['11']]);
assert.equal(reads, 2);
layer.destroy();
+408
View File
@@ -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,
};
},
};
}
+613
View File
@@ -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',
);
},
);
-209
View File
@@ -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 -306
View File
@@ -1,9 +1,4 @@
import {
CYCLONE_ACCENT,
CYCLONE_MARKER_PX,
CYCLONE_SELECTED_ACCENT,
createCycloneLabels,
} from './labels.js';
// Which advisory geometry the cyclone display draws (render/display.js).
/** Only geometry from the displayed status advisory is eligible for rendering. */
export function coherentCycloneGeometry(storm) {
@@ -12,303 +7,3 @@ export function coherentCycloneGeometry(storm) {
storm.geometryAdvisoryNumber === storm.advisoryNumber
);
}
/**
* Static Cesium entities with owned horizon culling; no timers or clock.
* Storm names and lead hours are published to the shared overlay host at the
* same anchors the points use.
*/
export function createCycloneRendering({ viewer, cesium: C, overlayHost }) {
const labels = createCycloneLabels({ host: overlayHost });
let source = null,
generation = 0,
selected = null,
destroyed = false;
let centers = new Map(),
spheres = new Map();
let entityStorms = new WeakMap();
let entityIds = new Set();
let counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
let horizonStorms = [],
removePreRender = null,
pointOccluder = null,
sphereOccluder = null;
const blue = C.Color.fromCssColorString(CYCLONE_ACCENT);
const gold = C.Color.fromCssColorString(CYCLONE_SELECTED_ACCENT);
const white = C.Color.WHITE;
const render = () => {
if (!viewer.isDestroyed?.()) viewer.scene.requestRender();
};
function cullHorizon() {
pointOccluder.cameraPosition = viewer.camera.positionWC;
sphereOccluder.cameraPosition = viewer.camera.positionWC;
let changed = false;
for (let i = 0; i < horizonStorms.length; i++) {
const storm = horizonStorms[i];
for (let j = 0; j < storm.points.length; j++) {
const { entity, position } = storm.points[j];
const show = pointOccluder.isPointVisible(position);
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
if (!storm.shapes.length) continue;
const show = sphereOccluder.isBoundingSphereVisible(storm.sphere);
for (let j = 0; j < storm.shapes.length; j++) {
const entity = storm.shapes[j];
if (entity.show !== show) {
entity.show = show;
changed = true;
}
}
}
if (changed) render();
}
function syncHorizonListener() {
if (!horizonStorms.length) {
removePreRender?.();
removePreRender = null;
return;
}
if (removePreRender) return;
pointOccluder ||= new C.EllipsoidalOccluder(
C.Ellipsoid.WGS84,
viewer.camera.positionWC,
);
// EllipsoidalOccluder only tests points. An inscribed WGS84 sphere
// conservatively culls extents, retaining partially visible tracks/cones.
sphereOccluder ||= new C.Occluder(
new C.BoundingSphere(C.Cartesian3.ZERO, C.Ellipsoid.WGS84.minimumRadius),
viewer.camera.positionWC,
);
removePreRender = viewer.scene.preRender.addEventListener(cullHorizon);
}
function remove(value) {
if (!value) return;
if (!viewer.dataSources.isDestroyed?.())
viewer.dataSources.remove(value, true);
value.entities.removeAll();
}
function select(id) {
selected = id;
for (const [stormId, entity] of centers) {
entity.point.color = stormId === id ? gold : blue;
entity.point.pixelSize =
stormId === id ? CYCLONE_MARKER_PX.selected : CYCLONE_MARKER_PX.storm;
}
labels.setSelection(id);
render();
}
return {
async setSnapshot(snapshot, { signal } = {}) {
signal?.throwIfAborted();
if (destroyed) return false;
const owner = ++generation;
const next = new C.CustomDataSource('weather-cyclones');
const nextCenters = new Map(),
nextSpheres = new Map();
const nextEntityStorms = new WeakMap();
const nextEntityIds = new Set();
const nextCounts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
const nextHorizonStorms = [];
const nextLabels = [];
const position = ({ longitude, latitude }) =>
C.Cartesian3.fromDegrees(longitude, latitude, 0);
const coordinate = (pair) =>
position({ longitude: pair[0], latitude: pair[1] });
try {
for (const storm of snapshot.storms) {
const horizon = { points: [], shapes: [], sphere: null };
const addEntity = (options) => {
const entity = next.entities.add(options);
if (options.position)
horizon.points.push({ entity, position: options.position });
else horizon.shapes.push(entity);
nextEntityStorms.set(entity, storm.id);
nextEntityIds.add(entity.id);
return entity;
};
const center = position(storm.position),
extent = [center];
const entity = addEntity({
id: `cyclone:${storm.id}:center`,
name: storm.name,
position: center,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.storm,
color: blue,
outlineColor: C.Color.BLACK,
outlineWidth: 2,
},
});
nextCenters.set(storm.id, entity);
const label = {
id: storm.id,
name: storm.name,
classification: storm.classification,
windKt: storm.windKt,
position: center,
forecasts: [],
};
nextLabels.push(label);
nextCounts.storms++;
if (coherentCycloneGeometry(storm)) {
const lines =
storm.track?.type === 'LineString'
? [storm.track.coordinates]
: storm.track?.coordinates || [];
lines.forEach((line, index) => {
const positions = line.map(coordinate);
extent.push(...positions);
addEntity({
id: `cyclone:${storm.id}:track:${index}`,
polyline: {
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
positions,
width: 2.5,
material: blue,
arcType: C.ArcType.GEODESIC,
},
});
nextCounts.tracks++;
});
const polygons =
storm.cone?.type === 'Polygon'
? [storm.cone.coordinates]
: storm.cone?.coordinates || [];
polygons.forEach((rings, index) => {
// Each exterior retains its own interior holes. Native geographic
// tessellation handles the antimeridian; never flatten rings.
const exterior = rings[0].map(coordinate);
extent.push(...exterior);
const holes = rings
.slice(1)
.map((ring) => new C.PolygonHierarchy(ring.map(coordinate)));
addEntity({
id: `cyclone:${storm.id}:cone:${index}`,
polygon: {
hierarchy: new C.PolygonHierarchy(exterior, holes),
classificationType: C.ClassificationType.BOTH,
material: blue.withAlpha(0.16),
arcType: C.ArcType.GEODESIC,
},
});
[exterior, ...holes.map((hole) => hole.positions)].forEach(
(positions, ringIndex) => {
addEntity({
id: `cyclone:${storm.id}:cone:${index}:outline:${ringIndex}`,
polyline: {
positions,
width: 1,
material: blue.withAlpha(0.55),
arcType: C.ArcType.GEODESIC,
clampToGround: true,
classificationType: C.ClassificationType.BOTH,
},
});
},
);
nextCounts.cones++;
});
for (const [index, point] of storm.forecastPoints.entries()) {
if (point.tauHours === 0) continue;
const p = position(point.position);
extent.push(p);
addEntity({
id: `cyclone:${storm.id}:forecast:${index}`,
position: p,
point: {
heightReference: C.HeightReference.CLAMP_TO_GROUND,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
pixelSize: CYCLONE_MARKER_PX.forecast,
color: white,
outlineColor: C.Color.BLACK,
outlineWidth: 1,
},
});
label.forecasts.push({ tauHours: point.tauHours, position: p });
nextCounts.forecastPoints++;
}
}
const sphere = C.BoundingSphere.fromPoints(extent);
// A status-only point still has a useful regional camera destination.
sphere.radius = Math.max(sphere.radius, 500_000);
nextSpheres.set(storm.id, sphere);
horizon.sphere = sphere;
nextHorizonStorms.push(horizon);
}
await viewer.dataSources.add(next);
if (destroyed || generation !== owner || signal?.aborted) {
remove(next);
return false;
}
remove(source);
source = next;
centers = nextCenters;
spheres = nextSpheres;
entityStorms = nextEntityStorms;
entityIds = nextEntityIds;
counts = nextCounts;
horizonStorms = nextHorizonStorms;
syncHorizonListener();
labels.setSnapshot(nextLabels, selected);
select(selected);
return true;
} catch (error) {
remove(next);
if (signal?.aborted || generation !== owner || destroyed) return false;
throw error;
}
},
setSelection: select,
ownsPickId(id) {
return source !== null && typeof id === 'string' && entityIds.has(id);
},
pickStorm(picked) {
// Cesium Entity picks carry the exact entity in `id`. IDs/prefixes alone
// cannot establish ownership, especially after an advisory replacement.
const entity = picked?.id;
return source && entity && typeof entity === 'object'
? entityStorms.get(entity) || null
: null;
},
getFocusSphere(id) {
return spheres.get(id) || null;
},
clear() {
++generation;
horizonStorms = [];
syncHorizonListener();
pointOccluder = null;
sphereOccluder = null;
remove(source);
source = null;
labels.clear();
centers.clear();
spheres.clear();
entityStorms = new WeakMap();
entityIds.clear();
selected = null;
counts = { storms: 0, tracks: 0, cones: 0, forecastPoints: 0 };
render();
},
destroy() {
if (destroyed) return;
this.clear();
destroyed = true;
},
getDiagnostics() {
return {
...counts,
dataSources: Number(!!source),
entities: source?.entities.values.length || 0,
selectedId: selected,
timerActive: false,
};
},
};
}
-669
View File
@@ -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]);
});
+5 -25
View File
@@ -1,6 +1,6 @@
import * as geo from '../../geo/index.js';
import { msg, t } from '../../i18n/index.js';
import { createZonesCesiumRenderer } from '../shared/render/cesiumZones.js';
import { createDroneAirspaceDisplay } from './render/display.js';
import {
DRONE_AIRSPACE_OVERLAY_SOURCE_ID,
droneAirspaceAnchorDegrees,
@@ -11,20 +11,6 @@ export { normalizeDroneAirspaceSnapshot } from './records.js';
export { createDroneAirspaceSource } from './source.js';
export * from './cards.js';
/** The zones on a Cesium globe (the default renderer). */
export function createDroneAirspaceCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
}) {
return createZonesCesiumRenderer({
viewer,
screenSpaceEventHandlerFactory,
name: 'drone-airspace',
idPrefix: 'drone-airspace',
colorOf: (row) => droneAirspaceAccent(row.typeCode),
});
}
/**
* FNV-1a over a zone's rings: a short, deterministic fingerprint so a
* boundary change with unchanged attributes still alters the refresh signature.
@@ -50,10 +36,9 @@ const CARD_HOST_OPTIONS = Object.freeze({
export function createDroneAirspaceLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
createRenderer = createDroneAirspaceCesiumRenderer,
createRenderer = ({ engine }) => createDroneAirspaceDisplay(engine),
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Drone airspace requires a snapshot source');
@@ -74,8 +59,7 @@ export function createDroneAirspaceLayer({
/** @type {Map<string, {stableId: string, anchor: {lon: number, lat: number}}>} */
const _rowById = new Map();
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
const canSelect = () => overlayHost && picking;
function publishSelectedCard() {
if (!canSelect() || !renderer) return;
@@ -172,7 +156,7 @@ export function createDroneAirspaceLayer({
throw new Error('Drone airspace layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
@@ -184,11 +168,7 @@ export function createDroneAirspaceLayer({
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid drone airspace scene attachment');
renderer = createRenderer({
viewer,
engine,
screenSpaceEventHandlerFactory,
});
renderer = createRenderer({ engine });
renderer.setVisible(_enabled);
if (_rows.length) renderer.replace(_rows);
if (_enabled) {
+61 -57
View File
@@ -1,24 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createDroneAirspaceLayer } from './index.js';
import { layerFeedState } from '../../data/feedState.js';
function harness(source, { pick = () => null, cardHit = () => null } = {}) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const ground = createFakeGroundHarness();
const { engine } = ground;
// What a click picks: `scene.pick()` (null for nothing).
const scene = { pick };
engine.fake.script('pick', () => scene.pick() ?? undefined);
const layer = createDroneAirspaceLayer({
source,
overlayHost: {
@@ -33,15 +26,6 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) {
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
@@ -51,9 +35,20 @@ function harness(source, { pick = () => null, cardHit = () => null } = {}) {
},
pointer: { isPointerFree: () => true },
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners };
layer.init(null, { engine });
layer.enable();
return {
layer,
engine,
ground,
scene,
overlay,
owners,
/** The zones' ground set (undefined once released). */
zones: () => ground.set('drone-airspace'),
/** A click through the engine's input, picking `scene.pick()`. */
click: (event) => engine.fake.input('click', event),
};
}
const ring = [
@@ -76,19 +71,27 @@ const row = {
polygons: [[ring]],
};
test('each zone polygon renders as one filled entity with its outline', async () => {
test('each zone polygon renders as one fill with its outline', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: '400', typeCode: 'R', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'drone-airspace:310:0');
assert.ok(entities[0].polygon, 'zone fill missing');
assert.ok(entities[0].polyline, 'zone outline missing');
assert.equal(await h.layer.update(), true);
const { items } = h.zones();
const ids = [
'drone-airspace:310:0',
'drone-airspace:400:0',
'drone-airspace:400:1',
];
assert.equal(items.length, 6, 'a fill and an outline per polygon');
assert.deepEqual(
items.map(({ pickId }) => pickId),
[...ids, ...ids],
);
assert.ok(items.slice(0, 3).every((item) => item.fill && !item.outline));
assert.ok(items.slice(3).every((item) => item.outline && !item.fill));
assert.equal(h.layer.getStats().count, 2);
});
@@ -98,12 +101,12 @@ test('clicking a zone publishes its card; empty space clears it', async () => {
{ getSnapshot: async () => [row] },
{ pick: () => pickResult },
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
await h.layer.update();
assert.equal(h.ground.listeners(), 1, 'click handler missing');
assert.equal(h.owners.size, 0);
pickResult = { id: 'drone-airspace:310:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('drone-airspace');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'drone-airspace-card:310');
@@ -111,7 +114,7 @@ test('clicking a zone publishes its card; empty space clears it', async () => {
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('drone-airspace')?.length,
1,
@@ -119,7 +122,7 @@ test('clicking a zone publishes its card; empty space clears it', async () => {
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('drone-airspace')?.length ?? 0, 0);
});
@@ -134,14 +137,14 @@ test('late refresh cannot publish after disable or destroy', async () => {
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
const pending = h.layer.update();
h.layer[action]();
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
if (action === 'disable') h.layer.enable();
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
h.layer.destroy();
}
});
@@ -152,7 +155,7 @@ test('the row legend names prohibited and restricted with the disclaimer', async
{ ...row, stableId: '400', typeCode: 'R', type: 'Restricted' },
],
});
await h.layer.update(h.viewer);
await h.layer.update();
const legend = h.layer.getRowControls().legend;
assert.equal(legend[0].label, 'Prohibited (P)');
assert.equal(legend[1].label, 'Restricted (R)');
@@ -168,12 +171,12 @@ test('a stale snapshot reads STALE, dated by its own fetch, and a fresh one clea
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
assert.equal(layerFeedState(h.layer.getStats()), 'nominal');
@@ -188,10 +191,10 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale',
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(layerFeedState(h.layer.getStats()), 'stale');
fail = true;
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'FAA SUA HTTP 502');
assert.equal(layerFeedState(h.layer.getStats()), 'degraded');
@@ -201,19 +204,21 @@ test('a boundary change alone redraws the zone; identical geometry does not', as
const moved = ring.map(([lon, lat]) => [lon + 0.5, lat]);
let rows = [row];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update(h.viewer);
const [first] = h.sources[0].entities.values;
await h.layer.update();
const first = h.zones();
rows = [{ ...row, polygons: [[ring.map((point) => [...point])]] }];
await h.layer.update(h.viewer);
assert.equal(h.sources[0].entities.values[0], first, 'same geometry kept');
await h.layer.update();
assert.equal(h.zones().commits, first.commits, 'same geometry kept');
assert.equal(h.zones().items, first.items);
rows = [{ ...row, polygons: [[moved]] }];
await h.layer.update(h.viewer);
const [redrawn] = h.sources[0].entities.values;
assert.notEqual(redrawn, first, 'moved boundary must redraw');
assert.notDeepEqual(
redrawn.polyline.positions.getValue(),
first.polyline.positions.getValue(),
await h.layer.update();
const redrawn = h.zones();
assert.equal(
redrawn.commits,
first.commits + 1,
'moved boundary must redraw',
);
assert.notDeepEqual(redrawn.items[1].rings, first.items[1].rings);
assert.ok(h.layer.getAnalystRecords()[0].lon > -76.6);
});
@@ -232,7 +237,6 @@ test('an engine switch keeps the zones and the selected card for the next globe'
},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => null,
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: () => false,
@@ -0,0 +1,84 @@
// The FAA special-use airspace zones on the globe contract (engine.ground,
// engine.input), the same on every engine, drawn as fire perimeters are:
// each row's polygons (holes kept) as a 0.25 fill in its type's colour,
// under a 2 px ground outline at 0.9 along the outer ring only, every
// polygon and its outline picked as `drone-airspace:${stableId}:${index}`.
// All fills come before all outlines in the set, so an outline is never
// covered by a later fill (on Cesium the outlines are one batch over the
// fills).
import { rgbaFromCss } from '../../shared/color.js';
import { droneAirspaceAccent } from '../cards.js';
const SET_ID = 'drone-airspace';
const FILL_ALPHA = 0.25;
const OUTLINE_ALPHA = 0.9;
const OUTLINE_PX = 2;
/** What a pick of a zone's polygon returns. */
export const droneAirspacePickId = (row, index) =>
`drone-airspace:${row.stableId}:${index}`;
/**
* The zones: `{ setVisible, replace(rows), onClick(listener) → remove,
* pick(click), destroy }`. A click listener gets the engine's click with its
* pick; pick(click) is that pick (null for none).
* @param {import('../../../globe/contract.js').GlobeEngine} engine
*/
export function createDroneAirspaceDisplay(engine) {
if (!engine?.ground)
throw new TypeError('The drone airspace display needs a globe engine');
const set = engine.ground.createSet({
id: SET_ID,
kind: 'polygon',
pickable: true,
});
set.show(false);
let destroyed = false;
const removers = new Set();
return {
setVisible(show) {
if (!destroyed) set.show(show);
},
replace(rows) {
if (destroyed) return;
const fills = [];
const outlines = [];
for (const row of rows) {
const { red, green, blue } = rgbaFromCss(
droneAirspaceAccent(row.typeCode),
);
for (const [index, rings] of row.polygons.entries()) {
const pickId = droneAirspacePickId(row, index);
fills.push({ rings, fill: [red, green, blue, FILL_ALPHA], pickId });
outlines.push({
rings: rings.slice(0, 1),
outline: {
color: [red, green, blue, OUTLINE_ALPHA],
widthPx: OUTLINE_PX,
},
pickId,
});
}
}
set.replace([...fills, ...outlines]);
set.commit();
},
onClick(listener) {
if (destroyed) return () => {};
const remove = engine.input.on('click', listener, { pick: true });
const off = () => {
if (removers.delete(off)) remove();
};
removers.add(off);
return off;
},
pick: (click) => click?.picked ?? null,
destroy() {
if (destroyed) return;
destroyed = true;
for (const off of [...removers]) off();
set.destroy();
},
};
}
@@ -0,0 +1,272 @@
// The FAA airspace zones on the engine contract, on the fake engine and on
// the Cesium engine (its ground batches over fake primitives): fills with
// their holes at 0.25 and a 2 px outline at 0.9 along the outer ring only,
// in the zone type's colour; clicks through the engine's input selecting a
// zone's card; visibility and teardown.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
GROUND_HARNESSES,
createFakeGroundHarness,
instanceColor,
} from '../../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../../shared/color.js';
import { droneAirspaceAccent } from '../cards.js';
import { createDroneAirspaceLayer } from '../index.js';
import { createDroneAirspaceDisplay } from './display.js';
const outer = [
[-77.2, 38.8],
[-76.9, 38.8],
[-76.9, 39.1],
[-77.2, 39.1],
[-77.2, 38.8],
];
const hole = [
[-77.1, 38.9],
[-77.0, 38.9],
[-77.0, 39.0],
[-77.1, 38.9],
];
const second = outer.map(([lon, lat]) => [lon + 1, lat]);
const row = {
stableId: '310',
name: 'P-56',
typeCode: 'P',
type: 'Prohibited',
lowerAltitude: 'SFC',
upperAltitude: '18,000 ft MSL',
timesOfUse: 'CONTINUOUS',
agency: null,
city: 'WASHINGTON',
state: 'DC',
polygons: [[outer, hole]],
};
const other = {
...row,
stableId: '400',
name: 'R-4001',
typeCode: 'R',
type: 'Restricted',
polygons: [[second], [second.map(([lon, lat]) => [lon, lat + 1])]],
};
const PICKS = [
'drone-airspace:310:0',
'drone-airspace:400:0',
'drone-airspace:400:1',
];
const colorOf = (zone) => rgbaFromCss(droneAirspaceAccent(zone.typeCode));
// Per polygon: its incident's colour.
const COLORS = [colorOf(row), colorOf(other), colorOf(other)];
function fixture(makeHarness) {
const h = makeHarness();
const entries = new Map();
const layer = createDroneAirspaceLayer({
source: { getSnapshot: async () => [row, other] },
overlayHost: {
setEntries: (id, value) => entries.set(id, value),
setVisible() {},
clearSource: (id) => entries.delete(id),
hitTest: () => null,
},
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (_layer, id) => id.startsWith('other-layer:'),
},
pointer: { isPointerFree: () => true },
});
layer.init(null, { engine: h.engine });
layer.enable();
return { ...h, layer, entries, card: () => entries.get('drone-airspace') };
}
const near = (actual, expected, label, tolerance = 1e-9) =>
assert.ok(
Math.abs(actual - expected) < tolerance,
`${label}: ${actual} vs ${expected}`,
);
test('the display needs a globe engine', () => {
assert.throws(() => createDroneAirspaceDisplay(null), TypeError);
});
for (const makeHarness of GROUND_HARNESSES) {
const { name } = makeHarness();
test(`each polygon is a 0.25 fill with its holes under a 2 px outline at 0.9 on its outer ring (${name})`, async () => {
const f = fixture(makeHarness);
try {
assert.equal(await f.layer.update(), true);
const set = f.set('drone-airspace');
assert.equal(set.options.kind, 'polygon');
assert.equal(set.options.pickable, true);
assert.equal(set.shown, true);
const fills = set.items.slice(0, 3);
const lines = set.items.slice(3);
assert.equal(set.items.length, 6, 'every fill, then every outline');
assert.deepEqual(
fills.map(({ rings, pickId, outline }) => ({ rings, pickId, outline })),
[
{ rings: [outer, hole], pickId: PICKS[0], outline: undefined },
{ rings: [second], pickId: PICKS[1], outline: undefined },
{
rings: other.polygons[1],
pickId: PICKS[2],
outline: undefined,
},
],
);
assert.deepEqual(
lines.map(({ rings, pickId, fill, outline }) => ({
rings,
pickId,
fill,
widthPx: outline.widthPx,
})),
[
{ rings: [outer], pickId: PICKS[0], fill: undefined, widthPx: 2 },
{ rings: [second], pickId: PICKS[1], fill: undefined, widthPx: 2 },
{
rings: other.polygons[1],
pickId: PICKS[2],
fill: undefined,
widthPx: 2,
},
],
);
fills.forEach(({ fill }, index) =>
assert.deepEqual(fill, COLORS[index].withAlpha(0.25).rgba()),
);
lines.forEach(({ outline }, index) =>
assert.deepEqual(outline.color, COLORS[index].withAlpha(0.9).rgba()),
);
if (f.cesium) {
const primitives = f.primitives('drone-airspace');
assert.equal(primitives.fills.length, 1, 'one fill batch');
assert.equal(primitives.lines.length, 1, 'one outline batch');
const fillInstances = primitives.fills[0].options.geometryInstances;
const lineInstances = primitives.lines[0].options.geometryInstances;
assert.equal(fillInstances.length, 3);
assert.equal(lineInstances.length, 3, 'no line along a hole');
assert.equal(
primitives.fills[0].options.classificationType,
Cesium.ClassificationType.BOTH,
);
assert.equal(primitives.fills[0].options.allowPicking, true);
assert.ok(fillInstances[0].geometry instanceof Cesium.PolygonGeometry);
assert.equal(
fillInstances[0].geometry._polygonHierarchy.holes.length,
1,
);
fillInstances.forEach((instance, index) =>
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.25).rgba()[at],
`fill ${index}.${at}`,
0.5 / 255,
),
),
);
lineInstances.forEach((instance, index) => {
assert.ok(instance.geometry instanceof Cesium.GroundPolylineGeometry);
assert.equal(instance.geometry.width, 2);
assert.equal(instance.geometry.loop, true);
instanceColor(instance).forEach((channel, at) =>
near(
channel,
COLORS[index].withAlpha(0.9).rgba()[at],
`line ${index}.${at}`,
0.5 / 255,
),
);
});
}
f.layer.disable();
assert.equal(f.set('drone-airspace').shown, false);
if (f.cesium) assert.equal(f.collectionShown('drone-airspace'), false);
} finally {
f.layer.destroy();
}
assert.equal(f.sets(), 0);
f.destroy();
});
test(`a click on a zone or its outline selects the zone; empty space clears it (${name})`, async () => {
const f = fixture(makeHarness);
try {
await f.layer.update();
assert.equal(f.listeners(), 1, 'one click listener while enabled');
f.click({ set: 'drone-airspace', instance: 0 });
assert.equal(f.card()[0].id, 'drone-airspace-card:310');
// The other zone's second outline.
f.click({ set: 'drone-airspace', instance: 5 });
assert.equal(f.card()[0].id, 'drone-airspace-card:400');
f.click({ id: 'other-layer:aircraft-1' });
assert.equal(
f.card()[0].id,
'drone-airspace-card:400',
'a sibling layer pick keeps the selection',
);
f.click(undefined);
assert.deepEqual(f.card(), []);
f.layer.disable();
assert.equal(f.listeners(), 0, 'disable removes the click listener');
f.layer.enable();
assert.equal(f.listeners(), 1);
f.layer.detachRenderer();
assert.equal(f.listeners(), 0, 'detach removes it too');
assert.equal(f.sets(), 0);
} finally {
f.layer.destroy();
f.destroy();
}
});
}
test('a click inside a zone picks it on the ground, not in its hole (the fake engine)', async () => {
const f = fixture(createFakeGroundHarness);
try {
await f.layer.update();
const clickAt = (lon, lat) => {
f.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(lon, lat, 30_000),
});
const { width, height } = f.engine.host.size();
f.engine.fake.input('click', {
position: { x: width / 2, y: height / 2 },
});
};
clickAt(-77.15, 39.05);
assert.equal(f.card()[0].id, 'drone-airspace-card:310');
clickAt(-77.03, 38.92);
assert.deepEqual(f.card(), [], 'the hole is empty space');
} finally {
f.layer.destroy();
f.destroy();
}
});
test('the display ignores calls after destroy and destroys once', () => {
const h = createFakeGroundHarness();
const display = createDroneAirspaceDisplay(h.engine);
assert.equal(h.set('drone-airspace').shown, false, 'hidden until shown');
const off = display.onClick(() => {});
assert.equal(h.listeners(), 1);
display.destroy();
display.destroy();
assert.equal(h.listeners(), 0, 'destroy removes its click listeners');
off();
assert.equal(h.sets(), 0);
display.setVisible(true);
display.replace([row]);
assert.equal(h.listeners(), 0);
display.onClick(() => {})();
assert.equal(display.pick({}), null);
assert.equal(display.pick({ picked: { id: 'x' } }).id, 'x');
h.destroy();
});
-14
View File
@@ -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),
},
);
}
+15 -34
View File
@@ -10,17 +10,22 @@ import {
selectEarthquakeOverlayCohort,
mapAnalystRecord,
} from './model.js';
import { createEarthquakesDisplay } from './render/display.js';
export * from './model.js';
export { createUsgsEarthquakeSource } from './source.js';
/**
* Own one earthquake display and its refresh lifecycle.
* renderer.create({viewer, engine}) owns drawing and returns:
* setVisible(boolean), replace(discs), destroy().
* Discs are plain ECEF anchors, metre radii and Rgba colours; renderers keep
* source order. The layer retains observations for queries while detached.
* renderer.create({ engine }) owns drawing (render/display.js by default)
* and returns: setVisible(boolean), replace(discs), destroy().
* Discs are degrees, metre radii and Rgba colours; renderers keep source
* order. The layer retains observations for queries while detached.
*/
export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
export function createEarthquakesLayer({
source,
overlayHost,
renderer = { create: ({ engine }) => createEarthquakesDisplay(engine) },
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Earthquakes require a snapshot source');
if (!overlayHost) throw new TypeError('Earthquakes require an overlay host');
@@ -57,7 +62,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
throw new Error('Earthquake layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
@@ -68,7 +73,7 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || _renderer)
throw new Error('Invalid earthquake scene attachment');
_renderer = renderer.create({ viewer, engine });
_renderer = renderer.create({ engine });
if (!_renderer) throw new Error('Earthquake renderer unavailable');
_renderer.setVisible(_enabled);
if (discs.length) _renderer.replace(discs);
@@ -117,38 +122,14 @@ export function createEarthquakesLayer({ source, overlayHost, renderer } = {}) {
let count = 0;
const overlayEntries = [];
for (const {
stableId,
usgsId,
lon,
lat,
depthKm,
mag,
place,
time,
} of rows) {
for (const { stableId, lon, lat, depthKm, mag } of rows) {
count++;
const baseRadius = Math.pow(2, mag) * 1000;
const color = depthColor(depthKm || 0);
const isSignificant = mag >= 5.0;
const fillAlpha = isSignificant ? 0.4 : 0.3;
const outlineAlpha = isSignificant ? 1.0 : 0.8;
const fillAlpha = mag >= 5.0 ? 0.4 : 0.3;
const position = geo.cartesian3.fromDegrees(lon, lat);
nextDiscs.push({
stableId,
usgsId,
position,
baseRadius,
color,
isSignificant,
fillAlpha,
outlineAlpha,
mag,
place,
time,
depthKm,
});
nextDiscs.push({ stableId, lon, lat, baseRadius, color, fillAlpha });
overlayEntries.push(
createEarthquakeOverlayEntry({
id: String(stableId),
+17 -30
View File
@@ -1,26 +1,13 @@
import { createEarthquakesCesiumRenderer } from './render/cesium.js';
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createEarthquakesLayer } from './index.js';
import { createUsgsEarthquakeSource } from './source.js';
function harness(source) {
const sources = [];
const events = [];
const viewer = {
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const ground = createFakeGroundHarness();
const layer = createEarthquakesLayer({
source,
renderer: {
create: ({ viewer }) => createEarthquakesCesiumRenderer(null, { viewer }),
},
overlayHost: {
setEntries(...args) {
events.push(args);
@@ -29,9 +16,9 @@ function harness(source) {
clearSource() {},
},
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, events };
layer.init(null, { engine: ground.engine });
layer.enable();
return { layer, ground, events };
}
const row = {
stableId: 'event-a',
@@ -54,27 +41,27 @@ test('late refresh cannot publish after disable, re-enable, or destroy', async (
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
const pending = h.layer.update();
h.layer[action]();
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
if (action === 'disable') h.layer.enable();
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
assert.equal(h.events.length, 0);
h.layer.destroy(h.viewer);
h.layer.destroy();
}
});
test('two displays own separate data sources and destruction', async () => {
test('two displays own separate ground sets and destruction', async () => {
const a = harness({ getSnapshot: async () => [row] });
const b = harness({ getSnapshot: async () => [] });
await a.layer.update(a.viewer);
await b.layer.update(b.viewer);
await a.layer.update();
await b.layer.update();
assert.equal(a.layer.getStats().count, 1);
assert.equal(b.layer.getStats().count, 0);
a.layer.destroy();
assert.equal(a.sources.length, 0);
assert.equal(b.sources.length, 1);
assert.equal(a.ground.sets(), 0);
assert.equal(b.ground.sets(), 1);
b.layer.destroy();
});
test('USGS body completion honors cancellation even with an uncooperative transport', async () => {
@@ -107,15 +94,15 @@ test('an observation received between scenes is queryable and draws without a se
try {
const pending = h.layer.update();
h.layer.detachRenderer();
assert.equal(h.sources.length, 0);
assert.equal(h.ground.sets(), 0);
resolve([row]);
assert.equal(await pending, true);
const [record] = h.layer.getAnalystRecords();
assert.equal(record.id, row.usgsId);
assert.equal(record.lat, row.lat);
assert.equal(h.events.length, 0);
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].entities.values.length, 1);
h.layer.attachRenderer(null, { engine: h.ground.engine });
assert.equal(h.ground.set('earthquakes').items.length, 1);
assert.equal(h.events.length, 1);
assert.equal(polls, 1);
} finally {
-83
View File
@@ -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);
});
+50
View File
@@ -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();
});
-69
View File
@@ -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();
},
};
}
-309
View File
@@ -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]));
});
+5 -7
View File
@@ -18,8 +18,8 @@ export function createCards({
* Rebuild the card entries for the shared world-overlay host: the
* selected-fire detail card (if any) plus at most MAX_AMBIENT_LABELS
* ambient cards picked by a greedy screen-space declutter — walk the
* priority-ranked candidates, project each with
* SceneTransforms.worldToWindowCoordinates, and accept only candidates
* priority-ranked candidates, project each through the renderer
* (engine.projector.worldToWindow), and accept only candidates
* ≥ LABEL_MIN_SEP_PX from every already-accepted card. Candidates whose
* projection fails are dropped silently. SELECTION runs here, on LOD/
* viewport rebuilds and camera moveEnd — never per frame; RENDERING is
@@ -28,17 +28,15 @@ export function createCards({
*/
function rebuildAmbientLabels() {
if (!layerState._viewer) return;
if (!layerState._renderer) return;
const scene = layerState._viewer.scene;
const project = (position, result) =>
layerState._renderer
? layerState._renderer.project(position, result)
: config.projectToWindow(scene, position, result);
layerState._renderer.project(position, result);
const now = Date.now();
const entries = [];
/** @type {Array<{x: number, y: number}>} Screen positions of accepted labels. */
const accepted = [];
// worldToWindowCoordinates happily projects points on the FAR side of the
// The window projection happily projects points on the FAR side of the
// planet, so without this the ≤18 ambient slots get spent on fires the
// overlay host will then horizon-cull at paint time (`horizonCull: true`
// in applyFirmsOverlayPolicy) — near-side fires silently lose their cards.
+5 -4
View File
@@ -3,8 +3,8 @@ import assert from 'node:assert/strict';
import * as geo from '../../geo/index.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createFirmsHeatmapLayer } from './index.js';
import { createFirmsNavaraRenderer } from './render/navara.js';
import { unregisterSpriteCollection } from '../../data/spriteOrder.js';
import { createFirmsDisplay } from './render/display.js';
import { createFirmsHeatDrape } from './render/navaraHeat.js';
const settle = async () => {
for (let i = 0; i < 5; i++)
@@ -88,6 +88,8 @@ function fixture() {
EffectDesc: class {},
NavaraEffect: class {},
};
// The heatmap draws through the Navara backend on this view.
engine.extensions = { navara: native };
const noop = () => {};
const layer = createFirmsHeatmapLayer({
id: 'local-firms',
@@ -116,7 +118,7 @@ function fixture() {
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
renderer: {
create: ({ engine: target, id }) =>
createFirmsNavaraRenderer(target, { ...native, id }),
createFirmsDisplay(target, { id, navaraHeat: createFirmsHeatDrape }),
},
});
layer.init(null, { engine });
@@ -156,7 +158,6 @@ function fixture() {
},
cleanup() {
layer.destroy();
unregisterSpriteCollection('local-firms');
performance.now = realNow;
restore();
},
+23 -28
View File
@@ -11,8 +11,8 @@ export function createLifecycle({
const { clearSelectedEntityContextForLayer } = services.context;
const { id, overlayHost } = config;
// The heatmap is draped on the renderer's globe (Cesium only); it goes
// with the renderer and is built again on the next one that can drape it.
// The heatmap is draped on the renderer's globe; it goes with the renderer
// and is built again on the next one.
function releaseHeat() {
clearTimeout(layerState._heatRebuildTimer);
layerState._heatRebuildTimer = null;
@@ -26,7 +26,8 @@ export function createLifecycle({
/**
* Shell services: `imageryHost()` resolves where draped imagery goes on
* the active map stack (globe or 3D tiles, src/maps/imageryHost.js). The
* heatmap moves with it; without a host it drapes on viewer.imageryLayers.
* heatmap moves with it; without a host it drapes on the globe's own
* imagery (render/heatField.js).
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
*/
attachShellServices(services) {
@@ -40,36 +41,33 @@ export function createLifecycle({
init(viewer, options) {
this.attachRenderer(viewer, options);
},
/** Draws on `engine` (the scene's globe engine); without one, nothing. */
attachRenderer(
viewer,
_viewer,
{ engine = null, waitForCameraRestore = false } = {},
) {
if (layerState._destroyed) return;
if (layerState._renderer) return;
layerState._renderer = config.renderer.create({
viewer,
engine,
id,
services,
screenSpaceEventHandlerFactory: config.screenSpaceEventHandlerFactory,
});
if (layerState._renderer || !engine) return;
layerState._renderer = config.renderer.create({ engine, id, services });
if (!layerState._renderer) return;
layerState._viewer = layerState._renderer.view;
layerState._dataSource = layerState._renderer.dataSource;
layerState._renderer.setVisible(false);
layerState._restoringViewport = waitForCameraRestore;
if (layerState._enabled && !waitForCameraRestore) {
this.showRenderer();
layerState._renderer.onEnable();
}
if (layerState._enabled && !waitForCameraRestore) this.showRenderer();
// Enabled before a renderer: the load enable() could not start.
if (
layerState._enabled &&
!layerState._fires.length &&
!layerState._loading
)
void components.ingestion.loadHeatmap();
},
resumeViewport() {
if (!layerState._restoringViewport) return;
layerState._restoringViewport = false;
if (layerState._enabled && layerState._renderer) {
this.showRenderer();
layerState._renderer.onEnable();
}
if (layerState._enabled && layerState._renderer) this.showRenderer();
},
detachRenderer() {
@@ -77,7 +75,7 @@ export function createLifecycle({
components.viewport.removeMoveEndWatcher();
components.viewport.removeLodWatcher();
unregisterPickOwner(id);
components.rendering.removeDetectionCollections(layerState._viewer);
components.rendering.removeDetectionCollections();
releaseHeat();
layerState._renderer?.destroy();
layerState._renderer = layerState._viewer = layerState._dataSource = null;
@@ -112,16 +110,13 @@ export function createLifecycle({
components.rendering.renderCurrentLod(true);
},
async enable(viewer) {
async enable() {
if (layerState._destroyed) return;
if (!layerState._dataSource) this.init(viewer);
layerState._enabled = true;
const renderer = layerState._renderer;
if (!layerState._renderer) return;
this.showRenderer();
if (!layerState._fires.length && !layerState._loading)
await components.ingestion.loadHeatmap();
if (layerState._enabled && layerState._renderer === renderer)
renderer.onEnable();
},
disable() {
@@ -141,7 +136,7 @@ export function createLifecycle({
components.viewport.removeLodWatcher();
},
destroy(viewer = layerState._viewer) {
destroy() {
layerState.request?.abort();
layerState.request = null;
layerState._loading = false;
@@ -152,7 +147,7 @@ export function createLifecycle({
components.viewport.removeMoveEndWatcher();
components.selection.removeClickHandler();
unregisterPickOwner(id);
components.rendering.removeDetectionCollections(viewer);
components.rendering.removeDetectionCollections();
layerState._renderer?.destroy();
layerState._renderer = null;
layerState._viewer = null;
+10 -6
View File
@@ -322,14 +322,18 @@ export function createQueries({
components.rendering.applyHandoffFade();
},
/** Test seam: entities drawn by the layer's data source. */
_entityCountForTest() {
return layerState._dataSource?.entities?.values?.length ?? 0;
/** Test seam: ground cells the renderer drew. */
_cellCountForTest() {
return layerState._renderer?.cellCount?.() ?? 0;
},
/** Test seam that binds the production click path and indexes. */
_bindInteractionForTest(viewer, fires = []) {
layerState._viewer = viewer;
/**
* Test seam that binds the production click path and indexes, on the
* renderer of `engine` (attached here when there is none yet).
*/
_bindInteractionForTest(engine, fires = []) {
if (!layerState._renderer) this.attachRenderer(null, { engine });
layerState._renderer.setVisible(true);
layerState._enabled = true;
layerState._allFires = fires;
layerState._allFiresByFrp = [...fires];
-147
View File
@@ -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);
},
};
}
-137
View File
@@ -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);
});
+20
View File
@@ -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',
},
});
}
+173
View File
@@ -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');
},
};
}
+618
View File
@@ -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();
}
});
+68
View File
@@ -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;
},
};
}
-151
View File
@@ -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');
},
};
}
-188
View File
@@ -1,188 +0,0 @@
import { createGroundCellDisplay } from '../../shared/render/navaraGroundCells.js';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createEngineEvent } from '../../../globe/contract.js';
import * as geo from '../../../geo/index.js';
import { createFirmsNavaraRenderer } from './navara.js';
const settle = () => new Promise((resolve) => setImmediate(resolve));
function harness() {
const effects = [],
registered = [],
requests = [],
input = new Map();
const frames = {
preUpdate: createEngineEvent(),
preRender: createEngineEvent(),
};
const position = geo.cartesian3.fromDegrees(0, 0, 4000000);
const engine = {
frames,
host: { size: () => ({ width: 1280, height: 800 }) },
movers: {
createSet() {
throw new Error('cells do not allocate sprite movers');
},
},
render: { request: (reason) => requests.push(reason) },
camera: {
livePosition: position,
moveEnd: createEngineEvent(),
viewRectangle: () => ({ west: -1, south: -0.5, east: 1, north: 0.5 }),
pose: () => ({
position,
cartographic: { longitude: 0, latitude: 0, height: 4000000 },
direction: { x: -1, y: 0, z: 0 },
up: { x: 0, y: 0, z: 1 },
right: { x: 0, y: 1, z: 0 },
heading: 0,
pitch: -Math.PI / 2,
roll: 0,
fovy: 1,
}),
},
projector: {
worldToWindow: (point, result) =>
Object.assign(result ?? {}, { x: 10, y: 20 }),
horizonVisible: () => true,
},
input: {
on(type, listener) {
input.set(type, listener);
return () => input.delete(type);
},
},
};
const view = {
camera: {},
registerEffect: (key, desc) => registered.push([key, desc]),
addEffect(config) {
const handle = {
config,
updates: [],
deleted: 0,
update(value) {
this.updates.push(value);
},
delete() {
this.deleted++;
},
};
effects.push(handle);
return handle;
},
};
const options = { view, EffectDesc: class {}, NavaraEffect: class {} };
return {
engine,
options,
effects,
registered,
requests,
input,
frames,
renderer: createFirmsNavaraRenderer(engine, options),
};
}
const cell = {
west: 0,
south: 0,
east: 1,
north: 1,
color: { red: 1, green: 0, blue: 0, alpha: 0.3 },
};
test('ground changes are atomic, lazy and dispose replaced and destroyed GPU textures once', async () => {
const h = harness(),
r = h.renderer;
r.setVisible(true);
assert.equal(h.effects.length, 0);
r.clearCells();
r.addCell(cell);
r.addCell({ ...cell, west: 1, east: 2 });
assert.equal(h.effects.length, 0);
await settle();
assert.equal(h.effects.length, 1);
const owner = h.effects[0].config.gevFirmsGround.owner;
assert.equal(owner.state.count, 2);
let disposed = 0;
for (const t of [
owner.state.packed.texture,
owner.state.gridTexture,
owner.state.candidates.texture,
])
t.addEventListener('dispose', () => disposed++);
r.clearCells();
r.addCell(cell);
await settle();
assert.equal(owner.state.count, 1);
assert.equal(disposed, 3);
r.setVisible(false);
assert.equal(r.dataSource.show, false);
assert.deepEqual(h.effects[0].updates.at(-1), { visible: false });
r.clearCells();
r.addCell(cell);
r.destroy();
r.destroy();
const before = h.requests.length;
await settle();
assert.equal(h.requests.length, before);
assert.equal(h.effects[0].deleted, 1);
assert.equal(h.frames.preUpdate.numberOfListeners, 0);
const second = createFirmsNavaraRenderer(h.engine, h.options);
second.destroy();
assert.equal(h.registered.length, 1);
});
test('GNSS and FIRMS share one ground pass without hiding or destroying one another', async () => {
const h = harness();
const gnss = createGroundCellDisplay(h.engine, h.options, 0);
const fire = h.renderer;
try {
fire.setVisible(true);
fire.addCell(cell);
await settle();
gnss.replace([cell, cell]);
gnss.setVisible(true);
const owner = h.effects[0].config.gevFirmsGround.owner;
assert.equal(h.effects.length, 1);
assert.equal(owner.state.count, 3);
gnss.setVisible(false);
assert.equal(owner.state.count, 1);
gnss.setVisible(true);
fire.destroy();
assert.equal(owner.state.count, 2);
assert.equal(h.effects[0].deleted, 0);
} finally {
fire.destroy();
gnss.destroy();
}
assert.equal(h.effects[0].deleted, 1);
});
test('FIRMS selection uses actual engine event picks and releases its dispatcher subscriptions', () => {
const h = harness(),
r = h.renderer;
r.setVisible(true);
const handler = r.createInputHandler();
let event;
handler.setInputAction((value) => (event = value), 'click');
assert.equal(h.input.size, 4);
const picked = { id: 'sibling-contact' };
h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] });
assert.equal(r.pick(event.position, event), picked);
assert.equal(r.view.camera.moveEnd, h.engine.camera.moveEnd);
assert.equal(r.view.scene.preRender, h.engine.frames.preRender);
assert.deepEqual(r.viewRectangle({}), {
west: -1,
south: -0.5,
east: 1,
north: 0.5,
});
assert.deepEqual(r.project({}, {}), { x: 10, y: 20 });
event = null;
r.setVisible(false);
h.input.get('click')({ position: { x: 10, y: 20 }, picked, drilled: [] });
assert.equal(event, null);
handler.destroy();
handler.destroy();
assert.equal(h.input.size, 0);
r.destroy();
});
@@ -1,120 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import {
rectangleBoundaries,
rectangleDistances,
rectangleFrame,
} from '../../shared/render/navaraCellGeometry.js';
const cases = [];
for (const lat of [-90, -80, -45, 0, 45, 80, 88])
for (const west of [-180, -123, 0, 178])
for (const width of [1, 2])
cases.push({
west,
south: lat,
east: west + width,
north: Math.min(90, lat + width),
});
test('geodetic cell sides contain public native rectangle vertices and retain exact parallels at every height', () => {
let vertices = 0,
heights = 0;
for (const rect of cases) {
const bounds = rectangleBoundaries(rect);
const native = Cesium.RectangleGeometry.createGeometry(
new Cesium.RectangleGeometry({
rectangle: Cesium.Rectangle.fromDegrees(
rect.west,
rect.south,
rect.east,
rect.north,
),
vertexFormat: Cesium.VertexFormat.POSITION_ONLY,
}),
);
const positions = native.attributes.position.values;
for (let i = 0; i < positions.length; i += 3) {
const distance = rectangleDistances(bounds, {
x: positions[i],
y: positions[i + 1],
z: positions[i + 2],
});
assert.ok(Math.min(...distance) > -1e-6, JSON.stringify(rect));
vertices++;
}
for (const h of [-100000, 0, 200, 12000])
for (const t of [0.1, 0.5, 0.9]) {
const lon = rect.west + (rect.east - rect.west) * t,
lat = rect.south + (rect.north - rect.south) * t;
for (const [x, y] of [
[lon, rect.south],
[lon, rect.north],
[rect.west, lat],
[rect.east, lat],
]) {
const d = rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(x, y, h),
);
assert.ok(
Math.abs(Math.min(...d)) < 1e-6,
`${JSON.stringify(rect)} height ${h}`,
);
heights++;
}
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(lon, lat, h),
),
) > 0,
);
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(rect.west - 0.01, lat, h),
),
) < 0,
);
assert.ok(
Math.min(
...rectangleDistances(
bounds,
geo.cartesian3.fromDegrees(rect.east + 0.01, lat, h),
),
) < 0,
);
}
}
assert.equal(vertices, 562);
assert.equal(heights, 2688);
});
test('candidate volumes enclose geodetic cells through their conservative terrain/roof height window', () => {
for (const rect of cases) {
const frame = rectangleFrame(rect);
for (const lat of [rect.south, (rect.south + rect.north) / 2, rect.north])
for (const lon of [rect.west, (rect.west + rect.east) / 2, rect.east])
for (const h of [-100000, 0, 12000]) {
const p = geo.cartesian3.fromDegrees(lon, lat, h),
delta = {
x: p.x - frame.center.x,
y: p.y - frame.center.y,
z: p.z - frame.center.z,
};
const x = geo.cartesian3.dot(delta, frame.east),
y = geo.cartesian3.dot(delta, frame.north),
z = geo.cartesian3.dot(delta, frame.up);
assert.ok(
Math.abs(x) <= frame.radius && Math.abs(y) <= frame.radius,
JSON.stringify(rect),
);
assert.ok(
z >= frame.floor - 1e-6 && z <= frame.ceiling + 1e-6,
`${JSON.stringify(rect)} ${z}`,
);
}
}
});
@@ -1,177 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMoverRegistry } from '../../../globe/movers.js';
import { createFirmsSpriteBank } from './navaraSprites.js';
const settle = () => new Promise((resolve) => setImmediate(resolve));
function harness(loadImage) {
let commits = 0,
disposals = 0;
const draws = [],
canvases = [];
const registry = createMoverRegistry((options, store) => ({
ready: () => true,
commit() {
commits++;
},
destroy() {
disposals++;
},
}));
const bank = createFirmsSpriteBank(
{ movers: registry.movers },
{
id: 'firms-test',
capacity: 3,
loadImage,
createCanvas() {
const canvas = {
getContext: () => ({ drawImage: (...args) => draws.push(args) }),
};
canvases.push(canvas);
return canvas;
},
},
);
return {
bank,
registry,
draws,
canvases,
get commits() {
return commits;
},
get disposals() {
return disposals;
},
};
}
test('actual image dimensions set both the atlas cell and visible/pickable quad, with batched horizon flips', async () => {
const h = harness(async (url) => ({
width: url === 'small' ? 16 : 56,
height: url === 'small' ? 16 : 56,
}));
const first = h.bank.add({
id: 'firms-0',
position: { x: 1, y: 2, z: 3 },
image: 'small',
});
const second = h.bank.add({
id: 'firms-1',
position: { x: 4, y: 5, z: 6 },
image: 'large',
});
h.bank.setVisible(true);
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(h.registry.sets.size, 1);
assert.equal(entry.options.sharpen, true);
assert.equal(entry.options.depthTest, 'always');
assert.equal(entry.options.pickable, true);
assert.equal(entry.store.count, 2);
assert.ok(Math.abs(entry.store.scales[0] - 16 / 56) < 1e-7);
assert.equal(entry.store.scales[1], 1);
assert.deepEqual(entry.store.pickIds.slice(0, 2), ['firms-0', 'firms-1']);
assert.equal(h.draws.length, 2);
assert.ok(
h.draws.every((args) => args.length === 3),
'atlas does not resize source PNGs',
);
const rect = entry.options.sprite.atlas.cells['0'];
assert.ok(Math.abs((rect[2] - rect[0]) * h.canvases[0].width - 16) < 1e-10);
assert.ok(
rect[0] > 0 && rect[1] > 0,
'transparent neighbor padding is retained',
);
const before = h.commits;
first.show = false;
second.show = false;
await settle();
assert.equal(h.commits, before + 1);
assert.equal(h.canvases.length, 1);
assert.deepEqual([...entry.store.shows.slice(0, 2)], [0, 0]);
h.bank.setVisible(false);
assert.equal(entry.store.count, 0);
const hidden = h.commits;
await settle();
assert.equal(h.commits, hidden);
h.bank.destroy();
h.bank.destroy();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.disposals, 1);
});
test('uncooperative image completion after disable or destroy cannot create movers or request a frame', async () => {
let finish;
const h = harness(() => new Promise((resolve) => (finish = resolve)));
h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'late' });
h.bank.setVisible(true);
await settle();
h.bank.setVisible(false);
finish({ width: 16, height: 16 });
await settle();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.commits, 0);
h.bank.collection.removeAll();
h.bank.add({ id: 'firms-1', position: { x: 1, y: 2, z: 3 }, image: 'later' });
h.bank.setVisible(true);
await settle();
h.bank.destroy();
finish({ width: 56, height: 56 });
await settle();
assert.equal(h.registry.sets.size, 0);
assert.equal(h.commits, 0);
assert.equal(h.bank.collection.length, 0);
});
test('clearing rows during decode preserves the new generation and commits only the replacement', async () => {
const complete = new Map(),
h = harness((url) => new Promise((resolve) => complete.set(url, resolve)));
h.bank.setVisible(true);
h.bank.add({ id: 'old', position: { x: 1, y: 2, z: 3 }, image: 'old' });
await settle();
h.bank.collection.removeAll();
h.bank.add({ id: 'new', position: { x: 4, y: 5, z: 6 }, image: 'new' });
await settle();
complete.get('old')({ width: 16, height: 16 });
await settle();
assert.equal(h.registry.sets.size, 0);
complete.get('new')({ width: 56, height: 56 });
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(entry.store.count, 1);
assert.equal(entry.store.pickIds[0], 'new');
assert.equal(h.commits, 1);
h.bank.destroy();
});
test('the heatmap hand-off fades every marker and hides them all once it completes', async () => {
const h = harness(async () => ({ width: 56, height: 56 }));
h.bank.add({ id: 'firms-0', position: { x: 1, y: 2, z: 3 }, image: 'a' });
h.bank.add({ id: 'firms-1', position: { x: 4, y: 5, z: 6 }, image: 'a' });
h.bank.setVisible(true);
await settle();
await settle();
const entry = h.registry.sets.get('firms-test');
assert.equal(entry.store.count, 2);
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 1, 1, 1, 1, 1],
);
h.bank.setFade(0.25, true);
await settle();
await settle();
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 0.25, 1, 1, 1, 0.25],
);
h.bank.setFade(0, false);
await settle();
await settle();
assert.equal(entry.store.count, 0, 'the heatmap alone: no marker drawn');
h.bank.setFade(1, true);
await settle();
await settle();
assert.equal(entry.store.count, 2);
assert.deepEqual(
[...entry.store.colors.slice(0, 8)],
[1, 1, 1, 1, 1, 1, 1, 1],
);
});
@@ -1,10 +1,17 @@
// The real FIRMS PNGs in one engine mover atlas.
// The FIRMS glow PNGs as one engine.movers sprite set over an atlas of the
// decoded images, the same on every engine. Each glow is drawn at its own
// pixel size (a scale of its side over BASE_SIZE), centred on its anchor,
// over the scene (depth test off), in the sprite order's 'firms' slot.
import { packImageAtlas } from '../../shared/render/imageAtlas.js';
import { SPRITE_LAYER_ORDER } from '../../../data/spriteOrder.js';
const WHITE = Object.freeze([1, 1, 1, 1]);
const CELL = 60,
PAD = 2,
BASE_SIZE = 56;
/** The glows' draw order among the sprite layers (data/spriteOrder.js). */
export const FIRMS_SPRITE_ORDER = SPRITE_LAYER_ORDER.indexOf('firms');
const imageFromUrl = (url) =>
new Promise((resolve, reject) => {
const image = new Image();
@@ -18,6 +25,19 @@ const imageFromUrl = (url) =>
};
image.src = url;
});
/**
* A bank of glow sprites: `collection` is billboard-collection shaped
* ({ length, get(i), removeAll() }) and each row's `show` is writable, as
* the layer's horizon cull expects. Writes are drawn on the next microtask,
* once the images they name are decoded; a show flip rewrites only that
* instance.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ id: string, capacity: number,
* loadImage?: (url: string) => Promise<object>,
* createCanvas?: () => object }} options `loadImage` and `createCanvas`
* are test seams (Node has neither Image nor canvas).
*/
export function createFirmsSpriteBank(
engine,
{
@@ -35,8 +55,11 @@ export function createFirmsSpriteBank(
destroyed = false,
generation = 0,
queued = false,
// Every row is rewritten on the next flush (else only `showDirty`).
full = true,
set = null,
atlas = null;
const showDirty = new Set();
const images = new Map();
const loaded = new Map();
const getImage = (url) => {
@@ -57,6 +80,7 @@ export function createFirmsSpriteBank(
return task;
};
const hide = () => {
full = true;
if (set && set.count() !== 0) {
set.setCount(0);
set.commit();
@@ -73,12 +97,12 @@ export function createFirmsSpriteBank(
lookup.set(url, { key, scale: width / BASE_SIZE });
return { key, image, width, height };
});
const atlas = packImageAtlas(entries, {
const packed = packImageAtlas(entries, {
canvas: createCanvas(),
cell: CELL,
pad: PAD,
});
return { ...atlas, lookup };
return { ...packed, lookup };
}
async function flush() {
if (destroyed) return;
@@ -98,6 +122,7 @@ export function createFirmsSpriteBank(
id,
kind: 'sprite',
capacity,
order: FIRMS_SPRITE_ORDER,
depthTest: 'always',
pickable: true,
sharpen: true,
@@ -107,19 +132,27 @@ export function createFirmsSpriteBank(
},
});
atlas = next;
full = true;
}
snapshot.forEach((row, index) => {
const cell = atlas.lookup.get(row.image);
set.set(index, {
position: row.position,
cell: cell.key,
scale: cell.scale,
color: fade,
show: row.show,
pickId: row.id,
if (full || set.count() !== snapshot.length) {
snapshot.forEach((row, index) => {
const cell = atlas.lookup.get(row.image);
set.set(index, {
position: row.position,
cell: cell.key,
scale: cell.scale,
color: fade,
show: row.show,
pickId: row.id,
});
});
});
set.setCount(snapshot.length);
set.setCount(snapshot.length);
} else
for (const index of showDirty)
if (index < snapshot.length)
set.set(index, { show: snapshot[index].show });
full = false;
showDirty.clear();
set.commit();
}
function schedule() {
@@ -145,6 +178,7 @@ export function createFirmsSpriteBank(
removeAll() {
if (destroyed) return;
rows = [];
full = true;
schedule();
},
};
@@ -154,6 +188,7 @@ export function createFirmsSpriteBank(
if (destroyed) return null;
if (rows.length >= capacity)
throw new RangeError('FIRMS sprite capacity exceeded');
const index = rows.length;
let show = description.show !== false;
const row = {
...description,
@@ -164,13 +199,20 @@ export function createFirmsSpriteBank(
const next = Boolean(value);
if (next === show || destroyed) return;
show = next;
showDirty.add(index);
schedule();
},
};
rows.push(row);
full = true;
schedule();
return row;
},
/** Whether a pick id names one of the bank's rows. */
owns(pickId) {
for (const row of rows) if (row.id === pickId) return true;
return false;
},
/** The hand-off fade: every marker at `alpha`, drawn while `show`. */
setFade(alpha, show) {
if (destroyed) return;
@@ -178,6 +220,7 @@ export function createFirmsSpriteBank(
if (next[3] === fade[3] && Boolean(show) === fadeShown) return;
fade = next;
fadeShown = Boolean(show);
full = true;
schedule();
},
setVisible(value) {
@@ -186,11 +229,16 @@ export function createFirmsSpriteBank(
if (!visible) hide();
schedule();
},
/** The mover set drawing the glows (null before the first flush). */
get set() {
return set;
},
destroy() {
if (destroyed) return;
destroyed = true;
generation++;
rows = [];
showDirty.clear();
images.clear();
loaded.clear();
set?.destroy();
+7 -14
View File
@@ -372,12 +372,12 @@ export function createRendering({
}
function ensureDetectionCollections() {
if (!layerState._viewer || layerState._billboards) return;
if (!layerState._renderer || layerState._billboards) return;
layerState._billboards = layerState._renderer.ensureSprites();
overlayHost.setVisible(FIRMS_OVERLAY_SOURCE_ID, layerState._enabled);
}
function removeDetectionCollections(viewer) {
function removeDetectionCollections() {
layerState._renderer?.removeSprites();
layerState._billboards = null;
overlayHost.clearSource(FIRMS_OVERLAY_SOURCE_ID);
@@ -385,21 +385,14 @@ export function createRendering({
}
/**
* Shared horizon occluder positioned at the current camera, or null when
* there is no usable camera yet (init order / headless stubs).
* `viewer.camera` and `viewer.scene.camera` are the SAME object in Cesium;
* both are probed so a partial stub (either shape) still gets a real
* occluder rather than silently degrading to "nothing is ever occluded".
* @returns {?Cesium.EllipsoidalOccluder}
* The ellipsoid horizon at the current camera (the engine's
* projector.horizonVisible, Cesium's EllipsoidalOccluder test), or null
* without a renderer.
* @returns {?{isPointVisible: function(Object): boolean}}
*/
function fireHorizonOccluder() {
if (layerState._renderer) return layerState._renderer.horizon();
const camera = layerState._viewer?.camera?.positionWC
? layerState._viewer.camera
: layerState._viewer?.scene?.camera;
if (!camera?.positionWC) return null;
return config.horizonOccluder(camera);
return layerState._renderer?.horizon() ?? null;
}
/**
+8 -17
View File
@@ -21,28 +21,21 @@ export function createSelection({
getContextStore,
registerEntityContext,
} = services.context;
const { id, name, overlayHost, screenSpaceEventHandlerFactory } = config;
const { id, name, overlayHost } = config;
/**
* Install the LEFT_CLICK handler for fire selection (enable-time only,
* Install the click handler for fire selection (enable-time only,
* mirroring the flight layers). Clicking a fire sprite selects it and
* surfaces it in the shared context store; clicking empty space clears
* the selection. Picks owned by sibling layers are left alone.
*/
function installClickHandler() {
if (layerState._clickHandler || !layerState._viewer) return;
layerState._clickHandler = layerState._renderer
? layerState._renderer.createInputHandler()
: screenSpaceEventHandlerFactory(layerState._viewer);
const clickType =
layerState._renderer?.clickType ?? config.leftClickEventType;
layerState._clickHandler.setInputAction((click) => {
if (layerState._clickHandler || !layerState._renderer) return;
layerState._clickHandler = layerState._renderer.onClick((click) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
const picked = layerState._renderer
? layerState._renderer.pick(click.position, click)
: layerState._viewer.scene.pick(click.position);
const picked = click.picked;
const fire = pickedFire(picked);
if (fire) {
selectAndFocusFire(fire);
@@ -67,14 +60,12 @@ export function createSelection({
return;
}
clearFireSelection();
}, clickType);
});
}
function removeClickHandler() {
if (layerState._clickHandler) {
layerState._clickHandler.destroy();
layerState._clickHandler = null;
}
layerState._clickHandler?.();
layerState._clickHandler = null;
}
/** Select one stable detection and request one UI-owned camera transfer. */
+2 -6
View File
@@ -98,13 +98,9 @@ export function createViewport({
function computeViewRect() {
try {
if (layerState._renderer)
return layerState._renderer.viewRectangle(layerState.scratchViewRect);
const rect = layerState._viewer?.camera?.computeViewRectangle(
layerState._viewer?.scene?.globe?.ellipsoid,
layerState.scratchViewRect,
return (
layerState._renderer?.viewRectangle(layerState.scratchViewRect) ?? null
);
return rect || null;
} catch {
return null;
}
+6 -15
View File
@@ -1,5 +1,4 @@
import { fromDegrees } from '../../geo/cartesian3.js';
import { createGhostMapsCesiumRenderer } from './render/cesium.js';
import {
decodeXml,
kmzImageResolver,
@@ -149,7 +148,6 @@ function anchorOf(geometry) {
export function createGhostMapsLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
createRenderer = null,
picking = null,
pointer = null,
@@ -187,10 +185,7 @@ export function createGhostMapsLayer({
const notifyRow = () => _rowListener?.();
const render = () => _display?.requestRender();
const canSelect = () =>
overlayHost &&
picking &&
(screenSpaceEventHandlerFactory || createRenderer);
const canSelect = () => overlayHost && picking && createRenderer;
function wanted(product) {
return _selection ? _selection.has(product.id) : product.defaultOn;
@@ -597,18 +592,14 @@ export function createGhostMapsLayer({
updateInterval: 300000,
init(viewer, options = {}) {
if (viewer || options.engine) return this.attachRenderer(viewer, options);
if (options.engine) return this.attachRenderer(viewer, options);
},
attachRenderer(viewer, { engine = null, signal = null } = {}) {
attachRenderer(_viewer, { engine = null, signal = null } = {}) {
if (_display) throw new Error('GhostMaps display is already attached');
_display = viewer
? createGhostMapsCesiumRenderer(
viewer,
screenSpaceEventHandlerFactory,
markerImage,
)
: createRenderer(engine);
if (typeof createRenderer !== 'function')
throw new TypeError('GhostMaps needs a renderer factory');
_display = createRenderer(engine);
if (!_enabled) return;
installClickHandler();
publishCard();
+111 -70
View File
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createGhostMapsLayer } from './index.js';
import { createGhostMapsDisplay } from './render/display.js';
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
function element(localName, children = [], value = '', attributes = {}) {
@@ -87,6 +88,30 @@ function fixtureKmz() {
return new Uint8Array(Buffer.concat([...local, directory, end]));
}
// The editions drawn on the engine: their ground and placemark sets carry
// `s2-ghostmaps:<product>:<draw>` in their ids.
function drawnEditions(engine) {
const ids = [
...engine.ground.list().map(({ id }) => id),
...engine.movers.list().map(({ id }) => id),
];
return new Set(ids.map((id) => id.split(':').slice(0, 3).join(':')));
}
// The pick ids drawn on the engine (placemarks and ground items), in order.
function drawnIds(engine, ground) {
const ids = [];
for (const { id, count } of engine.ground.list())
ids.push(...(ground.get(id) ?? []).slice(0, count).map((i) => i.pickId));
for (const { id } of engine.movers.list()) {
const { store } = engine.fake.moverSet(id);
ids.push(
...Array.from({ length: store.count }, (_, i) => store.pickIds[i]),
);
}
return ids;
}
function fixture(
t,
{
@@ -118,22 +143,28 @@ function fixture(
});
t.mock.method(URL, 'revokeObjectURL', (url) => revoked.push(url));
t.mock.method(console, 'warn', () => {});
const mounted = new Set();
const viewer = {
scene: { requestRender() {} },
dataSources: {
async add(dataSource) {
mounted.add(dataSource);
return dataSource;
},
remove(dataSource) {
return mounted.delete(dataSource);
},
},
const engine = createFakeEngine();
// What each ground set was last given, by set id.
const ground = new Map();
const createSet = engine.ground.createSet.bind(engine.ground);
engine.ground.createSet = (options) => {
const set = createSet(options);
const replace = set.replace.bind(set);
set.replace = (items) => {
ground.set(options.id, items);
replace(items);
};
return set;
};
const display = () =>
createGhostMapsDisplay(engine, {
loadImage: async () => ({ naturalWidth: 32, naturalHeight: 32 }),
createCanvas: () => ({ getContext: () => ({ drawImage() {} }) }),
});
const layer = createGhostMapsLayer({
storage: null,
now,
createRenderer: display,
...controls,
source: {
getCatalog: async () => ({
@@ -147,12 +178,36 @@ function fixture(
},
},
});
layer.init(viewer);
layer.init(null, { engine });
layer.enable();
t.after(() => layer.destroy());
return { layer, viewer, mounted, created, revoked };
const mounted = {
get size() {
return drawnEditions(engine).size;
},
};
const ids = () => drawnIds(engine, ground);
// A click at the window centre on what the engine picks there.
const click = (pickId) => {
engine.fake.script('pick', pickId ? { id: pickId } : undefined);
engine.fake.input('click', { position: { x: 512, y: 384 } });
};
return {
layer,
viewer: null,
engine,
mounted,
ids,
editions: () => drawnEditions(engine),
click,
created,
revoked,
};
}
// The layer's picking service, as the application's resolves a pick.
const picking = { resolvePickId: (picked) => picked?.id ?? null };
test('cancelling a file read does not allocate packed-image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t, {
beforeFileReturns: (layer) => queueMicrotask(() => layer.disable()),
@@ -187,7 +242,7 @@ test('a KMZ delivered between planets is reused by the new scene and releases it
['Test report'],
);
assert.equal(h.mounted.size, 0);
await layer.attachRenderer(h.viewer);
await layer.attachRenderer(null, { engine: h.engine });
assert.equal(h.mounted.size, 1);
assert.equal(h.created.length, 1);
assert.equal(h.revoked.length, 0);
@@ -196,9 +251,9 @@ test('a KMZ delivered between planets is reused by the new scene and releases it
assert.equal(h.mounted.size, 0);
});
test('entity construction failure releases parsed image URLs', async (t) => {
const { layer, viewer, mounted, created, revoked } = fixture(t);
t.mock.method(Cesium.EntityCollection.prototype, 'add', () => {
test('a drawing failure releases parsed image URLs', async (t) => {
const { layer, viewer, engine, mounted, created, revoked } = fixture(t);
t.mock.method(engine.ground, 'createSet', () => {
throw new Error('fixture entity failure');
});
await layer.update(viewer);
@@ -386,7 +441,7 @@ test('age filtering and All markers apply to every conflict-map product, includi
});
test('default products filter old event dates while keeping recent, future and undated markers', async (t) => {
const { layer, viewer, mounted } = fixture(t, {
const { layer, viewer, ids } = fixture(t, {
now: () => Date.UTC(2026, 10, 3, 12),
reports: [
dated('Old event', '10/20/2026'),
@@ -420,21 +475,17 @@ test('default products filter old event dates while keeping recent, future and u
],
);
assert.equal(layer.getStats().count, 7);
assert.equal([...mounted][0].entities.values.length, 7);
assert.equal(ids().length, 7);
// Filtering cannot shift pick ids onto a different report.
assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0'));
assert.equal(
[...mounted][0].entities.getById('ghostmaps:cip:0:0'),
undefined,
);
assert.ok(ids().includes('ghostmaps:cip:1:0'));
assert.equal(ids().includes('ghostmaps:cip:0:0'), false);
});
test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => {
t.mock.timers.enable({ apis: ['setInterval'] });
let at = Date.UTC(2026, 10, 3, 12);
let click;
const cards = [];
const { layer, viewer, mounted } = fixture(t, {
const { layer, viewer, mounted, ids, editions, click } = fixture(t, {
now: () => at,
reports: [
dated('Expiring', '10/27/2026 12:00:00 PM'),
@@ -442,31 +493,27 @@ test('unchanged cached products age out on the timer, clear selected cards and k
],
controls: {
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
screenSpaceEventHandlerFactory: () => ({
setInputAction: (fn) => {
click = fn;
},
destroy() {},
}),
picking: { resolvePickId: (picked) => picked },
picking,
},
});
await layer.update(viewer);
const ds = [...mounted][0];
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
click({ position: { x: 0, y: 0 } });
const [edition] = editions();
click('ghostmaps:cip:0:0');
assert.equal(cards.at(-1).length, 1);
at += 60_000;
t.mock.timers.tick(60_000);
assert.equal(layer.getStats().count, 1);
assert.equal(ds.entities.values.length, 1);
assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined);
assert.ok(ds.entities.getById('ghostmaps:cip:1:0'));
// Dropped in place: the edition's drawing stays for the survivor.
assert.deepEqual(ids(), ['ghostmaps:cip:1:0']);
assert.deepEqual([...editions()], [edition]);
assert.deepEqual(cards.at(-1), []);
await layer.update(viewer);
assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded');
viewer.scene.pick = () => 'ghostmaps:cip:1:0';
click({ position: { x: 0, y: 0 } });
assert.deepEqual(
[...editions()],
[edition],
'same-version KMZ is not reloaded',
);
click('ghostmaps:cip:1:0');
assert.match(cards.at(-1)[0].title, /Surviving/);
layer.disable();
at += 14 * 86400_000;
@@ -487,8 +534,13 @@ test('expiry removes every geometry and polygon outline belonging to an old repo
element('coordinates', [], '12,34 13,34 13,35 12,34'),
]),
]),
element('innerBoundaryIs', [
element('LinearRing', [
element('coordinates', [], '12.6,34.2 12.8,34.2 12.8,34.4 12.6,34.2'),
]),
]),
]);
const { layer, viewer, mounted } = fixture(t, {
const { layer, viewer, ids } = fixture(t, {
now: () => at,
reports: [
{
@@ -502,11 +554,16 @@ test('expiry removes every geometry and polygon outline belonging to an old repo
],
});
await layer.update(viewer);
const ds = [...mounted][0];
assert.equal(ds.entities.values.length, 4);
// The placemark, the line, the polygon and its outer outline.
assert.deepEqual(ids().sort(), [
'ghostmaps:cip:0:0',
'ghostmaps:cip:0:1',
'ghostmaps:cip:0:2',
'ghostmaps:cip:0:2:outline',
]);
at += 60_000;
t.mock.timers.tick(60_000);
assert.equal(ds.entities.values.length, 0);
assert.deepEqual(ids(), []);
assert.equal(layer.getStats().count, 0);
});
@@ -541,27 +598,19 @@ test('two quick clicks, 1d then 7d, restore the report', async (t) => {
});
test('narrowing the window keeps the open card of a surviving marker', async (t) => {
let click;
const cards = [];
const { layer, viewer } = fixture(t, {
const { layer, viewer, click } = fixture(t, {
now: () => Date.UTC(2026, 10, 3, 12),
reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')],
controls: {
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
screenSpaceEventHandlerFactory: () => ({
setInputAction: (fn) => {
click = fn;
},
destroy() {},
}),
picking: { resolvePickId: (picked) => picked },
picking,
},
});
await layer.update(viewer);
await ageChip(layer, 30).onClick();
assert.equal(layer.getStats().count, 2);
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
click({ position: { x: 0, y: 0 } });
click('ghostmaps:cip:0:0');
assert.equal(cards.at(-1).length, 1);
await ageChip(layer, 3).onClick();
assert.equal(layer.getStats().count, 1);
@@ -583,26 +632,18 @@ test('a report dated yesterday is shown by 1d one hour into today (UTC)', async
});
test('a widened reload that returns a different report at the selected index closes its card', async (t) => {
let click;
const cards = [];
const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')];
const { layer, viewer } = fixture(t, {
const { layer, viewer, click } = fixture(t, {
now: () => Date.UTC(2026, 10, 3, 12),
reports,
controls: {
overlayHost: { setEntries: (_id, entries) => cards.push(entries) },
screenSpaceEventHandlerFactory: () => ({
setInputAction: (fn) => {
click = fn;
},
destroy() {},
}),
picking: { resolvePickId: (picked) => picked },
picking,
},
});
await layer.update(viewer);
viewer.scene.pick = () => 'ghostmaps:cip:0:0';
click({ position: { x: 0, y: 0 } });
click('ghostmaps:cip:0:0');
assert.equal(cards.at(-1).length, 1);
// The server answers the pinned version with a newer edition's bytes.
reports[0] = dated('Replacement', '11/2/2026');
-157
View File
@@ -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,
};
}
+388
View File
@@ -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/,
);
});
-145
View File
@@ -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() {},
};
}
+6 -5
View File
@@ -1,5 +1,5 @@
import { createGnssCesiumRenderer } from './render/cesium.js';
export { defaultGnssViewAnchor } from './render/cesium.js';
import { createGnssDisplay } from './render/display.js';
export { defaultGnssViewAnchor } from './render/display.js';
import { formatNumber, msg, t } from '../../i18n/index.js';
import { translateFeedError } from '../../ui/statusText.js';
import {
@@ -30,7 +30,8 @@ const LEVEL_LABELS = Object.freeze({
export function createGnssIntegrityLayer({
source,
viewAnchor,
createRenderer = createGnssCesiumRenderer,
createRenderer = ({ engine, viewAnchor: anchor }) =>
createGnssDisplay(engine, { viewAnchor: anchor }),
now = () => Date.now(),
monotonicNow = () => performance.now(),
windowMs: requestedWindowMs,
@@ -160,7 +161,7 @@ export function createGnssIntegrityLayer({
throw new Error('GNSS integrity layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
@@ -170,7 +171,7 @@ export function createGnssIntegrityLayer({
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid GNSS scene attachment');
renderer = createRenderer({ viewer, engine, viewAnchor });
renderer = createRenderer({ engine, viewAnchor });
_removeCameraEnd = renderer.camera?.moveEnd?.addEventListener(
scheduleCameraRefresh,
);
+115 -92
View File
@@ -1,6 +1,8 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { rgbaFromCss } from '../shared/color.js';
import { createGnssIntegrityLayer, defaultGnssViewAnchor } from './index.js';
import { LayerLifecycle } from '../../data/lifecycle.js';
import { layerFeedState } from '../../data/feedState.js';
@@ -23,22 +25,10 @@ function harness(
} = {},
) {
let anchor = initialAnchor;
const sources = [];
const viewer = {
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
value.destroy();
},
},
},
};
const ground = createFakeGroundHarness();
const { engine } = ground;
// What the manager's lifecycle reads of a scene besides the engine.
const viewer = { scene: { globe: { show: true } } };
let clock = 1_000;
const anchors = [];
const layer = createGnssIntegrityLayer({
@@ -56,10 +46,16 @@ function harness(
now: () => clock,
monotonicNow: () => clock,
});
if (initialize) layer.init(viewer);
if (initialize) layer.init(null, { engine });
return {
layer,
sources,
ground,
engine,
camera: engine.camera,
/** The drawn cells' ground set (undefined once released). */
cells: () => ground.set('gnss-integrity'),
/** How many cells are drawn. */
drawn: () => ground.set('gnss-integrity')?.items.length ?? 0,
anchors,
viewer,
advance(ms) {
@@ -83,16 +79,13 @@ const flush = () => new Promise((resolve) => setImmediate(resolve));
test('a globe-wide rectangle uses the visible screen centre as the query anchor', () => {
const anchor = defaultGnssViewAnchor({
scene: {
canvas: { clientWidth: 1000, clientHeight: 800 },
globe: { ellipsoid: Cesium.Ellipsoid.WGS84 },
},
host: { size: () => ({ width: 1000, height: 800 }) },
camera: {
pickEllipsoid(pixel) {
assert.deepEqual(pixel, new Cesium.Cartesian2(500, 400));
return Cesium.Cartesian3.fromDegrees(-97, 30);
assert.deepEqual(pixel, { x: 500, y: 400 });
return geo.cartesian3.fromDegrees(-97, 30);
},
computeViewRectangle: () => {
viewRectangle: () => {
throw new Error('centre hit must not compute a rectangle');
},
},
@@ -111,16 +104,16 @@ test('pan and zoom refresh through the manager without toggling; nearby changes
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
const manager = new LayerLifecycle(h.viewer, { engine: h.engine });
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
manager.subscribe((change) => changes.push(change.type));
await manager.setEnabled(h.layer.id, true);
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.setAnchor({ latitude: 55, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
@@ -137,14 +130,14 @@ test('pan and zoom refresh through the manager without toggling; nearby changes
});
assert.match(h.layer.getRowControls().info, /Regional sample.*250 nm radius/);
h.setAnchor({ latitude: 55.1, longitude: 20.1 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.advance(5000);
t.mock.timers.tick(5000);
await flush();
assert.equal(calls, 2, 'nearby views reuse the same query region');
await manager.destroyAll();
assert.equal(h.viewer.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
assert.equal(h.camera.moveEnd.numberOfListeners, 0);
assert.equal(h.camera.changed.numberOfListeners, 0);
});
test('movement during a pending refresh follows up at the newest view; disable cancels queued work', async (t) => {
@@ -162,12 +155,12 @@ test('movement during a pending refresh follows up at the newest view; disable c
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 54, longitude: 19 });
h.viewer.camera.moveEnd.raiseEvent();
h.camera.moveEnd.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
h.setAnchor({ latitude: 35, longitude: 140 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
@@ -180,7 +173,7 @@ test('movement during a pending refresh follows up at the newest view; disable c
assert.equal(calls.length, 3);
assert.deepEqual(calls.at(-1), { latitude: 35, longitude: 140 });
h.setAnchor({ latitude: 10, longitude: 10 });
h.viewer.camera.moveEnd.raiseEvent();
h.camera.moveEnd.raiseEvent();
h.layer.disable();
h.advance(5000);
t.mock.timers.tick(5000);
@@ -192,11 +185,11 @@ test('movement during a pending refresh follows up at the newest view; disable c
test('the layer requires a snapshot source and cannot be initialized twice', () => {
assert.throws(() => createGnssIntegrityLayer({}), TypeError);
const { layer } = harness(async () => ({ rows: [] }));
assert.throws(() => layer.init({ dataSources: { add() {} } }), /already/);
assert.throws(() => layer.init(null, { engine: {} }), /already/);
});
test('an enabled refresh bins the snapshot into coloured ground cells', async () => {
const { layer, sources, anchors } = harness(async (anchor, { signal }) => {
const { layer, cells, anchors } = harness(async (anchor, { signal }) => {
assert.deepEqual(anchor, { latitude: 50, longitude: 30 });
assert.ok(signal instanceof AbortSignal);
return {
@@ -213,13 +206,21 @@ test('an enabled refresh bins the snapshot into coloured ground cells', async ()
assert.equal(await layer.update(), false, 'disabled layers do not fetch');
assert.equal(anchors.length, 0);
layer.enable();
assert.equal(sources[0].show, true);
assert.equal(cells().shown, true);
assert.equal(await layer.update(), true);
const entities = sources[0].get(0).geometryInstances;
assert.equal(entities.length, 1, 'the one-aircraft cell is withheld');
assert.equal(entities[0].id, 'gnss-integrity:280:420');
assert.ok(entities[0].geometry instanceof Cesium.RectangleGeometry);
assert.equal(sources[0].get(0).allowPicking, false);
const { items, options } = cells();
assert.equal(items.length, 1, 'the one-aircraft cell is withheld');
// Cell 280:420, half a degree from 50° N, 30° E, at the high level.
assert.deepEqual(
{ ...items[0], fill: undefined },
{ west: 30, south: 50, east: 30.5, north: 50.5, fill: undefined },
);
assert.deepEqual(
items[0].fill,
rgbaFromCss('#f85149').withAlpha(0.55).rgba(),
);
assert.equal(options.kind, 'cell');
assert.equal(options.pickable, false);
const stats = layer.getStats();
assert.equal(stats.count, 1);
assert.equal(stats.error, null);
@@ -272,7 +273,7 @@ test('the visible layer leads with low navigation accuracy and exposes its prove
test('a failed refresh keeps the last cells and reports the error', async () => {
let fail = false;
const { layer, sources } = harness(async () => {
const { layer, drawn } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return {
rows: [
@@ -288,14 +289,14 @@ test('a failed refresh keeps the last cells and reports the error', async () =>
assert.equal(layer.getStats().stale, true);
fail = true;
assert.equal(await layer.update(), true);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(drawn(), 1);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
assert.equal(layer.getStats().count, 1);
});
test('observations expire with the window, and disable aborts the request in flight', async () => {
let pending;
const { layer, sources, advance } = harness(
const { layer, drawn, cells, advance } = harness(
(anchor, { signal }) =>
new Promise((resolve, reject) => {
pending = { resolve };
@@ -315,17 +316,17 @@ test('observations expire with the window, and disable aborts the request in fli
pending.resolve({ rows: [] });
assert.equal(await result, true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(drawn(), 0);
result = layer.update();
layer.disable();
assert.equal(await result, false);
assert.equal(sources[0].show, false);
assert.equal(cells().shown, false);
});
test('no view anchor means no request, and destroy releases the data source', async () => {
test('no view anchor means no request, and destroy releases the ground set', async () => {
let calls = 0;
const { layer, sources } = harness(
const { layer, ground } = harness(
async () => {
calls += 1;
return { rows: [] };
@@ -341,13 +342,13 @@ test('no view anchor means no request, and destroy releases the data source', as
assert.equal(calls, 0);
assert.equal(layer.getStats().error, null);
layer.destroy();
assert.equal(sources.length, 0);
assert.equal(ground.sets(), 0);
assert.equal(layer.getStats().count, 0);
});
test('a replayed stale snapshot keeps its observation age and expires on time', async () => {
// The proxy keeps answering with the snapshot it observed at t=1000.
const { layer, sources, advance, now } = harness(async () => ({
const { layer, drawn, advance, now } = harness(async () => ({
rows: threeHealthy(),
ageMs: now() - 1_000,
stale: true,
@@ -369,12 +370,12 @@ test('a replayed stale snapshot keeps its observation age and expires on time',
0,
'the replay did not restart the window',
);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(drawn(), 0);
});
test('cells expire during prolonged fetch failures', async () => {
let fail = false;
const { layer, sources, advance } = harness(async () => {
const { layer, drawn, advance } = harness(async () => {
if (fail) throw new Error('adsb.lol HTTP 502');
return { rows: threeHealthy() };
});
@@ -387,13 +388,13 @@ test('cells expire during prolonged fetch failures', async () => {
advance(11 * MIN);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(drawn(), 0);
assert.equal(layer.getStats().error, 'adsb.lol HTTP 502');
});
test('cells expire while no view anchor is available', async () => {
let calls = 0;
const { layer, sources, advance, setAnchor } = harness(async () => {
const { layer, drawn, advance, setAnchor } = harness(async () => {
calls += 1;
return { rows: threeHealthy() };
});
@@ -407,36 +408,37 @@ test('cells expire while no view anchor is available', async () => {
assert.equal(await layer.update(), true);
assert.equal(calls, 1, 'no anchor means no request');
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(drawn(), 0);
assert.equal(layer.getStats().error, null);
});
test('re-enabling after a long disable does not show cells past the window', async () => {
const { layer, sources, advance } = harness(async () => ({
const { layer, drawn, advance } = harness(async () => ({
rows: threeHealthy(),
}));
layer.enable();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(drawn(), 1);
layer.disable();
advance(31 * MIN);
layer.enable();
assert.equal(layer.getStats().count, 0);
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 0);
assert.equal(drawn(), 0);
});
const threeCells = () => ({
rows: [row('a1', 50.1, true), row('a2', 50.1, true), row('a3', 50.2, false)],
});
test('an identical snapshot leaves the drawn entities untouched', async () => {
const { layer, sources } = harness(async () => threeCells());
test('an identical snapshot leaves the drawn cells untouched', async () => {
const { layer, drawn, cells } = harness(async () => threeCells());
layer.enable();
await layer.update();
const first = sources[0].get(0);
const first = cells();
await layer.update();
assert.equal(sources[0].get(0)?.geometryInstances.length ?? 0, 1);
assert.equal(sources[0].get(0), first);
assert.equal(drawn(), 1);
assert.equal(cells().commits, first.commits);
assert.equal(cells().items, first.items);
});
test('an initial feed failure stays enabled, reports unavailable, and recovers automatically', async (t) => {
@@ -450,7 +452,7 @@ test('an initial feed failure stays enabled, reports unavailable, and recovers a
},
{ initialize: false },
);
const manager = new LayerLifecycle(h.viewer);
const manager = new LayerLifecycle(h.viewer, { engine: h.engine });
manager.register(h.layer);
h.layer.attachDataManager(manager);
const changes = [];
@@ -496,7 +498,7 @@ test('provider cooldown is preserved across movement and toggling; disable cance
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 10, longitude: 20 });
h.viewer.camera.moveEnd.raiseEvent();
h.camera.moveEnd.raiseEvent();
h.advance(30000);
t.mock.timers.tick(30000);
await flush();
@@ -583,7 +585,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) =>
h.layer.enable();
await h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.advance(2499);
t.mock.timers.tick(2499);
await flush();
@@ -594,7 +596,7 @@ test('GERMAN: camera debounce and minimum request gap are pinned', async (t) =>
assert.equal(calls, 2);
h.advance(3000);
h.setAnchor({ latitude: 52, longitude: 32 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.advance(599);
t.mock.timers.tick(599);
await flush();
@@ -619,7 +621,7 @@ test('camera movement during initial activation waits for acquisition before fol
h.layer.enable();
const initial = h.layer.update();
h.setAnchor({ latitude: 51, longitude: 31 });
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
h.advance(2500);
t.mock.timers.tick(2500);
await flush();
@@ -652,13 +654,13 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async (
now: () => wall,
monotonicNow: () => mono,
});
layer.init(h.viewer);
layer.init(null, { engine: h.engine });
layer.enable();
await layer.update();
wall -= 3600000;
mono = 3000;
anchor = { latitude: 51, longitude: 31 };
h.viewer.camera.changed.raiseEvent();
h.camera.changed.raiseEvent();
mono += 600;
t.mock.timers.tick(600);
await flush();
@@ -666,37 +668,58 @@ test('monotonic camera scheduling is unaffected by wall-clock rollback', async (
layer.destroy();
});
test('GNSS fill emits no pick-pass commands but keeps normal rendering', async (t) => {
test('the cells stay out of picks', async () => {
const h = harness(async () => ({ rows: threeHealthy() }));
h.layer.enable();
await h.layer.update();
const fill = h.sources[0].get(0);
let updates = 0;
t.mock.method(Cesium.GroundPrimitive.prototype, 'update', () => updates++);
fill.update({ passes: { pick: true } });
assert.equal(updates, 0);
fill.update({ passes: { render: true, pick: false } });
assert.equal(updates, 1);
assert.equal(h.cells().options.pickable, false);
// A pick at the drawn cell (1° N, 30.2° E) finds nothing of the layer.
h.engine.camera.setView({
destination: geo.cartesian3.fromDegrees(30.2, 1.2, 100_000),
});
const { width, height } = h.engine.host.size();
assert.equal(
h.engine.picker.pick({ x: width / 2, y: height / 2 }),
undefined,
);
h.layer.destroy();
assert.equal(fill.isDestroyed(), true);
assert.equal(h.ground.sets(), 0);
});
test('view anchor falls back to rectangle, then nadir, then no camera', () => {
assert.equal(defaultGnssViewAnchor({}), null);
const viewer = {
const radians = geo.math.toRadians;
const engine = {
host: { size: () => ({ width: 1000, height: 800 }) },
camera: {
computeViewRectangle: () => Cesium.Rectangle.fromDegrees(20, 40, 22, 42),
pickEllipsoid: () => undefined,
viewRectangle: () => ({
west: radians(20),
south: radians(40),
east: radians(22),
north: radians(42),
}),
pose: () => assert.fail('a rectangle must not read the pose'),
},
};
const rectangleAnchor = defaultGnssViewAnchor(viewer);
const rectangleAnchor = defaultGnssViewAnchor(engine);
assert.ok(Math.abs(rectangleAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(rectangleAnchor.longitude - 21) < 1e-10);
viewer.camera = {
positionCartographic: Cesium.Cartographic.fromDegrees(21, 41),
engine.camera = {
pickEllipsoid: () => assert.fail('an empty canvas has no centre'),
viewRectangle: () => undefined,
pose: () => ({
cartographic: { longitude: radians(21), latitude: radians(41) },
}),
};
const nadirAnchor = defaultGnssViewAnchor(viewer);
engine.host = { size: () => ({ width: 0, height: 0 }) };
const nadirAnchor = defaultGnssViewAnchor(engine);
assert.ok(Math.abs(nadirAnchor.latitude - 41) < 1e-10);
assert.ok(Math.abs(nadirAnchor.longitude - 21) < 1e-10);
engine.camera.pose = () => ({
cartographic: { longitude: Number.NaN, latitude: 0 },
});
assert.equal(defaultGnssViewAnchor(engine), null);
});
test('an in-flight regional response supplies the next renderer without a new request', async () => {
@@ -712,15 +735,15 @@ test('an in-flight regional response supplies the next renderer without a new re
h.layer.enable();
const pending = h.layer.update();
h.layer.detachRenderer();
assert.equal(h.viewer.camera.changed.numberOfListeners, 0);
assert.equal(h.camera.changed.numberOfListeners, 0);
resolve({ rows: threeHealthy() });
assert.equal(await pending, true);
assert.equal(h.sources.length, 0);
assert.equal(h.ground.sets(), 0);
assert.equal(h.layer.getStats().levels.low, 1);
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].length, 1);
h.layer.attachRenderer(null, { engine: h.engine });
assert.equal(h.drawn(), 1);
assert.equal(calls, 1);
assert.equal(h.viewer.camera.changed.numberOfListeners, 1);
assert.equal(h.camera.changed.numberOfListeners, 1);
} finally {
h.layer.destroy();
}
-99
View File
@@ -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);
},
};
}
+89
View File
@@ -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();
},
};
}
+173
View File
@@ -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();
});
-40
View File
@@ -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 -8
View File
@@ -2,7 +2,7 @@
// card's info line shows them in the UI language, the HTTP form included.
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { setLocale } from '../../i18n/index.js';
import { createGnssIntegrityLayer } from './index.js';
@@ -20,13 +20,7 @@ async function failedLayer(message) {
now: () => 1_000,
monotonicNow: () => 1_000,
});
layer.init({
camera: { moveEnd: new Cesium.Event(), changed: new Cesium.Event() },
scene: {
globe: { show: true },
groundPrimitives: { add() {}, remove() {} },
},
});
layer.init(null, { engine: createFakeEngine() });
layer.enable();
await layer.update();
return layer;
+5 -12
View File
@@ -1,6 +1,5 @@
import { fromDegrees } from '../../geo/cartesian3.js';
import { createPerimetersCesiumRenderer } from './render/cesium.js';
export { containmentColor } from './render/cesium.js';
import { createPerimetersDisplay } from './render/display.js';
import {
PERIMETER_OVERLAY_SOURCE_ID,
perimeterAnchorDegrees,
@@ -51,13 +50,12 @@ function samePolygons(left, right) {
export function createFirePerimetersLayer({
source,
overlayHost = null,
screenSpaceEventHandlerFactory = null,
picking = null,
pointer = null,
inciwebSource = null,
inciwebPublications = null,
openExternal = null,
createRenderer = createPerimetersCesiumRenderer,
createRenderer = ({ engine }) => createPerimetersDisplay(engine),
} = {}) {
if (typeof source?.getSnapshot !== 'function')
throw new TypeError('Fire perimeters require a snapshot source');
@@ -90,8 +88,7 @@ export function createFirePerimetersLayer({
_linkRequest = null;
}
const canSelect = () =>
overlayHost && screenSpaceEventHandlerFactory && picking;
const canSelect = () => overlayHost && picking;
/**
* Refresh the InciWeb catalog off the render path: a hanging or failed
@@ -273,7 +270,7 @@ export function createFirePerimetersLayer({
throw new Error('Fire perimeter layer is already initialized');
initialized = true;
try {
if (viewer || engine) this.attachRenderer(viewer, { engine });
if (engine) this.attachRenderer(viewer, { engine });
} catch (error) {
this.destroy();
throw error;
@@ -283,11 +280,7 @@ export function createFirePerimetersLayer({
attachRenderer(viewer, { engine = null } = {}) {
if (!initialized || renderer)
throw new Error('Invalid fire perimeter scene attachment');
renderer = createRenderer({
viewer,
engine,
screenSpaceEventHandlerFactory,
});
renderer = createRenderer({ engine });
renderer.setVisible(_enabled);
if (geometry.size) renderer.replace([...geometry.values()]);
if (_enabled) {
+121 -151
View File
@@ -1,5 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import { createFakeGroundHarness } from '../../testSupport/groundDisplayEngines.mjs';
import { createFirePerimetersLayer } from './index.js';
function harness(
@@ -14,22 +16,14 @@ function harness(
}),
} = {},
) {
const sources = [];
const overlay = { entries: new Map(), visible: null };
const clicks = { handler: null, destroyed: 0 };
const owners = new Map();
const opened = [];
const viewer = {
scene: { pick },
dataSources: {
add(value) {
sources.push(value);
},
remove(value) {
sources.splice(sources.indexOf(value), 1);
},
},
};
const ground = createFakeGroundHarness();
const { engine } = ground;
// What a click picks: `scene.pick()` (null for nothing).
const scene = { pick };
engine.fake.script('pick', () => scene.pick() ?? undefined);
const layer = createFirePerimetersLayer({
source,
overlayHost: {
@@ -44,15 +38,6 @@ function harness(
},
hitTest: (x, y, options) => cardHit(x, y, options),
},
screenSpaceEventHandlerFactory: () => ({
setInputAction(callback) {
clicks.handler = callback;
},
destroy() {
clicks.destroyed += 1;
clicks.handler = null;
},
}),
picking: {
resolvePickId: (picked) => picked?.id ?? null,
isOwnedByOtherLayer: (layerId, pickedId) =>
@@ -67,9 +52,45 @@ function harness(
inciwebPublications: { getPublication: publication },
openExternal: (url) => opened.push(url),
});
layer.init(viewer);
layer.enable(viewer);
return { layer, viewer, sources, overlay, clicks, owners, opened };
layer.init(null, { engine });
layer.enable();
return {
layer,
engine,
ground,
scene,
overlay,
owners,
opened,
/** The perimeters' ground set (undefined once released). */
zones: () => ground.set('fire-perimeters'),
/** A click through the engine's input, picking `scene.pick()`. */
click: (event) => engine.fake.input('click', event),
};
}
// A layer on its own fake engine, for tests that only refresh.
function plainLayer(options) {
const layer = createFirePerimetersLayer({
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
openExternal: () => {},
...options,
});
layer.init(null, { engine: createFakeEngine() });
layer.enable();
return layer;
}
const ring = [
@@ -97,21 +118,27 @@ const row = {
polygons: [[ring]],
};
test('each perimeter polygon renders as one filled entity with its fire line', async () => {
test('each perimeter polygon renders as one fill with its fire line', async () => {
const h = harness({
getSnapshot: async () => [
row,
{ ...row, stableId: 'other', polygons: [[ring], [ring]] },
],
});
assert.equal(await h.layer.update(h.viewer), true);
const entities = h.sources[0].entities.values;
assert.equal(entities.length, 3);
assert.equal(entities[0].id, 'fire-perimeter:2026-NMGNF-000123:0');
assert.ok(entities[0].polygon, 'perimeter fill missing');
assert.ok(entities[0].polyline, 'fire line missing');
assert.equal(entities[1].id, 'fire-perimeter:other:0');
assert.equal(entities[2].id, 'fire-perimeter:other:1');
assert.equal(await h.layer.update(), true);
const { items } = h.zones();
const ids = [
'fire-perimeter:2026-NMGNF-000123:0',
'fire-perimeter:other:0',
'fire-perimeter:other:1',
];
assert.equal(items.length, 6, 'a fill and a fire line per polygon');
assert.deepEqual(
items.map(({ pickId }) => pickId),
[...ids, ...ids],
);
assert.ok(items.slice(0, 3).every((item) => item.fill && !item.outline));
assert.ok(items.slice(3).every((item) => item.outline && !item.fill));
assert.equal(h.layer.getStats().count, 2);
});
@@ -126,14 +153,14 @@ test('late refresh cannot publish after disable or destroy', async () => {
});
},
});
const pending = h.layer.update(h.viewer);
h.layer[action](h.viewer);
const pending = h.layer.update();
h.layer[action]();
assert.equal(signal.aborted, true);
if (action === 'disable') h.layer.enable(h.viewer);
if (action === 'disable') h.layer.enable();
resolve([row]);
assert.equal(await pending, false);
assert.equal(h.layer.getStats().count, 0);
h.layer.destroy(h.viewer);
h.layer.destroy();
}
});
@@ -145,8 +172,8 @@ test('clicking a perimeter publishes its incident card; empty space clears it',
pick: () => pickResult,
},
);
await h.layer.update(h.viewer);
assert.equal(typeof h.clicks.handler, 'function', 'click handler missing');
await h.layer.update();
assert.equal(h.ground.listeners(), 1, 'click handler missing');
// Deliberately NOT registered as a pick owner: sibling layers' click
// handlers yield to owned picks before their own card hit-tests, so
// claiming these large ground polygons would make FIRMS/vessel/CCTV
@@ -154,7 +181,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it',
assert.equal(h.owners.size, 0);
pickResult = { id: 'fire-perimeter:2026-NMGNF-000123:0' };
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
assert.equal(entries[0].id, 'fire-perimeter-card:2026-NMGNF-000123');
@@ -162,7 +189,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it',
assert.ok(entries[0].position, 'card must carry a world anchor');
pickResult = { id: 'other-layer:aircraft-1' };
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
assert.equal(
h.overlay.entries.get('fire-perimeters')?.length,
1,
@@ -170,7 +197,7 @@ test('clicking a perimeter publishes its incident card; empty space clears it',
);
pickResult = null;
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
@@ -179,10 +206,10 @@ test('disable removes the click handler, pick ownership, and any card', async ()
{ getSnapshot: async () => [row] },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
h.layer.disable(h.viewer);
assert.equal(h.clicks.destroyed, 1);
await h.layer.update();
h.click({ position: { x: 10, y: 10 } });
h.layer.disable();
assert.equal(h.ground.listeners(), 0);
assert.equal(h.owners.has('fire-perimeters'), false);
assert.equal(h.overlay.entries.has('fire-perimeters'), false);
});
@@ -193,11 +220,11 @@ test('a refresh that drops the selected incident also drops its card', async ()
{ getSnapshot: async () => rows },
{ pick: () => ({ id: 'fire-perimeter:2026-NMGNF-000123:0' }) },
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await h.layer.update();
h.click({ position: { x: 10, y: 10 } });
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
rows = [{ ...row, stableId: 'different' }];
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.overlay.entries.get('fire-perimeters')?.length ?? 0, 0);
});
@@ -218,12 +245,12 @@ test('a matched incident card carries the InciWeb line and click-through', async
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
await h.layer.update();
// Select via a perimeter pick.
h.viewer.scene.pick = () => ({
h.scene.pick = () => ({
id: 'fire-perimeter:2026-NMGNF-000123:0',
});
h.clicks.handler({ position: { x: 10, y: 10 } });
h.click({ position: { x: 10, y: 10 } });
// The card publishes immediately; the link line lands once the
// publication's currency is validated.
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
@@ -233,8 +260,8 @@ test('a matched incident card carries the InciWeb line and click-through', async
// Clicking the card itself opens the InciWeb page.
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
h.scene.pick = () => null;
h.click({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, ['https://inciweb.wildfire.gov/node/329300']);
assert.equal(
h.overlay.entries.get('fire-perimeters').length,
@@ -253,16 +280,16 @@ test('an unmatched incident renders no InciWeb line and card clicks stay inert',
cardHit: () => cardHitResult,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await h.layer.update();
h.click({ position: { x: 10, y: 10 } });
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(
entries[0].details.some((line) => line.includes('InciWeb')),
false,
);
cardHitResult = { entryId: entries[0].id };
h.viewer.scene.pick = () => null;
h.clicks.handler({ position: { x: 12, y: 12 } });
h.scene.pick = () => null;
h.click({ position: { x: 12, y: 12 } });
assert.deepEqual(h.opened, []);
assert.equal(h.overlay.entries.get('fire-perimeters').length, 1);
});
@@ -283,8 +310,8 @@ test('a complex member resolves to its complex page from the catalog', async ()
],
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await h.layer.update();
h.click({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries[0].details.at(-1), 'InciWeb ↗ · click card to open');
@@ -317,8 +344,8 @@ test('a stale or unverifiable publication yields no link', async () => {
publication,
},
);
await h.layer.update(h.viewer);
h.clicks.handler({ position: { x: 10, y: 10 } });
await h.layer.update();
h.click({ position: { x: 10, y: 10 } });
await new Promise((done) => setTimeout(done, 0));
const entries = h.overlay.entries.get('fire-perimeters');
assert.equal(entries.length, 1);
@@ -327,99 +354,47 @@ test('a stale or unverifiable publication yields no link', async () => {
false,
);
assert.equal(entries[0].interactive, false);
h.layer.destroy(h.viewer);
h.layer.destroy();
}
});
test('a hanging InciWeb index fetch never blocks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] }, { inciwebIndex: null });
// Replace the index source with one that never resolves.
h.layer.destroy(h.viewer);
h.layer.destroy();
const hanging = harness({ getSnapshot: async () => [row] });
hanging.layer.destroy(hanging.viewer);
const sources = [];
const viewer = {
scene: { pick: () => null },
dataSources: {
add: (v) => sources.push(v),
remove: (v) => sources.splice(sources.indexOf(v), 1),
},
};
const layer = createFirePerimetersLayer({
hanging.layer.destroy();
const layer = plainLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: { getIndex: () => new Promise(() => {}) },
inciwebPublications: {
getPublication: async () => ({ createdMs: 1, changedMs: 1 }),
},
openExternal: () => {},
});
layer.init(viewer);
layer.enable(viewer);
assert.equal(await layer.update(viewer), true);
assert.equal(await layer.update(), true);
assert.equal(layer.getStats().count, 1);
layer.destroy(viewer);
layer.destroy();
});
test('an InciWeb outage never breaks the perimeter refresh', async () => {
const h = harness({ getSnapshot: async () => [row] });
const failing = createFirePerimetersLayer({
const failing = plainLayer({
source: { getSnapshot: async () => [row] },
overlayHost: {
setEntries() {},
setVisible() {},
clearSource() {},
hitTest: () => null,
},
screenSpaceEventHandlerFactory: () => ({
setInputAction() {},
destroy() {},
}),
picking: {
resolvePickId: () => null,
isOwnedByOtherLayer: () => false,
registerPickOwner() {},
unregisterPickOwner() {},
},
pointer: { isPointerFree: () => true },
inciwebSource: {
getIndex: async () => {
throw new Error('InciWeb HTTP 503');
},
},
openExternal: () => {},
});
const viewer = {
scene: { pick: () => null },
dataSources: { add() {}, remove() {} },
};
failing.init(viewer);
failing.enable(viewer);
assert.equal(await failing.update(viewer), true);
assert.equal(await failing.update(), true);
assert.equal(failing.getStats().count, 1);
failing.destroy(viewer);
h.layer.destroy(h.viewer);
failing.destroy();
h.layer.destroy();
});
test('analyst records expose incident facts without geometry payloads', async () => {
const h = harness({ getSnapshot: async () => [row] });
await h.layer.update(h.viewer);
await h.layer.update();
const records = h.layer.getAnalystRecords();
assert.equal(records.length, 1);
assert.equal(records[0].name, 'Fixture Fire');
@@ -438,40 +413,34 @@ test('analyst records expose incident facts without geometry payloads', async ()
assert.equal('polygons' in records[0], false);
});
test('unchanged snapshots retain entity identity even if source ordering changes', async () => {
test('unchanged snapshots keep the drawn perimeters even if source ordering changes', async () => {
let rows = [row, { ...row, stableId: 'other' }];
const h = harness({ getSnapshot: async () => rows });
await h.layer.update();
const original = [...h.sources[0].entities.values];
let removals = 0;
const entities = h.sources[0].entities;
const removeAll = entities.removeAll.bind(entities);
entities.removeAll = () => {
removals++;
removeAll();
};
const original = h.zones();
const redraws = () => h.zones().commits - original.commits;
rows = [...rows].reverse();
await h.layer.update();
assert.equal(removals, 0);
assert.equal(entities.values[0], original[0]);
assert.equal(redraws(), 0);
assert.equal(h.zones().items, original.items);
rows = rows.map((value) => ({
...value,
updatedTime: value.updatedTime + 1,
}));
await h.layer.update();
assert.equal(removals, 1);
assert.notEqual(entities.values[0], original[0]);
assert.equal(redraws(), 1);
assert.notEqual(h.zones().items, original.items);
rows = rows.map((value) => ({ ...value, containedPct: 100 }));
await h.layer.update();
assert.equal(
removals,
redraws(),
2,
'incident facts can change without a new polygon timestamp',
);
assert.equal(h.layer.getRowControls().legend[3].count, 2);
rows = [];
await h.layer.update();
assert.equal(entities.values.length, 0);
assert.equal(h.zones().items.length, 0);
h.layer.destroy();
});
@@ -538,11 +507,11 @@ for (const action of ['disable', 'destroy', 'selection']) {
},
);
await h.layer.update();
h.clicks.handler({ position: { x: 1, y: 1 } });
h.click({ position: { x: 1, y: 1 } });
assert.equal(signal.aborted, false);
if (action === 'selection') {
pick = { id: 'fire-perimeter:other:0' };
h.clicks.handler({ position: { x: 1, y: 1 } });
h.click({ position: { x: 1, y: 1 } });
} else h.layer[action]();
assert.equal(signal.aborted, true);
const entries = h.overlay.entries.get('fire-perimeters');
@@ -560,11 +529,11 @@ test('a stale snapshot reads stale, dated by its own fetch, and a fresh one clea
getSnapshot: async () => snapshot.rows,
getSnapshotDetail: async () => snapshot,
});
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, true);
assert.equal(h.layer.getStats().lastUpdate, fetchedAt);
snapshot = { rows: [row], stale: false, fetchedAt: Date.now() };
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, false);
assert.ok(h.layer.getStats().lastUpdate > fetchedAt);
});
@@ -578,10 +547,10 @@ test('a failed refresh after a stale snapshot reads as the failure, not stale',
return { rows: [row], stale: true, fetchedAt: Date.now() - 60_000 };
},
});
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, true);
fail = true;
await h.layer.update(h.viewer);
await h.layer.update();
assert.equal(h.layer.getStats().stale, false);
assert.equal(h.layer.getStats().error, 'WFIGS HTTP 502');
});
@@ -603,19 +572,20 @@ test('a selected fire retains its card and changed polygon through a scene hando
);
try {
await h.layer.update();
h.clicks.handler({ position: { x: 0, y: 0 } });
h.click({ position: { x: 0, y: 0 } });
assert.equal(
h.overlay.entries.get('fire-perimeters')[0].id,
'fire-perimeter-card:incident:one',
);
h.layer.detachRenderer();
assert.equal(h.sources.length, 0);
assert.equal(h.ground.sets(), 0);
geometry = [[ring.map(([lon, lat]) => [lon + 0.1, lat])]];
assert.equal(await h.layer.update(), true);
const record = h.layer.getAnalystRecords()[0];
assert.equal(record.id, 'incident:one');
h.layer.attachRenderer(h.viewer);
assert.equal(h.sources[0].entities.values.length, 1);
h.layer.attachRenderer(null, { engine: h.engine });
assert.equal(h.zones().items.length, 2, 'the fill and its fire line');
assert.equal(h.zones().items[0].rings[0][0][0], ring[0][0] + 0.1);
assert.equal(
h.overlay.entries.get('fire-perimeters')[0].id,
'fire-perimeter-card:incident:one',
-20
View File
@@ -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),
});
}
+83
View File
@@ -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();
});
-46
View File
@@ -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();
},
};
}
-13
View File
@@ -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),
},
);
}
-77
View File
@@ -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