From e59282dc9c0479a3a920c26cd225d08fc6f89242 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sun, 13 Sep 2026 18:37:49 -0700 Subject: [PATCH] refactor(maps): separate source factories and scene coordination --- CHANGELOG.md | 2 + docs/CODE-BOUNDARIES.md | 22 ++ docs/CURRENT-STATE.md | 18 + package.json | 8 +- scripts/format-scope.json | 18 +- scripts/package-boundaries.json | 414 +++++++++++++++----- src/mapStackChips.test.mjs | 461 +++++++++++++++------- src/mapStackController.js | 512 ++----------------------- src/mapStackUnavailableReason.test.mjs | 10 +- src/mapStartup.js | 55 +-- src/mapStartup.test.mjs | 62 ++- src/maps/availability.js | 15 + src/maps/catalog.js | 40 ++ src/maps/controller.js | 335 ++++++++++++++++ src/maps/controller.test.mjs | 300 +++++++++++++++ src/maps/credits.js | 25 ++ src/maps/defaultSources.js | 74 ++++ src/maps/google3d.js | 81 ++++ src/maps/imagery.js | 29 ++ src/maps/registry.js | 21 + src/maps/sourceFactories.test.mjs | 68 ++++ src/maps/terrain.js | 35 ++ 22 files changed, 1807 insertions(+), 798 deletions(-) create mode 100644 src/maps/availability.js create mode 100644 src/maps/catalog.js create mode 100644 src/maps/controller.js create mode 100644 src/maps/controller.test.mjs create mode 100644 src/maps/credits.js create mode 100644 src/maps/defaultSources.js create mode 100644 src/maps/google3d.js create mode 100644 src/maps/imagery.js create mode 100644 src/maps/registry.js create mode 100644 src/maps/sourceFactories.test.mjs create mode 100644 src/maps/terrain.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 32f8245b4..21c308b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,8 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +- Separate map source factories from switching and resource ownership; retain current source IDs, attribution and fallbacks. + - Separate radio directory loading, station selection, globe presentation and playback into composed components with an explicit metadata source. - Separate submarine cable sources and rendering components, and export bundled geography lookup modules. diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index abb07a114..30a9f11c6 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -363,3 +363,25 @@ return an unsubscribe function. Initial state is emitted by default; pass immutable plain data. Reentrant publications retain delivery order; removing a listener or destroying its owner prevents further queued delivery. These APIs perform no network requests and discover no additional modules. + +## Maps + +`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`, +`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the +standard sources and their setup/fallback policy. The standalone facade wires +these to the application's render governor. + +A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`. +Each source has a user-facing `descriptor`, availability/reason, and either an +`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`. +Imagery sources can share a terrain definition with a stable `id` and +`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must +identify the same source for the lifetime of that registry. Sources may supply +trusted credit markup and construction/tile-error fallback policy. Provider +configuration stays in factories; descriptor values are the presentation API. + +Factories should honor cancellation where their SDK supports it. The controller +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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index f8d8d7bd3..a009d68f7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3318,3 +3318,21 @@ Natural Earth regions and neighborhood polygon lookup are package exports. Their existing lazy loaders, retry behavior, bundled datasets and attribution are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by the project's MIT license; see `DATA_SOURCES.md`. + +## Map source factories and coordination + +Map selection is composed from separate imagery, terrain and 3D factories. +The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and +OSM, including their setup guidance and existing preset/share IDs. Esri still +falls back to OSM on construction failure or two active tile failures, and +credits follow the source actually displayed. Terrain remains lazy while the +photoreal globe is hidden. + +Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults. + +The controller accepts other source registries without adding provider branches. +Each instance caches source construction, ignores superseded scene changes and +releases imagery layers/listeners on replacement. Destroy invalidates pending +work and releases owned resources, including late factory results. Supplied 3D +tilesets remain owned by the caller; tilesets created through the controller's +factory are added to its viewer and removed on destruction. diff --git a/package.json b/package.json index 2452b819a..c861e10b3 100644 --- a/package.json +++ b/package.json @@ -160,6 +160,12 @@ "./layers/submarine-cables": "./src/layers/submarineCables/index.js", "./sources/bundled-cables": "./src/layers/submarineCables/bundledSource.js", "./geography/natural-earth": "./src/data/naturalEarthRegions.js", - "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js" + "./geography/neighborhoods": "./src/data/neighborhoodPolygons.js", + "./maps/controller": "./src/maps/controller.js", + "./maps/catalog": "./src/maps/catalog.js", + "./maps/imagery": "./src/maps/imagery.js", + "./maps/terrain": "./src/maps/terrain.js", + "./maps/3d": "./src/maps/google3d.js", + "./maps/defaults": "./src/maps/defaultSources.js" } } diff --git a/scripts/format-scope.json b/scripts/format-scope.json index ffbeac327..a2e60502c 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -495,5 +495,21 @@ "src/data/telegeographySubmarineCables.test.mjs", "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", - "src/layers/submarineCables/bundledSource.test.mjs" + "src/layers/submarineCables/bundledSource.test.mjs", + "src/maps/availability.js", + "src/maps/catalog.js", + "src/maps/controller.js", + "src/maps/controller.test.mjs", + "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/mapStackController.js", + "src/mapStartup.js", + "src/mapStackChips.test.mjs", + "src/mapStackUnavailableReason.test.mjs", + "src/mapStartup.test.mjs", + "src/maps/sourceFactories.test.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index ebee63435..ea9b0cdda 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -10,27 +10,47 @@ "src/data/localGeojsonCore.js", "src/data/localGeojsonLod.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "application": { - "exports": ["./application"], - "modules": ["src/app/application.js"], + "exports": [ + "./application" + ], + "modules": [ + "src/app/application.js" + ], "external": [] }, "viewer": { - "exports": ["./application/viewer"], - "modules": ["src/app/viewer.js"], - "external": ["cesium"] + "exports": [ + "./application/viewer" + ], + "modules": [ + "src/app/viewer.js" + ], + "external": [ + "cesium" + ] }, "vite-build": { "runtime": "node", - "exports": ["./build/vite"], - "modules": ["build/vite.js"], - "external": ["vite-plugin-cesium"] + "exports": [ + "./build/vite" + ], + "modules": [ + "build/vite.js" + ], + "external": [ + "vite-plugin-cesium" + ] }, "live-providers": { "runtime": "node", - "exports": ["./server/providers/live"], + "exports": [ + "./server/providers/live" + ], "modules": [ "server/providers/live.js", "server/providers/common/http.js", @@ -45,16 +65,24 @@ "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js" ], - "external": ["ws"] + "external": [ + "ws" + ] }, "adsb-lol-source": { - "exports": ["./sources/adsb-lol"], - "modules": ["src/data/adsbLolFallback.js"], + "exports": [ + "./sources/adsb-lol" + ], + "modules": [ + "src/data/adsbLolFallback.js" + ], "external": [] }, "place-providers": { "runtime": "node", - "exports": ["./server/providers/places"], + "exports": [ + "./server/providers/places" + ], "modules": [ "server/providers/places.js", "server/providers/places/google.js", @@ -69,13 +97,19 @@ "external": [] }, "place-payloads": { - "exports": ["./sources/places"], - "modules": ["src/data/placeProviderPayloads.js"], + "exports": [ + "./sources/places" + ], + "modules": [ + "src/data/placeProviderPayloads.js" + ], "external": [] }, "space-providers": { "runtime": "node", - "exports": ["./server/providers/space"], + "exports": [ + "./server/providers/space" + ], "modules": [ "server/providers/space.js", "server/providers/space/celestrak.js", @@ -86,13 +120,19 @@ "external": [] }, "space-requests": { - "exports": ["./sources/space"], - "modules": ["src/data/spaceProviderRequests.js"], + "exports": [ + "./sources/space" + ], + "modules": [ + "src/data/spaceProviderRequests.js" + ], "external": [] }, "terrain-provider": { "runtime": "node", - "exports": ["./server/providers/terrain"], + "exports": [ + "./server/providers/terrain" + ], "modules": [ "server/providers/terrain.js", "src/data/terrainHeightsProxy.js" @@ -100,30 +140,50 @@ "external": [] }, "terrain-source": { - "exports": ["./sources/terrain"], - "modules": ["src/data/terrainHeightsProxy.js"], + "exports": [ + "./sources/terrain" + ], + "modules": [ + "src/data/terrainHeightsProxy.js" + ], "external": [] }, "traffic-provider": { "runtime": "node", - "exports": ["./server/providers/traffic"], - "modules": ["server/providers/traffic.js", "src/data/tomtomTiles.js"], + "exports": [ + "./server/providers/traffic" + ], + "modules": [ + "server/providers/traffic.js", + "src/data/tomtomTiles.js" + ], "external": [] }, "traffic-source": { - "exports": ["./sources/traffic"], - "modules": ["src/data/tomtomTiles.js"], + "exports": [ + "./sources/traffic" + ], + "modules": [ + "src/data/tomtomTiles.js" + ], "external": [] }, "firms-provider": { "runtime": "node", - "exports": ["./server/providers/firms"], - "modules": ["server/providers/firms.js", "src/data/firmsCsv.js"], + "exports": [ + "./server/providers/firms" + ], + "modules": [ + "server/providers/firms.js", + "src/data/firmsCsv.js" + ], "external": [] }, "gbfs-provider": { "runtime": "node", - "exports": ["./server/providers/gbfs"], + "exports": [ + "./server/providers/gbfs" + ], "modules": [ "server/providers/gbfs.js", "server/providers/common/http.js", @@ -132,13 +192,19 @@ "external": [] }, "gbfs-source": { - "exports": ["./sources/gbfs"], - "modules": ["src/data/gbfsSource.js"], + "exports": [ + "./sources/gbfs" + ], + "modules": [ + "src/data/gbfsSource.js" + ], "external": [] }, "cctv-provider": { "runtime": "node", - "exports": ["./server/providers/cctv"], + "exports": [ + "./server/providers/cctv" + ], "modules": [ "server/providers/cctv.js", "server/providers/cctv/catalog.js", @@ -155,7 +221,9 @@ }, "radio-provider": { "runtime": "node", - "exports": ["./server/providers/radio"], + "exports": [ + "./server/providers/radio" + ], "modules": [ "server/providers/radio.js", "server/providers/radio/catalog.js", @@ -169,7 +237,9 @@ }, "overpass-provider": { "runtime": "node", - "exports": ["./server/providers/overpass"], + "exports": [ + "./server/providers/overpass" + ], "modules": [ "server/providers/common/geo.js", "server/providers/common/http.js", @@ -188,7 +258,9 @@ }, "military-installations-provider": { "runtime": "node", - "exports": ["./server/providers/military-installations"], + "exports": [ + "./server/providers/military-installations" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -205,7 +277,9 @@ }, "regional-provider": { "runtime": "node", - "exports": ["./server/providers/regional"], + "exports": [ + "./server/providers/regional" + ], "modules": [ "server/providers/common/http.js", "server/providers/common/query.js", @@ -224,7 +298,9 @@ }, "openai-provider": { "runtime": "node", - "exports": ["./server/providers/openai"], + "exports": [ + "./server/providers/openai" + ], "modules": [ "server/providers/common/rate-limit.js", "server/providers/common/request.js", @@ -244,7 +320,9 @@ }, "standalone-key-setup": { "runtime": "node", - "exports": ["./server/standalone/key-setup"], + "exports": [ + "./server/standalone/key-setup" + ], "modules": [ "scripts/pinokio-environment.mjs", "server/providers/common/source-root.js", @@ -255,7 +333,9 @@ "external": [] }, "place-search": { - "exports": ["./search"], + "exports": [ + "./search" + ], "modules": [ "src/search/index.js", "src/search/placeSearch.js", @@ -265,17 +345,27 @@ "external": [] }, "panel-disclosure": { - "exports": ["./ui/panels"], - "modules": ["src/ui/panelDisclosure.js"], + "exports": [ + "./ui/panels" + ], + "modules": [ + "src/ui/panelDisclosure.js" + ], "external": [] }, "surface-keyboard": { - "exports": ["./ui/surfaces"], - "modules": ["src/ui/surfaceKeyboard.js"], + "exports": [ + "./ui/surfaces" + ], + "modules": [ + "src/ui/surfaceKeyboard.js" + ], "external": [] }, "panel-rail-layout": { - "exports": ["./ui/layout"], + "exports": [ + "./ui/layout" + ], "modules": [ "src/ui/panelRails.js", "src/ui/leftPanelRail.js", @@ -287,7 +377,9 @@ "external": [] }, "visual-input": { - "exports": ["./ui/input"], + "exports": [ + "./ui/input" + ], "modules": [ "src/ui/visualInput.js", "src/ui/applicationShortcuts.js", @@ -296,12 +388,19 @@ "external": [] }, "display-controls": { - "exports": ["./ui/display"], - "modules": ["src/ui/displayControls.js"], + "exports": [ + "./ui/display" + ], + "modules": [ + "src/ui/displayControls.js" + ], "external": [] }, "visual-effects": { - "exports": ["./ui/effects", "./ui/effects/bloom"], + "exports": [ + "./ui/effects", + "./ui/effects/bloom" + ], "modules": [ "src/ui/effects.js", "src/ui/visualEffects.js", @@ -314,10 +413,14 @@ "src/styles/noir.js", "src/styles/snow.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "map-source-controls": { - "exports": ["./ui/maps"], + "exports": [ + "./ui/maps" + ], "modules": [ "src/ui/mapSource.js", "src/ui/mapSourceControls.js", @@ -327,7 +430,10 @@ "external": [] }, "layer-panel": { - "exports": ["./ui/layers", "./ui/layers/feedback"], + "exports": [ + "./ui/layers", + "./ui/layers/feedback" + ], "modules": [ "src/ui/layers.js", "src/ui/layerPanel.js", @@ -338,7 +444,9 @@ "external": [] }, "location-controls": { - "exports": ["./ui/location"], + "exports": [ + "./ui/location" + ], "modules": [ "src/ui/location.js", "src/ui/locationControls.js", @@ -349,7 +457,9 @@ "external": [] }, "radio-controls": { - "exports": ["./ui/radio"], + "exports": [ + "./ui/radio" + ], "modules": [ "src/ui/radio.js", "src/ui/radioControls.js", @@ -360,7 +470,9 @@ "external": [] }, "cctv-controls": { - "exports": ["./ui/cctv"], + "exports": [ + "./ui/cctv" + ], "modules": [ "src/ui/cctv.js", "src/ui/cctvBindings.js", @@ -372,7 +484,10 @@ "external": [] }, "context-controls": { - "exports": ["./ui/context", "./ui/context/policy"], + "exports": [ + "./ui/context", + "./ui/context/policy" + ], "modules": [ "src/ui/context.js", "src/ui/contextActions.js", @@ -414,10 +529,14 @@ "src/cockpitVisionPolicy.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "scene-controls": { - "exports": ["./ui/scenes"], + "exports": [ + "./ui/scenes" + ], "modules": [ "src/ui/scenes.js", "src/ui/sceneControls.js", @@ -426,7 +545,9 @@ "external": [] }, "ui-shell": { - "exports": ["./ui/shell"], + "exports": [ + "./ui/shell" + ], "modules": [ "src/bloom.js", "src/cctvFocusPolicy.js", @@ -523,10 +644,14 @@ "src/app/stateChannel.js", "src/ui/frameRateMonitor.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "ui-styles": { - "exports": ["./ui/styles"], + "exports": [ + "./ui/styles" + ], "modules": [ "src/ui/styles.js", "style.css", @@ -553,7 +678,9 @@ "external": [] }, "scenes": { - "exports": ["./scenes"], + "exports": [ + "./scenes" + ], "modules": [ "src/scenes/director.js", "src/scenes/recipes.js", @@ -565,10 +692,14 @@ "src/ui/scenePresentation.js", "src/contextModePolicy.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "live-observations": { - "exports": ["./sources/live"], + "exports": [ + "./sources/live" + ], "modules": [ "src/sources/live/aircraft.js", "src/sources/live/contract.js", @@ -579,7 +710,9 @@ "external": [] }, "civil-flights": { - "exports": ["./layers/flights"], + "exports": [ + "./layers/flights" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -610,10 +743,14 @@ "src/layers/flights/testing.js", "src/layers/flights/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "military-flights": { - "exports": ["./layers/military"], + "exports": [ + "./layers/military" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -641,10 +778,14 @@ "src/layers/military/testing.js", "src/layers/military/tracking.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "aircraft-mechanics": { - "exports": ["./aircraft"], + "exports": [ + "./aircraft" + ], "modules": [ "src/data/aircraftClass.js", "src/data/aircraftIcons.js", @@ -664,10 +805,14 @@ "src/layers/aircraft/classification.js", "src/layers/aircraft/index.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "vessel-layer": { - "exports": ["./layers/vessels"], + "exports": [ + "./layers/vessels" + ], "modules": [ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -685,19 +830,27 @@ "src/data/iconOrientation.js", "src/data/vesselLabels.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "earthquakes": { - "exports": ["./layers/earthquakes"], + "exports": [ + "./layers/earthquakes" + ], "modules": [ "src/layers/earthquakes/index.js", "src/layers/earthquakes/model.js", "src/layers/earthquakes/source.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "fire-layer": { - "exports": ["./layers/firms"], + "exports": [ + "./layers/firms" + ], "modules": [ "src/data/firmsAdapt.js", "src/data/firmsLabels.js", @@ -716,10 +869,14 @@ "src/layers/firms/state.js", "src/layers/firms/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "satellites-layer": { - "exports": ["./layers/satellites"], + "exports": [ + "./layers/satellites" + ], "modules": [ "src/data/issPass.js", "src/data/satelliteClass.js", @@ -738,10 +895,15 @@ "src/layers/satellites/testing.js", "src/layers/satellites/tracking.js" ], - "external": ["cesium", "satellite.js"] + "external": [ + "cesium", + "satellite.js" + ] }, "launches-layer": { - "exports": ["./layers/launches"], + "exports": [ + "./layers/launches" + ], "modules": [ "src/layers/launches/camera.js", "src/layers/launches/controls.js", @@ -763,10 +925,14 @@ "src/layers/launches/state.js", "src/layers/launches/testing.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "installations-layer": { - "exports": ["./layers/installations"], + "exports": [ + "./layers/installations" + ], "modules": [ "src/data/installationFeedback.js", "src/data/militaryInstallationData.js", @@ -782,10 +948,14 @@ "src/layers/installations/state.js", "src/layers/installations/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "awareness-layer": { - "exports": ["./layers/awareness"], + "exports": [ + "./layers/awareness" + ], "modules": [ "src/cockpitMath.js", "src/data/iconOrientation.js", @@ -808,10 +978,14 @@ "src/layers/awareness/testing.js", "src/ui/panelRailGeometry.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "traffic-layer": { - "exports": ["./layers/traffic"], + "exports": [ + "./layers/traffic" + ], "modules": [ "src/data/flowMatch.js", "src/data/tomtomTiles.js", @@ -836,10 +1010,16 @@ "src/layers/traffic/timing.js", "src/layers/traffic/viewport.js" ], - "external": ["cesium", "pbf", "@mapbox/vector-tile"] + "external": [ + "cesium", + "pbf", + "@mapbox/vector-tile" + ] }, "bikeshare-layer": { - "exports": ["./layers/bikeshare"], + "exports": [ + "./layers/bikeshare" + ], "modules": [ "src/layers/bikeshare/controls.js", "src/layers/bikeshare/index.js", @@ -856,10 +1036,14 @@ "src/layers/bikeshare/testing.js", "src/layers/bikeshare/viewport.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "cctv-layer": { - "exports": ["./layers/cctv"], + "exports": [ + "./layers/cctv" + ], "modules": [ "src/data/cctvCards.js", "src/data/cctvGizmo.js", @@ -892,10 +1076,14 @@ "src/layers/cctv/testing.js", "src/overlays/worldOverlayTokens.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "radio-layer": { - "exports": ["./layers/radio"], + "exports": [ + "./layers/radio" + ], "modules": [ "src/data/iconOrientation.js", "src/data/layerState.js", @@ -924,10 +1112,14 @@ "src/layers/radio/volume.js", "src/ui/radioTunerModel.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "submarine-cables": { - "exports": ["./layers/submarine-cables"], + "exports": [ + "./layers/submarine-cables" + ], "modules": [ "src/layers/submarineCables/geometry.js", "src/layers/submarineCables/index.js", @@ -940,10 +1132,14 @@ "src/layers/submarineCables/state.js", "src/layers/submarineCables/surface.js" ], - "external": ["cesium"] + "external": [ + "cesium" + ] }, "bundled-cables": { - "exports": ["./sources/bundled-cables"], + "exports": [ + "./sources/bundled-cables" + ], "modules": [ "src/layers/submarineCables/bundledSource.js", "src/data/local_data/telegeography_submarine_cables/cable-geo.json", @@ -952,7 +1148,10 @@ "external": [] }, "geography": { - "exports": ["./geography/natural-earth", "./geography/neighborhoods"], + "exports": [ + "./geography/natural-earth", + "./geography/neighborhoods" + ], "modules": [ "src/data/naturalEarthRegions.js", "src/data/neighborhoodPolygons.js", @@ -962,5 +1161,30 @@ "src/data/local_data/natural_earth/marine.json" ], "external": [] + }, + "maps": { + "exports": [ + "./maps/controller", + "./maps/catalog", + "./maps/imagery", + "./maps/terrain", + "./maps/3d", + "./maps/defaults" + ], + "modules": [ + "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/keySetupCore.mjs" + ], + "external": [ + "cesium" + ] } } diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs index 7106cd2e7..befb7b673 100644 --- a/src/mapStackChips.test.mjs +++ b/src/mapStackChips.test.mjs @@ -34,7 +34,9 @@ function makeElement(tagName = 'div') { children: [], classList: { toggle(name, force) { - const classes = new Set(String(element.className).split(/\s+/).filter(Boolean)); + const classes = new Set( + String(element.className).split(/\s+/).filter(Boolean), + ); const next = force === undefined ? !classes.has(name) : !!force; if (next) classes.add(name); else classes.delete(name); @@ -44,15 +46,30 @@ function makeElement(tagName = 'div') { return String(element.className).split(/\s+/).includes(name); }, }, - appendChild(child) { element.children.push(child); return child; }, - setAttribute(name, value) { element.attributes[name] = String(value); }, - getAttribute(name) { return element.attributes[name] ?? null; }, - addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); }, - click() { for (const handler of element.listeners.click || []) handler(); }, + appendChild(child) { + element.children.push(child); + return child; + }, + setAttribute(name, value) { + element.attributes[name] = String(value); + }, + getAttribute(name) { + return element.attributes[name] ?? null; + }, + addEventListener(type, handler) { + (element.listeners[type] ||= []).push(handler); + }, + click() { + for (const handler of element.listeners.click || []) handler(); + }, }; Object.defineProperty(element, 'innerHTML', { - get() { return ''; }, - set() { element.children.length = 0; }, + get() { + return ''; + }, + set() { + element.children.length = 0; + }, }); return element; } @@ -60,37 +77,93 @@ function makeElement(tagName = 'div') { const doc = { createElement: (tagName) => makeElement(tagName) }; /** Text a chip renders, label + optional requirement badge. */ -const chipText = (chip) => chip.children.map((child) => child.textContent).join(' '); +const chipText = (chip) => + chip.children.map((child) => child.textContent).join(' '); // Shaped exactly like MapStackController.getStacks() output. const CONTROLLER_STACKS = [ - { id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null }, - { id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null }, - { id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null }, - { id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null }, - { id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null }, + { + id: 'photoreal', + label: 'Google 3D', + requiresIon: false, + available: true, + unavailableReason: null, + }, + { + id: 'bing-aerial', + label: 'Bing Aerial', + requiresIon: true, + available: true, + unavailableReason: null, + }, + { + id: 'bing-labels', + label: 'Bing Labels', + requiresIon: true, + available: true, + unavailableReason: null, + }, + { + id: 'esri-imagery', + label: 'Esri Satellite', + requiresIon: false, + available: true, + unavailableReason: null, + }, + { + id: 'osm', + label: 'OSM', + requiresIon: false, + available: true, + unavailableReason: null, + }, ]; test('the row renders exactly the five owner-approved sources', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); - assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [ - 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm', - ]); + assert.deepEqual( + container.children.map((chip) => chip.dataset.stackId), + ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'], + ); assert.deepEqual(container.children.map(chipText), [ - 'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM', + 'Google 3D', + 'Bing Aerial', + 'Bing Labels', + 'Esri Satellite', + 'OSM', ]); - assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']); - assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button')); - assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS))); + assert.deepEqual(PRESENTED_MAP_STACK_IDS, [ + 'photoreal', + 'bing-aerial', + 'bing-labels', + 'esri-imagery', + 'osm', + ]); + assert.ok( + container.children.every( + (chip) => chip.tagName === 'button' && chip.type === 'button', + ), + ); + assert.ok( + container.children.every((chip) => + chip.classList.contains(MAP_STACK_CHIP_CLASS), + ), + ); }); test('internal and future stacks stay outside the approved presentation set', () => { const container = makeElement(); // A future Hybrid stack may land in the controller, but it must not appear // until the owner-approved presentation allowlist explicitly includes it. - const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }]; + const withHybrid = [ + ...CONTROLLER_STACKS, + { id: 'hybrid', label: 'Hybrid', available: true }, + ]; renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc }); assert.equal(container.children.length, 5); @@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', () test('re-rendering replaces the previous chips instead of stacking a second row', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc }); assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length); @@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo test('the active chip is the pressed chip, and exactly one is pressed', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'bing-labels', + doc, + }); - const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true'); - assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']); + const pressed = container.children.filter( + (chip) => chip.getAttribute('aria-pressed') === 'true', + ); + assert.deepEqual( + pressed.map((chip) => chip.dataset.stackId), + ['bing-labels'], + ); assert.ok(pressed[0].classList.contains('active')); - assert.ok(container.children - .filter((chip) => chip.dataset.stackId !== 'bing-labels') - .every((chip) => !chip.classList.contains('active'))); + assert.ok( + container.children + .filter((chip) => chip.dataset.stackId !== 'bing-labels') + .every((chip) => !chip.classList.contains('active')), + ); }); test('the lit chip tracks controller state, not the click', () => { const container = makeElement(); - renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc }); + renderMapStackChips(container, CONTROLLER_STACKS, { + activeId: 'photoreal', + doc, + }); // A rejected/superseded switch reports the stack that is genuinely active. syncMapStackChips(container, 'photoreal'); @@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => { test('keyless ion stacks stay focusable, aria-disabled, and say why', () => { const container = makeElement(); - const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? { - ...stack, - available: false, - unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', - } : stack)); + const keyless = CONTROLLER_STACKS.map((stack) => + stack.requiresIon + ? { + ...stack, + available: false, + unavailableReason: + 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', + } + : stack, + ); const selected = []; renderMapStackChips(container, keyless, { activeId: 'photoreal', @@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => { }); const bingAerial = container.children[1]; - assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable'); + assert.equal( + bingAerial.disabled, + false, + 'unavailable sources remain keyboard-reachable', + ); assert.equal(bingAerial.getAttribute('aria-disabled'), 'true'); assert.equal( bingAerial.getAttribute('aria-label'), 'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings', ); assert.ok(bingAerial.classList.contains('unavailable')); - assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings'); + assert.equal( + bingAerial.title, + 'Needs CESIUM_ION_TOKEN — add it in Provider Settings', + ); assert.equal(chipText(bingAerial), 'Bing Aerial ION'); bingAerial.click(); - assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path'); + assert.deepEqual( + selected, + [], + 'an unavailable stack must not reach the switch path', + ); - assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable'); + assert.equal( + container.children[3].getAttribute('aria-disabled'), + 'false', + 'OSM stays selectable', + ); }); test('a non-ion stack that fails never claims an ion token is required', () => { // The startup fallback-to-OSM case: Google 3D tiles failed to load, so // photoreal is unavailable for a reason that has nothing to do with ion. const container = makeElement(); - const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? { - ...stack, - available: false, - unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', - } : stack)); + const tilesFailed = CONTROLLER_STACKS.map((stack) => + stack.id === 'photoreal' + ? { + ...stack, + available: false, + unavailableReason: + 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + } + : stack, + ); renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc }); const google = container.children[0]; assert.equal(google.getAttribute('aria-disabled'), 'true'); - assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings'); - assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion'); - assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings'); - assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged'); + assert.equal( + google.getAttribute('aria-label'), + 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + ); + assert.equal( + chipText(google), + 'Google 3D', + 'no ION badge on a stack that does not need ion', + ); + assert.equal( + google.title, + 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings', + ); + assert.equal( + chipText(container.children[1]), + 'Bing Aerial', + 'available ion stacks stay unbadged', + ); }); -test('models carry the stack\'s own reason and never invent an active chip', () => { - assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{ - id: 'osm', - label: 'OSM', - available: true, - active: false, - requiresIon: false, - requirement: '', - unavailableHint: '', - title: 'OSM', - }]); +test("models carry the stack's own reason and never invent an active chip", () => { + assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [ + { + id: 'osm', + label: 'OSM', + available: true, + active: false, + requiresIon: false, + requirement: '', + unavailableHint: '', + title: 'OSM', + }, + ]); // A stack list without a controller-supplied reason still explains itself. assert.deepEqual( [ - mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null), - mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null), - ].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })), + mapStackChipModel( + { + id: 'bing-aerial', + label: 'Bing Aerial', + requiresIon: true, + available: false, + }, + null, + ), + mapStackChipModel( + { id: 'hybrid', label: 'Hybrid', available: false }, + null, + ), + ].map(({ requirement, unavailableHint, title }) => ({ + requirement, + unavailableHint, + title, + })), [ { requirement: 'ION', @@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', () assert.deepEqual(mapStackChipModels(undefined, 'osm'), []); }); -test('the controller is the single source of the unavailability reason', () => { - const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8'); - - assert.doesNotMatch( - controller, - /bing-road/, - 'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback', - ); - - assert.match( - controller, - /getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/, - 'getStacks() must expose why a stack is unavailable, not just that it is', - ); - assert.match( - controller, - /if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/, - 'the toast and the chip tooltip must read the same reason', - ); - assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/); -}); - test('a missing row or document is inert rather than throwing during boot', () => { assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []); - assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []); + assert.deepEqual( + renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), + [], + ); assert.doesNotThrow(() => syncMapStackChips(null, 'osm')); }); @@ -268,8 +394,14 @@ test('the active cyan survives hover', () => { const active = css.indexOf('.map-stack-chip.active {'); const unavailable = css.indexOf('.map-stack-chip.unavailable'); - assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity'); - assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up'); + assert.ok( + hover > 0 && active > hover, + 'active must follow hover so it wins at equal specificity', + ); + assert.ok( + unavailable > active, + 'unavailable must follow both so a keyless chip never lights up', + ); assert.doesNotMatch( css.slice(hover, active), /:not\(/, @@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // chip-state rule sets. const css = readStylesheet(new URL('../style.css', import.meta.url)); const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, ''); - const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)] - .map(([, selector, body], order) => ({ - selector: stripComments(selector).trim(), - body: stripComments(body), - order, - })); + const chipRules = [ + ...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g), + ].map(([, selector, body], order) => ({ + selector: stripComments(selector).trim(), + body: stripComments(body), + order, + })); assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found'); - const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible') - && /outline:\s*(?!none)\S/.test(body) - && /outline-offset:/.test(body)); - assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring'); + const ringIndex = chipRules.findIndex( + ({ selector, body }) => + selector.includes(':focus-visible') && + /outline:\s*(?!none)\S/.test(body) && + /outline-offset:/.test(body), + ); + assert.ok( + ringIndex >= 0, + 'a :focus-visible rule must draw a real outline ring', + ); // Nothing after it may touch outline again, so the ring cannot be erased by // .active, :disabled, or anything added later. @@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { `"${rule.selector}" must not touch outline — it would erase the focus ring`, ); } - for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) { + for (const selector of [ + '.map-stack-chip.active', + '.map-stack-chip.unavailable', + ]) { assert.ok( chipRules.some((rule) => rule.selector.includes(selector)), `expected a ${selector} rule to exist for this check to mean anything`, @@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => { // `transition: all` animates outline-width off the `outline: none` base, and // Chrome parks that transition at 0px for chips on a wrapped line — the ring // never appeared on rows 2 and 3. The base rule must list its properties. - const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip')); + const base = chipRules.find((rule) => + rule.selector.endsWith('.map-stack-chip'), + ); assert.ok(base, 'expected the base .map-stack-chip rule'); assert.doesNotMatch( base.body, /transition:\s*all\b/, 'transition: all animates outline-width and kills the focus ring on wrapped rows', ); - assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated'); - assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates'); + assert.doesNotMatch( + base.body, + /transition:[^;]*outline/, + 'the focus ring must not be animated', + ); + assert.match( + base.body, + /transition:\s*\n?\s*color/, + 'the hover/active treatment still animates', + ); }); 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 ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8'); + const ui = readFileSync( + new URL('./ui/applicationShell.js', import.meta.url), + 'utf8', + ); - assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row'); + assert.doesNotMatch( + html, + /map-stack-select/, + 'the SOURCE dropdown is replaced by the chip row', + ); assert.match( html, /
<\/div>/, ); - assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired'); - assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/); + assert.doesNotMatch( + html, + /id="stack-panel"/, + 'the duplicate left MAP STACK panel is retired', + ); + assert.match( + html, + /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/, + ); assert.match( html, /