Merge remote-tracking branch 'origin/main' into refactor/alpr-camera-source

# Conflicts:
#	package.json
#	scripts/format-scope.json
#	scripts/package-boundaries.json
This commit is contained in:
Sameh Khamis committed 2026-09-13 19:21:57 -07:00
commit 211eb70eed
66 files changed
+8542 -4938

No files matched your search

+6
View File
@@ -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
+22
View File
@@ -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.
+44
View File
@@ -3302,3 +3302,47 @@ Cesium post-render events over one-second windows and does not request extra
frames. Typing fields, modified keys and key repeats do not toggle it. The
readout starts hidden each session and releases its timer and frame listener
when hidden or when the application is disposed.
## Radio components
The radio entry composes one layer from directory ingestion, station queries,
selection, globe rendering and playback modules. Its source supplies directory
metadata and click reporting; scene, ground and overlay services are provided
explicitly. Each constructed layer owns its catalog, audio and lifecycle state.
Existing catalog validation, category filters, tuning and voice playback behavior
remain unchanged. Audio connects directly to the broadcaster after an explicit
play action; the source does not relay or record streams.
## Bundled geography and submarine cable components
Submarine cables use separate source, geometry, rendering, interaction and
lifecycle modules. The layer factory accepts cable and landing-point GeoJSON
collections from a source with `fetch(signal)` and a display label. The default
source loads the same bundled TeleGeography files. Disabling still removes all
three Cesium data sources; enabling rebuilds from the accepted parsed cache,
and destroying clears it. Load ownership prevents cancelled work from adding
entities after teardown.
Natural Earth regions and neighborhood polygon lookup are package exports.
Their existing lazy loaders, retry behavior, bundled datasets and attribution
are unchanged. The cable dataset remains CC BY-NC-SA 3.0 and is not covered by
the project's MIT license; see `DATA_SOURCES.md`.
## Map source factories and coordination
Map selection is composed from separate imagery, terrain and 3D factories.
The default registry retains Google 3D, Bing Aerial/Labels, Esri Satellite and
OSM, including their setup guidance and existing preset/share IDs. Esri still
falls back to OSM on construction failure or two active tile failures, and
credits follow the source actually displayed. Terrain remains lazy while the
photoreal globe is hidden.
Map credentials are passed to each source constructor rather than changing SDK-wide Google or ion defaults.
The controller accepts other source registries without adding provider branches.
Each instance caches source construction, ignores superseded scene changes and
releases imagery layers/listeners on replacement. Destroy invalidates pending
work and releases owned resources, including late factory results. Supplied 3D
tilesets remain owned by the caller; tilesets created through the controller's
factory are added to its viewer and removed on destruction.
+12 -1
View File
@@ -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"
}
}
+57 -1
View File
@@ -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"
]
+92
View File
@@ -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"]
}
}
+8
View File
@@ -714,6 +714,14 @@ try {
if (controller.googleTileset) controller.googleTileset.show = false;
controller.googleTileset = null;
controller.cesiumToken = '';
// Availability is now composed in the source registry. Override only
// this fixture's choices, as the previous token-field seam did.
for (const source of controller._sources.values()) {
if (source.descriptor.requiresIon || source.descriptor.kind === 'photoreal') {
source.available = false;
}
}
controller._registry.state.hasCesiumIonToken = false;
await styleManager._setMapStack('osm', { syncShare: false });
styleManager._initMapStackControl();
});
+28 -6
View File
@@ -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');
+56 -29
View File
@@ -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;
+45 -13
View File
@@ -14,7 +14,14 @@ import { createRetryableLoader } from './retryableLoad.js';
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
const CITY_FILES = [
{ id: 'san-francisco', bbox: [-122.55, 37.70, -122.35, 37.84], loader: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' } }) },
{
id: 'san-francisco',
bbox: [-122.55, 37.7, -122.35, 37.84],
loader: () =>
import('./local_data/neighborhoods/san-francisco.json', {
with: { type: 'json' },
}),
},
];
/**
@@ -27,7 +34,11 @@ const CITY_FILES = [
const _cityLoaders = new Map();
function normalize(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
return String(s || '')
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
@@ -45,10 +56,12 @@ const NAME_ALIASES = new Map([
function pointInRing(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0]; const yi = ring[i][1];
const xj = ring[j][0]; const yj = ring[j][1];
const intersect = ((yi > lat) !== (yj > lat))
&& (lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi);
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
@@ -70,7 +83,10 @@ function containingOuterRing(feature, lon, lat) {
if (!pointInRing(lon, lat, outer)) continue;
let inHole = false;
for (let h = 1; h < poly.length; h++) {
if (pointInRing(lon, lat, poly[h])) { inHole = true; break; }
if (pointInRing(lon, lat, poly[h])) {
inHole = true;
break;
}
}
if (!inHole) return outer;
}
@@ -108,7 +124,10 @@ async function loadCity(city) {
} catch (error) {
// The caller falls back to the live resolver ladder, which looks exactly
// like "no bundled coverage" — say so once, out loud, instead.
console.warn(`[neighborhoods] ${city.id} pack unavailable:`, error?.message || error);
console.warn(
`[neighborhoods] ${city.id} pack unavailable:`,
error?.message || error,
);
return [];
}
}
@@ -122,7 +141,13 @@ async function loadCity(city) {
* neighborhood name, or null when the point isn't in a covered city / no match.
*/
export async function lookupNeighborhoodRing(lat, lon, matchName) {
const city = CITY_FILES.find((c) => lon >= c.bbox[0] && lat >= c.bbox[1] && lon <= c.bbox[2] && lat <= c.bbox[3]);
const city = CITY_FILES.find(
(c) =>
lon >= c.bbox[0] &&
lat >= c.bbox[1] &&
lon <= c.bbox[2] &&
lat <= c.bbox[3],
);
if (!city) return null;
const feats = await loadCity(city);
if (!feats.length) return null;
@@ -153,11 +178,18 @@ export async function lookupNeighborhoodRing(lat, lon, matchName) {
const containRing = containingOuterRing(f, lon, lat);
const ring = containRing || largestOuterRing(f.geometry);
if (!ring) continue;
const cand = { ring, name: f.properties.name, contains: !!containRing, fwordCount: fWords.length };
const cand = {
ring,
name: f.properties.name,
contains: !!containRing,
fwordCount: fWords.length,
};
// Prefer a feature that CONTAINS the point, then the most specific name (most words).
if (!best
|| (cand.contains && !best.contains)
|| (cand.contains === best.contains && cand.fwordCount > best.fwordCount)) {
if (
!best ||
(cand.contains && !best.contains) ||
(cand.contains === best.contains && cand.fwordCount > best.fwordCount)
) {
best = cand;
}
}
+97 -2788
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeRadioCountryInput } from './radioCountry.js';
import { normalizeRadioCountryInput } from '../layers/radio/index.js';
test('Radio country normalization maps ISO codes and bounded common names', () => {
for (const [input, code] of [
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+147
View File
@@ -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,
};
}
+131
View File
@@ -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,
};
}
+266
View File
@@ -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,
};
}
+119
View File
@@ -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 };
}
+142
View File
@@ -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';
+174
View File
@@ -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 };
}
+159
View File
@@ -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,
};
}
+215
View File
@@ -0,0 +1,215 @@
import {
RADIO_LABEL_SEGMENTER,
RADIO_GLOBE_LABEL_MAX_CHARS,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_CATEGORY_COLORS,
} from './policy.js';
export function createLabels({ state: layerState, services, parts, source }) {
function compactRadioLabelText(value, maxChars) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
const graphemes = RADIO_LABEL_SEGMENTER
? [...RADIO_LABEL_SEGMENTER.segment(text)].map(
(segment) => segment.segment,
)
: Array.from(text);
if (graphemes.length <= maxChars) return text;
return `${graphemes
.slice(0, Math.max(1, maxChars - 1))
.join('')
.trimEnd()}…`;
}
function cleanRadioLabelName(value) {
return String(value || '')
.replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '')
.replace(/\s+/g, ' ')
.replace(/\s+\([^()]{1,40}\)$/, '')
.replace(/\s+FM$/i, '')
.trim();
}
/** Return compact frequency-first text for a Radio globe label. */
function radioGlobeLabel(station) {
const fullName = String(station?.name || '')
.replace(/\s+/g, ' ')
.trim();
if (!fullName) return '';
let frequency = null;
let labelName = '';
const explicitMatches = [
...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi),
];
const explicit = explicitMatches.find((match) => {
const value = Number(match[1]);
return value >= 64 && value <= 108;
});
if (explicit) {
frequency = explicit[1];
const before = cleanRadioLabelName(fullName.slice(0, explicit.index));
const after = cleanRadioLabelName(
fullName.slice(explicit.index + explicit[0].length),
);
labelName = before || after;
} else {
// Infer only a leading decimal in the conventional FM band. Integers such
// as "80's" or "100 GREATEST" and domains such as "1.fm" stay names.
const leading = fullName.match(
/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/,
);
const value = Number(leading?.[1]);
if (leading && value >= 87.5 && value <= 108) {
frequency = leading[1];
labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]);
}
}
if (!frequency)
return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS);
const prefix = `${frequency} FM`;
if (!labelName) return prefix;
const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3;
return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`;
}
/** Build the protected selected-station text published through WorldOverlay. */
function createRadioSelectedOverlayEntry(station, position) {
if (!station?.id || !station?.name || !position) return null;
return {
id: `selected:${station.id}`,
position,
variant: 'selected',
selected: true,
protected: true,
paintLane: 'selected',
collisionGroup: 'ambient-label',
priority: Number.MAX_SAFE_INTEGER,
title: radioGlobeLabel(station),
accent: parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
),
interactive: false,
anchorRadiusPx: 20,
minAnchorGapPx: 8,
verticalOnly: true,
placement: 'above',
gapPx: 8,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
};
}
/** Build one bounded ambient cluster badge for the shared overlay host. */
function createRadioClusterOverlayEntry({
id,
position,
text,
accent,
stationCount,
}) {
if (!id || !position || !text) return null;
return {
id: `cluster:${id}`,
position,
variant: 'label',
paintLane: 'ambient-label',
collisionGroup: 'ambient-label',
priority: Math.max(1, Number(stationCount) || 1),
title: text,
accent: accent || RADIO_CATEGORY_COLORS.other,
interactive: false,
anchorRadiusPx: Math.min(
13,
6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8,
),
minAnchorGapPx: 4,
verticalOnly: true,
placement: 'above',
gapPx: 6,
// Cluster membership/counts update continuously while the camera moves.
// Paint them as hard-opacity incumbents so a replacement count never
// cross-fades with its predecessor or dims merely for leaving the keyhole.
// Collision, viewport rejection, and horizon culling remain host-owned.
stateless: true,
edgeFade: 'none',
horizonCull: true,
terrainOcclusion: false,
// The camera can sit above 24,000 km in the supported full-globe view, and
// horizon clusters are farther from it than the camera's surface altitude.
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
distanceScale: {
near: 100_000,
nearValue: 1.08,
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
farValue: 0.92,
},
};
}
/** Build one ambient station label while Cesium retains its point and picking. */
function createRadioSingletonOverlayEntry({
station,
position,
priority = 1,
}) {
if (!station?.id || !station?.name || !position) return null;
return {
id: `station:${station.id}`,
position,
variant: 'label',
paintLane: 'ambient-label',
collisionGroup: 'ambient-label',
priority: Math.max(1, Number(priority) || 1),
title: radioGlobeLabel(station),
accent: parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
),
interactive: false,
anchorRadiusPx: 9,
minAnchorGapPx: 4,
verticalOnly: true,
placement: 'above',
gapPx: 5,
// Camera/filter changes replace this bounded cohort. Prevent the prior
// cohort from fading over the current visible points.
stateless: true,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
distanceScale: {
near: 100_000,
nearValue: 1,
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
farValue: 0.86,
},
};
}
/** Build the code-native four-corner bracket used by the selected station. */
function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) {
const stroke = /^#[0-9a-f]{6}$/i.test(String(color))
? String(color)
: RADIO_CATEGORY_COLORS.other;
return `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><path d="M2 13V2H13 M27 2H38V13 M38 27V38H27 M13 38H2V27" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="square"/></svg>`;
}
return {
compactRadioLabelText,
cleanRadioLabelName,
radioGlobeLabel,
createRadioSelectedOverlayEntry,
createRadioClusterOverlayEntry,
createRadioSingletonOverlayEntry,
radioSelectionBracketSvg,
};
}
+122
View File
@@ -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 };
}
+131
View File
@@ -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,
};
}
+395
View File
@@ -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,
};
}
+382
View File
@@ -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,
};
}
+170
View File
@@ -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;
+105
View File
@@ -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,
};
}
+152
View File
@@ -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,
};
}
+411
View File
@@ -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,
};
}
+269
View File
@@ -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,
};
}
+33
View File
@@ -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}`);
},
};
}
+108
View File
@@ -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();
});
+157
View File
@@ -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;
}
+233
View File
@@ -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,
};
}
+145
View File
@@ -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,
};
}
+148
View File
@@ -0,0 +1,148 @@
import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js';
export function createVolume({ state: layerState, services, parts, source }) {
function clampRadioVolume(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function cancelRadioVolumeTransition() {
layerState._volumeTransitionGeneration += 1;
if (layerState._voiceRestoreTimer)
clearTimeout(layerState._voiceRestoreTimer);
layerState._voiceRestoreTimer = null;
if (layerState._volumeFadeFrame) {
if (typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(layerState._volumeFadeFrame);
else clearTimeout(layerState._volumeFadeFrame);
}
layerState._volumeFadeFrame = null;
}
function scheduleVolumeFrame(callback) {
if (typeof requestAnimationFrame === 'function')
return requestAnimationFrame(callback);
return setTimeout(() => callback(Date.now()), 16);
}
function volumeClock() {
return typeof performance !== 'undefined' &&
typeof performance.now === 'function'
? performance.now()
: Date.now();
}
/** Set shared audio volume, clamped to [0, 1]. */
function setRadioVolume(value) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const volume = clampRadioVolume(value);
layerState._userVolume = volume;
parts.playback.installAudio();
if (!layerState._voiceDucked) {
cancelRadioVolumeTransition();
layerState._voiceRestoring = false;
if (layerState._audio) layerState._audio.volume = volume;
}
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return true;
}
/**
* Mute Radio during a live voice turn, then gently restore the user-owned
* volume after voice returns to standby. Repeated state sync is idempotent.
*/
function setRadioVoiceDucking(
ducked,
{
restoreDelayMs = VOICE_RESTORE_DELAY_MS,
restoreDurationMs = VOICE_RESTORE_DURATION_MS,
} = {},
) {
const shouldDuck = Boolean(ducked);
if (
shouldDuck === layerState._voiceDucked &&
(shouldDuck || layerState._voiceRestoring)
)
return;
if (
!shouldDuck &&
!layerState._voiceDucked &&
!layerState._voiceRestoring &&
(!layerState._audio ||
Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001)
)
return;
cancelRadioVolumeTransition();
layerState._voiceDucked = shouldDuck;
layerState._voiceRestoring = false;
if (shouldDuck) {
if (layerState._audio) layerState._audio.volume = 0;
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return;
}
if (!layerState._audio) {
parts.tuningNoise.syncTuningNoiseGain();
layerState._voiceRestoring = false;
parts.presentation.emitState();
return;
}
const generation = layerState._volumeTransitionGeneration;
parts.tuningNoise.syncTuningNoiseGain();
layerState._voiceRestoring = true;
parts.presentation.emitState();
const beginRestore = () => {
layerState._voiceRestoreTimer = null;
if (
generation !== layerState._volumeTransitionGeneration ||
layerState._voiceDucked ||
!layerState._audio
)
return;
const startedAt = volumeClock();
const initialVolume = layerState._audio.volume;
const duration = Math.max(0, Number(restoreDurationMs) || 0);
const step = (now) => {
if (
generation !== layerState._volumeTransitionGeneration ||
layerState._voiceDucked ||
!layerState._audio
)
return;
const progress =
duration === 0
? 1
: Math.min(1, Math.max(0, (now - startedAt) / duration));
const eased = progress * progress * (3 - 2 * progress);
layerState._audio.volume = clampRadioVolume(
initialVolume + (layerState._userVolume - initialVolume) * eased,
);
if (progress < 1) {
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
return;
}
layerState._volumeFadeFrame = null;
layerState._voiceRestoring = false;
parts.presentation.emitState();
};
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
};
const delay = Math.max(0, Number(restoreDelayMs) || 0);
if (delay > 0)
layerState._voiceRestoreTimer = setTimeout(beginRestore, delay);
else beginRestore();
}
return {
clampRadioVolume,
cancelRadioVolumeTransition,
scheduleVolumeFrame,
volumeClock,
setRadioVolume,
setRadioVoiceDucking,
};
}
@@ -0,0 +1,42 @@
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
// Commercial users must remove it or obtain a TeleGeography license.
// See DATA_SOURCES.md and the bundled dataset's source.json.
const cableUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
const landingPointUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
export function createBundledCableSource({
fetchImpl = (...args) => fetch(...args),
} = {}) {
async function read(url, signal) {
signal?.throwIfAborted();
const response = await fetchImpl(url, { signal, cache: 'force-cache' });
if (!response.ok) {
try {
await response.body?.cancel();
} catch {
/* best effort */
}
throw new Error(`HTTP ${response.status} for ${url}`);
}
const json = await response.json();
signal?.throwIfAborted();
return json;
}
return {
label: 'TeleGeography',
async fetch(signal) {
const [cables, landingPoints] = await Promise.all([
read(cableUrl, signal),
read(landingPointUrl, signal),
]);
return { cables, landingPoints };
},
};
}
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBundledCableSource } from './bundledSource.js';
test('a failed bundled response releases its body', async () => {
let released = 0;
const source = createBundledCableSource({
fetchImpl: async () => ({
ok: false,
status: 503,
body: {
cancel: async () => {
released++;
},
},
}),
});
await assert.rejects(source.fetch(), /HTTP 503/);
assert.equal(released, 2);
});
test('cancellation during JSON parsing cannot return a late snapshot', async () => {
const controller = new AbortController();
const source = createBundledCableSource({
fetchImpl: async () => ({
ok: true,
async json() {
controller.abort();
return { type: 'FeatureCollection', features: [] };
},
}),
});
await assert.rejects(source.fetch(controller.signal), { name: 'AbortError' });
});
+79
View File
@@ -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),
};
});
}
+53
View File
@@ -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';
+210
View File
@@ -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 };
}
+75
View File
@@ -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,
};
}
+156
View File
@@ -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,
};
},
};
}
+309
View File
@@ -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))}...`;
}
+109
View File
@@ -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();
}
+274
View File
@@ -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,
};
}
+107
View File
@@ -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;
}
+126
View File
@@ -0,0 +1,126 @@
import * as Cesium from 'cesium';
import {
CABLE_GLOBE_STACK_IDS,
MARKER_COLLECTION_EXPECTATIONS,
} from './policy.js';
/**
* Ground-line classification for one map stack. The photoreal stack renders
* Google 3D tiles with the Cesium globe HIDDEN, so cable ground lines only
* need the 3D-tile classification pass there; every other known stack (the
* bing stacks and osm) renders imagery on the shown globe, so only the
* terrain pass applies.
* Classifying against just the active surface halves the batched
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
* for an unknown stack — it renders on every surface, exactly the shipped
* pre-optimization behavior.
* @param {string|null|undefined} activeId MapStackController stack id.
* @returns {Cesium.ClassificationType}
*/
export function cableClassificationTypeForStack(activeId) {
if (activeId === 'photoreal') return Cesium.ClassificationType.CESIUM_3D_TILE;
if (CABLE_GLOBE_STACK_IDS.has(activeId))
return Cesium.ClassificationType.TERRAIN;
return Cesium.ClassificationType.BOTH;
}
/**
* Derive the active surface from live scene state. The boot-time
* `setStack(..., { silent: true })` fires no 'gev:map-stack-changed' event,
* so the initial classification reads the scene the way the height-datum
* listeners do: the photoreal regime is exactly "globe hidden".
* @param {Cesium.Scene|null|undefined} scene
* @returns {Cesium.ClassificationType}
*/
export function cableClassificationTypeForScene(scene) {
if (!scene?.globe) return Cesium.ClassificationType.BOTH;
return scene.globe.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.TERRAIN;
}
/**
* Validate one data source's marker collections WITHOUT mutating anything.
* Splitting validation from mutation is what makes the fallback honest: a
* shape failure on the SECOND collection (or the second data source) must
* not leave the first one already forced to TRANSLUCENT.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
*/
export function probeTranslucentMarkerBlend(dataSource) {
const cluster = dataSource?.clustering;
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
const ready = [];
let pending = 0;
let invariantFailed = false;
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
if (!(key in cluster)) {
invariantFailed = true;
continue;
}
const collection = cluster[key];
if (collection === undefined || collection === null) {
pending++;
continue;
}
if (!(collection instanceof type) || !('blendOption' in collection)) {
invariantFailed = true;
continue;
}
ready.push(collection);
}
// Any failure discards every collected target: nothing is mutated at all.
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
return { ready, pending, invariantFailed: false };
}
/**
* Commit a clean probe. Only ever called once every probe in the batch has
* passed, so this cannot land a partial application.
* @param {{ready:object[],pending:number}} probe
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function commitTranslucentMarkerBlend(probe) {
for (const collection of probe.ready) {
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
}
}
return {
applied: probe.ready.length,
pending: probe.pending,
invariantFailed: false,
};
}
/**
* Force one translucent draw pass for a data source's marker collections.
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
* collection, and every cable marker is translucent (alpha < 1), so the
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
* reference points. Cesium has no public accessor for the entity-backed
* collections, so this reaches through EntityCluster's private fields under
* a SHAPE INVARIANT the caller must honor loudly: the constructor
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
* shape changed — report `invariantFailed` and never touch anything —
* while a present-but-undefined value only means "not created yet"
* (`pending`). Wrong instance types also fail the invariant. The cables
* sources never enable clustering, so created collections persist.
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
* default blend — the fallback this helper promises.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function applyTranslucentMarkerBlend(dataSource) {
const probe = probeTranslucentMarkerBlend(dataSource);
if (probe.invariantFailed)
return { applied: 0, pending: 0, invariantFailed: true };
return commitTranslucentMarkerBlend(probe);
}
+312 -149
View File
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
children: [],
classList: {
toggle(name, force) {
const classes = new Set(String(element.className).split(/\s+/).filter(Boolean));
const classes = new Set(
String(element.className).split(/\s+/).filter(Boolean),
);
const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name);
else classes.delete(name);
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
return String(element.className).split(/\s+/).includes(name);
},
},
appendChild(child) { element.children.push(child); return child; },
setAttribute(name, value) { element.attributes[name] = String(value); },
getAttribute(name) { return element.attributes[name] ?? null; },
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
click() { for (const handler of element.listeners.click || []) handler(); },
appendChild(child) {
element.children.push(child);
return child;
},
setAttribute(name, value) {
element.attributes[name] = String(value);
},
getAttribute(name) {
return element.attributes[name] ?? null;
},
addEventListener(type, handler) {
(element.listeners[type] ||= []).push(handler);
},
click() {
for (const handler of element.listeners.click || []) handler();
},
};
Object.defineProperty(element, 'innerHTML', {
get() { return ''; },
set() { element.children.length = 0; },
get() {
return '';
},
set() {
element.children.length = 0;
},
});
return element;
}
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
const doc = { createElement: (tagName) => makeElement(tagName) };
/** Text a chip renders, label + optional requirement badge. */
const chipText = (chip) => chip.children.map((child) => child.textContent).join(' ');
const chipText = (chip) =>
chip.children.map((child) => child.textContent).join(' ');
// Shaped exactly like MapStackController.getStacks() output.
const CONTROLLER_STACKS = [
{ id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
{ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
{
id: 'photoreal',
label: 'Google 3D',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'bing-labels',
label: 'Bing Labels',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'osm',
label: 'OSM',
requiresIon: false,
available: true,
unavailableReason: null,
},
];
test('the row renders exactly the five owner-approved sources', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
]);
assert.deepEqual(
container.children.map((chip) => chip.dataset.stackId),
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
);
assert.deepEqual(container.children.map(chipText), [
'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
'Google 3D',
'Bing Aerial',
'Bing Labels',
'Esri Satellite',
'OSM',
]);
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
'photoreal',
'bing-aerial',
'bing-labels',
'esri-imagery',
'osm',
]);
assert.ok(
container.children.every(
(chip) => chip.tagName === 'button' && chip.type === 'button',
),
);
assert.ok(
container.children.every((chip) =>
chip.classList.contains(MAP_STACK_CHIP_CLASS),
),
);
});
test('internal and future stacks stay outside the approved presentation set', () => {
const container = makeElement();
// A future Hybrid stack may land in the controller, but it must not appear
// until the owner-approved presentation allowlist explicitly includes it.
const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
const withHybrid = [
...CONTROLLER_STACKS,
{ id: 'hybrid', label: 'Hybrid', available: true },
];
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
assert.equal(container.children.length, 5);
@@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', ()
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
@@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
test('the active chip is the pressed chip, and exactly one is pressed', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'bing-labels',
doc,
});
const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true');
assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
const pressed = container.children.filter(
(chip) => chip.getAttribute('aria-pressed') === 'true',
);
assert.deepEqual(
pressed.map((chip) => chip.dataset.stackId),
['bing-labels'],
);
assert.ok(pressed[0].classList.contains('active'));
assert.ok(container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')));
assert.ok(
container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')),
);
});
test('the lit chip tracks controller state, not the click', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
// A rejected/superseded switch reports the stack that is genuinely active.
syncMapStackChips(container, 'photoreal');
@@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => {
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
const container = makeElement();
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
...stack,
available: false,
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
} : stack));
const keyless = CONTROLLER_STACKS.map((stack) =>
stack.requiresIon
? {
...stack,
available: false,
unavailableReason:
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
}
: stack,
);
const selected = [];
renderMapStackChips(container, keyless, {
activeId: 'photoreal',
@@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
});
const bingAerial = container.children[1];
assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable');
assert.equal(
bingAerial.disabled,
false,
'unavailable sources remain keyboard-reachable',
);
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
assert.equal(
bingAerial.getAttribute('aria-label'),
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.ok(bingAerial.classList.contains('unavailable'));
assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
assert.equal(
bingAerial.title,
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
bingAerial.click();
assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path');
assert.deepEqual(
selected,
[],
'an unavailable stack must not reach the switch path',
);
assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable');
assert.equal(
container.children[3].getAttribute('aria-disabled'),
'false',
'OSM stays selectable',
);
});
test('a non-ion stack that fails never claims an ion token is required', () => {
// The startup fallback-to-OSM case: Google 3D tiles failed to load, so
// photoreal is unavailable for a reason that has nothing to do with ion.
const container = makeElement();
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
...stack,
available: false,
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
} : stack));
const tilesFailed = CONTROLLER_STACKS.map((stack) =>
stack.id === 'photoreal'
? {
...stack,
available: false,
unavailableReason:
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
}
: stack,
);
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0];
assert.equal(google.getAttribute('aria-disabled'), 'true');
assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
assert.equal(
google.getAttribute('aria-label'),
'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(google),
'Google 3D',
'no ION badge on a stack that does not need ion',
);
assert.equal(
google.title,
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(container.children[1]),
'Bing Aerial',
'available ion stacks stay unbadged',
);
});
test('models carry the stack\'s own reason and never invent an active chip', () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
}]);
test("models carry the stack's own reason and never invent an active chip", () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
},
]);
// A stack list without a controller-supplied reason still explains itself.
assert.deepEqual(
[
mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
mapStackChipModel(
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: false,
},
null,
),
mapStackChipModel(
{ id: 'hybrid', label: 'Hybrid', available: false },
null,
),
].map(({ requirement, unavailableHint, title }) => ({
requirement,
unavailableHint,
title,
})),
[
{
requirement: 'ION',
@@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
assert.deepEqual(mapStackChipModels(undefined, 'osm'), []);
});
test('the controller is the single source of the unavailability reason', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.doesNotMatch(
controller,
/bing-road/,
'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback',
);
assert.match(
controller,
/getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/,
'getStacks() must expose why a stack is unavailable, not just that it is',
);
assert.match(
controller,
/if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/,
'the toast and the chip tooltip must read the same reason',
);
assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/);
});
test('a missing row or document is inert rather than throwing during boot', () => {
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []);
assert.deepEqual(
renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }),
[],
);
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
});
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity');
assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
assert.ok(
hover > 0 && active > hover,
'active must follow hover so it wins at equal specificity',
);
assert.ok(
unavailable > active,
'unavailable must follow both so a keyless chip never lights up',
);
assert.doesNotMatch(
css.slice(hover, active),
/:not\(/,
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// chip-state rule sets.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
.map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
const chipRules = [
...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
].map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
&& /outline:\s*(?!none)\S/.test(body)
&& /outline-offset:/.test(body));
assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
const ringIndex = chipRules.findIndex(
({ selector, body }) =>
selector.includes(':focus-visible') &&
/outline:\s*(?!none)\S/.test(body) &&
/outline-offset:/.test(body),
);
assert.ok(
ringIndex >= 0,
'a :focus-visible rule must draw a real outline ring',
);
// Nothing after it may touch outline again, so the ring cannot be erased by
// .active, :disabled, or anything added later.
@@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
`"${rule.selector}" must not touch outline — it would erase the focus ring`,
);
}
for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) {
for (const selector of [
'.map-stack-chip.active',
'.map-stack-chip.unavailable',
]) {
assert.ok(
chipRules.some((rule) => rule.selector.includes(selector)),
`expected a ${selector} rule to exist for this check to mean anything`,
@@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `transition: all` animates outline-width off the `outline: none` base, and
// Chrome parks that transition at 0px for chips on a wrapped line — the ring
// never appeared on rows 2 and 3. The base rule must list its properties.
const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip'));
const base = chipRules.find((rule) =>
rule.selector.endsWith('.map-stack-chip'),
);
assert.ok(base, 'expected the base .map-stack-chip rule');
assert.doesNotMatch(
base.body,
/transition:\s*all\b/,
'transition: all animates outline-width and kills the focus ring on wrapped rows',
);
assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated');
assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
assert.doesNotMatch(
base.body,
/transition:[^;]*outline/,
'the focus ring must not be animated',
);
assert.match(
base.body,
/transition:\s*\n?\s*color/,
'the hover/active treatment still animates',
);
});
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const ui = readFileSync(
new URL('./ui/applicationShell.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
assert.doesNotMatch(
html,
/map-stack-select/,
'the SOURCE dropdown is replaced by the chip row',
);
assert.match(
html,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
);
assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired');
assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
assert.doesNotMatch(
html,
/id="stack-panel"/,
'the duplicate left MAP STACK panel is retired',
);
assert.match(
html,
/id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/,
);
assert.match(
html,
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
'the compact wing must expose a semantic keyboard disclosure',
);
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
const panels = readFileSync(
new URL('./ui/panelDisclosure.js', import.meta.url),
'utf8',
);
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path');
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path');
assert.match(controls, /await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller');
assert.match(controls, /render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state');
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click');
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover');
assert.match(controls, /unsubscribe\?\.\(\)/,
'component destruction releases its subscription');
});
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(
controller,
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
'construction failure must resolve truthfully to OSM',
ui,
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
);
const controls = readFileSync(
new URL('./ui/mapSourceControls.js', import.meta.url),
'utf8',
);
assert.match(
controller,
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
'the active chip must follow the effective provider, not the requested stack',
ui,
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path',
);
assert.match(
controller,
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
'Esri credit must follow the effective provider',
controls,
/onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path',
);
});
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(controller, /let failures = 0/);
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
assert.match(
controller,
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
'a stale provider error must not replace a newer user selection',
controls,
/await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller',
);
assert.match(
controls,
/render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state',
);
assert.match(
ui,
/window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click',
);
assert.match(
ui,
/window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover',
);
assert.match(
controls,
/unsubscribe\?\.\(\)/,
'component destruction releases its subscription',
);
});
+25 -487
View File
@@ -1,494 +1,32 @@
import * as Cesium from 'cesium';
import { MapSourceController } from './maps/controller.js';
import { createDefaultMapSources } from './maps/defaultSources.js';
import { governorRequestRender } from './renderGovernor.js';
import { keySetupRequirement } from './keySetupCore.mjs';
export { MAP_STACKS } from './maps/catalog.js';
export { photorealUnavailableReason } from './maps/availability.js';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
// Esri World Imagery — the keyless satellite basemap and the default keyless
// landing (a spy-satellite simulator should open on satellite imagery, not a
// street map). The classic ArcGIS Online tile service answers without a key;
// attribution is required and the provider carries the service's own credit
// line. Terms note in DATA_SOURCES.md.
const ESRI_WORLD_IMAGERY_URL =
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
const ESRI_IMAGERY_CREDIT =
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
// The on-screen notice Esri requires when a third-party library draws its
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
// regime C (keyless globe stacks previously rendered a flat
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
// URL (review correction, spec §1a).
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
/**
* Controls the active globe/map stack. Google Photorealistic 3D Tiles remain
* the cinematic default, while Cesium ion world imagery and OSM run as globe
* imagery stacks.
*/
export class MapStackController {
constructor(viewer, {
googleTileset = null,
cesiumToken = '',
initialStack = 'photoreal',
onChange = null,
onError = null,
} = {}) {
this.viewer = viewer;
this.googleTileset = googleTileset;
this.cesiumToken = String(cesiumToken || '').trim();
this._onChange = onChange;
this._onError = onError;
this._activeId = googleTileset ? initialStack : 'esri-imagery';
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._esriFallbackPending = false;
this._imageryProviders = new Map();
this._isSwitching = false;
this._lastError = null;
// Tracks which terrain PROVIDER is actually installed on the scene, not
// just an ion-available boolean: 'world' (Cesium World Terrain, ion
// token), 'keyless' (Re:Earth or its Ellipsoid fallback), or null (never
// set yet — Cesium's own startup default). Using a tri-state here (rather
// than the `enabled` boolean `_setWorldTerrainEnabled` receives) matters
// because both the "never set" and "keyless" states pass `enabled=false`;
// collapsing them to a boolean would make the first real keyless switch
// a no-op against the initial `false` default and leave Cesium's built-in
// provider in place instead of installing Re:Earth terrain.
this._terrainMode = null;
// Cache of the constructed keyless Re:Earth CesiumTerrainProvider, so
// repeat switches into a keyless globe stack don't refetch `layer.json`.
// Lives independently of `_switchGen` — construction is async and racy
// switches are guarded where it's awaited (`_setWorldTerrainEnabled`).
this._reearthTerrainProvider = null;
// Monotonic switch counter. setStack() awaits network-bound provider
// creation; a rapid A→B switch where A (e.g. slow Bing) resolves AFTER B
// (fast OSM) would otherwise revert the user's last choice (M7). Each call
// captures a generation and aborts its own commit once superseded.
this._switchGen = 0;
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
}
}
getStacks() {
return MAP_STACKS.map((stack) => {
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
// Why this stack can't be picked, from the ONE place that decides it.
// A stack can be unavailable for reasons other than a missing ion
// token (photoreal is unavailable when the Google tileset failed to
// load), so callers must not infer the reason from `available` alone.
unavailableReason: available ? null : this._unavailableReason(stack),
};
/** Preserve the standalone entry point; applications can compose the source controller directly. */
export class MapStackController extends MapSourceController {
constructor(viewer, options = {}) {
const googleApiKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
const registry = createDefaultMapSources({ ...options, googleApiKey });
super(viewer, {
registry,
initialStack: options.googleTileset
? options.initialStack || 'photoreal'
: registry.defaultId,
...options,
requestRender: governorRequestRender,
});
this.googleTileset = options.googleTileset || null;
this.cesiumToken = String(options.cesiumToken || '').trim();
}
/**
* Human-readable reason a stack can't be activated. Shared by `getStacks()`
* and `setStack()` so the tooltip and the toast never drift apart.
* @param {object} stack - Stack descriptor.
* @returns {string}
*/
_unavailableReason(stack) {
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
return `${stack?.label || 'This map stack'} is unavailable`;
}
/** A direct Google key or an ion token is enough to attempt Google 3D. */
_hasPhotorealCredentials() {
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
}
getStack(id) {
return MAP_STACKS.find((stack) => stack.id === id) || null;
}
getActiveId() {
return this._activeId;
}
/**
* Monotonic id of the most recently STARTED switch.
*
* A switch is only superseded by another `setStack()` — nothing else moves
* this number — so a caller that must know whether the globe it is looking
* at is still the one IT asked for can compare this across its own await.
* Unchanged (or advanced by exactly its own call) means no newer switch has
* claimed the globe.
* @returns {number}
*/
getSwitchGeneration() {
return this._switchGen;
}
getActiveStack() {
return this.getStack(this._activeId);
}
isStackAvailable(id) {
const stack = this.getStack(id);
if (!stack) return false;
if (stack.kind === 'photoreal') return !!this.googleTileset;
if (stack.requiresIon) return !!this.cesiumToken;
return true;
}
/** Invalidate pending switches before the viewer is destroyed. */
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._isSwitching = false;
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
this._onChange = null;
this._onError = null;
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack('photoreal');
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
let activation = null;
if (stack.kind === 'photoreal') {
await this._activatePhotoreal(gen);
} else {
activation = await this._activateGlobeStack(stack, gen);
}
// A newer switch started while we were awaiting the provider — that call
// owns the final state now, so don't commit ours or emit a stale 'ready'.
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
// subsequent tile loads self-request via Cesium. (perf wave 2)
governorRequestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
if (this.googleTileset) {
await this._activatePhotoreal(gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = 'photoreal';
}
if (!silent) this._emitChange('error');
} finally {
// Only the latest switch clears the switching flag; a superseded call
// must not stomp a newer switch that is still in progress.
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
hasCesiumIonToken: !!this.cesiumToken,
};
}
async _activatePhotoreal(gen) {
this._removeImageryLayer();
this._syncEsriAttribution(null); // Esri is no longer on screen.
if (this.googleTileset) this.googleTileset.show = true;
this.viewer.scene.globe.show = false;
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
// (`globe.show = false`), so the terrain provider is inert — it renders and
// streams nothing. Routing this through `_setWorldTerrainEnabled(false)`
// would make the DEFAULT startup stack await a keyless Re:Earth `layer.json`
// fetch it can't use, delaying photoreal boot on a slow/blocked network and
// (on failure) caching the flat `EllipsoidTerrainProvider` fallback for
// later OSM switches. The Re:Earth fetch is therefore lazy: it happens on
// the first switch to an actual globe stack (`_activateGlobeStack`).
// `_terrainMode` is intentionally not changed — every globe-stack transition
// re-derives the correct provider from it (null/'world'/'keyless'), so
// leaving it as-is keeps the next switch correct without a photoreal fetch.
void gen;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
// A newer switch started while the provider was resolving — don't touch the
// scene's imagery layers, the winning switch already owns them (M7).
if (gen != null && gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
this._syncEsriAttribution(resolution.effectiveStackId);
this._watchEsriProvider(resolution, gen);
if (this.googleTileset) this.googleTileset.show = false;
this.viewer.scene.globe.show = true;
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
return resolution;
}
/**
* Show or hide the required "Powered by Esri" notice with the Esri layer's
* own lifecycle.
*
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
* option for tiled ArcGIS MapServer sources, so passing it there displays
* nothing and the app would be using the service without the attribution
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
* lightbox, where per-layer data credits live) because that is what the
* requirement asks for, and it is removed when another stack takes over so
* the globe never claims a source it is not showing.
*/
_syncEsriAttribution(activeStackId) {
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
if (!creditDisplay) return;
const wanted = activeStackId === 'esri-imagery';
if (wanted === !!this._esriCreditShown) return;
if (!this._esriCredit) {
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
}
try {
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
else creditDisplay.removeStaticCredit(this._esriCredit);
this._esriCreditShown = wanted;
} catch {
// A Cesium build without static-credit removal must not break switching.
}
}
async _getImageryProvider(stack) {
if (this._imageryProviders.has(stack.id)) {
return this._imageryProviders.get(stack.id);
}
let provider;
let effectiveStackId = stack.id;
let fallbackMessage = null;
if (stack.kind === 'ion') {
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
} else if (stack.kind === 'esri-imagery') {
try {
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
credit: ESRI_IMAGERY_CREDIT,
enablePickFeatures: false,
});
} catch (error) {
// The keyless DEFAULT landing must never strand a first run on a blank
// globe because Esri is unreachable — fall back to OSM tiles for this
// session. (The fallback is cached under this stack id like any other
// provider, so the session won't re-probe Esri; a restart does.)
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
effectiveStackId = 'osm';
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
}
} else if (stack.kind === 'osm') {
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
} else {
throw new Error(`Unsupported map stack: ${stack.id}`);
}
const resolution = { provider, effectiveStackId, fallbackMessage };
this._imageryProviders.set(stack.id, resolution);
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
}
return resolution;
}
/**
* Esri provider construction can succeed while its first tile requests fail.
* Two failures for the active provider trigger the same truthful OSM fallback
* as a construction failure; one transient error is left to Cesium's retry.
*/
_watchEsriProvider(resolution, gen) {
if (resolution.effectiveStackId !== 'esri-imagery') return;
const errorEvent = resolution.provider?.errorEvent;
if (!errorEvent?.addEventListener) return;
let failures = 0;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
const retryCount = Number(error?.timesRetried);
failures = Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < 2 || this._esriFallbackPending) return;
this._esriFallbackPending = true;
const message = 'Esri Satellite tile requests failed; using OSM';
this._onError?.(message, this.getStack('esri-imagery'));
void this.setStack('osm', { silent: true }).then((state) => {
if (state?.activeId === 'osm') {
this._lastError = message;
this._emitChange('error');
}
}).finally(() => {
this._esriFallbackPending = false;
});
});
}
_removeImageryLayer() {
if (this._removeImageryErrorListener) {
this._removeImageryErrorListener();
this._removeImageryErrorListener = null;
}
if (!this._imageryLayer) return;
this.viewer.imageryLayers.remove(this._imageryLayer, false);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
/**
* Sets the scene's terrain provider for the current globe stack.
*
* `enabled` selects Cesium World Terrain (ion token present — regime B,
* unchanged). Disabled/keyless (regime C: OSM or any globe stack without an
* ion token) now tries the keyless Re:Earth ellipsoidal terrain instead of
* the flat `EllipsoidTerrainProvider`, falling back to the flat provider
* (today's behavior) if construction fails — no worse than before this fix.
*
* `CesiumTerrainProvider.fromUrl()` is async (fetches `layer.json`), so this
* method is async-safe: `gen` is the caller's switch generation (from
* `setStack`'s `_switchGen`, threaded through `_activatePhotoreal` /
* `_activateGlobeStack`, mirroring the M7 pattern in `_activateGlobeStack`
* for imagery providers). If a newer switch starts while the Re:Earth
* fetch is in flight, this call's result is discarded instead of
* clobbering the newer switch's terrain.
* @param {boolean} enabled
* @param {number} [gen] — switch generation this call belongs to
*/
async _setWorldTerrainEnabled(enabled, gen) {
const targetMode = enabled ? 'world' : 'keyless';
if (targetMode === this._terrainMode) return;
if (enabled) {
this.viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain({
requestVertexNormals: true,
}));
} else {
const provider = await this._getKeylessTerrainProvider();
// A newer switch started while the Re:Earth layer.json fetch was in
// flight — that call owns terrain now; don't stomp it (M7 pattern).
if (gen != null && gen !== this._switchGen) return;
this.viewer.terrainProvider = provider;
}
this._terrainMode = targetMode;
}
/**
* Resolves (and caches) the keyless terrain provider for globe stacks
* without an ion token: Re:Earth ellipsoidal quantized-mesh terrain, or
* `EllipsoidTerrainProvider` (flat — current/prior behavior) if the
* Re:Earth endpoint can't be constructed. Never throws.
* @returns {Promise<Cesium.TerrainProvider>}
*/
async _getKeylessTerrainProvider() {
if (this._reearthTerrainProvider) return this._reearthTerrainProvider;
try {
this._reearthTerrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(REEARTH_TERRAIN_URL);
} catch (error) {
console.warn('[mapStackController] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error);
this._reearthTerrainProvider = new Cesium.EllipsoidTerrainProvider();
}
return this._reearthTerrainProvider;
}
_emitChange(status) {
this._onChange?.(this.getState(status));
const googleKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return (
Boolean(String(googleKey || '').trim()) ||
Boolean(String(this.cesiumToken || '').trim())
);
}
}
+8 -2
View File
@@ -1,9 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
import {
MapStackController,
photorealUnavailableReason,
} from './mapStackController.js';
test('missing photoreal credentials explain both supported setup routes', () => {
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
assert.match(
photorealUnavailableReason(false),
/Needs GOOGLE_MAPS_API_KEY.*Provider Settings/,
);
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
});
+4 -51
View File
@@ -1,51 +1,4 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
try {
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
onlyUsingWithGoogleGeocoder: true,
});
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
Cesium.GoogleMaps.defaultApiKey = undefined;
return { tileset: null, route: 'osm', errors };
}
export {
selectMapStartupRoute,
loadPhotorealisticTileset,
} from './maps/google3d.js';
+50 -12
View File
@@ -7,25 +7,42 @@ import {
function fakeCesium(outcomes = []) {
const calls = [];
const next = () => {
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
};
return {
calls,
Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined },
async createGooglePhotorealistic3DTileset(options) {
calls.push({
options,
googleKey: this.GoogleMaps.defaultApiKey,
ionToken: this.Ion.defaultAccessToken,
});
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
calls.push({ options, googleKey: options.key });
return next();
},
IonResource: {
async fromAssetId(assetId, options) {
return { assetId, ...options };
},
},
Cesium3DTileset: {
async fromUrl(resource, options) {
calls.push({
options,
ionToken: resource.accessToken,
assetId: resource.assetId,
});
return next();
},
},
};
}
test('map startup route reflects the best configured provider', () => {
assert.equal(selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }), 'google-direct');
assert.equal(
selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }),
'google-direct',
);
assert.equal(selectMapStartupRoute({ cesiumToken: 'ion' }), 'google-ion');
assert.equal(selectMapStartupRoute(), 'osm');
});
@@ -53,13 +70,17 @@ test('a direct Google key is preferred', async () => {
test('an ion-only setup loads the hosted Google 3D asset', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, { cesiumToken: 'ion-secret' });
const result = await loadPhotorealisticTileset(Cesium, {
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-ion');
assert.equal(Cesium.calls.length, 1);
assert.equal(Cesium.calls[0].googleKey, undefined);
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, undefined);
assert.equal(Cesium.calls[0].assetId, 2275207);
assert.equal(Cesium.calls[0].options.enableCollision, true);
});
test('a failed direct request retries through ion before falling back', async () => {
@@ -91,7 +112,10 @@ test('a failed direct-only request does not consume an implicit Cesium token', a
});
test('failed direct and ion requests preserve the keyless OSM fallback', async () => {
const Cesium = fakeCesium([new Error('direct denied'), new Error('ion denied')]);
const Cesium = fakeCesium([
new Error('direct denied'),
new Error('ion denied'),
]);
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
cesiumToken: 'ion-secret',
@@ -102,3 +126,17 @@ test('failed direct and ion requests preserve the keyless OSM fallback', async (
assert.equal(Cesium.calls.length, 2);
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
});
test('independent source configurations never mutate shared SDK credentials', async () => {
const Cesium = fakeCesium([{ id: 'a' }, { id: 'b' }]);
Cesium.Ion.defaultAccessToken = 'untouched-ion';
Cesium.GoogleMaps.defaultApiKey = 'untouched-google';
await Promise.all([
loadPhotorealisticTileset(Cesium, { googleApiKey: 'source-a' }),
loadPhotorealisticTileset(Cesium, { cesiumToken: 'source-b' }),
]);
assert.equal(Cesium.calls[0].googleKey, 'source-a');
assert.equal(Cesium.calls[1].ionToken, 'source-b');
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
});
+15
View File
@@ -0,0 +1,15 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
+40
View File
@@ -0,0 +1,40 @@
import * as Cesium from 'cesium';
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
+337
View File
@@ -0,0 +1,337 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
export class MapSourceController {
constructor(
viewer,
{
registry,
initialStack,
onChange = null,
onError = null,
requestRender = () => viewer?.scene?.requestRender?.(),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
},
) {
this.viewer = viewer;
this._registry = registry;
this._sources = indexMapSources(registry.sources);
this._activeId = this.isStackAvailable(initialStack)
? initialStack
: registry.defaultId;
this._onChange = onChange;
this._onError = onError;
this._requestRender = requestRender;
this._createImageryLayer = createImageryLayer;
this._credits = createMapCredits(viewer);
this._abort = new AbortController();
this._imageryProviders = new Map();
this._terrainProviders = new Map();
this._tilesets = new Map();
this._ownedTilesets = new Set();
this._disposed = new WeakSet();
this._switchGen = 0;
this._isSwitching = false;
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._destroyed = false;
}
getStack(id) {
return this._sources.get(id)?.descriptor || null;
}
getStacks() {
return [...this._sources.values()].map(({ descriptor }) => {
const stack = descriptor;
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
unavailableReason: available ? null : this._unavailableReason(stack),
};
});
}
isStackAvailable(id) {
const source = this._sources.get(id);
return Boolean(source && source.available !== false);
}
_unavailableReason(stack) {
return (
this._sources.get(stack?.id)?.unavailableReason ||
`${stack?.label || 'This map stack'} is unavailable`
);
}
getActiveId() {
return this._activeId;
}
getActiveStack() {
return this.getStack(this._activeId);
}
getSwitchGeneration() {
return this._switchGen;
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
...this._registry.state,
};
}
_emitChange(status) {
this._onChange?.(this.getState(status));
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
const activation = await this._activate(stack, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
this._requestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
const recovery = this.getStack(this._registry.recoveryId);
if (
recovery &&
recovery.id !== stack.id &&
this.isStackAvailable(recovery.id)
) {
try {
const activation = await this._activate(recovery, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || recovery.id;
} catch (recoveryError) {
if (gen !== this._switchGen) return this.getState();
this._lastError = recoveryError?.message || String(recoveryError);
this._onError?.(this._lastError, recovery);
}
}
if (!silent) this._emitChange('error');
} finally {
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
_activate(stack, gen) {
const source = this._sources.get(stack.id);
return source.imagery
? this._activateGlobeStack(stack, gen)
: this._activateTileset(source, gen);
}
async _activateTileset(source, gen) {
let tileset = source.tileset;
if (!tileset) {
if (!source.createTileset)
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
tileset = await this._cached(this._tilesets, source.descriptor.id, () =>
source.createTileset({ signal: this._abort.signal }),
);
}
if (gen !== this._switchGen) return;
if (!source.tileset && !this._ownedTilesets.has(tileset)) {
tileset.show = false;
this.viewer.scene.primitives.add(tileset);
this._ownedTilesets.add(tileset);
}
this._removeImageryLayer();
this._credits.show(source.credit || null);
this._showTileset(tileset);
this.viewer.scene.globe.show = false;
// Terrain is intentionally untouched while the globe is hidden.
}
_showTileset(active) {
for (const source of this._sources.values())
if (source.tileset) source.tileset.show = source.tileset === active;
for (const tileset of this._ownedTilesets)
tileset.show = tileset === active;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
if (gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = this._createImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
const source = this._sources.get(resolution.effectiveStackId);
this._credits.show(source?.credit || null);
this._watchProvider(resolution, gen);
this._showTileset(null);
this.viewer.scene.globe.show = true;
this._activeId = resolution.effectiveStackId;
if (source?.terrain && source.terrain.id !== this._terrainMode) {
const terrain = source.terrain;
const result = await this._cached(
this._terrainProviders,
terrain.id,
() => terrain.create({ signal: this._abort.signal }),
);
if (gen !== this._switchGen) return;
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
this._terrainMode = terrain.id;
}
return resolution;
}
_cached(cache, id, create) {
if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve()
.then(() => {
this._abort.signal.throwIfAborted();
return create();
})
.catch((error) => {
if (cache.get(id) === promise) cache.delete(id);
throw error;
});
cache.set(id, promise);
return promise;
}
_getImageryProvider(stack, visited = new Set()) {
if (visited.has(stack.id))
return Promise.reject(new Error('Map source fallback cycle'));
return this._cached(this._imageryProviders, stack.id, async () => {
const source = this._sources.get(stack.id);
try {
const provider = await source.imagery({ signal: this._abort.signal });
if (this._destroyed) this._dispose(provider);
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
} catch (error) {
const fallback = source.constructionFallback;
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
throw error;
const next = new Set(visited).add(stack.id);
const resolution = await this._getImageryProvider(
this.getStack(fallback.id),
next,
);
return { ...resolution, fallbackMessage: fallback.message };
}
});
}
_watchProvider(resolution, gen) {
const fallback = this._sources.get(
resolution.effectiveStackId,
)?.tileFailureFallback;
const errorEvent = resolution.provider?.errorEvent;
if (!fallback || !errorEvent?.addEventListener) return;
let failures = 0;
let pending = false;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (
gen !== this._switchGen ||
this._activeImageryProvider !== resolution.provider
)
return;
const retryCount = Number(error?.timesRetried);
failures =
Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < fallback.threshold || pending) return;
pending = true;
this._onError?.(
fallback.message,
this.getStack(resolution.effectiveStackId),
);
const expectedGen = this._switchGen + 1;
void this.setStack(fallback.id, { silent: true })
.then((state) => {
if (
!this._destroyed &&
this._switchGen === expectedGen &&
state?.activeId === fallback.id
) {
this._lastError = fallback.message;
this._emitChange('error');
}
})
.finally(() => {
pending = false;
});
});
}
_removeImageryLayer() {
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
if (this._imageryLayer)
this.viewer.imageryLayers.remove(this._imageryLayer, true);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
_dispose(value) {
if (!value || this._disposed.has(value)) return;
this._disposed.add(value);
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
value.destroy();
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._abort.abort();
this._isSwitching = false;
this._removeImageryLayer();
this._credits.destroy();
for (const promise of this._imageryProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const promise of this._terrainProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const tileset of this._ownedTilesets) {
this.viewer.scene.primitives.remove(tileset);
this._dispose(tileset);
}
for (const promise of this._tilesets.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value),
() => {},
);
this._imageryProviders.clear();
this._terrainProviders.clear();
this._tilesets.clear();
this._ownedTilesets.clear();
this._onChange = null;
this._onError = null;
}
}
+383
View File
@@ -0,0 +1,383 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
import { createDefaultMapSources } from './defaultSources.js';
function event() {
const listeners = new Set();
return {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
raise(value = {}) {
for (const fn of [...listeners]) fn(value);
},
get size() {
return listeners.size;
},
};
}
function fixture(registry, options = {}) {
const imagery = [],
credits = new Set(),
primitives = [],
removed = [],
changes = [];
const viewer = {
scene: {
globe: { show: true },
requestRender() {},
frameState: {
creditDisplay: {
addStaticCredit(value) {
credits.add(value);
},
removeStaticCredit(value) {
credits.delete(value);
},
},
},
primitives: {
add(value) {
primitives.push(value);
},
remove(value) {
primitives.splice(primitives.indexOf(value), 1);
value.destroy?.();
},
},
},
imageryLayers: {
add(value) {
imagery.push(value);
},
remove(value, destroy) {
imagery.splice(imagery.indexOf(value), 1);
removed.push({ value, destroy });
},
},
};
const controller = new MapSourceController(viewer, {
registry,
createImageryLayer: (provider) => ({ provider }),
onChange: (state) => changes.push(state),
...options,
});
return { viewer, controller, imagery, credits, primitives, removed, changes };
}
const settle = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
function publicFixture() {
const tileset = { show: true };
const registry = createDefaultMapSources({ googleTileset: tileset });
const providers = new Map();
for (const source of registry.sources) {
if (!source.imagery) continue;
const provider = { id: source.descriptor.id, errorEvent: event() };
providers.set(source.descriptor.id, provider);
source.imagery = async () => provider;
source.terrain = {
id: 'keyless',
create: async () => ({ provider: { id: 'terrain' } }),
};
}
return { ...fixture(registry), registry, providers, tileset };
}
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
let reads = 0,
destroys = 0,
terrainReads = 0;
const provider = {
destroy() {
destroys++;
},
};
const terrain = {
id: 'custom-floor',
create: async () => {
terrainReads++;
return { provider: { id: 'terrain' } };
},
};
const registry = {
defaultId: 'custom',
sources: [
{
descriptor: descriptor('custom'),
imagery: async () => {
reads++;
return provider;
},
terrain,
},
],
};
const env = fixture(registry);
await env.controller.setStack('custom');
await env.controller.setStack('custom');
assert.equal(env.controller.getActiveId(), 'custom');
assert.equal(env.viewer.terrainProvider.id, 'terrain');
assert.equal(reads, 1);
assert.equal(terrainReads, 1);
assert.equal(env.imagery.length, 1);
assert.equal(env.removed[0].destroy, true);
env.controller.destroy();
env.controller.destroy();
await settle();
assert.equal(env.imagery.length, 0);
assert.equal(destroys, 1);
});
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
let resolve,
signal,
destroyed = 0;
const env = fixture({
defaultId: 'slow',
sources: [
{
descriptor: descriptor('slow'),
imagery: (request) => {
signal = request.signal;
return new Promise((done) => {
resolve = done;
});
},
},
],
});
const loading = env.controller.setStack('slow');
await settle();
env.controller.destroy();
assert.equal(signal.aborted, true);
resolve({
destroy() {
destroyed++;
},
});
await loading;
await settle();
assert.equal(destroyed, 1);
assert.equal(env.imagery.length, 0);
assert.deepEqual(
env.changes.map((state) => state.status),
['switching'],
);
});
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
const env = publicFixture();
let resolve,
reads = 0;
const osm = env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
);
osm.terrain = {
id: 'slow-floor',
create: () => {
reads++;
return new Promise((done) => {
resolve = done;
});
},
};
await env.controller.setStack('photoreal');
assert.equal(reads, 0);
const loading = env.controller.setStack('osm');
await settle();
await env.controller.setStack('photoreal');
resolve({ provider: { id: 'late-terrain' } });
await loading;
assert.equal(env.viewer.terrainProvider, undefined);
assert.equal(env.viewer.scene.globe.show, false);
assert.equal(env.tileset.show, true);
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
const env = publicFixture();
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
throw new Error('unreachable');
};
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(
env.controller.getState().lastError,
'Esri Satellite is unavailable; using OSM',
);
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
assert.equal(env.credits.size, 0);
env.controller.destroy();
});
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
const env = publicFixture();
await env.controller.setStack('esri-imagery');
assert.equal(env.credits.size, 1);
const errorEvent = env.providers.get('esri-imagery').errorEvent;
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'esri-imagery');
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(env.credits.size, 0);
assert.equal(
env.controller.getState().lastError,
'Esri Satellite tile requests failed; using OSM',
);
assert.equal(errorEvent.size, 0);
await env.controller.setStack('photoreal');
errorEvent.raise({ timesRetried: 9 });
await settle();
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
const errors = [],
env = publicFixture();
env.controller._onError = (message) => errors.push(message);
const reason = env.controller
.getStacks()
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
await env.controller.setStack('bing-aerial');
assert.deepEqual(errors, [reason]);
assert.match(reason, /CESIUM_ION_TOKEN/);
await env.controller.setStack('bing-road');
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
let resolve,
destroyed = 0;
const supplied = {
show: true,
destroy() {
assert.fail('caller-owned tileset');
},
};
const env = fixture({
defaultId: 'three',
sources: [
{
descriptor: { id: 'three' },
createTileset: () =>
new Promise((done) => {
resolve = done;
}),
},
{ descriptor: { id: 'supplied' }, tileset: supplied },
],
});
const loading = env.controller.setStack('three');
await settle();
env.controller.destroy();
resolve({
destroy() {
destroyed++;
},
});
await loading;
assert.equal(destroyed, 1);
assert.equal(env.primitives.length, 0);
});
test('invalid source graphs fail before constructing or caching a provider', () => {
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
});
for (const reactivate of [false, true]) {
test(`a superseded 3D load stays outside the scene and is released (reactivate: ${reactivate})`, async () => {
let resolve,
destroyed = 0,
reads = 0;
const tileset = {
show: true,
isDestroyed: () => destroyed > 0,
destroy() {
destroyed++;
},
};
const supplied = { show: true };
const env = fixture({
defaultId: 'slow',
sources: [
{
descriptor: { id: 'slow' },
createTileset: () => {
reads++;
return new Promise((done) => {
resolve = done;
});
},
},
{ descriptor: { id: 'supplied' }, tileset: supplied },
],
});
const loading = env.controller.setStack('slow');
await settle();
await env.controller.setStack('supplied');
resolve(tileset);
await loading;
assert.equal(env.primitives.length, 0);
assert.equal(supplied.show, true);
assert.equal(env.controller.getActiveId(), 'supplied');
if (reactivate) {
await env.controller.setStack('slow');
await env.controller.setStack('slow');
assert.deepEqual(env.primitives, [tileset]);
assert.equal(reads, 1);
assert.equal(tileset.show, true);
assert.equal(supplied.show, false);
}
env.controller.destroy();
await settle();
assert.equal(env.primitives.length, 0);
assert.equal(destroyed, 1);
});
}
test('a failed recovery reports its error and leaves switching settled', async () => {
const errors = [];
const env = fixture(
{
defaultId: 'first',
recoveryId: 'recovery',
sources: [
{
descriptor: descriptor('first'),
imagery: async () => {
throw new Error('first offline');
},
},
{
descriptor: descriptor('recovery'),
imagery: async () => {
throw new Error('recovery offline');
},
},
],
},
{ onError: (message) => errors.push(message) },
);
const result = await env.controller.setStack('first');
assert.equal(result.status, 'ready');
assert.equal(result.lastError, 'recovery offline');
assert.deepEqual(errors, ['first offline', 'recovery offline']);
assert.equal(env.changes.at(-1).status, 'error');
assert.equal(env.imagery.length, 0);
env.controller.destroy();
});
+25
View File
@@ -0,0 +1,25 @@
import * as Cesium from 'cesium';
/** Static credits follow the source actually on screen, including fallback. */
export function createMapCredits(viewer) {
let active = null;
let markup = null;
return {
show(html) {
if (html === markup) return;
const display = viewer?.scene?.frameState?.creditDisplay;
if (!display) return;
try {
if (active) display.removeStaticCredit(active);
active = html ? new Cesium.Credit(html, true) : null;
markup = html;
if (active) display.addStaticCredit(active);
} catch {
/* Switching remains usable on older credit displays. */
}
},
destroy() {
this.show(null);
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import { MAP_STACKS } from './catalog.js';
import { photorealUnavailableReason } from './availability.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
createOsmImagery,
createEsriImagery,
createIonImagery,
ESRI_ATTRIBUTION_HTML,
} from './imagery.js';
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
/** Select sources and setup guidance without putting provider branches in the controller. */
export function createDefaultMapSources({
googleTileset = null,
cesiumToken = '',
googleApiKey = '',
} = {}) {
const ionToken = String(cesiumToken || '').trim();
const hasIon = Boolean(ionToken);
const hasGoogle = Boolean(String(googleApiKey || '').trim());
const terrain = {
id: hasIon ? 'world' : 'keyless',
create: hasIon
? (request) => createWorldTerrain(ionToken, request)
: createKeylessTerrain,
};
return {
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
unknownId: 'photoreal',
recoveryId: googleTileset ? 'photoreal' : null,
state: { hasCesiumIonToken: hasIon },
sources: MAP_STACKS.map((descriptor) => {
const common = {
descriptor,
available: !descriptor.requiresIon || hasIon,
unavailableReason: descriptor.requiresIon
? keySetupRequirement('cesium-ion')
: null,
};
if (descriptor.kind === 'photoreal')
return {
...common,
available: Boolean(googleTileset),
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
tileset: googleTileset,
};
const imagery =
descriptor.kind === 'ion'
? () => createIonImagery(descriptor.style, ionToken)
: descriptor.id === 'osm'
? createOsmImagery
: createEsriImagery;
return {
...common,
imagery,
terrain,
...(descriptor.id === 'esri-imagery'
? {
credit: ESRI_ATTRIBUTION_HTML,
constructionFallback: {
id: 'osm',
message: 'Esri Satellite is unavailable; using OSM',
},
tileFailureFallback: {
id: 'osm',
threshold: 2,
message: 'Esri Satellite tile requests failed; using OSM',
},
}
: {}),
};
}),
};
}
+81
View File
@@ -0,0 +1,81 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({
googleApiKey = '',
cesiumToken = '',
} = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
try {
const tileset = attempt.googleKey
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
return { tileset: null, route: 'osm', errors };
}
/** Pass credentials to the source instead of changing SDK-wide defaults. */
export function createGoogleDirectTileset(Cesium, key) {
key = clean(key);
if (!key) throw new Error('Google 3D requires an explicit browser key');
return Cesium.createGooglePhotorealistic3DTileset({
key,
onlyUsingWithGoogleGeocoder: true,
});
}
export async function createGoogleIonTileset(
Cesium,
accessToken,
{ signal } = {},
) {
accessToken = clean(accessToken);
if (!accessToken)
throw new Error('Google 3D through ion requires an explicit token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(2275207, {
accessToken,
});
signal?.throwIfAborted();
// Match the installed SDK's Google helper rendering/cache defaults.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
});
}
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
// Attribution and service rights are documented in DATA_SOURCES.md.
export const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
export function createOsmImagery() {
return new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: '© OpenStreetMap contributors',
});
}
export function createEsriImagery() {
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
{
credit:
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
enablePickFeatures: false,
},
);
}
export function createIonImagery(style, accessToken) {
accessToken = String(accessToken || '').trim();
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
+21
View File
@@ -0,0 +1,21 @@
/** Catch invalid source graphs before a cached fallback can wait on itself. */
export function indexMapSources(sources) {
const index = new Map();
for (const source of sources) {
const id = source?.descriptor?.id;
if (typeof id !== 'string' || !id || index.has(id))
throw new TypeError('Map source IDs must be nonempty and unique');
index.set(id, source);
}
for (const id of index.keys()) {
const seen = new Set();
let next = id;
while (next) {
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
seen.add(next);
next = index.get(next).constructionFallback?.id;
}
}
return index;
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createIonImagery } from './imagery.js';
import { createWorldTerrain } from './terrain.js';
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const calls = [];
const defaultToken = Cesium.Ion.defaultAccessToken;
try {
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
calls.push({ kind: 'imagery', id, options });
return { id };
};
Cesium.IonResource.fromAssetId = async (id, options) => {
calls.push({ kind: 'resource', id, options });
return { id };
};
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
calls.push({ kind: 'terrain', resource, options });
return { id: 'terrain' };
};
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
const result = await createWorldTerrain('terrain-token');
assert.equal(calls[0].options.accessToken, 'imagery-token');
assert.equal(calls[1].options.accessToken, 'terrain-token');
assert.equal(calls[1].id, 1);
assert.equal(calls[2].options.requestVertexNormals, true);
assert.equal(result.provider.id, 'terrain');
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
} finally {
Cesium.IonImageryProvider.fromAssetId = originalImagery;
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('cancellation after ion metadata prevents terrain construction', async () => {
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const controller = new AbortController();
try {
Cesium.IonResource.fromAssetId = async () => {
controller.abort();
return {};
};
Cesium.CesiumTerrainProvider.fromUrl = () =>
assert.fail('cancelled terrain construction');
await assert.rejects(
createWorldTerrain('test-token', { signal: controller.signal }),
{ name: 'AbortError' },
);
} finally {
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
assert.throws(
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
/explicit token/,
);
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
});
+35
View File
@@ -0,0 +1,35 @@
import * as Cesium from 'cesium';
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
export async function createWorldTerrain(accessToken, { signal } = {}) {
accessToken = String(accessToken || '').trim();
if (!accessToken)
throw new Error('World terrain requires an explicit ion token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
signal?.throwIfAborted();
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
requestVertexNormals: true,
requestWaterMask: false,
ellipsoid: Cesium.Ellipsoid.WGS84,
}),
};
}
export async function createKeylessTerrain() {
try {
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
),
};
} catch (error) {
console.warn(
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
error,
);
return { provider: new Cesium.EllipsoidTerrainProvider() };
}
}
+7 -5
View File
@@ -11,7 +11,9 @@ import test from 'node:test';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
@@ -342,13 +344,13 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
});
test('successful explicit user playback hands the speaker from voice to Radio', () => {
const playStart = radio.indexOf('export async function playSelectedRadio');
const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10));
const playStart = radio.indexOf('async function playSelectedRadio');
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);