mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
Merge remote-tracking branch 'origin/main' into refactor/alpr-camera-source
# Conflicts: # package.json # scripts/format-scope.json # scripts/package-boundaries.json
This commit is contained in:
commit
211eb70eed
66 files changed
+8542
-4938
No files matched your search
@@ -66,6 +66,12 @@ 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Traffic now retries a failed destination after city navigation without a layer
|
||||
|
||||
@@ -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.
|
||||
@@ -3302,3 +3302,47 @@ Cesium post-render events over one-second windows and does not request extra
|
||||
frames. Typing fields, modified keys and key repeats do not toggle it. The
|
||||
readout starts hidden each session and releases its timer and frame listener
|
||||
when hidden or when the application is disposed.
|
||||
|
||||
|
||||
## Radio components
|
||||
|
||||
The radio entry composes one layer from directory ingestion, station queries,
|
||||
selection, globe rendering and playback modules. Its source supplies directory
|
||||
metadata and click reporting; scene, ground and overlay services are provided
|
||||
explicitly. Each constructed layer owns its catalog, audio and lifecycle state.
|
||||
Existing catalog validation, category filters, tuning and voice playback behavior
|
||||
remain unchanged. Audio connects directly to the broadcaster after an explicit
|
||||
play action; the source does not relay or record streams.
|
||||
|
||||
## Bundled geography and submarine cable components
|
||||
|
||||
Submarine cables use separate source, geometry, rendering, interaction and
|
||||
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
|
||||
collections from a source with `fetch(signal)` and a display label. The default
|
||||
source loads the same bundled TeleGeography files. Disabling still removes all
|
||||
three Cesium data sources; enabling rebuilds from the accepted parsed cache,
|
||||
and destroying clears it. Load ownership prevents cancelled work from adding
|
||||
entities after teardown.
|
||||
|
||||
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.
|
||||
+12
-1
@@ -156,6 +156,17 @@
|
||||
"./layers/traffic": "./src/layers/traffic/index.js",
|
||||
"./layers/bikeshare": "./src/layers/bikeshare/index.js",
|
||||
"./layers/cctv": "./src/layers/cctv/index.js",
|
||||
"./layers/alpr": "./src/layers/alpr/index.js"
|
||||
"./layers/alpr": "./src/layers/alpr/index.js",
|
||||
"./layers/radio": "./src/layers/radio/index.js",
|
||||
"./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",
|
||||
"./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"
|
||||
}
|
||||
}
|
||||
@@ -463,5 +463,61 @@
|
||||
"src/layers/alpr/policy.js",
|
||||
"src/layers/alpr/presentation.js",
|
||||
"src/layers/alpr/source.js",
|
||||
"src/layers/alpr/source.test.mjs"
|
||||
"src/layers/alpr/source.test.mjs",
|
||||
"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/data/radio.js",
|
||||
"src/layers/radio/source.test.mjs",
|
||||
"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/data/telegeographySubmarineCables.js",
|
||||
"src/data/telegeographySubmarineCables.test.mjs",
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"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"
|
||||
]
|
||||
@@ -904,5 +904,97 @@
|
||||
"src/layers/alpr/source.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"radio-layer": {
|
||||
"exports": ["./layers/radio"],
|
||||
"modules": [
|
||||
"src/data/iconOrientation.js",
|
||||
"src/data/layerState.js",
|
||||
"src/data/radioCountry.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/ui/radioTunerModel.js"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"submarine-cables": {
|
||||
"exports": ["./layers/submarine-cables"],
|
||||
"modules": [
|
||||
"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"
|
||||
],
|
||||
"external": ["cesium"]
|
||||
},
|
||||
"bundled-cables": {
|
||||
"exports": ["./sources/bundled-cables"],
|
||||
"modules": [
|
||||
"src/layers/submarineCables/bundledSource.js",
|
||||
"src/data/local_data/telegeography_submarine_cables/cable-geo.json",
|
||||
"src/data/local_data/telegeography_submarine_cables/landing-point-geo.json"
|
||||
],
|
||||
"external": []
|
||||
},
|
||||
"geography": {
|
||||
"exports": ["./geography/natural-earth", "./geography/neighborhoods"],
|
||||
"modules": [
|
||||
"src/data/naturalEarthRegions.js",
|
||||
"src/data/neighborhoodPolygons.js",
|
||||
"src/data/retryableLoad.js",
|
||||
"src/data/local_data/neighborhoods/san-francisco.json",
|
||||
"src/data/local_data/natural_earth/regions.json",
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
@@ -714,6 +714,14 @@ try {
|
||||
if (controller.googleTileset) controller.googleTileset.show = false;
|
||||
controller.googleTileset = null;
|
||||
controller.cesiumToken = '';
|
||||
// Availability is now composed in the source registry. Override only
|
||||
// this fixture's choices, as the previous token-field seam did.
|
||||
for (const source of controller._sources.values()) {
|
||||
if (source.descriptor.requiresIon || source.descriptor.kind === 'photoreal') {
|
||||
source.available = false;
|
||||
}
|
||||
}
|
||||
controller._registry.state.hasCesiumIonToken = false;
|
||||
await styleManager._setMapStack('osm', { syncShare: false });
|
||||
styleManager._initMapStackControl();
|
||||
});
|
||||
|
||||
+28
-6
@@ -2025,16 +2025,23 @@ async function main() {
|
||||
// owns the real tracked-aircraft camera session. Hold the frame update so
|
||||
// the intentionally synthetic Cockpit shell is not auto-exited mid-check.
|
||||
const realCockpitUpdate = manager.cockpitView.update;
|
||||
const intelHud = document.getElementById('intel-hud');
|
||||
const priorHudTransition = intelHud.style.getPropertyValue('transition');
|
||||
const priorHudTransitionPriority = intelHud.style.getPropertyPriority('transition');
|
||||
const waitForLayout = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 320));
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const waitForHudSettle = async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 560));
|
||||
// The synthetic shell below stops the aircraft/context update loop.
|
||||
// Restore its layout pass after the HUD fade before measuring geometry;
|
||||
// qa-cockpit-utility exercises scheduling with the real controller.
|
||||
manager.cockpitView.scheduleContextLayout();
|
||||
// Let panel changes settle. qa-cockpit-utility covers scheduling with
|
||||
// the real tracked-aircraft controller; this synthetic shell pauses it.
|
||||
intelHud.getBoundingClientRect();
|
||||
// This fixture pauses the controller update. Run its actual layout
|
||||
// methods against the settled HUD, then measure both unchanged lanes.
|
||||
manager.cockpitView.syncContextLayout();
|
||||
manager.cockpitView.syncSignalLayout();
|
||||
manager._syncLeftPanelAdaptiveLayout();
|
||||
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||
};
|
||||
const prior = {
|
||||
@@ -2073,6 +2080,9 @@ async function main() {
|
||||
let result = {};
|
||||
try {
|
||||
manager.cockpitView.update = () => {};
|
||||
// This block measures settled panel geometry, not fade timing. The
|
||||
// synthetic paused controller cannot advance layout during a HUD fade.
|
||||
intelHud.style.setProperty('transition', 'none', 'important');
|
||||
document.body.classList.add('cockpit-mode');
|
||||
manager.cockpitView.active = true;
|
||||
manager._setCockpitDisplayPortalActive(true);
|
||||
@@ -2139,8 +2149,7 @@ async function main() {
|
||||
layoutMode: document.getElementById('left-panel-stack').dataset.layoutMode,
|
||||
});
|
||||
};
|
||||
// The Intel HUD fades over 400ms and keeps its readout rects for the
|
||||
// whole transition, so both lanes are measured only once it has settled.
|
||||
// Both lanes are measured against the settled HUD variant.
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
recordLayoutStep();
|
||||
if (index < 4) {
|
||||
@@ -2356,6 +2365,8 @@ async function main() {
|
||||
manager._setModels3dEnabled(prior.models3dEnabled);
|
||||
manager._setHudVariant(prior.hudVariant);
|
||||
manager.hud.setMode(prior.hudMode);
|
||||
if (priorHudTransition) intelHud.style.setProperty('transition', priorHudTransition, priorHudTransitionPriority);
|
||||
else intelHud.style.removeProperty('transition');
|
||||
manager._updateHudButtonState();
|
||||
manager.cockpitView.active = prior.cockpitActive;
|
||||
manager.cockpitView.update = realCockpitUpdate;
|
||||
@@ -2403,6 +2414,8 @@ async function main() {
|
||||
&& document.getElementById('context-radio-toggle-btn')?.getAttribute('aria-expanded') === String(prior.contextRadioExpanded)
|
||||
&& hud.hidden === prior.hudHidden
|
||||
&& signal.hidden === prior.signalHidden
|
||||
&& intelHud.style.getPropertyValue('transition') === priorHudTransition
|
||||
&& intelHud.style.getPropertyPriority('transition') === priorHudTransitionPriority
|
||||
&& manager.hud.getMode() === prior.hudMode
|
||||
&& manager.hud.getVariant() === prior.hudVariant
|
||||
&& Object.entries(prior.panels).every(([id, state]) => {
|
||||
@@ -3194,6 +3207,15 @@ async function main() {
|
||||
&& tunerDirectRelease.spread < 0.5,
|
||||
JSON.stringify(tunerDirectRelease),
|
||||
);
|
||||
// Earlier cases scroll and resize the directory. Bring the physical drag
|
||||
// target back into its scroller before deriving viewport mouse coordinates.
|
||||
await page.$eval('#radio-tuner-slider', (slider) => slider.scrollIntoView({ block: 'center', inline: 'nearest' }));
|
||||
await page.waitForFunction(() => {
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
const rect = slider.getBoundingClientRect();
|
||||
const x = rect.left + 7 + (rect.width - 14) * Number(slider.value) / Math.max(1, Number(slider.max));
|
||||
return document.elementFromPoint(x, rect.top + rect.height / 2) === slider;
|
||||
});
|
||||
const tunerCommitTarget = await page.evaluate(() => {
|
||||
const gev = window.__godsEyeView;
|
||||
const slider = document.getElementById('radio-tuner-slider');
|
||||
|
||||
@@ -29,7 +29,8 @@ function ringAreaKm2(ring) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const [lon1, lat1] = ring[i];
|
||||
const [lon2, lat2] = ring[(i + 1) % n];
|
||||
sum += toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
sum +=
|
||||
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
|
||||
}
|
||||
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
|
||||
}
|
||||
@@ -37,8 +38,9 @@ function ringAreaKm2(ring) {
|
||||
function haversineKm(lon1, lat1, lon2, lat2) {
|
||||
const dLat = toRad(lat2 - lat1);
|
||||
const dLon = toRad(lon2 - lon1);
|
||||
const h = Math.sin(dLat / 2) ** 2
|
||||
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
|
||||
}
|
||||
|
||||
@@ -62,34 +64,34 @@ function normalizeName(s) {
|
||||
* Keys and values are both in normalizeName() form.
|
||||
*/
|
||||
const ALIASES = {
|
||||
'rockies': 'rocky mountains',
|
||||
'himalaya': 'himalayas',
|
||||
rockies: 'rocky mountains',
|
||||
himalaya: 'himalayas',
|
||||
'the himalaya': 'himalayas',
|
||||
'alps mountains': 'alps',
|
||||
'sahara desert': 'sahara',
|
||||
'gobi': 'gobi desert',
|
||||
'kalahari': 'kalahari desert',
|
||||
'atacama': 'desierto de atacama',
|
||||
gobi: 'gobi desert',
|
||||
kalahari: 'kalahari desert',
|
||||
atacama: 'desierto de atacama',
|
||||
'atacama desert': 'desierto de atacama',
|
||||
'tibetan plateau': 'plateau of tibet',
|
||||
'tibet plateau': 'plateau of tibet',
|
||||
'appalachians': 'appalachian mts',
|
||||
appalachians: 'appalachian mts',
|
||||
'appalachian mountains': 'appalachian mts',
|
||||
'caucasus': 'caucasus mts',
|
||||
caucasus: 'caucasus mts',
|
||||
'caucasus mountains': 'caucasus mts',
|
||||
'balkans': 'balkan pen',
|
||||
balkans: 'balkan pen',
|
||||
'balkan peninsula': 'balkan pen',
|
||||
'andes mountains': 'andes',
|
||||
'urals': 'ural mountains',
|
||||
urals: 'ural mountains',
|
||||
'pyrenees mountains': 'pyrenees',
|
||||
'arabian gulf': 'persian gulf',
|
||||
'gulf of arabia': 'persian gulf',
|
||||
'mediterranean': 'mediterranean sea',
|
||||
'caribbean': 'caribbean sea',
|
||||
'baja': 'baja california',
|
||||
'yucatan': 'pen de yucatan',
|
||||
mediterranean: 'mediterranean sea',
|
||||
caribbean: 'caribbean sea',
|
||||
baja: 'baja california',
|
||||
yucatan: 'pen de yucatan',
|
||||
'yucatan peninsula': 'pen de yucatan',
|
||||
'kamchatka': 'kamchatka peninsula',
|
||||
kamchatka: 'kamchatka peninsula',
|
||||
'sierra nevada mountains': 'sierra nevada',
|
||||
};
|
||||
|
||||
@@ -97,8 +99,13 @@ const ALIASES = {
|
||||
function suffixVariants(norm) {
|
||||
const v = [];
|
||||
// "x mountains" ↔ "x mts" (pack uses "Mts."; normalization strips the dot)
|
||||
if (norm.endsWith(' mountains')) v.push(norm.replace(/ mountains$/, ' mts'), norm.replace(/ mountains$/, ''));
|
||||
if (norm.endsWith(' mts')) v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
|
||||
if (norm.endsWith(' mountains'))
|
||||
v.push(
|
||||
norm.replace(/ mountains$/, ' mts'),
|
||||
norm.replace(/ mountains$/, ''),
|
||||
);
|
||||
if (norm.endsWith(' mts'))
|
||||
v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
|
||||
// "x desert" ↔ "x"
|
||||
if (norm.endsWith(' desert')) v.push(norm.replace(/ desert$/, ''));
|
||||
else v.push(norm + ' desert');
|
||||
@@ -117,9 +124,14 @@ async function loadPackFile(base) {
|
||||
// Vite bundles these JSON files as modules; the import attribute is what Node
|
||||
// needs to load the same files under node:test (same pattern as
|
||||
// neighborhoodPolygons.js). One path, so no node: import reaches the browser.
|
||||
const mod = base === 'regions'
|
||||
? await import('./local_data/natural_earth/regions.json', { with: { type: 'json' } })
|
||||
: await import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
|
||||
const mod =
|
||||
base === 'regions'
|
||||
? await import('./local_data/natural_earth/regions.json', {
|
||||
with: { type: 'json' },
|
||||
})
|
||||
: await import('./local_data/natural_earth/marine.json', {
|
||||
with: { type: 'json' },
|
||||
});
|
||||
return mod.default || mod;
|
||||
}
|
||||
|
||||
@@ -129,7 +141,10 @@ function buildEntries(pack, kind) {
|
||||
const polygons = ft.polygons || [];
|
||||
if (!polygons.length) continue;
|
||||
let areaKm2 = 0;
|
||||
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
|
||||
let minLon = Infinity,
|
||||
minLat = Infinity,
|
||||
maxLon = -Infinity,
|
||||
maxLat = -Infinity;
|
||||
for (const ring of polygons) {
|
||||
areaKm2 += ringAreaKm2(ring);
|
||||
for (const [lon, lat] of ring) {
|
||||
@@ -169,10 +184,14 @@ const loadIndex = createRetryableLoader(async () => {
|
||||
];
|
||||
const index = new Map();
|
||||
for (const entry of _entries) {
|
||||
for (const key of new Set([normalizeName(entry.name), normalizeName(entry.namealt)])) {
|
||||
for (const key of new Set([
|
||||
normalizeName(entry.name),
|
||||
normalizeName(entry.namealt),
|
||||
])) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
if (list) list.push(entry); else index.set(key, [entry]);
|
||||
if (list) list.push(entry);
|
||||
else index.set(key, [entry]);
|
||||
}
|
||||
}
|
||||
// duplicate names exist in Natural Earth (e.g. two "Cordillera Oriental",
|
||||
@@ -207,7 +226,11 @@ export async function findNaturalRegion(query) {
|
||||
const norm = normalizeName(query);
|
||||
if (!norm) return null;
|
||||
const index = await loadIndex();
|
||||
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
|
||||
const candidates = [
|
||||
norm,
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
for (const key of candidates) {
|
||||
if (!key) continue;
|
||||
const list = index.get(key);
|
||||
@@ -248,8 +271,8 @@ export function pointInRing(ring, lat, lon) {
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
const intersects = (yi > lat) !== (yj > lat)
|
||||
&& lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
const intersects =
|
||||
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
if (intersects) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
@@ -274,7 +297,11 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
|
||||
const norm = normalizeName(query);
|
||||
if (!norm || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const index = await loadIndex();
|
||||
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
|
||||
const candidates = [
|
||||
norm,
|
||||
ALIASES[norm],
|
||||
...suffixVariants(ALIASES[norm] || norm),
|
||||
];
|
||||
const seen = new Set();
|
||||
for (const key of candidates) {
|
||||
if (!key || seen.has(key)) continue;
|
||||
|
||||
@@ -14,7 +14,14 @@ import { createRetryableLoader } from './retryableLoad.js';
|
||||
|
||||
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
|
||||
const CITY_FILES = [
|
||||
{ id: 'san-francisco', bbox: [-122.55, 37.70, -122.35, 37.84], loader: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' } }) },
|
||||
{
|
||||
id: 'san-francisco',
|
||||
bbox: [-122.55, 37.7, -122.35, 37.84],
|
||||
loader: () =>
|
||||
import('./local_data/neighborhoods/san-francisco.json', {
|
||||
with: { type: 'json' },
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -27,7 +34,11 @@ const CITY_FILES = [
|
||||
const _cityLoaders = new Map();
|
||||
|
||||
function normalize(s) {
|
||||
return String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return String(s || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9 ]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,10 +56,12 @@ const NAME_ALIASES = new Map([
|
||||
function pointInRing(lon, lat, ring) {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const xi = ring[i][0]; const yi = ring[i][1];
|
||||
const xj = ring[j][0]; const yj = ring[j][1];
|
||||
const intersect = ((yi > lat) !== (yj > lat))
|
||||
&& (lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi);
|
||||
const xi = ring[i][0];
|
||||
const yi = ring[i][1];
|
||||
const xj = ring[j][0];
|
||||
const yj = ring[j][1];
|
||||
const intersect =
|
||||
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
@@ -70,7 +83,10 @@ function containingOuterRing(feature, lon, lat) {
|
||||
if (!pointInRing(lon, lat, outer)) continue;
|
||||
let inHole = false;
|
||||
for (let h = 1; h < poly.length; h++) {
|
||||
if (pointInRing(lon, lat, poly[h])) { inHole = true; break; }
|
||||
if (pointInRing(lon, lat, poly[h])) {
|
||||
inHole = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inHole) return outer;
|
||||
}
|
||||
@@ -108,7 +124,10 @@ async function loadCity(city) {
|
||||
} catch (error) {
|
||||
// The caller falls back to the live resolver ladder, which looks exactly
|
||||
// like "no bundled coverage" — say so once, out loud, instead.
|
||||
console.warn(`[neighborhoods] ${city.id} pack unavailable:`, error?.message || error);
|
||||
console.warn(
|
||||
`[neighborhoods] ${city.id} pack unavailable:`,
|
||||
error?.message || error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -122,7 +141,13 @@ async function loadCity(city) {
|
||||
* neighborhood name, or null when the point isn't in a covered city / no match.
|
||||
*/
|
||||
export async function lookupNeighborhoodRing(lat, lon, matchName) {
|
||||
const city = CITY_FILES.find((c) => lon >= c.bbox[0] && lat >= c.bbox[1] && lon <= c.bbox[2] && lat <= c.bbox[3]);
|
||||
const city = CITY_FILES.find(
|
||||
(c) =>
|
||||
lon >= c.bbox[0] &&
|
||||
lat >= c.bbox[1] &&
|
||||
lon <= c.bbox[2] &&
|
||||
lat <= c.bbox[3],
|
||||
);
|
||||
if (!city) return null;
|
||||
const feats = await loadCity(city);
|
||||
if (!feats.length) return null;
|
||||
@@ -153,11 +178,18 @@ export async function lookupNeighborhoodRing(lat, lon, matchName) {
|
||||
const containRing = containingOuterRing(f, lon, lat);
|
||||
const ring = containRing || largestOuterRing(f.geometry);
|
||||
if (!ring) continue;
|
||||
const cand = { ring, name: f.properties.name, contains: !!containRing, fwordCount: fWords.length };
|
||||
const cand = {
|
||||
ring,
|
||||
name: f.properties.name,
|
||||
contains: !!containRing,
|
||||
fwordCount: fWords.length,
|
||||
};
|
||||
// Prefer a feature that CONTAINS the point, then the most specific name (most words).
|
||||
if (!best
|
||||
|| (cand.contains && !best.contains)
|
||||
|| (cand.contains === best.contains && cand.fwordCount > best.fwordCount)) {
|
||||
if (
|
||||
!best ||
|
||||
(cand.contains && !best.contains) ||
|
||||
(cand.contains === best.contains && cand.fwordCount > best.fwordCount)
|
||||
) {
|
||||
best = cand;
|
||||
}
|
||||
}
|
||||
|
||||
+97
-2788
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeRadioCountryInput } from './radioCountry.js';
|
||||
import { normalizeRadioCountryInput } from '../layers/radio/index.js';
|
||||
|
||||
test('Radio country normalization maps ISO codes and bounded common names', () => {
|
||||
for (const [input, code] of [
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,147 @@
|
||||
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||
import { RADIO_UUID_RE } from './policy.js';
|
||||
|
||||
export function createCatalogModel({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
function isNonGlobalRadioIpv4(hostname) {
|
||||
const pieces = hostname.split('.');
|
||||
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
|
||||
return false;
|
||||
const values = pieces.map(Number);
|
||||
if (values.some((value) => value > 255)) return true;
|
||||
const [a, b, c] = values;
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
a >= 224 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113)
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeRadioHttpsUrl(value) {
|
||||
if (typeof value !== 'string' || !value) return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const hostname = url.hostname
|
||||
.toLowerCase()
|
||||
.replace(/^\[|\]$/g, '')
|
||||
.replace(/\.$/, '');
|
||||
if (
|
||||
url.protocol !== 'https:' ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
!hostname
|
||||
)
|
||||
return false;
|
||||
return !(
|
||||
hostname === 'localhost' ||
|
||||
hostname.endsWith('.localhost') ||
|
||||
hostname.endsWith('.local') ||
|
||||
isNonGlobalRadioIpv4(hostname) ||
|
||||
hostname.includes(':')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidRadioDirectoryStation(station) {
|
||||
const cleanText = (value, maxLength, { allowEmpty = true } = {}) =>
|
||||
typeof value === 'string' &&
|
||||
value.length <= maxLength &&
|
||||
(allowEmpty || value.trim().length > 0) &&
|
||||
!/[\u0000-\u001f\u007f]/.test(value) &&
|
||||
value === value.trim() &&
|
||||
!/\s{2,}/.test(value);
|
||||
const textArray = (value, limit, itemMaxLength) =>
|
||||
Array.isArray(value) &&
|
||||
value.length <= limit &&
|
||||
value.every((item) =>
|
||||
cleanText(item, itemMaxLength, { allowEmpty: false }),
|
||||
);
|
||||
return Boolean(
|
||||
station &&
|
||||
RADIO_UUID_RE.test(station.id) &&
|
||||
cleanText(station.name, 140, { allowEmpty: false }) &&
|
||||
Number.isFinite(station.lat) &&
|
||||
station.lat >= -90 &&
|
||||
station.lat <= 90 &&
|
||||
Number.isFinite(station.lon) &&
|
||||
station.lon >= -180 &&
|
||||
station.lon <= 180 &&
|
||||
isSafeRadioHttpsUrl(station.streamUrl) &&
|
||||
(station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) &&
|
||||
textArray(station.tags, 24, 80) &&
|
||||
textArray(station.languages, 8, 40) &&
|
||||
cleanText(station.state, 80) &&
|
||||
cleanText(station.country, 80) &&
|
||||
cleanText(station.countryCode, 2) &&
|
||||
(station.countryCode === '' ||
|
||||
normalizeRadioCountryInput(station.countryCode).valid) &&
|
||||
station.metadataTrust === 'untrusted-community' &&
|
||||
cleanText(station.codec, 16, { allowEmpty: false }) &&
|
||||
/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) &&
|
||||
(station.bitrate === null ||
|
||||
(Number.isInteger(station.bitrate) &&
|
||||
station.bitrate >= 8 &&
|
||||
station.bitrate <= 1024)),
|
||||
);
|
||||
}
|
||||
|
||||
function freezeRadioStation(station) {
|
||||
return Object.freeze({
|
||||
id: station.id,
|
||||
name: station.name,
|
||||
lat: station.lat,
|
||||
lon: station.lon,
|
||||
streamUrl: station.streamUrl,
|
||||
homepage: station.homepage,
|
||||
tags: Object.freeze([...station.tags]),
|
||||
languages: Object.freeze([...station.languages]),
|
||||
state: station.state,
|
||||
country: station.country,
|
||||
countryCode: station.countryCode,
|
||||
metadataTrust: station.metadataTrust,
|
||||
codec: station.codec,
|
||||
bitrate: station.bitrate,
|
||||
});
|
||||
}
|
||||
|
||||
function createAcceptedCatalogSnapshot(
|
||||
instance,
|
||||
generation,
|
||||
updatedAt,
|
||||
stations,
|
||||
) {
|
||||
if (!Number.isSafeInteger(generation) || generation < 1) return null;
|
||||
if (typeof instance !== 'string' || !instance) return null;
|
||||
const frozenStations = Object.freeze(stations.map(freezeRadioStation));
|
||||
return Object.freeze({
|
||||
instance,
|
||||
generation,
|
||||
updatedAt,
|
||||
stations: frozenStations,
|
||||
stationIds: Object.freeze(frozenStations.map((station) => station.id)),
|
||||
});
|
||||
}
|
||||
return {
|
||||
isNonGlobalRadioIpv4,
|
||||
isSafeRadioHttpsUrl,
|
||||
isValidRadioDirectoryStation,
|
||||
freezeRadioStation,
|
||||
createAcceptedCatalogSnapshot,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
|
||||
|
||||
export function createCategories({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Normalize one directory tag to a stable, lower-case display token. */
|
||||
|
||||
function normalizeRadioTag(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function stationTags(station) {
|
||||
if (Array.isArray(station?.tags))
|
||||
return station.tags.map(normalizeRadioTag).filter(Boolean);
|
||||
return String(station?.tags ?? '')
|
||||
.split(',')
|
||||
.map(normalizeRadioTag)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasTag(station, needles) {
|
||||
const tags = stationTags(station);
|
||||
return needles.some((needle) =>
|
||||
tags.some((tag) => tag === needle || tag.includes(needle)),
|
||||
);
|
||||
}
|
||||
|
||||
function detectedGenres(station) {
|
||||
return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(
|
||||
([genre]) => genre,
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether a station belongs in a station-tag category. */
|
||||
|
||||
function stationMatchesRadioCategory(station, categoryId) {
|
||||
if (categoryId === 'all') return true;
|
||||
if (categoryId.startsWith('genre:')) {
|
||||
return detectedGenres(station).includes(
|
||||
categoryId.slice('genre:'.length),
|
||||
);
|
||||
}
|
||||
if (categoryId === 'music') {
|
||||
return (
|
||||
detectedGenres(station).length > 0 ||
|
||||
hasTag(station, ['music', 'hits', 'songs'])
|
||||
);
|
||||
}
|
||||
if (categoryId === 'other') {
|
||||
return (
|
||||
!Object.entries(CATEGORY_MATCHERS).some(([id]) =>
|
||||
stationMatchesRadioCategory(station, id),
|
||||
) && !stationMatchesRadioCategory(station, 'music')
|
||||
);
|
||||
}
|
||||
return hasTag(station, CATEGORY_MATCHERS[categoryId] || []);
|
||||
}
|
||||
|
||||
/** Build canonical and detected-genre categories from station-level tags. */
|
||||
|
||||
function buildRadioCategories(stations) {
|
||||
const rows = Array.isArray(stations) ? stations : [];
|
||||
const categories = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'news', label: 'News' },
|
||||
{ id: 'talk', label: 'Talk' },
|
||||
{ id: 'weather', label: 'Weather / Emergency' },
|
||||
{ id: 'public-safety', label: 'Public Safety' },
|
||||
{ id: 'aviation-marine', label: 'Aviation / Marine' },
|
||||
{ id: 'traffic-transit', label: 'Traffic / Transit' },
|
||||
{ id: 'music', label: 'Music' },
|
||||
];
|
||||
|
||||
for (const [genre, label] of MUSIC_GENRES) {
|
||||
const id = `genre:${genre}`;
|
||||
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
|
||||
categories.push({ id, label });
|
||||
}
|
||||
}
|
||||
categories.push({ id: 'other', label: 'Other' });
|
||||
return categories.map((category) => ({
|
||||
...category,
|
||||
color: parts.model.radioCategoryColor(category.id),
|
||||
count: rows.filter((station) =>
|
||||
stationMatchesRadioCategory(station, category.id),
|
||||
).length,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Filter stations without changing the active stream or selection. */
|
||||
|
||||
function filterRadioStations(stations, categoryId = 'all') {
|
||||
return (Array.isArray(stations) ? stations : []).filter((station) =>
|
||||
stationMatchesRadioCategory(station, categoryId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Return whether Radio Browser metadata identifies a station as English-language. */
|
||||
|
||||
function isEnglishRadioStation(station) {
|
||||
const languages = Array.isArray(station?.languages)
|
||||
? station.languages
|
||||
: [];
|
||||
return languages.some((language) => {
|
||||
const normalized = normalizeRadioTag(language);
|
||||
return (
|
||||
normalized === 'en' ||
|
||||
normalized === 'eng' ||
|
||||
normalized.startsWith('english')
|
||||
);
|
||||
});
|
||||
}
|
||||
return {
|
||||
normalizeRadioTag,
|
||||
stationTags,
|
||||
hasTag,
|
||||
detectedGenres,
|
||||
stationMatchesRadioCategory,
|
||||
buildRadioCategories,
|
||||
filterRadioStations,
|
||||
isEnglishRadioStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import {
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_SINGLETON_GLOBAL_LIMIT,
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createClustering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Return the bounded singleton-label allowance for the current camera scale. */
|
||||
|
||||
function radioSingletonLabelLimit(cameraHeightM) {
|
||||
const height = Math.max(0, Number(cameraHeightM) || 0);
|
||||
if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT;
|
||||
if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT;
|
||||
return RADIO_SINGLETON_NEAR_LIMIT;
|
||||
}
|
||||
|
||||
/** Rank visible singleton stations by camera distance and stable station id. */
|
||||
|
||||
function selectRadioSingletonCandidates(
|
||||
candidates,
|
||||
limit = RADIO_SINGLETON_NEAR_LIMIT,
|
||||
) {
|
||||
const distance = (candidate) => {
|
||||
const value = Number(candidate?.distanceM);
|
||||
return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
|
||||
};
|
||||
return [...(candidates || [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
distance(a) - distance(b) ||
|
||||
String(a?.station?.id || a?.id || '').localeCompare(
|
||||
String(b?.station?.id || b?.id || ''),
|
||||
),
|
||||
)
|
||||
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
|
||||
}
|
||||
|
||||
/** Rank and cap ambient Radio clusters before shared-host entry allocation. */
|
||||
|
||||
function selectRadioClusterCandidates(
|
||||
candidates,
|
||||
limit = RADIO_OVERLAY_COHORT_LIMIT,
|
||||
) {
|
||||
return [...(candidates || [])]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) ||
|
||||
String(a?.id || '').localeCompare(String(b?.id || '')),
|
||||
)
|
||||
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve one-to-one overlay identity for overlapping clusters.
|
||||
* Cesium clustering is screen-space, so a small camera move may alter exact
|
||||
* membership even when the same geographic cluster remains visible. Every
|
||||
* positive overlap participates in greatest-contributor discovery, while
|
||||
* inheritance remains mutual-best: a current cluster accepts only one of its
|
||||
* greatest contributors and a prior identity transfers only to one of its
|
||||
* strongest split children.
|
||||
*/
|
||||
|
||||
function reconcileRadioClusterCandidates(
|
||||
candidates,
|
||||
previous = [],
|
||||
createId = null,
|
||||
) {
|
||||
const current = (Array.isArray(candidates) ? candidates : []).map(
|
||||
(candidate, index) => {
|
||||
const stationIds = [
|
||||
...new Set((candidate?.stationIds || []).map((id) => String(id))),
|
||||
].sort();
|
||||
const membershipId = String(
|
||||
candidate?.id || `membership:${stationIds.join('|')}`,
|
||||
);
|
||||
return {
|
||||
...candidate,
|
||||
_reconcileIndex: index,
|
||||
_reconcileMembershipId: membershipId,
|
||||
_reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`,
|
||||
stationIds,
|
||||
};
|
||||
},
|
||||
);
|
||||
const priorMembershipsByIdentity = new Map();
|
||||
for (const candidate of Array.isArray(previous) ? previous : []) {
|
||||
const identityId = String(candidate?.identityId || candidate?.id || '');
|
||||
if (!identityId) continue;
|
||||
if (!priorMembershipsByIdentity.has(identityId)) {
|
||||
priorMembershipsByIdentity.set(identityId, new Set());
|
||||
}
|
||||
const membership = priorMembershipsByIdentity.get(identityId);
|
||||
for (const stationId of candidate?.stationIds || [])
|
||||
membership.add(String(stationId));
|
||||
}
|
||||
const prior = [...priorMembershipsByIdentity]
|
||||
.map(([identityId, stationIds]) => ({
|
||||
identityId,
|
||||
stationIds: [...stationIds].sort(),
|
||||
}))
|
||||
.filter((candidate) => candidate.stationIds.length)
|
||||
.sort((a, b) => a.identityId.localeCompare(b.identityId));
|
||||
const priorByStation = new Map();
|
||||
for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) {
|
||||
for (const stationId of prior[priorIndex].stationIds) {
|
||||
if (!priorByStation.has(stationId)) priorByStation.set(stationId, []);
|
||||
priorByStation.get(stationId).push(priorIndex);
|
||||
}
|
||||
}
|
||||
|
||||
const edges = [];
|
||||
const greatestOverlapByCurrent = new Map();
|
||||
const greatestOverlapByPrior = new Map();
|
||||
for (
|
||||
let currentIndex = 0;
|
||||
currentIndex < current.length;
|
||||
currentIndex += 1
|
||||
) {
|
||||
const overlapByPrior = new Map();
|
||||
for (const stationId of current[currentIndex].stationIds) {
|
||||
for (const priorIndex of priorByStation.get(stationId) || []) {
|
||||
overlapByPrior.set(
|
||||
priorIndex,
|
||||
(overlapByPrior.get(priorIndex) || 0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [priorIndex, overlap] of overlapByPrior) {
|
||||
const union =
|
||||
current[currentIndex].stationIds.length +
|
||||
prior[priorIndex].stationIds.length -
|
||||
overlap;
|
||||
const smaller = Math.min(
|
||||
current[currentIndex].stationIds.length,
|
||||
prior[priorIndex].stationIds.length,
|
||||
);
|
||||
const score = smaller > 0 ? overlap / smaller : 0;
|
||||
const similarity = union > 0 ? overlap / union : 0;
|
||||
edges.push({ currentIndex, priorIndex, overlap, score, similarity });
|
||||
greatestOverlapByCurrent.set(
|
||||
currentIndex,
|
||||
Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap),
|
||||
);
|
||||
greatestOverlapByPrior.set(
|
||||
priorIndex,
|
||||
Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap),
|
||||
);
|
||||
}
|
||||
}
|
||||
const eligibleEdges = edges.filter(
|
||||
(edge) =>
|
||||
edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) &&
|
||||
edge.overlap === greatestOverlapByPrior.get(edge.priorIndex),
|
||||
);
|
||||
eligibleEdges.sort(
|
||||
(a, b) =>
|
||||
// Identity follows the contributor representing the largest share of the
|
||||
// new cluster. Overlap-coefficient-first incorrectly lets a fully retained
|
||||
// two-station minority beat a larger partial contributor during a merge.
|
||||
b.overlap - a.overlap ||
|
||||
b.score - a.score ||
|
||||
b.similarity - a.similarity ||
|
||||
current[a.currentIndex]._reconcileCanonicalKey.localeCompare(
|
||||
current[b.currentIndex]._reconcileCanonicalKey,
|
||||
) ||
|
||||
prior[a.priorIndex].identityId.localeCompare(
|
||||
prior[b.priorIndex].identityId,
|
||||
),
|
||||
);
|
||||
|
||||
const inheritedByCurrent = new Map();
|
||||
const usedPriorIdentities = new Set();
|
||||
for (const edge of eligibleEdges) {
|
||||
const identityId = prior[edge.priorIndex].identityId;
|
||||
if (
|
||||
inheritedByCurrent.has(edge.currentIndex) ||
|
||||
usedPriorIdentities.has(identityId)
|
||||
)
|
||||
continue;
|
||||
inheritedByCurrent.set(
|
||||
edge.currentIndex,
|
||||
prior[edge.priorIndex].identityId,
|
||||
);
|
||||
usedPriorIdentities.add(identityId);
|
||||
}
|
||||
|
||||
const generatedByCurrent = new Map();
|
||||
if (typeof createId === 'function') {
|
||||
const freshIndices = current
|
||||
.map((candidate, index) => ({ candidate, index }))
|
||||
.filter(({ index }) => !inheritedByCurrent.has(index))
|
||||
.sort((a, b) =>
|
||||
a.candidate._reconcileCanonicalKey.localeCompare(
|
||||
b.candidate._reconcileCanonicalKey,
|
||||
),
|
||||
);
|
||||
for (const { candidate, index } of freshIndices) {
|
||||
generatedByCurrent.set(index, String(createId(candidate, index) || ''));
|
||||
}
|
||||
}
|
||||
|
||||
return current.map((candidate, index) => {
|
||||
const membershipId = candidate._reconcileMembershipId;
|
||||
const inherited = inheritedByCurrent.get(index);
|
||||
const generated = generatedByCurrent.get(index) || '';
|
||||
const identityId = inherited || generated || membershipId;
|
||||
const {
|
||||
_reconcileIndex,
|
||||
_reconcileMembershipId,
|
||||
_reconcileCanonicalKey,
|
||||
...rest
|
||||
} = candidate;
|
||||
return { ...rest, membershipId, identityId, id: identityId };
|
||||
});
|
||||
}
|
||||
|
||||
function resetRadioClusterOverlayIdentities() {
|
||||
layerState._clusterOverlayIdentities = [];
|
||||
}
|
||||
|
||||
function emptyRadioOverlayDiagnostics() {
|
||||
return {
|
||||
entryCount: 0,
|
||||
selectedCount: 0,
|
||||
singletonTexts: [],
|
||||
singletonIds: [],
|
||||
clusterTexts: [],
|
||||
clusterIds: [],
|
||||
clusterMemberships: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Retain refresh identities only while every represented station still exists. */
|
||||
|
||||
function retainRadioClusterIdentitiesForStations(previous, stations) {
|
||||
const stationIds = new Set(
|
||||
(stations || [])
|
||||
.map((station) => String(station?.id || ''))
|
||||
.filter(Boolean),
|
||||
);
|
||||
return (previous || []).filter(
|
||||
(candidate) =>
|
||||
Array.isArray(candidate?.stationIds) &&
|
||||
candidate.stationIds.length > 0 &&
|
||||
candidate.stationIds.every((stationId) =>
|
||||
stationIds.has(String(stationId)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return {
|
||||
radioSingletonLabelLimit,
|
||||
selectRadioSingletonCandidates,
|
||||
selectRadioClusterCandidates,
|
||||
reconcileRadioClusterCandidates,
|
||||
resetRadioClusterOverlayIdentities,
|
||||
emptyRadioOverlayDiagnostics,
|
||||
retainRadioClusterIdentitiesForStations,
|
||||
};
|
||||
}
|
||||
Vendored
+119
@@ -0,0 +1,119 @@
|
||||
export function createControls({ state: layerState, services, parts, source }) {
|
||||
const methods = {
|
||||
id: 'radio',
|
||||
|
||||
name: 'Radio',
|
||||
|
||||
icon: '◉',
|
||||
|
||||
source: 'Radio Browser',
|
||||
|
||||
updateInterval: 45 * 60 * 1000,
|
||||
|
||||
/** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */
|
||||
setLifecyclePresentation({
|
||||
lifecycleState = null,
|
||||
enabled = false,
|
||||
uncertain = false,
|
||||
} = {}) {
|
||||
const settledState = enabled ? 'enabled' : 'disabled';
|
||||
const normalizedState = [
|
||||
'enabling',
|
||||
'enabled',
|
||||
'disabling',
|
||||
'disabled',
|
||||
].includes(lifecycleState)
|
||||
? lifecycleState
|
||||
: settledState;
|
||||
layerState._managerLifecyclePresentation = {
|
||||
lifecycleState: normalizedState,
|
||||
enabled: Boolean(enabled),
|
||||
uncertain: Boolean(uncertain),
|
||||
};
|
||||
parts.interaction.syncRadioLifecyclePresentation();
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Layer statistics for HUD/debug surfaces. */
|
||||
getStats() {
|
||||
return {
|
||||
count: layerState._stations.length,
|
||||
filtered: parts.queries.visibleStations().length,
|
||||
selected: layerState._selectedId,
|
||||
playing: layerState._audioStationId,
|
||||
stale: layerState._stale,
|
||||
degraded: layerState._degraded,
|
||||
loading: layerState._loading,
|
||||
error: layerState._error,
|
||||
lastUpdate: layerState._updatedAt
|
||||
? Date.parse(layerState._updatedAt)
|
||||
: null,
|
||||
horizonScans: layerState._horizonScanCount,
|
||||
overlayEntries: layerState._overlayDiagnostics.entryCount,
|
||||
};
|
||||
},
|
||||
|
||||
subscribe: parts.presentation.subscribeToRadio,
|
||||
|
||||
subscribePlaybackControls:
|
||||
parts.presentation.subscribeToRadioPlaybackControls,
|
||||
|
||||
getAcceptedCatalogSnapshot:
|
||||
parts.presentation.getRadioAcceptedCatalogSnapshot,
|
||||
|
||||
getUIState: parts.presentation.getRadioUIState,
|
||||
|
||||
getParams: parts.selection.getRadioParams,
|
||||
|
||||
setParams: parts.selection.setRadioParams,
|
||||
|
||||
getOverlayDiagnostics: () => ({
|
||||
...layerState._overlayDiagnostics,
|
||||
singletonTexts: [...layerState._overlayDiagnostics.singletonTexts],
|
||||
singletonIds: [...layerState._overlayDiagnostics.singletonIds],
|
||||
clusterTexts: [...layerState._overlayDiagnostics.clusterTexts],
|
||||
clusterIds: [...layerState._overlayDiagnostics.clusterIds],
|
||||
clusterMemberships: layerState._overlayDiagnostics.clusterMemberships.map(
|
||||
(entry) => ({ ...entry }),
|
||||
),
|
||||
}),
|
||||
|
||||
getTunerStations: parts.tuning.getRadioTunerStations,
|
||||
|
||||
beginTuning: parts.tuning.beginRadioTuning,
|
||||
|
||||
setTuningStatic: parts.tuning.setRadioTuningStatic,
|
||||
|
||||
previewTuningStation: parts.tuning.previewRadioTuningStation,
|
||||
|
||||
commitTuningStation: parts.tuning.commitRadioTuningStation,
|
||||
|
||||
cancelTuning: parts.tuning.cancelRadioTuning,
|
||||
|
||||
endTuning: parts.tuning.endRadioTuning,
|
||||
|
||||
setFilter: parts.selection.setRadioFilter,
|
||||
|
||||
selectStation: parts.selection.selectRadioStation,
|
||||
|
||||
selectRequestedStation: parts.selection.selectRequestedRadioStation,
|
||||
|
||||
cycleStation: parts.selection.cycleRadioStation,
|
||||
|
||||
togglePlayback: parts.playback.toggleRadioPlayback,
|
||||
|
||||
play: parts.playback.playSelectedRadio,
|
||||
|
||||
playForVoice: parts.playback.playPreparedRadioForVoice,
|
||||
|
||||
pause: parts.playback.pauseRadioPlayback,
|
||||
|
||||
stopPlayback: parts.playback.stopRadioPlayback,
|
||||
|
||||
setVolume: parts.volume.setRadioVolume,
|
||||
|
||||
setVoiceDucked: parts.volume.setRadioVoiceDucking,
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createCatalogModel } from './catalogModel.js';
|
||||
import { createLabels } from './labels.js';
|
||||
import { createClustering } from './clustering.js';
|
||||
import { createVolume } from './volume.js';
|
||||
import { createNavigation } from './navigation.js';
|
||||
import { createCategories } from './categories.js';
|
||||
import { createModel } from './model.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
import { createTuning } from './tuning.js';
|
||||
import { createTuningNoise } from './tuningNoise.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createPresentation } from './presentation.js';
|
||||
import { createPlayback } from './playback.js';
|
||||
import { createSelection } from './selection.js';
|
||||
import { createControls } from './controls.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createState } from './state.js';
|
||||
|
||||
/** Construct one layer with its own scene state and supplied application services. */
|
||||
export function createRadioLayer({ services, source }) {
|
||||
const state = createState({ services });
|
||||
const parts = {};
|
||||
const context = { state, services, parts, source };
|
||||
parts.catalogModel = createCatalogModel(context);
|
||||
parts.labels = createLabels(context);
|
||||
parts.clustering = createClustering(context);
|
||||
parts.volume = createVolume(context);
|
||||
parts.navigation = createNavigation(context);
|
||||
parts.categories = createCategories(context);
|
||||
parts.model = createModel(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.interaction = createInteraction(context);
|
||||
parts.tuning = createTuning(context);
|
||||
parts.tuningNoise = createTuningNoise(context);
|
||||
parts.rendering = createRendering(context);
|
||||
parts.presentation = createPresentation(context);
|
||||
parts.playback = createPlayback(context);
|
||||
parts.selection = createSelection(context);
|
||||
parts.controls = createControls(context);
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
return Object.assign(
|
||||
{},
|
||||
parts.controls.methods,
|
||||
parts.lifecycle.methods,
|
||||
parts.ingestion?.methods,
|
||||
{
|
||||
radioGlobeLabel: parts.labels.radioGlobeLabel,
|
||||
createRadioSelectedOverlayEntry:
|
||||
parts.labels.createRadioSelectedOverlayEntry,
|
||||
createRadioClusterOverlayEntry:
|
||||
parts.labels.createRadioClusterOverlayEntry,
|
||||
createRadioSingletonOverlayEntry:
|
||||
parts.labels.createRadioSingletonOverlayEntry,
|
||||
radioSingletonLabelLimit: parts.clustering.radioSingletonLabelLimit,
|
||||
selectRadioSingletonCandidates:
|
||||
parts.clustering.selectRadioSingletonCandidates,
|
||||
selectRadioClusterCandidates:
|
||||
parts.clustering.selectRadioClusterCandidates,
|
||||
reconcileRadioClusterCandidates:
|
||||
parts.clustering.reconcileRadioClusterCandidates,
|
||||
radioCameraPositionChanged: parts.navigation.radioCameraPositionChanged,
|
||||
normalizeRadioTag: parts.categories.normalizeRadioTag,
|
||||
stationMatchesRadioCategory: parts.categories.stationMatchesRadioCategory,
|
||||
radioCategoryColor: parts.model.radioCategoryColor,
|
||||
radioClusterBadgeText: parts.model.radioClusterBadgeText,
|
||||
radioClusterCategoryId: parts.model.radioClusterCategoryId,
|
||||
radioSelectionBracketSvg: parts.labels.radioSelectionBracketSvg,
|
||||
radioStationCategoryId: parts.model.radioStationCategoryId,
|
||||
buildRadioCategories: parts.categories.buildRadioCategories,
|
||||
filterRadioStations: parts.categories.filterRadioStations,
|
||||
isEnglishRadioStation: parts.categories.isEnglishRadioStation,
|
||||
rankRadioStationsForViewport: parts.queries.rankRadioStationsForViewport,
|
||||
rankRadioStationsForRequest: parts.queries.rankRadioStationsForRequest,
|
||||
radioViewIsGlobal: parts.navigation.radioViewIsGlobal,
|
||||
radioRequestIsCurrent: parts.model.radioRequestIsCurrent,
|
||||
radioStationIdFromPick: parts.interaction.radioStationIdFromPick,
|
||||
radioStationCameraPlan: parts.navigation.radioStationCameraPlan,
|
||||
radioGlobeNeedsRecentering: parts.navigation.radioGlobeNeedsRecentering,
|
||||
radioGlobeRecenterHeight: parts.navigation.radioGlobeRecenterHeight,
|
||||
buildRadioTunerBand: parts.tuning.buildRadioTunerBand,
|
||||
radioTuningStaticShouldPlay:
|
||||
parts.tuningNoise.radioTuningStaticShouldPlay,
|
||||
getRadioAcceptedCatalogSnapshot:
|
||||
parts.presentation.getRadioAcceptedCatalogSnapshot,
|
||||
radioStationResolutionMatches: parts.model.radioStationResolutionMatches,
|
||||
getRadioUIState: parts.presentation.getRadioUIState,
|
||||
subscribeToRadio: parts.presentation.subscribeToRadio,
|
||||
subscribeToRadioPlaybackControls:
|
||||
parts.presentation.subscribeToRadioPlaybackControls,
|
||||
getRadioTunerStations: parts.tuning.getRadioTunerStations,
|
||||
beginRadioTuning: parts.tuning.beginRadioTuning,
|
||||
setRadioTuningStatic: parts.tuning.setRadioTuningStatic,
|
||||
radioCameraNavigationAllowed:
|
||||
parts.navigation.radioCameraNavigationAllowed,
|
||||
previewRadioTuningStation: parts.tuning.previewRadioTuningStation,
|
||||
endRadioTuning: parts.tuning.endRadioTuning,
|
||||
cancelRadioTuning: parts.tuning.cancelRadioTuning,
|
||||
commitRadioTuningStation: parts.tuning.commitRadioTuningStation,
|
||||
playSelectedRadio: parts.playback.playSelectedRadio,
|
||||
confirmRadioPlayback: parts.playback.confirmRadioPlayback,
|
||||
playPreparedRadioForVoice: parts.playback.playPreparedRadioForVoice,
|
||||
stopRadioPlayback: parts.playback.stopRadioPlayback,
|
||||
toggleRadioPlayback: parts.playback.toggleRadioPlayback,
|
||||
pauseRadioPlayback: parts.playback.pauseRadioPlayback,
|
||||
setRadioVolume: parts.volume.setRadioVolume,
|
||||
setRadioParams: parts.selection.setRadioParams,
|
||||
getRadioParams: parts.selection.getRadioParams,
|
||||
setRadioVoiceDucking: parts.volume.setRadioVoiceDucking,
|
||||
selectRadioStation: parts.selection.selectRadioStation,
|
||||
cycleRadioStation: parts.selection.cycleRadioStation,
|
||||
selectRequestedRadioStation: parts.selection.selectRequestedRadioStation,
|
||||
setRadioFilter: parts.selection.setRadioFilter,
|
||||
retainRadioClusterIdentitiesForStations:
|
||||
parts.clustering.retainRadioClusterIdentitiesForStations,
|
||||
},
|
||||
);
|
||||
}
|
||||
export {
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
RADIO_SINGLETON_GLOBAL_LIMIT,
|
||||
RADIO_SINGLETON_MID_LIMIT,
|
||||
RADIO_SINGLETON_NEAR_LIMIT,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
} from './policy.js';
|
||||
|
||||
export { createRadioSource } from './source.js';
|
||||
export {
|
||||
radioTunerSlot,
|
||||
radioTunerCommitSlot,
|
||||
radioTunerPointerPosition,
|
||||
buildRadioTunerTicks,
|
||||
} from '../../ui/radioTunerModel.js';
|
||||
|
||||
export { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||
@@ -0,0 +1,174 @@
|
||||
import {
|
||||
RADIO_DIRECTORY_STALE_MS,
|
||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createIngestion({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const methods = {
|
||||
/** Refresh directory metadata through the hardened same-origin broker. */
|
||||
async update() {
|
||||
if (!layerState._enabled) return;
|
||||
const generation = ++layerState._requestGeneration;
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
layerState._abortController?.abort();
|
||||
layerState._abortController = new AbortController();
|
||||
layerState._loading = true;
|
||||
layerState._error = null;
|
||||
parts.presentation.emitState();
|
||||
try {
|
||||
const body = await source.getDirectory({
|
||||
signal: layerState._abortController.signal,
|
||||
});
|
||||
if (
|
||||
!parts.model.radioRequestIsCurrent(
|
||||
generation,
|
||||
layerState._requestGeneration,
|
||||
layerState._enabled,
|
||||
sessionGeneration,
|
||||
layerState._sessionGeneration,
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (!Array.isArray(body?.stations))
|
||||
throw new Error('Radio directory response was malformed');
|
||||
const updatedAt =
|
||||
typeof body.updatedAt === 'string' &&
|
||||
Number.isFinite(Date.parse(body.updatedAt))
|
||||
? body.updatedAt
|
||||
: null;
|
||||
const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN;
|
||||
if (
|
||||
!updatedAt ||
|
||||
updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS ||
|
||||
updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS ||
|
||||
typeof body.stale !== 'boolean' ||
|
||||
typeof body.degraded !== 'boolean'
|
||||
)
|
||||
throw new Error('Radio directory freshness metadata was malformed');
|
||||
const rows = body.stations;
|
||||
const acceptedRows = rows.filter(
|
||||
parts.catalogModel.isValidRadioDirectoryStation,
|
||||
);
|
||||
if (!acceptedRows.length)
|
||||
throw new Error('Radio directory returned no usable stations');
|
||||
if (acceptedRows.length !== rows.length) {
|
||||
throw new Error(
|
||||
'Radio directory response contained malformed stations',
|
||||
);
|
||||
}
|
||||
const acceptedGeneration = body.acceptedGeneration;
|
||||
if (
|
||||
acceptedGeneration !== null &&
|
||||
(!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1)
|
||||
)
|
||||
throw new Error('Radio directory generation metadata was malformed');
|
||||
if (!body.stale && !body.degraded && acceptedGeneration === null) {
|
||||
throw new Error('Radio directory omitted its accepted generation');
|
||||
}
|
||||
const catalogInstance = body.catalogInstance;
|
||||
if (
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
(typeof catalogInstance !== 'string' || !catalogInstance)
|
||||
) {
|
||||
throw new Error('Radio directory omitted its catalog instance');
|
||||
}
|
||||
const preservingWarmCatalog =
|
||||
layerState._stations.length > 0 && (body.stale || body.degraded);
|
||||
// Generations are only comparable within one producer instance. A new
|
||||
// instance token (server restart, different proxy process) starts a fresh
|
||||
// sequence: never a repeat and never a regression.
|
||||
const sameCatalogInstance =
|
||||
layerState._acceptedCatalogSnapshot?.instance === catalogInstance;
|
||||
const currentAcceptedGeneration = sameCatalogInstance
|
||||
? layerState._acceptedCatalogSnapshot?.generation
|
||||
: null;
|
||||
if (
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
Number.isSafeInteger(currentAcceptedGeneration) &&
|
||||
acceptedGeneration < currentAcceptedGeneration
|
||||
)
|
||||
throw new Error('Radio directory generation regressed');
|
||||
const repeatingAcceptedGeneration =
|
||||
!body.stale &&
|
||||
!body.degraded &&
|
||||
Number.isSafeInteger(currentAcceptedGeneration) &&
|
||||
acceptedGeneration === currentAcceptedGeneration;
|
||||
if (!preservingWarmCatalog) {
|
||||
const immutableRows = acceptedRows.map(
|
||||
parts.catalogModel.freezeRadioStation,
|
||||
);
|
||||
if (repeatingAcceptedGeneration) {
|
||||
layerState._updatedAt =
|
||||
layerState._acceptedCatalogSnapshot.updatedAt;
|
||||
if (
|
||||
!layerState._tuningActive &&
|
||||
layerState._cancelledTuningPresentationStation
|
||||
) {
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
parts.rendering.updateSelectionEntity();
|
||||
}
|
||||
} else if (!body.stale && !body.degraded) {
|
||||
const acceptedSnapshot =
|
||||
parts.catalogModel.createAcceptedCatalogSnapshot(
|
||||
catalogInstance,
|
||||
acceptedGeneration,
|
||||
updatedAt,
|
||||
immutableRows,
|
||||
);
|
||||
if (!acceptedSnapshot)
|
||||
throw new Error(
|
||||
'Radio directory generation metadata was malformed',
|
||||
);
|
||||
layerState._acceptedCatalogSnapshot = acceptedSnapshot;
|
||||
parts.rendering.reconcileStations(acceptedSnapshot.stations);
|
||||
} else {
|
||||
parts.rendering.reconcileStations(immutableRows);
|
||||
}
|
||||
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
|
||||
}
|
||||
layerState._degraded = body.degraded;
|
||||
layerState._stale = body.stale;
|
||||
layerState._error = preservingWarmCatalog
|
||||
? 'Directory refresh degraded; showing the previous station catalog.'
|
||||
: layerState._degraded
|
||||
? 'Radio directory coverage is degraded.'
|
||||
: null;
|
||||
} catch (error) {
|
||||
if (
|
||||
error?.name === 'AbortError' ||
|
||||
!parts.model.radioRequestIsCurrent(
|
||||
generation,
|
||||
layerState._requestGeneration,
|
||||
layerState._enabled,
|
||||
sessionGeneration,
|
||||
layerState._sessionGeneration,
|
||||
)
|
||||
)
|
||||
return;
|
||||
layerState._error = layerState._stations.length
|
||||
? 'Directory refresh failed; showing the previous station catalog.'
|
||||
: 'Radio directory is temporarily unavailable.';
|
||||
layerState._stale = layerState._stations.length > 0;
|
||||
layerState._degraded = layerState._stations.length > 0;
|
||||
} finally {
|
||||
if (
|
||||
generation === layerState._requestGeneration &&
|
||||
sessionGeneration === layerState._sessionGeneration
|
||||
) {
|
||||
layerState._loading = false;
|
||||
layerState._abortController = null;
|
||||
parts.presentation.emitState();
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
RADIO_PREFIX,
|
||||
RADIO_PICK_OFFSETS,
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
HORIZON_TICK_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createInteraction({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
resolvePickId,
|
||||
isOwnedByOtherLayer,
|
||||
registerPickOwner,
|
||||
unregisterPickOwner,
|
||||
} = services.picking;
|
||||
const { setOverlaySourceVisible } = services.overlays;
|
||||
|
||||
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
|
||||
|
||||
function radioStationIdFromPick(picked) {
|
||||
const pending = [picked?.id, picked?.primitive?.id];
|
||||
const seen = new Set();
|
||||
while (pending.length) {
|
||||
const value = pending.shift();
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
const id = String(value);
|
||||
if (id.startsWith(`${RADIO_PREFIX}selected:`))
|
||||
return id.slice(`${RADIO_PREFIX}selected:`.length);
|
||||
if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length);
|
||||
continue;
|
||||
}
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) pending.push(...value);
|
||||
else {
|
||||
pending.push(value.id);
|
||||
if (value.primitive) pending.push(value.primitive.id);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickedRadioStationAt(position) {
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!scene || !position) return null;
|
||||
|
||||
const stationFromPick = (picked) => {
|
||||
const stationId = radioStationIdFromPick(picked);
|
||||
return stationId && layerState._stationById.has(stationId)
|
||||
? stationId
|
||||
: null;
|
||||
};
|
||||
const primaryPick = scene.pick(position);
|
||||
const primaryStationId = stationFromPick(primaryPick);
|
||||
if (primaryStationId) return primaryStationId;
|
||||
const primaryId = resolvePickId(primaryPick);
|
||||
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
|
||||
|
||||
if (typeof scene.drillPick === 'function') {
|
||||
const drilled = scene.drillPick(position, 16) || [];
|
||||
for (const picked of drilled) {
|
||||
const stationId = stationFromPick(picked);
|
||||
if (stationId) return stationId;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) {
|
||||
const offsetPosition = new Cesium.Cartesian2(
|
||||
position.x + offsetX,
|
||||
position.y + offsetY,
|
||||
);
|
||||
const picked = scene.pick(offsetPosition);
|
||||
const pickedId = resolvePickId(picked);
|
||||
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
|
||||
const stationId = stationFromPick(picked);
|
||||
if (stationId) return stationId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function radioPresentationAllowed() {
|
||||
if (!layerState._managerLifecyclePresentation) return layerState._enabled;
|
||||
return (
|
||||
layerState._enabled &&
|
||||
layerState._managerLifecyclePresentation.lifecycleState === 'enabled' &&
|
||||
layerState._managerLifecyclePresentation.enabled &&
|
||||
!layerState._managerLifecyclePresentation.uncertain
|
||||
);
|
||||
}
|
||||
|
||||
function syncRadioLifecyclePresentation() {
|
||||
const visible = radioPresentationAllowed();
|
||||
if (layerState._dataSource) layerState._dataSource.show = visible;
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
|
||||
if (visible) {
|
||||
installInteraction();
|
||||
parts.rendering.updateSelectionEntity();
|
||||
parts.rendering.updateRenderVisibility();
|
||||
parts.rendering.scheduleRadioOverlayPublish();
|
||||
} else {
|
||||
removeInteraction();
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
}
|
||||
}
|
||||
|
||||
function installInteraction() {
|
||||
if (!layerState._viewer || layerState._clickHandler) return;
|
||||
registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX));
|
||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
||||
layerState._viewer.scene.canvas,
|
||||
);
|
||||
layerState._clickHandler.setInputAction((click) => {
|
||||
if (!radioPresentationAllowed()) return;
|
||||
const stationId = pickedRadioStationAt(click.position);
|
||||
if (!stationId) return;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
parts.selection.selectRadioStation(stationId, {
|
||||
autoplay: true,
|
||||
origin: 'user',
|
||||
});
|
||||
if (typeof document !== 'undefined') {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('gev:radio-selected', { detail: { stationId } }),
|
||||
);
|
||||
}
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
// Polling remains bounded during flights, but a stationary camera no longer
|
||||
// rewrites every station's visibility four times per second while voice and
|
||||
// audio processing share the main thread.
|
||||
layerState._horizonTimer = setInterval(
|
||||
() => parts.rendering.updateRenderVisibility({ force: false }),
|
||||
HORIZON_TICK_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function removeInteraction() {
|
||||
unregisterPickOwner('radio');
|
||||
layerState._clickHandler?.destroy();
|
||||
layerState._clickHandler = null;
|
||||
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
|
||||
layerState._horizonTimer = null;
|
||||
}
|
||||
return {
|
||||
radioStationIdFromPick,
|
||||
pickedRadioStationAt,
|
||||
radioPresentationAllowed,
|
||||
syncRadioLifecyclePresentation,
|
||||
installInteraction,
|
||||
removeInteraction,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
RADIO_LABEL_SEGMENTER,
|
||||
RADIO_GLOBE_LABEL_MAX_CHARS,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_CATEGORY_COLORS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLabels({ state: layerState, services, parts, source }) {
|
||||
function compactRadioLabelText(value, maxChars) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const graphemes = RADIO_LABEL_SEGMENTER
|
||||
? [...RADIO_LABEL_SEGMENTER.segment(text)].map(
|
||||
(segment) => segment.segment,
|
||||
)
|
||||
: Array.from(text);
|
||||
if (graphemes.length <= maxChars) return text;
|
||||
return `${graphemes
|
||||
.slice(0, Math.max(1, maxChars - 1))
|
||||
.join('')
|
||||
.trimEnd()}…`;
|
||||
}
|
||||
|
||||
function cleanRadioLabelName(value) {
|
||||
return String(value || '')
|
||||
.replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\s+\([^()]{1,40}\)$/, '')
|
||||
.replace(/\s+FM$/i, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Return compact frequency-first text for a Radio globe label. */
|
||||
|
||||
function radioGlobeLabel(station) {
|
||||
const fullName = String(station?.name || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!fullName) return '';
|
||||
|
||||
let frequency = null;
|
||||
let labelName = '';
|
||||
const explicitMatches = [
|
||||
...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi),
|
||||
];
|
||||
const explicit = explicitMatches.find((match) => {
|
||||
const value = Number(match[1]);
|
||||
return value >= 64 && value <= 108;
|
||||
});
|
||||
if (explicit) {
|
||||
frequency = explicit[1];
|
||||
const before = cleanRadioLabelName(fullName.slice(0, explicit.index));
|
||||
const after = cleanRadioLabelName(
|
||||
fullName.slice(explicit.index + explicit[0].length),
|
||||
);
|
||||
labelName = before || after;
|
||||
} else {
|
||||
// Infer only a leading decimal in the conventional FM band. Integers such
|
||||
// as "80's" or "100 GREATEST" and domains such as "1.fm" stay names.
|
||||
const leading = fullName.match(
|
||||
/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/,
|
||||
);
|
||||
const value = Number(leading?.[1]);
|
||||
if (leading && value >= 87.5 && value <= 108) {
|
||||
frequency = leading[1];
|
||||
labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]);
|
||||
}
|
||||
}
|
||||
|
||||
if (!frequency)
|
||||
return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS);
|
||||
const prefix = `${frequency} FM`;
|
||||
if (!labelName) return prefix;
|
||||
const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3;
|
||||
return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`;
|
||||
}
|
||||
|
||||
/** Build the protected selected-station text published through WorldOverlay. */
|
||||
|
||||
function createRadioSelectedOverlayEntry(station, position) {
|
||||
if (!station?.id || !station?.name || !position) return null;
|
||||
return {
|
||||
id: `selected:${station.id}`,
|
||||
position,
|
||||
variant: 'selected',
|
||||
selected: true,
|
||||
protected: true,
|
||||
paintLane: 'selected',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Number.MAX_SAFE_INTEGER,
|
||||
title: radioGlobeLabel(station),
|
||||
accent: parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
),
|
||||
interactive: false,
|
||||
anchorRadiusPx: 20,
|
||||
minAnchorGapPx: 8,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 8,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build one bounded ambient cluster badge for the shared overlay host. */
|
||||
|
||||
function createRadioClusterOverlayEntry({
|
||||
id,
|
||||
position,
|
||||
text,
|
||||
accent,
|
||||
stationCount,
|
||||
}) {
|
||||
if (!id || !position || !text) return null;
|
||||
return {
|
||||
id: `cluster:${id}`,
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Math.max(1, Number(stationCount) || 1),
|
||||
title: text,
|
||||
accent: accent || RADIO_CATEGORY_COLORS.other,
|
||||
interactive: false,
|
||||
anchorRadiusPx: Math.min(
|
||||
13,
|
||||
6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8,
|
||||
),
|
||||
minAnchorGapPx: 4,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 6,
|
||||
// Cluster membership/counts update continuously while the camera moves.
|
||||
// Paint them as hard-opacity incumbents so a replacement count never
|
||||
// cross-fades with its predecessor or dims merely for leaving the keyhole.
|
||||
// Collision, viewport rejection, and horizon culling remain host-owned.
|
||||
stateless: true,
|
||||
edgeFade: 'none',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// The camera can sit above 24,000 km in the supported full-globe view, and
|
||||
// horizon clusters are farther from it than the camera's surface altitude.
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
distanceScale: {
|
||||
near: 100_000,
|
||||
nearValue: 1.08,
|
||||
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
farValue: 0.92,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build one ambient station label while Cesium retains its point and picking. */
|
||||
|
||||
function createRadioSingletonOverlayEntry({
|
||||
station,
|
||||
position,
|
||||
priority = 1,
|
||||
}) {
|
||||
if (!station?.id || !station?.name || !position) return null;
|
||||
return {
|
||||
id: `station:${station.id}`,
|
||||
position,
|
||||
variant: 'label',
|
||||
paintLane: 'ambient-label',
|
||||
collisionGroup: 'ambient-label',
|
||||
priority: Math.max(1, Number(priority) || 1),
|
||||
title: radioGlobeLabel(station),
|
||||
accent: parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
),
|
||||
interactive: false,
|
||||
anchorRadiusPx: 9,
|
||||
minAnchorGapPx: 4,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
gapPx: 5,
|
||||
// Camera/filter changes replace this bounded cohort. Prevent the prior
|
||||
// cohort from fading over the current visible points.
|
||||
stateless: true,
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
distanceScale: {
|
||||
near: 100_000,
|
||||
nearValue: 1,
|
||||
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
farValue: 0.86,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the code-native four-corner bracket used by the selected station. */
|
||||
|
||||
function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) {
|
||||
const stroke = /^#[0-9a-f]{6}$/i.test(String(color))
|
||||
? String(color)
|
||||
: RADIO_CATEGORY_COLORS.other;
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><path d="M2 13V2H13 M27 2H38V13 M38 27V38H27 M13 38H2V27" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="square"/></svg>`;
|
||||
}
|
||||
return {
|
||||
compactRadioLabelText,
|
||||
cleanRadioLabelName,
|
||||
radioGlobeLabel,
|
||||
createRadioSelectedOverlayEntry,
|
||||
createRadioClusterOverlayEntry,
|
||||
createRadioSingletonOverlayEntry,
|
||||
radioSelectionBracketSvg,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
DEFAULT_RADIO_VOLUME,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createLifecycle({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
|
||||
|
||||
const methods = {
|
||||
/** Initialize the Cesium data source and the single audio element. */
|
||||
init(viewer) {
|
||||
layerState._sessionGeneration += 1;
|
||||
layerState._viewer = viewer;
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
parts.playback.installAudio();
|
||||
if (!layerState._dataSource) {
|
||||
layerState._dataSource = new Cesium.CustomDataSource('Radio stations');
|
||||
viewer.dataSources.add(layerState._dataSource);
|
||||
parts.rendering.installClusterStyling();
|
||||
}
|
||||
layerState._dataSource.show = false;
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
},
|
||||
|
||||
/** Show stations. Enabling or preset restoration never starts audio. */
|
||||
enable() {
|
||||
layerState._enabled = true;
|
||||
parts.interaction.syncRadioLifecyclePresentation();
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Hide the layer and stop playback without forgetting the selected station. */
|
||||
disable() {
|
||||
layerState._sessionGeneration += 1;
|
||||
layerState._enabled = false;
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._requestGeneration += 1;
|
||||
layerState._abortController?.abort();
|
||||
layerState._abortController = null;
|
||||
layerState._loading = false;
|
||||
layerState._selectionGeneration += 1;
|
||||
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
|
||||
layerState._selectionTimer = null;
|
||||
parts.interaction.removeInteraction();
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
parts.playback.stopRadioPlayback({ origin: 'layer-disable' });
|
||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
if (layerState._overlayPublishTimer)
|
||||
clearTimeout(layerState._overlayPublishTimer);
|
||||
layerState._overlayPublishTimer = null;
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._overlayDiagnostics =
|
||||
parts.clustering.emptyRadioOverlayDiagnostics();
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
parts.presentation.emitState();
|
||||
},
|
||||
|
||||
/** Release rendering, event, request, and playback resources. */
|
||||
destroy() {
|
||||
this.disable();
|
||||
parts.volume.cancelRadioVolumeTransition();
|
||||
layerState._voiceDucked = false;
|
||||
layerState._voiceRestoring = false;
|
||||
parts.tuning.endRadioTuning();
|
||||
const closedTuningNoise = layerState._tuningNoiseContext?.close?.();
|
||||
if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {});
|
||||
layerState._tuningNoiseContext = null;
|
||||
if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME;
|
||||
layerState._audio = null;
|
||||
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
||||
layerState._removeClusterListener?.();
|
||||
layerState._removeClusterListener = null;
|
||||
if (layerState._dataSource && layerState._viewer)
|
||||
layerState._viewer.dataSources.remove(layerState._dataSource, true);
|
||||
layerState._dataSource = null;
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
layerState._stations = [];
|
||||
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._stationById.clear();
|
||||
layerState._categories = Object.freeze([]);
|
||||
layerState._filter = DEFAULT_RADIO_FILTER;
|
||||
layerState._renderById.clear();
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._lastHorizonCameraPosition = null;
|
||||
layerState._horizonScanCount = 0;
|
||||
layerState._selectedId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._loading = false;
|
||||
layerState._stale = false;
|
||||
layerState._degraded = false;
|
||||
layerState._error = null;
|
||||
layerState._updatedAt = null;
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
layerState._viewer = null;
|
||||
parts.presentation.emitState();
|
||||
layerState._listeners.clear();
|
||||
layerState._playbackControlListeners.clear();
|
||||
},
|
||||
};
|
||||
|
||||
return { methods };
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
RADIO_CATEGORY_COLORS,
|
||||
RADIO_CLUSTER_LABELS,
|
||||
RADIO_MARKER_CATEGORY_ORDER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/** Return the shared CSS color for a canonical or detected-genre category. */
|
||||
|
||||
function radioCategoryColor(categoryId = 'other') {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other;
|
||||
}
|
||||
|
||||
/** Format the concise count/category badge shown above a Radio cluster. */
|
||||
|
||||
function radioClusterBadgeText(categoryId = 'other', count = 0) {
|
||||
const normalized = String(categoryId || 'other');
|
||||
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
|
||||
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
|
||||
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
|
||||
return `${stationCount} ${label}`;
|
||||
}
|
||||
|
||||
/** Choose the category advertised by a cluster in the active station-tag view. */
|
||||
|
||||
function radioClusterCategoryId(stations, activeFilter = 'all') {
|
||||
const filter = String(activeFilter || 'all');
|
||||
if (filter !== 'all') {
|
||||
if (
|
||||
filter.startsWith('genre:') ||
|
||||
RADIO_MARKER_CATEGORY_ORDER.includes(filter) ||
|
||||
filter === 'other'
|
||||
) {
|
||||
return filter;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
const categoryCounts = new Map();
|
||||
for (const station of Array.isArray(stations) ? stations : []) {
|
||||
const categoryId = radioStationCategoryId(station);
|
||||
categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1);
|
||||
}
|
||||
let clusterCategory = 'other';
|
||||
let clusterCategoryCount = 0;
|
||||
for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) {
|
||||
const count = categoryCounts.get(categoryId) || 0;
|
||||
if (count > clusterCategoryCount) {
|
||||
clusterCategory = categoryId;
|
||||
clusterCategoryCount = count;
|
||||
}
|
||||
}
|
||||
if ((categoryCounts.get('other') || 0) > clusterCategoryCount)
|
||||
return 'other';
|
||||
return clusterCategory;
|
||||
}
|
||||
|
||||
/** Choose one stable display category for a station that may match several filters. */
|
||||
|
||||
function radioStationCategoryId(station) {
|
||||
return (
|
||||
RADIO_MARKER_CATEGORY_ORDER.find((categoryId) =>
|
||||
parts.categories.stationMatchesRadioCategory(station, categoryId),
|
||||
) || 'other'
|
||||
);
|
||||
}
|
||||
|
||||
/** Pure stale-response guard shared by the async directory update path. */
|
||||
|
||||
function radioRequestIsCurrent(
|
||||
generation,
|
||||
currentGeneration,
|
||||
enabled,
|
||||
sessionGeneration = null,
|
||||
currentSessionGeneration = sessionGeneration,
|
||||
) {
|
||||
return (
|
||||
generation === currentGeneration &&
|
||||
Boolean(enabled) &&
|
||||
sessionGeneration === currentSessionGeneration
|
||||
);
|
||||
}
|
||||
|
||||
/** Compare every station field that determines tuner presentation and playback. */
|
||||
|
||||
function radioStationResolutionMatches(frozenStation, currentStation) {
|
||||
if (!frozenStation || !currentStation) return false;
|
||||
for (const key of [
|
||||
'id',
|
||||
'name',
|
||||
'lat',
|
||||
'lon',
|
||||
'streamUrl',
|
||||
'homepage',
|
||||
'state',
|
||||
'country',
|
||||
'countryCode',
|
||||
'metadataTrust',
|
||||
'codec',
|
||||
'bitrate',
|
||||
]) {
|
||||
if (frozenStation[key] !== currentStation[key]) return false;
|
||||
}
|
||||
return ['tags', 'languages'].every((key) => {
|
||||
const frozenValues = Array.isArray(frozenStation[key])
|
||||
? frozenStation[key]
|
||||
: [];
|
||||
const currentValues = Array.isArray(currentStation[key])
|
||||
? currentStation[key]
|
||||
: [];
|
||||
return (
|
||||
frozenValues.length === currentValues.length &&
|
||||
frozenValues.every((value, index) => value === currentValues[index])
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function tuningResolutionStation(id) {
|
||||
return id ? layerState._tuningStationById.get(String(id)) || null : null;
|
||||
}
|
||||
return {
|
||||
radioCategoryColor,
|
||||
radioClusterBadgeText,
|
||||
radioClusterCategoryId,
|
||||
radioStationCategoryId,
|
||||
radioRequestIsCurrent,
|
||||
radioStationResolutionMatches,
|
||||
tuningResolutionStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
HORIZON_CAMERA_MOVE_EPSILON_M,
|
||||
GLOBAL_RADIO_ALTITUDE_M,
|
||||
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
|
||||
} from './policy.js';
|
||||
|
||||
export function createNavigation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const {
|
||||
isFullGlobeInsideKeyhole,
|
||||
GLOBE_ENTER_CLEARANCE_PX,
|
||||
projectEarthDiscToViewport,
|
||||
earthDiscScreenRadius,
|
||||
getKeyholeGeometry,
|
||||
} = services.globe;
|
||||
|
||||
/** Return whether camera translation materially changes horizon visibility. */
|
||||
|
||||
function radioCameraPositionChanged(
|
||||
previous,
|
||||
current,
|
||||
epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M,
|
||||
) {
|
||||
if (!previous || !current) return true;
|
||||
const dx = Number(current.x) - Number(previous.x);
|
||||
const dy = Number(current.y) - Number(previous.y);
|
||||
const dz = Number(current.z) - Number(previous.z);
|
||||
if (![dx, dy, dz].every(Number.isFinite)) return true;
|
||||
const threshold = Math.max(0, Number(epsilonM) || 0);
|
||||
return dx * dx + dy * dy + dz * dz > threshold * threshold;
|
||||
}
|
||||
|
||||
/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */
|
||||
|
||||
function radioViewIsGlobal(altitudeM) {
|
||||
return (
|
||||
Number.isFinite(altitudeM) &&
|
||||
Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M
|
||||
);
|
||||
}
|
||||
|
||||
/** Plan a station-centered camera move that preserves altitude and view angle. */
|
||||
|
||||
function radioStationCameraPlan(station, cameraState = {}) {
|
||||
const targetLat = Number(station?.lat);
|
||||
const targetLon = Number(station?.lon);
|
||||
const height = Math.max(1, Number(cameraState.height) || 1);
|
||||
const heading = Number.isFinite(cameraState.heading)
|
||||
? cameraState.heading
|
||||
: 0;
|
||||
const pitch = Number.isFinite(cameraState.pitch)
|
||||
? cameraState.pitch
|
||||
: -Math.PI / 2;
|
||||
const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0;
|
||||
if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null;
|
||||
|
||||
const downAngle = Math.min(
|
||||
Math.PI / 2,
|
||||
Math.max(0.08, Math.abs(Math.min(-0.001, pitch))),
|
||||
);
|
||||
const groundOffsetM =
|
||||
downAngle > Math.PI / 2 - 1e-6
|
||||
? 0
|
||||
: Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle)));
|
||||
const angularDistance = groundOffsetM / 6_378_137;
|
||||
const targetLatRad = (targetLat * Math.PI) / 180;
|
||||
const cameraBearing = heading + Math.PI;
|
||||
const cameraLatRad = Math.asin(
|
||||
Math.sin(targetLatRad) * Math.cos(angularDistance) +
|
||||
Math.cos(targetLatRad) *
|
||||
Math.sin(angularDistance) *
|
||||
Math.cos(cameraBearing),
|
||||
);
|
||||
const cameraLonRad =
|
||||
(targetLon * Math.PI) / 180 +
|
||||
Math.atan2(
|
||||
Math.sin(cameraBearing) *
|
||||
Math.sin(angularDistance) *
|
||||
Math.cos(targetLatRad),
|
||||
Math.cos(angularDistance) -
|
||||
Math.sin(targetLatRad) * Math.sin(cameraLatRad),
|
||||
);
|
||||
const cameraLon = (((cameraLonRad * 180) / Math.PI + 540) % 360) - 180;
|
||||
return {
|
||||
lat: (cameraLatRad * 180) / Math.PI,
|
||||
lon: cameraLon,
|
||||
height,
|
||||
heading,
|
||||
pitch,
|
||||
roll,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether Radio navigation should first restore a centered Earth view.
|
||||
* Fit-capable clipped discs and closer views whose optical center has reached
|
||||
* the Earth limb use the staged path; ordinary centered local views stay direct.
|
||||
*/
|
||||
|
||||
function radioGlobeNeedsRecentering(geometry) {
|
||||
if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false;
|
||||
const centeredGeometry = {
|
||||
...geometry,
|
||||
earthCenterX: geometry.keyholeCenterX,
|
||||
earthCenterY: geometry.keyholeCenterY,
|
||||
};
|
||||
if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true;
|
||||
const values = [
|
||||
geometry.earthCenterX,
|
||||
geometry.earthCenterY,
|
||||
geometry.earthRadius,
|
||||
geometry.keyholeCenterX,
|
||||
geometry.keyholeCenterY,
|
||||
];
|
||||
if (!values.every(Number.isFinite) || geometry.earthRadius <= 0)
|
||||
return false;
|
||||
const centerOffset = Math.hypot(
|
||||
geometry.earthCenterX - geometry.keyholeCenterX,
|
||||
geometry.earthCenterY - geometry.keyholeCenterY,
|
||||
);
|
||||
return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX;
|
||||
}
|
||||
|
||||
/** Preserve closer zoom while capping an extreme full-globe recovery. */
|
||||
|
||||
function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) {
|
||||
if (!Number.isFinite(currentHeight) || currentHeight < 0) return null;
|
||||
return fullGlobeCapable
|
||||
? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M)
|
||||
: currentHeight;
|
||||
}
|
||||
|
||||
function radioCameraState(camera = layerState._viewer?.camera) {
|
||||
if (!camera) return null;
|
||||
return {
|
||||
height: camera.positionCartographic?.height,
|
||||
heading: camera.heading,
|
||||
pitch: camera.pitch,
|
||||
roll: camera.roll,
|
||||
};
|
||||
}
|
||||
|
||||
/** Radio may move the globe only while no tracked entity owns the follow camera. */
|
||||
|
||||
function radioCameraNavigationAllowed(viewer = layerState._viewer) {
|
||||
return Boolean(viewer?.camera) && !viewer.trackedEntity;
|
||||
}
|
||||
|
||||
function radioGlobeRecenterPlan(viewer = layerState._viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const canvas = viewer?.scene?.canvas;
|
||||
const width = canvas?.clientWidth || canvas?.width;
|
||||
const height = canvas?.clientHeight || canvas?.height;
|
||||
const cartographic = camera?.positionCartographic;
|
||||
if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null;
|
||||
const geometry = projectEarthDiscToViewport(
|
||||
viewer,
|
||||
width,
|
||||
height,
|
||||
layerState._radioEarthScreenCenter,
|
||||
layerState._radioEarthToCenter,
|
||||
);
|
||||
let fullGlobeCapable = false;
|
||||
if (geometry) {
|
||||
if (!radioGlobeNeedsRecentering(geometry)) return null;
|
||||
fullGlobeCapable = isFullGlobeInsideKeyhole(
|
||||
{
|
||||
...geometry,
|
||||
earthCenterX: geometry.keyholeCenterX,
|
||||
earthCenterY: geometry.keyholeCenterY,
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
const earthRadius = earthDiscScreenRadius(
|
||||
Cesium.Cartesian3.magnitude(camera.positionWC),
|
||||
height,
|
||||
camera.frustum?.fovy,
|
||||
);
|
||||
const facingEarthCenter =
|
||||
Cesium.Cartesian3.dot(
|
||||
camera.directionWC,
|
||||
layerState._radioEarthToCenter,
|
||||
) > 0;
|
||||
if (!earthRadius || facingEarthCenter) return null;
|
||||
const keyhole = getKeyholeGeometry(width, height);
|
||||
fullGlobeCapable =
|
||||
earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius;
|
||||
}
|
||||
const recenterHeight = radioGlobeRecenterHeight(
|
||||
cartographic.height,
|
||||
fullGlobeCapable,
|
||||
);
|
||||
if (recenterHeight == null) return null;
|
||||
return {
|
||||
destination: Cesium.Cartesian3.fromRadians(
|
||||
cartographic.longitude,
|
||||
cartographic.latitude,
|
||||
recenterHeight,
|
||||
),
|
||||
cameraState: {
|
||||
...radioCameraState(camera),
|
||||
height: recenterHeight,
|
||||
heading: 0,
|
||||
pitch: -Cesium.Math.PI_OVER_TWO,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function radioCameraNavigationIsCurrent(navigation) {
|
||||
return Boolean(
|
||||
navigation &&
|
||||
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
||||
layerState._enabled &&
|
||||
radioCameraNavigationAllowed(layerState._viewer),
|
||||
);
|
||||
}
|
||||
|
||||
function cancelActiveRadioCameraFlight() {
|
||||
if (
|
||||
!layerState._activeRadioCameraFlight ||
|
||||
!radioCameraNavigationAllowed(layerState._viewer)
|
||||
)
|
||||
return;
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
layerState._viewer.camera.cancelFlight();
|
||||
}
|
||||
|
||||
function invalidateRadioCameraNavigation() {
|
||||
layerState._radioCameraNavigationGeneration += 1;
|
||||
cancelActiveRadioCameraFlight();
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
}
|
||||
|
||||
function radioCameraNavigationOwnsSelection(navigation, station) {
|
||||
return Boolean(
|
||||
navigation &&
|
||||
station &&
|
||||
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
||||
navigation.target?.id === station.id,
|
||||
);
|
||||
}
|
||||
|
||||
function startRadioCameraFlight(navigation, options, onComplete) {
|
||||
if (!radioCameraNavigationIsCurrent(navigation)) return false;
|
||||
const token = ++layerState._radioCameraFlightSequence;
|
||||
layerState._activeRadioCameraFlight = {
|
||||
generation: navigation.generation,
|
||||
token,
|
||||
};
|
||||
const finish = (completed) => {
|
||||
if (layerState._activeRadioCameraFlight?.token === token)
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
if (completed && radioCameraNavigationIsCurrent(navigation))
|
||||
onComplete?.();
|
||||
};
|
||||
layerState._viewer.camera.flyTo({
|
||||
...options,
|
||||
complete: () => finish(true),
|
||||
cancel: () => finish(false),
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function focusRadioNavigationTarget(navigation) {
|
||||
if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target)
|
||||
return false;
|
||||
const plan = radioStationCameraPlan(
|
||||
navigation.target,
|
||||
navigation.cameraState,
|
||||
);
|
||||
if (!plan) return false;
|
||||
navigation.phase = 'focusing';
|
||||
return startRadioCameraFlight(
|
||||
navigation,
|
||||
{
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
plan.lon,
|
||||
plan.lat,
|
||||
plan.height,
|
||||
),
|
||||
orientation: {
|
||||
heading: plan.heading,
|
||||
pitch: plan.pitch,
|
||||
roll: plan.roll,
|
||||
},
|
||||
duration: navigation.duration,
|
||||
},
|
||||
() => {
|
||||
navigation.phase = 'settled';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function beginRadioCameraNavigation(cameraState = null) {
|
||||
const generation = ++layerState._radioCameraNavigationGeneration;
|
||||
if (
|
||||
!radioCameraNavigationAllowed(layerState._viewer) ||
|
||||
!layerState._enabled
|
||||
)
|
||||
return null;
|
||||
cancelActiveRadioCameraFlight();
|
||||
const recenterPlan = radioGlobeRecenterPlan(layerState._viewer);
|
||||
return {
|
||||
generation,
|
||||
phase: 'idle',
|
||||
recentered: false,
|
||||
recenterPlan,
|
||||
cameraState:
|
||||
recenterPlan?.cameraState || cameraState || radioCameraState(),
|
||||
target: null,
|
||||
duration: 0.35,
|
||||
};
|
||||
}
|
||||
|
||||
function rotateRadioStationIntoView(
|
||||
station,
|
||||
duration = 0.35,
|
||||
cameraState = null,
|
||||
navigation = null,
|
||||
) {
|
||||
const activeNavigation =
|
||||
navigation || beginRadioCameraNavigation(cameraState);
|
||||
if (!station || !radioCameraNavigationIsCurrent(activeNavigation))
|
||||
return false;
|
||||
activeNavigation.target = station;
|
||||
activeNavigation.duration = duration;
|
||||
if (activeNavigation.phase === 'recentering') return true;
|
||||
if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight();
|
||||
if (activeNavigation.recenterPlan && !activeNavigation.recentered) {
|
||||
activeNavigation.phase = 'recentering';
|
||||
const recenterDuration = Math.min(0.9, Math.max(0.65, duration));
|
||||
return startRadioCameraFlight(
|
||||
activeNavigation,
|
||||
{
|
||||
destination: activeNavigation.recenterPlan.destination,
|
||||
orientation: {
|
||||
heading: activeNavigation.cameraState.heading,
|
||||
pitch: activeNavigation.cameraState.pitch,
|
||||
roll: activeNavigation.cameraState.roll,
|
||||
},
|
||||
duration: recenterDuration,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
},
|
||||
() => {
|
||||
activeNavigation.recentered = true;
|
||||
activeNavigation.phase = 'idle';
|
||||
focusRadioNavigationTarget(activeNavigation);
|
||||
},
|
||||
);
|
||||
}
|
||||
return focusRadioNavigationTarget(activeNavigation);
|
||||
}
|
||||
|
||||
function focusStation(station) {
|
||||
layerState._radioCameraNavigationGeneration += 1;
|
||||
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
|
||||
return false;
|
||||
cancelActiveRadioCameraFlight();
|
||||
layerState._viewer.camera.flyTo({
|
||||
destination: Cesium.Cartesian3.fromDegrees(
|
||||
station.lon,
|
||||
station.lat,
|
||||
85_000,
|
||||
),
|
||||
duration: 1.1,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
radioCameraPositionChanged,
|
||||
radioViewIsGlobal,
|
||||
radioStationCameraPlan,
|
||||
radioGlobeNeedsRecentering,
|
||||
radioGlobeRecenterHeight,
|
||||
radioCameraState,
|
||||
radioCameraNavigationAllowed,
|
||||
radioGlobeRecenterPlan,
|
||||
radioCameraNavigationIsCurrent,
|
||||
cancelActiveRadioCameraFlight,
|
||||
invalidateRadioCameraNavigation,
|
||||
radioCameraNavigationOwnsSelection,
|
||||
startRadioCameraFlight,
|
||||
focusRadioNavigationTarget,
|
||||
beginRadioCameraNavigation,
|
||||
rotateRadioStationIntoView,
|
||||
focusStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
|
||||
|
||||
export function createPlayback({ state: layerState, services, parts, source }) {
|
||||
function audioEventBelongsToActiveAttempt(audio) {
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
return (
|
||||
layerState._audio === audio &&
|
||||
Boolean(attempt) &&
|
||||
attempt.generation === layerState._playGeneration &&
|
||||
attempt.stationId === layerState._audioStationId
|
||||
);
|
||||
}
|
||||
|
||||
function installAudio({ replace = false } = {}) {
|
||||
if (layerState._audio && !replace) return;
|
||||
if (typeof Audio === 'undefined') return;
|
||||
const previousAudio = layerState._audio;
|
||||
layerState._audio = null;
|
||||
if (previousAudio) {
|
||||
try {
|
||||
previousAudio.pause();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
try {
|
||||
previousAudio.removeAttribute('src');
|
||||
} catch {
|
||||
/* no source */
|
||||
}
|
||||
try {
|
||||
previousAudio.load();
|
||||
} catch {
|
||||
/* detached media */
|
||||
}
|
||||
}
|
||||
const audio = new Audio();
|
||||
layerState._audio = audio;
|
||||
audio.preload = 'none';
|
||||
audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume;
|
||||
audio.addEventListener('playing', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
layerState._audioState = 'playing';
|
||||
layerState._audioError = null;
|
||||
if (layerState._tuningAwaitingStationId === layerState._audioStationId)
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('pause', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (
|
||||
layerState._audioState === 'stopped' ||
|
||||
layerState._audioState === 'loading'
|
||||
)
|
||||
return;
|
||||
layerState._audioState = 'paused';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('waiting', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
layerState._audioState = 'buffering';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
audio.addEventListener('error', () => {
|
||||
if (layerState._audio !== audio) return;
|
||||
if (!audioEventBelongsToActiveAttempt(audio)) return;
|
||||
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
|
||||
return;
|
||||
const failedId = layerState._audioStationId;
|
||||
const attempt = layerState._activePlaybackAttempt;
|
||||
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
'Broadcaster stream is unavailable or blocked by the browser.';
|
||||
parts.presentation.emitState();
|
||||
});
|
||||
}
|
||||
|
||||
function tryRadioFallback(
|
||||
failedId,
|
||||
origin = layerState._playFallbackOrigin || 'programmatic',
|
||||
attemptId = layerState._playFallbackAttemptId,
|
||||
) {
|
||||
const fallbackId = layerState._playFallbackId;
|
||||
const fallbackStation = fallbackId
|
||||
? layerState._stationById.get(fallbackId)
|
||||
: null;
|
||||
const fallbackFocusPolicy = layerState._playFallbackFocus;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
if (
|
||||
!fallbackStation ||
|
||||
fallbackId === failedId ||
|
||||
layerState._selectedId !== failedId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const fallbackFocusResult =
|
||||
typeof fallbackFocusPolicy === 'function'
|
||||
? fallbackFocusPolicy(fallbackStation)
|
||||
: fallbackFocusPolicy;
|
||||
const fallbackCameraNavigation =
|
||||
fallbackFocusResult && typeof fallbackFocusResult === 'object'
|
||||
? fallbackFocusResult
|
||||
: null;
|
||||
const fallbackFocus = fallbackCameraNavigation
|
||||
? false
|
||||
: Boolean(fallbackFocusResult);
|
||||
parts.selection.selectRadioStation(fallbackId, {
|
||||
autoplay: true,
|
||||
focus: fallbackFocus,
|
||||
origin: origin || 'programmatic',
|
||||
attemptId,
|
||||
cameraNavigation: fallbackCameraNavigation,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordDirectoryClick(id) {
|
||||
Promise.resolve()
|
||||
.then(() => source.recordClick(id))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
/** Play the selected broadcaster stream after an explicit user action. */
|
||||
|
||||
async function playSelectedRadio({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
} = {}) {
|
||||
const station = parts.queries.selectedStation();
|
||||
if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl)
|
||||
return false;
|
||||
if (layerState._tuningActive) parts.tuning.endRadioTuning();
|
||||
// A media event carries no reliable attempt identity. Give every explicit
|
||||
// play/resume/replacement its own element so queued events from the retired
|
||||
// attempt remain bound to the discarded element and cannot mutate this one.
|
||||
installAudio({ replace: true });
|
||||
if (!layerState._audio) return false;
|
||||
|
||||
const generation = ++layerState._playGeneration;
|
||||
const ownedAttemptId =
|
||||
attemptId || `radio-play-${++layerState._playAttemptSequence}`;
|
||||
layerState._activePlaybackAttempt = {
|
||||
id: ownedAttemptId,
|
||||
origin,
|
||||
stationId: station.id,
|
||||
streamUrl: station.streamUrl,
|
||||
generation,
|
||||
};
|
||||
if (layerState._playFallbackId) {
|
||||
layerState._playFallbackOrigin = origin;
|
||||
layerState._playFallbackAttemptId = ownedAttemptId;
|
||||
}
|
||||
layerState._audioError = null;
|
||||
layerState._audioState = 'loading';
|
||||
if (
|
||||
layerState._audioStationId !== station.id ||
|
||||
layerState._audio.src !== station.streamUrl
|
||||
) {
|
||||
layerState._audio.pause();
|
||||
layerState._audio.src = station.streamUrl;
|
||||
layerState._audioStationId = station.id;
|
||||
}
|
||||
parts.presentation.emitState();
|
||||
|
||||
try {
|
||||
const playback = layerState._audio.play();
|
||||
if (playback?.then) await playback;
|
||||
if (
|
||||
generation !== layerState._playGeneration ||
|
||||
layerState._audioStationId !== station.id ||
|
||||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
|
||||
)
|
||||
return false;
|
||||
layerState._audioState = 'playing';
|
||||
if (layerState._tuningAwaitingStationId === station.id)
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
recordDirectoryClick(station.id);
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user')
|
||||
parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (
|
||||
generation !== layerState._playGeneration ||
|
||||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
|
||||
)
|
||||
return false;
|
||||
if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false;
|
||||
layerState._audioState = 'error';
|
||||
layerState._audioError =
|
||||
error?.name === 'NotAllowedError'
|
||||
? 'Playback requires a direct click or tap.'
|
||||
: 'Broadcaster stream could not be started.';
|
||||
parts.presentation.emitState();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for confirmed Radio playback while voice owns a hard mute.
|
||||
* A fallback station may replace the first stream during this wait, so the
|
||||
* state subscription—not the first play() promise—is authoritative.
|
||||
*/
|
||||
|
||||
function confirmRadioPlayback({
|
||||
startPlayback,
|
||||
subscribe,
|
||||
getState,
|
||||
timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS,
|
||||
} = {}) {
|
||||
if (
|
||||
typeof startPlayback !== 'function' ||
|
||||
typeof subscribe !== 'function' ||
|
||||
typeof getState !== 'function'
|
||||
) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let started = false;
|
||||
let unsubscribe = () => {};
|
||||
let timer = null;
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe();
|
||||
resolve(Boolean(ok));
|
||||
};
|
||||
const inspect = (state) => {
|
||||
if (!started || !state) return;
|
||||
if (
|
||||
!state.voiceDucked &&
|
||||
['loading', 'buffering', 'playing'].includes(state.audioState)
|
||||
) {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
state.audioState === 'playing' &&
|
||||
state.playingStationId &&
|
||||
state.voiceDucked
|
||||
) {
|
||||
finish(true);
|
||||
} else if (state.audioState === 'error') {
|
||||
finish(false);
|
||||
}
|
||||
};
|
||||
unsubscribe = subscribe(inspect);
|
||||
started = true;
|
||||
timer = setTimeout(
|
||||
() => finish(false),
|
||||
Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS),
|
||||
);
|
||||
Promise.resolve()
|
||||
.then(startPlayback)
|
||||
.then((playStarted) => {
|
||||
const state = getState();
|
||||
inspect(state);
|
||||
if (
|
||||
!playStarted &&
|
||||
!['loading', 'buffering', 'playing'].includes(state?.audioState)
|
||||
)
|
||||
finish(false);
|
||||
})
|
||||
.catch(() => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/** Start and verify a prepared station without ever making it audible under voice. */
|
||||
|
||||
function playPreparedRadioForVoice(options = {}) {
|
||||
if (!layerState._voiceDucked) return Promise.resolve(false);
|
||||
return confirmRadioPlayback({
|
||||
startPlayback: () =>
|
||||
playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }),
|
||||
subscribe: parts.presentation.subscribeToRadio,
|
||||
getState: parts.presentation.getRadioUIState,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stop the shared stream and release its network resource. */
|
||||
|
||||
function stopRadioPlayback({
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
} = {}) {
|
||||
if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId)
|
||||
return false;
|
||||
const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null;
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._playGeneration += 1;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
layerState._activePlaybackAttempt = null;
|
||||
if (layerState._audio) {
|
||||
layerState._audio.pause();
|
||||
layerState._audio.removeAttribute('src');
|
||||
layerState._audio.load();
|
||||
}
|
||||
layerState._audioStationId = null;
|
||||
layerState._audioState = 'stopped';
|
||||
layerState._audioError = null;
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user' || origin === 'voice') {
|
||||
parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Pause or resume the selected stream. Resuming is still click initiated. */
|
||||
|
||||
function toggleRadioPlayback({ origin = 'programmatic' } = {}) {
|
||||
if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) {
|
||||
return Promise.resolve(pauseRadioPlayback({ origin }));
|
||||
}
|
||||
if (!parts.interaction.radioPresentationAllowed())
|
||||
return Promise.resolve(false);
|
||||
if (!layerState._selectedId) {
|
||||
const ranked = parts.queries.rankedVisibleStations();
|
||||
if (!ranked.length) return Promise.resolve(false);
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
parts.selection.selectRadioStation(ranked[0].id, {
|
||||
autoplay: false,
|
||||
focus: false,
|
||||
});
|
||||
}
|
||||
return playSelectedRadio({ origin });
|
||||
}
|
||||
|
||||
/** Pause Radio without toggling a stopped or already-paused stream back on. */
|
||||
|
||||
function pauseRadioPlayback({ origin = 'programmatic' } = {}) {
|
||||
if (!['loading', 'playing', 'buffering'].includes(layerState._audioState))
|
||||
return false;
|
||||
const pausedAttemptId = layerState._activePlaybackAttempt?.id || null;
|
||||
if (layerState._tuningAwaitingStationId && !layerState._tuningActive)
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._playGeneration += 1;
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
layerState._activePlaybackAttempt = null;
|
||||
layerState._audio?.pause();
|
||||
layerState._audioState = 'paused';
|
||||
parts.presentation.emitState();
|
||||
if (origin === 'user' || origin === 'voice') {
|
||||
parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
audioEventBelongsToActiveAttempt,
|
||||
installAudio,
|
||||
tryRadioFallback,
|
||||
recordDirectoryClick,
|
||||
playSelectedRadio,
|
||||
confirmRadioPlayback,
|
||||
playPreparedRadioForVoice,
|
||||
stopRadioPlayback,
|
||||
toggleRadioPlayback,
|
||||
pauseRadioPlayback,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
export const RADIO_PREFIX = 'radio:';
|
||||
|
||||
export const DIRECTORY_ENDPOINT = '/api/radio/stations';
|
||||
|
||||
export const HORIZON_TICK_MS = 250;
|
||||
|
||||
export const HORIZON_CAMERA_MOVE_EPSILON_M = 1;
|
||||
|
||||
export const MARKER_LIFT_M = 2.5;
|
||||
|
||||
export const SELECTED_LIFT_M = 5;
|
||||
|
||||
export const RADIO_PICK_TOLERANCE_PX = 8;
|
||||
|
||||
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
|
||||
|
||||
export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT;
|
||||
|
||||
export const RADIO_TUNER_STATIC_MAX_GAIN = 0.018;
|
||||
|
||||
export const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000;
|
||||
|
||||
export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
||||
|
||||
export const RADIO_UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export const RADIO_OVERLAY_SOURCE_ID = 'radio';
|
||||
|
||||
export const RADIO_OVERLAY_COHORT_LIMIT = 64;
|
||||
|
||||
export const RADIO_SINGLETON_GLOBAL_LIMIT = 16;
|
||||
|
||||
export const RADIO_SINGLETON_MID_LIMIT = 32;
|
||||
|
||||
export const RADIO_SINGLETON_NEAR_LIMIT = 48;
|
||||
|
||||
export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000;
|
||||
|
||||
export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000;
|
||||
|
||||
export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
cohortLimit: RADIO_OVERLAY_COHORT_LIMIT,
|
||||
collisionCapacity: 96,
|
||||
moving: false,
|
||||
});
|
||||
|
||||
export const RADIO_PICK_OFFSETS = Object.freeze([
|
||||
[0, -RADIO_PICK_TOLERANCE_PX],
|
||||
[RADIO_PICK_TOLERANCE_PX, 0],
|
||||
[0, RADIO_PICK_TOLERANCE_PX],
|
||||
[-RADIO_PICK_TOLERANCE_PX, 0],
|
||||
[6, -6],
|
||||
[6, 6],
|
||||
[-6, 6],
|
||||
[-6, -6],
|
||||
]);
|
||||
|
||||
export const DEFAULT_RADIO_FILTER = 'all';
|
||||
|
||||
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
|
||||
|
||||
export const MUSIC_GENRES = Object.freeze([
|
||||
['alternative', 'Alternative'],
|
||||
['ambient', 'Ambient'],
|
||||
['blues', 'Blues'],
|
||||
['classical', 'Classical'],
|
||||
['country', 'Country'],
|
||||
['dance', 'Dance'],
|
||||
['electronic', 'Electronic'],
|
||||
['folk', 'Folk'],
|
||||
['funk', 'Funk'],
|
||||
['hip hop', 'Hip-Hop'],
|
||||
['house', 'House'],
|
||||
['indie', 'Indie'],
|
||||
['jazz', 'Jazz'],
|
||||
['latin', 'Latin'],
|
||||
['metal', 'Metal'],
|
||||
['oldies', 'Oldies'],
|
||||
['pop', 'Pop'],
|
||||
['punk', 'Punk'],
|
||||
['r&b', 'R&B'],
|
||||
['reggae', 'Reggae'],
|
||||
['rock', 'Rock'],
|
||||
['soul', 'Soul'],
|
||||
['techno', 'Techno'],
|
||||
['trance', 'Trance'],
|
||||
['world', 'World'],
|
||||
]);
|
||||
|
||||
export const CATEGORY_MATCHERS = Object.freeze({
|
||||
news: ['news', 'current affairs', 'journalism'],
|
||||
talk: ['talk', 'spoken word', 'interview', 'podcast'],
|
||||
weather: ['weather', 'emergency', 'noaa'],
|
||||
'public-safety': [
|
||||
'public safety',
|
||||
'scanner',
|
||||
'police',
|
||||
'fire',
|
||||
'ems',
|
||||
'dispatch',
|
||||
'emergency',
|
||||
],
|
||||
'aviation-marine': [
|
||||
'aviation',
|
||||
'air traffic',
|
||||
'atc',
|
||||
'airport',
|
||||
'marine',
|
||||
'maritime',
|
||||
'coast guard',
|
||||
],
|
||||
'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'],
|
||||
});
|
||||
|
||||
export const RADIO_CATEGORY_COLORS = Object.freeze({
|
||||
all: '#b9fbff',
|
||||
news: '#44adff',
|
||||
talk: '#f2b84b',
|
||||
weather: '#ff5c78',
|
||||
'public-safety': '#ff8b4a',
|
||||
'aviation-marine': '#a87cff',
|
||||
'traffic-transit': '#ffd166',
|
||||
music: '#54d17a',
|
||||
other: '#9aa7b3',
|
||||
});
|
||||
|
||||
export const RADIO_CLUSTER_LABELS = Object.freeze({
|
||||
news: 'NEWS',
|
||||
talk: 'TALK',
|
||||
weather: 'WEATHER',
|
||||
'public-safety': 'SAFETY',
|
||||
'aviation-marine': 'AIR / SEA',
|
||||
'traffic-transit': 'TRANSIT',
|
||||
music: 'MUSIC',
|
||||
other: 'OTHER',
|
||||
});
|
||||
|
||||
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
|
||||
'news',
|
||||
'public-safety',
|
||||
'weather',
|
||||
'aviation-marine',
|
||||
'traffic-transit',
|
||||
'talk',
|
||||
'music',
|
||||
]);
|
||||
|
||||
export const DEFAULT_RADIO_VOLUME = 0.8;
|
||||
|
||||
export const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({
|
||||
instance: null,
|
||||
generation: null,
|
||||
updatedAt: null,
|
||||
stations: Object.freeze([]),
|
||||
stationIds: Object.freeze([]),
|
||||
});
|
||||
|
||||
export const VOICE_RESTORE_DELAY_MS = 650;
|
||||
|
||||
export const VOICE_RESTORE_DURATION_MS = 1800;
|
||||
|
||||
export const RADIO_GLOBE_LABEL_MAX_CHARS = 30;
|
||||
|
||||
export const RADIO_LABEL_SEGMENTER =
|
||||
typeof Intl?.Segmenter === 'function'
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: null;
|
||||
@@ -0,0 +1,105 @@
|
||||
export function createPresentation({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */
|
||||
|
||||
function getRadioAcceptedCatalogSnapshot() {
|
||||
return layerState._acceptedCatalogSnapshot;
|
||||
}
|
||||
|
||||
/** Return an immutable snapshot consumed by the right-rail UI. */
|
||||
|
||||
function getRadioUIState() {
|
||||
const visible = parts.queries.visibleStations();
|
||||
const selected = parts.queries.selectedStation();
|
||||
return Object.freeze({
|
||||
enabled: layerState._enabled,
|
||||
loading: layerState._loading,
|
||||
stale: layerState._stale,
|
||||
degraded: layerState._degraded,
|
||||
error: layerState._error,
|
||||
updatedAt: layerState._updatedAt,
|
||||
filter: layerState._filter,
|
||||
categories: layerState._categories,
|
||||
acceptedCatalogGeneration: layerState._acceptedCatalogSnapshot.generation,
|
||||
presentationActive: parts.interaction.radioPresentationAllowed(),
|
||||
stationCount: layerState._stations.length,
|
||||
filteredCount: visible.length,
|
||||
selected,
|
||||
selectedIndex: selected
|
||||
? visible.findIndex((station) => station.id === selected.id)
|
||||
: -1,
|
||||
audioState: layerState._audioState,
|
||||
audioError: layerState._audioError,
|
||||
playingStationId: layerState._audioStationId,
|
||||
volume: layerState._userVolume,
|
||||
effectiveVolume:
|
||||
layerState._audio?.volume ??
|
||||
(layerState._voiceDucked ? 0 : layerState._userVolume),
|
||||
voiceDucked: layerState._voiceDucked,
|
||||
voiceRestoring: layerState._voiceRestoring,
|
||||
tuningActive: layerState._tuningActive,
|
||||
tuningStatic: layerState._tuningStatic,
|
||||
tuningAwaitingStationId: layerState._tuningAwaitingStationId,
|
||||
tuningPreviewStationId: layerState._tuningPreviewId,
|
||||
tuningRestoredStationId:
|
||||
layerState._cancelledTuningPresentationStation?.id || null,
|
||||
tuningCatalogGeneration: layerState._tuningResolutionSnapshot.generation,
|
||||
tuningUnavailableStationId: layerState._tuningUnavailableStationId,
|
||||
});
|
||||
}
|
||||
|
||||
function emitState() {
|
||||
const snapshot = getRadioUIState();
|
||||
for (const listener of layerState._listeners) {
|
||||
try {
|
||||
listener(snapshot);
|
||||
} catch {
|
||||
// A broken consumer must not break playback or rendering.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Subscribe to radio state; the current state is delivered immediately. */
|
||||
|
||||
function subscribeToRadio(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
layerState._listeners.add(listener);
|
||||
listener(getRadioUIState());
|
||||
return () => layerState._listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */
|
||||
|
||||
function subscribeToRadioPlaybackControls(listener) {
|
||||
if (typeof listener !== 'function') return () => {};
|
||||
layerState._playbackControlListeners.add(listener);
|
||||
return () => layerState._playbackControlListeners.delete(listener);
|
||||
}
|
||||
|
||||
function emitPlaybackControl(
|
||||
action,
|
||||
origin,
|
||||
attemptId = layerState._activePlaybackAttempt?.id || null,
|
||||
) {
|
||||
const event = { action, origin, attemptId };
|
||||
for (const listener of layerState._playbackControlListeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// Playback controls must remain usable if an observer fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
getRadioAcceptedCatalogSnapshot,
|
||||
getRadioUIState,
|
||||
emitState,
|
||||
subscribeToRadio,
|
||||
subscribeToRadioPlaybackControls,
|
||||
emitPlaybackControl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||
|
||||
export function createQueries({ state: layerState, services, parts, source }) {
|
||||
function radioAngularDistance(station, anchor) {
|
||||
const lat1 = Cesium.Math.toRadians(Number(anchor?.lat));
|
||||
const lon1 = Cesium.Math.toRadians(Number(anchor?.lon));
|
||||
const lat2 = Cesium.Math.toRadians(Number(station?.lat));
|
||||
const lon2 = Cesium.Math.toRadians(Number(station?.lon));
|
||||
if (![lat1, lon1, lat2, lon2].every(Number.isFinite))
|
||||
return Number.POSITIVE_INFINITY;
|
||||
const deltaLat = lat2 - lat1;
|
||||
const deltaLon = lon2 - lon1;
|
||||
const haversine =
|
||||
Math.sin(deltaLat / 2) ** 2 +
|
||||
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
||||
return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine))));
|
||||
}
|
||||
|
||||
/** Rank a copied station list by viewport distance, with an optional English-first tier. */
|
||||
|
||||
function rankRadioStationsForViewport(
|
||||
stations,
|
||||
anchor,
|
||||
{ preferEnglish = false } = {},
|
||||
) {
|
||||
return (Array.isArray(stations) ? stations : [])
|
||||
.map((station, index) => ({
|
||||
station,
|
||||
index,
|
||||
distance: radioAngularDistance(station, anchor),
|
||||
languageTier:
|
||||
preferEnglish && !parts.categories.isEnglishRadioStation(station)
|
||||
? 1
|
||||
: 0,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.languageTier - b.languageTier ||
|
||||
a.distance - b.distance ||
|
||||
a.index - b.index,
|
||||
)
|
||||
.map(({ station }) => station);
|
||||
}
|
||||
|
||||
/** Rank stations for an explicit voice/player request without moving the camera. */
|
||||
|
||||
function rankRadioStationsForRequest(
|
||||
stations,
|
||||
{ categoryId = 'all', anchor = null, country = '', stationQuery = '' } = {},
|
||||
) {
|
||||
const countryFilter = normalizeRadioCountryInput(country);
|
||||
if (!countryFilter.valid) return [];
|
||||
const query = parts.categories.normalizeRadioTag(stationQuery);
|
||||
let matches = parts.categories.filterRadioStations(stations, categoryId);
|
||||
if (countryFilter.code || countryFilter.name) {
|
||||
matches = matches.filter((station) => {
|
||||
const stationCode = String(station?.countryCode || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const stationCountry = normalizeRadioCountryInput(station?.country);
|
||||
return (
|
||||
(countryFilter.code && stationCode === countryFilter.code) ||
|
||||
(countryFilter.code &&
|
||||
stationCountry.valid &&
|
||||
stationCountry.code === countryFilter.code)
|
||||
);
|
||||
});
|
||||
}
|
||||
if (query) {
|
||||
matches = matches.filter((station) =>
|
||||
[
|
||||
station?.id,
|
||||
station?.name,
|
||||
station?.state,
|
||||
station?.country,
|
||||
station?.countryCode,
|
||||
...(Array.isArray(station?.tags) ? station.tags : []),
|
||||
].some((value) =>
|
||||
parts.categories.normalizeRadioTag(value).includes(query),
|
||||
),
|
||||
);
|
||||
}
|
||||
return anchor
|
||||
? rankRadioStationsForViewport(matches, anchor)
|
||||
: matches.slice();
|
||||
}
|
||||
|
||||
function selectedStation() {
|
||||
return layerState._selectedId
|
||||
? layerState._stationById.get(layerState._selectedId) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
function selectedPresentationStation() {
|
||||
if (layerState._tuningActive)
|
||||
return parts.model.tuningResolutionStation(layerState._tuningPreviewId);
|
||||
return layerState._cancelledTuningPresentationStation || selectedStation();
|
||||
}
|
||||
|
||||
function visibleStations() {
|
||||
return parts.categories.filterRadioStations(
|
||||
layerState._stations,
|
||||
layerState._filter,
|
||||
);
|
||||
}
|
||||
|
||||
function viewportRadioAnchor() {
|
||||
const camera = layerState._viewer?.camera;
|
||||
const scene = layerState._viewer?.scene;
|
||||
if (!camera) return null;
|
||||
const altitudeM = Number(camera.positionCartographic?.height);
|
||||
let cartographic = null;
|
||||
const canvas = scene?.canvas;
|
||||
if (canvas && typeof camera.pickEllipsoid === 'function') {
|
||||
const center = new Cesium.Cartesian2(
|
||||
canvas.clientWidth / 2,
|
||||
canvas.clientHeight / 2,
|
||||
);
|
||||
const position = camera.pickEllipsoid(
|
||||
center,
|
||||
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
|
||||
);
|
||||
if (position) cartographic = Cesium.Cartographic.fromCartesian(position);
|
||||
}
|
||||
cartographic ||= camera.positionCartographic || null;
|
||||
if (!cartographic) return null;
|
||||
return {
|
||||
lat: Cesium.Math.toDegrees(cartographic.latitude),
|
||||
lon: Cesium.Math.toDegrees(cartographic.longitude),
|
||||
altitudeM,
|
||||
globalView: parts.navigation.radioViewIsGlobal(altitudeM),
|
||||
};
|
||||
}
|
||||
|
||||
function rankedVisibleStations() {
|
||||
const anchor = viewportRadioAnchor();
|
||||
return rankRadioStationsForViewport(visibleStations(), anchor, {
|
||||
preferEnglish: Boolean(anchor?.globalView),
|
||||
});
|
||||
}
|
||||
return {
|
||||
radioAngularDistance,
|
||||
rankRadioStationsForViewport,
|
||||
rankRadioStationsForRequest,
|
||||
selectedStation,
|
||||
selectedPresentationStation,
|
||||
visibleStations,
|
||||
viewportRadioAnchor,
|
||||
rankedVisibleStations,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import {
|
||||
MARKER_LIFT_M,
|
||||
RADIO_PREFIX,
|
||||
SELECTED_LIFT_M,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
RADIO_OVERLAY_COHORT_LIMIT,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
} from './policy.js';
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { cachedGroundFloor } = services.ground;
|
||||
const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } =
|
||||
services.overlays;
|
||||
const { governorRequestRender } = services.render;
|
||||
|
||||
function markerPosition(station, liftM = MARKER_LIFT_M) {
|
||||
const floor = cachedGroundFloor(station.lat, station.lon);
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
station.lon,
|
||||
station.lat,
|
||||
(Number.isFinite(floor) ? floor : 0) + liftM,
|
||||
);
|
||||
}
|
||||
|
||||
function updateSelectionEntity() {
|
||||
if (layerState._selectedEntity && layerState._viewer)
|
||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
||||
layerState._selectedEntity = null;
|
||||
const station = parts.queries.selectedPresentationStation();
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._viewer ||
|
||||
!station
|
||||
) {
|
||||
scheduleRadioOverlayPublish();
|
||||
return;
|
||||
}
|
||||
const selectionColor = parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
);
|
||||
const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`;
|
||||
|
||||
layerState._selectedEntity = layerState._viewer.entities.add({
|
||||
id: `${RADIO_PREFIX}selected:${station.id}`,
|
||||
position: markerPosition(station, SELECTED_LIFT_M),
|
||||
point: {
|
||||
pixelSize: 14,
|
||||
color: Cesium.Color.fromCssColorString(selectionColor),
|
||||
outlineColor: Cesium.Color.BLACK,
|
||||
outlineWidth: 2,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
},
|
||||
billboard: {
|
||||
image: bracketImage,
|
||||
width: 40,
|
||||
height: 40,
|
||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
|
||||
},
|
||||
});
|
||||
scheduleRadioOverlayPublish();
|
||||
}
|
||||
|
||||
function clusterPointCollection() {
|
||||
return layerState._dataSource?.clustering?._clusterPointCollection || null;
|
||||
}
|
||||
|
||||
function publishRadioOverlayEntries() {
|
||||
layerState._overlayPublishTimer = null;
|
||||
if (!parts.interaction.radioPresentationAllowed()) {
|
||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
layerState._overlayDiagnostics =
|
||||
parts.clustering.emptyRadioOverlayDiagnostics();
|
||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = [];
|
||||
const station = parts.queries.selectedPresentationStation();
|
||||
if (station) {
|
||||
const selectedEntry = parts.labels.createRadioSelectedOverlayEntry(
|
||||
station,
|
||||
markerPosition(station, SELECTED_LIFT_M),
|
||||
);
|
||||
if (selectedEntry) entries.push(selectedEntry);
|
||||
}
|
||||
|
||||
const clusterCandidates = [];
|
||||
const clusteredStationIds = new Set();
|
||||
const points = clusterPointCollection();
|
||||
for (let index = 0; index < (points?.length || 0); index += 1) {
|
||||
const point = points.get(index);
|
||||
if (
|
||||
!point?.show ||
|
||||
!point.position ||
|
||||
!Array.isArray(point.id) ||
|
||||
point.id.length < 3
|
||||
)
|
||||
continue;
|
||||
const stationIds = point.id
|
||||
.map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length))
|
||||
.filter((id) => {
|
||||
const station = layerState._stationById.get(id);
|
||||
return (
|
||||
station &&
|
||||
parts.categories.stationMatchesRadioCategory(
|
||||
station,
|
||||
layerState._filter,
|
||||
)
|
||||
);
|
||||
})
|
||||
.sort();
|
||||
if (stationIds.length < 3) continue;
|
||||
for (const stationId of stationIds) clusteredStationIds.add(stationId);
|
||||
const clusteredStations = stationIds.map((id) =>
|
||||
layerState._stationById.get(id),
|
||||
);
|
||||
const category = parts.model.radioClusterCategoryId(
|
||||
clusteredStations,
|
||||
layerState._filter,
|
||||
);
|
||||
clusterCandidates.push({
|
||||
id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`,
|
||||
stationIds,
|
||||
point,
|
||||
text: parts.model.radioClusterBadgeText(category, stationIds.length),
|
||||
accent: parts.model.radioCategoryColor(category),
|
||||
stationCount: stationIds.length,
|
||||
});
|
||||
}
|
||||
const selectedClusterCandidates =
|
||||
parts.clustering.selectRadioClusterCandidates(clusterCandidates);
|
||||
layerState._clusterOverlayIdentities =
|
||||
parts.clustering.reconcileRadioClusterCandidates(
|
||||
selectedClusterCandidates,
|
||||
layerState._clusterOverlayIdentities,
|
||||
() => `stable:${++layerState._clusterOverlayIdentitySequence}`,
|
||||
);
|
||||
for (const candidate of layerState._clusterOverlayIdentities) {
|
||||
const clusterEntry = parts.labels.createRadioClusterOverlayEntry({
|
||||
...candidate,
|
||||
position: () => candidate.point.position,
|
||||
});
|
||||
if (clusterEntry) entries.push(clusterEntry);
|
||||
}
|
||||
|
||||
const cameraPosition = layerState._viewer?.camera?.positionWC;
|
||||
const singletonAllowance = Math.min(
|
||||
parts.clustering.radioSingletonLabelLimit(
|
||||
layerState._viewer?.camera?.positionCartographic?.height,
|
||||
),
|
||||
Math.max(
|
||||
0,
|
||||
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
|
||||
),
|
||||
);
|
||||
const singletonCandidates = parts.clustering.selectRadioSingletonCandidates(
|
||||
[...layerState._renderById.values()]
|
||||
.filter(
|
||||
(record) =>
|
||||
record.entity?.show &&
|
||||
record.station?.id !== station?.id &&
|
||||
!clusteredStationIds.has(record.station?.id),
|
||||
)
|
||||
.map((record) => ({
|
||||
...record,
|
||||
distanceM: cameraPosition
|
||||
? Cesium.Cartesian3.distance(cameraPosition, record.position)
|
||||
: Number.POSITIVE_INFINITY,
|
||||
})),
|
||||
singletonAllowance,
|
||||
);
|
||||
for (let index = 0; index < singletonCandidates.length; index += 1) {
|
||||
const candidate = singletonCandidates[index];
|
||||
const singletonEntry = parts.labels.createRadioSingletonOverlayEntry({
|
||||
station: candidate.station,
|
||||
position: candidate.position,
|
||||
// Cluster priorities begin at three. Keep clusters authoritative while
|
||||
// retaining nearest-first singleton ordering inside the ambient lane.
|
||||
priority: 2 - index / Math.max(1, singletonCandidates.length + 1),
|
||||
});
|
||||
if (singletonEntry) entries.push(singletonEntry);
|
||||
}
|
||||
|
||||
setOverlayEntries(
|
||||
RADIO_OVERLAY_SOURCE_ID,
|
||||
entries,
|
||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||
);
|
||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true);
|
||||
layerState._overlayDiagnostics = {
|
||||
entryCount: entries.length,
|
||||
selectedCount: entries.filter((entry) => entry.selected).length,
|
||||
singletonTexts: entries
|
||||
.filter((entry) => entry.id.startsWith('station:'))
|
||||
.map((entry) => entry.title),
|
||||
singletonIds: entries
|
||||
.filter((entry) => entry.id.startsWith('station:'))
|
||||
.map((entry) => entry.id),
|
||||
clusterTexts: entries
|
||||
.filter((entry) => entry.id.startsWith('cluster:'))
|
||||
.map((entry) => entry.title),
|
||||
clusterIds: entries
|
||||
.filter((entry) => entry.id.startsWith('cluster:'))
|
||||
.map((entry) => entry.id),
|
||||
clusterMemberships: layerState._clusterOverlayIdentities.map(
|
||||
(candidate) => ({
|
||||
membershipId: candidate.membershipId,
|
||||
entryId: `cluster:${candidate.id}`,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleRadioOverlayPublish() {
|
||||
if (layerState._overlayPublishTimer)
|
||||
clearTimeout(layerState._overlayPublishTimer);
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
layerState._overlayPublishTimer = setTimeout(() => {
|
||||
if (sessionGeneration === layerState._sessionGeneration)
|
||||
publishRadioOverlayEntries();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function updateRenderVisibility({ force = true } = {}) {
|
||||
if (!layerState._viewer || !layerState._dataSource) return;
|
||||
const cameraPosition = layerState._viewer.camera?.positionWC;
|
||||
if (
|
||||
!force &&
|
||||
!parts.navigation.radioCameraPositionChanged(
|
||||
layerState._lastHorizonCameraPosition,
|
||||
cameraPosition,
|
||||
)
|
||||
)
|
||||
return;
|
||||
layerState._lastHorizonCameraPosition = cameraPosition
|
||||
? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
|
||||
: null;
|
||||
layerState._horizonScanCount += 1;
|
||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
||||
let visibilityChanged = false;
|
||||
for (const [id, record] of layerState._renderById) {
|
||||
const matches = parts.categories.stationMatchesRadioCategory(
|
||||
record.station,
|
||||
layerState._filter,
|
||||
);
|
||||
const visible = matches && occluder.isPointVisible(record.position);
|
||||
if (record.entity.show !== visible) visibilityChanged = true;
|
||||
record.entity.show = visible;
|
||||
}
|
||||
if (layerState._selectedEntity) {
|
||||
const position = layerState._selectedEntity.position?.getValue?.(
|
||||
Cesium.JulianDate.now(),
|
||||
);
|
||||
const selectedVisible = !position || occluder.isPointVisible(position);
|
||||
if (layerState._selectedEntity.show !== selectedVisible)
|
||||
visibilityChanged = true;
|
||||
layerState._selectedEntity.show = selectedVisible;
|
||||
}
|
||||
scheduleRadioOverlayPublish();
|
||||
// The horizon timer can commit AFTER the camera settles and the governor
|
||||
// parks the scene — a changed show flag needs one frame. (perf wave 2 fix)
|
||||
if (visibilityChanged) governorRequestRender('radio-horizon');
|
||||
}
|
||||
|
||||
function reconcileStations(stations) {
|
||||
if (!layerState._tuningActive)
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._clusterOverlayIdentities =
|
||||
parts.clustering.retainRadioClusterIdentitiesForStations(
|
||||
layerState._clusterOverlayIdentities,
|
||||
stations,
|
||||
);
|
||||
layerState._stations = Object.freeze([...stations]);
|
||||
layerState._stationById = new Map(
|
||||
stations.map((station) => [station.id, station]),
|
||||
);
|
||||
layerState._categories = Object.freeze(
|
||||
parts.categories
|
||||
.buildRadioCategories(stations)
|
||||
.map((category) => Object.freeze(category)),
|
||||
);
|
||||
if (
|
||||
layerState._filter === DEFAULT_RADIO_FILTER &&
|
||||
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
|
||||
.length
|
||||
) {
|
||||
layerState._filter = 'all';
|
||||
}
|
||||
if (
|
||||
layerState._selectedId &&
|
||||
!layerState._stationById.has(layerState._selectedId)
|
||||
) {
|
||||
if (layerState._audioStationId === layerState._selectedId)
|
||||
parts.playback.stopRadioPlayback();
|
||||
layerState._selectedId = null;
|
||||
}
|
||||
|
||||
if (layerState._dataSource) layerState._dataSource.entities.removeAll();
|
||||
layerState._renderById.clear();
|
||||
for (const station of stations) {
|
||||
const position = markerPosition(station);
|
||||
const markerColor = parts.model.radioCategoryColor(
|
||||
parts.model.radioStationCategoryId(station),
|
||||
);
|
||||
const entity = layerState._dataSource.entities.add({
|
||||
id: `${RADIO_PREFIX}${station.id}`,
|
||||
position,
|
||||
point: {
|
||||
pixelSize: 13,
|
||||
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
|
||||
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
scaleByDistance: new Cesium.NearFarScalar(
|
||||
100_000,
|
||||
1.15,
|
||||
12_000_000,
|
||||
1,
|
||||
),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
),
|
||||
},
|
||||
});
|
||||
layerState._renderById.set(station.id, { station, entity, position });
|
||||
}
|
||||
updateSelectionEntity();
|
||||
updateRenderVisibility();
|
||||
}
|
||||
|
||||
function installClusterStyling() {
|
||||
if (!layerState._dataSource || layerState._removeClusterListener) return;
|
||||
const clustering = layerState._dataSource.clustering;
|
||||
clustering.enabled = true;
|
||||
clustering.pixelRange = 42;
|
||||
clustering.minimumClusterSize = 3;
|
||||
clustering.clusterPoints = true;
|
||||
clustering.clusterLabels = false;
|
||||
clustering.clusterBillboards = false;
|
||||
layerState._removeClusterListener =
|
||||
clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => {
|
||||
const clusteredStations = [];
|
||||
for (const entity of clusteredEntities) {
|
||||
const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
|
||||
const station = layerState._renderById.get(stationId)?.station;
|
||||
if (station) clusteredStations.push(station);
|
||||
}
|
||||
const clusterCategory = parts.model.radioClusterCategoryId(
|
||||
clusteredStations,
|
||||
layerState._filter,
|
||||
);
|
||||
const clusterColor = Cesium.Color.fromCssColorString(
|
||||
parts.model.radioCategoryColor(clusterCategory),
|
||||
);
|
||||
// Cesium assigns the entity-id array only to the generated cluster label.
|
||||
// Mirror it to the visible point so clicking either part of the callout
|
||||
// resolves the first station and remains a direct playback gesture.
|
||||
cluster.point.id = clusteredEntities;
|
||||
cluster.billboard.id = clusteredEntities;
|
||||
cluster.label.show = false;
|
||||
cluster.label.text = '';
|
||||
cluster.point.show = true;
|
||||
cluster.point.pixelSize = Math.min(
|
||||
26,
|
||||
12 + Math.log2(clusteredEntities.length) * 1.6,
|
||||
);
|
||||
cluster.point.color = clusterColor.withAlpha(0.9);
|
||||
cluster.point.outlineColor = Cesium.Color.BLACK;
|
||||
cluster.point.outlineWidth = 2;
|
||||
cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY;
|
||||
cluster.point.distanceDisplayCondition =
|
||||
new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
||||
);
|
||||
scheduleRadioOverlayPublish();
|
||||
});
|
||||
}
|
||||
return {
|
||||
markerPosition,
|
||||
updateSelectionEntity,
|
||||
clusterPointCollection,
|
||||
publishRadioOverlayEntries,
|
||||
scheduleRadioOverlayPublish,
|
||||
updateRenderVisibility,
|
||||
reconcileStations,
|
||||
installClusterStyling,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { normalizeRadioFilter } from '../../data/layerState.js';
|
||||
import { RADIO_TUNER_DIRECTORY_LIMIT } from './policy.js';
|
||||
|
||||
export function createSelection({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
const { warmGroundFloor } = services.ground;
|
||||
|
||||
/** Durable Radio preferences; this surface never creates or plays audio. */
|
||||
|
||||
function setRadioParams(params = {}) {
|
||||
const nextFilter = Object.hasOwn(params, 'filter')
|
||||
? normalizeRadioFilter(params.filter)
|
||||
: null;
|
||||
if (Object.hasOwn(params, 'filter') && !nextFilter) return false;
|
||||
const numericVolume = Object.hasOwn(params, 'volume')
|
||||
? Number(params.volume)
|
||||
: null;
|
||||
if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume))
|
||||
return false;
|
||||
const nextVolume =
|
||||
numericVolume === null
|
||||
? null
|
||||
: parts.volume.clampRadioVolume(numericVolume);
|
||||
|
||||
let changed = false;
|
||||
let filterChanged = false;
|
||||
let clearedCancelledPresentation = false;
|
||||
if (nextFilter !== null) {
|
||||
filterChanged = nextFilter !== layerState._filter;
|
||||
if (
|
||||
filterChanged &&
|
||||
(layerState._tuningActive || layerState._tuningAwaitingStationId)
|
||||
)
|
||||
parts.tuning.endRadioTuning();
|
||||
clearedCancelledPresentation = Boolean(
|
||||
layerState._cancelledTuningPresentationStation,
|
||||
);
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._filter = nextFilter;
|
||||
if (clearedCancelledPresentation) parts.rendering.updateSelectionEntity();
|
||||
if (filterChanged) parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
changed ||= filterChanged || clearedCancelledPresentation;
|
||||
}
|
||||
if (nextVolume !== null && nextVolume !== layerState._userVolume) {
|
||||
layerState._userVolume = nextVolume;
|
||||
if (layerState._audio && !layerState._voiceDucked) {
|
||||
parts.volume.cancelRadioVolumeTransition();
|
||||
layerState._voiceRestoring = false;
|
||||
layerState._audio.volume = nextVolume;
|
||||
}
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
changed = true;
|
||||
}
|
||||
if (changed && parts.interaction.radioPresentationAllowed()) {
|
||||
parts.rendering.updateRenderVisibility();
|
||||
if (filterChanged && layerState._dataSource?.clustering?.enabled) {
|
||||
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
||||
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
||||
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
||||
layerState._viewer?.scene?.requestRender();
|
||||
}
|
||||
parts.rendering.scheduleRadioOverlayPublish();
|
||||
}
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
function getRadioParams() {
|
||||
return { filter: layerState._filter, volume: layerState._userVolume };
|
||||
}
|
||||
|
||||
/** Select a station. Playback occurs only when autoplay is explicitly true. */
|
||||
|
||||
function selectRadioStation(
|
||||
id,
|
||||
{
|
||||
autoplay = false,
|
||||
focus = false,
|
||||
origin = 'programmatic',
|
||||
attemptId = null,
|
||||
cameraNavigation = null,
|
||||
} = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const station = layerState._stationById.get(String(id));
|
||||
if (!station) return false;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
if (
|
||||
!parts.navigation.radioCameraNavigationOwnsSelection(
|
||||
cameraNavigation,
|
||||
station,
|
||||
)
|
||||
) {
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
}
|
||||
if (
|
||||
layerState._tuningActive ||
|
||||
(layerState._tuningAwaitingStationId &&
|
||||
layerState._tuningAwaitingStationId !== station.id)
|
||||
)
|
||||
parts.tuning.endRadioTuning();
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._selectedId = station.id;
|
||||
const generation = ++layerState._selectionGeneration;
|
||||
const sessionGeneration = layerState._sessionGeneration;
|
||||
parts.rendering.updateSelectionEntity();
|
||||
warmGroundFloor([{ lat: station.lat, lon: station.lon }]);
|
||||
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
|
||||
layerState._selectionTimer = setTimeout(() => {
|
||||
layerState._selectionTimer = null;
|
||||
if (
|
||||
sessionGeneration === layerState._sessionGeneration &&
|
||||
generation === layerState._selectionGeneration &&
|
||||
layerState._selectedId === station.id
|
||||
)
|
||||
parts.rendering.updateSelectionEntity();
|
||||
}, 1300);
|
||||
if (focus) parts.navigation.focusStation(station);
|
||||
parts.presentation.emitState();
|
||||
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Select the previous or next station and optionally retain a UI-owned band order. */
|
||||
|
||||
function cycleRadioStation(
|
||||
direction = 1,
|
||||
{
|
||||
rotate = false,
|
||||
stationIds = null,
|
||||
autoplay = true,
|
||||
origin = 'programmatic',
|
||||
} = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const ranked =
|
||||
Array.isArray(stationIds) && stationIds.length
|
||||
? stationIds
|
||||
.slice(0, RADIO_TUNER_DIRECTORY_LIMIT)
|
||||
.map((id) => layerState._stationById.get(String(id)))
|
||||
.filter(
|
||||
(station) =>
|
||||
station &&
|
||||
parts.categories.stationMatchesRadioCategory(
|
||||
station,
|
||||
layerState._filter,
|
||||
),
|
||||
)
|
||||
: parts.queries.rankedVisibleStations();
|
||||
if (!ranked.length) return false;
|
||||
const current = ranked.findIndex(
|
||||
(station) => station.id === layerState._selectedId,
|
||||
);
|
||||
const nextIndex =
|
||||
current < 0
|
||||
? 0
|
||||
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
|
||||
layerState._playFallbackId =
|
||||
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
|
||||
const rotationCameraState = rotate
|
||||
? parts.navigation.radioCameraState()
|
||||
: null;
|
||||
const rotationNavigation = rotate
|
||||
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
|
||||
: null;
|
||||
layerState._playFallbackFocus = rotate
|
||||
? (fallbackStation) => {
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
fallbackStation,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return rotationNavigation;
|
||||
}
|
||||
: false;
|
||||
const station = ranked[nextIndex];
|
||||
if (rotate)
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
station,
|
||||
0.65,
|
||||
rotationCameraState,
|
||||
rotationNavigation,
|
||||
);
|
||||
return selectRadioStation(station.id, {
|
||||
autoplay,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation: rotationNavigation,
|
||||
});
|
||||
}
|
||||
|
||||
/** Select and optionally play the best station for a location/category request. */
|
||||
|
||||
function selectRequestedRadioStation(
|
||||
criteria = {},
|
||||
{ autoplay = true, origin = 'programmatic' } = {},
|
||||
) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return null;
|
||||
const requestedCategory = String(criteria.categoryId || 'all');
|
||||
const categoryId = layerState._categories.some(
|
||||
(category) => category.id === requestedCategory,
|
||||
)
|
||||
? requestedCategory
|
||||
: 'all';
|
||||
setRadioFilter(categoryId);
|
||||
const ranked = parts.queries.rankRadioStationsForRequest(
|
||||
layerState._stations,
|
||||
{
|
||||
...criteria,
|
||||
categoryId,
|
||||
anchor: criteria.anchor || parts.queries.viewportRadioAnchor(),
|
||||
},
|
||||
);
|
||||
if (!ranked.length) return null;
|
||||
layerState._playFallbackId = ranked[1]?.id || null;
|
||||
layerState._playFallbackFocus = false;
|
||||
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
|
||||
return ranked[0];
|
||||
}
|
||||
|
||||
/** Change marker/list category without interrupting an active station. */
|
||||
|
||||
function setRadioFilter(categoryId) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const valid = layerState._categories.some(
|
||||
(category) => category.id === categoryId,
|
||||
);
|
||||
const nextFilter = valid ? categoryId : 'all';
|
||||
const changed = nextFilter !== layerState._filter;
|
||||
if (
|
||||
changed &&
|
||||
(layerState._tuningActive || layerState._tuningAwaitingStationId)
|
||||
)
|
||||
parts.tuning.endRadioTuning();
|
||||
const clearsCancelledPresentation = Boolean(
|
||||
layerState._cancelledTuningPresentationStation,
|
||||
);
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._filter = nextFilter;
|
||||
if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity();
|
||||
if (changed) parts.clustering.resetRadioClusterOverlayIdentities();
|
||||
parts.rendering.updateRenderVisibility();
|
||||
if (changed && layerState._dataSource?.clustering?.enabled) {
|
||||
// Entity visibility changes do not invalidate Cesium's existing cluster
|
||||
// primitives. Toggle a public clustering input twice to mark the current
|
||||
// cluster set dirty without changing its effective configuration.
|
||||
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
||||
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
||||
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
||||
layerState._viewer?.scene?.requestRender();
|
||||
}
|
||||
parts.rendering.scheduleRadioOverlayPublish();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
return {
|
||||
setRadioParams,
|
||||
getRadioParams,
|
||||
selectRadioStation,
|
||||
cycleRadioStation,
|
||||
selectRequestedRadioStation,
|
||||
setRadioFilter,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { DIRECTORY_ENDPOINT, RADIO_UUID_RE } from './policy.js';
|
||||
|
||||
/** Supply directory metadata and click reporting; audio stays with the broadcaster. */
|
||||
export function createRadioSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async getDirectory({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(DIRECTORY_ENDPOINT, { signal });
|
||||
if (!response.ok)
|
||||
throw new Error(`Radio directory returned ${response.status}`);
|
||||
const body = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
return body;
|
||||
},
|
||||
async recordClick(id, { signal } = {}) {
|
||||
if (typeof id !== 'string' || !RADIO_UUID_RE.test(id))
|
||||
throw new Error('Invalid radio station id');
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
`/api/radio/click/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
signal,
|
||||
},
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!response.ok)
|
||||
throw new Error(`Radio click returned ${response.status}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRadioSource, createRadioLayer } from './index.js';
|
||||
|
||||
const id = '00000000-0000-4000-8000-000000000001';
|
||||
|
||||
test('radio source confines directory and click requests to their existing routes', async () => {
|
||||
const calls = [];
|
||||
const body = { stations: [], stale: false };
|
||||
const source = createRadioSource({
|
||||
fetchImpl: async (path, options) => {
|
||||
calls.push({ path, options });
|
||||
return new Response(JSON.stringify(body));
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
assert.deepEqual(
|
||||
await source.getDirectory({ signal: controller.signal }),
|
||||
body,
|
||||
);
|
||||
await source.recordClick(id, { signal: controller.signal });
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.path),
|
||||
['/api/radio/stations', `/api/radio/click/${id}`],
|
||||
);
|
||||
assert.equal(calls[1].options.method, 'POST');
|
||||
assert.ok(calls.every((call) => call.options.signal === controller.signal));
|
||||
for (const invalid of [
|
||||
'../stations',
|
||||
'https://example.com',
|
||||
'',
|
||||
null,
|
||||
`${id}?x=1`,
|
||||
])
|
||||
await assert.rejects(
|
||||
source.recordClick(invalid),
|
||||
/Invalid radio station id/,
|
||||
);
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('radio source propagates denial and cancels completed body parsing', async () => {
|
||||
const denied = createRadioSource({
|
||||
fetchImpl: async () => new Response('', { status: 403 }),
|
||||
});
|
||||
await assert.rejects(denied.getDirectory(), /403/);
|
||||
await assert.rejects(denied.recordClick(id), /403/);
|
||||
const controller = new AbortController();
|
||||
const source = createRadioSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: async () => {
|
||||
controller.abort();
|
||||
return { stations: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.getDirectory({ signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
let called = false;
|
||||
const idle = createRadioSource({
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
},
|
||||
});
|
||||
await assert.rejects(idle.recordClick(id, { signal: controller.signal }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
test('radio factories keep settings and subscriptions independent without fetching or audio startup', () => {
|
||||
let requested = false;
|
||||
const source = {
|
||||
getDirectory() {
|
||||
requested = true;
|
||||
},
|
||||
recordClick() {
|
||||
requested = true;
|
||||
},
|
||||
};
|
||||
const services = {
|
||||
ground: {},
|
||||
picking: { unregisterPickOwner() {} },
|
||||
globe: {},
|
||||
render: { governorRequestRender() {} },
|
||||
overlays: {
|
||||
clearOverlaySource() {},
|
||||
setOverlaySourceVisible() {},
|
||||
setOverlayEntries() {},
|
||||
},
|
||||
};
|
||||
const first = createRadioLayer({ source, services });
|
||||
const second = createRadioLayer({ source, services });
|
||||
let changes = 0;
|
||||
const off = second.subscribeToRadio(() => changes++);
|
||||
const initialChanges = changes;
|
||||
first.setRadioParams({ volume: 0.3 });
|
||||
assert.equal(first.getRadioParams().volume, 0.3);
|
||||
assert.equal(second.getRadioParams().volume, 0.8);
|
||||
assert.equal(changes, initialChanges);
|
||||
assert.equal(requested, false);
|
||||
off();
|
||||
first.destroy();
|
||||
assert.equal(second.getRadioParams().volume, 0.8);
|
||||
second.destroy();
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
DEFAULT_RADIO_FILTER,
|
||||
DEFAULT_RADIO_VOLUME,
|
||||
} from './policy.js';
|
||||
|
||||
export function createState({ services }) {
|
||||
const layerState = {};
|
||||
|
||||
layerState._radioEarthScreenCenter = new Cesium.Cartesian2();
|
||||
|
||||
layerState._radioEarthToCenter = new Cesium.Cartesian3();
|
||||
|
||||
layerState._viewer = null;
|
||||
|
||||
layerState._dataSource = null;
|
||||
|
||||
layerState._enabled = false;
|
||||
|
||||
layerState._managerLifecyclePresentation = null;
|
||||
|
||||
layerState._loading = false;
|
||||
|
||||
layerState._stale = false;
|
||||
|
||||
layerState._degraded = false;
|
||||
|
||||
layerState._error = null;
|
||||
|
||||
layerState._updatedAt = null;
|
||||
|
||||
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._stations = [];
|
||||
|
||||
layerState._stationById = new Map();
|
||||
|
||||
layerState._categories = [];
|
||||
|
||||
layerState._renderById = new Map();
|
||||
|
||||
layerState._filter = DEFAULT_RADIO_FILTER;
|
||||
|
||||
layerState._selectedId = null;
|
||||
|
||||
layerState._selectedEntity = null;
|
||||
|
||||
layerState._selectionGeneration = 0;
|
||||
|
||||
layerState._selectionTimer = null;
|
||||
|
||||
layerState._radioCameraNavigationGeneration = 0;
|
||||
|
||||
layerState._radioCameraFlightSequence = 0;
|
||||
|
||||
layerState._activeRadioCameraFlight = null;
|
||||
|
||||
layerState._audio = null;
|
||||
|
||||
layerState._audioStationId = null;
|
||||
|
||||
layerState._audioState = 'stopped';
|
||||
|
||||
layerState._audioError = null;
|
||||
|
||||
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
||||
|
||||
layerState._tuningActive = false;
|
||||
|
||||
layerState._tuningStatic = false;
|
||||
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
|
||||
layerState._tuningPreviewId = null;
|
||||
|
||||
layerState._tuningStartStationId = null;
|
||||
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
|
||||
layerState._tuningStationById = new Map();
|
||||
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
|
||||
layerState._tuningCameraNavigation = null;
|
||||
|
||||
layerState._tuningNoiseContext = null;
|
||||
|
||||
layerState._tuningNoiseSource = null;
|
||||
|
||||
layerState._tuningNoiseFilter = null;
|
||||
|
||||
layerState._tuningNoiseGain = null;
|
||||
|
||||
layerState._voiceDucked = false;
|
||||
|
||||
layerState._voiceRestoring = false;
|
||||
|
||||
layerState._voiceRestoreTimer = null;
|
||||
|
||||
layerState._volumeFadeFrame = null;
|
||||
|
||||
layerState._volumeTransitionGeneration = 0;
|
||||
|
||||
layerState._playGeneration = 0;
|
||||
|
||||
layerState._playAttemptSequence = 0;
|
||||
|
||||
layerState._activePlaybackAttempt = null;
|
||||
|
||||
layerState._playFallbackId = null;
|
||||
|
||||
layerState._playFallbackFocus = null;
|
||||
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
|
||||
layerState._playFallbackAttemptId = null;
|
||||
|
||||
layerState._clickHandler = null;
|
||||
|
||||
layerState._horizonTimer = null;
|
||||
|
||||
layerState._lastHorizonCameraPosition = null;
|
||||
|
||||
layerState._horizonScanCount = 0;
|
||||
|
||||
layerState._abortController = null;
|
||||
|
||||
layerState._requestGeneration = 0;
|
||||
|
||||
layerState._sessionGeneration = 0;
|
||||
|
||||
layerState._removeClusterListener = null;
|
||||
|
||||
layerState._overlayPublishTimer = null;
|
||||
|
||||
layerState._clusterOverlayIdentitySequence = 0;
|
||||
|
||||
layerState._clusterOverlayIdentities = [];
|
||||
|
||||
layerState._overlayDiagnostics = {
|
||||
entryCount: 0,
|
||||
selectedCount: 0,
|
||||
singletonTexts: [],
|
||||
singletonIds: [],
|
||||
clusterTexts: [],
|
||||
clusterIds: [],
|
||||
clusterMemberships: [],
|
||||
};
|
||||
|
||||
layerState._listeners = new Set();
|
||||
|
||||
layerState._playbackControlListeners = new Set();
|
||||
return layerState;
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
RADIO_TUNER_STATION_LIMIT,
|
||||
RADIO_TUNER_DIRECTORY_LIMIT,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTuning({ state: layerState, services, parts, source }) {
|
||||
/** Bound one already ordered filtered directory without injecting outside selections. */
|
||||
|
||||
function buildRadioTunerBand(
|
||||
rankedStations,
|
||||
selected,
|
||||
limit = RADIO_TUNER_STATION_LIMIT,
|
||||
) {
|
||||
const boundedLimit = Math.min(
|
||||
RADIO_TUNER_DIRECTORY_LIMIT,
|
||||
Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)),
|
||||
);
|
||||
return (Array.isArray(rankedStations) ? rankedStations : []).slice(
|
||||
0,
|
||||
boundedLimit,
|
||||
);
|
||||
}
|
||||
|
||||
function captureRadioTuningResolutionSnapshot() {
|
||||
return layerState._acceptedCatalogSnapshot;
|
||||
}
|
||||
|
||||
/** Return the complete accepted filtered directory in stable catalog order. */
|
||||
|
||||
function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._acceptedCatalogSnapshot.stations.length
|
||||
)
|
||||
return [];
|
||||
return buildRadioTunerBand(
|
||||
parts.queries.visibleStations(),
|
||||
parts.queries.selectedStation(),
|
||||
limit,
|
||||
);
|
||||
}
|
||||
|
||||
/** Begin a direct-manipulation tuning gesture and pause the current stream. */
|
||||
|
||||
function beginRadioTuning() {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!parts.queries.visibleStations().length
|
||||
)
|
||||
return false;
|
||||
const snapshot = captureRadioTuningResolutionSnapshot();
|
||||
if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length)
|
||||
return false;
|
||||
layerState._tuningResolutionSnapshot = snapshot;
|
||||
layerState._tuningStationById = new Map(
|
||||
layerState._tuningResolutionSnapshot.stations.map((station) => [
|
||||
station.id,
|
||||
station,
|
||||
]),
|
||||
);
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
layerState._tuningActive = true;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._cancelledTuningPresentationStation = null;
|
||||
layerState._tuningStartStationId = layerState._selectedId;
|
||||
layerState._tuningPreviewId = layerState._selectedId;
|
||||
parts.tuningNoise.installTuningNoise();
|
||||
parts.playback.pauseRadioPlayback({ origin: 'user' });
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Toggle low-volume synthesized static for tuner preview and stream handoff. */
|
||||
|
||||
function setRadioTuningStatic(active) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive
|
||||
)
|
||||
return false;
|
||||
const next = Boolean(active);
|
||||
if (next === layerState._tuningStatic) return true;
|
||||
layerState._tuningStatic = next;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Preview a tuner station bracket and camera orientation without starting audio. */
|
||||
|
||||
function previewRadioTuningStation(id, { rotate = true } = {}) {
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive
|
||||
)
|
||||
return false;
|
||||
const station = parts.model.tuningResolutionStation(id);
|
||||
const nextId = station?.id || null;
|
||||
const changed = nextId !== layerState._tuningPreviewId;
|
||||
layerState._tuningPreviewId = nextId;
|
||||
layerState._tuningStatic = !station;
|
||||
if (changed) {
|
||||
parts.rendering.updateSelectionEntity();
|
||||
if (station && rotate) {
|
||||
const cameraState = parts.navigation.radioCameraState();
|
||||
layerState._tuningCameraNavigation =
|
||||
parts.navigation.beginRadioCameraNavigation(cameraState);
|
||||
parts.navigation.rotateRadioStationIntoView(
|
||||
station,
|
||||
0.35,
|
||||
cameraState,
|
||||
layerState._tuningCameraNavigation,
|
||||
);
|
||||
} else if (rotate) {
|
||||
layerState._tuningCameraNavigation = null;
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
}
|
||||
}
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return Boolean(station);
|
||||
}
|
||||
|
||||
/** Finish a tuning gesture and release its synthesized noise source. */
|
||||
|
||||
function endRadioTuning() {
|
||||
if (
|
||||
!layerState._tuningActive &&
|
||||
!layerState._tuningStatic &&
|
||||
!layerState._tuningAwaitingStationId &&
|
||||
!layerState._tuningNoiseSource &&
|
||||
!layerState._cancelledTuningPresentationStation
|
||||
)
|
||||
return;
|
||||
parts.tuningNoise.clearRadioTuningNoise();
|
||||
}
|
||||
|
||||
/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */
|
||||
|
||||
function cancelRadioTuning() {
|
||||
if (!layerState._tuningActive) return false;
|
||||
const restoredStation = parts.model.tuningResolutionStation(
|
||||
layerState._tuningStartStationId,
|
||||
);
|
||||
parts.tuningNoise.clearRadioTuningNoise({ restoredStation });
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Commit the exact frozen drag resolution or report that it is unavailable. */
|
||||
|
||||
function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) {
|
||||
const frozenStation = parts.model.tuningResolutionStation(id);
|
||||
const currentStation = layerState._stationById.get(String(id)) || null;
|
||||
const generation = layerState._tuningResolutionSnapshot.generation;
|
||||
if (
|
||||
!parts.interaction.radioPresentationAllowed() ||
|
||||
!layerState._tuningActive ||
|
||||
!frozenStation
|
||||
) {
|
||||
endRadioTuning();
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'not-tuning',
|
||||
stationId: String(id || ''),
|
||||
generation,
|
||||
});
|
||||
}
|
||||
if (
|
||||
!parts.model.radioStationResolutionMatches(frozenStation, currentStation)
|
||||
) {
|
||||
const stationId = frozenStation.id;
|
||||
// A failed exact release owns the visible outcome. Do not let the previous
|
||||
// selection—or changed metadata under the same ID—reappear between the
|
||||
// frozen preview and the unavailable state.
|
||||
layerState._selectedId = null;
|
||||
layerState._selectionGeneration += 1;
|
||||
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
|
||||
layerState._tuningUnavailableStationId = stationId;
|
||||
parts.presentation.emitState();
|
||||
return Object.freeze({
|
||||
ok: false,
|
||||
reason: 'station-unavailable',
|
||||
stationId,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = true;
|
||||
layerState._tuningAwaitingStationId = frozenStation.id;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningUnavailableStationId = null;
|
||||
const cameraNavigation = layerState._tuningCameraNavigation;
|
||||
layerState._tuningCameraNavigation = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
// An exact tuner release owns its frozen target. A fallback armed by an
|
||||
// earlier non-playing selection must never retarget this gesture if the
|
||||
// broadcaster fails after release.
|
||||
layerState._playFallbackId = null;
|
||||
layerState._playFallbackFocus = null;
|
||||
layerState._playFallbackOrigin = 'programmatic';
|
||||
layerState._playFallbackAttemptId = null;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
const selected = parts.selection.selectRadioStation(frozenStation.id, {
|
||||
autoplay: true,
|
||||
focus: false,
|
||||
origin,
|
||||
cameraNavigation,
|
||||
});
|
||||
return Object.freeze({
|
||||
ok: selected,
|
||||
reason: selected ? null : 'station-unavailable',
|
||||
stationId: frozenStation.id,
|
||||
generation,
|
||||
});
|
||||
}
|
||||
return {
|
||||
buildRadioTunerBand,
|
||||
captureRadioTuningResolutionSnapshot,
|
||||
getRadioTunerStations,
|
||||
beginRadioTuning,
|
||||
setRadioTuningStatic,
|
||||
previewRadioTuningStation,
|
||||
endRadioTuning,
|
||||
cancelRadioTuning,
|
||||
commitRadioTuningStation,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
RADIO_TUNER_STATIC_MAX_GAIN,
|
||||
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTuningNoise({
|
||||
state: layerState,
|
||||
services,
|
||||
parts,
|
||||
source,
|
||||
}) {
|
||||
/** Decide whether tuner static should be audible for the current handoff state. */
|
||||
|
||||
function radioTuningStaticShouldPlay({
|
||||
tuningActive = false,
|
||||
tuningStatic = false,
|
||||
awaitingStationId = null,
|
||||
voiceDucked = false,
|
||||
} = {}) {
|
||||
return Boolean(
|
||||
tuningStatic && !voiceDucked && (tuningActive || awaitingStationId),
|
||||
);
|
||||
}
|
||||
|
||||
function stopTuningNoiseSource() {
|
||||
if (layerState._tuningNoiseSource) {
|
||||
try {
|
||||
layerState._tuningNoiseSource.stop();
|
||||
} catch {
|
||||
/* already stopped */
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseSource.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseFilter?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
try {
|
||||
layerState._tuningNoiseGain?.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
layerState._tuningNoiseSource = null;
|
||||
layerState._tuningNoiseFilter = null;
|
||||
layerState._tuningNoiseGain = null;
|
||||
}
|
||||
|
||||
function syncTuningNoiseGain() {
|
||||
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
|
||||
const audible = radioTuningStaticShouldPlay({
|
||||
tuningActive: layerState._tuningActive,
|
||||
tuningStatic: layerState._tuningStatic,
|
||||
awaitingStationId: layerState._tuningAwaitingStationId,
|
||||
voiceDucked: layerState._voiceDucked,
|
||||
});
|
||||
const target = audible
|
||||
? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, layerState._userVolume * 0.03)
|
||||
: 0;
|
||||
const now = layerState._tuningNoiseContext.currentTime;
|
||||
layerState._tuningNoiseGain.gain.cancelScheduledValues(now);
|
||||
layerState._tuningNoiseGain.gain.setValueAtTime(
|
||||
layerState._tuningNoiseGain.gain.value,
|
||||
now,
|
||||
);
|
||||
layerState._tuningNoiseGain.gain.linearRampToValueAtTime(
|
||||
target,
|
||||
now + 0.025,
|
||||
);
|
||||
}
|
||||
|
||||
function installTuningNoise() {
|
||||
const AudioContextClass =
|
||||
globalThis.AudioContext || globalThis.webkitAudioContext;
|
||||
if (!AudioContextClass) return false;
|
||||
if (!layerState._tuningNoiseContext)
|
||||
layerState._tuningNoiseContext = new AudioContextClass();
|
||||
const resumed = layerState._tuningNoiseContext.resume?.();
|
||||
if (resumed?.catch) void resumed.catch(() => {});
|
||||
if (layerState._tuningNoiseSource) return true;
|
||||
|
||||
const frameCount = Math.max(
|
||||
1,
|
||||
Math.floor(layerState._tuningNoiseContext.sampleRate * 0.75),
|
||||
);
|
||||
const buffer = layerState._tuningNoiseContext.createBuffer(
|
||||
1,
|
||||
frameCount,
|
||||
layerState._tuningNoiseContext.sampleRate,
|
||||
);
|
||||
const channel = buffer.getChannelData(0);
|
||||
for (let index = 0; index < channel.length; index += 1)
|
||||
channel[index] = Math.random() * 2 - 1;
|
||||
|
||||
layerState._tuningNoiseSource =
|
||||
layerState._tuningNoiseContext.createBufferSource();
|
||||
layerState._tuningNoiseFilter =
|
||||
layerState._tuningNoiseContext.createBiquadFilter();
|
||||
layerState._tuningNoiseGain = layerState._tuningNoiseContext.createGain();
|
||||
layerState._tuningNoiseSource.buffer = buffer;
|
||||
layerState._tuningNoiseSource.loop = true;
|
||||
layerState._tuningNoiseFilter.type = 'bandpass';
|
||||
layerState._tuningNoiseFilter.frequency.value = 1_650;
|
||||
layerState._tuningNoiseFilter.Q.value = 0.55;
|
||||
layerState._tuningNoiseGain.gain.value = 0;
|
||||
layerState._tuningNoiseSource.connect(layerState._tuningNoiseFilter);
|
||||
layerState._tuningNoiseFilter.connect(layerState._tuningNoiseGain);
|
||||
layerState._tuningNoiseGain.connect(
|
||||
layerState._tuningNoiseContext.destination,
|
||||
);
|
||||
layerState._tuningNoiseSource.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) {
|
||||
if (layerState._tuningCameraNavigation)
|
||||
parts.navigation.invalidateRadioCameraNavigation();
|
||||
layerState._tuningActive = false;
|
||||
layerState._tuningStatic = false;
|
||||
layerState._tuningAwaitingStationId = null;
|
||||
layerState._tuningPreviewId = null;
|
||||
layerState._tuningStartStationId = null;
|
||||
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||
layerState._tuningStationById = new Map();
|
||||
layerState._tuningCameraNavigation = null;
|
||||
layerState._cancelledTuningPresentationStation = restoredStation;
|
||||
parts.rendering.updateSelectionEntity();
|
||||
syncTuningNoiseGain();
|
||||
stopTuningNoiseSource();
|
||||
const suspended = layerState._tuningNoiseContext?.suspend?.();
|
||||
if (suspended?.catch) void suspended.catch(() => {});
|
||||
if (emit) parts.presentation.emitState();
|
||||
}
|
||||
return {
|
||||
radioTuningStaticShouldPlay,
|
||||
stopTuningNoiseSource,
|
||||
syncTuningNoiseGain,
|
||||
installTuningNoise,
|
||||
clearRadioTuningNoise,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js';
|
||||
|
||||
export function createVolume({ state: layerState, services, parts, source }) {
|
||||
function clampRadioVolume(value) {
|
||||
return Math.min(1, Math.max(0, Number(value) || 0));
|
||||
}
|
||||
|
||||
function cancelRadioVolumeTransition() {
|
||||
layerState._volumeTransitionGeneration += 1;
|
||||
if (layerState._voiceRestoreTimer)
|
||||
clearTimeout(layerState._voiceRestoreTimer);
|
||||
layerState._voiceRestoreTimer = null;
|
||||
if (layerState._volumeFadeFrame) {
|
||||
if (typeof cancelAnimationFrame === 'function')
|
||||
cancelAnimationFrame(layerState._volumeFadeFrame);
|
||||
else clearTimeout(layerState._volumeFadeFrame);
|
||||
}
|
||||
layerState._volumeFadeFrame = null;
|
||||
}
|
||||
|
||||
function scheduleVolumeFrame(callback) {
|
||||
if (typeof requestAnimationFrame === 'function')
|
||||
return requestAnimationFrame(callback);
|
||||
return setTimeout(() => callback(Date.now()), 16);
|
||||
}
|
||||
|
||||
function volumeClock() {
|
||||
return typeof performance !== 'undefined' &&
|
||||
typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
|
||||
/** Set shared audio volume, clamped to [0, 1]. */
|
||||
|
||||
function setRadioVolume(value) {
|
||||
if (!parts.interaction.radioPresentationAllowed()) return false;
|
||||
const volume = clampRadioVolume(value);
|
||||
layerState._userVolume = volume;
|
||||
parts.playback.installAudio();
|
||||
if (!layerState._voiceDucked) {
|
||||
cancelRadioVolumeTransition();
|
||||
layerState._voiceRestoring = false;
|
||||
if (layerState._audio) layerState._audio.volume = volume;
|
||||
}
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute Radio during a live voice turn, then gently restore the user-owned
|
||||
* volume after voice returns to standby. Repeated state sync is idempotent.
|
||||
*/
|
||||
|
||||
function setRadioVoiceDucking(
|
||||
ducked,
|
||||
{
|
||||
restoreDelayMs = VOICE_RESTORE_DELAY_MS,
|
||||
restoreDurationMs = VOICE_RESTORE_DURATION_MS,
|
||||
} = {},
|
||||
) {
|
||||
const shouldDuck = Boolean(ducked);
|
||||
if (
|
||||
shouldDuck === layerState._voiceDucked &&
|
||||
(shouldDuck || layerState._voiceRestoring)
|
||||
)
|
||||
return;
|
||||
if (
|
||||
!shouldDuck &&
|
||||
!layerState._voiceDucked &&
|
||||
!layerState._voiceRestoring &&
|
||||
(!layerState._audio ||
|
||||
Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001)
|
||||
)
|
||||
return;
|
||||
|
||||
cancelRadioVolumeTransition();
|
||||
layerState._voiceDucked = shouldDuck;
|
||||
layerState._voiceRestoring = false;
|
||||
|
||||
if (shouldDuck) {
|
||||
if (layerState._audio) layerState._audio.volume = 0;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
parts.presentation.emitState();
|
||||
return;
|
||||
}
|
||||
if (!layerState._audio) {
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
layerState._voiceRestoring = false;
|
||||
parts.presentation.emitState();
|
||||
return;
|
||||
}
|
||||
|
||||
const generation = layerState._volumeTransitionGeneration;
|
||||
parts.tuningNoise.syncTuningNoiseGain();
|
||||
layerState._voiceRestoring = true;
|
||||
parts.presentation.emitState();
|
||||
const beginRestore = () => {
|
||||
layerState._voiceRestoreTimer = null;
|
||||
if (
|
||||
generation !== layerState._volumeTransitionGeneration ||
|
||||
layerState._voiceDucked ||
|
||||
!layerState._audio
|
||||
)
|
||||
return;
|
||||
const startedAt = volumeClock();
|
||||
const initialVolume = layerState._audio.volume;
|
||||
const duration = Math.max(0, Number(restoreDurationMs) || 0);
|
||||
const step = (now) => {
|
||||
if (
|
||||
generation !== layerState._volumeTransitionGeneration ||
|
||||
layerState._voiceDucked ||
|
||||
!layerState._audio
|
||||
)
|
||||
return;
|
||||
const progress =
|
||||
duration === 0
|
||||
? 1
|
||||
: Math.min(1, Math.max(0, (now - startedAt) / duration));
|
||||
const eased = progress * progress * (3 - 2 * progress);
|
||||
layerState._audio.volume = clampRadioVolume(
|
||||
initialVolume + (layerState._userVolume - initialVolume) * eased,
|
||||
);
|
||||
if (progress < 1) {
|
||||
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
|
||||
return;
|
||||
}
|
||||
layerState._volumeFadeFrame = null;
|
||||
layerState._voiceRestoring = false;
|
||||
parts.presentation.emitState();
|
||||
};
|
||||
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
|
||||
};
|
||||
const delay = Math.max(0, Number(restoreDelayMs) || 0);
|
||||
if (delay > 0)
|
||||
layerState._voiceRestoreTimer = setTimeout(beginRestore, delay);
|
||||
else beginRestore();
|
||||
}
|
||||
return {
|
||||
clampRadioVolume,
|
||||
cancelRadioVolumeTransition,
|
||||
scheduleVolumeFrame,
|
||||
volumeClock,
|
||||
setRadioVolume,
|
||||
setRadioVoiceDucking,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
|
||||
// Commercial users must remove it or obtain a TeleGeography license.
|
||||
// See DATA_SOURCES.md and the bundled dataset's source.json.
|
||||
const cableUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const landingPointUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
|
||||
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
|
||||
export function createBundledCableSource({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
} = {}) {
|
||||
async function read(url, signal) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(url, { signal, cache: 'force-cache' });
|
||||
if (!response.ok) {
|
||||
try {
|
||||
await response.body?.cancel();
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
const json = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
return json;
|
||||
}
|
||||
return {
|
||||
label: 'TeleGeography',
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl, signal),
|
||||
read(landingPointUrl, signal),
|
||||
]);
|
||||
return { cables, landingPoints };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createBundledCableSource } from './bundledSource.js';
|
||||
|
||||
test('a failed bundled response releases its body', async () => {
|
||||
let released = 0;
|
||||
const source = createBundledCableSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
cancel: async () => {
|
||||
released++;
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(), /HTTP 503/);
|
||||
assert.equal(released, 2);
|
||||
});
|
||||
|
||||
test('cancellation during JSON parsing cannot return a late snapshot', async () => {
|
||||
const controller = new AbortController();
|
||||
const source = createBundledCableSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
controller.abort();
|
||||
return { type: 'FeatureCollection', features: [] };
|
||||
},
|
||||
}),
|
||||
});
|
||||
await assert.rejects(source.fetch(controller.signal), { name: 'AbortError' });
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
export function featureReference(feature) {
|
||||
const geometry = feature?.geometry;
|
||||
if (!geometry) return null;
|
||||
|
||||
const props = feature?.properties || {};
|
||||
const propertyCoords = coordsFromProperty(props.coordinates);
|
||||
if (propertyCoords) {
|
||||
return {
|
||||
lon: propertyCoords[0],
|
||||
lat: propertyCoords[1],
|
||||
};
|
||||
}
|
||||
|
||||
if (geometry.type === 'Point') {
|
||||
const coords = coordsFromPoint(geometry.coordinates);
|
||||
if (!coords) return null;
|
||||
return { lon: coords[0], lat: coords[1] };
|
||||
}
|
||||
|
||||
const coords = [];
|
||||
collectLonLat(geometry.coordinates, coords);
|
||||
if (!coords.length) return null;
|
||||
|
||||
let lonSum = 0;
|
||||
let latSum = 0;
|
||||
for (const [lon, lat] of coords) {
|
||||
lonSum += lon;
|
||||
latSum += lat;
|
||||
}
|
||||
return {
|
||||
lon: lonSum / coords.length,
|
||||
lat: latSum / coords.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function coordsFromProperty(value) {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export function coordsFromPoint(value) {
|
||||
if (!Array.isArray(value) || value.length < 2) return null;
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export function collectLonLat(value, out) {
|
||||
if (!Array.isArray(value)) return;
|
||||
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
|
||||
const lon = Number(value[0]);
|
||||
const lat = Number(value[1]);
|
||||
if (Number.isFinite(lon) && Number.isFinite(lat)) {
|
||||
out.push([lon, lat]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of value) collectLonLat(child, out);
|
||||
}
|
||||
|
||||
export function featureLabel(feature) {
|
||||
const props = feature?.properties || {};
|
||||
return String(props.name || props.id || feature?.id || '').trim();
|
||||
}
|
||||
|
||||
export function normalizeFeatures(json, kind) {
|
||||
const features = Array.isArray(json?.features) ? json.features : [];
|
||||
return features.map((feature, index) => {
|
||||
const id = feature?.properties?.id || feature?.id || `${kind}-${index}`;
|
||||
return {
|
||||
...feature,
|
||||
id: String(id),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createState } from './state.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { defaultSweepClock } from './policy.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createInteraction } from './interaction.js';
|
||||
export function createSubmarineCableLayer({
|
||||
source,
|
||||
overlayHost,
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget = null,
|
||||
sweepClock = defaultSweepClock,
|
||||
}) {
|
||||
if (!source?.fetch || !source.label)
|
||||
throw new TypeError(
|
||||
'A cable source with a label and fetch(signal) is required',
|
||||
);
|
||||
const state = createState({ overlayHost, sweepClock });
|
||||
const parts = {};
|
||||
const context = {
|
||||
state,
|
||||
parts,
|
||||
source,
|
||||
screenSpaceEventHandlerFactory,
|
||||
mapStackEventTarget,
|
||||
};
|
||||
parts.rendering = createRendering(context);
|
||||
parts.ingestion = createIngestion(context);
|
||||
parts.interaction = createInteraction(context);
|
||||
return createLifecycle(context);
|
||||
}
|
||||
export {
|
||||
selectCableReferenceLabelWinners,
|
||||
cableReferencePriority,
|
||||
createCableOverlayEntry,
|
||||
createCableOverlayPublisher,
|
||||
createCableReferenceSweepGate,
|
||||
updateCableReferenceStem,
|
||||
} from './overlay.js';
|
||||
export {
|
||||
cableClassificationTypeForStack,
|
||||
cableClassificationTypeForScene,
|
||||
applyTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
export {
|
||||
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
CABLE_OVERLAY_SOURCE_ID,
|
||||
CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
CABLE_STEM_TIP_EPSILON_M,
|
||||
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
CABLE_LABEL_DEPTH_DECISION,
|
||||
} from './policy.js';
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
featureLabel,
|
||||
featureReference,
|
||||
normalizeFeatures,
|
||||
} from './geometry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createIngestion({ state, parts, source }) {
|
||||
async function load(viewer) {
|
||||
if (state._loading || state._loaded) return;
|
||||
|
||||
state._loading = true;
|
||||
state._error = null;
|
||||
state._loadingLabel = 'loading...';
|
||||
// Ownership token (the militaryAwareness activationId pattern): a
|
||||
// disable/abort followed by a fresh enable starts a NEWER load while this
|
||||
// one is still settling. The stale load must bail after every await and
|
||||
// must never touch shared lifecycle state or the viewer it no longer
|
||||
// owns — clearing `_loading`/`_abort` for the successor is how duplicate
|
||||
// or post-destroy data sources got added.
|
||||
const generation = ++state._loadGeneration;
|
||||
const abort = new AbortController();
|
||||
state._abort = abort;
|
||||
const owns = () =>
|
||||
generation === state._loadGeneration && !abort.signal.aborted;
|
||||
|
||||
try {
|
||||
let cableJson = state._cachedCableJson;
|
||||
let landingJson = state._cachedLandingJson;
|
||||
if (!cableJson || !landingJson) {
|
||||
({ cables: cableJson, landingPoints: landingJson } = await source.fetch(
|
||||
abort.signal,
|
||||
));
|
||||
if (!owns()) return;
|
||||
if (
|
||||
!Array.isArray(cableJson?.features) ||
|
||||
!Array.isArray(landingJson?.features)
|
||||
) {
|
||||
throw new TypeError(
|
||||
'A cable source must return cables and landingPoints GeoJSON collections',
|
||||
);
|
||||
}
|
||||
state._cachedCableJson = cableJson;
|
||||
state._cachedLandingJson = landingJson;
|
||||
}
|
||||
|
||||
const cableFeatures = normalizeFeatures(cableJson, 'cable');
|
||||
const landingFeatures = normalizeFeatures(landingJson, 'landing');
|
||||
|
||||
const cableDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: cableFeatures },
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.cableColor.withAlpha(0.95),
|
||||
fill: state.cableColor.withAlpha(0.18),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.cableColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
const landingDataSource = await Cesium.GeoJsonDataSource.load(
|
||||
{ type: 'FeatureCollection', features: landingFeatures },
|
||||
{
|
||||
clampToGround: true,
|
||||
stroke: state.landingColor.withAlpha(0.9),
|
||||
fill: state.landingColor.withAlpha(0.35),
|
||||
strokeWidth: 2,
|
||||
markerColor: state.landingColor,
|
||||
markerSize: 6,
|
||||
},
|
||||
);
|
||||
if (!owns()) return;
|
||||
|
||||
cableDataSource.name = `${source.label} Submarine Cables`;
|
||||
landingDataSource.name = `${source.label} Landing Points`;
|
||||
const referenceDataSource = new Cesium.CustomDataSource(
|
||||
`${source.label} Cable References`,
|
||||
);
|
||||
const addedSources = [
|
||||
cableDataSource,
|
||||
landingDataSource,
|
||||
referenceDataSource,
|
||||
];
|
||||
|
||||
// dataSources.add() is itself an await point: Cesium mutates the
|
||||
// collection on a DEFERRED tick, so a disable/destroy racing this
|
||||
// window finds nothing to remove and the adds still materialize
|
||||
// afterwards. Await every add to settlement (allSettled so a partial
|
||||
// failure cannot leave an unawaited add materializing later), then
|
||||
// re-check ownership. Pinned teardown mechanism: disable/destroy never
|
||||
// wait for in-flight adds — the stale generation's own post-await
|
||||
// check here guarantees cleanup by removing exactly the sources THIS
|
||||
// generation added.
|
||||
const addResults = await Promise.allSettled([
|
||||
viewer.dataSources.add(cableDataSource),
|
||||
viewer.dataSources.add(landingDataSource),
|
||||
viewer.dataSources.add(referenceDataSource),
|
||||
]);
|
||||
const rejectedAdd = addResults.find(
|
||||
(result) => result.status === 'rejected',
|
||||
);
|
||||
if (!owns() || rejectedAdd) {
|
||||
// Compensate: every add has settled by now, so removal is effective
|
||||
// (a remove inside the deferred window would have been a no-op).
|
||||
// Remove EVERY generation-local source regardless of settle status:
|
||||
// Cesium's add() pushes into the collection BEFORE raising
|
||||
// dataSourceAdded, so a REJECTED add may still have landed its
|
||||
// mutation. remove() of a never-added source is a harmless no-op.
|
||||
for (const source of addedSources) {
|
||||
try {
|
||||
viewer.dataSources.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
if (owns() && rejectedAdd) throw rejectedAdd.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
// Await-free commit: the viewer accepted all three sources and this
|
||||
// load still owns the lifecycle.
|
||||
state._cableDataSource = cableDataSource;
|
||||
state._landingDataSource = landingDataSource;
|
||||
state._referenceDataSource = referenceDataSource;
|
||||
|
||||
const cableEntities = state._cableDataSource.entities.values;
|
||||
const landingEntities = state._landingDataSource.entities.values;
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
|
||||
cableEntities.forEach((entity, index) => {
|
||||
const feature = cableFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleCableEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'cable',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'cable',
|
||||
color: state.cableColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
landingEntities.forEach((entity, index) => {
|
||||
const feature = landingFeatures[index];
|
||||
const reference = featureReference(feature);
|
||||
if (!reference) return;
|
||||
|
||||
parts.rendering.styleLandingEntity(entity, feature);
|
||||
parts.interaction.registerPickEntity(entity, {
|
||||
kind: 'landing-point',
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
});
|
||||
state._surfaceRecords.push({
|
||||
entity,
|
||||
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
|
||||
});
|
||||
parts.rendering.addReferenceStem({
|
||||
reference,
|
||||
label: featureLabel(feature),
|
||||
kind: 'landing-point',
|
||||
color: state.landingColor,
|
||||
feature,
|
||||
});
|
||||
});
|
||||
|
||||
state._count = cableFeatures.length + landingFeatures.length;
|
||||
state._loaded = true;
|
||||
state._lastUpdate = Date.now();
|
||||
state._loadingLabel = '';
|
||||
parts.rendering.updateVisibility();
|
||||
state._referenceSweepGate.markDirty();
|
||||
viewer.scene.requestRender?.();
|
||||
} catch (error) {
|
||||
// A stale or aborted load reports nothing: its failure belongs to a
|
||||
// lifecycle the user already left.
|
||||
if (owns() && error?.name !== 'AbortError') {
|
||||
state._error = error?.message || 'TeleGeography load failed';
|
||||
console.warn(
|
||||
'[Data:telegeography-submarine-cables]',
|
||||
state._error,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Only the OWNING load may clear the shared lifecycle. A stale load
|
||||
// clearing `_loading`/`_abort` would hand a later enable/update a
|
||||
// duplicate load while the real one is still in flight.
|
||||
if (generation === state._loadGeneration) {
|
||||
state._loading = false;
|
||||
if (state._abort === abort) state._abort = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { load };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
|
||||
function registerPickEntity(entity, info) {
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
}
|
||||
|
||||
function beginInteraction(viewer) {
|
||||
if (state._clickHandler) return;
|
||||
state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
|
||||
state._clickHandler.setInputAction((click) => {
|
||||
if (!state._enabled) return;
|
||||
const picked = viewer.scene.pick(click.position);
|
||||
const record = resolvePickRecord(picked);
|
||||
if (!record?.reference) return;
|
||||
flyToReference(viewer, record.reference);
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function resolvePickRecord(picked) {
|
||||
if (!picked) return null;
|
||||
const primitive = picked.primitive;
|
||||
if (primitive) {
|
||||
const primitiveInfo = state._pickByEntity.get(primitive);
|
||||
if (primitiveInfo) return primitiveInfo;
|
||||
if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography;
|
||||
if (
|
||||
primitive.id &&
|
||||
typeof primitive.id === 'object' &&
|
||||
primitive.id.reference
|
||||
) {
|
||||
return primitive.id;
|
||||
}
|
||||
}
|
||||
|
||||
const entity = picked.id;
|
||||
if (entity) {
|
||||
const entityInfo = state._pickByEntity.get(entity);
|
||||
if (entityInfo) return entityInfo;
|
||||
if (entity.__gevTeleGeography) return entity.__gevTeleGeography;
|
||||
if (entity.id && typeof entity.id === 'object' && entity.id.reference) {
|
||||
return entity.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function flyToReference(viewer, reference) {
|
||||
if (!viewer || !reference) return;
|
||||
const destination = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
6500,
|
||||
);
|
||||
viewer.camera.cancelFlight();
|
||||
viewer.camera.flyTo({
|
||||
destination,
|
||||
orientation: {
|
||||
heading: viewer.camera.heading || 0,
|
||||
pitch: Cesium.Math.toRadians(-52),
|
||||
roll: 0,
|
||||
},
|
||||
duration: 1.35,
|
||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
||||
});
|
||||
}
|
||||
return {
|
||||
registerPickEntity,
|
||||
beginInteraction,
|
||||
resolvePickRecord,
|
||||
flyToReference,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
cableClassificationTypeForScene,
|
||||
cableClassificationTypeForStack,
|
||||
} from './surface.js';
|
||||
|
||||
export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
|
||||
return {
|
||||
id: 'telegeography-submarine-cables',
|
||||
name: 'Submarine Cables',
|
||||
icon: '≋',
|
||||
source: source.label,
|
||||
updateInterval: 0,
|
||||
statsRefreshInterval: 500,
|
||||
|
||||
init(viewer) {
|
||||
state._viewer = viewer;
|
||||
state._classificationType = cableClassificationTypeForScene(
|
||||
viewer?.scene,
|
||||
);
|
||||
if (!state._mapStackListener && mapStackEventTarget?.addEventListener) {
|
||||
state._mapStackListener = (event) => {
|
||||
parts.rendering.applyCableClassification(
|
||||
event?.detail?.activeId
|
||||
? cableClassificationTypeForStack(event.detail.activeId)
|
||||
: cableClassificationTypeForScene(state._viewer?.scene),
|
||||
);
|
||||
};
|
||||
mapStackEventTarget.addEventListener(
|
||||
'gev:map-stack-changed',
|
||||
state._mapStackListener,
|
||||
);
|
||||
}
|
||||
parts.interaction.beginInteraction(viewer);
|
||||
if (!state._preRenderRemover) {
|
||||
state._preRenderRemover = viewer.scene.preRender.addEventListener(
|
||||
() => {
|
||||
// The camera is handed to the gate so tracked/orbit cameras — which
|
||||
// never emit moveEnd — reach the motion fallback. No render is
|
||||
// requested for a fallback sweep: it only fires while the camera is
|
||||
// already moving, so frames are flowing by construction.
|
||||
if (
|
||||
!state._enabled ||
|
||||
!state._loaded ||
|
||||
!state._referenceSweepGate.shouldRun(viewer.camera)
|
||||
)
|
||||
return;
|
||||
parts.rendering.updateReferenceVisibility();
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!state._moveEndRemover) {
|
||||
state._moveEndRemover = viewer.camera.moveEnd.addEventListener(() => {
|
||||
if (!state._enabled) return;
|
||||
state._referenceSweepGate.markDirty();
|
||||
viewer.scene.requestRender?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
enable(viewer) {
|
||||
state._enabled = true;
|
||||
parts.rendering.updateVisibility();
|
||||
state._overlayPublisher.show();
|
||||
// A hide() cleared the host source, so an identical cohort must still
|
||||
// republish on the next sweep.
|
||||
parts.rendering.resetPublishSignature();
|
||||
state._referenceSweepGate.markDirty();
|
||||
// The gate is dirty-only; ask for the frame its sweep needs in case the
|
||||
// camera is parked and nothing else is rendering.
|
||||
(viewer || state._viewer)?.scene?.requestRender?.();
|
||||
void parts.ingestion.load(viewer || state._viewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
state._enabled = false;
|
||||
state._overlayPublisher.hide();
|
||||
parts.rendering.resetPublishSignature();
|
||||
if (state._loading && state._abort) {
|
||||
state._abort.abort();
|
||||
state._loading = false;
|
||||
state._loadingLabel = '';
|
||||
}
|
||||
// Free the entities rather than hide them; enable() rebuilds from the
|
||||
// cached JSON. An in-flight load's own post-await ownership check
|
||||
// removes whatever that stale generation adds later.
|
||||
parts.rendering.releaseDataSources(state._viewer);
|
||||
},
|
||||
|
||||
update(viewer) {
|
||||
return parts.ingestion.load(viewer || state._viewer);
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
if (state._abort) state._abort.abort();
|
||||
parts.rendering.releaseDataSources(viewer || state._viewer);
|
||||
state._cachedCableJson = null;
|
||||
state._cachedLandingJson = null;
|
||||
// hide() clears every published host entry and goes invisible while
|
||||
// keeping the publisher reusable — the legacy layer supported re-init
|
||||
// after destroy, and hidden publishers already drop late publishes.
|
||||
// Pinned by the direct-destroy host test: removing this line leaves a
|
||||
// visible overlay source with orphan labels.
|
||||
state._overlayPublisher.hide();
|
||||
if (state._clickHandler) {
|
||||
state._clickHandler.destroy();
|
||||
state._clickHandler = null;
|
||||
}
|
||||
if (state._preRenderRemover) {
|
||||
state._preRenderRemover();
|
||||
state._preRenderRemover = null;
|
||||
}
|
||||
if (state._moveEndRemover) {
|
||||
state._moveEndRemover();
|
||||
state._moveEndRemover = null;
|
||||
}
|
||||
if (state._mapStackListener && mapStackEventTarget?.removeEventListener) {
|
||||
mapStackEventTarget.removeEventListener(
|
||||
'gev:map-stack-changed',
|
||||
state._mapStackListener,
|
||||
);
|
||||
state._mapStackListener = null;
|
||||
}
|
||||
state._viewer = null;
|
||||
state._enabled = false;
|
||||
state._loading = false;
|
||||
state._loaded = false;
|
||||
state._count = 0;
|
||||
state._error = null;
|
||||
state._lastUpdate = null;
|
||||
state._loadingLabel = '';
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
state._markerBlendDone = false;
|
||||
state._markerBlendInvariantWarned = false;
|
||||
parts.rendering.resetPublishSignature();
|
||||
state._referenceSweepGate.reset();
|
||||
},
|
||||
|
||||
getStats() {
|
||||
return {
|
||||
count: state._count,
|
||||
lastUpdate: state._lastUpdate,
|
||||
loading: state._loading,
|
||||
loadingLabel: state._loadingLabel,
|
||||
error: state._error,
|
||||
referenceLabelCount: state._referenceLabelCount,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
BASE_CABLE_COLOR,
|
||||
BASE_LANDING_COLOR,
|
||||
CABLE_OVERLAY_SOURCE_ID,
|
||||
CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
defaultSweepClock,
|
||||
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
STEM_TARGET_PX,
|
||||
CABLE_STEM_TIP_EPSILON_SQ,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Select the nearest visible cable references without exceeding the bounded
|
||||
* cohort cap the shared host is offered.
|
||||
* @param {object[]} records Records annotated with `visible` and `distanceM`.
|
||||
* @param {number} [limit]
|
||||
* @returns {object[]}
|
||||
*/
|
||||
|
||||
export function selectCableReferenceLabelWinners(
|
||||
records,
|
||||
limit = CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
) {
|
||||
const cap = Math.max(
|
||||
0,
|
||||
Math.min(CABLE_REFERENCE_LABEL_WINNER_CAP, Math.floor(Number(limit) || 0)),
|
||||
);
|
||||
if (!Array.isArray(records) || cap === 0) return [];
|
||||
return records
|
||||
.filter(
|
||||
(record) =>
|
||||
record?.visible === true &&
|
||||
record.label &&
|
||||
Number.isFinite(record.distanceM) &&
|
||||
record.distanceM <= CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.distanceM - b.distanceM ||
|
||||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
|
||||
)
|
||||
.slice(0, cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance-derived arbiter priority. Quantized to 50 km buckets so a slowly
|
||||
* moving camera does not reshuffle equal-rank labels on every sweep; nearer
|
||||
* references still win collisions, matching the shipped nearest-first feel.
|
||||
* @param {number} distanceM
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function cableReferencePriority(distanceM) {
|
||||
const distance = Number.isFinite(distanceM)
|
||||
? Math.max(0, distanceM)
|
||||
: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M;
|
||||
return 1000 - Math.round(distance / 50000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one shared-host ambient label for a cable or landing-point reference.
|
||||
* The entry stays attached to the record's mutable stem-tip Cartesian and is
|
||||
* created once per record; only `priority` is refreshed per sweep.
|
||||
* `interactive: false` follows the infrastructure precedent — the depth-tested
|
||||
* native point/stem/line remains the click surface, and a non-interactive
|
||||
* label keeps the host's per-frame accessibility/hit sync allocation-free.
|
||||
* @param {object} record Reference record ({ id, kind, label, tip }).
|
||||
* @returns {object}
|
||||
*/
|
||||
|
||||
export function createCableOverlayEntry(record) {
|
||||
const kind = record?.kind === 'landing-point' ? 'landing-point' : 'cable';
|
||||
return {
|
||||
id: String(record?.id || ''),
|
||||
position: record?.tip,
|
||||
variant: 'label',
|
||||
title: String(record?.label || ''),
|
||||
accent: kind === 'cable' ? BASE_CABLE_COLOR : BASE_LANDING_COLOR,
|
||||
priority: cableReferencePriority(record?.distanceM),
|
||||
collisionGroup: 'ambient-label',
|
||||
paintLane: 'ambient-label',
|
||||
interactive: false,
|
||||
minDistance: 0,
|
||||
maxDistance: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
|
||||
distanceFadeStartRatio: 0.7,
|
||||
// Former native scaleByDistance curve, unchanged: 1.0× at 250 km → 0.62×
|
||||
// at 9,000 km.
|
||||
distanceScale: {
|
||||
near: 250000,
|
||||
nearValue: 1,
|
||||
far: 9000000,
|
||||
farValue: 0.62,
|
||||
},
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
gapPx: 14,
|
||||
verticalOnly: true,
|
||||
placement: 'above',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the cable layer's host visibility and entry lifecycle to the shared
|
||||
* world overlay (same contract as the infrastructure publisher). The layer
|
||||
* supports re-init after destroy (legacy contract), so its teardown path is
|
||||
* hide() — clear the published source and go invisible while staying
|
||||
* reusable. There is deliberately no permanent-destroy method: a hidden
|
||||
* publisher already drops late publishes until the next show().
|
||||
* @param {object} [options]
|
||||
* @param {string} [options.sourceId]
|
||||
* @param {object} [options.host] Test seam for the three host lifecycle calls.
|
||||
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void}}
|
||||
*/
|
||||
|
||||
export function createCableOverlayPublisher({
|
||||
sourceId = CABLE_OVERLAY_SOURCE_ID,
|
||||
host,
|
||||
} = {}) {
|
||||
let visible = false;
|
||||
let published = false;
|
||||
const sourceOptions = {
|
||||
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
|
||||
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
|
||||
moving: false,
|
||||
};
|
||||
|
||||
return {
|
||||
show() {
|
||||
if (visible) return;
|
||||
visible = true;
|
||||
host.setVisible(sourceId, true);
|
||||
},
|
||||
publish(entries) {
|
||||
if (!visible) return;
|
||||
host.setEntries(sourceId, entries, sourceOptions);
|
||||
published = entries.length > 0;
|
||||
},
|
||||
hide() {
|
||||
if (published) host.clearSource(sourceId);
|
||||
if (visible) host.setVisible(sourceId, false);
|
||||
visible = false;
|
||||
published = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Dirty gate for the reference sweep. TWO dirty conditions, no timer:
|
||||
*
|
||||
* 1. EVENT (primary): the sweep runs on the first frame after a camera
|
||||
* `moveEnd`, layer enable, or load completion. The former 500 ms timer
|
||||
* path re-sized stems while frames were flowing, and every re-size
|
||||
* rebuilt the 2,629-instance batched translucent stem primitive
|
||||
* (measured: 12 rebuilds in 6 s with the replacement primitive unready
|
||||
* for 46/360 frames — the felt hitch). Stem lengths are therefore
|
||||
* deliberately stale mid-drag/mid-flight and settle on release;
|
||||
* reference visibility and label winners go stale the same way, and the
|
||||
* shared host keeps reprojecting the published positions per frame.
|
||||
* 2. MOTION FALLBACK: `moveEnd` covers drags, one-shot `setView`, and voice
|
||||
* `flyTo`, but a TRACKED-entity follow camera or an orbit never emits it,
|
||||
* so condition 1 alone starves those sessions forever (frozen horizon
|
||||
* visibility, frozen stems, stale label winners for as long as tracking
|
||||
* lasts). While frames render, the gate therefore samples the camera at
|
||||
* most once per `CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS` and re-arms itself
|
||||
* only when the camera has actually travelled past
|
||||
* `CABLE_SWEEP_MOTION_EPSILON_M` since the last sweep.
|
||||
*
|
||||
* A PARKED camera still costs exactly zero sweeps: the probe compares against
|
||||
* the last swept position (never the previous frame), so neither a frozen
|
||||
* camera nor sub-epsilon numeric jitter ever accumulates into a sweep, and
|
||||
* the per-frame cost of the fallback is one clock read plus one subtraction.
|
||||
* Position is the only motion channel because it is the only camera input the
|
||||
* sweep consumes — the horizon occluder, the reference distances, the stem
|
||||
* sizing, and the winner ranks all derive from `positionWC` alone, so a
|
||||
* heading-only rotation cannot change a single sweep output and deliberately
|
||||
* does not spend one.
|
||||
* @param {object} [options]
|
||||
* @param {function():number} [options.now] Monotonic clock (test seam).
|
||||
* @param {number} [options.probeIntervalMs]
|
||||
* @param {number} [options.motionEpsilonM]
|
||||
*/
|
||||
|
||||
export function createCableReferenceSweepGate({
|
||||
now = defaultSweepClock,
|
||||
probeIntervalMs = CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
|
||||
motionEpsilonM = CABLE_SWEEP_MOTION_EPSILON_M,
|
||||
} = {}) {
|
||||
const motionEpsilonSq = motionEpsilonM ** 2;
|
||||
// Preallocated: the probe clones into it, so a moving camera allocates
|
||||
// nothing per frame.
|
||||
const lastSweptPosition = new Cesium.Cartesian3();
|
||||
let hasSweptPosition = false;
|
||||
let dirty = true;
|
||||
let lastProbeAt = -Infinity;
|
||||
|
||||
/** Arm the next probe window and adopt the camera position being swept. */
|
||||
function accept(camera, time) {
|
||||
lastProbeAt = time;
|
||||
const position = camera?.positionWC;
|
||||
hasSweptPosition = Boolean(position);
|
||||
if (position) Cesium.Cartesian3.clone(position, lastSweptPosition);
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
markDirty() {
|
||||
dirty = true;
|
||||
},
|
||||
/**
|
||||
* @param {Cesium.Camera} [camera] Live camera; omit to disable the motion
|
||||
* fallback entirely (pure dirty-only gate).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
shouldRun(camera) {
|
||||
if (dirty) {
|
||||
dirty = false;
|
||||
return accept(camera, now());
|
||||
}
|
||||
if (!camera) return false;
|
||||
// Cheapest possible per-frame path: a clock read and a subtraction. The
|
||||
// camera getter is only touched once the probe window has actually
|
||||
// opened, and the window re-arms whether or not the probe sweeps.
|
||||
const time = now();
|
||||
if (time - lastProbeAt < probeIntervalMs) return false;
|
||||
lastProbeAt = time;
|
||||
const position = camera.positionWC;
|
||||
if (!position) return false;
|
||||
if (
|
||||
hasSweptPosition &&
|
||||
Cesium.Cartesian3.distanceSquared(position, lastSweptPosition) <=
|
||||
motionEpsilonSq
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return accept(camera, time);
|
||||
},
|
||||
reset() {
|
||||
dirty = true;
|
||||
lastProbeAt = -Infinity;
|
||||
hasSweptPosition = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute one staticized stem on the sweep cadence. Replaces the former
|
||||
* per-frame `CallbackProperty` pair: constant properties are redefined only
|
||||
* when the camera-scaled tip moved beyond the settle epsilon, and the two
|
||||
* preallocated position buffers alternate so each real change raises exactly
|
||||
* one geometry notification (the localGeojson double-buffer pattern).
|
||||
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
|
||||
* @param {Cesium.Cartesian3} cameraPositionWC
|
||||
* @param {number} canvasHeight CSS-pixel canvas height.
|
||||
* @param {number} fov Camera frustum field of view (radians).
|
||||
* @returns {boolean} True when the stem geometry was redefined.
|
||||
*/
|
||||
|
||||
export function updateCableReferenceStem(
|
||||
record,
|
||||
cameraPositionWC,
|
||||
canvasHeight,
|
||||
fov,
|
||||
) {
|
||||
const distance = Cesium.Cartesian3.distance(cameraPositionWC, record.base);
|
||||
const effectiveDistance = Math.max(distance, 5000);
|
||||
const height = canvasHeight || 1080;
|
||||
const fieldOfView = fov || Math.PI / 3;
|
||||
const metersPerPixelFactor = (2 * Math.tan(fieldOfView / 2)) / height;
|
||||
const tipHeight = Math.max(
|
||||
700,
|
||||
Math.min(85000, effectiveDistance * metersPerPixelFactor * STEM_TARGET_PX),
|
||||
);
|
||||
Cesium.Cartesian3.fromDegrees(
|
||||
record.reference.lon,
|
||||
record.reference.lat,
|
||||
tipHeight,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
record.nextTip,
|
||||
);
|
||||
if (
|
||||
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
|
||||
CABLE_STEM_TIP_EPSILON_SQ
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
Cesium.Cartesian3.clone(record.nextTip, record.tip);
|
||||
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
|
||||
const stemPositions =
|
||||
record.stemPositionBuffers[record.stemPositionBufferIndex];
|
||||
stemPositions[0] = record.base;
|
||||
stemPositions[1] = record.tip;
|
||||
record.entity.position.setValue(record.tip);
|
||||
record.entity.polyline.positions.setValue(stemPositions);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function clampLabel(value, maxLength = 34) {
|
||||
const text = String(value || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!text) return '';
|
||||
if (text.length <= maxLength) return text;
|
||||
return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
export const BASE_CABLE_COLOR = '#39d5ff';
|
||||
|
||||
export const BASE_LANDING_COLOR = '#8fffd2';
|
||||
|
||||
export const STEM_TARGET_PX = 66;
|
||||
|
||||
export const CABLE_REFERENCE_LABEL_MAX_DISTANCE_M = 9000000;
|
||||
|
||||
/** Bounded nearest-visible cohort the layer offers the shared host. */
|
||||
|
||||
export const CABLE_REFERENCE_LABEL_WINNER_CAP = 160;
|
||||
|
||||
/** Shared world-overlay source id (matches the layer id). */
|
||||
|
||||
export const CABLE_OVERLAY_SOURCE_ID = 'telegeography-submarine-cables';
|
||||
|
||||
/** Shared ambient-label paint budget, matching the infrastructure sources. */
|
||||
|
||||
export const CABLE_OVERLAY_COLLISION_CAPACITY = 96;
|
||||
|
||||
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
|
||||
|
||||
export const CABLE_STEM_TIP_EPSILON_M = 0.5;
|
||||
|
||||
export const CABLE_STEM_TIP_EPSILON_SQ = CABLE_STEM_TIP_EPSILON_M ** 2;
|
||||
|
||||
/**
|
||||
* Motion-fallback probe window. Cameras that never emit `moveEnd` (tracked
|
||||
* entities, orbits) would otherwise starve the dirty-only sweep forever, so
|
||||
* the gate samples camera motion at most this often while frames render.
|
||||
*/
|
||||
|
||||
export const CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS = 2000;
|
||||
|
||||
/**
|
||||
* Camera displacement that counts as meaningful motion for that probe. Stem
|
||||
* tip height is ~8.5% of camera distance at the shipped 66 px target, so
|
||||
* 250 m of camera travel moves a tip ~21 m; below that the frozen sweep is
|
||||
* visually indistinguishable and `moveEnd` settles it exactly.
|
||||
*/
|
||||
|
||||
export const CABLE_SWEEP_MOTION_EPSILON_M = 250;
|
||||
|
||||
/**
|
||||
* Phase-5 depth decision, REVISED 2026-08-18: Option 2. Cable reference text
|
||||
* now renders in the shared world-overlay host (horizon-cull + keyhole fade,
|
||||
* no per-label tile depth test), superseding the 2026-08-02 Option-1 native
|
||||
* `LabelGraphics` exception. The native path evaluated 2 `CallbackProperty`
|
||||
* channels per reference entity per frame (5,258 across the 2,629-reference
|
||||
* dataset) and re-batched a 160-label `LabelCollection` on every sweep — the
|
||||
* layer alone cost ~9.5 ms/frame during camera motion. The depth cue this
|
||||
* trades away (photoreal tiles occluding label TEXT at low, city-level
|
||||
* cameras) matches the sibling dams/datacenters sources, which shipped
|
||||
* host-composited under the same ruling; the anchor points/stems remain
|
||||
* Cesium-native and depth-tested. See
|
||||
* the world-overlay consolidation design notes ("Depth-testing
|
||||
* decision") for both dated decisions.
|
||||
*/
|
||||
|
||||
export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
|
||||
option: 2,
|
||||
decidedAt: '2026-08-18',
|
||||
supersedes: '2026-08-02',
|
||||
depthTested: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
|
||||
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
|
||||
* is not photoreal": an id this module has never heard of is UNKNOWN, and
|
||||
* unknown must reach the documented BOTH fallback rather than being asserted
|
||||
* onto the terrain surface. A stack added to `MAP_STACKS` without being added
|
||||
* here therefore degrades to the safe pre-optimization behavior (visible on
|
||||
* every surface) instead of vanishing — and the per-stack unit test walks the
|
||||
* real `MAP_STACKS` so the omission is caught loudly.
|
||||
*/
|
||||
|
||||
export const CABLE_GLOBE_STACK_IDS = Object.freeze(
|
||||
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
|
||||
);
|
||||
|
||||
/** EntityCluster's private marker collections and their required types. */
|
||||
|
||||
export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
|
||||
Object.freeze({
|
||||
key: '_billboardCollection',
|
||||
type: Cesium.BillboardCollection,
|
||||
}),
|
||||
Object.freeze({
|
||||
key: '_pointCollection',
|
||||
type: Cesium.PointPrimitiveCollection,
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
|
||||
* the module's `export default createTeleGeographySubmarineCableLayer()`
|
||||
* runs before this point in source order.
|
||||
* @returns {number}
|
||||
*/
|
||||
|
||||
export function defaultSweepClock() {
|
||||
return typeof performance === 'object' &&
|
||||
typeof performance.now === 'function'
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
probeTranslucentMarkerBlend,
|
||||
commitTranslucentMarkerBlend,
|
||||
} from './surface.js';
|
||||
import {
|
||||
clampLabel,
|
||||
createCableOverlayEntry,
|
||||
updateCableReferenceStem,
|
||||
selectCableReferenceLabelWinners,
|
||||
cableReferencePriority,
|
||||
} from './overlay.js';
|
||||
|
||||
export function createRendering({ state }) {
|
||||
function resetPublishSignature() {
|
||||
state._lastPublishedIds.length = 0;
|
||||
state._lastPublishedPriorities.length = 0;
|
||||
state._lastPublishedCount = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the three data sources from the viewer and forget the built
|
||||
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
|
||||
* DataSourceDisplay still walks every visualizer of a hidden source on
|
||||
* every frame, and the thousands of hidden polylines/billboards keep
|
||||
* costing CPU and GC pressure long after the user toggled the layer off —
|
||||
* that is the "everything gets sluggish after I turn layers off" report.
|
||||
* The fetched JSON stays cached, so the next enable rebuilds without a
|
||||
* network round trip or a re-parse.
|
||||
*/
|
||||
|
||||
function releaseDataSources(viewer) {
|
||||
const sources = [
|
||||
state._cableDataSource,
|
||||
state._landingDataSource,
|
||||
state._referenceDataSource,
|
||||
];
|
||||
state._cableDataSource = null;
|
||||
state._landingDataSource = null;
|
||||
state._referenceDataSource = null;
|
||||
state._referenceRecords = [];
|
||||
state._surfaceRecords = [];
|
||||
state._pickByEntity = new WeakMap();
|
||||
state._referenceLabelCount = 0;
|
||||
state._publishScratch.length = 0;
|
||||
// The blend pass targets the collections being dropped; the rebuilt
|
||||
// sources need their own pass.
|
||||
state._markerBlendDone = false;
|
||||
state._loaded = false;
|
||||
state._referenceSweepGate.reset();
|
||||
for (const source of sources) {
|
||||
if (!source) continue;
|
||||
try {
|
||||
viewer?.dataSources?.remove(source, true);
|
||||
} catch {
|
||||
/* collection gone */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
if (state._cableDataSource) state._cableDataSource.show = state._enabled;
|
||||
if (state._landingDataSource)
|
||||
state._landingDataSource.show = state._enabled;
|
||||
if (state._referenceDataSource)
|
||||
state._referenceDataSource.show = state._enabled;
|
||||
}
|
||||
|
||||
function styleCableEntity(entity, feature) {
|
||||
if (!entity?.polyline) return;
|
||||
const color = feature?.properties?.color
|
||||
? Cesium.Color.fromCssColorString(String(feature.properties.color))
|
||||
: state.cableColor;
|
||||
entity.polyline.material = color.withAlpha(0.92);
|
||||
entity.polyline.width = 2.5;
|
||||
entity.polyline.clampToGround = true;
|
||||
entity.polyline.classificationType = state._classificationType;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the single translucent blend pass to the landing-billboard and
|
||||
* reference-point collections. Landing entities carry only billboards and
|
||||
* reference entities only points, so completion is one applied collection
|
||||
* per source; a failed shape probe disables the optimization permanently
|
||||
* for this session and says so loudly in dev builds.
|
||||
* Both sources are probed BEFORE either is committed, so the helper's
|
||||
* "defaults untouched on shape drift" fallback holds across the pair too —
|
||||
* a reference-source failure can never leave the landing source already
|
||||
* forced to TRANSLUCENT.
|
||||
*/
|
||||
|
||||
function applyMarkerBlendOnce() {
|
||||
const landing = probeTranslucentMarkerBlend(state._landingDataSource);
|
||||
const reference = probeTranslucentMarkerBlend(state._referenceDataSource);
|
||||
if (landing.invariantFailed || reference.invariantFailed) {
|
||||
state._markerBlendDone = true;
|
||||
if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) {
|
||||
state._markerBlendInvariantWarned = true;
|
||||
console.error(
|
||||
'[Data:telegeography-submarine-cables] EntityCluster marker-collection ' +
|
||||
'shape changed — single-pass translucent blend skipped; markers fall ' +
|
||||
"back to Cesium's default two-pass blend.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const landingBlend = commitTranslucentMarkerBlend(landing);
|
||||
const referenceBlend = commitTranslucentMarkerBlend(reference);
|
||||
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
|
||||
state._markerBlendDone = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-classify every cable ground line for the active surface. One batched
|
||||
* ground-primitive rebuild per stack switch — never per frame.
|
||||
* @param {Cesium.ClassificationType} next
|
||||
*/
|
||||
|
||||
function applyCableClassification(next) {
|
||||
if (next === undefined || next === state._classificationType) return;
|
||||
state._classificationType = next;
|
||||
if (!state._cableDataSource) return;
|
||||
const entities = state._cableDataSource.entities.values;
|
||||
for (let i = 0; i < entities.length; i++) {
|
||||
const polyline = entities[i].polyline;
|
||||
if (polyline) polyline.classificationType = next;
|
||||
}
|
||||
state._viewer?.scene?.requestRender?.();
|
||||
}
|
||||
|
||||
function styleLandingEntity(entity, feature) {
|
||||
if (!entity?.point) return;
|
||||
entity.point.color = state.landingColor.withAlpha(0.92);
|
||||
entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
|
||||
entity.point.outlineColor = state.cableOutline;
|
||||
entity.point.outlineWidth = 1;
|
||||
entity.point.disableDepthTestDistance = 0;
|
||||
entity.show = true;
|
||||
}
|
||||
|
||||
function addReferenceStem({ reference, label, kind, color, feature }) {
|
||||
if (!state._referenceDataSource || !reference) return;
|
||||
|
||||
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
|
||||
const tip = Cesium.Cartesian3.fromDegrees(
|
||||
reference.lon,
|
||||
reference.lat,
|
||||
2500,
|
||||
);
|
||||
const info = {
|
||||
kind,
|
||||
reference,
|
||||
label,
|
||||
featureId: feature?.id || feature?.properties?.id || null,
|
||||
};
|
||||
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
|
||||
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68);
|
||||
// Constant properties on the sweep cadence, never per-frame callbacks:
|
||||
// the sweep redefines them through the alternating buffers below.
|
||||
const stemPositionBuffers = [
|
||||
[base, tip],
|
||||
[base, tip],
|
||||
];
|
||||
|
||||
const entity = state._referenceDataSource.entities.add({
|
||||
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
|
||||
position: tip,
|
||||
polyline: {
|
||||
positions: stemPositionBuffers[0],
|
||||
width: kind === 'cable' ? 2 : 2.4,
|
||||
material: new Cesium.ColorMaterialProperty(stemColor),
|
||||
},
|
||||
point: {
|
||||
pixelSize: kind === 'cable' ? 7 : 8,
|
||||
color: pointColor,
|
||||
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 0,
|
||||
},
|
||||
});
|
||||
|
||||
entity.__gevTeleGeography = info;
|
||||
state._pickByEntity.set(entity, info);
|
||||
const record = {
|
||||
id: entity.id,
|
||||
entity,
|
||||
base,
|
||||
tip,
|
||||
nextTip: Cesium.Cartesian3.clone(tip),
|
||||
stemPositionBuffers,
|
||||
stemPositionBufferIndex: 0,
|
||||
reference,
|
||||
kind,
|
||||
label: clampLabel(label),
|
||||
visible: false,
|
||||
distanceM: Infinity,
|
||||
entry: null,
|
||||
};
|
||||
record.entry = createCableOverlayEntry(record);
|
||||
state._referenceRecords.push(record);
|
||||
}
|
||||
|
||||
function updateReferenceVisibility() {
|
||||
if (!state._enabled || !state._viewer?.camera) return;
|
||||
const cameraPos = state._viewer.camera.positionWC;
|
||||
if (!cameraPos) return;
|
||||
|
||||
const canvasHeight = state._viewer.scene?.canvas?.clientHeight || 1080;
|
||||
const fov = state._viewer.camera.frustum?.fov || Math.PI / 3;
|
||||
const occluder = new Cesium.EllipsoidalOccluder(
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
cameraPos,
|
||||
);
|
||||
for (const record of state._referenceRecords) {
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
record.visible = visible;
|
||||
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
// Hidden stems keep their last geometry; this same sweep refreshes them
|
||||
// in the pass where they turn visible again.
|
||||
if (visible)
|
||||
updateCableReferenceStem(record, cameraPos, canvasHeight, fov);
|
||||
}
|
||||
for (const record of state._surfaceRecords) {
|
||||
const visible = occluder.isPointVisible(record.base);
|
||||
if (record.entity.show !== visible) {
|
||||
record.entity.show = visible;
|
||||
}
|
||||
}
|
||||
const winners = selectCableReferenceLabelWinners(state._referenceRecords);
|
||||
let changed = winners.length !== state._lastPublishedCount;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
const record = winners[i];
|
||||
const priority = cableReferencePriority(record.distanceM);
|
||||
record.entry.priority = priority;
|
||||
state._publishScratch[i] = record.entry;
|
||||
if (
|
||||
!changed &&
|
||||
(state._lastPublishedIds[i] !== record.entry.id ||
|
||||
state._lastPublishedPriorities[i] !== priority)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
state._publishScratch.length = winners.length;
|
||||
state._referenceLabelCount = winners.length;
|
||||
// The marker collections are created lazily by the visualizers, so the
|
||||
// single-pass blend is applied on the sweep cadence until both landed.
|
||||
if (!state._markerBlendDone) applyMarkerBlendOnce();
|
||||
if (!changed) return;
|
||||
state._overlayPublisher.publish(state._publishScratch);
|
||||
state._lastPublishedCount = winners.length;
|
||||
for (let i = 0; i < winners.length; i++) {
|
||||
state._lastPublishedIds[i] = state._publishScratch[i].id;
|
||||
state._lastPublishedPriorities[i] = state._publishScratch[i].priority;
|
||||
}
|
||||
state._lastPublishedIds.length = winners.length;
|
||||
state._lastPublishedPriorities.length = winners.length;
|
||||
}
|
||||
return {
|
||||
resetPublishSignature,
|
||||
releaseDataSources,
|
||||
updateVisibility,
|
||||
styleCableEntity,
|
||||
applyMarkerBlendOnce,
|
||||
applyCableClassification,
|
||||
styleLandingEntity,
|
||||
addReferenceStem,
|
||||
updateReferenceVisibility,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
createCableReferenceSweepGate,
|
||||
createCableOverlayPublisher,
|
||||
} from './overlay.js';
|
||||
import { BASE_CABLE_COLOR, BASE_LANDING_COLOR } from './policy.js';
|
||||
|
||||
export function createState({ sweepClock, overlayHost }) {
|
||||
const state = {};
|
||||
|
||||
state._viewer = null;
|
||||
|
||||
state._enabled = false;
|
||||
|
||||
state._loading = false;
|
||||
|
||||
state._loaded = false;
|
||||
|
||||
state._count = 0;
|
||||
|
||||
state._error = null;
|
||||
|
||||
state._lastUpdate = null;
|
||||
|
||||
state._loadingLabel = '';
|
||||
|
||||
state._abort = null;
|
||||
|
||||
/** Monotonic load-ownership token; every load() start claims a new one. */
|
||||
|
||||
state._loadGeneration = 0;
|
||||
|
||||
state._cableDataSource = null;
|
||||
|
||||
state._landingDataSource = null;
|
||||
|
||||
state._referenceDataSource = null;
|
||||
|
||||
/**
|
||||
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
|
||||
* the three data sources (see releaseDataSources) instead of hiding them,
|
||||
* so a re-enable has to rebuild the entities; the network/parse half of
|
||||
* that rebuild is the expensive part users would notice, so it is cached.
|
||||
*/
|
||||
|
||||
state._cachedCableJson = null;
|
||||
|
||||
state._cachedLandingJson = null;
|
||||
|
||||
state._referenceRecords = [];
|
||||
|
||||
state._surfaceRecords = [];
|
||||
|
||||
state._clickHandler = null;
|
||||
|
||||
state._preRenderRemover = null;
|
||||
|
||||
state._moveEndRemover = null;
|
||||
|
||||
state._mapStackListener = null;
|
||||
|
||||
/** Active ground-line classification; refined from live scene state at init. */
|
||||
|
||||
state._classificationType = Cesium.ClassificationType.BOTH;
|
||||
|
||||
/** True once both marker collections blend translucent (or the shape probe failed). */
|
||||
|
||||
state._markerBlendDone = false;
|
||||
|
||||
state._markerBlendInvariantWarned = false;
|
||||
|
||||
state._pickByEntity = new WeakMap();
|
||||
|
||||
state._referenceLabelCount = 0;
|
||||
|
||||
state._referenceSweepGate = createCableReferenceSweepGate({
|
||||
now: sweepClock,
|
||||
});
|
||||
|
||||
state._overlayPublisher = createCableOverlayPublisher({ host: overlayHost });
|
||||
|
||||
/** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */
|
||||
|
||||
state._publishScratch = [];
|
||||
|
||||
/**
|
||||
* Last published cohort signature (ids + quantized priorities). A parked
|
||||
* camera reproduces the same winners every sweep; skipping the identical
|
||||
* republish keeps the host from requesting a render per publish, so the
|
||||
* layer stays render-governor idle exactly like its native predecessor.
|
||||
* Tip positions need no republish either: the host reprojects the same
|
||||
* mutable Cartesians on every painted frame.
|
||||
*/
|
||||
|
||||
state._lastPublishedIds = [];
|
||||
|
||||
state._lastPublishedPriorities = [];
|
||||
|
||||
state._lastPublishedCount = -1;
|
||||
|
||||
state.cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR);
|
||||
|
||||
state.cableOutline = Cesium.Color.BLACK.withAlpha(0.45);
|
||||
|
||||
state.landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR);
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
CABLE_GLOBE_STACK_IDS,
|
||||
MARKER_COLLECTION_EXPECTATIONS,
|
||||
} from './policy.js';
|
||||
|
||||
/**
|
||||
* Ground-line classification for one map stack. The photoreal stack renders
|
||||
* Google 3D tiles with the Cesium globe HIDDEN, so cable ground lines only
|
||||
* need the 3D-tile classification pass there; every other known stack (the
|
||||
* bing stacks and osm) renders imagery on the shown globe, so only the
|
||||
* terrain pass applies.
|
||||
* Classifying against just the active surface halves the batched
|
||||
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
|
||||
* for an unknown stack — it renders on every surface, exactly the shipped
|
||||
* pre-optimization behavior.
|
||||
* @param {string|null|undefined} activeId MapStackController stack id.
|
||||
* @returns {Cesium.ClassificationType}
|
||||
*/
|
||||
|
||||
export function cableClassificationTypeForStack(activeId) {
|
||||
if (activeId === 'photoreal') return Cesium.ClassificationType.CESIUM_3D_TILE;
|
||||
if (CABLE_GLOBE_STACK_IDS.has(activeId))
|
||||
return Cesium.ClassificationType.TERRAIN;
|
||||
return Cesium.ClassificationType.BOTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the active surface from live scene state. The boot-time
|
||||
* `setStack(..., { silent: true })` fires no 'gev:map-stack-changed' event,
|
||||
* so the initial classification reads the scene the way the height-datum
|
||||
* listeners do: the photoreal regime is exactly "globe hidden".
|
||||
* @param {Cesium.Scene|null|undefined} scene
|
||||
* @returns {Cesium.ClassificationType}
|
||||
*/
|
||||
|
||||
export function cableClassificationTypeForScene(scene) {
|
||||
if (!scene?.globe) return Cesium.ClassificationType.BOTH;
|
||||
return scene.globe.show === false
|
||||
? Cesium.ClassificationType.CESIUM_3D_TILE
|
||||
: Cesium.ClassificationType.TERRAIN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate one data source's marker collections WITHOUT mutating anything.
|
||||
* Splitting validation from mutation is what makes the fallback honest: a
|
||||
* shape failure on the SECOND collection (or the second data source) must
|
||||
* not leave the first one already forced to TRANSLUCENT.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function probeTranslucentMarkerBlend(dataSource) {
|
||||
const cluster = dataSource?.clustering;
|
||||
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
|
||||
const ready = [];
|
||||
let pending = 0;
|
||||
let invariantFailed = false;
|
||||
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
|
||||
if (!(key in cluster)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
const collection = cluster[key];
|
||||
if (collection === undefined || collection === null) {
|
||||
pending++;
|
||||
continue;
|
||||
}
|
||||
if (!(collection instanceof type) || !('blendOption' in collection)) {
|
||||
invariantFailed = true;
|
||||
continue;
|
||||
}
|
||||
ready.push(collection);
|
||||
}
|
||||
// Any failure discards every collected target: nothing is mutated at all.
|
||||
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
|
||||
return { ready, pending, invariantFailed: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit a clean probe. Only ever called once every probe in the batch has
|
||||
* passed, so this cannot land a partial application.
|
||||
* @param {{ready:object[],pending:number}} probe
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function commitTranslucentMarkerBlend(probe) {
|
||||
for (const collection of probe.ready) {
|
||||
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
|
||||
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
|
||||
}
|
||||
}
|
||||
return {
|
||||
applied: probe.ready.length,
|
||||
pending: probe.pending,
|
||||
invariantFailed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force one translucent draw pass for a data source's marker collections.
|
||||
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
|
||||
* collection, and every cable marker is translucent (alpha < 1), so the
|
||||
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
|
||||
* reference points. Cesium has no public accessor for the entity-backed
|
||||
* collections, so this reaches through EntityCluster's private fields under
|
||||
* a SHAPE INVARIANT the caller must honor loudly: the constructor
|
||||
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
|
||||
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
|
||||
* shape changed — report `invariantFailed` and never touch anything —
|
||||
* while a present-but-undefined value only means "not created yet"
|
||||
* (`pending`). Wrong instance types also fail the invariant. The cables
|
||||
* sources never enable clustering, so created collections persist.
|
||||
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
|
||||
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
|
||||
* default blend — the fallback this helper promises.
|
||||
* @param {Cesium.DataSource|null|undefined} dataSource
|
||||
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
|
||||
*/
|
||||
|
||||
export function applyTranslucentMarkerBlend(dataSource) {
|
||||
const probe = probeTranslucentMarkerBlend(dataSource);
|
||||
if (probe.invariantFailed)
|
||||
return { applied: 0, pending: 0, invariantFailed: true };
|
||||
return commitTranslucentMarkerBlend(probe);
|
||||
}
|
||||
+312
-149
@@ -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,
|
||||
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/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,
|
||||
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
|
||||
'the compact wing must expose a semantic keyboard disclosure',
|
||||
);
|
||||
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
|
||||
const panels = readFileSync(
|
||||
new URL('./ui/panelDisclosure.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
|
||||
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
|
||||
|
||||
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
|
||||
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
'the application action uses the component selection path');
|
||||
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
|
||||
'chip clicks use the same component selection path');
|
||||
assert.match(controls, /await controller\.setStack\(stackId\)/,
|
||||
'selection still delegates source loading to the map controller');
|
||||
assert.match(controls, /render\(controller\.getState\(\)\)/,
|
||||
'the active chip is re-synced from actual controller state');
|
||||
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'provider-driven fallback reaches the component without a user click');
|
||||
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'the provider-driven subscription has an explicit remover');
|
||||
assert.match(controls, /unsubscribe\?\.\(\)/,
|
||||
'component destruction releases its subscription');
|
||||
|
||||
});
|
||||
|
||||
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(
|
||||
controller,
|
||||
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
|
||||
'construction failure must resolve truthfully to OSM',
|
||||
ui,
|
||||
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
|
||||
);
|
||||
|
||||
const controls = readFileSync(
|
||||
new URL('./ui/mapSourceControls.js', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
|
||||
'the active chip must follow the effective provider, not the requested stack',
|
||||
ui,
|
||||
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
|
||||
'the application action uses the component selection path',
|
||||
);
|
||||
assert.match(
|
||||
controller,
|
||||
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
|
||||
'Esri credit must follow the effective provider',
|
||||
controls,
|
||||
/onSelect: [\s\S]*?select\(id\)/,
|
||||
'chip clicks use the same component selection path',
|
||||
);
|
||||
});
|
||||
|
||||
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
|
||||
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
|
||||
assert.match(controller, /let failures = 0/);
|
||||
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
|
||||
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
|
||||
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
|
||||
assert.match(
|
||||
controller,
|
||||
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
|
||||
'a stale provider error must not replace a newer user selection',
|
||||
controls,
|
||||
/await controller\.setStack\(stackId\)/,
|
||||
'selection still delegates source loading to the map controller',
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/render\(controller\.getState\(\)\)/,
|
||||
'the active chip is re-synced from actual controller state',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.addEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'provider-driven fallback reaches the component without a user click',
|
||||
);
|
||||
assert.match(
|
||||
ui,
|
||||
/window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
|
||||
'the provider-driven subscription has an explicit remover',
|
||||
);
|
||||
assert.match(
|
||||
controls,
|
||||
/unsubscribe\?\.\(\)/,
|
||||
'component destruction releases its subscription',
|
||||
);
|
||||
});
|
||||
+25
-487
@@ -1,494 +1,32 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { MapSourceController } from './maps/controller.js';
|
||||
import { createDefaultMapSources } from './maps/defaultSources.js';
|
||||
import { governorRequestRender } from './renderGovernor.js';
|
||||
import { keySetupRequirement } from './keySetupCore.mjs';
|
||||
export { MAP_STACKS } from './maps/catalog.js';
|
||||
export { photorealUnavailableReason } from './maps/availability.js';
|
||||
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
* key or ion token configured, the tileset failed for another reason —
|
||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
||||
* user to add a key they already added is the wrong advice.
|
||||
* @param {boolean} hasCredentials
|
||||
* @returns {string}
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
|
||||
|
||||
// Esri World Imagery — the keyless satellite basemap and the default keyless
|
||||
// landing (a spy-satellite simulator should open on satellite imagery, not a
|
||||
// street map). The classic ArcGIS Online tile service answers without a key;
|
||||
// attribution is required and the provider carries the service's own credit
|
||||
// line. Terms note in DATA_SOURCES.md.
|
||||
const ESRI_WORLD_IMAGERY_URL =
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
|
||||
const ESRI_IMAGERY_CREDIT =
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
|
||||
// The on-screen notice Esri requires when a third-party library draws its
|
||||
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
|
||||
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
|
||||
const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
|
||||
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
|
||||
// regime C (keyless globe stacks previously rendered a flat
|
||||
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
|
||||
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
|
||||
// URL (review correction, spec §1a).
|
||||
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
|
||||
|
||||
/**
|
||||
* Controls the active globe/map stack. Google Photorealistic 3D Tiles remain
|
||||
* the cinematic default, while Cesium ion world imagery and OSM run as globe
|
||||
* imagery stacks.
|
||||
*/
|
||||
export class MapStackController {
|
||||
constructor(viewer, {
|
||||
googleTileset = null,
|
||||
cesiumToken = '',
|
||||
initialStack = 'photoreal',
|
||||
onChange = null,
|
||||
onError = null,
|
||||
} = {}) {
|
||||
this.viewer = viewer;
|
||||
this.googleTileset = googleTileset;
|
||||
this.cesiumToken = String(cesiumToken || '').trim();
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._activeId = googleTileset ? initialStack : 'esri-imagery';
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._esriFallbackPending = false;
|
||||
this._imageryProviders = new Map();
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
// Tracks which terrain PROVIDER is actually installed on the scene, not
|
||||
// just an ion-available boolean: 'world' (Cesium World Terrain, ion
|
||||
// token), 'keyless' (Re:Earth or its Ellipsoid fallback), or null (never
|
||||
// set yet — Cesium's own startup default). Using a tri-state here (rather
|
||||
// than the `enabled` boolean `_setWorldTerrainEnabled` receives) matters
|
||||
// because both the "never set" and "keyless" states pass `enabled=false`;
|
||||
// collapsing them to a boolean would make the first real keyless switch
|
||||
// a no-op against the initial `false` default and leave Cesium's built-in
|
||||
// provider in place instead of installing Re:Earth terrain.
|
||||
this._terrainMode = null;
|
||||
// Cache of the constructed keyless Re:Earth CesiumTerrainProvider, so
|
||||
// repeat switches into a keyless globe stack don't refetch `layer.json`.
|
||||
// Lives independently of `_switchGen` — construction is async and racy
|
||||
// switches are guarded where it's awaited (`_setWorldTerrainEnabled`).
|
||||
this._reearthTerrainProvider = null;
|
||||
// Monotonic switch counter. setStack() awaits network-bound provider
|
||||
// creation; a rapid A→B switch where A (e.g. slow Bing) resolves AFTER B
|
||||
// (fast OSM) would otherwise revert the user's last choice (M7). Each call
|
||||
// captures a generation and aborts its own commit once superseded.
|
||||
this._switchGen = 0;
|
||||
|
||||
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
|
||||
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
|
||||
}
|
||||
}
|
||||
|
||||
getStacks() {
|
||||
return MAP_STACKS.map((stack) => {
|
||||
const available = this.isStackAvailable(stack.id);
|
||||
return {
|
||||
...stack,
|
||||
available,
|
||||
// Why this stack can't be picked, from the ONE place that decides it.
|
||||
// A stack can be unavailable for reasons other than a missing ion
|
||||
// token (photoreal is unavailable when the Google tileset failed to
|
||||
// load), so callers must not infer the reason from `available` alone.
|
||||
unavailableReason: available ? null : this._unavailableReason(stack),
|
||||
};
|
||||
/** Preserve the standalone entry point; applications can compose the source controller directly. */
|
||||
export class MapStackController extends MapSourceController {
|
||||
constructor(viewer, options = {}) {
|
||||
const googleApiKey =
|
||||
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
const registry = createDefaultMapSources({ ...options, googleApiKey });
|
||||
super(viewer, {
|
||||
registry,
|
||||
initialStack: options.googleTileset
|
||||
? options.initialStack || 'photoreal'
|
||||
: registry.defaultId,
|
||||
...options,
|
||||
requestRender: governorRequestRender,
|
||||
});
|
||||
this.googleTileset = options.googleTileset || null;
|
||||
this.cesiumToken = String(options.cesiumToken || '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable reason a stack can't be activated. Shared by `getStacks()`
|
||||
* and `setStack()` so the tooltip and the toast never drift apart.
|
||||
* @param {object} stack - Stack descriptor.
|
||||
* @returns {string}
|
||||
*/
|
||||
_unavailableReason(stack) {
|
||||
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
|
||||
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
|
||||
return `${stack?.label || 'This map stack'} is unavailable`;
|
||||
}
|
||||
|
||||
/** A direct Google key or an ion token is enough to attempt Google 3D. */
|
||||
_hasPhotorealCredentials() {
|
||||
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
|
||||
}
|
||||
|
||||
getStack(id) {
|
||||
return MAP_STACKS.find((stack) => stack.id === id) || null;
|
||||
}
|
||||
|
||||
getActiveId() {
|
||||
return this._activeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monotonic id of the most recently STARTED switch.
|
||||
*
|
||||
* A switch is only superseded by another `setStack()` — nothing else moves
|
||||
* this number — so a caller that must know whether the globe it is looking
|
||||
* at is still the one IT asked for can compare this across its own await.
|
||||
* Unchanged (or advanced by exactly its own call) means no newer switch has
|
||||
* claimed the globe.
|
||||
* @returns {number}
|
||||
*/
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
|
||||
getActiveStack() {
|
||||
return this.getStack(this._activeId);
|
||||
}
|
||||
|
||||
isStackAvailable(id) {
|
||||
const stack = this.getStack(id);
|
||||
if (!stack) return false;
|
||||
if (stack.kind === 'photoreal') return !!this.googleTileset;
|
||||
if (stack.requiresIon) return !!this.cesiumToken;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Invalidate pending switches before the viewer is destroyed. */
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._switchGen++;
|
||||
this._isSwitching = false;
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
this._onChange = null;
|
||||
this._onError = null;
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack('photoreal');
|
||||
if (!stack) return null;
|
||||
|
||||
if (!this.isStackAvailable(stack.id)) {
|
||||
const message = this._unavailableReason(stack);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
const gen = ++this._switchGen;
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
|
||||
try {
|
||||
let activation = null;
|
||||
if (stack.kind === 'photoreal') {
|
||||
await this._activatePhotoreal(gen);
|
||||
} else {
|
||||
activation = await this._activateGlobeStack(stack, gen);
|
||||
}
|
||||
// A newer switch started while we were awaiting the provider — that call
|
||||
// owns the final state now, so don't commit ours or emit a stale 'ready'.
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
|
||||
// subsequent tile loads self-request via Cesium. (perf wave 2)
|
||||
governorRequestRender('map-stack');
|
||||
if (!silent) this._emitChange('ready');
|
||||
} catch (error) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
const message = error?.message || String(error);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
if (this.googleTileset) {
|
||||
await this._activatePhotoreal(gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = 'photoreal';
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
// Only the latest switch clears the switching flag; a superseded call
|
||||
// must not stomp a newer switch that is still in progress.
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
}
|
||||
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
activeStack: this.getActiveStack(),
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
hasCesiumIonToken: !!this.cesiumToken,
|
||||
};
|
||||
}
|
||||
|
||||
async _activatePhotoreal(gen) {
|
||||
this._removeImageryLayer();
|
||||
this._syncEsriAttribution(null); // Esri is no longer on screen.
|
||||
if (this.googleTileset) this.googleTileset.show = true;
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
|
||||
// (`globe.show = false`), so the terrain provider is inert — it renders and
|
||||
// streams nothing. Routing this through `_setWorldTerrainEnabled(false)`
|
||||
// would make the DEFAULT startup stack await a keyless Re:Earth `layer.json`
|
||||
// fetch it can't use, delaying photoreal boot on a slow/blocked network and
|
||||
// (on failure) caching the flat `EllipsoidTerrainProvider` fallback for
|
||||
// later OSM switches. The Re:Earth fetch is therefore lazy: it happens on
|
||||
// the first switch to an actual globe stack (`_activateGlobeStack`).
|
||||
// `_terrainMode` is intentionally not changed — every globe-stack transition
|
||||
// re-derives the correct provider from it (null/'world'/'keyless'), so
|
||||
// leaving it as-is keeps the next switch correct without a photoreal fetch.
|
||||
void gen;
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
// A newer switch started while the provider was resolving — don't touch the
|
||||
// scene's imagery layers, the winning switch already owns them (M7).
|
||||
if (gen != null && gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
|
||||
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
this._syncEsriAttribution(resolution.effectiveStackId);
|
||||
this._watchEsriProvider(resolution, gen);
|
||||
|
||||
if (this.googleTileset) this.googleTileset.show = false;
|
||||
this.viewer.scene.globe.show = true;
|
||||
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show or hide the required "Powered by Esri" notice with the Esri layer's
|
||||
* own lifecycle.
|
||||
*
|
||||
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
|
||||
* option for tiled ArcGIS MapServer sources, so passing it there displays
|
||||
* nothing and the app would be using the service without the attribution
|
||||
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
|
||||
* lightbox, where per-layer data credits live) because that is what the
|
||||
* requirement asks for, and it is removed when another stack takes over so
|
||||
* the globe never claims a source it is not showing.
|
||||
*/
|
||||
_syncEsriAttribution(activeStackId) {
|
||||
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!creditDisplay) return;
|
||||
const wanted = activeStackId === 'esri-imagery';
|
||||
if (wanted === !!this._esriCreditShown) return;
|
||||
if (!this._esriCredit) {
|
||||
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
|
||||
}
|
||||
try {
|
||||
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
|
||||
else creditDisplay.removeStaticCredit(this._esriCredit);
|
||||
this._esriCreditShown = wanted;
|
||||
} catch {
|
||||
// A Cesium build without static-credit removal must not break switching.
|
||||
}
|
||||
}
|
||||
|
||||
async _getImageryProvider(stack) {
|
||||
if (this._imageryProviders.has(stack.id)) {
|
||||
return this._imageryProviders.get(stack.id);
|
||||
}
|
||||
|
||||
let provider;
|
||||
let effectiveStackId = stack.id;
|
||||
let fallbackMessage = null;
|
||||
if (stack.kind === 'ion') {
|
||||
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
|
||||
} else if (stack.kind === 'esri-imagery') {
|
||||
try {
|
||||
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
|
||||
credit: ESRI_IMAGERY_CREDIT,
|
||||
enablePickFeatures: false,
|
||||
});
|
||||
} catch (error) {
|
||||
// The keyless DEFAULT landing must never strand a first run on a blank
|
||||
// globe because Esri is unreachable — fall back to OSM tiles for this
|
||||
// session. (The fallback is cached under this stack id like any other
|
||||
// provider, so the session won't re-probe Esri; a restart does.)
|
||||
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: DEFAULT_OSM_CREDIT,
|
||||
});
|
||||
effectiveStackId = 'osm';
|
||||
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
|
||||
}
|
||||
} else if (stack.kind === 'osm') {
|
||||
provider = new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: DEFAULT_OSM_CREDIT,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported map stack: ${stack.id}`);
|
||||
}
|
||||
|
||||
const resolution = { provider, effectiveStackId, fallbackMessage };
|
||||
this._imageryProviders.set(stack.id, resolution);
|
||||
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
|
||||
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
/**
|
||||
* Esri provider construction can succeed while its first tile requests fail.
|
||||
* Two failures for the active provider trigger the same truthful OSM fallback
|
||||
* as a construction failure; one transient error is left to Cesium's retry.
|
||||
*/
|
||||
_watchEsriProvider(resolution, gen) {
|
||||
if (resolution.effectiveStackId !== 'esri-imagery') return;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures = Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < 2 || this._esriFallbackPending) return;
|
||||
this._esriFallbackPending = true;
|
||||
const message = 'Esri Satellite tile requests failed; using OSM';
|
||||
this._onError?.(message, this.getStack('esri-imagery'));
|
||||
void this.setStack('osm', { silent: true }).then((state) => {
|
||||
if (state?.activeId === 'osm') {
|
||||
this._lastError = message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
}).finally(() => {
|
||||
this._esriFallbackPending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
if (this._removeImageryErrorListener) {
|
||||
this._removeImageryErrorListener();
|
||||
this._removeImageryErrorListener = null;
|
||||
}
|
||||
if (!this._imageryLayer) return;
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, false);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the scene's terrain provider for the current globe stack.
|
||||
*
|
||||
* `enabled` selects Cesium World Terrain (ion token present — regime B,
|
||||
* unchanged). Disabled/keyless (regime C: OSM or any globe stack without an
|
||||
* ion token) now tries the keyless Re:Earth ellipsoidal terrain instead of
|
||||
* the flat `EllipsoidTerrainProvider`, falling back to the flat provider
|
||||
* (today's behavior) if construction fails — no worse than before this fix.
|
||||
*
|
||||
* `CesiumTerrainProvider.fromUrl()` is async (fetches `layer.json`), so this
|
||||
* method is async-safe: `gen` is the caller's switch generation (from
|
||||
* `setStack`'s `_switchGen`, threaded through `_activatePhotoreal` /
|
||||
* `_activateGlobeStack`, mirroring the M7 pattern in `_activateGlobeStack`
|
||||
* for imagery providers). If a newer switch starts while the Re:Earth
|
||||
* fetch is in flight, this call's result is discarded instead of
|
||||
* clobbering the newer switch's terrain.
|
||||
* @param {boolean} enabled
|
||||
* @param {number} [gen] — switch generation this call belongs to
|
||||
*/
|
||||
async _setWorldTerrainEnabled(enabled, gen) {
|
||||
const targetMode = enabled ? 'world' : 'keyless';
|
||||
if (targetMode === this._terrainMode) return;
|
||||
if (enabled) {
|
||||
this.viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain({
|
||||
requestVertexNormals: true,
|
||||
}));
|
||||
} else {
|
||||
const provider = await this._getKeylessTerrainProvider();
|
||||
// A newer switch started while the Re:Earth layer.json fetch was in
|
||||
// flight — that call owns terrain now; don't stomp it (M7 pattern).
|
||||
if (gen != null && gen !== this._switchGen) return;
|
||||
this.viewer.terrainProvider = provider;
|
||||
}
|
||||
this._terrainMode = targetMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves (and caches) the keyless terrain provider for globe stacks
|
||||
* without an ion token: Re:Earth ellipsoidal quantized-mesh terrain, or
|
||||
* `EllipsoidTerrainProvider` (flat — current/prior behavior) if the
|
||||
* Re:Earth endpoint can't be constructed. Never throws.
|
||||
* @returns {Promise<Cesium.TerrainProvider>}
|
||||
*/
|
||||
async _getKeylessTerrainProvider() {
|
||||
if (this._reearthTerrainProvider) return this._reearthTerrainProvider;
|
||||
try {
|
||||
this._reearthTerrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(REEARTH_TERRAIN_URL);
|
||||
} catch (error) {
|
||||
console.warn('[mapStackController] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error);
|
||||
this._reearthTerrainProvider = new Cesium.EllipsoidTerrainProvider();
|
||||
}
|
||||
return this._reearthTerrainProvider;
|
||||
}
|
||||
|
||||
_emitChange(status) {
|
||||
this._onChange?.(this.getState(status));
|
||||
const googleKey =
|
||||
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
|
||||
return (
|
||||
Boolean(String(googleKey || '').trim()) ||
|
||||
Boolean(String(this.cesiumToken || '').trim())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
|
||||
import {
|
||||
MapStackController,
|
||||
photorealUnavailableReason,
|
||||
} from './mapStackController.js';
|
||||
|
||||
test('missing photoreal credentials explain both supported setup routes', () => {
|
||||
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
|
||||
assert.match(
|
||||
photorealUnavailableReason(false),
|
||||
/Needs GOOGLE_MAPS_API_KEY.*Provider Settings/,
|
||||
);
|
||||
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
|
||||
});
|
||||
|
||||
|
||||
+4
-51
@@ -1,51 +1,4 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
|
||||
try {
|
||||
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
Cesium.GoogleMaps.defaultApiKey = undefined;
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
export {
|
||||
selectMapStartupRoute,
|
||||
loadPhotorealisticTileset,
|
||||
} from './maps/google3d.js';
|
||||
+50
-12
@@ -7,25 +7,42 @@ import {
|
||||
|
||||
function fakeCesium(outcomes = []) {
|
||||
const calls = [];
|
||||
const next = () => {
|
||||
const outcome = outcomes.shift();
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
Ion: { defaultAccessToken: undefined },
|
||||
GoogleMaps: { defaultApiKey: undefined },
|
||||
async createGooglePhotorealistic3DTileset(options) {
|
||||
calls.push({
|
||||
options,
|
||||
googleKey: this.GoogleMaps.defaultApiKey,
|
||||
ionToken: this.Ion.defaultAccessToken,
|
||||
});
|
||||
const outcome = outcomes.shift();
|
||||
if (outcome instanceof Error) throw outcome;
|
||||
return outcome;
|
||||
calls.push({ options, googleKey: options.key });
|
||||
return next();
|
||||
},
|
||||
IonResource: {
|
||||
async fromAssetId(assetId, options) {
|
||||
return { assetId, ...options };
|
||||
},
|
||||
},
|
||||
Cesium3DTileset: {
|
||||
async fromUrl(resource, options) {
|
||||
calls.push({
|
||||
options,
|
||||
ionToken: resource.accessToken,
|
||||
assetId: resource.assetId,
|
||||
});
|
||||
return next();
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('map startup route reflects the best configured provider', () => {
|
||||
assert.equal(selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }), 'google-direct');
|
||||
assert.equal(
|
||||
selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }),
|
||||
'google-direct',
|
||||
);
|
||||
assert.equal(selectMapStartupRoute({ cesiumToken: 'ion' }), 'google-ion');
|
||||
assert.equal(selectMapStartupRoute(), 'osm');
|
||||
});
|
||||
@@ -53,13 +70,17 @@ test('a direct Google key is preferred', async () => {
|
||||
test('an ion-only setup loads the hosted Google 3D asset', async () => {
|
||||
const tileset = { id: 'ion' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, { cesiumToken: 'ion-secret' });
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.tileset, tileset);
|
||||
assert.equal(result.route, 'google-ion');
|
||||
assert.equal(Cesium.calls.length, 1);
|
||||
assert.equal(Cesium.calls[0].googleKey, undefined);
|
||||
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'ion-secret');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, undefined);
|
||||
assert.equal(Cesium.calls[0].assetId, 2275207);
|
||||
assert.equal(Cesium.calls[0].options.enableCollision, true);
|
||||
});
|
||||
|
||||
test('a failed direct request retries through ion before falling back', async () => {
|
||||
@@ -91,7 +112,10 @@ test('a failed direct-only request does not consume an implicit Cesium token', a
|
||||
});
|
||||
|
||||
test('failed direct and ion requests preserve the keyless OSM fallback', async () => {
|
||||
const Cesium = fakeCesium([new Error('direct denied'), new Error('ion denied')]);
|
||||
const Cesium = fakeCesium([
|
||||
new Error('direct denied'),
|
||||
new Error('ion denied'),
|
||||
]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey: 'google-secret',
|
||||
cesiumToken: 'ion-secret',
|
||||
@@ -102,3 +126,17 @@ test('failed direct and ion requests preserve the keyless OSM fallback', async (
|
||||
assert.equal(Cesium.calls.length, 2);
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
|
||||
});
|
||||
|
||||
test('independent source configurations never mutate shared SDK credentials', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'a' }, { id: 'b' }]);
|
||||
Cesium.Ion.defaultAccessToken = 'untouched-ion';
|
||||
Cesium.GoogleMaps.defaultApiKey = 'untouched-google';
|
||||
await Promise.all([
|
||||
loadPhotorealisticTileset(Cesium, { googleApiKey: 'source-a' }),
|
||||
loadPhotorealisticTileset(Cesium, { cesiumToken: 'source-b' }),
|
||||
]);
|
||||
assert.equal(Cesium.calls[0].googleKey, 'source-a');
|
||||
assert.equal(Cesium.calls[1].ionToken, 'source-b');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
/**
|
||||
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
|
||||
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
|
||||
* key or ion token configured, the tileset failed for another reason —
|
||||
* restrictions, quota, an EEA-billed key, or the network — and telling the
|
||||
* user to add a key they already added is the wrong advice.
|
||||
* @param {boolean} hasCredentials
|
||||
* @returns {string}
|
||||
*/
|
||||
export function photorealUnavailableReason(hasCredentials) {
|
||||
if (hasCredentials)
|
||||
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
|
||||
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as Cesium from 'cesium';
|
||||
export const MAP_STACKS = [
|
||||
{
|
||||
id: 'photoreal',
|
||||
label: 'Google 3D',
|
||||
shortLabel: '3D',
|
||||
kind: 'photoreal',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'bing-aerial',
|
||||
label: 'Bing Aerial',
|
||||
shortLabel: 'Aerial',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'bing-labels',
|
||||
label: 'Bing Labels',
|
||||
shortLabel: 'Labels',
|
||||
kind: 'ion',
|
||||
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
|
||||
requiresIon: true,
|
||||
},
|
||||
{
|
||||
id: 'esri-imagery',
|
||||
label: 'Esri Satellite',
|
||||
shortLabel: 'SAT',
|
||||
kind: 'esri-imagery',
|
||||
requiresIon: false,
|
||||
},
|
||||
{
|
||||
id: 'osm',
|
||||
label: 'OSM',
|
||||
shortLabel: 'OSM',
|
||||
kind: 'osm',
|
||||
requiresIon: false,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,337 @@
|
||||
import { indexMapSources } from './registry.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createMapCredits } from './credits.js';
|
||||
|
||||
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
|
||||
export class MapSourceController {
|
||||
constructor(
|
||||
viewer,
|
||||
{
|
||||
registry,
|
||||
initialStack,
|
||||
onChange = null,
|
||||
onError = null,
|
||||
requestRender = () => viewer?.scene?.requestRender?.(),
|
||||
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
|
||||
},
|
||||
) {
|
||||
this.viewer = viewer;
|
||||
this._registry = registry;
|
||||
this._sources = indexMapSources(registry.sources);
|
||||
this._activeId = this.isStackAvailable(initialStack)
|
||||
? initialStack
|
||||
: registry.defaultId;
|
||||
this._onChange = onChange;
|
||||
this._onError = onError;
|
||||
this._requestRender = requestRender;
|
||||
this._createImageryLayer = createImageryLayer;
|
||||
this._credits = createMapCredits(viewer);
|
||||
this._abort = new AbortController();
|
||||
this._imageryProviders = new Map();
|
||||
this._terrainProviders = new Map();
|
||||
this._tilesets = new Map();
|
||||
this._ownedTilesets = new Set();
|
||||
this._disposed = new WeakSet();
|
||||
this._switchGen = 0;
|
||||
this._isSwitching = false;
|
||||
this._lastError = null;
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
this._removeImageryErrorListener = null;
|
||||
this._terrainMode = null;
|
||||
this._destroyed = false;
|
||||
}
|
||||
|
||||
getStack(id) {
|
||||
return this._sources.get(id)?.descriptor || null;
|
||||
}
|
||||
getStacks() {
|
||||
return [...this._sources.values()].map(({ descriptor }) => {
|
||||
const stack = descriptor;
|
||||
const available = this.isStackAvailable(stack.id);
|
||||
return {
|
||||
...stack,
|
||||
available,
|
||||
unavailableReason: available ? null : this._unavailableReason(stack),
|
||||
};
|
||||
});
|
||||
}
|
||||
isStackAvailable(id) {
|
||||
const source = this._sources.get(id);
|
||||
return Boolean(source && source.available !== false);
|
||||
}
|
||||
_unavailableReason(stack) {
|
||||
return (
|
||||
this._sources.get(stack?.id)?.unavailableReason ||
|
||||
`${stack?.label || 'This map stack'} is unavailable`
|
||||
);
|
||||
}
|
||||
getActiveId() {
|
||||
return this._activeId;
|
||||
}
|
||||
getActiveStack() {
|
||||
return this.getStack(this._activeId);
|
||||
}
|
||||
getSwitchGeneration() {
|
||||
return this._switchGen;
|
||||
}
|
||||
getState(status = this._isSwitching ? 'switching' : 'ready') {
|
||||
return {
|
||||
activeId: this._activeId,
|
||||
activeStack: this.getActiveStack(),
|
||||
stacks: this.getStacks(),
|
||||
status,
|
||||
lastError: this._lastError,
|
||||
...this._registry.state,
|
||||
};
|
||||
}
|
||||
_emitChange(status) {
|
||||
this._onChange?.(this.getState(status));
|
||||
}
|
||||
|
||||
async setStack(id, { silent = false } = {}) {
|
||||
if (this._destroyed) return this.getState();
|
||||
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
|
||||
if (!stack) return null;
|
||||
if (!this.isStackAvailable(stack.id)) {
|
||||
const message = this._unavailableReason(stack);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
return this.getState();
|
||||
}
|
||||
const gen = ++this._switchGen;
|
||||
this._isSwitching = true;
|
||||
this._lastError = null;
|
||||
if (!silent) this._emitChange('switching');
|
||||
try {
|
||||
const activation = await this._activate(stack, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || stack.id;
|
||||
if (activation?.fallbackMessage) {
|
||||
this._lastError = activation.fallbackMessage;
|
||||
this._onError?.(activation.fallbackMessage, stack);
|
||||
}
|
||||
this._requestRender('map-stack');
|
||||
if (!silent) this._emitChange('ready');
|
||||
} catch (error) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
const message = error?.message || String(error);
|
||||
this._lastError = message;
|
||||
this._onError?.(message, stack);
|
||||
const recovery = this.getStack(this._registry.recoveryId);
|
||||
if (
|
||||
recovery &&
|
||||
recovery.id !== stack.id &&
|
||||
this.isStackAvailable(recovery.id)
|
||||
) {
|
||||
try {
|
||||
const activation = await this._activate(recovery, gen);
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._activeId = activation?.effectiveStackId || recovery.id;
|
||||
} catch (recoveryError) {
|
||||
if (gen !== this._switchGen) return this.getState();
|
||||
this._lastError = recoveryError?.message || String(recoveryError);
|
||||
this._onError?.(this._lastError, recovery);
|
||||
}
|
||||
}
|
||||
if (!silent) this._emitChange('error');
|
||||
} finally {
|
||||
if (gen === this._switchGen) this._isSwitching = false;
|
||||
}
|
||||
return this.getState();
|
||||
}
|
||||
|
||||
_activate(stack, gen) {
|
||||
const source = this._sources.get(stack.id);
|
||||
return source.imagery
|
||||
? this._activateGlobeStack(stack, gen)
|
||||
: this._activateTileset(source, gen);
|
||||
}
|
||||
|
||||
async _activateTileset(source, gen) {
|
||||
let tileset = source.tileset;
|
||||
if (!tileset) {
|
||||
if (!source.createTileset)
|
||||
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
|
||||
tileset = await this._cached(this._tilesets, source.descriptor.id, () =>
|
||||
source.createTileset({ signal: this._abort.signal }),
|
||||
);
|
||||
}
|
||||
if (gen !== this._switchGen) return;
|
||||
if (!source.tileset && !this._ownedTilesets.has(tileset)) {
|
||||
tileset.show = false;
|
||||
this.viewer.scene.primitives.add(tileset);
|
||||
this._ownedTilesets.add(tileset);
|
||||
}
|
||||
this._removeImageryLayer();
|
||||
this._credits.show(source.credit || null);
|
||||
this._showTileset(tileset);
|
||||
this.viewer.scene.globe.show = false;
|
||||
// Terrain is intentionally untouched while the globe is hidden.
|
||||
}
|
||||
|
||||
_showTileset(active) {
|
||||
for (const source of this._sources.values())
|
||||
if (source.tileset) source.tileset.show = source.tileset === active;
|
||||
for (const tileset of this._ownedTilesets)
|
||||
tileset.show = tileset === active;
|
||||
}
|
||||
|
||||
async _activateGlobeStack(stack, gen) {
|
||||
const resolution = await this._getImageryProvider(stack);
|
||||
if (gen !== this._switchGen) return;
|
||||
this._removeImageryLayer();
|
||||
this._imageryLayer = this._createImageryLayer(resolution.provider);
|
||||
this._activeImageryProvider = resolution.provider;
|
||||
this.viewer.imageryLayers.add(this._imageryLayer, 0);
|
||||
const source = this._sources.get(resolution.effectiveStackId);
|
||||
this._credits.show(source?.credit || null);
|
||||
this._watchProvider(resolution, gen);
|
||||
this._showTileset(null);
|
||||
this.viewer.scene.globe.show = true;
|
||||
this._activeId = resolution.effectiveStackId;
|
||||
if (source?.terrain && source.terrain.id !== this._terrainMode) {
|
||||
const terrain = source.terrain;
|
||||
const result = await this._cached(
|
||||
this._terrainProviders,
|
||||
terrain.id,
|
||||
() => terrain.create({ signal: this._abort.signal }),
|
||||
);
|
||||
if (gen !== this._switchGen) return;
|
||||
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
|
||||
else this.viewer.terrainProvider = result.provider;
|
||||
this._terrainMode = terrain.id;
|
||||
}
|
||||
return resolution;
|
||||
}
|
||||
|
||||
_cached(cache, id, create) {
|
||||
if (cache.has(id)) return cache.get(id);
|
||||
const promise = Promise.resolve()
|
||||
.then(() => {
|
||||
this._abort.signal.throwIfAborted();
|
||||
return create();
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cache.get(id) === promise) cache.delete(id);
|
||||
throw error;
|
||||
});
|
||||
cache.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
_getImageryProvider(stack, visited = new Set()) {
|
||||
if (visited.has(stack.id))
|
||||
return Promise.reject(new Error('Map source fallback cycle'));
|
||||
return this._cached(this._imageryProviders, stack.id, async () => {
|
||||
const source = this._sources.get(stack.id);
|
||||
try {
|
||||
const provider = await source.imagery({ signal: this._abort.signal });
|
||||
if (this._destroyed) this._dispose(provider);
|
||||
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
|
||||
} catch (error) {
|
||||
const fallback = source.constructionFallback;
|
||||
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
|
||||
throw error;
|
||||
const next = new Set(visited).add(stack.id);
|
||||
const resolution = await this._getImageryProvider(
|
||||
this.getStack(fallback.id),
|
||||
next,
|
||||
);
|
||||
return { ...resolution, fallbackMessage: fallback.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_watchProvider(resolution, gen) {
|
||||
const fallback = this._sources.get(
|
||||
resolution.effectiveStackId,
|
||||
)?.tileFailureFallback;
|
||||
const errorEvent = resolution.provider?.errorEvent;
|
||||
if (!fallback || !errorEvent?.addEventListener) return;
|
||||
let failures = 0;
|
||||
let pending = false;
|
||||
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
|
||||
if (
|
||||
gen !== this._switchGen ||
|
||||
this._activeImageryProvider !== resolution.provider
|
||||
)
|
||||
return;
|
||||
const retryCount = Number(error?.timesRetried);
|
||||
failures =
|
||||
Number.isInteger(retryCount) && retryCount >= 0
|
||||
? Math.max(failures + 1, retryCount + 1)
|
||||
: failures + 1;
|
||||
if (failures < fallback.threshold || pending) return;
|
||||
pending = true;
|
||||
this._onError?.(
|
||||
fallback.message,
|
||||
this.getStack(resolution.effectiveStackId),
|
||||
);
|
||||
const expectedGen = this._switchGen + 1;
|
||||
void this.setStack(fallback.id, { silent: true })
|
||||
.then((state) => {
|
||||
if (
|
||||
!this._destroyed &&
|
||||
this._switchGen === expectedGen &&
|
||||
state?.activeId === fallback.id
|
||||
) {
|
||||
this._lastError = fallback.message;
|
||||
this._emitChange('error');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
pending = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_removeImageryLayer() {
|
||||
this._removeImageryErrorListener?.();
|
||||
this._removeImageryErrorListener = null;
|
||||
if (this._imageryLayer)
|
||||
this.viewer.imageryLayers.remove(this._imageryLayer, true);
|
||||
this._imageryLayer = null;
|
||||
this._activeImageryProvider = null;
|
||||
}
|
||||
_dispose(value) {
|
||||
if (!value || this._disposed.has(value)) return;
|
||||
this._disposed.add(value);
|
||||
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
|
||||
value.destroy();
|
||||
}
|
||||
destroy() {
|
||||
if (this._destroyed) return;
|
||||
this._destroyed = true;
|
||||
this._switchGen++;
|
||||
this._abort.abort();
|
||||
this._isSwitching = false;
|
||||
this._removeImageryLayer();
|
||||
this._credits.destroy();
|
||||
for (const promise of this._imageryProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const promise of this._terrainProviders.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value.provider),
|
||||
() => {},
|
||||
);
|
||||
for (const tileset of this._ownedTilesets) {
|
||||
this.viewer.scene.primitives.remove(tileset);
|
||||
this._dispose(tileset);
|
||||
}
|
||||
for (const promise of this._tilesets.values())
|
||||
void Promise.resolve(promise).then(
|
||||
(value) => this._dispose(value),
|
||||
() => {},
|
||||
);
|
||||
this._imageryProviders.clear();
|
||||
this._terrainProviders.clear();
|
||||
this._tilesets.clear();
|
||||
this._ownedTilesets.clear();
|
||||
this._onChange = null;
|
||||
this._onError = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MapSourceController } from './controller.js';
|
||||
import { createDefaultMapSources } from './defaultSources.js';
|
||||
|
||||
function event() {
|
||||
const listeners = new Set();
|
||||
return {
|
||||
addEventListener(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
raise(value = {}) {
|
||||
for (const fn of [...listeners]) fn(value);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
function fixture(registry, options = {}) {
|
||||
const imagery = [],
|
||||
credits = new Set(),
|
||||
primitives = [],
|
||||
removed = [],
|
||||
changes = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
globe: { show: true },
|
||||
requestRender() {},
|
||||
frameState: {
|
||||
creditDisplay: {
|
||||
addStaticCredit(value) {
|
||||
credits.add(value);
|
||||
},
|
||||
removeStaticCredit(value) {
|
||||
credits.delete(value);
|
||||
},
|
||||
},
|
||||
},
|
||||
primitives: {
|
||||
add(value) {
|
||||
primitives.push(value);
|
||||
},
|
||||
remove(value) {
|
||||
primitives.splice(primitives.indexOf(value), 1);
|
||||
value.destroy?.();
|
||||
},
|
||||
},
|
||||
},
|
||||
imageryLayers: {
|
||||
add(value) {
|
||||
imagery.push(value);
|
||||
},
|
||||
remove(value, destroy) {
|
||||
imagery.splice(imagery.indexOf(value), 1);
|
||||
removed.push({ value, destroy });
|
||||
},
|
||||
},
|
||||
};
|
||||
const controller = new MapSourceController(viewer, {
|
||||
registry,
|
||||
createImageryLayer: (provider) => ({ provider }),
|
||||
onChange: (state) => changes.push(state),
|
||||
...options,
|
||||
});
|
||||
return { viewer, controller, imagery, credits, primitives, removed, changes };
|
||||
}
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||
};
|
||||
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
|
||||
function publicFixture() {
|
||||
const tileset = { show: true };
|
||||
const registry = createDefaultMapSources({ googleTileset: tileset });
|
||||
const providers = new Map();
|
||||
for (const source of registry.sources) {
|
||||
if (!source.imagery) continue;
|
||||
const provider = { id: source.descriptor.id, errorEvent: event() };
|
||||
providers.set(source.descriptor.id, provider);
|
||||
source.imagery = async () => provider;
|
||||
source.terrain = {
|
||||
id: 'keyless',
|
||||
create: async () => ({ provider: { id: 'terrain' } }),
|
||||
};
|
||||
}
|
||||
return { ...fixture(registry), registry, providers, tileset };
|
||||
}
|
||||
|
||||
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
|
||||
let reads = 0,
|
||||
destroys = 0,
|
||||
terrainReads = 0;
|
||||
const provider = {
|
||||
destroy() {
|
||||
destroys++;
|
||||
},
|
||||
};
|
||||
const terrain = {
|
||||
id: 'custom-floor',
|
||||
create: async () => {
|
||||
terrainReads++;
|
||||
return { provider: { id: 'terrain' } };
|
||||
},
|
||||
};
|
||||
const registry = {
|
||||
defaultId: 'custom',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('custom'),
|
||||
imagery: async () => {
|
||||
reads++;
|
||||
return provider;
|
||||
},
|
||||
terrain,
|
||||
},
|
||||
],
|
||||
};
|
||||
const env = fixture(registry);
|
||||
await env.controller.setStack('custom');
|
||||
await env.controller.setStack('custom');
|
||||
assert.equal(env.controller.getActiveId(), 'custom');
|
||||
assert.equal(env.viewer.terrainProvider.id, 'terrain');
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(terrainReads, 1);
|
||||
assert.equal(env.imagery.length, 1);
|
||||
assert.equal(env.removed[0].destroy, true);
|
||||
env.controller.destroy();
|
||||
env.controller.destroy();
|
||||
await settle();
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.equal(destroys, 1);
|
||||
});
|
||||
|
||||
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
|
||||
let resolve,
|
||||
signal,
|
||||
destroyed = 0;
|
||||
const env = fixture({
|
||||
defaultId: 'slow',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('slow'),
|
||||
imagery: (request) => {
|
||||
signal = request.signal;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('slow');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
assert.equal(signal.aborted, true);
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
await settle();
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.imagery.length, 0);
|
||||
assert.deepEqual(
|
||||
env.changes.map((state) => state.status),
|
||||
['switching'],
|
||||
);
|
||||
});
|
||||
|
||||
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
|
||||
const env = publicFixture();
|
||||
let resolve,
|
||||
reads = 0;
|
||||
const osm = env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'osm',
|
||||
);
|
||||
osm.terrain = {
|
||||
id: 'slow-floor',
|
||||
create: () => {
|
||||
reads++;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
};
|
||||
await env.controller.setStack('photoreal');
|
||||
assert.equal(reads, 0);
|
||||
const loading = env.controller.setStack('osm');
|
||||
await settle();
|
||||
await env.controller.setStack('photoreal');
|
||||
resolve({ provider: { id: 'late-terrain' } });
|
||||
await loading;
|
||||
assert.equal(env.viewer.terrainProvider, undefined);
|
||||
assert.equal(env.viewer.scene.globe.show, false);
|
||||
assert.equal(env.tileset.show, true);
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
|
||||
const env = publicFixture();
|
||||
env.registry.sources.find(
|
||||
(source) => source.descriptor.id === 'esri-imagery',
|
||||
).imagery = async () => {
|
||||
throw new Error('unreachable');
|
||||
};
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite is unavailable; using OSM',
|
||||
);
|
||||
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
|
||||
assert.equal(env.credits.size, 0);
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
|
||||
const env = publicFixture();
|
||||
await env.controller.setStack('esri-imagery');
|
||||
assert.equal(env.credits.size, 1);
|
||||
const errorEvent = env.providers.get('esri-imagery').errorEvent;
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'esri-imagery');
|
||||
errorEvent.raise();
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'osm');
|
||||
assert.equal(env.credits.size, 0);
|
||||
assert.equal(
|
||||
env.controller.getState().lastError,
|
||||
'Esri Satellite tile requests failed; using OSM',
|
||||
);
|
||||
assert.equal(errorEvent.size, 0);
|
||||
await env.controller.setStack('photoreal');
|
||||
errorEvent.raise({ timesRetried: 9 });
|
||||
await settle();
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
|
||||
const errors = [],
|
||||
env = publicFixture();
|
||||
env.controller._onError = (message) => errors.push(message);
|
||||
const reason = env.controller
|
||||
.getStacks()
|
||||
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
|
||||
await env.controller.setStack('bing-aerial');
|
||||
assert.deepEqual(errors, [reason]);
|
||||
assert.match(reason, /CESIUM_ION_TOKEN/);
|
||||
await env.controller.setStack('bing-road');
|
||||
assert.equal(env.controller.getActiveId(), 'photoreal');
|
||||
env.controller.destroy();
|
||||
});
|
||||
|
||||
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
|
||||
let resolve,
|
||||
destroyed = 0;
|
||||
const supplied = {
|
||||
show: true,
|
||||
destroy() {
|
||||
assert.fail('caller-owned tileset');
|
||||
},
|
||||
};
|
||||
const env = fixture({
|
||||
defaultId: 'three',
|
||||
sources: [
|
||||
{
|
||||
descriptor: { id: 'three' },
|
||||
createTileset: () =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
},
|
||||
{ descriptor: { id: 'supplied' }, tileset: supplied },
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('three');
|
||||
await settle();
|
||||
env.controller.destroy();
|
||||
resolve({
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
});
|
||||
await loading;
|
||||
assert.equal(destroyed, 1);
|
||||
assert.equal(env.primitives.length, 0);
|
||||
});
|
||||
|
||||
test('invalid source graphs fail before constructing or caching a provider', () => {
|
||||
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
|
||||
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
|
||||
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
|
||||
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
|
||||
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
|
||||
});
|
||||
|
||||
for (const reactivate of [false, true]) {
|
||||
test(`a superseded 3D load stays outside the scene and is released (reactivate: ${reactivate})`, async () => {
|
||||
let resolve,
|
||||
destroyed = 0,
|
||||
reads = 0;
|
||||
const tileset = {
|
||||
show: true,
|
||||
isDestroyed: () => destroyed > 0,
|
||||
destroy() {
|
||||
destroyed++;
|
||||
},
|
||||
};
|
||||
const supplied = { show: true };
|
||||
const env = fixture({
|
||||
defaultId: 'slow',
|
||||
sources: [
|
||||
{
|
||||
descriptor: { id: 'slow' },
|
||||
createTileset: () => {
|
||||
reads++;
|
||||
return new Promise((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
},
|
||||
},
|
||||
{ descriptor: { id: 'supplied' }, tileset: supplied },
|
||||
],
|
||||
});
|
||||
const loading = env.controller.setStack('slow');
|
||||
await settle();
|
||||
await env.controller.setStack('supplied');
|
||||
resolve(tileset);
|
||||
await loading;
|
||||
assert.equal(env.primitives.length, 0);
|
||||
assert.equal(supplied.show, true);
|
||||
assert.equal(env.controller.getActiveId(), 'supplied');
|
||||
if (reactivate) {
|
||||
await env.controller.setStack('slow');
|
||||
await env.controller.setStack('slow');
|
||||
assert.deepEqual(env.primitives, [tileset]);
|
||||
assert.equal(reads, 1);
|
||||
assert.equal(tileset.show, true);
|
||||
assert.equal(supplied.show, false);
|
||||
}
|
||||
env.controller.destroy();
|
||||
await settle();
|
||||
assert.equal(env.primitives.length, 0);
|
||||
assert.equal(destroyed, 1);
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed recovery reports its error and leaves switching settled', async () => {
|
||||
const errors = [];
|
||||
const env = fixture(
|
||||
{
|
||||
defaultId: 'first',
|
||||
recoveryId: 'recovery',
|
||||
sources: [
|
||||
{
|
||||
descriptor: descriptor('first'),
|
||||
imagery: async () => {
|
||||
throw new Error('first offline');
|
||||
},
|
||||
},
|
||||
{
|
||||
descriptor: descriptor('recovery'),
|
||||
imagery: async () => {
|
||||
throw new Error('recovery offline');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ onError: (message) => errors.push(message) },
|
||||
);
|
||||
const result = await env.controller.setStack('first');
|
||||
assert.equal(result.status, 'ready');
|
||||
assert.equal(result.lastError, 'recovery offline');
|
||||
assert.deepEqual(errors, ['first offline', 'recovery offline']);
|
||||
assert.equal(env.changes.at(-1).status, 'error');
|
||||
assert.equal(env.imagery.length, 0);
|
||||
env.controller.destroy();
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** Static credits follow the source actually on screen, including fallback. */
|
||||
export function createMapCredits(viewer) {
|
||||
let active = null;
|
||||
let markup = null;
|
||||
return {
|
||||
show(html) {
|
||||
if (html === markup) return;
|
||||
const display = viewer?.scene?.frameState?.creditDisplay;
|
||||
if (!display) return;
|
||||
try {
|
||||
if (active) display.removeStaticCredit(active);
|
||||
active = html ? new Cesium.Credit(html, true) : null;
|
||||
markup = html;
|
||||
if (active) display.addStaticCredit(active);
|
||||
} catch {
|
||||
/* Switching remains usable on older credit displays. */
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
this.show(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MAP_STACKS } from './catalog.js';
|
||||
import { photorealUnavailableReason } from './availability.js';
|
||||
import { keySetupRequirement } from '../keySetupCore.mjs';
|
||||
import {
|
||||
createOsmImagery,
|
||||
createEsriImagery,
|
||||
createIonImagery,
|
||||
ESRI_ATTRIBUTION_HTML,
|
||||
} from './imagery.js';
|
||||
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
|
||||
|
||||
/** Select sources and setup guidance without putting provider branches in the controller. */
|
||||
export function createDefaultMapSources({
|
||||
googleTileset = null,
|
||||
cesiumToken = '',
|
||||
googleApiKey = '',
|
||||
} = {}) {
|
||||
const ionToken = String(cesiumToken || '').trim();
|
||||
const hasIon = Boolean(ionToken);
|
||||
const hasGoogle = Boolean(String(googleApiKey || '').trim());
|
||||
const terrain = {
|
||||
id: hasIon ? 'world' : 'keyless',
|
||||
create: hasIon
|
||||
? (request) => createWorldTerrain(ionToken, request)
|
||||
: createKeylessTerrain,
|
||||
};
|
||||
return {
|
||||
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
|
||||
unknownId: 'photoreal',
|
||||
recoveryId: googleTileset ? 'photoreal' : null,
|
||||
state: { hasCesiumIonToken: hasIon },
|
||||
sources: MAP_STACKS.map((descriptor) => {
|
||||
const common = {
|
||||
descriptor,
|
||||
available: !descriptor.requiresIon || hasIon,
|
||||
unavailableReason: descriptor.requiresIon
|
||||
? keySetupRequirement('cesium-ion')
|
||||
: null,
|
||||
};
|
||||
if (descriptor.kind === 'photoreal')
|
||||
return {
|
||||
...common,
|
||||
available: Boolean(googleTileset),
|
||||
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
|
||||
tileset: googleTileset,
|
||||
};
|
||||
const imagery =
|
||||
descriptor.kind === 'ion'
|
||||
? () => createIonImagery(descriptor.style, ionToken)
|
||||
: descriptor.id === 'osm'
|
||||
? createOsmImagery
|
||||
: createEsriImagery;
|
||||
return {
|
||||
...common,
|
||||
imagery,
|
||||
terrain,
|
||||
...(descriptor.id === 'esri-imagery'
|
||||
? {
|
||||
credit: ESRI_ATTRIBUTION_HTML,
|
||||
constructionFallback: {
|
||||
id: 'osm',
|
||||
message: 'Esri Satellite is unavailable; using OSM',
|
||||
},
|
||||
tileFailureFallback: {
|
||||
id: 'osm',
|
||||
threshold: 2,
|
||||
message: 'Esri Satellite tile requests failed; using OSM',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
const clean = (value) => String(value || '').trim();
|
||||
|
||||
/**
|
||||
* Decide which map provider can deliver the best startup experience.
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {'google-direct'|'google-ion'|'osm'}
|
||||
*/
|
||||
export function selectMapStartupRoute({
|
||||
googleApiKey = '',
|
||||
cesiumToken = '',
|
||||
} = {}) {
|
||||
if (clean(googleApiKey)) return 'google-direct';
|
||||
if (clean(cesiumToken)) return 'google-ion';
|
||||
return 'osm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
}
|
||||
|
||||
return { tileset: null, route: 'osm', errors };
|
||||
}
|
||||
|
||||
/** Pass credentials to the source instead of changing SDK-wide defaults. */
|
||||
export function createGoogleDirectTileset(Cesium, key) {
|
||||
key = clean(key);
|
||||
if (!key) throw new Error('Google 3D requires an explicit browser key');
|
||||
return Cesium.createGooglePhotorealistic3DTileset({
|
||||
key,
|
||||
onlyUsingWithGoogleGeocoder: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGoogleIonTileset(
|
||||
Cesium,
|
||||
accessToken,
|
||||
{ signal } = {},
|
||||
) {
|
||||
accessToken = clean(accessToken);
|
||||
if (!accessToken)
|
||||
throw new Error('Google 3D through ion requires an explicit token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(2275207, {
|
||||
accessToken,
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
// Match the installed SDK's Google helper rendering/cache defaults.
|
||||
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||
cacheBytes: 1536 * 1024 * 1024,
|
||||
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||
enableCollision: true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
// Attribution and service rights are documented in DATA_SOURCES.md.
|
||||
export const ESRI_ATTRIBUTION_HTML =
|
||||
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
|
||||
|
||||
export function createOsmImagery() {
|
||||
return new Cesium.OpenStreetMapImageryProvider({
|
||||
url: 'https://tile.openstreetmap.org/',
|
||||
credit: '© OpenStreetMap contributors',
|
||||
});
|
||||
}
|
||||
|
||||
export function createEsriImagery() {
|
||||
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
|
||||
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
|
||||
{
|
||||
credit:
|
||||
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
||||
enablePickFeatures: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function createIonImagery(style, accessToken) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
|
||||
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Catch invalid source graphs before a cached fallback can wait on itself. */
|
||||
export function indexMapSources(sources) {
|
||||
const index = new Map();
|
||||
for (const source of sources) {
|
||||
const id = source?.descriptor?.id;
|
||||
if (typeof id !== 'string' || !id || index.has(id))
|
||||
throw new TypeError('Map source IDs must be nonempty and unique');
|
||||
index.set(id, source);
|
||||
}
|
||||
for (const id of index.keys()) {
|
||||
const seen = new Set();
|
||||
let next = id;
|
||||
while (next) {
|
||||
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
|
||||
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
|
||||
seen.add(next);
|
||||
next = index.get(next).constructionFallback?.id;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createIonImagery } from './imagery.js';
|
||||
import { createWorldTerrain } from './terrain.js';
|
||||
|
||||
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
|
||||
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const calls = [];
|
||||
const defaultToken = Cesium.Ion.defaultAccessToken;
|
||||
try {
|
||||
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'imagery', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.IonResource.fromAssetId = async (id, options) => {
|
||||
calls.push({ kind: 'resource', id, options });
|
||||
return { id };
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
|
||||
calls.push({ kind: 'terrain', resource, options });
|
||||
return { id: 'terrain' };
|
||||
};
|
||||
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
|
||||
const result = await createWorldTerrain('terrain-token');
|
||||
assert.equal(calls[0].options.accessToken, 'imagery-token');
|
||||
assert.equal(calls[1].options.accessToken, 'terrain-token');
|
||||
assert.equal(calls[1].id, 1);
|
||||
assert.equal(calls[2].options.requestVertexNormals, true);
|
||||
assert.equal(result.provider.id, 'terrain');
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
|
||||
} finally {
|
||||
Cesium.IonImageryProvider.fromAssetId = originalImagery;
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation after ion metadata prevents terrain construction', async () => {
|
||||
const originalResource = Cesium.IonResource.fromAssetId;
|
||||
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
Cesium.IonResource.fromAssetId = async () => {
|
||||
controller.abort();
|
||||
return {};
|
||||
};
|
||||
Cesium.CesiumTerrainProvider.fromUrl = () =>
|
||||
assert.fail('cancelled terrain construction');
|
||||
await assert.rejects(
|
||||
createWorldTerrain('test-token', { signal: controller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
} finally {
|
||||
Cesium.IonResource.fromAssetId = originalResource;
|
||||
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
|
||||
}
|
||||
});
|
||||
|
||||
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
|
||||
assert.throws(
|
||||
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
|
||||
/explicit token/,
|
||||
);
|
||||
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
|
||||
export async function createWorldTerrain(accessToken, { signal } = {}) {
|
||||
accessToken = String(accessToken || '').trim();
|
||||
if (!accessToken)
|
||||
throw new Error('World terrain requires an explicit ion token');
|
||||
signal?.throwIfAborted();
|
||||
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
|
||||
signal?.throwIfAborted();
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
|
||||
requestVertexNormals: true,
|
||||
requestWaterMask: false,
|
||||
ellipsoid: Cesium.Ellipsoid.WGS84,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createKeylessTerrain() {
|
||||
try {
|
||||
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
|
||||
return {
|
||||
provider: await Cesium.CesiumTerrainProvider.fromUrl(
|
||||
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
|
||||
error,
|
||||
);
|
||||
return { provider: new Cesium.EllipsoidTerrainProvider() };
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import test from 'node:test';
|
||||
|
||||
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
|
||||
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
|
||||
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
|
||||
const radio = ['playback', 'interaction'].map(name =>
|
||||
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
|
||||
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
|
||||
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
|
||||
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
|
||||
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
|
||||
@@ -342,13 +344,13 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
|
||||
});
|
||||
|
||||
test('successful explicit user playback hands the speaker from voice to Radio', () => {
|
||||
const playStart = radio.indexOf('export async function playSelectedRadio');
|
||||
const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10));
|
||||
const playStart = radio.indexOf('async function playSelectedRadio');
|
||||
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
|
||||
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
|
||||
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
|
||||
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
|
||||
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
|
||||
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
|
||||
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
|
||||
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
|
||||
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
|
||||
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
|
||||
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);
|
||||
|
||||
Reference in new issue
Block a user