diff --git a/CHANGELOG.md b/CHANGELOG.md index eb46dd987..48a30caf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +- Split application scene, controls, catalog, tools and HTML into reusable components; configure application request services and sources without changing global fetch. Preserve standalone markup and voice behavior. Explicit annotation navigation may resolve a distant named target. + ## Voice component boundaries - Separate voice controls, Realtime connection requests and the action runner. diff --git a/build/application-html.js b/build/application-html.js new file mode 100644 index 000000000..b14210a56 --- /dev/null +++ b/build/application-html.js @@ -0,0 +1,40 @@ +import { readFileSync } from 'node:fs'; + +export const APPLICATION_TEMPLATES = Object.freeze([ + 'scene-chrome', + 'cockpit', + 'display-controls', + 'command-dock', + 'layer-panels', + 'context', + 'welcome', + 'provider-settings', + 'hud-loading', +]); +const allowed = new Set(APPLICATION_TEMPLATES); + +/** Expand only known component templates; markers cannot name filesystem paths. */ +export function expandApplicationHtml(html) { + return html.replace( + /^[ \t]*\r?\n?/gm, + (_, name) => { + if (!allowed.has(name)) + throw new Error(`Unknown application template: ${name}`); + return readFileSync( + new URL(`../src/ui/templates/${name}.html`, import.meta.url), + 'utf8', + ); + }, + ); +} + +/** Assemble static application markup before Vite processes scripts and assets. */ +export function applicationHtmlPlugin() { + return { + name: 'application-component-templates', + transformIndexHtml: { + order: 'pre', + handler: expandApplicationHtml, + }, + }; +} diff --git a/build/vite.js b/build/vite.js index 5d990f3a1..82a1c3d68 100644 --- a/build/vite.js +++ b/build/vite.js @@ -1,15 +1,18 @@ +import { applicationHtmlPlugin } from './application-html.js'; import cesium from 'vite-plugin-cesium'; /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ plugins = [], + publicDir, googleApiKey, cesiumToken, host = 'localhost', port = 4173, } = {}) { return { - plugins: [cesium(), ...plugins], + plugins: [cesium(), applicationHtmlPlugin(), ...plugins], + ...(publicDir === undefined ? {} : { publicDir }), server: { host: host || 'localhost', port: parseInt(port, 10) || 4173, diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 2d339e5fe..cf901c8a6 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -172,3 +172,27 @@ existing environment variables remain the default configuration. Model choices come from server configuration. Keep secret keys in the server adapter. Tool schemas, model defaults and cost estimates are unchanged. Unknown model IDs retain the existing conservative estimate until their rates are registered. + +### Composing the existing application components + +`application/scene`, `application/controls`, `application/data` and +`application/tools` expose the existing globe, controls, catalog and tools as +separate constructors. Pass these to `createApplication`; use its `signal` and +`defer` for ownership. The compatibility catalog is still page-scoped: construct +one application per page. Controls, actions and renderers use the same layer +instances. Configure sources with `application/sources` before registration or +state restoration, and release them after the consumers stop. + +`application/services` accepts boundary, terrain, regional-context, weather and +summary services. `application/requests` supplies the existing HTTP protocols +with configurable endpoints and a scoped transport. Changing an endpoint works +only for a compatible protocol; another protocol supplies a service adapter. +Cancellation discards late response bodies, and replacing a source invalidates +its pending results. Source disposal does not silently reinstate a default. + +`ui/composition` supplies the default engines to the shell. Applications can +supply a HUD implementation or request policy through control services. The +shared chrome owns the welcome/loading transition; standalone composition adds +Provider Settings. `build/html` expands an allowlist of component markers from +`src/ui/templates`; unknown names cannot read arbitrary filesystem paths. The +standalone document expands to the same markup as before this extraction. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 30a9f11c6..cf71854d8 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -385,3 +385,9 @@ also checks scene ownership after asynchronous work, so late results cannot replace a newer selection. Supplied tilesets are caller-owned; factory-created tilesets and provider caches are controller-owned. Construct a fresh controller for a new viewer or configuration lifetime. + +The application-components group assembles the existing page-scoped catalog and +its engines. Its explicit module graph includes the smaller layer/UI groups; +those independent groups retain their narrower gates. Source adapters and +request services enter through construction, without replacing global fetch. +The Node build group also owns the allowlisted static HTML template assembler. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0b37ecded..7bc10963a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,5 +1,7 @@ # God's Eye View Current State +Application composition now uses shared scene/control/catalog/tool constructors and allowlisted HTML component templates. The standalone entry supplies settings and the default sources. Terrain, boundaries, weather, regional context and summaries use configurable services; renderer/action owners remain page-scoped. Annotation proximity guards remain on by default and allow distant targets only for explicit navigation. + Voice controls compose a supplied action runner and connection controller. Realtime token and SDP requests live in a configurable backend, with independent transports and cancellation through response parsing. Stop and application diff --git a/index.html b/index.html index 87fb168bc..4ab989de7 100644 --- a/index.html +++ b/index.html @@ -13,915 +13,23 @@ -
- -
-
-
-
+ - -
-
-

GOD'S EYE VIEW

-

NO PLACE LEFT BEHIND

-
+ - -
- ACTIVE STYLE - NORMAL -
+ - - - -
- syncing road network - 0% -
-
- loading frames - 0/0 -
+ - -
+ - -
-
-
+ - - + - + - - - - - -
- - - - - -
- -
- - - - - - - - - -
- - - - -
- - - - - - - - - -
- - -
-
- -

GOD'S EYE VIEW

-

Initializing photorealistic world...

-
-
+ diff --git a/package.json b/package.json index 915dca020..b6aa6c956 100644 --- a/package.json +++ b/package.json @@ -172,6 +172,24 @@ "./voice/control": "./src/voice/control.js", "./voice/realtime-backend": "./src/voice/realtimeBackend.js", "./voice/commands": "./src/voice/commands.js", - "./voice/cost": "./src/voice/voiceCost.js" + "./voice/cost": "./src/voice/voiceCost.js", + "./application/scene": "./src/app/scene.js", + "./application/controls": "./src/app/controls.js", + "./application/data": "./src/app/data.js", + "./application/tools": "./src/app/tools.js", + "./application/chrome": "./src/app/startupChrome.js", + "./application/sources": "./src/app/sources.js", + "./application/services": "./src/services/application.js", + "./application/requests": "./src/services/requests.js", + "./application/errors": "./src/app/errors.js", + "./ui/composition": "./src/ui/composition.js", + "./ui/hud": "./src/hud.js", + "./standalone/settings": "./src/keySetup.js", + "./voice/actions": "./src/voice/gevActions.js", + "./annotations": "./src/annotations/index.js", + "./context": "./src/data/contextStore.js", + "./build/html": { + "node": "./build/application-html.js" + } } } diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh index b03e542b9..cc87e1960 100755 --- a/scripts/dev-fresh.sh +++ b/scripts/dev-fresh.sh @@ -245,8 +245,8 @@ if [[ ! -f "src/data/cctv.js" ]]; then exit 1 fi -if ! grep -q "dataManager.register(cctvLayer)" src/standalone/data.js; then - echo "error: CCTV layer not wired in src/standalone/data.js" +if ! grep -q "dataManager.register(cctvLayer)" src/app/data.js; then + echo "error: CCTV layer not wired in src/app/data.js" exit 1 fi diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 4802fe1ec..1a5a2a54c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -541,5 +541,17 @@ "src/voice/control.js", "src/voice/realtimeBackend.js", "src/voice/realtimeBackend.test.mjs", - "src/voice/layerSummary.js" + "src/voice/layerSummary.js", + "build/application-html.js", + "src/app/sourceSlot.js", + "src/app/sources.js", + "src/ui/composition.js", + "src/services/application.js", + "src/services/requests.js", + "src/app/scene.js", + "src/app/controls.js", + "src/app/data.js", + "src/app/tools.js", + "src/app/startupChrome.js", + "src/app/errors.js" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 73635976a..fb9469e52 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -24,8 +24,8 @@ }, "vite-build": { "runtime": "node", - "exports": ["./build/vite"], - "modules": ["build/vite.js"], + "exports": ["./build/vite", "./build/html"], + "modules": ["build/vite.js", "build/application-html.js"], "external": ["vite-plugin-cesium"] }, "live-providers": { @@ -221,7 +221,7 @@ "server/providers/regional/query.js", "server/providers/regional/weather-effects.js", "server/providers/regional/weather.js", - "src/data/regionalBrief.js" + "src/data/regionalModel.js" ], "external": [] }, @@ -1021,5 +1021,489 @@ "src/voice/commands.js" ], "external": [] + }, + "application-components": { + "exports": [ + "./application/scene", + "./application/controls", + "./application/data", + "./application/tools", + "./application/chrome", + "./application/sources", + "./application/services", + "./application/requests", + "./application/errors", + "./ui/composition", + "./ui/hud", + "./standalone/settings", + "./voice/actions", + "./annotations", + "./context" + ], + "modules": [ + "src/annotations/annotationEngine.js", + "src/annotations/annotationResolver.js", + "src/annotations/hybridAnnotationRenderer.js", + "src/annotations/index.js", + "src/annotations/screenAnnotationRenderer.js", + "src/annotations/worldAnnotationRenderer.js", + "src/app/controls.js", + "src/app/data.js", + "src/app/errors.js", + "src/app/scene.js", + "src/app/sourceSlot.js", + "src/app/sources.js", + "src/app/startupChrome.js", + "src/app/stateChannel.js", + "src/app/tools.js", + "src/app/viewer.js", + "src/bloom.js", + "src/camera.js", + "src/cameraVerbs.js", + "src/cctvFocusPolicy.js", + "src/cctvFocusRequest.js", + "src/celestialRing.js", + "src/cockpitCloudEffects.js", + "src/cockpitMath.js", + "src/cockpitTracking.js", + "src/cockpitUtilityLayout.js", + "src/cockpitVisionPolicy.js", + "src/contactsDetectionPolicy.js", + "src/contextModePolicy.js", + "src/creditKeyboard.js", + "src/data/aircraftClass.js", + "src/data/aircraftIcons.js", + "src/data/aircraftMeta.js", + "src/data/aircraftNearbyPolicy.js", + "src/data/aircraftRecession.js", + "src/data/aisLiveVessels.js", + "src/data/alprCameras.js", + "src/data/analystEngine.js", + "src/data/bikeshare.js", + "src/data/cctv.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/detection.js", + "src/data/detectionCohort.js", + "src/data/detectionDraw.js", + "src/data/detectionPolicy.js", + "src/data/detectionPresentation.js", + "src/data/detectionRenderDemand.js", + "src/data/earthquakes.js", + "src/data/fireAnchors.js", + "src/data/firmsAdapt.js", + "src/data/firmsHeatmap.js", + "src/data/firmsLabels.js", + "src/data/flights.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/localLayers.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/manager.js", + "src/data/meshFloorSampler.js", + "src/data/militaryAwareness.js", + "src/data/militaryAwarenessEngine.js", + "src/data/militaryFlights.js", + "src/data/militaryInstallationData.js", + "src/data/militaryInstallations.js", + "src/data/militaryRegistry.js", + "src/data/modelVisualAnchor.js", + "src/data/motionModel.js", + "src/data/naturalEarthRegions.js", + "src/data/neighborhoodPolygons.js", + "src/data/pickRegistry.js", + "src/data/radio.js", + "src/data/radioCountry.js", + "src/data/regionalBrief.js", + "src/data/regionalModel.js", + "src/data/renderAltitude.js", + "src/data/retryableLoad.js", + "src/data/rocketLaunches.js", + "src/data/routePlausible.js", + "src/data/satelliteClass.js", + "src/data/satellites.js", + "src/data/scenePick.js", + "src/data/spriteOrder.js", + "src/data/telegeographySubmarineCables.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/traffic.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/firstRunExperience.js", + "src/hud.js", + "src/hudLocality.js", + "src/hudSummaryResponse.js", + "src/keySetup.js", + "src/keySetupCore.mjs", + "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/bundledSource.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/loadingFeedback.js", + "src/locationStatus.js", + "src/locations.js", + "src/logoGaze.js", + "src/mapStackChips.js", + "src/mapStackController.js", + "src/mapStartup.js", + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/credits.js", + "src/maps/defaultSources.js", + "src/maps/google3d.js", + "src/maps/imagery.js", + "src/maps/registry.js", + "src/maps/terrain.js", + "src/navigationPolicy.js", + "src/orbit.js", + "src/overlays/worldOverlay.js", + "src/overlays/worldOverlayDraw.js", + "src/overlays/worldOverlayTokens.js", + "src/panelStackLayout.js", + "src/renderGovernor.js", + "src/rightRailPolicy.js", + "src/scenes/director.js", + "src/scenes/recipes.js", + "src/scenes/scenePolicy.js", + "src/scopeMask.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/sharelink.js", + "src/sources/live/aircraft.js", + "src/sources/live/contract.js", + "src/sources/live/standalone.js", + "src/sources/live/vessels.js", + "src/splitFlap.js", + "src/styles/anime.js", + "src/styles/noir.js", + "src/styles/retro.js", + "src/styles/snow.js", + "src/styles/surveillance.js", + "src/styles/thermal.js", + "src/ui/applicationShell.js", + "src/ui/applicationShortcuts.js", + "src/ui/cctv.js", + "src/ui/cctvBindings.js", + "src/ui/cctvCalibration.js", + "src/ui/cctvControls.js", + "src/ui/cctvFrames.js", + "src/ui/cctvPresentation.js", + "src/ui/clearLayersControl.js", + "src/ui/cockpit.js", + "src/ui/cockpitBriefing.js", + "src/ui/cockpitCamera.js", + "src/ui/cockpitContext.js", + "src/ui/cockpitController.js", + "src/ui/cockpitDisplayPortal.js", + "src/ui/cockpitInput.js", + "src/ui/cockpitInstruments.js", + "src/ui/cockpitLayout.js", + "src/ui/cockpitPresentation.js", + "src/ui/cockpitSignals.js", + "src/ui/cockpitTrackingController.js", + "src/ui/composition.js", + "src/ui/context.js", + "src/ui/contextActions.js", + "src/ui/contextBindings.js", + "src/ui/contextControls.js", + "src/ui/contextLayerChanges.js", + "src/ui/contextPresentation.js", + "src/ui/contextSession.js", + "src/ui/contextSubscriptions.js", + "src/ui/contextTransactions.js", + "src/ui/displayControls.js", + "src/ui/effects.js", + "src/ui/frameRateMonitor.js", + "src/ui/layerPanel.js", + "src/ui/layers.js", + "src/ui/leftPanelRail.js", + "src/ui/location.js", + "src/ui/locationControls.js", + "src/ui/locationSearch.js", + "src/ui/mapSource.js", + "src/ui/mapSourceControls.js", + "src/ui/panelDisclosure.js", + "src/ui/panelLayoutController.js", + "src/ui/panelMeasurement.js", + "src/ui/panelPositionControls.js", + "src/ui/panelRailGeometry.js", + "src/ui/panelRails.js", + "src/ui/radio.js", + "src/ui/radioBindings.js", + "src/ui/radioControls.js", + "src/ui/radioPresentation.js", + "src/ui/radioTunerModel.js", + "src/ui/recordingControls.js", + "src/ui/rightPanelRail.js", + "src/ui/sceneControls.js", + "src/ui/scenePresentation.js", + "src/ui/scenes.js", + "src/ui/shellElements.js", + "src/ui/shellFeedback.js", + "src/ui/styleParameters.js", + "src/ui/surfaceKeyboard.js", + "src/ui/uiLifetime.js", + "src/ui/visualEffects.js", + "src/ui/visualInput.js", + "src/ui/visualPresets.js", + "src/voice/commands.js", + "src/voice/control.js", + "src/voice/gevActions.js", + "src/voice/gevRealtime.js", + "src/voice/layerSummary.js", + "src/voice/realtimeBackend.js", + "src/voice/realtimeController.js", + "src/voice/voiceCost.js", + "src/weatherEffectsMath.js", + "src/worldFocus.js" + ], + "external": [ + "@mapbox/vector-tile", + "cesium", + "egm96-universal", + "mgrs", + "pbf", + "satellite.js" + ] } } diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index 7f521edce..f9c31585a 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -85,6 +85,7 @@ * Exits non-zero if ANY invariant fails. DOES NOT COMMIT anything. * * Flags: + * --source-base Browser module prefix (default /src) * --url App URL (default http://localhost:4173) * --headful Show the browser (debugging) * --keep-open Leave the browser open after the run (debugging) @@ -105,6 +106,7 @@ const getOpt = (name, dflt) => { return i >= 0 && argv[i + 1] ? argv[i + 1] : dflt; }; +const SOURCE_BASE = getOpt('--source-base', '/src').replace(/\/$/, ''); const APP_URL = getOpt('--url', 'http://localhost:4173'); const APP_ORIGIN = new URL(APP_URL).origin; const HEADFUL = getFlag('--headful'); @@ -249,6 +251,7 @@ async function main() { try { const page = await browser.newPage(); + await page.evaluateOnNewDocument((base) => { window.__gevQaSourceBase = base; }, SOURCE_BASE); await page.setViewport({ width: 1280, height: 800 }); await page.setRequestInterception(true); page.on('request', (request) => { @@ -2655,7 +2658,7 @@ async function main() { const arrival = await evalPage(async () => { const v = window.__godsEyeView.viewer; const dm = window.__godsEyeView.dataManager; - const { screenProjectedRotation } = await import('/src/data/iconOrientation.js'); + const { screenProjectedRotation } = await import(`${window.__gevQaSourceBase || '/src'}/data/iconOrientation.js`); // 3D models OFF for this phase: a model-handed-off billboard is hidden // and skips rotation updates entirely — the probes need live billboards // (this is also the app's default state the field report came from). @@ -2937,7 +2940,7 @@ async function main() { // Read the thresholds from the app's own policy module so a later retune // of the swap distance moves these pins with it rather than stranding // them on stale numbers. - const reg = await import('/src/data/trackedModelRegime.js'); + const reg = await import(`${window.__gevQaSourceBase || '/src'}/data/trackedModelRegime.js`); window.__dfRegime = { enter: reg.TRACKED_MODEL_ENTER_ALT_M, exit: reg.TRACKED_MODEL_EXIT_ALT_M, @@ -2990,7 +2993,7 @@ async function main() { const seen = performance.getEntriesByType('resource') .map((e) => e.name) .filter((n) => /\/src\/data\/groundFloor\.js(\?|$)/.test(n)); - window.__dfCandidates = [...new Set([...seen.reverse(), '/src/data/groundFloor.js'])]; + window.__dfCandidates = [...new Set([...seen.reverse(), `${window.__gevQaSourceBase || '/src'}/data/groundFloor.js`])]; return { candidates: window.__dfCandidates.length }; }); record('display-floor: groundFloor module URL candidates found', dfSetup.candidates > 0, @@ -3821,7 +3824,7 @@ async function main() { const urls = [...new Set([ ...performance.getEntriesByType('resource').map((e) => e.name) .filter((n) => /\/src\/data\/flights\.js(\?|$)/.test(n)).reverse(), - '/src/data/flights.js', + `${window.__gevQaSourceBase || '/src'}/data/flights.js`, ])]; for (const url of urls) { let mod; try { mod = await import(/* @vite-ignore */ url); } catch { continue; } diff --git a/server/providers/regional/news.js b/server/providers/regional/news.js index 1a4954986..155dfa908 100644 --- a/server/providers/regional/news.js +++ b/server/providers/regional/news.js @@ -1,5 +1,5 @@ import { fetchRegionalText, fetchRegionalJson } from './http.js'; -import { normalizeRegionalArticles } from '../../../src/data/regionalBrief.js'; +import { normalizeRegionalArticles } from '../../../src/data/regionalModel.js'; function decodeRssText(value) { return String(value || '') diff --git a/server/providers/regional/place.js b/server/providers/regional/place.js index a27e9c6e6..0e9e1ac26 100644 --- a/server/providers/regional/place.js +++ b/server/providers/regional/place.js @@ -1,5 +1,5 @@ import { fetchRegionalJson } from './http.js'; -import { normalizeRegionalPlace } from '../../../src/data/regionalBrief.js'; +import { normalizeRegionalPlace } from '../../../src/data/regionalModel.js'; /** Construct the serialized Nominatim adapter with a trusted endpoint. */ export function createRegionalPlaceProvider({ diff --git a/server/providers/regional/weather.js b/server/providers/regional/weather.js index 2b7ef8b4b..e486ff49a 100644 --- a/server/providers/regional/weather.js +++ b/server/providers/regional/weather.js @@ -1,5 +1,5 @@ import { fetchRegionalJson } from './http.js'; -import { normalizeRegionalWeather } from '../../../src/data/regionalBrief.js'; +import { normalizeRegionalWeather } from '../../../src/data/regionalModel.js'; const WEATHER_EFFECTS_MAX_RESPONSE_BYTES = 512 * 1024; diff --git a/src/annotations/annotationEngine.js b/src/annotations/annotationEngine.js index e289b8393..f13a7e4f8 100644 --- a/src/annotations/annotationEngine.js +++ b/src/annotations/annotationEngine.js @@ -246,7 +246,7 @@ export function createAnnotationEngine({ // allSettled gives per-item error isolation (one failed item never aborts the batch); the // mutation pass below then runs in ORDER, so de-dup, the synchronous live-cap check, and output // order are all preserved exactly as the old serial loop had them. - const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal))); + const settled = await Promise.allSettled(list.map((spec) => resolveSpec(spec, controller.signal, opts.flyTo === true))); try { for (let i = 0; i < list.length; i += 1) { @@ -385,7 +385,7 @@ export function createAnnotationEngine({ }; } - async function resolveSpec(spec, signal) { + async function resolveSpec(spec, signal, allowDistant = false) { const type = normalizeType(spec?.type); if (type === 'route') { const points = Array.isArray(spec.points) ? spec.points : []; @@ -395,13 +395,15 @@ export function createAnnotationEngine({ for (const pt of points) { const name = pt.target ?? pt.name ?? null; const r = await resolveTarget({ - placeSearch, viewer, + placeSearch, + viewer, target: name, latitude: pt.latitude, longitude: pt.longitude, screenX: pt.screenX, screenY: pt.screenY, footprint: false, + allowDistant, signal, }); if (r) resolvedPts.push(r); @@ -438,23 +440,27 @@ export function createAnnotationEngine({ } if (type === 'arrow') { const from = await resolveTarget({ - placeSearch, viewer, + placeSearch, + viewer, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, screenX: spec.screenX, screenY: spec.screenY, footprint: false, + allowDistant, signal, }); const to = await resolveTarget({ - placeSearch, viewer, + placeSearch, + viewer, target: spec.toTarget, latitude: spec.toLatitude, longitude: spec.toLongitude, screenX: spec.toScreenX, screenY: spec.toScreenY, footprint: false, + allowDistant, signal, }); if (!from || !to) { @@ -473,7 +479,8 @@ export function createAnnotationEngine({ } const wantFootprint = type === 'area' ? spec.footprint !== false : Boolean(spec.footprint); return resolveTarget({ - placeSearch, viewer, + placeSearch, + viewer, target: spec.target, latitude: spec.latitude, longitude: spec.longitude, @@ -490,6 +497,7 @@ export function createAnnotationEngine({ // timeout — field test 7 logs); deferring them lets the mark appear and the tool // result return while the outline resolves, then upgrades the mark in place. deferFootprint: wantFootprint, + allowDistant, signal, }); } diff --git a/src/annotations/annotationEngine.test.mjs b/src/annotations/annotationEngine.test.mjs index 92909c048..0d26974ae 100644 --- a/src/annotations/annotationEngine.test.mjs +++ b/src/annotations/annotationEngine.test.mjs @@ -710,3 +710,19 @@ test('a late annotation resolver cannot redraw after destruction', async (t) => assert.equal(calls.add, 0); assert.equal(engine.count(), 0); }); + +test('only an explicit navigation request permits resolving distant annotation targets', async (t) => { + installAnimationFrameStubs(t); + _resetRenderGovernorForTest(); + t.after(() => _resetRenderGovernorForTest()); + const { renderer } = throwingRendererHarness(); + renderer.destroy = () => {}; + const received = []; + const engine = createAnnotationEngine({ viewer: {}, renderer, + resolveTarget: async (options) => { received.push(options); return null; }, + }); + await engine.annotate([{ type: 'point', target: 'Remote landmark' }]); + await engine.annotate([{ type: 'point', target: 'Remote landmark' }], { flyTo: true }); + assert.deepEqual(received.map(options => options.allowDistant), [false, true]); + engine.destroy(); +}); diff --git a/src/annotations/annotationResolver.js b/src/annotations/annotationResolver.js index 3d2e302d7..72b83b03d 100644 --- a/src/annotations/annotationResolver.js +++ b/src/annotations/annotationResolver.js @@ -1,3 +1,4 @@ +import { applicationServices } from '../services/application.js'; import { defaultGeospatial } from '../search/defaults.js'; import * as Cesium from 'cesium'; import { lookupNeighborhoodRing } from '../data/neighborhoodPolygons.js'; @@ -93,7 +94,7 @@ function linkAbort(controller, externalSignal) { export async function resolveAnnotationTarget({ placeSearch = unavailablePlaceSearch, viewer, target, latitude, longitude, footprint = false, intent = 'the_thing', - entityKind = null, labelHint = null, deferFootprint = false, screenX, screenY, signal, + entityKind = null, labelHint = null, deferFootprint = false, allowDistant = false, screenX, screenY, signal, }) { let lon = Number(longitude); let lat = Number(latitude); @@ -158,7 +159,7 @@ export async function resolveAnnotationTarget({ // LOOKING AT. If the geocode missed or landed far from the view centre, try a view-biased // Places Text Search; a hit within the trust bound overrides + skips the gate. Local geocodes // (neighborhoods, nearby buildings) are NOT far, so they keep the geocode + scope/polygon path. - if (center && trace.places === 'skipped' && !bypassNearViewGuards) { + if (center && trace.places === 'skipped' && !bypassNearViewGuards && !allowDistant) { let geocodeFar = source !== 'geocode'; if (source === 'geocode' && approximateDistanceM(center.lat, center.lon, lat, lon) / 1000 > MIN_DRIFT_FLOOR_KM) { geocodeFar = true; @@ -243,7 +244,7 @@ export async function resolveAnnotationTarget({ const limitKm = Math.max(VIEWPORT_DRIFT_FACTOR * vpGate.radiusKm, MIN_DRIFT_FLOOR_KM); return driftKm > limitKm ? { driftKm, limitKm } : null; }; - if (fromGeocode && !bypassNearViewGuards) { + if (fromGeocode && !bypassNearViewGuards && !allowDistant) { const drift = gateDrift(lat, lon); if (drift) { if (trace.query) { @@ -739,48 +740,18 @@ export function refineScope(scope, entityKind) { return scope; } -/** True when an HTTP-200 Overpass body actually signals a runtime FAILURE (server-side - * timeout / out-of-memory) via its `remark` — a transient error, not an authoritative - * empty result, so callers must not cache it as a definitive not-found. */ -function overpassHasError(data) { - const remark = String(data?.remark || '').toLowerCase(); - return remark.includes('runtime error') || remark.includes('timed out') || remark.includes('out of memory'); -} - /** A distinct Overpass throttle result that must not enter the ordinary transient ladder. */ export function isRateLimitedOutcome(value) { return value?.rateLimited === true; } -/** Parse Retry-After seconds or an HTTP date into a non-negative millisecond delay. */ -function parseRetryAfterMs(value) { - if (value == null || String(value).trim() === '') return null; - const seconds = Number(value); - if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000); - const at = Date.parse(String(value)); - return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null; -} - /** POST an Overpass QL query and return elements, a transient null, or a throttle object. */ async function overpassJson(query, timeoutMs = 14000, signal) { const controller = new AbortController(); const detach = linkAbort(controller, signal); const timer = window.setTimeout(() => controller.abort(), timeoutMs); try { - const res = await fetch('/api/overpass', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(query)}`, - signal: controller.signal, - }); - const retryAfter = res.headers?.get?.('Retry-After'); - if (res.status === 429 || (res.status === 503 && retryAfter != null)) { - return { rateLimited: true, retryAfterMs: parseRetryAfterMs(retryAfter) }; - } - if (!res.ok) return null; - const data = await res.json(); - if (overpassHasError(data)) return null; // 200 with a body-level timeout/error → transient - return Array.isArray(data?.elements) ? data.elements : null; + return await applicationServices.boundaries.query(query, { signal: controller.signal }); } catch { return null; } finally { diff --git a/src/app/controls.js b/src/app/controls.js new file mode 100644 index 000000000..6ab7ab599 --- /dev/null +++ b/src/app/controls.js @@ -0,0 +1,39 @@ +import { StyleManager } from '../ui/composition.js'; +import { flyToAustin } from '../camera.js'; +import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; + +/** Construct the existing controls and camera presentation. */ +export function createApplicationControls({ + scene: { viewer, mapStackController }, + loaderStatus, + Controls = StyleManager, + services, + placeSearch, + defer, +}) { + // Initialize the style manager (post-processing, HUD, locations, share links) + const styleManager = new Controls(viewer, { + services, + mapStackController, + placeSearch, + }); + defer(() => styleManager.orbitController.stop()); + defer(() => styleManager.hud.destroy()); + defer(() => styleManager.dispose()); + // The previous multi-canvas weather compositor remains disabled. Cockpit + // clouds use a separate, capped low-resolution GPU pass that never attaches + // Cesium fog or post-process stages and is fully stopped in map mode. + const weatherEffects = null; + const cockpitCloudEffects = initCockpitCloudEffects(viewer); + defer(() => cockpitCloudEffects?.destroy()); + + // If no share link state, do default fly-to Austin + if (!styleManager.hasShareState) { + loaderStatus.textContent = 'Flying to Austin, TX...'; + defer(flyToAustin(viewer)); + } else { + loaderStatus.textContent = 'Restoring shared view...'; + } + + return { styleManager, weatherEffects, cockpitCloudEffects }; +} diff --git a/src/app/data.js b/src/app/data.js new file mode 100644 index 000000000..a2f161cd6 --- /dev/null +++ b/src/app/data.js @@ -0,0 +1,82 @@ +import { DataLayerManager } from '../data/manager.js'; +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'; + +/** Register the application layer catalog before allowing state restoration. */ +export function createApplicationData({ + scene: { viewer }, + controls: { styleManager }, + allowQaRegistration, + onData, + defer, +}) { + // Initialize data layer manager + const dataManager = new DataLayerManager(viewer, { + allowQaRegistration, + }); + defer(async () => { + await dataManager.destroyAll(); + if (dataManager.layers.size) + throw new Error( + `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`, + ); + }); + onData?.(dataManager); + dataManager.register(flightsLayer); + dataManager.register(militaryFlightsLayer); + dataManager.register(earthquakesLayer); + dataManager.register(alprCamerasLayer); + dataManager.register(satellitesLayer); + dataManager.register(rocketLaunchesLayer); + rocketLaunchesLayer.attachDataManager(dataManager); + dataManager.register(trafficLayer); + dataManager.register(cctvLayer); + dataManager.register(radioLayer); + dataManager.register(bikeshareLayer); + dataManager.register(aisLiveVesselsLayer); + dataManager.register(militaryInstallationsLayer); + dataManager.register(militaryAwarenessLayer); + militaryAwarenessLayer.attachDataManager(dataManager); + for (const layer of localDataLayers) { + dataManager.register(layer); + } + // Restoration starts only after the complete production registry is sealed. + dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY); + if (allowQaRegistration) { + window.__gevQaRegisterLayer = (targetManager, layerModule) => { + if (targetManager !== dataManager) + throw new Error('QA layer manager mismatch'); + return dataManager.registerForQa(layerModule); + }; + window.__gevQaUnregisterLayer = (targetManager, layerId) => { + if (targetManager !== dataManager) + throw new Error('QA layer manager mismatch'); + return dataManager.unregisterForQa(layerId); + }; + const register = window.__gevQaRegisterLayer; + const unregister = window.__gevQaUnregisterLayer; + defer(() => { + if (window.__gevQaRegisterLayer === register) + delete window.__gevQaRegisterLayer; + if (window.__gevQaUnregisterLayer === unregister) + delete window.__gevQaUnregisterLayer; + }); + } + dataManager.buildTogglePanel(document.getElementById('data-toggles')); + styleManager.attachDataManager(dataManager); + + return { dataManager }; +} diff --git a/src/app/errors.js b/src/app/errors.js new file mode 100644 index 000000000..aebba3a3a --- /dev/null +++ b/src/app/errors.js @@ -0,0 +1,25 @@ +/** + * Extract a human-readable error message from any thrown value. + * Handles Error objects, strings, and plain objects with message/error fields. + * @param {*} error — caught exception value + * @returns {string} best-effort error description + */ +export function describeError(error) { + if (!error) return 'Unknown initialization error'; + if (error instanceof Error) { + if (error.message && error.message.trim()) return error.message.trim(); + return error.name || 'Initialization error'; + } + if (typeof error === 'string' && error.trim()) return error.trim(); + if (typeof error === 'object') { + const maybeMessage = String(error.message || error.error || '').trim(); + if (maybeMessage) return maybeMessage; + try { + const serialized = JSON.stringify(error); + if (serialized && serialized !== '{}') return serialized; + } catch { + // ignore serialization error + } + } + return String(error); +} diff --git a/src/app/scene.js b/src/app/scene.js new file mode 100644 index 000000000..430fbb53a --- /dev/null +++ b/src/app/scene.js @@ -0,0 +1,113 @@ +import * as Cesium from 'cesium'; +import { createApplicationViewer } from '../app/viewer.js'; +import { registerDataCredits } from '../data/dataCredits.js'; +import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; +import { MapStackController } from '../mapStackController.js'; +import { loadPhotorealisticTileset } from '../mapStartup.js'; +import { initLogoGaze } from '../logoGaze.js'; +import { + uninstallRenderGovernor, + governorRequestRender, +} from '../renderGovernor.js'; +import { describeError } from './errors.js'; + +/** Construct the application globe using the caller's local configuration. */ +export async function createApplicationScene({ + googleApiKey, + cesiumToken, + credits, + MapController = MapStackController, + mapOptions = {}, + loaderStatus, + signal, + defer, +}) { + defer(initLogoGaze()); + const previousKey = window.__GOOGLE_MAPS_API_KEY__; + if (googleApiKey) { + window.__GOOGLE_MAPS_API_KEY__ = googleApiKey; + defer(() => { + if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return; + if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__; + else window.__GOOGLE_MAPS_API_KEY__ = previousKey; + }); + } + loaderStatus.textContent = 'Configuring viewer...'; + // Provider attribution stays visible, including clean-view and recording. + const creditContainer = document.createElement('div'); + creditContainer.id = 'cesium-credits'; + document.body.appendChild(creditContainer); + defer(() => creditContainer.remove()); + const viewer = createApplicationViewer({ + container: 'cesiumContainer', + creditContainer, + }); + defer(() => { + uninstallRenderGovernor(viewer); + if (!viewer.isDestroyed()) viewer.destroy(); + }); + registerDataCredits(viewer, credits); + configureCreditKeyboardAccess(document); + loaderStatus.textContent = + googleApiKey || cesiumToken + ? 'Loading Google 3D Tiles...' + : 'Loading the keyless globe...'; + const photoreal = await loadPhotorealisticTileset(Cesium, { + googleApiKey, + cesiumToken, + }); + const tileset = photoreal.tileset; + // A provider can finish after cancellation; retain ownership of its result. + defer(() => { + if (tileset && !tileset.isDestroyed()) { + if (!viewer.scene.primitives.remove(tileset)) tileset.destroy(); + } + }); + signal.throwIfAborted(); + if (tileset) { + viewer.scene.primitives.add(tileset); + // NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom. + // Google Photorealistic 3D Tiles provide their own terrain/elevation. + viewer.scene.globe.show = false; + console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`); + } else { + if (photoreal.errors.length) { + const tileError = photoreal.errors.at(-1); + console.warn( + '[Init] Google 3D Tiles unavailable, using the keyless globe:', + tileError, + ); + const tileErrorDetail = describeError(tileError); + loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; + } + viewer.scene.globe.show = true; + } + + loaderStatus.textContent = 'Initializing systems...'; + + const mapStackController = new MapController(viewer, { + requestRender: governorRequestRender, + ...mapOptions, + googleTileset: tileset, + cesiumToken, + initialStack: tileset ? 'photoreal' : 'esri-imagery', + // Task 5 (height-datum fix): rebroadcast stack changes as a window + // CustomEvent so data layers (CCTV per-regime ground resolution) can + // react without coupling MapStackController to layer modules. Fires on + // 'switching'/'ready'/'error'; listeners derive the surface regime from + // live scene state, so intermediate emissions are harmless. + onChange: (state) => { + window.dispatchEvent( + new CustomEvent('gev:map-stack-changed', { detail: state }), + ); + }, + onError: (message) => console.warn('[MapStack]', message), + }); + defer(() => mapStackController.destroy()); + await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { + silent: true, + }); + + signal.throwIfAborted(); + return { viewer, tileset, mapStackController }; +} diff --git a/src/app/sourceSlot.js b/src/app/sourceSlot.js new file mode 100644 index 000000000..8883c0171 --- /dev/null +++ b/src/app/sourceSlot.js @@ -0,0 +1,52 @@ +/** Bind a source contract once for the application without replacing its layer instance. */ +export function createSourceSlot( + initial, + methods, + label = 'Source', + optional = {}, +) { + let active = initial; + let binding = null; + const source = Object.fromEntries( + [...methods, ...Object.keys(optional)].map((method) => [ + method, + (...args) => { + if (typeof active?.[method] !== 'function' && optional[method]) + return optional[method](...args); + if (typeof active?.[method] !== 'function') + throw new Error(`${label} is not configured`); + const owner = binding; + const provider = active; + const value = provider[method](...args); + if (value && typeof value.then === 'function') + return value.then((result) => { + if (binding !== owner || active !== provider) + throw new DOMException(`${label} was replaced`, 'AbortError'); + return result; + }); + return value; + }, + ]), + ); + for (const property of ['label', 'attribution']) { + Object.defineProperty(source, property, { + enumerable: true, + get: () => active?.[property], + }); + } + return { + source, + configure(next) { + if (methods.some((method) => typeof next?.[method] !== 'function')) + throw new TypeError(`Invalid ${label.toLowerCase()}`); + const owner = Symbol(label); + active = next; + binding = owner; + return () => { + if (binding !== owner) return; + active = null; + binding = null; + }; + }, + }; +} diff --git a/src/app/sourceSlot.test.mjs b/src/app/sourceSlot.test.mjs new file mode 100644 index 000000000..1b1face0c --- /dev/null +++ b/src/app/sourceSlot.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createSourceSlot } from './sourceSlot.js'; + +test('replacing a source rejects its late result and old cleanup cannot clear the new source', async () => { + let finish; + const slot = createSourceSlot({ read: async () => 'default' }, ['read']); + const old = slot.configure({ read: () => new Promise(resolve => { finish = resolve; }) }); + const pending = slot.source.read(); + const stop = slot.configure({ read: async () => 'replacement', attribution: { name: 'Replacement' } }); + old(); + finish('stale'); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(await slot.source.read(), 'replacement'); + assert.equal(slot.source.attribution.name, 'Replacement'); + stop(); + assert.throws(() => slot.source.read(), /not configured/); +}); + +test('an invalid replacement leaves the working source intact', async () => { + const slot = createSourceSlot({ read: async () => 42 }, ['read']); + assert.throws(() => slot.configure({}), TypeError); + assert.equal(await slot.source.read(), 42); +}); diff --git a/src/app/sources.js b/src/app/sources.js new file mode 100644 index 000000000..ced314e6a --- /dev/null +++ b/src/app/sources.js @@ -0,0 +1,43 @@ +import flights from '../data/flights.js'; +import military from '../data/militaryFlights.js'; +import vessels from '../data/aisLiveVessels.js'; +import { configureAlprSource } from '../data/alprCameras.js'; +import { configureCctvSource } from '../data/cctv.js'; +import { configureRadioSource } from '../data/radio.js'; +import { configureTrafficSource } from '../data/traffic.js'; +import { configureBikeshareSource } from '../data/bikeshare.js'; +import { configureInstallationSource } from '../data/militaryInstallations.js'; +import { configureSatelliteSource } from '../data/satellites.js'; +import { configureLaunchSource } from '../data/rocketLaunches.js'; +import { configureFirmsSource } from '../data/firmsHeatmap.js'; +import { configureMilitaryRegistrySource } from '../data/militaryRegistry.js'; +const configure = { + alpr: configureAlprSource, + cctv: configureCctvSource, + radio: configureRadioSource, + traffic: configureTrafficSource, + bikeshare: configureBikeshareSource, + installations: configureInstallationSource, + satellites: configureSatelliteSource, + launches: configureLaunchSource, + firms: configureFirmsSource, +}; +/** Configure sources before any registration or state restoration starts. */ +export function configureApplicationSources({ + layers = {}, + live = {}, + signal, + defer, +}) { + for (const [name, source] of Object.entries(layers)) { + if (!configure[name]) throw new TypeError(`Unknown layer source: ${name}`); + defer(configure[name](source)); + } + if (live.flights) flights.setSource(live.flights); + if (live.military) { + military.setSource(live.military); + defer(configureMilitaryRegistrySource(live.military, { signal })); + } + if (live.vessels) vessels.setSource(live.vessels); +} +export const liveLayers = Object.freeze({ flights, military, vessels }); diff --git a/src/app/startupChrome.js b/src/app/startupChrome.js new file mode 100644 index 000000000..0cda050f4 --- /dev/null +++ b/src/app/startupChrome.js @@ -0,0 +1,54 @@ +import { initFirstRunExperience } from '../firstRunExperience.js'; + +/** Reveal welcome controls only after restoration and the loading transition. */ +export function startApplicationChrome({ + loadingScreen, + styleManager, + dataManager, + signal, + initializeWelcome = initFirstRunExperience, + initializeSettings, +}) { + let disposed = false; + let firstRun; + let revealTimer; + let resolveDelay; + const minimumDelay = new Promise((resolve) => { + resolveDelay = resolve; + }); + const delayTimer = setTimeout(resolveDelay, 1000); + const revealFirstRun = () => { + if (disposed || signal.aborted || firstRun) return; + firstRun = initializeWelcome?.({ styleManager, dataManager }); + clearTimeout(revealTimer); + loadingScreen.removeEventListener('transitionend', revealFirstRun); + }; + void Promise.all([styleManager.initialRestorePromise, minimumDelay]) + .catch(() => { + /* Restoration reports its own outcome through the controls. */ + }) + .then(() => { + if (disposed || signal.aborted) return; + loadingScreen.classList.add('hidden'); + loadingScreen.addEventListener('transitionend', revealFirstRun, { + once: true, + }); + revealTimer = setTimeout(revealFirstRun, 900); + }); + const keySetup = Promise.resolve( + signal.aborted ? null : initializeSettings?.({ signal }), + ); + // Own the pending initializer too; it must not reveal a dialog after abort. + void keySetup.catch(() => + console.error('Provider settings initialization failed'), + ); + return async () => { + disposed = true; + clearTimeout(delayTimer); + clearTimeout(revealTimer); + resolveDelay(); + loadingScreen.removeEventListener('transitionend', revealFirstRun); + firstRun?.destroy(); + (await keySetup.catch(() => null))?.destroy(); + }; +} diff --git a/src/app/tools.js b/src/app/tools.js new file mode 100644 index 000000000..192f02ef7 --- /dev/null +++ b/src/app/tools.js @@ -0,0 +1,121 @@ +import { SceneDirector } from '../scenes/director.js'; +import { initAnnotations } from '../annotations/index.js'; +import { initGevVoiceCommands } from '../voice/gevRealtime.js'; +import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; +import { + installRenderGovernor, + getRenderGovernorDiagnostics, + governorRequestRender, + holdContinuousRender, + releaseContinuousRender, +} from '../renderGovernor.js'; + +/** Attach scene tools, rendering listeners and the application debug handle. */ +export function createApplicationTools({ + scene, + controls, + data, + loadingScreen, + placeSearch, + voice = {}, + startChrome, + onSceneDirector, + signal, + defer, +}) { + const { viewer, tileset, mapStackController } = scene; + const { styleManager, weatherEffects, cockpitCloudEffects } = controls; + const { dataManager } = data; + const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); + defer(() => sceneDirector.destroy()); + onSceneDirector?.(sceneDirector); + const annotations = initAnnotations({ viewer, tileset, placeSearch }); + defer(() => { + if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; + annotations.destroy(); + }); + if (startChrome) + defer(startChrome({ loadingScreen, styleManager, dataManager, signal })); + // Idle render governor: flips the scene into requestRenderMode whenever + // nothing animates per frame. Installed AFTER every module above has had + // its chance to register pre-install holds. (perf wave 2) + installRenderGovernor(viewer); + + // Install the explicit scope mask used by the DISPLAY controls. + installScopeMask(viewer); + defer(() => destroyScopeMask()); + + // The follow camera recomputes the tracked target's dead-reckon position + // every frame — tracking anything is a per-frame animation. (perf wave 2) + const removeTrackingListener = viewer.trackedEntityChanged.addEventListener( + () => { + if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); + else releaseContinuousRender('tracked-entity'); + }, + ); + + // Hidden-state suspension (perf wave 2): when the window/tab is hidden, + // stop the default render loop outright — a hidden canvas repaints for + // nobody, and browser rAF throttling still lets throttled frames burn + // GPU. Holder/data state is untouched, so return is seamless: restore + // the loop, refresh the one DOM surface we gated, render a frame. + const syncVisibilitySuspension = () => { + const hidden = document.hidden; + viewer.useDefaultRenderLoop = !hidden; + cockpitCloudEffects?.setSuspended?.(hidden); + if (!hidden) { + if (dataManager._panelRefreshPendingOnVisible) { + dataManager._panelRefreshPendingOnVisible = false; + dataManager._refreshTogglePanel(); + } + governorRequestRender('visibility-restore'); + } + }; + document.addEventListener('visibilitychange', syncVisibilitySuspension); + defer(() => + document.removeEventListener('visibilitychange', syncVisibilitySuspension), + ); + defer(() => { + removeTrackingListener(); + releaseContinuousRender('tracked-entity'); + }); + // Apply the CURRENT state too — bootstrap can complete while the tab is + // already hidden, and waiting for the next transition would leave the + // loop burning behind a hidden tab. (perf wave 2 fix) + syncVisibilitySuspension(); + + window.__godsEyeView = { + viewer, + styleManager, + tileset, + dataManager, + sceneDirector, + mapStackController, + annotations, + weatherEffects, + cockpitCloudEffects, + getRenderGovernorDiagnostics, + requestRender: governorRequestRender, + }; + const debug = window.__godsEyeView; + defer(() => { + if (window.__godsEyeView === debug) delete window.__godsEyeView; + }); + const voiceCommands = initGevVoiceCommands({ + ...voice, + signal, + placeSearch, + viewer, + styleManager, + dataManager, + sceneDirector, + annotations, + }); + defer(() => { + voiceCommands.stop({ removeUi: true }); + if (window.__gevVoiceCommands === voiceCommands) + delete window.__gevVoiceCommands; + }); + debug.voiceCommands = voiceCommands; + return { sceneDirector, annotations, voiceCommands }; +} diff --git a/src/cockpitCloudEffects.js b/src/cockpitCloudEffects.js index 11ba68d56..b57b56425 100644 --- a/src/cockpitCloudEffects.js +++ b/src/cockpitCloudEffects.js @@ -1,3 +1,4 @@ +import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; import { deriveWeatherEffectProfile, weatherAltitudeFactors } from './weatherEffectsMath.js'; @@ -368,14 +369,10 @@ export class CockpitCloudEffectsController { this.abort?.abort(); this.abort = new AbortController(); - const params = new URLSearchParams({ - latitude: point.latitude.toFixed(5), - longitude: point.longitude.toFixed(5), - }); - this.pending = fetch(`/api/weather-effects?${params}`, { signal: this.abort.signal }) - .then(async (response) => { - if (!response.ok) throw new Error(`Cloud weather unavailable (${response.status})`); - const payload = await response.json(); + const pending = applicationServices.weather.getConditions(point.latitude, point.longitude, { signal: this.abort.signal }); + const request = this.abort; + this.pending = pending.then(async (payload) => { + if (this.abort !== request || request.signal.aborted) return null; if (!payload?.weather) throw new Error('Cloud weather observation unavailable'); this.weather = payload.weather; this.fetchedAt = Date.now(); @@ -385,6 +382,7 @@ export class CockpitCloudEffectsController { return payload.weather; }) .catch((error) => { + if (request.signal.aborted || this.abort !== request) return; if (error?.name !== 'AbortError') { this.targetStrength = 0; this.canvas.dataset.sourceStatus = 'unavailable'; @@ -392,8 +390,7 @@ export class CockpitCloudEffectsController { return null; }) .finally(() => { - this.pending = null; - this.abort = null; + if (this.abort === request) { this.pending = null; this.abort = null; } }); return this.pending; } diff --git a/src/cockpitMarkup.test.mjs b/src/cockpitMarkup.test.mjs index 24884b5ad..a6db28237 100644 --- a/src/cockpitMarkup.test.mjs +++ b/src/cockpitMarkup.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { PanelLayoutController } from './ui/panelLayoutController.js'; import { readShellElements } from './ui/shellElements.js'; @@ -26,7 +27,7 @@ import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8'); +const html = expandApplicationHtml(fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8')); const ui = fs.readFileSync(path.join(ROOT, 'src', 'ui', 'applicationShell.js'), 'utf8'); const css = readStylesheet(path.join(ROOT, 'style.css')); const sceneDirector = fs.readFileSync(path.join(ROOT, 'src', 'scenes', 'director.js'), 'utf8'); diff --git a/src/contextTabKeyboard.test.mjs b/src/contextTabKeyboard.test.mjs index 49a640d97..e71daabff 100644 --- a/src/contextTabKeyboard.test.mjs +++ b/src/contextTabKeyboard.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { _selectContextMode } from './ui/contextTransactions.js'; import { _syncContextModeButtons } from './ui/contextPresentation.js'; @@ -11,7 +12,7 @@ import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import assert from 'node:assert/strict'; -const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); diff --git a/src/data/alprCameras.js b/src/data/alprCameras.js index 40802d945..c4a4575ec 100644 --- a/src/data/alprCameras.js +++ b/src/data/alprCameras.js @@ -1,3 +1,4 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; import { createAlprCamerasLayer, createOverpassAlprSource, @@ -7,7 +8,13 @@ import * as context from './contextStore.js'; import * as picking from './pickRegistry.js'; import * as groundFloor from './groundFloor.js'; export * from '../layers/alpr/index.js'; +const slot = createSourceSlot( + createOverpassAlprSource(), + ['fetch'], + 'ALPR source', +); +export const configureAlprSource = slot.configure; export default createAlprCamerasLayer({ - source: createOverpassAlprSource(), + source: slot.source, services: { render, context, picking, groundFloor }, }); diff --git a/src/data/bikeshare.js b/src/data/bikeshare.js index a07140c74..9ebbcd156 100644 --- a/src/data/bikeshare.js +++ b/src/data/bikeshare.js @@ -1,3 +1,4 @@ +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'; @@ -5,8 +6,14 @@ import * as sprites from './spriteOrder.js'; import * as picking from './pickRegistry.js'; import * as overlays from '../overlays/worldOverlay.js'; +const sourceSlot = createSourceSlot( + createBikeshareSource(), + ['getStations'], + 'Bikeshare source', +); +export const configureBikeshareSource = sourceSlot.configure; const layer = createBikeshareLayer({ - source: createBikeshareSource(), + source: sourceSlot.source, services: { render, sprites, picking, overlays }, }); export const createBikeshareSelectedOverlayEntry = diff --git a/src/data/cctv.js b/src/data/cctv.js index cd0902198..fe0baca25 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -1,3 +1,4 @@ +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'; @@ -11,8 +12,14 @@ import * as mesh from './meshFloorSampler.js'; import * as focus from './focusDeemphasis.js'; import * as render from '../renderGovernor.js'; +const sourceSlot = createSourceSlot( + createCctvSource(), + ['getCatalog', 'getHealth', 'getFrameUrl', 'getMediaUrl'], + 'Cctv source', +); +export const configureCctvSource = sourceSlot.configure; const layer = createCctvLayer({ - source: createCctvSource(), + source: sourceSlot.source, services: { sprites, activation, diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index aaadbce9c..8fc891433 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -300,12 +300,12 @@ export function registerDynamicCredit(viewer, credit) { * present in the "Data attribution" popover. * @param {Cesium.Viewer} viewer — the initialized Cesium viewer */ -export function registerDataCredits(viewer) { +export function registerDataCredits(viewer, credits = DATA_CREDITS) { const creditDisplay = viewer?.creditDisplay; if (!creditDisplay || typeof creditDisplay.addStaticCredit !== 'function') { return; } - for (const { html } of DATA_CREDITS) { + for (const { html } of credits) { // showOnScreen=false → lives in the expandable "Data attribution" popover, // not the on-globe credit line. creditDisplay.addStaticCredit(new Cesium.Credit(html, false)); diff --git a/src/data/detectionPolicy.test.mjs b/src/data/detectionPolicy.test.mjs index 7d3e34d2b..afddb8bd1 100644 --- a/src/data/detectionPolicy.test.mjs +++ b/src/data/detectionPolicy.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../../build/application-html.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -18,7 +19,7 @@ import { } from './detectionPolicy.js'; import { KEYHOLE_OUTSIDE_OPACITY_DEFAULT } from '../celestialRing.js'; -const indexHtml = fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); +const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../../index.html', import.meta.url), 'utf8')); test('side aircraft brackets stay readable without changing zero-opacity intent', () => { assert.equal(detectionBracketAlpha('AIR', 0), 0); diff --git a/src/data/firmsHeatmap.js b/src/data/firmsHeatmap.js index 4b2428cf9..5bdad57d4 100644 --- a/src/data/firmsHeatmap.js +++ b/src/data/firmsHeatmap.js @@ -1,3 +1,4 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; import * as Cesium from 'cesium'; import { createFirmsHeatmapLayer as createLayer, @@ -20,6 +21,12 @@ const services = { overlays, focus, }; +const sourceSlot = createSourceSlot( + createFirmsSource(), + ['getSnapshot'], + 'Fire source', +); +export const configureFirmsSource = sourceSlot.configure; const helpers = createFirmsHelpers({ services }); export const mapAnalystRecord = helpers.mapAnalystRecord; export const fireCullPosition = helpers.fireCullPosition; @@ -35,7 +42,7 @@ export function createFirmsHeatmapLayer(options) { ...options, icon: options.icon ?? '▲', source: options.source ?? 'NASA FIRMS', - feed: options.feed ?? createFirmsSource(), + feed: options.feed ?? sourceSlot.source, services, overlayHost: options.overlayHost ?? { setEntries: overlays.setOverlayEntries, diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index a2682fce0..fe964d19f 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../../build/application-html.js'; import { readLayerSource } from '../testSupport/readLayerSource.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -377,7 +378,7 @@ test('a fresh boot starts 3D aircraft ON in proximity — codec, both layers, an 'ui.js: the DISPLAY rail believes 3D is on before any layer-state sync arrives'); assert.match(ui, /this\.(?:flightState\.)?_models3dMode = 'proximity';/, 'ui.js: and believes the mode is proximity'); - const html = await readFile(new URL('../../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml(await readFile(new URL('../../index.html', import.meta.url), 'utf8')); assert.match(html, /class="pp-toggle-btn active" id="models3d-toggle" aria-pressed="true"/, 'index.html: the 3D button paints lit on first paint, before ui.js runs — and says so'); assert.match(ui, /this\._models3dBtn\?\.setAttribute\(\s*'aria-pressed',\s*String\(this\.(?:flightState\.)?_models3dEnabled\),?\s*\)/, diff --git a/src/data/militaryInstallations.js b/src/data/militaryInstallations.js index af61d8c91..67f7e4383 100644 --- a/src/data/militaryInstallations.js +++ b/src/data/militaryInstallations.js @@ -1,3 +1,4 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; import { createInstallationsLayer, createInstallationSource, @@ -8,8 +9,14 @@ import * as ground from './groundFloor.js'; import * as anchors from './fireAnchors.js'; import * as picking from './pickRegistry.js'; +const sourceSlot = createSourceSlot( + createInstallationSource(), + ['getMappedSites', 'searchNearby'], + 'Installation source', +); +export const configureInstallationSource = sourceSlot.configure; const layer = createInstallationsLayer({ - source: createInstallationSource(), + source: sourceSlot.source, services: { render, context, ground, anchors, picking }, }); export const approximateSurfaceDistanceM = layer.approximateSurfaceDistanceM; diff --git a/src/data/militaryRegistry.js b/src/data/militaryRegistry.js index 8cd3e1ed9..f581c3ff0 100644 --- a/src/data/militaryRegistry.js +++ b/src/data/militaryRegistry.js @@ -9,3 +9,6 @@ export const registerMilitaryIcaos = registry.registerMilitaryIcaos; export const isMilitaryIcao = registry.isMilitaryIcao; export const refreshMilitaryRegistryIfStale = registry.refreshMilitaryRegistryIfStale; + +/** Configure classification from the same source used by the aircraft layer. */ +export const configureMilitaryRegistrySource = registry.configureSource; diff --git a/src/data/radio.js b/src/data/radio.js index bb454344e..b4ec1f59d 100644 --- a/src/data/radio.js +++ b/src/data/radio.js @@ -1,3 +1,4 @@ +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'; @@ -6,8 +7,14 @@ import * as overlays from '../overlays/worldOverlay.js'; import * as globe from '../celestialRing.js'; import * as render from '../renderGovernor.js'; +const sourceSlot = createSourceSlot( + createRadioSource(), + ['getDirectory', 'recordClick'], + 'Radio source', +); +export const configureRadioSource = sourceSlot.configure; const layer = createRadioLayer({ - source: createRadioSource(), + source: sourceSlot.source, services: { ground, picking, overlays, globe, render }, }); export const radioGlobeLabel = layer.radioGlobeLabel; diff --git a/src/data/regionalBrief.js b/src/data/regionalBrief.js index 07ec3fb36..805f133e9 100644 --- a/src/data/regionalBrief.js +++ b/src/data/regionalBrief.js @@ -1,131 +1,7 @@ -const MAX_ARTICLES = 5; - -function cleanText(value, maxLength = 180) { - return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength); -} - -function safeHttpUrl(value) { - try { - const parsed = new URL(String(value || '')); - return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null; - } catch { - return null; - } -} - -/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */ -export function normalizeRegionalPlace(payload) { - const address = payload?.address || {}; - const locality = cleanText( - address.city || address.town || address.village || address.municipality - || address.hamlet || address.county, - 90, - ); - const region = cleanText(address.state || address.region || address.county, 90); - const country = cleanText(address.country, 90); - const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index) - .join(', ') || country || cleanText(payload?.display_name, 120); - if (!label) return null; - return { - label, - locality: locality || null, - region: region || null, - country: country || null, - countryCode: cleanText(address.country_code, 4).toUpperCase() || null, - }; -} - -/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */ -export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { - const rows = Array.isArray(payload?.articles) ? payload.articles : []; - const seen = new Set(); - const articles = []; - for (const row of rows) { - const url = safeHttpUrl(row?.url || row?.url_mobile); - const title = cleanText(row?.title, 180); - if (!url || !title) continue; - const signature = `${title.toLowerCase()}|${new URL(url).hostname}`; - if (seen.has(signature)) continue; - seen.add(signature); - const rawDate = cleanText(row?.seendate, 32); - const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); - const publishedAt = compactDate - ? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z` - : Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(); - articles.push({ - title, - url, - domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80), - publishedAt, - sourceCountry: cleanText(row?.sourcecountry, 60) || null, - }); - if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break; - } - return articles; -} - -/** Normalize Open-Meteo current conditions into a small source-stamped record. */ -export function normalizeRegionalWeather(payload) { - const current = payload?.current; - if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; - const numberOrNull = (value) => { - if (value === null || value === undefined || value === '') return null; - return Number.isFinite(Number(value)) ? Number(value) : null; - }; - // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, - // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. - const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time) - ? `${current.time}Z` - : current.time; - return { - observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(), - temperatureC: numberOrNull(current.temperature_2m), - apparentTemperatureC: numberOrNull(current.apparent_temperature), - precipitationMm: numberOrNull(current.precipitation), - cloudCoverPct: numberOrNull(current.cloud_cover), - windKph: numberOrNull(current.wind_speed_10m), - windDirectionDeg: numberOrNull(current.wind_direction_10m), - visibilityM: numberOrNull(current.visibility), - weatherCode: numberOrNull(current.weather_code), - }; -} - -/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ -export function weatherCodeLabel(code) { - const value = Number(code); - if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; - if (value === 0) return 'CLEAR'; - if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; - if (value === 3) return 'OVERCAST'; - if ([45, 48].includes(value)) return 'FOG'; - if (value >= 51 && value <= 57) return 'DRIZZLE'; - if (value >= 61 && value <= 67) return 'RAIN'; - if (value >= 71 && value <= 77) return 'SNOW'; - if (value >= 80 && value <= 82) return 'RAIN SHOWERS'; - if (value >= 85 && value <= 86) return 'SNOW SHOWERS'; - if (value >= 95) return 'THUNDERSTORM'; - return 'MIXED CONDITIONS'; -} - -/** Great-circle distance used to avoid refetching a regional brief every animation frame. */ -export function regionalDistanceM(from, to) { - if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) { - return Infinity; - } - const phi1 = from.latitude * Math.PI / 180; - const phi2 = to.latitude * Math.PI / 180; - const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180; - const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180; - const a = Math.sin(deltaPhi / 2) ** 2 - + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; - return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); -} - +import { applicationServices } from '../services/application.js'; +export * from './regionalModel.js'; /** Fetch a bounded regional brief through the same-origin dev/preview proxy. */ export async function fetchRegionalBrief(latitude, longitude, { signal } = {}) { if (![latitude, longitude].every(Number.isFinite)) throw new Error('Valid coordinates are required'); - const params = new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) }); - const response = await fetch(`/api/regional-brief?${params}`, { signal }); - if (!response.ok) throw new Error(`Regional brief unavailable (${response.status})`); - return response.json(); + return applicationServices.regional.getBrief(latitude, longitude, { signal }); } diff --git a/src/data/regionalModel.js b/src/data/regionalModel.js new file mode 100644 index 000000000..21300d143 --- /dev/null +++ b/src/data/regionalModel.js @@ -0,0 +1,122 @@ +const MAX_ARTICLES = 5; + +function cleanText(value, maxLength = 180) { + return String(value || '').replace(/\s+/g, ' ').trim().slice(0, maxLength); +} + +function safeHttpUrl(value) { + try { + const parsed = new URL(String(value || '')); + return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.href : null; + } catch { + return null; + } +} + +/** Normalize a Nominatim reverse-geocode response into cockpit-sized place context. */ +export function normalizeRegionalPlace(payload) { + const address = payload?.address || {}; + const locality = cleanText( + address.city || address.town || address.village || address.municipality + || address.hamlet || address.county, + 90, + ); + const region = cleanText(address.state || address.region || address.county, 90); + const country = cleanText(address.country, 90); + const label = [locality, region].filter((value, index, values) => value && values.indexOf(value) === index) + .join(', ') || country || cleanText(payload?.display_name, 120); + if (!label) return null; + return { + label, + locality: locality || null, + region: region || null, + country: country || null, + countryCode: cleanText(address.country_code, 4).toUpperCase() || null, + }; +} + +/** Normalize and deduplicate GDELT ArticleList output without trusting article HTML. */ +export function normalizeRegionalArticles(payload, limit = MAX_ARTICLES) { + const rows = Array.isArray(payload?.articles) ? payload.articles : []; + const seen = new Set(); + const articles = []; + for (const row of rows) { + const url = safeHttpUrl(row?.url || row?.url_mobile); + const title = cleanText(row?.title, 180); + if (!url || !title) continue; + const signature = `${title.toLowerCase()}|${new URL(url).hostname}`; + if (seen.has(signature)) continue; + seen.add(signature); + const rawDate = cleanText(row?.seendate, 32); + const compactDate = /^(\d{8})T(\d{6})Z$/.exec(rawDate); + const publishedAt = compactDate + ? `${compactDate[1].slice(0, 4)}-${compactDate[1].slice(4, 6)}-${compactDate[1].slice(6, 8)}T${compactDate[2].slice(0, 2)}:${compactDate[2].slice(2, 4)}:${compactDate[2].slice(4, 6)}Z` + : Number.isNaN(Date.parse(rawDate)) ? null : new Date(rawDate).toISOString(); + articles.push({ + title, + url, + domain: cleanText(row?.domain || new URL(url).hostname.replace(/^www\./, ''), 80), + publishedAt, + sourceCountry: cleanText(row?.sourcecountry, 60) || null, + }); + if (articles.length >= Math.max(1, Math.min(MAX_ARTICLES, limit))) break; + } + return articles; +} + +/** Normalize Open-Meteo current conditions into a small source-stamped record. */ +export function normalizeRegionalWeather(payload) { + const current = payload?.current; + if (!current || !Number.isFinite(Number(current.temperature_2m))) return null; + const numberOrNull = (value) => { + if (value === null || value === undefined || value === '') return null; + return Number.isFinite(Number(value)) ? Number(value) : null; + }; + // Open-Meteo reports zone-naive timestamps ("2026-08-17T00:15") that are UTC, + // but JS parses zoneless date-times as LOCAL — pin them to UTC explicitly. + const observedRaw = typeof current.time === 'string' && !/(?:[zZ]|[+-]\d\d:?\d\d)$/.test(current.time) + ? `${current.time}Z` + : current.time; + return { + observedAt: Number.isNaN(Date.parse(observedRaw)) ? null : new Date(observedRaw).toISOString(), + temperatureC: numberOrNull(current.temperature_2m), + apparentTemperatureC: numberOrNull(current.apparent_temperature), + precipitationMm: numberOrNull(current.precipitation), + cloudCoverPct: numberOrNull(current.cloud_cover), + windKph: numberOrNull(current.wind_speed_10m), + windDirectionDeg: numberOrNull(current.wind_direction_10m), + visibilityM: numberOrNull(current.visibility), + weatherCode: numberOrNull(current.weather_code), + }; +} + +/** Translate the WMO weather code used by Open-Meteo into concise cockpit copy. */ +export function weatherCodeLabel(code) { + const value = Number(code); + if (!Number.isFinite(value)) return 'CONDITIONS UNKNOWN'; + if (value === 0) return 'CLEAR'; + if ([1, 2].includes(value)) return 'PARTLY CLOUDY'; + if (value === 3) return 'OVERCAST'; + if ([45, 48].includes(value)) return 'FOG'; + if (value >= 51 && value <= 57) return 'DRIZZLE'; + if (value >= 61 && value <= 67) return 'RAIN'; + if (value >= 71 && value <= 77) return 'SNOW'; + if (value >= 80 && value <= 82) return 'RAIN SHOWERS'; + if (value >= 85 && value <= 86) return 'SNOW SHOWERS'; + if (value >= 95) return 'THUNDERSTORM'; + return 'MIXED CONDITIONS'; +} + +/** Great-circle distance used to avoid refetching a regional brief every animation frame. */ +export function regionalDistanceM(from, to) { + if (![from?.latitude, from?.longitude, to?.latitude, to?.longitude].every(Number.isFinite)) { + return Infinity; + } + const phi1 = from.latitude * Math.PI / 180; + const phi2 = to.latitude * Math.PI / 180; + const deltaPhi = (to.latitude - from.latitude) * Math.PI / 180; + const deltaLambda = (to.longitude - from.longitude) * Math.PI / 180; + const a = Math.sin(deltaPhi / 2) ** 2 + + Math.cos(phi1) * Math.cos(phi2) * Math.sin(deltaLambda / 2) ** 2; + return 6371000 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); +} diff --git a/src/data/rocketLaunches.js b/src/data/rocketLaunches.js index c63b5e7a9..f19323f0f 100644 --- a/src/data/rocketLaunches.js +++ b/src/data/rocketLaunches.js @@ -1,3 +1,4 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; import { createRocketLaunchesLayer, createLaunchSource, @@ -7,8 +8,14 @@ import * as geometry from '../celestialRing.js'; import * as overlays from '../overlays/worldOverlay.js'; import * as render from '../renderGovernor.js'; +const sourceSlot = createSourceSlot( + createLaunchSource(), + ['getLaunches', 'getActiveTle'], + 'Launch source', +); +export const configureLaunchSource = sourceSlot.configure; const layer = createRocketLaunchesLayer({ - source: createLaunchSource(), + source: sourceSlot.source, services: { satellites, geometry, overlays, render }, }); export const satelliteParamsForSpaceMissions = diff --git a/src/data/satellites.js b/src/data/satellites.js index ecad3df45..11093ec13 100644 --- a/src/data/satellites.js +++ b/src/data/satellites.js @@ -1,3 +1,4 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; import { createSatellitesLayer, createSatelliteSource, @@ -10,8 +11,14 @@ import * as context from './contextStore.js'; import * as render from '../renderGovernor.js'; import * as layerState from './layerState.js'; +const sourceSlot = createSourceSlot( + createSatelliteSource(), + ['readGroup'], + 'Satellite source', +); +export const configureSatelliteSource = sourceSlot.configure; const layer = createSatellitesLayer({ - source: createSatelliteSource(), + source: sourceSlot.source, services: { picking, focus, readout, overlays, context, render, layerState }, }); export const satelliteVisualsVisible = layer.satelliteVisualsVisible; diff --git a/src/data/terrainHeights.js b/src/data/terrainHeights.js index de1dd9860..cbae46808 100644 --- a/src/data/terrainHeights.js +++ b/src/data/terrainHeights.js @@ -1,3 +1,4 @@ +import { applicationServices } from '../services/application.js'; // src/data/terrainHeights.js — batched, cached client terrain-height resolver // (docs/plans/2026-07-05-entity-height-datum-fix.md Task 3). // @@ -101,11 +102,8 @@ export function cachedRealEllipsoidalGround(lat, lon) { async function fetchChunk(chunk) { // lon,lat order (matches the proxy's documented `points=lon,lat;…` contract // and Task 2's implementation). - const pointsParam = chunk.map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`).join(';'); - const url = `/api/terrain/heights?points=${encodeURIComponent(pointsParam)}`; - const res = await fetch(url, { signal: AbortSignal.timeout(30000) }); - if (!res.ok) throw new Error(`terrain heights proxy HTTP ${res.status}`); - const body = await res.json(); + const results = await applicationServices.terrain.getHeights(chunk, { signal: AbortSignal.timeout(30000) }); + const body = { results }; if (!Array.isArray(body?.results)) throw new Error('malformed terrain heights response (no results array)'); if (body.results.length !== chunk.length) { throw new Error( diff --git a/src/data/traffic.js b/src/data/traffic.js index 05fed3bf1..a236e1d8e 100644 --- a/src/data/traffic.js +++ b/src/data/traffic.js @@ -1,10 +1,21 @@ +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(), + ['requestRoads', 'getStatus', 'fetchFlowForBounds'], + 'Traffic source', + { + getFlowSessionStats: () => ({ tilesFetched: 0 }), + resetFlowTileCache: () => {}, + }, +); +export const configureTrafficSource = sourceSlot.configure; const layer = createTrafficLayer({ - source: createTrafficSource(), + source: sourceSlot.source, services: { credits, render }, }); export const getTrafficTimingDiagnostics = layer.getTrafficTimingDiagnostics; diff --git a/src/devCctv.test.mjs b/src/devCctv.test.mjs index b1a2f0194..b2a7f7122 100644 --- a/src/devCctv.test.mjs +++ b/src/devCctv.test.mjs @@ -20,10 +20,10 @@ async function launch(overrides = {}, dotenv = '') { for (const name of ['dev-cctv.sh', 'dev-fresh.sh', 'read-dotenv-value.mjs']) { await fs.copyFile(new URL(`../scripts/${name}`, import.meta.url), path.join(root, 'scripts', name)); } - await fs.mkdir(path.join(root, 'src', 'standalone'), { recursive: true }); - await fs.copyFile(new URL('./standalone/data.js', import.meta.url), path.join(root, 'src', 'standalone', 'data.js')); + await fs.mkdir(path.join(root, 'src', 'app'), { recursive: true }); + await fs.copyFile(new URL('./app/data.js', import.meta.url), path.join(root, 'src', 'app', 'data.js')); await fs.mkdir(path.join(root, 'node_modules')); - await fs.symlink(fileURLToPath(new URL('../node_modules/vite', import.meta.url)), path.join(root, 'node_modules', 'vite'), 'dir'); + 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); // Stub only external programs; both production launchers and dotenv parsing run. for (const command of ['security', 'pkill', 'lsof']) { diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 41bd894f2..7b882a67d 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { GEV_REALTIME_TOOLS } from '../server/providers/openai/tools.js'; import test from 'node:test'; @@ -550,8 +551,8 @@ test('the decision table is written down where the next editor will read it', () // ── Markup, startup ordering, accessibility ───────────────────────────────── test('markup, startup ordering and accessibility remain pinned', () => { - const html = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); - const startup = fs.readFileSync(new URL('./standalone/startupChrome.js', import.meta.url), 'utf8'); + const html = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8')); + const startup = fs.readFileSync(new URL('./app/startupChrome.js', import.meta.url), 'utf8'); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match(html, /id="first-run-launcher" role="dialog"[^>]*aria-labelledby="first-run-title"[^>]*hidden/); @@ -584,7 +585,7 @@ test('markup, startup ordering and accessibility remain pinned', () => { assert.match(startup, /styleManager\.initialRestorePromise/); assert.ok(startup.indexOf("loadingScreen.classList.add('hidden')") < startup.indexOf("loadingScreen.addEventListener('transitionend', revealFirstRun")); - assert.match(startup, /initFirstRunExperience\(\{ styleManager, dataManager \}\)/); + assert.match(startup, /initializeWelcome\?\.\(\{ styleManager, dataManager \}\)/); assert.match(css, /body\.ui-clean-view #first-run-launcher/); assert.match(css, /body\.recording-mode #first-run-launcher/); diff --git a/src/hud.js b/src/hud.js index fe152a478..95721f9f0 100644 --- a/src/hud.js +++ b/src/hud.js @@ -1,3 +1,4 @@ +import { applicationServices } from './services/application.js'; /** * @module hud * @description Intelligence HUD Overlay — NRO/NGA Satellite Aesthetic. @@ -35,7 +36,6 @@ const MILITARY_STYLES = new Set(['retro', 'surveillance', 'thermal']); /** Allowed HUD layout variants. */ const HUD_VARIANTS = new Set(['tactical', 'operator', 'minimal']); const HUD_SUMMARY_INTERVAL_MS = 15000; -const HUD_SUMMARY_URL = '/api/openai/hud-summary'; /** * Cell size (degrees) for the ALT readout's geoid-undulation cache. N changes @@ -66,7 +66,9 @@ export class IntelHUD { * @param {Cesium.Viewer} viewer - The Cesium Viewer instance used for * camera telemetry and coordinate derivation. */ - constructor(viewer, { placeSearch } = {}) { + constructor(viewer, { placeSearch, summaryPolicy = {}, basemapContext = {} } = {}) { + this.summaryPolicy = summaryPolicy; + this.basemapContext = basemapContext; this.placeSearch = placeSearch; this.viewer = viewer; this._visible = false; @@ -627,6 +629,7 @@ export class IntelHUD { return; } if (!force && !this._summaryDirty) return; + if (this.summaryPolicy.canRequest?.() === false) return; const revision = this._summaryRevision; // Every caller invokes this as `void this._updateSummary(...)`, so nothing @@ -660,13 +663,9 @@ export class IntelHUD { const timeout = window.setTimeout(() => controller.abort(), 5000); this._summaryRequest = controller; try { - const response = await fetch(HUD_SUMMARY_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(context), - signal: controller.signal, - }); - const data = await response.json().catch(() => null); + this.summaryPolicy.onRequest?.(); + const response = await applicationServices.summary.summarize(context, { signal: controller.signal }); + const data = response.data; if (revision !== this._summaryRevision) return; if (isHudSummaryUnconfigured(response.status, data)) { this._setSummaryText(fallbackText, animate); @@ -701,7 +700,7 @@ export class IntelHUD { } async _summaryContext() { - const labels = await getBasemapLabelContext(this.viewer, this.placeSearch); + const labels = await getBasemapLabelContext(this.viewer, this.placeSearch, this.basemapContext); const enabledLayers = this._dataManager?.getAll?.() ?.filter((layer) => layer.enabled) .map((layer) => layer.name) || []; diff --git a/src/hudA11y.test.mjs b/src/hudA11y.test.mjs index bb468ff5d..3d11ebf3e 100644 --- a/src/hudA11y.test.mjs +++ b/src/hudA11y.test.mjs @@ -1,8 +1,9 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const parameters = readFileSync(new URL('./ui/styleParameters.js', import.meta.url), 'utf8'); // Focused markup guards; actual computed names are checked in Chromium. diff --git a/src/layers/alpr/index.js b/src/layers/alpr/index.js index 74a20ffcf..4f74209eb 100644 --- a/src/layers/alpr/index.js +++ b/src/layers/alpr/index.js @@ -33,7 +33,6 @@ import { createAlprPresentation } from './presentation.js'; export function createAlprCamerasLayer({ source, services } = {}) { if (typeof source?.fetch !== 'function') throw new TypeError('ALPR requires a camera source'); - const creditMarkup = alprCreditMarkup(source.attribution); const { governorRequestRender } = services.render; const { registerPickOwner, unregisterPickOwner } = services.picking; @@ -242,6 +241,7 @@ export function createAlprCamerasLayer({ source, services } = {}) { if (state.viewer) throw new Error('ALPR layer is already initialized'); state.viewer = viewer; state.dataSource = new Cesium.CustomDataSource('alpr-cameras'); + const creditMarkup = alprCreditMarkup(source.attribution); state.credit = creditMarkup ? new Cesium.Credit(creditMarkup, true) : null; diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index 09e81d43c..b57c92eb1 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -197,7 +198,7 @@ test('replacement, repetition, and hidden-tab elapsed time use the newest fixed test('universal notice lifecycle clears on dispose and uses the one top-center live region', () => { const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); - const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); diff --git a/src/locations.js b/src/locations.js index 15b110536..c248fe66a 100644 --- a/src/locations.js +++ b/src/locations.js @@ -1,3 +1,4 @@ +import { applicationServices } from './services/application.js'; import * as Cesium from 'cesium'; import { viewportBias, @@ -1345,15 +1346,8 @@ async function resolveBuildingBounds(lat, lon, query) { const controller = new AbortController(); const timeout = window.setTimeout(() => controller.abort(), 6000); try { - const response = await fetch('/api/overpass', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: `data=${encodeURIComponent(overpassQuery)}`, - signal: controller.signal, - }); - if (!response.ok) return null; - const data = await response.json(); - return selectBuildingBounds(data?.elements || [], lat, lon, query); + const elements = await applicationServices.boundaries.query(overpassQuery, { signal: controller.signal }); + return selectBuildingBounds(Array.isArray(elements) ? elements : [], lat, lon, query); } catch { return null; } finally { diff --git a/src/mapSourceFocus.test.mjs b/src/mapSourceFocus.test.mjs index f4a033442..548b4202f 100644 --- a/src/mapSourceFocus.test.mjs +++ b/src/mapSourceFocus.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { StyleManager } from './ui/applicationShell.js'; import { createHoverDisclosure, collapsePanelOnEscape } from './ui/panelDisclosure.js'; import test from 'node:test'; @@ -6,7 +7,7 @@ import { readFileSync } from 'node:fs'; // Exercise the installed event routes and central close method, without WebGL. const source = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); -const markup = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const markup = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const locationMarkup = markup.slice(markup.indexOf('
]*\bid="location-bar-toggle"[^>]*)>([\s\S]*?)<\/button>/); const locationToggleAttributes = Object.fromEntries( diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index befb7b673..f1f2d12e5 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; // MAP STACK chip row — the dropdown's replacement control surface. // @@ -481,7 +482,9 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { }); test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => { - const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml( + readFileSync(new URL('../index.html', import.meta.url), 'utf8'), + ); const ui = readFileSync( new URL('./ui/applicationShell.js', import.meta.url), 'utf8', diff --git a/src/overlays/worldOverlay.test.mjs b/src/overlays/worldOverlay.test.mjs index dac9abdcb..edb6fa1b4 100644 --- a/src/overlays/worldOverlay.test.mjs +++ b/src/overlays/worldOverlay.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../../build/application-html.js'; import { readStylesheet } from '../testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; @@ -463,7 +464,7 @@ test('lifecycle is idempotent and teardown removes listeners, observers, and DOM assert.deepEqual(root.children, [canvas], 'the overlay root carries only the shared card canvas'); assert.doesNotMatch( - readFileSync(new URL('../../index.html', import.meta.url), 'utf8'), + expandApplicationHtml(readFileSync(new URL('../../index.html', import.meta.url), 'utf8')), /world-overlay-detection-surface/, 'the surface is runtime host-owned, not static markup', ); diff --git a/src/panelStackLayout.test.mjs b/src/panelStackLayout.test.mjs index 465f3cf4a..e05f045fb 100644 --- a/src/panelStackLayout.test.mjs +++ b/src/panelStackLayout.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -262,7 +263,7 @@ test('expanded left panels integrate their headers with the container shell', () }); test('Map Source uses five compact tiles in the bottom Visual Presets tray', () => { - const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.doesNotMatch(html, /id="stack-panel"/); @@ -275,7 +276,7 @@ test('Map Source uses five compact tiles in the bottom Visual Presets tray', () }); test('expanded right panels highlight the title divider without changing collapsed launchers', () => { - const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const css = readStylesheet(new URL('../style.css', import.meta.url)); assert.match( diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 814c9a611..06892f07d 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; import { readLayerSource } from './testSupport/readLayerSource.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import { readFileSync as readRadioSource } from 'node:fs'; @@ -9,7 +10,7 @@ import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; import test from 'node:test'; -const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const html = expandApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); const radio = ['playback', 'interaction'].map(name => readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8') diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index fa745c1ca..a3ee563c7 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -1,3 +1,4 @@ +import { expandApplicationHtml } from '../build/application-html.js'; // src/reasonableDefaults.test.mjs // // What the console looks like the FIRST time it opens — before any share link, @@ -44,7 +45,7 @@ import { ShareLinkManager } from './sharelink.js'; // Follow the UI wiring and its extracted preset definitions. const uiSource = fs.readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8') + '\n' + fs.readFileSync(new URL('./ui/visualPresets.js', import.meta.url), 'utf8'); -const indexHtml = fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const indexHtml = expandApplicationHtml(fs.readFileSync(new URL('../index.html', import.meta.url), 'utf8')); const shareSource = fs.readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8'); /** Slice ui.js between two literal anchors, so a pin reads one method, not the file. */ diff --git a/src/services/application.js b/src/services/application.js new file mode 100644 index 000000000..446c677cb --- /dev/null +++ b/src/services/application.js @@ -0,0 +1,40 @@ +import { createSourceSlot } from '../app/sourceSlot.js'; +import { createApplicationRequestServices } from './requests.js'; + +// Compatibility owners are page-scoped, like the viewer and its layer registry. +const defaults = createApplicationRequestServices(); +const slots = Object.fromEntries( + Object.entries({ + boundaries: ['query'], + terrain: ['getHeights'], + regional: ['getBrief'], + weather: ['getConditions'], + summary: ['summarize'], + }).map(([name, methods]) => [ + name, + createSourceSlot(defaults[name], methods, `${name} service`), + ]), +); +export const applicationServices = Object.freeze( + Object.fromEntries( + Object.entries(slots).map(([name, slot]) => [name, slot.source]), + ), +); + +/** Supply selected services before constructing the application; release after its consumers. */ +export function configureApplicationServices(services) { + const releases = []; + try { + for (const [name, service] of Object.entries(services)) { + if (!slots[name]) + throw new TypeError(`Unknown application service: ${name}`); + releases.push(slots[name].configure(service)); + } + } catch (error) { + for (const release of releases.reverse()) release(); + throw error; + } + return () => { + for (const release of releases.reverse()) release(); + }; +} diff --git a/src/services/requests.js b/src/services/requests.js new file mode 100644 index 000000000..f82fa8eeb --- /dev/null +++ b/src/services/requests.js @@ -0,0 +1,128 @@ +/** Parse bounded retry information from a service response. */ +function retryAfterMs(value) { + if (value == null || String(value).trim() === '') return null; + const seconds = Number(value); + if (Number.isFinite(seconds) && seconds >= 0) + return Math.ceil(seconds * 1000); + const at = Date.parse(String(value)); + return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null; +} + +/** Construct independent request services for compatible application protocols. */ +export function createApplicationRequestServices({ + fetchImpl = (...args) => fetch(...args), + signal: lifetime, + endpoints = {}, +} = {}) { + const urls = { + boundaries: '/api/overpass', + terrain: '/api/terrain/heights', + regional: '/api/regional-brief', + weather: '/api/weather-effects', + summary: '/api/openai/hud-summary', + ...endpoints, + }; + async function request(endpoint, { signal, ...init } = {}) { + signal = AbortSignal.any([lifetime, signal].filter(Boolean)); + signal.throwIfAborted(); + const response = await fetchImpl(endpoint, { + ...init, + signal, + redirect: 'error', + }); + signal.throwIfAborted(); + let data = null; + try { + data = await response.json(); + } catch { + /* Status remains authoritative for non-JSON errors. */ + } + signal.throwIfAborted(); + return { + ok: response.ok, + status: response.status, + headers: response.headers, + data, + }; + } + function pointUrl(endpoint, latitude, longitude) { + if ( + !Number.isFinite(latitude) || + latitude < -90 || + latitude > 90 || + !Number.isFinite(longitude) || + longitude < -180 || + longitude > 180 + ) + throw new TypeError('Valid coordinates are required'); + return `${endpoint}?${new URLSearchParams({ latitude: latitude.toFixed(5), longitude: longitude.toFixed(5) })}`; + } + function requireOk(response, label) { + if (!response.ok) + throw new Error(`${label} unavailable (${response.status})`); + return response.data; + } + return { + boundaries: { + async query(query, { signal } = {}) { + const response = await request(urls.boundaries, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `data=${encodeURIComponent(query)}`, + }); + const retry = response.headers?.get?.('Retry-After'); + if ( + response.status === 429 || + (response.status === 503 && retry != null) + ) + return { rateLimited: true, retryAfterMs: retryAfterMs(retry) }; + if (!response.ok) return null; + const remark = String(response.data?.remark || '').toLowerCase(); + if (/runtime error|timed out|out of memory/.test(remark)) return null; + return Array.isArray(response.data?.elements) + ? response.data.elements + : null; + }, + }, + terrain: { + async getHeights(points, { signal } = {}) { + const query = points + .map(({ lat, lon }) => `${lon.toFixed(5)},${lat.toFixed(5)}`) + .join(';'); + return requireOk( + await request(`${urls.terrain}?points=${encodeURIComponent(query)}`, { + signal, + }), + 'Terrain heights', + )?.results; + }, + }, + regional: { + async getBrief(latitude, longitude, options) { + return requireOk( + await request(pointUrl(urls.regional, latitude, longitude), options), + 'Regional brief', + ); + }, + }, + weather: { + async getConditions(latitude, longitude, options) { + return requireOk( + await request(pointUrl(urls.weather, latitude, longitude), options), + 'Weather', + ); + }, + }, + summary: { + async summarize(context, { signal } = {}) { + return request(urls.summary, { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(context), + }); + }, + }, + }; +} diff --git a/src/services/requests.test.mjs b/src/services/requests.test.mjs new file mode 100644 index 000000000..e8531a774 --- /dev/null +++ b/src/services/requests.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApplicationRequestServices } from './requests.js'; + +test('independent compatible endpoints receive normalized requests without changing global fetch', async () => { + const requests = []; + const original = globalThis.fetch; + const services = createApplicationRequestServices({ + endpoints: { regional: '/custom/region', terrain: '/custom/floors', summary: '/custom/summary' }, + fetchImpl: async (url, init) => { + requests.push({ url, init }); + return Response.json({ results: [{ ellipsoid: 10 }], summary: 'fixture' }); + }, + }); + await services.regional.getBrief(30, -97); + assert.match(requests[0].url, /^\/custom\/region\?latitude=30.00000&longitude=-97.00000$/); + assert.deepEqual(await services.terrain.getHeights([{ lat: 30, lon: -97 }]), [{ ellipsoid: 10 }]); + const result = await services.summary.summarize({ place: 'fixture' }); + assert.equal(result.data.summary, 'fixture'); + assert.equal(requests[2].init.body, '{"place":"fixture"}'); + assert.equal(requests[2].init.redirect, 'error'); + assert.equal(globalThis.fetch, original); +}); + +test('lifetime cancellation discards a late response body even from an uncooperative transport', async () => { + const lifetime = new AbortController(); + let finish; + const services = createApplicationRequestServices({ signal: lifetime.signal, + fetchImpl: async () => ({ ok: true, status: 200, json: () => new Promise(resolve => { finish = resolve; }) }), + }); + const pending = services.weather.getConditions(30, -97); + while (!finish) await Promise.resolve(); + lifetime.abort(); + finish({ weather: { temperatureC: 10 } }); + await assert.rejects(pending, { name: 'AbortError' }); + await assert.rejects(services.regional.getBrief(30, -97), { name: 'AbortError' }); +}); + +test('boundary throttle status survives empty or invalid error bodies', async () => { + for (const status of [429, 503]) { + const services = createApplicationRequestServices({ fetchImpl: async () => new Response('unavailable', { status, headers: { 'Retry-After': '5' } }) }); + assert.deepEqual(await services.boundaries.query('fixture'), { rateLimited: true, retryAfterMs: 5000 }); + } +}); diff --git a/src/standalone/controls.js b/src/standalone/controls.js index 96dd33f4c..4d083868b 100644 --- a/src/standalone/controls.js +++ b/src/standalone/controls.js @@ -1,36 +1 @@ -import { StyleManager } from '../ui.js'; -import { flyToAustin } from '../camera.js'; -import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; - -/** Construct the existing controls and camera presentation. */ -export function createStandaloneControls({ - scene: { viewer, mapStackController }, - loaderStatus, - placeSearch, - defer, -}) { - // Initialize the style manager (post-processing, HUD, locations, share links) - const styleManager = new StyleManager(viewer, { - mapStackController, - placeSearch, - }); - defer(() => styleManager.orbitController.stop()); - defer(() => styleManager.hud.destroy()); - defer(() => styleManager.dispose()); - // The previous multi-canvas weather compositor remains disabled. Cockpit - // clouds use a separate, capped low-resolution GPU pass that never attaches - // Cesium fog or post-process stages and is fully stopped in map mode. - const weatherEffects = null; - const cockpitCloudEffects = initCockpitCloudEffects(viewer); - defer(() => cockpitCloudEffects?.destroy()); - - // If no share link state, do default fly-to Austin - if (!styleManager.hasShareState) { - loaderStatus.textContent = 'Flying to Austin, TX...'; - defer(flyToAustin(viewer)); - } else { - loaderStatus.textContent = 'Restoring shared view...'; - } - - return { styleManager, weatherEffects, cockpitCloudEffects }; -} +export { createApplicationControls as createStandaloneControls } from '../app/controls.js'; diff --git a/src/standalone/data.js b/src/standalone/data.js index 717a2ae18..38c18d17d 100644 --- a/src/standalone/data.js +++ b/src/standalone/data.js @@ -1,80 +1 @@ -import { DataLayerManager } from '../data/manager.js'; -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'; - -/** Register the standalone layer catalog before allowing state restoration. */ -export function createStandaloneData({ - scene: { viewer }, - controls: { styleManager }, - allowQaRegistration, - defer, -}) { - // Initialize data layer manager - const dataManager = new DataLayerManager(viewer, { - allowQaRegistration, - }); - defer(async () => { - await dataManager.destroyAll(); - if (dataManager.layers.size) - throw new Error( - `Data layers could not be destroyed: ${[...dataManager.layers.keys()].join(', ')}`, - ); - }); - dataManager.register(flightsLayer); - dataManager.register(militaryFlightsLayer); - dataManager.register(earthquakesLayer); - dataManager.register(alprCamerasLayer); - dataManager.register(satellitesLayer); - dataManager.register(rocketLaunchesLayer); - rocketLaunchesLayer.attachDataManager(dataManager); - dataManager.register(trafficLayer); - dataManager.register(cctvLayer); - dataManager.register(radioLayer); - dataManager.register(bikeshareLayer); - dataManager.register(aisLiveVesselsLayer); - dataManager.register(militaryInstallationsLayer); - dataManager.register(militaryAwarenessLayer); - militaryAwarenessLayer.attachDataManager(dataManager); - for (const layer of localDataLayers) { - dataManager.register(layer); - } - // Restoration starts only after the complete production registry is sealed. - dataManager.finalizeRegistrations(LAYER_STATE_REGISTRY); - if (allowQaRegistration) { - window.__gevQaRegisterLayer = (targetManager, layerModule) => { - if (targetManager !== dataManager) - throw new Error('QA layer manager mismatch'); - return dataManager.registerForQa(layerModule); - }; - window.__gevQaUnregisterLayer = (targetManager, layerId) => { - if (targetManager !== dataManager) - throw new Error('QA layer manager mismatch'); - return dataManager.unregisterForQa(layerId); - }; - const register = window.__gevQaRegisterLayer; - const unregister = window.__gevQaUnregisterLayer; - defer(() => { - if (window.__gevQaRegisterLayer === register) - delete window.__gevQaRegisterLayer; - if (window.__gevQaUnregisterLayer === unregister) - delete window.__gevQaUnregisterLayer; - }); - } - dataManager.buildTogglePanel(document.getElementById('data-toggles')); - styleManager.attachDataManager(dataManager); - - return { dataManager }; -} +export { createApplicationData as createStandaloneData } from '../app/data.js'; diff --git a/src/standalone/errors.js b/src/standalone/errors.js index aebba3a3a..f56fb58fc 100644 --- a/src/standalone/errors.js +++ b/src/standalone/errors.js @@ -1,25 +1 @@ -/** - * Extract a human-readable error message from any thrown value. - * Handles Error objects, strings, and plain objects with message/error fields. - * @param {*} error — caught exception value - * @returns {string} best-effort error description - */ -export function describeError(error) { - if (!error) return 'Unknown initialization error'; - if (error instanceof Error) { - if (error.message && error.message.trim()) return error.message.trim(); - return error.name || 'Initialization error'; - } - if (typeof error === 'string' && error.trim()) return error.trim(); - if (typeof error === 'object') { - const maybeMessage = String(error.message || error.error || '').trim(); - if (maybeMessage) return maybeMessage; - try { - const serialized = JSON.stringify(error); - if (serialized && serialized !== '{}') return serialized; - } catch { - // ignore serialization error - } - } - return String(error); -} +export { describeError } from '../app/errors.js'; diff --git a/src/standalone/scene.js b/src/standalone/scene.js index a8661e201..ae464d751 100644 --- a/src/standalone/scene.js +++ b/src/standalone/scene.js @@ -1,105 +1 @@ -import * as Cesium from 'cesium'; -import { createApplicationViewer } from '../app/viewer.js'; -import { registerDataCredits } from '../data/dataCredits.js'; -import { configureCreditKeyboardAccess } from '../creditKeyboard.js'; -import { MapStackController } from '../mapStackController.js'; -import { loadPhotorealisticTileset } from '../mapStartup.js'; -import { initLogoGaze } from '../logoGaze.js'; -import { uninstallRenderGovernor } from '../renderGovernor.js'; -import { describeError } from './errors.js'; - -/** Construct the standalone globe using the caller's local configuration. */ -export async function createStandaloneScene({ - googleApiKey, - cesiumToken, - loaderStatus, - signal, - defer, -}) { - defer(initLogoGaze()); - const previousKey = window.__GOOGLE_MAPS_API_KEY__; - if (googleApiKey) { - window.__GOOGLE_MAPS_API_KEY__ = googleApiKey; - defer(() => { - if (window.__GOOGLE_MAPS_API_KEY__ !== googleApiKey) return; - if (previousKey === undefined) delete window.__GOOGLE_MAPS_API_KEY__; - else window.__GOOGLE_MAPS_API_KEY__ = previousKey; - }); - } - loaderStatus.textContent = 'Configuring viewer...'; - // Provider attribution stays visible, including clean-view and recording. - const creditContainer = document.createElement('div'); - creditContainer.id = 'cesium-credits'; - document.body.appendChild(creditContainer); - defer(() => creditContainer.remove()); - const viewer = createApplicationViewer({ - container: 'cesiumContainer', - creditContainer, - }); - defer(() => { - uninstallRenderGovernor(viewer); - if (!viewer.isDestroyed()) viewer.destroy(); - }); - registerDataCredits(viewer); - configureCreditKeyboardAccess(document); - loaderStatus.textContent = - googleApiKey || cesiumToken - ? 'Loading Google 3D Tiles...' - : 'Loading the keyless globe...'; - const photoreal = await loadPhotorealisticTileset(Cesium, { - googleApiKey, - cesiumToken, - }); - const tileset = photoreal.tileset; - // A provider can finish after cancellation; retain ownership of its result. - defer(() => { - if (tileset && !tileset.isDestroyed()) { - if (!viewer.scene.primitives.remove(tileset)) tileset.destroy(); - } - }); - signal.throwIfAborted(); - if (tileset) { - viewer.scene.primitives.add(tileset); - // NOTE: Cesium World Terrain intentionally disabled — conflicts with Google 3D Tiles at high zoom. - // Google Photorealistic 3D Tiles provide their own terrain/elevation. - viewer.scene.globe.show = false; - console.info(`[Init] Google 3D Tiles loaded via ${photoreal.route}.`); - } else { - if (photoreal.errors.length) { - const tileError = photoreal.errors.at(-1); - console.warn( - '[Init] Google 3D Tiles unavailable, using the keyless globe:', - tileError, - ); - const tileErrorDetail = describeError(tileError); - loaderStatus.textContent = `Google 3D Tiles unavailable (${tileErrorDetail}). Loading the keyless globe...`; - } - viewer.scene.globe.show = true; - } - - loaderStatus.textContent = 'Initializing systems...'; - - const mapStackController = new MapStackController(viewer, { - googleTileset: tileset, - cesiumToken, - initialStack: tileset ? 'photoreal' : 'esri-imagery', - // Task 5 (height-datum fix): rebroadcast stack changes as a window - // CustomEvent so data layers (CCTV per-regime ground resolution) can - // react without coupling MapStackController to layer modules. Fires on - // 'switching'/'ready'/'error'; listeners derive the surface regime from - // live scene state, so intermediate emissions are harmless. - onChange: (state) => { - window.dispatchEvent( - new CustomEvent('gev:map-stack-changed', { detail: state }), - ); - }, - onError: (message) => console.warn('[MapStack]', message), - }); - defer(() => mapStackController.destroy()); - await mapStackController.setStack(tileset ? 'photoreal' : 'esri-imagery', { - silent: true, - }); - - signal.throwIfAborted(); - return { viewer, tileset, mapStackController }; -} +export { createApplicationScene as createStandaloneScene } from '../app/scene.js'; diff --git a/src/standalone/startupChrome.js b/src/standalone/startupChrome.js index 5cb3fb855..8eef32346 100644 --- a/src/standalone/startupChrome.js +++ b/src/standalone/startupChrome.js @@ -1,51 +1,8 @@ -import { initFirstRunExperience } from '../firstRunExperience.js'; +import { startApplicationChrome } from '../app/startupChrome.js'; import { initKeySetup } from '../keySetup.js'; - -/** Reveal welcome controls only after restoration and the loading transition. */ -export function startStandaloneChrome({ - loadingScreen, - styleManager, - dataManager, - signal, -}) { - let disposed = false; - let firstRun; - let revealTimer; - let resolveDelay; - const minimumDelay = new Promise((resolve) => { - resolveDelay = resolve; +export function startStandaloneChrome(options) { + return startApplicationChrome({ + initializeSettings: initKeySetup, + ...options, }); - const delayTimer = setTimeout(resolveDelay, 1000); - const revealFirstRun = () => { - if (disposed || signal.aborted || firstRun) return; - firstRun = initFirstRunExperience({ styleManager, dataManager }); - clearTimeout(revealTimer); - loadingScreen.removeEventListener('transitionend', revealFirstRun); - }; - void Promise.all([styleManager.initialRestorePromise, minimumDelay]) - .catch(() => { - /* Restoration reports its own outcome through the controls. */ - }) - .then(() => { - if (disposed || signal.aborted) return; - loadingScreen.classList.add('hidden'); - loadingScreen.addEventListener('transitionend', revealFirstRun, { - once: true, - }); - revealTimer = setTimeout(revealFirstRun, 900); - }); - const keySetup = initKeySetup({ signal }); - // Own the pending initializer too; it must not reveal a dialog after abort. - void keySetup.catch(() => - console.error('Provider settings initialization failed'), - ); - return async () => { - disposed = true; - clearTimeout(delayTimer); - clearTimeout(revealTimer); - resolveDelay(); - loadingScreen.removeEventListener('transitionend', revealFirstRun); - firstRun?.destroy(); - (await keySetup.catch(() => null))?.destroy(); - }; } diff --git a/src/standalone/startupChrome.test.mjs b/src/standalone/startupChrome.test.mjs index d9e1cbef5..5f934a72b 100644 --- a/src/standalone/startupChrome.test.mjs +++ b/src/standalone/startupChrome.test.mjs @@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs'; import vm from 'node:vm'; const source = readFileSync( - new URL('./startupChrome.js', import.meta.url), + new URL('../app/startupChrome.js', import.meta.url), 'utf8', ) .replace(/^import .*;\n/gm, '') @@ -37,7 +37,8 @@ function fixture() { }; vm.createContext(context); vm.runInContext(source, context); - const stop = context.startStandaloneChrome({ + const stop = context.startApplicationChrome({ + initializeSettings: context.initKeySetup, loadingScreen: { classList: { add: (value) => events.push(value) }, addEventListener: (type, listener) => listeners.set(type, listener), diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 33a39393c..6c74a4854 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -1,120 +1,8 @@ -import { SceneDirector } from '../scenes/director.js'; -import { initAnnotations } from '../annotations/index.js'; -import { initGevVoiceCommands } from '../voice/gevRealtime.js'; -import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; -import { - installRenderGovernor, - getRenderGovernorDiagnostics, - governorRequestRender, - holdContinuousRender, - releaseContinuousRender, -} from '../renderGovernor.js'; +import { createApplicationTools } from '../app/tools.js'; import { startStandaloneChrome } from './startupChrome.js'; - -/** Attach scene tools, rendering listeners and the standalone debug handle. */ -export function createStandaloneTools({ - scene, - controls, - data, - loadingScreen, - placeSearch, - voice = {}, - signal, - defer, -}) { - const { viewer, tileset, mapStackController } = scene; - const { styleManager, weatherEffects, cockpitCloudEffects } = controls; - const { dataManager } = data; - const sceneDirector = new SceneDirector(viewer, styleManager, dataManager); - defer(() => sceneDirector.destroy()); - const annotations = initAnnotations({ viewer, tileset, placeSearch }); - defer(() => { - if (window.__gevAnnotations === annotations) delete window.__gevAnnotations; - annotations.destroy(); +export function createStandaloneTools(options) { + return createApplicationTools({ + startChrome: startStandaloneChrome, + ...options, }); - defer( - startStandaloneChrome({ loadingScreen, styleManager, dataManager, signal }), - ); - // Idle render governor: flips the scene into requestRenderMode whenever - // nothing animates per frame. Installed AFTER every module above has had - // its chance to register pre-install holds. (perf wave 2) - installRenderGovernor(viewer); - - // Install the explicit scope mask used by the DISPLAY controls. - installScopeMask(viewer); - defer(() => destroyScopeMask()); - - // The follow camera recomputes the tracked target's dead-reckon position - // every frame — tracking anything is a per-frame animation. (perf wave 2) - const removeTrackingListener = viewer.trackedEntityChanged.addEventListener( - () => { - if (viewer.trackedEntity) holdContinuousRender('tracked-entity'); - else releaseContinuousRender('tracked-entity'); - }, - ); - - // Hidden-state suspension (perf wave 2): when the window/tab is hidden, - // stop the default render loop outright — a hidden canvas repaints for - // nobody, and browser rAF throttling still lets throttled frames burn - // GPU. Holder/data state is untouched, so return is seamless: restore - // the loop, refresh the one DOM surface we gated, render a frame. - const syncVisibilitySuspension = () => { - const hidden = document.hidden; - viewer.useDefaultRenderLoop = !hidden; - cockpitCloudEffects?.setSuspended?.(hidden); - if (!hidden) { - if (dataManager._panelRefreshPendingOnVisible) { - dataManager._panelRefreshPendingOnVisible = false; - dataManager._refreshTogglePanel(); - } - governorRequestRender('visibility-restore'); - } - }; - document.addEventListener('visibilitychange', syncVisibilitySuspension); - defer(() => - document.removeEventListener('visibilitychange', syncVisibilitySuspension), - ); - defer(() => { - removeTrackingListener(); - releaseContinuousRender('tracked-entity'); - }); - // Apply the CURRENT state too — bootstrap can complete while the tab is - // already hidden, and waiting for the next transition would leave the - // loop burning behind a hidden tab. (perf wave 2 fix) - syncVisibilitySuspension(); - - window.__godsEyeView = { - viewer, - styleManager, - tileset, - dataManager, - sceneDirector, - mapStackController, - annotations, - weatherEffects, - cockpitCloudEffects, - getRenderGovernorDiagnostics, - requestRender: governorRequestRender, - }; - const debug = window.__godsEyeView; - defer(() => { - if (window.__godsEyeView === debug) delete window.__godsEyeView; - }); - const voiceCommands = initGevVoiceCommands({ - ...voice, - signal, - placeSearch, - viewer, - styleManager, - dataManager, - sceneDirector, - annotations, - }); - defer(() => { - voiceCommands.stop({ removeUi: true }); - if (window.__gevVoiceCommands === voiceCommands) - delete window.__gevVoiceCommands; - }); - debug.voiceCommands = voiceCommands; - return { sceneDirector, annotations, voiceCommands }; } diff --git a/src/standalone/ui.js b/src/standalone/ui.js index 7f46543bb..780d515cf 100644 --- a/src/standalone/ui.js +++ b/src/standalone/ui.js @@ -1,153 +1 @@ -/** Compose UI controls with the application's existing engines and layer instances. */ -import { StyleManager as ApplicationShell } from '../ui/applicationShell.js'; -import { LocationSearch } from '../ui/location.js'; -import { - CITY_POIS, - GLOBE_VIEW, - flyToGlobeView, - flyToPresetLocation, - flyToPOI, - searchAndFlyTo, -} from '../locations.js'; -import { interruptCameraMotion } from '../cameraVerbs.js'; -import { IntelHUD } from '../hud.js'; -import { ShareLinkManager } from '../sharelink.js'; -import { OrbitController } from '../orbit.js'; -import { - CelestialRing, - getKeyholeFadeTuning, - isCelestialRingStyleSupported, - setKeyholeFadeTuning, -} from '../celestialRing.js'; -import { - destroyTrackedReadout, - initTrackedReadout, -} from '../data/trackedReadout.js'; -import { - destroyWorldOverlay, - initWorldOverlay, -} from '../overlays/worldOverlay.js'; -import { - destroyDetection, - initDetection, - cycleMode as cycleDetectionMode, - getDetectionDiagnostics as readDetectionDiagnostics, - getDetectionTuning, - getMode as getDetectionMode, - setMode as setDetectionModeByLabel, - suspendDetection, - resumeDetection, - 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, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, -} from '../data/groundFloor.js'; -import { sampleMeshFloorCells } from '../data/meshFloorSampler.js'; -import { - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, -} from '../renderGovernor.js'; -import { - setScopeMaskEnabled, - isScopeMaskEnabled, - setScopeMaskFeather, - getScopeMaskFeather, - setScopeTerminusOverride, - getScopeTerminusOverride, - clampScopeTerminusPct, -} from '../scopeMask.js'; -import { - fetchRegionalBrief, - regionalDistanceM, - weatherCodeLabel, -} from '../data/regionalBrief.js'; - -export class StyleManager extends ApplicationShell { - constructor(viewer, options = {}) { - super(viewer, { - ...options, - services: { - CITY_POIS, - GLOBE_VIEW, - flyToGlobeView, - flyToPresetLocation, - flyToPOI, - searchAndFlyTo, - interruptCameraMotion, - IntelHUD, - ShareLinkManager, - OrbitController, - CelestialRing, - getKeyholeFadeTuning, - isCelestialRingStyleSupported, - setKeyholeFadeTuning, - destroyTrackedReadout, - initTrackedReadout, - destroyWorldOverlay, - initWorldOverlay, - destroyDetection, - initDetection, - cycleDetectionMode, - readDetectionDiagnostics, - getDetectionTuning, - getDetectionMode, - setDetectionModeByLabel, - suspendDetection, - resumeDetection, - setDetectionStyle, - setDetectionTuning, - trafficLayer, - flightsLayer, - militaryFlightsLayer, - isTr3b, - toggleTr3b, - satellitesLayer, - cctvLayer, - radioLayer, - bikeshareLayer, - aisLiveVesselsLayer, - militaryAwarenessLayer, - militaryInstallationsLayer, - rocketLaunchesLayer, - cachedGroundFloor, - cachedMeshFloor, - GROUND_FLOOR_LIFT_M, - meshFloorPreferred, - warmGroundFloor, - sampleMeshFloorCells, - holdContinuousRender, - releaseContinuousRender, - governorRequestRender, - setScopeMaskEnabled, - isScopeMaskEnabled, - setScopeMaskFeather, - getScopeMaskFeather, - setScopeTerminusOverride, - getScopeTerminusOverride, - clampScopeTerminusPct, - fetchRegionalBrief, - regionalDistanceM, - weatherCodeLabel, - LocationSearch, - }, - }); - } -} +export { StyleManager } from '../ui/composition.js'; diff --git a/src/tooling/applicationHtml.test.mjs b/src/tooling/applicationHtml.test.mjs new file mode 100644 index 000000000..783c737a4 --- /dev/null +++ b/src/tooling/applicationHtml.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { expandApplicationHtml, APPLICATION_TEMPLATES } from '../../build/application-html.js'; + +test('the standalone document expands every component once and preserves unique element ids', () => { + const source = readFileSync(new URL('../../index.html', import.meta.url), 'utf8'); + const html = expandApplicationHtml(source); + assert.equal([...source.matchAll(/gev:template /g)].length, APPLICATION_TEMPLATES.length); + assert.doesNotMatch(html, /gev:template/); + const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map(match => match[1]); + assert.equal(new Set(ids).size, ids.length); + assert.match(html, /id="cesiumContainer"/); + assert.match(html, /type="module" src="\/src\/main.js"/); +}); + +test('component selection includes only requested markup and refuses filesystem traversal', () => { + const html = expandApplicationHtml('\n'); + assert.match(html, /id="first-run-launcher"/); + assert.doesNotMatch(html, /id="cesiumContainer"/); + assert.throws(() => expandApplicationHtml(''), /Unknown application template/); +}); diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index acfe243b5..497035a40 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -12,7 +12,7 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', }); - assert.equal(config.plugins[1], plugin); + assert.equal(config.plugins[2], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, [ @@ -51,7 +51,7 @@ test('build helper does not discover environment values or construct local provi config.define['import.meta.env.GOOGLE_MAPS_API_KEY'], undefined, ); - assert.equal(config.plugins.length, 1); + assert.equal(config.plugins.length, 2); } finally { if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY; else process.env.GOOGLE_MAPS_API_KEY = before; @@ -63,7 +63,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(1, -1).map((plugin) => plugin.name), + config.plugins.slice(2, -1).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); assert.equal(config.plugins.at(-2).name, 'gev-key-setup'); diff --git a/src/ui.js b/src/ui.js index ada8689fe..c864ee19b 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,2 +1,2 @@ -/** Compatibility entry for the standalone application's UI composition. */ -export { StyleManager } from './standalone/ui.js'; +/** Compatibility entry for the application's UI composition. */ +export { StyleManager } from './ui/composition.js'; diff --git a/src/ui/composition.js b/src/ui/composition.js new file mode 100644 index 000000000..5d4f1a65a --- /dev/null +++ b/src/ui/composition.js @@ -0,0 +1,154 @@ +/** Compose UI controls with the application's existing engines and layer instances. */ +import { StyleManager as ApplicationShell } from './applicationShell.js'; +import { LocationSearch } from './location.js'; +import { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, +} from '../locations.js'; +import { interruptCameraMotion } from '../cameraVerbs.js'; +import { IntelHUD } from '../hud.js'; +import { ShareLinkManager } from '../sharelink.js'; +import { OrbitController } from '../orbit.js'; +import { + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, +} from '../celestialRing.js'; +import { + destroyTrackedReadout, + initTrackedReadout, +} from '../data/trackedReadout.js'; +import { + destroyWorldOverlay, + initWorldOverlay, +} from '../overlays/worldOverlay.js'; +import { + destroyDetection, + initDetection, + cycleMode as cycleDetectionMode, + getDetectionDiagnostics as readDetectionDiagnostics, + getDetectionTuning, + getMode as getDetectionMode, + setMode as setDetectionModeByLabel, + suspendDetection, + resumeDetection, + 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, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, +} from '../data/groundFloor.js'; +import { sampleMeshFloorCells } from '../data/meshFloorSampler.js'; +import { + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, +} from '../renderGovernor.js'; +import { + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, +} from '../scopeMask.js'; +import { + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, +} from '../data/regionalBrief.js'; + +export class StyleManager extends ApplicationShell { + constructor(viewer, options = {}) { + super(viewer, { + ...options, + services: { + CITY_POIS, + GLOBE_VIEW, + flyToGlobeView, + flyToPresetLocation, + flyToPOI, + searchAndFlyTo, + interruptCameraMotion, + IntelHUD, + ShareLinkManager, + OrbitController, + CelestialRing, + getKeyholeFadeTuning, + isCelestialRingStyleSupported, + setKeyholeFadeTuning, + destroyTrackedReadout, + initTrackedReadout, + destroyWorldOverlay, + initWorldOverlay, + destroyDetection, + initDetection, + cycleDetectionMode, + readDetectionDiagnostics, + getDetectionTuning, + getDetectionMode, + setDetectionModeByLabel, + suspendDetection, + resumeDetection, + setDetectionStyle, + setDetectionTuning, + trafficLayer, + flightsLayer, + militaryFlightsLayer, + isTr3b, + toggleTr3b, + satellitesLayer, + cctvLayer, + radioLayer, + bikeshareLayer, + aisLiveVesselsLayer, + militaryAwarenessLayer, + militaryInstallationsLayer, + rocketLaunchesLayer, + cachedGroundFloor, + cachedMeshFloor, + GROUND_FLOOR_LIFT_M, + meshFloorPreferred, + warmGroundFloor, + sampleMeshFloorCells, + holdContinuousRender, + releaseContinuousRender, + governorRequestRender, + setScopeMaskEnabled, + isScopeMaskEnabled, + setScopeMaskFeather, + getScopeMaskFeather, + setScopeTerminusOverride, + getScopeTerminusOverride, + clampScopeTerminusPct, + fetchRegionalBrief, + regionalDistanceM, + weatherCodeLabel, + LocationSearch, + ...options.services, + }, + }); + } +} diff --git a/src/ui/templates/cockpit.html b/src/ui/templates/cockpit.html new file mode 100644 index 000000000..df8f3c4b9 --- /dev/null +++ b/src/ui/templates/cockpit.html @@ -0,0 +1,284 @@ + + + + diff --git a/src/ui/templates/command-dock.html b/src/ui/templates/command-dock.html new file mode 100644 index 000000000..cf1af7efb --- /dev/null +++ b/src/ui/templates/command-dock.html @@ -0,0 +1,97 @@ + +
+ + + + + +
diff --git a/src/ui/templates/context.html b/src/ui/templates/context.html new file mode 100644 index 000000000..4e7e331c2 --- /dev/null +++ b/src/ui/templates/context.html @@ -0,0 +1,147 @@ + + + +
diff --git a/src/ui/templates/display-controls.html b/src/ui/templates/display-controls.html new file mode 100644 index 000000000..5ed677b5b --- /dev/null +++ b/src/ui/templates/display-controls.html @@ -0,0 +1,123 @@ + + + diff --git a/src/ui/templates/hud-loading.html b/src/ui/templates/hud-loading.html new file mode 100644 index 000000000..f4ffb3a27 --- /dev/null +++ b/src/ui/templates/hud-loading.html @@ -0,0 +1,11 @@ + +
+ + +
+
+ +

GOD'S EYE VIEW

+

Initializing photorealistic world...

+
+
diff --git a/src/ui/templates/layer-panels.html b/src/ui/templates/layer-panels.html new file mode 100644 index 000000000..4f41b2452 --- /dev/null +++ b/src/ui/templates/layer-panels.html @@ -0,0 +1,108 @@ +
+ + + + + + + + + +
diff --git a/src/ui/templates/provider-settings.html b/src/ui/templates/provider-settings.html new file mode 100644 index 000000000..3b5d98a97 --- /dev/null +++ b/src/ui/templates/provider-settings.html @@ -0,0 +1,25 @@ + + + diff --git a/src/ui/templates/scene-chrome.html b/src/ui/templates/scene-chrome.html new file mode 100644 index 000000000..aeb3527b8 --- /dev/null +++ b/src/ui/templates/scene-chrome.html @@ -0,0 +1,54 @@ +
+ +
+
+
+
+ + +
+
+

GOD'S EYE VIEW

+

NO PLACE LEFT BEHIND

+
+ + +
+ ACTIVE STYLE + NORMAL +
+ + + + +
+ syncing road network + 0% +
+
+ loading frames + 0/0 +
+ + +
+ + +
+
+
diff --git a/src/ui/templates/welcome.html b/src/ui/templates/welcome.html new file mode 100644 index 000000000..fe682abd2 --- /dev/null +++ b/src/ui/templates/welcome.html @@ -0,0 +1,52 @@ + + diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 0f5b20046..a607e27de 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -1859,7 +1859,8 @@ function collectTrackedEntities(dataManager) { return tracked; } -export async function getBasemapLabelContext(viewer, service = defaultGeospatial) { +export async function getBasemapLabelContext(viewer, service = defaultGeospatial, { cachedOnly = false } = {}) { + const { reverseGeocodeCache, nearbyPlacesCache } = cachesFor(service); const samples = sampleViewportCartographics(viewer); const cameraHeightM = viewer.camera.positionCartographic.height; const target = getViewTargetCartographic(viewer); @@ -1876,12 +1877,12 @@ export async function getBasemapLabelContext(viewer, service = defaultGeospatial const cachedViewportPlaces = viewportPlacesFromCache(samples, cameraHeightM, service); const viewportPromise = cachedViewportPlaces ? Promise.resolve(cachedViewportPlaces) - : reverseGeocodeViewportSamples(samples, cameraHeightM, service); + : cachedOnly ? Promise.resolve(null) : reverseGeocodeViewportSamples(samples, cameraHeightM, service); const placePromise = shouldReverseGeocode(cameraHeightM) - ? reverseGeocode(latitude, longitude, service) + ? cachedOnly ? Promise.resolve(reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude, service)) || null) : reverseGeocode(latitude, longitude, service) : Promise.resolve(null); const nearbyPromise = shouldFetchNearbyPlaces(cameraHeightM) - ? fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) + ? cachedOnly ? Promise.resolve(nearbyPlacesCache.get(nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service)) || []) : fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) : Promise.resolve([]); const [viewportPlaces, place, nearbyPlaces] = await Promise.all([ resolveWithin(viewportPromise, BASEMAP_CONTEXT_WAIT_MS, cachedViewportPlaces), @@ -2625,9 +2626,9 @@ async function getBasemapContext(viewer, viewTarget = null, service = defaultGeo const knownLandmarks = nearbyKnownLandmarks(latitude, longitude, cameraHeightM); const fallbackPlace = coarseBasemapPlace(viewScale, latitude, longitude, inferredCountry); const cachedPlace = shouldReverseGeocode(cameraHeightM) - ? reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude)) || null + ? reverseGeocodeCache.get(reverseGeocodeKey(latitude, longitude, service)) || null : null; - const nearbyCacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); + const nearbyCacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service); const cachedNearbyPlaces = shouldFetchNearbyPlaces(cameraHeightM) && nearbyPlacesCache.has(nearbyCacheKey) ? nearbyPlacesCache.get(nearbyCacheKey) : null; @@ -2907,7 +2908,7 @@ function inferCountry(latitude, longitude) { async function reverseGeocode(latitude, longitude, service) { const { reverseGeocodeCache, reverseGeocodeInFlight } = cachesFor(service); if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null; - const key = reverseGeocodeKey(latitude, longitude); + const key = reverseGeocodeKey(latitude, longitude, service); if (reverseGeocodeCache.has(key)) return reverseGeocodeCache.get(key); if (reverseGeocodeInFlight.has(key)) return reverseGeocodeInFlight.get(key); @@ -2957,7 +2958,7 @@ function viewportPlacesFromCache(samples, cameraHeightM, service) { const { reverseGeocodeCache } = cachesFor(service); if (!shouldReverseGeocodeViewport(cameraHeightM) || cameraHeightM <= 10000 || !samples.length) return null; const places = [samples[0], samples[1], samples[2]].filter(Boolean).flatMap((sample) => { - const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude)); + const place = reverseGeocodeCache.get(reverseGeocodeKey(sample.latitude, sample.longitude, service)); if (!place) return []; return [{ latitude: sample.latitude, @@ -2989,7 +2990,7 @@ function summarizeViewportPlaces(places) { async function fetchNearbyPlaces(latitude, longitude, cameraHeightM, service) { const { nearbyPlacesCache, nearbyPlacesInFlight } = cachesFor(service); const radiusM = nearbyPlacesRadiusM(cameraHeightM); - const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM); + const cacheKey = nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service); if (nearbyPlacesCache.has(cacheKey)) return nearbyPlacesCache.get(cacheKey); if (nearbyPlacesInFlight.has(cacheKey)) return nearbyPlacesInFlight.get(cacheKey); @@ -3035,13 +3036,17 @@ function sanitizeLabel(value) { return out.replace(/\s+/g, ' ').trim().slice(0, 120); } -function reverseGeocodeKey(latitude, longitude) { - return `${latitude.toFixed(4)},${longitude.toFixed(4)}`; +function cachePrecision(service) { + return Number.isInteger(service?.cachePrecision) ? Math.max(3, Math.min(6, service.cachePrecision)) : 4; } -function nearbyPlacesCacheKey(latitude, longitude, cameraHeightM) { +function reverseGeocodeKey(latitude, longitude, service) { + return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))}`; +} + +function nearbyPlacesCacheKey(latitude, longitude, cameraHeightM, service) { const radiusM = nearbyPlacesRadiusM(cameraHeightM); - return `${latitude.toFixed(4)},${longitude.toFixed(4)},${radiusM}`; + return `${latitude.toFixed(cachePrecision(service))},${longitude.toFixed(cachePrecision(service))},${radiusM}`; } function nearbyPlacesRadiusM(cameraHeightM) {