mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-09 21:02:19 +00:00
Add raster imagery to the globe contract and draw FIRMS heat and Recent Imagery through it
engine.imagery draws raster layers over the globe: Web Mercator tile templates and single images or canvases stretched over a rectangle, with alpha, show, a left/right split against the engine's split position and an order. A layer's source can be replaced in place. - Cesium puts one ImageryLayer per layer on the globe's imagery stack, or on the shown 3D tileset's when the globe is hidden, and moves it when the surface changes; the Recent Imagery request gate moved here. - Navara draws them in one screen pass after tone mapping that samples up to eight layers by geodetic position from mipmapped textures and a tile atlas, under the other drapes and engine.ground. - The fake engine keeps each layer's state. The FIRMS far-zoom heat raster and Recent Imagery now draw through it, so FIRMS' heat seam and both its backends, and Recent Imagery's Cesium and Navara renderers and tile plan, go. Against HEAD, Cesium's pixels are identical in all four compared scenes; Navara's orbital heat changes by a mean of 3.8 from new mipmapping, slightly towards Cesium. The contract suite passes on both engines (Cesium 68/68, Navara 71/71). engine.maps.addOverlay stays a Navara-only extension for Sea Marks and Ski Pistes until imagery has a finer tile path for oblique global views.
This commit is contained in:
1 parent
1559bd57f4
commit
406dd9122e
52 files changed
+4171
-1988
No files matched your search
+27
-7
@@ -183,8 +183,10 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
`node scripts/qa-contract-suite.mjs <origin>` runs them in headless Chrome
|
||||
on the real Cesium and Navara of a keyless dev server, with a real pointer
|
||||
and real canvases; checks of state the app drives itself (contact sonar)
|
||||
are skipped and named. On November 7, 2026 Cesium passed 66 of 66 and
|
||||
Navara 69 of 69. `movers` and `lines` are required of every engine: Cesium
|
||||
are skipped and named. On November 7, 2026, with the imagery checks,
|
||||
Cesium passed 68 of 68 and Navara 71 of 71 (the camera-events check now
|
||||
and then misses its moveEnd on a busy machine on either engine and passes
|
||||
when run again). `movers` and `lines` are required of every engine: Cesium
|
||||
draws them with billboards, points, models and polyline primitives
|
||||
(`src/globe/cesium/movers.js`, `lines.js`).
|
||||
- **Fills on the ground** (`engine.ground`) are required of every engine
|
||||
@@ -202,6 +204,26 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
perimeters have prototypes in `render/ground.js`.
|
||||
- On November 7, 2026, `scripts/qa-ground-suite.mjs` (the ground checks
|
||||
with pixels) passed 3 of 3 on both engines.
|
||||
- **Rasters on the ground** (`engine.imagery`) are required of every engine
|
||||
too: layers of Web Mercator tiles from a URL template or of one image (a
|
||||
canvas, an image, RGBA bytes or an image URL) over a rectangle, each with
|
||||
its alpha, show, split side and draw `order`, and one split position per
|
||||
engine (`docs/GLOBE-PRIMITIVES.md`).
|
||||
- Cesium draws each as an `ImageryLayer` on the imagery stack of the
|
||||
surface shown (the globe, or the shown 3D tileset's), and follows it
|
||||
(`src/globe/cesium/imagery.js`).
|
||||
- Navara draws them in one screen pass after tone mapping, classified on
|
||||
the scene depth (`src/globe/navara/imagery/`).
|
||||
- Active Fires' far-zoom heatmap and Recent Imagery draw through it;
|
||||
weather, wind and the Bhote Koshi event imagery do not yet.
|
||||
- `engine.maps.addOverlay` (Navara's native raster layers for Sea Marks
|
||||
and Ski Pistes) stays until those overlays move to `engine.imagery`.
|
||||
- On November 7, 2026, `scripts/qa-ground-suite.mjs --only=imagery:` (the
|
||||
imagery checks with pixels) passed 2 of 2 on both engines, and
|
||||
`scripts/qa-imagery-compare.mjs` found the heatmap and Recent Imagery
|
||||
drawn as before on Cesium (no pixel changed where they draw) and on
|
||||
Navara (no channel off by more than 1 of 255, but for the heatmap from
|
||||
orbit, which Navara now mipmaps as Cesium does).
|
||||
- **Engine coupling.** `node scripts/report-engine-coupling.mjs` counts the
|
||||
engine-specific code outside the engines: renderer lines per engine, Cesium
|
||||
imports, `viewer` mentions, engine-name checks, raw Navara reads, Navara
|
||||
@@ -233,11 +255,9 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
`src/layers/awareness/render/pulseDisc.js` (`render/cesiumPulses.js`, a
|
||||
material disc, on Cesium; `render/navaraPulses.js`, a surface pass that the
|
||||
Navara mode loads and passes in).
|
||||
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
|
||||
The Active Fires far-zoom heatmap is an `engine.imagery` layer, and Recent
|
||||
Imagery draws its days through `engine.imagery` from its own
|
||||
`render/display.js`. 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
|
||||
|
||||
+118
-3
@@ -1,13 +1,14 @@
|
||||
# Globe primitives
|
||||
|
||||
A layer draws through the globe-engine contract (`src/globe/contract.js`),
|
||||
not through Cesium or Navara. The contract has three drawing primitives:
|
||||
not through Cesium or Navara. The contract has four drawing primitives:
|
||||
|
||||
- `engine.movers`: things at points;
|
||||
- `engine.lines`: lines in the air;
|
||||
- `engine.ground`: fills on the ground.
|
||||
- `engine.ground`: fills on the ground;
|
||||
- `engine.imagery`: rasters on the ground.
|
||||
|
||||
Each engine implements all three. Cesium's are in `src/globe/cesium/`,
|
||||
Each engine implements all four. Cesium's are in `src/globe/cesium/`,
|
||||
Navara's in `src/globe/navara/`, and the fake engine's in
|
||||
`src/globe/testing/fakeEngine.js`. The checks every engine must pass are in
|
||||
`src/globe/testing/contractSuite.mjs`.
|
||||
@@ -211,6 +212,118 @@ take 135 ms. A layer with more lines than that (Street Level's up to 14 400
|
||||
sequences) needs a set-wide grid of bins first, as areas have, before it
|
||||
moves to `engine.ground`.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
`engine.imagery.createLayer({ id, source, alpha, show, split, order })`
|
||||
makes one raster layer draped over the terrain and the 3D tiles, under
|
||||
every ground fill, mover, line and model. Its colours are the image's own,
|
||||
blended by the texel's alpha times the layer's `alpha`.
|
||||
|
||||
There are two kinds of source:
|
||||
|
||||
| kind | source | for |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `tiles` | `url` with `{z}`, `{x}`, `{y}` (and `{s}` of `subdomains`), `minLevel`, `maxLevel`, `tileSize` | Recent Imagery's GIBS days, weather tiles, open overlays |
|
||||
| `image` | `image`: a canvas, an ImageBitmap or image element, or `{ width, height, data }` RGBA bytes; or `url` | the FIRMS heat raster, a field image, event imagery |
|
||||
|
||||
Both take:
|
||||
|
||||
- `rectangle` (`{ west, south, east, north }` in degrees; west > east
|
||||
crosses the antimeridian): where the layer draws, the globe when omitted;
|
||||
- `credit`: HTML shown while the layer is.
|
||||
|
||||
Tiles are Web Mercator XYZ tiles and stop at ±85.05°. `opaque` tiles have
|
||||
no alpha channel, and `maxRequests` caps the layer's tile requests in
|
||||
flight. An image is stretched over its rectangle in longitude and latitude,
|
||||
row 0 on the north edge; one over the whole globe wraps across 180°.
|
||||
|
||||
Work with a layer like this:
|
||||
|
||||
- `setSource(source)` draws another source in its place, at its place in
|
||||
the order; the same canvas or bytes again draw their new pixels;
|
||||
- `setAlpha(alpha)` fades it: at 0 it draws nothing but stays on its
|
||||
surface, so it comes back without a gap (the FIRMS hand-off);
|
||||
- `show(visible)` shows or hides it (Cesium unmaps a hidden layer's tiles);
|
||||
- `setSplit('left' | 'right' | 'none')` puts it on one side of the split;
|
||||
- `ready()` says the source is built (an image URL loaded); tiles arrive
|
||||
after it;
|
||||
- `destroy()`.
|
||||
|
||||
On the engine:
|
||||
|
||||
- `list()` names the layers in draw order, bottom first: a higher `order`
|
||||
draws on top, then the later layer;
|
||||
- `setSplitPosition(fraction)` sets where the split runs, a fraction of the
|
||||
canvas width from the left (one per engine, Cesium's
|
||||
`scene.splitPosition`); `splitPosition()` reads it;
|
||||
- `surface()` says where imagery drapes now: `'globe'`, `'tiles'` (3D tiles)
|
||||
or `'none'`.
|
||||
|
||||
### How each engine draws imagery
|
||||
|
||||
**Cesium** (`src/globe/cesium/imagery.js`) gives each layer one
|
||||
`ImageryLayer`:
|
||||
|
||||
- on the globe's imagery layers while the globe shows, else on the shown 3D
|
||||
tileset's own imagery layers (Cesium drapes imagery on tiles there), as
|
||||
`src/maps/imageryHost.js` resolves it for the shell; with neither, the
|
||||
layers wait;
|
||||
- the engine checks the surface on every frame while it holds a layer, and
|
||||
builds each layer again on the new stack after a switch;
|
||||
- above the base imagery, in order among the engine's layers; imagery other
|
||||
code adds stays where it was put;
|
||||
- tiles: a `UrlTemplateImageryProvider` in Web Mercator, bounded to the
|
||||
rectangle; `maxRequests` gates `requestImage`, and a request the gate or
|
||||
the provider defers asks for one delayed frame, never one per frame;
|
||||
- an image: one geographic tile over the rectangle (as
|
||||
`SingleTileImageryProvider`), handed to Cesium as the canvas or image
|
||||
itself, with no PNG round trip;
|
||||
- `split` is the layer's `splitDirection`; Cesium splits on the globe only.
|
||||
|
||||
**Navara** (`src/globe/navara/imagery/`) draws every shown layer in one
|
||||
screen pass right after tone mapping, under the other drapes and
|
||||
`engine.ground`:
|
||||
|
||||
- each pixel's ground position comes from the scene depth (terrain and 3D
|
||||
tiles), as `engine.ground`'s pass finds it;
|
||||
- up to 8 layers are sampled in draw order at its longitude and latitude,
|
||||
each in its rectangle and on its side of the split (the top 8 when more
|
||||
are shown);
|
||||
- an image is one mipmapped texture, sampled in longitude and latitude;
|
||||
- tiles are an atlas of at most 8×8 Web Mercator tiles over the part of the
|
||||
rectangle in view, at the level the camera's height asks for; it is
|
||||
rebuilt when the camera moves to another plan and filled as tiles arrive,
|
||||
6 loads at a time (or `maxRequests`);
|
||||
- texels are blended in display space, as Cesium's globe blends imagery;
|
||||
mip levels come from the derivatives of the ground position, taken
|
||||
before any branch, so rectangle edges and the antimeridian show no seams.
|
||||
|
||||
`engine.imagery` is the successor of `engine.maps.addOverlay`, Navara's
|
||||
undocumented extension for the open map overlays (Sea Marks, Ski Pistes).
|
||||
That one adds a native Navara raster layer: Navara's own per-tile levels on
|
||||
the terrain, lit like the base map, but no rectangle, split, alpha or order,
|
||||
and nothing on 3D tiles. It stays until those overlays move, which needs a
|
||||
finer Navara tiles path first: a global overlay seen obliquely gets coarse
|
||||
near the camera from one 8×8 atlas over the whole view.
|
||||
|
||||
### What is still to move
|
||||
|
||||
- **Weather radar, satellite clouds and lightning** (`src/layers/weather/`):
|
||||
tile templates in geographic and Web Mercator tilings, decoded canvases
|
||||
tiled into a provider, staged frames (a new frame loads under the shown
|
||||
one at alpha 0 and replaces it once its tiles are in), a per-tile image
|
||||
transform (the infrared mask), a detail window over the global image,
|
||||
and the raised shells Cesium draws over 3D tiles at a height. They need a
|
||||
geographic `tiles` scheme, a staged `setSource` (or `ready()` per tile
|
||||
set) and a tile transform; the raised shell is a separate primitive.
|
||||
- **The wind field** (`src/layers/wind/`): one image over the globe at a
|
||||
fixed alpha, faded by camera height — an `image` layer — and the same
|
||||
raised shell on 3D tiles.
|
||||
- **The Bhote Koshi event imagery** (`src/layers/bhoteKoshi/`, Cesium in
|
||||
`src/data/bhoteKoshiEvent.js`): two image URLs over one rectangle, split
|
||||
left and right, alpha 0.96 — two `image` layers with `url` and `split`.
|
||||
Navara still draws them with `src/layers/recentImagery/render/navaraGround.js`.
|
||||
|
||||
### Choosing a primitive
|
||||
|
||||
- **A position and an on-screen size:** a mover.
|
||||
@@ -224,3 +337,5 @@ moves to `engine.ground`.
|
||||
- Group items that change together into one set, and replace them in bulk.
|
||||
A ground set is rebuilt on every commit, so an animated fill (a pulse, a
|
||||
flowing route) is not a ground set yet.
|
||||
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
|
||||
Fade it with `setAlpha`, and give a new raster to `setSource`.
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"metrics": {
|
||||
"cesiumLayerRendererLines": 351,
|
||||
"navaraLayerRendererLines": 2767,
|
||||
"cesiumImportFiles": 32,
|
||||
"viewerLines": 1373,
|
||||
"navaraKindChecks": 7,
|
||||
"cesiumLayerRendererLines": 330,
|
||||
"navaraLayerRendererLines": 2304,
|
||||
"cesiumImportFiles": 31,
|
||||
"viewerLines": 1367,
|
||||
"navaraKindChecks": 6,
|
||||
"engineSniffCalls": 14,
|
||||
"engineExtensionReads": 15,
|
||||
"groundEffectPasses": 4,
|
||||
"engineExtensionReads": 12,
|
||||
"groundEffectPasses": 3,
|
||||
"cesiumSdkLoads": 4,
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 81
|
||||
"engineNameLines": 78
|
||||
},
|
||||
"measuredAt": "after the Street Level, traffic and directions displays, 2026-11-07"
|
||||
"measuredAt": "after engine.imagery with FIRMS heat and Recent Imagery on it, 2026-11-07"
|
||||
}
|
||||
@@ -937,7 +937,6 @@
|
||||
"src/layers/recentImagery/model.test.mjs",
|
||||
"src/layers/recentImagery/catalog.test.mjs",
|
||||
"src/maps/imageryHost.test.mjs",
|
||||
"src/layers/recentImagery/rendering.test.mjs",
|
||||
"src/layers/recentImagery/thumbnails.test.mjs",
|
||||
"src/layers/recentImagery/index.test.mjs",
|
||||
"src/ui/imageryBoxTool.test.mjs",
|
||||
@@ -1402,8 +1401,6 @@
|
||||
"scripts/fixtures/w2/radio.mjs",
|
||||
"scripts/qa-map-policy.mjs",
|
||||
"scripts/qa-map-policy.test.mjs",
|
||||
"src/layers/recentImagery/render/tiles.test.mjs",
|
||||
"src/layers/recentImagery/render/navara.test.mjs",
|
||||
"src/ui/imageryBoxNavara.test.mjs",
|
||||
"scripts/qa-w2-imagery.mjs",
|
||||
"src/globe/navara/postSamplingPass.test.mjs",
|
||||
@@ -1609,10 +1606,8 @@
|
||||
"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",
|
||||
@@ -1649,5 +1644,15 @@
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/traffic/render/display.test.mjs",
|
||||
"src/layers/directions/edgeCases.test.mjs",
|
||||
"src/layers/streetLevel/edgeCases.test.mjs"
|
||||
"src/layers/streetLevel/edgeCases.test.mjs",
|
||||
"scripts/qa-imagery-compare.mjs",
|
||||
"src/globe/cesium/imagery.js",
|
||||
"src/globe/cesium/imagery.test.mjs",
|
||||
"src/globe/imagery.js",
|
||||
"src/globe/imagery.test.mjs",
|
||||
"src/globe/navara/imagery/index.js",
|
||||
"src/globe/navara/imagery/index.test.mjs",
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/layers/recentImagery/render/display.test.mjs"
|
||||
]
|
||||
@@ -1601,7 +1601,6 @@
|
||||
"src/layers/recentImagery/source.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/i18n/index.js",
|
||||
@@ -1638,7 +1637,6 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -2680,7 +2678,6 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -3194,7 +3191,6 @@
|
||||
"src/layers/recentImagery/source.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/ui/recentImagery.js",
|
||||
@@ -3482,15 +3478,12 @@
|
||||
"src/layers/firms/rectangle.js",
|
||||
"src/layers/shared/render/navaraMoverBank.js",
|
||||
"src/layers/shared/render/moverBank.js",
|
||||
"src/layers/recentImagery/render/navara.js",
|
||||
"src/layers/recentImagery/render/navaraGround.js",
|
||||
"src/layers/recentImagery/render/tiles.js",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/alpr/overlay.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/radio/screenClusters.js",
|
||||
"src/layers/shared/render/imageAtlas.js",
|
||||
"src/layers/firms/render/navaraHeat.js",
|
||||
"src/layers/shared/render/navaraGroundSurface.js",
|
||||
"src/layers/shared/render/navaraGroundEffect.js",
|
||||
"src/annotations/outlineRungs.js",
|
||||
@@ -3570,8 +3563,6 @@
|
||||
"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",
|
||||
"src/layers/installations/render/style.js",
|
||||
"src/layers/awareness/render/cesiumPulses.js",
|
||||
@@ -3583,7 +3574,12 @@
|
||||
"src/layers/directions/render/style.js",
|
||||
"src/layers/directions/render/display.js",
|
||||
"src/layers/streetLevel/render/display.js",
|
||||
"src/layers/traffic/render/display.js"
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/globe/navara/imagery/pass.js",
|
||||
"src/globe/navara/imagery/index.js",
|
||||
"src/globe/imagery.js",
|
||||
"src/globe/cesium/imagery.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -3816,7 +3812,6 @@
|
||||
"src/layers/firms/policy.js",
|
||||
"src/layers/firms/queries.js",
|
||||
"src/layers/firms/rendering.js",
|
||||
"src/layers/firms/heatImagery.js",
|
||||
"src/layers/firms/selection.js",
|
||||
"src/layers/firms/source.js",
|
||||
"src/layers/firms/state.js",
|
||||
@@ -4102,7 +4097,6 @@
|
||||
"src/layers/recentImagery/source.js",
|
||||
"src/layers/recentImagery/model.js",
|
||||
"src/layers/recentImagery/catalog.js",
|
||||
"src/layers/recentImagery/rendering.js",
|
||||
"src/layers/recentImagery/thumbnails.js",
|
||||
"src/maps/imageryHost.js",
|
||||
"src/layers/streetLevel/index.js",
|
||||
@@ -4311,8 +4305,6 @@
|
||||
"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",
|
||||
"src/layers/infrastructure/render/display.js",
|
||||
"src/layers/installations/render/style.js",
|
||||
@@ -4324,7 +4316,8 @@
|
||||
"src/layers/directions/render/style.js",
|
||||
"src/layers/directions/render/display.js",
|
||||
"src/layers/streetLevel/render/display.js",
|
||||
"src/layers/traffic/render/display.js"
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/recentImagery/render/display.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Draw the rasters that moved to engine.imagery — the Active Fires far-zoom
|
||||
* heatmap and Recent Imagery — on both real engines, read the canvas back,
|
||||
* and compare two runs pixel by pixel (before and after a change), and the
|
||||
* two engines against each other. Run it against a keyless dev server of
|
||||
* each version, then compare:
|
||||
*
|
||||
* GOOGLE_MAPS_API_KEY= CESIUM_ION_TOKEN= npm run dev -- --port 4291
|
||||
* node scripts/qa-imagery-compare.mjs shoot http://localhost:4291 --out=DIR
|
||||
* node scripts/qa-imagery-compare.mjs compare BEFORE_DIR AFTER_DIR
|
||||
*
|
||||
* Every scene freezes the app clock at local noon (Navara lights the scene
|
||||
* with the app clock's sun) and waits for the map tiles. The heatmap is a
|
||||
* fixed set of fires through the layer's own raster builder; Recent Imagery
|
||||
* draws generated tiles (a gradient per tile, intercepted at qa-tiles.test)
|
||||
* through the layer's renderer from the registry, falling back to the
|
||||
* renderers of older versions. Each scene is shot with and without the
|
||||
* raster: `overlay` is where the raster changed the frame. Google and
|
||||
* Cesium ion are blocked, so no engine draws photoreal tiles.
|
||||
*/
|
||||
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 TILE_HOST = 'qa-tiles.test';
|
||||
const args = process.argv.slice(2);
|
||||
const [command, first, second] = args.filter((arg) => !arg.startsWith('--'));
|
||||
const option = (name) =>
|
||||
args.find((arg) => arg.startsWith(`--${name}=`))?.slice(name.length + 3);
|
||||
const SIZE = { width: 1280, height: 800 };
|
||||
|
||||
// Fires on three continents, a dense cluster and a sparse field each.
|
||||
const FIRES = [
|
||||
...Array.from({ length: 400 }, (_, i) => ({
|
||||
lat: 8 + (i % 20) * 0.08,
|
||||
lon: 18 + Math.floor(i / 20) * 0.09,
|
||||
frp: 40 + (i % 7) * 30,
|
||||
})),
|
||||
...Array.from({ length: 120 }, (_, i) => ({
|
||||
lat: -12 + (i % 12) * 0.9,
|
||||
lon: 22 + Math.floor(i / 12) * 1.1,
|
||||
frp: 8 + (i % 5) * 6,
|
||||
})),
|
||||
...Array.from({ length: 60 }, (_, i) => ({
|
||||
lat: 4 + (i % 6) * 0.5,
|
||||
lon: 30 + Math.floor(i / 6) * 0.4,
|
||||
frp: 200,
|
||||
})),
|
||||
];
|
||||
|
||||
const SCENES = [
|
||||
{
|
||||
id: 'heat-orbit',
|
||||
kind: 'heat',
|
||||
view: { lon: 22, lat: 2, height: 12_000_000 },
|
||||
noonUtc: 10.5,
|
||||
},
|
||||
{
|
||||
id: 'heat-regional',
|
||||
kind: 'heat',
|
||||
view: { lon: 19, lat: 8.8, height: 1_500_000 },
|
||||
noonUtc: 10.5,
|
||||
},
|
||||
{
|
||||
id: 'imagery-box',
|
||||
kind: 'imagery',
|
||||
view: { lon: -97.75, lat: 30.25, height: 16_000 },
|
||||
noonUtc: 18.5,
|
||||
slots: [{ id: 'a', product: 'S30', alpha: 1, split: 'none' }],
|
||||
},
|
||||
{
|
||||
id: 'imagery-swipe',
|
||||
kind: 'imagery',
|
||||
view: { lon: -97.75, lat: 30.25, height: 16_000 },
|
||||
noonUtc: 18.5,
|
||||
split: 0.5,
|
||||
slots: [
|
||||
{ id: 'a', product: 'S30', alpha: 0.7, split: 'left' },
|
||||
{ id: 'b', product: 'VIIRS', alpha: 1, split: 'right' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
|
||||
|
||||
// A 256 px tile: red across, green down, blue by the tile's parity; the
|
||||
// VIIRS product (JPEG on GIBS) is a darker variant.
|
||||
const tileCache = new Map();
|
||||
async function tile(z, x, y, dark) {
|
||||
const key = `${z}/${x}/${y}/${dark}`;
|
||||
if (tileCache.has(key)) return tileCache.get(key);
|
||||
const data = Buffer.alloc(256 * 256 * 4);
|
||||
const parity = (x + y) % 2 ? 200 : 40;
|
||||
for (let row = 0; row < 256; row++)
|
||||
for (let column = 0; column < 256; column++) {
|
||||
const i = (row * 256 + column) * 4;
|
||||
const scale = dark ? 0.6 : 1;
|
||||
data[i] = column * scale;
|
||||
data[i + 1] = row * scale;
|
||||
data[i + 2] = parity * scale;
|
||||
data[i + 3] = 255;
|
||||
}
|
||||
const png = await sharp(data, {
|
||||
raw: { width: 256, height: 256, channels: 4 },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
tileCache.set(key, png);
|
||||
return png;
|
||||
}
|
||||
|
||||
async function shootScene(browser, origin, kind, scene) {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport(SIZE);
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
await page.setRequestInterception(true);
|
||||
let tiles = 0;
|
||||
page.on('request', async (request) => {
|
||||
const url = new URL(request.url());
|
||||
if (url.hostname === TILE_HOST) {
|
||||
tiles++;
|
||||
const [, product, z, y, x] = url.pathname.match(
|
||||
/^\/(\w+)\/(\d+)\/(\d+)\/(\d+)\.png$/,
|
||||
);
|
||||
await request.respond({
|
||||
status: 200,
|
||||
contentType: 'image/png',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: await tile(+z, +x, +y, product === 'VIIRS'),
|
||||
});
|
||||
} else if (BLOCKED_HOSTS.test(url.hostname)) request.abort();
|
||||
else request.continue();
|
||||
});
|
||||
const { lon, lat, height } = scene.view;
|
||||
await page.goto(
|
||||
`${origin}/?welcome=0&engine=${kind}#v=2&lat=${lat}&lon=${lon}&alt=${height}&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,
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() => !window.__godsEyeView.engine.camera.flightActive(),
|
||||
{ timeout: 60_000, polling: 250 },
|
||||
);
|
||||
// The scene: the view, noon, and helpers the shots below use.
|
||||
await page.evaluate(
|
||||
async ({ scene, fires, box, tileHost }) => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const geo = await import('/src/geo/index.js');
|
||||
const { appClock } = await import('/src/time/clock.js');
|
||||
const noon = Date.UTC(2026, 10, 7) + scene.noonUtc * 3_600_000;
|
||||
appClock.freeze(noon);
|
||||
engine.camera.setView({
|
||||
destination: geo.cartesian3.fromDegrees(
|
||||
scene.view.lon,
|
||||
scene.view.lat,
|
||||
scene.view.height,
|
||||
),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
});
|
||||
// `count` frames, each asked for (a scene at rest draws only on request).
|
||||
const frames = (count) =>
|
||||
new Promise((resolve) => {
|
||||
let left = count;
|
||||
const remove = engine.frames.postRender.addEventListener(() => {
|
||||
if (--left > 0) {
|
||||
setTimeout(() => engine.render.request('qa-imagery'), 0);
|
||||
return;
|
||||
}
|
||||
remove();
|
||||
resolve();
|
||||
});
|
||||
engine.render.request('qa-imagery');
|
||||
});
|
||||
const { rendererFor } = await import('/src/app/layers/renderers.js');
|
||||
const qa = { frames, raster: null };
|
||||
window.__qaImagery = qa;
|
||||
if (scene.kind === 'heat') {
|
||||
const [{ buildHeatRaster }, { HEAT_RASTER }] = await Promise.all([
|
||||
import('/src/data/firmsHeatRaster.js'),
|
||||
import('/src/layers/firms/policy.js'),
|
||||
]);
|
||||
const display = rendererFor('local-firms', engine, { id: 'qa-firms' });
|
||||
const heat = display.createHeatImagery();
|
||||
const raster = buildHeatRaster(fires, HEAT_RASTER);
|
||||
qa.show = () => {
|
||||
heat.setRaster(raster);
|
||||
heat.setOpacity(1);
|
||||
heat.setShow(true);
|
||||
};
|
||||
qa.hide = () => heat.setShow(false);
|
||||
return;
|
||||
}
|
||||
const options = {
|
||||
tileTemplate: (product) =>
|
||||
`https://${tileHost}/${product}/{z}/{y}/{x}.png`,
|
||||
credit: 'QA tiles',
|
||||
};
|
||||
let renderer = rendererFor('recent-imagery', engine, options);
|
||||
const viewer = engine.extensions?.cesium?.viewer;
|
||||
if (!renderer) {
|
||||
// Versions before engine.imagery: Cesium's own renderer on the globe.
|
||||
const { createRecentImageryRenderer } =
|
||||
await import('/src/layers/recentImagery/rendering.js');
|
||||
renderer = createRecentImageryRenderer({
|
||||
...options,
|
||||
requestRender: () => engine.render.request('qa-imagery'),
|
||||
});
|
||||
renderer.rebind({ collection: viewer.imageryLayers, kind: 'globe' });
|
||||
} else renderer.rebind(renderer.resolveHost());
|
||||
const candidate = (product) => ({
|
||||
key: `${product}:2026-10-28`,
|
||||
product,
|
||||
day: '2026-10-28',
|
||||
});
|
||||
qa.show = () => {
|
||||
if (scene.split != null) {
|
||||
renderer.setSplit?.(scene.split);
|
||||
if (viewer) viewer.scene.splitPosition = scene.split;
|
||||
}
|
||||
for (const slot of scene.slots)
|
||||
renderer.showSlot(slot.id, candidate(slot.product), box, {
|
||||
alpha: slot.alpha,
|
||||
splitDirection: slot.split,
|
||||
});
|
||||
};
|
||||
qa.hide = () => {
|
||||
for (const slot of scene.slots) renderer.hideSlot(slot.id);
|
||||
};
|
||||
},
|
||||
{ scene, fires: FIRES, box: BOX, tileHost: TILE_HOST },
|
||||
);
|
||||
// Settle: the map tiles loaded, then tile requests quiet for 2 s.
|
||||
const settle = async () => {
|
||||
for (let round = 0; round < 120; round++) {
|
||||
const before = tiles;
|
||||
await page.evaluate(() => window.__qaImagery.frames(10));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const loaded = await page.evaluate(() =>
|
||||
window.__godsEyeView.engine.surface.tilesLoaded(),
|
||||
);
|
||||
if (loaded && tiles === before && round >= 4) break;
|
||||
}
|
||||
await page.evaluate(() => window.__qaImagery.frames(20));
|
||||
};
|
||||
const read = () =>
|
||||
page.evaluate(() => {
|
||||
const { engine } = window.__godsEyeView;
|
||||
const canvas = engine.host.canvas;
|
||||
const scratch = document.createElement('canvas');
|
||||
scratch.width = canvas.clientWidth;
|
||||
scratch.height = canvas.clientHeight;
|
||||
const context = scratch.getContext('2d');
|
||||
context.drawImage(canvas, 0, 0, scratch.width, scratch.height);
|
||||
return scratch.toDataURL('image/png');
|
||||
});
|
||||
await settle();
|
||||
const without = await read();
|
||||
await page.evaluate(() => window.__qaImagery.show());
|
||||
await settle();
|
||||
const withRaster = await read();
|
||||
await page.close();
|
||||
return { without, with: withRaster, errors };
|
||||
}
|
||||
|
||||
async function shoot(origin, out) {
|
||||
fs.mkdirSync(out, { recursive: true });
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
protocolTimeout: 600_000,
|
||||
args: ['--use-angle=metal', '--enable-gpu', '--no-sandbox'],
|
||||
});
|
||||
const engines = (option('engine') ?? 'cesium,navara').split(',');
|
||||
const only = option('only')?.split(',') ?? null;
|
||||
try {
|
||||
for (const kind of engines)
|
||||
for (const scene of SCENES) {
|
||||
if (only && !only.includes(scene.id)) continue;
|
||||
const shot = await shootScene(browser, origin, kind, scene);
|
||||
for (const name of ['without', 'with'])
|
||||
fs.writeFileSync(
|
||||
path.join(out, `${kind}-${scene.id}-${name}.png`),
|
||||
Buffer.from(shot[name].split(',')[1], 'base64'),
|
||||
);
|
||||
console.log(
|
||||
`${kind} ${scene.id}: shot${shot.errors.length ? ` (page errors: ${shot.errors.join('; ')})` : ''}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
const raw = async (file) =>
|
||||
(await sharp(file).ensureAlpha().raw().toBuffer({ resolveWithObject: true }))
|
||||
.data;
|
||||
|
||||
// Where the raster changed the frame (any channel by more than 6).
|
||||
function overlayMask(without, withRaster) {
|
||||
const mask = new Uint8Array(without.length / 4);
|
||||
for (let p = 0; p < mask.length; p++)
|
||||
for (let k = 0; k < 3; k++)
|
||||
if (Math.abs(without[p * 4 + k] - withRaster[p * 4 + k]) > 6) {
|
||||
mask[p] = 1;
|
||||
break;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
// Per-pixel differences of two frames inside `mask` (or everywhere).
|
||||
function difference(a, b, mask) {
|
||||
let count = 0,
|
||||
sum = 0,
|
||||
over8 = 0,
|
||||
over24 = 0,
|
||||
max = 0;
|
||||
for (let p = 0; p < a.length / 4; p++) {
|
||||
if (mask && !mask[p]) continue;
|
||||
let worst = 0;
|
||||
for (let k = 0; k < 3; k++)
|
||||
worst = Math.max(worst, Math.abs(a[p * 4 + k] - b[p * 4 + k]));
|
||||
count++;
|
||||
sum += worst;
|
||||
if (worst > 8) over8++;
|
||||
if (worst > 24) over24++;
|
||||
max = Math.max(max, worst);
|
||||
}
|
||||
return {
|
||||
pixels: count,
|
||||
meanMaxChannel: count ? +(sum / count).toFixed(2) : 0,
|
||||
over8: count ? +((100 * over8) / count).toFixed(2) : 0,
|
||||
over24: count ? +((100 * over24) / count).toFixed(2) : 0,
|
||||
max,
|
||||
};
|
||||
}
|
||||
|
||||
// The raster's own change of the frame: with minus without, per channel.
|
||||
function contribution(without, withRaster) {
|
||||
const out = new Int16Array(without.length);
|
||||
for (let i = 0; i < without.length; i++) out[i] = withRaster[i] - without[i];
|
||||
return out;
|
||||
}
|
||||
|
||||
async function compare(beforeDir, afterDir) {
|
||||
const report = {};
|
||||
const engines = ['cesium', 'navara'];
|
||||
for (const scene of SCENES) {
|
||||
const row = (report[scene.id] = {});
|
||||
const loaded = {};
|
||||
for (const engine of engines)
|
||||
for (const [version, dir] of [
|
||||
['before', beforeDir],
|
||||
['after', afterDir],
|
||||
])
|
||||
for (const name of ['without', 'with']) {
|
||||
const file = path.join(dir, `${engine}-${scene.id}-${name}.png`);
|
||||
if (fs.existsSync(file))
|
||||
loaded[`${engine}-${version}-${name}`] = await raw(file);
|
||||
}
|
||||
for (const engine of engines) {
|
||||
const before = loaded[`${engine}-before-with`];
|
||||
const after = loaded[`${engine}-after-with`];
|
||||
if (!before || !after) continue;
|
||||
const mask = overlayMask(loaded[`${engine}-before-without`], before);
|
||||
row[`${engine}: before vs after, where the raster draws`] = difference(
|
||||
before,
|
||||
after,
|
||||
mask,
|
||||
);
|
||||
row[`${engine}: before vs after, whole frame`] = difference(
|
||||
before,
|
||||
after,
|
||||
);
|
||||
row[`${engine}: base map before vs after`] = difference(
|
||||
loaded[`${engine}-before-without`],
|
||||
loaded[`${engine}-after-without`],
|
||||
);
|
||||
}
|
||||
// Navara against Cesium: the raster's change of each frame (the base
|
||||
// maps are drawn differently), where Cesium's raster draws.
|
||||
const reference = loaded['cesium-after-with'];
|
||||
const navara = loaded['navara-after-with'];
|
||||
if (reference && navara) {
|
||||
const mask = overlayMask(loaded['cesium-after-without'], reference);
|
||||
const a = contribution(loaded['cesium-after-without'], reference);
|
||||
const b = contribution(loaded['navara-after-without'], navara);
|
||||
const shift = (values) =>
|
||||
Uint8Array.from(values, (value) =>
|
||||
Math.max(0, Math.min(255, value + 128)),
|
||||
);
|
||||
row['navara vs cesium after: the raster’s change of the frame'] =
|
||||
difference(shift(a), shift(b), mask);
|
||||
row['navara vs cesium after: frames with the raster'] = difference(
|
||||
reference,
|
||||
navara,
|
||||
mask,
|
||||
);
|
||||
const beforeReference = loaded['cesium-before-with'];
|
||||
const beforeNavara = loaded['navara-before-with'];
|
||||
if (beforeReference && beforeNavara)
|
||||
row['navara vs cesium before: frames with the raster'] = difference(
|
||||
beforeReference,
|
||||
beforeNavara,
|
||||
overlayMask(loaded['cesium-before-without'], beforeReference),
|
||||
);
|
||||
}
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
if (command === 'shoot' && first) {
|
||||
await shoot(first, option('out') ?? 'qa-imagery');
|
||||
} else if (command === 'compare' && first && second) {
|
||||
const report = await compare(first, second);
|
||||
const json = option('json');
|
||||
if (json) fs.writeFileSync(json, `${JSON.stringify(report, null, 2)}\n`);
|
||||
for (const [scene, rows] of Object.entries(report)) {
|
||||
console.log(scene);
|
||||
for (const [label, stats] of Object.entries(rows))
|
||||
console.log(
|
||||
` ${label}: ${stats.pixels} px, mean ${stats.meanMaxChannel}, >8 ${stats.over8}%, >24 ${stats.over24}%, max ${stats.max}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
'Usage: node scripts/qa-imagery-compare.mjs shoot <origin> [--out=DIR] [--engine=cesium,navara] [--only=scene,...]\n' +
|
||||
' node scripts/qa-imagery-compare.mjs compare <beforeDir> <afterDir> [--json=FILE]',
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import { createRecentImageryLayer } from '../../layers/recentImagery/index.js';
|
||||
import { createRecentImageryRenderer } from '../../layers/recentImagery/rendering.js';
|
||||
import { createThumbnailLoader } from '../../layers/recentImagery/thumbnails.js';
|
||||
import { governorRequestRender } from '../../renderGovernor.js';
|
||||
import { rendererFor } from './renderers.js';
|
||||
|
||||
/** Construct imagery presentation over one explicitly supplied acquisition source. */
|
||||
/**
|
||||
* Construct imagery presentation over one explicitly supplied acquisition
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
const options = {
|
||||
requestRender: governorRequestRender,
|
||||
tileTemplate: (...args) => source.getTileTemplate(...args),
|
||||
credit: source.credit || source.label || 'Imagery',
|
||||
};
|
||||
return createRecentImageryLayer({
|
||||
catalog: { searchHls: (...args) => source.searchHls(...args) },
|
||||
rendererFactory: ({ engine }) =>
|
||||
engine?.kind === 'navara'
|
||||
? rendererFor('recent-imagery', engine, options)
|
||||
: createRecentImageryRenderer(options),
|
||||
rendererFor('recent-imagery', engine, options),
|
||||
thumbnails: createThumbnailLoader({
|
||||
requestThumbnail: (...args) => source.getThumbnail(...args),
|
||||
}),
|
||||
|
||||
@@ -52,6 +52,7 @@ import { createAwarenessDisplay } from '../../layers/awareness/render/display.js
|
||||
import { createStreetLevelDisplay } from '../../layers/streetLevel/render/display.js';
|
||||
import { createTrafficDisplay } from '../../layers/traffic/render/display.js';
|
||||
import { createDirectionsDisplay } from '../../layers/directions/render/display.js';
|
||||
import { createRecentImageryDisplay } from '../../layers/recentImagery/render/display.js';
|
||||
|
||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||
|
||||
@@ -139,6 +140,7 @@ export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
'street-level': createStreetLevelDisplay,
|
||||
traffic: createTrafficDisplay,
|
||||
directions: createDirectionsDisplay,
|
||||
'recent-imagery': createRecentImageryDisplay,
|
||||
});
|
||||
|
||||
// The other application layers still draw themselves on Cesium.
|
||||
|
||||
@@ -88,6 +88,7 @@ test('the layers with extracted renderers', () => {
|
||||
'street-level',
|
||||
'traffic',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -117,6 +118,8 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'street-level': ['streetLevel', 'createStreetLevelDisplay'],
|
||||
traffic: ['traffic', 'createTrafficDisplay'],
|
||||
directions: ['directions', 'createDirectionsDisplay'],
|
||||
'local-firms': ['firms', 'createFirmsDisplay'],
|
||||
'recent-imagery': ['recentImagery', 'createRecentImageryDisplay'],
|
||||
'telegeography-submarine-cables': [
|
||||
'submarineCables',
|
||||
'createCablesDisplay',
|
||||
|
||||
+5
-22
@@ -94,15 +94,9 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
*/
|
||||
export async function registerNavaraLayerRenderers() {
|
||||
const loaders = {
|
||||
'recent-imagery': async () => {
|
||||
const { createRecentImageryNavaraRenderer } =
|
||||
await import('../layers/recentImagery/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createRecentImageryNavaraRenderer(engine, {
|
||||
...options,
|
||||
...engine.extensions.navara,
|
||||
});
|
||||
},
|
||||
'recent-imagery': async () =>
|
||||
(await import('../layers/recentImagery/render/display.js'))
|
||||
.createRecentImageryDisplay,
|
||||
transit: async () =>
|
||||
(await import('../layers/transit/render/display.js'))
|
||||
.createTransitDisplay,
|
||||
@@ -198,19 +192,8 @@ export async function registerNavaraLayerRenderers() {
|
||||
'military-installations': async () =>
|
||||
(await import('../layers/installations/render/display.js'))
|
||||
.createInstallationsDisplay,
|
||||
// The FIRMS display, with the Navara backend of its heatmap.
|
||||
'local-firms': async () => {
|
||||
const [{ createFirmsDisplay }, { createFirmsHeatDrape }] =
|
||||
await Promise.all([
|
||||
import('../layers/firms/render/display.js'),
|
||||
import('../layers/firms/render/navaraHeat.js'),
|
||||
]);
|
||||
return (engine, options) =>
|
||||
createFirmsDisplay(engine, {
|
||||
...options,
|
||||
navaraHeat: createFirmsHeatDrape,
|
||||
});
|
||||
},
|
||||
'local-firms': async () =>
|
||||
(await import('../layers/firms/render/display.js')).createFirmsDisplay,
|
||||
'osm-places': async () =>
|
||||
(await import('../layers/osmPlaces/render/display.js'))
|
||||
.createOsmPlacesDisplay,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
blurGaussian,
|
||||
buildHeatRaster,
|
||||
} from './firmsHeatRaster.js';
|
||||
import { HEAT_RASTER } from '../layers/firms/policy.js';
|
||||
|
||||
const fire = (lat, lon, frp = 10) => ({ lat, lon, frp });
|
||||
|
||||
@@ -116,3 +117,26 @@ test('buildHeatRaster with no fires is fully transparent', () => {
|
||||
const r = buildHeatRaster([], { width: 8, height: 4, sigmaPx: 1 });
|
||||
assert.ok(r.rgba.every((v) => v === 0));
|
||||
});
|
||||
|
||||
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
|
||||
// A hot cluster elsewhere sets the scale, as Africa does on real data.
|
||||
const fires = [{ lat: 0, lon: 0, frp: 5 }];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
|
||||
}
|
||||
const r = buildHeatRaster(fires, HEAT_RASTER);
|
||||
const y = Math.floor((90 / 180) * r.height);
|
||||
const painted = [];
|
||||
for (let x = 0; x < r.width; x++) {
|
||||
const lon = ((x + 0.5) / r.width) * 360 - 180;
|
||||
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
|
||||
painted.push(x);
|
||||
}
|
||||
}
|
||||
const kmPerPx = 40075 / r.width;
|
||||
assert.ok(painted.length > 0, 'the weak fire still shows');
|
||||
assert.ok(
|
||||
painted.length * kmPerPx <= 100,
|
||||
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
|
||||
);
|
||||
});
|
||||
@@ -44,6 +44,13 @@ function makeFire(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/** The globe's imagery stack, shown: where the engine drapes the heatmap. */
|
||||
function globeImagery(viewer) {
|
||||
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
|
||||
viewer.scene.globe.show = true;
|
||||
return viewer.imageryLayers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand the layer up far enough to drive one click. `cardHit` is what the
|
||||
* overlay host reports under the click position — the real host only publishes
|
||||
@@ -543,28 +550,15 @@ test('the heatmap is preloaded under the markers before the first zoom-out', asy
|
||||
const h = harness({
|
||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 1_000_000),
|
||||
});
|
||||
const added = [];
|
||||
// The engine drapes the heatmap on the globe's own imagery.
|
||||
const added = globeImagery(h.viewer);
|
||||
try {
|
||||
h.layer.attachShellServices({
|
||||
imageryHost: () => ({
|
||||
kind: 'globe',
|
||||
collection: {
|
||||
addImageryProvider: (provider) => {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
added.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove() {},
|
||||
raiseToTop() {},
|
||||
},
|
||||
}),
|
||||
});
|
||||
h.layer._renderLodForTest('close');
|
||||
assert.equal(added.length, 0, 'the markers paint first; the build trails');
|
||||
t.mock.timers.tick(HEAT_REBUILD_MS);
|
||||
assert.equal(added.length, 1, 'then the heatmap is draped');
|
||||
assert.equal(added[0].alpha, 0, 'invisible until the hand-off');
|
||||
assert.equal(added[0].show, true, 'but mapped onto the globe tiles');
|
||||
assert.equal(added.get(0).alpha, 0, 'invisible until the hand-off');
|
||||
assert.equal(added.get(0).show, true, 'but mapped onto the globe tiles');
|
||||
} finally {
|
||||
h.cleanup();
|
||||
if (priorDocument === undefined) delete globalThis.document;
|
||||
@@ -596,8 +590,9 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke
|
||||
fires: [makeFire(), makeFire({ index: 8, lat: 30.6, lon: -98.3, frp: 12 })],
|
||||
cameraPosition: Cesium.Cartesian3.fromDegrees(-98, 30, 12_000_000),
|
||||
});
|
||||
const added = [];
|
||||
const added = globeImagery(h.viewer);
|
||||
const removed = [];
|
||||
added.layerRemoved.addEventListener((layer) => removed.push(layer));
|
||||
// The markers as drawn: how many the sprite set draws, and their alphas.
|
||||
const drawn = () => {
|
||||
const count =
|
||||
@@ -607,23 +602,12 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke
|
||||
return Array.from({ length: count }, (_, i) => markers.get(i).color.alpha);
|
||||
};
|
||||
try {
|
||||
const collection = {
|
||||
addImageryProvider: (provider) => {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
added.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove: (layer, destroy) => removed.push({ layer, destroy }),
|
||||
raiseToTop() {},
|
||||
};
|
||||
h.layer.attachShellServices({
|
||||
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);
|
||||
const heat = added.get(0);
|
||||
assert.equal(heat.show, true);
|
||||
assert.equal(heat.alpha, 1);
|
||||
assert.equal(h.layer._cellCountForTest(), 0, 'no ground cells');
|
||||
assert.ok(h.published.length > 0, 'the far band still paints cards');
|
||||
assert.deepEqual(drawn(), [], 'no marker drawn while the heatmap is whole');
|
||||
@@ -643,34 +627,33 @@ test('far zoom drapes one heatmap and the hand-off cross-fades it with the marke
|
||||
|
||||
h.layer._renderLodForTest('regional');
|
||||
assert.equal(added.length, 1, 'switching far bands reuses the raster');
|
||||
assert.equal(added.get(0), heat);
|
||||
|
||||
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(added.get(0), heat, 'the markers band keeps the same layer');
|
||||
assert.equal(heat.show, true, 'still mapped onto the globe tiles');
|
||||
assert.equal(heat.alpha, 0, 'faded out under the markers');
|
||||
assert.deepEqual(drawn(), [1, 1]);
|
||||
|
||||
// Mid hand-off both are drawn, half each, in either band.
|
||||
h.setHeight(4_500_000);
|
||||
h.layer._applyHandoffFadeForTest();
|
||||
await settle();
|
||||
assert.equal(added[0].alpha, 0.5);
|
||||
assert.equal(heat.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(heat.alpha, 0.5);
|
||||
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');
|
||||
assert.equal(heat.show, false, 'disable hides the heatmap');
|
||||
|
||||
h.layer.destroy();
|
||||
assert.deepEqual(
|
||||
removed.map((entry) => [entry.layer, entry.destroy]),
|
||||
[[added[0], true]],
|
||||
);
|
||||
assert.deepEqual(removed, [heat]);
|
||||
assert.equal(heat.isDestroyed(), true);
|
||||
} finally {
|
||||
h.cleanup();
|
||||
restoreDocument();
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
// engine clock's frame latch on viewer.clock.onTick (clock.js), attached at
|
||||
// creation so it runs before onTick listeners added later. Mover sets and
|
||||
// lines join the scene in one engine-owned collection each, with the first
|
||||
// set or line; ground sets join scene.groundPrimitives the same way.
|
||||
// set or line; ground sets join scene.groundPrimitives the same way, and
|
||||
// imagery layers the imagery stack of the shown surface.
|
||||
|
||||
import { createCesiumCamera } from './camera.js';
|
||||
import { createCesiumClock } from './clock.js';
|
||||
import { createCesiumCredits } from './credits.js';
|
||||
import { createCesiumFrames } from './frames.js';
|
||||
import { createCesiumGround } from './ground.js';
|
||||
import { createCesiumImagery } from './imagery.js';
|
||||
import { createCesiumInput } from './input.js';
|
||||
import { createCesiumLines } from './lines.js';
|
||||
import { createCesiumMaps } from './maps.js';
|
||||
@@ -33,9 +35,11 @@ import { createCesiumSurface } from './surface.js';
|
||||
* moverFactories?: object,
|
||||
* lineFactories?: object,
|
||||
* groundFactories?: object,
|
||||
* imageryFactories?: object,
|
||||
* }} [options] `requestRender` defaults to the render governor; `appClock`
|
||||
* to the page's app clock; the `create*`/`load*` options and the mover and
|
||||
* line and ground factories (movers.js, lines.js, ground.js) are test seams.
|
||||
* line, ground and imagery factories (movers.js, lines.js, ground.js,
|
||||
* imagery.js) are test seams.
|
||||
*
|
||||
* `render.setContinuous` takes a hold on the app's singleton renderGovernor
|
||||
* (src/renderGovernor.js), which governs the one viewer it was installed on;
|
||||
@@ -60,6 +64,11 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
requestRender,
|
||||
...(options.groundFactories ? { factories: options.groundFactories } : {}),
|
||||
});
|
||||
const maps = createCesiumMaps(viewer, {
|
||||
...options,
|
||||
credits: credits.credits,
|
||||
requestRender,
|
||||
});
|
||||
// Picks name the mover set, line or ground set they hit, with its pickId.
|
||||
const picker = createCesiumPicker(viewer, {
|
||||
identify: (picked) =>
|
||||
@@ -83,11 +92,15 @@ export function createCesiumEngine(viewer, options = {}) {
|
||||
}),
|
||||
input: createCesiumInput(viewer, { ...options, picker: picker.picker }),
|
||||
credits,
|
||||
maps: createCesiumMaps(viewer, {
|
||||
...options,
|
||||
credits: credits.credits,
|
||||
// Before the maps: its layers leave the tilesets before they go.
|
||||
imagery: createCesiumImagery(viewer, {
|
||||
requestRender,
|
||||
tilesets: maps.tilesets,
|
||||
...(options.imageryFactories
|
||||
? { factories: options.imageryFactories }
|
||||
: {}),
|
||||
}),
|
||||
maps,
|
||||
};
|
||||
let destroyed = false;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
// engine.imagery on Cesium: one ImageryLayer per layer, on the imagery
|
||||
// stack of the surface the map shows — the globe's imagery layers, or the
|
||||
// shown 3D tileset's own (Cesium drapes imagery on tiles there), as
|
||||
// src/maps/imageryHost.js resolves it for the shell. Layers sit above the
|
||||
// base imagery, in their order among the engine's layers (higher on top,
|
||||
// then creation); imagery added by others stays where it was put. A tiles
|
||||
// source is a UrlTemplateImageryProvider in Web Mercator, an image source a
|
||||
// one-tile provider over its rectangle (as SingleTileImageryProvider, but
|
||||
// from the canvas or image itself, without a PNG round trip). The engine
|
||||
// follows the surface on every frame while it holds a layer: on a switch
|
||||
// each layer is built again on the new stack, never re-parented.
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { createImageryRegistry, isPixelImage } from '../imagery.js';
|
||||
|
||||
const SPLIT = { none: 'NONE', left: 'LEFT', right: 'RIGHT' };
|
||||
const NO_HOST = Object.freeze({ collection: null, surface: 'none' });
|
||||
/** Delay before the one frame that retries tiles a provider deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
|
||||
const rectangleOf = ({ west, south, east, north }) =>
|
||||
Cesium.Rectangle.fromDegrees(west, south, east, north);
|
||||
|
||||
const defaultCanvas = (width, height) =>
|
||||
Object.assign(document.createElement('canvas'), { width, height });
|
||||
|
||||
// RGBA bytes on a canvas Cesium can upload.
|
||||
function drawable(image, createCanvas) {
|
||||
if (!isPixelImage(image)) return image;
|
||||
const canvas = createCanvas(image.width, image.height);
|
||||
const context = canvas.getContext('2d');
|
||||
const pixels = context.createImageData(image.width, image.height);
|
||||
pixels.data.set(image.data);
|
||||
context.putImageData(pixels, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// One image over `rectangle`: SingleTileImageryProvider's tiling (one
|
||||
// geographic tile at level 0), handing Cesium the image itself.
|
||||
function singleImageProvider(image, rectangle, credit) {
|
||||
const tilingScheme = new Cesium.GeographicTilingScheme({
|
||||
rectangle,
|
||||
numberOfLevelZeroTilesX: 1,
|
||||
numberOfLevelZeroTilesY: 1,
|
||||
});
|
||||
return {
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: image.width,
|
||||
tileHeight: image.height,
|
||||
minimumLevel: 0,
|
||||
maximumLevel: 0,
|
||||
tileDiscardPolicy: undefined,
|
||||
credit: credit ? new Cesium.Credit(credit) : undefined,
|
||||
errorEvent: new Cesium.Event(),
|
||||
hasAlphaChannel: true,
|
||||
proxy: undefined,
|
||||
getTileCredits: () => undefined,
|
||||
pickFeatures: () => undefined,
|
||||
// ImageryLayer consumes requestImage results as promises.
|
||||
requestImage: () => Promise.resolve(image),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate a provider's `requestImage` to `limit` requests in flight. Returning
|
||||
* `undefined` tells Cesium to retry on a later frame, but under
|
||||
* `requestRenderMode` there is no later frame unless something asks — and a
|
||||
* deferral must never ask for one itself: the retried tile would defer again
|
||||
* and ask again, keeping the scene rendering every frame. So a request that
|
||||
* finds the gate full asks for nothing (the request holding the slot asks
|
||||
* for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through one coalesced delayed retry.
|
||||
*/
|
||||
export function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
admission.inFlight += 1;
|
||||
const release = () => {
|
||||
admission.inFlight -= 1;
|
||||
};
|
||||
let promise;
|
||||
try {
|
||||
promise = original(x, y, level, request);
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
if (!promise) {
|
||||
release();
|
||||
retry.schedule();
|
||||
return undefined;
|
||||
}
|
||||
return Promise.resolve(promise).finally(() => {
|
||||
release();
|
||||
requestRender('imagery-tiles');
|
||||
});
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
const defaultFactories = Object.freeze({
|
||||
tiles: (options) => new Cesium.UrlTemplateImageryProvider(options),
|
||||
layer: (provider, options) => new Cesium.ImageryLayer(provider, options),
|
||||
loadImage: (url) => Cesium.Resource.fetchImage({ url }),
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {Cesium.Viewer} viewer
|
||||
* @param {{ requestRender?: (reason: string) => void,
|
||||
* tilesets?: () => object[], createCanvas?: (width: number, height: number) => object,
|
||||
* factories?: object, setTimeout?: Function, clearTimeout?: Function }} [options]
|
||||
* `tilesets` are the 3D tilesets the map may show (maps.js); a shown one
|
||||
* with imagery layers hosts the imagery while the globe is hidden.
|
||||
* `createCanvas`, `factories` (the tile provider, the layer, the image
|
||||
* load) and the timers are test seams.
|
||||
* @returns {{ imagery: import('../contract.js').GlobeImagery, destroy: () => void }}
|
||||
*/
|
||||
export function createCesiumImagery(
|
||||
viewer,
|
||||
{
|
||||
requestRender = () => viewer.scene.requestRender(),
|
||||
tilesets = () => [],
|
||||
createCanvas = defaultCanvas,
|
||||
factories = {},
|
||||
setTimeout: after = (run, ms) => globalThis.setTimeout(run, ms),
|
||||
clearTimeout: cancel = (id) => globalThis.clearTimeout(id),
|
||||
} = {},
|
||||
) {
|
||||
const make = { ...defaultFactories, ...factories };
|
||||
const { scene } = viewer;
|
||||
const entries = new Set();
|
||||
let removeWatch = null;
|
||||
|
||||
function host() {
|
||||
if (scene.globe?.show === true && viewer.imageryLayers)
|
||||
return { collection: viewer.imageryLayers, surface: 'globe' };
|
||||
const tileset = tilesets().find(
|
||||
(candidate) =>
|
||||
candidate?.show === true &&
|
||||
candidate.imageryLayers &&
|
||||
!candidate.isDestroyed?.(),
|
||||
);
|
||||
return tileset
|
||||
? { collection: tileset.imageryLayers, surface: 'tiles' }
|
||||
: NO_HOST;
|
||||
}
|
||||
|
||||
const usable = (collection) => collection && !collection.isDestroyed?.();
|
||||
function detach(entry) {
|
||||
const { layer, collection } = entry;
|
||||
entry.layer = null;
|
||||
entry.collection = null;
|
||||
if (!layer) return;
|
||||
if (usable(collection) && collection.contains(layer))
|
||||
collection.remove(layer, true);
|
||||
else if (!layer.isDestroyed?.()) layer.destroy?.();
|
||||
}
|
||||
|
||||
// Above every engine layer under it in draw order, below every one over
|
||||
// it; at the top when none is over it.
|
||||
function place(entry, layer, collection) {
|
||||
let index = collection.length;
|
||||
for (const other of entries) {
|
||||
if (other === entry || other.collection !== collection || !other.layer)
|
||||
continue;
|
||||
const over =
|
||||
other.state.options.order > entry.state.options.order ||
|
||||
(other.state.options.order === entry.state.options.order &&
|
||||
other.state.seq > entry.state.seq);
|
||||
if (over) index = Math.min(index, collection.indexOf(other.layer));
|
||||
}
|
||||
collection.add(layer, index);
|
||||
}
|
||||
|
||||
function providerFor(entry, source) {
|
||||
if (source.kind === 'tiles') {
|
||||
const provider = make.tiles({
|
||||
url: source.url,
|
||||
subdomains: [...source.subdomains],
|
||||
minimumLevel: source.minLevel,
|
||||
maximumLevel: source.maxLevel,
|
||||
tileWidth: source.tileSize,
|
||||
tileHeight: source.tileSize,
|
||||
tilingScheme: new Cesium.WebMercatorTilingScheme(),
|
||||
rectangle: rectangleOf(source.rectangle),
|
||||
hasAlphaChannel: !source.opaque,
|
||||
enablePickFeatures: false,
|
||||
...(source.credit ? { credit: source.credit } : {}),
|
||||
});
|
||||
return source.maxRequests
|
||||
? admitTileRequests(
|
||||
provider,
|
||||
{ limit: source.maxRequests, inFlight: 0 },
|
||||
requestRender,
|
||||
entry.retry,
|
||||
)
|
||||
: provider;
|
||||
}
|
||||
return singleImageProvider(
|
||||
drawable(entry.image, createCanvas),
|
||||
rectangleOf(source.rectangle),
|
||||
source.credit,
|
||||
);
|
||||
}
|
||||
|
||||
function applyLook(entry) {
|
||||
const { layer, state } = entry;
|
||||
if (!layer) return;
|
||||
layer.alpha = state.alpha;
|
||||
layer.show = state.shown;
|
||||
layer.splitDirection =
|
||||
Cesium.SplitDirection?.[SPLIT[state.split]] ?? layer.splitDirection;
|
||||
}
|
||||
|
||||
// The layer for the current source on the current host, in place of the
|
||||
// one drawn (at its index, so a new source keeps its stack position).
|
||||
function mount(entry) {
|
||||
const { collection } = host();
|
||||
const source = entry.state.source;
|
||||
if (!usable(collection) || (source.kind === 'image' && !entry.image)) {
|
||||
detach(entry);
|
||||
return;
|
||||
}
|
||||
const layer = make.layer(providerFor(entry, source), {
|
||||
alpha: entry.state.alpha,
|
||||
show: entry.state.shown,
|
||||
});
|
||||
const previous = entry.layer;
|
||||
if (previous && entry.collection === collection) {
|
||||
collection.add(layer, collection.indexOf(previous));
|
||||
collection.remove(previous, true);
|
||||
} else {
|
||||
detach(entry);
|
||||
place(entry, layer, collection);
|
||||
}
|
||||
entry.layer = layer;
|
||||
entry.collection = collection;
|
||||
applyLook(entry);
|
||||
requestRender('imagery');
|
||||
}
|
||||
|
||||
// An image source: its image now, or once its URL has loaded.
|
||||
function load(entry) {
|
||||
const source = entry.state.source;
|
||||
entry.loading = null;
|
||||
if (source.kind !== 'image' || source.image) {
|
||||
entry.image = source.image;
|
||||
mount(entry);
|
||||
return;
|
||||
}
|
||||
entry.image = null;
|
||||
const pending = Promise.resolve(make.loadImage(source.url));
|
||||
entry.loading = pending;
|
||||
pending.then(
|
||||
(image) => {
|
||||
if (entry.loading !== pending || entry.state.destroyed) return;
|
||||
entry.loading = null;
|
||||
entry.image = image;
|
||||
mount(entry);
|
||||
},
|
||||
(error) => {
|
||||
if (entry.loading !== pending) return;
|
||||
entry.loading = null;
|
||||
console.warn('[Imagery] image failed to load:', error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Follow the surface: a layer whose stack is not the current one is
|
||||
// built again there (none: it waits without a layer).
|
||||
const follow = () => {
|
||||
const { collection } = host();
|
||||
for (const entry of entries)
|
||||
if (entry.collection !== collection && (entry.layer || collection))
|
||||
mount(entry);
|
||||
};
|
||||
const watch = () => {
|
||||
if (!removeWatch && scene.preRender)
|
||||
removeWatch = scene.preRender.addEventListener(follow);
|
||||
};
|
||||
const unwatch = () => {
|
||||
if (entries.size || !removeWatch) return;
|
||||
removeWatch();
|
||||
removeWatch = null;
|
||||
};
|
||||
|
||||
const registry = createImageryRegistry(
|
||||
(state) => {
|
||||
let retryTimer = null;
|
||||
const entry = {
|
||||
state,
|
||||
layer: null,
|
||||
collection: null,
|
||||
image: null,
|
||||
loading: null,
|
||||
// One timer per layer: any number of provider deferrals while it is
|
||||
// armed share the same single retry frame.
|
||||
retry: {
|
||||
schedule() {
|
||||
if (retryTimer !== null) return;
|
||||
retryTimer = after(() => {
|
||||
retryTimer = null;
|
||||
if (!state.destroyed) requestRender('imagery-retry');
|
||||
}, TILE_RETRY_DELAY_MS);
|
||||
},
|
||||
cancel() {
|
||||
if (retryTimer !== null) cancel(retryTimer);
|
||||
retryTimer = null;
|
||||
},
|
||||
},
|
||||
};
|
||||
entries.add(entry);
|
||||
watch();
|
||||
load(entry);
|
||||
return {
|
||||
source: () => load(entry),
|
||||
look() {
|
||||
applyLook(entry);
|
||||
requestRender('imagery-look');
|
||||
},
|
||||
ready: () => Boolean(entry.layer?.ready),
|
||||
destroy() {
|
||||
entries.delete(entry);
|
||||
entry.loading = null;
|
||||
entry.retry.cancel();
|
||||
detach(entry);
|
||||
unwatch();
|
||||
requestRender('imagery-destroy');
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
// The scene's own: the shell's swipe controls set it too.
|
||||
readSplit: () =>
|
||||
Number.isFinite(scene.splitPosition) ? scene.splitPosition : 0.5,
|
||||
splitPosition(fraction) {
|
||||
scene.splitPosition = fraction;
|
||||
requestRender('imagery-split');
|
||||
},
|
||||
surface: () => host().surface,
|
||||
},
|
||||
);
|
||||
return {
|
||||
imagery: registry.imagery,
|
||||
destroy: () => registry.destroyAll(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { TILE_RETRY_DELAY_MS, createCesiumImagery } from './imagery.js';
|
||||
|
||||
const BOX = { west: -97.8, south: 30.2, east: -97.7, north: 30.3 };
|
||||
const tiles = (extra = {}) => ({
|
||||
kind: 'tiles',
|
||||
url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
|
||||
maxLevel: 12,
|
||||
rectangle: BOX,
|
||||
...extra,
|
||||
});
|
||||
const pixels = (width = 4, height = 2) => ({
|
||||
width,
|
||||
height,
|
||||
data: new Uint8ClampedArray(width * height * 4),
|
||||
});
|
||||
|
||||
/** A tile provider whose requests stay pending until the test settles them. */
|
||||
class PendingProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
// What a Cesium ImageryLayer reads of its provider.
|
||||
this.tilingScheme =
|
||||
options.tilingScheme ?? new Cesium.WebMercatorTilingScheme();
|
||||
this.rectangle = options.rectangle ?? this.tilingScheme.rectangle;
|
||||
this.tileWidth = this.tileHeight = 256;
|
||||
this.minimumLevel = 0;
|
||||
this.maximumLevel = options.maximumLevel ?? 18;
|
||||
this.errorEvent = new Cesium.Event();
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function manualTimers() {
|
||||
const pending = new Map();
|
||||
let next = 1;
|
||||
return {
|
||||
pending,
|
||||
setTimeout(run, ms) {
|
||||
const id = next++;
|
||||
pending.set(id, { run, ms });
|
||||
return id;
|
||||
},
|
||||
clearTimeout: (id) => pending.delete(id),
|
||||
flush() {
|
||||
const entries = [...pending.values()];
|
||||
pending.clear();
|
||||
for (const entry of entries) entry.run();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture({ requestImage } = {}) {
|
||||
const preRender = new Cesium.Event();
|
||||
const viewer = {
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
scene: { globe: { show: true }, preRender, splitPosition: 0.5 },
|
||||
};
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const tileset = {
|
||||
show: false,
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
isDestroyed: () => false,
|
||||
};
|
||||
const providers = [];
|
||||
const { imagery, destroy } = createCesiumImagery(viewer, {
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
tilesets: () => [tileset],
|
||||
createCanvas: (width, height) => ({
|
||||
width,
|
||||
height,
|
||||
getContext: () => ({
|
||||
createImageData: (w, h) => ({ data: new Uint8ClampedArray(w * h * 4) }),
|
||||
putImageData() {},
|
||||
}),
|
||||
}),
|
||||
factories: {
|
||||
tiles(options) {
|
||||
const provider = new PendingProvider(options);
|
||||
if (requestImage) provider.requestImage = requestImage;
|
||||
providers.push(provider);
|
||||
return provider;
|
||||
},
|
||||
},
|
||||
setTimeout: timers.setTimeout,
|
||||
clearTimeout: timers.clearTimeout,
|
||||
});
|
||||
const globe = viewer.imageryLayers;
|
||||
const frame = () => preRender.raiseEvent();
|
||||
return {
|
||||
viewer,
|
||||
imagery,
|
||||
destroy,
|
||||
globe,
|
||||
tileset,
|
||||
providers,
|
||||
renders,
|
||||
timers,
|
||||
frame,
|
||||
tileFrames: () => renders.filter((r) => r === 'imagery-tiles').length,
|
||||
};
|
||||
}
|
||||
|
||||
const layersOf = (collection) =>
|
||||
Array.from({ length: collection.length }, (_, i) => collection.get(i));
|
||||
|
||||
test('a tiles layer is one Web Mercator template provider bounded to its rectangle', () => {
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({
|
||||
id: 'a',
|
||||
source: tiles({ credit: 'NASA GIBS', opaque: true, maxLevel: 9 }),
|
||||
alpha: 0.8,
|
||||
split: 'left',
|
||||
});
|
||||
const [drawn] = layersOf(f.globe);
|
||||
const { options } = drawn.imageryProvider;
|
||||
assert.equal(options.url, 'https://gibs-{s}.example/{z}/{y}/{x}.png');
|
||||
assert.deepEqual(options.subdomains, ['a', 'b', 'c']);
|
||||
assert.equal(options.maximumLevel, 9);
|
||||
assert.equal(options.hasAlphaChannel, false, 'opaque tiles have no alpha');
|
||||
assert.equal(options.credit, 'NASA GIBS');
|
||||
assert.ok(options.tilingScheme instanceof Cesium.WebMercatorTilingScheme);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.equalsEpsilon(
|
||||
options.rectangle,
|
||||
Cesium.Rectangle.fromDegrees(-97.8, 30.2, -97.7, 30.3),
|
||||
1e-12,
|
||||
),
|
||||
);
|
||||
assert.equal(drawn.alpha, 0.8);
|
||||
assert.equal(drawn.splitDirection, Cesium.SplitDirection.LEFT);
|
||||
assert.equal(layer.ready(), true);
|
||||
layer.setAlpha(0.25);
|
||||
layer.show(false);
|
||||
layer.setSplit('right');
|
||||
assert.deepEqual(layersOf(f.globe), [drawn], 'a restyle keeps the layer');
|
||||
assert.deepEqual(
|
||||
[drawn.alpha, drawn.show, drawn.splitDirection],
|
||||
[0.25, false, Cesium.SplitDirection.RIGHT],
|
||||
);
|
||||
// A new source replaces the layer at its place, keeping the look.
|
||||
f.imagery.createLayer({ id: 'over', source: tiles(), order: 1 });
|
||||
layer.setSource(tiles({ url: 'https://other.example/{z}/{x}/{y}.png' }));
|
||||
const [replaced, over] = layersOf(f.globe);
|
||||
assert.equal(drawn.isDestroyed(), true);
|
||||
assert.equal(
|
||||
replaced.imageryProvider.options.url,
|
||||
'https://other.example/{z}/{x}/{y}.png',
|
||||
);
|
||||
assert.equal(over.imageryProvider.options.url, tiles().url);
|
||||
assert.deepEqual([replaced.alpha, replaced.show], [0.25, false]);
|
||||
f.destroy();
|
||||
assert.equal(f.globe.length, 0);
|
||||
});
|
||||
|
||||
test('an image layer is one geographic tile of the image itself over its rectangle', async () => {
|
||||
const f = fixture();
|
||||
const image = pixels(8, 4);
|
||||
f.imagery.createLayer({ id: 'heat', source: { kind: 'image', image } });
|
||||
const provider = f.globe.get(0).imageryProvider;
|
||||
assert.ok(provider.tilingScheme instanceof Cesium.GeographicTilingScheme);
|
||||
assert.deepEqual(
|
||||
[
|
||||
provider.tileWidth,
|
||||
provider.tileHeight,
|
||||
provider.maximumLevel,
|
||||
provider.tilingScheme.getNumberOfXTilesAtLevel(0),
|
||||
provider.tilingScheme.getNumberOfYTilesAtLevel(0),
|
||||
],
|
||||
[8, 4, 0, 1, 1],
|
||||
);
|
||||
assert.ok(
|
||||
Cesium.Rectangle.equalsEpsilon(
|
||||
provider.rectangle,
|
||||
Cesium.Rectangle.fromDegrees(-180, -90, 180, 90),
|
||||
1e-12,
|
||||
),
|
||||
);
|
||||
const drawn = await provider.requestImage(0, 0, 0);
|
||||
assert.equal(drawn.width, 8, 'RGBA bytes go on a canvas');
|
||||
assert.equal(typeof drawn.getContext, 'function');
|
||||
});
|
||||
|
||||
test('layers sit over the base imagery in their order, then creation; others stay put', () => {
|
||||
const f = fixture();
|
||||
const base = new Cesium.ImageryLayer(new PendingProvider({}));
|
||||
f.globe.add(base);
|
||||
f.imagery.createLayer({ id: 'high', source: tiles(), order: 5 });
|
||||
f.imagery.createLayer({ id: 'low', source: tiles(), order: -1 });
|
||||
f.imagery.createLayer({ id: 'mid', source: tiles() });
|
||||
f.imagery.createLayer({ id: 'mid-later', source: tiles() });
|
||||
assert.deepEqual(
|
||||
f.imagery.list().map(({ id }) => id),
|
||||
['low', 'mid', 'mid-later', 'high'],
|
||||
);
|
||||
const layers = layersOf(f.globe);
|
||||
assert.equal(layers[0], base);
|
||||
assert.equal(layers.length, 5);
|
||||
// Each engine layer's provider was made in creation order.
|
||||
const created = ['high', 'low', 'mid', 'mid-later'];
|
||||
const drawnOrder = layers
|
||||
.slice(1)
|
||||
.map((layer) => created[f.providers.indexOf(layer.imageryProvider)]);
|
||||
assert.deepEqual(drawnOrder, ['low', 'mid', 'mid-later', 'high']);
|
||||
});
|
||||
|
||||
test('the layers follow the surface: the globe, a shown tileset, none, and back', () => {
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({
|
||||
id: 'a',
|
||||
source: tiles(),
|
||||
alpha: 0.6,
|
||||
split: 'left',
|
||||
});
|
||||
assert.equal(f.imagery.surface(), 'globe');
|
||||
f.viewer.scene.globe.show = false;
|
||||
f.tileset.show = true;
|
||||
assert.equal(f.imagery.surface(), 'tiles');
|
||||
f.frame();
|
||||
assert.equal(f.globe.length, 0, 'the globe stack is left empty');
|
||||
const [onTiles] = layersOf(f.tileset.imageryLayers);
|
||||
assert.deepEqual(
|
||||
[onTiles.alpha, onTiles.splitDirection],
|
||||
[0.6, Cesium.SplitDirection.LEFT],
|
||||
);
|
||||
f.tileset.show = false;
|
||||
assert.equal(f.imagery.surface(), 'none');
|
||||
f.frame();
|
||||
assert.equal(f.tileset.imageryLayers.length, 0);
|
||||
assert.equal(layer.ready(), false, 'no surface: nothing drawn');
|
||||
f.viewer.scene.globe.show = true;
|
||||
f.frame();
|
||||
assert.equal(f.globe.length, 1);
|
||||
assert.equal(layer.ready(), true);
|
||||
layer.destroy();
|
||||
assert.equal(f.globe.length, 0);
|
||||
assert.equal(f.viewer.scene.preRender.numberOfListeners, 0, 'unwatched');
|
||||
});
|
||||
|
||||
test('the split position is the scene’s, set by the engine or the shell', () => {
|
||||
const f = fixture();
|
||||
assert.equal(f.imagery.splitPosition(), 0.5);
|
||||
f.imagery.setSplitPosition(0.3);
|
||||
assert.equal(f.viewer.scene.splitPosition, 0.3);
|
||||
// The shell's swipe writes the scene too: the engine reads it.
|
||||
f.viewer.scene.splitPosition = 0.7;
|
||||
assert.equal(f.imagery.splitPosition(), 0.7);
|
||||
f.imagery.setSplitPosition(0.3);
|
||||
assert.equal(f.viewer.scene.splitPosition, 0.3);
|
||||
});
|
||||
|
||||
test('above maxRequests a tile request defers silently; its settlement asks for one frame', async () => {
|
||||
const f = fixture();
|
||||
f.imagery.createLayer({ id: 'a', source: tiles({ maxRequests: 1 }) });
|
||||
const provider = f.globe.get(0).imageryProvider;
|
||||
const first = provider.requestImage(0, 0, 1);
|
||||
assert.ok(first instanceof Promise);
|
||||
for (let i = 1; i <= 120; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 1), undefined);
|
||||
assert.equal(
|
||||
f.providers[0].calls.length,
|
||||
1,
|
||||
'deferred requests never go out',
|
||||
);
|
||||
assert.equal(f.tileFrames(), 0, 'a deferral asks for no frame');
|
||||
assert.equal(f.timers.pending.size, 0);
|
||||
f.providers[0].pending[0]('tile');
|
||||
await first;
|
||||
assert.equal(f.tileFrames(), 1, 'the settlement asks for the retry frame');
|
||||
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
|
||||
});
|
||||
|
||||
test('provider deferrals share one delayed retry frame per layer; destroy disarms it', () => {
|
||||
let throwNext = false;
|
||||
const f = fixture({
|
||||
requestImage() {
|
||||
if (throwNext) throw new Error('boom');
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const layer = f.imagery.createLayer({
|
||||
id: 'a',
|
||||
source: tiles({ maxRequests: 1 }),
|
||||
});
|
||||
const provider = f.globe.get(0).imageryProvider;
|
||||
for (let i = 0; i < 40; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 0), undefined);
|
||||
assert.equal(f.timers.pending.size, 1);
|
||||
assert.equal([...f.timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
|
||||
f.timers.flush();
|
||||
assert.equal(
|
||||
f.renders.filter((reason) => reason === 'imagery-retry').length,
|
||||
1,
|
||||
);
|
||||
// A throwing provider releases its slot too.
|
||||
throwNext = true;
|
||||
assert.throws(() => provider.requestImage(0, 0, 0), /boom/);
|
||||
throwNext = false;
|
||||
provider.requestImage(0, 0, 0);
|
||||
assert.equal(f.timers.pending.size, 1, 'the next deferral arms a timer');
|
||||
layer.destroy();
|
||||
assert.equal(f.timers.pending.size, 0, 'destroy disarms the retry');
|
||||
});
|
||||
|
||||
test('an image URL draws once it has loaded; a newer source wins', async () => {
|
||||
const loads = [];
|
||||
const preRender = new Cesium.Event();
|
||||
const viewer = {
|
||||
imageryLayers: new Cesium.ImageryLayerCollection(),
|
||||
scene: { globe: { show: true }, preRender },
|
||||
};
|
||||
const { imagery } = createCesiumImagery(viewer, {
|
||||
requestRender() {},
|
||||
factories: {
|
||||
loadImage: (url) =>
|
||||
new Promise((resolve) => loads.push({ url, resolve })),
|
||||
},
|
||||
});
|
||||
const layer = imagery.createLayer({
|
||||
id: 'event',
|
||||
source: { kind: 'image', url: 'before.webp', rectangle: BOX },
|
||||
});
|
||||
assert.equal(viewer.imageryLayers.length, 0);
|
||||
assert.equal(layer.ready(), false);
|
||||
layer.setSource({ kind: 'image', url: 'after.webp', rectangle: BOX });
|
||||
loads[0].resolve({ width: 2, height: 2, name: 'before' });
|
||||
await Promise.resolve();
|
||||
assert.equal(viewer.imageryLayers.length, 0, 'a superseded load is dropped');
|
||||
loads[1].resolve({ width: 2, height: 2, name: 'after' });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(viewer.imageryLayers.length, 1);
|
||||
const image = await viewer.imageryLayers
|
||||
.get(0)
|
||||
.imageryProvider.requestImage(0, 0, 0);
|
||||
assert.equal(image.name, 'after');
|
||||
assert.equal(layer.ready(), true);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ const dispose = (value) => {
|
||||
* credits?: object,
|
||||
* loadPhotorealTileset?: (credentials: object) => Promise<{ tileset: object | null, errors: Error[] }>,
|
||||
* }} [options] `tilesetRef` names the startup Google tileset the app added.
|
||||
* @returns {{ maps: object, destroy: () => void }}
|
||||
* @returns {{ maps: object, tilesets: () => object[], destroy: () => void }}
|
||||
*/
|
||||
export function createCesiumMaps(
|
||||
viewer,
|
||||
@@ -221,6 +221,8 @@ export function createCesiumMaps(
|
||||
|
||||
return {
|
||||
maps,
|
||||
/** Every tileset the map may show (engine.imagery drapes on a shown one). */
|
||||
tilesets,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
|
||||
+83
-3
@@ -513,6 +513,78 @@
|
||||
* @property {(options: GroundSetOptions) => GroundSet} createSet
|
||||
* @property {() => { id: string, kind: string, count: number, shown: boolean, order: number }[]} list
|
||||
*
|
||||
* @typedef {{ west: number, south: number, east: number, north: number }} DegreeRectangle
|
||||
* Degrees; south below north; west > east crosses the antimeridian.
|
||||
*
|
||||
* @typedef {object} ImagerySource
|
||||
* What an imagery layer draws, and where (src/globe/imagery.js checks and
|
||||
* defaults these). Two kinds:
|
||||
* - tiles: `{ kind: 'tiles', url, subdomains?, minLevel?, maxLevel?,
|
||||
* tileSize?, rectangle?, credit?, opaque?, maxRequests? }`, XYZ tiles in
|
||||
* Web Mercator; `url` holds {z}, {x} and {y}, and {s} for one of
|
||||
* `subdomains` (['a', 'b', 'c']); levels 0..18 and 256 px tiles by
|
||||
* default; `opaque` tiles have no alpha (Cesium uploads them as RGB);
|
||||
* `maxRequests` caps the layer's tile requests in flight (Cesium:
|
||||
* unbounded by default; Navara loads 6 at a time);
|
||||
* - image: `{ kind: 'image', image | url, rectangle?, credit? }`, one
|
||||
* image stretched over the rectangle in longitude and latitude
|
||||
* (equirectangular, row 0 on the north edge): a canvas, an ImageBitmap
|
||||
* or image element, `{ width, height, data }` RGBA bytes, or an image
|
||||
* URL the engine loads.
|
||||
* `rectangle` (DegreeRectangle) bounds what draws: the globe when omitted
|
||||
* (Web Mercator tiles stop at ±85.05°). `credit` is HTML, shown while the
|
||||
* layer is.
|
||||
* @property {'tiles'|'image'} kind
|
||||
*
|
||||
* @typedef {object} ImageryLayerOptions
|
||||
* A raster layer draped on the ground: on terrain and 3D tiles, following
|
||||
* their relief, under every ground fill, mover, line and model; colours are
|
||||
* the image's own, blended by its alpha times the layer's (Cesium: an
|
||||
* ImageryLayer on the imagery stack of the surface shown).
|
||||
* @property {string} id Unique among the engine's live imagery layers.
|
||||
* @property {ImagerySource} source
|
||||
* @property {number} [alpha] 0..1, 1 by default.
|
||||
* @property {boolean} [show] Shown when created unless false.
|
||||
* @property {'none'|'left'|'right'} [split] The side of the split position
|
||||
* the layer draws on ('none', the default: both). Cesium splits on the
|
||||
* globe only.
|
||||
* @property {number} [order] Draw order among imagery layers (higher draws
|
||||
* later, on top); 0 by default, then creation order. Every imagery layer
|
||||
* draws above the base imagery.
|
||||
*
|
||||
* @typedef {object} ImageryLayer
|
||||
* @property {string} id
|
||||
* @property {(source: ImagerySource) => void} setSource Draws `source` in
|
||||
* place of the last one, at the layer's place in the order; the same
|
||||
* canvas again draws its new pixels.
|
||||
* @property {() => object} source The checked source drawn.
|
||||
* @property {(alpha: number) => void} setAlpha A fade: at 0 the layer draws
|
||||
* nothing but stays on its surface (Cesium keeps its tiles mapped), so it
|
||||
* comes back without a gap.
|
||||
* @property {() => number} alpha
|
||||
* @property {(visible: boolean) => void} show Shows or hides the layer
|
||||
* (Cesium unmaps a hidden layer's tiles: hide with show, fade with alpha).
|
||||
* @property {() => boolean} shown
|
||||
* @property {(side: 'none'|'left'|'right') => void} setSplit
|
||||
* @property {() => 'none'|'left'|'right'} split
|
||||
* @property {() => boolean} ready Whether the source is built and drawn
|
||||
* from the next frame (an image URL loaded); tiles arrive after it.
|
||||
* @property {() => void} destroy
|
||||
* Every call requests a render; nothing animates on its own.
|
||||
*
|
||||
* @typedef {object} GlobeImagery
|
||||
* @property {(options: ImageryLayerOptions) => ImageryLayer} createLayer
|
||||
* @property {() => { id: string, kind: string, shown: boolean, alpha: number, split: string, order: number }[]} list
|
||||
* In draw order, bottom first.
|
||||
* @property {(fraction: number) => void} setSplitPosition Where the split
|
||||
* runs: a fraction of the canvas width from its left edge (0..1); layers
|
||||
* split 'left' draw left of it, 'right' right of it. One per engine
|
||||
* (Cesium's scene.splitPosition).
|
||||
* @property {() => number} splitPosition 0.5 until set.
|
||||
* @property {() => 'globe'|'tiles'|'none'} surface Where imagery drapes
|
||||
* now: the globe, 3D tiles, or nothing (a Cesium stack with neither
|
||||
* draws no imagery until one shows; the layers wait).
|
||||
*
|
||||
* @typedef {object} GlobeEngine
|
||||
* @property {'cesium'|'navara'} kind
|
||||
* @property {{ container: object, canvas: object, size: () => { width: number, height: number } }} host
|
||||
@@ -537,6 +609,7 @@
|
||||
* points, models), drawn by the engine.
|
||||
* @property {GlobeLines} lines The layers' dynamic polylines.
|
||||
* @property {GlobeGround} ground The layers' fills on the ground.
|
||||
* @property {GlobeImagery} imagery The layers' rasters on the ground.
|
||||
* @property {() => void} destroy
|
||||
* @property {() => boolean} isDestroyed True once destroy() has run.
|
||||
* @property {{ cesium?: object, navara?: object }} [extensions]
|
||||
@@ -745,6 +818,13 @@ const SHAPE = {
|
||||
movers: { createSet: isFunction, list: isFunction },
|
||||
lines: { createLine: isFunction, list: isFunction },
|
||||
ground: { createSet: isFunction, list: isFunction },
|
||||
imagery: {
|
||||
createLayer: isFunction,
|
||||
list: isFunction,
|
||||
setSplitPosition: isFunction,
|
||||
splitPosition: isFunction,
|
||||
surface: isFunction,
|
||||
},
|
||||
maps: {
|
||||
setBaseImagery: isFunction,
|
||||
setTerrain: isFunction,
|
||||
@@ -785,7 +865,7 @@ const OPTIONAL_SHAPE = {
|
||||
|
||||
// Required capabilities engineCapabilities reports as well: true for an
|
||||
// engine that has them whole, as every valid engine does.
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground'];
|
||||
const REPORTED_REQUIRED = ['movers', 'lines', 'ground', 'imagery'];
|
||||
|
||||
const memberAt = (engine, path) =>
|
||||
path.split('.').reduce((value, key) => value?.[key], engine);
|
||||
@@ -794,8 +874,8 @@ const memberAt = (engine, path) =>
|
||||
* Which optional capabilities an engine has: { visualStyles, ... } as
|
||||
* booleans. A caller that refuses a feature on an engine without a Cesium
|
||||
* viewer (voice, say) asks here instead: Cesium draws these through the app's
|
||||
* own stages and reports false. `movers`, `lines` and `ground` are
|
||||
* required; they are reported too, true for every valid engine.
|
||||
* own stages and reports false. `movers`, `lines`, `ground` and `imagery`
|
||||
* are required; they are reported too, true for every valid engine.
|
||||
*/
|
||||
export function engineCapabilities(engine) {
|
||||
const whole = (members, prefix = '') =>
|
||||
|
||||
@@ -328,6 +328,21 @@ test('ground is required and reported as a capability', () => {
|
||||
assert.throws(() => assertGlobeEngine(half), /ground\.list/);
|
||||
});
|
||||
|
||||
test('imagery is required and reported as a capability', () => {
|
||||
const engine = createFakeEngine();
|
||||
assert.equal(engineCapabilities(engine).imagery, true);
|
||||
const without = { ...engine };
|
||||
delete without.imagery;
|
||||
assert.throws(() => assertGlobeEngine(without), /\bimagery\b/);
|
||||
assert.equal(engineCapabilities(without).imagery, false);
|
||||
const half = { ...engine, imagery: { createLayer: () => {} } };
|
||||
assert.throws(() => assertGlobeEngine(half), /imagery\.list/);
|
||||
const names = createContractSuite(engine, {
|
||||
waitFrames: async () => {},
|
||||
}).map(({ name }) => name);
|
||||
assert.ok(names.some((name) => name.startsWith('imagery:')));
|
||||
});
|
||||
|
||||
test('engineCapabilities names the optional capabilities an engine has whole', () => {
|
||||
const plain = createFakeEngine();
|
||||
for (const flag of ['visualStyles', 'bloom', 'cloudCover', 'contactSonar'])
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
// engine.imagery, the engine-neutral part: raster layers draped on the
|
||||
// ground (terrain and 3D tiles) — XYZ tiles from a URL template, or one
|
||||
// image (a canvas, a decoded image, RGBA bytes or an image URL) stretched
|
||||
// over a longitude/latitude rectangle. This checks layer options and
|
||||
// sources, keeps each layer's look (alpha, shown, split, order) and the
|
||||
// split position, and tiles a Web Mercator template for the engines that
|
||||
// mosaic their own tiles (Navara). Rectangles are degrees; colours are the
|
||||
// image's own. No engine imports.
|
||||
|
||||
export const IMAGERY_SOURCE_KINDS = Object.freeze(['tiles', 'image']);
|
||||
export const IMAGERY_SPLITS = Object.freeze(['none', 'left', 'right']);
|
||||
/** Where imagery drapes now (GlobeImagery.surface). */
|
||||
export const IMAGERY_SURFACES = Object.freeze(['globe', 'tiles', 'none']);
|
||||
/** The latitude Web Mercator tiles stop at. */
|
||||
export const WEB_MERCATOR_LIMIT = 85.05112878;
|
||||
|
||||
const isFiniteNumber = (value) =>
|
||||
typeof value === 'number' && Number.isFinite(value);
|
||||
const isObject = (value) => value != null && typeof value === 'object';
|
||||
const isWhole = (value) => Number.isInteger(value) && value >= 0;
|
||||
|
||||
function fail(message) {
|
||||
throw new TypeError(`Imagery layer: ${message}`);
|
||||
}
|
||||
|
||||
const unit = (value, label) => {
|
||||
if (!(isFiniteNumber(value) && value >= 0 && value <= 1))
|
||||
fail(`${label} must be a number in 0..1: ${value}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
const splitOf = (value = 'none') => {
|
||||
if (!IMAGERY_SPLITS.includes(value))
|
||||
fail(`split must be one of ${IMAGERY_SPLITS.join(', ')}: ${value}`);
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* A rectangle in degrees, checked: { west, south, east, north } with south
|
||||
* below north; west > east crosses the antimeridian. Missing: the globe.
|
||||
*/
|
||||
function rectangleOf(rectangle) {
|
||||
if (rectangle == null)
|
||||
return Object.freeze({ west: -180, south: -90, east: 180, north: 90 });
|
||||
if (!isObject(rectangle)) fail('rectangle must be an object');
|
||||
const { west, south, east, north } = rectangle;
|
||||
if (
|
||||
![west, south, east, north].every(isFiniteNumber) ||
|
||||
south >= north ||
|
||||
south < -90 ||
|
||||
north > 90 ||
|
||||
west < -180 ||
|
||||
west > 180 ||
|
||||
east < -180 ||
|
||||
east > 180 ||
|
||||
west === east
|
||||
)
|
||||
fail('rectangle must be { west, south, east, north } in degrees');
|
||||
return Object.freeze({ west, south, east, north });
|
||||
}
|
||||
|
||||
// What an image source can hold: a canvas, an ImageBitmap or image element
|
||||
// (anything with a width and a height), or { width, height, data } RGBA bytes.
|
||||
function imageOf(image) {
|
||||
if (!isObject(image) && typeof image !== 'function')
|
||||
fail('image must be a canvas, an image or { width, height, data }');
|
||||
const { width, height } = image;
|
||||
if (!(isWhole(width) && width > 0 && isWhole(height) && height > 0))
|
||||
fail(`image must have a positive width and height: ${width}×${height}`);
|
||||
if (
|
||||
image.data !== undefined &&
|
||||
(!ArrayBuffer.isView(image.data) ||
|
||||
image.data.length !== width * height * 4)
|
||||
)
|
||||
fail('image data must be width × height × 4 RGBA bytes');
|
||||
return image;
|
||||
}
|
||||
|
||||
/** Whether an image source holds RGBA bytes rather than a drawable image. */
|
||||
export const isPixelImage = (image) => ArrayBuffer.isView(image?.data);
|
||||
|
||||
/**
|
||||
* An imagery source, checked, as the engines draw it:
|
||||
* - { kind: 'tiles', url, subdomains, minLevel, maxLevel, tileSize,
|
||||
* rectangle, credit, opaque, maxRequests }: XYZ tiles in Web Mercator;
|
||||
* `url` holds {z}, {x} and {y}, and {s} for one of `subdomains`;
|
||||
* - { kind: 'image', image | url, rectangle, credit }: one image over the
|
||||
* rectangle, equirectangular (row 0 on the north edge).
|
||||
* The rectangle is in degrees (the globe when omitted); `credit` is HTML.
|
||||
*/
|
||||
export function normalizeImagerySource(source) {
|
||||
if (!isObject(source)) fail('source must be an object');
|
||||
const { kind } = source;
|
||||
if (!IMAGERY_SOURCE_KINDS.includes(kind))
|
||||
fail(
|
||||
`source kind must be one of ${IMAGERY_SOURCE_KINDS.join(', ')}: ${kind}`,
|
||||
);
|
||||
const rectangle = rectangleOf(source.rectangle);
|
||||
const credit = source.credit ?? null;
|
||||
if (credit !== null && typeof credit !== 'string')
|
||||
fail('credit must be a string of HTML');
|
||||
if (kind === 'image') {
|
||||
const hasImage = source.image != null;
|
||||
const hasUrl = source.url != null;
|
||||
if (hasImage === hasUrl) fail('an image source needs an image or a url');
|
||||
if (hasUrl && (typeof source.url !== 'string' || !source.url))
|
||||
fail('url must be a non-empty string');
|
||||
return Object.freeze({
|
||||
kind,
|
||||
image: hasImage ? imageOf(source.image) : null,
|
||||
url: hasUrl ? source.url : null,
|
||||
rectangle,
|
||||
credit,
|
||||
});
|
||||
}
|
||||
const { url } = source;
|
||||
if (
|
||||
typeof url !== 'string' ||
|
||||
!['{z}', '{x}', '{y}'].every((key) => url.includes(key))
|
||||
)
|
||||
fail('a tiles url must hold {z}, {x} and {y}');
|
||||
const subdomains = source.subdomains ?? ['a', 'b', 'c'];
|
||||
if (
|
||||
!Array.isArray(subdomains) ||
|
||||
!subdomains.length ||
|
||||
!subdomains.every((name) => typeof name === 'string')
|
||||
)
|
||||
fail('subdomains must be a list of strings');
|
||||
const minLevel = source.minLevel ?? 0;
|
||||
const maxLevel = source.maxLevel ?? 18;
|
||||
if (!(isWhole(minLevel) && isWhole(maxLevel) && minLevel <= maxLevel))
|
||||
fail(`levels must be whole numbers, min <= max: ${minLevel}..${maxLevel}`);
|
||||
const tileSize = source.tileSize ?? 256;
|
||||
if (!(isWhole(tileSize) && tileSize > 0))
|
||||
fail(`tileSize must be a positive whole number: ${tileSize}`);
|
||||
const maxRequests = source.maxRequests ?? null;
|
||||
if (maxRequests !== null && !(isWhole(maxRequests) && maxRequests > 0))
|
||||
fail(`maxRequests must be a positive whole number: ${maxRequests}`);
|
||||
if (source.opaque !== undefined && typeof source.opaque !== 'boolean')
|
||||
fail('opaque must be a boolean');
|
||||
return Object.freeze({
|
||||
kind,
|
||||
url,
|
||||
subdomains: Object.freeze([...subdomains]),
|
||||
minLevel,
|
||||
maxLevel,
|
||||
tileSize,
|
||||
rectangle,
|
||||
credit,
|
||||
opaque: source.opaque === true,
|
||||
maxRequests,
|
||||
});
|
||||
}
|
||||
|
||||
/** createLayer options with defaults, or a TypeError. */
|
||||
export function normalizeImageryLayerOptions(options) {
|
||||
if (!isObject(options)) fail('options must be an object');
|
||||
const { id } = options;
|
||||
if (typeof id !== 'string' || !id) fail('id must be a non-empty string');
|
||||
const order = options.order ?? 0;
|
||||
if (!isFiniteNumber(order)) fail(`order must be a number: ${order}`);
|
||||
if (options.show !== undefined && typeof options.show !== 'boolean')
|
||||
fail('show must be a boolean');
|
||||
return {
|
||||
id,
|
||||
order,
|
||||
source: normalizeImagerySource(options.source),
|
||||
alpha: unit(options.alpha ?? 1, 'alpha'),
|
||||
shown: options.show !== false,
|
||||
split: splitOf(options.split),
|
||||
};
|
||||
}
|
||||
|
||||
/** A whole-globe rectangle wraps across the antimeridian. */
|
||||
export const wrapsGlobe = ({ west, east }) => west === -180 && east === 180;
|
||||
|
||||
/**
|
||||
* engine.imagery over an engine's drawing: `build(state)` returns the
|
||||
* engine part of one layer ({ source(), look(), destroy(), ready?() }),
|
||||
* called with the layer's checked state ({ options, source, alpha, shown,
|
||||
* split, seq }) after it is stored; `source()` follows a new source and
|
||||
* `look()` a new alpha, show or split. `splitPosition(fraction)` draws the
|
||||
* engine's split, `surface()` answers GlobeImagery.surface. Layer ids are
|
||||
* unique while the layer lives; destroyAll() is for the engine's destroy.
|
||||
* `initialSplit` is the split position before one is set; `readSplit`,
|
||||
* when given, reads the one drawn (an engine whose split others set too).
|
||||
*/
|
||||
export function createImageryRegistry(
|
||||
build,
|
||||
{
|
||||
splitPosition = () => {},
|
||||
surface = () => 'globe',
|
||||
initialSplit = 0.5,
|
||||
readSplit = null,
|
||||
} = {},
|
||||
) {
|
||||
const live = new Map();
|
||||
let split = initialSplit;
|
||||
let seq = 0;
|
||||
const imagery = {
|
||||
createLayer(input) {
|
||||
const options = normalizeImageryLayerOptions(input);
|
||||
if (live.has(options.id))
|
||||
throw new Error(`Imagery layer ${options.id} already exists`);
|
||||
const state = {
|
||||
options,
|
||||
source: options.source,
|
||||
alpha: options.alpha,
|
||||
shown: options.shown,
|
||||
split: options.split,
|
||||
seq: seq++,
|
||||
destroyed: false,
|
||||
};
|
||||
live.set(options.id, state);
|
||||
let part;
|
||||
try {
|
||||
part = build(state);
|
||||
} catch (error) {
|
||||
live.delete(options.id);
|
||||
throw error;
|
||||
}
|
||||
const alive = () => {
|
||||
if (state.destroyed) throw new Error('Imagery layer was destroyed');
|
||||
};
|
||||
const restyle = (key, value) => {
|
||||
alive();
|
||||
if (state[key] === value) return;
|
||||
state[key] = value;
|
||||
part.look();
|
||||
};
|
||||
const layer = {
|
||||
id: options.id,
|
||||
setSource(next) {
|
||||
alive();
|
||||
state.source = normalizeImagerySource(next);
|
||||
part.source();
|
||||
},
|
||||
source: () => state.source,
|
||||
setAlpha: (alpha) => restyle('alpha', unit(alpha, 'alpha')),
|
||||
alpha: () => state.alpha,
|
||||
show(visible) {
|
||||
if (typeof visible !== 'boolean') fail('show takes a boolean');
|
||||
restyle('shown', visible);
|
||||
},
|
||||
shown: () => state.shown,
|
||||
setSplit: (side) => restyle('split', splitOf(side)),
|
||||
split: () => state.split,
|
||||
ready: () =>
|
||||
!state.destroyed && (part.ready ? part.ready() === true : true),
|
||||
destroy() {
|
||||
if (state.destroyed) return;
|
||||
state.destroyed = true;
|
||||
live.delete(options.id);
|
||||
part.destroy();
|
||||
},
|
||||
isDestroyed: () => state.destroyed,
|
||||
};
|
||||
state.layer = layer;
|
||||
return layer;
|
||||
},
|
||||
list: () =>
|
||||
[...live.values()]
|
||||
.sort((a, b) => a.options.order - b.options.order || a.seq - b.seq)
|
||||
.map(({ options, source, shown, alpha, split: side }) => ({
|
||||
id: options.id,
|
||||
kind: source.kind,
|
||||
shown,
|
||||
alpha,
|
||||
split: side,
|
||||
order: options.order,
|
||||
})),
|
||||
setSplitPosition(fraction) {
|
||||
unit(fraction, 'split position');
|
||||
if (fraction === imagery.splitPosition()) return;
|
||||
split = fraction;
|
||||
splitPosition(fraction);
|
||||
},
|
||||
splitPosition: () => readSplit?.() ?? split,
|
||||
surface,
|
||||
};
|
||||
return {
|
||||
imagery,
|
||||
live,
|
||||
/** The live layers in draw order: lowest order first, then creation. */
|
||||
ordered: () =>
|
||||
[...live.values()].sort(
|
||||
(a, b) => a.options.order - b.options.order || a.seq - b.seq,
|
||||
),
|
||||
destroyAll() {
|
||||
for (const state of [...live.values()]) state.layer.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Web Mercator tiles of a template, for the engines that mosaic them
|
||||
// (Navara draws one atlas per tiles layer).
|
||||
|
||||
const MAX_TILE_SIDE = 8;
|
||||
export const mercatorX = (lon) => (lon + 180) / 360;
|
||||
export const mercatorY = (lat) => {
|
||||
const r =
|
||||
(Math.max(-WEB_MERCATOR_LIMIT, Math.min(WEB_MERCATOR_LIMIT, lat)) *
|
||||
Math.PI) /
|
||||
180;
|
||||
return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2;
|
||||
};
|
||||
|
||||
/**
|
||||
* The tiles over `box` (degrees) that a view `rectangle` (radians, or null:
|
||||
* all of the box) shows at `level`, or the level below until they are at
|
||||
* most 8×8: { key, z, x, y, width, height, bounds: [x, y, w, h] in Web
|
||||
* Mercator units }, or null when the view misses the box. Never a global
|
||||
* mosaic.
|
||||
*/
|
||||
export function imageryTilePlan(box, rectangle, level) {
|
||||
let area = { ...box };
|
||||
if (rectangle && rectangle.east >= rectangle.west) {
|
||||
const degrees = 180 / Math.PI;
|
||||
area = {
|
||||
west: Math.max(box.west, rectangle.west * degrees),
|
||||
east: Math.min(box.east, rectangle.east * degrees),
|
||||
south: Math.max(box.south, rectangle.south * degrees),
|
||||
north: Math.min(box.north, rectangle.north * degrees),
|
||||
};
|
||||
}
|
||||
if (area.west >= area.east || area.south >= area.north) return null;
|
||||
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
|
||||
const n = 2 ** z;
|
||||
const x = Math.max(0, Math.floor(mercatorX(area.west) * n));
|
||||
const y = Math.max(0, Math.floor(mercatorY(area.north) * n));
|
||||
const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1);
|
||||
const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1);
|
||||
const width = right - x + 1,
|
||||
height = bottom - y + 1;
|
||||
if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE)
|
||||
return {
|
||||
key: [z, x, y, width, height].join('/'),
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
bounds: [x / n, y / n, width / n, height / n],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** One tile's URL: {z}/{x}/{y}, and {s} picked by the tile from `subdomains`. */
|
||||
export function imageryTileUrl(
|
||||
template,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
subdomains = ['a', 'b', 'c'],
|
||||
) {
|
||||
return template
|
||||
.replaceAll('{x}', String(x))
|
||||
.replaceAll('{y}', String(y))
|
||||
.replaceAll('{z}', String(z))
|
||||
.replaceAll('{s}', subdomains[(x + y) % subdomains.length]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tile level whose texels match the ground under a camera `height` m
|
||||
* up at `latitude` (radians), seen with a vertical field of view `fovy`
|
||||
* over `heightPx` CSS pixels.
|
||||
*/
|
||||
export function imageryTileLevel({ height, latitude, fovy, heightPx }) {
|
||||
const metresPerPixel =
|
||||
(2 * Math.max(1, height) * Math.tan(fovy / 2)) / Math.max(1, heightPx);
|
||||
return Math.ceil(
|
||||
Math.log2((156543.033928 * Math.cos(latitude)) / metresPerPixel),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile loads `limit` at a time. A task is { url, controllers, live(),
|
||||
* draw(image), fail(error) }; one no longer live is skipped, and an image
|
||||
* that arrives for one is closed without drawing. Cancellation holds its
|
||||
* admission slot until fetch AND decode settle.
|
||||
*/
|
||||
export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
let active = 0,
|
||||
peak = 0,
|
||||
destroyed = false;
|
||||
const pending = [],
|
||||
controllers = new Set();
|
||||
function pump() {
|
||||
while (!destroyed && active < limit && pending.length) {
|
||||
const task = pending.shift();
|
||||
if (!task.live()) continue;
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
task.controllers.add(controller);
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
Promise.resolve()
|
||||
.then(() => load(task.url, controller.signal))
|
||||
.then((image) => {
|
||||
try {
|
||||
if (!destroyed && !controller.signal.aborted && task.live())
|
||||
task.draw(image);
|
||||
} finally {
|
||||
image?.close?.();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!destroyed && !controller.signal.aborted && task.live())
|
||||
task.fail(error);
|
||||
})
|
||||
.finally(() => {
|
||||
controllers.delete(controller);
|
||||
task.controllers.delete(controller);
|
||||
active--;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
add(tasks) {
|
||||
if (!destroyed) {
|
||||
pending.push(...tasks);
|
||||
pump();
|
||||
}
|
||||
},
|
||||
prune() {
|
||||
for (let i = pending.length - 1; i >= 0; i--)
|
||||
if (!pending[i].live()) pending.splice(i, 1);
|
||||
},
|
||||
diagnostics: () => ({
|
||||
active,
|
||||
queued: pending.filter((t) => t.live()).length,
|
||||
peak,
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
pending.length = 0;
|
||||
for (const c of controllers) c.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createImageryRegistry,
|
||||
createImageryTileQueue,
|
||||
imageryTileLevel,
|
||||
imageryTilePlan,
|
||||
imageryTileUrl,
|
||||
mercatorX,
|
||||
mercatorY,
|
||||
normalizeImageryLayerOptions,
|
||||
normalizeImagerySource,
|
||||
} from './imagery.js';
|
||||
|
||||
const image = { width: 2, height: 1, data: new Uint8Array(8) };
|
||||
|
||||
test('sources are checked and defaulted; rectangles default to the globe', () => {
|
||||
const tiles = normalizeImagerySource({
|
||||
kind: 'tiles',
|
||||
url: 'https://t.example/{z}/{x}/{y}.png',
|
||||
});
|
||||
assert.deepEqual(
|
||||
{ ...tiles },
|
||||
{
|
||||
kind: 'tiles',
|
||||
url: 'https://t.example/{z}/{x}/{y}.png',
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minLevel: 0,
|
||||
maxLevel: 18,
|
||||
tileSize: 256,
|
||||
rectangle: { west: -180, south: -90, east: 180, north: 90 },
|
||||
credit: null,
|
||||
opaque: false,
|
||||
maxRequests: null,
|
||||
},
|
||||
);
|
||||
const across = normalizeImagerySource({
|
||||
kind: 'image',
|
||||
image,
|
||||
rectangle: { west: 170, south: -10, east: -170, north: 10 },
|
||||
credit: '<b>x</b>',
|
||||
});
|
||||
assert.equal(across.image, image);
|
||||
assert.equal(across.rectangle.west, 170, 'west > east crosses 180°');
|
||||
assert.equal(
|
||||
normalizeImagerySource({ kind: 'image', url: 'a.png' }).url,
|
||||
'a.png',
|
||||
);
|
||||
for (const bad of [
|
||||
null,
|
||||
{ kind: 'wms' },
|
||||
{ kind: 'tiles', url: 'https://t.example/{z}/{x}.png' },
|
||||
{
|
||||
kind: 'tiles',
|
||||
url: 'https://t.example/{z}/{x}/{y}',
|
||||
minLevel: 4,
|
||||
maxLevel: 2,
|
||||
},
|
||||
{ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', maxRequests: 0 },
|
||||
{ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}', subdomains: [] },
|
||||
{ kind: 'image' },
|
||||
{ kind: 'image', image, url: 'a.png' },
|
||||
{ kind: 'image', image: { width: 0, height: 1 } },
|
||||
{ kind: 'image', image: { width: 2, height: 2, data: new Uint8Array(4) } },
|
||||
{
|
||||
kind: 'image',
|
||||
image,
|
||||
rectangle: { west: 0, south: 1, east: 2, north: 1 },
|
||||
},
|
||||
{
|
||||
kind: 'image',
|
||||
image,
|
||||
rectangle: { west: 0, south: 0, east: 0, north: 1 },
|
||||
},
|
||||
{ kind: 'image', image, credit: 7 },
|
||||
])
|
||||
assert.throws(
|
||||
() => normalizeImagerySource(bad),
|
||||
TypeError,
|
||||
JSON.stringify(bad),
|
||||
);
|
||||
});
|
||||
|
||||
test('layer options are checked and defaulted', () => {
|
||||
const options = normalizeImageryLayerOptions({
|
||||
id: 'a',
|
||||
source: { kind: 'image', image },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[options.order, options.alpha, options.shown, options.split],
|
||||
[0, 1, true, 'none'],
|
||||
);
|
||||
for (const bad of [
|
||||
{ source: { kind: 'image', image } },
|
||||
{ id: 'a' },
|
||||
{ id: 'a', source: { kind: 'image', image }, alpha: 1.5 },
|
||||
{ id: 'a', source: { kind: 'image', image }, show: 1 },
|
||||
{ id: 'a', source: { kind: 'image', image }, split: 'top' },
|
||||
{ id: 'a', source: { kind: 'image', image }, order: Infinity },
|
||||
])
|
||||
assert.throws(() => normalizeImageryLayerOptions(bad), TypeError);
|
||||
});
|
||||
|
||||
test('the registry keeps each layer’s look, draws in order and calls its part', () => {
|
||||
const calls = [];
|
||||
const { imagery, ordered, destroyAll } = createImageryRegistry(
|
||||
(state) => ({
|
||||
source: () => calls.push(['source', state.options.id, state.source.kind]),
|
||||
look: () =>
|
||||
calls.push([
|
||||
'look',
|
||||
state.options.id,
|
||||
state.alpha,
|
||||
state.shown,
|
||||
state.split,
|
||||
]),
|
||||
destroy: () => calls.push(['destroy', state.options.id]),
|
||||
}),
|
||||
{
|
||||
splitPosition: (fraction) => calls.push(['split', fraction]),
|
||||
surface: () => 'tiles',
|
||||
},
|
||||
);
|
||||
const top = imagery.createLayer({
|
||||
id: 'top',
|
||||
source: { kind: 'image', image },
|
||||
order: 1,
|
||||
});
|
||||
const bottom = imagery.createLayer({
|
||||
id: 'bottom',
|
||||
source: { kind: 'image', image },
|
||||
});
|
||||
assert.throws(
|
||||
() => imagery.createLayer({ id: 'top', source: { kind: 'image', image } }),
|
||||
/already exists/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
ordered().map(({ options }) => options.id),
|
||||
['bottom', 'top'],
|
||||
);
|
||||
bottom.setAlpha(0.5);
|
||||
bottom.setAlpha(0.5);
|
||||
bottom.show(false);
|
||||
bottom.setSplit('left');
|
||||
top.setSource({ kind: 'tiles', url: 'https://t.example/{z}/{x}/{y}' });
|
||||
imagery.setSplitPosition(0.25);
|
||||
imagery.setSplitPosition(0.25);
|
||||
assert.equal(imagery.surface(), 'tiles');
|
||||
assert.deepEqual(calls, [
|
||||
['look', 'bottom', 0.5, true, 'none'],
|
||||
['look', 'bottom', 0.5, false, 'none'],
|
||||
['look', 'bottom', 0.5, false, 'left'],
|
||||
['source', 'top', 'tiles'],
|
||||
['split', 0.25],
|
||||
]);
|
||||
assert.throws(() => bottom.show('no'), TypeError);
|
||||
assert.throws(() => top.setSource({ kind: 'tiles' }), TypeError);
|
||||
assert.equal(top.source().kind, 'tiles', 'a refused source changes nothing');
|
||||
destroyAll();
|
||||
assert.deepEqual(imagery.list(), []);
|
||||
assert.deepEqual(calls.slice(-2), [
|
||||
['destroy', 'top'],
|
||||
['destroy', 'bottom'],
|
||||
]);
|
||||
assert.equal(top.isDestroyed(), true);
|
||||
assert.throws(() => top.setAlpha(1), /destroyed/);
|
||||
});
|
||||
|
||||
test('the tile level matches the ground resolution under the camera', () => {
|
||||
// About 38 m per pixel at the equator: level 12 (38.2 m at 256 px).
|
||||
const level = imageryTileLevel({
|
||||
height: 18_000,
|
||||
latitude: 0,
|
||||
fovy: Math.PI / 3,
|
||||
heightPx: 540,
|
||||
});
|
||||
assert.equal(level, 12);
|
||||
});
|
||||
|
||||
test('visible imagery tiles stay bounded and contain the intersected geographic area', () => {
|
||||
const box = { west: -100, east: -90, south: 25, north: 35 };
|
||||
for (const level of [0, 5, 9, 12]) {
|
||||
const plan = imageryTilePlan(box, null, level),
|
||||
n = 2 ** plan.z;
|
||||
assert.ok(plan.width <= 8 && plan.height <= 8);
|
||||
assert.ok(
|
||||
plan.x <= mercatorX(box.west) * n &&
|
||||
plan.x + plan.width >= mercatorX(box.east) * n,
|
||||
);
|
||||
assert.ok(
|
||||
plan.y <= mercatorY(box.north) * n &&
|
||||
plan.y + plan.height >= mercatorY(box.south) * n,
|
||||
);
|
||||
}
|
||||
const r = (x) => (x * Math.PI) / 180;
|
||||
const view = {
|
||||
west: r(-97.75),
|
||||
east: r(-97.73),
|
||||
south: r(30.26),
|
||||
north: r(30.28),
|
||||
};
|
||||
const plan = imageryTilePlan(box, view, 12);
|
||||
assert.equal(plan.z, 12);
|
||||
assert.ok(plan.width * plan.height < 8);
|
||||
assert.equal(
|
||||
imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('source tile addressing retains XYZ order and deterministic subdomain', () => {
|
||||
assert.equal(
|
||||
imageryTileUrl('https://gibs-{s}.example/{z}/{y}/{x}.png', 4, 7, 9),
|
||||
'https://gibs-c.example/9/7/4.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('six admissions include slow aborted decodes; stale images close without drawing or errors', async () => {
|
||||
const pending = [],
|
||||
drawn = [],
|
||||
failed = [],
|
||||
closed = [];
|
||||
let live = true;
|
||||
const controllers = new Set();
|
||||
const queue = createImageryTileQueue({
|
||||
load: (url, signal) =>
|
||||
new Promise((resolve) => pending.push({ url, signal, resolve })),
|
||||
});
|
||||
const task = (id) => ({
|
||||
url: String(id),
|
||||
controllers,
|
||||
live: () => live,
|
||||
draw: () => drawn.push(id),
|
||||
fail: () => failed.push(id),
|
||||
});
|
||||
queue.add(Array.from({ length: 10 }, (_, i) => task(i)));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 6);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
live = false;
|
||||
for (const c of controllers) c.abort();
|
||||
queue.prune();
|
||||
let replacement = true;
|
||||
queue.add([{ ...task(10), live: () => replacement }]);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
for (const p of pending) p.resolve({ close: () => closed.push(p.url) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 7);
|
||||
assert.equal(drawn.length, 0);
|
||||
assert.equal(closed.length, 6);
|
||||
queue.destroy();
|
||||
replacement = false;
|
||||
pending[6].resolve({ close: () => closed.push('last') });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(drawn, []);
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(closed.length, 7);
|
||||
assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 });
|
||||
});
|
||||
@@ -54,6 +54,15 @@ const NO_GROUND = Object.freeze({
|
||||
},
|
||||
list: () => [],
|
||||
});
|
||||
const NO_IMAGERY = Object.freeze({
|
||||
createLayer() {
|
||||
throw new Error("Navara imagery needs the view's three runtime (imagery/)");
|
||||
},
|
||||
list: () => [],
|
||||
setSplitPosition() {},
|
||||
splitPosition: () => 0.5,
|
||||
surface: () => 'globe',
|
||||
});
|
||||
|
||||
// Navara's drawn camera (view.camera.raw, a three PerspectiveCamera in
|
||||
// Earth-fixed metres): matrixWorld columns are right, up, back, position.
|
||||
@@ -73,6 +82,7 @@ function readCamera(raw) {
|
||||
* @param {object} view An initialised Navara ThreeView.
|
||||
* @param {{
|
||||
* hooks: { tileRoots: Function, isIdle: Function, retainTileGeometry: Function, dropOrphanFeatureChanges?: Function },
|
||||
* imagery?: (parts: { camera: object, size: Function, credits: object, photorealActive: () => boolean }) => { imagery: object, diagnostics: Function, destroy: Function },
|
||||
* container?: HTMLElement,
|
||||
* fetch?: typeof fetch,
|
||||
* clock?: object,
|
||||
@@ -97,7 +107,9 @@ function readCamera(raw) {
|
||||
* `movers` is engine.movers on the view's three runtime (movers/index.js;
|
||||
* without it createSet throws), `lines` engine.lines (lines/index.js;
|
||||
* likewise), `ground` engine.ground (ground/index.js; likewise), all
|
||||
* destroyed with the engine;
|
||||
* destroyed with the engine; `imagery` makes engine.imagery from the
|
||||
* engine's camera, size, credits and photoreal state (imagery/index.js;
|
||||
* without it createLayer throws), destroyed with the engine;
|
||||
* `fetch` and `clock` (the camera model's timers) are test seams.
|
||||
* @returns {import('../contract.js').GlobeEngine}
|
||||
*/
|
||||
@@ -112,6 +124,7 @@ export function createNavaraEngine(
|
||||
movers: moversPort = null,
|
||||
lines: linesPort = null,
|
||||
ground: groundPort = null,
|
||||
imagery: imageryPort = null,
|
||||
sharpen: sharpenPort = null,
|
||||
styles: stylesPort = null,
|
||||
bloom: bloomPort = null,
|
||||
@@ -223,6 +236,13 @@ export function createNavaraEngine(
|
||||
});
|
||||
const credits = createNavaraCredits(view);
|
||||
const maps = createNavaraMaps(view, fetch ? { fetch } : {});
|
||||
const imagery =
|
||||
imageryPort?.({
|
||||
camera: model.camera,
|
||||
size,
|
||||
credits: credits.credits,
|
||||
photorealActive: () => maps.maps.photorealActive(),
|
||||
}) ?? null;
|
||||
const { surface } = createNavaraSurface(view, hooks);
|
||||
// Navara's controller zooms through the ground; keep the camera above it.
|
||||
// With the camera inputs off nothing zooms, and the app places the camera
|
||||
@@ -414,6 +434,7 @@ export function createNavaraEngine(
|
||||
movers: moversPort?.movers ?? NO_MOVERS,
|
||||
lines: linesPort?.lines ?? NO_LINES,
|
||||
ground: groundPort?.ground ?? NO_GROUND,
|
||||
imagery: imagery?.imagery ?? NO_IMAGERY,
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
@@ -424,6 +445,7 @@ export function createNavaraEngine(
|
||||
input.destroy();
|
||||
removeGroundPicks();
|
||||
picking.destroy();
|
||||
imagery?.destroy();
|
||||
credits.destroy();
|
||||
maps.destroy();
|
||||
sharpenPort?.destroy();
|
||||
@@ -450,6 +472,8 @@ export function createNavaraEngine(
|
||||
},
|
||||
// Navara only, not contract: what the ground pass holds (QA reads it).
|
||||
groundDiagnostics: () => groundPort?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: what each imagery layer holds.
|
||||
imageryDiagnostics: () => imagery?.diagnostics?.() ?? null,
|
||||
// Navara only, not contract: the ground the floor last sampled under
|
||||
// the camera and the margin it keeps above it (QA measures against it).
|
||||
groundFloor: {
|
||||
|
||||
@@ -230,7 +230,7 @@ class GroundEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevRecentImagery',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
];
|
||||
static insertBefore = [
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
// engine.imagery on Navara: every shown layer in one screen pass (pass.js),
|
||||
// classified on the scene depth, so rasters drape on terrain and 3D tiles
|
||||
// alike. An image source is one texture (mipmapped, wrapping across 180°
|
||||
// when it spans the globe); setting the same canvas again uploads its new
|
||||
// pixels. A tiles source is an atlas of at most 8×8 Web Mercator tiles over
|
||||
// the part of its rectangle in view, at the level the camera's height asks
|
||||
// for, rebuilt when the camera moves to another plan and filled as tiles
|
||||
// arrive (6 loads at a time, or the source's maxRequests). A layer's credit
|
||||
// shows while it does. Every change composes the drawn layers (bottom
|
||||
// first) and asks for one frame; nothing animates on its own.
|
||||
|
||||
import {
|
||||
CanvasTexture,
|
||||
ClampToEdgeWrapping,
|
||||
DataTexture,
|
||||
LinearFilter,
|
||||
LinearMipmapLinearFilter,
|
||||
RepeatWrapping,
|
||||
RGBAFormat,
|
||||
Texture,
|
||||
UnsignedByteType,
|
||||
} from 'three';
|
||||
import {
|
||||
WEB_MERCATOR_LIMIT,
|
||||
createImageryRegistry,
|
||||
createImageryTileQueue,
|
||||
imageryTileLevel,
|
||||
imageryTilePlan,
|
||||
imageryTileUrl,
|
||||
isPixelImage,
|
||||
wrapsGlobe,
|
||||
} from '../../imagery.js';
|
||||
/** Layers one frame of the pass draws (pass.js samples one texture each). */
|
||||
export const MAX_IMAGERY_SLOTS = 8;
|
||||
|
||||
const SIDE = { none: 0, left: -1, right: 1 };
|
||||
|
||||
async function loadTile(url, signal) {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`Imagery tile HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
signal.throwIfAborted();
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
const defaultCanvas = (width, height) =>
|
||||
Object.assign(document.createElement('canvas'), { width, height });
|
||||
|
||||
// Linear between texels, mipmapped, row 0 at v = 0 (no flip).
|
||||
function finish(texture, wrap) {
|
||||
texture.flipY = false;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.minFilter = LinearMipmapLinearFilter;
|
||||
texture.generateMipmaps = true;
|
||||
texture.wrapS = wrap ? RepeatWrapping : ClampToEdgeWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
// An image source's texture. RGBA bytes are read as they are (sRGB bytes,
|
||||
// blended in display space), as canvases and images are.
|
||||
function imageTexture(image, wrap) {
|
||||
if (isPixelImage(image))
|
||||
return finish(
|
||||
new DataTexture(
|
||||
image.data,
|
||||
image.width,
|
||||
image.height,
|
||||
RGBAFormat,
|
||||
UnsignedByteType,
|
||||
),
|
||||
wrap,
|
||||
);
|
||||
return finish(
|
||||
typeof image.getContext === 'function'
|
||||
? new CanvasTexture(image)
|
||||
: new Texture(image),
|
||||
wrap,
|
||||
);
|
||||
}
|
||||
|
||||
// Degrees, east past west (an antimeridian crossing adds 360°).
|
||||
const unwrapped = ({ west, south, east, north }) => [
|
||||
west,
|
||||
south,
|
||||
east <= west ? east + 360 : east,
|
||||
north,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {object} view an initialised Navara ThreeView
|
||||
* @param {{ camera: object, size: () => { width: number, height: number },
|
||||
* credits: { addStatic: (html: string) => () => void },
|
||||
* photorealActive: () => boolean,
|
||||
* createPass: (view: object) => ReturnType<typeof import('./pass.js').createImageryPass>,
|
||||
* load?: (url: string, signal: AbortSignal) => Promise<object>,
|
||||
* createCanvas?: (width: number, height: number) => object }} options
|
||||
* The engine's camera, size and credits; `createPass` makes the pass
|
||||
* (pass.js createImageryPass; the view's three runtime, which Node tests
|
||||
* replace); `load` (a tile or an image URL to a decoded image) and
|
||||
* `createCanvas` are test seams.
|
||||
* @returns {{ imagery: import('../../contract.js').GlobeImagery,
|
||||
* diagnostics: () => object, destroy: () => void }}
|
||||
*/
|
||||
export function createNavaraImagery(
|
||||
view,
|
||||
{
|
||||
camera,
|
||||
size,
|
||||
credits,
|
||||
photorealActive,
|
||||
createPass,
|
||||
load = loadTile,
|
||||
createCanvas = defaultCanvas,
|
||||
},
|
||||
) {
|
||||
const pass = createPass(view);
|
||||
const entries = new Map();
|
||||
let warned = false;
|
||||
|
||||
const compose = () => {
|
||||
const drawn = registry
|
||||
.ordered()
|
||||
.map((state) => entries.get(state))
|
||||
.filter(
|
||||
(entry) => entry?.state.shown && entry.state.alpha > 0 && entry.slot(),
|
||||
);
|
||||
if (drawn.length > MAX_IMAGERY_SLOTS && !warned) {
|
||||
warned = true;
|
||||
console.warn(
|
||||
`[Imagery] Navara draws the top ${MAX_IMAGERY_SLOTS} of ${drawn.length} layers`,
|
||||
);
|
||||
}
|
||||
pass.set(
|
||||
drawn.slice(-MAX_IMAGERY_SLOTS).map((entry) => entry.slot()),
|
||||
registry.imagery.splitPosition(),
|
||||
);
|
||||
view.forceUpdate();
|
||||
};
|
||||
|
||||
// A layer's credit shows while the layer does.
|
||||
function syncCredit(entry) {
|
||||
const credit = entry.state.shown ? entry.state.source.credit : null;
|
||||
if (credit === entry.credit) return;
|
||||
entry.releaseCredit?.();
|
||||
entry.credit = credit;
|
||||
entry.releaseCredit = credit ? credits.addStatic(credit) : null;
|
||||
}
|
||||
|
||||
function imageEntry(entry) {
|
||||
let texture = null,
|
||||
image = null,
|
||||
loading = null;
|
||||
const drop = () => {
|
||||
texture?.dispose();
|
||||
texture = null;
|
||||
};
|
||||
const show = (next, source) => {
|
||||
const wrapS = wrapsGlobe(source.rectangle)
|
||||
? RepeatWrapping
|
||||
: ClampToEdgeWrapping;
|
||||
if (texture && image === next && texture.wrapS === wrapS) {
|
||||
// The same canvas or bytes again: their new pixels.
|
||||
texture.needsUpdate = true;
|
||||
} else {
|
||||
drop();
|
||||
texture = imageTexture(next, wrapS === RepeatWrapping);
|
||||
}
|
||||
image = next;
|
||||
};
|
||||
return {
|
||||
source() {
|
||||
const { source } = entry.state;
|
||||
loading?.abort();
|
||||
loading = null;
|
||||
if (source.image) {
|
||||
show(source.image, source);
|
||||
return;
|
||||
}
|
||||
drop();
|
||||
image = null;
|
||||
const controller = new AbortController();
|
||||
loading = controller;
|
||||
Promise.resolve()
|
||||
.then(() => load(source.url, controller.signal))
|
||||
.then(
|
||||
(decoded) => {
|
||||
if (controller.signal.aborted || entry.state.destroyed) {
|
||||
decoded?.close?.();
|
||||
return;
|
||||
}
|
||||
loading = null;
|
||||
show(decoded, source);
|
||||
compose();
|
||||
},
|
||||
(error) => {
|
||||
if (controller.signal.aborted) return;
|
||||
loading = null;
|
||||
console.warn('[Imagery] image failed to load:', error);
|
||||
},
|
||||
);
|
||||
},
|
||||
slot() {
|
||||
if (!texture) return null;
|
||||
const rect = unwrapped(entry.state.source.rectangle);
|
||||
return {
|
||||
texture,
|
||||
rect,
|
||||
bounds: rect,
|
||||
look: [entry.state.alpha, SIDE[entry.state.split], 0, 0],
|
||||
};
|
||||
},
|
||||
ready: () => texture !== null,
|
||||
cameraChanged: () => false,
|
||||
destroy() {
|
||||
loading?.abort();
|
||||
loading = null;
|
||||
drop();
|
||||
image = null;
|
||||
},
|
||||
diagnostics: () => ({ kind: 'image', loaded: texture !== null }),
|
||||
};
|
||||
}
|
||||
|
||||
function tilesEntry(entry) {
|
||||
let atlas = null,
|
||||
queue = null;
|
||||
const disposeAtlas = () => {
|
||||
const current = atlas;
|
||||
atlas = null;
|
||||
if (!current) return;
|
||||
for (const controller of current.controllers) controller.abort();
|
||||
current.texture.dispose();
|
||||
current.canvas.width = current.canvas.height = 0;
|
||||
queue?.prune();
|
||||
};
|
||||
// The source's rectangle within Web Mercator's latitudes; across the
|
||||
// antimeridian the atlas covers every longitude.
|
||||
const box = () => {
|
||||
const { west, south, east, north } = entry.state.source.rectangle;
|
||||
return {
|
||||
west: west < east ? west : -180,
|
||||
east: west < east ? east : 180,
|
||||
south: Math.max(south, -WEB_MERCATOR_LIMIT),
|
||||
north: Math.min(north, WEB_MERCATOR_LIMIT),
|
||||
};
|
||||
};
|
||||
// The atlas for the view; true when it changed.
|
||||
function update() {
|
||||
if (!entry.state.shown) {
|
||||
if (!atlas) return false;
|
||||
disposeAtlas();
|
||||
return true;
|
||||
}
|
||||
const source = entry.state.source;
|
||||
let pose;
|
||||
try {
|
||||
pose = camera.pose();
|
||||
} catch {
|
||||
// Before the first frame the camera has no pose yet.
|
||||
return false;
|
||||
}
|
||||
const level = Math.max(
|
||||
source.minLevel,
|
||||
Math.min(
|
||||
source.maxLevel,
|
||||
imageryTileLevel({
|
||||
height: pose.cartographic.height,
|
||||
latitude: pose.cartographic.latitude,
|
||||
fovy: pose.fovy,
|
||||
heightPx: size().height,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const plan = imageryTilePlan(box(), camera.viewRectangle(), level);
|
||||
if ((atlas?.plan.key ?? null) === (plan?.key ?? null)) return false;
|
||||
disposeAtlas();
|
||||
if (!plan) return true;
|
||||
const tile = source.tileSize;
|
||||
const canvas = createCanvas(plan.width * tile, plan.height * tile);
|
||||
const texture = finish(new CanvasTexture(canvas), false);
|
||||
const current = {
|
||||
plan,
|
||||
canvas,
|
||||
texture,
|
||||
controllers: new Set(),
|
||||
loaded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
atlas = current;
|
||||
const context = canvas.getContext('2d');
|
||||
const tasks = [];
|
||||
for (let y = 0; y < plan.height; y++)
|
||||
for (let x = 0; x < plan.width; x++)
|
||||
tasks.push({
|
||||
url: imageryTileUrl(
|
||||
source.url,
|
||||
plan.x + x,
|
||||
plan.y + y,
|
||||
plan.z,
|
||||
source.subdomains,
|
||||
),
|
||||
controllers: current.controllers,
|
||||
live: () => !entry.state.destroyed && atlas === current,
|
||||
draw(image) {
|
||||
context.drawImage(image, x * tile, y * tile, tile, tile);
|
||||
current.loaded++;
|
||||
texture.needsUpdate = true;
|
||||
view.forceUpdate();
|
||||
},
|
||||
fail() {
|
||||
current.failed++;
|
||||
},
|
||||
});
|
||||
queue.add(tasks);
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
source() {
|
||||
queue?.destroy();
|
||||
disposeAtlas();
|
||||
queue = createImageryTileQueue({
|
||||
load,
|
||||
limit: entry.state.source.maxRequests ?? 6,
|
||||
});
|
||||
update();
|
||||
},
|
||||
slot() {
|
||||
if (!atlas) return null;
|
||||
const [west, south, east, north] = unwrapped(
|
||||
entry.state.source.rectangle,
|
||||
);
|
||||
return {
|
||||
texture: atlas.texture,
|
||||
rect: [
|
||||
west,
|
||||
Math.max(south, -WEB_MERCATOR_LIMIT),
|
||||
east,
|
||||
Math.min(north, WEB_MERCATOR_LIMIT),
|
||||
],
|
||||
bounds: atlas.plan.bounds,
|
||||
look: [entry.state.alpha, SIDE[entry.state.split], 1, 0],
|
||||
};
|
||||
},
|
||||
ready: () => true,
|
||||
cameraChanged: update,
|
||||
look: update,
|
||||
destroy() {
|
||||
queue?.destroy();
|
||||
queue = null;
|
||||
disposeAtlas();
|
||||
},
|
||||
diagnostics: () => ({
|
||||
kind: 'tiles',
|
||||
...(queue?.diagnostics() ?? {}),
|
||||
atlas: atlas
|
||||
? {
|
||||
level: atlas.plan.z,
|
||||
tiles: atlas.plan.width * atlas.plan.height,
|
||||
loaded: atlas.loaded,
|
||||
failed: atlas.failed,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// One drawing per source kind; a new kind replaces it.
|
||||
function drawingFor(entry) {
|
||||
const kind = entry.state.source.kind;
|
||||
if (entry.drawing?.kind === kind) return entry.drawing;
|
||||
entry.drawing?.destroy();
|
||||
entry.drawing = kind === 'image' ? imageEntry(entry) : tilesEntry(entry);
|
||||
entry.drawing.kind = kind;
|
||||
return entry.drawing;
|
||||
}
|
||||
|
||||
const registry = createImageryRegistry(
|
||||
(state) => {
|
||||
const entry = {
|
||||
state,
|
||||
drawing: null,
|
||||
credit: null,
|
||||
releaseCredit: null,
|
||||
slot: () => entry.drawing?.slot() ?? null,
|
||||
};
|
||||
entries.set(state, entry);
|
||||
drawingFor(entry).source();
|
||||
syncCredit(entry);
|
||||
compose();
|
||||
return {
|
||||
source() {
|
||||
drawingFor(entry).source();
|
||||
syncCredit(entry);
|
||||
compose();
|
||||
},
|
||||
look() {
|
||||
entry.drawing.look?.();
|
||||
syncCredit(entry);
|
||||
compose();
|
||||
},
|
||||
ready: () => entry.drawing.ready(),
|
||||
destroy() {
|
||||
entries.delete(state);
|
||||
entry.drawing.destroy();
|
||||
entry.releaseCredit?.();
|
||||
entry.releaseCredit = null;
|
||||
compose();
|
||||
},
|
||||
};
|
||||
},
|
||||
{
|
||||
splitPosition: () => compose(),
|
||||
surface: () => (photorealActive() ? 'tiles' : 'globe'),
|
||||
},
|
||||
);
|
||||
|
||||
const offCamera = camera.changed.addEventListener(() => {
|
||||
let changed = false;
|
||||
for (const entry of entries.values())
|
||||
changed = entry.drawing.cameraChanged() || changed;
|
||||
if (changed) compose();
|
||||
});
|
||||
|
||||
return {
|
||||
imagery: registry.imagery,
|
||||
/** Not contract: what each layer holds (QA reads it). */
|
||||
diagnostics: () =>
|
||||
Object.fromEntries(
|
||||
registry
|
||||
.ordered()
|
||||
.map((state) => [
|
||||
state.options.id,
|
||||
entries.get(state)?.drawing.diagnostics(),
|
||||
]),
|
||||
),
|
||||
destroy() {
|
||||
offCamera();
|
||||
registry.destroyAll();
|
||||
pass.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createEngineEvent } from '../../contract.js';
|
||||
import { MAX_IMAGERY_SLOTS, createNavaraImagery } from './index.js';
|
||||
|
||||
const BOX = { west: -97.77, south: 30.25, east: -97.71, north: 30.29 };
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function fixture() {
|
||||
const canvases = [];
|
||||
const draws = [];
|
||||
const jobs = [];
|
||||
const sets = [];
|
||||
const credits = new Map();
|
||||
const changed = createEngineEvent();
|
||||
let photoreal = false;
|
||||
let frames = 0;
|
||||
const view = {
|
||||
forceUpdate: () => frames++,
|
||||
};
|
||||
const { imagery, diagnostics, destroy } = createNavaraImagery(view, {
|
||||
camera: {
|
||||
changed,
|
||||
pose: () => ({
|
||||
cartographic: { height: 12_000, latitude: 0.53 },
|
||||
fovy: 0.7,
|
||||
}),
|
||||
viewRectangle: () => null,
|
||||
},
|
||||
size: () => ({ width: 1280, height: 800 }),
|
||||
credits: {
|
||||
addStatic(html) {
|
||||
credits.set(html, (credits.get(html) ?? 0) + 1);
|
||||
return () => credits.set(html, credits.get(html) - 1);
|
||||
},
|
||||
},
|
||||
photorealActive: () => photoreal,
|
||||
createPass: () => ({
|
||||
set: (slots, split) => sets.push({ slots, split }),
|
||||
destroy: () => sets.push('destroyed'),
|
||||
}),
|
||||
load: (url, signal) =>
|
||||
new Promise((resolve) => jobs.push({ url, signal, resolve })),
|
||||
createCanvas(width, height) {
|
||||
const canvas = {
|
||||
width,
|
||||
height,
|
||||
getContext: () => ({ drawImage: (image) => draws.push(image) }),
|
||||
};
|
||||
canvases.push(canvas);
|
||||
return canvas;
|
||||
},
|
||||
});
|
||||
return {
|
||||
imagery,
|
||||
diagnostics,
|
||||
destroy,
|
||||
canvases,
|
||||
draws,
|
||||
jobs,
|
||||
credits,
|
||||
changed,
|
||||
last: () => sets.filter((entry) => entry !== 'destroyed').at(-1),
|
||||
sets,
|
||||
frames: () => frames,
|
||||
setPhotoreal: (value) => (photoreal = value),
|
||||
};
|
||||
}
|
||||
|
||||
const tiles = (extra = {}) => ({
|
||||
kind: 'tiles',
|
||||
url: 'https://gibs-{s}.example/{z}/{y}/{x}.png',
|
||||
maxLevel: 12,
|
||||
rectangle: BOX,
|
||||
credit: 'NASA GIBS',
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('a tiles layer mosaics the visible tiles into one atlas; a new source drops it and its loads', async () => {
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
await tick();
|
||||
const first = f.diagnostics().a;
|
||||
assert.equal(first.atlas.level, 12);
|
||||
assert.ok(first.atlas.tiles >= 1 && first.atlas.tiles <= 64);
|
||||
assert.equal(f.jobs.length, Math.min(6, first.atlas.tiles), 'six at a time');
|
||||
assert.match(
|
||||
f.jobs[0].url,
|
||||
/^https:\/\/gibs-[abc]\.example\/12\/\d+\/\d+\.png$/,
|
||||
);
|
||||
const [slot] = f.last().slots;
|
||||
assert.deepEqual(slot.look, [1, 0, 1, 0], 'alpha 1, no side, Web Mercator');
|
||||
assert.deepEqual(slot.rect, [BOX.west, BOX.south, BOX.east, BOX.north]);
|
||||
assert.equal(f.credits.get('NASA GIBS'), 1);
|
||||
// A tile arriving draws into the atlas and asks for a frame.
|
||||
const before = f.frames();
|
||||
f.jobs[0].resolve({ close() {} });
|
||||
await tick();
|
||||
assert.equal(f.draws.length, 1);
|
||||
assert.ok(f.frames() > before);
|
||||
// A restyle keeps the atlas; the split side reaches the pass.
|
||||
const texture = slot.texture;
|
||||
layer.setAlpha(0.4);
|
||||
layer.setSplit('left');
|
||||
assert.equal(f.last().slots[0].texture, texture);
|
||||
assert.deepEqual(f.last().slots[0].look, [0.4, -1, 1, 0]);
|
||||
// Another day: the atlas goes, its pending loads abort, late images close.
|
||||
let disposed = 0;
|
||||
texture.addEventListener('dispose', () => disposed++);
|
||||
const pending = f.jobs.slice(1);
|
||||
layer.setSource(tiles({ url: 'https://other.example/{z}/{x}/{y}.png' }));
|
||||
assert.equal(disposed, 1);
|
||||
assert.ok(pending.every((job) => job.signal.aborted));
|
||||
assert.equal(f.canvases[0].width, 0, 'the old atlas canvas is released');
|
||||
let closed = 0;
|
||||
for (const job of pending) job.resolve({ close: () => closed++ });
|
||||
await tick();
|
||||
assert.equal(closed, pending.length);
|
||||
assert.equal(f.draws.length, 1, 'late images never draw');
|
||||
assert.match(f.jobs.at(-1).url, /^https:\/\/other\.example\//);
|
||||
layer.destroy();
|
||||
assert.equal(f.credits.get('NASA GIBS'), 0);
|
||||
assert.deepEqual(f.last().slots, []);
|
||||
f.destroy();
|
||||
assert.equal(f.sets.at(-1), 'destroyed');
|
||||
});
|
||||
|
||||
test('an image layer is one texture; the same canvas again uploads its pixels', () => {
|
||||
const f = fixture();
|
||||
const canvas = { width: 8, height: 4, getContext: () => ({}) };
|
||||
const layer = f.imagery.createLayer({
|
||||
id: 'heat',
|
||||
source: { kind: 'image', image: canvas },
|
||||
order: 1,
|
||||
});
|
||||
const [slot] = f.last().slots;
|
||||
assert.deepEqual(slot.rect, [-180, -90, 180, 90]);
|
||||
assert.deepEqual(slot.bounds, slot.rect);
|
||||
assert.deepEqual(slot.look, [1, 0, 0, 0], 'geographic');
|
||||
assert.equal(slot.texture.image, canvas);
|
||||
assert.equal(slot.texture.flipY, false, 'row 0 is the north edge');
|
||||
const version = slot.texture.version;
|
||||
layer.setSource({ kind: 'image', image: canvas });
|
||||
assert.equal(f.last().slots[0].texture, slot.texture);
|
||||
assert.ok(slot.texture.version > version, 'its new pixels upload');
|
||||
// Across the antimeridian the east edge comes after the west one.
|
||||
layer.setSource({
|
||||
kind: 'image',
|
||||
image: { width: 2, height: 2, data: new Uint8Array(16) },
|
||||
rectangle: { west: 170, south: -10, east: -170, north: 10 },
|
||||
});
|
||||
assert.deepEqual(f.last().slots[0].rect, [170, -10, 190, 10]);
|
||||
layer.setAlpha(0);
|
||||
assert.deepEqual(f.last().slots, [], 'alpha 0 draws nothing');
|
||||
layer.setAlpha(1);
|
||||
layer.show(false);
|
||||
assert.deepEqual(f.last().slots, []);
|
||||
});
|
||||
|
||||
test('the pass draws the shown layers bottom first, at most its slot count', () => {
|
||||
const f = fixture();
|
||||
const image = { width: 1, height: 1, data: new Uint8Array(4) };
|
||||
const layers = Array.from({ length: MAX_IMAGERY_SLOTS + 2 }, (_, index) =>
|
||||
f.imagery.createLayer({
|
||||
id: `layer-${index}`,
|
||||
source: { kind: 'image', image },
|
||||
order: -index,
|
||||
alpha: 0.5 + index / 100,
|
||||
}),
|
||||
);
|
||||
const drawn = f.last().slots.map((slot) => slot.look[0]);
|
||||
assert.equal(drawn.length, MAX_IMAGERY_SLOTS);
|
||||
// Highest order last; the lowest two are left out.
|
||||
assert.deepEqual(
|
||||
drawn,
|
||||
layers
|
||||
.slice(0, MAX_IMAGERY_SLOTS)
|
||||
.map((layer) => layer.alpha())
|
||||
.reverse(),
|
||||
);
|
||||
f.imagery.setSplitPosition(0.3);
|
||||
assert.equal(f.last().split, 0.3);
|
||||
assert.equal(f.imagery.surface(), 'globe');
|
||||
f.setPhotoreal(true);
|
||||
assert.equal(f.imagery.surface(), 'tiles');
|
||||
});
|
||||
|
||||
test('a hidden tiles layer drops its atlas; shown again it builds one at the view', async () => {
|
||||
const f = fixture();
|
||||
const layer = f.imagery.createLayer({ id: 'a', source: tiles() });
|
||||
layer.show(false);
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
assert.equal(f.credits.get('NASA GIBS'), 0, 'its credit goes too');
|
||||
f.changed.raiseEvent();
|
||||
assert.equal(f.diagnostics().a.atlas, null);
|
||||
layer.show(true);
|
||||
await tick();
|
||||
assert.ok(f.diagnostics().a.atlas);
|
||||
assert.equal(f.last().slots.length, 1);
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
// engine.imagery's screen pass on Navara: one public EffectDesc right after
|
||||
// tone mapping, under every other surface pass (the remaining layer drapes,
|
||||
// engine.ground) and before the sharpening, anti-aliasing and gev's
|
||||
// overlays. Each pixel's world position comes from Navara's packed scene
|
||||
// depth (terrain, 3D tiles); where that is the ground (not a mesh in front
|
||||
// of it), up to MAX_IMAGERY_SLOTS layers are sampled in draw order at its
|
||||
// longitude and latitude, each in its rectangle, on its side of the split:
|
||||
// an image in longitude and latitude, a tile atlas in Web Mercator. Texels
|
||||
// are blended in display space by their alpha times the layer's, as
|
||||
// Cesium's globe blends imagery layers. Mip levels come from the
|
||||
// derivatives of the ground position, taken before any branch. Costs
|
||||
// nothing while no layer is drawn (the effect is hidden).
|
||||
|
||||
import { EffectDesc, Effect as NavaraEffect } from '@navaramap/three';
|
||||
import { Effect, EffectAttribute } from 'postprocessing';
|
||||
import { DataTexture, Matrix4, RGBADepthPacking, Uniform } from 'three';
|
||||
import { withFrameUpdates, withPassDisposal } from '../frameDescriptor.js';
|
||||
import { MAX_IMAGERY_SLOTS } from './index.js';
|
||||
|
||||
export const IMAGERY_EFFECT_KEY = 'gevImagery';
|
||||
|
||||
const SLOTS = Array.from({ length: MAX_IMAGERY_SLOTS }, (_, index) => index);
|
||||
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uWorld;
|
||||
uniform float uLogDepthFC;
|
||||
uniform float uSplit;
|
||||
${SLOTS.map(
|
||||
(i) => `uniform sampler2D tImage${i};
|
||||
uniform vec4 uRect${i};
|
||||
uniform vec4 uBounds${i};
|
||||
uniform vec4 uLook${i};`,
|
||||
).join('\n')}
|
||||
vec3 toDisplay(vec3 c) {
|
||||
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||
}
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
// One layer over the colour: rect is where it draws (degrees, east past
|
||||
// west), bounds the image's extent (degrees) or the atlas's (Web Mercator
|
||||
// x, y, width, height), look its alpha, side (-1 left, 1 right, 0 both)
|
||||
// and projection (1 Web Mercator).
|
||||
vec3 paint(vec3 color, sampler2D image, vec4 rect, vec4 bounds, vec4 look,
|
||||
vec2 geo, vec2 geoDx, vec2 geoDy, vec2 merc, vec2 mercDx, vec2 mercDy, float x) {
|
||||
if (look.x <= 0.0) return color;
|
||||
if ((look.y < 0.0 && x > uSplit) || (look.y > 0.0 && x < uSplit)) return color;
|
||||
float lon = geo.x < rect.x ? geo.x + 360.0 : geo.x;
|
||||
if (lon > rect.z || geo.y < rect.y || geo.y > rect.w) return color;
|
||||
vec2 at;
|
||||
vec2 dx;
|
||||
vec2 dy;
|
||||
if (look.z > 0.5) {
|
||||
vec2 m = vec2(merc.x + (lon - geo.x) / 360.0, merc.y);
|
||||
at = (m - bounds.xy) / bounds.zw;
|
||||
dx = mercDx / bounds.zw;
|
||||
dy = mercDy / bounds.zw;
|
||||
} else {
|
||||
vec2 span = vec2(bounds.z - bounds.x, bounds.y - bounds.w);
|
||||
at = vec2(lon - bounds.x, geo.y - bounds.w) / span;
|
||||
dx = geoDx / span;
|
||||
dy = geoDy / span;
|
||||
}
|
||||
if (any(lessThan(at, vec2(0.0))) || any(greaterThan(at, vec2(1.0)))) return color;
|
||||
vec4 texel = textureGrad(image, at, dx, dy);
|
||||
return mix(color, texel.rgb, texel.a * look.x);
|
||||
}
|
||||
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 = (uWorld * vec4(ray.xyz * (distance / -ray.z), 1.0)).xyz;
|
||||
// Geodetic longitude and latitude (Bowring), degrees.
|
||||
float p = length(point.xy);
|
||||
float theta = atan(point.z * 6378137.0, p * 6356752.314245);
|
||||
float st = sin(theta);
|
||||
float ct = cos(theta);
|
||||
float latitude = atan(point.z + 42841.311513 * st * st * st, p - 42697.672707 * ct * ct * ct);
|
||||
vec2 geo = vec2(atan(point.y, point.x), latitude) * 57.29577951308232;
|
||||
float clamped = clamp(latitude, -1.4844222297, 1.4844222297);
|
||||
vec2 merc = vec2((geo.x + 180.0) / 360.0,
|
||||
(1.0 - log(tan(0.7853981633974483 + clamped * 0.5)) / 3.141592653589793) / 2.0);
|
||||
// Derivatives before any divergent branch; across the antimeridian a
|
||||
// longitude step wraps; across the sky's edge there is none.
|
||||
vec2 geoDx = dFdx(geo);
|
||||
vec2 geoDy = dFdy(geo);
|
||||
vec2 mercDx = dFdx(merc);
|
||||
vec2 mercDy = dFdy(merc);
|
||||
geoDx.x -= 360.0 * floor(geoDx.x / 360.0 + 0.5);
|
||||
geoDy.x -= 360.0 * floor(geoDy.x / 360.0 + 0.5);
|
||||
mercDx.x -= floor(mercDx.x + 0.5);
|
||||
mercDy.x -= floor(mercDy.x + 0.5);
|
||||
float onSurface = 1.0 - step(1.0, depth);
|
||||
float edge = 1.0 - step(0.5, abs(dFdx(onSurface)) + abs(dFdy(onSurface)));
|
||||
geoDx *= edge;
|
||||
geoDy *= edge;
|
||||
mercDx *= edge;
|
||||
mercDy *= edge;
|
||||
if (depth >= 1.0) return;
|
||||
// The packed depth also holds forward meshes, which are not ground.
|
||||
if (depth < texture2D(tSurfaceDepth, uv).r - 2e-7) return;
|
||||
vec3 color = toDisplay(inputColor.rgb);
|
||||
${SLOTS.map(
|
||||
(i) =>
|
||||
`color = paint(color, tImage${i}, uRect${i}, uBounds${i}, uLook${i}, geo, geoDx, geoDy, merc, mercDx, mercDy, uv.x);`,
|
||||
).join('\n ')}
|
||||
outputColor = vec4(toLinear(color), inputColor.a);
|
||||
}
|
||||
`;
|
||||
|
||||
class ImageryEffectDesc extends withPassDisposal(withFrameUpdates(EffectDesc)) {
|
||||
static key = IMAGERY_EFFECT_KEY;
|
||||
// Navara takes the first anchor that exists: right after tone mapping.
|
||||
static insertAfter = ['toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevNepalEvent',
|
||||
'gevObservedWeather',
|
||||
'gevEarthquakeGround',
|
||||
'gevAwarenessPulses',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
];
|
||||
|
||||
constructor(view, ctx, config) {
|
||||
super(view, ctx, config);
|
||||
this.owner = config[IMAGERY_EFFECT_KEY].owner;
|
||||
}
|
||||
|
||||
createPass() {
|
||||
const camera = this.view.camera.raw;
|
||||
if (!camera) throw new Error('Camera not available for the imagery pass');
|
||||
this.effect = new Effect('GevImagery', fragmentShader, {
|
||||
// Its own pass: inputColor is the tone-mapped frame.
|
||||
attributes: EffectAttribute.CONVOLUTION,
|
||||
uniforms: new Map([
|
||||
['tAllDepth', new Uniform(null)],
|
||||
['tSurfaceDepth', new Uniform(null)],
|
||||
['uInverseProjection', new Uniform(new Matrix4())],
|
||||
['uWorld', new Uniform(new Matrix4())],
|
||||
['uLogDepthFC', new Uniform(1)],
|
||||
['uSplit', new Uniform(0.5)],
|
||||
...SLOTS.flatMap((i) => [
|
||||
[`tImage${i}`, new Uniform(null)],
|
||||
[`uRect${i}`, new Uniform([0, 0, 0, 0])],
|
||||
[`uBounds${i}`, new Uniform([0, 0, 1, 1])],
|
||||
[`uLook${i}`, new Uniform([0, 0, 0, 0])],
|
||||
]),
|
||||
]),
|
||||
});
|
||||
return new NavaraEffect(camera, this.effect);
|
||||
}
|
||||
|
||||
updateFrame() {
|
||||
const camera = this.view.camera.raw;
|
||||
const owner = this.owner;
|
||||
if (!this.effect || !camera) return;
|
||||
const mrt = this.find('mrt');
|
||||
if (mrt?.depthBufferPacking !== RGBADepthPacking)
|
||||
throw new Error('The imagery pass needs packed scene depth');
|
||||
const u = this.effect.uniforms;
|
||||
u.get('tAllDepth').value = mrt.depthBuffer;
|
||||
u.get('tSurfaceDepth').value = this.ctx.getRenderTarget().depthTexture;
|
||||
u.get('uInverseProjection').value.copy(camera.projectionMatrixInverse);
|
||||
u.get('uWorld').value.copy(camera.matrixWorld);
|
||||
u.get('uLogDepthFC').value = 2 / Math.log2(camera.far + 1);
|
||||
u.get('uSplit').value = owner.split;
|
||||
for (const i of SLOTS) {
|
||||
const slot = owner.slots[i];
|
||||
u.get(`tImage${i}`).value = slot?.texture ?? owner.empty;
|
||||
u.get(`uRect${i}`).value = slot?.rect ?? [0, 0, 0, 0];
|
||||
u.get(`uBounds${i}`).value = slot?.bounds ?? [0, 0, 1, 1];
|
||||
u.get(`uLook${i}`).value = slot?.look ?? [0, 0, 0, 0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const registered = new WeakSet();
|
||||
|
||||
/**
|
||||
* The pass of one view: set(slots, split) draws up to MAX_IMAGERY_SLOTS
|
||||
* layers ({ texture, rect, bounds, look }, bottom first; none: the effect
|
||||
* hidden) with the split at `split` (a fraction of the width from the left).
|
||||
*/
|
||||
export function createImageryPass(view) {
|
||||
if (!registered.has(view)) {
|
||||
view.registerEffect(IMAGERY_EFFECT_KEY, ImageryEffectDesc);
|
||||
registered.add(view);
|
||||
}
|
||||
const empty = new DataTexture(new Uint8Array(4), 1, 1);
|
||||
empty.needsUpdate = true;
|
||||
const owner = { slots: [], split: 0.5, empty };
|
||||
let handle = null;
|
||||
return {
|
||||
owner,
|
||||
set(slots, split) {
|
||||
owner.slots = slots.slice(0, MAX_IMAGERY_SLOTS);
|
||||
owner.split = split;
|
||||
const visible = owner.slots.length > 0;
|
||||
if (visible && !handle)
|
||||
handle = view.addEffect({
|
||||
[IMAGERY_EFFECT_KEY]: { owner },
|
||||
visible: true,
|
||||
});
|
||||
else handle?.update({ visible });
|
||||
},
|
||||
destroy() {
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
owner.slots = [];
|
||||
empty.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,8 @@ import { createNavaraMovers } from './movers/index.js';
|
||||
import { createNavaraLines } from './lines/index.js';
|
||||
import { createNavaraGround } from './ground/index.js';
|
||||
import { createGroundPass } from './ground/pass.js';
|
||||
import { createNavaraImagery } from './imagery/index.js';
|
||||
import { createImageryPass } from './imagery/pass.js';
|
||||
import { createNavaraOverlay } from './overlay.js';
|
||||
import { createPostSampling } from './postSampling.js';
|
||||
import {
|
||||
@@ -173,6 +175,8 @@ export async function createNavaraGlobe({ container, search, hd = false }) {
|
||||
}),
|
||||
lines: createNavaraLines(view, { overlay }),
|
||||
ground: createNavaraGround(view, { createPass: createGroundPass }),
|
||||
imagery: (parts) =>
|
||||
createNavaraImagery(view, { ...parts, createPass: createImageryPass }),
|
||||
});
|
||||
const camera = engine.camera;
|
||||
let ground = Number.NaN;
|
||||
|
||||
@@ -2747,6 +2747,7 @@ export function createContractSuite(
|
||||
];
|
||||
checks.push(
|
||||
...groundChecks(engine, { settle, until, nadirView, readRegion, pointer }),
|
||||
...imageryChecks(engine, { settle, until, nadirView, readRegion }),
|
||||
);
|
||||
if (mapSources?.photoreal && mapSources.imagery?.length >= 2)
|
||||
checks.push(...mapChecks(engine.maps, mapSources));
|
||||
@@ -3241,6 +3242,350 @@ function groundChecks(
|
||||
];
|
||||
}
|
||||
|
||||
// engine.imagery: raster layers on the ground (every engine).
|
||||
function imageryChecks(engine, { settle, until, nadirView, readRegion }) {
|
||||
const { imagery } = engine;
|
||||
const throwsType = (run, label) => {
|
||||
let error = null;
|
||||
try {
|
||||
run();
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
ensure(error instanceof TypeError, `${label} did not throw a TypeError`);
|
||||
};
|
||||
// A flat colour as RGBA bytes (no canvas needed), size × size texels.
|
||||
const flat = (rgba, size = 8) => {
|
||||
const data = new Uint8Array(size * size * 4);
|
||||
for (let i = 0; i < data.length; i += 4) data.set(rgba, i);
|
||||
return { width: size, height: size, data };
|
||||
};
|
||||
const box = { west: 19, south: 9.5, east: 20, north: 10.5 };
|
||||
const listed = (id) => imagery.list().find((entry) => entry.id === id);
|
||||
return [
|
||||
[
|
||||
'imagery: layers are created, listed, restyled, ordered and destroyed',
|
||||
async () => {
|
||||
const source = { kind: 'image', image: flat([255, 0, 255, 255]) };
|
||||
throwsType(
|
||||
() =>
|
||||
imagery.createLayer({ id: 'contract-bad', source: { kind: 'x' } }),
|
||||
'an unknown source kind',
|
||||
);
|
||||
throwsType(
|
||||
() =>
|
||||
imagery.createLayer({
|
||||
id: 'contract-bad',
|
||||
source: {
|
||||
kind: 'tiles',
|
||||
url: 'https://example.test/{z}/{x}.png',
|
||||
},
|
||||
}),
|
||||
'a template without {y}',
|
||||
);
|
||||
throwsType(
|
||||
() =>
|
||||
imagery.createLayer({
|
||||
id: 'contract-bad',
|
||||
source: {
|
||||
kind: 'image',
|
||||
image: { width: 2, height: 2, data: new Uint8Array(3) },
|
||||
},
|
||||
}),
|
||||
'RGBA bytes of the wrong length',
|
||||
);
|
||||
throwsType(
|
||||
() => imagery.createLayer({ id: 'contract-bad', source, alpha: 2 }),
|
||||
'an alpha above 1',
|
||||
);
|
||||
throwsType(
|
||||
() =>
|
||||
imagery.createLayer({ id: 'contract-bad', source, split: 'up' }),
|
||||
'an unknown split',
|
||||
);
|
||||
throwsType(
|
||||
() =>
|
||||
imagery.createLayer({
|
||||
id: 'contract-bad',
|
||||
source: {
|
||||
...source,
|
||||
rectangle: { west: 0, south: 5, east: 1, north: 4 },
|
||||
},
|
||||
}),
|
||||
'a rectangle upside down',
|
||||
);
|
||||
ensure(!listed('contract-bad'), 'a refused layer is listed');
|
||||
ensure(
|
||||
['globe', 'tiles', 'none'].includes(imagery.surface()),
|
||||
`surface() ${imagery.surface()}`,
|
||||
);
|
||||
const top = imagery.createLayer({
|
||||
id: 'contract-imagery-top',
|
||||
source,
|
||||
order: 2,
|
||||
});
|
||||
const bottom = imagery.createLayer({
|
||||
id: 'contract-imagery-bottom',
|
||||
source: {
|
||||
kind: 'image',
|
||||
image: flat([0, 255, 0, 255]),
|
||||
rectangle: box,
|
||||
},
|
||||
alpha: 0.5,
|
||||
show: false,
|
||||
split: 'left',
|
||||
});
|
||||
const split = imagery.splitPosition();
|
||||
try {
|
||||
let duplicate = null;
|
||||
try {
|
||||
imagery.createLayer({ id: 'contract-imagery-top', source });
|
||||
} catch (error) {
|
||||
duplicate = error;
|
||||
}
|
||||
ensure(duplicate, 'a second live layer with the same id');
|
||||
ensure(top.id === 'contract-imagery-top', `id ${top.id}`);
|
||||
const ids = imagery.list().map(({ id }) => id);
|
||||
ensure(
|
||||
ids.indexOf('contract-imagery-bottom') <
|
||||
ids.indexOf('contract-imagery-top'),
|
||||
`list() is not in draw order: ${ids.join(', ')}`,
|
||||
);
|
||||
ensure(
|
||||
listed('contract-imagery-bottom')?.kind === 'image' &&
|
||||
listed('contract-imagery-bottom').shown === false &&
|
||||
listed('contract-imagery-bottom').alpha === 0.5 &&
|
||||
listed('contract-imagery-bottom').split === 'left' &&
|
||||
listed('contract-imagery-top').order === 2,
|
||||
`list(): ${JSON.stringify(imagery.list())}`,
|
||||
);
|
||||
ensure(
|
||||
bottom.shown() === false &&
|
||||
bottom.alpha() === 0.5 &&
|
||||
bottom.split() === 'left',
|
||||
'the options are not the layer state',
|
||||
);
|
||||
bottom.show(true);
|
||||
bottom.setAlpha(0.25);
|
||||
bottom.setSplit('right');
|
||||
ensure(
|
||||
bottom.shown() === true &&
|
||||
bottom.alpha() === 0.25 &&
|
||||
bottom.split() === 'right' &&
|
||||
listed('contract-imagery-bottom').alpha === 0.25,
|
||||
'a restyle is not reported',
|
||||
);
|
||||
throwsType(() => bottom.setAlpha(-1), 'a negative alpha');
|
||||
throwsType(() => bottom.setSplit('down'), 'an unknown side');
|
||||
throwsType(
|
||||
() => bottom.setSource({ kind: 'image' }),
|
||||
'an empty source',
|
||||
);
|
||||
ensure(
|
||||
bottom.source().kind === 'image',
|
||||
'a refused source replaced it',
|
||||
);
|
||||
bottom.setSource({
|
||||
kind: 'tiles',
|
||||
url: 'https://tiles.invalid/{z}/{x}/{y}.png',
|
||||
maxLevel: 3,
|
||||
rectangle: box,
|
||||
});
|
||||
ensure(
|
||||
bottom.source().kind === 'tiles' &&
|
||||
listed('contract-imagery-bottom').kind === 'tiles',
|
||||
'setSource() did not replace the source',
|
||||
);
|
||||
imagery.setSplitPosition(0.25);
|
||||
ensure(imagery.splitPosition() === 0.25, 'the split position');
|
||||
throwsType(
|
||||
() => imagery.setSplitPosition(1.5),
|
||||
'a split outside 0..1',
|
||||
);
|
||||
await until(() => top.ready(), 'the image layer ready');
|
||||
await settle();
|
||||
} finally {
|
||||
imagery.setSplitPosition(split);
|
||||
top.destroy();
|
||||
bottom.destroy();
|
||||
}
|
||||
ensure(!listed('contract-imagery-top'), 'a destroyed layer is listed');
|
||||
ensure(!top.ready(), 'a destroyed layer is ready');
|
||||
top.destroy();
|
||||
let refused = null;
|
||||
try {
|
||||
top.setAlpha(1);
|
||||
} catch (error) {
|
||||
refused = error;
|
||||
}
|
||||
ensure(refused, 'a destroyed layer took a restyle');
|
||||
},
|
||||
],
|
||||
[
|
||||
'imagery: images draw on the ground by alpha, show, order and split (pixels)',
|
||||
async () => {
|
||||
if (!readRegion) return;
|
||||
await nadirView(1e6);
|
||||
const MAGENTA = [255, 0, 255];
|
||||
const GREEN = [0, 255, 0];
|
||||
const screenOf = (lon, lat) =>
|
||||
engine.projector.worldToWindow(cartesian3.fromDegrees(lon, lat));
|
||||
// The mean colour in a box centred on a ground point.
|
||||
const meanAt = (lon, lat, half = 6) => {
|
||||
const { x, y } = screenOf(lon, lat);
|
||||
const data = readRegion(
|
||||
Math.round(x) - half,
|
||||
Math.round(y) - half,
|
||||
2 * half,
|
||||
2 * half,
|
||||
);
|
||||
const mean = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let k = 0; k < 3; k++) mean[k] += data[i + k];
|
||||
return mean.map((sum) => sum / (data.length / 4));
|
||||
};
|
||||
const near = (actual, expected, tolerance = 40) =>
|
||||
actual.every(
|
||||
(value, k) => Math.abs(value - expected[k]) <= tolerance,
|
||||
);
|
||||
const show = (colour) =>
|
||||
colour.map((value) => value.toFixed(0)).join(',');
|
||||
// The map's own tiles loaded: what lies under the layers holds still.
|
||||
await until(() => engine.surface.tilesLoaded(), 'the map tiles loaded');
|
||||
await settle(4);
|
||||
const base = meanAt(19.5, 10);
|
||||
ensure(!near(base, MAGENTA), 'the globe is already magenta there');
|
||||
const magenta = imagery.createLayer({
|
||||
id: 'contract-paint-magenta',
|
||||
source: {
|
||||
kind: 'image',
|
||||
image: flat([...MAGENTA, 255]),
|
||||
rectangle: box,
|
||||
},
|
||||
});
|
||||
const layers = [magenta];
|
||||
try {
|
||||
await until(() => magenta.ready(), 'the magenta layer ready');
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), MAGENTA),
|
||||
'the magenta layer drawn',
|
||||
);
|
||||
ensure(
|
||||
near(meanAt(21, 10), base, 60) || !near(meanAt(21, 10), MAGENTA),
|
||||
'the layer spills outside its rectangle',
|
||||
);
|
||||
// Alpha: at 0 the globe under it, at 0.5 half way to magenta.
|
||||
magenta.setAlpha(0);
|
||||
await until(() => !near(meanAt(19.5, 10), MAGENTA), 'alpha 0 drawn');
|
||||
await settle(2);
|
||||
const below = meanAt(19.5, 10);
|
||||
magenta.setAlpha(0.5);
|
||||
const half = below.map((value, k) => (value + MAGENTA[k]) / 2);
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), half, 30),
|
||||
`alpha 0.5 drawn (${show(half)} wanted)`,
|
||||
);
|
||||
magenta.setAlpha(1);
|
||||
magenta.show(false);
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), below, 30),
|
||||
'the hidden layer gone',
|
||||
);
|
||||
magenta.show(true);
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), MAGENTA),
|
||||
'the layer shown again',
|
||||
);
|
||||
// Order: a higher order draws on top, a lower one under it.
|
||||
const green = imagery.createLayer({
|
||||
id: 'contract-paint-green',
|
||||
source: {
|
||||
kind: 'image',
|
||||
image: flat([...GREEN, 255]),
|
||||
rectangle: box,
|
||||
},
|
||||
order: 1,
|
||||
});
|
||||
layers.push(green);
|
||||
const lower = imagery.createLayer({
|
||||
id: 'contract-paint-under',
|
||||
source: {
|
||||
kind: 'image',
|
||||
image: flat([0, 255, 255, 255]),
|
||||
rectangle: box,
|
||||
},
|
||||
order: -1,
|
||||
});
|
||||
layers.push(lower);
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), GREEN),
|
||||
'the higher order on top',
|
||||
);
|
||||
green.destroy();
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), MAGENTA),
|
||||
'the destroyed layer gone',
|
||||
);
|
||||
// A new source in place: the same layer, another colour.
|
||||
magenta.setSource({
|
||||
kind: 'image',
|
||||
image: flat([...GREEN, 255]),
|
||||
rectangle: box,
|
||||
});
|
||||
await until(
|
||||
() => near(meanAt(19.5, 10), GREEN),
|
||||
'the new source drawn',
|
||||
);
|
||||
magenta.setSource({
|
||||
kind: 'image',
|
||||
image: flat([...MAGENTA, 255]),
|
||||
rectangle: box,
|
||||
});
|
||||
lower.destroy();
|
||||
// Split: a left layer draws only left of the split position.
|
||||
const width = engine.host.size().width;
|
||||
const middle = screenOf(19.5, 10).x;
|
||||
imagery.setSplitPosition(middle / width);
|
||||
magenta.setSplit('left');
|
||||
const left = screenOf(19.25, 10);
|
||||
const right = screenOf(19.75, 10);
|
||||
const at = ({ x, y }) => {
|
||||
const data = readRegion(Math.round(x) - 3, Math.round(y) - 3, 6, 6);
|
||||
const mean = [0, 0, 0];
|
||||
for (let i = 0; i < data.length; i += 4)
|
||||
for (let k = 0; k < 3; k++) mean[k] += data[i + k] / 36;
|
||||
return mean;
|
||||
};
|
||||
await until(
|
||||
() => near(at(left), MAGENTA) && !near(at(right), MAGENTA),
|
||||
`the left split drawn (left ${show(at(left))}, right ${show(at(right))})`,
|
||||
);
|
||||
magenta.setSplit('right');
|
||||
await until(
|
||||
() => !near(at(left), MAGENTA) && near(at(right), MAGENTA),
|
||||
'the right split drawn',
|
||||
);
|
||||
magenta.setSplit('none');
|
||||
await until(
|
||||
() => near(at(left), MAGENTA) && near(at(right), MAGENTA),
|
||||
'the unsplit layer drawn',
|
||||
);
|
||||
} finally {
|
||||
imagery.setSplitPosition(0.5);
|
||||
for (const layer of layers) layer.destroy();
|
||||
}
|
||||
await until(
|
||||
() =>
|
||||
[MAGENTA, GREEN, [0, 255, 255]].every(
|
||||
(colour) => !near(meanAt(19.5, 10), colour),
|
||||
),
|
||||
'every layer gone',
|
||||
);
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
// The optional contactSonar capability (render.setContactSonar).
|
||||
function contactSonarChecks(render, settle) {
|
||||
return [
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
viewRectangle,
|
||||
} from '../cameraMath.js';
|
||||
import { createGroundPicks, createGroundRegistry } from '../ground.js';
|
||||
import { createImageryRegistry } from '../imagery.js';
|
||||
import { createLineRegistry } from '../lines.js';
|
||||
import { createMoverRegistry } from '../movers.js';
|
||||
import { createCpuPicker } from '../cpuPicks.js';
|
||||
@@ -268,6 +269,27 @@ export function createFakeEngine(options = {}) {
|
||||
},
|
||||
destroy() {},
|
||||
}));
|
||||
// Imagery layers keep their state for tests; every change is a render
|
||||
// request. They drape on the globe, or on tiles while photoreal shows.
|
||||
const imageryRegistry = createImageryRegistry(
|
||||
() => ({
|
||||
source() {
|
||||
fake.renderRequests++;
|
||||
},
|
||||
look() {
|
||||
fake.renderRequests++;
|
||||
},
|
||||
destroy() {
|
||||
fake.renderRequests++;
|
||||
},
|
||||
}),
|
||||
{
|
||||
splitPosition() {
|
||||
fake.renderRequests++;
|
||||
},
|
||||
surface: () => (fake.maps.photoreal != null ? 'tiles' : 'globe'),
|
||||
},
|
||||
);
|
||||
const inputs = new Map(INPUT_TYPES.map((type) => [type, new Set()]));
|
||||
const livePosition = cartesian3.create();
|
||||
let lastBaseImagery = null;
|
||||
@@ -664,6 +686,7 @@ export function createFakeEngine(options = {}) {
|
||||
movers: moverRegistry.movers,
|
||||
lines: lineRegistry.lines,
|
||||
ground: groundRegistry.ground,
|
||||
imagery: imageryRegistry.imagery,
|
||||
clock: {
|
||||
now: () => appClock.now(),
|
||||
frameTime: () => appClock.frameTime(),
|
||||
@@ -802,6 +825,7 @@ export function createFakeEngine(options = {}) {
|
||||
moverRegistry.destroyAll();
|
||||
lineRegistry.destroyAll();
|
||||
groundRegistry.destroyAll();
|
||||
imageryRegistry.destroyAll();
|
||||
fake.destroyed = true;
|
||||
},
|
||||
isDestroyed: () => fake.destroyed,
|
||||
@@ -889,6 +913,24 @@ export function createFakeEngine(options = {}) {
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
/**
|
||||
* A live imagery layer's state, by id: { options, source, alpha, shown,
|
||||
* split }; `layers()` all of them in draw order.
|
||||
*/
|
||||
imageryLayer(id) {
|
||||
const state = imageryRegistry.live.get(id);
|
||||
return state
|
||||
? {
|
||||
options: state.options,
|
||||
source: state.source,
|
||||
alpha: state.alpha,
|
||||
shown: state.shown,
|
||||
split: state.split,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
imageryLayers: () =>
|
||||
imageryRegistry.ordered().map(({ options }) => options.id),
|
||||
/** The app clock the engine clock reads (freeze/offset/rate for tests). */
|
||||
appClock,
|
||||
/** Moves the fake wall clock without a frame (own app clock only). */
|
||||
|
||||
@@ -88,12 +88,10 @@ const pulseEffect = defineGroundEffect({
|
||||
name: 'GevAwarenessPulses',
|
||||
fragmentShader,
|
||||
insertAfter: [
|
||||
'gevEarthquakeGround',
|
||||
'gevGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevRecentImagery',
|
||||
'gevFirmsHeat',
|
||||
'gevFirmsGround',
|
||||
'gevImagery',
|
||||
'toneMapping',
|
||||
],
|
||||
insertBefore: [
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as geo from '../../geo/index.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
import { createFirmsHeatmapLayer } from './index.js';
|
||||
import { createFirmsDisplay } from './render/display.js';
|
||||
import { createFirmsHeatDrape } from './render/navaraHeat.js';
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 5; i++)
|
||||
@@ -64,32 +63,6 @@ function fixture() {
|
||||
roll: 0,
|
||||
});
|
||||
pose(12_000_000);
|
||||
const effects = [];
|
||||
const native = {
|
||||
view: {
|
||||
camera: {},
|
||||
registerEffect() {},
|
||||
addEffect(config) {
|
||||
const handle = {
|
||||
config,
|
||||
visible: config.visible,
|
||||
deleted: false,
|
||||
update(next) {
|
||||
if ('visible' in next) handle.visible = next.visible;
|
||||
},
|
||||
delete() {
|
||||
handle.deleted = true;
|
||||
},
|
||||
};
|
||||
effects.push(handle);
|
||||
return handle;
|
||||
},
|
||||
},
|
||||
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',
|
||||
@@ -117,8 +90,7 @@ function fixture() {
|
||||
},
|
||||
overlayHost: { setEntries: noop, setVisible: noop, clearSource: noop },
|
||||
renderer: {
|
||||
create: ({ engine: target, id }) =>
|
||||
createFirmsDisplay(target, { id, navaraHeat: createFirmsHeatDrape }),
|
||||
create: ({ engine: target, id }) => createFirmsDisplay(target, { id }),
|
||||
},
|
||||
});
|
||||
layer.init(null, { engine });
|
||||
@@ -129,18 +101,15 @@ function fixture() {
|
||||
engine,
|
||||
layer,
|
||||
pose,
|
||||
// The heatmap as drawn: its raster's alpha at the fire, 0 when the
|
||||
// drape is hidden, faded out or unpainted.
|
||||
// The heatmap as drawn: its raster's alpha at the fire times the
|
||||
// layer's, 0 when the imagery layer is hidden, faded out or unpainted.
|
||||
drawnHeat() {
|
||||
const handle = effects.find(
|
||||
(effect) => effect.config.gevFirmsHeat && !effect.deleted,
|
||||
);
|
||||
const owner = handle?.config.gevFirmsHeat.owner;
|
||||
if (!handle?.visible || !owner?.texture || !(owner.opacity > 0)) return 0;
|
||||
const { data, width, height } = owner.texture.image;
|
||||
const heat = engine.fake.imageryLayer('local-firms:heat');
|
||||
if (!heat?.shown || !(heat.alpha > 0)) return 0;
|
||||
const { data, width, height } = heat.source.image;
|
||||
const x = Math.floor(((FIRE.lon + 180) / 360) * width);
|
||||
const y = Math.floor(((90 - FIRE.lat) / 180) * height);
|
||||
return (data[(y * width + x) * 4 + 3] / 255) * owner.opacity;
|
||||
return (data[(y * width + x) * 4 + 3] / 255) * heat.alpha;
|
||||
},
|
||||
cards: () => layer.getStats().cells,
|
||||
// Fire sprites actually drawn (shown instances of the engine set).
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Drapes the Active Fires heat raster over the globe as one full-extent
|
||||
* imagery tile on the active imagery host (globe or 3D-tiles), like the wind
|
||||
* layer's scalar field. Owns the canvas and the imagery layer; nothing else
|
||||
* in the fires layer touches imagery.
|
||||
*
|
||||
* `show` is only for the layer being on or off. The zoom hand-off fades
|
||||
* `alpha` instead: Cesium unmaps a hidden imagery layer from the globe
|
||||
* tiles and re-maps it when shown, so a show toggle at the band edge left
|
||||
* a beat with neither markers nor heat. At alpha 0 the layer stays mapped
|
||||
* and costs no draw.
|
||||
*/
|
||||
|
||||
const defaultCanvas = (width, height) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
return canvas;
|
||||
};
|
||||
|
||||
export function createHeatImagery({
|
||||
cesium,
|
||||
getHost,
|
||||
createCanvas = defaultCanvas,
|
||||
}) {
|
||||
let raster = null;
|
||||
let layer = null;
|
||||
let collection = null;
|
||||
let show = true;
|
||||
let opacity = 1;
|
||||
|
||||
function detach() {
|
||||
if (layer && collection) collection.remove(layer, true);
|
||||
layer = null;
|
||||
collection = null;
|
||||
}
|
||||
|
||||
function attach() {
|
||||
detach();
|
||||
if (!raster) return;
|
||||
const host = getHost?.() ?? { collection: null, kind: 'none' };
|
||||
if (!host.collection) return;
|
||||
const canvas = createCanvas(raster.width, raster.height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const image = ctx.createImageData(raster.width, raster.height);
|
||||
image.data.set(raster.rgba);
|
||||
ctx.putImageData(image, 0, 0);
|
||||
const provider = new cesium.SingleTileImageryProvider({
|
||||
url: canvas.toDataURL('image/png'),
|
||||
tileWidth: raster.width,
|
||||
tileHeight: raster.height,
|
||||
rectangle: cesium.Rectangle.MAX_VALUE,
|
||||
});
|
||||
collection = host.collection;
|
||||
layer = collection.addImageryProvider(provider);
|
||||
layer.show = show;
|
||||
layer.alpha = opacity;
|
||||
collection.raiseToTop?.(layer);
|
||||
}
|
||||
|
||||
return {
|
||||
setRaster(next) {
|
||||
raster = next;
|
||||
attach();
|
||||
},
|
||||
/** True once a raster was handed over (painted or waiting for a host). */
|
||||
hasRaster() {
|
||||
return raster !== null;
|
||||
},
|
||||
setShow(value) {
|
||||
show = Boolean(value);
|
||||
if (layer) layer.show = show;
|
||||
},
|
||||
/** Hand-off fade, 0..1; the layer stays mapped at 0. */
|
||||
setOpacity(value) {
|
||||
opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
if (layer) layer.alpha = opacity;
|
||||
},
|
||||
rehome() {
|
||||
const host = getHost?.();
|
||||
if ((host?.collection ?? null) !== collection) attach();
|
||||
},
|
||||
destroy() {
|
||||
detach();
|
||||
raster = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHeatImagery } from './heatImagery.js';
|
||||
import { buildHeatRaster } from '../../data/firmsHeatRaster.js';
|
||||
import { HEAT_RASTER } from './policy.js';
|
||||
|
||||
function harness() {
|
||||
const layers = [];
|
||||
const removed = [];
|
||||
const collection = {
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, show: true, alpha: 1 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
removed.push({ layer, destroy });
|
||||
},
|
||||
raiseToTop() {},
|
||||
};
|
||||
let host = { collection, kind: 'globe' };
|
||||
const cesium = {
|
||||
Rectangle: { MAX_VALUE: { max: true } },
|
||||
SingleTileImageryProvider: class {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
}
|
||||
},
|
||||
};
|
||||
const createCanvas = (w, h) => ({
|
||||
width: w,
|
||||
height: h,
|
||||
getContext: () => ({
|
||||
createImageData: (cw, ch) => ({
|
||||
data: new Uint8ClampedArray(cw * ch * 4),
|
||||
}),
|
||||
putImageData() {},
|
||||
}),
|
||||
toDataURL: () => 'data:image/png;base64,AAAA',
|
||||
});
|
||||
const imagery = createHeatImagery({
|
||||
cesium,
|
||||
getHost: () => host,
|
||||
createCanvas,
|
||||
});
|
||||
return { imagery, layers, removed, collection, setHost: (h) => (host = h) };
|
||||
}
|
||||
|
||||
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
|
||||
|
||||
test('setRaster drapes one full-globe tile and replaces it on the next build', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1);
|
||||
assert.deepEqual(h.layers[0].provider.options.rectangle, { max: true });
|
||||
assert.equal(h.layers[0].provider.options.tileWidth, 4);
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 1, 'never two heat layers');
|
||||
assert.equal(h.removed[0].destroy, true);
|
||||
});
|
||||
|
||||
test('setShow toggles without rebuilding', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
const layer = h.layers[0];
|
||||
h.imagery.setShow(false);
|
||||
assert.equal(layer.show, false);
|
||||
h.imagery.setShow(true);
|
||||
assert.equal(h.layers[0], layer);
|
||||
assert.equal(layer.show, true);
|
||||
});
|
||||
|
||||
test('setOpacity fades the same layer and survives a rebuild', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
const layer = h.layers[0];
|
||||
h.imagery.setOpacity(0);
|
||||
assert.equal(layer.alpha, 0);
|
||||
assert.equal(layer.show, true, 'a faded layer stays mapped');
|
||||
h.imagery.setOpacity(0.25);
|
||||
assert.equal(h.layers[0], layer);
|
||||
assert.equal(layer.alpha, 0.25);
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers[0].alpha, 0.25, 'a rebuilt layer keeps the fade');
|
||||
h.imagery.setOpacity(7);
|
||||
assert.equal(h.layers[0].alpha, 1);
|
||||
});
|
||||
|
||||
test('no imagery host keeps nothing draped; rehome brings it back', () => {
|
||||
const h = harness();
|
||||
h.setHost({ collection: null, kind: 'none' });
|
||||
h.imagery.setRaster(raster);
|
||||
assert.equal(h.layers.length, 0);
|
||||
h.setHost({ collection: h.collection, kind: 'globe' });
|
||||
h.imagery.rehome();
|
||||
assert.equal(h.layers.length, 1);
|
||||
});
|
||||
|
||||
test('destroy removes the layer', () => {
|
||||
const h = harness();
|
||||
h.imagery.setRaster(raster);
|
||||
h.imagery.destroy();
|
||||
assert.equal(h.layers.length, 0);
|
||||
});
|
||||
|
||||
test('the shipped raster paints a lone weak fire as a distinct spot', () => {
|
||||
// A hot cluster elsewhere sets the scale, as Africa does on real data.
|
||||
const fires = [{ lat: 0, lon: 0, frp: 5 }];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
fires.push({ lat: -10 + (i % 5) * 0.1, lon: 25 + i * 0.05, frp: 300 });
|
||||
}
|
||||
const r = buildHeatRaster(fires, HEAT_RASTER);
|
||||
const y = Math.floor((90 / 180) * r.height);
|
||||
const painted = [];
|
||||
for (let x = 0; x < r.width; x++) {
|
||||
const lon = ((x + 0.5) / r.width) * 360 - 180;
|
||||
if (Math.abs(lon) < 5 && r.rgba[(y * r.width + x) * 4 + 3] > 0) {
|
||||
painted.push(x);
|
||||
}
|
||||
}
|
||||
const kmPerPx = 40075 / r.width;
|
||||
assert.ok(painted.length > 0, 'the weak fire still shows');
|
||||
assert.ok(
|
||||
painted.length * kmPerPx <= 100,
|
||||
`spot ${Math.round(painted.length * kmPerPx)} km wide`,
|
||||
);
|
||||
});
|
||||
@@ -23,21 +23,6 @@ export function createLifecycle({
|
||||
}
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* 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 the globe's own
|
||||
* imagery (render/heatField.js).
|
||||
* @param {{imageryHost?: () => {collection: ?Object, kind: string}}|null} services
|
||||
*/
|
||||
attachShellServices(services) {
|
||||
layerState._imageryHost =
|
||||
typeof services?.imageryHost === 'function'
|
||||
? services.imageryHost
|
||||
: null;
|
||||
layerState._heatImagery?.rehome();
|
||||
},
|
||||
|
||||
init(viewer, options) {
|
||||
this.attachRenderer(viewer, options);
|
||||
},
|
||||
@@ -161,7 +146,6 @@ export function createLifecycle({
|
||||
layerState._cellCacheByGrid.clear();
|
||||
layerState.glowSpriteCache.clear();
|
||||
releaseHeat();
|
||||
layerState._imageryHost = null;
|
||||
layerState._count = 0;
|
||||
layerState._totalCount = 0;
|
||||
layerState._rowControlsListener = null;
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,15 +5,17 @@
|
||||
// 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.
|
||||
// engine's (engine.camera, engine.projector);
|
||||
// - the far-zoom heatmap: one engine.imagery layer, the heat raster as RGBA
|
||||
// bytes over the whole globe, faded by its alpha for the hand-off.
|
||||
|
||||
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';
|
||||
|
||||
// Above imagery layers of the default order (a pinned Recent Imagery day).
|
||||
const HEAT_ORDER = 1;
|
||||
|
||||
const rgbaOf = (color) => [
|
||||
color?.red ?? 1,
|
||||
@@ -22,25 +24,89 @@ const rgbaOf = (color) => [
|
||||
color?.alpha ?? 1,
|
||||
];
|
||||
|
||||
/**
|
||||
* The heatmap on engine.imagery: setRaster (data/firmsHeatRaster.js's
|
||||
* equirectangular RGBA over the globe; a new one replaces the image in
|
||||
* place), hasRaster, setShow (the layer on or off), setOpacity (the
|
||||
* hand-off fade, 0..1: a faded layer stays on its surface, so it comes
|
||||
* back without a gap) and destroy. The engine layer is made with the first
|
||||
* raster.
|
||||
*/
|
||||
function createHeatImagery(engine, id, onDestroy) {
|
||||
let layer = null,
|
||||
raster = null,
|
||||
show = true,
|
||||
opacity = 1,
|
||||
destroyed = false;
|
||||
const heat = {
|
||||
setRaster(next) {
|
||||
if (destroyed) return;
|
||||
raster = next ?? null;
|
||||
if (!raster) {
|
||||
layer?.destroy();
|
||||
layer = null;
|
||||
return;
|
||||
}
|
||||
const source = {
|
||||
kind: 'image',
|
||||
image: {
|
||||
width: raster.width,
|
||||
height: raster.height,
|
||||
data: raster.rgba,
|
||||
},
|
||||
};
|
||||
if (layer) layer.setSource(source);
|
||||
else
|
||||
layer = engine.imagery.createLayer({
|
||||
id,
|
||||
source,
|
||||
alpha: opacity,
|
||||
show,
|
||||
order: HEAT_ORDER,
|
||||
});
|
||||
},
|
||||
/** True once a raster was handed over. */
|
||||
hasRaster: () => raster !== null,
|
||||
setShow(value) {
|
||||
if (destroyed) return;
|
||||
show = Boolean(value);
|
||||
layer?.show(show);
|
||||
},
|
||||
setOpacity(value) {
|
||||
if (destroyed) return;
|
||||
opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
layer?.setAlpha(opacity);
|
||||
},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
layer?.destroy();
|
||||
layer = null;
|
||||
raster = null;
|
||||
onDestroy(heat);
|
||||
},
|
||||
};
|
||||
return heat;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
* @param {{ id?: string, loadImage?: Function, createCanvas?: Function }} [options]
|
||||
* `loadImage` and `createCanvas` are the sprite atlas's test seams.
|
||||
*/
|
||||
export function createFirmsDisplay(
|
||||
engine,
|
||||
{ id = 'local-firms', navaraHeat, loadImage, createCanvas } = {},
|
||||
{ id = 'local-firms', loadImage, createCanvas } = {},
|
||||
) {
|
||||
if (!engine?.movers || !engine?.ground)
|
||||
if (!engine?.movers || !engine?.ground || !engine?.imagery)
|
||||
throw new TypeError('The FIRMS display needs a globe engine');
|
||||
let destroyed = false,
|
||||
visible = false,
|
||||
cellItems = [],
|
||||
cellsQueued = false,
|
||||
sprites = null;
|
||||
sprites = null,
|
||||
heat = null;
|
||||
// The layer's visibility as the context store reads a data source's.
|
||||
const dataSource = { show: false };
|
||||
const clickRemovers = new Set();
|
||||
@@ -144,14 +210,16 @@ export function createFirmsDisplay(
|
||||
/** 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.
|
||||
* The far-zoom heatmap on this globe's imagery (createHeatImagery
|
||||
* above); one at a time, destroyed with the display.
|
||||
*/
|
||||
createHeatImagery(imageryHost) {
|
||||
return destroyed
|
||||
? null
|
||||
: createFirmsHeatField(engine, { imageryHost, navaraHeat });
|
||||
createHeatImagery() {
|
||||
if (destroyed) return null;
|
||||
heat?.destroy();
|
||||
heat = createHeatImagery(engine, `${id}:heat`, (gone) => {
|
||||
if (heat === gone) heat = null;
|
||||
});
|
||||
return heat;
|
||||
},
|
||||
/** The hand-off fade: every marker at `alpha`, shown while `show`. */
|
||||
fadeSprites(alpha, show) {
|
||||
@@ -165,6 +233,7 @@ export function createFirmsDisplay(
|
||||
cellItems = [];
|
||||
for (const release of [...clickRemovers]) release();
|
||||
removeSprites();
|
||||
heat?.destroy();
|
||||
cells.destroy();
|
||||
layerView.destroy();
|
||||
engine.render.request('firms-destroy');
|
||||
|
||||
@@ -509,110 +509,99 @@ test('the fake engine: clearing rows during a decode commits only the replacemen
|
||||
}
|
||||
});
|
||||
|
||||
test('the heatmap: the Cesium engine drapes one imagery layer on the viewer, the host when there is one', () => {
|
||||
const priorDocument = globalThis.document;
|
||||
// A 2D canvas for the Cesium engine's image upload (RGBA bytes go on one).
|
||||
function stubDocument() {
|
||||
const prior = globalThis.document;
|
||||
globalThis.document = {
|
||||
createElement: () => ({
|
||||
width: 0,
|
||||
height: 0,
|
||||
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;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
if (prior === undefined) delete globalThis.document;
|
||||
else globalThis.document = prior;
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
for (const make of HARNESSES) {
|
||||
const name = make().name;
|
||||
test(`${name}: the heatmap is one imagery layer over the globe, faded by its alpha`, () => {
|
||||
const restore = stubDocument();
|
||||
const h = make();
|
||||
// Cesium drapes it on the globe's own imagery stack.
|
||||
const stack = new Cesium.ImageryLayerCollection();
|
||||
if (h.viewer) {
|
||||
h.viewer.imageryLayers = stack;
|
||||
h.viewer.scene.globe.show = true;
|
||||
}
|
||||
try {
|
||||
const r = display(h);
|
||||
const heat = r.createHeatImagery();
|
||||
const listed = () => h.engine.imagery.list();
|
||||
assert.equal(heat.hasRaster(), false);
|
||||
assert.deepEqual(listed(), [], 'no layer before the first raster');
|
||||
heat.setOpacity(0);
|
||||
const raster = { width: 4, height: 2, rgba: new Uint8ClampedArray(32) };
|
||||
heat.setRaster(raster);
|
||||
assert.equal(heat.hasRaster(), true);
|
||||
assert.deepEqual(listed(), [
|
||||
{
|
||||
id: 'firms-fixture:heat',
|
||||
kind: 'image',
|
||||
shown: true,
|
||||
alpha: 0,
|
||||
split: 'none',
|
||||
order: 1,
|
||||
},
|
||||
]);
|
||||
if (h.viewer) {
|
||||
assert.equal(stack.length, 1, 'one imagery layer on the globe');
|
||||
assert.equal(stack.get(0).alpha, 0);
|
||||
assert.equal(stack.get(0).show, true, 'a faded layer stays mapped');
|
||||
} else {
|
||||
const drawn = h.engine.fake.imageryLayer('firms-fixture:heat');
|
||||
assert.equal(drawn.source.image.data, raster.rgba);
|
||||
assert.deepEqual(drawn.source.rectangle, {
|
||||
west: -180,
|
||||
south: -90,
|
||||
east: 180,
|
||||
north: 90,
|
||||
});
|
||||
}
|
||||
heat.setOpacity(7);
|
||||
heat.setShow(false);
|
||||
assert.deepEqual(
|
||||
[listed()[0].alpha, listed()[0].shown],
|
||||
[1, false],
|
||||
'the opacity clamps; show hides',
|
||||
);
|
||||
heat.setRaster({ ...raster, rgba: new Uint8ClampedArray(32) });
|
||||
assert.equal(listed().length, 1, 'a new raster replaces the image');
|
||||
assert.deepEqual([listed()[0].alpha, listed()[0].shown], [1, false]);
|
||||
if (h.viewer) {
|
||||
assert.equal(stack.length, 1);
|
||||
assert.equal(stack.get(0).show, false);
|
||||
}
|
||||
heat.destroy();
|
||||
heat.destroy();
|
||||
assert.deepEqual(listed(), []);
|
||||
if (h.viewer) assert.equal(stack.length, 0);
|
||||
// The display takes its heatmap with it.
|
||||
r.createHeatImagery().setRaster(raster);
|
||||
assert.equal(listed().length, 1);
|
||||
r.destroy();
|
||||
assert.deepEqual(listed(), []);
|
||||
assert.equal(r.createHeatImagery(), null);
|
||||
} finally {
|
||||
h.destroy();
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
// The Active Fires far-zoom heatmap behind one seam: one equirectangular
|
||||
// RGBA raster over the whole globe (data/firmsHeatRaster.js), draped on the
|
||||
// terrain and 3D tiles and blended between texels. The globe contract has no
|
||||
// primitive for an image draped on the ground yet (engine.ground draws flat
|
||||
// colours per item), so each engine draws it through its own backend:
|
||||
// render/cesiumHeat.js (one imagery layer on the active imagery host) and
|
||||
// render/navaraHeat.js (a surface pass in Navara's view), which the Navara
|
||||
// mode loads and passes in, as Navara modules reach the app only through its
|
||||
// import(). This is the only FIRMS module that reads engine.extensions.
|
||||
//
|
||||
// The field keeps what the layer asked for, so an engine with no backend
|
||||
// (the fake engine) draws nothing and still answers.
|
||||
|
||||
import { createFirmsCesiumHeat } from './cesiumHeat.js';
|
||||
|
||||
/**
|
||||
* The heatmap with heatImagery.js's interface: setRaster, hasRaster,
|
||||
* setShow, setOpacity (the hand-off fade, 0..1), rehome (follow the active
|
||||
* imagery host) and destroy.
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ imageryHost?: () => ({ collection: object, kind: string } | null),
|
||||
* navaraHeat?: (engine: object, navara: object) => object }} [options]
|
||||
* `imageryHost` is the shell's imagery host (Cesium); `navaraHeat` the
|
||||
* Navara backend's factory (render/navaraHeat.js).
|
||||
*/
|
||||
export function createFirmsHeatField(engine, { imageryHost, navaraHeat } = {}) {
|
||||
const cesium = engine.extensions?.cesium;
|
||||
const navara = engine.extensions?.navara;
|
||||
const backend = cesium
|
||||
? createFirmsCesiumHeat(cesium, imageryHost)
|
||||
: navara && navaraHeat
|
||||
? navaraHeat(engine, navara)
|
||||
: null;
|
||||
let raster = null,
|
||||
show = true,
|
||||
opacity = 1,
|
||||
destroyed = false;
|
||||
return {
|
||||
setRaster(next) {
|
||||
if (destroyed) return;
|
||||
raster = next;
|
||||
backend?.setRaster(next);
|
||||
},
|
||||
/** True once a raster was handed over. */
|
||||
hasRaster: () => raster !== null,
|
||||
setShow(value) {
|
||||
if (destroyed) return;
|
||||
show = Boolean(value);
|
||||
backend?.setShow(show);
|
||||
},
|
||||
setOpacity(value) {
|
||||
if (destroyed) return;
|
||||
opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
backend?.setOpacity(opacity);
|
||||
},
|
||||
rehome() {
|
||||
if (!destroyed) backend?.rehome();
|
||||
},
|
||||
/** What the layer asked for: { raster, show, opacity, drawn }. */
|
||||
state: () => ({ raster, show, opacity, drawn: backend !== null }),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
backend?.destroy();
|
||||
raster = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
// The Active Fires far-zoom heatmap on Navara: the same equirectangular heat
|
||||
// raster the Cesium globe drapes as imagery (firmsHeatRaster.js), drawn over
|
||||
// visible terrain and 3D tiles by one non-pickable classification pass. Only
|
||||
// surface pixels take it, so the fire markers above stay untouched. The
|
||||
// adapter has heatImagery.js's interface, so the layer drives both alike.
|
||||
import {
|
||||
DataTexture,
|
||||
LinearFilter,
|
||||
Matrix4,
|
||||
RGBAFormat,
|
||||
RepeatWrapping,
|
||||
ClampToEdgeWrapping,
|
||||
Uniform,
|
||||
UnsignedByteType,
|
||||
} from 'three';
|
||||
import {
|
||||
groundSurfaceUniforms,
|
||||
updateGroundSurface,
|
||||
} from '../../shared/render/navaraGroundSurface.js';
|
||||
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
|
||||
|
||||
const KEY = 'gevFirmsHeat';
|
||||
|
||||
// Row 0 of the raster is the north edge and column 0 is 180°W; the texture
|
||||
// keeps that order (no flip), so v = 0 is north.
|
||||
const fragmentShader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth;
|
||||
uniform sampler2D tSurfaceDepth;
|
||||
uniform sampler2D tHeat;
|
||||
uniform mat4 uInverseProjection;
|
||||
uniform mat4 uInverseView;
|
||||
uniform float uLogDepthFC;
|
||||
uniform float uOpacity;
|
||||
// WGS84 first eccentricity squared: geodetic latitude of a near-surface point.
|
||||
const float E2=0.0066943799901413165;
|
||||
vec3 toDisplay(vec3 c) {
|
||||
return mix(12.92*c,1.055*pow(max(c,vec3(0.0)),vec3(1.0/2.4))-0.055,step(vec3(0.0031308),c));
|
||||
}
|
||||
vec3 toLinear(vec3 c) {
|
||||
return mix(c/12.92,pow(max((c+0.055)/1.055,vec3(0.0)),vec3(2.4)),step(vec3(0.04045),c));
|
||||
}
|
||||
void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||
outputColor=inputColor;
|
||||
float depth=unpackRGBAToDepth(texture2D(tAllDepth,uv));
|
||||
if(depth>=1.0) return;
|
||||
float surface=texture2D(tSurfaceDepth,uv).r;
|
||||
if(depth<surface-2e-7) return;
|
||||
float distance=exp2(2.0*depth/uLogDepthFC)-1.0;
|
||||
vec4 ray=uInverseProjection*vec4(uv*2.0-1.0,-1.0,1.0);
|
||||
vec3 point=ray.xyz*(distance/-ray.z);
|
||||
vec3 world=(uInverseView*vec4(point,1.0)).xyz;
|
||||
float rho=length(world.xy);
|
||||
float longitude=atan(world.y,world.x);
|
||||
float latitude=atan(world.z,rho*(1.0-E2));
|
||||
// PI comes from three.js's shader chunks.
|
||||
vec2 st=vec2(longitude/(2.0*PI)+0.5,0.5-latitude/PI);
|
||||
vec4 heat=texture2D(tHeat,st);
|
||||
float alpha=heat.a*uOpacity;
|
||||
if(alpha<=0.0) return;
|
||||
vec3 color=mix(toDisplay(inputColor.rgb),heat.rgb,alpha);
|
||||
outputColor=vec4(toLinear(color),inputColor.a);
|
||||
}
|
||||
`;
|
||||
|
||||
const heatEffect = defineGroundEffect({
|
||||
key: KEY,
|
||||
name: 'GevFirmsHeat',
|
||||
fragmentShader,
|
||||
// Under the heat cells' and markers' place in the chain: after the other
|
||||
// surface drapes, before sharpening, anti-aliasing and overlays.
|
||||
insertAfter: [
|
||||
'gevEarthquakeGround',
|
||||
'gevObservedWeather',
|
||||
'gevNepalEvent',
|
||||
'gevRecentImagery',
|
||||
'toneMapping',
|
||||
],
|
||||
insertBefore: [
|
||||
'gevFirmsGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
'fxaa',
|
||||
'gevOverlay',
|
||||
'final',
|
||||
],
|
||||
uniforms: () => [
|
||||
...groundSurfaceUniforms(),
|
||||
['tHeat', new Uniform(null)],
|
||||
['uOpacity', new Uniform(1)],
|
||||
['uInverseView', new Uniform(new Matrix4())],
|
||||
],
|
||||
update() {
|
||||
const owner = this.owner,
|
||||
camera = this.view.camera.raw;
|
||||
if (!this.effect || !owner.texture || !camera) return;
|
||||
updateGroundSurface(this.effect, camera, this.ctx, this.find('mrt'));
|
||||
const u = this.effect.uniforms;
|
||||
u.get('tHeat').value = owner.texture;
|
||||
u.get('uOpacity').value = owner.opacity;
|
||||
u.get('uInverseView').value.copy(camera.matrixWorld);
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* The raster as a texture: linear between pixels, wrapping across 180°. Its
|
||||
* sRGB bytes are read as they are and blended in display space, as the heat
|
||||
* cells' colors are.
|
||||
*/
|
||||
function heatTexture(raster) {
|
||||
const texture = new DataTexture(
|
||||
raster.rgba,
|
||||
raster.width,
|
||||
raster.height,
|
||||
RGBAFormat,
|
||||
UnsignedByteType,
|
||||
);
|
||||
texture.minFilter = LinearFilter;
|
||||
texture.magFilter = LinearFilter;
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = ClampToEdgeWrapping;
|
||||
texture.generateMipmaps = false;
|
||||
texture.needsUpdate = true;
|
||||
return texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* The heatmap drape on one Navara view, with heatImagery.js's interface:
|
||||
* setRaster, hasRaster, setShow, setOpacity (the hand-off fade), rehome and
|
||||
* destroy. Drawn only while shown, painted and above zero opacity.
|
||||
*/
|
||||
export function createFirmsHeatDrape(
|
||||
engine,
|
||||
{ view, EffectDesc, NavaraEffect },
|
||||
) {
|
||||
heatEffect.register(view, { EffectDesc, NavaraEffect });
|
||||
const owner = { texture: null, opacity: 1 };
|
||||
let raster = null,
|
||||
show = true,
|
||||
handle = null,
|
||||
destroyed = false;
|
||||
function sync() {
|
||||
if (destroyed) return;
|
||||
const visible = Boolean(owner.texture && show && owner.opacity > 0);
|
||||
if (!handle && visible)
|
||||
handle = view.addEffect({ [KEY]: { owner }, visible: true });
|
||||
else handle?.update({ visible });
|
||||
engine.render.request('firms-heat');
|
||||
}
|
||||
return {
|
||||
setRaster(next) {
|
||||
if (destroyed) return;
|
||||
raster = next;
|
||||
owner.texture?.dispose();
|
||||
owner.texture = next ? heatTexture(next) : null;
|
||||
sync();
|
||||
},
|
||||
/** True once a raster was handed over. */
|
||||
hasRaster() {
|
||||
return raster !== null;
|
||||
},
|
||||
setShow(value) {
|
||||
show = Boolean(value);
|
||||
sync();
|
||||
},
|
||||
/** Hand-off fade, 0..1. */
|
||||
setOpacity(value) {
|
||||
owner.opacity = Math.min(1, Math.max(0, Number(value) || 0));
|
||||
sync();
|
||||
},
|
||||
/** Navara drapes on its own surface; there is no imagery host to follow. */
|
||||
rehome() {},
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
handle?.delete();
|
||||
handle = null;
|
||||
owner.texture?.dispose();
|
||||
owner.texture = null;
|
||||
raster = null;
|
||||
engine.render.request('firms-heat-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFirmsHeatDrape } from './navaraHeat.js';
|
||||
|
||||
function harness() {
|
||||
const effects = [];
|
||||
const requests = [];
|
||||
const view = {
|
||||
registered: [],
|
||||
registerEffect(key) {
|
||||
view.registered.push(key);
|
||||
},
|
||||
addEffect(config) {
|
||||
const handle = {
|
||||
config,
|
||||
visible: config.visible,
|
||||
deleted: false,
|
||||
update(next) {
|
||||
handle.visible = next.visible;
|
||||
},
|
||||
delete() {
|
||||
handle.deleted = true;
|
||||
},
|
||||
};
|
||||
effects.push(handle);
|
||||
return handle;
|
||||
},
|
||||
};
|
||||
const engine = { render: { request: (reason) => requests.push(reason) } };
|
||||
const drape = createFirmsHeatDrape(engine, {
|
||||
view,
|
||||
EffectDesc: class {},
|
||||
NavaraEffect: class {},
|
||||
});
|
||||
return { drape, view, effects, requests };
|
||||
}
|
||||
|
||||
const raster = (alpha) => ({
|
||||
width: 4,
|
||||
height: 2,
|
||||
rgba: new Uint8ClampedArray(4 * 2 * 4).fill(alpha),
|
||||
});
|
||||
|
||||
test('the heatmap drape draws only a painted, shown raster above zero opacity', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.view.registered, ['gevFirmsHeat']);
|
||||
assert.equal(h.drape.hasRaster(), false);
|
||||
assert.equal(h.effects.length, 0, 'nothing to draw before a raster');
|
||||
h.drape.setRaster(raster(200));
|
||||
assert.equal(h.drape.hasRaster(), true);
|
||||
assert.equal(h.effects.length, 1);
|
||||
const [effect] = h.effects;
|
||||
const owner = effect.config.gevFirmsHeat.owner;
|
||||
assert.equal(owner.texture.image.width, 4);
|
||||
assert.equal(owner.texture.image.height, 2);
|
||||
assert.equal(effect.visible, true);
|
||||
h.drape.setOpacity(0);
|
||||
assert.equal(effect.visible, false, 'faded out under the markers');
|
||||
h.drape.setOpacity(0.5);
|
||||
assert.equal(effect.visible, true);
|
||||
assert.equal(owner.opacity, 0.5);
|
||||
h.drape.setShow(false);
|
||||
assert.equal(effect.visible, false, 'the layer is off');
|
||||
h.drape.setShow(true);
|
||||
// A new raster replaces the texture in the same pass.
|
||||
const first = owner.texture;
|
||||
let disposed = false;
|
||||
first.dispose = () => (disposed = true);
|
||||
h.drape.setRaster(raster(100));
|
||||
assert.equal(disposed, true);
|
||||
assert.notEqual(owner.texture, first);
|
||||
assert.equal(h.effects.length, 1);
|
||||
assert.ok(h.requests.length > 0, 'every change asks for a frame');
|
||||
h.drape.destroy();
|
||||
assert.equal(effect.deleted, true);
|
||||
assert.equal(owner.texture, null);
|
||||
assert.equal(h.drape.hasRaster(), false);
|
||||
h.drape.setRaster(raster(50));
|
||||
assert.equal(h.effects.length, 1, 'a destroyed drape draws nothing again');
|
||||
});
|
||||
@@ -168,13 +168,11 @@ export function createRendering({
|
||||
return clipped;
|
||||
}
|
||||
|
||||
/** The renderer's heat imagery adapter, created on first use. */
|
||||
/** The renderer's heat imagery layer, created on first use. */
|
||||
|
||||
function heatImagery() {
|
||||
layerState._heatImagery ??=
|
||||
layerState._renderer?.createHeatImagery?.(
|
||||
() => layerState._imageryHost?.() ?? null,
|
||||
) ?? null;
|
||||
layerState._renderer?.createHeatImagery?.() ?? null;
|
||||
return layerState._heatImagery;
|
||||
}
|
||||
|
||||
@@ -210,15 +208,14 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the heatmap draped on the current map stack and current with the
|
||||
* shown set in every band; the hand-off fade decides how much of it shows.
|
||||
* Keep the heatmap current with the shown set in every band (the engine
|
||||
* keeps it on the map's surface); the hand-off fade decides how much of
|
||||
* it shows.
|
||||
* @param {boolean} now The camera is in a far band.
|
||||
*/
|
||||
|
||||
function prepareHeat(now) {
|
||||
const imagery = heatImagery();
|
||||
// The map stack may have changed since the last render.
|
||||
imagery.rehome();
|
||||
ensureHeatRaster(now);
|
||||
imagery.setShow(layerState._enabled);
|
||||
}
|
||||
|
||||
@@ -148,14 +148,10 @@ export function createFirmsState({ services, config }) {
|
||||
|
||||
layerState._handoffHeat = -1;
|
||||
|
||||
/** Imagery adapter (heatImagery.js), created on the first far render. */
|
||||
/** The heatmap's imagery layer (render/display.js), made on the first far render. */
|
||||
|
||||
layerState._heatImagery = null;
|
||||
|
||||
/** Shell-provided imagery host resolver; falls back to viewer.imageryLayers. */
|
||||
|
||||
layerState._imageryHost = null;
|
||||
|
||||
/** Camera idle snapshot so the throttled LOD check is ~free when parked. */
|
||||
|
||||
layerState._camSnapValid = false;
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// The Recent Imagery drawing on the globe contract, the same on every
|
||||
// engine: at most two GIBS tile layers (slot `a`, left of a swipe; slot `b`,
|
||||
// right) on engine.imagery, bounded to the box and split at the swipe; the
|
||||
// box tool's transient preview as one ground cell. The engine drapes the
|
||||
// layers on the surface the map shows (globe or 3D tiles) and follows it;
|
||||
// a slot shown again with the same day and box only restyles its layer, any
|
||||
// other day or box replaces its source in place. Tile requests are capped
|
||||
// per layer (`maxTileRequests`).
|
||||
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { PRODUCTS, fitViewHeightM, gibsTemplate } from '../model.js';
|
||||
|
||||
const SLOT_IDS = ['a', 'b'];
|
||||
const PREVIEW = Object.freeze({
|
||||
fill: [0.545, 0.914, 1, 0.12],
|
||||
outline: { color: [0.545, 0.914, 1, 0.95], widthPx: 2 },
|
||||
});
|
||||
// What the layer calls a surface (src/maps/imageryHost.js kinds).
|
||||
const HOST_KIND = { globe: 'globe', tiles: 'tileset', none: 'none' };
|
||||
|
||||
const boxKey = (box) =>
|
||||
[box.west, box.south, box.east, box.north].map(Number).join(',');
|
||||
const alphaOf = (value) => {
|
||||
const alpha = Number(value);
|
||||
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
|
||||
};
|
||||
const hasArea = (box) =>
|
||||
box != null &&
|
||||
[box.west, box.south, box.east, box.north].every(Number.isFinite) &&
|
||||
box.west < box.east &&
|
||||
box.south < box.north &&
|
||||
box.south >= -90 &&
|
||||
box.north <= 90;
|
||||
const sideOf = (direction) =>
|
||||
direction === 'left' || direction === 'right' ? direction : 'none';
|
||||
|
||||
/**
|
||||
* The renderer the Recent Imagery layer draws through (index.js).
|
||||
* @param {import('../../../globe/contract.js').GlobeEngine} engine
|
||||
* @param {{ tileTemplate?: (product: string, day: string) => string,
|
||||
* credit?: string, maxTileRequests?: number }} [options]
|
||||
*/
|
||||
export function createRecentImageryDisplay(
|
||||
engine,
|
||||
{
|
||||
tileTemplate = gibsTemplate,
|
||||
credit = 'NASA GIBS',
|
||||
maxTileRequests = 6,
|
||||
} = {},
|
||||
) {
|
||||
if (!engine?.imagery || !engine?.ground)
|
||||
throw new TypeError('The Recent Imagery display needs a globe engine');
|
||||
/** @type {Record<'a'|'b', null | { layer: object, candidate: object, box: object, alpha: number, split: string }>} */
|
||||
const owned = { a: null, b: null };
|
||||
let host = { identity: null, kind: 'none' };
|
||||
let preview = null;
|
||||
let destroyed = false;
|
||||
|
||||
function sourceFor(candidate, box) {
|
||||
const spec = PRODUCTS[candidate.product];
|
||||
return {
|
||||
kind: 'tiles',
|
||||
url: tileTemplate(candidate.product, candidate.day),
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minLevel: 0,
|
||||
maxLevel: spec.maxLevel,
|
||||
rectangle: {
|
||||
west: box.west,
|
||||
south: box.south,
|
||||
east: box.east,
|
||||
north: box.north,
|
||||
},
|
||||
credit,
|
||||
opaque: spec.format !== 'png',
|
||||
maxRequests: maxTileRequests,
|
||||
};
|
||||
}
|
||||
|
||||
function hideSlot(slotId) {
|
||||
const record = owned[slotId];
|
||||
if (!record) return;
|
||||
owned[slotId] = null;
|
||||
record.layer.destroy();
|
||||
}
|
||||
|
||||
return {
|
||||
/** Where the engine drapes imagery now, as the layer names hosts. */
|
||||
resolveHost: () => {
|
||||
const kind = HOST_KIND[engine.imagery.surface()] ?? 'none';
|
||||
return { identity: kind === 'none' ? null : engine, kind };
|
||||
},
|
||||
/** The engine follows the surface itself; the host is the layer's view of it. */
|
||||
rebind(next) {
|
||||
host = next ?? { identity: null, kind: 'none' };
|
||||
},
|
||||
/**
|
||||
* Drape a candidate into a slot. The same candidate and box only
|
||||
* restyle it; anything else draws the new source in its place.
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
|
||||
* @returns {boolean} Whether a layer is now draped for the slot.
|
||||
*/
|
||||
showSlot(
|
||||
slotId,
|
||||
candidate,
|
||||
box,
|
||||
{ alpha = 1, splitDirection = 'none' } = {},
|
||||
) {
|
||||
if (
|
||||
destroyed ||
|
||||
!SLOT_IDS.includes(slotId) ||
|
||||
!candidate?.key ||
|
||||
!PRODUCTS[candidate.product] ||
|
||||
!box
|
||||
)
|
||||
return false;
|
||||
if (host.kind === 'none') {
|
||||
hideSlot(slotId);
|
||||
return false;
|
||||
}
|
||||
const look = { alpha: alphaOf(alpha), split: sideOf(splitDirection) };
|
||||
const record = owned[slotId];
|
||||
if (!record) {
|
||||
owned[slotId] = {
|
||||
layer: engine.imagery.createLayer({
|
||||
id: `recent-imagery:${slotId}`,
|
||||
source: sourceFor(candidate, box),
|
||||
...look,
|
||||
}),
|
||||
candidate: { ...candidate },
|
||||
box: { ...box },
|
||||
...look,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
record.candidate.key !== candidate.key ||
|
||||
boxKey(record.box) !== boxKey(box)
|
||||
) {
|
||||
record.layer.setSource(sourceFor(candidate, box));
|
||||
record.candidate = { ...candidate };
|
||||
record.box = { ...box };
|
||||
}
|
||||
if (record.alpha !== look.alpha) record.layer.setAlpha(look.alpha);
|
||||
if (record.split !== look.split) record.layer.setSplit(look.split);
|
||||
Object.assign(record, look);
|
||||
return true;
|
||||
},
|
||||
/** @param {'a'|'b'} slotId */
|
||||
hideSlot,
|
||||
/**
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {number} alpha 0–1
|
||||
*/
|
||||
setAlpha(slotId, alpha) {
|
||||
const record = owned[slotId];
|
||||
const next = alphaOf(alpha);
|
||||
if (!record || record.alpha === next) return;
|
||||
record.alpha = next;
|
||||
record.layer.setAlpha(next);
|
||||
},
|
||||
/** The swipe line, a fraction of the canvas width from the left. */
|
||||
setSplit(value) {
|
||||
if (destroyed) return;
|
||||
const fraction = Number(value);
|
||||
if (Number.isFinite(fraction))
|
||||
engine.imagery.setSplitPosition(Math.max(0, Math.min(1, fraction)));
|
||||
},
|
||||
/**
|
||||
* The box tool's transient ground preview, or none; a box without area
|
||||
* yet (the press before a drag) shows nothing.
|
||||
*/
|
||||
setBoxPreview(box) {
|
||||
if (destroyed) return;
|
||||
if (!hasArea(box)) {
|
||||
preview?.show(false);
|
||||
return;
|
||||
}
|
||||
preview ??= engine.ground.createSet({
|
||||
id: 'recent-imagery:preview',
|
||||
kind: 'cell',
|
||||
order: 10,
|
||||
});
|
||||
preview.replace([
|
||||
{
|
||||
west: box.west,
|
||||
south: box.south,
|
||||
east: box.east,
|
||||
north: box.north,
|
||||
...PREVIEW,
|
||||
},
|
||||
]);
|
||||
preview.commit();
|
||||
preview.show(true);
|
||||
},
|
||||
viewCentre() {
|
||||
const { width, height } = engine.host.size(),
|
||||
hit = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
|
||||
return c
|
||||
? {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
zoomToFit(target) {
|
||||
const height = fitViewHeightM({
|
||||
...engine.host.size(),
|
||||
fovy: engine.camera.pose().fovy,
|
||||
});
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(target.lon, target.lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
duration: 1.2,
|
||||
});
|
||||
return height;
|
||||
},
|
||||
/** What is draped right now, per slot. */
|
||||
getOwned: () =>
|
||||
Object.fromEntries(
|
||||
SLOT_IDS.map((slotId) => {
|
||||
const record = owned[slotId];
|
||||
return [
|
||||
slotId,
|
||||
record
|
||||
? {
|
||||
key: record.candidate.key,
|
||||
kind: host.kind,
|
||||
alpha: record.alpha,
|
||||
split: record.split,
|
||||
}
|
||||
: null,
|
||||
];
|
||||
}),
|
||||
),
|
||||
/** Number of layers this display owns (never above two). */
|
||||
ownedCount: () => SLOT_IDS.filter((slotId) => owned[slotId]).length,
|
||||
/** Remove everything and refuse further work. */
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
for (const slotId of SLOT_IDS) hideSlot(slotId);
|
||||
preview?.destroy();
|
||||
preview = null;
|
||||
host = { identity: null, kind: 'none' };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// The Recent Imagery display on the fake engine and on the Cesium engine
|
||||
// over a stub scene: the same calls, read back from each engine's own
|
||||
// store (the fake's imagery layers, Cesium's imagery layers and their
|
||||
// template providers).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
|
||||
import { createCesiumMoversEngine } from '../../../testSupport/cesiumMoversEngine.mjs';
|
||||
import { BOX } from '../testDoubles.mjs';
|
||||
import { createRecentImageryDisplay } from './display.js';
|
||||
|
||||
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||
const OTHER_BOX = { west: -97.9, south: 30.1, east: -97.6, north: 30.4 };
|
||||
|
||||
function fakeHarness() {
|
||||
const engine = createFakeEngine();
|
||||
return {
|
||||
name: 'the fake engine',
|
||||
engine,
|
||||
/** Each drawn layer as { url, maxLevel, opaque, rectangle, credit, alpha, split, shown }. */
|
||||
drawn: () =>
|
||||
engine.fake.imageryLayers().map((id) => {
|
||||
const { source, alpha, split, shown } = engine.fake.imageryLayer(id);
|
||||
return {
|
||||
url: source.url,
|
||||
maxLevel: source.maxLevel,
|
||||
opaque: source.opaque,
|
||||
rectangle: { ...source.rectangle },
|
||||
credit: source.credit,
|
||||
alpha,
|
||||
split,
|
||||
shown,
|
||||
};
|
||||
}),
|
||||
hideSurface() {
|
||||
engine.imagery.surface = () => 'none';
|
||||
},
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function cesiumHarness() {
|
||||
const { engine, viewer } = createCesiumMoversEngine();
|
||||
viewer.imageryLayers = new Cesium.ImageryLayerCollection();
|
||||
viewer.scene.globe.show = true;
|
||||
viewer.scene.groundPrimitives = new Cesium.PrimitiveCollection();
|
||||
const side = (direction) =>
|
||||
({
|
||||
[Cesium.SplitDirection.LEFT]: 'left',
|
||||
[Cesium.SplitDirection.RIGHT]: 'right',
|
||||
})[direction] ?? 'none';
|
||||
const degrees = (value) => Number(Cesium.Math.toDegrees(value).toFixed(9));
|
||||
return {
|
||||
name: 'the Cesium engine',
|
||||
engine,
|
||||
drawn: () =>
|
||||
Array.from({ length: viewer.imageryLayers.length }, (_, i) => {
|
||||
const layer = viewer.imageryLayers.get(i);
|
||||
const provider = layer.imageryProvider;
|
||||
const { west, south, east, north } = provider.rectangle;
|
||||
return {
|
||||
url: provider.url,
|
||||
maxLevel: provider.maximumLevel,
|
||||
opaque: !provider.hasAlphaChannel,
|
||||
rectangle: {
|
||||
west: degrees(west),
|
||||
south: degrees(south),
|
||||
east: degrees(east),
|
||||
north: degrees(north),
|
||||
},
|
||||
credit: provider.credit?.html ?? null,
|
||||
alpha: layer.alpha,
|
||||
split: side(layer.splitDirection),
|
||||
shown: layer.show,
|
||||
};
|
||||
}),
|
||||
hideSurface() {
|
||||
viewer.scene.globe.show = false;
|
||||
},
|
||||
destroy: () => engine.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
for (const make of [fakeHarness, cesiumHarness]) {
|
||||
const name = make().name;
|
||||
|
||||
test(`${name}: a slot drapes one GIBS template bounded to the box; the same day only restyles it`, () => {
|
||||
const h = make();
|
||||
try {
|
||||
const display = createRecentImageryDisplay(h.engine);
|
||||
display.rebind(display.resolveHost());
|
||||
assert.deepEqual(display.resolveHost(), {
|
||||
identity: h.engine,
|
||||
kind: 'globe',
|
||||
});
|
||||
assert.equal(display.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
|
||||
const [drawn] = h.drawn();
|
||||
assert.match(
|
||||
drawn.url,
|
||||
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
|
||||
);
|
||||
assert.equal(drawn.maxLevel, 12);
|
||||
assert.equal(drawn.opaque, false);
|
||||
assert.equal(drawn.credit, 'NASA GIBS');
|
||||
assert.deepEqual(drawn.rectangle, BOX);
|
||||
assert.equal(drawn.alpha, 0.8);
|
||||
assert.deepEqual(display.getOwned().a, {
|
||||
key: 'S30:2026-09-18',
|
||||
kind: 'globe',
|
||||
alpha: 0.8,
|
||||
split: 'none',
|
||||
});
|
||||
display.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
|
||||
assert.equal(h.drawn().length, 1);
|
||||
assert.deepEqual([h.drawn()[0].alpha, h.drawn()[0].split], [0.5, 'left']);
|
||||
// Another day or box draws in the slot's place.
|
||||
display.showSlot('a', L30, OTHER_BOX, {
|
||||
alpha: 0.5,
|
||||
splitDirection: 'left',
|
||||
});
|
||||
assert.equal(h.drawn().length, 1);
|
||||
assert.match(h.drawn()[0].url, /HLS_L30/);
|
||||
assert.deepEqual(h.drawn()[0].rectangle, OTHER_BOX);
|
||||
// VIIRS is a level-9 JPEG overview with no alpha channel.
|
||||
display.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
|
||||
const [, viirs] = h.drawn();
|
||||
assert.equal(viirs.maxLevel, 9);
|
||||
assert.equal(viirs.opaque, true);
|
||||
assert.equal(viirs.split, 'right');
|
||||
display.setAlpha('b', 7);
|
||||
assert.equal(h.drawn()[1].alpha, 1, 'alpha clamps to 0–1');
|
||||
display.setSplit(0.3);
|
||||
assert.equal(h.engine.imagery.splitPosition(), 0.3);
|
||||
display.destroy();
|
||||
assert.deepEqual(h.drawn(), []);
|
||||
assert.equal(display.showSlot('a', S30, BOX), false, 'destroyed');
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name}: rapid replacement never owns more than two layers`, () => {
|
||||
const h = make();
|
||||
try {
|
||||
const display = createRecentImageryDisplay(h.engine);
|
||||
display.rebind(display.resolveHost());
|
||||
const days = [S30, L30, VIIRS];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
display.showSlot('a', days[i % 3], BOX);
|
||||
display.showSlot('b', days[(i + 1) % 3], BOX);
|
||||
if (i % 4 === 0) display.hideSlot('a');
|
||||
assert.ok(h.drawn().length <= 2 && display.ownedCount() <= 2);
|
||||
}
|
||||
display.hideSlot('a');
|
||||
display.hideSlot('b');
|
||||
assert.deepEqual(h.drawn(), []);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name}: without a surface a slot refuses and drops its layer`, () => {
|
||||
const h = make();
|
||||
try {
|
||||
const display = createRecentImageryDisplay(h.engine);
|
||||
display.rebind(display.resolveHost());
|
||||
display.showSlot('a', S30, BOX);
|
||||
h.hideSurface();
|
||||
const host = display.resolveHost();
|
||||
assert.deepEqual(host, { identity: null, kind: 'none' });
|
||||
display.rebind(host);
|
||||
assert.equal(display.showSlot('a', S30, BOX), false);
|
||||
assert.equal(display.ownedCount(), 0);
|
||||
assert.deepEqual(h.drawn(), []);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test(`${name}: replacement imagery uses source-owned tile URLs and credit`, () => {
|
||||
const h = make();
|
||||
try {
|
||||
const display = createRecentImageryDisplay(h.engine, {
|
||||
tileTemplate: (product, day) =>
|
||||
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||
credit: 'Example imagery',
|
||||
});
|
||||
display.rebind(display.resolveHost());
|
||||
assert.equal(display.showSlot('a', S30, BOX), true);
|
||||
const [drawn] = h.drawn();
|
||||
assert.equal(
|
||||
drawn.url,
|
||||
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
|
||||
);
|
||||
assert.equal(drawn.credit, 'Example imagery');
|
||||
display.destroy();
|
||||
assert.deepEqual(h.drawn(), []);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The box tool's preview (the Navara box tool's; Cesium's tool draws its own).
|
||||
test('the box preview is one ground cell, hidden with no box', () => {
|
||||
const h = fakeHarness();
|
||||
try {
|
||||
const display = createRecentImageryDisplay(h.engine);
|
||||
const listed = () =>
|
||||
h.engine.ground
|
||||
.list()
|
||||
.find((entry) => entry.id === 'recent-imagery:preview');
|
||||
assert.equal(listed(), undefined, 'none before a box');
|
||||
display.setBoxPreview(BOX);
|
||||
assert.deepEqual(
|
||||
{ ...listed(), id: undefined },
|
||||
{ id: undefined, kind: 'cell', count: 1, shown: true, order: 10 },
|
||||
);
|
||||
display.setBoxPreview(null);
|
||||
assert.equal(listed().shown, false);
|
||||
// The press before a drag: a box without area shows nothing.
|
||||
display.setBoxPreview(BOX);
|
||||
display.setBoxPreview({ ...BOX, east: BOX.west, north: BOX.south });
|
||||
assert.equal(listed().shown, false);
|
||||
display.destroy();
|
||||
assert.equal(listed(), undefined);
|
||||
} finally {
|
||||
h.destroy();
|
||||
}
|
||||
});
|
||||
@@ -1,275 +0,0 @@
|
||||
import { CanvasTexture, LinearFilter } from 'three';
|
||||
import * as geo from '../../../geo/index.js';
|
||||
import { PRODUCTS, fitViewHeightM, gibsTemplate } from '../model.js';
|
||||
import { createImageryGround } from './navaraGround.js';
|
||||
import {
|
||||
TILE_SIZE,
|
||||
imageryTilePlan,
|
||||
imageryTileUrl,
|
||||
createImageryTileQueue,
|
||||
} from './tiles.js';
|
||||
|
||||
const keyOf = (b) => [b.west, b.south, b.east, b.north].join(',');
|
||||
const alphaOf = (x) =>
|
||||
Number.isFinite(Number(x)) ? Math.max(0, Math.min(1, Number(x))) : 1;
|
||||
async function loadTile(url, signal) {
|
||||
const response = await fetch(url, { signal });
|
||||
if (!response.ok) throw new Error(`Imagery tile HTTP ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
signal.throwIfAborted();
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
/** Two bounded visible mosaics on actual terrain depth, with no paid source. */
|
||||
export function createRecentImageryNavaraRenderer(engine, options = {}) {
|
||||
const {
|
||||
tileTemplate = gibsTemplate,
|
||||
credit = 'NASA GIBS',
|
||||
load = loadTile,
|
||||
} = options;
|
||||
const owner = { slots: { a: null, b: null }, split: 0.5, preview: null };
|
||||
const ground = createImageryGround(engine, options, owner);
|
||||
const queue = createImageryTileQueue({ load });
|
||||
let destroyed = false,
|
||||
host = null,
|
||||
releaseCredit = null;
|
||||
const wake = () => engine.render.request('recent-imagery');
|
||||
function disposeAtlas(record) {
|
||||
const atlas = record.atlas;
|
||||
record.atlas = null;
|
||||
if (!atlas) return;
|
||||
for (const c of atlas.controllers) c.abort();
|
||||
atlas.texture.dispose();
|
||||
atlas.canvas.width = atlas.canvas.height = 0;
|
||||
queue.prune();
|
||||
}
|
||||
function updateTiles(record) {
|
||||
const pose = engine.camera.pose(),
|
||||
size = engine.host.size();
|
||||
const metresPerPixel =
|
||||
(2 * Math.max(1, pose.cartographic.height) * Math.tan(pose.fovy / 2)) /
|
||||
Math.max(1, size.height);
|
||||
const level = Math.min(
|
||||
PRODUCTS[record.candidate.product].maxLevel,
|
||||
Math.ceil(
|
||||
Math.log2(
|
||||
(156543.033928 * Math.cos(pose.cartographic.latitude)) /
|
||||
metresPerPixel,
|
||||
),
|
||||
),
|
||||
);
|
||||
const plan = imageryTilePlan(
|
||||
record.box,
|
||||
engine.camera.viewRectangle(),
|
||||
level,
|
||||
);
|
||||
if ((record.atlas?.plan.key || null) === (plan?.key || null)) return;
|
||||
disposeAtlas(record);
|
||||
if (!plan) {
|
||||
wake();
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = plan.width * TILE_SIZE;
|
||||
canvas.height = plan.height * TILE_SIZE;
|
||||
const texture = new CanvasTexture(canvas);
|
||||
texture.flipY = false;
|
||||
texture.minFilter = texture.magFilter = LinearFilter;
|
||||
texture.generateMipmaps = false;
|
||||
const atlas = {
|
||||
plan,
|
||||
canvas,
|
||||
texture,
|
||||
controllers: new Set(),
|
||||
loaded: 0,
|
||||
failed: 0,
|
||||
};
|
||||
record.atlas = atlas;
|
||||
const context = canvas.getContext('2d');
|
||||
const template = tileTemplate(
|
||||
record.candidate.product,
|
||||
record.candidate.day,
|
||||
),
|
||||
tasks = [];
|
||||
for (let y = 0; y < plan.height; y++)
|
||||
for (let x = 0; x < plan.width; x++)
|
||||
tasks.push({
|
||||
url: imageryTileUrl(template, plan.x + x, plan.y + y, plan.z),
|
||||
controllers: atlas.controllers,
|
||||
live: () => !destroyed && record.atlas === atlas,
|
||||
draw(image) {
|
||||
context.drawImage(
|
||||
image,
|
||||
x * TILE_SIZE,
|
||||
y * TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
TILE_SIZE,
|
||||
);
|
||||
atlas.loaded++;
|
||||
texture.needsUpdate = true;
|
||||
wake();
|
||||
},
|
||||
fail() {
|
||||
atlas.failed++;
|
||||
},
|
||||
});
|
||||
queue.add(tasks);
|
||||
wake();
|
||||
}
|
||||
function sync() {
|
||||
ground.sync();
|
||||
const shown = owner.slots.a || owner.slots.b;
|
||||
if (shown && !releaseCredit)
|
||||
releaseCredit = engine.credits.addStatic(credit);
|
||||
else if (!shown && releaseCredit) {
|
||||
releaseCredit();
|
||||
releaseCredit = null;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
function hideSlot(id) {
|
||||
const record = owner.slots[id];
|
||||
if (!record) return;
|
||||
owner.slots[id] = null;
|
||||
disposeAtlas(record);
|
||||
sync();
|
||||
}
|
||||
const offCamera = engine.camera.changed.addEventListener(() => {
|
||||
for (const record of Object.values(owner.slots))
|
||||
if (record) updateTiles(record);
|
||||
});
|
||||
return {
|
||||
resolveHost: () => ({
|
||||
identity: engine,
|
||||
kind: engine.maps.current()?.photoreal ? 'tileset' : 'globe',
|
||||
}),
|
||||
rebind(next) {
|
||||
host = next;
|
||||
},
|
||||
showSlot(id, candidate, box, { alpha = 1, splitDirection = 'none' } = {}) {
|
||||
if (
|
||||
destroyed ||
|
||||
!['a', 'b'].includes(id) ||
|
||||
!candidate?.key ||
|
||||
!PRODUCTS[candidate.product] ||
|
||||
!box ||
|
||||
!host?.identity
|
||||
)
|
||||
return false;
|
||||
let record = owner.slots[id];
|
||||
if (
|
||||
record &&
|
||||
(record.candidate.key !== candidate.key ||
|
||||
keyOf(record.box) !== keyOf(box))
|
||||
) {
|
||||
hideSlot(id);
|
||||
record = null;
|
||||
}
|
||||
if (!record) {
|
||||
record = owner.slots[id] = {
|
||||
candidate: { ...candidate },
|
||||
box: { ...box },
|
||||
alpha: alphaOf(alpha),
|
||||
split: splitDirection,
|
||||
atlas: null,
|
||||
};
|
||||
updateTiles(record);
|
||||
sync();
|
||||
} else if (
|
||||
record.alpha !== alphaOf(alpha) ||
|
||||
record.split !== splitDirection
|
||||
) {
|
||||
record.alpha = alphaOf(alpha);
|
||||
record.split = splitDirection;
|
||||
wake();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hideSlot,
|
||||
setAlpha(id, alpha) {
|
||||
const r = owner.slots[id],
|
||||
next = alphaOf(alpha);
|
||||
if (r && r.alpha !== next) {
|
||||
r.alpha = next;
|
||||
wake();
|
||||
}
|
||||
},
|
||||
setSplit(value) {
|
||||
if (owner.split !== value) {
|
||||
owner.split = value;
|
||||
wake();
|
||||
}
|
||||
},
|
||||
setBoxPreview(box) {
|
||||
owner.preview = box ? { ...box } : null;
|
||||
ground.sync();
|
||||
wake();
|
||||
},
|
||||
viewCentre() {
|
||||
const { width, height } = engine.host.size(),
|
||||
hit = engine.camera.pickEllipsoid({ x: width / 2, y: height / 2 });
|
||||
const c = hit && geo.cartographic.fromCartesian(hit, geo.ellipsoid.WGS84);
|
||||
return c
|
||||
? {
|
||||
lon: (c.longitude * 180) / Math.PI,
|
||||
lat: (c.latitude * 180) / Math.PI,
|
||||
}
|
||||
: null;
|
||||
},
|
||||
zoomToFit(target) {
|
||||
const height = fitViewHeightM({
|
||||
...engine.host.size(),
|
||||
fovy: engine.camera.pose().fovy,
|
||||
});
|
||||
engine.camera.flyTo({
|
||||
destination: geo.cartesian3.fromDegrees(target.lon, target.lat, height),
|
||||
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
|
||||
duration: 1.2,
|
||||
});
|
||||
return height;
|
||||
},
|
||||
getOwned: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(owner.slots).map(([id, r]) => [
|
||||
id,
|
||||
r
|
||||
? {
|
||||
key: r.candidate.key,
|
||||
kind: 'globe',
|
||||
alpha: r.alpha,
|
||||
split: r.split,
|
||||
}
|
||||
: null,
|
||||
]),
|
||||
),
|
||||
ownedCount: () => Object.values(owner.slots).filter(Boolean).length,
|
||||
diagnostics: () => ({
|
||||
...queue.diagnostics(),
|
||||
slots: Object.fromEntries(
|
||||
Object.entries(owner.slots).map(([id, r]) => [
|
||||
id,
|
||||
r?.atlas
|
||||
? {
|
||||
level: r.atlas.plan.z,
|
||||
tiles: r.atlas.plan.width * r.atlas.plan.height,
|
||||
loaded: r.atlas.loaded,
|
||||
failed: r.atlas.failed,
|
||||
}
|
||||
: null,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
offCamera();
|
||||
hideSlot('a');
|
||||
hideSlot('b');
|
||||
queue.destroy();
|
||||
ground.destroy();
|
||||
releaseCredit?.();
|
||||
releaseCredit = null;
|
||||
owner.preview = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRecentImageryNavaraRenderer } from './navara.js';
|
||||
|
||||
test('slot replacement, alpha-only edits, and destruction own textures, credits and late decodes', async () => {
|
||||
const previousDocument = globalThis.document,
|
||||
canvases = [],
|
||||
jobs = [],
|
||||
draws = [];
|
||||
globalThis.document = {
|
||||
createElement() {
|
||||
const c = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
getContext: () => ({ drawImage: (i) => draws.push(i) }),
|
||||
};
|
||||
canvases.push(c);
|
||||
return c;
|
||||
},
|
||||
};
|
||||
let owner,
|
||||
removed = 0,
|
||||
credits = 0,
|
||||
textureDisposals = 0,
|
||||
off = 0;
|
||||
const callbacks = [];
|
||||
const engine = {
|
||||
camera: {
|
||||
changed: {
|
||||
addEventListener(fn) {
|
||||
callbacks.push(fn);
|
||||
return () => off++;
|
||||
},
|
||||
},
|
||||
pose: () => ({
|
||||
cartographic: { height: 12000, latitude: 0.5 },
|
||||
fovy: 0.7,
|
||||
}),
|
||||
viewRectangle: () => null,
|
||||
},
|
||||
host: {
|
||||
canvas: { width: 1280 },
|
||||
size: () => ({ width: 1280, height: 800 }),
|
||||
},
|
||||
maps: { current: () => ({ photoreal: null }) },
|
||||
render: { request() {} },
|
||||
credits: {
|
||||
addStatic() {
|
||||
credits++;
|
||||
return () => credits--;
|
||||
},
|
||||
},
|
||||
};
|
||||
const view = {
|
||||
registerEffect() {},
|
||||
addEffect(config) {
|
||||
owner = config.gevRecentImagery.owner;
|
||||
return {
|
||||
update() {},
|
||||
delete() {
|
||||
removed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const renderer = createRecentImageryNavaraRenderer(engine, {
|
||||
view,
|
||||
EffectDesc: class {},
|
||||
NavaraEffect: class {},
|
||||
load: (url, signal) =>
|
||||
new Promise((resolve) => jobs.push({ url, signal, resolve })),
|
||||
});
|
||||
try {
|
||||
const box = { west: -97.77, east: -97.71, south: 30.25, north: 30.29 };
|
||||
const candidate = {
|
||||
key: 'S30:2026-10-28',
|
||||
product: 'S30',
|
||||
day: '2026-10-28',
|
||||
};
|
||||
renderer.rebind(renderer.resolveHost());
|
||||
renderer.showSlot('a', candidate, box);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const texture = owner.slots.a.atlas.texture,
|
||||
canvas = owner.slots.a.atlas.canvas;
|
||||
texture.addEventListener('dispose', () => textureDisposals++);
|
||||
const originalJobs = jobs.length;
|
||||
renderer.showSlot('a', candidate, box, {
|
||||
alpha: 0.4,
|
||||
splitDirection: 'left',
|
||||
});
|
||||
renderer.setSplit(0.3);
|
||||
callbacks[0]();
|
||||
assert.equal(owner.slots.a.atlas.texture, texture);
|
||||
assert.equal(jobs.length, originalJobs);
|
||||
assert.equal(owner.split, 0.3);
|
||||
assert.equal(renderer.getOwned().a.alpha, 0.4);
|
||||
assert.equal(credits, 1);
|
||||
renderer.showSlot(
|
||||
'a',
|
||||
{ ...candidate, key: 'new', day: '2026-10-27' },
|
||||
box,
|
||||
);
|
||||
assert.equal(textureDisposals, 1);
|
||||
assert.equal(canvas.width, 0);
|
||||
assert.ok(jobs.every((j) => j.signal.aborted));
|
||||
let closed = 0;
|
||||
for (const j of jobs) j.resolve({ close: () => closed++ });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(draws.length, 0);
|
||||
assert.equal(closed, originalJobs);
|
||||
renderer.destroy();
|
||||
renderer.destroy();
|
||||
for (const j of jobs.slice(originalJobs))
|
||||
j.resolve({ close: () => closed++ });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(renderer.ownedCount(), 0);
|
||||
assert.equal(credits, 0);
|
||||
assert.equal(removed, 1);
|
||||
assert.equal(off, 1);
|
||||
assert.equal(renderer.diagnostics().active, 0);
|
||||
assert.ok(canvases.every((c) => c.width === 0));
|
||||
} finally {
|
||||
renderer.destroy();
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import {
|
||||
} from '../../shared/render/navaraGroundSurface.js';
|
||||
import { defineGroundEffect } from '../../shared/render/navaraGroundEffect.js';
|
||||
|
||||
const KEY = 'gevRecentImagery',
|
||||
effects = new Map();
|
||||
// Two images under a swipe on a Navara surface pass. Recent Imagery draws
|
||||
// through engine.imagery now; the Nepal event imagery (bhoteKoshi) still
|
||||
// draws here until it moves there too.
|
||||
const effects = new Map();
|
||||
const shader = /* glsl */ `
|
||||
uniform sampler2D tAllDepth,tSurfaceDepth,tA,tB;
|
||||
uniform mat4 uInverseProjection,uWorld;
|
||||
@@ -57,19 +59,17 @@ void mainImage(const in vec4 inputColor,const in vec2 uv,out vec4 outputColor) {
|
||||
const boxVector = (b) =>
|
||||
b ? [b.west, b.south, b.east, b.north] : [0, 0, 0, 0];
|
||||
|
||||
// Another key (the Nepal event imagery) draws over the recent imagery.
|
||||
// Over engine.imagery's layers (src/globe/navara/imagery/pass.js).
|
||||
const imageryEffect = (key) =>
|
||||
defineGroundEffect({
|
||||
key,
|
||||
name: 'GevRecentImagery',
|
||||
name: 'GevEventImagery',
|
||||
fragmentShader: shader,
|
||||
insertAfter:
|
||||
key === KEY ? ['toneMapping'] : ['gevRecentImagery', 'toneMapping'],
|
||||
insertAfter: ['gevImagery', 'toneMapping'],
|
||||
insertBefore: [
|
||||
'gevNepalEvent',
|
||||
'gevObservedWeather',
|
||||
'gevEarthquakeGround',
|
||||
'gevFirmsGround',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
@@ -122,7 +122,7 @@ export function createImageryGround(
|
||||
engine,
|
||||
{ view, EffectDesc, NavaraEffect },
|
||||
owner,
|
||||
key = KEY,
|
||||
key,
|
||||
) {
|
||||
if (!effects.has(key)) effects.set(key, imageryEffect(key));
|
||||
effects.get(key).register(view, { EffectDesc, NavaraEffect });
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
const LIMIT = 85.05112878;
|
||||
export const TILE_SIZE = 256;
|
||||
const MAX_TILE_SIDE = 8;
|
||||
export const mercatorX = (lon) => (lon + 180) / 360;
|
||||
export const mercatorY = (lat) => {
|
||||
const r = (Math.max(-LIMIT, Math.min(LIMIT, lat)) * Math.PI) / 180;
|
||||
return (1 - Math.log(Math.tan(Math.PI / 4 + r / 2)) / Math.PI) / 2;
|
||||
};
|
||||
|
||||
/** At most 8×8 visible source tiles per slot, never a global mosaic. */
|
||||
export function imageryTilePlan(box, rectangle, level) {
|
||||
let area = { ...box };
|
||||
if (rectangle && rectangle.east >= rectangle.west) {
|
||||
const degrees = 180 / Math.PI;
|
||||
area = {
|
||||
west: Math.max(box.west, rectangle.west * degrees),
|
||||
east: Math.min(box.east, rectangle.east * degrees),
|
||||
south: Math.max(box.south, rectangle.south * degrees),
|
||||
north: Math.min(box.north, rectangle.north * degrees),
|
||||
};
|
||||
}
|
||||
if (area.west >= area.east || area.south >= area.north) return null;
|
||||
for (let z = Math.max(0, Math.floor(level)); z >= 0; z--) {
|
||||
const n = 2 ** z;
|
||||
const x = Math.max(0, Math.floor(mercatorX(area.west) * n));
|
||||
const y = Math.max(0, Math.floor(mercatorY(area.north) * n));
|
||||
const right = Math.min(n - 1, Math.ceil(mercatorX(area.east) * n) - 1);
|
||||
const bottom = Math.min(n - 1, Math.ceil(mercatorY(area.south) * n) - 1);
|
||||
const width = right - x + 1,
|
||||
height = bottom - y + 1;
|
||||
if (width <= MAX_TILE_SIDE && height <= MAX_TILE_SIDE)
|
||||
return {
|
||||
key: [z, x, y, width, height].join('/'),
|
||||
z,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
bounds: [x / n, y / n, width / n, height / n],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function imageryTileUrl(template, x, y, z) {
|
||||
return template
|
||||
.replaceAll('{x}', String(x))
|
||||
.replaceAll('{y}', String(y))
|
||||
.replaceAll('{z}', String(z))
|
||||
.replaceAll('{s}', ['a', 'b', 'c'][(x + y) % 3]);
|
||||
}
|
||||
|
||||
/** Cancellation holds its admission slot until fetch AND decode settle. */
|
||||
export function createImageryTileQueue({ load, limit = 6 }) {
|
||||
let active = 0,
|
||||
peak = 0,
|
||||
destroyed = false;
|
||||
const pending = [],
|
||||
controllers = new Set();
|
||||
function pump() {
|
||||
while (!destroyed && active < limit && pending.length) {
|
||||
const task = pending.shift();
|
||||
if (!task.live()) continue;
|
||||
const controller = new AbortController();
|
||||
controllers.add(controller);
|
||||
task.controllers.add(controller);
|
||||
active++;
|
||||
peak = Math.max(peak, active);
|
||||
Promise.resolve()
|
||||
.then(() => load(task.url, controller.signal))
|
||||
.then((image) => {
|
||||
try {
|
||||
if (!destroyed && !controller.signal.aborted && task.live())
|
||||
task.draw(image);
|
||||
} finally {
|
||||
image?.close?.();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!destroyed && !controller.signal.aborted && task.live())
|
||||
task.fail(error);
|
||||
})
|
||||
.finally(() => {
|
||||
controllers.delete(controller);
|
||||
task.controllers.delete(controller);
|
||||
active--;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
add(tasks) {
|
||||
if (!destroyed) {
|
||||
pending.push(...tasks);
|
||||
pump();
|
||||
}
|
||||
},
|
||||
prune() {
|
||||
for (let i = pending.length - 1; i >= 0; i--)
|
||||
if (!pending[i].live()) pending.splice(i, 1);
|
||||
},
|
||||
diagnostics: () => ({
|
||||
active,
|
||||
queued: pending.filter((t) => t.live()).length,
|
||||
peak,
|
||||
}),
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
pending.length = 0;
|
||||
for (const c of controllers) c.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
imageryTilePlan,
|
||||
imageryTileUrl,
|
||||
createImageryTileQueue,
|
||||
mercatorX,
|
||||
mercatorY,
|
||||
} from './tiles.js';
|
||||
|
||||
test('visible imagery tiles stay bounded and contain the intersected geographic area', () => {
|
||||
const box = { west: -100, east: -90, south: 25, north: 35 };
|
||||
for (const level of [0, 5, 9, 12]) {
|
||||
const plan = imageryTilePlan(box, null, level),
|
||||
n = 2 ** plan.z;
|
||||
assert.ok(plan.width <= 8 && plan.height <= 8);
|
||||
assert.ok(
|
||||
plan.x <= mercatorX(box.west) * n &&
|
||||
plan.x + plan.width >= mercatorX(box.east) * n,
|
||||
);
|
||||
assert.ok(
|
||||
plan.y <= mercatorY(box.north) * n &&
|
||||
plan.y + plan.height >= mercatorY(box.south) * n,
|
||||
);
|
||||
}
|
||||
const r = (x) => (x * Math.PI) / 180;
|
||||
const view = {
|
||||
west: r(-97.75),
|
||||
east: r(-97.73),
|
||||
south: r(30.26),
|
||||
north: r(30.28),
|
||||
};
|
||||
const plan = imageryTilePlan(box, view, 12);
|
||||
assert.equal(plan.z, 12);
|
||||
assert.ok(plan.width * plan.height < 8);
|
||||
assert.equal(
|
||||
imageryTilePlan(box, { west: 0, east: 0.1, south: 0, north: 0.1 }, 12),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('source tile addressing retains XYZ order and deterministic subdomain', () => {
|
||||
assert.equal(
|
||||
imageryTileUrl('https://gibs-{s}.example/{z}/{y}/{x}.png', 4, 7, 9),
|
||||
'https://gibs-c.example/9/7/4.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('six admissions include slow aborted decodes; stale images close without drawing or errors', async () => {
|
||||
const pending = [],
|
||||
drawn = [],
|
||||
failed = [],
|
||||
closed = [];
|
||||
let live = true;
|
||||
const controllers = new Set();
|
||||
const queue = createImageryTileQueue({
|
||||
load: (url, signal) =>
|
||||
new Promise((resolve) => pending.push({ url, signal, resolve })),
|
||||
});
|
||||
const task = (id) => ({
|
||||
url: String(id),
|
||||
controllers,
|
||||
live: () => live,
|
||||
draw: () => drawn.push(id),
|
||||
fail: () => failed.push(id),
|
||||
});
|
||||
queue.add(Array.from({ length: 10 }, (_, i) => task(i)));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 6);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
live = false;
|
||||
for (const c of controllers) c.abort();
|
||||
queue.prune();
|
||||
let replacement = true;
|
||||
queue.add([{ ...task(10), live: () => replacement }]);
|
||||
assert.equal(queue.diagnostics().active, 6);
|
||||
for (const p of pending) p.resolve({ close: () => closed.push(p.url) });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(pending.length, 7);
|
||||
assert.equal(drawn.length, 0);
|
||||
assert.equal(closed.length, 6);
|
||||
queue.destroy();
|
||||
replacement = false;
|
||||
pending[6].resolve({ close: () => closed.push('last') });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(drawn, []);
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(closed.length, 7);
|
||||
assert.deepEqual(queue.diagnostics(), { active: 0, queued: 0, peak: 6 });
|
||||
});
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Draped-imagery renderer for the Recent Imagery layer: at most two GIBS
|
||||
* imagery layers (slot `a`, left of a swipe; slot `b`, right) on whichever
|
||||
* host the map stack offers. Against the basemap slot `a` splits left and
|
||||
* slot `b` stays empty. Every layer is removed from the collection that owns it, so a
|
||||
* host switch or a replaced image never strands one. Tile requests pass a
|
||||
* small semaphore of our own; Cesium's global request limits stay untouched.
|
||||
*/
|
||||
import * as Cesium from 'cesium';
|
||||
import { PRODUCTS, gibsTemplate } from './model.js';
|
||||
|
||||
const SLOT_IDS = ['a', 'b'];
|
||||
const SPLIT_ENUM = { left: 'LEFT', right: 'RIGHT', none: 'NONE' };
|
||||
const NO_HOST = Object.freeze({ collection: null, kind: 'none' });
|
||||
|
||||
/** Render reason for a deferred or settled tile request. */
|
||||
export const TILE_RENDER_REASON = 'recent-imagery-tiles';
|
||||
/** Delay before the one frame that retries tiles Cesium itself deferred. */
|
||||
export const TILE_RETRY_DELAY_MS = 250;
|
||||
|
||||
function clampAlpha(value) {
|
||||
const alpha = Number(value);
|
||||
return Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 1;
|
||||
}
|
||||
|
||||
const boxKey = (box) =>
|
||||
[box.west, box.south, box.east, box.north].map(Number).join(',');
|
||||
|
||||
/**
|
||||
* Gate a provider's `requestImage` through the semaphore. Returning
|
||||
* `undefined` tells Cesium to retry on a later frame, but under
|
||||
* `requestRenderMode` there is no later frame unless something asks — and a
|
||||
* deferral must never ask for one itself: the retried tile would defer again
|
||||
* and ask again, keeping the scene rendering every frame. So a request that
|
||||
* finds our semaphore full asks for nothing (the request holding the slot
|
||||
* asks for one frame when it settles), and a request the provider itself
|
||||
* deferred goes through the one coalesced delayed retry.
|
||||
*/
|
||||
function admitTileRequests(provider, admission, requestRender, retry) {
|
||||
const original = provider.requestImage.bind(provider);
|
||||
provider.requestImage = (x, y, level, request) => {
|
||||
if (admission.inFlight >= admission.limit) return undefined;
|
||||
admission.inFlight += 1;
|
||||
const release = () => {
|
||||
admission.inFlight -= 1;
|
||||
};
|
||||
let promise;
|
||||
try {
|
||||
promise = original(x, y, level, request);
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
if (!promise) {
|
||||
release();
|
||||
retry.schedule();
|
||||
return undefined;
|
||||
}
|
||||
return Promise.resolve(promise).finally(() => {
|
||||
release();
|
||||
requestRender(TILE_RENDER_REASON);
|
||||
});
|
||||
};
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the renderer.
|
||||
* @param {{ cesium?: object, maxTileRequests?: number, requestRender?: (reason: string) => void, setTimeoutImpl?: Function, clearTimeoutImpl?: Function }} [options]
|
||||
*/
|
||||
export function createRecentImageryRenderer({
|
||||
cesium = Cesium,
|
||||
tileTemplate = gibsTemplate,
|
||||
credit = 'NASA GIBS',
|
||||
maxTileRequests = 6,
|
||||
requestRender = () => {},
|
||||
setTimeoutImpl = (fn, ms) => globalThis.setTimeout(fn, ms),
|
||||
clearTimeoutImpl = (id) => globalThis.clearTimeout(id),
|
||||
} = {}) {
|
||||
let host = NO_HOST;
|
||||
let destroyed = false;
|
||||
const admission = { limit: maxTileRequests, inFlight: 0 };
|
||||
/** @type {Record<'a'|'b', null | { layer: object, collection: object, candidate: object, box: object, alpha: number, split: string }>} */
|
||||
const owned = { a: null, b: null };
|
||||
|
||||
// One timer for both slots: any number of upstream deferrals while it is
|
||||
// armed share the same single retry frame.
|
||||
let retryTimer = null;
|
||||
const retry = {
|
||||
schedule() {
|
||||
if (retryTimer !== null) return;
|
||||
retryTimer = setTimeoutImpl(() => {
|
||||
retryTimer = null;
|
||||
if (!destroyed) requestRender(TILE_RENDER_REASON);
|
||||
}, TILE_RETRY_DELAY_MS);
|
||||
},
|
||||
cancel() {
|
||||
if (retryTimer !== null) clearTimeoutImpl(retryTimer);
|
||||
retryTimer = null;
|
||||
},
|
||||
};
|
||||
|
||||
function createProvider(candidate, box) {
|
||||
const spec = PRODUCTS[candidate.product];
|
||||
const provider = new cesium.UrlTemplateImageryProvider({
|
||||
url: tileTemplate(candidate.product, candidate.day),
|
||||
subdomains: ['a', 'b', 'c'],
|
||||
minimumLevel: 0,
|
||||
maximumLevel: spec.maxLevel,
|
||||
tilingScheme: new cesium.WebMercatorTilingScheme(),
|
||||
rectangle: cesium.Rectangle.fromDegrees(
|
||||
box.west,
|
||||
box.south,
|
||||
box.east,
|
||||
box.north,
|
||||
),
|
||||
credit,
|
||||
hasAlphaChannel: spec.format === 'png',
|
||||
});
|
||||
return admitTileRequests(provider, admission, requestRender, retry);
|
||||
}
|
||||
|
||||
function removeOwned(slotId) {
|
||||
const record = owned[slotId];
|
||||
if (!record) return false;
|
||||
owned[slotId] = null;
|
||||
try {
|
||||
record.collection.remove(record.layer, true);
|
||||
} catch {
|
||||
/* the collection is already gone */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function applyLook(record) {
|
||||
record.layer.alpha = record.alpha;
|
||||
record.layer.splitDirection =
|
||||
cesium.SplitDirection?.[SPLIT_ENUM[record.split] || 'NONE'] ?? 0;
|
||||
}
|
||||
|
||||
function mount(slotId, { candidate, box, alpha, split }) {
|
||||
const layer = host.collection.addImageryProvider(
|
||||
createProvider(candidate, box),
|
||||
);
|
||||
owned[slotId] = {
|
||||
layer,
|
||||
collection: host.collection,
|
||||
candidate,
|
||||
box,
|
||||
alpha,
|
||||
split,
|
||||
};
|
||||
applyLook(owned[slotId]);
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Drape a candidate into a slot. The same candidate on the same host only
|
||||
* updates its look; anything else replaces the slot's layer.
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {object} candidate
|
||||
* @param {object} box Degrees box.
|
||||
* @param {{ alpha?: number, splitDirection?: 'left'|'right'|'none' }} [look]
|
||||
* @returns {boolean} Whether a layer is now draped for the slot.
|
||||
*/
|
||||
showSlot(
|
||||
slotId,
|
||||
candidate,
|
||||
box,
|
||||
{ alpha = 1, splitDirection = 'none' } = {},
|
||||
) {
|
||||
if (destroyed || !candidate?.key || !box) return false;
|
||||
if (!host.collection) {
|
||||
removeOwned(slotId);
|
||||
return false;
|
||||
}
|
||||
const current = owned[slotId];
|
||||
const look = { alpha: clampAlpha(alpha), split: splitDirection };
|
||||
if (
|
||||
current &&
|
||||
current.collection === host.collection &&
|
||||
current.candidate.key === candidate.key &&
|
||||
boxKey(current.box) === boxKey(box)
|
||||
) {
|
||||
if (current.alpha !== look.alpha || current.split !== look.split) {
|
||||
Object.assign(current, look);
|
||||
applyLook(current);
|
||||
requestRender('recent-imagery-look');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
removeOwned(slotId);
|
||||
mount(slotId, { candidate, box, ...look });
|
||||
requestRender('recent-imagery-show');
|
||||
return true;
|
||||
},
|
||||
|
||||
/** @param {'a'|'b'} slotId */
|
||||
hideSlot(slotId) {
|
||||
if (removeOwned(slotId)) requestRender('recent-imagery-hide');
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {'a'|'b'} slotId
|
||||
* @param {number} alpha 0–1
|
||||
*/
|
||||
setAlpha(slotId, alpha) {
|
||||
const record = owned[slotId];
|
||||
const next = clampAlpha(alpha);
|
||||
if (!record || record.alpha === next) return;
|
||||
record.alpha = next;
|
||||
applyLook(record);
|
||||
requestRender('recent-imagery-alpha');
|
||||
},
|
||||
|
||||
/**
|
||||
* Move every owned layer to a new host. Layers are rebuilt there, never
|
||||
* re-parented, so the old collection is left as it was found.
|
||||
* @param {{ collection: object | null, kind: string }} nextHost
|
||||
*/
|
||||
rebind(nextHost) {
|
||||
host = nextHost || NO_HOST;
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
if (!record || record.collection === host.collection) continue;
|
||||
removeOwned(slotId);
|
||||
changed = true;
|
||||
if (host.collection && !destroyed) mount(slotId, record);
|
||||
}
|
||||
if (changed) requestRender('recent-imagery-rebind');
|
||||
},
|
||||
|
||||
/** What is draped right now, per slot. */
|
||||
getOwned() {
|
||||
const out = {};
|
||||
for (const slotId of SLOT_IDS) {
|
||||
const record = owned[slotId];
|
||||
out[slotId] = record
|
||||
? {
|
||||
key: record.candidate.key,
|
||||
kind: host.kind,
|
||||
alpha: record.alpha,
|
||||
split: record.split,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
|
||||
/** Number of layers this renderer owns (never above two). */
|
||||
ownedCount() {
|
||||
return SLOT_IDS.filter((slotId) => owned[slotId]).length;
|
||||
},
|
||||
|
||||
/** Remove everything and refuse further work. */
|
||||
destroy() {
|
||||
if (destroyed) return;
|
||||
destroyed = true;
|
||||
retry.cancel();
|
||||
let changed = false;
|
||||
for (const slotId of SLOT_IDS) changed = removeOwned(slotId) || changed;
|
||||
host = NO_HOST;
|
||||
if (changed) requestRender('recent-imagery-destroy');
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
TILE_RENDER_REASON,
|
||||
TILE_RETRY_DELAY_MS,
|
||||
createRecentImageryRenderer,
|
||||
} from './rendering.js';
|
||||
import { BOX, manualTimers } from './testDoubles.mjs';
|
||||
|
||||
/** A provider whose tile requests stay pending until the test settles them. */
|
||||
class UrlTemplateImageryProvider {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.calls = [];
|
||||
this.pending = [];
|
||||
}
|
||||
requestImage(x, y, level) {
|
||||
this.calls.push([x, y, level]);
|
||||
return new Promise((resolve) => this.pending.push(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
function fakeCesium(requestImage) {
|
||||
class Provider extends UrlTemplateImageryProvider {}
|
||||
if (requestImage) Provider.prototype.requestImage = requestImage;
|
||||
return {
|
||||
UrlTemplateImageryProvider: Provider,
|
||||
WebMercatorTilingScheme: class {},
|
||||
Rectangle: {
|
||||
fromDegrees: (west, south, east, north) => ({ west, south, east, north }),
|
||||
},
|
||||
SplitDirection: { LEFT: -1, NONE: 0, RIGHT: 1 },
|
||||
};
|
||||
}
|
||||
|
||||
function fakeCollection() {
|
||||
const layers = [];
|
||||
return {
|
||||
layers,
|
||||
addImageryProvider(provider) {
|
||||
const layer = { provider, alpha: 1, splitDirection: 0 };
|
||||
layers.push(layer);
|
||||
return layer;
|
||||
},
|
||||
remove(layer, destroy) {
|
||||
layers.splice(layers.indexOf(layer), 1);
|
||||
layer.destroyed = destroy;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const S30 = { key: 'S30:2026-09-18', product: 'S30', day: '2026-09-18' };
|
||||
const L30 = { key: 'L30:2026-09-16', product: 'L30', day: '2026-09-16' };
|
||||
const VIIRS = { key: 'VIIRS:2026-09-20', product: 'VIIRS', day: '2026-09-20' };
|
||||
|
||||
function fixture({ requestImage, maxTileRequests, ...options } = {}) {
|
||||
const renders = [];
|
||||
const timers = manualTimers();
|
||||
const renderer = createRecentImageryRenderer({
|
||||
...options,
|
||||
cesium: fakeCesium(requestImage),
|
||||
maxTileRequests,
|
||||
requestRender: (reason) => renders.push(reason),
|
||||
setTimeoutImpl: timers.setTimeoutImpl,
|
||||
clearTimeoutImpl: timers.clearTimeoutImpl,
|
||||
});
|
||||
const globe = fakeCollection();
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
const tileFrames = () =>
|
||||
renders.filter((reason) => reason === TILE_RENDER_REASON).length;
|
||||
return { renderer, globe, renders, timers, tileFrames };
|
||||
}
|
||||
|
||||
test('a slot drapes one GIBS provider bounded to the box; the same day only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
assert.equal(renderer.showSlot('a', S30, BOX, { alpha: 0.8 }), true);
|
||||
const [layer] = globe.layers;
|
||||
assert.match(
|
||||
layer.provider.options.url,
|
||||
/^https:\/\/gibs-\{s\}\.earthdata\.nasa\.gov\/.*HLS_S30.*\/2026-09-18\/GoogleMapsCompatible_Level12\/\{z\}\/\{y\}\/\{x\}\.png$/,
|
||||
);
|
||||
assert.deepEqual(layer.provider.options.subdomains, ['a', 'b', 'c']);
|
||||
assert.equal(layer.provider.options.credit, 'NASA GIBS');
|
||||
assert.deepEqual(layer.provider.options.rectangle, BOX);
|
||||
assert.equal(layer.alpha, 0.8);
|
||||
assert.deepEqual(renders, ['recent-imagery-show']);
|
||||
assert.deepEqual(renderer.getOwned().a, {
|
||||
key: 'S30:2026-09-18',
|
||||
kind: 'globe',
|
||||
alpha: 0.8,
|
||||
split: 'none',
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.5, splitDirection: 'left' });
|
||||
assert.deepEqual(globe.layers, [layer], 'the layer is reused');
|
||||
assert.equal(layer.splitDirection, -1);
|
||||
// A different day replaces the layer in its own collection.
|
||||
renderer.showSlot('a', L30, BOX);
|
||||
assert.equal(layer.destroyed, true);
|
||||
assert.equal(globe.layers.length, 1);
|
||||
// VIIRS is a level-9 JPEG overview with no alpha channel.
|
||||
renderer.showSlot('b', VIIRS, BOX, { splitDirection: 'right' });
|
||||
assert.equal(globe.layers[1].provider.options.maximumLevel, 9);
|
||||
assert.equal(globe.layers[1].provider.options.hasAlphaChannel, false);
|
||||
assert.equal(globe.layers[1].splitDirection, 1);
|
||||
renderer.setAlpha('b', 7);
|
||||
assert.equal(globe.layers[1].alpha, 1, 'alpha clamps to 0–1');
|
||||
});
|
||||
|
||||
test('rapid replacement never owns more than two layers', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
const days = [S30, L30, VIIRS];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
renderer.showSlot('a', days[i % 3], BOX);
|
||||
renderer.showSlot('b', days[(i + 1) % 3], BOX);
|
||||
if (i % 4 === 0) renderer.hideSlot('a');
|
||||
assert.ok(globe.layers.length <= 2 && renderer.ownedCount() <= 2);
|
||||
}
|
||||
renderer.hideSlot('a');
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 0);
|
||||
});
|
||||
|
||||
test('rebind rebuilds the owned layers on the new host and leaves the old one empty', () => {
|
||||
const { renderer, globe } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { alpha: 0.6, splitDirection: 'left' });
|
||||
renderer.showSlot('b', L30, BOX, { splitDirection: 'right' });
|
||||
const tileset = fakeCollection();
|
||||
renderer.rebind({ collection: tileset, kind: 'tileset' });
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.deepEqual(
|
||||
tileset.layers.map((layer) => [layer.alpha, layer.splitDirection]),
|
||||
[
|
||||
[0.6, -1],
|
||||
[1, 1],
|
||||
],
|
||||
);
|
||||
assert.equal(renderer.getOwned().a.kind, 'tileset');
|
||||
renderer.rebind({ collection: null, kind: 'none' });
|
||||
assert.equal(tileset.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false);
|
||||
renderer.rebind({ collection: globe, kind: 'globe' });
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), false, 'destroyed');
|
||||
});
|
||||
|
||||
test('above the tile limit a request defers silently; its settlement asks for exactly one frame', async () => {
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
const { provider } = globe.layers[0];
|
||||
const first = provider.requestImage(0, 0, 1);
|
||||
assert.ok(first instanceof Promise);
|
||||
for (let i = 1; i <= 120; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 1), undefined);
|
||||
assert.equal(provider.calls.length, 1, 'deferred requests never go out');
|
||||
assert.equal(tileFrames(), 0, 'a deferral asks for no frame');
|
||||
assert.equal(timers.armed(), 0);
|
||||
provider.pending[0]('tile');
|
||||
await first;
|
||||
assert.equal(tileFrames(), 1, 'the settlement asks for the retry frame');
|
||||
assert.ok(provider.requestImage(200, 0, 1) instanceof Promise);
|
||||
});
|
||||
|
||||
test('upstream deferrals from both slots share one delayed retry frame; destroy disarms it', () => {
|
||||
let throwNext = false;
|
||||
const { renderer, globe, timers, tileFrames } = fixture({
|
||||
maxTileRequests: 1,
|
||||
requestImage() {
|
||||
if (throwNext) throw new Error('boom');
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
renderer.showSlot('a', S30, BOX);
|
||||
renderer.showSlot('b', L30, BOX);
|
||||
for (const { provider } of globe.layers)
|
||||
for (let i = 0; i < 40; i += 1)
|
||||
assert.equal(provider.requestImage(i, 0, 0), undefined);
|
||||
// Every deferral released its slot (the limit is one), and none asked
|
||||
// for a frame of its own: one timer holds the single retry.
|
||||
assert.equal(tileFrames(), 0);
|
||||
assert.equal(timers.armed(), 1);
|
||||
assert.equal([...timers.pending.values()][0].ms, TILE_RETRY_DELAY_MS);
|
||||
timers.flush();
|
||||
assert.equal(tileFrames(), 1);
|
||||
// A throwing provider releases its slot too.
|
||||
throwNext = true;
|
||||
assert.throws(() => globe.layers[0].provider.requestImage(0, 0, 0), /boom/);
|
||||
throwNext = false;
|
||||
globe.layers[0].provider.requestImage(0, 0, 0);
|
||||
assert.equal(timers.armed(), 1, 'the next deferral arms a fresh timer');
|
||||
renderer.destroy();
|
||||
assert.equal(timers.armed(), 0, 'destroy disarms the retry');
|
||||
assert.equal(tileFrames(), 1);
|
||||
});
|
||||
|
||||
test('against the basemap slot a splits left with no second layer, and leaving the swipe only restyles it', () => {
|
||||
const { renderer, globe, renders } = fixture();
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'left' });
|
||||
renderer.hideSlot('b');
|
||||
assert.equal(globe.layers.length, 1, 'the basemap is the right side');
|
||||
assert.equal(globe.layers[0].splitDirection, -1);
|
||||
assert.equal(renderer.getOwned().b, null);
|
||||
const [layer] = globe.layers;
|
||||
renderer.showSlot('a', S30, BOX, { splitDirection: 'none' });
|
||||
assert.deepEqual(globe.layers, [layer], 'no rebuild for a new look');
|
||||
assert.equal(layer.splitDirection, 0);
|
||||
assert.deepEqual(renders, ['recent-imagery-show', 'recent-imagery-look']);
|
||||
});
|
||||
|
||||
test('replacement imagery uses source-owned tile URLs and credit and tears down normally', () => {
|
||||
const { renderer, globe } = fixture({
|
||||
tileTemplate: (product, day) =>
|
||||
`https://tiles.example/${product}/${day}/{z}/{x}/{y}`,
|
||||
credit: 'Example imagery',
|
||||
});
|
||||
assert.equal(renderer.showSlot('a', S30, BOX), true);
|
||||
const layer = globe.layers[0];
|
||||
assert.equal(
|
||||
layer.provider.options.url,
|
||||
'https://tiles.example/S30/2026-09-18/{z}/{x}/{y}',
|
||||
);
|
||||
assert.equal(layer.provider.options.credit, 'Example imagery');
|
||||
renderer.destroy();
|
||||
assert.equal(globe.layers.length, 0);
|
||||
assert.equal(layer.destroyed, true);
|
||||
});
|
||||
@@ -7,9 +7,10 @@ import { createImageryGround } from '../../recentImagery/render/navaraGround.js'
|
||||
// actual renderer declarations under every activation order, including the
|
||||
// minimal look, whose pipeline has no toneMapping anchor.
|
||||
const declarations = [
|
||||
'gevRecentImagery',
|
||||
'gevImagery',
|
||||
'gevNepalEvent',
|
||||
'gevObservedWeather',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
];
|
||||
// The descriptor a renderer registers; its setup stops there.
|
||||
@@ -39,12 +40,25 @@ async function declared(key, path, className) {
|
||||
.match(new RegExp(`static ${name} = \\[([^\\]]*)\\]`))[1]
|
||||
.matchAll(/'([^']+)'/g),
|
||||
].map((m) => m[1]);
|
||||
return { key, after: anchors('insertAfter'), before: anchors('insertBefore') };
|
||||
return {
|
||||
key,
|
||||
after: anchors('insertAfter'),
|
||||
before: anchors('insertBefore'),
|
||||
};
|
||||
}
|
||||
const specs = [
|
||||
registered((sdk) => createImageryGround({}, sdk, {})),
|
||||
await declared(
|
||||
'gevImagery',
|
||||
'../../../globe/navara/imagery/pass.js',
|
||||
'ImageryEffectDesc',
|
||||
),
|
||||
registered((sdk) => createImageryGround({}, sdk, {}, 'gevNepalEvent')),
|
||||
await declared('gevObservedWeather', '../../weather/render/navaraSurface.js'),
|
||||
await declared(
|
||||
'gevGround',
|
||||
'../../../globe/navara/ground/pass.js',
|
||||
'GroundEffectDesc',
|
||||
),
|
||||
await declared(
|
||||
'gevPreSharpenOverlay',
|
||||
'../../../globe/navara/overlay.js',
|
||||
|
||||
@@ -95,10 +95,10 @@ function weatherPass(engine, { view, EffectDesc, NavaraEffect }) {
|
||||
empty = null;
|
||||
class WeatherEffect extends EffectDesc {
|
||||
static key = KEY;
|
||||
static insertAfter = ['gevNepalEvent', 'gevRecentImagery', 'toneMapping'];
|
||||
static insertAfter = ['gevNepalEvent', 'gevImagery', 'toneMapping'];
|
||||
static insertBefore = [
|
||||
'gevEarthquakeGround',
|
||||
'gevFirmsGround',
|
||||
'gevGround',
|
||||
'gevPreSharpenOverlay',
|
||||
'gevSharpen',
|
||||
'smaa',
|
||||
|
||||
Reference in new issue
Block a user