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

+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);
}