mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 05:42:22 +00:00
Build the catalog from one layer manifest and attach one tools tree
src/app/layers/renderers.js now holds LAYER_MANIFEST, one entry per layer in directory order: a display (the same render/display.js on every engine, with the contract primitives it needs and, for CCTV and Global Context, a Navara backend loaded by import()), a layer that draws itself through the contract, or per-engine renderers for the layers not on a display yet (weather, wind, Bhote Koshi, sea marks, ski pistes). A layer is available where the engine has the primitives it needs. The Cesium renderer table, CESIUM_BUILTIN, the registry and the Navara mode's map of about 35 loaders go; the application loads a kind's renderers before it builds the catalog. The tools attach through one tree over the engine, in Cesium's order. Each engine kind adds its own tools from one backend that reads its extension once: the Cesium backend holds the viewer-only tools, the Navara backend the annotation renderer, draw surface and file import drawing. Coupling: engine.extensions reads fall from 12 to 8, engine-name literal lines from 73 to 66.
This commit is contained in:
1 parent
f1ec9d4a22
commit
48885d9e61
46 files changed
+1057
-899
No files matched your search
+45
-12
@@ -194,14 +194,25 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
corridors (a width in px or m), draped over terrain and 3D tiles. Each
|
||||
item has its own fill, outline and `pickId`. A set has a draw `order`, and
|
||||
shows, hides and replaces its items in bulk.
|
||||
- A pixel corridor can take Cesium's old looks: a `glow`
|
||||
(PolylineGlow's falloff), an alpha `pulse` and a flowing `dash` (the
|
||||
annotation route's). The GPU works them out from the time each frame;
|
||||
while a shown set has a pulse or a dash, the engine draws a frame after
|
||||
every frame, as Cesium's continuous render did. Traffic's jam heat
|
||||
lines glow and pulse, and the Directions route's dashes flow (it also
|
||||
holds continuous render again while drawn).
|
||||
- Cesium draws them with batched `GroundPrimitive` and
|
||||
`GroundPolylinePrimitive`.
|
||||
`GroundPolylinePrimitive`; a corridor with a look draws through one
|
||||
material of its own (`GevGroundCorridor`), which drew the jam lines and
|
||||
the route pixel for pixel as the old PolylineGlow and route flow
|
||||
materials did.
|
||||
- Navara draws them in one screen pass classified on the scene depth
|
||||
(`src/globe/navara/ground/`).
|
||||
(`src/globe/navara/ground/`). A corridor set is tiles of binned
|
||||
segments (`corridors.js`): 10,000 corridors draw in about 12 ms still
|
||||
and 15 ms moving, where Cesium takes 23 ms (the chunks before took 151
|
||||
and 196 ms).
|
||||
- Navara's vector layers were measured against that pass and lost
|
||||
(`docs/GLOBE-PRIMITIVES.md`).
|
||||
- No layer draws through `engine.ground` yet. Earthquakes and fire
|
||||
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
|
||||
@@ -254,20 +265,42 @@ one application, and only the globe-bound parts move to the next engine.
|
||||
contract has no primitive for yet, draw behind one seam,
|
||||
`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).
|
||||
layer manifest loads and passes in).
|
||||
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
|
||||
renderers are listed in `src/app/layers/renderers.js`
|
||||
(`CESIUM_LAYER_RENDERERS`), Navara ones are loaded by
|
||||
`registerNavaraLayerRenderers` in `src/app/navaraMode.js`; both register the
|
||||
same display factory for a layer that has one. Navara
|
||||
ground effects (heat, pulses, trails, pads, drapes) are declared with
|
||||
`defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
|
||||
`render/navaraMeshes.js`, which the layer manifest loads and passes in).
|
||||
Navara ground effects (heat, pulses, trails, pads, drapes) are declared
|
||||
with `defineGroundEffect` (`src/layers/shared/render/navaraGroundEffect.js`);
|
||||
their insert order is pinned by `navaraGroundOrder.test.mjs`.
|
||||
- **One layer manifest.** `LAYER_MANIFEST` in `src/app/layers/renderers.js`
|
||||
says how each directory layer draws: its display with the contract
|
||||
primitives it needs (and an engine-specific backend option keyed by engine
|
||||
kind: CCTV's meshes and Global Context's pulses on Navara), a layer that
|
||||
draws itself through the contract (Wildlife Sightings), or, without a
|
||||
display, its drawing per engine kind (wind, the weather rasters and the
|
||||
Bhote Koshi event and locator draw themselves on Cesium and through a
|
||||
Navara renderer on Navara; Sea Marks and Ski Pistes drape themselves on
|
||||
both). A layer is available on an engine when that engine has the
|
||||
primitives it needs, or, without a display, when its entry lists that
|
||||
kind; today every layer shows on both. The manifest's Navara backends
|
||||
and renderers load through `import()` (`loadLayerRenderers(kind)`, awaited
|
||||
by `src/standalone/application.js` before the engine's catalog), so
|
||||
Cesium mode fetches none of them; `rendererFor(id, engine, options)`
|
||||
answers synchronously after that. The layer list reads
|
||||
`createLayerAvailability(kind)`.
|
||||
- **One tools tree.** `src/app/tools.js` attaches the same tools to every
|
||||
engine (`attachEngineTools`): the annotation board, Draw, Import, the
|
||||
Recent Imagery box and panel, the render governor, the scope mask,
|
||||
hidden-tab handling and voice. Each engine kind's tool backend says where
|
||||
they draw; Cesium's also holds the tools that still drive its viewer
|
||||
(release trim, tracked-entity hold, the render-loop suspension, the
|
||||
embedded views), which read it through `engine.extensions.cesium?.viewer`
|
||||
and so stay absent on Navara. Navara's backend (`navaraToolBackend` in
|
||||
`src/app/navaraMode.js`, loaded with its mode) adds the daylight of a
|
||||
restored view and the engine notice.
|
||||
- **Cesium only on demand.** The production build loads `/cesium/Cesium.js`
|
||||
when a Cesium globe starts or a Navara user turns on a feature that still
|
||||
uses Cesium's maths or entity model (traffic, Street Level, the Bhote Koshi
|
||||
|
||||
@@ -86,6 +86,31 @@ Item options:
|
||||
- `drape` is `'both'` (the default), `'terrain'` or `'tiles'`. Cesium
|
||||
honours it; Navara drapes on whatever surface it draws.
|
||||
|
||||
A `widthPx` corridor can also take the looks Cesium's ground polyline
|
||||
materials gave it. The GPU works them out every frame from the time, so a
|
||||
commit never rebuilds them:
|
||||
|
||||
| look | fields | Cesium's |
|
||||
| ------- | ------------------------------------------------ | ---------------------------------------------------------------- |
|
||||
| `glow` | `power`, `taper` (1: none) | `PolylineGlow`'s `glowPower` and `taperPower` |
|
||||
| `pulse` | `periodMs`, `min`, `max` | the alpha times a factor from `min` to `max` and back, a sine |
|
||||
| `dash` | `cells`, `duty`, `speed`, `gapAlpha`, `emission` | the annotation route's `GevRouteFlow`: dashes flowing to the end |
|
||||
|
||||
- `glow` brightens the line toward white along its centre and fades it to
|
||||
nothing at its edges.
|
||||
- `pulse` follows the page's monotonic clock (`performance.now()`), as the
|
||||
old materials did, not the app clock.
|
||||
- `dash` lays `cells` dashes along the whole line. Each is lit for its first
|
||||
`duty`, with soft edges of 0.08 of a cell, and brightened by `emission`
|
||||
times its colour. Between them the alpha is times `gapAlpha`. The dashes
|
||||
move toward the line's end at `speed` cells a second.
|
||||
- A shown set with a `pulse` or a `dash` makes the engine draw a frame after
|
||||
every frame, as Cesium's continuous render did. `list()` reports it as
|
||||
`animated`. A `glow` alone does not animate.
|
||||
|
||||
Traffic's jam heat lines glow (power 0.25) and pulse (alpha 0.35 to 0.75,
|
||||
1.63 s). The Directions route's 64 dashes flow at 0.55 cells a second.
|
||||
|
||||
### How each engine draws the ground
|
||||
|
||||
**Cesium** (`src/globe/cesium/ground.js`) builds at most two batches per set:
|
||||
@@ -94,7 +119,11 @@ Item options:
|
||||
metre corridors);
|
||||
- one `GroundPolylinePrimitive` for all the outlines and pixel corridors.
|
||||
|
||||
Both use per-instance colours and the set's `classificationType`. Cesium
|
||||
Both use per-instance colours and the set's `classificationType`. A
|
||||
corridor with a look draws in a `GroundPolylinePrimitive` of its own look
|
||||
and colour, over the set's other batches. Its material does the glow, pulse
|
||||
and dash. The engine writes its time before every frame, and asks for the
|
||||
next frame after every frame while the set is shown. Cesium
|
||||
builds them in workers; the set's `ready()` turns true once they are drawn.
|
||||
A set that is not pickable stays out of the pick pass, so it never hides
|
||||
what lies under it.
|
||||
@@ -106,7 +135,7 @@ its screen cell:
|
||||
|
||||
- a disc against its radius;
|
||||
- an area against its edges;
|
||||
- a corridor against its distance to the line.
|
||||
- a corridor tile against the corridor segments near the pixel.
|
||||
|
||||
The tests run in each item's own gnomonic plane, so great-circle edges stay
|
||||
straight. Fills and outlines are anti-aliased across one pixel and blended
|
||||
@@ -116,9 +145,38 @@ Large shapes get extra structure:
|
||||
|
||||
- An area with more than 32 edges gets a grid of bins. A pixel then tests
|
||||
only the edges of its own bin.
|
||||
- A long corridor is split into chunks of 4 segments.
|
||||
- A corridor set becomes tiles, set-wide grids of bins
|
||||
(`src/globe/navara/ground/corridors.js`). See below.
|
||||
- The terrain heights under each item bound its screen box.
|
||||
|
||||
**Corridor tiles.** A set's lines are cut into pieces of at most 0.5°
|
||||
along their great circles. The pieces fall into a tree of longitude and
|
||||
latitude tiles, each with at most 20 000 pieces and at most 20° a side. A
|
||||
tile is one pass item:
|
||||
|
||||
- a gnomonic plane of its own;
|
||||
- the pieces that come near it, as segments in that plane;
|
||||
- a pyramid of square bins. A bin of level z is 2^z times the finest bin.
|
||||
It lists runs of segments, item by item in draw order: the segments that
|
||||
reach within its margin. The margin is half a bin for the set's thinnest
|
||||
corridor, more for a wider one, plus a width in metres.
|
||||
|
||||
The finest bin is sized so that it holds about two runs per piece. The
|
||||
coarsest level has at most 8 bins across.
|
||||
|
||||
A pixel finds its ground in world space once. It takes the one tile of the
|
||||
set whose cell holds it: the cells are tested with the meridians' normals
|
||||
and the parallels' sines, so two neighbouring tiles never both draw a
|
||||
pixel. The pixel then takes the finest level whose margin covers the
|
||||
reach of the set's thinnest corridor, and tests only its bin's runs. Each
|
||||
item takes its nearest segment there, and blends once, in item order. When
|
||||
the pixel is wider than the coarsest margin covers (a tile under about 30
|
||||
pixels), lines near a tile's edge can lose a little of a neighbour's
|
||||
reach; the tile is a solid blur by then.
|
||||
|
||||
A commit packs the tiles once. Only the heights are sampled again when the
|
||||
terrain loads later.
|
||||
|
||||
Picks are CPU picks at the ground under the pointer (`src/globe/ground.js`).
|
||||
|
||||
Navara's vector layers (`src/globe/navara/ground/vector.js`) were measured
|
||||
@@ -178,6 +236,24 @@ Corridors (40-point lines, 2.5 px), still / moving:
|
||||
At 10 000 corridors Cesium allocated 613 MB of WebGL memory, the pass 44 MB
|
||||
and the vector layers about 70 MB.
|
||||
|
||||
The pass with corridor tiles, measured again with the pass before them on
|
||||
the same afternoon (the machine busier, the empty scene 6.0–8.9 ms):
|
||||
|
||||
| N | Cesium | pass before | pass with tiles |
|
||||
| ------ | -------------- | -------------- | --------------- |
|
||||
| 1 000 | 12.8 / 13.9 ms | 19.8 / 24.9 ms | 9.1 / 12.6 ms |
|
||||
| 10 000 | 23.1 / 23.2 ms | 151 / 196 ms | 12.1 / 15.4 ms |
|
||||
| 15 000 | 31.8 / 31.4 ms | 226 / 289 ms | 17.7 / 19.7 ms |
|
||||
|
||||
The tiles hold about what the chunks held: 47 MB of textures at 10 000
|
||||
corridors (46 MB before), 68 MB at 15 000 (69 MB). Packing takes longer:
|
||||
the commit of 10 000 corridors went from 0.6 s to about 1.9 s, and 15 000
|
||||
from 0.8 s to 3.0 s.
|
||||
|
||||
A pixel corridor's width is now measured across the line (the gradient of
|
||||
the distance to it), so it is the same at any angle, as on Cesium. Before,
|
||||
a diagonal line drew up to √2 wider.
|
||||
|
||||
Edge quality over the Hajar mountains, oblique view:
|
||||
|
||||
- **Cesium:** a sharp drape with its outline.
|
||||
@@ -207,10 +283,9 @@ more on every frame the camera moves, whatever their count. They draw no
|
||||
ground outlines and no metre widths. Their colours are lit, and hiding them
|
||||
needs a rebuild.
|
||||
|
||||
Corridors are the pass's limit: about 1 000 lines draw at 18 ms, but 10 000
|
||||
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`.
|
||||
Corridors were the pass's limit: 10 000 lines took 135 ms. With a
|
||||
set-wide grid of bins (corridor tiles), 10 000 take 12 ms, under Cesium's
|
||||
23 ms.
|
||||
|
||||
## Imagery: rasters on the ground
|
||||
|
||||
@@ -335,7 +410,8 @@ near the camera from one 8×8 atlas over the whole view.
|
||||
- a line on the ground: `corridor`, with `widthM` when its width is a
|
||||
real width (an outline in metres) and `widthPx` otherwise.
|
||||
- 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.
|
||||
A ground set is rebuilt on every commit, so a fill that changes over time
|
||||
is not a ground set, except a pixel corridor's glow, pulse and dash, which
|
||||
the engine animates.
|
||||
- **Pixels on the ground** (tiles, a raster, a photo): an imagery layer.
|
||||
Fade it with `setAlpha`, and give a new raster to `setSource`.
|
||||
@@ -86,6 +86,11 @@ const cesiumLayerRenderer = (file) =>
|
||||
// Navara renderers outside a layer's render/ directory: like those, they
|
||||
// reach the app only through the Navara mode's import().
|
||||
const NAVARA_SCENE_RENDERERS = new Set(['src/scenes/dataPacks/navara.js']);
|
||||
// The modules that load Navara renderers, each through import().
|
||||
const NAVARA_RENDERER_LOADERS = new Set([
|
||||
'src/app/navaraMode.js',
|
||||
'src/app/layers/renderers.js',
|
||||
]);
|
||||
const navaraLayerRenderer = (file) =>
|
||||
/\/render\/navara[^/]*\.js$/.test(file) || NAVARA_SCENE_RENDERERS.has(file);
|
||||
const renderer = (file) =>
|
||||
@@ -227,12 +232,13 @@ export function checkImportDirections(root) {
|
||||
`The Navara engine loads only through a dynamic import(): ${to}`,
|
||||
);
|
||||
// Like the engine, a layer's Navara renderer reaches the app only
|
||||
// through the Navara mode's import() (src/app/navaraMode.js).
|
||||
// through an import() of the Navara mode (src/app/navaraMode.js) or
|
||||
// of the layer manifest (src/app/layers/renderers.js).
|
||||
if (
|
||||
navaraLayerRenderer(to) &&
|
||||
!navaraLayerRenderer(file) &&
|
||||
!(
|
||||
file === 'src/app/navaraMode.js' &&
|
||||
NAVARA_RENDERER_LOADERS.has(file) &&
|
||||
analysis.dynamicImports.includes(specifier)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
"cesiumLayerRendererLines": 330,
|
||||
"navaraLayerRendererLines": 2304,
|
||||
"cesiumImportFiles": 27,
|
||||
"viewerLines": 1234,
|
||||
"viewerLines": 1232,
|
||||
"navaraKindChecks": 5,
|
||||
"engineSniffCalls": 0,
|
||||
"engineExtensionReads": 12,
|
||||
"engineExtensionReads": 8,
|
||||
"groundEffectPasses": 3,
|
||||
"cesiumSdkLoads": 4,
|
||||
"screenSpaceHandlerFiles": 4,
|
||||
"engineNameLines": 73
|
||||
"engineNameLines": 66
|
||||
},
|
||||
"measuredAt": "after the phase 7 feature moves, 2026-11-07"
|
||||
"measuredAt": "after one layer manifest and one tools tree, 2026-11-07"
|
||||
}
|
||||
@@ -3402,7 +3402,6 @@
|
||||
"src/globe/navara/viewOptions.js",
|
||||
"src/app/engineChoice.js",
|
||||
"src/app/navaraMode.js",
|
||||
"src/app/navaraTools.js",
|
||||
"src/annotations/navaraAnnotationRenderer.js",
|
||||
"src/annotations/drawInputNavara.js",
|
||||
"src/data/render/cesiumProjection.js",
|
||||
@@ -4316,7 +4315,18 @@
|
||||
"src/layers/directions/render/display.js",
|
||||
"src/layers/streetLevel/render/display.js",
|
||||
"src/layers/traffic/render/display.js",
|
||||
"src/layers/recentImagery/render/display.js"
|
||||
"src/layers/recentImagery/render/display.js",
|
||||
"src/layers/shared/render/navaraStory.js",
|
||||
"src/layers/cctv/render/navaraMeshes.js",
|
||||
"src/layers/recentImagery/render/navaraGround.js",
|
||||
"src/layers/shared/render/navaraGroundSurface.js",
|
||||
"src/layers/wind/render/flowMesh.js",
|
||||
"src/layers/wind/render/navara.js",
|
||||
"src/layers/shared/render/navaraGroundEffect.js",
|
||||
"src/layers/awareness/render/navaraPulses.js",
|
||||
"src/layers/weather/render/navaraSurface.js",
|
||||
"src/layers/weather/render/navara.js",
|
||||
"src/layers/bhoteKoshi/render/navara.js"
|
||||
],
|
||||
"external": [
|
||||
"hls.js",
|
||||
@@ -4331,7 +4341,11 @@
|
||||
"vue",
|
||||
"pinia",
|
||||
"vue-i18n",
|
||||
"kdbush"
|
||||
"kdbush",
|
||||
"postprocessing",
|
||||
"@navaramap/three",
|
||||
"@navaramap/three-default-plugin",
|
||||
"three"
|
||||
]
|
||||
},
|
||||
"application-operations": {
|
||||
|
||||
@@ -200,9 +200,9 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
);
|
||||
});
|
||||
|
||||
test('the catalog per engine: Cesium as before, Navara only what it can show', async (t) => {
|
||||
test('the catalog per engine: the same layers on both, a subset when the manifest says so', async (t) => {
|
||||
const { APPLICATION_LAYER_DIRECTORY } = await import('./layers/directory.js');
|
||||
const { createLayerAvailability, createRendererRegistry } =
|
||||
const { createLayerAvailability, LAYER_MANIFEST } =
|
||||
await import('./layers/renderers.js');
|
||||
const controller = new AbortController();
|
||||
t.after(() => controller.abort());
|
||||
@@ -224,20 +224,20 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
|
||||
assert.equal(cesium.availability.unavailable().length, 0);
|
||||
|
||||
const navara = build({ engineKind: 'navara' });
|
||||
assert.equal(navara.layers.length, 0, 'no Navara renderers yet');
|
||||
assert.equal(navara.unavailableMetadata.length, cesium.metadata.length);
|
||||
assert.equal(navara.layers.length, cesium.layers.length);
|
||||
assert.deepEqual(navara.unavailableMetadata, []);
|
||||
assert.equal(navara.availability.engine, 'navara');
|
||||
|
||||
// With Navara renderers for two layers, only those are constructed, in
|
||||
// catalog order, with their metadata.
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', {
|
||||
satellites: () => ({}),
|
||||
flights: () => ({}),
|
||||
});
|
||||
// When the engine can show only two layers, only those are constructed,
|
||||
// in catalog order, with their metadata.
|
||||
const subset = build({
|
||||
engineKind: 'navara',
|
||||
availability: createLayerAvailability('navara', { registry }),
|
||||
availability: createLayerAvailability('navara', {
|
||||
manifest: {
|
||||
satellites: LAYER_MANIFEST.satellites,
|
||||
flights: LAYER_MANIFEST.flights,
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
subset.layers.map(({ id }) => id),
|
||||
@@ -251,8 +251,8 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
|
||||
assert.equal(subset.get('military'), undefined);
|
||||
});
|
||||
|
||||
test('both production registries attach to the same 38 session models', async (t) => {
|
||||
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
|
||||
test('both engines attach to the same 38 session models', async (t) => {
|
||||
const { loadLayerRenderers } = await import('./layers/renderers.js');
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const options = {
|
||||
@@ -262,7 +262,7 @@ test('both production registries attach to the same 38 session models', async (t
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const cesium = createApplicationCatalog(options);
|
||||
await registerNavaraLayerRenderers();
|
||||
await loadLayerRenderers('navara');
|
||||
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
|
||||
assert.equal(cesium.layers.length, 38);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/**
|
||||
* Wire air-quality readings to the application overlay host; the station
|
||||
* discs are the engine's renderer from the registry (render/display.js on
|
||||
* discs are the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationAirQuality(options) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createApplicationVessels({
|
||||
source,
|
||||
options = {},
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the vessels display on every engine; none without an engine).
|
||||
// manifest: the vessels display on every engine; none without an engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('ais-live-vessels', engine),
|
||||
},
|
||||
@@ -36,7 +36,7 @@ export function createApplicationVessels({
|
||||
worldFocus,
|
||||
render,
|
||||
selection,
|
||||
// The engine's vessel display from the registry (render/display.js).
|
||||
// The engine's vessel display from the manifest (render/display.js).
|
||||
renderer,
|
||||
readouts,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationAlpr({
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as overlays from '../../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationBikeshare({ source }) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationCctv({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Wire cyclone selection to the application shared context; the layer draws
|
||||
* with the engine's renderer from the registry (render/display.js on every
|
||||
* with the engine's renderer from the manifest (render/display.js on every
|
||||
* engine). The catalog passes the same factory as `createNativeRendering`.
|
||||
*/
|
||||
export function createApplicationCyclones({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct Directions with application scene owners and an explicit route
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationDirections({ source, surface }) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire FAA special-use airspace into the application catalog; the zones are
|
||||
* the engine's renderer from the registry (render/display.js on every
|
||||
* the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationDroneAirspace(options) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/**
|
||||
* Wire earthquake observations to the application overlay host; the discs
|
||||
* are the engine's renderer from the registry (render/display.js on every
|
||||
* are the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationEarthquakes({
|
||||
|
||||
@@ -15,7 +15,7 @@ export const firmsServices = {
|
||||
|
||||
/**
|
||||
* Bind a supplied fire feed to the application scene service owners; it
|
||||
* draws with the engine's renderer from the registry (render/display.js on
|
||||
* draws with the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationFirms({ surface, ...options }) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createApplicationFlights({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the flights display on every engine).
|
||||
// manifest: the flights display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('flights', engine),
|
||||
},
|
||||
@@ -32,7 +32,7 @@ export function createApplicationFlights({
|
||||
services: {
|
||||
picking,
|
||||
selection,
|
||||
// The engine's flights renderer from the registry (render/display.js).
|
||||
// The engine's flights renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
aircraftPresentation,
|
||||
camera: trackedCamera,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire the S2 Underground GhostMaps layer into the application catalog; it
|
||||
* draws with the engine's renderer from the registry (render/display.js on
|
||||
* draws with the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationGhostMaps(options) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
|
||||
/**
|
||||
* Wire the adsb.lol GNSS navigation-integrity layer into the application
|
||||
* catalog; the cells are the engine's renderer from the registry
|
||||
* catalog; the cells are the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createApplicationLocalAdsb({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the Local ADS-B display on every engine).
|
||||
// manifest: the Local ADS-B display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('local-adsb', engine),
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export function createApplicationLocalAdsb({
|
||||
context,
|
||||
picking,
|
||||
selection,
|
||||
// The engine's Local ADS-B renderer from the registry (render/display.js).
|
||||
// The engine's Local ADS-B renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
geoid,
|
||||
groundSnap: surface?.groundSnap || null,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createApplicationMilitary({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the military display on every engine).
|
||||
// manifest: the military display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('military', engine),
|
||||
},
|
||||
@@ -32,7 +32,7 @@ export function createApplicationMilitary({
|
||||
services: {
|
||||
picking,
|
||||
selection,
|
||||
// The engine's military renderer from the registry (render/display.js).
|
||||
// The engine's military renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
aircraftPresentation,
|
||||
camera: trackedCamera,
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationInstallations({ surface, source }) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Wire OSM Places labels to the application overlay host; the points are the
|
||||
* engine's renderer from the registry (render/display.js on every engine).
|
||||
* engine's renderer from the manifest (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationOsmPlaces() {
|
||||
return createOsmPlacesLayer({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire WFIGS fire perimeters into the application catalog; the perimeters
|
||||
* are the engine's renderer from the registry (render/display.js on every
|
||||
* are the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationRadio({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct imagery presentation over one explicitly supplied acquisition
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
|
||||
+307
-107
@@ -1,13 +1,33 @@
|
||||
// Which engine draws which layer: the layer renderer registry and the layer
|
||||
// availability API built on it.
|
||||
// How each application layer draws on each engine: the layer manifest, and
|
||||
// the layer availability API built on it.
|
||||
//
|
||||
// A renderer factory `(engine, options) => renderer` draws one layer on one
|
||||
// engine kind (`render/cesium.js`, `render/navara.js` of a layer), or on
|
||||
// every kind (its `render/display.js`, registered for each). Cesium
|
||||
// layers without a render/cesium.js draw themselves: they are registered as
|
||||
// CESIUM_BUILTIN. Cesium entries are registered here, statically (main
|
||||
// chunk); Navara factories are registered by the Navara mode after its
|
||||
// import() (src/app/navaraMode.js).
|
||||
// ## The manifest
|
||||
//
|
||||
// One entry per layer of the directory (src/app/layers/directory.js), in
|
||||
// its order, of one of three kinds:
|
||||
//
|
||||
// - `display(factory, needs, backends)`: the layer's render/display.js, the
|
||||
// same factory `(engine, options) => renderer` on every engine, drawn
|
||||
// through the contract primitives in `needs` (docs/GLOBE-PRIMITIVES.md).
|
||||
// `backends` is the display's engine-specific option, keyed by engine
|
||||
// kind: an import() loader of `{ option: value }` merged into the
|
||||
// display's options on that engine (CCTV's meshes, Global Context's
|
||||
// pulses on Navara). The displays are static (the main chunk, as before):
|
||||
// the layers build their renderers synchronously.
|
||||
// - `drawsItself(needs)`: no renderer; the layer draws itself through the
|
||||
// contract primitives in `needs` (Wildlife Sightings).
|
||||
// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display
|
||||
// yet. On each engine kind listed it either draws itself (DRAWS_ITSELF,
|
||||
// the Cesium weather and wind, the open map overlays) or has an
|
||||
// engine-specific renderer, an import() loader of its factory (Navara's
|
||||
// weather, wind and Bhote Koshi). It shows only on the kinds listed.
|
||||
//
|
||||
// Every import() here is resolved by `loadLayerRenderers(kind)`, which the
|
||||
// page awaits before it builds an engine's catalog (src/standalone/
|
||||
// application.js), so a Cesium page fetches none of the Navara ones.
|
||||
// `rendererFor(id, engine, options)` then answers synchronously: the
|
||||
// layer's renderer on that engine, or null when it draws itself or the
|
||||
// engine cannot show it.
|
||||
//
|
||||
// ## Layer availability (stable API, used by the layer list)
|
||||
//
|
||||
@@ -18,12 +38,14 @@
|
||||
// availability.available() // [{ id, name }] in catalog order
|
||||
// availability.unavailable() // [{ id, name }] the catalog leaves out
|
||||
//
|
||||
// The object is frozen; available() and unavailable() return fresh arrays.
|
||||
// It reads the registry when called, so a Navara factory registered later
|
||||
// (after the Navara mode loads) shows up. `name` is the layer's display name
|
||||
// A layer is available on an engine kind when that engine has every
|
||||
// primitive its display (or its own drawing) needs, or, without one, when
|
||||
// its entry lists that kind. The object is frozen; available() and
|
||||
// unavailable() return fresh arrays. `name` is the layer's display name
|
||||
// (src/app/layers/directory.js). The layer list takes it as its
|
||||
// `availability` provider (`{ engine, unavailable() }` is the part it reads).
|
||||
|
||||
import { ENGINE_KINDS } from '../../globe/contract.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { createFlightsDisplay } from '../../layers/flights/render/display.js';
|
||||
import { createMilitaryDisplay } from '../../layers/military/render/display.js';
|
||||
@@ -54,105 +76,282 @@ 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']);
|
||||
/**
|
||||
* The contract's drawing primitives (docs/GLOBE-PRIMITIVES.md). They are
|
||||
* required members of every engine (src/globe/contract.js), so every
|
||||
* engine kind has them all.
|
||||
*/
|
||||
export const CONTRACT_PRIMITIVES = Object.freeze([
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
'imagery',
|
||||
]);
|
||||
|
||||
/** The layer draws itself on Cesium (no separate renderer yet). */
|
||||
export const CESIUM_BUILTIN = Object.freeze({ builtin: 'cesium' });
|
||||
/** The primitives an engine kind draws with: all of them, on every kind. */
|
||||
const primitivesOf = (engineKind) =>
|
||||
ENGINE_KINDS.includes(engineKind) ? CONTRACT_PRIMITIVES : [];
|
||||
|
||||
const isFactory = (value) =>
|
||||
typeof value === 'function' || value === CESIUM_BUILTIN;
|
||||
/** In a `perEngine` entry: the layer draws itself on that engine kind. */
|
||||
export const DRAWS_ITSELF = Object.freeze({ drawsItself: true });
|
||||
|
||||
/** A registry of layer renderers per engine kind. */
|
||||
export function createRendererRegistry() {
|
||||
const byEngine = new Map();
|
||||
const entries = (engineKind) => byEngine.get(engineKind) ?? new Map();
|
||||
return {
|
||||
/** Adds `{ [layerId]: factory }` for an engine kind; one per layer. */
|
||||
register(engineKind, factories) {
|
||||
if (typeof engineKind !== 'string' || !engineKind)
|
||||
throw new TypeError('registerLayerRenderers needs an engine kind');
|
||||
let map = byEngine.get(engineKind);
|
||||
if (!map) byEngine.set(engineKind, (map = new Map()));
|
||||
for (const [layerId, factory] of Object.entries(factories ?? {})) {
|
||||
if (!isFactory(factory))
|
||||
throw new TypeError(
|
||||
`Layer renderer for ${layerId} on ${engineKind} is not a factory`,
|
||||
);
|
||||
if (map.has(layerId))
|
||||
throw new Error(
|
||||
`Layer ${layerId} already has a renderer on ${engineKind}`,
|
||||
);
|
||||
map.set(layerId, factory);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The renderer of a layer on `engine` (factory(engine, options)), or null
|
||||
* when the engine has none or the layer still draws itself (Cesium).
|
||||
*/
|
||||
rendererFor(layerId, engine, options = {}) {
|
||||
const factory = entries(engine?.kind).get(layerId);
|
||||
return typeof factory === 'function' ? factory(engine, options) : null;
|
||||
},
|
||||
layerAvailableOn: (layerId, engineKind) => entries(engineKind).has(layerId),
|
||||
availableIds: (engineKind) => [...entries(engineKind).keys()],
|
||||
};
|
||||
const checkNeeds = (needs) => {
|
||||
if (
|
||||
!Array.isArray(needs) ||
|
||||
!needs.length ||
|
||||
!needs.every((name) => CONTRACT_PRIMITIVES.includes(name))
|
||||
)
|
||||
throw new TypeError(`Not contract primitives: ${needs}`);
|
||||
return Object.freeze([...needs]);
|
||||
};
|
||||
|
||||
const checkLoaders = (loaders, allowDrawsItself) => {
|
||||
for (const [kind, loader] of Object.entries(loaders)) {
|
||||
if (!ENGINE_KINDS.includes(kind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${kind}`);
|
||||
if (
|
||||
typeof loader !== 'function' &&
|
||||
!(allowDrawsItself && loader === DRAWS_ITSELF)
|
||||
)
|
||||
throw new TypeError(`Not a loader for ${kind}: ${loader}`);
|
||||
}
|
||||
return Object.freeze({ ...loaders });
|
||||
};
|
||||
|
||||
/** A layer drawn by its display on every engine (see the module comment). */
|
||||
function display(factory, needs, backends = {}) {
|
||||
if (typeof factory !== 'function')
|
||||
throw new TypeError('A layer display is a factory');
|
||||
return Object.freeze({
|
||||
display: factory,
|
||||
needs: checkNeeds(needs),
|
||||
backends: checkLoaders(backends, false),
|
||||
});
|
||||
}
|
||||
|
||||
const defaultRegistry = createRendererRegistry();
|
||||
/** A layer that draws itself through the contract (no renderer). */
|
||||
const drawsItself = (needs) => Object.freeze({ needs: checkNeeds(needs) });
|
||||
|
||||
/** Registers layer renderers on the application's registry. */
|
||||
export const registerLayerRenderers = (engineKind, factories) =>
|
||||
defaultRegistry.register(engineKind, factories);
|
||||
/** The application registry's renderer of a layer on an engine, or null. */
|
||||
export const rendererFor = (layerId, engine, options) =>
|
||||
defaultRegistry.rendererFor(layerId, engine, options);
|
||||
/** Whether the application can show a layer on an engine kind. */
|
||||
export const layerAvailableOn = (layerId, engineKind) =>
|
||||
defaultRegistry.layerAvailableOn(layerId, engineKind);
|
||||
/** A layer without a display: its drawing per engine kind. */
|
||||
const perEngine = (engines) =>
|
||||
Object.freeze({ engines: checkLoaders(engines, true) });
|
||||
|
||||
/** Cesium renderers extracted from their layers (render/cesium.js). */
|
||||
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
earthquakes: createEarthquakesDisplay,
|
||||
'air-quality': createAirQualityDisplay,
|
||||
'gnss-integrity': createGnssDisplay,
|
||||
'fire-perimeters': createPerimetersDisplay,
|
||||
'drone-airspace': createDroneAirspaceDisplay,
|
||||
'osm-places': createOsmPlacesDisplay,
|
||||
'alpr-cameras': createAlprDisplay,
|
||||
bikeshare: createBikeshareDisplay,
|
||||
radio: createRadioDisplay,
|
||||
transit: createTransitDisplay,
|
||||
'local-firms': createFirmsDisplay,
|
||||
's2-ghostmaps': createGhostMapsDisplay,
|
||||
'weather-cyclones': createCyclonesDisplay,
|
||||
'telegeography-submarine-cables': createCablesDisplay,
|
||||
'local-datacenters': createInfrastructureDisplay,
|
||||
'local-dams': createInfrastructureDisplay,
|
||||
'rocket-launches': createLaunchesDisplay,
|
||||
satellites: createSatellitesDisplay,
|
||||
flights: createFlightsDisplay,
|
||||
military: createMilitaryDisplay,
|
||||
'local-adsb': createLocalAdsbDisplay,
|
||||
'ais-live-vessels': createVesselsDisplay,
|
||||
cctv: createCctvDisplay,
|
||||
'military-installations': createInstallationsDisplay,
|
||||
'military-awareness': createAwarenessDisplay,
|
||||
'street-level': createStreetLevelDisplay,
|
||||
traffic: createTrafficDisplay,
|
||||
directions: createDirectionsDisplay,
|
||||
'recent-imagery': createRecentImageryDisplay,
|
||||
/** Navara's own view and classes, for the renderers that still use them. */
|
||||
const navaraNative = (engine) => engine.extensions.navara;
|
||||
|
||||
// Navara's renderers of the layers without a display. Each loads only here,
|
||||
// through import(), when a page loads Navara's renderers.
|
||||
const navaraWeather = async () => {
|
||||
const { createWeatherNavaraRenderer } =
|
||||
await import('../../layers/weather/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createWeatherNavaraRenderer(engine, navaraNative(engine), options);
|
||||
};
|
||||
const navaraOnly = (navara, others = DRAWS_ITSELF) =>
|
||||
perEngine({ cesium: others, navara });
|
||||
|
||||
/** How every application layer draws, by id, in directory order. */
|
||||
export const LAYER_MANIFEST = Object.freeze({
|
||||
// The Bhote Koshi event and locator draw themselves on Cesium
|
||||
// (src/data/bhoteKoshiEvent.js, bhoteKoshiLocator.js).
|
||||
'bhote-koshi-2026': navaraOnly(async () => {
|
||||
const { createNepalEventDisplay } =
|
||||
await import('../../layers/bhoteKoshi/render/navara.js');
|
||||
return (engine) => createNepalEventDisplay(engine, navaraNative(engine));
|
||||
}),
|
||||
'bhote-koshi-locator': navaraOnly(async () => {
|
||||
const { drawStorySource } =
|
||||
await import('../../layers/shared/render/navaraStory.js');
|
||||
return (engine, { data }) =>
|
||||
drawStorySource(engine, navaraNative(engine), data);
|
||||
}),
|
||||
// The aircraft family.
|
||||
flights: display(createFlightsDisplay, ['movers', 'lines']),
|
||||
military: display(createMilitaryDisplay, ['movers', 'lines']),
|
||||
'local-adsb': display(createLocalAdsbDisplay, ['movers', 'lines']),
|
||||
earthquakes: display(createEarthquakesDisplay, ['ground']),
|
||||
'air-quality': display(createAirQualityDisplay, ['ground']),
|
||||
'fire-perimeters': display(createPerimetersDisplay, ['ground']),
|
||||
'gnss-integrity': display(createGnssDisplay, ['ground']),
|
||||
's2-ghostmaps': display(createGhostMapsDisplay, ['movers', 'ground']),
|
||||
'drone-airspace': display(createDroneAirspaceDisplay, ['ground']),
|
||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||
'wildlife-sightings': drawsItself(['movers', 'lines']),
|
||||
'alpr-cameras': display(createAlprDisplay, ['movers']),
|
||||
'street-level': display(createStreetLevelDisplay, ['movers', 'ground']),
|
||||
satellites: display(createSatellitesDisplay, ['movers', 'lines']),
|
||||
'rocket-launches': display(createLaunchesDisplay, ['movers', 'lines']),
|
||||
traffic: display(createTrafficDisplay, ['movers', 'ground']),
|
||||
// CCTV's display, with the Navara backend of its plane and volume
|
||||
// (render/meshes.js).
|
||||
cctv: display(createCctvDisplay, ['movers', 'lines'], {
|
||||
navara: async () => ({
|
||||
navaraMeshes: (await import('../../layers/cctv/render/navaraMeshes.js'))
|
||||
.createCctvNavaraMeshes,
|
||||
}),
|
||||
}),
|
||||
radio: display(createRadioDisplay, ['movers']),
|
||||
transit: display(createTransitDisplay, ['movers', 'lines']),
|
||||
bikeshare: display(createBikeshareDisplay, ['movers']),
|
||||
directions: display(createDirectionsDisplay, ['movers', 'ground']),
|
||||
'recent-imagery': display(createRecentImageryDisplay, ['ground', 'imagery']),
|
||||
'osm-places': display(createOsmPlacesDisplay, ['movers']),
|
||||
'ais-live-vessels': display(createVesselsDisplay, ['movers', 'lines']),
|
||||
'military-installations': display(createInstallationsDisplay, [
|
||||
'movers',
|
||||
'ground',
|
||||
]),
|
||||
// Global Context's display, with the Navara backend of its range pulses
|
||||
// (render/pulseDisc.js).
|
||||
'military-awareness': display(createAwarenessDisplay, ['lines'], {
|
||||
navara: async () => ({
|
||||
navaraPulses: (
|
||||
await import('../../layers/awareness/render/navaraPulses.js')
|
||||
).createAwarenessPulseDisc,
|
||||
}),
|
||||
}),
|
||||
// Wind and the weather rasters draw themselves on Cesium
|
||||
// (src/layers/wind, src/layers/weather); on Navara through its renderers.
|
||||
wind: navaraOnly(async () => {
|
||||
const { createWindNavaraRenderer } =
|
||||
await import('../../layers/wind/render/navara.js');
|
||||
return (engine) => createWindNavaraRenderer(engine, navaraNative(engine));
|
||||
}),
|
||||
'weather-radar': navaraOnly(navaraWeather),
|
||||
'weather-satellite': navaraOnly(navaraWeather),
|
||||
'weather-lightning': navaraOnly(navaraWeather),
|
||||
'weather-cyclones': display(createCyclonesDisplay, ['movers', 'ground']),
|
||||
// The open map overlays drape themselves: Cesium imagery on the viewer,
|
||||
// engine.maps.addOverlay on Navara (src/data/osmOverlays.js).
|
||||
'sea-marks': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
|
||||
'ski-pistes': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
|
||||
'local-datacenters': display(createInfrastructureDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'local-dams': display(createInfrastructureDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'telegeography-submarine-cables': display(createCablesDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'local-firms': display(createFirmsDisplay, ['movers', 'ground', 'imagery']),
|
||||
});
|
||||
|
||||
// The other application layers still draw themselves on Cesium.
|
||||
registerLayerRenderers(
|
||||
'cesium',
|
||||
Object.fromEntries(
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => [
|
||||
id,
|
||||
CESIUM_LAYER_RENDERERS[id] ?? CESIUM_BUILTIN,
|
||||
]),
|
||||
),
|
||||
);
|
||||
/** The manifest entry of a layer, or null. */
|
||||
const entryOf = (manifest, layerId) =>
|
||||
Object.hasOwn(manifest, layerId) ? manifest[layerId] : null;
|
||||
|
||||
/** Whether `entry` shows on `engineKind` (see the module comment). */
|
||||
const entryAvailableOn = (entry, engineKind) => {
|
||||
if (!entry) return false;
|
||||
if (entry.needs) {
|
||||
const primitives = primitivesOf(engineKind);
|
||||
return entry.needs.every((name) => primitives.includes(name));
|
||||
}
|
||||
return Object.hasOwn(entry.engines, engineKind);
|
||||
};
|
||||
|
||||
/** The import() loader of a layer on an engine kind, or null. */
|
||||
const loaderOf = (entry, engineKind) => {
|
||||
const loader = entry.backends?.[engineKind] ?? entry.engines?.[engineKind];
|
||||
return typeof loader === 'function' ? loader : null;
|
||||
};
|
||||
|
||||
// What loadLayerRenderers resolved: engine kind → layer id → value (the
|
||||
// backend's options, or the engine-specific factory).
|
||||
const loaded = new Map();
|
||||
const loading = new Map();
|
||||
|
||||
/**
|
||||
* Loads every import() the manifest has for `engineKind`, once per page:
|
||||
* concurrent calls share the load, and after a failure the next call loads
|
||||
* what is still missing.
|
||||
* @param {string} engineKind One of ENGINE_KINDS.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function loadLayerRenderers(engineKind) {
|
||||
if (!ENGINE_KINDS.includes(engineKind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${engineKind}`);
|
||||
if (loading.has(engineKind)) return loading.get(engineKind);
|
||||
if (!loaded.has(engineKind)) loaded.set(engineKind, new Map());
|
||||
const done = loaded.get(engineKind);
|
||||
const load = Promise.all(
|
||||
Object.entries(LAYER_MANIFEST).map(async ([id, entry]) => {
|
||||
const loader = loaderOf(entry, engineKind);
|
||||
if (loader && !done.has(id)) done.set(id, await loader());
|
||||
}),
|
||||
).then(
|
||||
() => {
|
||||
loading.delete(engineKind);
|
||||
},
|
||||
(error) => {
|
||||
loading.delete(engineKind);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
loading.set(engineKind, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
// Renderers of the engine kinds a test brings (a stand-in engine of its
|
||||
// own kind): layer id → factory. The application's kinds use the manifest.
|
||||
const standIns = new Map();
|
||||
|
||||
/**
|
||||
* Test seam: renderer factories `{ [layerId]: (engine, options) => renderer }`
|
||||
* for an engine kind outside the application's (ENGINE_KINDS).
|
||||
*/
|
||||
export function registerLayerRenderers(engineKind, factories) {
|
||||
if (typeof engineKind !== 'string' || !engineKind)
|
||||
throw new TypeError('registerLayerRenderers needs an engine kind');
|
||||
if (ENGINE_KINDS.includes(engineKind))
|
||||
throw new Error(`The layer manifest draws ${engineKind}`);
|
||||
const map = standIns.get(engineKind) ?? new Map();
|
||||
standIns.set(engineKind, map);
|
||||
for (const [layerId, factory] of Object.entries(factories ?? {})) {
|
||||
if (typeof factory !== 'function')
|
||||
throw new TypeError(
|
||||
`Layer renderer for ${layerId} on ${engineKind} is not a factory`,
|
||||
);
|
||||
if (map.has(layerId))
|
||||
throw new Error(
|
||||
`Layer ${layerId} already has a renderer on ${engineKind}`,
|
||||
);
|
||||
map.set(layerId, factory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer of a layer on `engine` (its display or engine-specific
|
||||
* renderer, built now), or null when the layer draws itself there or the
|
||||
* engine cannot show it. Throws when that engine's import()s are not
|
||||
* loaded yet (loadLayerRenderers).
|
||||
*/
|
||||
export function rendererFor(layerId, engine, options = {}) {
|
||||
const engineKind = engine?.kind;
|
||||
const standIn = standIns.get(engineKind)?.get(layerId);
|
||||
if (standIn) return standIn(engine, options);
|
||||
const entry = entryOf(LAYER_MANIFEST, layerId);
|
||||
if (!entryAvailableOn(entry, engineKind)) return null;
|
||||
const loader = loaderOf(entry, engineKind);
|
||||
const value = loader ? loaded.get(engineKind)?.get(layerId) : undefined;
|
||||
if (loader && value === undefined)
|
||||
throw new Error(
|
||||
`Layer ${layerId} on ${engineKind}: its renderer is not loaded (loadLayerRenderers)`,
|
||||
);
|
||||
if (entry.display) return entry.display(engine, { ...options, ...value });
|
||||
return typeof value === 'function' ? value(engine, options) : null;
|
||||
}
|
||||
|
||||
/** Whether the application can show a layer on an engine kind. */
|
||||
export const layerAvailableOn = (layerId, engineKind) =>
|
||||
entryAvailableOn(entryOf(LAYER_MANIFEST, layerId), engineKind);
|
||||
|
||||
/**
|
||||
* @typedef {object} LayerAvailability
|
||||
@@ -165,18 +364,19 @@ registerLayerRenderers(
|
||||
/**
|
||||
* The layers an engine kind can show (stable API; see the module comment).
|
||||
* @param {'cesium' | 'navara'} engineKind
|
||||
* @param {{ registry?: object, directory?: { id: string, name: string }[] }} [options]
|
||||
* Test seams: the registry and the layer directory.
|
||||
* @param {{ manifest?: object, directory?: { id: string, name: string }[] }} [options]
|
||||
* Test seams: the manifest (some of LAYER_MANIFEST's entries) and the
|
||||
* layer directory.
|
||||
* @returns {LayerAvailability}
|
||||
*/
|
||||
export function createLayerAvailability(
|
||||
engineKind,
|
||||
{ registry = defaultRegistry, directory = APPLICATION_LAYER_DIRECTORY } = {},
|
||||
{ manifest = LAYER_MANIFEST, directory = APPLICATION_LAYER_DIRECTORY } = {},
|
||||
) {
|
||||
if (!ENGINE_KINDS.has(engineKind))
|
||||
if (!ENGINE_KINDS.includes(engineKind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${engineKind}`);
|
||||
const isAvailable = (layerId) =>
|
||||
registry.layerAvailableOn(layerId, engineKind);
|
||||
entryAvailableOn(entryOf(manifest, layerId), engineKind);
|
||||
const rows = (wanted) =>
|
||||
directory
|
||||
.filter(({ id }) => isAvailable(id) === wanted)
|
||||
|
||||
@@ -1,95 +1,121 @@
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CESIUM_BUILTIN,
|
||||
CESIUM_LAYER_RENDERERS,
|
||||
rendererFor,
|
||||
CONTRACT_PRIMITIVES,
|
||||
DRAWS_ITSELF,
|
||||
LAYER_MANIFEST,
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
layerAvailableOn,
|
||||
registerLayerRenderers,
|
||||
rendererFor,
|
||||
} from './renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { ENGINE_KINDS, engineCapabilities } from '../../globe/contract.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
|
||||
test('a registry knows which engine renders which layer', () => {
|
||||
const registry = createRendererRegistry();
|
||||
const made = [];
|
||||
registry.register('navara', {
|
||||
flights: (engine, options) => {
|
||||
made.push([engine.kind, options]);
|
||||
return { destroy() {} };
|
||||
},
|
||||
});
|
||||
registry.register('cesium', {
|
||||
flights: CESIUM_BUILTIN,
|
||||
satellites: CESIUM_BUILTIN,
|
||||
});
|
||||
assert.equal(registry.layerAvailableOn('flights', 'navara'), true);
|
||||
assert.equal(registry.layerAvailableOn('satellites', 'navara'), false);
|
||||
assert.equal(registry.layerAvailableOn('satellites', 'cesium'), true);
|
||||
assert.equal(registry.layerAvailableOn('flights', 'other'), false);
|
||||
const renderer = registry.rendererFor(
|
||||
'flights',
|
||||
{ kind: 'navara' },
|
||||
{ tint: 1 },
|
||||
);
|
||||
assert.ok(renderer);
|
||||
assert.deepEqual(made, [['navara', { tint: 1 }]]);
|
||||
// A Cesium layer that still renders itself has no separate renderer.
|
||||
assert.equal(registry.rendererFor('flights', { kind: 'cesium' }), null);
|
||||
assert.equal(registry.rendererFor('satellites', { kind: 'navara' }), null);
|
||||
assert.deepEqual(registry.availableIds('cesium').sort(), [
|
||||
'flights',
|
||||
'satellites',
|
||||
]);
|
||||
assert.throws(() => registry.register('navara', { x: 42 }), TypeError);
|
||||
assert.throws(
|
||||
() => registry.register('navara', { flights: () => ({}) }),
|
||||
/already/,
|
||||
'one renderer per layer and engine',
|
||||
test('the manifest lists every directory layer, in its order', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(LAYER_MANIFEST),
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => id),
|
||||
);
|
||||
assert.ok(Object.isFrozen(LAYER_MANIFEST));
|
||||
for (const entry of Object.values(LAYER_MANIFEST))
|
||||
assert.ok(Object.isFrozen(entry));
|
||||
});
|
||||
|
||||
test('the default registry: every layer on Cesium, none yet on Navara', () => {
|
||||
for (const { id } of APPLICATION_LAYER_DIRECTORY) {
|
||||
assert.equal(layerAvailableOn(id, 'cesium'), true, id);
|
||||
assert.equal(layerAvailableOn(id, 'navara'), false, id);
|
||||
}
|
||||
test('the primitives a display needs are the contract primitives every engine has', () => {
|
||||
const capabilities = engineCapabilities(createFakeEngine());
|
||||
for (const name of CONTRACT_PRIMITIVES)
|
||||
assert.equal(capabilities[name], true, name);
|
||||
for (const [id, entry] of Object.entries(LAYER_MANIFEST))
|
||||
for (const name of entry.needs ?? [])
|
||||
assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`);
|
||||
});
|
||||
|
||||
test('the layers with extracted renderers', () => {
|
||||
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'fire-perimeters',
|
||||
'drone-airspace',
|
||||
'osm-places',
|
||||
'alpr-cameras',
|
||||
'bikeshare',
|
||||
'radio',
|
||||
'transit',
|
||||
'local-firms',
|
||||
's2-ghostmaps',
|
||||
'weather-cyclones',
|
||||
'telegeography-submarine-cables',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'cctv',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'street-level',
|
||||
'traffic',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
]);
|
||||
test('every layer shows on both engines, as before', () => {
|
||||
for (const { id } of APPLICATION_LAYER_DIRECTORY)
|
||||
for (const kind of ENGINE_KINDS)
|
||||
assert.equal(layerAvailableOn(id, kind), true, `${id} on ${kind}`);
|
||||
assert.equal(layerAvailableOn('flights', 'vulkan'), false);
|
||||
assert.equal(layerAvailableOn('unknown', 'cesium'), false);
|
||||
assert.equal(layerAvailableOn('toString', 'cesium'), false);
|
||||
assert.equal(rendererFor('toString', createFakeEngine()), null);
|
||||
});
|
||||
|
||||
test('the layers drawn through one display, and the ones that draw without one', () => {
|
||||
const kinds = (filter) =>
|
||||
Object.entries(LAYER_MANIFEST)
|
||||
.filter(([, entry]) => filter(entry))
|
||||
.map(([id]) => id);
|
||||
assert.deepEqual(
|
||||
kinds((entry) => entry.display),
|
||||
[
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'drone-airspace',
|
||||
'alpr-cameras',
|
||||
'street-level',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
'osm-places',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'weather-cyclones',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
],
|
||||
);
|
||||
// Engine-specific options of a display: the Navara backends.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => Object.keys(entry.backends ?? {}).length),
|
||||
['cctv', 'military-awareness'],
|
||||
);
|
||||
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), ['navara']);
|
||||
// The layer that draws itself through the contract.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => !entry.display && entry.needs),
|
||||
['wildlife-sightings'],
|
||||
);
|
||||
// Without a display: drawn by the layer itself or by an engine-specific
|
||||
// renderer.
|
||||
const engines = Object.fromEntries(
|
||||
kinds((entry) => entry.engines).map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
Object.entries(LAYER_MANIFEST[id].engines).map(([kind, drawing]) => [
|
||||
kind,
|
||||
drawing === DRAWS_ITSELF ? 'itself' : 'renderer',
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const navaraRenderer = { cesium: 'itself', navara: 'renderer' };
|
||||
assert.deepEqual(engines, {
|
||||
'bhote-koshi-2026': navaraRenderer,
|
||||
'bhote-koshi-locator': navaraRenderer,
|
||||
wind: navaraRenderer,
|
||||
'weather-radar': navaraRenderer,
|
||||
'weather-satellite': navaraRenderer,
|
||||
'weather-lightning': navaraRenderer,
|
||||
'sea-marks': { cesium: 'itself', navara: 'itself' },
|
||||
'ski-pistes': { cesium: 'itself', navara: 'itself' },
|
||||
});
|
||||
});
|
||||
|
||||
test('the layers drawn through one display get the same factory on every engine', async () => {
|
||||
@@ -105,6 +131,7 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
|
||||
radio: ['radio', 'createRadioDisplay'],
|
||||
transit: ['transit', 'createTransitDisplay'],
|
||||
cctv: ['cctv', 'createCctvDisplay'],
|
||||
earthquakes: ['earthquakes', 'createEarthquakesDisplay'],
|
||||
'air-quality': ['airQuality', 'createAirQualityDisplay'],
|
||||
'gnss-integrity': ['gnss', 'createGnssDisplay'],
|
||||
@@ -115,6 +142,7 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'local-dams': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'military-installations': ['installations', 'createInstallationsDisplay'],
|
||||
'military-awareness': ['awareness', 'createAwarenessDisplay'],
|
||||
'street-level': ['streetLevel', 'createStreetLevelDisplay'],
|
||||
traffic: ['traffic', 'createTrafficDisplay'],
|
||||
directions: ['directions', 'createDirectionsDisplay'],
|
||||
@@ -125,30 +153,24 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'createCablesDisplay',
|
||||
],
|
||||
};
|
||||
// The Navara mode registers the same modules' factories.
|
||||
const navaraMode = readFileSync(
|
||||
new URL('../navaraMode.js', import.meta.url),
|
||||
'utf8',
|
||||
assert.deepEqual(
|
||||
Object.keys(displays).sort(),
|
||||
Object.keys(LAYER_MANIFEST)
|
||||
.filter((id) => LAYER_MANIFEST[id].display)
|
||||
.sort(),
|
||||
);
|
||||
const drawAtlas = async () => ({ canvas: {}, cells: {} });
|
||||
for (const [id, [directory, name]] of Object.entries(displays)) {
|
||||
const factory = (
|
||||
await import(`../../layers/${directory}/render/display.js`)
|
||||
)[name];
|
||||
assert.equal(CESIUM_LAYER_RENDERERS[id], factory, id);
|
||||
assert.match(
|
||||
navaraMode,
|
||||
new RegExp(
|
||||
`'?${id}'?: async \\(\\) =>\\s*\\(await import\\('[^']+/render/display\\.js'\\)\\)\\s*\\.${name}\\b`,
|
||||
),
|
||||
id,
|
||||
);
|
||||
assert.equal(LAYER_MANIFEST[id].display, factory, id);
|
||||
// Each display's own tests build it with its services; the aircraft
|
||||
// family needs none beyond an atlas.
|
||||
if (!['flights', 'military', 'local-adsb'].includes(id)) continue;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
for (const kind of ENGINE_KINDS) {
|
||||
const engine = createFakeEngine({ kind });
|
||||
const display = factory(engine, { drawAtlas });
|
||||
const display = rendererFor(id, engine, { drawAtlas });
|
||||
assert.equal(display.engine, engine, `${id} on ${kind}`);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
@@ -156,21 +178,75 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that draws itself has no renderer; an unknown engine none either', () => {
|
||||
const engine = createFakeEngine();
|
||||
for (const id of [
|
||||
'wildlife-sightings',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'bhote-koshi-2026',
|
||||
])
|
||||
assert.equal(rendererFor(id, engine), null, id);
|
||||
assert.equal(rendererFor('flights', null), null);
|
||||
assert.equal(rendererFor('flights', { kind: 'vulkan' }), null);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('an engine-specific renderer answers only after its engine loaded them', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
// This test file never loads Navara's renderers.
|
||||
assert.throws(
|
||||
() => rendererFor('weather-radar', engine),
|
||||
/weather-radar on navara: its renderer is not loaded/,
|
||||
);
|
||||
assert.throws(() => rendererFor('cctv', engine), /not loaded/);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a test engine of its own kind brings its renderers', () => {
|
||||
const made = [];
|
||||
registerLayerRenderers('renderers-test', {
|
||||
flights: (engine, options) => {
|
||||
made.push([engine.kind, options]);
|
||||
return { destroy() {} };
|
||||
},
|
||||
});
|
||||
const engine = { kind: 'renderers-test' };
|
||||
assert.ok(rendererFor('flights', engine, { tint: 1 }));
|
||||
assert.deepEqual(made, [['renderers-test', { tint: 1 }]]);
|
||||
assert.equal(rendererFor('satellites', engine), null);
|
||||
assert.throws(
|
||||
() => registerLayerRenderers('renderers-test', { flights: () => ({}) }),
|
||||
/already/,
|
||||
'one renderer per layer and engine',
|
||||
);
|
||||
assert.throws(
|
||||
() => registerLayerRenderers('renderers-test', { x: 42 }),
|
||||
TypeError,
|
||||
);
|
||||
for (const kind of ENGINE_KINDS)
|
||||
assert.throws(
|
||||
() => registerLayerRenderers(kind, { flights: () => ({}) }),
|
||||
/manifest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer availability API: engine, isAvailable, available, unavailable', () => {
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { flights: () => ({}) });
|
||||
const directory = [
|
||||
{ id: 'flights', name: 'Live Flights' },
|
||||
{ id: 'satellites', name: 'Satellites' },
|
||||
];
|
||||
const availability = createLayerAvailability('navara', {
|
||||
registry,
|
||||
manifest: { flights: LAYER_MANIFEST.flights },
|
||||
directory,
|
||||
});
|
||||
assert.equal(availability.engine, 'navara');
|
||||
assert.equal(availability.isAvailable('flights'), true);
|
||||
assert.equal(availability.isAvailable('satellites'), false);
|
||||
assert.equal(availability.isAvailable('unknown'), false);
|
||||
assert.equal(availability.isAvailable('toString'), false);
|
||||
assert.deepEqual(availability.available(), [
|
||||
{ id: 'flights', name: 'Live Flights' },
|
||||
]);
|
||||
@@ -181,8 +257,25 @@ test('the layer availability API: engine, isAvailable, available, unavailable',
|
||||
availability.unavailable().push({ id: 'x', name: 'x' });
|
||||
assert.equal(availability.unavailable().length, 1);
|
||||
assert.ok(Object.isFrozen(availability));
|
||||
const cesium = createLayerAvailability('cesium');
|
||||
assert.equal(cesium.unavailable().length, 0);
|
||||
assert.equal(cesium.available().length, APPLICATION_LAYER_DIRECTORY.length);
|
||||
// A layer without a display shows only on the engine kinds it lists.
|
||||
const cesiumOnly = { wind: { engines: { cesium: DRAWS_ITSELF } } };
|
||||
const wind = [{ id: 'wind', name: 'Wind' }];
|
||||
for (const [kind, shown] of [
|
||||
['cesium', true],
|
||||
['navara', false],
|
||||
])
|
||||
assert.equal(
|
||||
createLayerAvailability(kind, {
|
||||
manifest: cesiumOnly,
|
||||
directory: wind,
|
||||
}).isAvailable('wind'),
|
||||
shown,
|
||||
kind,
|
||||
);
|
||||
for (const kind of ENGINE_KINDS) {
|
||||
const all = createLayerAvailability(kind);
|
||||
assert.equal(all.unavailable().length, 0, kind);
|
||||
assert.equal(all.available().length, APPLICATION_LAYER_DIRECTORY.length);
|
||||
}
|
||||
assert.throws(() => createLayerAvailability('vulkan'), TypeError);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export function createApplicationLaunches({
|
||||
source,
|
||||
satellites,
|
||||
// Test seam: the display factory (default: the engine's, from the
|
||||
// registry: render/display.js on every engine).
|
||||
// manifest: render/display.js on every engine).
|
||||
renderer = {
|
||||
create: ({ engine }) => rendererFor('rocket-launches', engine),
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as layerState from '../../data/layerState.js';
|
||||
export function createApplicationSatellites({
|
||||
source,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the satellites display on every engine).
|
||||
// manifest: the satellites display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('satellites', engine),
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ function defaultProviders(sources) {
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty. The layer draws with the engine's
|
||||
* renderer from the registry (render/display.js on every engine).
|
||||
* renderer from the manifest (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
sources = {},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { overlayHost as applicationOverlayHost } from './overlayHost.js';
|
||||
|
||||
/**
|
||||
* Wire cable source geometry to the application overlay host and map-stack
|
||||
* events; it draws with the engine's renderer from the registry
|
||||
* events; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationCables({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationTraffic({ source, surface }) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
* cell is the floor transit draws on, and a transit-only session warms its
|
||||
* own mesh floors. An earlier cut imported the legacy module-level singleton
|
||||
* instead and could not see either. It draws with the engine's renderer from
|
||||
* the registry (render/display.js on every engine).
|
||||
* the manifest (render/display.js on every engine).
|
||||
*
|
||||
* @param {{surface: object}} options
|
||||
* @returns {object}
|
||||
|
||||
@@ -107,8 +107,8 @@ test('the standalone startup graph can be imported before the Cesium SDK exists'
|
||||
return code ? { url: 'data:text/javascript,' + encodeURIComponent(code), shortCircuit: true } : next(specifier, context);
|
||||
}});
|
||||
await import('./src/standalone/application.js');
|
||||
const { registerNavaraLayerRenderers } = await import('./src/app/navaraMode.js');
|
||||
await registerNavaraLayerRenderers();
|
||||
const { loadLayerRenderers } = await import('./src/app/layers/renderers.js');
|
||||
await loadLayerRenderers('navara');
|
||||
const { createStandaloneCatalog } = await import('./src/standalone/catalog.js');
|
||||
const lifetime = new AbortController();
|
||||
const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal });
|
||||
|
||||
@@ -1,87 +1,39 @@
|
||||
// The layers the Navara mode draws (registerNavaraLayerRenderers): each W1
|
||||
// layer with a Navara renderer, registered once, through import().
|
||||
// The layers on Navara: every directory layer, with the manifest's Navara
|
||||
// renderers and backends loaded once, through import() (loadLayerRenderers).
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerNavaraLayerRenderers } from './navaraMode.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
layerAvailableOn,
|
||||
loadLayerRenderers,
|
||||
rendererFor,
|
||||
} from './layers/renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
test('the Navara mode registers migrated renderers once', async () => {
|
||||
assert.equal(layerAvailableOn('military', 'navara'), false);
|
||||
await Promise.all([
|
||||
registerNavaraLayerRenderers(),
|
||||
registerNavaraLayerRenderers(),
|
||||
]);
|
||||
await registerNavaraLayerRenderers();
|
||||
for (const id of [
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'ais-live-vessels',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
'local-firms',
|
||||
'military-installations',
|
||||
'bikeshare',
|
||||
'radio',
|
||||
'alpr-cameras',
|
||||
'cctv',
|
||||
'transit',
|
||||
'recent-imagery',
|
||||
])
|
||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||
test('Navara loads its renderers once and shows every layer', async () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
assert.throws(() => rendererFor('wind', engine), /not loaded/);
|
||||
const first = loadLayerRenderers('navara');
|
||||
assert.equal(loadLayerRenderers('navara'), first, 'one load at a time');
|
||||
await Promise.all([first, loadLayerRenderers('navara')]);
|
||||
await loadLayerRenderers('navara');
|
||||
assert.deepEqual(
|
||||
createLayerAvailability('navara')
|
||||
.available()
|
||||
.map(({ id }) => id),
|
||||
[
|
||||
'bhote-koshi-2026',
|
||||
'bhote-koshi-locator',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'drone-airspace',
|
||||
'wildlife-sightings',
|
||||
'alpr-cameras',
|
||||
'street-level',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
'osm-places',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
],
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => id),
|
||||
);
|
||||
assert.equal(layerAvailableOn('satellites', 'navara'), true);
|
||||
// Navara's own renderers answer now, with its view: a weather renderer
|
||||
// draws nothing until it has a frame.
|
||||
const viewed = { ...engine, extensions: { navara: {} } };
|
||||
const weather = rendererFor('weather-radar', viewed, {});
|
||||
assert.equal(typeof weather, 'object');
|
||||
// The layers that draw themselves still have none.
|
||||
for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes'])
|
||||
assert.equal(rendererFor(id, engine), null, id);
|
||||
assert.throws(() => loadLayerRenderers('vulkan'), TypeError);
|
||||
// Cesium has nothing to load.
|
||||
await loadLayerRenderers('cesium');
|
||||
assert.equal(rendererFor('wind', createFakeEngine()), null);
|
||||
});
|
||||
+58
-303
@@ -1,42 +1,30 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
// The experimental Navara mode (?engine=navara) of the application: its
|
||||
// scene and its viewerless tools. src/app/scene.js and src/app/tools.js load
|
||||
// this module with import() only in that mode, and it loads the Navara globe
|
||||
// the same way, so the Cesium-mode bundle carries none of it.
|
||||
// scene and the Navara side of the application tools. src/app/scene.js and
|
||||
// src/app/tools.js load this module with import() only in that mode, and it
|
||||
// loads the Navara globe the same way, so the Cesium-mode bundle carries
|
||||
// none of it.
|
||||
|
||||
import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js';
|
||||
import { isEmbeddedInline } from './embed.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
layerAvailableOn,
|
||||
registerLayerRenderers,
|
||||
} from './layers/renderers.js';
|
||||
import { createLayerAvailability } from './layers/renderers.js';
|
||||
import {
|
||||
governorRequestRender,
|
||||
installEngineRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import { attachSessionVoice } from './sessionVoice.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import {
|
||||
attachNavaraAnnotations,
|
||||
attachNavaraDraw,
|
||||
attachNavaraGeoImport,
|
||||
attachNavaraScopeMask,
|
||||
} from './navaraTools.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
||||
|
||||
/**
|
||||
* An imported file's data source drawn on `engine` (UTILITIES ▸ Import); the
|
||||
* drawing loads on first use, like the layers' Navara renderers.
|
||||
* An imported file's data source drawn on `engine` with Navara's `views`
|
||||
* (UTILITIES ▸ Import); the drawing loads on first use, like the layers'
|
||||
* Navara renderers.
|
||||
*/
|
||||
async function drawImported(engine, dataSource) {
|
||||
async function drawImported(engine, views, dataSource) {
|
||||
const { drawImportedSource, measureImportImages } =
|
||||
await import('../layers/shared/render/navaraStory.js');
|
||||
const sizes = await measureImportImages(dataSource);
|
||||
return drawImportedSource(engine, engine.extensions.navara, dataSource, {
|
||||
sizes,
|
||||
});
|
||||
return drawImportedSource(engine, views, dataSource, { sizes });
|
||||
}
|
||||
|
||||
/** The Navara globe module, loaded on demand. */
|
||||
@@ -45,8 +33,8 @@ const loadNavaraGlobeModule = async () =>
|
||||
|
||||
/**
|
||||
* The shell's notice in the experimental Navara mode (label and detail): the
|
||||
* data layers this engine shows (its catalog, from the renderer registry)
|
||||
* and what it does not have.
|
||||
* data layers this engine shows (its catalog, from the layer manifest) and
|
||||
* what it does not have.
|
||||
* @param {import('./layers/renderers.js').LayerAvailability} availability
|
||||
*/
|
||||
export function navaraModeNotice(availability) {
|
||||
@@ -88,169 +76,6 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
'radio-browser',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Registers the Navara layer renderers (once per page). Each loads only
|
||||
* here, through import(), so Cesium mode never fetches them.
|
||||
*/
|
||||
export async function registerNavaraLayerRenderers() {
|
||||
const loaders = {
|
||||
'recent-imagery': async () =>
|
||||
(await import('../layers/recentImagery/render/display.js'))
|
||||
.createRecentImageryDisplay,
|
||||
transit: async () =>
|
||||
(await import('../layers/transit/render/display.js'))
|
||||
.createTransitDisplay,
|
||||
// CCTV's display, with the Navara backend of its plane and volume.
|
||||
cctv: async () => {
|
||||
const [{ createCctvDisplay }, { createCctvNavaraMeshes }] =
|
||||
await Promise.all([
|
||||
import('../layers/cctv/render/display.js'),
|
||||
import('../layers/cctv/render/navaraMeshes.js'),
|
||||
]);
|
||||
return (engine, options) =>
|
||||
createCctvDisplay(engine, {
|
||||
...options,
|
||||
navaraMeshes: createCctvNavaraMeshes,
|
||||
});
|
||||
},
|
||||
'alpr-cameras': async () =>
|
||||
(await import('../layers/alpr/render/display.js')).createAlprDisplay,
|
||||
radio: async () =>
|
||||
(await import('../layers/radio/render/display.js')).createRadioDisplay,
|
||||
bikeshare: async () =>
|
||||
(await import('../layers/bikeshare/render/display.js'))
|
||||
.createBikeshareDisplay,
|
||||
...Object.fromEntries(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) => [
|
||||
id,
|
||||
async () => {
|
||||
const { createWeatherNavaraRenderer } =
|
||||
await import('../layers/weather/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createWeatherNavaraRenderer(
|
||||
engine,
|
||||
engine.extensions.navara,
|
||||
options,
|
||||
);
|
||||
},
|
||||
]),
|
||||
),
|
||||
'bhote-koshi-2026': async () => {
|
||||
const { createNepalEventDisplay } =
|
||||
await import('../layers/bhoteKoshi/render/navara.js');
|
||||
return (engine) =>
|
||||
createNepalEventDisplay(engine, engine.extensions.navara);
|
||||
},
|
||||
'bhote-koshi-locator': async () => {
|
||||
const { drawStorySource } =
|
||||
await import('../layers/shared/render/navaraStory.js');
|
||||
return (engine, { data }) =>
|
||||
drawStorySource(engine, engine.extensions.navara, data);
|
||||
},
|
||||
'street-level': async () =>
|
||||
(await import('../layers/streetLevel/render/display.js'))
|
||||
.createStreetLevelDisplay,
|
||||
traffic: async () =>
|
||||
(await import('../layers/traffic/render/display.js'))
|
||||
.createTrafficDisplay,
|
||||
directions: async () =>
|
||||
(await import('../layers/directions/render/display.js'))
|
||||
.createDirectionsDisplay,
|
||||
'telegeography-submarine-cables': async () =>
|
||||
(await import('../layers/submarineCables/render/display.js'))
|
||||
.createCablesDisplay,
|
||||
wind: async () => {
|
||||
const { createWindNavaraRenderer } =
|
||||
await import('../layers/wind/render/navara.js');
|
||||
return (engine) =>
|
||||
createWindNavaraRenderer(engine, engine.extensions.navara);
|
||||
},
|
||||
'weather-cyclones': async () =>
|
||||
(await import('../layers/cyclones/render/display.js'))
|
||||
.createCyclonesDisplay,
|
||||
'local-datacenters': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
'local-dams': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
's2-ghostmaps': async () =>
|
||||
(await import('../layers/ghostMaps/render/display.js'))
|
||||
.createGhostMapsDisplay,
|
||||
// Global Context's display, with the Navara backend of its range pulses.
|
||||
'military-awareness': async () => {
|
||||
const [{ createAwarenessDisplay }, { createAwarenessPulseDisc }] =
|
||||
await Promise.all([
|
||||
import('../layers/awareness/render/display.js'),
|
||||
import('../layers/awareness/render/navaraPulses.js'),
|
||||
]);
|
||||
return (engine) =>
|
||||
createAwarenessDisplay(engine, {
|
||||
navaraPulses: createAwarenessPulseDisc,
|
||||
});
|
||||
},
|
||||
'military-installations': async () =>
|
||||
(await import('../layers/installations/render/display.js'))
|
||||
.createInstallationsDisplay,
|
||||
'local-firms': async () =>
|
||||
(await import('../layers/firms/render/display.js')).createFirmsDisplay,
|
||||
'osm-places': async () =>
|
||||
(await import('../layers/osmPlaces/render/display.js'))
|
||||
.createOsmPlacesDisplay,
|
||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||
'wildlife-sightings': async () => () => null,
|
||||
'drone-airspace': async () =>
|
||||
(await import('../layers/droneAirspace/render/display.js'))
|
||||
.createDroneAirspaceDisplay,
|
||||
'fire-perimeters': async () =>
|
||||
(await import('../layers/perimeters/render/display.js'))
|
||||
.createPerimetersDisplay,
|
||||
'gnss-integrity': async () =>
|
||||
(await import('../layers/gnss/render/display.js')).createGnssDisplay,
|
||||
'air-quality': async () =>
|
||||
(await import('../layers/airQuality/render/display.js'))
|
||||
.createAirQualityDisplay,
|
||||
earthquakes: async () =>
|
||||
(await import('../layers/earthquakes/render/display.js'))
|
||||
.createEarthquakesDisplay,
|
||||
// The same displays as on Cesium (renderers.js).
|
||||
'rocket-launches': async () =>
|
||||
(await import('../layers/launches/render/display.js'))
|
||||
.createLaunchesDisplay,
|
||||
satellites: async () =>
|
||||
(await import('../layers/satellites/render/display.js'))
|
||||
.createSatellitesDisplay,
|
||||
// The aircraft family: the same displays as on Cesium (renderers.js).
|
||||
flights: async () =>
|
||||
(await import('../layers/flights/render/display.js'))
|
||||
.createFlightsDisplay,
|
||||
military: async () =>
|
||||
(await import('../layers/military/render/display.js'))
|
||||
.createMilitaryDisplay,
|
||||
'local-adsb': async () =>
|
||||
(await import('../layers/localAdsb/render/display.js'))
|
||||
.createLocalAdsbDisplay,
|
||||
'ais-live-vessels': async () =>
|
||||
(await import('../layers/vessels/render/display.js'))
|
||||
.createVesselsDisplay,
|
||||
// The open map overlays drape themselves through engine.maps.addOverlay.
|
||||
'sea-marks': async () => () => null,
|
||||
'ski-pistes': async () => () => null,
|
||||
};
|
||||
const missing = Object.keys(loaders).filter(
|
||||
(id) => !layerAvailableOn(id, 'navara'),
|
||||
);
|
||||
if (!missing.length) return;
|
||||
const factories = await Promise.all(missing.map((id) => loaders[id]()));
|
||||
// A concurrent call may have registered some meanwhile.
|
||||
const fresh = Object.fromEntries(
|
||||
missing
|
||||
.map((id, index) => [id, factories[index]])
|
||||
.filter(([id]) => !layerAvailableOn(id, 'navara')),
|
||||
);
|
||||
if (Object.keys(fresh).length) registerLayerRenderers('navara', fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scene on the experimental Navara engine: no viewer and no Cesium
|
||||
* objects. Navara builds the map sources from the controller's descriptions
|
||||
@@ -281,10 +106,6 @@ export async function createNavaraScene({
|
||||
});
|
||||
defer(() => globe.destroy());
|
||||
const { engine } = globe;
|
||||
// The layers drawn on Navara (their render/navara.js), before the catalog
|
||||
// asks the registry which layers this engine shows.
|
||||
await registerNavaraLayerRenderers();
|
||||
signal.throwIfAborted();
|
||||
// The tools install the governor on this engine; it goes before the engine.
|
||||
defer(() => uninstallRenderGovernor(engine));
|
||||
signal.throwIfAborted();
|
||||
@@ -343,116 +164,50 @@ export async function createNavaraScene({
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools without a viewer (the experimental Navara engine), attached to
|
||||
* one Navara scene: the render governor, hidden-tab handling, imagery box,
|
||||
* engine notice, annotations, Draw, Import, scope mask and voice. The shared
|
||||
* tools keep their session state across engines. Returns the annotation
|
||||
* board (null without a Navara view) and voice commands.
|
||||
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
|
||||
* annotation board, Draw and Import draw (on the engine and Navara's view;
|
||||
* null without the view), the Recent Imagery panel's globe, the render
|
||||
* governor on the engine, and this engine's own parts: the daylight of a
|
||||
* restored view and the engine notice.
|
||||
*/
|
||||
export function attachViewerlessTools(
|
||||
{ engine, mapStackController = null },
|
||||
{
|
||||
data,
|
||||
operations,
|
||||
placeSearch,
|
||||
voice = {},
|
||||
sceneDirector,
|
||||
layerSession = null,
|
||||
styleManager,
|
||||
},
|
||||
{ signal, defer },
|
||||
) {
|
||||
const { dataManager } = data;
|
||||
void styleManager.initialRestorePromise?.then(() => {
|
||||
if (!signal.aborted) engine.extensions.navara.alignDaylightToRestoredView();
|
||||
});
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
const tool = initImageryBoxTool({
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
onPreview: (box) => recentImagery.setBoxPreview(box),
|
||||
onCancel: (reason, message, box) => {
|
||||
if (message) recentImagery.reportBoxRefusal(message, box);
|
||||
},
|
||||
onActive: (active) => recentImagery.setToolActive(active),
|
||||
onEscape: () => recentImagery.clearPreview(),
|
||||
});
|
||||
data.presentation.attachRecentImagery((container) =>
|
||||
createRecentImageryPanel({
|
||||
container,
|
||||
engine,
|
||||
layer: recentImagery,
|
||||
tool,
|
||||
}),
|
||||
);
|
||||
const handle = { layer: recentImagery, tool };
|
||||
window.__gevRecentImagery = handle;
|
||||
defer(() => {
|
||||
if (window.__gevRecentImagery === handle)
|
||||
delete window.__gevRecentImagery;
|
||||
data.presentation.attachRecentImagery(null);
|
||||
tool.destroy();
|
||||
});
|
||||
}
|
||||
// Holds drive engine.render.setContinuous; requests go to the engine. A
|
||||
// parked engine uninstalls it (createNavaraScene suspend).
|
||||
installEngineRenderGovernor(engine);
|
||||
const syncVisibility = () => {
|
||||
if (document.hidden && !isEmbeddedInline()) return;
|
||||
data.presentation.flushVisible();
|
||||
governorRequestRender('visibility-restore');
|
||||
};
|
||||
document.addEventListener('visibilitychange', syncVisibility);
|
||||
defer(() => document.removeEventListener('visibilitychange', syncVisibility));
|
||||
const notice = navaraModeNotice(
|
||||
data.catalog?.availability ?? createLayerAvailability('navara'),
|
||||
);
|
||||
styleManager._showGlobalStatusNotice?.(notice.label, {
|
||||
state: 'retry',
|
||||
detail: notice.detail,
|
||||
persistent: true,
|
||||
});
|
||||
// The notice is this engine's: it goes when the engine does.
|
||||
defer(() => styleManager._clearPersistentStatusNotice?.());
|
||||
// The page session's annotation board, Draw and Import on this engine
|
||||
// (src/app/navaraTools.js).
|
||||
const tools = {
|
||||
engine,
|
||||
views: engine.extensions?.navara,
|
||||
mapStackController,
|
||||
};
|
||||
const context = {
|
||||
operations,
|
||||
placeSearch,
|
||||
layerSession,
|
||||
styleManager,
|
||||
drawImported,
|
||||
};
|
||||
const annotations = attachNavaraAnnotations(tools, context, {
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
attachNavaraDraw(tools, { ...context, annotations }, { signal, defer });
|
||||
attachNavaraGeoImport(tools, context, { signal, defer });
|
||||
attachNavaraScopeMask(tools, context, { signal, defer });
|
||||
// Voice uses the engine and shared session services; each action checks
|
||||
// the capabilities it needs on the attached scene.
|
||||
const voiceCommands = attachSessionVoice({
|
||||
layerSession,
|
||||
voice,
|
||||
operations,
|
||||
placeSearch,
|
||||
dataManager,
|
||||
scene: {
|
||||
viewer: null,
|
||||
engine,
|
||||
styleManager,
|
||||
sceneDirector,
|
||||
annotations,
|
||||
export function navaraToolBackend({ engine }) {
|
||||
const views = engine.extensions?.navara ?? null;
|
||||
const drawn = Boolean(views?.view);
|
||||
return {
|
||||
board: () =>
|
||||
drawn
|
||||
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
|
||||
: null,
|
||||
// The session's tool on the engine's input and preview
|
||||
// (src/annotations/drawInputNavara.js).
|
||||
draw: () => ({ surface: navaraDrawSurface(engine) }),
|
||||
// The session's files drawn by src/layers/shared/render/navaraStory.js;
|
||||
// what Navara leaves out of them is reported in the control's hint.
|
||||
geoImport: () =>
|
||||
drawn
|
||||
? {
|
||||
engine,
|
||||
draw: (dataSource) => drawImported(engine, views, dataSource),
|
||||
}
|
||||
: null,
|
||||
recentImagery: () => ({ engine }),
|
||||
// Holds drive engine.render.setContinuous; requests go to the engine. A
|
||||
// parked engine uninstalls it (createNavaraScene suspend).
|
||||
installGovernor: () => installEngineRenderGovernor(engine),
|
||||
attach({ data, styleManager }, { signal, defer }) {
|
||||
void styleManager.initialRestorePromise?.then(() => {
|
||||
if (!signal.aborted) views.alignDaylightToRestoredView();
|
||||
});
|
||||
const notice = navaraModeNotice(
|
||||
data.catalog?.availability ?? createLayerAvailability(engine.kind),
|
||||
);
|
||||
styleManager._showGlobalStatusNotice?.(notice.label, {
|
||||
state: 'retry',
|
||||
detail: notice.detail,
|
||||
persistent: true,
|
||||
});
|
||||
// The notice is this engine's: it goes when the engine does.
|
||||
defer(() => styleManager._clearPersistentStatusNotice?.());
|
||||
},
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
return { annotations, voiceCommands };
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { attachNavaraAnnotations } from './navaraTools.js';
|
||||
import { navaraToolBackend } from './navaraMode.js';
|
||||
import { sessionAnnotations } from './sessionTools.js';
|
||||
|
||||
function fakeViews() {
|
||||
const meshes = [];
|
||||
@@ -37,22 +38,25 @@ function fakeViews() {
|
||||
};
|
||||
}
|
||||
|
||||
test('Navara attaches the page session board and its switch takes only the drawing', async () => {
|
||||
test('Navara draws the page session board and its switch takes only the drawing', async () => {
|
||||
const lifetime = new AbortController();
|
||||
const layerSession = { lifetime };
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const { views, meshes } = fakeViews();
|
||||
const context = {
|
||||
layerSession,
|
||||
placeSearch: null,
|
||||
operations: { annotationResolver: null },
|
||||
const engine = {
|
||||
...createFakeEngine({ kind: 'navara' }),
|
||||
extensions: { navara: views },
|
||||
};
|
||||
// As src/app/tools.js attaches the board on the backend's scene.
|
||||
const attach = () => {
|
||||
const cleanups = [];
|
||||
const board = attachNavaraAnnotations({ engine, views }, context, {
|
||||
const board = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch: null,
|
||||
resolver: null,
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
});
|
||||
return { board, detach: () => cleanups.reverse().forEach((c) => c()) };
|
||||
board.attachScene(navaraToolBackend({ engine }).board());
|
||||
return { board, detach: () => board.detachScene() };
|
||||
};
|
||||
const first = attach();
|
||||
assert.ok(first.board);
|
||||
@@ -85,11 +89,12 @@ test('Navara attaches the page session board and its switch takes only the drawi
|
||||
second.detach();
|
||||
lifetime.abort();
|
||||
assert.equal(window.__gevAnnotations, undefined);
|
||||
// Without the Navara view there is nothing to draw on.
|
||||
assert.equal(
|
||||
attachNavaraAnnotations({ engine, views: undefined }, context, {
|
||||
defer() {},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// Without the Navara view there is nothing to draw on: no board, no
|
||||
// Import.
|
||||
const viewless = navaraToolBackend({
|
||||
engine: createFakeEngine({ kind: 'navara' }),
|
||||
});
|
||||
assert.equal(viewless.board(), null);
|
||||
assert.equal(viewless.geoImport(), null);
|
||||
assert.deepEqual(Object.keys(viewless.recentImagery()), ['engine']);
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// The page session's annotation board, Draw and Import on the experimental
|
||||
// Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded
|
||||
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
|
||||
|
||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
||||
import { destroyScopeMask, installScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
sessionAnnotations,
|
||||
sessionDrawTool,
|
||||
sessionGeoImport,
|
||||
} from './sessionTools.js';
|
||||
|
||||
/**
|
||||
* The annotation board drawn on this Navara scene: draped areas and routes
|
||||
* on the engine, every other mark on the shared screen board. The board
|
||||
* belongs to the page session; an engine switch takes only its drawing off
|
||||
* this engine. Returns the board (null without the Navara view).
|
||||
* @param {{ engine: object, views: object }} scene `views`: the Navara view
|
||||
* and its classes (src/app/navaraMode.js).
|
||||
*/
|
||||
export function attachNavaraAnnotations(
|
||||
{ engine, views },
|
||||
{ layerSession, placeSearch, operations },
|
||||
{ defer },
|
||||
) {
|
||||
if (!views?.view) return null;
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({
|
||||
engine,
|
||||
renderer: createNavaraAnnotationRenderer(engine, views),
|
||||
});
|
||||
defer(() => annotations.detachScene());
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Draw on this Navara scene: the session's tool on the engine's
|
||||
* input and preview (src/annotations/drawInputNavara.js). A switch ends an
|
||||
* open sketch and keeps the chosen shape and colour. Returns the tool (null
|
||||
* on a page without it or without the board).
|
||||
*/
|
||||
export function attachNavaraDraw(
|
||||
{ engine },
|
||||
{ layerSession, annotations, styleManager },
|
||||
{ defer },
|
||||
) {
|
||||
if (!annotations) return null;
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({
|
||||
surface: navaraDrawSurface(engine),
|
||||
navigation: styleManager,
|
||||
});
|
||||
defer(() => drawTool?.detachScene());
|
||||
return drawTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Import on this Navara scene: the session's files drawn by
|
||||
* `drawImported(engine, dataSource)` (src/layers/shared/render/navaraStory.js,
|
||||
* loaded by src/app/navaraMode.js), what Navara leaves out of them reported
|
||||
* in the control's hint; drops and camera gestures on this engine's canvas.
|
||||
* Returns the control (null on a page without it or without the view).
|
||||
*/
|
||||
export function attachNavaraGeoImport(
|
||||
{ engine, views, mapStackController },
|
||||
{ layerSession, styleManager, operations, drawImported },
|
||||
{ defer },
|
||||
) {
|
||||
if (!views?.view || typeof drawImported !== 'function') return null;
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
engine,
|
||||
draw: (dataSource) => drawImported(engine, dataSource),
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
return geoFileImport;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DISPLAY scope mask over this Navara scene: the same canvas as on
|
||||
* Cesium, in the engine's container, its terminus following the engine
|
||||
* camera's height. It goes with the engine.
|
||||
*/
|
||||
export function attachNavaraScopeMask({ engine }, _context, { defer }) {
|
||||
installScopeMask(engine);
|
||||
defer(() => destroyScopeMask());
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createQaProbe } from './qaProbe.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
} from './layers/renderers.js';
|
||||
import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js';
|
||||
|
||||
function fakeManager(modules) {
|
||||
const enabled = new Set();
|
||||
@@ -33,12 +30,12 @@ test('the probe lists, toggles and reads layers through the engine', async () =>
|
||||
getTrackedInfo: () => ({ icao24: 'ae01ce' }),
|
||||
};
|
||||
const manager = fakeManager([flights]);
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { flights: () => ({}) });
|
||||
const probe = createQaProbe({
|
||||
engine,
|
||||
dataManager: manager,
|
||||
availability: createLayerAvailability('navara', { registry }),
|
||||
availability: createLayerAvailability('navara', {
|
||||
manifest: { flights: LAYER_MANIFEST.flights },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(probe.list(), [
|
||||
{ id: 'flights', name: 'Live Flights', enabled: false, available: true },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// The page session's tools that outlive a globe engine: the annotation board,
|
||||
// UTILITIES ▸ Draw and UTILITIES ▸ Import. Each is created once per page
|
||||
// session (destroyed with the page) and attaches its drawing to whichever
|
||||
// engine the page shows: src/app/tools.js on a Cesium viewer,
|
||||
// src/app/navaraTools.js on Navara.
|
||||
// engine the page shows (src/app/tools.js; on Navara through its tool
|
||||
// backend, src/app/navaraMode.js).
|
||||
|
||||
import { createSessionAnnotations } from '../annotations/index.js';
|
||||
import { createSessionDrawTool } from '../annotations/drawTool.js';
|
||||
|
||||
+217
-98
@@ -35,9 +35,10 @@ import {
|
||||
* handle belong to the application; the tools bound to one globe (render
|
||||
* governor, release trim, scope mask, imagery box, tracking and visibility
|
||||
* listeners, embedded views, the debug handle's globe fields) attach to each
|
||||
* engine the page shows (src/app/sceneParts.js). Navara attaches the shared
|
||||
* annotation board, Draw, Import, scope mask and voice through its engine
|
||||
* adapters (src/app/navaraMode.js); the Director follows the attached engine.
|
||||
* engine the page shows (src/app/sceneParts.js), one tree for every engine
|
||||
* (attachEngineTools): each engine kind's tool backend says where the shared
|
||||
* tools draw on it, and the tools that still drive the Cesium viewer attach
|
||||
* only where there is one. The Director follows the attached engine.
|
||||
*/
|
||||
export async function createApplicationTools({
|
||||
scene,
|
||||
@@ -99,9 +100,10 @@ export async function createApplicationTools({
|
||||
// Cesium only for now: it reads Cesium's camera (positionWC, its
|
||||
// running flight).
|
||||
attachScene(next, { defer: deferScene }) {
|
||||
if (!next.viewer) return;
|
||||
const viewer = cesiumViewerOf(next.engine);
|
||||
if (!viewer) return;
|
||||
deferScene(
|
||||
installGlobeInViewRecovery(next.viewer, {
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
// The scope mask hides everything outside its keyhole.
|
||||
keyholeRadius: () =>
|
||||
isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0,
|
||||
@@ -175,11 +177,7 @@ export async function createApplicationTools({
|
||||
{
|
||||
name: 'tools',
|
||||
async attachScene(next, attachment) {
|
||||
const tools = next.viewer
|
||||
? attachViewerTools(next, context, attachment)
|
||||
: await import('./navaraMode.js').then(({ attachViewerlessTools }) =>
|
||||
attachViewerlessTools(next, context, attachment),
|
||||
);
|
||||
const tools = await attachEngineTools(next, context, attachment);
|
||||
attached.annotations = tools.annotations;
|
||||
attached.voiceCommands = tools.voiceCommands;
|
||||
attachment.defer(() => {
|
||||
@@ -250,8 +248,103 @@ export async function createApplicationTools({
|
||||
};
|
||||
}
|
||||
|
||||
/** The tools bound to a Cesium viewer. Returns its annotations and voice. */
|
||||
function attachViewerTools(
|
||||
/**
|
||||
* The Cesium viewer under an engine, or null on an engine without one: the
|
||||
* one place the tools read it.
|
||||
*/
|
||||
const cesiumViewerOf = (engine) => engine?.extensions?.cesium?.viewer ?? null;
|
||||
|
||||
/**
|
||||
* The tool backend of each engine kind (see attachEngineTools). Cesium's
|
||||
* holds the tools that still drive its viewer, so they stay absent on
|
||||
* other engines; Navara's loads with its mode (src/app/navaraMode.js), so
|
||||
* the Cesium-mode bundle carries none of it.
|
||||
*/
|
||||
const TOOL_BACKENDS = Object.freeze({
|
||||
cesium: async () => cesiumToolBackend,
|
||||
navara: async () => (await import('./navaraMode.js')).navaraToolBackend,
|
||||
});
|
||||
|
||||
/** The Cesium tool backend: the shared tools and its own on the viewer. */
|
||||
function cesiumToolBackend({ engine, tileset }) {
|
||||
const viewer = cesiumViewerOf(engine);
|
||||
return {
|
||||
viewer,
|
||||
board: () => ({ viewer, engine, tileset }),
|
||||
draw: () => ({ viewer, engine }),
|
||||
geoImport: () => ({ viewer, engine }),
|
||||
// The tileset lets the layer drape while the globe is hidden.
|
||||
recentImagery(layer) {
|
||||
layer.attachTileset(tileset);
|
||||
return { viewer };
|
||||
},
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. (perf wave 2) A parked viewer uninstalls
|
||||
// it (src/app/scene.js suspend).
|
||||
installGovernor: () => installRenderGovernor(viewer),
|
||||
attach({ dataManager }, { defer }) {
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its
|
||||
// entities) alive in reused per-pass arrays; trim them after each
|
||||
// disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
// The follow camera recomputes the tracked target's dead-reckon
|
||||
// position every frame — tracking anything is a per-frame animation.
|
||||
// (perf wave 2)
|
||||
const removeTrackingListener =
|
||||
viewer.trackedEntityChanged.addEventListener(() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
defer(() => {
|
||||
removeTrackingListener();
|
||||
releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
},
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU.
|
||||
suspend(hidden, { cockpitCloudEffects }) {
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
},
|
||||
attachViews(
|
||||
{ styleManager, dataManager, voiceCommands },
|
||||
{ signal, defer },
|
||||
) {
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools of one globe, on any engine: the annotation board, Draw,
|
||||
* Import, the Recent Imagery box and panel, the render governor, the scope
|
||||
* mask, hidden-tab handling and voice, and the engine's own tools. The
|
||||
* engine kind's backend (TOOL_BACKENDS) says where the shared tools draw:
|
||||
* `board()`, `draw()` and `geoImport()` give their attachScene arguments
|
||||
* (null: not on this engine), `recentImagery(layer)` the panel's globe,
|
||||
* `installGovernor()` the governor's; `attach(context, attachment)` adds
|
||||
* the engine's own tools, `suspend(hidden, controls)` its hidden-tab
|
||||
* handling and `attachViews(context, attachment)` its embedded views. The
|
||||
* shared tools keep their session state across engines. Returns the
|
||||
* annotation board (null without one) and voice commands.
|
||||
*/
|
||||
async function attachEngineTools(
|
||||
scene,
|
||||
{
|
||||
controls,
|
||||
@@ -266,48 +359,26 @@ function attachViewerTools(
|
||||
},
|
||||
{ signal, defer },
|
||||
) {
|
||||
const { viewer, engine, tileset, mapStackController } = scene;
|
||||
// The annotation board belongs to the page session when there is one: an
|
||||
// engine switch only takes its drawing off this viewer.
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
const { engine, mapStackController } = scene;
|
||||
const backend = (await TOOL_BACKENDS[engine.kind]())(scene);
|
||||
// The page session's board, Draw and Import (src/app/sessionTools.js):
|
||||
// an engine switch only takes their drawing off this globe.
|
||||
const annotations = attachBoard(
|
||||
backend.board(),
|
||||
{ layerSession, placeSearch, operations },
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({ viewer, engine, tileset });
|
||||
defer(() => annotations.detachScene());
|
||||
// UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool
|
||||
// claims this viewer's pointer while a sketch is open.
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
);
|
||||
attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
|
||||
attachGeoImport(
|
||||
backend.geoImport(),
|
||||
{ layerSession, styleManager, operations, mapStackController },
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({ viewer, engine, navigation: styleManager });
|
||||
defer(() => drawTool?.detachScene());
|
||||
// UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the
|
||||
// session's files again. The drop listeners belong to this viewer.
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
viewer,
|
||||
engine,
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
);
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
// panel lives on the right rail, so both belong to this globe. The tileset
|
||||
// lets the layer drape while the globe is hidden.
|
||||
// panel lives on the right rail, so both belong to this globe.
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
recentImagery.attachTileset(tileset);
|
||||
const panelGlobe = backend.recentImagery(recentImagery);
|
||||
const imageryBoxTool = initImageryBoxTool({
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
@@ -325,7 +396,7 @@ function attachViewerTools(
|
||||
data.presentation.attachRecentImagery((container) =>
|
||||
createRecentImageryPanel({
|
||||
container,
|
||||
viewer,
|
||||
...panelGlobe,
|
||||
layer: recentImagery,
|
||||
tool: imageryBoxTool,
|
||||
}),
|
||||
@@ -340,45 +411,24 @@ function attachViewerTools(
|
||||
imageryBoxTool?.destroy();
|
||||
});
|
||||
}
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2) A parked viewer
|
||||
// uninstalls it (src/app/scene.js suspend).
|
||||
installRenderGovernor(viewer);
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||
// alive in reused per-pass arrays; trim them after each disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
// Installed AFTER every module above has had its chance to register
|
||||
// pre-install holds.
|
||||
backend.installGovernor();
|
||||
backend.attach?.({ data, styleManager, dataManager }, { signal, defer });
|
||||
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
// Install the explicit scope mask used by the DISPLAY controls: a canvas
|
||||
// in the engine's container, its terminus following the camera's height.
|
||||
installScopeMask(engine);
|
||||
defer(() => destroyScopeMask());
|
||||
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
||||
() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
},
|
||||
);
|
||||
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
// A hidden tab: the engine's own suspension, if any; holder/data state is
|
||||
// untouched, so return is seamless: restore the loop, refresh the one DOM
|
||||
// surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
// A panel's host may report it hidden while it is on screen; the panel
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
controls.cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
backend.suspend?.(hidden, controls);
|
||||
if (!hidden) {
|
||||
data.presentation.flushVisible();
|
||||
governorRequestRender('visibility-restore');
|
||||
@@ -388,33 +438,102 @@ function attachViewerTools(
|
||||
defer(() =>
|
||||
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
|
||||
);
|
||||
defer(() => {
|
||||
removeTrackingListener();
|
||||
releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
// Apply the CURRENT state too — bootstrap can complete while the tab is
|
||||
// already hidden, and waiting for the next transition would leave the
|
||||
// loop burning behind a hidden tab. (perf wave 2 fix)
|
||||
syncVisibilitySuspension();
|
||||
// Apply the CURRENT state to a suspending engine too — bootstrap can
|
||||
// complete while the tab is already hidden, and waiting for the next
|
||||
// transition would leave the loop burning behind a hidden tab. (perf wave
|
||||
// 2 fix)
|
||||
if (backend.suspend) syncVisibilitySuspension();
|
||||
|
||||
// Voice uses the engine and shared session services; each action checks
|
||||
// the capabilities it needs on the attached scene.
|
||||
const voiceCommands = attachSessionVoice({
|
||||
layerSession,
|
||||
voice,
|
||||
operations,
|
||||
placeSearch,
|
||||
dataManager,
|
||||
scene: { viewer, engine, styleManager, sceneDirector, annotations },
|
||||
scene: {
|
||||
viewer: backend.viewer ?? null,
|
||||
engine,
|
||||
styleManager,
|
||||
sceneDirector,
|
||||
annotations,
|
||||
},
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
backend.attachViews?.(
|
||||
{ styleManager, dataManager, voiceCommands },
|
||||
{ signal, defer },
|
||||
);
|
||||
return { annotations, voiceCommands };
|
||||
}
|
||||
|
||||
/** The annotation board drawn on this globe (`boardScene`), or null. */
|
||||
function attachBoard(
|
||||
boardScene,
|
||||
{ layerSession, placeSearch, operations },
|
||||
defer,
|
||||
) {
|
||||
if (!boardScene) return null;
|
||||
// The board belongs to the page session when there is one: an engine
|
||||
// switch only takes its drawing off this globe.
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene(boardScene);
|
||||
defer(() => annotations.detachScene());
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview):
|
||||
* the tool claims the globe's pointer while a sketch is open, so a switch
|
||||
* ends an open sketch and keeps the chosen shape and colour. Returns the
|
||||
* tool (null on a page without it or without the board).
|
||||
*/
|
||||
function attachDraw(
|
||||
drawScene,
|
||||
{ layerSession, annotations, styleManager },
|
||||
defer,
|
||||
) {
|
||||
if (!annotations) return null;
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({ ...drawScene(), navigation: styleManager });
|
||||
defer(() => drawTool?.detachScene());
|
||||
return drawTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Import on this globe (`importScene`, null: not on it): the
|
||||
* next globe reads the session's files again. The drop listeners belong to
|
||||
* this globe. Returns the control (null on a page without it).
|
||||
*/
|
||||
function attachGeoImport(
|
||||
importScene,
|
||||
{ layerSession, styleManager, operations, mapStackController },
|
||||
defer,
|
||||
) {
|
||||
if (!importScene) return null;
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
...importScene,
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
return geoFileImport;
|
||||
}
|
||||
@@ -2,10 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApplicationTools } from './tools.js';
|
||||
import { navaraModeNotice } from './navaraMode.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
} from './layers/renderers.js';
|
||||
import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import {
|
||||
_resetRenderGovernorForTest,
|
||||
@@ -14,8 +11,8 @@ import {
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
// The tools without a viewer (the experimental Navara engine); they load
|
||||
// from src/app/navaraMode.js.
|
||||
// The tools on an engine without a viewer (the experimental Navara engine):
|
||||
// the same tree, with Navara's tool backend (src/app/navaraMode.js).
|
||||
test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => {
|
||||
const saved = {
|
||||
window: globalThis.window,
|
||||
@@ -121,6 +118,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
|
||||
assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome');
|
||||
assert.equal(chrome[0].styleManager, styleManager);
|
||||
const notice = navaraModeNotice(createLayerAvailability('navara'));
|
||||
assert.match(notice.detail, /^Data layers: Bhote Koshi Flood, /);
|
||||
assert.deepEqual(notices, [
|
||||
{
|
||||
label: notice.label,
|
||||
@@ -131,15 +129,20 @@ test('without a viewer the tools run the governor on the engine, show the notice
|
||||
},
|
||||
},
|
||||
]);
|
||||
const none = navaraModeNotice(
|
||||
createLayerAvailability('navara', { manifest: {} }),
|
||||
);
|
||||
assert.match(
|
||||
`${notice.label}: ${notice.detail}`,
|
||||
`${none.label}: ${none.detail}`,
|
||||
/^Experimental Navara engine: no registered data layers$/i,
|
||||
);
|
||||
// With Navara renderers, the notice names the layers it shows.
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { satellites: () => ({}), flights: () => ({}) });
|
||||
// The notice names the layers the engine shows, in catalog order.
|
||||
const manifest = {
|
||||
satellites: LAYER_MANIFEST.satellites,
|
||||
flights: LAYER_MANIFEST.flights,
|
||||
};
|
||||
assert.equal(
|
||||
navaraModeNotice(createLayerAvailability('navara', { registry })).detail,
|
||||
navaraModeNotice(createLayerAvailability('navara', { manifest })).detail,
|
||||
'Data layers: Live Flights, Satellites',
|
||||
);
|
||||
// The governor drives the engine's continuous rendering.
|
||||
|
||||
@@ -81,6 +81,8 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
|
||||
engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'),
|
||||
createStandalonePlaceSearch: () => ({}),
|
||||
createStandaloneCatalog: () => ({}),
|
||||
// Cesium's displays are in the main chunk: nothing to load.
|
||||
loadLayerRenderers: async () => {},
|
||||
createGoogleTokenSource: () => createGoogleTokenSource({ fetchImpl }),
|
||||
selectMapStartupRoute,
|
||||
// The real scene forwards these credentials to this same loader.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||
import { engineOfChoice, resolveEngineKind } from '../app/engineChoice.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { attachScenePart } from '../app/sceneParts.js';
|
||||
import { loadLayerRenderers } from '../app/layers/renderers.js';
|
||||
|
||||
// Only one engine may own the page at a time.
|
||||
let owner = null;
|
||||
@@ -109,6 +110,10 @@ export function createStandaloneApplication({
|
||||
throw new AggregateError(errors, 'The globe could not be released');
|
||||
};
|
||||
const signal = AbortSignal.any([controller.signal, lifetime]);
|
||||
// The layers' engine-specific renderers of this engine (the layer
|
||||
// manifest's import()s, none on Cesium) load beside its globe.
|
||||
const layerRenderers = loadLayerRenderers(engineOfChoice(kind));
|
||||
layerRenderers.catch(() => {});
|
||||
try {
|
||||
const scene = await createStandaloneScene({
|
||||
...sessionContext,
|
||||
@@ -125,8 +130,10 @@ export function createStandaloneApplication({
|
||||
layerSession,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
// The catalog of this engine: on Navara only the layers with a Navara
|
||||
// renderer (src/app/layers/renderers.js), the others' state kept for
|
||||
await layerRenderers;
|
||||
signal.throwIfAborted();
|
||||
// The catalog of this engine: only the layers it can show (the layer
|
||||
// manifest, src/app/layers/renderers.js), the others' state kept for
|
||||
// links. The layer models are the session's (layerSession.models).
|
||||
// Session models keep the catalog's closures past this scene: they
|
||||
// capture the operations, never the scene (it holds the engine).
|
||||
|
||||
@@ -40,6 +40,7 @@ function standalone({ failOpen = null } = {}) {
|
||||
engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'),
|
||||
createStandalonePlaceSearch: () => ({ kind: 'search' }),
|
||||
createStandaloneCatalog: ({ engineKind }) => ({ engineKind }),
|
||||
loadLayerRenderers: async () => {},
|
||||
createGoogleTokenSource: () => null,
|
||||
createStandaloneScene: async ({ engineKind, defer }) => {
|
||||
if (failOpen === engineKind) throw new Error(`${engineKind} refused`);
|
||||
|
||||
Reference in new issue
Block a user