diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index dd2d6be8c..d12d4dd66 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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 diff --git a/docs/GLOBE-PRIMITIVES.md b/docs/GLOBE-PRIMITIVES.md index 24c734180..80295afc6 100644 --- a/docs/GLOBE-PRIMITIVES.md +++ b/docs/GLOBE-PRIMITIVES.md @@ -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`. diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index bad579e72..0d6deb59d 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -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) ) ) diff --git a/scripts/engine-coupling-baseline.json b/scripts/engine-coupling-baseline.json index f1b3cdbdd..84223b1bc 100644 --- a/scripts/engine-coupling-baseline.json +++ b/scripts/engine-coupling-baseline.json @@ -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" } diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d04143ac4..dbc21269a 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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": { diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f94ab895b..2d1e43b0d 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -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( diff --git a/src/app/layers/airQuality.js b/src/app/layers/airQuality.js index 171836404..d23e373b1 100644 --- a/src/app/layers/airQuality.js +++ b/src/app/layers/airQuality.js @@ -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) { diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index 43f696c20..9ec29901b 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -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, }, diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js index 787e5aed2..f94a80cc9 100644 --- a/src/app/layers/alprCameras.js +++ b/src/app/layers/alprCameras.js @@ -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({ diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js index a4ab30aff..5a520d8d9 100644 --- a/src/app/layers/bikeshare.js +++ b/src/app/layers/bikeshare.js @@ -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 }) { diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js index 82aeaab12..40ca15de4 100644 --- a/src/app/layers/cctv.js +++ b/src/app/layers/cctv.js @@ -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({ diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js index 27f090e9a..a62dbbb77 100644 --- a/src/app/layers/cyclones.js +++ b/src/app/layers/cyclones.js @@ -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({ diff --git a/src/app/layers/directions.js b/src/app/layers/directions.js index 611d6ad6c..a22d9f7d0 100644 --- a/src/app/layers/directions.js +++ b/src/app/layers/directions.js @@ -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 }) { diff --git a/src/app/layers/droneAirspace.js b/src/app/layers/droneAirspace.js index a2cede31a..32f45d98b 100644 --- a/src/app/layers/droneAirspace.js +++ b/src/app/layers/droneAirspace.js @@ -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) { diff --git a/src/app/layers/earthquakes.js b/src/app/layers/earthquakes.js index 9c43aa024..2c0b57129 100644 --- a/src/app/layers/earthquakes.js +++ b/src/app/layers/earthquakes.js @@ -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({ diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js index 6c78eeeff..29c51f924 100644 --- a/src/app/layers/firms.js +++ b/src/app/layers/firms.js @@ -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 }) { diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js index 88a49c11b..d3d942b57 100644 --- a/src/app/layers/flights.js +++ b/src/app/layers/flights.js @@ -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, diff --git a/src/app/layers/ghostMaps.js b/src/app/layers/ghostMaps.js index fd6306710..f50a3d1d7 100644 --- a/src/app/layers/ghostMaps.js +++ b/src/app/layers/ghostMaps.js @@ -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) { diff --git a/src/app/layers/gnss.js b/src/app/layers/gnss.js index 30a4515c1..15eaa94ad 100644 --- a/src/app/layers/gnss.js +++ b/src/app/layers/gnss.js @@ -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) { diff --git a/src/app/layers/localAdsb.js b/src/app/layers/localAdsb.js index 38f831a3e..e50a452eb 100644 --- a/src/app/layers/localAdsb.js +++ b/src/app/layers/localAdsb.js @@ -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, diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js index 4a23c7d45..0e58266a8 100644 --- a/src/app/layers/militaryFlights.js +++ b/src/app/layers/militaryFlights.js @@ -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, diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js index 2ed0e698e..3fc78cd02 100644 --- a/src/app/layers/militaryInstallations.js +++ b/src/app/layers/militaryInstallations.js @@ -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 }) { diff --git a/src/app/layers/osmPlaces.js b/src/app/layers/osmPlaces.js index 000f9a23f..32bc84775 100644 --- a/src/app/layers/osmPlaces.js +++ b/src/app/layers/osmPlaces.js @@ -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({ diff --git a/src/app/layers/perimeters.js b/src/app/layers/perimeters.js index 9bac7a407..000f7568e 100644 --- a/src/app/layers/perimeters.js +++ b/src/app/layers/perimeters.js @@ -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) { diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js index 205c557d9..2cb79547f 100644 --- a/src/app/layers/radio.js +++ b/src/app/layers/radio.js @@ -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({ diff --git a/src/app/layers/recentImagery.js b/src/app/layers/recentImagery.js index ca0872066..9e5694bfa 100644 --- a/src/app/layers/recentImagery.js +++ b/src/app/layers/recentImagery.js @@ -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 }) { diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js index 8a536b7f7..4d744ce93 100644 --- a/src/app/layers/renderers.js +++ b/src/app/layers/renderers.js @@ -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} + */ +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) diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs index 5bb1d84e7..0221e6aa7 100644 --- a/src/app/layers/renderers.test.mjs +++ b/src/app/layers/renderers.test.mjs @@ -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); }); diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js index 66c692e58..2e12f6549 100644 --- a/src/app/layers/rocketLaunches.js +++ b/src/app/layers/rocketLaunches.js @@ -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), }, diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js index ef0aa2cd8..d5ca5e4db 100644 --- a/src/app/layers/satellites.js +++ b/src/app/layers/satellites.js @@ -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), }, diff --git a/src/app/layers/streetLevel.js b/src/app/layers/streetLevel.js index e6b9fd71e..e9a9efb5b 100644 --- a/src/app/layers/streetLevel.js +++ b/src/app/layers/streetLevel.js @@ -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 = {}, diff --git a/src/app/layers/submarineCables.js b/src/app/layers/submarineCables.js index 0e8ca3600..cf6af1aff 100644 --- a/src/app/layers/submarineCables.js +++ b/src/app/layers/submarineCables.js @@ -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({ diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js index deb57d7cc..b999c6757 100644 --- a/src/app/layers/traffic.js +++ b/src/app/layers/traffic.js @@ -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 }) { diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 50d18cf1b..253fac3c1 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -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} diff --git a/src/app/loadCesium.test.mjs b/src/app/loadCesium.test.mjs index 374259713..1b91861a9 100644 --- a/src/app/loadCesium.test.mjs +++ b/src/app/loadCesium.test.mjs @@ -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 }); diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 62b7d57ca..6e70542c9 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -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); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index d8169690a..32450aa8f 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -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 }; + }; } diff --git a/src/app/navaraTools.test.mjs b/src/app/navaraToolBackend.test.mjs similarity index 67% rename from src/app/navaraTools.test.mjs rename to src/app/navaraToolBackend.test.mjs index 7e7f61dfb..af8b94be5 100644 --- a/src/app/navaraTools.test.mjs +++ b/src/app/navaraToolBackend.test.mjs @@ -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']); }); diff --git a/src/app/navaraTools.js b/src/app/navaraTools.js deleted file mode 100644 index 6a8706d11..000000000 --- a/src/app/navaraTools.js +++ /dev/null @@ -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()); -} diff --git a/src/app/qaProbe.test.mjs b/src/app/qaProbe.test.mjs index a247f70e0..d875546bb 100644 --- a/src/app/qaProbe.test.mjs +++ b/src/app/qaProbe.test.mjs @@ -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 }, diff --git a/src/app/sessionTools.js b/src/app/sessionTools.js index 38fe3ddb9..39b9e69d2 100644 --- a/src/app/sessionTools.js +++ b/src/app/sessionTools.js @@ -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'; diff --git a/src/app/tools.js b/src/app/tools.js index f52096ef3..092a2f99e 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -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; +} diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 94a4251fd..2786e6238 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -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. diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index af24c3cbb..42154ab33 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -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. diff --git a/src/standalone/application.js b/src/standalone/application.js index 2b542b6ea..47f07c624 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -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). diff --git a/src/standalone/applicationSwitch.test.mjs b/src/standalone/applicationSwitch.test.mjs index 26bd718cf..ea6b96163 100644 --- a/src/standalone/applicationSwitch.test.mjs +++ b/src/standalone/applicationSwitch.test.mjs @@ -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`);