diff --git a/CHANGELOG.md b/CHANGELOG.md index ef83d9451..6381678c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Construct application layers from explicit sources, with standalone provider selection and catalog-owned aircraft classification; controls and voice queries use those instances. + - Let application data and controls receive the same explicit layer catalog; keep standalone defaults and registration-before-restoration ordering. - Expose existing FIRMS CSV parsing and UTC time-window helpers through a portable package export, with shared contract fixtures. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 71b3e3994..73da4a0ae 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -409,3 +409,24 @@ that schema requires a corresponding codec change. `standalone/catalog` selects the existing page-scoped default instances and metadata. Reusable data setup imports no standalone layer defaults. The current compatibility source setters remain available while callers migrate to instance construction. + +### Layer construction + +`application/layers` constructs the current catalog from explicit source objects +and an application AbortSignal. Small `src/app/layers` modules wire existing scene +services into each family factory. Standalone provider selection lives in +`src/standalone/layerSources.js`. The construction export has its own checked +dependency graph, excluding standalone setup and compatibility layer instances. + +Both aircraft layers share the catalog's classification registry; launches use +its satellites and Contacts uses its aircraft, vessels and installations. Data +registration, controls and voice actions read those same instances. Destruction +remains the manager's responsibility; classification also observes application +abort when startup has not reached registration. Scene engines remain page-owned, +so this change does not introduce multiple simultaneous viewers. + +Direct `src/data` compatibility entries retain their old defaults and testing +exports. Browser regression probes use the registered instance's testing surface +to avoid accidentally inspecting an unused compatibility instance. Existing source +setters apply only to compatibility instances; the normal application supplies +its sources at construction. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index c90aa51a8..1750b0ae5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,13 @@ # God's Eye View Current State +Application startup constructs fresh layer instances from explicit source objects. +Standalone composition selects the existing providers. Controls, launch orbit +lookups, Contacts voice answers and ISS pass queries use the registered catalog. +Aircraft classification is catalog-owned and cancelled with application teardown; +scene engines still support one application per page. Compatibility data entrypoints +remain for direct callers, but normal startup does not configure global sources. + + Application data setup receives an ordered layer catalog and serialization metadata. Controls use the same instances. The standalone composition selects the existing default catalog, and restoration starts only after all registrations are sealed. diff --git a/package.json b/package.json index ae62bc4de..737bbda92 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "node": "./build/application-html.js" }, "./application/catalog": "./src/app/catalog.js", - "./standalone/catalog": "./src/standalone/catalog.js" + "./standalone/catalog": "./src/standalone/catalog.js", + "./application/layers": "./src/app/constructCatalog.js" } } diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index ba8966515..6ec989ed6 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -246,7 +246,8 @@ if [[ ! -f "$SOURCE_ROOT/src/data/cctv.js" ]]; then exit 1 fi -if ! grep -q "^[[:space:]]*cctvLayer," "$SOURCE_ROOT/src/standalone/catalog.js"; then +if ! grep -q "return createApplicationCatalog(" "$SOURCE_ROOT/src/standalone/catalog.js" || \ + ! grep -q "^[[:space:]]*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 644b34c26..698c18c2f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -558,5 +558,24 @@ "src/toolProjectRoot.test.mjs", "src/app/catalog.js", "src/standalone/catalog.js", - "src/app/catalog.test.mjs" + "src/app/catalog.test.mjs", + "src/app/layers/militaryInstallations.js", + "src/app/layers/alprCameras.js", + "src/app/layers/traffic.js", + "src/app/layers/submarineCables.js", + "src/app/layers/militaryFlights.js", + "src/app/layers/bikeshare.js", + "src/app/layers/flights.js", + "src/app/layers/radio.js", + "src/app/layers/earthquakes.js", + "src/app/layers/cctv.js", + "src/app/layers/firms.js", + "src/app/layers/satellites.js", + "src/app/layers/militaryAwareness.js", + "src/app/layers/rocketLaunches.js", + "src/app/layers/overlayHost.js", + "src/app/layers/aisLiveVessels.js", + "src/app/constructCatalog.js", + "src/standalone/layerSources.js", + "src/app/constructCatalog.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 4463c9372..e04ca1225 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1503,7 +1503,25 @@ "src/weatherEffectsMath.js", "src/worldFocus.js", "src/app/catalog.js", - "src/standalone/catalog.js" + "src/standalone/catalog.js", + "src/app/layers/militaryInstallations.js", + "src/app/layers/alprCameras.js", + "src/app/layers/traffic.js", + "src/app/layers/submarineCables.js", + "src/app/layers/militaryFlights.js", + "src/app/layers/bikeshare.js", + "src/app/layers/flights.js", + "src/app/layers/radio.js", + "src/app/layers/earthquakes.js", + "src/app/layers/cctv.js", + "src/app/layers/firms.js", + "src/app/layers/satellites.js", + "src/app/layers/militaryAwareness.js", + "src/app/layers/rocketLaunches.js", + "src/app/layers/overlayHost.js", + "src/app/layers/aisLiveVessels.js", + "src/app/constructCatalog.js", + "src/standalone/layerSources.js" ], "external": [ "@mapbox/vector-tile", @@ -1518,5 +1536,328 @@ "exports": ["./application/catalog"], "modules": ["src/app/catalog.js"], "external": [] + }, + "application-layer-construction": { + "exports": ["./application/layers"], + "modules": [ + "src/annotations/annotationResolver.js", + "src/app/catalog.js", + "src/app/constructCatalog.js", + "src/app/layers/aisLiveVessels.js", + "src/app/layers/alprCameras.js", + "src/app/layers/bikeshare.js", + "src/app/layers/cctv.js", + "src/app/layers/earthquakes.js", + "src/app/layers/firms.js", + "src/app/layers/flights.js", + "src/app/layers/militaryAwareness.js", + "src/app/layers/militaryFlights.js", + "src/app/layers/militaryInstallations.js", + "src/app/layers/overlayHost.js", + "src/app/layers/radio.js", + "src/app/layers/rocketLaunches.js", + "src/app/layers/satellites.js", + "src/app/layers/submarineCables.js", + "src/app/layers/traffic.js", + "src/app/sourceSlot.js", + "src/cctvFocusRequest.js", + "src/celestialRing.js", + "src/cockpitMath.js", + "src/data/aircraftClass.js", + "src/data/aircraftIcons.js", + "src/data/aircraftMeta.js", + "src/data/aircraftNearbyPolicy.js", + "src/data/aircraftRecession.js", + "src/data/cctvCards.js", + "src/data/cctvFootprint.js", + "src/data/cctvGizmo.js", + "src/data/cctvLod.js", + "src/data/cctvViewshed.js", + "src/data/cockpitAirLod.js", + "src/data/cockpitContactDot.js", + "src/data/contactMatch.js", + "src/data/contextStore.js", + "src/data/dataCredits.js", + "src/data/detectionCohort.js", + "src/data/detectionDraw.js", + "src/data/detectionPolicy.js", + "src/data/fireAnchors.js", + "src/data/firmsAdapt.js", + "src/data/firmsLabels.js", + "src/data/flowMatch.js", + "src/data/focusDeemphasis.js", + "src/data/geoid.js", + "src/data/groundFloor.js", + "src/data/groundSnap.js", + "src/data/iconOrientation.js", + "src/data/infrastructure.js", + "src/data/installationFeedback.js", + "src/data/issPass.js", + "src/data/labelArbiter.js", + "src/data/layerState.js", + "src/data/localGeojson.js", + "src/data/localGeojsonCore.js", + "src/data/localGeojsonLod.js", + "src/data/local_data/natural_earth/marine.json", + "src/data/local_data/natural_earth/regions.json", + "src/data/local_data/neighborhoods/san-francisco.json", + "src/data/meshFloorSampler.js", + "src/data/militaryAwarenessEngine.js", + "src/data/militaryInstallationData.js", + "src/data/modelVisualAnchor.js", + "src/data/motionModel.js", + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/data/pickRegistry.js", + "src/data/radioCountry.js", + "src/data/renderAltitude.js", + "src/data/retryableLoad.js", + "src/data/routePlausible.js", + "src/data/satelliteClass.js", + "src/data/scenePick.js", + "src/data/spriteOrder.js", + "src/data/terrainHeights.js", + "src/data/tomtomTiles.js", + "src/data/tr3bRegistry.js", + "src/data/trackedCamera.js", + "src/data/trackedModelRegime.js", + "src/data/trackedReadout.js", + "src/data/trackingClickGesture.js", + "src/data/trafficBounds.js", + "src/data/trafficFlowStyle.js", + "src/data/trafficPresetStyle.js", + "src/data/trafficQueue.js", + "src/data/trailRenderer.js", + "src/data/vesselLabels.js", + "src/keylessGeocoder.js", + "src/layers/aircraft/classification.js", + "src/layers/alpr/index.js", + "src/layers/alpr/model.js", + "src/layers/alpr/policy.js", + "src/layers/alpr/presentation.js", + "src/layers/alpr/source.js", + "src/layers/awareness/controls.js", + "src/layers/awareness/dependencies.js", + "src/layers/awareness/focus.js", + "src/layers/awareness/history.js", + "src/layers/awareness/index.js", + "src/layers/awareness/ingestion.js", + "src/layers/awareness/lifecycle.js", + "src/layers/awareness/model.js", + "src/layers/awareness/navigation.js", + "src/layers/awareness/panel.js", + "src/layers/awareness/policy.js", + "src/layers/awareness/queries.js", + "src/layers/awareness/rendering.js", + "src/layers/awareness/state.js", + "src/layers/awareness/subject.js", + "src/layers/awareness/testing.js", + "src/layers/bikeshare/controls.js", + "src/layers/bikeshare/index.js", + "src/layers/bikeshare/ingestion.js", + "src/layers/bikeshare/lifecycle.js", + "src/layers/bikeshare/model.js", + "src/layers/bikeshare/policy.js", + "src/layers/bikeshare/queries.js", + "src/layers/bikeshare/registry.js", + "src/layers/bikeshare/rendering.js", + "src/layers/bikeshare/selection.js", + "src/layers/bikeshare/source.js", + "src/layers/bikeshare/state.js", + "src/layers/bikeshare/testing.js", + "src/layers/bikeshare/viewport.js", + "src/layers/cctv/calibration.js", + "src/layers/cctv/cards.js", + "src/layers/cctv/catalog.js", + "src/layers/cctv/controls.js", + "src/layers/cctv/frames.js", + "src/layers/cctv/geometry.js", + "src/layers/cctv/geometryQueue.js", + "src/layers/cctv/ground.js", + "src/layers/cctv/health.js", + "src/layers/cctv/hover.js", + "src/layers/cctv/index.js", + "src/layers/cctv/ingestion.js", + "src/layers/cctv/lifecycle.js", + "src/layers/cctv/model.js", + "src/layers/cctv/navigation.js", + "src/layers/cctv/policy.js", + "src/layers/cctv/presentation.js", + "src/layers/cctv/projection.js", + "src/layers/cctv/rendering.js", + "src/layers/cctv/selection.js", + "src/layers/cctv/source.js", + "src/layers/cctv/state.js", + "src/layers/cctv/testing.js", + "src/layers/earthquakes/index.js", + "src/layers/earthquakes/model.js", + "src/layers/earthquakes/source.js", + "src/layers/firms/anchors.js", + "src/layers/firms/cards.js", + "src/layers/firms/index.js", + "src/layers/firms/ingestion.js", + "src/layers/firms/lifecycle.js", + "src/layers/firms/model.js", + "src/layers/firms/policy.js", + "src/layers/firms/queries.js", + "src/layers/firms/rendering.js", + "src/layers/firms/selection.js", + "src/layers/firms/source.js", + "src/layers/firms/state.js", + "src/layers/firms/viewport.js", + "src/layers/flights/controller.js", + "src/layers/flights/enrichment.js", + "src/layers/flights/evidence.js", + "src/layers/flights/index.js", + "src/layers/flights/ingestion.js", + "src/layers/flights/lifecycle.js", + "src/layers/flights/motion.js", + "src/layers/flights/policy.js", + "src/layers/flights/queries.js", + "src/layers/flights/rendering.js", + "src/layers/flights/state.js", + "src/layers/flights/testing.js", + "src/layers/flights/tracking.js", + "src/layers/installations/controls.js", + "src/layers/installations/index.js", + "src/layers/installations/ingestion.js", + "src/layers/installations/lifecycle.js", + "src/layers/installations/model.js", + "src/layers/installations/policy.js", + "src/layers/installations/rendering.js", + "src/layers/installations/selection.js", + "src/layers/installations/source.js", + "src/layers/installations/state.js", + "src/layers/installations/viewport.js", + "src/layers/launches/camera.js", + "src/layers/launches/controls.js", + "src/layers/launches/index.js", + "src/layers/launches/ingestion.js", + "src/layers/launches/launchPad.js", + "src/layers/launches/lifecycle.js", + "src/layers/launches/model.js", + "src/layers/launches/orbitRendering.js", + "src/layers/launches/overlays.js", + "src/layers/launches/panel.js", + "src/layers/launches/paths.js", + "src/layers/launches/policy.js", + "src/layers/launches/policyHelpers.js", + "src/layers/launches/rendering.js", + "src/layers/launches/replay.js", + "src/layers/launches/selection.js", + "src/layers/launches/source.js", + "src/layers/launches/state.js", + "src/layers/launches/testing.js", + "src/layers/military/controller.js", + "src/layers/military/index.js", + "src/layers/military/ingestion.js", + "src/layers/military/lifecycle.js", + "src/layers/military/motion.js", + "src/layers/military/policy.js", + "src/layers/military/queries.js", + "src/layers/military/rendering.js", + "src/layers/military/state.js", + "src/layers/military/testing.js", + "src/layers/military/tracking.js", + "src/layers/radio/catalogModel.js", + "src/layers/radio/categories.js", + "src/layers/radio/clustering.js", + "src/layers/radio/controls.js", + "src/layers/radio/index.js", + "src/layers/radio/ingestion.js", + "src/layers/radio/interaction.js", + "src/layers/radio/labels.js", + "src/layers/radio/lifecycle.js", + "src/layers/radio/model.js", + "src/layers/radio/navigation.js", + "src/layers/radio/playback.js", + "src/layers/radio/policy.js", + "src/layers/radio/presentation.js", + "src/layers/radio/queries.js", + "src/layers/radio/rendering.js", + "src/layers/radio/selection.js", + "src/layers/radio/source.js", + "src/layers/radio/state.js", + "src/layers/radio/tuning.js", + "src/layers/radio/tuningNoise.js", + "src/layers/radio/volume.js", + "src/layers/satellites/catalog.js", + "src/layers/satellites/controls.js", + "src/layers/satellites/index.js", + "src/layers/satellites/ingestion.js", + "src/layers/satellites/interaction.js", + "src/layers/satellites/labels.js", + "src/layers/satellites/lifecycle.js", + "src/layers/satellites/orbits.js", + "src/layers/satellites/policy.js", + "src/layers/satellites/rendering.js", + "src/layers/satellites/source.js", + "src/layers/satellites/state.js", + "src/layers/satellites/testing.js", + "src/layers/satellites/tracking.js", + "src/layers/submarineCables/geometry.js", + "src/layers/submarineCables/index.js", + "src/layers/submarineCables/ingestion.js", + "src/layers/submarineCables/interaction.js", + "src/layers/submarineCables/lifecycle.js", + "src/layers/submarineCables/overlay.js", + "src/layers/submarineCables/policy.js", + "src/layers/submarineCables/rendering.js", + "src/layers/submarineCables/state.js", + "src/layers/submarineCables/surface.js", + "src/layers/traffic/animation.js", + "src/layers/traffic/controls.js", + "src/layers/traffic/flow.js", + "src/layers/traffic/flowDecode.js", + "src/layers/traffic/flowSource.js", + "src/layers/traffic/index.js", + "src/layers/traffic/ingestion.js", + "src/layers/traffic/lifecycle.js", + "src/layers/traffic/model.js", + "src/layers/traffic/policy.js", + "src/layers/traffic/rendering.js", + "src/layers/traffic/source.js", + "src/layers/traffic/state.js", + "src/layers/traffic/style.js", + "src/layers/traffic/timing.js", + "src/layers/traffic/viewport.js", + "src/layers/vessels/cards.js", + "src/layers/vessels/evidence.js", + "src/layers/vessels/index.js", + "src/layers/vessels/ingestion.js", + "src/layers/vessels/lifecycle.js", + "src/layers/vessels/policy.js", + "src/layers/vessels/queries.js", + "src/layers/vessels/rendering.js", + "src/layers/vessels/selection.js", + "src/layers/vessels/state.js", + "src/layers/vessels/store.js", + "src/layers/vessels/testing.js", + "src/layers/vessels/tracking.js", + "src/locations.js", + "src/navigationPolicy.js", + "src/overlays/worldOverlay.js", + "src/overlays/worldOverlayDraw.js", + "src/overlays/worldOverlayTokens.js", + "src/renderGovernor.js", + "src/search/defaults.js", + "src/search/geospatial.js", + "src/search/google.js", + "src/search/http.js", + "src/search/placeSearch.js", + "src/services/application.js", + "src/services/requests.js", + "src/ui/panelRailGeometry.js", + "src/ui/radioTunerModel.js", + "src/worldFocus.js" + ], + "external": [ + "cesium", + "egm96-universal", + "satellite.js", + "@mapbox/vector-tile", + "pbf", + "mgrs" + ] } } diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index f9c31585a..c62c27dd8 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -3813,27 +3813,11 @@ async function main() { const Cesium = await import('/node_modules/cesium/Build/Cesium/index.js'); const v = window.__godsEyeView.viewer; const fl = window.__godsEyeView.dataManager.layers.get('flights').module; - // `.module` is the layer OBJECT (the default export), not the module - // namespace, so the handoff seam is not on it. Reach the namespace the - // same way this group reaches groundFloor's: offer the URL the app - // itself loaded (Vite serves an edited file as `…?t=`, and - // the plain path would hand back a second, unrelated instance whose - // module state the app never touches), then prove identity by requiring - // its default export to BE the live layer object. - let ns = null; - const urls = [...new Set([ - ...performance.getEntriesByType('resource').map((e) => e.name) - .filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(), - `${window.__gevQaSourceBase || '/src'}/data/flights.js`, - ])]; - for (const url of urls) { - let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; } - if (mod?.default === fl && typeof mod._driveFleetModelHandoffForTest === 'function') { - ns = mod; - break; - } - } - if (!ns) return { skipped: `the app's own flights module was not reachable (tried ${urls.length})` }; + // Test the registered instance directly, including catalogs constructed + // with their own sources. Never import a second compatibility instance. + const ns = fl.testing; + if (typeof ns?._driveFleetModelHandoffForTest !== 'function') + return { error: "the registered flights instance has no handoff test seam" }; // Use a scenario-owned contact so its groundSnap entry is provably cold; // earlier display-floor cases intentionally exercise aaa097's cache. const holdIcao = 'aaa098'; diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js new file mode 100644 index 000000000..40a52e781 --- /dev/null +++ b/src/app/constructCatalog.js @@ -0,0 +1,130 @@ +import { createLayerCatalog } from './catalog.js'; +import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; +import { createMilitaryRegistry } from '../layers/aircraft/classification.js'; +import { createApplicationFlights } from './layers/flights.js'; +import { createApplicationMilitary } from './layers/militaryFlights.js'; +import { createApplicationVessels } from './layers/aisLiveVessels.js'; +import { createApplicationCctv } from './layers/cctv.js'; +import { createApplicationRadio } from './layers/radio.js'; +import { createApplicationTraffic } from './layers/traffic.js'; +import { createApplicationBikeshare } from './layers/bikeshare.js'; +import { createApplicationInstallations } from './layers/militaryInstallations.js'; +import { createApplicationSatellites } from './layers/satellites.js'; +import { createApplicationLaunches } from './layers/rocketLaunches.js'; +import { createApplicationAlpr } from './layers/alprCameras.js'; +import { createApplicationAwareness } from './layers/militaryAwareness.js'; +import { createApplicationFirms } from './layers/firms.js'; +import { createApplicationEarthquakes } from './layers/earthquakes.js'; +import { createApplicationCables } from './layers/submarineCables.js'; +import { createInfrastructureLayers } from '../data/infrastructure.js'; +import { localGeoJsonServices } from '../data/localGeojson.js'; + +const SOURCE_METHODS = Object.freeze({ + flights: ['getSnapshot'], + military: ['getSnapshot'], + vessels: ['getSnapshot'], + cctv: ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], + radio: ['getDirectory', 'recordClick'], + traffic: [ + 'requestRoads', + 'getStatus', + 'fetchFlowForBounds', + 'getFlowSessionStats', + 'resetFlowTileCache', + ], + bikeshare: ['getStations'], + installations: ['getMappedSites', 'searchNearby'], + satellites: ['readGroup'], + launches: ['getLaunches', 'getActiveTle'], + alpr: ['fetch'], + firms: ['getSnapshot'], + earthquakes: ['getSnapshot'], + cables: ['fetch'], +}); + +/** Construct the current catalog without choosing any source provider. + * 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. + */ +export function createApplicationCatalog({ + sources, + signal, + metadata = LAYER_STATE_REGISTRY, + vesselOptions, + resolveAsset, +}) { + if (!signal?.addEventListener) + throw new TypeError('An application lifetime signal is required'); + signal.throwIfAborted(); + for (const [name, methods] of Object.entries(SOURCE_METHODS)) { + if ( + methods.some((method) => typeof sources?.[name]?.[method] !== 'function') + ) + throw new TypeError(`Invalid catalog source: ${name}`); + } + const militaryRegistry = createMilitaryRegistry(); + const dispose = () => { + signal.removeEventListener('abort', dispose); + militaryRegistry.dispose(); + }; + signal.addEventListener('abort', dispose, { once: true }); + try { + militaryRegistry.configureSource(sources.military, { signal }); + const flights = createApplicationFlights({ + source: sources.flights, + militaryRegistry, + resolveAsset, + }); + const military = createApplicationMilitary({ + source: sources.military, + militaryRegistry, + resolveAsset, + }); + const vessels = createApplicationVessels({ + source: sources.vessels, + options: vesselOptions, + }); + const installations = createApplicationInstallations({ + source: sources.installations, + }); + const satellites = createApplicationSatellites({ + source: sources.satellites, + }); + const catalog = createLayerCatalog( + [ + flights, + military, + createApplicationEarthquakes({ source: sources.earthquakes }), + createApplicationAlpr({ source: sources.alpr }), + satellites, + createApplicationLaunches({ source: sources.launches, satellites }), + createApplicationTraffic({ source: sources.traffic }), + createApplicationCctv({ source: sources.cctv }), + createApplicationRadio({ source: sources.radio }), + createApplicationBikeshare({ source: sources.bikeshare }), + vessels, + installations, + createApplicationAwareness({ + flights, + military, + vessels, + installations, + }), + ...createInfrastructureLayers(localGeoJsonServices), + createApplicationCables({ source: sources.cables }), + createApplicationFirms({ + id: 'local-firms', + name: 'FIRMS Active Fires', + icon: '▲', + source: 'NASA FIRMS · LIVE', + feed: sources.firms, + }), + ], + metadata, + ); + return Object.freeze({ ...catalog, militaryRegistry }); + } catch (error) { + dispose(); + throw error; + } +} diff --git a/src/app/constructCatalog.test.mjs b/src/app/constructCatalog.test.mjs new file mode 100644 index 000000000..44fc7766c --- /dev/null +++ b/src/app/constructCatalog.test.mjs @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationCatalog } from './constructCatalog.js'; +import { createStandaloneLayerSources } from '../standalone/layerSources.js'; +import { catalogControlServices } from './catalog.js'; + +function fixtureSources(ids, calls) { + const sources = createStandaloneLayerSources(); + sources.military = { + getSnapshot: async () => { + throw new Error('Use identity acquisition'); + }, + async getIdentities(_query, { signal }) { + calls.push(signal); + return ids; + }, + }; + return sources; +} + +test('catalogs construct distinct layers and classification from their supplied source', async (t) => { + const a = new AbortController(); + const b = new AbortController(); + t.after(() => { + a.abort(); + b.abort(); + }); + const callsA = []; + const callsB = []; + const first = createApplicationCatalog({ + sources: fixtureSources(['abc123'], callsA), + signal: a.signal, + }); + const second = createApplicationCatalog({ + sources: fixtureSources(['def456'], callsB), + signal: b.signal, + }); + assert.equal(first.layers.length, 17); + assert.deepEqual( + first.layers.map(({ id }) => id), + second.layers.map(({ id }) => id), + ); + for (const layer of first.layers) + assert.notEqual(layer, second.get(layer.id)); + assert.equal( + catalogControlServices(first).satellitesLayer, + first.get('satellites'), + ); + assert.equal(callsA.length, 0, 'construction must not acquire'); + await first.militaryRegistry.refreshMilitaryRegistryIfStale(); + await second.militaryRegistry.refreshMilitaryRegistryIfStale(); + assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), true); + assert.equal(first.militaryRegistry.isMilitaryIcao('def456'), false); + assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true); + a.abort(); + assert.equal(callsA[0].aborted, true); + assert.equal(first.militaryRegistry.isMilitaryIcao('abc123'), false); + assert.equal(callsB[0].aborted, false); + assert.equal(second.militaryRegistry.isMilitaryIcao('def456'), true); +}); + +test('invalid or already cancelled construction fails before classification can acquire', () => { + const lifetime = new AbortController(); + assert.throws( + () => createApplicationCatalog({ sources: {}, signal: lifetime.signal }), + /catalog source/, + ); + lifetime.abort(); + assert.throws( + () => + createApplicationCatalog({ + sources: createStandaloneLayerSources(), + signal: lifetime.signal, + }), + { name: 'AbortError' }, + ); +}); diff --git a/src/app/data.js b/src/app/data.js index a2c73c338..14fd7df68 100644 --- a/src/app/data.js +++ b/src/app/data.js @@ -49,5 +49,5 @@ export function createApplicationData({ dataManager.buildTogglePanel(document.getElementById('data-toggles')); styleManager.attachDataManager(dataManager); - return { dataManager }; + return { dataManager, catalog }; } diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js new file mode 100644 index 000000000..32dfb8638 --- /dev/null +++ b/src/app/layers/aisLiveVessels.js @@ -0,0 +1,31 @@ +import { createVesselLayer } from '../../layers/vessels/index.js'; +import * as context from '../../data/contextStore.js'; +import * as trails from '../../data/trailRenderer.js'; +import * as labels from '../../data/detectionDraw.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlay from '../../overlays/worldOverlay.js'; +import * as geoid from '../../data/geoid.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as focus from '../../data/focusDeemphasis.js'; +import * as worldFocus from '../../worldFocus.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationVessels({ source, options = {} }) { + return createVesselLayer({ + source, + options, + services: { + context, + trails, + labels, + picking, + overlay, + geoid, + sprites, + focus, + worldFocus, + render, + }, + }); +} diff --git a/src/app/layers/alprCameras.js b/src/app/layers/alprCameras.js new file mode 100644 index 000000000..4930d75f8 --- /dev/null +++ b/src/app/layers/alprCameras.js @@ -0,0 +1,13 @@ +import { createAlprCamerasLayer } from '../../layers/alpr/index.js'; +import * as render from '../../renderGovernor.js'; +import * as context from '../../data/contextStore.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as groundFloor from '../../data/groundFloor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationAlpr({ source }) { + return createAlprCamerasLayer({ + source, + services: { render, context, picking, groundFloor }, + }); +} diff --git a/src/app/layers/bikeshare.js b/src/app/layers/bikeshare.js new file mode 100644 index 000000000..590808a96 --- /dev/null +++ b/src/app/layers/bikeshare.js @@ -0,0 +1,13 @@ +import { createBikeshareLayer } from '../../layers/bikeshare/index.js'; +import * as render from '../../renderGovernor.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationBikeshare({ source }) { + return createBikeshareLayer({ + source, + services: { render, sprites, picking, overlays }, + }); +} diff --git a/src/app/layers/cctv.js b/src/app/layers/cctv.js new file mode 100644 index 000000000..0d2f1cbaf --- /dev/null +++ b/src/app/layers/cctv.js @@ -0,0 +1,30 @@ +import { createCctvLayer } from '../../layers/cctv/index.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as activation from '../../cctvFocusRequest.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import * as locations from '../../locations.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as terrain from '../../data/terrainHeights.js'; +import * as ground from '../../data/groundFloor.js'; +import * as mesh from '../../data/meshFloorSampler.js'; +import * as focus from '../../data/focusDeemphasis.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationCctv({ source }) { + return createCctvLayer({ + source, + services: { + sprites, + activation, + overlays, + locations, + picking, + terrain, + ground, + mesh, + focus, + render, + }, + }); +} diff --git a/src/app/layers/earthquakes.js b/src/app/layers/earthquakes.js new file mode 100644 index 000000000..ae6bc1b63 --- /dev/null +++ b/src/app/layers/earthquakes.js @@ -0,0 +1,6 @@ +import { createEarthquakesLayer } from '../../layers/earthquakes/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire earthquake observations to the application overlay host. */ +export function createApplicationEarthquakes(options) { + return createEarthquakesLayer({ overlayHost, ...options }); +} diff --git a/src/app/layers/firms.js b/src/app/layers/firms.js new file mode 100644 index 000000000..8be70b206 --- /dev/null +++ b/src/app/layers/firms.js @@ -0,0 +1,38 @@ +import * as Cesium from 'cesium'; +import { createFirmsHeatmapLayer as createLayer } from '../../layers/firms/index.js'; +import * as render from '../../renderGovernor.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as context from '../../data/contextStore.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as anchors from '../../data/fireAnchors.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import * as focus from '../../worldFocus.js'; +export const firmsServices = { + render, + sprites, + context, + picking, + anchors, + overlays, + focus, +}; + +/** Bind a supplied fire feed to the application scene service owners. */ +export function createApplicationFirms(options) { + return createLayer({ + ...options, + icon: options.icon ?? '▲', + source: options.source ?? 'NASA FIRMS', + feed: options.feed, + services: firmsServices, + overlayHost: options.overlayHost ?? { + setEntries: overlays.setOverlayEntries, + setVisible: overlays.setOverlaySourceVisible, + clearSource: overlays.clearOverlaySource, + hitTest: overlays.hitTestWorldOverlay, + }, + screenSpaceEventHandlerFactory: + options.screenSpaceEventHandlerFactory ?? + ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), + }); +} diff --git a/src/app/layers/flights.js b/src/app/layers/flights.js new file mode 100644 index 000000000..e56e3d32a --- /dev/null +++ b/src/app/layers/flights.js @@ -0,0 +1,47 @@ +import { createCivilFlightLayer } from '../../layers/flights/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as trails from '../../data/trailRenderer.js'; +import * as aircraftPresentation from '../../data/tr3bRegistry.js'; +import * as camera from '../../data/trackedCamera.js'; +import * as labels from '../../data/detectionDraw.js'; +import * as groundFloor from '../../data/groundFloor.js'; +import * as meshFloor from '../../data/meshFloorSampler.js'; +import * as geoid from '../../data/geoid.js'; +import * as focus from '../../data/focusDeemphasis.js'; +import * as readout from '../../data/trackedReadout.js'; +import * as context from '../../data/contextStore.js'; +import * as render from '../../renderGovernor.js'; +import * as groundSnap from '../../data/groundSnap.js'; +import * as recession from '../../data/aircraftRecession.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationFlights({ + source, + militaryRegistry, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +}) { + return createCivilFlightLayer({ + source, + resolveAsset, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, + }, + }); +} diff --git a/src/app/layers/militaryAwareness.js b/src/app/layers/militaryAwareness.js new file mode 100644 index 000000000..ff52ec3af --- /dev/null +++ b/src/app/layers/militaryAwareness.js @@ -0,0 +1,23 @@ +import { createAwarenessLayer } from '../../layers/awareness/index.js'; +import * as navigation from '../../navigationPolicy.js'; +import * as geometry from '../../celestialRing.js'; +import * as render from '../../renderGovernor.js'; +/** Bind Contacts to the same catalog instances used for rendering. */ +export function createApplicationAwareness({ + flights, + military, + vessels, + installations, +}) { + return createAwarenessLayer({ + services: { + flights, + military, + vessels, + installations, + navigation, + geometry, + render, + }, + }); +} diff --git a/src/app/layers/militaryFlights.js b/src/app/layers/militaryFlights.js new file mode 100644 index 000000000..61fea4662 --- /dev/null +++ b/src/app/layers/militaryFlights.js @@ -0,0 +1,47 @@ +import { createMilitaryFlightLayer } from '../../layers/military/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as sprites from '../../data/spriteOrder.js'; +import * as trails from '../../data/trailRenderer.js'; +import * as aircraftPresentation from '../../data/tr3bRegistry.js'; +import * as camera from '../../data/trackedCamera.js'; +import * as labels from '../../data/detectionDraw.js'; +import * as groundFloor from '../../data/groundFloor.js'; +import * as meshFloor from '../../data/meshFloorSampler.js'; +import * as geoid from '../../data/geoid.js'; +import * as focus from '../../data/focusDeemphasis.js'; +import * as readout from '../../data/trackedReadout.js'; +import * as context from '../../data/contextStore.js'; +import * as render from '../../renderGovernor.js'; +import * as groundSnap from '../../data/groundSnap.js'; +import * as recession from '../../data/aircraftRecession.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationMilitary({ + source, + militaryRegistry, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +}) { + return createMilitaryFlightLayer({ + source, + resolveAsset, + services: { + picking, + sprites, + trails, + aircraftPresentation, + camera, + militaryRegistry, + labels, + groundFloor, + meshFloor, + geoid, + focus, + readout, + context, + render, + groundSnap, + recession, + }, + }); +} diff --git a/src/app/layers/militaryInstallations.js b/src/app/layers/militaryInstallations.js new file mode 100644 index 000000000..b772bd3ac --- /dev/null +++ b/src/app/layers/militaryInstallations.js @@ -0,0 +1,14 @@ +import { createInstallationsLayer } from '../../layers/installations/index.js'; +import * as render from '../../renderGovernor.js'; +import * as context from '../../data/contextStore.js'; +import * as ground from '../../data/groundFloor.js'; +import * as anchors from '../../data/fireAnchors.js'; +import * as picking from '../../data/pickRegistry.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationInstallations({ source }) { + return createInstallationsLayer({ + source, + services: { render, context, ground, anchors, picking }, + }); +} diff --git a/src/app/layers/overlayHost.js b/src/app/layers/overlayHost.js new file mode 100644 index 000000000..cf3672281 --- /dev/null +++ b/src/app/layers/overlayHost.js @@ -0,0 +1,10 @@ +import { + clearOverlaySource, + setOverlayEntries, + setOverlaySourceVisible, +} from '../../overlays/worldOverlay.js'; +export const overlayHost = Object.freeze({ + clearSource: clearOverlaySource, + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, +}); diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js new file mode 100644 index 000000000..3474996dd --- /dev/null +++ b/src/app/layers/radio.js @@ -0,0 +1,14 @@ +import { createRadioLayer } from '../../layers/radio/index.js'; +import * as ground from '../../data/groundFloor.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import * as globe from '../../celestialRing.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationRadio({ source }) { + return createRadioLayer({ + source, + services: { ground, picking, overlays, globe, render }, + }); +} diff --git a/src/app/layers/rocketLaunches.js b/src/app/layers/rocketLaunches.js new file mode 100644 index 000000000..af60bd3b6 --- /dev/null +++ b/src/app/layers/rocketLaunches.js @@ -0,0 +1,12 @@ +import { createRocketLaunchesLayer } from '../../layers/launches/index.js'; +import * as geometry from '../../celestialRing.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationLaunches({ source, satellites }) { + return createRocketLaunchesLayer({ + source, + services: { satellites, geometry, overlays, render }, + }); +} diff --git a/src/app/layers/satellites.js b/src/app/layers/satellites.js new file mode 100644 index 000000000..10eccb39f --- /dev/null +++ b/src/app/layers/satellites.js @@ -0,0 +1,24 @@ +import { createSatellitesLayer } from '../../layers/satellites/index.js'; +import * as picking from '../../data/pickRegistry.js'; +import * as focus from '../../data/focusDeemphasis.js'; +import * as readout from '../../data/trackedReadout.js'; +import * as overlays from '../../overlays/worldOverlay.js'; +import * as context from '../../data/contextStore.js'; +import * as render from '../../renderGovernor.js'; +import * as layerState from '../../data/layerState.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationSatellites({ source }) { + return createSatellitesLayer({ + source, + services: { + picking, + focus, + readout, + overlays, + context, + render, + layerState, + }, + }); +} diff --git a/src/app/layers/submarineCables.js b/src/app/layers/submarineCables.js new file mode 100644 index 000000000..b84c072db --- /dev/null +++ b/src/app/layers/submarineCables.js @@ -0,0 +1,13 @@ +import * as Cesium from 'cesium'; +import { createSubmarineCableLayer } from '../../layers/submarineCables/index.js'; +import { overlayHost } from './overlayHost.js'; +/** Wire cable source geometry to the application overlay host. */ +export function createApplicationCables(options) { + return createSubmarineCableLayer({ + overlayHost, + screenSpaceEventHandlerFactory: (canvas) => + new Cesium.ScreenSpaceEventHandler(canvas), + mapStackEventTarget: typeof window !== 'undefined' ? window : null, + ...options, + }); +} diff --git a/src/app/layers/traffic.js b/src/app/layers/traffic.js new file mode 100644 index 000000000..9ecb50ac3 --- /dev/null +++ b/src/app/layers/traffic.js @@ -0,0 +1,11 @@ +import { createTrafficLayer } from '../../layers/traffic/index.js'; +import * as credits from '../../data/dataCredits.js'; +import * as render from '../../renderGovernor.js'; + +/** Construct one layer using the application scene owners and a supplied source. */ +export function createApplicationTraffic({ source }) { + return createTrafficLayer({ + source, + services: { credits, render }, + }); +} diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 9a63a36cd..348ca26c3 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -1,17 +1,8 @@ -import { createVesselLayer } from '../layers/vessels/index.js'; -import { createAisStreamSource } from '../sources/live/standalone.js'; -import * as context from './contextStore.js'; -import * as trails from './trailRenderer.js'; -import * as labels from './detectionDraw.js'; -import * as picking from './pickRegistry.js'; -import * as overlay from '../overlays/worldOverlay.js'; -import * as geoid from './geoid.js'; -import * as sprites from './spriteOrder.js'; -import * as focus from './focusDeemphasis.js'; -import * as worldFocus from '../worldFocus.js'; -import * as render from '../renderGovernor.js'; +import { createApplicationVessels } from '../app/layers/aisLiveVessels.js'; -const aisLiveVesselsLayer = createVesselLayer({ +import { createAisStreamSource } from '../sources/live/standalone.js'; + +const aisLiveVesselsLayer = createApplicationVessels({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', }), @@ -19,18 +10,6 @@ const aisLiveVesselsLayer = createVesselLayer({ maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS, }, - services: { - context, - trails, - labels, - picking, - overlay, - geoid, - sprites, - focus, - worldFocus, - render, - }, }); export { AIS_FIRST_CONNECT_GRACE_MS } from '../layers/vessels/policy.js'; export const deriveAisFeedError = aisLiveVesselsLayer.deriveAisFeedError; diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js index c4a4575ec..5f5d05a12 100644 --- a/src/data/alprCameras.js +++ b/src/data/alprCameras.js @@ -1,12 +1,6 @@ +import { createApplicationAlpr } from '../app/layers/alprCameras.js'; import { createSourceSlot } from '../app/sourceSlot.js'; -import { - createAlprCamerasLayer, - createOverpassAlprSource, -} from '../layers/alpr/index.js'; -import * as render from '../renderGovernor.js'; -import * as context from './contextStore.js'; -import * as picking from './pickRegistry.js'; -import * as groundFloor from './groundFloor.js'; +import { createOverpassAlprSource } from '../layers/alpr/index.js'; export * from '../layers/alpr/index.js'; const slot = createSourceSlot( createOverpassAlprSource(), @@ -14,7 +8,6 @@ const slot = createSourceSlot( 'ALPR source', ); export const configureAlprSource = slot.configure; -export default createAlprCamerasLayer({ +export default createApplicationAlpr({ source: slot.source, - services: { render, context, picking, groundFloor }, }); diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index 9ebbcd156..e91686fbf 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -1,10 +1,6 @@ +import { createApplicationBikeshare } from '../app/layers/bikeshare.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createBikeshareSource } from '../layers/bikeshare/source.js'; -import { createBikeshareLayer } from '../layers/bikeshare/index.js'; -import * as render from '../renderGovernor.js'; -import * as sprites from './spriteOrder.js'; -import * as picking from './pickRegistry.js'; -import * as overlays from '../overlays/worldOverlay.js'; const sourceSlot = createSourceSlot( createBikeshareSource(), @@ -12,9 +8,8 @@ const sourceSlot = createSourceSlot( 'Bikeshare source', ); export const configureBikeshareSource = sourceSlot.configure; -const layer = createBikeshareLayer({ +const layer = createApplicationBikeshare({ source: sourceSlot.source, - services: { render, sprites, picking, overlays }, }); export const createBikeshareSelectedOverlayEntry = layer.createBikeshareSelectedOverlayEntry; diff --git a/src/data/cctv.js b/src/data/cctv.js index fe0baca25..069cb377c 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,16 +1,6 @@ +import { createApplicationCctv } from '../app/layers/cctv.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createCctvSource } from '../layers/cctv/source.js'; -import { createCctvLayer } from '../layers/cctv/index.js'; -import * as sprites from './spriteOrder.js'; -import * as activation from '../cctvFocusRequest.js'; -import * as overlays from '../overlays/worldOverlay.js'; -import * as locations from '../locations.js'; -import * as picking from './pickRegistry.js'; -import * as terrain from './terrainHeights.js'; -import * as ground from './groundFloor.js'; -import * as mesh from './meshFloorSampler.js'; -import * as focus from './focusDeemphasis.js'; -import * as render from '../renderGovernor.js'; const sourceSlot = createSourceSlot( createCctvSource(), @@ -18,20 +8,8 @@ const sourceSlot = createSourceSlot( 'Cctv source', ); export const configureCctvSource = sourceSlot.configure; -const layer = createCctvLayer({ +const layer = createApplicationCctv({ source: sourceSlot.source, - services: { - sprites, - activation, - overlays, - locations, - picking, - terrain, - ground, - mesh, - focus, - render, - }, }); export const calibrationPatchMovesAnchor = layer.calibrationPatchMovesAnchor; export const migrateRangeScaleForFloor = layer.migrateRangeScaleForFloor; diff --git a/src/data/earthquakes.js b/src/data/earthquakes.js index 6642f3b01..f003bf712 100644 --- a/src/data/earthquakes.js +++ b/src/data/earthquakes.js @@ -1,22 +1,11 @@ -import { - createEarthquakesLayer as createLayer, - createUsgsEarthquakeSource, -} from '../layers/earthquakes/index.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; +import { createUsgsEarthquakeSource } from '../layers/earthquakes/index.js'; +import { createApplicationEarthquakes } from '../app/layers/earthquakes.js'; export * from '../layers/earthquakes/index.js'; /** Wire the standalone source and application overlay owner. */ export function createEarthquakesLayer({ source = createUsgsEarthquakeSource(), - overlayHost = { - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, - clearSource: clearOverlaySource, - }, + ...options } = {}) { - return createLayer({ source, overlayHost }); + return createApplicationEarthquakes({ source, ...options }); } export default createEarthquakesLayer(); diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index 5bdad57d4..89ac3259e 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,33 +1,16 @@ +import { createApplicationFirms, firmsServices } from '../app/layers/firms.js'; import { createSourceSlot } from '../app/sourceSlot.js'; -import * as Cesium from 'cesium'; import { - createFirmsHeatmapLayer as createLayer, createFirmsHelpers, createFirmsSource, } from '../layers/firms/index.js'; -import * as render from '../renderGovernor.js'; -import * as sprites from './spriteOrder.js'; -import * as context from './contextStore.js'; -import * as picking from './pickRegistry.js'; -import * as anchors from './fireAnchors.js'; -import * as overlays from '../overlays/worldOverlay.js'; -import * as focus from '../worldFocus.js'; -const services = { - render, - sprites, - context, - picking, - anchors, - overlays, - focus, -}; const sourceSlot = createSourceSlot( createFirmsSource(), ['getSnapshot'], 'Fire source', ); export const configureFirmsSource = sourceSlot.configure; -const helpers = createFirmsHelpers({ services }); +const helpers = createFirmsHelpers({ services: firmsServices }); export const mapAnalystRecord = helpers.mapAnalystRecord; export const fireCullPosition = helpers.fireCullPosition; export const applyHorizonCull = helpers.applyHorizonCull; @@ -36,22 +19,10 @@ export const buildFireCard = helpers.buildFireCard; export const buildCellCard = helpers.buildCellCard; export const applyFirmsOverlayPolicy = helpers.applyFirmsOverlayPolicy; -/** Supply the local source and the application's scene service owners. */ +/** Retain the existing default feed and helper exports for direct callers. */ export function createFirmsHeatmapLayer(options) { - return createLayer({ + return createApplicationFirms({ ...options, - icon: options.icon ?? '▲', - source: options.source ?? 'NASA FIRMS', feed: options.feed ?? sourceSlot.source, - services, - overlayHost: options.overlayHost ?? { - setEntries: overlays.setOverlayEntries, - setVisible: overlays.setOverlaySourceVisible, - clearSource: overlays.clearOverlaySource, - hitTest: overlays.hitTestWorldOverlay, - }, - screenSpaceEventHandlerFactory: - options.screenSpaceEventHandlerFactory ?? - ((viewer) => new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas)), }); } diff --git a/src/data/flights.js b/src/data/flights.js index 8ab095fcc..47aa2e4cb 100644 --- a/src/data/flights.js +++ b/src/data/flights.js @@ -1,44 +1,11 @@ -import { createCivilFlightLayer } from '../layers/flights/index.js'; -import { createOpenSkySource } from '../sources/live/standalone.js'; -import * as picking from './pickRegistry.js'; -import * as sprites from './spriteOrder.js'; -import * as trails from './trailRenderer.js'; -import * as aircraftPresentation from './tr3bRegistry.js'; -import * as camera from './trackedCamera.js'; -import * as militaryRegistry from './militaryRegistry.js'; -import * as labels from './detectionDraw.js'; -import * as groundFloor from './groundFloor.js'; -import * as meshFloor from './meshFloorSampler.js'; -import * as geoid from './geoid.js'; -import * as focus from './focusDeemphasis.js'; -import * as readout from './trackedReadout.js'; -import * as context from './contextStore.js'; -import * as render from '../renderGovernor.js'; -import * as groundSnap from './groundSnap.js'; -import * as recession from './aircraftRecession.js'; +import { createApplicationFlights } from '../app/layers/flights.js'; -const flightsLayer = createCivilFlightLayer({ +import { createOpenSkySource } from '../sources/live/standalone.js'; +import * as militaryRegistry from './militaryRegistry.js'; + +const flightsLayer = createApplicationFlights({ source: createOpenSkySource(), - resolveAsset: (url) => - `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, - services: { - picking, - sprites, - trails, - aircraftPresentation, - camera, - militaryRegistry, - labels, - groundFloor, - meshFloor, - geoid, - focus, - readout, - context, - render, - groundSnap, - recession, - }, + militaryRegistry, }); export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js'; export const _floorGroundedDisplayPositionForTest = diff --git a/src/data/militaryAwareness.js b/src/data/militaryAwareness.js index 5692f8027..756f142d2 100644 --- a/src/data/militaryAwareness.js +++ b/src/data/militaryAwareness.js @@ -1,22 +1,14 @@ -import { createAwarenessLayer } from '../layers/awareness/index.js'; +import { createApplicationAwareness } from '../app/layers/militaryAwareness.js'; import flights from './flights.js'; import military from './militaryFlights.js'; import vessels from './aisLiveVessels.js'; import installations from './militaryInstallations.js'; -import * as navigation from '../navigationPolicy.js'; -import * as geometry from '../celestialRing.js'; -import * as render from '../renderGovernor.js'; -const layer = createAwarenessLayer({ - services: { - flights, - military, - vessels, - installations, - navigation, - geometry, - render, - }, +const layer = createApplicationAwareness({ + flights, + military, + vessels, + installations, }); export const contextTargetFlyToAllowed = layer.contextTargetFlyToAllowed; export const awarenessClearMatchesSubject = layer.awarenessClearMatchesSubject; diff --git a/src/data/militaryFlights.js b/src/data/militaryFlights.js index a37fc0933..1c2a602ab 100644 --- a/src/data/militaryFlights.js +++ b/src/data/militaryFlights.js @@ -1,44 +1,11 @@ -import { createMilitaryFlightLayer } from '../layers/military/index.js'; -import { createAdsbLolSource } from '../sources/live/standalone.js'; -import * as picking from './pickRegistry.js'; -import * as sprites from './spriteOrder.js'; -import * as trails from './trailRenderer.js'; -import * as aircraftPresentation from './tr3bRegistry.js'; -import * as camera from './trackedCamera.js'; -import * as militaryRegistry from './militaryRegistry.js'; -import * as labels from './detectionDraw.js'; -import * as groundFloor from './groundFloor.js'; -import * as meshFloor from './meshFloorSampler.js'; -import * as geoid from './geoid.js'; -import * as focus from './focusDeemphasis.js'; -import * as readout from './trackedReadout.js'; -import * as context from './contextStore.js'; -import * as render from '../renderGovernor.js'; -import * as groundSnap from './groundSnap.js'; -import * as recession from './aircraftRecession.js'; +import { createApplicationMilitary } from '../app/layers/militaryFlights.js'; -const militaryFlightsLayer = createMilitaryFlightLayer({ +import { createAdsbLolSource } from '../sources/live/standalone.js'; +import * as militaryRegistry from './militaryRegistry.js'; + +const militaryFlightsLayer = createApplicationMilitary({ source: createAdsbLolSource(), - resolveAsset: (url) => - `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, - services: { - picking, - sprites, - trails, - aircraftPresentation, - camera, - militaryRegistry, - labels, - groundFloor, - meshFloor, - geoid, - focus, - readout, - context, - render, - groundSnap, - recession, - }, + militaryRegistry, }); export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js'; export const _setTrackedMilitaryRefreshStateForTest = diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index 67f7e4383..c8b54eef8 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,13 +1,6 @@ +import { createApplicationInstallations } from '../app/layers/militaryInstallations.js'; import { createSourceSlot } from '../app/sourceSlot.js'; -import { - createInstallationsLayer, - createInstallationSource, -} from '../layers/installations/index.js'; -import * as render from '../renderGovernor.js'; -import * as context from './contextStore.js'; -import * as ground from './groundFloor.js'; -import * as anchors from './fireAnchors.js'; -import * as picking from './pickRegistry.js'; +import { createInstallationSource } from '../layers/installations/index.js'; const sourceSlot = createSourceSlot( createInstallationSource(), @@ -15,9 +8,8 @@ const sourceSlot = createSourceSlot( 'Installation source', ); export const configureInstallationSource = sourceSlot.configure; -const layer = createInstallationsLayer({ +const layer = createApplicationInstallations({ source: sourceSlot.source, - services: { render, context, ground, anchors, picking }, }); export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; export const classifyGoogleMilitaryPlace = layer.classifyGoogleMilitaryPlace; diff --git a/src/data/radio.js b/src/data/radio.js index b4ec1f59d..fa2ee01b2 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,11 +1,6 @@ +import { createApplicationRadio } from '../app/layers/radio.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createRadioSource } from '../layers/radio/source.js'; -import { createRadioLayer } from '../layers/radio/index.js'; -import * as ground from './groundFloor.js'; -import * as picking from './pickRegistry.js'; -import * as overlays from '../overlays/worldOverlay.js'; -import * as globe from '../celestialRing.js'; -import * as render from '../renderGovernor.js'; const sourceSlot = createSourceSlot( createRadioSource(), @@ -13,9 +8,8 @@ const sourceSlot = createSourceSlot( 'Radio source', ); export const configureRadioSource = sourceSlot.configure; -const layer = createRadioLayer({ +const layer = createApplicationRadio({ source: sourceSlot.source, - services: { ground, picking, overlays, globe, render }, }); export const radioGlobeLabel = layer.radioGlobeLabel; export const createRadioSelectedOverlayEntry = diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index f19323f0f..4ed012d6e 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,12 +1,7 @@ +import { createApplicationLaunches } from '../app/layers/rocketLaunches.js'; import { createSourceSlot } from '../app/sourceSlot.js'; -import { - createRocketLaunchesLayer, - createLaunchSource, -} from '../layers/launches/index.js'; +import { createLaunchSource } from '../layers/launches/index.js'; import * as satellites from './satellites.js'; -import * as geometry from '../celestialRing.js'; -import * as overlays from '../overlays/worldOverlay.js'; -import * as render from '../renderGovernor.js'; const sourceSlot = createSourceSlot( createLaunchSource(), @@ -14,9 +9,9 @@ const sourceSlot = createSourceSlot( 'Launch source', ); export const configureLaunchSource = sourceSlot.configure; -const layer = createRocketLaunchesLayer({ +const layer = createApplicationLaunches({ source: sourceSlot.source, - services: { satellites, geometry, overlays, render }, + satellites, }); export const satelliteParamsForSpaceMissions = layer.satelliteParamsForSpaceMissions; diff --git a/src/data/satellites.js b/src/data/satellites.js index 11093ec13..3cc47b315 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,15 +1,6 @@ +import { createApplicationSatellites } from '../app/layers/satellites.js'; import { createSourceSlot } from '../app/sourceSlot.js'; -import { - createSatellitesLayer, - createSatelliteSource, -} from '../layers/satellites/index.js'; -import * as picking from './pickRegistry.js'; -import * as focus from './focusDeemphasis.js'; -import * as readout from './trackedReadout.js'; -import * as overlays from '../overlays/worldOverlay.js'; -import * as context from './contextStore.js'; -import * as render from '../renderGovernor.js'; -import * as layerState from './layerState.js'; +import { createSatelliteSource } from '../layers/satellites/index.js'; const sourceSlot = createSourceSlot( createSatelliteSource(), @@ -17,9 +8,8 @@ const sourceSlot = createSourceSlot( 'Satellite source', ); export const configureSatelliteSource = sourceSlot.configure; -const layer = createSatellitesLayer({ +const layer = createApplicationSatellites({ source: sourceSlot.source, - services: { picking, focus, readout, overlays, context, render, layerState }, }); export const satelliteVisualsVisible = layer.satelliteVisualsVisible; export const satelliteCatalogModeChanged = layer.satelliteCatalogModeChanged; diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js index 998829f5c..6aaba700f 100644 --- a/src/data/telegeographySubmarineCables.js +++ b/src/data/telegeographySubmarineCables.js @@ -1,26 +1,10 @@ -import * as Cesium from 'cesium'; -import { - createSubmarineCableLayer, - createCableOverlayPublisher as createPublisher, -} from '../layers/submarineCables/index.js'; +import { createApplicationCables } from '../app/layers/submarineCables.js'; +import { overlayHost } from '../app/layers/overlayHost.js'; +import { createCableOverlayPublisher as createPublisher } from '../layers/submarineCables/index.js'; import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; -import { - clearOverlaySource, - setOverlayEntries, - setOverlaySourceVisible, -} from '../overlays/worldOverlay.js'; -const overlayHost = Object.freeze({ - clearSource: clearOverlaySource, - setEntries: setOverlayEntries, - setVisible: setOverlaySourceVisible, -}); export function createTeleGeographySubmarineCableLayer(options = {}) { - return createSubmarineCableLayer({ + return createApplicationCables({ source: createBundledCableSource(), - overlayHost, - screenSpaceEventHandlerFactory: (canvas) => - new Cesium.ScreenSpaceEventHandler(canvas), - mapStackEventTarget: typeof window !== 'undefined' ? window : null, ...options, }); } diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs index 675c6a3b9..3850748dc 100644 --- a/src/data/telegeographySubmarineCables.test.mjs +++ b/src/data/telegeographySubmarineCables.test.mjs @@ -631,6 +631,8 @@ test('the depth decision is the dated Option-2 host migration, superseding Optio test('cables create no native labels and no per-frame geometry callbacks', () => { const source = [ '../../data/telegeographySubmarineCables.js', + '../../app/layers/submarineCables.js', + '../../app/layers/overlayHost.js', 'rendering.js', 'overlay.js', 'surface.js', diff --git a/src/data/traffic.js b/src/data/traffic.js index a236e1d8e..57ffff927 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,8 +1,6 @@ +import { createApplicationTraffic } from '../app/layers/traffic.js'; import { createSourceSlot } from '../app/sourceSlot.js'; import { createTrafficSource } from '../layers/traffic/source.js'; -import { createTrafficLayer } from '../layers/traffic/index.js'; -import * as credits from './dataCredits.js'; -import * as render from '../renderGovernor.js'; const sourceSlot = createSourceSlot( createTrafficSource(), @@ -14,9 +12,8 @@ const sourceSlot = createSourceSlot( }, ); export const configureTrafficSource = sourceSlot.configure; -const layer = createTrafficLayer({ +const layer = createApplicationTraffic({ source: sourceSlot.source, - services: { credits, render }, }); export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; export const deriveTrafficFlowError = layer.deriveTrafficFlowError; diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs index 907161b7d..0b62da61c 100644 --- a/src/devCctv.test.mjs +++ b/src/devCctv.test.mjs @@ -22,7 +22,10 @@ async function launch(overrides = {}, dotenv = '', omitCctv = false) { } await fs.mkdir(path.join(root, 'src', 'standalone'), { recursive: true }); const catalog = await fs.readFile(new URL('./standalone/catalog.js', import.meta.url), 'utf8'); - await fs.writeFile(path.join(root, 'src', 'standalone', 'catalog.js'), omitCctv ? catalog.replace(/^\s*cctvLayer,\s*$/m, '') : catalog); + 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.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 636181fd7..450a534c4 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -23,9 +23,10 @@ export function createStandaloneApplication({ const loadingScreen = document.getElementById('loading-screen'); const loaderStatus = loadingScreen.querySelector('.loader-status'); let placeSearch; - const catalog = createStandaloneCatalog(); + let catalog; return createApplication({ createScene: (context) => { + catalog = createStandaloneCatalog({ signal: context.signal }); placeSearch = createStandalonePlaceSearch({ ...geospatial, resolveApiKey: () => googleApiKey, diff --git a/src/standalone/catalog.js b/src/standalone/catalog.js index 61a093cf0..8394c0905 100644 --- a/src/standalone/catalog.js +++ b/src/standalone/catalog.js @@ -1,40 +1,23 @@ -import flightsLayer from '../data/flights.js'; -import militaryFlightsLayer from '../data/militaryFlights.js'; -import alprCamerasLayer from '../data/alprCameras.js'; -import earthquakesLayer from '../data/earthquakes.js'; -import satellitesLayer from '../data/satellites.js'; -import rocketLaunchesLayer from '../data/rocketLaunches.js'; -import trafficLayer from '../data/traffic.js'; -import cctvLayer from '../data/cctv.js'; -import radioLayer from '../data/radio.js'; -import bikeshareLayer from '../data/bikeshare.js'; -import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; -import militaryInstallationsLayer from '../data/militaryInstallations.js'; -import militaryAwarenessLayer from '../data/militaryAwareness.js'; -import localDataLayers from '../data/localLayers.js'; -import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; +import { createApplicationCatalog } from '../app/constructCatalog.js'; +import { createStandaloneLayerSources } from './layerSources.js'; +export { createStandaloneReferenceSources } from './layerSources.js'; -import { createLayerCatalog } from '../app/catalog.js'; +/** Create fresh layer instances using the existing standalone source choices. */ +export function createStandaloneCatalog({ + signal = new AbortController().signal, +} = {}) { + return createApplicationCatalog({ + sources: createStandaloneLayerSources(), + signal, + vesselOptions: { + maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, + maxLabels: import.meta.env?.VITE_AIS_LIVE_LABEL_MAX_ROWS, + }, + }); +} -/** Select the existing standalone instances and current persistence schema. */ -export function createStandaloneCatalog() { - return createLayerCatalog( - [ - flightsLayer, - militaryFlightsLayer, - earthquakesLayer, - alprCamerasLayer, - satellitesLayer, - rocketLaunchesLayer, - trafficLayer, - cctvLayer, - radioLayer, - bikeshareLayer, - aisLiveVesselsLayer, - militaryInstallationsLayer, - militaryAwarenessLayer, - ...localDataLayers, - ], - LAYER_STATE_REGISTRY, - ); +// Direct compatibility callers share one catalog; application startup supplies its own. +let compatibilityCatalog; +export function getStandaloneCatalog() { + return (compatibilityCatalog ||= createStandaloneCatalog()); } diff --git a/src/standalone/controls.js b/src/standalone/controls.js index 22074aa67..b168c5e1b 100644 --- a/src/standalone/controls.js +++ b/src/standalone/controls.js @@ -1,8 +1,8 @@ import { createApplicationControls } from '../app/controls.js'; -import { createStandaloneCatalog } from './catalog.js'; +import { getStandaloneCatalog } from './catalog.js'; export function createStandaloneControls(options) { return createApplicationControls({ - catalog: createStandaloneCatalog(), + catalog: options?.catalog ?? getStandaloneCatalog(), ...options, }); } diff --git a/src/standalone/data.js b/src/standalone/data.js index 3df4db000..55564c06c 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -1,8 +1,8 @@ import { createApplicationData } from '../app/data.js'; -import { createStandaloneCatalog } from './catalog.js'; +import { getStandaloneCatalog } from './catalog.js'; export function createStandaloneData(options) { return createApplicationData({ - catalog: createStandaloneCatalog(), + catalog: options?.catalog ?? getStandaloneCatalog(), ...options, }); } diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js new file mode 100644 index 000000000..0b89938e0 --- /dev/null +++ b/src/standalone/layerSources.js @@ -0,0 +1,45 @@ +import { + createOpenSkySource, + createAdsbLolSource, + createAisStreamSource, +} from '../sources/live/standalone.js'; +import { createCctvSource } from '../layers/cctv/source.js'; +import { createRadioSource } from '../layers/radio/source.js'; +import { createTrafficSource } from '../layers/traffic/source.js'; +import { createBikeshareSource } from '../layers/bikeshare/source.js'; +import { createInstallationSource } from '../layers/installations/index.js'; +import { createSatelliteSource } from '../layers/satellites/index.js'; +import { createLaunchSource } from '../layers/launches/index.js'; +import { createOverpassAlprSource } from '../layers/alpr/index.js'; +import { createFirmsSource } from '../layers/firms/index.js'; +import { createUsgsEarthquakeSource } from '../layers/earthquakes/source.js'; +import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js'; + +/** Existing reference feeds, usable independently of live source selection. */ +export function createStandaloneReferenceSources() { + return { + earthquakes: createUsgsEarthquakeSource(), + cables: createBundledCableSource(), + }; +} + +/** Select standalone providers without starting their acquisition. */ +export function createStandaloneLayerSources() { + return { + ...createStandaloneReferenceSources(), + flights: createOpenSkySource(), + military: createAdsbLolSource(), + vessels: createAisStreamSource({ + apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + }), + cctv: createCctvSource(), + radio: createRadioSource(), + traffic: createTrafficSource(), + bikeshare: createBikeshareSource(), + installations: createInstallationSource(), + satellites: createSatelliteSource(), + launches: createLaunchSource(), + alpr: createOverpassAlprSource(), + firms: createFirmsSource(), + }; +} diff --git a/src/ui.js b/src/ui.js index c864ee19b..450979d9b 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,2 +1,15 @@ -/** Compatibility entry for the application's UI composition. */ -export { StyleManager } from './ui/composition.js'; +import { StyleManager as ApplicationControls } from './ui/composition.js'; +import { catalogControlServices } from './app/catalog.js'; +import { getStandaloneCatalog } from './standalone/catalog.js'; +/** Compatibility entry for direct standalone control construction. */ +export class StyleManager extends ApplicationControls { + constructor(viewer, options = {}) { + super(viewer, { + ...options, + services: { + ...catalogControlServices(getStandaloneCatalog()), + ...options.services, + }, + }); + } +} diff --git a/src/ui/composition.js b/src/ui/composition.js index 5d4f1a65a..5db8ebe47 100644 --- a/src/ui/composition.js +++ b/src/ui/composition.js @@ -40,18 +40,7 @@ import { setDetectionStyle, setDetectionTuning, } from '../data/detection.js'; -import trafficLayer from '../data/traffic.js'; -import flightsLayer from '../data/flights.js'; -import militaryFlightsLayer from '../data/militaryFlights.js'; import { isTr3b, toggleTr3b } from '../data/tr3bRegistry.js'; -import satellitesLayer from '../data/satellites.js'; -import cctvLayer from '../data/cctv.js'; -import radioLayer from '../data/radio.js'; -import bikeshareLayer from '../data/bikeshare.js'; -import aisLiveVesselsLayer from '../data/aisLiveVessels.js'; -import militaryAwarenessLayer from '../data/militaryAwareness.js'; -import militaryInstallationsLayer from '../data/militaryInstallations.js'; -import rocketLaunchesLayer from '../data/rocketLaunches.js'; import { cachedGroundFloor, cachedMeshFloor, @@ -114,19 +103,8 @@ export class StyleManager extends ApplicationShell { resumeDetection, setDetectionStyle, setDetectionTuning, - trafficLayer, - flightsLayer, - militaryFlightsLayer, isTr3b, toggleTr3b, - satellitesLayer, - cctvLayer, - radioLayer, - bikeshareLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - militaryInstallationsLayer, - rocketLaunchesLayer, cachedGroundFloor, cachedMeshFloor, GROUND_FLOOR_LIFT_M, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a607e27de..323a7c915 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -8,15 +8,10 @@ import { getSelectedEntityContext, isContextRecordActive, } from '../data/contextStore.js'; -import { getNextIssPass } from '../data/satellites.js'; -import { CCTV_FOCUS_RESULT } from '../data/cctv.js'; +import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; import { contextModeWord } from '../contextModePolicy.js'; import { createAnalystEngine } from '../data/analystEngine.js'; import { layerFeedState } from '../data/manager.js'; -import militaryAwarenessLayer, { - collectAircraftProximityWindow, - contactsWindowFromSnapshot, -} from '../data/militaryAwareness.js'; import { initCameraVerbs, moveCamera, flyRoute, interruptCameraMotion, adjustOrbitRange } from '../cameraVerbs.js'; import { cachedGroundFloor, warmGroundFloor } from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; @@ -612,7 +607,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene }; } const contactsWindow = ['contacts', 'flights'].includes(mode) - ? activeContactsWindow() + ? activeContactsWindow(dataManager) : null; return { ...withContextModeVocabulary(result), @@ -792,7 +787,7 @@ export function createGevActionRunner({ viewer, styleManager, dataManager, scene } if (name === 'next_iss_pass') { - return nextIssPass(viewer, args); + return nextIssPass(viewer, dataManager, args); } if (name === 'analyst_query') { @@ -2007,7 +2002,7 @@ function compassDir(azDeg) { return COMPASS_16[Math.round(((((azDeg % 360) + 360) % 360)) / 22.5) % 16]; } -function nextIssPass(viewer, args) { +function nextIssPass(viewer, dataManager, args) { let latDeg = Number.isFinite(args.latitude) ? args.latitude : null; let lonDeg = Number.isFinite(args.longitude) ? args.longitude : null; if (latDeg == null || lonDeg == null) { @@ -2017,7 +2012,7 @@ function nextIssPass(viewer, args) { lonDeg = Cesium.Math.toDegrees(carto.longitude); } const minElevDeg = Number.isFinite(args.minElevationDeg) ? args.minElevationDeg : 10; - const result = getNextIssPass({ latDeg, lonDeg, minElevDeg }); + const result = dataManager?.layers?.get('satellites')?.module?.getNextIssPass?.({ latDeg, lonDeg, minElevDeg }) ?? { status: 'no-tle' }; if (result.status === 'no-tle') { return { ok: false, @@ -2337,7 +2332,7 @@ function getCurrentViewState(viewer, styleManager, dataManager, sceneDirector = ...withContextModeVocabulary(styleManager.getContextModeState()), // The numbers on the operator's Contacts panel, so a window/count // question can be answered from what they are looking at. - ...(activeContactsWindow() ? { contactsWindow: activeContactsWindow() } : {}), + ...(activeContactsWindow(dataManager) ? { contactsWindow: activeContactsWindow(dataManager) } : {}), } : null, cockpit: typeof styleManager.getCockpitState === 'function' @@ -2408,19 +2403,19 @@ async function getEntityContext(viewer, dataManager, styleManager, args = {}, se * @param {object} result The general engine's result, reused for scope text. * @returns {object|null} A unified-count payload, or null. */ -function aircraftProximityWindowForQuery(args, result) { +function aircraftProximityWindowForQuery(dataManager, args, result) { const scope = args?.scope; if (String(scope?.kind || '').toLowerCase() !== 'radius') return null; const layers = Array.isArray(args.layers) ? args.layers : []; if (!layers.some((layer) => layer === 'flights' || layer === 'military')) return null; - const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); + const snapshot = dataManager?.layers?.get('military-awareness')?.module?.getContextSnapshot?.(); const subject = snapshot?.subject; if (!subject?.position) return null; // An explicit centre only qualifies when it IS the subject; otherwise the // operator asked about somewhere else and must get that answer. if (scope.center && !centerMatchesSubject(scope.center, subject.position)) return null; const radiusM = Number.isFinite(scope.km) ? scope.km * 1000 : (snapshot.radiusM || 250_000); - const window = collectAircraftProximityWindow(subject.position, { radiusM, subject }); + const window = dataManager.layers.get('military-awareness').module.collectAircraftProximityWindow(subject.position, { radiusM, subject }); if (!window) return null; const label = subject.label || subject.id || 'the selected contact'; const radiusKm = Math.round(radiusM / 1000); @@ -2459,7 +2454,7 @@ function aircraftProximityWindowForQuery(args, result) { military: window.military.length, aircraft: window.aircraft, }, - ...(activeContactsWindow() ? { contactsWindow: activeContactsWindow() } : {}), + ...(activeContactsWindow(dataManager) ? { contactsWindow: activeContactsWindow(dataManager) } : {}), }; } @@ -3235,9 +3230,10 @@ const VIEWPORT_LOADED_LAYERS = new Set(['flights']); * diverge no matter which surface asks. * @returns {object|null} Panel-equivalent window counts. */ -function activeContactsWindow() { +function activeContactsWindow(dataManager) { try { - return contactsWindowFromSnapshot(militaryAwarenessLayer.getContextSnapshot?.()); + const layer = dataManager?.layers?.get('military-awareness')?.module; + return layer?.contactsWindowFromSnapshot?.(layer.getContextSnapshot?.()) ?? null; } catch { return null; } @@ -3263,7 +3259,7 @@ function analystProviders(viewer, dataManager, { recordLimitByLayer = null, plac * @returns {{lat: number, lon: number, label: string|null}|null} Subject centre. */ getContextSubject() { - const snapshot = militaryAwarenessLayer.getContextSnapshot?.(); + const snapshot = dataManager?.layers?.get('military-awareness')?.module?.getContextSnapshot?.(); const subject = snapshot?.subject; if (!subject?.position) return null; const carto = Cesium.Cartographic.fromCartesian(subject.position); @@ -3343,10 +3339,10 @@ async function runAnalystQuery(viewer, dataManager, args = {}, placeSearch = una // differ. The generic record/scope engine still owns explicit regions and // arbitrary points — only "how many aircraft around " is // unified, because that is the question the panel is already answering. - const entityWindow = aircraftProximityWindowForQuery(args, result); + const entityWindow = aircraftProximityWindowForQuery(dataManager, args, result); if (entityWindow) return entityWindow; - const contactsWindow = activeContactsWindow(); + const contactsWindow = activeContactsWindow(dataManager); const aircraftQueried = (result.coverage?.layersQueried || []) .some((l) => l.layerKey === 'flights' || l.layerKey === 'military'); // Both numbers, and which one answers the question. The window counts have diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index c7a0677bc..ba0ed9cf7 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -2838,7 +2838,7 @@ async function withAwareness(harness, run) { : originals.cartoFrom(value) ); try { - return await run(); + return await run(awareness); } finally { awareness.getContextSnapshot = originals.snapshot; flightsLayer.getNearby = originals.flightsNearby; @@ -2847,7 +2847,7 @@ async function withAwareness(harness, run) { } } -function analystRunner() { +function analystRunner(awareness) { const flights = { id: 'flights', // Deliberately a DIFFERENT population from the proximity window: this is @@ -2869,7 +2869,7 @@ function analystRunner() { viewer, styleManager: {}, dataManager: { - layers: new Map([['flights', { module: flights }]]), + layers: new Map([['flights', { module: flights }], ['military-awareness', { module: awareness }]]), isEnabled: (id) => id === 'flights', getAll: () => [{ id: 'flights', name: 'Live Flights', enabled: true, stats: { count: 1 } }], }, @@ -2883,8 +2883,8 @@ test('front5: a nearby ask centres on the Contacts SUBJECT, not the selected dat flights: Array.from({ length: 111 }, (_, i) => ({ id: `F${i}`, icao24: `f${i}`, distance: 1000 * i })), military: Array.from({ length: 5 }, (_, i) => ({ id: `M${i}`, icao24: `m${i}`, distance: 500 * i })), }); - await withAwareness(harness, async () => { - const runner = analystRunner(); + await withAwareness(harness, async (awareness) => { + const runner = analystRunner(awareness); const result = await runner('analyst_query', { layers: ['flights', 'military'], // The centre the model reached for in the field: the selected datacenter. @@ -2913,13 +2913,13 @@ test('front5: the spoken count and the panel window are ONE number by constructi flights: Array.from({ length: 111 }, (_, i) => ({ id: `F${i}`, icao24: `f${i}` })), military: Array.from({ length: 5 }, (_, i) => ({ id: `M${i}`, icao24: `m${i}` })), }); - await withAwareness(harness, async () => { + await withAwareness(harness, async (awareness) => { // What the PANEL computes for this subject... const panel = collectAircraftProximityWindow(harness.snapshot.subject.position, { subject: harness.snapshot.subject, }); // ...and what VOICE answers for the same subject. - const spoken = await analystRunner()('analyst_query', { + const spoken = await analystRunner(awareness)('analyst_query', { layers: ['flights', 'military'], scope: { kind: 'radius', km: 250 }, }); @@ -2936,8 +2936,8 @@ test('front5: the spoken count and the panel window are ONE number by constructi test('front5: Contacts active with NO subject falls back to the view, not an empty panel', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const harness = awarenessSubjectHarness({ subject: null }); - await withAwareness(harness, async () => { - const result = await analystRunner()('analyst_query', { + await withAwareness(harness, async (awareness) => { + const result = await analystRunner(awareness)('analyst_query', { layers: ['flights'], scope: { kind: 'radius', km: 250 }, }); @@ -2954,8 +2954,8 @@ test('front5: an explicit region still uses the region engine while Contacts is subject: { id: 'a1b2c3', label: 'N546PC' }, flights: Array.from({ length: 111 }, (_, i) => ({ id: `F${i}`, icao24: `f${i}` })), }); - await withAwareness(harness, async () => { - const result = await analystRunner()('analyst_query', { + await withAwareness(harness, async (awareness) => { + const result = await analystRunner(awareness)('analyst_query', { layers: ['flights'], scope: { kind: 'region', name: 'Texas' }, }); @@ -2988,8 +2988,8 @@ test('front5: the box DIAGONAL is not the subject — 1.32 km away is somewhere // separation is 1.32 km. This is the case the coordinator flagged: a centre // far enough to be a different place, slipping through on the diagonal. globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; - await withAwareness(subjectWindowHarness(), async () => { - const result = await analystRunner()('analyst_query', { + await withAwareness(subjectWindowHarness(), async (awareness) => { + const result = await analystRunner(awareness)('analyst_query', { layers: ['flights', 'military'], scope: { kind: 'radius', km: 250, center: { lat: 29.9 + 0.009, lon: -97.9 + 0.009 } }, }); @@ -3009,8 +3009,8 @@ test('front5: 0.99 km due EAST is the subject, though a degree box rejects it', // 0.99 km — inside 1 km — yet over the 0.01 box threshold. A box would send // the operator a different, smaller number for a centre that IS the contact. globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; - await withAwareness(subjectWindowHarness(), async () => { - const result = await analystRunner()('analyst_query', { + await withAwareness(subjectWindowHarness(), async (awareness) => { + const result = await analystRunner(awareness)('analyst_query', { layers: ['flights', 'military'], scope: { kind: 'radius', km: 250, center: { lat: 29.9, lon: -97.9 + 0.0103 } }, }); @@ -3164,3 +3164,21 @@ test('ALPR common names toggle only the registered camera layer through the norm } } }); + +test('ISS voice lookup uses the registered satellite instance', async () => { + const calls = []; + const viewer = { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { moveEnd: { addEventListener() {} } }, + }; + const runner = createGevActionRunner({ viewer, styleManager: {}, dataManager: { + layers: new Map([['satellites', { module: { getNextIssPass(query) { + calls.push(query); + return { status: 'none' }; + } } }]]), + } }); + const result = await runner('next_iss_pass', { latitude: 30, longitude: -97, minElevationDeg: 15 }); + assert.deepEqual(calls, [{ latDeg: 30, lonDeg: -97, minElevDeg: 15 }]); + assert.match(result.error, /No ISS pass above 15/); +});