diff --git a/CHANGELOG.md b/CHANGELOG.md
index b9988b044..ffaeed332 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md
index abb07a114..30a9f11c6 100644
--- a/docs/CODE-BOUNDARIES.md
+++ b/docs/CODE-BOUNDARIES.md
@@ -363,3 +363,25 @@ return an unsubscribe function. Initial state is emitted by default; pass
immutable plain data. Reentrant publications retain delivery order; removing a
listener or destroying its owner prevents further queued delivery. These APIs
perform no network requests and discover no additional modules.
+
+## Maps
+
+`maps/controller` coordinates scene changes and lifetimes. `maps/imagery`,
+`maps/terrain` and `maps/3d` supply constructors; `maps/defaults` selects the
+standard sources and their setup/fallback policy. The standalone facade wires
+these to the application's render governor.
+
+A registry supplies `sources`, `defaultId`, `unknownId` and optional `recoveryId`.
+Each source has a user-facing `descriptor`, availability/reason, and either an
+`imagery({ signal })` factory or a supplied `tileset`/`createTileset({ signal })`.
+Imagery sources can share a terrain definition with a stable `id` and
+`create({ signal })` returning `{ provider }` or `{ terrain }`. Cache IDs must
+identify the same source for the lifetime of that registry. Sources may supply
+trusted credit markup and construction/tile-error fallback policy. Provider
+configuration stays in factories; descriptor values are the presentation API.
+
+Factories should honor cancellation where their SDK supports it. The controller
+also checks scene ownership after asynchronous work, so late results cannot
+replace a newer selection. Supplied tilesets are caller-owned; factory-created
+tilesets and provider caches are controller-owned. Construct a fresh controller
+for a new viewer or configuration lifetime.
diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md
index 90cbe96dc..d3cd03af6 100644
--- a/docs/CURRENT-STATE.md
+++ b/docs/CURRENT-STATE.md
@@ -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.
diff --git a/package.json b/package.json
index 084a2614f..07297242a 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
diff --git a/scripts/format-scope.json b/scripts/format-scope.json
index ccac80a9b..62464ec58 100644
--- a/scripts/format-scope.json
+++ b/scripts/format-scope.json
@@ -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"
]
diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json
index fcb93195d..ce5e3dfee 100644
--- a/scripts/package-boundaries.json
+++ b/scripts/package-boundaries.json
@@ -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"]
}
}
diff --git a/scripts/qa-map-source-tray.mjs b/scripts/qa-map-source-tray.mjs
index f6c0b0f54..8449097a0 100644
--- a/scripts/qa-map-source-tray.mjs
+++ b/scripts/qa-map-source-tray.mjs
@@ -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();
});
diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs
index d3c26471c..c5629d2f4 100644
--- a/scripts/qa-radio.mjs
+++ b/scripts/qa-radio.mjs
@@ -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');
diff --git a/src/data/naturalEarthRegions.js b/src/data/naturalEarthRegions.js
index 5929a8921..891e7e9a1 100644
--- a/src/data/naturalEarthRegions.js
+++ b/src/data/naturalEarthRegions.js
@@ -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;
diff --git a/src/data/neighborhoodPolygons.js b/src/data/neighborhoodPolygons.js
index 9ec672d38..2861f9318 100644
--- a/src/data/neighborhoodPolygons.js
+++ b/src/data/neighborhoodPolygons.js
@@ -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;
}
}
diff --git a/src/data/radio.js b/src/data/radio.js
index 0f28ce8d2..bb454344e 100644
--- a/src/data/radio.js
+++ b/src/data/radio.js
@@ -1,2790 +1,99 @@
-export { radioTunerSlot, radioTunerCommitSlot, radioTunerPointerPosition, buildRadioTunerTicks } from '../ui/radioTunerModel.js';
-/**
- * Internet-radio station directory and direct-playback layer.
- *
- * Radio Browser supplies public-domain directory metadata through the local
- * `/api/radio/*` broker. Audio always travels directly from the broadcaster
- * to one active HTMLAudioElement after an explicit user action; GEV does not
- * proxy, cache, record, or redistribute streams.
- *
- * @module radio
- */
-import * as Cesium from 'cesium';
-import { cachedGroundFloor, warmGroundFloor } from './groundFloor.js';
-import { normalizeRadioCountryInput } from './radioCountry.js';
-import { normalizeRadioFilter } from './layerState.js';
-import { horizonOccluder } from './iconOrientation.js';
-import {
- isOwnedByOtherLayer,
- registerPickOwner,
- resolvePickId,
- unregisterPickOwner,
-} from './pickRegistry.js';
-import {
- clearOverlaySource,
- setOverlayEntries,
- setOverlaySourceVisible,
-} from '../overlays/worldOverlay.js';
-import {
- earthDiscScreenRadius,
- getKeyholeGeometry,
- GLOBE_ENTER_CLEARANCE_PX,
- isFullGlobeInsideKeyhole,
- projectEarthDiscToViewport,
-} from '../celestialRing.js';
-import { governorRequestRender } from '../renderGovernor.js';
+import { createRadioSource } from '../layers/radio/source.js';
+import { createRadioLayer } from '../layers/radio/index.js';
+import * as ground from './groundFloor.js';
+import * as picking from './pickRegistry.js';
+import * as overlays from '../overlays/worldOverlay.js';
+import * as globe from '../celestialRing.js';
+import * as render from '../renderGovernor.js';
-const RADIO_PREFIX = 'radio:';
-const DIRECTORY_ENDPOINT = '/api/radio/stations';
-const HORIZON_TICK_MS = 250;
-const HORIZON_CAMERA_MOVE_EPSILON_M = 1;
-const MARKER_LIFT_M = 2.5;
-const SELECTED_LIFT_M = 5;
-const RADIO_PICK_TOLERANCE_PX = 8;
-const RADIO_TUNER_DIRECTORY_LIMIT = 750;
-const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT;
-const RADIO_TUNER_STATIC_MAX_GAIN = 0.018;
-const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000;
-const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
-const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
-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,
+const layer = createRadioLayer({
+ source: createRadioSource(),
+ services: { ground, picking, overlays, globe, render },
});
-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],
-]);
-const _radioEarthScreenCenter = new Cesium.Cartesian2();
-const _radioEarthToCenter = new Cesium.Cartesian3();
-export const DEFAULT_RADIO_FILTER = 'all';
-export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
-
-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'],
-]);
-
-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'],
-});
-
-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',
-});
-
-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',
-});
-
-const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
- 'news',
- 'public-safety',
- 'weather',
- 'aviation-marine',
- 'traffic-transit',
- 'talk',
- 'music',
-]);
-const DEFAULT_RADIO_VOLUME = 0.8;
-const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({
- instance: null,
- generation: null,
- updatedAt: null,
- stations: Object.freeze([]),
- stationIds: Object.freeze([]),
-});
-
-let _viewer = null;
-let _dataSource = null;
-let _enabled = false;
-let _managerLifecyclePresentation = null;
-let _loading = false;
-let _stale = false;
-let _degraded = false;
-let _error = null;
-let _updatedAt = null;
-let _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
-let _stations = [];
-let _stationById = new Map();
-let _categories = [];
-let _renderById = new Map();
-let _filter = DEFAULT_RADIO_FILTER;
-let _selectedId = null;
-let _selectedEntity = null;
-let _selectionGeneration = 0;
-let _selectionTimer = null;
-let _radioCameraNavigationGeneration = 0;
-let _radioCameraFlightSequence = 0;
-let _activeRadioCameraFlight = null;
-let _audio = null;
-let _audioStationId = null;
-let _audioState = 'stopped';
-let _audioError = null;
-let _userVolume = DEFAULT_RADIO_VOLUME;
-let _tuningActive = false;
-let _tuningStatic = false;
-let _tuningAwaitingStationId = null;
-let _tuningPreviewId = null;
-let _tuningStartStationId = null;
-let _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
-let _tuningStationById = new Map();
-let _tuningUnavailableStationId = null;
-let _cancelledTuningPresentationStation = null;
-let _tuningCameraNavigation = null;
-let _tuningNoiseContext = null;
-let _tuningNoiseSource = null;
-let _tuningNoiseFilter = null;
-let _tuningNoiseGain = null;
-let _voiceDucked = false;
-let _voiceRestoring = false;
-let _voiceRestoreTimer = null;
-let _volumeFadeFrame = null;
-let _volumeTransitionGeneration = 0;
-let _playGeneration = 0;
-let _playAttemptSequence = 0;
-let _activePlaybackAttempt = null;
-let _playFallbackId = null;
-let _playFallbackFocus = null;
-let _playFallbackOrigin = 'programmatic';
-let _playFallbackAttemptId = null;
-let _clickHandler = null;
-let _horizonTimer = null;
-let _lastHorizonCameraPosition = null;
-let _horizonScanCount = 0;
-let _abortController = null;
-let _requestGeneration = 0;
-let _sessionGeneration = 0;
-let _removeClusterListener = null;
-let _overlayPublishTimer = null;
-let _clusterOverlayIdentitySequence = 0;
-let _clusterOverlayIdentities = [];
-let _overlayDiagnostics = {
- entryCount: 0,
- selectedCount: 0,
- singletonTexts: [],
- singletonIds: [],
- clusterTexts: [],
- clusterIds: [],
- clusterMemberships: [],
-};
-const _listeners = new Set();
-const _playbackControlListeners = new Set();
-const VOICE_RESTORE_DELAY_MS = 650;
-const VOICE_RESTORE_DURATION_MS = 1800;
-
-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)),
- });
-}
-
-const RADIO_GLOBE_LABEL_MAX_CHARS = 30;
-const RADIO_LABEL_SEGMENTER = typeof Intl?.Segmenter === 'function'
- ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
- : null;
-
-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. */
-export 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. */
-export 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: radioCategoryColor(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. */
-export 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. */
-export 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: radioCategoryColor(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,
- },
- };
-}
-
-/** Return the bounded singleton-label allowance for the current camera scale. */
-export 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. */
-export 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. */
-export 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.
- */
-export 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() {
- _clusterOverlayIdentities = [];
-}
-
-function emptyRadioOverlayDiagnostics() {
- return {
- entryCount: 0,
- selectedCount: 0,
- singletonTexts: [],
- singletonIds: [],
- clusterTexts: [],
- clusterIds: [],
- clusterMemberships: [],
- };
-}
-
-function clampRadioVolume(value) {
- return Math.min(1, Math.max(0, Number(value) || 0));
-}
-
-/** Return whether camera translation materially changes horizon visibility. */
-export 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;
-}
-
-function cancelRadioVolumeTransition() {
- _volumeTransitionGeneration += 1;
- if (_voiceRestoreTimer) clearTimeout(_voiceRestoreTimer);
- _voiceRestoreTimer = null;
- if (_volumeFadeFrame) {
- if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(_volumeFadeFrame);
- else clearTimeout(_volumeFadeFrame);
- }
- _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();
-}
-
-/** Normalize one directory tag to a stable, lower-case display token. */
-export 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. */
-export 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] || []);
-}
-
-/** Return the shared CSS color for a canonical or detected-genre category. */
-export 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. */
-export 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. */
-export 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;
-}
-
-/** Build the code-native four-corner bracket used by the selected station. */
-export function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) {
- const stroke = /^#[0-9a-f]{6}$/i.test(String(color)) ? String(color) : RADIO_CATEGORY_COLORS.other;
- return ``;
-}
-
-/** Choose one stable display category for a station that may match several filters. */
-export function radioStationCategoryId(station) {
- return RADIO_MARKER_CATEGORY_ORDER.find((categoryId) => (
- stationMatchesRadioCategory(station, categoryId)
- )) || 'other';
-}
-
-/** Build canonical and detected-genre categories from station-level tags. */
-export 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: radioCategoryColor(category.id),
- count: rows.filter((station) => stationMatchesRadioCategory(station, category.id)).length,
- }));
-}
-
-/** Filter stations without changing the active stream or selection. */
-export 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. */
-export 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');
- });
-}
-
-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. */
-export function rankRadioStationsForViewport(stations, anchor, { preferEnglish = false } = {}) {
- return (Array.isArray(stations) ? stations : [])
- .map((station, index) => ({
- station,
- index,
- distance: radioAngularDistance(station, anchor),
- languageTier: preferEnglish && !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. */
-export function rankRadioStationsForRequest(stations, {
- categoryId = 'all',
- anchor = null,
- country = '',
- stationQuery = '',
-} = {}) {
- const countryFilter = normalizeRadioCountryInput(country);
- if (!countryFilter.valid) return [];
- const query = normalizeRadioTag(stationQuery);
- let matches = 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) => normalizeRadioTag(value).includes(query)));
- }
- return anchor
- ? rankRadioStationsForViewport(matches, anchor)
- : matches.slice();
-}
-
-/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */
-export function radioViewIsGlobal(altitudeM) {
- return Number.isFinite(altitudeM) && Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M;
-}
-
-/** Pure stale-response guard shared by the async directory update path. */
-export function radioRequestIsCurrent(
- generation,
- currentGeneration,
- enabled,
- sessionGeneration = null,
- currentSessionGeneration = sessionGeneration,
-) {
- return generation === currentGeneration
- && Boolean(enabled)
- && sessionGeneration === currentSessionGeneration;
-}
-
-/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
-export 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;
-}
-
-/** Plan a station-centered camera move that preserves altitude and view angle. */
-export 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.
- */
-export 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. */
-export function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) {
- if (!Number.isFinite(currentHeight) || currentHeight < 0) return null;
- return fullGlobeCapable
- ? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M)
- : currentHeight;
-}
-
-/** Bound one already ordered filtered directory without injecting outside selections. */
-export 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);
-}
-
-/** Decide whether tuner static should be audible for the current handoff state. */
-export function radioTuningStaticShouldPlay({
- tuningActive = false,
- tuningStatic = false,
- awaitingStationId = null,
- voiceDucked = false,
-} = {}) {
- return Boolean(tuningStatic && !voiceDucked && (tuningActive || awaitingStationId));
-}
-
-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 selectedStation() {
- return _selectedId ? _stationById.get(_selectedId) || null : null;
-}
-
-function selectedPresentationStation() {
- if (_tuningActive) return tuningResolutionStation(_tuningPreviewId);
- return _cancelledTuningPresentationStation || selectedStation();
-}
-
-function visibleStations() {
- return filterRadioStations(_stations, _filter);
-}
-
-function viewportRadioAnchor() {
- const camera = _viewer?.camera;
- const scene = _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: radioViewIsGlobal(altitudeM),
- };
-}
-
-function rankedVisibleStations() {
- const anchor = viewportRadioAnchor();
- return rankRadioStationsForViewport(visibleStations(), anchor, {
- preferEnglish: Boolean(anchor?.globalView),
- });
-}
-
-/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */
-export function getRadioAcceptedCatalogSnapshot() {
- return _acceptedCatalogSnapshot;
-}
-
-/** Compare every station field that determines tuner presentation and playback. */
-export 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 captureRadioTuningResolutionSnapshot() {
- return _acceptedCatalogSnapshot;
-}
-
-function tuningResolutionStation(id) {
- return id ? _tuningStationById.get(String(id)) || null : null;
-}
-
-/** Return an immutable snapshot consumed by the right-rail UI. */
-export function getRadioUIState() {
- const visible = visibleStations();
- const selected = selectedStation();
- return Object.freeze({
- enabled: _enabled,
- loading: _loading,
- stale: _stale,
- degraded: _degraded,
- error: _error,
- updatedAt: _updatedAt,
- filter: _filter,
- categories: _categories,
- acceptedCatalogGeneration: _acceptedCatalogSnapshot.generation,
- presentationActive: radioPresentationAllowed(),
- stationCount: _stations.length,
- filteredCount: visible.length,
- selected,
- selectedIndex: selected ? visible.findIndex((station) => station.id === selected.id) : -1,
- audioState: _audioState,
- audioError: _audioError,
- playingStationId: _audioStationId,
- volume: _userVolume,
- effectiveVolume: _audio?.volume ?? (_voiceDucked ? 0 : _userVolume),
- voiceDucked: _voiceDucked,
- voiceRestoring: _voiceRestoring,
- tuningActive: _tuningActive,
- tuningStatic: _tuningStatic,
- tuningAwaitingStationId: _tuningAwaitingStationId,
- tuningPreviewStationId: _tuningPreviewId,
- tuningRestoredStationId: _cancelledTuningPresentationStation?.id || null,
- tuningCatalogGeneration: _tuningResolutionSnapshot.generation,
- tuningUnavailableStationId: _tuningUnavailableStationId,
- });
-}
-
-function emitState() {
- const snapshot = getRadioUIState();
- for (const listener of _listeners) {
- try {
- listener(snapshot);
- } catch {
- // A broken consumer must not break playback or rendering.
- }
- }
-}
-
-/** Subscribe to radio state; the current state is delivered immediately. */
-export function subscribeToRadio(listener) {
- if (typeof listener !== 'function') return () => {};
- _listeners.add(listener);
- listener(getRadioUIState());
- return () => _listeners.delete(listener);
-}
-
-/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */
-export function subscribeToRadioPlaybackControls(listener) {
- if (typeof listener !== 'function') return () => {};
- _playbackControlListeners.add(listener);
- return () => _playbackControlListeners.delete(listener);
-}
-
-function emitPlaybackControl(action, origin, attemptId = _activePlaybackAttempt?.id || null) {
- const event = { action, origin, attemptId };
- for (const listener of _playbackControlListeners) {
- try {
- listener(event);
- } catch {
- // Playback controls must remain usable if an observer fails.
- }
- }
-}
-
-function audioEventBelongsToActiveAttempt(audio) {
- const attempt = _activePlaybackAttempt;
- return _audio === audio
- && Boolean(attempt)
- && attempt.generation === _playGeneration
- && attempt.stationId === _audioStationId;
-}
-
-function installAudio({ replace = false } = {}) {
- if (_audio && !replace) return;
- if (typeof Audio === 'undefined') return;
- const previousAudio = _audio;
- _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();
- _audio = audio;
- audio.preload = 'none';
- audio.volume = _voiceDucked ? 0 : _userVolume;
- audio.addEventListener('playing', () => {
- if (_audio !== audio) return;
- if (!audioEventBelongsToActiveAttempt(audio)) return;
- if (!['loading', 'buffering', 'playing'].includes(_audioState)) return;
- _audioState = 'playing';
- _audioError = null;
- if (_tuningAwaitingStationId === _audioStationId) clearRadioTuningNoise({ emit: false });
- emitState();
- });
- audio.addEventListener('pause', () => {
- if (_audio !== audio) return;
- if (_audioState === 'stopped' || _audioState === 'loading') return;
- _audioState = 'paused';
- emitState();
- });
- audio.addEventListener('waiting', () => {
- if (_audio !== audio) return;
- if (!audioEventBelongsToActiveAttempt(audio)) return;
- if (!['loading', 'buffering', 'playing'].includes(_audioState)) return;
- _audioState = 'buffering';
- emitState();
- });
- audio.addEventListener('error', () => {
- if (_audio !== audio) return;
- if (!audioEventBelongsToActiveAttempt(audio)) return;
- if (!['loading', 'buffering', 'playing'].includes(_audioState)) return;
- const failedId = _audioStationId;
- const attempt = _activePlaybackAttempt;
- if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
- _audioState = 'error';
- _audioError = 'Broadcaster stream is unavailable or blocked by the browser.';
- emitState();
- });
-}
-
-function stopTuningNoiseSource() {
- if (_tuningNoiseSource) {
- try { _tuningNoiseSource.stop(); } catch { /* already stopped */ }
- try { _tuningNoiseSource.disconnect(); } catch { /* already disconnected */ }
- }
- try { _tuningNoiseFilter?.disconnect(); } catch { /* already disconnected */ }
- try { _tuningNoiseGain?.disconnect(); } catch { /* already disconnected */ }
- _tuningNoiseSource = null;
- _tuningNoiseFilter = null;
- _tuningNoiseGain = null;
-}
-
-function syncTuningNoiseGain() {
- if (!_tuningNoiseContext || !_tuningNoiseGain) return;
- const audible = radioTuningStaticShouldPlay({
- tuningActive: _tuningActive,
- tuningStatic: _tuningStatic,
- awaitingStationId: _tuningAwaitingStationId,
- voiceDucked: _voiceDucked,
- });
- const target = audible
- ? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, _userVolume * 0.03)
- : 0;
- const now = _tuningNoiseContext.currentTime;
- _tuningNoiseGain.gain.cancelScheduledValues(now);
- _tuningNoiseGain.gain.setValueAtTime(_tuningNoiseGain.gain.value, now);
- _tuningNoiseGain.gain.linearRampToValueAtTime(target, now + 0.025);
-}
-
-function installTuningNoise() {
- const AudioContextClass = globalThis.AudioContext || globalThis.webkitAudioContext;
- if (!AudioContextClass) return false;
- if (!_tuningNoiseContext) _tuningNoiseContext = new AudioContextClass();
- const resumed = _tuningNoiseContext.resume?.();
- if (resumed?.catch) void resumed.catch(() => {});
- if (_tuningNoiseSource) return true;
-
- const frameCount = Math.max(1, Math.floor(_tuningNoiseContext.sampleRate * 0.75));
- const buffer = _tuningNoiseContext.createBuffer(1, frameCount, _tuningNoiseContext.sampleRate);
- const channel = buffer.getChannelData(0);
- for (let index = 0; index < channel.length; index += 1) channel[index] = Math.random() * 2 - 1;
-
- _tuningNoiseSource = _tuningNoiseContext.createBufferSource();
- _tuningNoiseFilter = _tuningNoiseContext.createBiquadFilter();
- _tuningNoiseGain = _tuningNoiseContext.createGain();
- _tuningNoiseSource.buffer = buffer;
- _tuningNoiseSource.loop = true;
- _tuningNoiseFilter.type = 'bandpass';
- _tuningNoiseFilter.frequency.value = 1_650;
- _tuningNoiseFilter.Q.value = 0.55;
- _tuningNoiseGain.gain.value = 0;
- _tuningNoiseSource.connect(_tuningNoiseFilter);
- _tuningNoiseFilter.connect(_tuningNoiseGain);
- _tuningNoiseGain.connect(_tuningNoiseContext.destination);
- _tuningNoiseSource.start();
- return true;
-}
-
-/** Return the complete accepted filtered directory in stable catalog order. */
-export function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) {
- if (!radioPresentationAllowed() || !_acceptedCatalogSnapshot.stations.length) return [];
- return buildRadioTunerBand(visibleStations(), selectedStation(), limit);
-}
-
-/** Begin a direct-manipulation tuning gesture and pause the current stream. */
-export function beginRadioTuning() {
- if (!radioPresentationAllowed() || !visibleStations().length) return false;
- const snapshot = captureRadioTuningResolutionSnapshot();
- if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length) return false;
- _tuningResolutionSnapshot = snapshot;
- _tuningStationById = new Map(
- _tuningResolutionSnapshot.stations.map((station) => [station.id, station]),
- );
- _tuningUnavailableStationId = null;
- _tuningActive = true;
- _tuningStatic = false;
- _tuningAwaitingStationId = null;
- _cancelledTuningPresentationStation = null;
- _tuningStartStationId = _selectedId;
- _tuningPreviewId = _selectedId;
- installTuningNoise();
- pauseRadioPlayback({ origin: 'user' });
- syncTuningNoiseGain();
- emitState();
- return true;
-}
-
-/** Toggle low-volume synthesized static for tuner preview and stream handoff. */
-export function setRadioTuningStatic(active) {
- if (!radioPresentationAllowed() || !_tuningActive) return false;
- const next = Boolean(active);
- if (next === _tuningStatic) return true;
- _tuningStatic = next;
- syncTuningNoiseGain();
- emitState();
- return true;
-}
-
-function radioCameraState(camera = _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. */
-export function radioCameraNavigationAllowed(viewer = _viewer) {
- return Boolean(viewer?.camera) && !viewer.trackedEntity;
-}
-
-function radioGlobeRecenterPlan(viewer = _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,
- _radioEarthScreenCenter,
- _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,
- _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 === _radioCameraNavigationGeneration
- && _enabled
- && radioCameraNavigationAllowed(_viewer)
- );
-}
-
-function cancelActiveRadioCameraFlight() {
- if (!_activeRadioCameraFlight || !radioCameraNavigationAllowed(_viewer)) return;
- _activeRadioCameraFlight = null;
- _viewer.camera.cancelFlight();
-}
-
-function invalidateRadioCameraNavigation() {
- _radioCameraNavigationGeneration += 1;
- cancelActiveRadioCameraFlight();
- _activeRadioCameraFlight = null;
-}
-
-function radioCameraNavigationOwnsSelection(navigation, station) {
- return Boolean(
- navigation
- && station
- && navigation.generation === _radioCameraNavigationGeneration
- && navigation.target?.id === station.id
- );
-}
-
-function startRadioCameraFlight(navigation, options, onComplete) {
- if (!radioCameraNavigationIsCurrent(navigation)) return false;
- const token = ++_radioCameraFlightSequence;
- _activeRadioCameraFlight = { generation: navigation.generation, token };
- const finish = (completed) => {
- if (_activeRadioCameraFlight?.token === token) _activeRadioCameraFlight = null;
- if (completed && radioCameraNavigationIsCurrent(navigation)) onComplete?.();
- };
- _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 = ++_radioCameraNavigationGeneration;
- if (!radioCameraNavigationAllowed(_viewer) || !_enabled) return null;
- cancelActiveRadioCameraFlight();
- const recenterPlan = radioGlobeRecenterPlan(_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);
-}
-
-/** Preview a tuner station bracket and camera orientation without starting audio. */
-export function previewRadioTuningStation(id, { rotate = true } = {}) {
- if (!radioPresentationAllowed() || !_tuningActive) return false;
- const station = tuningResolutionStation(id);
- const nextId = station?.id || null;
- const changed = nextId !== _tuningPreviewId;
- _tuningPreviewId = nextId;
- _tuningStatic = !station;
- if (changed) {
- updateSelectionEntity();
- if (station && rotate) {
- const cameraState = radioCameraState();
- _tuningCameraNavigation = beginRadioCameraNavigation(cameraState);
- rotateRadioStationIntoView(station, 0.35, cameraState, _tuningCameraNavigation);
- }
- else if (rotate) {
- _tuningCameraNavigation = null;
- invalidateRadioCameraNavigation();
- }
- }
- syncTuningNoiseGain();
- emitState();
- return Boolean(station);
-}
-
-function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) {
- if (_tuningCameraNavigation) invalidateRadioCameraNavigation();
- _tuningActive = false;
- _tuningStatic = false;
- _tuningAwaitingStationId = null;
- _tuningPreviewId = null;
- _tuningStartStationId = null;
- _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
- _tuningStationById = new Map();
- _tuningCameraNavigation = null;
- _cancelledTuningPresentationStation = restoredStation;
- updateSelectionEntity();
- syncTuningNoiseGain();
- stopTuningNoiseSource();
- const suspended = _tuningNoiseContext?.suspend?.();
- if (suspended?.catch) void suspended.catch(() => {});
- if (emit) emitState();
-}
-
-/** Finish a tuning gesture and release its synthesized noise source. */
-export function endRadioTuning() {
- if (!_tuningActive && !_tuningStatic && !_tuningAwaitingStationId
- && !_tuningNoiseSource && !_cancelledTuningPresentationStation) return;
- clearRadioTuningNoise();
-}
-
-/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */
-export function cancelRadioTuning() {
- if (!_tuningActive) return false;
- const restoredStation = tuningResolutionStation(_tuningStartStationId);
- clearRadioTuningNoise({ restoredStation });
- return true;
-}
-
-/** Commit the exact frozen drag resolution or report that it is unavailable. */
-export function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) {
- const frozenStation = tuningResolutionStation(id);
- const currentStation = _stationById.get(String(id)) || null;
- const generation = _tuningResolutionSnapshot.generation;
- if (!radioPresentationAllowed() || !_tuningActive || !frozenStation) {
- endRadioTuning();
- return Object.freeze({ ok: false, reason: 'not-tuning', stationId: String(id || ''), generation });
- }
- if (!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.
- _selectedId = null;
- _selectionGeneration += 1;
- clearRadioTuningNoise({ emit: false });
- _tuningUnavailableStationId = stationId;
- emitState();
- return Object.freeze({ ok: false, reason: 'station-unavailable', stationId, generation });
- }
- _tuningActive = false;
- _tuningStatic = true;
- _tuningAwaitingStationId = frozenStation.id;
- _tuningPreviewId = null;
- _tuningStartStationId = null;
- _tuningUnavailableStationId = null;
- const cameraNavigation = _tuningCameraNavigation;
- _tuningCameraNavigation = null;
- _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
- _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.
- _playFallbackId = null;
- _playFallbackFocus = null;
- _playFallbackOrigin = 'programmatic';
- _playFallbackAttemptId = null;
- syncTuningNoiseGain();
- emitState();
- const selected = selectRadioStation(frozenStation.id, {
- autoplay: true,
- focus: false,
- origin,
- cameraNavigation,
- });
- return Object.freeze({
- ok: selected,
- reason: selected ? null : 'station-unavailable',
- stationId: frozenStation.id,
- generation,
- });
-}
-
-function tryRadioFallback(
- failedId,
- origin = _playFallbackOrigin || 'programmatic',
- attemptId = _playFallbackAttemptId,
-) {
- const fallbackId = _playFallbackId;
- const fallbackStation = fallbackId ? _stationById.get(fallbackId) : null;
- const fallbackFocusPolicy = _playFallbackFocus;
- _playFallbackId = null;
- _playFallbackFocus = null;
- _playFallbackOrigin = 'programmatic';
- _playFallbackAttemptId = null;
- if (!fallbackStation || fallbackId === failedId || _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);
- selectRadioStation(fallbackId, {
- autoplay: true,
- focus: fallbackFocus,
- origin: origin || 'programmatic',
- attemptId,
- cameraNavigation: fallbackCameraNavigation,
- });
- return true;
-}
-
-function recordDirectoryClick(id) {
- fetch(`/api/radio/click/${encodeURIComponent(id)}`, { method: 'POST' }).catch(() => {});
-}
-
-/** Play the selected broadcaster stream after an explicit user action. */
-export async function playSelectedRadio({ origin = 'programmatic', attemptId = null } = {}) {
- const station = selectedStation();
- if (!radioPresentationAllowed() || !station?.streamUrl) return false;
- if (_tuningActive) 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 (!_audio) return false;
-
- const generation = ++_playGeneration;
- const ownedAttemptId = attemptId || `radio-play-${++_playAttemptSequence}`;
- _activePlaybackAttempt = {
- id: ownedAttemptId,
- origin,
- stationId: station.id,
- streamUrl: station.streamUrl,
- generation,
- };
- if (_playFallbackId) {
- _playFallbackOrigin = origin;
- _playFallbackAttemptId = ownedAttemptId;
- }
- _audioError = null;
- _audioState = 'loading';
- if (_audioStationId !== station.id || _audio.src !== station.streamUrl) {
- _audio.pause();
- _audio.src = station.streamUrl;
- _audioStationId = station.id;
- }
- emitState();
-
- try {
- const playback = _audio.play();
- if (playback?.then) await playback;
- if (
- generation !== _playGeneration
- || _audioStationId !== station.id
- || _activePlaybackAttempt?.id !== ownedAttemptId
- ) return false;
- _audioState = 'playing';
- if (_tuningAwaitingStationId === station.id) clearRadioTuningNoise({ emit: false });
- _playFallbackId = null;
- _playFallbackFocus = null;
- _playFallbackOrigin = 'programmatic';
- _playFallbackAttemptId = null;
- recordDirectoryClick(station.id);
- emitState();
- if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId);
- return true;
- } catch (error) {
- if (generation !== _playGeneration || _activePlaybackAttempt?.id !== ownedAttemptId) return false;
- if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false;
- _audioState = 'error';
- _audioError = error?.name === 'NotAllowedError'
- ? 'Playback requires a direct click or tap.'
- : 'Broadcaster stream could not be started.';
- 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.
- */
-export 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. */
-export function playPreparedRadioForVoice(options = {}) {
- if (!_voiceDucked) return Promise.resolve(false);
- return confirmRadioPlayback({
- startPlayback: () => playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }),
- subscribe: subscribeToRadio,
- getState: getRadioUIState,
- timeoutMs: options.timeoutMs,
- });
-}
-
-/** Stop the shared stream and release its network resource. */
-export function stopRadioPlayback({ origin = 'programmatic', attemptId = null } = {}) {
- if (attemptId && _activePlaybackAttempt?.id !== attemptId) return false;
- const stoppedAttemptId = _activePlaybackAttempt?.id || null;
- endRadioTuning();
- _playGeneration += 1;
- _playFallbackId = null;
- _playFallbackFocus = null;
- _playFallbackOrigin = 'programmatic';
- _playFallbackAttemptId = null;
- _activePlaybackAttempt = null;
- if (_audio) {
- _audio.pause();
- _audio.removeAttribute('src');
- _audio.load();
- }
- _audioStationId = null;
- _audioState = 'stopped';
- _audioError = null;
- emitState();
- if (origin === 'user' || origin === 'voice') {
- emitPlaybackControl('stop', origin, stoppedAttemptId);
- }
- return true;
-}
-
-/** Pause or resume the selected stream. Resuming is still click initiated. */
-export function toggleRadioPlayback({ origin = 'programmatic' } = {}) {
- if (['loading', 'playing', 'buffering'].includes(_audioState)) {
- return Promise.resolve(pauseRadioPlayback({ origin }));
- }
- if (!radioPresentationAllowed()) return Promise.resolve(false);
- if (!_selectedId) {
- const ranked = rankedVisibleStations();
- if (!ranked.length) return Promise.resolve(false);
- _playFallbackId = ranked[1]?.id || null;
- _playFallbackFocus = false;
- selectRadioStation(ranked[0].id, { autoplay: false, focus: false });
- }
- return playSelectedRadio({ origin });
-}
-
-/** Pause Radio without toggling a stopped or already-paused stream back on. */
-export function pauseRadioPlayback({ origin = 'programmatic' } = {}) {
- if (!['loading', 'playing', 'buffering'].includes(_audioState)) return false;
- const pausedAttemptId = _activePlaybackAttempt?.id || null;
- if (_tuningAwaitingStationId && !_tuningActive) endRadioTuning();
- _playGeneration += 1;
- _playFallbackId = null;
- _playFallbackFocus = null;
- _playFallbackOrigin = 'programmatic';
- _playFallbackAttemptId = null;
- _activePlaybackAttempt = null;
- _audio?.pause();
- _audioState = 'paused';
- emitState();
- if (origin === 'user' || origin === 'voice') {
- emitPlaybackControl('pause', origin, pausedAttemptId);
- }
- return true;
-}
-
-/** Set shared audio volume, clamped to [0, 1]. */
-export function setRadioVolume(value) {
- if (!radioPresentationAllowed()) return false;
- const volume = clampRadioVolume(value);
- _userVolume = volume;
- installAudio();
- if (!_voiceDucked) {
- cancelRadioVolumeTransition();
- _voiceRestoring = false;
- if (_audio) _audio.volume = volume;
- }
- syncTuningNoiseGain();
- emitState();
- return true;
-}
-
-/** Durable Radio preferences; this surface never creates or plays audio. */
-export 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 : clampRadioVolume(numericVolume);
-
- let changed = false;
- let filterChanged = false;
- let clearedCancelledPresentation = false;
- if (nextFilter !== null) {
- filterChanged = nextFilter !== _filter;
- if (filterChanged && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning();
- clearedCancelledPresentation = Boolean(_cancelledTuningPresentationStation);
- _cancelledTuningPresentationStation = null;
- _filter = nextFilter;
- if (clearedCancelledPresentation) updateSelectionEntity();
- if (filterChanged) resetRadioClusterOverlayIdentities();
- changed ||= filterChanged || clearedCancelledPresentation;
- }
- if (nextVolume !== null && nextVolume !== _userVolume) {
- _userVolume = nextVolume;
- if (_audio && !_voiceDucked) {
- cancelRadioVolumeTransition();
- _voiceRestoring = false;
- _audio.volume = nextVolume;
- }
- syncTuningNoiseGain();
- changed = true;
- }
- if (changed && radioPresentationAllowed()) {
- updateRenderVisibility();
- if (filterChanged && _dataSource?.clustering?.enabled) {
- const clusterPoints = _dataSource.clustering.clusterPoints;
- _dataSource.clustering.clusterPoints = !clusterPoints;
- _dataSource.clustering.clusterPoints = clusterPoints;
- _viewer?.scene?.requestRender();
- }
- scheduleRadioOverlayPublish();
- }
- emitState();
- return true;
-}
-
-export function getRadioParams() {
- return { filter: _filter, volume: _userVolume };
-}
-
-/**
- * Mute Radio during a live voice turn, then gently restore the user-owned
- * volume after voice returns to standby. Repeated state sync is idempotent.
- */
-export function setRadioVoiceDucking(ducked, {
- restoreDelayMs = VOICE_RESTORE_DELAY_MS,
- restoreDurationMs = VOICE_RESTORE_DURATION_MS,
-} = {}) {
- const shouldDuck = Boolean(ducked);
- if (shouldDuck === _voiceDucked && (shouldDuck || _voiceRestoring)) return;
- if (!shouldDuck && !_voiceDucked && !_voiceRestoring
- && (!_audio || Math.abs(_audio.volume - _userVolume) < 0.001)) return;
-
- cancelRadioVolumeTransition();
- _voiceDucked = shouldDuck;
- _voiceRestoring = false;
-
- if (shouldDuck) {
- if (_audio) _audio.volume = 0;
- syncTuningNoiseGain();
- emitState();
- return;
- }
- if (!_audio) {
- syncTuningNoiseGain();
- _voiceRestoring = false;
- emitState();
- return;
- }
-
- const generation = _volumeTransitionGeneration;
- syncTuningNoiseGain();
- _voiceRestoring = true;
- emitState();
- const beginRestore = () => {
- _voiceRestoreTimer = null;
- if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return;
- const startedAt = volumeClock();
- const initialVolume = _audio.volume;
- const duration = Math.max(0, Number(restoreDurationMs) || 0);
- const step = (now) => {
- if (generation !== _volumeTransitionGeneration || _voiceDucked || !_audio) return;
- const progress = duration === 0 ? 1 : Math.min(1, Math.max(0, (now - startedAt) / duration));
- const eased = progress * progress * (3 - 2 * progress);
- _audio.volume = clampRadioVolume(initialVolume + (_userVolume - initialVolume) * eased);
- if (progress < 1) {
- _volumeFadeFrame = scheduleVolumeFrame(step);
- return;
- }
- _volumeFadeFrame = null;
- _voiceRestoring = false;
- emitState();
- };
- _volumeFadeFrame = scheduleVolumeFrame(step);
- };
- const delay = Math.max(0, Number(restoreDelayMs) || 0);
- if (delay > 0) _voiceRestoreTimer = setTimeout(beginRestore, delay);
- else beginRestore();
-}
-
-function updateSelectionEntity() {
- if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity);
- _selectedEntity = null;
- const station = selectedPresentationStation();
- if (!radioPresentationAllowed() || !_viewer || !station) {
- scheduleRadioOverlayPublish();
- return;
- }
- const selectionColor = radioCategoryColor(radioStationCategoryId(station));
- const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(radioSelectionBracketSvg(selectionColor))}`;
-
- _selectedEntity = _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 _dataSource?.clustering?._clusterPointCollection || null;
-}
-
-function publishRadioOverlayEntries() {
- _overlayPublishTimer = null;
- if (!radioPresentationAllowed()) {
- resetRadioClusterOverlayIdentities();
- _overlayDiagnostics = emptyRadioOverlayDiagnostics();
- clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
- setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
- return;
- }
-
- const entries = [];
- const station = selectedPresentationStation();
- if (station) {
- const selectedEntry = 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 = _stationById.get(id);
- return station && stationMatchesRadioCategory(station, _filter);
- })
- .sort();
- if (stationIds.length < 3) continue;
- for (const stationId of stationIds) clusteredStationIds.add(stationId);
- const clusteredStations = stationIds.map((id) => _stationById.get(id));
- const category = radioClusterCategoryId(clusteredStations, _filter);
- clusterCandidates.push({
- id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`,
- stationIds,
- point,
- text: radioClusterBadgeText(category, stationIds.length),
- accent: radioCategoryColor(category),
- stationCount: stationIds.length,
- });
- }
- const selectedClusterCandidates = selectRadioClusterCandidates(clusterCandidates);
- _clusterOverlayIdentities = reconcileRadioClusterCandidates(
- selectedClusterCandidates,
- _clusterOverlayIdentities,
- () => `stable:${++_clusterOverlayIdentitySequence}`,
- );
- for (const candidate of _clusterOverlayIdentities) {
- const clusterEntry = createRadioClusterOverlayEntry({
- ...candidate,
- position: () => candidate.point.position,
- });
- if (clusterEntry) entries.push(clusterEntry);
- }
-
- const cameraPosition = _viewer?.camera?.positionWC;
- const singletonAllowance = Math.min(
- radioSingletonLabelLimit(_viewer?.camera?.positionCartographic?.height),
- Math.max(0, RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length),
- );
- const singletonCandidates = selectRadioSingletonCandidates(
- [..._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 = 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);
- _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: _clusterOverlayIdentities.map((candidate) => ({
- membershipId: candidate.membershipId,
- entryId: `cluster:${candidate.id}`,
- })),
- };
-}
-
-function scheduleRadioOverlayPublish() {
- if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer);
- const sessionGeneration = _sessionGeneration;
- _overlayPublishTimer = setTimeout(() => {
- if (sessionGeneration === _sessionGeneration) publishRadioOverlayEntries();
- }, 0);
-}
-
-function focusStation(station) {
- _radioCameraNavigationGeneration += 1;
- if (!station || !radioCameraNavigationAllowed(_viewer)) return false;
- cancelActiveRadioCameraFlight();
- _viewer.camera.flyTo({
- destination: Cesium.Cartesian3.fromDegrees(station.lon, station.lat, 85_000),
- duration: 1.1,
- });
- return true;
-}
-
-/** Select a station. Playback occurs only when autoplay is explicitly true. */
-export function selectRadioStation(id, {
- autoplay = false,
- focus = false,
- origin = 'programmatic',
- attemptId = null,
- cameraNavigation = null,
-} = {}) {
- if (!radioPresentationAllowed()) return false;
- const station = _stationById.get(String(id));
- if (!station) return false;
- _tuningUnavailableStationId = null;
- if (!radioCameraNavigationOwnsSelection(cameraNavigation, station)) {
- invalidateRadioCameraNavigation();
- }
- if (_tuningActive || (_tuningAwaitingStationId && _tuningAwaitingStationId !== station.id)) endRadioTuning();
- _cancelledTuningPresentationStation = null;
- _selectedId = station.id;
- const generation = ++_selectionGeneration;
- const sessionGeneration = _sessionGeneration;
- updateSelectionEntity();
- warmGroundFloor([{ lat: station.lat, lon: station.lon }]);
- if (_selectionTimer) clearTimeout(_selectionTimer);
- _selectionTimer = setTimeout(() => {
- _selectionTimer = null;
- if (
- sessionGeneration === _sessionGeneration
- && generation === _selectionGeneration
- && _selectedId === station.id
- ) updateSelectionEntity();
- }, 1300);
- if (focus) focusStation(station);
- emitState();
- if (autoplay) void playSelectedRadio({ origin, attemptId });
- return true;
-}
-
-/** Select the previous or next station and optionally retain a UI-owned band order. */
-export function cycleRadioStation(direction = 1, {
- rotate = false,
- stationIds = null,
- autoplay = true,
- origin = 'programmatic',
-} = {}) {
- if (!radioPresentationAllowed()) return false;
- const ranked = Array.isArray(stationIds) && stationIds.length
- ? stationIds
- .slice(0, RADIO_TUNER_DIRECTORY_LIMIT)
- .map((id) => _stationById.get(String(id)))
- .filter((station) => station && stationMatchesRadioCategory(station, _filter))
- : rankedVisibleStations();
- if (!ranked.length) return false;
- const current = ranked.findIndex((station) => station.id === _selectedId);
- const nextIndex = current < 0
- ? 0
- : (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
- _playFallbackId = ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
- const rotationCameraState = rotate ? radioCameraState() : null;
- const rotationNavigation = rotate ? beginRadioCameraNavigation(rotationCameraState) : null;
- _playFallbackFocus = rotate
- ? (fallbackStation) => {
- rotateRadioStationIntoView(fallbackStation, 0.65, rotationCameraState, rotationNavigation);
- return rotationNavigation;
- }
- : false;
- const station = ranked[nextIndex];
- if (rotate) 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. */
-export function selectRequestedRadioStation(criteria = {}, { autoplay = true, origin = 'programmatic' } = {}) {
- if (!radioPresentationAllowed()) return null;
- const requestedCategory = String(criteria.categoryId || 'all');
- const categoryId = _categories.some((category) => category.id === requestedCategory)
- ? requestedCategory
- : 'all';
- setRadioFilter(categoryId);
- const ranked = rankRadioStationsForRequest(_stations, {
- ...criteria,
- categoryId,
- anchor: criteria.anchor || viewportRadioAnchor(),
- });
- if (!ranked.length) return null;
- _playFallbackId = ranked[1]?.id || null;
- _playFallbackFocus = false;
- selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
- return ranked[0];
-}
-
-function updateRenderVisibility({ force = true } = {}) {
- if (!_viewer || !_dataSource) return;
- const cameraPosition = _viewer.camera?.positionWC;
- if (!force && !radioCameraPositionChanged(_lastHorizonCameraPosition, cameraPosition)) return;
- _lastHorizonCameraPosition = cameraPosition
- ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
- : null;
- _horizonScanCount += 1;
- const occluder = horizonOccluder(_viewer.camera);
- let visibilityChanged = false;
- for (const [id, record] of _renderById) {
- const matches = stationMatchesRadioCategory(record.station, _filter);
- const visible = matches && occluder.isPointVisible(record.position);
- if (record.entity.show !== visible) visibilityChanged = true;
- record.entity.show = visible;
- }
- if (_selectedEntity) {
- const position = _selectedEntity.position?.getValue?.(Cesium.JulianDate.now());
- const selectedVisible = !position || occluder.isPointVisible(position);
- if (_selectedEntity.show !== selectedVisible) visibilityChanged = true;
- _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');
-}
-
-/** Change marker/list category without interrupting an active station. */
-export function setRadioFilter(categoryId) {
- if (!radioPresentationAllowed()) return false;
- const valid = _categories.some((category) => category.id === categoryId);
- const nextFilter = valid ? categoryId : 'all';
- const changed = nextFilter !== _filter;
- if (changed && (_tuningActive || _tuningAwaitingStationId)) endRadioTuning();
- const clearsCancelledPresentation = Boolean(_cancelledTuningPresentationStation);
- _cancelledTuningPresentationStation = null;
- _filter = nextFilter;
- if (clearsCancelledPresentation) updateSelectionEntity();
- if (changed) resetRadioClusterOverlayIdentities();
- updateRenderVisibility();
- if (changed && _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 = _dataSource.clustering.clusterPoints;
- _dataSource.clustering.clusterPoints = !clusterPoints;
- _dataSource.clustering.clusterPoints = clusterPoints;
- _viewer?.scene?.requestRender();
- }
- scheduleRadioOverlayPublish();
- emitState();
- return true;
-}
-
-/** Retain refresh identities only while every represented station still exists. */
-export 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)))
- ));
-}
-
-function reconcileStations(stations) {
- if (!_tuningActive) _cancelledTuningPresentationStation = null;
- _clusterOverlayIdentities = retainRadioClusterIdentitiesForStations(
- _clusterOverlayIdentities,
- stations,
- );
- _stations = Object.freeze([...stations]);
- _stationById = new Map(stations.map((station) => [station.id, station]));
- _categories = Object.freeze(buildRadioCategories(stations).map((category) => Object.freeze(category)));
- if (_filter === DEFAULT_RADIO_FILTER && !filterRadioStations(stations, DEFAULT_RADIO_FILTER).length) {
- _filter = 'all';
- }
- if (_selectedId && !_stationById.has(_selectedId)) {
- if (_audioStationId === _selectedId) stopRadioPlayback();
- _selectedId = null;
- }
-
- if (_dataSource) _dataSource.entities.removeAll();
- _renderById.clear();
- for (const station of stations) {
- const position = markerPosition(station);
- const markerColor = radioCategoryColor(radioStationCategoryId(station));
- const entity = _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,
- ),
- },
- });
- _renderById.set(station.id, { station, entity, position });
- }
- updateSelectionEntity();
- updateRenderVisibility();
-}
-
-function installClusterStyling() {
- if (!_dataSource || _removeClusterListener) return;
- const clustering = _dataSource.clustering;
- clustering.enabled = true;
- clustering.pixelRange = 42;
- clustering.minimumClusterSize = 3;
- clustering.clusterPoints = true;
- clustering.clusterLabels = false;
- clustering.clusterBillboards = false;
- _removeClusterListener = clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => {
- const clusteredStations = [];
- for (const entity of clusteredEntities) {
- const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
- const station = _renderById.get(stationId)?.station;
- if (station) clusteredStations.push(station);
- }
- const clusterCategory = radioClusterCategoryId(clusteredStations, _filter);
- const clusterColor = Cesium.Color.fromCssColorString(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();
- });
-}
-
-function pickedRadioStationAt(position) {
- const scene = _viewer?.scene;
- if (!scene || !position) return null;
-
- const stationFromPick = (picked) => {
- const stationId = radioStationIdFromPick(picked);
- return stationId && _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 (!_managerLifecyclePresentation) return _enabled;
- return _enabled
- && _managerLifecyclePresentation.lifecycleState === 'enabled'
- && _managerLifecyclePresentation.enabled
- && !_managerLifecyclePresentation.uncertain;
-}
-
-function syncRadioLifecyclePresentation() {
- const visible = radioPresentationAllowed();
- if (_dataSource) _dataSource.show = visible;
- setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
- if (visible) {
- installInteraction();
- updateSelectionEntity();
- updateRenderVisibility();
- scheduleRadioOverlayPublish();
- } else {
- removeInteraction();
- if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity);
- _selectedEntity = null;
- }
-}
-
-function installInteraction() {
- if (!_viewer || _clickHandler) return;
- registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX));
- _clickHandler = new Cesium.ScreenSpaceEventHandler(_viewer.scene.canvas);
- _clickHandler.setInputAction((click) => {
- if (!radioPresentationAllowed()) return;
- const stationId = pickedRadioStationAt(click.position);
- if (!stationId) return;
- _playFallbackId = null;
- _playFallbackFocus = null;
- 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.
- _horizonTimer = setInterval(() => updateRenderVisibility({ force: false }), HORIZON_TICK_MS);
-}
-
-function removeInteraction() {
- unregisterPickOwner('radio');
- _clickHandler?.destroy();
- _clickHandler = null;
- if (_horizonTimer) clearInterval(_horizonTimer);
- _horizonTimer = null;
-}
-
-/** Radio layer lifecycle implementation. */
-export const radioLayer = {
- id: 'radio',
- name: 'Radio',
- icon: '◉',
- source: 'Radio Browser',
- updateInterval: 45 * 60 * 1000,
-
- /** Initialize the Cesium data source and the single audio element. */
- init(viewer) {
- _sessionGeneration += 1;
- _viewer = viewer;
- resetRadioClusterOverlayIdentities();
- installAudio();
- if (!_dataSource) {
- _dataSource = new Cesium.CustomDataSource('Radio stations');
- viewer.dataSources.add(_dataSource);
- installClusterStyling();
- }
- _dataSource.show = false;
- clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
- setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
- },
-
- /** Show stations. Enabling or preset restoration never starts audio. */
- enable() {
- _enabled = true;
- syncRadioLifecyclePresentation();
- emitState();
- },
-
- /** 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;
- _managerLifecyclePresentation = {
- lifecycleState: normalizedState,
- enabled: Boolean(enabled),
- uncertain: Boolean(uncertain),
- };
- syncRadioLifecyclePresentation();
- emitState();
- },
-
- /** Hide the layer and stop playback without forgetting the selected station. */
- disable() {
- _sessionGeneration += 1;
- _enabled = false;
- invalidateRadioCameraNavigation();
- _requestGeneration += 1;
- _abortController?.abort();
- _abortController = null;
- _loading = false;
- _selectionGeneration += 1;
- if (_selectionTimer) clearTimeout(_selectionTimer);
- _selectionTimer = null;
- removeInteraction();
- endRadioTuning();
- _cancelledTuningPresentationStation = null;
- _tuningUnavailableStationId = null;
- stopRadioPlayback({ origin: 'layer-disable' });
- if (_dataSource) _dataSource.show = false;
- if (_selectedEntity && _viewer) _viewer.entities.remove(_selectedEntity);
- _selectedEntity = null;
- if (_overlayPublishTimer) clearTimeout(_overlayPublishTimer);
- _overlayPublishTimer = null;
- resetRadioClusterOverlayIdentities();
- _overlayDiagnostics = emptyRadioOverlayDiagnostics();
- clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
- setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
- emitState();
- },
-
- /** Refresh directory metadata through the hardened same-origin broker. */
- async update() {
- if (!_enabled) return;
- const generation = ++_requestGeneration;
- const sessionGeneration = _sessionGeneration;
- _abortController?.abort();
- _abortController = new AbortController();
- _loading = true;
- _error = null;
- emitState();
- try {
- const response = await fetch(DIRECTORY_ENDPOINT, { signal: _abortController.signal });
- if (!response.ok) throw new Error(`Radio directory returned ${response.status}`);
- const body = await response.json();
- if (!radioRequestIsCurrent(
- generation,
- _requestGeneration,
- _enabled,
- sessionGeneration,
- _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(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 = _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 = _acceptedCatalogSnapshot?.instance === catalogInstance;
- const currentAcceptedGeneration = sameCatalogInstance
- ? _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(freezeRadioStation);
- if (repeatingAcceptedGeneration) {
- _updatedAt = _acceptedCatalogSnapshot.updatedAt;
- if (!_tuningActive && _cancelledTuningPresentationStation) {
- _cancelledTuningPresentationStation = null;
- updateSelectionEntity();
- }
- } else if (!body.stale && !body.degraded) {
- const acceptedSnapshot = createAcceptedCatalogSnapshot(
- catalogInstance,
- acceptedGeneration,
- updatedAt,
- immutableRows,
- );
- if (!acceptedSnapshot) throw new Error('Radio directory generation metadata was malformed');
- _acceptedCatalogSnapshot = acceptedSnapshot;
- reconcileStations(acceptedSnapshot.stations);
- } else {
- reconcileStations(immutableRows);
- }
- if (!repeatingAcceptedGeneration) _updatedAt = updatedAt;
- }
- _degraded = body.degraded;
- _stale = body.stale;
- _error = preservingWarmCatalog
- ? 'Directory refresh degraded; showing the previous station catalog.'
- : (_degraded ? 'Radio directory coverage is degraded.' : null);
- } catch (error) {
- if (error?.name === 'AbortError' || !radioRequestIsCurrent(
- generation,
- _requestGeneration,
- _enabled,
- sessionGeneration,
- _sessionGeneration,
- )) return;
- _error = _stations.length
- ? 'Directory refresh failed; showing the previous station catalog.'
- : 'Radio directory is temporarily unavailable.';
- _stale = _stations.length > 0;
- _degraded = _stations.length > 0;
- } finally {
- if (generation === _requestGeneration && sessionGeneration === _sessionGeneration) {
- _loading = false;
- _abortController = null;
- emitState();
- }
- }
- },
-
- /** Release rendering, event, request, and playback resources. */
- destroy() {
- this.disable();
- cancelRadioVolumeTransition();
- _voiceDucked = false;
- _voiceRestoring = false;
- endRadioTuning();
- const closedTuningNoise = _tuningNoiseContext?.close?.();
- if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {});
- _tuningNoiseContext = null;
- if (_audio) _audio.volume = DEFAULT_RADIO_VOLUME;
- _audio = null;
- _userVolume = DEFAULT_RADIO_VOLUME;
- _removeClusterListener?.();
- _removeClusterListener = null;
- if (_dataSource && _viewer) _viewer.dataSources.remove(_dataSource, true);
- _dataSource = null;
- clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
- _stations = [];
- _acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
- _stationById.clear();
- _categories = Object.freeze([]);
- _filter = DEFAULT_RADIO_FILTER;
- _renderById.clear();
- resetRadioClusterOverlayIdentities();
- _lastHorizonCameraPosition = null;
- _horizonScanCount = 0;
- _selectedId = null;
- _tuningPreviewId = null;
- _tuningStartStationId = null;
- _tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
- _tuningStationById = new Map();
- _tuningUnavailableStationId = null;
- _cancelledTuningPresentationStation = null;
- _loading = false;
- _stale = false;
- _degraded = false;
- _error = null;
- _updatedAt = null;
- _managerLifecyclePresentation = null;
- _viewer = null;
- emitState();
- _listeners.clear();
- _playbackControlListeners.clear();
- },
-
- /** Layer statistics for HUD/debug surfaces. */
- getStats() {
- return {
- count: _stations.length,
- filtered: visibleStations().length,
- selected: _selectedId,
- playing: _audioStationId,
- stale: _stale,
- degraded: _degraded,
- loading: _loading,
- error: _error,
- lastUpdate: _updatedAt ? Date.parse(_updatedAt) : null,
- horizonScans: _horizonScanCount,
- overlayEntries: _overlayDiagnostics.entryCount,
- };
- },
-
- subscribe: subscribeToRadio,
- subscribePlaybackControls: subscribeToRadioPlaybackControls,
- getAcceptedCatalogSnapshot: getRadioAcceptedCatalogSnapshot,
- getUIState: getRadioUIState,
- getParams: getRadioParams,
- setParams: setRadioParams,
- getOverlayDiagnostics: () => ({
- ..._overlayDiagnostics,
- singletonTexts: [..._overlayDiagnostics.singletonTexts],
- singletonIds: [..._overlayDiagnostics.singletonIds],
- clusterTexts: [..._overlayDiagnostics.clusterTexts],
- clusterIds: [..._overlayDiagnostics.clusterIds],
- clusterMemberships: _overlayDiagnostics.clusterMemberships.map((entry) => ({ ...entry })),
- }),
- getTunerStations: getRadioTunerStations,
- beginTuning: beginRadioTuning,
- setTuningStatic: setRadioTuningStatic,
- previewTuningStation: previewRadioTuningStation,
- commitTuningStation: commitRadioTuningStation,
- cancelTuning: cancelRadioTuning,
- endTuning: endRadioTuning,
- setFilter: setRadioFilter,
- selectStation: selectRadioStation,
- selectRequestedStation: selectRequestedRadioStation,
- cycleStation: cycleRadioStation,
- togglePlayback: toggleRadioPlayback,
- play: playSelectedRadio,
- playForVoice: playPreparedRadioForVoice,
- pause: pauseRadioPlayback,
- stopPlayback: stopRadioPlayback,
- setVolume: setRadioVolume,
- setVoiceDucked: setRadioVoiceDucking,
-};
-
-export default radioLayer;
+export const radioGlobeLabel = layer.radioGlobeLabel;
+export const createRadioSelectedOverlayEntry =
+ layer.createRadioSelectedOverlayEntry;
+export const createRadioClusterOverlayEntry =
+ layer.createRadioClusterOverlayEntry;
+export const createRadioSingletonOverlayEntry =
+ layer.createRadioSingletonOverlayEntry;
+export const radioSingletonLabelLimit = layer.radioSingletonLabelLimit;
+export const selectRadioSingletonCandidates =
+ layer.selectRadioSingletonCandidates;
+export const selectRadioClusterCandidates = layer.selectRadioClusterCandidates;
+export const reconcileRadioClusterCandidates =
+ layer.reconcileRadioClusterCandidates;
+export const radioCameraPositionChanged = layer.radioCameraPositionChanged;
+export const normalizeRadioTag = layer.normalizeRadioTag;
+export const stationMatchesRadioCategory = layer.stationMatchesRadioCategory;
+export const radioCategoryColor = layer.radioCategoryColor;
+export const radioClusterBadgeText = layer.radioClusterBadgeText;
+export const radioClusterCategoryId = layer.radioClusterCategoryId;
+export const radioSelectionBracketSvg = layer.radioSelectionBracketSvg;
+export const radioStationCategoryId = layer.radioStationCategoryId;
+export const buildRadioCategories = layer.buildRadioCategories;
+export const filterRadioStations = layer.filterRadioStations;
+export const isEnglishRadioStation = layer.isEnglishRadioStation;
+export const rankRadioStationsForViewport = layer.rankRadioStationsForViewport;
+export const rankRadioStationsForRequest = layer.rankRadioStationsForRequest;
+export const radioViewIsGlobal = layer.radioViewIsGlobal;
+export const radioRequestIsCurrent = layer.radioRequestIsCurrent;
+export const radioStationIdFromPick = layer.radioStationIdFromPick;
+export const radioStationCameraPlan = layer.radioStationCameraPlan;
+export const radioGlobeNeedsRecentering = layer.radioGlobeNeedsRecentering;
+export const radioGlobeRecenterHeight = layer.radioGlobeRecenterHeight;
+export const buildRadioTunerBand = layer.buildRadioTunerBand;
+export const radioTuningStaticShouldPlay = layer.radioTuningStaticShouldPlay;
+export const getRadioAcceptedCatalogSnapshot =
+ layer.getRadioAcceptedCatalogSnapshot;
+export const radioStationResolutionMatches =
+ layer.radioStationResolutionMatches;
+export const getRadioUIState = layer.getRadioUIState;
+export const subscribeToRadio = layer.subscribeToRadio;
+export const subscribeToRadioPlaybackControls =
+ layer.subscribeToRadioPlaybackControls;
+export const getRadioTunerStations = layer.getRadioTunerStations;
+export const beginRadioTuning = layer.beginRadioTuning;
+export const setRadioTuningStatic = layer.setRadioTuningStatic;
+export const radioCameraNavigationAllowed = layer.radioCameraNavigationAllowed;
+export const previewRadioTuningStation = layer.previewRadioTuningStation;
+export const endRadioTuning = layer.endRadioTuning;
+export const cancelRadioTuning = layer.cancelRadioTuning;
+export const commitRadioTuningStation = layer.commitRadioTuningStation;
+export const playSelectedRadio = layer.playSelectedRadio;
+export const confirmRadioPlayback = layer.confirmRadioPlayback;
+export const playPreparedRadioForVoice = layer.playPreparedRadioForVoice;
+export const stopRadioPlayback = layer.stopRadioPlayback;
+export const toggleRadioPlayback = layer.toggleRadioPlayback;
+export const pauseRadioPlayback = layer.pauseRadioPlayback;
+export const setRadioVolume = layer.setRadioVolume;
+export const setRadioParams = layer.setRadioParams;
+export const getRadioParams = layer.getRadioParams;
+export const setRadioVoiceDucking = layer.setRadioVoiceDucking;
+export const selectRadioStation = layer.selectRadioStation;
+export const cycleRadioStation = layer.cycleRadioStation;
+export const selectRequestedRadioStation = layer.selectRequestedRadioStation;
+export const setRadioFilter = layer.setRadioFilter;
+export const retainRadioClusterIdentitiesForStations =
+ layer.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 '../layers/radio/index.js';
+export default layer;
+
+export const radioLayer = layer;
+export {
+ radioTunerSlot,
+ radioTunerCommitSlot,
+ radioTunerPointerPosition,
+ buildRadioTunerTicks,
+} from '../ui/radioTunerModel.js';
diff --git a/src/data/radioCountry.test.mjs b/src/data/radioCountry.test.mjs
index 3270b46dc..aafe13a07 100644
--- a/src/data/radioCountry.test.mjs
+++ b/src/data/radioCountry.test.mjs
@@ -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 [
diff --git a/src/data/telegeographySubmarineCables.js b/src/data/telegeographySubmarineCables.js
index 20d6e02ff..998829f5c 100644
--- a/src/data/telegeographySubmarineCables.js
+++ b/src/data/telegeographySubmarineCables.js
@@ -1,1225 +1,47 @@
import * as Cesium from 'cesium';
+import {
+ createSubmarineCableLayer,
+ createCableOverlayPublisher as createPublisher,
+} from '../layers/submarineCables/index.js';
+import { createBundledCableSource } from '../layers/submarineCables/bundledSource.js';
import {
clearOverlaySource,
setOverlayEntries,
setOverlaySourceVisible,
} from '../overlays/worldOverlay.js';
-
-// TeleGeography submarine-cable data is bundled for an out-of-the-box
-// experience. IMPORTANT: it is CC BY-NC-SA 3.0 (NonCommercial + ShareAlike),
-// NOT covered by this project's MIT license — see DATA_SOURCES.md. Commercial
-// users must remove this dataset or obtain a license from TeleGeography.
-const cableUrl = new URL(
- './local_data/telegeography_submarine_cables/cable-geo.json',
- import.meta.url,
-).href;
-const landingPointUrl = new URL(
- './local_data/telegeography_submarine_cables/landing-point-geo.json',
- import.meta.url,
-).href;
-
-const BASE_CABLE_COLOR = '#39d5ff';
-const BASE_LANDING_COLOR = '#8fffd2';
-const STEM_TARGET_PX = 66;
-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;
-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,
-});
-
-const DEFAULT_OVERLAY_HOST = Object.freeze({
+const overlayHost = Object.freeze({
clearSource: clearOverlaySource,
setEntries: setOverlayEntries,
setVisible: setOverlaySourceVisible,
});
-
+export function createTeleGeographySubmarineCableLayer(options = {}) {
+ return createSubmarineCableLayer({
+ source: createBundledCableSource(),
+ overlayHost,
+ screenSpaceEventHandlerFactory: (canvas) =>
+ new Cesium.ScreenSpaceEventHandler(canvas),
+ mapStackEventTarget: typeof window !== 'undefined' ? window : null,
+ ...options,
+ });
+}
+export function createCableOverlayPublisher(options = {}) {
+ return createPublisher({ host: overlayHost, ...options });
+}
+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,
+ selectCableReferenceLabelWinners,
+ cableReferencePriority,
+ createCableOverlayEntry,
+ cableClassificationTypeForStack,
+ cableClassificationTypeForScene,
+ applyTranslucentMarkerBlend,
+ createCableReferenceSweepGate,
+ updateCableReferenceStem,
+} from '../layers/submarineCables/index.js';
export default createTeleGeographySubmarineCableLayer();
-
-/**
- * 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 = DEFAULT_OVERLAY_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;
- },
- };
-}
-
-/**
- * `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.
- */
-const CABLE_GLOBE_STACK_IDS = Object.freeze(
- new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
-);
-
-/**
- * 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;
-}
-
-/** EntityCluster's private marker collections and their required types. */
-const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
- Object.freeze({ key: '_billboardCollection', type: Cesium.BillboardCollection }),
- Object.freeze({ key: '_pointCollection', type: Cesium.PointPrimitiveCollection }),
-]);
-
-/**
- * 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}}
- */
-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}}
- */
-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);
-}
-
-/**
- * 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}
- */
-function defaultSweepClock() {
- return (typeof performance === 'object' && typeof performance.now === 'function')
- ? performance.now()
- : Date.now();
-}
-
-/**
- * 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 createTeleGeographySubmarineCableLayer({
- overlayHost = DEFAULT_OVERLAY_HOST,
- screenSpaceEventHandlerFactory = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
- mapStackEventTarget = typeof window !== 'undefined' ? window : null,
- // Test seam: the sweep gate's motion-fallback clock. Tests freeze it so the
- // fallback window is driven explicitly instead of by wall-clock timing.
- sweepClock = defaultSweepClock,
-} = {}) {
- let _viewer = null;
- let _enabled = false;
- let _loading = false;
- let _loaded = false;
- let _count = 0;
- let _error = null;
- let _lastUpdate = null;
- let _loadingLabel = '';
- let _abort = null;
- /** Monotonic load-ownership token; every load() start claims a new one. */
- let _loadGeneration = 0;
- let _cableDataSource = null;
- let _landingDataSource = null;
- let _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.
- */
- let _cachedCableJson = null;
- let _cachedLandingJson = null;
- let _referenceRecords = [];
- let _surfaceRecords = [];
- let _clickHandler = null;
- let _preRenderRemover = null;
- let _moveEndRemover = null;
- let _mapStackListener = null;
- /** Active ground-line classification; refined from live scene state at init. */
- let _classificationType = Cesium.ClassificationType.BOTH;
- /** True once both marker collections blend translucent (or the shape probe failed). */
- let _markerBlendDone = false;
- let _markerBlendInvariantWarned = false;
- let _pickByEntity = new WeakMap();
- let _referenceLabelCount = 0;
- const _referenceSweepGate = createCableReferenceSweepGate({ now: sweepClock });
- const _overlayPublisher = createCableOverlayPublisher({ host: overlayHost });
- /** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */
- const _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.
- */
- const _lastPublishedIds = [];
- const _lastPublishedPriorities = [];
- let _lastPublishedCount = -1;
-
- function resetPublishSignature() {
- _lastPublishedIds.length = 0;
- _lastPublishedPriorities.length = 0;
- _lastPublishedCount = -1;
- }
-
- const cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR);
- const cableOutline = Cesium.Color.BLACK.withAlpha(0.45);
- const landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR);
-
- async function load(viewer) {
- if (_loading || _loaded) return;
-
- _loading = true;
- _error = null;
- _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 = ++_loadGeneration;
- const abort = new AbortController();
- _abort = abort;
- const owns = () => generation === _loadGeneration && !abort.signal.aborted;
-
- try {
- let cableJson = _cachedCableJson;
- let landingJson = _cachedLandingJson;
- if (!cableJson || !landingJson) {
- [cableJson, landingJson] = await Promise.all([
- fetchJson(cableUrl, abort.signal),
- fetchJson(landingPointUrl, abort.signal),
- ]);
- if (!owns()) return;
- _cachedCableJson = cableJson;
- _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: cableColor.withAlpha(0.95),
- fill: cableColor.withAlpha(0.18),
- strokeWidth: 2,
- markerColor: cableColor,
- markerSize: 6,
- }
- );
- if (!owns()) return;
- const landingDataSource = await Cesium.GeoJsonDataSource.load(
- { type: 'FeatureCollection', features: landingFeatures },
- {
- clampToGround: true,
- stroke: landingColor.withAlpha(0.9),
- fill: landingColor.withAlpha(0.35),
- strokeWidth: 2,
- markerColor: landingColor,
- markerSize: 6,
- }
- );
- if (!owns()) return;
-
- cableDataSource.name = 'TeleGeography Submarine Cables';
- landingDataSource.name = 'TeleGeography Landing Points';
- const referenceDataSource = new Cesium.CustomDataSource('TeleGeography 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.
- _cableDataSource = cableDataSource;
- _landingDataSource = landingDataSource;
- _referenceDataSource = referenceDataSource;
-
- const cableEntities = _cableDataSource.entities.values;
- const landingEntities = _landingDataSource.entities.values;
- _pickByEntity = new WeakMap();
- _referenceRecords = [];
- _surfaceRecords = [];
-
- cableEntities.forEach((entity, index) => {
- const feature = cableFeatures[index];
- const reference = featureReference(feature);
- if (!reference) return;
-
- styleCableEntity(entity, feature);
- registerPickEntity(entity, {
- kind: 'cable',
- reference,
- label: featureLabel(feature),
- });
- _surfaceRecords.push({
- entity,
- base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
- });
- addReferenceStem({
- reference,
- label: featureLabel(feature),
- kind: 'cable',
- color: cableColor,
- feature,
- });
- });
-
- landingEntities.forEach((entity, index) => {
- const feature = landingFeatures[index];
- const reference = featureReference(feature);
- if (!reference) return;
-
- styleLandingEntity(entity, feature);
- registerPickEntity(entity, {
- kind: 'landing-point',
- reference,
- label: featureLabel(feature),
- });
- _surfaceRecords.push({
- entity,
- base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
- });
- addReferenceStem({
- reference,
- label: featureLabel(feature),
- kind: 'landing-point',
- color: landingColor,
- feature,
- });
- });
-
- _count = cableFeatures.length + landingFeatures.length;
- _loaded = true;
- _lastUpdate = Date.now();
- _loadingLabel = '';
- updateVisibility();
- _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') {
- _error = error?.message || 'TeleGeography load failed';
- console.warn('[Data:telegeography-submarine-cables]', _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 === _loadGeneration) {
- _loading = false;
- if (_abort === abort) _abort = null;
- }
- }
- }
-
- /**
- * 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 = [_cableDataSource, _landingDataSource, _referenceDataSource];
- _cableDataSource = null;
- _landingDataSource = null;
- _referenceDataSource = null;
- _referenceRecords = [];
- _surfaceRecords = [];
- _pickByEntity = new WeakMap();
- _referenceLabelCount = 0;
- _publishScratch.length = 0;
- // The blend pass targets the collections being dropped; the rebuilt
- // sources need their own pass.
- _markerBlendDone = false;
- _loaded = false;
- _referenceSweepGate.reset();
- for (const source of sources) {
- if (!source) continue;
- try { viewer?.dataSources?.remove(source, true); } catch { /* collection gone */ }
- }
- }
-
- function updateVisibility() {
- if (_cableDataSource) _cableDataSource.show = _enabled;
- if (_landingDataSource) _landingDataSource.show = _enabled;
- if (_referenceDataSource) _referenceDataSource.show = _enabled;
- }
-
- function registerPickEntity(entity, info) {
- entity.__gevTeleGeography = info;
- _pickByEntity.set(entity, info);
- }
-
- function styleCableEntity(entity, feature) {
- if (!entity?.polyline) return;
- const color = feature?.properties?.color
- ? Cesium.Color.fromCssColorString(String(feature.properties.color))
- : cableColor;
- entity.polyline.material = color.withAlpha(0.92);
- entity.polyline.width = 2.5;
- entity.polyline.clampToGround = true;
- entity.polyline.classificationType = _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(_landingDataSource);
- const reference = probeTranslucentMarkerBlend(_referenceDataSource);
- if (landing.invariantFailed || reference.invariantFailed) {
- _markerBlendDone = true;
- if (!_markerBlendInvariantWarned && import.meta.env?.DEV === true) {
- _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) _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 === _classificationType) return;
- _classificationType = next;
- if (!_cableDataSource) return;
- const entities = _cableDataSource.entities.values;
- for (let i = 0; i < entities.length; i++) {
- const polyline = entities[i].polyline;
- if (polyline) polyline.classificationType = next;
- }
- _viewer?.scene?.requestRender?.();
- }
-
- function styleLandingEntity(entity, feature) {
- if (!entity?.point) return;
- entity.point.color = landingColor.withAlpha(0.92);
- entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
- entity.point.outlineColor = cableOutline;
- entity.point.outlineWidth = 1;
- entity.point.disableDepthTestDistance = 0;
- entity.show = true;
- }
-
- function addReferenceStem({ reference, label, kind, color, feature }) {
- if (!_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 = _referenceDataSource.entities.add({
- id: `${kind}-reference-${_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;
- _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);
- _referenceRecords.push(record);
- }
-
- function updateReferenceVisibility() {
- if (!_enabled || !_viewer?.camera) return;
- const cameraPos = _viewer.camera.positionWC;
- if (!cameraPos) return;
-
- const canvasHeight = _viewer.scene?.canvas?.clientHeight || 1080;
- const fov = _viewer.camera.frustum?.fov || (Math.PI / 3);
- const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84, cameraPos);
- for (const record of _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 _surfaceRecords) {
- const visible = occluder.isPointVisible(record.base);
- if (record.entity.show !== visible) {
- record.entity.show = visible;
- }
- }
- const winners = selectCableReferenceLabelWinners(_referenceRecords);
- let changed = winners.length !== _lastPublishedCount;
- for (let i = 0; i < winners.length; i++) {
- const record = winners[i];
- const priority = cableReferencePriority(record.distanceM);
- record.entry.priority = priority;
- _publishScratch[i] = record.entry;
- if (!changed
- && (_lastPublishedIds[i] !== record.entry.id
- || _lastPublishedPriorities[i] !== priority)) {
- changed = true;
- }
- }
- _publishScratch.length = winners.length;
- _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 (!_markerBlendDone) applyMarkerBlendOnce();
- if (!changed) return;
- _overlayPublisher.publish(_publishScratch);
- _lastPublishedCount = winners.length;
- for (let i = 0; i < winners.length; i++) {
- _lastPublishedIds[i] = _publishScratch[i].id;
- _lastPublishedPriorities[i] = _publishScratch[i].priority;
- }
- _lastPublishedIds.length = winners.length;
- _lastPublishedPriorities.length = winners.length;
- }
-
- function beginInteraction(viewer) {
- if (_clickHandler) return;
- _clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
- _clickHandler.setInputAction((click) => {
- if (!_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 = _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 = _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,
- });
- }
-
- 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,
- };
- }
-
- 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];
- }
-
- 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];
- }
-
- 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);
- }
-
- function featureLabel(feature) {
- const props = feature?.properties || {};
- return String(props.name || props.id || feature?.id || '').trim();
- }
-
- 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),
- };
- });
- }
-
- async function fetchJson(url, signal) {
- const response = await fetch(url, { signal, cache: 'force-cache' });
- if (!response.ok) {
- throw new Error(`HTTP ${response.status} for ${url}`);
- }
- return response.json();
- }
-
- return {
- id: 'telegeography-submarine-cables',
- name: 'Submarine Cables',
- icon: '≋',
- source: 'TeleGeography',
- updateInterval: 0,
- statsRefreshInterval: 500,
-
- init(viewer) {
- _viewer = viewer;
- _classificationType = cableClassificationTypeForScene(viewer?.scene);
- if (!_mapStackListener && mapStackEventTarget?.addEventListener) {
- _mapStackListener = (event) => {
- applyCableClassification(event?.detail?.activeId
- ? cableClassificationTypeForStack(event.detail.activeId)
- : cableClassificationTypeForScene(_viewer?.scene));
- };
- mapStackEventTarget.addEventListener('gev:map-stack-changed', _mapStackListener);
- }
- beginInteraction(viewer);
- if (!_preRenderRemover) {
- _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 (!_enabled || !_loaded
- || !_referenceSweepGate.shouldRun(viewer.camera)) return;
- updateReferenceVisibility();
- });
- }
- if (!_moveEndRemover) {
- _moveEndRemover = viewer.camera.moveEnd.addEventListener(() => {
- if (!_enabled) return;
- _referenceSweepGate.markDirty();
- viewer.scene.requestRender?.();
- });
- }
- },
-
- enable(viewer) {
- _enabled = true;
- updateVisibility();
- _overlayPublisher.show();
- // A hide() cleared the host source, so an identical cohort must still
- // republish on the next sweep.
- resetPublishSignature();
- _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 || _viewer)?.scene?.requestRender?.();
- void load(viewer || _viewer);
- },
-
- disable() {
- _enabled = false;
- _overlayPublisher.hide();
- resetPublishSignature();
- if (_loading && _abort) {
- _abort.abort();
- _loading = false;
- _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.
- releaseDataSources(_viewer);
- },
-
- update(viewer) {
- return load(viewer || _viewer);
- },
-
- destroy(viewer) {
- if (_abort) _abort.abort();
- releaseDataSources(viewer || _viewer);
- _cachedCableJson = null;
- _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.
- _overlayPublisher.hide();
- if (_clickHandler) {
- _clickHandler.destroy();
- _clickHandler = null;
- }
- if (_preRenderRemover) {
- _preRenderRemover();
- _preRenderRemover = null;
- }
- if (_moveEndRemover) {
- _moveEndRemover();
- _moveEndRemover = null;
- }
- if (_mapStackListener && mapStackEventTarget?.removeEventListener) {
- mapStackEventTarget.removeEventListener('gev:map-stack-changed', _mapStackListener);
- _mapStackListener = null;
- }
- _viewer = null;
- _enabled = false;
- _loading = false;
- _loaded = false;
- _count = 0;
- _error = null;
- _lastUpdate = null;
- _loadingLabel = '';
- _cableDataSource = null;
- _landingDataSource = null;
- _referenceDataSource = null;
- _referenceRecords = [];
- _surfaceRecords = [];
- _pickByEntity = new WeakMap();
- _referenceLabelCount = 0;
- _publishScratch.length = 0;
- _markerBlendDone = false;
- _markerBlendInvariantWarned = false;
- resetPublishSignature();
- _referenceSweepGate.reset();
- },
-
- getStats() {
- return {
- count: _count,
- lastUpdate: _lastUpdate,
- loading: _loading,
- loadingLabel: _loadingLabel,
- error: _error,
- referenceLabelCount: _referenceLabelCount,
- };
- },
- };
-}
-
-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))}...`;
-}
diff --git a/src/data/telegeographySubmarineCables.test.mjs b/src/data/telegeographySubmarineCables.test.mjs
index 58bfaa87d..675c6a3b9 100644
--- a/src/data/telegeographySubmarineCables.test.mjs
+++ b/src/data/telegeographySubmarineCables.test.mjs
@@ -39,11 +39,18 @@ test('cable reference winners remain nearest-first and hard-capped', () => {
for (let index = 0; index < 80; index++) records[index].visible = false;
winners = selectCableReferenceLabelWinners(records);
assert.equal(winners.length, CABLE_REFERENCE_LABEL_WINNER_CAP);
- assert.equal(winners[0], records[80], 'hidden references release their slots to the next nearest');
+ assert.equal(
+ winners[0],
+ records[80],
+ 'hidden references release their slots to the next nearest',
+ );
records[90].distanceM = 10_000_000; // beyond the 9,000 km reference range
winners = selectCableReferenceLabelWinners(records);
- assert.ok(!winners.includes(records[90]), 'out-of-range references never reach the host');
+ assert.ok(
+ !winners.includes(records[90]),
+ 'out-of-range references never reach the host',
+ );
});
test('cable overlay entries satisfy the shared presentation contract', () => {
@@ -58,21 +65,33 @@ test('cable overlay entries satisfy the shared presentation contract', () => {
const entry = createCableOverlayEntry(record);
assert.equal(entry.id, 'cable-reference-0-atlantic-1');
- assert.equal(entry.position, tip, 'entry stays attached to the mutable stem-tip Cartesian');
+ assert.equal(
+ entry.position,
+ tip,
+ 'entry stays attached to the mutable stem-tip Cartesian',
+ );
assert.equal(entry.variant, 'label');
assert.equal(entry.title, 'Atlantic Crossing-1');
assert.equal(entry.accent, '#39d5ff');
assert.equal(entry.collisionGroup, 'ambient-label');
assert.equal(entry.paintLane, 'ambient-label');
- assert.equal(entry.interactive, false, 'point/stem/line picking remains Cesium-native');
+ assert.equal(
+ entry.interactive,
+ false,
+ 'point/stem/line picking remains Cesium-native',
+ );
assert.equal(entry.maxDistance, 9_000_000);
assert.equal(entry.distanceFadeStartRatio, 0.7);
- assert.deepEqual(entry.distanceScale, {
- near: 250_000,
- nearValue: 1,
- far: 9_000_000,
- farValue: 0.62,
- }, 'former native scaleByDistance curve is preserved');
+ assert.deepEqual(
+ entry.distanceScale,
+ {
+ near: 250_000,
+ nearValue: 1,
+ far: 9_000_000,
+ farValue: 0.62,
+ },
+ 'former native scaleByDistance curve is preserved',
+ );
assert.equal(entry.edgeFade, 'keyhole');
assert.equal(entry.horizonCull, true);
assert.equal(entry.terrainOcclusion, false);
@@ -86,13 +105,19 @@ test('cable overlay entries satisfy the shared presentation contract', () => {
tip,
distanceM: 100_000,
});
- assert.equal(landing.accent, '#8fffd2', 'landing points keep their mint accent');
+ assert.equal(
+ landing.accent,
+ '#8fffd2',
+ 'landing points keep their mint accent',
+ );
assert.equal(landing.interactive, false);
});
test('cable reference priority is nearest-first and sweep-stable', () => {
assert.ok(cableReferencePriority(1_000) > cableReferencePriority(1_000_000));
- assert.ok(cableReferencePriority(1_000_000) > cableReferencePriority(8_000_000));
+ assert.ok(
+ cableReferencePriority(1_000_000) > cableReferencePriority(8_000_000),
+ );
assert.equal(
cableReferencePriority(500_000),
cableReferencePriority(500_000 + 10_000),
@@ -124,7 +149,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle',
publisher.hide();
assert.deepEqual(calls[0], ['visible', CABLE_OVERLAY_SOURCE_ID, true]);
- assert.deepEqual(calls[1].slice(0, 3), ['entries', CABLE_OVERLAY_SOURCE_ID, [{ id: 'cable-1' }]]);
+ assert.deepEqual(calls[1].slice(0, 3), [
+ 'entries',
+ CABLE_OVERLAY_SOURCE_ID,
+ [{ id: 'cable-1' }],
+ ]);
assert.deepEqual(calls[1][3], {
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
@@ -132,7 +161,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle',
});
assert.deepEqual(calls[2], ['clear', CABLE_OVERLAY_SOURCE_ID]);
assert.deepEqual(calls[3], ['visible', CABLE_OVERLAY_SOURCE_ID, false]);
- assert.equal(calls.length, 4, 'hidden publishers drop late publishes; repeat hide is a no-op');
+ assert.equal(
+ calls.length,
+ 4,
+ 'hidden publishers drop late publishes; repeat hide is a no-op',
+ );
// The production teardown (layer.destroy) is hide(): the publisher stays
// reusable for the layer's supported re-init-after-destroy contract. There
@@ -141,7 +174,11 @@ test('cable overlay publisher owns the production show/publish/hide lifecycle',
publisher.show();
publisher.publish([{ id: 'cable-2' }]);
assert.deepEqual(calls[4], ['visible', CABLE_OVERLAY_SOURCE_ID, true]);
- assert.deepEqual(calls[5].slice(0, 3), ['entries', CABLE_OVERLAY_SOURCE_ID, [{ id: 'cable-2' }]]);
+ assert.deepEqual(calls[5].slice(0, 3), [
+ 'entries',
+ CABLE_OVERLAY_SOURCE_ID,
+ [{ id: 'cable-2' }],
+ ]);
});
test('cable ground lines classify against exactly the active surface on every stack', () => {
@@ -163,11 +200,26 @@ test('cable ground lines classify against exactly the active surface on every st
// claim as "renders on the globe", and asserting TERRAIN for an id this
// module has never heard of would hide the cables on a future 3D-tile
// stack instead of degrading to the documented safe behavior.
- assert.equal(cableClassificationTypeForStack('some-future-stack'), Cesium.ClassificationType.BOTH);
- assert.equal(cableClassificationTypeForStack('photoreal-v2'), Cesium.ClassificationType.BOTH);
- assert.equal(cableClassificationTypeForStack(''), Cesium.ClassificationType.BOTH);
- assert.equal(cableClassificationTypeForStack(undefined), Cesium.ClassificationType.BOTH);
- assert.equal(cableClassificationTypeForStack(null), Cesium.ClassificationType.BOTH);
+ assert.equal(
+ cableClassificationTypeForStack('some-future-stack'),
+ Cesium.ClassificationType.BOTH,
+ );
+ assert.equal(
+ cableClassificationTypeForStack('photoreal-v2'),
+ Cesium.ClassificationType.BOTH,
+ );
+ assert.equal(
+ cableClassificationTypeForStack(''),
+ Cesium.ClassificationType.BOTH,
+ );
+ assert.equal(
+ cableClassificationTypeForStack(undefined),
+ Cesium.ClassificationType.BOTH,
+ );
+ assert.equal(
+ cableClassificationTypeForStack(null),
+ Cesium.ClassificationType.BOTH,
+ );
// Every id that actually ships must be a KNOWN id: a stack added to
// MAP_STACKS without a mapping here silently loses its halved command set,
@@ -190,7 +242,10 @@ test('cable ground lines classify against exactly the active surface on every st
cableClassificationTypeForScene({ globe: { show: true } }),
Cesium.ClassificationType.TERRAIN,
);
- assert.equal(cableClassificationTypeForScene(null), Cesium.ClassificationType.BOTH);
+ assert.equal(
+ cableClassificationTypeForScene(null),
+ Cesium.ClassificationType.BOTH,
+ );
});
test('a map-stack change re-classifies every cable line once, and destroy detaches the listener', async () => {
@@ -201,49 +256,84 @@ test('a map-stack change re-classifies every cable line once, and destroy detach
if (listeners.get(name) === fn) listeners.delete(name);
},
};
- const env = await createRealCableLayerHarness({ mapStackEventTarget: eventTarget });
- const cableSource = env.dataSources.find((ds) => /Submarine Cables/.test(ds.name || ''));
- const classificationOf = (entity) => entity.polyline.classificationType.getValue();
+ const env = await createRealCableLayerHarness({
+ mapStackEventTarget: eventTarget,
+ });
+ const cableSource = env.dataSources.find((ds) =>
+ /Submarine Cables/.test(ds.name || ''),
+ );
+ const classificationOf = (entity) =>
+ entity.polyline.classificationType.getValue();
// The harness scene has no globe → BOTH at init (safe unknown fallback).
const cableEntity = cableSource.entities.values[0];
assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.BOTH);
const listener = listeners.get('gev:map-stack-changed');
- assert.equal(typeof listener, 'function', 'init must subscribe to the stack event');
+ assert.equal(
+ typeof listener,
+ 'function',
+ 'init must subscribe to the stack event',
+ );
listener({ detail: { activeId: 'photoreal', status: 'ready' } });
- assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.CESIUM_3D_TILE);
+ assert.equal(
+ classificationOf(cableEntity),
+ Cesium.ClassificationType.CESIUM_3D_TILE,
+ );
listener({ detail: { activeId: 'osm', status: 'ready' } });
- assert.equal(classificationOf(cableEntity), Cesium.ClassificationType.TERRAIN);
+ assert.equal(
+ classificationOf(cableEntity),
+ Cesium.ClassificationType.TERRAIN,
+ );
env.layer.destroy(env.viewer);
- assert.equal(listeners.has('gev:map-stack-changed'), false, 'destroy must detach the stack listener');
+ assert.equal(
+ listeners.has('gev:map-stack-changed'),
+ false,
+ 'destroy must detach the stack listener',
+ );
});
test('marker collections blend in a single translucent pass behind a loud shape invariant', async () => {
// Helper contract on a REAL Cesium cluster shape.
const ds = new Cesium.CustomDataSource('blend-probe');
let result = applyTranslucentMarkerBlend(ds);
- assert.deepEqual(result, { applied: 0, pending: 2, invariantFailed: false },
- 'pre-visualizer clusters are pending, never a shape failure');
+ assert.deepEqual(
+ result,
+ { applied: 0, pending: 2, invariantFailed: false },
+ 'pre-visualizer clusters are pending, never a shape failure',
+ );
ds.clustering._billboardCollection = new Cesium.BillboardCollection();
ds.clustering._pointCollection = new Cesium.PointPrimitiveCollection();
result = applyTranslucentMarkerBlend(ds);
assert.deepEqual(result, { applied: 2, pending: 0, invariantFailed: false });
- assert.equal(ds.clustering._billboardCollection.blendOption, Cesium.BlendOption.TRANSLUCENT);
- assert.equal(ds.clustering._pointCollection.blendOption, Cesium.BlendOption.TRANSLUCENT);
+ assert.equal(
+ ds.clustering._billboardCollection.blendOption,
+ Cesium.BlendOption.TRANSLUCENT,
+ );
+ assert.equal(
+ ds.clustering._pointCollection.blendOption,
+ Cesium.BlendOption.TRANSLUCENT,
+ );
// Shape drift fails LOUDLY-detectably instead of applying blindly: a
// renamed field or a foreign object type must never be touched.
- assert.equal(applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed, true,
- 'missing private fields mean Cesium changed shape');
+ assert.equal(
+ applyTranslucentMarkerBlend({ clustering: {} }).invariantFailed,
+ true,
+ 'missing private fields mean Cesium changed shape',
+ );
const wrongType = new Cesium.CustomDataSource('wrong-type');
wrongType.clustering._billboardCollection = { blendOption: 0 };
const wrongResult = applyTranslucentMarkerBlend(wrongType);
assert.equal(wrongResult.invariantFailed, true);
- assert.equal(wrongType.clustering._billboardCollection.blendOption, 0, 'foreign objects are left untouched');
+ assert.equal(
+ wrongType.clustering._billboardCollection.blendOption,
+ 0,
+ 'foreign objects are left untouched',
+ );
assert.equal(applyTranslucentMarkerBlend(null).invariantFailed, true);
// ATOMICITY: a shape failure on the SECOND collection must not leave the
@@ -255,24 +345,40 @@ test('marker collections blend in a single translucent pass behind a loud shape
partial.clustering._pointCollection = { blendOption: 0 }; // renamed/reshaped
const partialResult = applyTranslucentMarkerBlend(partial);
assert.equal(partialResult.invariantFailed, true);
- assert.equal(partialResult.applied, 0, 'a failed probe reports zero applications');
+ assert.equal(
+ partialResult.applied,
+ 0,
+ 'a failed probe reports zero applications',
+ );
assert.equal(
partial.clustering._billboardCollection.blendOption,
Cesium.BlendOption.OPAQUE_AND_TRANSLUCENT,
- 'the valid first collection keeps Cesium\'s default blend when the second fails',
+ "the valid first collection keeps Cesium's default blend when the second fails",
+ );
+ assert.equal(
+ partial.clustering._pointCollection.blendOption,
+ 0,
+ 'the malformed collection is never touched',
);
- assert.equal(partial.clustering._pointCollection.blendOption, 0, 'the malformed collection is never touched');
// Real-layer wiring: once the visualizers have created the collections,
// the next sweep applies the single-pass blend to both marker sources.
const env = await createRealCableLayerHarness();
env.raiseSweep();
- const landingSource = env.dataSources.find((source) => /Landing Points/.test(source.name || ''));
- const referenceSource = env.dataSources.find((source) => /References/.test(source.name || ''));
- landingSource.clustering._billboardCollection = new Cesium.BillboardCollection();
- landingSource.clustering._pointCollection = new Cesium.PointPrimitiveCollection();
- referenceSource.clustering._billboardCollection = new Cesium.BillboardCollection();
- referenceSource.clustering._pointCollection = new Cesium.PointPrimitiveCollection();
+ const landingSource = env.dataSources.find((source) =>
+ /Landing Points/.test(source.name || ''),
+ );
+ const referenceSource = env.dataSources.find((source) =>
+ /References/.test(source.name || ''),
+ );
+ landingSource.clustering._billboardCollection =
+ new Cesium.BillboardCollection();
+ landingSource.clustering._pointCollection =
+ new Cesium.PointPrimitiveCollection();
+ referenceSource.clustering._billboardCollection =
+ new Cesium.BillboardCollection();
+ referenceSource.clustering._pointCollection =
+ new Cesium.PointPrimitiveCollection();
for (const fn of env.listeners.moveEnd) fn();
env.raiseSweep();
assert.equal(
@@ -291,10 +397,16 @@ test('marker collections blend in a single translucent pass behind a loud shape
// both before committing either, so a reshaped reference source can never
// leave the landing source already forced to TRANSLUCENT.
const failEnv = await createRealCableLayerHarness();
- const failLanding = failEnv.dataSources.find((source) => /Landing Points/.test(source.name || ''));
- const failReference = failEnv.dataSources.find((source) => /References/.test(source.name || ''));
- failLanding.clustering._billboardCollection = new Cesium.BillboardCollection();
- failLanding.clustering._pointCollection = new Cesium.PointPrimitiveCollection();
+ const failLanding = failEnv.dataSources.find((source) =>
+ /Landing Points/.test(source.name || ''),
+ );
+ const failReference = failEnv.dataSources.find((source) =>
+ /References/.test(source.name || ''),
+ );
+ failLanding.clustering._billboardCollection =
+ new Cesium.BillboardCollection();
+ failLanding.clustering._pointCollection =
+ new Cesium.PointPrimitiveCollection();
delete failReference.clustering._pointCollection; // Cesium reshaped
failEnv.raiseSweep();
assert.equal(
@@ -336,18 +448,30 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov
return sweeps;
};
- assert.equal(gate.shouldRun(camera), true, 'initial dirtiness still covers enable/load');
+ assert.equal(
+ gate.shouldRun(camera),
+ true,
+ 'initial dirtiness still covers enable/load',
+ );
// PARKED: 100 s of frames on a camera that never moves must cost ZERO
// sweeps — the fallback is motion-aware, not a reinstated timer.
- assert.equal(runFrames(6000), 0, 'a parked camera costs zero sweeps however long frames flow');
+ assert.equal(
+ runFrames(6000),
+ 0,
+ 'a parked camera costs zero sweeps however long frames flow',
+ );
// Sub-epsilon jitter is not motion either: the probe compares against the
// last SWEPT position, so numeric noise can never accumulate into a sweep.
const jitter = runFrames(6000, (frame) => {
camera.positionWC = Cesium.Cartesian3.add(
origin,
- new Cesium.Cartesian3(0, 0, (frame % 2) * (CABLE_SWEEP_MOTION_EPSILON_M / 10)),
+ new Cesium.Cartesian3(
+ 0,
+ 0,
+ (frame % 2) * (CABLE_SWEEP_MOTION_EPSILON_M / 10),
+ ),
new Cesium.Cartesian3(),
);
});
@@ -369,7 +493,11 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov
);
if (timedGate.shouldRun(timedCamera)) sweepAt = clock;
}
- assert.notEqual(sweepAt, null, 'a continuously moving camera must not starve the sweep');
+ assert.notEqual(
+ sweepAt,
+ null,
+ 'a continuously moving camera must not starve the sweep',
+ );
assert.ok(
sweepAt - trackingStart <= CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS + frameMs,
`the fallback sweep must land inside one probe window (landed at ${sweepAt - trackingStart} ms)`,
@@ -382,25 +510,44 @@ test('the sweep gate falls back to motion probes for cameras that never emit mov
// moveEnd still wins immediately: an event-dirtied gate does not wait for
// the probe window it is sitting inside.
timedGate.markDirty();
- assert.equal(timedGate.shouldRun(timedCamera), true, 'moveEnd dirtiness bypasses the probe window');
- assert.equal(timedGate.shouldRun(timedCamera), false, 'and still runs exactly once');
+ assert.equal(
+ timedGate.shouldRun(timedCamera),
+ true,
+ 'moveEnd dirtiness bypasses the probe window',
+ );
+ assert.equal(
+ timedGate.shouldRun(timedCamera),
+ false,
+ 'and still runs exactly once',
+ );
// A gate called without a camera keeps the pure dirty-only contract.
const cameraless = createCableReferenceSweepGate({ now: () => clock });
assert.equal(cameraless.shouldRun(), true);
clock += CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS * 10;
- assert.equal(cameraless.shouldRun(), false, 'no camera state means no motion fallback');
+ assert.equal(
+ cameraless.shouldRun(),
+ false,
+ 'no camera state means no motion fallback',
+ );
});
function makeStemRecord(lon = -40, lat = 35) {
const base = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
const tip = Cesium.Cartesian3.fromDegrees(lon, lat, 2500);
- const stemPositionBuffers = [[base, tip], [base, tip]];
+ const stemPositionBuffers = [
+ [base, tip],
+ [base, tip],
+ ];
const setCalls = { position: 0, polyline: 0, polylineArrays: [] };
return {
record: {
entity: {
- position: { setValue: () => { setCalls.position++; } },
+ position: {
+ setValue: () => {
+ setCalls.position++;
+ },
+ },
polyline: {
positions: {
setValue: (value) => {
@@ -428,10 +575,17 @@ test('staticized stems redefine constants only on real tip changes, alternating
const fov = Math.PI / 3;
const camera = Cesium.Cartesian3.fromDegrees(-40, 35, 500_000);
- assert.equal(updateCableReferenceStem(record, camera, canvasHeight, fov), true);
+ assert.equal(
+ updateCableReferenceStem(record, camera, canvasHeight, fov),
+ true,
+ );
assert.equal(setCalls.position, 1);
assert.equal(setCalls.polyline, 1);
- assert.equal(setCalls.polylineArrays[0], stemPositionBuffers[1], 'first real update selects the alternate buffer');
+ assert.equal(
+ setCalls.polylineArrays[0],
+ stemPositionBuffers[1],
+ 'first real update selects the alternate buffer',
+ );
// Sub-epsilon camera noise must not redefine anything.
const jitter = Cesium.Cartesian3.add(
@@ -439,7 +593,10 @@ test('staticized stems redefine constants only on real tip changes, alternating
new Cesium.Cartesian3(CABLE_STEM_TIP_EPSILON_M / 10, 0, 0),
new Cesium.Cartesian3(),
);
- assert.equal(updateCableReferenceStem(record, jitter, canvasHeight, fov), false);
+ assert.equal(
+ updateCableReferenceStem(record, jitter, canvasHeight, fov),
+ false,
+ );
assert.equal(setCalls.position, 1);
assert.equal(setCalls.polyline, 1);
@@ -450,7 +607,10 @@ test('staticized stems redefine constants only on real tip changes, alternating
new Cesium.Cartesian3(0, 0, 400_000),
new Cesium.Cartesian3(),
);
- assert.equal(updateCableReferenceStem(record, moved, canvasHeight, fov), true);
+ assert.equal(
+ updateCableReferenceStem(record, moved, canvasHeight, fov),
+ true,
+ );
assert.equal(setCalls.polylineArrays[1], stemPositionBuffers[0]);
assert.equal(new Set(setCalls.polylineArrays).size, 2);
@@ -469,13 +629,47 @@ test('the depth decision is the dated Option-2 host migration, superseding Optio
});
test('cables create no native labels and no per-frame geometry callbacks', () => {
- const source = readFileSync(new URL('./telegeographySubmarineCables.js', import.meta.url), 'utf8');
- assert.doesNotMatch(source, /new Cesium\.LabelGraphics/, 'reverting to native labels must fail this pin');
- assert.doesNotMatch(source, /new Cesium\.CallbackProperty/, 'reverting to per-frame stem callbacks must fail this pin');
- assert.match(source, /setOverlayEntries/, 'labels must flow through the shared world-overlay host');
+ const source = [
+ '../../data/telegeographySubmarineCables.js',
+ 'rendering.js',
+ 'overlay.js',
+ 'surface.js',
+ 'ingestion.js',
+ 'interaction.js',
+ 'geometry.js',
+ 'lifecycle.js',
+ ]
+ .map((file) =>
+ readFileSync(
+ new URL('../layers/submarineCables/' + file, import.meta.url),
+ 'utf8',
+ ),
+ )
+ .join('\n');
+ assert.doesNotMatch(
+ source,
+ /new Cesium\.LabelGraphics/,
+ 'reverting to native labels must fail this pin',
+ );
+ assert.doesNotMatch(
+ source,
+ /new Cesium\.CallbackProperty/,
+ 'reverting to per-frame stem callbacks must fail this pin',
+ );
+ assert.match(
+ source,
+ /setOverlayEntries/,
+ 'labels must flow through the shared world-overlay host',
+ );
assert.match(source, /record\.entity\.position\.setValue\(record\.tip\)/);
- assert.match(source, /const stemPositionBuffers = \[\[base, tip\], \[base, tip\]\]/);
- assert.match(source, /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/);
+ assert.match(
+ source,
+ /const stemPositionBuffers = \[\s*\[base, tip\],\s*\[base, tip\],?\s*\]/,
+ );
+ assert.match(
+ source,
+ /record\.entity\.polyline\.positions\.setValue\(stemPositions\)/,
+ );
});
const CABLE_FIXTURE = {
@@ -485,7 +679,13 @@ const CABLE_FIXTURE = {
type: 'Feature',
id: 'ac1',
properties: { id: 'ac1', name: 'Atlantic Crossing-1' },
- geometry: { type: 'LineString', coordinates: [[-40, 35], [-30, 40]] },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [-40, 35],
+ [-30, 40],
+ ],
+ },
},
],
};
@@ -498,17 +698,34 @@ const LANDING_FIXTURE = {
{
type: 'Feature',
id: 'lisbon',
- properties: { id: 'lisbon', name: 'Lisbon, Portugal', coordinates: [-9.1, 38.7] },
- geometry: { type: 'LineString', coordinates: [[-9.1, 38.7], [-9.05, 38.72]] },
+ properties: {
+ id: 'lisbon',
+ name: 'Lisbon, Portugal',
+ coordinates: [-9.1, 38.7],
+ },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [-9.1, 38.7],
+ [-9.05, 38.72],
+ ],
+ },
},
],
};
function fixtureForUrl(url) {
- return String(url).includes('landing-point') ? LANDING_FIXTURE : CABLE_FIXTURE;
+ return String(url).includes('landing-point')
+ ? LANDING_FIXTURE
+ : CABLE_FIXTURE;
}
-function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addControl = null) {
+function makeStubViewer(
+ listeners,
+ dataSources,
+ addCalls = { count: 0 },
+ addControl = null,
+) {
return {
dataSources: {
// Mirror Cesium's real DataSourceCollection.add(): the returned promise
@@ -527,7 +744,11 @@ function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addCont
const settle = () => {
dataSources.push(source);
if (addControl?.rejectAfterPush?.has(callIndex)) {
- reject(new Error('dataSourceAdded listener threw after the push landed'));
+ reject(
+ new Error(
+ 'dataSourceAdded listener threw after the push landed',
+ ),
+ );
return;
}
resolve(source);
@@ -558,7 +779,9 @@ function makeStubViewer(listeners, dataSources, addCalls = { count: 0 }, addCont
scene: {
canvas: { clientHeight: 900 },
requestRender() {},
- pick() { return null; },
+ pick() {
+ return null;
+ },
preRender: {
addEventListener: (fn) => {
listeners.preRender.add(fn);
@@ -581,33 +804,41 @@ function createDeferredCableLayerHarness() {
const addCalls = { count: 0 };
const addControl = { held: false, pending: [], rejectAfterPush: new Set() };
const pendingFetches = [];
- const abortError = () => Object.assign(new Error('The operation was aborted'), { name: 'AbortError' });
+ const abortError = () =>
+ Object.assign(new Error('The operation was aborted'), {
+ name: 'AbortError',
+ });
const originalFetch = globalThis.fetch;
- globalThis.fetch = (url, options = {}) => new Promise((resolve, reject) => {
- const entry = {
- url: String(url),
- settled: false,
- release() {
- if (entry.settled) return;
- entry.settled = true;
- resolve({ ok: true, json: async () => fixtureForUrl(url) });
- },
- };
- const signal = options?.signal;
- if (signal) {
- if (signal.aborted) {
- entry.settled = true;
- reject(abortError());
- return;
+ globalThis.fetch = (url, options = {}) =>
+ new Promise((resolve, reject) => {
+ const entry = {
+ url: String(url),
+ settled: false,
+ release() {
+ if (entry.settled) return;
+ entry.settled = true;
+ resolve({ ok: true, json: async () => fixtureForUrl(url) });
+ },
+ };
+ const signal = options?.signal;
+ if (signal) {
+ if (signal.aborted) {
+ entry.settled = true;
+ reject(abortError());
+ return;
+ }
+ signal.addEventListener(
+ 'abort',
+ () => {
+ if (entry.settled) return;
+ entry.settled = true;
+ reject(abortError());
+ },
+ { once: true },
+ );
}
- signal.addEventListener('abort', () => {
- if (entry.settled) return;
- entry.settled = true;
- reject(abortError());
- }, { once: true });
- }
- pendingFetches.push(entry);
- });
+ pendingFetches.push(entry);
+ });
const layer = createTeleGeographySubmarineCableLayer({
overlayHost: {
@@ -615,7 +846,10 @@ function createDeferredCableLayerHarness() {
setEntries: (...args) => hostCalls.push(['entries', ...args]),
clearSource: (...args) => hostCalls.push(['clear', ...args]),
},
- screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
+ screenSpaceEventHandlerFactory: () => ({
+ setInputAction() {},
+ destroy() {},
+ }),
});
const viewer = makeStubViewer(listeners, dataSources, addCalls, addControl);
layer.init(viewer);
@@ -651,7 +885,8 @@ function createDeferredCableLayerHarness() {
},
async settle() {
// Drain the microtask/timer interleave the load path spans.
- for (let i = 0; i < 8; i++) await new Promise((resolve) => setTimeout(resolve, 0));
+ for (let i = 0; i < 8; i++)
+ await new Promise((resolve) => setTimeout(resolve, 0));
},
cleanup() {
globalThis.fetch = originalFetch;
@@ -672,9 +907,12 @@ function installGeoJsonLoadGate() {
Cesium.GeoJsonDataSource.load = function gatedLoad(...args) {
calls.push(args);
let open;
- const opened = new Promise((resolve) => { open = resolve; });
+ const opened = new Promise((resolve) => {
+ open = resolve;
+ });
gates.push({ open });
- return original.apply(Cesium.GeoJsonDataSource, args)
+ return original
+ .apply(Cesium.GeoJsonDataSource, args)
.then((result) => opened.then(() => result));
};
return {
@@ -711,12 +949,20 @@ test('an abort landing at the post-fetch boundary stops the stale load before Ge
env.layer.disable();
env.layer.enable(env.viewer);
await env.settle();
- assert.equal(geojson.calls.length, 0, 'stale load A must not reach GeoJsonDataSource.load');
+ assert.equal(
+ geojson.calls.length,
+ 0,
+ 'stale load A must not reach GeoJsonDataSource.load',
+ );
env.releaseAll();
await env.settle();
await geojson.drainOpen(() => env.settle());
- assert.equal(geojson.calls.length, 2, 'load B alone performs the two GeoJson loads');
+ assert.equal(
+ geojson.calls.length,
+ 2,
+ 'load B alone performs the two GeoJson loads',
+ );
assert.equal(env.addCalls.count, 3);
assert.equal(env.dataSources.length, 3);
assert.equal(env.layer.getStats().count, 2);
@@ -735,7 +981,11 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b
env.releaseAll();
await env.settle();
assert.equal(env.pendingFetches.length, 0);
- assert.equal(geojson.calls.length, 1, 'load A is parked on the gated cable GeoJson build');
+ assert.equal(
+ geojson.calls.length,
+ 1,
+ 'load A is parked on the gated cable GeoJson build',
+ );
// Open the cable gate and abort in the same frame: A resumes at the
// post-cable owns() check with ownership lost.
@@ -746,17 +996,32 @@ test('an abort landing at the post-cable-GeoJson boundary stops the stale load b
// A already cached the fetched JSON, so load B skips the network and goes
// straight to its own CABLE build; the only new GeoJson call must be that
// one — a landing build here would mean stale A ran on past its check.
- assert.equal(env.pendingFetches.length, 0, 'load B rebuilds from the cached JSON without refetching');
- assert.equal(geojson.calls.length, 2, 'stale load A must not start the landing GeoJson build');
+ assert.equal(
+ env.pendingFetches.length,
+ 0,
+ 'load B rebuilds from the cached JSON without refetching',
+ );
+ assert.equal(
+ geojson.calls.length,
+ 2,
+ 'stale load A must not start the landing GeoJson build',
+ );
assert.ok(
- Cesium.Color.equals(geojson.calls[1][1].stroke, geojson.calls[0][1].stroke),
- 'the second GeoJson call is load B\'s cable build, not A\'s landing build',
+ Cesium.Color.equals(
+ geojson.calls[1][1].stroke,
+ geojson.calls[0][1].stroke,
+ ),
+ "the second GeoJson call is load B's cable build, not A's landing build",
);
env.releaseAll();
await env.settle();
await geojson.drainOpen(() => env.settle());
- assert.equal(geojson.calls.length, 3, 'load B adds its own two GeoJson builds');
+ assert.equal(
+ geojson.calls.length,
+ 3,
+ 'load B adds its own two GeoJson builds',
+ );
assert.equal(env.addCalls.count, 3);
assert.equal(env.dataSources.length, 3);
assert.equal(env.layer.getStats().count, 2);
@@ -776,7 +1041,11 @@ test('an abort landing at the pre-add boundary stops the stale load before any d
await env.settle();
geojson.open(0);
await env.settle();
- assert.equal(geojson.calls.length, 2, 'load A is parked on the gated landing GeoJson build');
+ assert.equal(
+ geojson.calls.length,
+ 2,
+ 'load A is parked on the gated landing GeoJson build',
+ );
// Open the landing gate and abort in the same frame: A resumes at the
// pre-add owns() check with ownership lost.
@@ -784,7 +1053,11 @@ test('an abort landing at the pre-add boundary stops the stale load before any d
env.layer.disable();
env.layer.enable(env.viewer);
await env.settle();
- assert.equal(env.addCalls.count, 0, 'stale load A must never call dataSources.add');
+ assert.equal(
+ env.addCalls.count,
+ 0,
+ 'stale load A must never call dataSources.add',
+ );
env.releaseAll();
await env.settle();
@@ -812,8 +1085,16 @@ test('a destroy inside the deferred add window leaves zero post-destroy data sou
while (env.addCalls.count < 3 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
- assert.equal(env.addCalls.count, 3, 'all three adds are in their deferred window');
- assert.equal(env.dataSources.length, 0, 'nothing has settled into the collection yet');
+ assert.equal(
+ env.addCalls.count,
+ 3,
+ 'all three adds are in their deferred window',
+ );
+ assert.equal(
+ env.dataSources.length,
+ 0,
+ 'nothing has settled into the collection yet',
+ );
// Destroy lands in the window: it can remove nothing, so the stale
// load's own post-add ownership check must compensate once the deferred
@@ -831,7 +1112,11 @@ test('a destroy inside the deferred add window leaves zero post-destroy data sou
env.layer.enable(env.viewer);
env.releaseAll();
await env.settle();
- assert.equal(env.dataSources.length, 3, 're-enable after the raced destroy loads cleanly');
+ assert.equal(
+ env.dataSources.length,
+ 3,
+ 're-enable after the raced destroy loads cleanly',
+ );
assert.equal(env.layer.getStats().count, 2);
} finally {
env.layer.destroy(env.viewer);
@@ -854,8 +1139,16 @@ test('a destroy in the deferred window with a rejected-after-push add still leav
while (env.addCalls.count < 3 && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
- assert.equal(env.addCalls.count, 3, 'all three adds are in their deferred window');
- assert.equal(env.dataSources.length, 0, 'nothing has settled into the collection yet');
+ assert.equal(
+ env.addCalls.count,
+ 3,
+ 'all three adds are in their deferred window',
+ );
+ assert.equal(
+ env.dataSources.length,
+ 0,
+ 'nothing has settled into the collection yet',
+ );
// Destroy lands in the window; the adds then settle as
// [rejected-after-push, fulfilled, fulfilled]. Compensation that removes
@@ -895,7 +1188,9 @@ test('a direct destroy of a published layer clears and hides its host source', a
// a visible overlay source with orphan labels.
env.layer.destroy(env.viewer);
assert.ok(
- env.hostCalls.some(([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID),
+ env.hostCalls.some(
+ ([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID,
+ ),
'direct destroy must clear the published host source',
);
assert.deepEqual(
@@ -908,12 +1203,16 @@ test('a direct destroy of a published layer clears and hides its host source', a
}
});
-test('a stale aborted load never clobbers the next load\'s lifecycle (A/B/C toggle race)', async () => {
+test("a stale aborted load never clobbers the next load's lifecycle (A/B/C toggle race)", async () => {
const env = createDeferredCableLayerHarness();
try {
// A: enable starts a load whose fetches stay pending.
env.layer.enable(env.viewer);
- assert.equal(env.pendingFetches.length, 2, 'load A has two fetches in flight');
+ assert.equal(
+ env.pendingFetches.length,
+ 2,
+ 'load A has two fetches in flight',
+ );
// Abort A and immediately start B before A\'s rejection lands.
env.layer.disable();
@@ -925,13 +1224,23 @@ test('a stale aborted load never clobbers the next load\'s lifecycle (A/B/C togg
// third load.
void env.layer.update(env.viewer);
await env.settle();
- const inFlight = env.pendingFetches.filter((entry) => !entry.settled).length;
- assert.equal(inFlight, 2, 'only load B\'s two fetches remain in flight — no duplicate load C');
+ const inFlight = env.pendingFetches.filter(
+ (entry) => !entry.settled,
+ ).length;
+ assert.equal(
+ inFlight,
+ 2,
+ "only load B's two fetches remain in flight — no duplicate load C",
+ );
// Releasing everything must produce exactly one set of scene adds.
env.releaseAll();
await env.settle();
- assert.equal(env.addCalls.count, 3, 'exactly one cable/landing/reference data-source trio is added');
+ assert.equal(
+ env.addCalls.count,
+ 3,
+ 'exactly one cable/landing/reference data-source trio is added',
+ );
assert.equal(env.dataSources.length, 3);
const stats = env.layer.getStats();
assert.equal(stats.error, null);
@@ -959,12 +1268,18 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once',
// update() must not start a duplicate load E.
void env.layer.update(env.viewer);
await env.settle();
- const inFlight = env.pendingFetches.filter((entry) => !entry.settled).length;
- assert.equal(inFlight, 2, 'only load D\'s two fetches remain in flight');
+ const inFlight = env.pendingFetches.filter(
+ (entry) => !entry.settled,
+ ).length;
+ assert.equal(inFlight, 2, "only load D's two fetches remain in flight");
env.releaseAll();
await env.settle();
- assert.equal(env.addCalls.count, 3, 'the destroyed load added nothing; the re-enable added once');
+ assert.equal(
+ env.addCalls.count,
+ 3,
+ 'the destroyed load added nothing; the re-enable added once',
+ );
assert.equal(env.dataSources.length, 3);
assert.equal(env.layer.getStats().count, 2);
} finally {
@@ -973,7 +1288,10 @@ test('destroy mid-load stays clean and a re-enabled layer reloads exactly once',
}
});
-async function createRealCableLayerHarness({ mapStackEventTarget = null } = {}) {
+async function createRealCableLayerHarness({
+ source,
+ mapStackEventTarget = null,
+} = {}) {
const hostCalls = [];
// The sweep gate's motion-fallback clock is FROZEN here and advanced only by
// the tests that exercise it, so every other harness test observes the pure
@@ -985,7 +1303,11 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {})
setEntries: (...args) => hostCalls.push(['entries', ...args]),
clearSource: (...args) => hostCalls.push(['clear', ...args]),
},
- screenSpaceEventHandlerFactory: () => ({ setInputAction() {}, destroy() {} }),
+ screenSpaceEventHandlerFactory: () => ({
+ setInputAction() {},
+ destroy() {},
+ }),
+ ...(source ? { source } : {}),
mapStackEventTarget,
sweepClock: () => clockMs,
});
@@ -994,7 +1316,10 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {})
const dataSources = [];
const viewer = {
dataSources: {
- add: (source) => { dataSources.push(source); return Promise.resolve(source); },
+ add: (source) => {
+ dataSources.push(source);
+ return Promise.resolve(source);
+ },
remove: (source) => {
const index = dataSources.indexOf(source);
if (index >= 0) dataSources.splice(index, 1);
@@ -1017,7 +1342,9 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {})
scene: {
canvas: { clientHeight: 900 },
requestRender() {},
- pick() { return null; },
+ pick() {
+ return null;
+ },
preRender: {
addEventListener: (fn) => {
listeners.preRender.add(fn);
@@ -1033,29 +1360,46 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {})
// `properties.coordinates`, so a line geometry exercises the same path.
globalThis.fetch = async (url) => ({
ok: true,
- json: async () => (String(url).includes('landing-point')
- ? {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- id: 'lisbon',
- properties: { id: 'lisbon', name: 'Lisbon, Portugal', coordinates: [-9.1, 38.7] },
- geometry: { type: 'LineString', coordinates: [[-9.1, 38.7], [-9.05, 38.72]] },
+ json: async () =>
+ String(url).includes('landing-point')
+ ? {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'lisbon',
+ properties: {
+ id: 'lisbon',
+ name: 'Lisbon, Portugal',
+ coordinates: [-9.1, 38.7],
+ },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [-9.1, 38.7],
+ [-9.05, 38.72],
+ ],
+ },
+ },
+ ],
+ }
+ : {
+ type: 'FeatureCollection',
+ features: [
+ {
+ type: 'Feature',
+ id: 'ac1',
+ properties: { id: 'ac1', name: 'Atlantic Crossing-1' },
+ geometry: {
+ type: 'LineString',
+ coordinates: [
+ [-40, 35],
+ [-30, 40],
+ ],
+ },
+ },
+ ],
},
- ],
- }
- : {
- type: 'FeatureCollection',
- features: [
- {
- type: 'Feature',
- id: 'ac1',
- properties: { id: 'ac1', name: 'Atlantic Crossing-1' },
- geometry: { type: 'LineString', coordinates: [[-40, 35], [-30, 40]] },
- },
- ],
- }),
});
try {
layer.init(viewer);
@@ -1075,8 +1419,18 @@ async function createRealCableLayerHarness({ mapStackEventTarget = null } = {})
const raiseSweep = () => {
for (const fn of listeners.preRender) fn();
};
- const advanceClock = (ms) => { clockMs += ms; };
- return { layer, viewer, hostCalls, dataSources, raiseSweep, listeners, advanceClock };
+ const advanceClock = (ms) => {
+ clockMs += ms;
+ };
+ return {
+ layer,
+ viewer,
+ hostCalls,
+ dataSources,
+ raiseSweep,
+ listeners,
+ advanceClock,
+ };
}
test('a real enabled cable layer publishes host entries and has zero native labels', async () => {
@@ -1095,7 +1449,9 @@ test('a real enabled cable layer publishes host entries and has zero native labe
moving: false,
});
- const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
+ const referenceSource = env.dataSources.find((ds) =>
+ /References/.test(ds.name || ''),
+ );
assert.ok(referenceSource, 'reference data source must exist');
const referenceEntities = referenceSource.entities.values;
assert.ok(referenceEntities.length > 0);
@@ -1104,8 +1460,11 @@ test('a real enabled cable layer publishes host entries and has zero native labe
'no reference entity may carry a native LabelGraphics',
);
assert.ok(
- referenceEntities.every((entity) => entity.position?.isConstant === true
- && entity.polyline?.positions?.isConstant === true),
+ referenceEntities.every(
+ (entity) =>
+ entity.position?.isConstant === true &&
+ entity.polyline?.positions?.isConstant === true,
+ ),
'stem properties must be constants, never per-frame callbacks',
);
@@ -1114,7 +1473,9 @@ test('a real enabled cable layer publishes host entries and has zero native labe
env.layer.disable();
assert.ok(
- env.hostCalls.some(([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID),
+ env.hostCalls.some(
+ ([type, id]) => type === 'clear' && id === CABLE_OVERLAY_SOURCE_ID,
+ ),
'disable must clear the host source',
);
assert.deepEqual(
@@ -1124,17 +1485,25 @@ test('a real enabled cable layer publishes host entries and has zero native labe
);
env.layer.destroy(env.viewer);
- assert.equal(env.dataSources.length, 0, 'destroy must remove every data source');
+ assert.equal(
+ env.dataSources.length,
+ 0,
+ 'destroy must remove every data source',
+ );
});
test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async () => {
const env = await createRealCableLayerHarness();
env.raiseSweep(); // initial sizing at the parked camera
- const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
+ const referenceSource = env.dataSources.find((ds) =>
+ /References/.test(ds.name || ''),
+ );
const entity = referenceSource.entities.values[0];
let stemRebuilds = 0;
- const originalSet = entity.polyline.positions.setValue.bind(entity.polyline.positions);
+ const originalSet = entity.polyline.positions.setValue.bind(
+ entity.polyline.positions,
+ );
entity.polyline.positions.setValue = (...args) => {
stemRebuilds++;
return originalSet(...args);
@@ -1154,12 +1523,20 @@ test('stems never rebuild mid-drag; exactly one rebuild lands on moveEnd', async
env.raiseSweep(); // preRender fires every frame during a drag
await new Promise((resolve) => setTimeout(resolve, 25));
}
- assert.equal(stemRebuilds, 0, 'render ticks without moveEnd must never redefine a stem');
+ assert.equal(
+ stemRebuilds,
+ 0,
+ 'render ticks without moveEnd must never redefine a stem',
+ );
// The gesture ends close over the cable's reference point (inside the
// 700 m..85 km stem clamp band, so the re-size is a real tip change):
// one moveEnd, one sweep, exactly one redefinition.
- env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-35, 37.5, 400_000);
+ env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -35,
+ 37.5,
+ 400_000,
+ );
for (const fn of env.listeners.moveEnd) fn();
env.raiseSweep();
assert.equal(stemRebuilds, 1, 'moveEnd re-sizes the stem exactly once');
@@ -1175,13 +1552,21 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback'
// inside the 700 m..85 km clamp band, so a sweep is observable as a real
// tip re-size (at the harness's 4,500 km parking altitude every stem sits
// pinned at the 85 km clamp and no sweep could be distinguished).
- env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-35, 37.5, 400_000);
+ env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -35,
+ 37.5,
+ 400_000,
+ );
env.raiseSweep(); // initial sizing at the tracking handoff
- const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
+ const referenceSource = env.dataSources.find((ds) =>
+ /References/.test(ds.name || ''),
+ );
const entity = referenceSource.entities.values[0];
let stemRebuilds = 0;
- const originalSet = entity.polyline.positions.setValue.bind(entity.polyline.positions);
+ const originalSet = entity.polyline.positions.setValue.bind(
+ entity.polyline.positions,
+ );
entity.polyline.positions.setValue = (...args) => {
stemRebuilds++;
return originalSet(...args);
@@ -1206,11 +1591,19 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback'
// Inside the first probe window the sweep stays event-dirty-only, so the
// batched stem primitive is not rebuilt mid-motion (the shipped tradeoff).
trackFrames(0, 60);
- assert.equal(stemRebuilds, 0, 'inside the probe window a tracked camera still costs no rebuilds');
+ assert.equal(
+ stemRebuilds,
+ 0,
+ 'inside the probe window a tracked camera still costs no rebuilds',
+ );
// Past the window the fallback fires — exactly once, not per frame.
trackFrames(60, 130);
- assert.equal(stemRebuilds, 1, 'the tracked camera earns exactly one fallback sweep per window');
+ assert.equal(
+ stemRebuilds,
+ 1,
+ 'the tracked camera earns exactly one fallback sweep per window',
+ );
// Tracking stops but frames keep rendering. The motion accumulated since
// the last fallback sweep settles in exactly ONE further sweep...
@@ -1221,18 +1614,27 @@ test('a tracked camera that never emits moveEnd is swept by the motion fallback'
}
};
parkFrames(240); // 4 s parked: two full probe windows
- assert.equal(stemRebuilds, 2, 'a stopped camera settles its residual motion in one further sweep');
+ assert.equal(
+ stemRebuilds,
+ 2,
+ 'a stopped camera settles its residual motion in one further sweep',
+ );
// ...and the parked camera then costs zero sweeps for as long as it sits.
parkFrames(3600); // 60 s parked
- assert.equal(stemRebuilds, 2, 'a settled parked camera never spends another sweep');
+ assert.equal(
+ stemRebuilds,
+ 2,
+ 'a settled parked camera never spends another sweep',
+ );
env.layer.destroy(env.viewer);
});
test('an unchanged cohort never republishes, so a parked camera stays governor-idle', async () => {
const env = await createRealCableLayerHarness();
- const publishCount = () => env.hostCalls.filter(([type]) => type === 'entries').length;
+ const publishCount = () =>
+ env.hostCalls.filter(([type]) => type === 'entries').length;
env.raiseSweep();
assert.equal(publishCount(), 1, 'first sweep publishes the cohort');
@@ -1247,7 +1649,11 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i
// A real camera move changes distances beyond the 50 km priority quantum,
// so the next sweep publishes again.
- env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(-40, 35, 6_500_000);
+ env.viewer.camera.positionWC = Cesium.Cartesian3.fromDegrees(
+ -40,
+ 35,
+ 6_500_000,
+ );
for (const fn of env.listeners.moveEnd) fn();
env.raiseSweep();
assert.equal(publishCount(), 2, 'a changed cohort publishes');
@@ -1258,24 +1664,39 @@ test('an unchanged cohort never republishes, so a parked camera stays governor-i
env.layer.enable(env.viewer);
await new Promise((resolve) => setTimeout(resolve, 10));
env.raiseSweep();
- assert.equal(publishCount(), 3, 're-enable republishes after the hide cleared the source');
+ assert.equal(
+ publishCount(),
+ 3,
+ 're-enable republishes after the hide cleared the source',
+ );
env.layer.destroy(env.viewer);
});
test('a toggle-off releases every data source and a toggle-on rebuilds them without refetching', async () => {
const env = await createRealCableLayerHarness();
- assert.equal(env.dataSources.length, 3, 'cable + landing + reference sources while on');
+ assert.equal(
+ env.dataSources.length,
+ 3,
+ 'cable + landing + reference sources while on',
+ );
const before = env.layer.getStats().count;
assert.equal(before, 2);
// Any fetch after this point is a bug: the rebuild must come from the cache.
const originalFetch = globalThis.fetch;
let fetches = 0;
- globalThis.fetch = async () => { fetches += 1; throw new Error('unexpected refetch'); };
+ globalThis.fetch = async () => {
+ fetches += 1;
+ throw new Error('unexpected refetch');
+ };
try {
env.layer.disable();
- assert.equal(env.dataSources.length, 0, 'hidden entities are not free; disable must remove the sources');
+ assert.equal(
+ env.dataSources.length,
+ 0,
+ 'hidden entities are not free; disable must remove the sources',
+ );
env.layer.enable(env.viewer);
const deadline = Date.now() + 2_000;
@@ -1286,8 +1707,13 @@ test('a toggle-off releases every data source and a toggle-on rebuilds them with
assert.equal(env.layer.getStats().error, null);
assert.equal(env.dataSources.length, 3, 're-enable rebuilds the trio');
assert.equal(env.layer.getStats().count, before);
- const referenceSource = env.dataSources.find((ds) => /References/.test(ds.name || ''));
- assert.ok(referenceSource?.entities.values.length > 0, 'reference stems are rebuilt');
+ const referenceSource = env.dataSources.find((ds) =>
+ /References/.test(ds.name || ''),
+ );
+ assert.ok(
+ referenceSource?.entities.values.length > 0,
+ 'reference stems are rebuilt',
+ );
env.layer.destroy(env.viewer);
assert.equal(env.dataSources.length, 0);
@@ -1295,3 +1721,34 @@ test('a toggle-off releases every data source and a toggle-on rebuilds them with
globalThis.fetch = originalFetch;
}
});
+
+test('another GeoJSON source renders with its own label and rebuilds from accepted cache', async () => {
+ let reads = 0;
+ const source = {
+ label: 'Test cable directory',
+ async fetch(signal) {
+ signal.throwIfAborted();
+ reads += 1;
+ return { cables: CABLE_FIXTURE, landingPoints: LANDING_FIXTURE };
+ },
+ };
+ const env = await createRealCableLayerHarness({ source });
+ try {
+ assert.equal(env.layer.source, source.label);
+ assert.equal(reads, 1);
+ assert.equal(env.dataSources.length, 3);
+ assert.ok(
+ env.dataSources.every((item) => item.name.startsWith(source.label)),
+ );
+ env.layer.disable();
+ assert.equal(env.dataSources.length, 0);
+ env.layer.enable(env.viewer);
+ for (let i = 0; i < 8; i++)
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.equal(env.layer.getStats().count, 2);
+ assert.equal(env.dataSources.length, 3);
+ assert.equal(reads, 1);
+ } finally {
+ env.layer.destroy(env.viewer);
+ }
+});
diff --git a/src/layers/radio/catalogModel.js b/src/layers/radio/catalogModel.js
new file mode 100644
index 000000000..ef06b08c3
--- /dev/null
+++ b/src/layers/radio/catalogModel.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/categories.js b/src/layers/radio/categories.js
new file mode 100644
index 000000000..a7bfc182e
--- /dev/null
+++ b/src/layers/radio/categories.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/clustering.js b/src/layers/radio/clustering.js
new file mode 100644
index 000000000..416276317
--- /dev/null
+++ b/src/layers/radio/clustering.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/controls.js b/src/layers/radio/controls.js
new file mode 100644
index 000000000..6313ccf4d
--- /dev/null
+++ b/src/layers/radio/controls.js
@@ -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 };
+}
diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js
new file mode 100644
index 000000000..fa84e1054
--- /dev/null
+++ b/src/layers/radio/index.js
@@ -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';
diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js
new file mode 100644
index 000000000..8a89b444c
--- /dev/null
+++ b/src/layers/radio/ingestion.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 };
+}
diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js
new file mode 100644
index 000000000..da8ff558a
--- /dev/null
+++ b/src/layers/radio/interaction.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/labels.js b/src/layers/radio/labels.js
new file mode 100644
index 000000000..f801384ce
--- /dev/null
+++ b/src/layers/radio/labels.js
@@ -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 ``;
+ }
+ return {
+ compactRadioLabelText,
+ cleanRadioLabelName,
+ radioGlobeLabel,
+ createRadioSelectedOverlayEntry,
+ createRadioClusterOverlayEntry,
+ createRadioSingletonOverlayEntry,
+ radioSelectionBracketSvg,
+ };
+}
diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js
new file mode 100644
index 000000000..414cfa5c0
--- /dev/null
+++ b/src/layers/radio/lifecycle.js
@@ -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 };
+}
diff --git a/src/layers/radio/model.js b/src/layers/radio/model.js
new file mode 100644
index 000000000..f969a63fe
--- /dev/null
+++ b/src/layers/radio/model.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js
new file mode 100644
index 000000000..243a1941b
--- /dev/null
+++ b/src/layers/radio/navigation.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/playback.js b/src/layers/radio/playback.js
new file mode 100644
index 000000000..cbb4b8be5
--- /dev/null
+++ b/src/layers/radio/playback.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/policy.js b/src/layers/radio/policy.js
new file mode 100644
index 000000000..21d987473
--- /dev/null
+++ b/src/layers/radio/policy.js
@@ -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;
diff --git a/src/layers/radio/presentation.js b/src/layers/radio/presentation.js
new file mode 100644
index 000000000..5d6763ca4
--- /dev/null
+++ b/src/layers/radio/presentation.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/queries.js b/src/layers/radio/queries.js
new file mode 100644
index 000000000..5bc4cc573
--- /dev/null
+++ b/src/layers/radio/queries.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js
new file mode 100644
index 000000000..36cbc5050
--- /dev/null
+++ b/src/layers/radio/rendering.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js
new file mode 100644
index 000000000..bffc7de04
--- /dev/null
+++ b/src/layers/radio/selection.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/source.js b/src/layers/radio/source.js
new file mode 100644
index 000000000..818e410f5
--- /dev/null
+++ b/src/layers/radio/source.js
@@ -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}`);
+ },
+ };
+}
diff --git a/src/layers/radio/source.test.mjs b/src/layers/radio/source.test.mjs
new file mode 100644
index 000000000..8d28e1485
--- /dev/null
+++ b/src/layers/radio/source.test.mjs
@@ -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();
+});
diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js
new file mode 100644
index 000000000..dca461e03
--- /dev/null
+++ b/src/layers/radio/state.js
@@ -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;
+}
diff --git a/src/layers/radio/tuning.js b/src/layers/radio/tuning.js
new file mode 100644
index 000000000..ab5c56bb5
--- /dev/null
+++ b/src/layers/radio/tuning.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/tuningNoise.js b/src/layers/radio/tuningNoise.js
new file mode 100644
index 000000000..dfbc3fe3b
--- /dev/null
+++ b/src/layers/radio/tuningNoise.js
@@ -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,
+ };
+}
diff --git a/src/layers/radio/volume.js b/src/layers/radio/volume.js
new file mode 100644
index 000000000..1be63cc53
--- /dev/null
+++ b/src/layers/radio/volume.js
@@ -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,
+ };
+}
diff --git a/src/layers/submarineCables/bundledSource.js b/src/layers/submarineCables/bundledSource.js
new file mode 100644
index 000000000..6aa0b70bb
--- /dev/null
+++ b/src/layers/submarineCables/bundledSource.js
@@ -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 };
+ },
+ };
+}
diff --git a/src/layers/submarineCables/bundledSource.test.mjs b/src/layers/submarineCables/bundledSource.test.mjs
new file mode 100644
index 000000000..d229254ae
--- /dev/null
+++ b/src/layers/submarineCables/bundledSource.test.mjs
@@ -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' });
+});
diff --git a/src/layers/submarineCables/geometry.js b/src/layers/submarineCables/geometry.js
new file mode 100644
index 000000000..08366dba4
--- /dev/null
+++ b/src/layers/submarineCables/geometry.js
@@ -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),
+ };
+ });
+}
diff --git a/src/layers/submarineCables/index.js b/src/layers/submarineCables/index.js
new file mode 100644
index 000000000..5de4fe6e2
--- /dev/null
+++ b/src/layers/submarineCables/index.js
@@ -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';
diff --git a/src/layers/submarineCables/ingestion.js b/src/layers/submarineCables/ingestion.js
new file mode 100644
index 000000000..eaefbeb39
--- /dev/null
+++ b/src/layers/submarineCables/ingestion.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 };
+}
diff --git a/src/layers/submarineCables/interaction.js b/src/layers/submarineCables/interaction.js
new file mode 100644
index 000000000..08e3384ed
--- /dev/null
+++ b/src/layers/submarineCables/interaction.js
@@ -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,
+ };
+}
diff --git a/src/layers/submarineCables/lifecycle.js b/src/layers/submarineCables/lifecycle.js
new file mode 100644
index 000000000..39e41f630
--- /dev/null
+++ b/src/layers/submarineCables/lifecycle.js
@@ -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,
+ };
+ },
+ };
+}
diff --git a/src/layers/submarineCables/overlay.js b/src/layers/submarineCables/overlay.js
new file mode 100644
index 000000000..a51d2769b
--- /dev/null
+++ b/src/layers/submarineCables/overlay.js
@@ -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))}...`;
+}
diff --git a/src/layers/submarineCables/policy.js b/src/layers/submarineCables/policy.js
new file mode 100644
index 000000000..68009af2e
--- /dev/null
+++ b/src/layers/submarineCables/policy.js
@@ -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();
+}
diff --git a/src/layers/submarineCables/rendering.js b/src/layers/submarineCables/rendering.js
new file mode 100644
index 000000000..91936573f
--- /dev/null
+++ b/src/layers/submarineCables/rendering.js
@@ -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,
+ };
+}
diff --git a/src/layers/submarineCables/state.js b/src/layers/submarineCables/state.js
new file mode 100644
index 000000000..0761688aa
--- /dev/null
+++ b/src/layers/submarineCables/state.js
@@ -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;
+}
diff --git a/src/layers/submarineCables/surface.js b/src/layers/submarineCables/surface.js
new file mode 100644
index 000000000..36556bbb3
--- /dev/null
+++ b/src/layers/submarineCables/surface.js
@@ -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);
+}
diff --git a/src/mapStackChips.test.mjs b/src/mapStackChips.test.mjs
index 7106cd2e7..befb7b673 100644
--- a/src/mapStackChips.test.mjs
+++ b/src/mapStackChips.test.mjs
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
children: [],
classList: {
toggle(name, force) {
- const classes = new Set(String(element.className).split(/\s+/).filter(Boolean));
+ const classes = new Set(
+ String(element.className).split(/\s+/).filter(Boolean),
+ );
const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name);
else classes.delete(name);
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
return String(element.className).split(/\s+/).includes(name);
},
},
- appendChild(child) { element.children.push(child); return child; },
- setAttribute(name, value) { element.attributes[name] = String(value); },
- getAttribute(name) { return element.attributes[name] ?? null; },
- addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
- click() { for (const handler of element.listeners.click || []) handler(); },
+ appendChild(child) {
+ element.children.push(child);
+ return child;
+ },
+ setAttribute(name, value) {
+ element.attributes[name] = String(value);
+ },
+ getAttribute(name) {
+ return element.attributes[name] ?? null;
+ },
+ addEventListener(type, handler) {
+ (element.listeners[type] ||= []).push(handler);
+ },
+ click() {
+ for (const handler of element.listeners.click || []) handler();
+ },
};
Object.defineProperty(element, 'innerHTML', {
- get() { return ''; },
- set() { element.children.length = 0; },
+ get() {
+ return '';
+ },
+ set() {
+ element.children.length = 0;
+ },
});
return element;
}
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
const doc = { createElement: (tagName) => makeElement(tagName) };
/** Text a chip renders, label + optional requirement badge. */
-const chipText = (chip) => chip.children.map((child) => child.textContent).join(' ');
+const chipText = (chip) =>
+ chip.children.map((child) => child.textContent).join(' ');
// Shaped exactly like MapStackController.getStacks() output.
const CONTROLLER_STACKS = [
- { id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
- { id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
- { id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
- { id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
- { id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
+ {
+ id: 'photoreal',
+ label: 'Google 3D',
+ requiresIon: false,
+ available: true,
+ unavailableReason: null,
+ },
+ {
+ id: 'bing-aerial',
+ label: 'Bing Aerial',
+ requiresIon: true,
+ available: true,
+ unavailableReason: null,
+ },
+ {
+ id: 'bing-labels',
+ label: 'Bing Labels',
+ requiresIon: true,
+ available: true,
+ unavailableReason: null,
+ },
+ {
+ id: 'esri-imagery',
+ label: 'Esri Satellite',
+ requiresIon: false,
+ available: true,
+ unavailableReason: null,
+ },
+ {
+ id: 'osm',
+ label: 'OSM',
+ requiresIon: false,
+ available: true,
+ unavailableReason: null,
+ },
];
test('the row renders exactly the five owner-approved sources', () => {
const container = makeElement();
- renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
+ renderMapStackChips(container, CONTROLLER_STACKS, {
+ activeId: 'photoreal',
+ doc,
+ });
- assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
- 'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
- ]);
+ assert.deepEqual(
+ container.children.map((chip) => chip.dataset.stackId),
+ ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
+ );
assert.deepEqual(container.children.map(chipText), [
- 'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
+ 'Google 3D',
+ 'Bing Aerial',
+ 'Bing Labels',
+ 'Esri Satellite',
+ 'OSM',
]);
- assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
- assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
- assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
+ assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
+ 'photoreal',
+ 'bing-aerial',
+ 'bing-labels',
+ 'esri-imagery',
+ 'osm',
+ ]);
+ assert.ok(
+ container.children.every(
+ (chip) => chip.tagName === 'button' && chip.type === 'button',
+ ),
+ );
+ assert.ok(
+ container.children.every((chip) =>
+ chip.classList.contains(MAP_STACK_CHIP_CLASS),
+ ),
+ );
});
test('internal and future stacks stay outside the approved presentation set', () => {
const container = makeElement();
// A future Hybrid stack may land in the controller, but it must not appear
// until the owner-approved presentation allowlist explicitly includes it.
- const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
+ const withHybrid = [
+ ...CONTROLLER_STACKS,
+ { id: 'hybrid', label: 'Hybrid', available: true },
+ ];
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
assert.equal(container.children.length, 5);
@@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', ()
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
const container = makeElement();
- renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
+ renderMapStackChips(container, CONTROLLER_STACKS, {
+ activeId: 'photoreal',
+ doc,
+ });
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
@@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
test('the active chip is the pressed chip, and exactly one is pressed', () => {
const container = makeElement();
- renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc });
+ renderMapStackChips(container, CONTROLLER_STACKS, {
+ activeId: 'bing-labels',
+ doc,
+ });
- const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true');
- assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
+ const pressed = container.children.filter(
+ (chip) => chip.getAttribute('aria-pressed') === 'true',
+ );
+ assert.deepEqual(
+ pressed.map((chip) => chip.dataset.stackId),
+ ['bing-labels'],
+ );
assert.ok(pressed[0].classList.contains('active'));
- assert.ok(container.children
- .filter((chip) => chip.dataset.stackId !== 'bing-labels')
- .every((chip) => !chip.classList.contains('active')));
+ assert.ok(
+ container.children
+ .filter((chip) => chip.dataset.stackId !== 'bing-labels')
+ .every((chip) => !chip.classList.contains('active')),
+ );
});
test('the lit chip tracks controller state, not the click', () => {
const container = makeElement();
- renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
+ renderMapStackChips(container, CONTROLLER_STACKS, {
+ activeId: 'photoreal',
+ doc,
+ });
// A rejected/superseded switch reports the stack that is genuinely active.
syncMapStackChips(container, 'photoreal');
@@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => {
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
const container = makeElement();
- const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
- ...stack,
- available: false,
- unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
- } : stack));
+ const keyless = CONTROLLER_STACKS.map((stack) =>
+ stack.requiresIon
+ ? {
+ ...stack,
+ available: false,
+ unavailableReason:
+ 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
+ }
+ : stack,
+ );
const selected = [];
renderMapStackChips(container, keyless, {
activeId: 'photoreal',
@@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
});
const bingAerial = container.children[1];
- assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable');
+ assert.equal(
+ bingAerial.disabled,
+ false,
+ 'unavailable sources remain keyboard-reachable',
+ );
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
assert.equal(
bingAerial.getAttribute('aria-label'),
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.ok(bingAerial.classList.contains('unavailable'));
- assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
+ assert.equal(
+ bingAerial.title,
+ 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
+ );
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
bingAerial.click();
- assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path');
+ assert.deepEqual(
+ selected,
+ [],
+ 'an unavailable stack must not reach the switch path',
+ );
- assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable');
+ assert.equal(
+ container.children[3].getAttribute('aria-disabled'),
+ 'false',
+ 'OSM stays selectable',
+ );
});
test('a non-ion stack that fails never claims an ion token is required', () => {
// The startup fallback-to-OSM case: Google 3D tiles failed to load, so
// photoreal is unavailable for a reason that has nothing to do with ion.
const container = makeElement();
- const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
- ...stack,
- available: false,
- unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
- } : stack));
+ const tilesFailed = CONTROLLER_STACKS.map((stack) =>
+ stack.id === 'photoreal'
+ ? {
+ ...stack,
+ available: false,
+ unavailableReason:
+ 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
+ }
+ : stack,
+ );
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0];
assert.equal(google.getAttribute('aria-disabled'), 'true');
- assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
- assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
- assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
- assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
+ assert.equal(
+ google.getAttribute('aria-label'),
+ 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
+ );
+ assert.equal(
+ chipText(google),
+ 'Google 3D',
+ 'no ION badge on a stack that does not need ion',
+ );
+ assert.equal(
+ google.title,
+ 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
+ );
+ assert.equal(
+ chipText(container.children[1]),
+ 'Bing Aerial',
+ 'available ion stacks stay unbadged',
+ );
});
-test('models carry the stack\'s own reason and never invent an active chip', () => {
- assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
- id: 'osm',
- label: 'OSM',
- available: true,
- active: false,
- requiresIon: false,
- requirement: '',
- unavailableHint: '',
- title: 'OSM',
- }]);
+test("models carry the stack's own reason and never invent an active chip", () => {
+ assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
+ {
+ id: 'osm',
+ label: 'OSM',
+ available: true,
+ active: false,
+ requiresIon: false,
+ requirement: '',
+ unavailableHint: '',
+ title: 'OSM',
+ },
+ ]);
// A stack list without a controller-supplied reason still explains itself.
assert.deepEqual(
[
- mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
- mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
- ].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
+ mapStackChipModel(
+ {
+ id: 'bing-aerial',
+ label: 'Bing Aerial',
+ requiresIon: true,
+ available: false,
+ },
+ null,
+ ),
+ mapStackChipModel(
+ { id: 'hybrid', label: 'Hybrid', available: false },
+ null,
+ ),
+ ].map(({ requirement, unavailableHint, title }) => ({
+ requirement,
+ unavailableHint,
+ title,
+ })),
[
{
requirement: 'ION',
@@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
assert.deepEqual(mapStackChipModels(undefined, 'osm'), []);
});
-test('the controller is the single source of the unavailability reason', () => {
- const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
-
- assert.doesNotMatch(
- controller,
- /bing-road/,
- 'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback',
- );
-
- assert.match(
- controller,
- /getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/,
- 'getStacks() must expose why a stack is unavailable, not just that it is',
- );
- assert.match(
- controller,
- /if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/,
- 'the toast and the chip tooltip must read the same reason',
- );
- assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/);
-});
-
test('a missing row or document is inert rather than throwing during boot', () => {
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
- assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []);
+ assert.deepEqual(
+ renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }),
+ [],
+ );
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
});
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
- assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity');
- assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
+ assert.ok(
+ hover > 0 && active > hover,
+ 'active must follow hover so it wins at equal specificity',
+ );
+ assert.ok(
+ unavailable > active,
+ 'unavailable must follow both so a keyless chip never lights up',
+ );
assert.doesNotMatch(
css.slice(hover, active),
/:not\(/,
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// chip-state rule sets.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
- const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
- .map(([, selector, body], order) => ({
- selector: stripComments(selector).trim(),
- body: stripComments(body),
- order,
- }));
+ const chipRules = [
+ ...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
+ ].map(([, selector, body], order) => ({
+ selector: stripComments(selector).trim(),
+ body: stripComments(body),
+ order,
+ }));
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
- const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
- && /outline:\s*(?!none)\S/.test(body)
- && /outline-offset:/.test(body));
- assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
+ const ringIndex = chipRules.findIndex(
+ ({ selector, body }) =>
+ selector.includes(':focus-visible') &&
+ /outline:\s*(?!none)\S/.test(body) &&
+ /outline-offset:/.test(body),
+ );
+ assert.ok(
+ ringIndex >= 0,
+ 'a :focus-visible rule must draw a real outline ring',
+ );
// Nothing after it may touch outline again, so the ring cannot be erased by
// .active, :disabled, or anything added later.
@@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
`"${rule.selector}" must not touch outline — it would erase the focus ring`,
);
}
- for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) {
+ for (const selector of [
+ '.map-stack-chip.active',
+ '.map-stack-chip.unavailable',
+ ]) {
assert.ok(
chipRules.some((rule) => rule.selector.includes(selector)),
`expected a ${selector} rule to exist for this check to mean anything`,
@@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `transition: all` animates outline-width off the `outline: none` base, and
// Chrome parks that transition at 0px for chips on a wrapped line — the ring
// never appeared on rows 2 and 3. The base rule must list its properties.
- const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip'));
+ const base = chipRules.find((rule) =>
+ rule.selector.endsWith('.map-stack-chip'),
+ );
assert.ok(base, 'expected the base .map-stack-chip rule');
assert.doesNotMatch(
base.body,
/transition:\s*all\b/,
'transition: all animates outline-width and kills the focus ring on wrapped rows',
);
- assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated');
- assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
+ assert.doesNotMatch(
+ base.body,
+ /transition:[^;]*outline/,
+ 'the focus ring must not be animated',
+ );
+ assert.match(
+ base.body,
+ /transition:\s*\n?\s*color/,
+ 'the hover/active treatment still animates',
+ );
});
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
- const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
+ const ui = readFileSync(
+ new URL('./ui/applicationShell.js', import.meta.url),
+ 'utf8',
+ );
- assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
+ assert.doesNotMatch(
+ html,
+ /map-stack-select/,
+ 'the SOURCE dropdown is replaced by the chip row',
+ );
assert.match(
html,
/<\/div>/,
);
- assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired');
- assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
+ assert.doesNotMatch(
+ html,
+ /id="stack-panel"/,
+ 'the duplicate left MAP STACK panel is retired',
+ );
+ assert.match(
+ html,
+ /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/,
+ );
assert.match(
html,
/