From b0cbed081b557a3e6403ab051aee9df8e4a720d3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Fri, 16 Oct 2026 22:07:51 +0400 Subject: [PATCH] feat(app): layer catalog per engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/app/layers/renderers.js: the layer renderer registry (registerLayerRenderers(engineKind, { [layerId]: factory }), rendererFor(layerId, engine), layerAvailableOn(layerId, engineKind)). Every application layer is registered for Cesium as CESIUM_BUILTIN (it still draws itself until its render/cesium.js); Navara factories come with the layer tasks. On it, the stable availability API phase 4 injects into its layer list: createLayerAvailability(engineKind) → { engine, isAvailable(id), available(), unavailable() } with { id, name } rows (src/app/layers/directory.js holds the ids and names in catalog order; a test keeps it equal to the constructed catalog). - createApplicationCatalog({ engineKind, availability }) constructs only the layers the engine can show, in the same order (shared instances made once, on demand); the catalog carries `availability` and the others' metadata (`unavailableMetadata`). Cesium builds the same 30 layers in the same order as before; Navara builds none yet (no Navara renderers) - the standalone app now asks the catalog for it instead of making an empty one. - catalogControlServices: a role whose layer the engine leaves out gets an empty stand-in (absentLayerControlServices folded in); any other missing layer is still a wiring error. - LayerLifecycle(viewer, { engine }): every module call (init, enable, update, disable, destroy) gets { signal, engine } as its second argument; viewer is null on Navara. - Links: a link that turns on layers this engine leaves out keeps them in the durable state (re-saved links carry them; back on Cesium they turn on) and activates nothing; a notice names them ("NOT ON THIS ENGINE · Kept in the link: …"). The Navara notice lists the engine's data layers from the registry. - The dev launcher's CCTV wiring check matches the new catalog form. --- scripts/dev-fresh.sh | 2 +- scripts/format-scope.json | 7 +- scripts/package-boundaries.json | 14 +- src/app/catalog.js | 17 +- src/app/catalog.test.mjs | 37 +++ src/app/constructCatalog.js | 297 +++++++++++++++++-------- src/app/constructCatalog.test.mjs | 51 +++++ src/app/controls.js | 12 +- src/app/data.js | 4 +- src/app/layers/directory.js | 39 ++++ src/app/layers/renderers.js | 125 +++++++++++ src/app/layers/renderers.test.mjs | 86 +++++++ src/app/navaraMode.js | 28 ++- src/app/sourceComposition.js | 9 +- src/app/viewerlessTools.test.mjs | 22 +- src/data/layerState.test.mjs | 46 ++++ src/data/lifecycle.js | 68 ++++-- src/data/lifecycleEngine.test.mjs | 48 ++++ src/devCctv.test.mjs | 2 +- src/standalone/application.js | 11 +- src/standalone/catalog.js | 2 + src/ui/shareRestoration.js | 29 ++- src/ui/shareRestorationEngine.test.mjs | 16 ++ 23 files changed, 826 insertions(+), 146 deletions(-) create mode 100644 src/app/layers/directory.js create mode 100644 src/app/layers/renderers.js create mode 100644 src/app/layers/renderers.test.mjs create mode 100644 src/data/lifecycleEngine.test.mjs create mode 100644 src/ui/shareRestorationEngine.test.mjs diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index 69d3ac319..4a4a2ca70 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -250,7 +250,7 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then fi if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \ - ! grep -q "^[[:space:]]*createApplicationCctv({" "$SOURCE_ROOT/src/app/constructCatalog.js"; then + ! grep -q "createApplicationCctv(" "$SOURCE_ROOT/src/app/constructCatalog.js"; then echo "error: CCTV layer not wired in src/standalone/catalog.js" exit 1 fi diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 6426480cc..4a94003aa 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1096,5 +1096,10 @@ "src/globe/navara/followGestures.js", "src/globe/navara/followGestures.test.mjs", "src/globe/cpuPicks.test.mjs", - "scripts/qa-overlay-engines.mjs" + "scripts/qa-overlay-engines.mjs", + "src/app/layers/directory.js", + "src/app/layers/renderers.js", + "src/app/layers/renderers.test.mjs", + "src/data/lifecycleEngine.test.mjs", + "src/ui/shareRestorationEngine.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 05ebf3baa..54365eb9b 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -702,7 +702,8 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js" + "src/geo/developerError.js", + "src/app/layers/directory.js" ], "external": ["cesium"] }, @@ -2583,7 +2584,9 @@ "src/globe/navara/movers/modelMover.js", "src/globe/navara/movers/index.js", "src/globe/surfaceDetail.js", - "src/globe/navara/followGestures.js" + "src/globe/navara/followGestures.js", + "src/app/layers/directory.js", + "src/app/layers/renderers.js" ], "external": [ "hls.js", @@ -3152,7 +3155,9 @@ "src/geo/developerError.js", "src/time/clock.js", "src/globe/follow.js", - "src/globe/cesium/follow.js" + "src/globe/cesium/follow.js", + "src/app/layers/directory.js", + "src/app/layers/renderers.js" ], "external": [ "hls.js", @@ -3279,7 +3284,8 @@ "src/loadingFeedback.js", "src/navigationPolicy.js", "src/ui/shareRestoration.js", - "src/ui/uiLifetime.js" + "src/ui/uiLifetime.js", + "src/app/layers/directory.js" ], "external": [] }, diff --git a/src/app/catalog.js b/src/app/catalog.js index aaead0026..b792edf95 100644 --- a/src/app/catalog.js +++ b/src/app/catalog.js @@ -46,16 +46,25 @@ export function createLayerCatalog(layers, metadata) { }); } -/** Bind the current control surface to the exact instances registered by the app. */ +/** + * Bind the current control surface to the exact instances registered by the + * app. A layer the catalog leaves out on purpose (another engine's: in + * `catalog.unavailableMetadata`) gets an empty stand-in, so the shell's + * optional calls (`layer.method?.()`) do nothing; any other missing layer is + * a wiring error. + */ export function catalogControlServices(catalog) { if (!catalog?.get) throw new TypeError('An application layer catalog is required'); + const omitted = new Set( + (catalog.unavailableMetadata ?? []).map(({ id }) => id), + ); return Object.fromEntries( Object.entries(CONTROL_LAYER_IDS).map(([role, id]) => { const layer = catalog.get(id); - if (!layer) - throw new TypeError(`Control layer missing from catalog: ${id}`); - return [role, layer]; + if (layer) return [role, layer]; + if (omitted.has(id)) return [role, {}]; + throw new TypeError(`Control layer missing from catalog: ${id}`); }), ); } diff --git a/src/app/catalog.test.mjs b/src/app/catalog.test.mjs index 34b1b1e51..159b6b1a5 100644 --- a/src/app/catalog.test.mjs +++ b/src/app/catalog.test.mjs @@ -190,3 +190,40 @@ test('data setup without a viewer registers an empty catalog and no sonar scene' assert.equal(dataManager.registrationsFinalized, true); for (const release of releases.reverse()) await release(); }); + +test('a catalog for an engine gives stand-ins only for the layers it leaves out', () => { + const flights = { id: 'flights' }; + const catalog = { + ...createLayerCatalog( + [flights], + [{ id: 'flights', disposition: 'enabled-only' }], + ), + unavailableMetadata: Object.keys({ + traffic: 1, + military: 1, + satellites: 1, + cctv: 1, + 'street-level': 1, + radio: 1, + bikeshare: 1, + transit: 1, + 'ais-live-vessels': 1, + 'military-awareness': 1, + 'military-installations': 1, + 'rocket-launches': 1, + 'local-adsb': 1, + }).map((id) => ({ id })), + }; + const services = catalogControlServices(catalog); + assert.equal(services.flightsLayer, flights); + assert.deepEqual(services.trafficLayer, {}); + assert.notEqual(services.trafficLayer, services.militaryFlightsLayer); + const missing = { + ...catalog, + unavailableMetadata: catalog.unavailableMetadata.slice(1), + }; + assert.throws( + () => catalogControlServices(missing), + /Control layer missing from catalog: traffic/, + ); +}); diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 80315fe85..eef340692 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -4,6 +4,8 @@ import { createWeatherLayer } from '../layers/weather/index.js'; import { createCyclonesLayer } from '../layers/cyclones/index.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; +import { createLayerAvailability } from './layers/renderers.js'; +import { APPLICATION_LAYER_DIRECTORY } from './layers/directory.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; import { createApplicationFlights } from './layers/flights.js'; @@ -47,6 +49,10 @@ export const APPLICATION_LAYER_METADATA = Object.freeze([ ]); /** Construct the current catalog without choosing any source provider. + * `engineKind` ('cesium' | 'navara'): only the layers that engine can show + * (src/app/layers/renderers.js) are constructed, in the same order; the + * catalog keeps the others' metadata (`unavailableMetadata`) and carries + * the engine's `availability`. * Scene engines remain page-owned; layers and classification have this app's lifetime. * The manager owns layer destruction, while abort releases classification even if startup fails. */ @@ -59,6 +65,8 @@ export function createApplicationCatalog({ resolveAsset, nepalBoundaryResolver, streetLevelProviders, + engineKind = 'cesium', + availability = createLayerAvailability(engineKind), }) { if (!signal?.addEventListener) throw new TypeError('An application lifetime signal is required'); @@ -79,104 +87,211 @@ export function createApplicationCatalog({ try { if (sourceComposition.isConfigured('military')) militaryRegistry.configureSource(sources.military, { signal }); - const flights = createApplicationFlights({ - surface, - source: sources.flights, - militaryRegistry, - resolveAsset, - }); - const military = createApplicationMilitary({ - surface, - source: sources.military, - militaryRegistry, - resolveAsset, - }); - const vessels = createApplicationVessels({ - source: sources.vessels, - options: vesselOptions, - }); - const installations = createApplicationInstallations({ - surface, - source: sources.installations, - }); - const satellites = createApplicationSatellites({ - source: sources.satellites, - }); - const catalog = createLayerCatalog( + // Shared instances (awareness and launches read others) are made once, + // when the first layer that needs them is built. + const once = (create) => { + let value; + return () => (value ??= create()); + }; + const flights = once(() => + createApplicationFlights({ + surface, + source: sources.flights, + militaryRegistry, + resolveAsset, + }), + ); + const military = once(() => + createApplicationMilitary({ + surface, + source: sources.military, + militaryRegistry, + resolveAsset, + }), + ); + const vessels = once(() => + createApplicationVessels({ + source: sources.vessels, + options: vesselOptions, + }), + ); + const installations = once(() => + createApplicationInstallations({ + surface, + source: sources.installations, + }), + ); + const satellites = once(() => + createApplicationSatellites({ + source: sources.satellites, + }), + ); + // Catalog order: [layer id(s), construction]. Only the layers the + // engine can show are constructed (renderers.js). + const entries = [ + ['bhote-koshi-2026', () => createBhoteKoshiEventLayer()], [ - createBhoteKoshiEventLayer(), - createBhoteKoshiLocatorLayer({ - boundaryResolver: nepalBoundaryResolver, - }), - flights, - military, - createApplicationLocalAdsb({ - surface, - enrichment: sources.flights, - displayParams: () => flights.getParams(), - ...(resolveAsset ? { resolveAsset } : {}), - }), - createApplicationEarthquakes({ source: sources.earthquakes }), - createApplicationFirePerimeters({ - source: sources['fire-perimeters'], - }), - createApplicationAlpr({ surface, source: sources.alpr }), - createApplicationStreetLevel({ - surface, - sources: { mapillary: sources.mapillary }, - providers: streetLevelProviders, - }), - satellites, - createApplicationLaunches({ source: sources.launches, satellites }), - createApplicationTraffic({ source: sources.traffic, surface }), - createApplicationCctv({ surface, source: sources.cctv }), - createApplicationRadio({ surface, source: sources.radio }), - createApplicationTransit({ surface, source: sources.transit }), - createApplicationBikeshare({ source: sources.bikeshare }), - createApplicationDirections({ source: sources.directions }), - createApplicationRecentImagery({ source: sources['recent-imagery'] }), - vessels, - installations, - createApplicationAwareness({ - flights, - military, - vessels, - installations, - }), - createWindLayer({ feed: sources.wind, clock: weatherClock }), - createWeatherLayer({ - feed: sources.weather, - id: 'weather-radar', - clock: weatherClock, - }), - createWeatherLayer({ - feed: sources.weather, - id: 'weather-satellite', - clock: weatherClock, - }), - createWeatherLayer({ - feed: sources.weather, - id: 'weather-lightning', - clock: weatherClock, - }), - createCyclonesLayer({ feed: sources.cyclones }), - ...createInfrastructureLayers(localGeoJsonServices), - createApplicationCables({ source: sources.cables }), - createApplicationFirms({ - surface, - id: 'local-firms', - name: 'FIRMS Active Fires', - icon: '▲', - source: 'NASA FIRMS · LIVE', - feed: sources.firms, - }), + 'bhote-koshi-locator', + () => + createBhoteKoshiLocatorLayer({ + boundaryResolver: nepalBoundaryResolver, + }), ], - metadata, + ['flights', flights], + ['military', military], + [ + 'local-adsb', + () => + createApplicationLocalAdsb({ + surface, + enrichment: sources.flights, + displayParams: () => flights().getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), + ], + [ + 'earthquakes', + () => createApplicationEarthquakes({ source: sources.earthquakes }), + ], + [ + 'fire-perimeters', + () => + createApplicationFirePerimeters({ + source: sources['fire-perimeters'], + }), + ], + [ + 'alpr-cameras', + () => createApplicationAlpr({ surface, source: sources.alpr }), + ], + [ + 'street-level', + () => + createApplicationStreetLevel({ + surface, + sources: { mapillary: sources.mapillary }, + providers: streetLevelProviders, + }), + ], + ['satellites', satellites], + [ + 'rocket-launches', + () => + createApplicationLaunches({ + source: sources.launches, + satellites: satellites(), + }), + ], + [ + 'traffic', + () => createApplicationTraffic({ source: sources.traffic, surface }), + ], + ['cctv', () => createApplicationCctv({ surface, source: sources.cctv })], + [ + 'radio', + () => createApplicationRadio({ surface, source: sources.radio }), + ], + [ + 'transit', + () => createApplicationTransit({ surface, source: sources.transit }), + ], + [ + 'bikeshare', + () => createApplicationBikeshare({ source: sources.bikeshare }), + ], + [ + 'directions', + () => createApplicationDirections({ source: sources.directions }), + ], + [ + 'recent-imagery', + () => + createApplicationRecentImagery({ + source: sources['recent-imagery'], + }), + ], + ['ais-live-vessels', vessels], + ['military-installations', installations], + [ + 'military-awareness', + () => + createApplicationAwareness({ + flights: flights(), + military: military(), + vessels: vessels(), + installations: installations(), + }), + ], + [ + 'wind', + () => createWindLayer({ feed: sources.wind, clock: weatherClock }), + ], + ...['weather-radar', 'weather-satellite', 'weather-lightning'].map( + (id) => [ + id, + () => + createWeatherLayer({ + feed: sources.weather, + id, + clock: weatherClock, + }), + ], + ), + [ + 'weather-cyclones', + () => createCyclonesLayer({ feed: sources.cyclones }), + ], + [ + ['local-datacenters', 'local-dams'], + () => createInfrastructureLayers(localGeoJsonServices), + ], + [ + 'telegeography-submarine-cables', + () => createApplicationCables({ source: sources.cables }), + ], + [ + 'local-firms', + () => + createApplicationFirms({ + surface, + id: 'local-firms', + name: 'FIRMS Active Fires', + icon: '▲', + source: 'NASA FIRMS · LIVE', + feed: sources.firms, + }), + ], + ]; + const available = (id) => availability.isAvailable(id); + const layers = []; + for (const [ids, create] of entries) { + const wanted = [ids].flat().filter(available); + if (!wanted.length) continue; + for (const layer of [create()].flat()) + if (wanted.includes(layer.id)) layers.push(layer); + } + const catalog = createLayerCatalog( + layers, + metadata.filter(({ id }) => available(id)), ); return Object.freeze({ ...catalog, + engineKind, + // Layers this engine cannot show: their metadata, so links keep their + // state (data.js), and the stable availability API (renderers.js). + unavailableMetadata: Object.freeze( + metadata.filter(({ id }) => !available(id)), + ), + availability, getSourceAvailability: sourceComposition.createAvailabilityLookup( catalog.layers, + { + omitted: new Set( + APPLICATION_LAYER_DIRECTORY.map(({ id }) => id).filter( + (id) => !available(id), + ), + ), + }, ), militaryRegistry, surface, diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs index f42e82c7c..af552bb71 100644 --- a/src/app/constructCatalog.test.mjs +++ b/src/app/constructCatalog.test.mjs @@ -191,3 +191,54 @@ test('Mapillary can be omitted or replaced without changing catalog membership', /Mapillary source/, ); }); + +test('the catalog per engine: Cesium as before, Navara only what it can show', async (t) => { + const { APPLICATION_LAYER_DIRECTORY } = await import('./layers/directory.js'); + const { createLayerAvailability, createRendererRegistry } = + await import('./layers/renderers.js'); + const controller = new AbortController(); + t.after(() => controller.abort()); + const build = (options) => + createApplicationCatalog({ + sources: createStandaloneLayerSources(), + signal: controller.signal, + surface: fixtureSurface(controller.signal), + ...options, + }); + const cesium = build({}); + // The directory is the catalog Cesium builds: ids, names and order. + assert.deepEqual( + cesium.layers.map(({ id, name }) => ({ id, name })), + APPLICATION_LAYER_DIRECTORY.map(({ id, name }) => ({ id, name })), + ); + assert.equal(cesium.engineKind, 'cesium'); + assert.deepEqual(cesium.unavailableMetadata, []); + 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.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: () => ({}), + }); + const subset = build({ + engineKind: 'navara', + availability: createLayerAvailability('navara', { registry }), + }); + assert.deepEqual( + subset.layers.map(({ id }) => id), + ['flights', 'satellites'], + ); + assert.deepEqual( + subset.metadata.map(({ id }) => id), + ['flights', 'satellites'], + ); + assert.ok(subset.unavailableMetadata.some(({ id }) => id === 'military')); + assert.equal(subset.get('military'), undefined); +}); diff --git a/src/app/controls.js b/src/app/controls.js index 1d74bcfa5..871d9e171 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -1,7 +1,4 @@ -import { - absentLayerControlServices, - catalogControlServices, -} from './catalog.js'; +import { catalogControlServices } from './catalog.js'; import { StyleManager } from '../ui/composition.js'; import { flyToAustin } from '../camera.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; @@ -24,10 +21,9 @@ export function createApplicationControls({ searchAndFlyTo: operations.searchAndFlyTo, fetchRegionalBrief: (...args) => operations.requests.regional.getBrief(...args), - // No viewer (the Navara engine): no data layers, empty stand-ins. - ...(viewer - ? catalogControlServices(catalog) - : absentLayerControlServices()), + // The catalog's layers; the ones this engine cannot show (Navara) + // are empty stand-ins. + ...catalogControlServices(catalog), }, requestServices: operations.requests, // The page's globe engine: the shell's overlay host runs on it. diff --git a/src/app/data.js b/src/app/data.js index fb3f59f1b..64dd72c7c 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -3,7 +3,7 @@ import { LayerPresentation } from './layerPresentation.js'; import { createCyberSonarScene } from '../cyberSonarScene.js'; /** Register the application layer catalog before allowing state restoration. */ export function createApplicationData({ - scene: { viewer, mapStackController }, + scene: { viewer, engine, mapStackController }, controls: { styleManager }, catalog, allowQaRegistration, @@ -11,7 +11,9 @@ export function createApplicationData({ defer, }) { // Initialize data layer manager + // The modules get { signal, engine } on every call (viewer null on Navara). const dataManager = new LayerLifecycle(viewer, { + engine, allowQaRegistration, getSourceAvailability: catalog?.getSourceAvailability, }); diff --git a/src/app/layers/directory.js b/src/app/layers/directory.js new file mode 100644 index 000000000..5128df204 --- /dev/null +++ b/src/app/layers/directory.js @@ -0,0 +1,39 @@ +// The application's layers in catalog order, by id and display name: what +// the catalog builds on Cesium. Plain data, so the availability of a layer +// on an engine (renderers.js) can be told without constructing it. +// constructCatalog.test.mjs keeps it equal to the constructed catalog. + +export const APPLICATION_LAYER_DIRECTORY = Object.freeze( + [ + ['bhote-koshi-2026', 'Bhote Koshi Flood'], + ['bhote-koshi-locator', 'Bhote Koshi Locator'], + ['flights', 'Live Flights'], + ['military', 'Military Flights'], + ['local-adsb', 'Local ADS-B'], + ['earthquakes', 'Earthquakes (24h)'], + ['fire-perimeters', 'Fire Perimeters'], + ['alpr-cameras', 'ALPR Cameras'], + ['street-level', 'Street Level'], + ['satellites', 'Satellites'], + ['rocket-launches', 'Space Missions (30d)'], + ['traffic', 'Street Traffic'], + ['cctv', 'CCTV'], + ['radio', 'Radio'], + ['transit', 'Transit'], + ['bikeshare', 'Bikeshare'], + ['directions', 'Directions'], + ['recent-imagery', 'Recent Imagery'], + ['ais-live-vessels', 'Live AIS Vessels'], + ['military-installations', 'Mapped Installations'], + ['military-awareness', 'Global Context'], + ['wind', 'Wind'], + ['weather-radar', 'Rain radar'], + ['weather-satellite', 'Satellite clouds'], + ['weather-lightning', 'Lightning density'], + ['weather-cyclones', 'Cyclone advisories'], + ['local-datacenters', 'Datacenters'], + ['local-dams', 'Dams'], + ['telegeography-submarine-cables', 'Submarine Cables'], + ['local-firms', 'FIRMS Active Fires'], + ].map(([id, name]) => Object.freeze({ id, name })), +); diff --git a/src/app/layers/renderers.js b/src/app/layers/renderers.js new file mode 100644 index 000000000..1fd0ca688 --- /dev/null +++ b/src/app/layers/renderers.js @@ -0,0 +1,125 @@ +// Which engine draws which layer: the layer renderer registry (plan, "layer +// renderer interface") 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). Cesium +// layers still draw themselves until their render/cesium.js is extracted +// (the *a tasks): 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). +// +// ## Layer availability (stable API, used by phase 4's layer list) +// +// import { createLayerAvailability } from './app/layers/renderers.js'; +// const availability = createLayerAvailability(engine.kind); +// availability.engine // 'cesium' | 'navara' +// availability.isAvailable(id) // can this engine show layer `id`? +// 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 +// (src/app/layers/directory.js). Phase 4 injects it as its `availability` +// provider (`{ engine, unavailable() }` is the part it reads). + +import { APPLICATION_LAYER_DIRECTORY } from './directory.js'; + +const ENGINE_KINDS = new Set(['cesium', 'navara']); + +/** The layer draws itself on Cesium (no separate renderer yet). */ +export const CESIUM_BUILTIN = Object.freeze({ builtin: 'cesium' }); + +const isFactory = (value) => + typeof value === 'function' || value === CESIUM_BUILTIN; + +/** 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 defaultRegistry = createRendererRegistry(); + +/** 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); + +// Every application layer draws itself on Cesium today. +registerLayerRenderers( + 'cesium', + Object.fromEntries( + APPLICATION_LAYER_DIRECTORY.map(({ id }) => [id, CESIUM_BUILTIN]), + ), +); + +/** + * @typedef {object} LayerAvailability + * @property {'cesium' | 'navara'} engine + * @property {(layerId: string) => boolean} isAvailable + * @property {() => { id: string, name: string }[]} available + * @property {() => { id: string, name: string }[]} unavailable + */ + +/** + * 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. + * @returns {LayerAvailability} + */ +export function createLayerAvailability( + engineKind, + { registry = defaultRegistry, directory = APPLICATION_LAYER_DIRECTORY } = {}, +) { + if (!ENGINE_KINDS.has(engineKind)) + throw new TypeError(`Unknown engine kind for layers: ${engineKind}`); + const isAvailable = (layerId) => + registry.layerAvailableOn(layerId, engineKind); + const rows = (wanted) => + directory + .filter(({ id }) => isAvailable(id) === wanted) + .map(({ id, name }) => ({ id, name })); + return Object.freeze({ + engine: engineKind, + isAvailable, + available: () => rows(true), + unavailable: () => rows(false), + }); +} diff --git a/src/app/layers/renderers.test.mjs b/src/app/layers/renderers.test.mjs new file mode 100644 index 000000000..3722aecee --- /dev/null +++ b/src/app/layers/renderers.test.mjs @@ -0,0 +1,86 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + CESIUM_BUILTIN, + createLayerAvailability, + createRendererRegistry, + layerAvailableOn, +} from './renderers.js'; +import { APPLICATION_LAYER_DIRECTORY } from './directory.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 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 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, + 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.deepEqual(availability.available(), [ + { id: 'flights', name: 'Live Flights' }, + ]); + assert.deepEqual(availability.unavailable(), [ + { id: 'satellites', name: 'Satellites' }, + ]); + // Fresh copies: a caller cannot change the answer. + 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); + assert.throws(() => createLayerAvailability('vulkan'), TypeError); +}); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 848201580..7add0d1e3 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -5,6 +5,7 @@ import { DATA_CREDITS, registerDataCredits } from '../data/dataCredits.js'; import { isEmbeddedInline } from './embed.js'; +import { createLayerAvailability } from './layers/renderers.js'; import { getRenderGovernorDiagnostics, governorRequestRender, @@ -16,11 +17,21 @@ import { const loadNavaraGlobeModule = async () => (await import('../globe/navara/index.js')).createNavaraGlobe; -/** The shell's notice in the experimental Navara mode (label and detail). */ -export const NAVARA_MODE_NOTICE = Object.freeze({ - label: 'Experimental Navara engine', - detail: 'Layers, cockpit, director and voice are unavailable', -}); +/** + * 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. + * @param {import('./layers/renderers.js').LayerAvailability} availability + */ +export function navaraModeNotice(availability) { + const names = availability.available().map(({ name }) => name); + return Object.freeze({ + label: 'Experimental Navara engine', + detail: names.length + ? `Data layers: ${names.join(', ')}; the other layers, cockpit, director and voice are unavailable` + : 'No data layers on this engine yet; cockpit, director and voice are unavailable', + }); +} /** * Data credits for what the shell uses without data layers: place search, @@ -131,9 +142,12 @@ export function createViewerlessTools({ }; document.addEventListener('visibilitychange', syncVisibility); defer(() => document.removeEventListener('visibilitychange', syncVisibility)); - styleManager._showGlobalStatusNotice?.(NAVARA_MODE_NOTICE.label, { + const notice = navaraModeNotice( + data.catalog?.availability ?? createLayerAvailability('navara'), + ); + styleManager._showGlobalStatusNotice?.(notice.label, { state: 'retry', - detail: NAVARA_MODE_NOTICE.detail, + detail: notice.detail, persistent: true, }); window.__godsEyeView = { diff --git a/src/app/sourceComposition.js b/src/app/sourceComposition.js index 14c864ac5..ac61da2e2 100644 --- a/src/app/sourceComposition.js +++ b/src/app/sourceComposition.js @@ -85,7 +85,9 @@ export function resolveCatalogSources(supplied = {}) { return { sources, isConfigured: (name) => !missing.has(name), - createAvailabilityLookup(layers) { + /** `omitted`: layer ids the catalog leaves out on purpose (another + * engine's layers); any other missing consumer is a wiring error. */ + createAvailabilityLookup(layers, { omitted = new Set() } = {}) { const byId = new Map(layers.map((layer) => [layer.id, layer])); const availability = new Map(); for (const [name, { layers: ids }] of Object.entries( @@ -93,7 +95,10 @@ export function resolveCatalogSources(supplied = {}) { )) { for (const id of ids) { const layer = byId.get(id); - if (!layer) throw new Error(`Missing source consumer: ${id}`); + if (!layer) { + if (omitted.has(id)) continue; + throw new Error(`Missing source consumer: ${id}`); + } availability.set( id, Object.freeze({ diff --git a/src/app/viewerlessTools.test.mjs b/src/app/viewerlessTools.test.mjs index 38413e3d8..9575a1ed5 100644 --- a/src/app/viewerlessTools.test.mjs +++ b/src/app/viewerlessTools.test.mjs @@ -1,7 +1,11 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { createApplicationTools } from './tools.js'; -import { NAVARA_MODE_NOTICE } from './navaraMode.js'; +import { navaraModeNotice } from './navaraMode.js'; +import { + createLayerAvailability, + createRendererRegistry, +} from './layers/renderers.js'; import { createFakeEngine } from '../globe/testing/fakeEngine.js'; import { _resetRenderGovernorForTest, @@ -66,19 +70,27 @@ test('without a viewer the tools run the governor on the engine, show the notice assert.equal(chrome.length, 1); assert.equal(chrome[0].initializeWelcome, null, 'no first-run welcome'); assert.equal(chrome[0].styleManager, styleManager); + const notice = navaraModeNotice(createLayerAvailability('navara')); assert.deepEqual(notices, [ { - label: NAVARA_MODE_NOTICE.label, + label: notice.label, options: { state: 'retry', - detail: NAVARA_MODE_NOTICE.detail, + detail: notice.detail, persistent: true, }, }, ]); assert.match( - `${NAVARA_MODE_NOTICE.label}: ${NAVARA_MODE_NOTICE.detail}`, - /^Experimental Navara engine: layers, cockpit, director and voice are unavailable$/i, + `${notice.label}: ${notice.detail}`, + /^Experimental Navara engine: no data layers on this engine yet; cockpit, director and voice are unavailable$/i, + ); + // With Navara renderers, the notice names the layers it shows. + const registry = createRendererRegistry(); + registry.register('navara', { satellites: () => ({}), flights: () => ({}) }); + assert.equal( + navaraModeNotice(createLayerAvailability('navara', { registry })).detail, + 'Data layers: Live Flights, Satellites; the other layers, cockpit, director and voice are unavailable', ); // The governor drives the engine's continuous rendering. assert.equal(isRenderGovernorInstalled(engine), true); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2b94d81d7..8005fc555 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -2793,3 +2793,49 @@ test('explicit provider switches reach durable state without adopting unrelated coordinator.destroy(); } }); + +test('a link through an engine without some layers keeps their state and activates nothing', async () => { + // The Navara catalog: only satellites registered. + const manager = new DataLayerManager({}); + const enabled = []; + manager.register( + fakeLayer('satellites', { enable: () => (enabled.push('satellites'), true) }), + ); + manager.finalizeRegistrations( + LAYER_STATE_REGISTRY.filter(({ id }) => id === 'satellites'), + ); + const shared = normalizeLayerState({ + enabledLayerIds: ['flights', 'ais-live-vessels', 'satellites'], + }); + const sink = shareSink(); + const coordinator = new LayerStateCoordinator(manager, sink, { + storage: memoryStorage(), + }); + const results = await coordinator.start({ + shareLayerState: shared, + shareCreatedAtMs: 1_000, + }); + assert.deepEqual(enabled, ['satellites'], 'only the registered layer runs'); + const missing = results + .filter(({ phase, targetEnabled }) => phase === 'missing' && targetEnabled) + .map(({ layerId }) => layerId) + .sort(); + assert.deepEqual(missing, ['ais-live-vessels', 'flights']); + // Re-saved (the link the user copies on Navara): the state is all there. + assert.deepEqual( + sink.provider().enabledLayerIds, + ['ais-live-vessels', 'flights', 'satellites'], + ); + // An explicit change of a layer that is here keeps the others too. + await manager.setEnabled('satellites', false, { origin: 'user' }); + assert.deepEqual(sink.provider().enabledLayerIds, [ + 'ais-live-vessels', + 'flights', + ]); + // And back on Cesium the link turns them on. + const decoded = decodeLayerStateParams( + new URLSearchParams(encode(sink.provider())), + ); + assert.deepEqual(decoded.enabledLayerIds, ['ais-live-vessels', 'flights']); + coordinator.destroy(); +}); diff --git a/src/data/lifecycle.js b/src/data/lifecycle.js index 0df5e87a4..5a1eb9d8a 100644 --- a/src/data/lifecycle.js +++ b/src/data/lifecycle.js @@ -1,3 +1,4 @@ +import { engineFromViewer } from '../globe/cesium/engineFromViewer.js'; import { cancelCameraArrival } from './cameraArrival.js'; function cloneLayerParams(value) { @@ -74,12 +75,20 @@ function refreshFailureFromStats(stats, label) { * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { + /** + * @param {object | null} viewer The Cesium viewer (null on Navara). + * @param {{ engine?: import('../globe/contract.js').GlobeEngine, + * allowQaRegistration?: boolean, getSourceAvailability?: Function }} [options] + * `engine` (default: the viewer's registered engine): every module call + * gets `{ signal, engine }` as its second argument. + */ constructor( viewer, - { allowQaRegistration = false, getSourceAvailability } = {}, + { allowQaRegistration = false, getSourceAvailability, engine } = {}, ) { this._catalogSourceAvailability = getSourceAvailability; this.viewer = viewer; + this.engine = engine ?? engineFromViewer(viewer) ?? null; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } this._listeners = new Set(); @@ -92,6 +101,11 @@ export class LayerLifecycle { this._qaLayerIds = new Set(); } + /** The second argument of every layer module call: `{ signal, engine }`. */ + _moduleContext(signal = null) { + return { signal, engine: this.engine }; + } + register(layerModule) { if (this._registrationsFinalized) { throw new Error('Data-layer registrations are finalized'); @@ -292,7 +306,10 @@ export class LayerLifecycle { let result; let failure = null; try { - result = await entry.module.update(this.viewer, { signal }); + result = await entry.module.update( + this.viewer, + this._moduleContext(signal), + ); // Even a rejected/partial update may have changed the data exposed by // the layer. Publish before classifying its result so observers see it. this._publishActivity({ type: 'data-updated', layerId }); @@ -739,7 +756,9 @@ export class LayerLifecycle { let compensated = false; let compensationError = null; try { - compensated = (await entry.module.enable(this.viewer)) !== false; + compensated = + (await entry.module.enable(this.viewer, this._moduleContext())) !== + false; if (!compensated) { compensationError = lifecycleRejectedError( layerId, @@ -757,7 +776,10 @@ export class LayerLifecycle { if (!compensated) { try { cleanupConfirmed = - (await entry.module.disable(this.viewer)) !== false; + (await entry.module.disable( + this.viewer, + this._moduleContext(), + )) !== false; } catch (error) { console.warn( `[Data] ${layerId} cancelled-disable final cleanup error:`, @@ -805,7 +827,10 @@ export class LayerLifecycle { return false; }; try { - const disabled = await entry.module.disable(this.viewer, { signal }); + const disabled = await entry.module.disable( + this.viewer, + this._moduleContext(signal), + ); if (disabled === false) throw lifecycleRejectedError(layerId, 'disable'); } catch (e) { @@ -854,7 +879,7 @@ export class LayerLifecycle { // where an asynchronous enable finishes after this callback. try { abortCleanup = Promise.resolve( - entry.module.disable(this.viewer), + entry.module.disable(this.viewer, this._moduleContext()), ).catch((error) => { console.warn( `[Data] ${layerId} cancelled-enable cleanup error:`, @@ -883,7 +908,8 @@ export class LayerLifecycle { let cleanupConfirmed = false; try { cleanupConfirmed = - (await entry.module.disable(this.viewer)) !== false; + (await entry.module.disable(this.viewer, this._moduleContext())) !== + false; } catch (error) { console.warn( `[Data] ${layerId} cancelled-enable final cleanup error:`, @@ -924,7 +950,8 @@ export class LayerLifecycle { let cleanupConfirmed = false; try { cleanupConfirmed = - (await entry.module.disable(this.viewer)) !== false; + (await entry.module.disable(this.viewer, this._moduleContext())) !== + false; } catch (cleanupError) { console.warn( `[Data] ${layerId} failed-enable cleanup error:`, @@ -950,7 +977,10 @@ export class LayerLifecycle { if (!entry.initialized) { this._setVisibilityIntentPhase(entry, intentEpoch, 'init'); try { - const initialized = await entry.module.init(this.viewer, { signal }); + const initialized = await entry.module.init( + this.viewer, + this._moduleContext(signal), + ); if (initialized === false) throw lifecycleRejectedError(layerId, 'init'); entry.initialized = true; @@ -990,7 +1020,10 @@ export class LayerLifecycle { entry.lifecycleUncertain = false; this._setVisibilityIntentPhase(entry, intentEpoch, 'enable'); try { - const enabled = await entry.module.enable(this.viewer, { signal }); + const enabled = await entry.module.enable( + this.viewer, + this._moduleContext(signal), + ); if (enabled === false) throw lifecycleRejectedError(layerId, 'enable'); } catch (e) { if (signal?.aborted || isAbortError(e)) { @@ -1006,7 +1039,10 @@ export class LayerLifecycle { // First update immediately this._setVisibilityIntentPhase(entry, intentEpoch, 'update'); try { - const updated = await entry.module.update(this.viewer, { signal }); + const updated = await entry.module.update( + this.viewer, + this._moduleContext(signal), + ); if (updated === false) throw lifecycleRejectedError(layerId, 'update'); } catch (e) { if (signal?.aborted || isAbortError(e)) { @@ -2146,7 +2182,10 @@ export class LayerLifecycle { entry.latestQueuedAbsoluteIntent = null; if (entry.enabled) { try { - const disabled = await entry.module.disable(this.viewer); + const disabled = await entry.module.disable( + this.viewer, + this._moduleContext(), + ); if (disabled === false) throw lifecycleRejectedError(layerId, 'destroy-disable'); } catch (e) { @@ -2165,7 +2204,10 @@ export class LayerLifecycle { } if (typeof entry.module.destroy === 'function') { try { - const destroyed = await entry.module.destroy(this.viewer); + const destroyed = await entry.module.destroy( + this.viewer, + this._moduleContext(), + ); if (destroyed === false) throw lifecycleRejectedError(layerId, 'destroy'); } catch (e) { diff --git a/src/data/lifecycleEngine.test.mjs b/src/data/lifecycleEngine.test.mjs new file mode 100644 index 000000000..8a8ca9cdd --- /dev/null +++ b/src/data/lifecycleEngine.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { LayerLifecycle } from './lifecycle.js'; +import { createFakeEngine } from '../globe/testing/fakeEngine.js'; + +// A layer module that records the second argument of every lifecycle call. +function recordingLayer(id) { + const calls = []; + const record = (name) => (_viewer, context) => { + calls.push([name, context]); + return true; + }; + return { + calls, + module: { + id, + name: id, + icon: '', + source: 'test', + init: record('init'), + enable: record('enable'), + update: record('update'), + disable: record('disable'), + destroy: record('destroy'), + }, + }; +} + +test('every layer module call gets { signal, engine }, also with no viewer', async () => { + const engine = createFakeEngine({ kind: 'navara' }); + const lifecycle = new LayerLifecycle(null, { engine }); + assert.equal(lifecycle.engine, engine); + const layer = recordingLayer('probe'); + lifecycle.register(layer.module); + lifecycle.finalizeRegistrations([ + { id: 'probe', disposition: 'enabled-only', token: 'z' }, + ]); + await lifecycle.setEnabled('probe', true, { origin: 'user' }); + await lifecycle.setEnabled('probe', false, { origin: 'user' }); + await lifecycle.destroyAll(); + const names = layer.calls.map(([name]) => name); + for (const name of ['init', 'enable', 'disable', 'destroy']) + assert.ok(names.includes(name), `${name} was called: ${names}`); + for (const [name, context] of layer.calls) { + assert.equal(context?.engine, engine, `${name} got the engine`); + assert.ok('signal' in context, `${name} got a signal slot`); + } +}); diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs index c4f66f832..c96e49249 100644 --- a/src/devCctv.test.mjs +++ b/src/devCctv.test.mjs @@ -28,7 +28,7 @@ async function launch(overrides = {}, dotenv = '', omitCctv = false) { await fs.writeFile(path.join(root, 'src', 'standalone', 'catalog.js'), catalog); await fs.mkdir(path.join(root, 'src', 'app'), { recursive: true }); const assembly = await fs.readFile(new URL('./app/constructCatalog.js', import.meta.url), 'utf8'); - await fs.writeFile(path.join(root, 'src', 'app', 'constructCatalog.js'), omitCctv ? assembly.replace(/^\s*createApplicationCctv\(.*$/m, '') : assembly); + await fs.writeFile(path.join(root, 'src', 'app', 'constructCatalog.js'), omitCctv ? assembly.replace(/^.*createApplicationCctv\(.*$/m, '') : assembly); await fs.mkdir(path.join(root, 'node_modules')); await fs.symlink(fileURLToPath(new URL('.', import.meta.resolve('vite/package.json'))), path.join(root, 'node_modules', 'vite'), 'dir'); await fs.writeFile(path.join(root, '.env'), dotenv); diff --git a/src/standalone/application.js b/src/standalone/application.js index ac6d60958..40c5d4d04 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -7,7 +7,6 @@ import { createStandaloneControls } from './controls.js'; import { createStandaloneData } from './data.js'; import { createStandaloneTools } from './tools.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; -import { createLayerCatalog } from '../app/catalog.js'; import { resolveEngineKind } from '../app/engineChoice.js'; // The existing controls and layer catalog contain page-scoped state. @@ -63,13 +62,11 @@ export function createStandaloneApplication({ loaderStatus, engineKind, }); - // The experimental Navara engine runs without data layers: an empty - // catalog with the same interface. - if (engineKind === 'navara') { - catalog = createLayerCatalog([], []); - return scene; - } + // The catalog of this engine: on Navara only the layers with a Navara + // renderer (src/app/layers/renderers.js; none yet), the others' state + // kept for links. catalog = createStandaloneCatalog({ + engineKind, sources, streetLevelProviders, nepalBoundaryResolver: (signal) => diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index 1ac6ce931..d58134fd4 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -7,6 +7,7 @@ export { createStandaloneReferenceSources } from './layerSources.js'; /** Create fresh layer instances using the existing standalone source choices. */ export function createStandaloneCatalog({ nepalBoundaryResolver, + engineKind = 'cesium', sources = {}, streetLevelProviders, signal = new AbortController().signal, @@ -16,6 +17,7 @@ export function createStandaloneCatalog({ }), } = {}) { return createApplicationCatalog({ + engineKind, nepalBoundaryResolver, surface, sources: createStandaloneLayerSources(sources), diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index 412688336..c7bd64cf6 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -1,4 +1,16 @@ import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; +import { APPLICATION_LAYER_DIRECTORY } from '../app/layers/directory.js'; + +/** Names of the layers a link turned on that this engine does not have. */ +export function layersNotOnThisEngine(results) { + return results + .filter(({ phase, targetEnabled }) => phase === 'missing' && targetEnabled) + .map( + ({ layerId }) => + APPLICATION_LAYER_DIRECTORY.find(({ id }) => id === layerId)?.name ?? + layerId, + ); +} import { stampInitialShareGesture } from '../navigationPolicy.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { UiLifetime } from './uiLifetime.js'; @@ -159,11 +171,26 @@ export class ShareRestoration { { clearSelection: true }, ); } - void this._layerStateRestorePromise.then(() => { + void this._layerStateRestorePromise.then((results) => { this.syncModels3d(this._layerStateCoordinator?.getDurableState()); + this._noticeLayersNotOnThisEngine(results); }); } } + /** + * A link that turns on layers this engine's catalog leaves out (Navara): + * they stay off here, their state stays in the link, and the notice names + * them. + */ + _noticeLayersNotOnThisEngine(results) { + if (this._disposed || !Array.isArray(results)) return; + const names = layersNotOnThisEngine(results); + if (!names.length) return; + this.showStatus('NOT ON THIS ENGINE', { + state: 'retry', + detail: `Kept in the link: ${names.join(', ')}`, + }); + } cancelSelection() { if ( this._hasShareState && diff --git a/src/ui/shareRestorationEngine.test.mjs b/src/ui/shareRestorationEngine.test.mjs new file mode 100644 index 000000000..e595896bc --- /dev/null +++ b/src/ui/shareRestorationEngine.test.mjs @@ -0,0 +1,16 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { layersNotOnThisEngine } from './shareRestoration.js'; + +test('the notice names the link layers this engine leaves out', () => { + assert.deepEqual( + layersNotOnThisEngine([ + { layerId: 'flights', phase: 'missing', targetEnabled: true }, + { layerId: 'ais-live-vessels', phase: 'missing', targetEnabled: true }, + { layerId: 'traffic', phase: 'missing', targetEnabled: false }, + { layerId: 'satellites', phase: 'settled', targetEnabled: true }, + ]), + ['Live Flights', 'Live AIS Vessels'], + ); + assert.deepEqual(layersNotOnThisEngine([]), []); +});