mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 06:42:23 +00:00
Build the catalog from one layer manifest and attach one tools tree
src/app/layers/renderers.js now holds LAYER_MANIFEST, one entry per layer in directory order: a display (the same render/display.js on every engine, with the contract primitives it needs and, for CCTV and Global Context, a Navara backend loaded by import()), a layer that draws itself through the contract, or per-engine renderers for the layers not on a display yet (weather, wind, Bhote Koshi, sea marks, ski pistes). A layer is available where the engine has the primitives it needs. The Cesium renderer table, CESIUM_BUILTIN, the registry and the Navara mode's map of about 35 loaders go; the application loads a kind's renderers before it builds the catalog. The tools attach through one tree over the engine, in Cesium's order. Each engine kind adds its own tools from one backend that reads its extension once: the Cesium backend holds the viewer-only tools, the Navara backend the annotation renderer, draw surface and file import drawing. Coupling: engine.extensions reads fall from 12 to 8, engine-name literal lines from 73 to 66.
This commit is contained in:
1 parent
f1ec9d4a22
commit
48885d9e61
46 files changed
+1057
-899
No files matched your search
@@ -200,9 +200,9 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
|
||||
);
|
||||
});
|
||||
|
||||
test('the catalog per engine: Cesium as before, Navara only what it can show', async (t) => {
|
||||
test('the catalog per engine: the same layers on both, a subset when the manifest says so', async (t) => {
|
||||
const { APPLICATION_LAYER_DIRECTORY } = await import('./layers/directory.js');
|
||||
const { createLayerAvailability, createRendererRegistry } =
|
||||
const { createLayerAvailability, LAYER_MANIFEST } =
|
||||
await import('./layers/renderers.js');
|
||||
const controller = new AbortController();
|
||||
t.after(() => controller.abort());
|
||||
@@ -224,20 +224,20 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
|
||||
assert.equal(cesium.availability.unavailable().length, 0);
|
||||
|
||||
const navara = build({ engineKind: 'navara' });
|
||||
assert.equal(navara.layers.length, 0, 'no Navara renderers yet');
|
||||
assert.equal(navara.unavailableMetadata.length, cesium.metadata.length);
|
||||
assert.equal(navara.layers.length, cesium.layers.length);
|
||||
assert.deepEqual(navara.unavailableMetadata, []);
|
||||
assert.equal(navara.availability.engine, 'navara');
|
||||
|
||||
// With Navara renderers for two layers, only those are constructed, in
|
||||
// catalog order, with their metadata.
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', {
|
||||
satellites: () => ({}),
|
||||
flights: () => ({}),
|
||||
});
|
||||
// When the engine can show only two layers, only those are constructed,
|
||||
// in catalog order, with their metadata.
|
||||
const subset = build({
|
||||
engineKind: 'navara',
|
||||
availability: createLayerAvailability('navara', { registry }),
|
||||
availability: createLayerAvailability('navara', {
|
||||
manifest: {
|
||||
satellites: LAYER_MANIFEST.satellites,
|
||||
flights: LAYER_MANIFEST.flights,
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(
|
||||
subset.layers.map(({ id }) => id),
|
||||
@@ -251,8 +251,8 @@ test('the catalog per engine: Cesium as before, Navara only what it can show', a
|
||||
assert.equal(subset.get('military'), undefined);
|
||||
});
|
||||
|
||||
test('both production registries attach to the same 38 session models', async (t) => {
|
||||
const { registerNavaraLayerRenderers } = await import('./navaraMode.js');
|
||||
test('both engines attach to the same 38 session models', async (t) => {
|
||||
const { loadLayerRenderers } = await import('./layers/renderers.js');
|
||||
const lifetime = new AbortController();
|
||||
t.after(() => lifetime.abort());
|
||||
const options = {
|
||||
@@ -262,7 +262,7 @@ test('both production registries attach to the same 38 session models', async (t
|
||||
surface: fixtureSurface(lifetime.signal),
|
||||
};
|
||||
const cesium = createApplicationCatalog(options);
|
||||
await registerNavaraLayerRenderers();
|
||||
await loadLayerRenderers('navara');
|
||||
const navara = createApplicationCatalog({ ...options, engineKind: 'navara' });
|
||||
assert.equal(cesium.layers.length, 38);
|
||||
assert.deepEqual(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createAirQualityLayer } from '../../layers/airQuality/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/**
|
||||
* Wire air-quality readings to the application overlay host; the station
|
||||
* discs are the engine's renderer from the registry (render/display.js on
|
||||
* discs are the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationAirQuality(options) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export function createApplicationVessels({
|
||||
source,
|
||||
options = {},
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the vessels display on every engine; none without an engine).
|
||||
// manifest: the vessels display on every engine; none without an engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('ais-live-vessels', engine),
|
||||
},
|
||||
@@ -36,7 +36,7 @@ export function createApplicationVessels({
|
||||
worldFocus,
|
||||
render,
|
||||
selection,
|
||||
// The engine's vessel display from the registry (render/display.js).
|
||||
// The engine's vessel display from the manifest (render/display.js).
|
||||
renderer,
|
||||
readouts,
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@ import { refreshTrackedReadout } from '../../data/trackedReadout.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationAlpr({
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as overlays from '../../overlays/worldOverlay.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationBikeshare({ source }) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationCctv({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Wire cyclone selection to the application shared context; the layer draws
|
||||
* with the engine's renderer from the registry (render/display.js on every
|
||||
* with the engine's renderer from the manifest (render/display.js on every
|
||||
* engine). The catalog passes the same factory as `createNativeRendering`.
|
||||
*/
|
||||
export function createApplicationCyclones({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct Directions with application scene owners and an explicit route
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationDirections({ source, surface }) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire FAA special-use airspace into the application catalog; the zones are
|
||||
* the engine's renderer from the registry (render/display.js on every
|
||||
* the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationDroneAirspace(options) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createEarthquakesLayer } from '../../layers/earthquakes/index.js';
|
||||
import { overlayHost } from './overlayHost.js';
|
||||
/**
|
||||
* Wire earthquake observations to the application overlay host; the discs
|
||||
* are the engine's renderer from the registry (render/display.js on every
|
||||
* are the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationEarthquakes({
|
||||
|
||||
@@ -15,7 +15,7 @@ export const firmsServices = {
|
||||
|
||||
/**
|
||||
* Bind a supplied fire feed to the application scene service owners; it
|
||||
* draws with the engine's renderer from the registry (render/display.js on
|
||||
* draws with the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationFirms({ surface, ...options }) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createApplicationFlights({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the flights display on every engine).
|
||||
// manifest: the flights display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('flights', engine),
|
||||
},
|
||||
@@ -32,7 +32,7 @@ export function createApplicationFlights({
|
||||
services: {
|
||||
picking,
|
||||
selection,
|
||||
// The engine's flights renderer from the registry (render/display.js).
|
||||
// The engine's flights renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
aircraftPresentation,
|
||||
camera: trackedCamera,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire the S2 Underground GhostMaps layer into the application catalog; it
|
||||
* draws with the engine's renderer from the registry (render/display.js on
|
||||
* draws with the engine's renderer from the manifest (render/display.js on
|
||||
* every engine).
|
||||
*/
|
||||
export function createApplicationGhostMaps(options) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createGnssIntegrityLayer } from '../../layers/gnss/index.js';
|
||||
|
||||
/**
|
||||
* Wire the adsb.lol GNSS navigation-integrity layer into the application
|
||||
* catalog; the cells are the engine's renderer from the registry
|
||||
* catalog; the cells are the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationGnssIntegrity(options) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function createApplicationLocalAdsb({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the Local ADS-B display on every engine).
|
||||
// manifest: the Local ADS-B display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('local-adsb', engine),
|
||||
},
|
||||
@@ -43,7 +43,7 @@ export function createApplicationLocalAdsb({
|
||||
context,
|
||||
picking,
|
||||
selection,
|
||||
// The engine's Local ADS-B renderer from the registry (render/display.js).
|
||||
// The engine's Local ADS-B renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
geoid,
|
||||
groundSnap: surface?.groundSnap || null,
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createApplicationMilitary({
|
||||
resolveAsset = (url) =>
|
||||
`${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the military display on every engine).
|
||||
// manifest: the military display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('military', engine),
|
||||
},
|
||||
@@ -32,7 +32,7 @@ export function createApplicationMilitary({
|
||||
services: {
|
||||
picking,
|
||||
selection,
|
||||
// The engine's military renderer from the registry (render/display.js).
|
||||
// The engine's military renderer from the manifest (render/display.js).
|
||||
renderer,
|
||||
aircraftPresentation,
|
||||
camera: trackedCamera,
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as picking from '../../data/pickRegistry.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationInstallations({ surface, source }) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Wire OSM Places labels to the application overlay host; the points are the
|
||||
* engine's renderer from the registry (render/display.js on every engine).
|
||||
* engine's renderer from the manifest (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationOsmPlaces() {
|
||||
return createOsmPlacesLayer({
|
||||
|
||||
@@ -10,7 +10,7 @@ import { isPointerFree } from '../../data/inputOwnership.js';
|
||||
|
||||
/**
|
||||
* Wire WFIGS fire perimeters into the application catalog; the perimeters
|
||||
* are the engine's renderer from the registry (render/display.js on every
|
||||
* are the engine's renderer from the manifest (render/display.js on every
|
||||
* engine).
|
||||
*/
|
||||
export function createApplicationFirePerimeters(options) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as render from '../../renderGovernor.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationRadio({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct imagery presentation over one explicitly supplied acquisition
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationRecentImagery({ source }) {
|
||||
|
||||
+307
-107
@@ -1,13 +1,33 @@
|
||||
// Which engine draws which layer: the layer renderer registry and the layer
|
||||
// availability API built on it.
|
||||
// How each application layer draws on each engine: the layer manifest, and
|
||||
// the layer availability API built on it.
|
||||
//
|
||||
// A renderer factory `(engine, options) => renderer` draws one layer on one
|
||||
// engine kind (`render/cesium.js`, `render/navara.js` of a layer), or on
|
||||
// every kind (its `render/display.js`, registered for each). Cesium
|
||||
// layers without a render/cesium.js draw themselves: they are registered as
|
||||
// CESIUM_BUILTIN. Cesium entries are registered here, statically (main
|
||||
// chunk); Navara factories are registered by the Navara mode after its
|
||||
// import() (src/app/navaraMode.js).
|
||||
// ## The manifest
|
||||
//
|
||||
// One entry per layer of the directory (src/app/layers/directory.js), in
|
||||
// its order, of one of three kinds:
|
||||
//
|
||||
// - `display(factory, needs, backends)`: the layer's render/display.js, the
|
||||
// same factory `(engine, options) => renderer` on every engine, drawn
|
||||
// through the contract primitives in `needs` (docs/GLOBE-PRIMITIVES.md).
|
||||
// `backends` is the display's engine-specific option, keyed by engine
|
||||
// kind: an import() loader of `{ option: value }` merged into the
|
||||
// display's options on that engine (CCTV's meshes, Global Context's
|
||||
// pulses on Navara). The displays are static (the main chunk, as before):
|
||||
// the layers build their renderers synchronously.
|
||||
// - `drawsItself(needs)`: no renderer; the layer draws itself through the
|
||||
// contract primitives in `needs` (Wildlife Sightings).
|
||||
// - `perEngine({ [kind]: loader | DRAWS_ITSELF })`: a layer with no display
|
||||
// yet. On each engine kind listed it either draws itself (DRAWS_ITSELF,
|
||||
// the Cesium weather and wind, the open map overlays) or has an
|
||||
// engine-specific renderer, an import() loader of its factory (Navara's
|
||||
// weather, wind and Bhote Koshi). It shows only on the kinds listed.
|
||||
//
|
||||
// Every import() here is resolved by `loadLayerRenderers(kind)`, which the
|
||||
// page awaits before it builds an engine's catalog (src/standalone/
|
||||
// application.js), so a Cesium page fetches none of the Navara ones.
|
||||
// `rendererFor(id, engine, options)` then answers synchronously: the
|
||||
// layer's renderer on that engine, or null when it draws itself or the
|
||||
// engine cannot show it.
|
||||
//
|
||||
// ## Layer availability (stable API, used by the layer list)
|
||||
//
|
||||
@@ -18,12 +38,14 @@
|
||||
// availability.available() // [{ id, name }] in catalog order
|
||||
// availability.unavailable() // [{ id, name }] the catalog leaves out
|
||||
//
|
||||
// The object is frozen; available() and unavailable() return fresh arrays.
|
||||
// It reads the registry when called, so a Navara factory registered later
|
||||
// (after the Navara mode loads) shows up. `name` is the layer's display name
|
||||
// A layer is available on an engine kind when that engine has every
|
||||
// primitive its display (or its own drawing) needs, or, without one, when
|
||||
// its entry lists that kind. The object is frozen; available() and
|
||||
// unavailable() return fresh arrays. `name` is the layer's display name
|
||||
// (src/app/layers/directory.js). The layer list takes it as its
|
||||
// `availability` provider (`{ engine, unavailable() }` is the part it reads).
|
||||
|
||||
import { ENGINE_KINDS } from '../../globe/contract.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { createFlightsDisplay } from '../../layers/flights/render/display.js';
|
||||
import { createMilitaryDisplay } from '../../layers/military/render/display.js';
|
||||
@@ -54,105 +76,282 @@ import { createTrafficDisplay } from '../../layers/traffic/render/display.js';
|
||||
import { createDirectionsDisplay } from '../../layers/directions/render/display.js';
|
||||
import { createRecentImageryDisplay } from '../../layers/recentImagery/render/display.js';
|
||||
|
||||
const ENGINE_KINDS = new Set(['cesium', 'navara']);
|
||||
/**
|
||||
* The contract's drawing primitives (docs/GLOBE-PRIMITIVES.md). They are
|
||||
* required members of every engine (src/globe/contract.js), so every
|
||||
* engine kind has them all.
|
||||
*/
|
||||
export const CONTRACT_PRIMITIVES = Object.freeze([
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
'imagery',
|
||||
]);
|
||||
|
||||
/** The layer draws itself on Cesium (no separate renderer yet). */
|
||||
export const CESIUM_BUILTIN = Object.freeze({ builtin: 'cesium' });
|
||||
/** The primitives an engine kind draws with: all of them, on every kind. */
|
||||
const primitivesOf = (engineKind) =>
|
||||
ENGINE_KINDS.includes(engineKind) ? CONTRACT_PRIMITIVES : [];
|
||||
|
||||
const isFactory = (value) =>
|
||||
typeof value === 'function' || value === CESIUM_BUILTIN;
|
||||
/** In a `perEngine` entry: the layer draws itself on that engine kind. */
|
||||
export const DRAWS_ITSELF = Object.freeze({ drawsItself: true });
|
||||
|
||||
/** A registry of layer renderers per engine kind. */
|
||||
export function createRendererRegistry() {
|
||||
const byEngine = new Map();
|
||||
const entries = (engineKind) => byEngine.get(engineKind) ?? new Map();
|
||||
return {
|
||||
/** Adds `{ [layerId]: factory }` for an engine kind; one per layer. */
|
||||
register(engineKind, factories) {
|
||||
if (typeof engineKind !== 'string' || !engineKind)
|
||||
throw new TypeError('registerLayerRenderers needs an engine kind');
|
||||
let map = byEngine.get(engineKind);
|
||||
if (!map) byEngine.set(engineKind, (map = new Map()));
|
||||
for (const [layerId, factory] of Object.entries(factories ?? {})) {
|
||||
if (!isFactory(factory))
|
||||
throw new TypeError(
|
||||
`Layer renderer for ${layerId} on ${engineKind} is not a factory`,
|
||||
);
|
||||
if (map.has(layerId))
|
||||
throw new Error(
|
||||
`Layer ${layerId} already has a renderer on ${engineKind}`,
|
||||
);
|
||||
map.set(layerId, factory);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The renderer of a layer on `engine` (factory(engine, options)), or null
|
||||
* when the engine has none or the layer still draws itself (Cesium).
|
||||
*/
|
||||
rendererFor(layerId, engine, options = {}) {
|
||||
const factory = entries(engine?.kind).get(layerId);
|
||||
return typeof factory === 'function' ? factory(engine, options) : null;
|
||||
},
|
||||
layerAvailableOn: (layerId, engineKind) => entries(engineKind).has(layerId),
|
||||
availableIds: (engineKind) => [...entries(engineKind).keys()],
|
||||
};
|
||||
const checkNeeds = (needs) => {
|
||||
if (
|
||||
!Array.isArray(needs) ||
|
||||
!needs.length ||
|
||||
!needs.every((name) => CONTRACT_PRIMITIVES.includes(name))
|
||||
)
|
||||
throw new TypeError(`Not contract primitives: ${needs}`);
|
||||
return Object.freeze([...needs]);
|
||||
};
|
||||
|
||||
const checkLoaders = (loaders, allowDrawsItself) => {
|
||||
for (const [kind, loader] of Object.entries(loaders)) {
|
||||
if (!ENGINE_KINDS.includes(kind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${kind}`);
|
||||
if (
|
||||
typeof loader !== 'function' &&
|
||||
!(allowDrawsItself && loader === DRAWS_ITSELF)
|
||||
)
|
||||
throw new TypeError(`Not a loader for ${kind}: ${loader}`);
|
||||
}
|
||||
return Object.freeze({ ...loaders });
|
||||
};
|
||||
|
||||
/** A layer drawn by its display on every engine (see the module comment). */
|
||||
function display(factory, needs, backends = {}) {
|
||||
if (typeof factory !== 'function')
|
||||
throw new TypeError('A layer display is a factory');
|
||||
return Object.freeze({
|
||||
display: factory,
|
||||
needs: checkNeeds(needs),
|
||||
backends: checkLoaders(backends, false),
|
||||
});
|
||||
}
|
||||
|
||||
const defaultRegistry = createRendererRegistry();
|
||||
/** A layer that draws itself through the contract (no renderer). */
|
||||
const drawsItself = (needs) => Object.freeze({ needs: checkNeeds(needs) });
|
||||
|
||||
/** Registers layer renderers on the application's registry. */
|
||||
export const registerLayerRenderers = (engineKind, factories) =>
|
||||
defaultRegistry.register(engineKind, factories);
|
||||
/** The application registry's renderer of a layer on an engine, or null. */
|
||||
export const rendererFor = (layerId, engine, options) =>
|
||||
defaultRegistry.rendererFor(layerId, engine, options);
|
||||
/** Whether the application can show a layer on an engine kind. */
|
||||
export const layerAvailableOn = (layerId, engineKind) =>
|
||||
defaultRegistry.layerAvailableOn(layerId, engineKind);
|
||||
/** A layer without a display: its drawing per engine kind. */
|
||||
const perEngine = (engines) =>
|
||||
Object.freeze({ engines: checkLoaders(engines, true) });
|
||||
|
||||
/** Cesium renderers extracted from their layers (render/cesium.js). */
|
||||
export const CESIUM_LAYER_RENDERERS = Object.freeze({
|
||||
earthquakes: createEarthquakesDisplay,
|
||||
'air-quality': createAirQualityDisplay,
|
||||
'gnss-integrity': createGnssDisplay,
|
||||
'fire-perimeters': createPerimetersDisplay,
|
||||
'drone-airspace': createDroneAirspaceDisplay,
|
||||
'osm-places': createOsmPlacesDisplay,
|
||||
'alpr-cameras': createAlprDisplay,
|
||||
bikeshare: createBikeshareDisplay,
|
||||
radio: createRadioDisplay,
|
||||
transit: createTransitDisplay,
|
||||
'local-firms': createFirmsDisplay,
|
||||
's2-ghostmaps': createGhostMapsDisplay,
|
||||
'weather-cyclones': createCyclonesDisplay,
|
||||
'telegeography-submarine-cables': createCablesDisplay,
|
||||
'local-datacenters': createInfrastructureDisplay,
|
||||
'local-dams': createInfrastructureDisplay,
|
||||
'rocket-launches': createLaunchesDisplay,
|
||||
satellites: createSatellitesDisplay,
|
||||
flights: createFlightsDisplay,
|
||||
military: createMilitaryDisplay,
|
||||
'local-adsb': createLocalAdsbDisplay,
|
||||
'ais-live-vessels': createVesselsDisplay,
|
||||
cctv: createCctvDisplay,
|
||||
'military-installations': createInstallationsDisplay,
|
||||
'military-awareness': createAwarenessDisplay,
|
||||
'street-level': createStreetLevelDisplay,
|
||||
traffic: createTrafficDisplay,
|
||||
directions: createDirectionsDisplay,
|
||||
'recent-imagery': createRecentImageryDisplay,
|
||||
/** Navara's own view and classes, for the renderers that still use them. */
|
||||
const navaraNative = (engine) => engine.extensions.navara;
|
||||
|
||||
// Navara's renderers of the layers without a display. Each loads only here,
|
||||
// through import(), when a page loads Navara's renderers.
|
||||
const navaraWeather = async () => {
|
||||
const { createWeatherNavaraRenderer } =
|
||||
await import('../../layers/weather/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createWeatherNavaraRenderer(engine, navaraNative(engine), options);
|
||||
};
|
||||
const navaraOnly = (navara, others = DRAWS_ITSELF) =>
|
||||
perEngine({ cesium: others, navara });
|
||||
|
||||
/** How every application layer draws, by id, in directory order. */
|
||||
export const LAYER_MANIFEST = Object.freeze({
|
||||
// The Bhote Koshi event and locator draw themselves on Cesium
|
||||
// (src/data/bhoteKoshiEvent.js, bhoteKoshiLocator.js).
|
||||
'bhote-koshi-2026': navaraOnly(async () => {
|
||||
const { createNepalEventDisplay } =
|
||||
await import('../../layers/bhoteKoshi/render/navara.js');
|
||||
return (engine) => createNepalEventDisplay(engine, navaraNative(engine));
|
||||
}),
|
||||
'bhote-koshi-locator': navaraOnly(async () => {
|
||||
const { drawStorySource } =
|
||||
await import('../../layers/shared/render/navaraStory.js');
|
||||
return (engine, { data }) =>
|
||||
drawStorySource(engine, navaraNative(engine), data);
|
||||
}),
|
||||
// The aircraft family.
|
||||
flights: display(createFlightsDisplay, ['movers', 'lines']),
|
||||
military: display(createMilitaryDisplay, ['movers', 'lines']),
|
||||
'local-adsb': display(createLocalAdsbDisplay, ['movers', 'lines']),
|
||||
earthquakes: display(createEarthquakesDisplay, ['ground']),
|
||||
'air-quality': display(createAirQualityDisplay, ['ground']),
|
||||
'fire-perimeters': display(createPerimetersDisplay, ['ground']),
|
||||
'gnss-integrity': display(createGnssDisplay, ['ground']),
|
||||
's2-ghostmaps': display(createGhostMapsDisplay, ['movers', 'ground']),
|
||||
'drone-airspace': display(createDroneAirspaceDisplay, ['ground']),
|
||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||
'wildlife-sightings': drawsItself(['movers', 'lines']),
|
||||
'alpr-cameras': display(createAlprDisplay, ['movers']),
|
||||
'street-level': display(createStreetLevelDisplay, ['movers', 'ground']),
|
||||
satellites: display(createSatellitesDisplay, ['movers', 'lines']),
|
||||
'rocket-launches': display(createLaunchesDisplay, ['movers', 'lines']),
|
||||
traffic: display(createTrafficDisplay, ['movers', 'ground']),
|
||||
// CCTV's display, with the Navara backend of its plane and volume
|
||||
// (render/meshes.js).
|
||||
cctv: display(createCctvDisplay, ['movers', 'lines'], {
|
||||
navara: async () => ({
|
||||
navaraMeshes: (await import('../../layers/cctv/render/navaraMeshes.js'))
|
||||
.createCctvNavaraMeshes,
|
||||
}),
|
||||
}),
|
||||
radio: display(createRadioDisplay, ['movers']),
|
||||
transit: display(createTransitDisplay, ['movers', 'lines']),
|
||||
bikeshare: display(createBikeshareDisplay, ['movers']),
|
||||
directions: display(createDirectionsDisplay, ['movers', 'ground']),
|
||||
'recent-imagery': display(createRecentImageryDisplay, ['ground', 'imagery']),
|
||||
'osm-places': display(createOsmPlacesDisplay, ['movers']),
|
||||
'ais-live-vessels': display(createVesselsDisplay, ['movers', 'lines']),
|
||||
'military-installations': display(createInstallationsDisplay, [
|
||||
'movers',
|
||||
'ground',
|
||||
]),
|
||||
// Global Context's display, with the Navara backend of its range pulses
|
||||
// (render/pulseDisc.js).
|
||||
'military-awareness': display(createAwarenessDisplay, ['lines'], {
|
||||
navara: async () => ({
|
||||
navaraPulses: (
|
||||
await import('../../layers/awareness/render/navaraPulses.js')
|
||||
).createAwarenessPulseDisc,
|
||||
}),
|
||||
}),
|
||||
// Wind and the weather rasters draw themselves on Cesium
|
||||
// (src/layers/wind, src/layers/weather); on Navara through its renderers.
|
||||
wind: navaraOnly(async () => {
|
||||
const { createWindNavaraRenderer } =
|
||||
await import('../../layers/wind/render/navara.js');
|
||||
return (engine) => createWindNavaraRenderer(engine, navaraNative(engine));
|
||||
}),
|
||||
'weather-radar': navaraOnly(navaraWeather),
|
||||
'weather-satellite': navaraOnly(navaraWeather),
|
||||
'weather-lightning': navaraOnly(navaraWeather),
|
||||
'weather-cyclones': display(createCyclonesDisplay, ['movers', 'ground']),
|
||||
// The open map overlays drape themselves: Cesium imagery on the viewer,
|
||||
// engine.maps.addOverlay on Navara (src/data/osmOverlays.js).
|
||||
'sea-marks': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
|
||||
'ski-pistes': perEngine({ cesium: DRAWS_ITSELF, navara: DRAWS_ITSELF }),
|
||||
'local-datacenters': display(createInfrastructureDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'local-dams': display(createInfrastructureDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'telegeography-submarine-cables': display(createCablesDisplay, [
|
||||
'movers',
|
||||
'lines',
|
||||
'ground',
|
||||
]),
|
||||
'local-firms': display(createFirmsDisplay, ['movers', 'ground', 'imagery']),
|
||||
});
|
||||
|
||||
// The other application layers still draw themselves on Cesium.
|
||||
registerLayerRenderers(
|
||||
'cesium',
|
||||
Object.fromEntries(
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => [
|
||||
id,
|
||||
CESIUM_LAYER_RENDERERS[id] ?? CESIUM_BUILTIN,
|
||||
]),
|
||||
),
|
||||
);
|
||||
/** The manifest entry of a layer, or null. */
|
||||
const entryOf = (manifest, layerId) =>
|
||||
Object.hasOwn(manifest, layerId) ? manifest[layerId] : null;
|
||||
|
||||
/** Whether `entry` shows on `engineKind` (see the module comment). */
|
||||
const entryAvailableOn = (entry, engineKind) => {
|
||||
if (!entry) return false;
|
||||
if (entry.needs) {
|
||||
const primitives = primitivesOf(engineKind);
|
||||
return entry.needs.every((name) => primitives.includes(name));
|
||||
}
|
||||
return Object.hasOwn(entry.engines, engineKind);
|
||||
};
|
||||
|
||||
/** The import() loader of a layer on an engine kind, or null. */
|
||||
const loaderOf = (entry, engineKind) => {
|
||||
const loader = entry.backends?.[engineKind] ?? entry.engines?.[engineKind];
|
||||
return typeof loader === 'function' ? loader : null;
|
||||
};
|
||||
|
||||
// What loadLayerRenderers resolved: engine kind → layer id → value (the
|
||||
// backend's options, or the engine-specific factory).
|
||||
const loaded = new Map();
|
||||
const loading = new Map();
|
||||
|
||||
/**
|
||||
* Loads every import() the manifest has for `engineKind`, once per page:
|
||||
* concurrent calls share the load, and after a failure the next call loads
|
||||
* what is still missing.
|
||||
* @param {string} engineKind One of ENGINE_KINDS.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export function loadLayerRenderers(engineKind) {
|
||||
if (!ENGINE_KINDS.includes(engineKind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${engineKind}`);
|
||||
if (loading.has(engineKind)) return loading.get(engineKind);
|
||||
if (!loaded.has(engineKind)) loaded.set(engineKind, new Map());
|
||||
const done = loaded.get(engineKind);
|
||||
const load = Promise.all(
|
||||
Object.entries(LAYER_MANIFEST).map(async ([id, entry]) => {
|
||||
const loader = loaderOf(entry, engineKind);
|
||||
if (loader && !done.has(id)) done.set(id, await loader());
|
||||
}),
|
||||
).then(
|
||||
() => {
|
||||
loading.delete(engineKind);
|
||||
},
|
||||
(error) => {
|
||||
loading.delete(engineKind);
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
loading.set(engineKind, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
// Renderers of the engine kinds a test brings (a stand-in engine of its
|
||||
// own kind): layer id → factory. The application's kinds use the manifest.
|
||||
const standIns = new Map();
|
||||
|
||||
/**
|
||||
* Test seam: renderer factories `{ [layerId]: (engine, options) => renderer }`
|
||||
* for an engine kind outside the application's (ENGINE_KINDS).
|
||||
*/
|
||||
export function registerLayerRenderers(engineKind, factories) {
|
||||
if (typeof engineKind !== 'string' || !engineKind)
|
||||
throw new TypeError('registerLayerRenderers needs an engine kind');
|
||||
if (ENGINE_KINDS.includes(engineKind))
|
||||
throw new Error(`The layer manifest draws ${engineKind}`);
|
||||
const map = standIns.get(engineKind) ?? new Map();
|
||||
standIns.set(engineKind, map);
|
||||
for (const [layerId, factory] of Object.entries(factories ?? {})) {
|
||||
if (typeof factory !== 'function')
|
||||
throw new TypeError(
|
||||
`Layer renderer for ${layerId} on ${engineKind} is not a factory`,
|
||||
);
|
||||
if (map.has(layerId))
|
||||
throw new Error(
|
||||
`Layer ${layerId} already has a renderer on ${engineKind}`,
|
||||
);
|
||||
map.set(layerId, factory);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer of a layer on `engine` (its display or engine-specific
|
||||
* renderer, built now), or null when the layer draws itself there or the
|
||||
* engine cannot show it. Throws when that engine's import()s are not
|
||||
* loaded yet (loadLayerRenderers).
|
||||
*/
|
||||
export function rendererFor(layerId, engine, options = {}) {
|
||||
const engineKind = engine?.kind;
|
||||
const standIn = standIns.get(engineKind)?.get(layerId);
|
||||
if (standIn) return standIn(engine, options);
|
||||
const entry = entryOf(LAYER_MANIFEST, layerId);
|
||||
if (!entryAvailableOn(entry, engineKind)) return null;
|
||||
const loader = loaderOf(entry, engineKind);
|
||||
const value = loader ? loaded.get(engineKind)?.get(layerId) : undefined;
|
||||
if (loader && value === undefined)
|
||||
throw new Error(
|
||||
`Layer ${layerId} on ${engineKind}: its renderer is not loaded (loadLayerRenderers)`,
|
||||
);
|
||||
if (entry.display) return entry.display(engine, { ...options, ...value });
|
||||
return typeof value === 'function' ? value(engine, options) : null;
|
||||
}
|
||||
|
||||
/** Whether the application can show a layer on an engine kind. */
|
||||
export const layerAvailableOn = (layerId, engineKind) =>
|
||||
entryAvailableOn(entryOf(LAYER_MANIFEST, layerId), engineKind);
|
||||
|
||||
/**
|
||||
* @typedef {object} LayerAvailability
|
||||
@@ -165,18 +364,19 @@ registerLayerRenderers(
|
||||
/**
|
||||
* The layers an engine kind can show (stable API; see the module comment).
|
||||
* @param {'cesium' | 'navara'} engineKind
|
||||
* @param {{ registry?: object, directory?: { id: string, name: string }[] }} [options]
|
||||
* Test seams: the registry and the layer directory.
|
||||
* @param {{ manifest?: object, directory?: { id: string, name: string }[] }} [options]
|
||||
* Test seams: the manifest (some of LAYER_MANIFEST's entries) and the
|
||||
* layer directory.
|
||||
* @returns {LayerAvailability}
|
||||
*/
|
||||
export function createLayerAvailability(
|
||||
engineKind,
|
||||
{ registry = defaultRegistry, directory = APPLICATION_LAYER_DIRECTORY } = {},
|
||||
{ manifest = LAYER_MANIFEST, directory = APPLICATION_LAYER_DIRECTORY } = {},
|
||||
) {
|
||||
if (!ENGINE_KINDS.has(engineKind))
|
||||
if (!ENGINE_KINDS.includes(engineKind))
|
||||
throw new TypeError(`Unknown engine kind for layers: ${engineKind}`);
|
||||
const isAvailable = (layerId) =>
|
||||
registry.layerAvailableOn(layerId, engineKind);
|
||||
entryAvailableOn(entryOf(manifest, layerId), engineKind);
|
||||
const rows = (wanted) =>
|
||||
directory
|
||||
.filter(({ id }) => isAvailable(id) === wanted)
|
||||
|
||||
@@ -1,95 +1,121 @@
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CESIUM_BUILTIN,
|
||||
CESIUM_LAYER_RENDERERS,
|
||||
rendererFor,
|
||||
CONTRACT_PRIMITIVES,
|
||||
DRAWS_ITSELF,
|
||||
LAYER_MANIFEST,
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
layerAvailableOn,
|
||||
registerLayerRenderers,
|
||||
rendererFor,
|
||||
} from './renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './directory.js';
|
||||
import { ENGINE_KINDS, engineCapabilities } from '../../globe/contract.js';
|
||||
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
|
||||
|
||||
test('a registry knows which engine renders which layer', () => {
|
||||
const registry = createRendererRegistry();
|
||||
const made = [];
|
||||
registry.register('navara', {
|
||||
flights: (engine, options) => {
|
||||
made.push([engine.kind, options]);
|
||||
return { destroy() {} };
|
||||
},
|
||||
});
|
||||
registry.register('cesium', {
|
||||
flights: CESIUM_BUILTIN,
|
||||
satellites: CESIUM_BUILTIN,
|
||||
});
|
||||
assert.equal(registry.layerAvailableOn('flights', 'navara'), true);
|
||||
assert.equal(registry.layerAvailableOn('satellites', 'navara'), false);
|
||||
assert.equal(registry.layerAvailableOn('satellites', 'cesium'), true);
|
||||
assert.equal(registry.layerAvailableOn('flights', 'other'), false);
|
||||
const renderer = registry.rendererFor(
|
||||
'flights',
|
||||
{ kind: 'navara' },
|
||||
{ tint: 1 },
|
||||
);
|
||||
assert.ok(renderer);
|
||||
assert.deepEqual(made, [['navara', { tint: 1 }]]);
|
||||
// A Cesium layer that still renders itself has no separate renderer.
|
||||
assert.equal(registry.rendererFor('flights', { kind: 'cesium' }), null);
|
||||
assert.equal(registry.rendererFor('satellites', { kind: 'navara' }), null);
|
||||
assert.deepEqual(registry.availableIds('cesium').sort(), [
|
||||
'flights',
|
||||
'satellites',
|
||||
]);
|
||||
assert.throws(() => registry.register('navara', { x: 42 }), TypeError);
|
||||
assert.throws(
|
||||
() => registry.register('navara', { flights: () => ({}) }),
|
||||
/already/,
|
||||
'one renderer per layer and engine',
|
||||
test('the manifest lists every directory layer, in its order', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(LAYER_MANIFEST),
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => id),
|
||||
);
|
||||
assert.ok(Object.isFrozen(LAYER_MANIFEST));
|
||||
for (const entry of Object.values(LAYER_MANIFEST))
|
||||
assert.ok(Object.isFrozen(entry));
|
||||
});
|
||||
|
||||
test('the default registry: every layer on Cesium, none yet on Navara', () => {
|
||||
for (const { id } of APPLICATION_LAYER_DIRECTORY) {
|
||||
assert.equal(layerAvailableOn(id, 'cesium'), true, id);
|
||||
assert.equal(layerAvailableOn(id, 'navara'), false, id);
|
||||
}
|
||||
test('the primitives a display needs are the contract primitives every engine has', () => {
|
||||
const capabilities = engineCapabilities(createFakeEngine());
|
||||
for (const name of CONTRACT_PRIMITIVES)
|
||||
assert.equal(capabilities[name], true, name);
|
||||
for (const [id, entry] of Object.entries(LAYER_MANIFEST))
|
||||
for (const name of entry.needs ?? [])
|
||||
assert.ok(CONTRACT_PRIMITIVES.includes(name), `${id} needs ${name}`);
|
||||
});
|
||||
|
||||
test('the layers with extracted renderers', () => {
|
||||
assert.deepEqual(Object.keys(CESIUM_LAYER_RENDERERS), [
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'gnss-integrity',
|
||||
'fire-perimeters',
|
||||
'drone-airspace',
|
||||
'osm-places',
|
||||
'alpr-cameras',
|
||||
'bikeshare',
|
||||
'radio',
|
||||
'transit',
|
||||
'local-firms',
|
||||
's2-ghostmaps',
|
||||
'weather-cyclones',
|
||||
'telegeography-submarine-cables',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'ais-live-vessels',
|
||||
'cctv',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'street-level',
|
||||
'traffic',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
]);
|
||||
test('every layer shows on both engines, as before', () => {
|
||||
for (const { id } of APPLICATION_LAYER_DIRECTORY)
|
||||
for (const kind of ENGINE_KINDS)
|
||||
assert.equal(layerAvailableOn(id, kind), true, `${id} on ${kind}`);
|
||||
assert.equal(layerAvailableOn('flights', 'vulkan'), false);
|
||||
assert.equal(layerAvailableOn('unknown', 'cesium'), false);
|
||||
assert.equal(layerAvailableOn('toString', 'cesium'), false);
|
||||
assert.equal(rendererFor('toString', createFakeEngine()), null);
|
||||
});
|
||||
|
||||
test('the layers drawn through one display, and the ones that draw without one', () => {
|
||||
const kinds = (filter) =>
|
||||
Object.entries(LAYER_MANIFEST)
|
||||
.filter(([, entry]) => filter(entry))
|
||||
.map(([id]) => id);
|
||||
assert.deepEqual(
|
||||
kinds((entry) => entry.display),
|
||||
[
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'drone-airspace',
|
||||
'alpr-cameras',
|
||||
'street-level',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
'osm-places',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'weather-cyclones',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
],
|
||||
);
|
||||
// Engine-specific options of a display: the Navara backends.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => Object.keys(entry.backends ?? {}).length),
|
||||
['cctv', 'military-awareness'],
|
||||
);
|
||||
assert.deepEqual(Object.keys(LAYER_MANIFEST.cctv.backends), ['navara']);
|
||||
// The layer that draws itself through the contract.
|
||||
assert.deepEqual(
|
||||
kinds((entry) => !entry.display && entry.needs),
|
||||
['wildlife-sightings'],
|
||||
);
|
||||
// Without a display: drawn by the layer itself or by an engine-specific
|
||||
// renderer.
|
||||
const engines = Object.fromEntries(
|
||||
kinds((entry) => entry.engines).map((id) => [
|
||||
id,
|
||||
Object.fromEntries(
|
||||
Object.entries(LAYER_MANIFEST[id].engines).map(([kind, drawing]) => [
|
||||
kind,
|
||||
drawing === DRAWS_ITSELF ? 'itself' : 'renderer',
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
const navaraRenderer = { cesium: 'itself', navara: 'renderer' };
|
||||
assert.deepEqual(engines, {
|
||||
'bhote-koshi-2026': navaraRenderer,
|
||||
'bhote-koshi-locator': navaraRenderer,
|
||||
wind: navaraRenderer,
|
||||
'weather-radar': navaraRenderer,
|
||||
'weather-satellite': navaraRenderer,
|
||||
'weather-lightning': navaraRenderer,
|
||||
'sea-marks': { cesium: 'itself', navara: 'itself' },
|
||||
'ski-pistes': { cesium: 'itself', navara: 'itself' },
|
||||
});
|
||||
});
|
||||
|
||||
test('the layers drawn through one display get the same factory on every engine', async () => {
|
||||
@@ -105,6 +131,7 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
bikeshare: ['bikeshare', 'createBikeshareDisplay'],
|
||||
radio: ['radio', 'createRadioDisplay'],
|
||||
transit: ['transit', 'createTransitDisplay'],
|
||||
cctv: ['cctv', 'createCctvDisplay'],
|
||||
earthquakes: ['earthquakes', 'createEarthquakesDisplay'],
|
||||
'air-quality': ['airQuality', 'createAirQualityDisplay'],
|
||||
'gnss-integrity': ['gnss', 'createGnssDisplay'],
|
||||
@@ -115,6 +142,7 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'local-datacenters': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'local-dams': ['infrastructure', 'createInfrastructureDisplay'],
|
||||
'military-installations': ['installations', 'createInstallationsDisplay'],
|
||||
'military-awareness': ['awareness', 'createAwarenessDisplay'],
|
||||
'street-level': ['streetLevel', 'createStreetLevelDisplay'],
|
||||
traffic: ['traffic', 'createTrafficDisplay'],
|
||||
directions: ['directions', 'createDirectionsDisplay'],
|
||||
@@ -125,30 +153,24 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
'createCablesDisplay',
|
||||
],
|
||||
};
|
||||
// The Navara mode registers the same modules' factories.
|
||||
const navaraMode = readFileSync(
|
||||
new URL('../navaraMode.js', import.meta.url),
|
||||
'utf8',
|
||||
assert.deepEqual(
|
||||
Object.keys(displays).sort(),
|
||||
Object.keys(LAYER_MANIFEST)
|
||||
.filter((id) => LAYER_MANIFEST[id].display)
|
||||
.sort(),
|
||||
);
|
||||
const drawAtlas = async () => ({ canvas: {}, cells: {} });
|
||||
for (const [id, [directory, name]] of Object.entries(displays)) {
|
||||
const factory = (
|
||||
await import(`../../layers/${directory}/render/display.js`)
|
||||
)[name];
|
||||
assert.equal(CESIUM_LAYER_RENDERERS[id], factory, id);
|
||||
assert.match(
|
||||
navaraMode,
|
||||
new RegExp(
|
||||
`'?${id}'?: async \\(\\) =>\\s*\\(await import\\('[^']+/render/display\\.js'\\)\\)\\s*\\.${name}\\b`,
|
||||
),
|
||||
id,
|
||||
);
|
||||
assert.equal(LAYER_MANIFEST[id].display, factory, id);
|
||||
// Each display's own tests build it with its services; the aircraft
|
||||
// family needs none beyond an atlas.
|
||||
if (!['flights', 'military', 'local-adsb'].includes(id)) continue;
|
||||
for (const kind of ['cesium', 'navara']) {
|
||||
for (const kind of ENGINE_KINDS) {
|
||||
const engine = createFakeEngine({ kind });
|
||||
const display = factory(engine, { drawAtlas });
|
||||
const display = rendererFor(id, engine, { drawAtlas });
|
||||
assert.equal(display.engine, engine, `${id} on ${kind}`);
|
||||
display.destroy();
|
||||
engine.destroy();
|
||||
@@ -156,21 +178,75 @@ test('the layers drawn through one display get the same factory on every engine'
|
||||
}
|
||||
});
|
||||
|
||||
test('a layer that draws itself has no renderer; an unknown engine none either', () => {
|
||||
const engine = createFakeEngine();
|
||||
for (const id of [
|
||||
'wildlife-sightings',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'bhote-koshi-2026',
|
||||
])
|
||||
assert.equal(rendererFor(id, engine), null, id);
|
||||
assert.equal(rendererFor('flights', null), null);
|
||||
assert.equal(rendererFor('flights', { kind: 'vulkan' }), null);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('an engine-specific renderer answers only after its engine loaded them', () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
// This test file never loads Navara's renderers.
|
||||
assert.throws(
|
||||
() => rendererFor('weather-radar', engine),
|
||||
/weather-radar on navara: its renderer is not loaded/,
|
||||
);
|
||||
assert.throws(() => rendererFor('cctv', engine), /not loaded/);
|
||||
engine.destroy();
|
||||
});
|
||||
|
||||
test('a test engine of its own kind brings its renderers', () => {
|
||||
const made = [];
|
||||
registerLayerRenderers('renderers-test', {
|
||||
flights: (engine, options) => {
|
||||
made.push([engine.kind, options]);
|
||||
return { destroy() {} };
|
||||
},
|
||||
});
|
||||
const engine = { kind: 'renderers-test' };
|
||||
assert.ok(rendererFor('flights', engine, { tint: 1 }));
|
||||
assert.deepEqual(made, [['renderers-test', { tint: 1 }]]);
|
||||
assert.equal(rendererFor('satellites', engine), null);
|
||||
assert.throws(
|
||||
() => registerLayerRenderers('renderers-test', { flights: () => ({}) }),
|
||||
/already/,
|
||||
'one renderer per layer and engine',
|
||||
);
|
||||
assert.throws(
|
||||
() => registerLayerRenderers('renderers-test', { x: 42 }),
|
||||
TypeError,
|
||||
);
|
||||
for (const kind of ENGINE_KINDS)
|
||||
assert.throws(
|
||||
() => registerLayerRenderers(kind, { flights: () => ({}) }),
|
||||
/manifest/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the layer availability API: engine, isAvailable, available, unavailable', () => {
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { flights: () => ({}) });
|
||||
const directory = [
|
||||
{ id: 'flights', name: 'Live Flights' },
|
||||
{ id: 'satellites', name: 'Satellites' },
|
||||
];
|
||||
const availability = createLayerAvailability('navara', {
|
||||
registry,
|
||||
manifest: { flights: LAYER_MANIFEST.flights },
|
||||
directory,
|
||||
});
|
||||
assert.equal(availability.engine, 'navara');
|
||||
assert.equal(availability.isAvailable('flights'), true);
|
||||
assert.equal(availability.isAvailable('satellites'), false);
|
||||
assert.equal(availability.isAvailable('unknown'), false);
|
||||
assert.equal(availability.isAvailable('toString'), false);
|
||||
assert.deepEqual(availability.available(), [
|
||||
{ id: 'flights', name: 'Live Flights' },
|
||||
]);
|
||||
@@ -181,8 +257,25 @@ test('the layer availability API: engine, isAvailable, available, unavailable',
|
||||
availability.unavailable().push({ id: 'x', name: 'x' });
|
||||
assert.equal(availability.unavailable().length, 1);
|
||||
assert.ok(Object.isFrozen(availability));
|
||||
const cesium = createLayerAvailability('cesium');
|
||||
assert.equal(cesium.unavailable().length, 0);
|
||||
assert.equal(cesium.available().length, APPLICATION_LAYER_DIRECTORY.length);
|
||||
// A layer without a display shows only on the engine kinds it lists.
|
||||
const cesiumOnly = { wind: { engines: { cesium: DRAWS_ITSELF } } };
|
||||
const wind = [{ id: 'wind', name: 'Wind' }];
|
||||
for (const [kind, shown] of [
|
||||
['cesium', true],
|
||||
['navara', false],
|
||||
])
|
||||
assert.equal(
|
||||
createLayerAvailability(kind, {
|
||||
manifest: cesiumOnly,
|
||||
directory: wind,
|
||||
}).isAvailable('wind'),
|
||||
shown,
|
||||
kind,
|
||||
);
|
||||
for (const kind of ENGINE_KINDS) {
|
||||
const all = createLayerAvailability(kind);
|
||||
assert.equal(all.unavailable().length, 0, kind);
|
||||
assert.equal(all.available().length, APPLICATION_LAYER_DIRECTORY.length);
|
||||
}
|
||||
assert.throws(() => createLayerAvailability('vulkan'), TypeError);
|
||||
});
|
||||
@@ -9,7 +9,7 @@ export function createApplicationLaunches({
|
||||
source,
|
||||
satellites,
|
||||
// Test seam: the display factory (default: the engine's, from the
|
||||
// registry: render/display.js on every engine).
|
||||
// manifest: render/display.js on every engine).
|
||||
renderer = {
|
||||
create: ({ engine }) => rendererFor('rocket-launches', engine),
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import * as layerState from '../../data/layerState.js';
|
||||
export function createApplicationSatellites({
|
||||
source,
|
||||
// Test seam: the renderer factory (default: the engine's, from the
|
||||
// registry: the satellites display on every engine).
|
||||
// manifest: the satellites display on every engine).
|
||||
renderer = {
|
||||
create: ({ engine = null } = {}) => rendererFor('satellites', engine),
|
||||
},
|
||||
|
||||
@@ -30,7 +30,7 @@ function defaultProviders(sources) {
|
||||
* Inject providers, or use the defaults. An explicit provider list replaces
|
||||
* them; an unused sources.mapillary is neither constructed nor validated,
|
||||
* including when the list is empty. The layer draws with the engine's
|
||||
* renderer from the registry (render/display.js on every engine).
|
||||
* renderer from the manifest (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationStreetLevel({
|
||||
sources = {},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { overlayHost as applicationOverlayHost } from './overlayHost.js';
|
||||
|
||||
/**
|
||||
* Wire cable source geometry to the application overlay host and map-stack
|
||||
* events; it draws with the engine's renderer from the registry
|
||||
* events; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationCables({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { rendererFor } from './renderers.js';
|
||||
|
||||
/**
|
||||
* Construct one layer using the application scene owners and a supplied
|
||||
* source; it draws with the engine's renderer from the registry
|
||||
* source; it draws with the engine's renderer from the manifest
|
||||
* (render/display.js on every engine).
|
||||
*/
|
||||
export function createApplicationTraffic({ source, surface }) {
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
* cell is the floor transit draws on, and a transit-only session warms its
|
||||
* own mesh floors. An earlier cut imported the legacy module-level singleton
|
||||
* instead and could not see either. It draws with the engine's renderer from
|
||||
* the registry (render/display.js on every engine).
|
||||
* the manifest (render/display.js on every engine).
|
||||
*
|
||||
* @param {{surface: object}} options
|
||||
* @returns {object}
|
||||
|
||||
@@ -107,8 +107,8 @@ test('the standalone startup graph can be imported before the Cesium SDK exists'
|
||||
return code ? { url: 'data:text/javascript,' + encodeURIComponent(code), shortCircuit: true } : next(specifier, context);
|
||||
}});
|
||||
await import('./src/standalone/application.js');
|
||||
const { registerNavaraLayerRenderers } = await import('./src/app/navaraMode.js');
|
||||
await registerNavaraLayerRenderers();
|
||||
const { loadLayerRenderers } = await import('./src/app/layers/renderers.js');
|
||||
await loadLayerRenderers('navara');
|
||||
const { createStandaloneCatalog } = await import('./src/standalone/catalog.js');
|
||||
const lifetime = new AbortController();
|
||||
const catalog = createStandaloneCatalog({ engineKind: 'navara', signal: lifetime.signal });
|
||||
|
||||
@@ -1,87 +1,39 @@
|
||||
// The layers the Navara mode draws (registerNavaraLayerRenderers): each W1
|
||||
// layer with a Navara renderer, registered once, through import().
|
||||
// The layers on Navara: every directory layer, with the manifest's Navara
|
||||
// renderers and backends loaded once, through import() (loadLayerRenderers).
|
||||
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerNavaraLayerRenderers } from './navaraMode.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
layerAvailableOn,
|
||||
loadLayerRenderers,
|
||||
rendererFor,
|
||||
} from './layers/renderers.js';
|
||||
import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
|
||||
test('the Navara mode registers migrated renderers once', async () => {
|
||||
assert.equal(layerAvailableOn('military', 'navara'), false);
|
||||
await Promise.all([
|
||||
registerNavaraLayerRenderers(),
|
||||
registerNavaraLayerRenderers(),
|
||||
]);
|
||||
await registerNavaraLayerRenderers();
|
||||
for (const id of [
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'rocket-launches',
|
||||
'satellites',
|
||||
'ais-live-vessels',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
'local-firms',
|
||||
'military-installations',
|
||||
'bikeshare',
|
||||
'radio',
|
||||
'alpr-cameras',
|
||||
'cctv',
|
||||
'transit',
|
||||
'recent-imagery',
|
||||
])
|
||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||
test('Navara loads its renderers once and shows every layer', async () => {
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
assert.throws(() => rendererFor('wind', engine), /not loaded/);
|
||||
const first = loadLayerRenderers('navara');
|
||||
assert.equal(loadLayerRenderers('navara'), first, 'one load at a time');
|
||||
await Promise.all([first, loadLayerRenderers('navara')]);
|
||||
await loadLayerRenderers('navara');
|
||||
assert.deepEqual(
|
||||
createLayerAvailability('navara')
|
||||
.available()
|
||||
.map(({ id }) => id),
|
||||
[
|
||||
'bhote-koshi-2026',
|
||||
'bhote-koshi-locator',
|
||||
'flights',
|
||||
'military',
|
||||
'local-adsb',
|
||||
'earthquakes',
|
||||
'air-quality',
|
||||
'fire-perimeters',
|
||||
'gnss-integrity',
|
||||
's2-ghostmaps',
|
||||
'drone-airspace',
|
||||
'wildlife-sightings',
|
||||
'alpr-cameras',
|
||||
'street-level',
|
||||
'satellites',
|
||||
'rocket-launches',
|
||||
'traffic',
|
||||
'cctv',
|
||||
'radio',
|
||||
'transit',
|
||||
'bikeshare',
|
||||
'directions',
|
||||
'recent-imagery',
|
||||
'osm-places',
|
||||
'ais-live-vessels',
|
||||
'military-installations',
|
||||
'military-awareness',
|
||||
'wind',
|
||||
'weather-radar',
|
||||
'weather-satellite',
|
||||
'weather-lightning',
|
||||
'weather-cyclones',
|
||||
'sea-marks',
|
||||
'ski-pistes',
|
||||
'local-datacenters',
|
||||
'local-dams',
|
||||
'telegeography-submarine-cables',
|
||||
'local-firms',
|
||||
],
|
||||
APPLICATION_LAYER_DIRECTORY.map(({ id }) => id),
|
||||
);
|
||||
assert.equal(layerAvailableOn('satellites', 'navara'), true);
|
||||
// Navara's own renderers answer now, with its view: a weather renderer
|
||||
// draws nothing until it has a frame.
|
||||
const viewed = { ...engine, extensions: { navara: {} } };
|
||||
const weather = rendererFor('weather-radar', viewed, {});
|
||||
assert.equal(typeof weather, 'object');
|
||||
// The layers that draw themselves still have none.
|
||||
for (const id of ['wildlife-sightings', 'sea-marks', 'ski-pistes'])
|
||||
assert.equal(rendererFor(id, engine), null, id);
|
||||
assert.throws(() => loadLayerRenderers('vulkan'), TypeError);
|
||||
// Cesium has nothing to load.
|
||||
await loadLayerRenderers('cesium');
|
||||
assert.equal(rendererFor('wind', createFakeEngine()), null);
|
||||
});
|
||||
+58
-303
@@ -1,42 +1,30 @@
|
||||
import { t } from '../i18n/index.js';
|
||||
// The experimental Navara mode (?engine=navara) of the application: its
|
||||
// scene and its viewerless tools. src/app/scene.js and src/app/tools.js load
|
||||
// this module with import() only in that mode, and it loads the Navara globe
|
||||
// the same way, so the Cesium-mode bundle carries none of it.
|
||||
// scene and the Navara side of the application tools. src/app/scene.js and
|
||||
// src/app/tools.js load this module with import() only in that mode, and it
|
||||
// loads the Navara globe the same way, so the Cesium-mode bundle carries
|
||||
// none of it.
|
||||
|
||||
import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js';
|
||||
import { isEmbeddedInline } from './embed.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
layerAvailableOn,
|
||||
registerLayerRenderers,
|
||||
} from './layers/renderers.js';
|
||||
import { createLayerAvailability } from './layers/renderers.js';
|
||||
import {
|
||||
governorRequestRender,
|
||||
installEngineRenderGovernor,
|
||||
uninstallRenderGovernor,
|
||||
} from '../renderGovernor.js';
|
||||
import { attachSessionVoice } from './sessionVoice.js';
|
||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||
import {
|
||||
attachNavaraAnnotations,
|
||||
attachNavaraDraw,
|
||||
attachNavaraGeoImport,
|
||||
attachNavaraScopeMask,
|
||||
} from './navaraTools.js';
|
||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
||||
|
||||
/**
|
||||
* An imported file's data source drawn on `engine` (UTILITIES ▸ Import); the
|
||||
* drawing loads on first use, like the layers' Navara renderers.
|
||||
* An imported file's data source drawn on `engine` with Navara's `views`
|
||||
* (UTILITIES ▸ Import); the drawing loads on first use, like the layers'
|
||||
* Navara renderers.
|
||||
*/
|
||||
async function drawImported(engine, dataSource) {
|
||||
async function drawImported(engine, views, dataSource) {
|
||||
const { drawImportedSource, measureImportImages } =
|
||||
await import('../layers/shared/render/navaraStory.js');
|
||||
const sizes = await measureImportImages(dataSource);
|
||||
return drawImportedSource(engine, engine.extensions.navara, dataSource, {
|
||||
sizes,
|
||||
});
|
||||
return drawImportedSource(engine, views, dataSource, { sizes });
|
||||
}
|
||||
|
||||
/** The Navara globe module, loaded on demand. */
|
||||
@@ -45,8 +33,8 @@ const loadNavaraGlobeModule = async () =>
|
||||
|
||||
/**
|
||||
* The shell's notice in the experimental Navara mode (label and detail): the
|
||||
* data layers this engine shows (its catalog, from the renderer registry)
|
||||
* and what it does not have.
|
||||
* data layers this engine shows (its catalog, from the layer manifest) and
|
||||
* what it does not have.
|
||||
* @param {import('./layers/renderers.js').LayerAvailability} availability
|
||||
*/
|
||||
export function navaraModeNotice(availability) {
|
||||
@@ -88,169 +76,6 @@ const NAVARA_CREDIT_KEYS = new Set([
|
||||
'radio-browser',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Registers the Navara layer renderers (once per page). Each loads only
|
||||
* here, through import(), so Cesium mode never fetches them.
|
||||
*/
|
||||
export async function registerNavaraLayerRenderers() {
|
||||
const loaders = {
|
||||
'recent-imagery': async () =>
|
||||
(await import('../layers/recentImagery/render/display.js'))
|
||||
.createRecentImageryDisplay,
|
||||
transit: async () =>
|
||||
(await import('../layers/transit/render/display.js'))
|
||||
.createTransitDisplay,
|
||||
// CCTV's display, with the Navara backend of its plane and volume.
|
||||
cctv: async () => {
|
||||
const [{ createCctvDisplay }, { createCctvNavaraMeshes }] =
|
||||
await Promise.all([
|
||||
import('../layers/cctv/render/display.js'),
|
||||
import('../layers/cctv/render/navaraMeshes.js'),
|
||||
]);
|
||||
return (engine, options) =>
|
||||
createCctvDisplay(engine, {
|
||||
...options,
|
||||
navaraMeshes: createCctvNavaraMeshes,
|
||||
});
|
||||
},
|
||||
'alpr-cameras': async () =>
|
||||
(await import('../layers/alpr/render/display.js')).createAlprDisplay,
|
||||
radio: async () =>
|
||||
(await import('../layers/radio/render/display.js')).createRadioDisplay,
|
||||
bikeshare: async () =>
|
||||
(await import('../layers/bikeshare/render/display.js'))
|
||||
.createBikeshareDisplay,
|
||||
...Object.fromEntries(
|
||||
['weather-radar', 'weather-satellite', 'weather-lightning'].map((id) => [
|
||||
id,
|
||||
async () => {
|
||||
const { createWeatherNavaraRenderer } =
|
||||
await import('../layers/weather/render/navara.js');
|
||||
return (engine, options) =>
|
||||
createWeatherNavaraRenderer(
|
||||
engine,
|
||||
engine.extensions.navara,
|
||||
options,
|
||||
);
|
||||
},
|
||||
]),
|
||||
),
|
||||
'bhote-koshi-2026': async () => {
|
||||
const { createNepalEventDisplay } =
|
||||
await import('../layers/bhoteKoshi/render/navara.js');
|
||||
return (engine) =>
|
||||
createNepalEventDisplay(engine, engine.extensions.navara);
|
||||
},
|
||||
'bhote-koshi-locator': async () => {
|
||||
const { drawStorySource } =
|
||||
await import('../layers/shared/render/navaraStory.js');
|
||||
return (engine, { data }) =>
|
||||
drawStorySource(engine, engine.extensions.navara, data);
|
||||
},
|
||||
'street-level': async () =>
|
||||
(await import('../layers/streetLevel/render/display.js'))
|
||||
.createStreetLevelDisplay,
|
||||
traffic: async () =>
|
||||
(await import('../layers/traffic/render/display.js'))
|
||||
.createTrafficDisplay,
|
||||
directions: async () =>
|
||||
(await import('../layers/directions/render/display.js'))
|
||||
.createDirectionsDisplay,
|
||||
'telegeography-submarine-cables': async () =>
|
||||
(await import('../layers/submarineCables/render/display.js'))
|
||||
.createCablesDisplay,
|
||||
wind: async () => {
|
||||
const { createWindNavaraRenderer } =
|
||||
await import('../layers/wind/render/navara.js');
|
||||
return (engine) =>
|
||||
createWindNavaraRenderer(engine, engine.extensions.navara);
|
||||
},
|
||||
'weather-cyclones': async () =>
|
||||
(await import('../layers/cyclones/render/display.js'))
|
||||
.createCyclonesDisplay,
|
||||
'local-datacenters': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
'local-dams': async () =>
|
||||
(await import('../layers/infrastructure/render/display.js'))
|
||||
.createInfrastructureDisplay,
|
||||
's2-ghostmaps': async () =>
|
||||
(await import('../layers/ghostMaps/render/display.js'))
|
||||
.createGhostMapsDisplay,
|
||||
// Global Context's display, with the Navara backend of its range pulses.
|
||||
'military-awareness': async () => {
|
||||
const [{ createAwarenessDisplay }, { createAwarenessPulseDisc }] =
|
||||
await Promise.all([
|
||||
import('../layers/awareness/render/display.js'),
|
||||
import('../layers/awareness/render/navaraPulses.js'),
|
||||
]);
|
||||
return (engine) =>
|
||||
createAwarenessDisplay(engine, {
|
||||
navaraPulses: createAwarenessPulseDisc,
|
||||
});
|
||||
},
|
||||
'military-installations': async () =>
|
||||
(await import('../layers/installations/render/display.js'))
|
||||
.createInstallationsDisplay,
|
||||
'local-firms': async () =>
|
||||
(await import('../layers/firms/render/display.js')).createFirmsDisplay,
|
||||
'osm-places': async () =>
|
||||
(await import('../layers/osmPlaces/render/display.js'))
|
||||
.createOsmPlacesDisplay,
|
||||
// Draws itself through the globe contract (engine.movers, engine.lines).
|
||||
'wildlife-sightings': async () => () => null,
|
||||
'drone-airspace': async () =>
|
||||
(await import('../layers/droneAirspace/render/display.js'))
|
||||
.createDroneAirspaceDisplay,
|
||||
'fire-perimeters': async () =>
|
||||
(await import('../layers/perimeters/render/display.js'))
|
||||
.createPerimetersDisplay,
|
||||
'gnss-integrity': async () =>
|
||||
(await import('../layers/gnss/render/display.js')).createGnssDisplay,
|
||||
'air-quality': async () =>
|
||||
(await import('../layers/airQuality/render/display.js'))
|
||||
.createAirQualityDisplay,
|
||||
earthquakes: async () =>
|
||||
(await import('../layers/earthquakes/render/display.js'))
|
||||
.createEarthquakesDisplay,
|
||||
// The same displays as on Cesium (renderers.js).
|
||||
'rocket-launches': async () =>
|
||||
(await import('../layers/launches/render/display.js'))
|
||||
.createLaunchesDisplay,
|
||||
satellites: async () =>
|
||||
(await import('../layers/satellites/render/display.js'))
|
||||
.createSatellitesDisplay,
|
||||
// The aircraft family: the same displays as on Cesium (renderers.js).
|
||||
flights: async () =>
|
||||
(await import('../layers/flights/render/display.js'))
|
||||
.createFlightsDisplay,
|
||||
military: async () =>
|
||||
(await import('../layers/military/render/display.js'))
|
||||
.createMilitaryDisplay,
|
||||
'local-adsb': async () =>
|
||||
(await import('../layers/localAdsb/render/display.js'))
|
||||
.createLocalAdsbDisplay,
|
||||
'ais-live-vessels': async () =>
|
||||
(await import('../layers/vessels/render/display.js'))
|
||||
.createVesselsDisplay,
|
||||
// The open map overlays drape themselves through engine.maps.addOverlay.
|
||||
'sea-marks': async () => () => null,
|
||||
'ski-pistes': async () => () => null,
|
||||
};
|
||||
const missing = Object.keys(loaders).filter(
|
||||
(id) => !layerAvailableOn(id, 'navara'),
|
||||
);
|
||||
if (!missing.length) return;
|
||||
const factories = await Promise.all(missing.map((id) => loaders[id]()));
|
||||
// A concurrent call may have registered some meanwhile.
|
||||
const fresh = Object.fromEntries(
|
||||
missing
|
||||
.map((id, index) => [id, factories[index]])
|
||||
.filter(([id]) => !layerAvailableOn(id, 'navara')),
|
||||
);
|
||||
if (Object.keys(fresh).length) registerLayerRenderers('navara', fresh);
|
||||
}
|
||||
|
||||
/**
|
||||
* The scene on the experimental Navara engine: no viewer and no Cesium
|
||||
* objects. Navara builds the map sources from the controller's descriptions
|
||||
@@ -281,10 +106,6 @@ export async function createNavaraScene({
|
||||
});
|
||||
defer(() => globe.destroy());
|
||||
const { engine } = globe;
|
||||
// The layers drawn on Navara (their render/navara.js), before the catalog
|
||||
// asks the registry which layers this engine shows.
|
||||
await registerNavaraLayerRenderers();
|
||||
signal.throwIfAborted();
|
||||
// The tools install the governor on this engine; it goes before the engine.
|
||||
defer(() => uninstallRenderGovernor(engine));
|
||||
signal.throwIfAborted();
|
||||
@@ -343,116 +164,50 @@ export async function createNavaraScene({
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools without a viewer (the experimental Navara engine), attached to
|
||||
* one Navara scene: the render governor, hidden-tab handling, imagery box,
|
||||
* engine notice, annotations, Draw, Import, scope mask and voice. The shared
|
||||
* tools keep their session state across engines. Returns the annotation
|
||||
* board (null without a Navara view) and voice commands.
|
||||
* The Navara tool backend (src/app/tools.js) of one Navara scene: where the
|
||||
* annotation board, Draw and Import draw (on the engine and Navara's view;
|
||||
* null without the view), the Recent Imagery panel's globe, the render
|
||||
* governor on the engine, and this engine's own parts: the daylight of a
|
||||
* restored view and the engine notice.
|
||||
*/
|
||||
export function attachViewerlessTools(
|
||||
{ engine, mapStackController = null },
|
||||
{
|
||||
data,
|
||||
operations,
|
||||
placeSearch,
|
||||
voice = {},
|
||||
sceneDirector,
|
||||
layerSession = null,
|
||||
styleManager,
|
||||
},
|
||||
{ signal, defer },
|
||||
) {
|
||||
const { dataManager } = data;
|
||||
void styleManager.initialRestorePromise?.then(() => {
|
||||
if (!signal.aborted) engine.extensions.navara.alignDaylightToRestoredView();
|
||||
});
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
const tool = initImageryBoxTool({
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
onPreview: (box) => recentImagery.setBoxPreview(box),
|
||||
onCancel: (reason, message, box) => {
|
||||
if (message) recentImagery.reportBoxRefusal(message, box);
|
||||
},
|
||||
onActive: (active) => recentImagery.setToolActive(active),
|
||||
onEscape: () => recentImagery.clearPreview(),
|
||||
});
|
||||
data.presentation.attachRecentImagery((container) =>
|
||||
createRecentImageryPanel({
|
||||
container,
|
||||
engine,
|
||||
layer: recentImagery,
|
||||
tool,
|
||||
}),
|
||||
);
|
||||
const handle = { layer: recentImagery, tool };
|
||||
window.__gevRecentImagery = handle;
|
||||
defer(() => {
|
||||
if (window.__gevRecentImagery === handle)
|
||||
delete window.__gevRecentImagery;
|
||||
data.presentation.attachRecentImagery(null);
|
||||
tool.destroy();
|
||||
});
|
||||
}
|
||||
// Holds drive engine.render.setContinuous; requests go to the engine. A
|
||||
// parked engine uninstalls it (createNavaraScene suspend).
|
||||
installEngineRenderGovernor(engine);
|
||||
const syncVisibility = () => {
|
||||
if (document.hidden && !isEmbeddedInline()) return;
|
||||
data.presentation.flushVisible();
|
||||
governorRequestRender('visibility-restore');
|
||||
};
|
||||
document.addEventListener('visibilitychange', syncVisibility);
|
||||
defer(() => document.removeEventListener('visibilitychange', syncVisibility));
|
||||
const notice = navaraModeNotice(
|
||||
data.catalog?.availability ?? createLayerAvailability('navara'),
|
||||
);
|
||||
styleManager._showGlobalStatusNotice?.(notice.label, {
|
||||
state: 'retry',
|
||||
detail: notice.detail,
|
||||
persistent: true,
|
||||
});
|
||||
// The notice is this engine's: it goes when the engine does.
|
||||
defer(() => styleManager._clearPersistentStatusNotice?.());
|
||||
// The page session's annotation board, Draw and Import on this engine
|
||||
// (src/app/navaraTools.js).
|
||||
const tools = {
|
||||
engine,
|
||||
views: engine.extensions?.navara,
|
||||
mapStackController,
|
||||
};
|
||||
const context = {
|
||||
operations,
|
||||
placeSearch,
|
||||
layerSession,
|
||||
styleManager,
|
||||
drawImported,
|
||||
};
|
||||
const annotations = attachNavaraAnnotations(tools, context, {
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
attachNavaraDraw(tools, { ...context, annotations }, { signal, defer });
|
||||
attachNavaraGeoImport(tools, context, { signal, defer });
|
||||
attachNavaraScopeMask(tools, context, { signal, defer });
|
||||
// Voice uses the engine and shared session services; each action checks
|
||||
// the capabilities it needs on the attached scene.
|
||||
const voiceCommands = attachSessionVoice({
|
||||
layerSession,
|
||||
voice,
|
||||
operations,
|
||||
placeSearch,
|
||||
dataManager,
|
||||
scene: {
|
||||
viewer: null,
|
||||
engine,
|
||||
styleManager,
|
||||
sceneDirector,
|
||||
annotations,
|
||||
export function navaraToolBackend({ engine }) {
|
||||
const views = engine.extensions?.navara ?? null;
|
||||
const drawn = Boolean(views?.view);
|
||||
return {
|
||||
board: () =>
|
||||
drawn
|
||||
? { engine, renderer: createNavaraAnnotationRenderer(engine, views) }
|
||||
: null,
|
||||
// The session's tool on the engine's input and preview
|
||||
// (src/annotations/drawInputNavara.js).
|
||||
draw: () => ({ surface: navaraDrawSurface(engine) }),
|
||||
// The session's files drawn by src/layers/shared/render/navaraStory.js;
|
||||
// what Navara leaves out of them is reported in the control's hint.
|
||||
geoImport: () =>
|
||||
drawn
|
||||
? {
|
||||
engine,
|
||||
draw: (dataSource) => drawImported(engine, views, dataSource),
|
||||
}
|
||||
: null,
|
||||
recentImagery: () => ({ engine }),
|
||||
// Holds drive engine.render.setContinuous; requests go to the engine. A
|
||||
// parked engine uninstalls it (createNavaraScene suspend).
|
||||
installGovernor: () => installEngineRenderGovernor(engine),
|
||||
attach({ data, styleManager }, { signal, defer }) {
|
||||
void styleManager.initialRestorePromise?.then(() => {
|
||||
if (!signal.aborted) views.alignDaylightToRestoredView();
|
||||
});
|
||||
const notice = navaraModeNotice(
|
||||
data.catalog?.availability ?? createLayerAvailability(engine.kind),
|
||||
);
|
||||
styleManager._showGlobalStatusNotice?.(notice.label, {
|
||||
state: 'retry',
|
||||
detail: notice.detail,
|
||||
persistent: true,
|
||||
});
|
||||
// The notice is this engine's: it goes when the engine does.
|
||||
defer(() => styleManager._clearPersistentStatusNotice?.());
|
||||
},
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
return { annotations, voiceCommands };
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,8 @@ import '../testSupport/registerDom.mjs';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { attachNavaraAnnotations } from './navaraTools.js';
|
||||
import { navaraToolBackend } from './navaraMode.js';
|
||||
import { sessionAnnotations } from './sessionTools.js';
|
||||
|
||||
function fakeViews() {
|
||||
const meshes = [];
|
||||
@@ -37,22 +38,25 @@ function fakeViews() {
|
||||
};
|
||||
}
|
||||
|
||||
test('Navara attaches the page session board and its switch takes only the drawing', async () => {
|
||||
test('Navara draws the page session board and its switch takes only the drawing', async () => {
|
||||
const lifetime = new AbortController();
|
||||
const layerSession = { lifetime };
|
||||
const engine = createFakeEngine({ kind: 'navara' });
|
||||
const { views, meshes } = fakeViews();
|
||||
const context = {
|
||||
layerSession,
|
||||
placeSearch: null,
|
||||
operations: { annotationResolver: null },
|
||||
const engine = {
|
||||
...createFakeEngine({ kind: 'navara' }),
|
||||
extensions: { navara: views },
|
||||
};
|
||||
// As src/app/tools.js attaches the board on the backend's scene.
|
||||
const attach = () => {
|
||||
const cleanups = [];
|
||||
const board = attachNavaraAnnotations({ engine, views }, context, {
|
||||
const board = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch: null,
|
||||
resolver: null,
|
||||
defer: (cleanup) => cleanups.push(cleanup),
|
||||
});
|
||||
return { board, detach: () => cleanups.reverse().forEach((c) => c()) };
|
||||
board.attachScene(navaraToolBackend({ engine }).board());
|
||||
return { board, detach: () => board.detachScene() };
|
||||
};
|
||||
const first = attach();
|
||||
assert.ok(first.board);
|
||||
@@ -85,11 +89,12 @@ test('Navara attaches the page session board and its switch takes only the drawi
|
||||
second.detach();
|
||||
lifetime.abort();
|
||||
assert.equal(window.__gevAnnotations, undefined);
|
||||
// Without the Navara view there is nothing to draw on.
|
||||
assert.equal(
|
||||
attachNavaraAnnotations({ engine, views: undefined }, context, {
|
||||
defer() {},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
// Without the Navara view there is nothing to draw on: no board, no
|
||||
// Import.
|
||||
const viewless = navaraToolBackend({
|
||||
engine: createFakeEngine({ kind: 'navara' }),
|
||||
});
|
||||
assert.equal(viewless.board(), null);
|
||||
assert.equal(viewless.geoImport(), null);
|
||||
assert.deepEqual(Object.keys(viewless.recentImagery()), ['engine']);
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
// The page session's annotation board, Draw and Import on the experimental
|
||||
// Navara engine (src/app/sessionTools.js holds the tools themselves). Loaded
|
||||
// with src/app/navaraMode.js, so the Cesium-mode bundle carries none of it.
|
||||
|
||||
import { createNavaraAnnotationRenderer } from '../annotations/navaraAnnotationRenderer.js';
|
||||
import { navaraDrawSurface } from '../annotations/drawInputNavara.js';
|
||||
import { destroyScopeMask, installScopeMask } from '../scopeMask.js';
|
||||
import {
|
||||
sessionAnnotations,
|
||||
sessionDrawTool,
|
||||
sessionGeoImport,
|
||||
} from './sessionTools.js';
|
||||
|
||||
/**
|
||||
* The annotation board drawn on this Navara scene: draped areas and routes
|
||||
* on the engine, every other mark on the shared screen board. The board
|
||||
* belongs to the page session; an engine switch takes only its drawing off
|
||||
* this engine. Returns the board (null without the Navara view).
|
||||
* @param {{ engine: object, views: object }} scene `views`: the Navara view
|
||||
* and its classes (src/app/navaraMode.js).
|
||||
*/
|
||||
export function attachNavaraAnnotations(
|
||||
{ engine, views },
|
||||
{ layerSession, placeSearch, operations },
|
||||
{ defer },
|
||||
) {
|
||||
if (!views?.view) return null;
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({
|
||||
engine,
|
||||
renderer: createNavaraAnnotationRenderer(engine, views),
|
||||
});
|
||||
defer(() => annotations.detachScene());
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Draw on this Navara scene: the session's tool on the engine's
|
||||
* input and preview (src/annotations/drawInputNavara.js). A switch ends an
|
||||
* open sketch and keeps the chosen shape and colour. Returns the tool (null
|
||||
* on a page without it or without the board).
|
||||
*/
|
||||
export function attachNavaraDraw(
|
||||
{ engine },
|
||||
{ layerSession, annotations, styleManager },
|
||||
{ defer },
|
||||
) {
|
||||
if (!annotations) return null;
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({
|
||||
surface: navaraDrawSurface(engine),
|
||||
navigation: styleManager,
|
||||
});
|
||||
defer(() => drawTool?.detachScene());
|
||||
return drawTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Import on this Navara scene: the session's files drawn by
|
||||
* `drawImported(engine, dataSource)` (src/layers/shared/render/navaraStory.js,
|
||||
* loaded by src/app/navaraMode.js), what Navara leaves out of them reported
|
||||
* in the control's hint; drops and camera gestures on this engine's canvas.
|
||||
* Returns the control (null on a page without it or without the view).
|
||||
*/
|
||||
export function attachNavaraGeoImport(
|
||||
{ engine, views, mapStackController },
|
||||
{ layerSession, styleManager, operations, drawImported },
|
||||
{ defer },
|
||||
) {
|
||||
if (!views?.view || typeof drawImported !== 'function') return null;
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
engine,
|
||||
draw: (dataSource) => drawImported(engine, dataSource),
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
return geoFileImport;
|
||||
}
|
||||
|
||||
/**
|
||||
* The DISPLAY scope mask over this Navara scene: the same canvas as on
|
||||
* Cesium, in the engine's container, its terminus following the engine
|
||||
* camera's height. It goes with the engine.
|
||||
*/
|
||||
export function attachNavaraScopeMask({ engine }, _context, { defer }) {
|
||||
installScopeMask(engine);
|
||||
defer(() => destroyScopeMask());
|
||||
}
|
||||
@@ -3,10 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import * as geo from '../geo/index.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import { createQaProbe } from './qaProbe.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
} from './layers/renderers.js';
|
||||
import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js';
|
||||
|
||||
function fakeManager(modules) {
|
||||
const enabled = new Set();
|
||||
@@ -33,12 +30,12 @@ test('the probe lists, toggles and reads layers through the engine', async () =>
|
||||
getTrackedInfo: () => ({ icao24: 'ae01ce' }),
|
||||
};
|
||||
const manager = fakeManager([flights]);
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { flights: () => ({}) });
|
||||
const probe = createQaProbe({
|
||||
engine,
|
||||
dataManager: manager,
|
||||
availability: createLayerAvailability('navara', { registry }),
|
||||
availability: createLayerAvailability('navara', {
|
||||
manifest: { flights: LAYER_MANIFEST.flights },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(probe.list(), [
|
||||
{ id: 'flights', name: 'Live Flights', enabled: false, available: true },
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// The page session's tools that outlive a globe engine: the annotation board,
|
||||
// UTILITIES ▸ Draw and UTILITIES ▸ Import. Each is created once per page
|
||||
// session (destroyed with the page) and attaches its drawing to whichever
|
||||
// engine the page shows: src/app/tools.js on a Cesium viewer,
|
||||
// src/app/navaraTools.js on Navara.
|
||||
// engine the page shows (src/app/tools.js; on Navara through its tool
|
||||
// backend, src/app/navaraMode.js).
|
||||
|
||||
import { createSessionAnnotations } from '../annotations/index.js';
|
||||
import { createSessionDrawTool } from '../annotations/drawTool.js';
|
||||
|
||||
+217
-98
@@ -35,9 +35,10 @@ import {
|
||||
* handle belong to the application; the tools bound to one globe (render
|
||||
* governor, release trim, scope mask, imagery box, tracking and visibility
|
||||
* listeners, embedded views, the debug handle's globe fields) attach to each
|
||||
* engine the page shows (src/app/sceneParts.js). Navara attaches the shared
|
||||
* annotation board, Draw, Import, scope mask and voice through its engine
|
||||
* adapters (src/app/navaraMode.js); the Director follows the attached engine.
|
||||
* engine the page shows (src/app/sceneParts.js), one tree for every engine
|
||||
* (attachEngineTools): each engine kind's tool backend says where the shared
|
||||
* tools draw on it, and the tools that still drive the Cesium viewer attach
|
||||
* only where there is one. The Director follows the attached engine.
|
||||
*/
|
||||
export async function createApplicationTools({
|
||||
scene,
|
||||
@@ -99,9 +100,10 @@ export async function createApplicationTools({
|
||||
// Cesium only for now: it reads Cesium's camera (positionWC, its
|
||||
// running flight).
|
||||
attachScene(next, { defer: deferScene }) {
|
||||
if (!next.viewer) return;
|
||||
const viewer = cesiumViewerOf(next.engine);
|
||||
if (!viewer) return;
|
||||
deferScene(
|
||||
installGlobeInViewRecovery(next.viewer, {
|
||||
installGlobeInViewRecovery(viewer, {
|
||||
// The scope mask hides everything outside its keyhole.
|
||||
keyholeRadius: () =>
|
||||
isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0,
|
||||
@@ -175,11 +177,7 @@ export async function createApplicationTools({
|
||||
{
|
||||
name: 'tools',
|
||||
async attachScene(next, attachment) {
|
||||
const tools = next.viewer
|
||||
? attachViewerTools(next, context, attachment)
|
||||
: await import('./navaraMode.js').then(({ attachViewerlessTools }) =>
|
||||
attachViewerlessTools(next, context, attachment),
|
||||
);
|
||||
const tools = await attachEngineTools(next, context, attachment);
|
||||
attached.annotations = tools.annotations;
|
||||
attached.voiceCommands = tools.voiceCommands;
|
||||
attachment.defer(() => {
|
||||
@@ -250,8 +248,103 @@ export async function createApplicationTools({
|
||||
};
|
||||
}
|
||||
|
||||
/** The tools bound to a Cesium viewer. Returns its annotations and voice. */
|
||||
function attachViewerTools(
|
||||
/**
|
||||
* The Cesium viewer under an engine, or null on an engine without one: the
|
||||
* one place the tools read it.
|
||||
*/
|
||||
const cesiumViewerOf = (engine) => engine?.extensions?.cesium?.viewer ?? null;
|
||||
|
||||
/**
|
||||
* The tool backend of each engine kind (see attachEngineTools). Cesium's
|
||||
* holds the tools that still drive its viewer, so they stay absent on
|
||||
* other engines; Navara's loads with its mode (src/app/navaraMode.js), so
|
||||
* the Cesium-mode bundle carries none of it.
|
||||
*/
|
||||
const TOOL_BACKENDS = Object.freeze({
|
||||
cesium: async () => cesiumToolBackend,
|
||||
navara: async () => (await import('./navaraMode.js')).navaraToolBackend,
|
||||
});
|
||||
|
||||
/** The Cesium tool backend: the shared tools and its own on the viewer. */
|
||||
function cesiumToolBackend({ engine, tileset }) {
|
||||
const viewer = cesiumViewerOf(engine);
|
||||
return {
|
||||
viewer,
|
||||
board: () => ({ viewer, engine, tileset }),
|
||||
draw: () => ({ viewer, engine }),
|
||||
geoImport: () => ({ viewer, engine }),
|
||||
// The tileset lets the layer drape while the globe is hidden.
|
||||
recentImagery(layer) {
|
||||
layer.attachTileset(tileset);
|
||||
return { viewer };
|
||||
},
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. (perf wave 2) A parked viewer uninstalls
|
||||
// it (src/app/scene.js suspend).
|
||||
installGovernor: () => installRenderGovernor(viewer),
|
||||
attach({ dataManager }, { defer }) {
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its
|
||||
// entities) alive in reused per-pass arrays; trim them after each
|
||||
// disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
// The follow camera recomputes the tracked target's dead-reckon
|
||||
// position every frame — tracking anything is a per-frame animation.
|
||||
// (perf wave 2)
|
||||
const removeTrackingListener =
|
||||
viewer.trackedEntityChanged.addEventListener(() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
defer(() => {
|
||||
removeTrackingListener();
|
||||
releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
},
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU.
|
||||
suspend(hidden, { cockpitCloudEffects }) {
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
},
|
||||
attachViews(
|
||||
{ styleManager, dataManager, voiceCommands },
|
||||
{ signal, defer },
|
||||
) {
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools of one globe, on any engine: the annotation board, Draw,
|
||||
* Import, the Recent Imagery box and panel, the render governor, the scope
|
||||
* mask, hidden-tab handling and voice, and the engine's own tools. The
|
||||
* engine kind's backend (TOOL_BACKENDS) says where the shared tools draw:
|
||||
* `board()`, `draw()` and `geoImport()` give their attachScene arguments
|
||||
* (null: not on this engine), `recentImagery(layer)` the panel's globe,
|
||||
* `installGovernor()` the governor's; `attach(context, attachment)` adds
|
||||
* the engine's own tools, `suspend(hidden, controls)` its hidden-tab
|
||||
* handling and `attachViews(context, attachment)` its embedded views. The
|
||||
* shared tools keep their session state across engines. Returns the
|
||||
* annotation board (null without one) and voice commands.
|
||||
*/
|
||||
async function attachEngineTools(
|
||||
scene,
|
||||
{
|
||||
controls,
|
||||
@@ -266,48 +359,26 @@ function attachViewerTools(
|
||||
},
|
||||
{ signal, defer },
|
||||
) {
|
||||
const { viewer, engine, tileset, mapStackController } = scene;
|
||||
// The annotation board belongs to the page session when there is one: an
|
||||
// engine switch only takes its drawing off this viewer.
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
const { engine, mapStackController } = scene;
|
||||
const backend = (await TOOL_BACKENDS[engine.kind]())(scene);
|
||||
// The page session's board, Draw and Import (src/app/sessionTools.js):
|
||||
// an engine switch only takes their drawing off this globe.
|
||||
const annotations = attachBoard(
|
||||
backend.board(),
|
||||
{ layerSession, placeSearch, operations },
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene({ viewer, engine, tileset });
|
||||
defer(() => annotations.detachScene());
|
||||
// UTILITIES ▸ Draw on this viewer (src/app/sessionTools.js): the tool
|
||||
// claims this viewer's pointer while a sketch is open.
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
);
|
||||
attachDraw(backend.draw, { layerSession, annotations, styleManager }, defer);
|
||||
attachGeoImport(
|
||||
backend.geoImport(),
|
||||
{ layerSession, styleManager, operations, mapStackController },
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({ viewer, engine, navigation: styleManager });
|
||||
defer(() => drawTool?.detachScene());
|
||||
// UTILITIES ▸ Import on this viewer: the next Cesium viewer reads the
|
||||
// session's files again. The drop listeners belong to this viewer.
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
viewer,
|
||||
engine,
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
);
|
||||
// DATA ▸ Recent Imagery: the box tool claims the pointer like Draw and the
|
||||
// panel lives on the right rail, so both belong to this globe. The tileset
|
||||
// lets the layer drape while the globe is hidden.
|
||||
// panel lives on the right rail, so both belong to this globe.
|
||||
const recentImagery = dataManager.layers.get('recent-imagery')?.module;
|
||||
if (recentImagery) {
|
||||
recentImagery.attachTileset(tileset);
|
||||
const panelGlobe = backend.recentImagery(recentImagery);
|
||||
const imageryBoxTool = initImageryBoxTool({
|
||||
engine,
|
||||
onBox: (box) => recentImagery.setBox(box),
|
||||
@@ -325,7 +396,7 @@ function attachViewerTools(
|
||||
data.presentation.attachRecentImagery((container) =>
|
||||
createRecentImageryPanel({
|
||||
container,
|
||||
viewer,
|
||||
...panelGlobe,
|
||||
layer: recentImagery,
|
||||
tool: imageryBoxTool,
|
||||
}),
|
||||
@@ -340,45 +411,24 @@ function attachViewerTools(
|
||||
imageryBoxTool?.destroy();
|
||||
});
|
||||
}
|
||||
// Idle render governor: flips the scene into requestRenderMode whenever
|
||||
// nothing animates per frame. Installed AFTER every module above has had
|
||||
// its chance to register pre-install holds. (perf wave 2) A parked viewer
|
||||
// uninstalls it (src/app/scene.js suspend).
|
||||
installRenderGovernor(viewer);
|
||||
// Cesium keeps a turned-off layer's draw commands (and so all its entities)
|
||||
// alive in reused per-pass arrays; trim them after each disable.
|
||||
defer(
|
||||
installLayerReleaseTrim({
|
||||
scene: viewer.scene,
|
||||
dataManager,
|
||||
requestRender: governorRequestRender,
|
||||
}),
|
||||
);
|
||||
// Installed AFTER every module above has had its chance to register
|
||||
// pre-install holds.
|
||||
backend.installGovernor();
|
||||
backend.attach?.({ data, styleManager, dataManager }, { signal, defer });
|
||||
|
||||
// Install the explicit scope mask used by the DISPLAY controls.
|
||||
// Install the explicit scope mask used by the DISPLAY controls: a canvas
|
||||
// in the engine's container, its terminus following the camera's height.
|
||||
installScopeMask(engine);
|
||||
defer(() => destroyScopeMask());
|
||||
|
||||
// The follow camera recomputes the tracked target's dead-reckon position
|
||||
// every frame — tracking anything is a per-frame animation. (perf wave 2)
|
||||
const removeTrackingListener = viewer.trackedEntityChanged.addEventListener(
|
||||
() => {
|
||||
if (viewer.trackedEntity) holdContinuousRender('tracked-entity');
|
||||
else releaseContinuousRender('tracked-entity');
|
||||
},
|
||||
);
|
||||
|
||||
// Hidden-state suspension (perf wave 2): when the window/tab is hidden,
|
||||
// stop the default render loop outright — a hidden canvas repaints for
|
||||
// nobody, and browser rAF throttling still lets throttled frames burn
|
||||
// GPU. Holder/data state is untouched, so return is seamless: restore
|
||||
// the loop, refresh the one DOM surface we gated, render a frame.
|
||||
// A hidden tab: the engine's own suspension, if any; holder/data state is
|
||||
// untouched, so return is seamless: restore the loop, refresh the one DOM
|
||||
// surface we gated, render a frame.
|
||||
const syncVisibilitySuspension = () => {
|
||||
// A panel's host may report it hidden while it is on screen; the panel
|
||||
// keeps drawing itself (see keepPanelRendering in embed.js).
|
||||
const hidden = document.hidden && !isEmbeddedInline();
|
||||
viewer.useDefaultRenderLoop = !hidden;
|
||||
controls.cockpitCloudEffects?.setSuspended?.(hidden);
|
||||
backend.suspend?.(hidden, controls);
|
||||
if (!hidden) {
|
||||
data.presentation.flushVisible();
|
||||
governorRequestRender('visibility-restore');
|
||||
@@ -388,33 +438,102 @@ function attachViewerTools(
|
||||
defer(() =>
|
||||
document.removeEventListener('visibilitychange', syncVisibilitySuspension),
|
||||
);
|
||||
defer(() => {
|
||||
removeTrackingListener();
|
||||
releaseContinuousRender('tracked-entity');
|
||||
});
|
||||
// Apply the CURRENT state too — bootstrap can complete while the tab is
|
||||
// already hidden, and waiting for the next transition would leave the
|
||||
// loop burning behind a hidden tab. (perf wave 2 fix)
|
||||
syncVisibilitySuspension();
|
||||
// Apply the CURRENT state to a suspending engine too — bootstrap can
|
||||
// complete while the tab is already hidden, and waiting for the next
|
||||
// transition would leave the loop burning behind a hidden tab. (perf wave
|
||||
// 2 fix)
|
||||
if (backend.suspend) syncVisibilitySuspension();
|
||||
|
||||
// Voice uses the engine and shared session services; each action checks
|
||||
// the capabilities it needs on the attached scene.
|
||||
const voiceCommands = attachSessionVoice({
|
||||
layerSession,
|
||||
voice,
|
||||
operations,
|
||||
placeSearch,
|
||||
dataManager,
|
||||
scene: { viewer, engine, styleManager, sceneDirector, annotations },
|
||||
scene: {
|
||||
viewer: backend.viewer ?? null,
|
||||
engine,
|
||||
styleManager,
|
||||
sceneDirector,
|
||||
annotations,
|
||||
},
|
||||
signal,
|
||||
defer,
|
||||
});
|
||||
defer(
|
||||
installViews({
|
||||
shell: styleManager,
|
||||
viewer,
|
||||
dataManager,
|
||||
run: (name, args) => voiceCommands.runner(name, args, { signal }),
|
||||
signal,
|
||||
}),
|
||||
backend.attachViews?.(
|
||||
{ styleManager, dataManager, voiceCommands },
|
||||
{ signal, defer },
|
||||
);
|
||||
return { annotations, voiceCommands };
|
||||
}
|
||||
|
||||
/** The annotation board drawn on this globe (`boardScene`), or null. */
|
||||
function attachBoard(
|
||||
boardScene,
|
||||
{ layerSession, placeSearch, operations },
|
||||
defer,
|
||||
) {
|
||||
if (!boardScene) return null;
|
||||
// The board belongs to the page session when there is one: an engine
|
||||
// switch only takes its drawing off this globe.
|
||||
const annotations = sessionAnnotations({
|
||||
layerSession,
|
||||
placeSearch,
|
||||
resolver: operations.annotationResolver,
|
||||
defer,
|
||||
});
|
||||
annotations.attachScene(boardScene);
|
||||
defer(() => annotations.detachScene());
|
||||
return annotations;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Draw on this globe (`drawScene()`: its input and preview):
|
||||
* the tool claims the globe's pointer while a sketch is open, so a switch
|
||||
* ends an open sketch and keeps the chosen shape and colour. Returns the
|
||||
* tool (null on a page without it or without the board).
|
||||
*/
|
||||
function attachDraw(
|
||||
drawScene,
|
||||
{ layerSession, annotations, styleManager },
|
||||
defer,
|
||||
) {
|
||||
if (!annotations) return null;
|
||||
const drawTool = sessionDrawTool({
|
||||
layerSession,
|
||||
annotations,
|
||||
styleManager,
|
||||
defer,
|
||||
});
|
||||
drawTool?.attachScene({ ...drawScene(), navigation: styleManager });
|
||||
defer(() => drawTool?.detachScene());
|
||||
return drawTool;
|
||||
}
|
||||
|
||||
/**
|
||||
* UTILITIES ▸ Import on this globe (`importScene`, null: not on it): the
|
||||
* next globe reads the session's files again. The drop listeners belong to
|
||||
* this globe. Returns the control (null on a page without it).
|
||||
*/
|
||||
function attachGeoImport(
|
||||
importScene,
|
||||
{ layerSession, styleManager, operations, mapStackController },
|
||||
defer,
|
||||
) {
|
||||
if (!importScene) return null;
|
||||
const geoFileImport = sessionGeoImport({
|
||||
layerSession,
|
||||
styleManager,
|
||||
operations,
|
||||
defer,
|
||||
});
|
||||
geoFileImport?.attachScene({
|
||||
...importScene,
|
||||
navigation: styleManager,
|
||||
subscribeMapSource: (listener) => mapStackController?.subscribe?.(listener),
|
||||
});
|
||||
defer(() => geoFileImport?.detachScene());
|
||||
return geoFileImport;
|
||||
}
|
||||
@@ -2,10 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createApplicationTools } from './tools.js';
|
||||
import { navaraModeNotice } from './navaraMode.js';
|
||||
import {
|
||||
createLayerAvailability,
|
||||
createRendererRegistry,
|
||||
} from './layers/renderers.js';
|
||||
import { LAYER_MANIFEST, createLayerAvailability } from './layers/renderers.js';
|
||||
import { createFakeEngine } from '../globe/testing/fakeEngine.js';
|
||||
import {
|
||||
_resetRenderGovernorForTest,
|
||||
@@ -14,8 +11,8 @@ import {
|
||||
releaseContinuousRender,
|
||||
} from '../renderGovernor.js';
|
||||
|
||||
// The tools without a viewer (the experimental Navara engine); they load
|
||||
// from src/app/navaraMode.js.
|
||||
// The tools on an engine without a viewer (the experimental Navara engine):
|
||||
// the same tree, with Navara's tool backend (src/app/navaraMode.js).
|
||||
test('without a viewer the tools run the governor on the engine, show the notice, create no viewer tools and run voice on the engine', async (t) => {
|
||||
const saved = {
|
||||
window: globalThis.window,
|
||||
@@ -121,6 +118,7 @@ test('without a viewer the tools run the governor on the engine, show the notice
|
||||
assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome');
|
||||
assert.equal(chrome[0].styleManager, styleManager);
|
||||
const notice = navaraModeNotice(createLayerAvailability('navara'));
|
||||
assert.match(notice.detail, /^Data layers: Bhote Koshi Flood, /);
|
||||
assert.deepEqual(notices, [
|
||||
{
|
||||
label: notice.label,
|
||||
@@ -131,15 +129,20 @@ test('without a viewer the tools run the governor on the engine, show the notice
|
||||
},
|
||||
},
|
||||
]);
|
||||
const none = navaraModeNotice(
|
||||
createLayerAvailability('navara', { manifest: {} }),
|
||||
);
|
||||
assert.match(
|
||||
`${notice.label}: ${notice.detail}`,
|
||||
`${none.label}: ${none.detail}`,
|
||||
/^Experimental Navara engine: no registered data layers$/i,
|
||||
);
|
||||
// With Navara renderers, the notice names the layers it shows.
|
||||
const registry = createRendererRegistry();
|
||||
registry.register('navara', { satellites: () => ({}), flights: () => ({}) });
|
||||
// The notice names the layers the engine shows, in catalog order.
|
||||
const manifest = {
|
||||
satellites: LAYER_MANIFEST.satellites,
|
||||
flights: LAYER_MANIFEST.flights,
|
||||
};
|
||||
assert.equal(
|
||||
navaraModeNotice(createLayerAvailability('navara', { registry })).detail,
|
||||
navaraModeNotice(createLayerAvailability('navara', { manifest })).detail,
|
||||
'Data layers: Live Flights, Satellites',
|
||||
);
|
||||
// The governor drives the engine's continuous rendering.
|
||||
|
||||
@@ -81,6 +81,8 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
|
||||
engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'),
|
||||
createStandalonePlaceSearch: () => ({}),
|
||||
createStandaloneCatalog: () => ({}),
|
||||
// Cesium's displays are in the main chunk: nothing to load.
|
||||
loadLayerRenderers: async () => {},
|
||||
createGoogleTokenSource: () => createGoogleTokenSource({ fetchImpl }),
|
||||
selectMapStartupRoute,
|
||||
// The real scene forwards these credentials to this same loader.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||
import { engineOfChoice, resolveEngineKind } from '../app/engineChoice.js';
|
||||
import { createSceneHost } from '../app/sceneHost.js';
|
||||
import { attachScenePart } from '../app/sceneParts.js';
|
||||
import { loadLayerRenderers } from '../app/layers/renderers.js';
|
||||
|
||||
// Only one engine may own the page at a time.
|
||||
let owner = null;
|
||||
@@ -109,6 +110,10 @@ export function createStandaloneApplication({
|
||||
throw new AggregateError(errors, 'The globe could not be released');
|
||||
};
|
||||
const signal = AbortSignal.any([controller.signal, lifetime]);
|
||||
// The layers' engine-specific renderers of this engine (the layer
|
||||
// manifest's import()s, none on Cesium) load beside its globe.
|
||||
const layerRenderers = loadLayerRenderers(engineOfChoice(kind));
|
||||
layerRenderers.catch(() => {});
|
||||
try {
|
||||
const scene = await createStandaloneScene({
|
||||
...sessionContext,
|
||||
@@ -125,8 +130,10 @@ export function createStandaloneApplication({
|
||||
layerSession,
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
// The catalog of this engine: on Navara only the layers with a Navara
|
||||
// renderer (src/app/layers/renderers.js), the others' state kept for
|
||||
await layerRenderers;
|
||||
signal.throwIfAborted();
|
||||
// The catalog of this engine: only the layers it can show (the layer
|
||||
// manifest, src/app/layers/renderers.js), the others' state kept for
|
||||
// links. The layer models are the session's (layerSession.models).
|
||||
// Session models keep the catalog's closures past this scene: they
|
||||
// capture the operations, never the scene (it holds the engine).
|
||||
|
||||
@@ -40,6 +40,7 @@ function standalone({ failOpen = null } = {}) {
|
||||
engineOfChoice: (choice) => (choice === 'cesium' ? 'cesium' : 'navara'),
|
||||
createStandalonePlaceSearch: () => ({ kind: 'search' }),
|
||||
createStandaloneCatalog: ({ engineKind }) => ({ engineKind }),
|
||||
loadLayerRenderers: async () => {},
|
||||
createGoogleTokenSource: () => null,
|
||||
createStandaloneScene: async ({ engineKind, defer }) => {
|
||||
if (failOpen === engineKind) throw new Error(`${engineKind} refused`);
|
||||
|
||||
Reference in new issue
Block a user