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

+56 -29
View File
@@ -29,7 +29,8 @@ function ringAreaKm2(ring) {
for (let i = 0; i < n; i++) {
const [lon1, lat1] = ring[i];
const [lon2, lat2] = ring[(i + 1) % n];
sum += toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
sum +=
toRad(lon2 - lon1) * (2 + Math.sin(toRad(lat1)) + Math.sin(toRad(lat2)));
}
return Math.abs((sum * EARTH_RADIUS_KM * EARTH_RADIUS_KM) / 2);
}
@@ -37,8 +38,9 @@ function ringAreaKm2(ring) {
function haversineKm(lon1, lat1, lon2, lat2) {
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const h = Math.sin(dLat / 2) ** 2
+ Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
@@ -62,34 +64,34 @@ function normalizeName(s) {
* Keys and values are both in normalizeName() form.
*/
const ALIASES = {
'rockies': 'rocky mountains',
'himalaya': 'himalayas',
rockies: 'rocky mountains',
himalaya: 'himalayas',
'the himalaya': 'himalayas',
'alps mountains': 'alps',
'sahara desert': 'sahara',
'gobi': 'gobi desert',
'kalahari': 'kalahari desert',
'atacama': 'desierto de atacama',
gobi: 'gobi desert',
kalahari: 'kalahari desert',
atacama: 'desierto de atacama',
'atacama desert': 'desierto de atacama',
'tibetan plateau': 'plateau of tibet',
'tibet plateau': 'plateau of tibet',
'appalachians': 'appalachian mts',
appalachians: 'appalachian mts',
'appalachian mountains': 'appalachian mts',
'caucasus': 'caucasus mts',
caucasus: 'caucasus mts',
'caucasus mountains': 'caucasus mts',
'balkans': 'balkan pen',
balkans: 'balkan pen',
'balkan peninsula': 'balkan pen',
'andes mountains': 'andes',
'urals': 'ural mountains',
urals: 'ural mountains',
'pyrenees mountains': 'pyrenees',
'arabian gulf': 'persian gulf',
'gulf of arabia': 'persian gulf',
'mediterranean': 'mediterranean sea',
'caribbean': 'caribbean sea',
'baja': 'baja california',
'yucatan': 'pen de yucatan',
mediterranean: 'mediterranean sea',
caribbean: 'caribbean sea',
baja: 'baja california',
yucatan: 'pen de yucatan',
'yucatan peninsula': 'pen de yucatan',
'kamchatka': 'kamchatka peninsula',
kamchatka: 'kamchatka peninsula',
'sierra nevada mountains': 'sierra nevada',
};
@@ -97,8 +99,13 @@ const ALIASES = {
function suffixVariants(norm) {
const v = [];
// "x mountains" ↔ "x mts" (pack uses "Mts."; normalization strips the dot)
if (norm.endsWith(' mountains')) v.push(norm.replace(/ mountains$/, ' mts'), norm.replace(/ mountains$/, ''));
if (norm.endsWith(' mts')) v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
if (norm.endsWith(' mountains'))
v.push(
norm.replace(/ mountains$/, ' mts'),
norm.replace(/ mountains$/, ''),
);
if (norm.endsWith(' mts'))
v.push(norm.replace(/ mts$/, ' mountains'), norm.replace(/ mts$/, ''));
// "x desert" ↔ "x"
if (norm.endsWith(' desert')) v.push(norm.replace(/ desert$/, ''));
else v.push(norm + ' desert');
@@ -117,9 +124,14 @@ async function loadPackFile(base) {
// Vite bundles these JSON files as modules; the import attribute is what Node
// needs to load the same files under node:test (same pattern as
// neighborhoodPolygons.js). One path, so no node: import reaches the browser.
const mod = base === 'regions'
? await import('./local_data/natural_earth/regions.json', { with: { type: 'json' } })
: await import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
const mod =
base === 'regions'
? await import('./local_data/natural_earth/regions.json', {
with: { type: 'json' },
})
: await import('./local_data/natural_earth/marine.json', {
with: { type: 'json' },
});
return mod.default || mod;
}
@@ -129,7 +141,10 @@ function buildEntries(pack, kind) {
const polygons = ft.polygons || [];
if (!polygons.length) continue;
let areaKm2 = 0;
let minLon = Infinity, minLat = Infinity, maxLon = -Infinity, maxLat = -Infinity;
let minLon = Infinity,
minLat = Infinity,
maxLon = -Infinity,
maxLat = -Infinity;
for (const ring of polygons) {
areaKm2 += ringAreaKm2(ring);
for (const [lon, lat] of ring) {
@@ -169,10 +184,14 @@ const loadIndex = createRetryableLoader(async () => {
];
const index = new Map();
for (const entry of _entries) {
for (const key of new Set([normalizeName(entry.name), normalizeName(entry.namealt)])) {
for (const key of new Set([
normalizeName(entry.name),
normalizeName(entry.namealt),
])) {
if (!key) continue;
const list = index.get(key);
if (list) list.push(entry); else index.set(key, [entry]);
if (list) list.push(entry);
else index.set(key, [entry]);
}
}
// duplicate names exist in Natural Earth (e.g. two "Cordillera Oriental",
@@ -207,7 +226,11 @@ export async function findNaturalRegion(query) {
const norm = normalizeName(query);
if (!norm) return null;
const index = await loadIndex();
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
const candidates = [
norm,
ALIASES[norm],
...suffixVariants(ALIASES[norm] || norm),
];
for (const key of candidates) {
if (!key) continue;
const list = index.get(key);
@@ -248,8 +271,8 @@ export function pointInRing(ring, lat, lon) {
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
const intersects = (yi > lat) !== (yj > lat)
&& lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
const intersects =
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersects) inside = !inside;
}
return inside;
@@ -274,7 +297,11 @@ export async function lookupNaturalRegionOutline(query, lat, lon) {
const norm = normalizeName(query);
if (!norm || !Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const index = await loadIndex();
const candidates = [norm, ALIASES[norm], ...suffixVariants(ALIASES[norm] || norm)];
const candidates = [
norm,
ALIASES[norm],
...suffixVariants(ALIASES[norm] || norm),
];
const seen = new Set();
for (const key of candidates) {
if (!key || seen.has(key)) continue;
+45 -13
View File
@@ -14,7 +14,14 @@ import { createRetryableLoader } from './retryableLoad.js';
// bbox = [west, south, east, north]; only load a city file when the point falls in its box.
const CITY_FILES = [
{ id: 'san-francisco', bbox: [-122.55, 37.70, -122.35, 37.84], loader: () => import('./local_data/neighborhoods/san-francisco.json', { with: { type: 'json' } }) },
{
id: 'san-francisco',
bbox: [-122.55, 37.7, -122.35, 37.84],
loader: () =>
import('./local_data/neighborhoods/san-francisco.json', {
with: { type: 'json' },
}),
},
];
/**
@@ -27,7 +34,11 @@ const CITY_FILES = [
const _cityLoaders = new Map();
function normalize(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
return String(s || '')
.toLowerCase()
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
@@ -45,10 +56,12 @@ const NAME_ALIASES = new Map([
function pointInRing(lon, lat, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const xi = ring[i][0]; const yi = ring[i][1];
const xj = ring[j][0]; const yj = ring[j][1];
const intersect = ((yi > lat) !== (yj > lat))
&& (lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi);
const xi = ring[i][0];
const yi = ring[i][1];
const xj = ring[j][0];
const yj = ring[j][1];
const intersect =
yi > lat !== yj > lat && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
@@ -70,7 +83,10 @@ function containingOuterRing(feature, lon, lat) {
if (!pointInRing(lon, lat, outer)) continue;
let inHole = false;
for (let h = 1; h < poly.length; h++) {
if (pointInRing(lon, lat, poly[h])) { inHole = true; break; }
if (pointInRing(lon, lat, poly[h])) {
inHole = true;
break;
}
}
if (!inHole) return outer;
}
@@ -108,7 +124,10 @@ async function loadCity(city) {
} catch (error) {
// The caller falls back to the live resolver ladder, which looks exactly
// like "no bundled coverage" — say so once, out loud, instead.
console.warn(`[neighborhoods] ${city.id} pack unavailable:`, error?.message || error);
console.warn(
`[neighborhoods] ${city.id} pack unavailable:`,
error?.message || error,
);
return [];
}
}
@@ -122,7 +141,13 @@ async function loadCity(city) {
* neighborhood name, or null when the point isn't in a covered city / no match.
*/
export async function lookupNeighborhoodRing(lat, lon, matchName) {
const city = CITY_FILES.find((c) => lon >= c.bbox[0] && lat >= c.bbox[1] && lon <= c.bbox[2] && lat <= c.bbox[3]);
const city = CITY_FILES.find(
(c) =>
lon >= c.bbox[0] &&
lat >= c.bbox[1] &&
lon <= c.bbox[2] &&
lat <= c.bbox[3],
);
if (!city) return null;
const feats = await loadCity(city);
if (!feats.length) return null;
@@ -153,11 +178,18 @@ export async function lookupNeighborhoodRing(lat, lon, matchName) {
const containRing = containingOuterRing(f, lon, lat);
const ring = containRing || largestOuterRing(f.geometry);
if (!ring) continue;
const cand = { ring, name: f.properties.name, contains: !!containRing, fwordCount: fWords.length };
const cand = {
ring,
name: f.properties.name,
contains: !!containRing,
fwordCount: fWords.length,
};
// Prefer a feature that CONTAINS the point, then the most specific name (most words).
if (!best
|| (cand.contains && !best.contains)
|| (cand.contains === best.contains && cand.fwordCount > best.fwordCount)) {
if (
!best ||
(cand.contains && !best.contains) ||
(cand.contains === best.contains && cand.fwordCount > best.fwordCount)
) {
best = cand;
}
}
+97 -2788
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeRadioCountryInput } from './radioCountry.js';
import { normalizeRadioCountryInput } from '../layers/radio/index.js';
test('Radio country normalization maps ISO codes and bounded common names', () => {
for (const [input, code] of [
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
import { RADIO_UUID_RE } from './policy.js';
export function createCatalogModel({
state: layerState,
services,
parts,
source,
}) {
function isNonGlobalRadioIpv4(hostname) {
const pieces = hostname.split('.');
if (pieces.length !== 4 || pieces.some((piece) => !/^\d{1,3}$/.test(piece)))
return false;
const values = pieces.map(Number);
if (values.some((value) => value > 255)) return true;
const [a, b, c] = values;
return (
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 0) ||
(a === 192 && b === 88 && c === 99) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 198 && b === 51 && c === 100) ||
(a === 203 && b === 0 && c === 113)
);
}
function isSafeRadioHttpsUrl(value) {
if (typeof value !== 'string' || !value) return false;
try {
const url = new URL(value);
const hostname = url.hostname
.toLowerCase()
.replace(/^\[|\]$/g, '')
.replace(/\.$/, '');
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
!hostname
)
return false;
return !(
hostname === 'localhost' ||
hostname.endsWith('.localhost') ||
hostname.endsWith('.local') ||
isNonGlobalRadioIpv4(hostname) ||
hostname.includes(':')
);
} catch {
return false;
}
}
function isValidRadioDirectoryStation(station) {
const cleanText = (value, maxLength, { allowEmpty = true } = {}) =>
typeof value === 'string' &&
value.length <= maxLength &&
(allowEmpty || value.trim().length > 0) &&
!/[\u0000-\u001f\u007f]/.test(value) &&
value === value.trim() &&
!/\s{2,}/.test(value);
const textArray = (value, limit, itemMaxLength) =>
Array.isArray(value) &&
value.length <= limit &&
value.every((item) =>
cleanText(item, itemMaxLength, { allowEmpty: false }),
);
return Boolean(
station &&
RADIO_UUID_RE.test(station.id) &&
cleanText(station.name, 140, { allowEmpty: false }) &&
Number.isFinite(station.lat) &&
station.lat >= -90 &&
station.lat <= 90 &&
Number.isFinite(station.lon) &&
station.lon >= -180 &&
station.lon <= 180 &&
isSafeRadioHttpsUrl(station.streamUrl) &&
(station.homepage === null || isSafeRadioHttpsUrl(station.homepage)) &&
textArray(station.tags, 24, 80) &&
textArray(station.languages, 8, 40) &&
cleanText(station.state, 80) &&
cleanText(station.country, 80) &&
cleanText(station.countryCode, 2) &&
(station.countryCode === '' ||
normalizeRadioCountryInput(station.countryCode).valid) &&
station.metadataTrust === 'untrusted-community' &&
cleanText(station.codec, 16, { allowEmpty: false }) &&
/^(?:MP3|AAC(?:\+|-LC|-HE)?|HE-AAC)$/i.test(station.codec) &&
(station.bitrate === null ||
(Number.isInteger(station.bitrate) &&
station.bitrate >= 8 &&
station.bitrate <= 1024)),
);
}
function freezeRadioStation(station) {
return Object.freeze({
id: station.id,
name: station.name,
lat: station.lat,
lon: station.lon,
streamUrl: station.streamUrl,
homepage: station.homepage,
tags: Object.freeze([...station.tags]),
languages: Object.freeze([...station.languages]),
state: station.state,
country: station.country,
countryCode: station.countryCode,
metadataTrust: station.metadataTrust,
codec: station.codec,
bitrate: station.bitrate,
});
}
function createAcceptedCatalogSnapshot(
instance,
generation,
updatedAt,
stations,
) {
if (!Number.isSafeInteger(generation) || generation < 1) return null;
if (typeof instance !== 'string' || !instance) return null;
const frozenStations = Object.freeze(stations.map(freezeRadioStation));
return Object.freeze({
instance,
generation,
updatedAt,
stations: frozenStations,
stationIds: Object.freeze(frozenStations.map((station) => station.id)),
});
}
return {
isNonGlobalRadioIpv4,
isSafeRadioHttpsUrl,
isValidRadioDirectoryStation,
freezeRadioStation,
createAcceptedCatalogSnapshot,
};
}
+131
View File
@@ -0,0 +1,131 @@
import { MUSIC_GENRES, CATEGORY_MATCHERS } from './policy.js';
export function createCategories({
state: layerState,
services,
parts,
source,
}) {
/** Normalize one directory tag to a stable, lower-case display token. */
function normalizeRadioTag(value) {
return String(value ?? '')
.trim()
.toLocaleLowerCase()
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.slice(0, 80);
}
function stationTags(station) {
if (Array.isArray(station?.tags))
return station.tags.map(normalizeRadioTag).filter(Boolean);
return String(station?.tags ?? '')
.split(',')
.map(normalizeRadioTag)
.filter(Boolean);
}
function hasTag(station, needles) {
const tags = stationTags(station);
return needles.some((needle) =>
tags.some((tag) => tag === needle || tag.includes(needle)),
);
}
function detectedGenres(station) {
return MUSIC_GENRES.filter(([genre]) => hasTag(station, [genre])).map(
([genre]) => genre,
);
}
/** Return whether a station belongs in a station-tag category. */
function stationMatchesRadioCategory(station, categoryId) {
if (categoryId === 'all') return true;
if (categoryId.startsWith('genre:')) {
return detectedGenres(station).includes(
categoryId.slice('genre:'.length),
);
}
if (categoryId === 'music') {
return (
detectedGenres(station).length > 0 ||
hasTag(station, ['music', 'hits', 'songs'])
);
}
if (categoryId === 'other') {
return (
!Object.entries(CATEGORY_MATCHERS).some(([id]) =>
stationMatchesRadioCategory(station, id),
) && !stationMatchesRadioCategory(station, 'music')
);
}
return hasTag(station, CATEGORY_MATCHERS[categoryId] || []);
}
/** Build canonical and detected-genre categories from station-level tags. */
function buildRadioCategories(stations) {
const rows = Array.isArray(stations) ? stations : [];
const categories = [
{ id: 'all', label: 'All' },
{ id: 'news', label: 'News' },
{ id: 'talk', label: 'Talk' },
{ id: 'weather', label: 'Weather / Emergency' },
{ id: 'public-safety', label: 'Public Safety' },
{ id: 'aviation-marine', label: 'Aviation / Marine' },
{ id: 'traffic-transit', label: 'Traffic / Transit' },
{ id: 'music', label: 'Music' },
];
for (const [genre, label] of MUSIC_GENRES) {
const id = `genre:${genre}`;
if (rows.some((station) => stationMatchesRadioCategory(station, id))) {
categories.push({ id, label });
}
}
categories.push({ id: 'other', label: 'Other' });
return categories.map((category) => ({
...category,
color: parts.model.radioCategoryColor(category.id),
count: rows.filter((station) =>
stationMatchesRadioCategory(station, category.id),
).length,
}));
}
/** Filter stations without changing the active stream or selection. */
function filterRadioStations(stations, categoryId = 'all') {
return (Array.isArray(stations) ? stations : []).filter((station) =>
stationMatchesRadioCategory(station, categoryId),
);
}
/** Return whether Radio Browser metadata identifies a station as English-language. */
function isEnglishRadioStation(station) {
const languages = Array.isArray(station?.languages)
? station.languages
: [];
return languages.some((language) => {
const normalized = normalizeRadioTag(language);
return (
normalized === 'en' ||
normalized === 'eng' ||
normalized.startsWith('english')
);
});
}
return {
normalizeRadioTag,
stationTags,
hasTag,
detectedGenres,
stationMatchesRadioCategory,
buildRadioCategories,
filterRadioStations,
isEnglishRadioStation,
};
}
+266
View File
@@ -0,0 +1,266 @@
import {
GLOBAL_RADIO_ALTITUDE_M,
RADIO_SINGLETON_GLOBAL_LIMIT,
RADIO_SINGLETON_MID_LIMIT,
RADIO_SINGLETON_NEAR_LIMIT,
RADIO_OVERLAY_COHORT_LIMIT,
} from './policy.js';
export function createClustering({
state: layerState,
services,
parts,
source,
}) {
/** Return the bounded singleton-label allowance for the current camera scale. */
function radioSingletonLabelLimit(cameraHeightM) {
const height = Math.max(0, Number(cameraHeightM) || 0);
if (height >= GLOBAL_RADIO_ALTITUDE_M) return RADIO_SINGLETON_GLOBAL_LIMIT;
if (height >= 250_000) return RADIO_SINGLETON_MID_LIMIT;
return RADIO_SINGLETON_NEAR_LIMIT;
}
/** Rank visible singleton stations by camera distance and stable station id. */
function selectRadioSingletonCandidates(
candidates,
limit = RADIO_SINGLETON_NEAR_LIMIT,
) {
const distance = (candidate) => {
const value = Number(candidate?.distanceM);
return Number.isFinite(value) ? value : Number.POSITIVE_INFINITY;
};
return [...(candidates || [])]
.sort(
(a, b) =>
distance(a) - distance(b) ||
String(a?.station?.id || a?.id || '').localeCompare(
String(b?.station?.id || b?.id || ''),
),
)
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
}
/** Rank and cap ambient Radio clusters before shared-host entry allocation. */
function selectRadioClusterCandidates(
candidates,
limit = RADIO_OVERLAY_COHORT_LIMIT,
) {
return [...(candidates || [])]
.sort(
(a, b) =>
(Number(b?.stationCount) || 0) - (Number(a?.stationCount) || 0) ||
String(a?.id || '').localeCompare(String(b?.id || '')),
)
.slice(0, Math.max(0, Math.floor(Number(limit) || 0)));
}
/**
* Preserve one-to-one overlay identity for overlapping clusters.
* Cesium clustering is screen-space, so a small camera move may alter exact
* membership even when the same geographic cluster remains visible. Every
* positive overlap participates in greatest-contributor discovery, while
* inheritance remains mutual-best: a current cluster accepts only one of its
* greatest contributors and a prior identity transfers only to one of its
* strongest split children.
*/
function reconcileRadioClusterCandidates(
candidates,
previous = [],
createId = null,
) {
const current = (Array.isArray(candidates) ? candidates : []).map(
(candidate, index) => {
const stationIds = [
...new Set((candidate?.stationIds || []).map((id) => String(id))),
].sort();
const membershipId = String(
candidate?.id || `membership:${stationIds.join('|')}`,
);
return {
...candidate,
_reconcileIndex: index,
_reconcileMembershipId: membershipId,
_reconcileCanonicalKey: `${membershipId}\u0000${stationIds.join('\u0000')}`,
stationIds,
};
},
);
const priorMembershipsByIdentity = new Map();
for (const candidate of Array.isArray(previous) ? previous : []) {
const identityId = String(candidate?.identityId || candidate?.id || '');
if (!identityId) continue;
if (!priorMembershipsByIdentity.has(identityId)) {
priorMembershipsByIdentity.set(identityId, new Set());
}
const membership = priorMembershipsByIdentity.get(identityId);
for (const stationId of candidate?.stationIds || [])
membership.add(String(stationId));
}
const prior = [...priorMembershipsByIdentity]
.map(([identityId, stationIds]) => ({
identityId,
stationIds: [...stationIds].sort(),
}))
.filter((candidate) => candidate.stationIds.length)
.sort((a, b) => a.identityId.localeCompare(b.identityId));
const priorByStation = new Map();
for (let priorIndex = 0; priorIndex < prior.length; priorIndex += 1) {
for (const stationId of prior[priorIndex].stationIds) {
if (!priorByStation.has(stationId)) priorByStation.set(stationId, []);
priorByStation.get(stationId).push(priorIndex);
}
}
const edges = [];
const greatestOverlapByCurrent = new Map();
const greatestOverlapByPrior = new Map();
for (
let currentIndex = 0;
currentIndex < current.length;
currentIndex += 1
) {
const overlapByPrior = new Map();
for (const stationId of current[currentIndex].stationIds) {
for (const priorIndex of priorByStation.get(stationId) || []) {
overlapByPrior.set(
priorIndex,
(overlapByPrior.get(priorIndex) || 0) + 1,
);
}
}
for (const [priorIndex, overlap] of overlapByPrior) {
const union =
current[currentIndex].stationIds.length +
prior[priorIndex].stationIds.length -
overlap;
const smaller = Math.min(
current[currentIndex].stationIds.length,
prior[priorIndex].stationIds.length,
);
const score = smaller > 0 ? overlap / smaller : 0;
const similarity = union > 0 ? overlap / union : 0;
edges.push({ currentIndex, priorIndex, overlap, score, similarity });
greatestOverlapByCurrent.set(
currentIndex,
Math.max(greatestOverlapByCurrent.get(currentIndex) || 0, overlap),
);
greatestOverlapByPrior.set(
priorIndex,
Math.max(greatestOverlapByPrior.get(priorIndex) || 0, overlap),
);
}
}
const eligibleEdges = edges.filter(
(edge) =>
edge.overlap === greatestOverlapByCurrent.get(edge.currentIndex) &&
edge.overlap === greatestOverlapByPrior.get(edge.priorIndex),
);
eligibleEdges.sort(
(a, b) =>
// Identity follows the contributor representing the largest share of the
// new cluster. Overlap-coefficient-first incorrectly lets a fully retained
// two-station minority beat a larger partial contributor during a merge.
b.overlap - a.overlap ||
b.score - a.score ||
b.similarity - a.similarity ||
current[a.currentIndex]._reconcileCanonicalKey.localeCompare(
current[b.currentIndex]._reconcileCanonicalKey,
) ||
prior[a.priorIndex].identityId.localeCompare(
prior[b.priorIndex].identityId,
),
);
const inheritedByCurrent = new Map();
const usedPriorIdentities = new Set();
for (const edge of eligibleEdges) {
const identityId = prior[edge.priorIndex].identityId;
if (
inheritedByCurrent.has(edge.currentIndex) ||
usedPriorIdentities.has(identityId)
)
continue;
inheritedByCurrent.set(
edge.currentIndex,
prior[edge.priorIndex].identityId,
);
usedPriorIdentities.add(identityId);
}
const generatedByCurrent = new Map();
if (typeof createId === 'function') {
const freshIndices = current
.map((candidate, index) => ({ candidate, index }))
.filter(({ index }) => !inheritedByCurrent.has(index))
.sort((a, b) =>
a.candidate._reconcileCanonicalKey.localeCompare(
b.candidate._reconcileCanonicalKey,
),
);
for (const { candidate, index } of freshIndices) {
generatedByCurrent.set(index, String(createId(candidate, index) || ''));
}
}
return current.map((candidate, index) => {
const membershipId = candidate._reconcileMembershipId;
const inherited = inheritedByCurrent.get(index);
const generated = generatedByCurrent.get(index) || '';
const identityId = inherited || generated || membershipId;
const {
_reconcileIndex,
_reconcileMembershipId,
_reconcileCanonicalKey,
...rest
} = candidate;
return { ...rest, membershipId, identityId, id: identityId };
});
}
function resetRadioClusterOverlayIdentities() {
layerState._clusterOverlayIdentities = [];
}
function emptyRadioOverlayDiagnostics() {
return {
entryCount: 0,
selectedCount: 0,
singletonTexts: [],
singletonIds: [],
clusterTexts: [],
clusterIds: [],
clusterMemberships: [],
};
}
/** Retain refresh identities only while every represented station still exists. */
function retainRadioClusterIdentitiesForStations(previous, stations) {
const stationIds = new Set(
(stations || [])
.map((station) => String(station?.id || ''))
.filter(Boolean),
);
return (previous || []).filter(
(candidate) =>
Array.isArray(candidate?.stationIds) &&
candidate.stationIds.length > 0 &&
candidate.stationIds.every((stationId) =>
stationIds.has(String(stationId)),
),
);
}
return {
radioSingletonLabelLimit,
selectRadioSingletonCandidates,
selectRadioClusterCandidates,
reconcileRadioClusterCandidates,
resetRadioClusterOverlayIdentities,
emptyRadioOverlayDiagnostics,
retainRadioClusterIdentitiesForStations,
};
}
+119
View File
@@ -0,0 +1,119 @@
export function createControls({ state: layerState, services, parts, source }) {
const methods = {
id: 'radio',
name: 'Radio',
icon: '◉',
source: 'Radio Browser',
updateInterval: 45 * 60 * 1000,
/** Apply the manager-owned lifecycle gate to visible and pickable Radio state. */
setLifecyclePresentation({
lifecycleState = null,
enabled = false,
uncertain = false,
} = {}) {
const settledState = enabled ? 'enabled' : 'disabled';
const normalizedState = [
'enabling',
'enabled',
'disabling',
'disabled',
].includes(lifecycleState)
? lifecycleState
: settledState;
layerState._managerLifecyclePresentation = {
lifecycleState: normalizedState,
enabled: Boolean(enabled),
uncertain: Boolean(uncertain),
};
parts.interaction.syncRadioLifecyclePresentation();
parts.presentation.emitState();
},
/** Layer statistics for HUD/debug surfaces. */
getStats() {
return {
count: layerState._stations.length,
filtered: parts.queries.visibleStations().length,
selected: layerState._selectedId,
playing: layerState._audioStationId,
stale: layerState._stale,
degraded: layerState._degraded,
loading: layerState._loading,
error: layerState._error,
lastUpdate: layerState._updatedAt
? Date.parse(layerState._updatedAt)
: null,
horizonScans: layerState._horizonScanCount,
overlayEntries: layerState._overlayDiagnostics.entryCount,
};
},
subscribe: parts.presentation.subscribeToRadio,
subscribePlaybackControls:
parts.presentation.subscribeToRadioPlaybackControls,
getAcceptedCatalogSnapshot:
parts.presentation.getRadioAcceptedCatalogSnapshot,
getUIState: parts.presentation.getRadioUIState,
getParams: parts.selection.getRadioParams,
setParams: parts.selection.setRadioParams,
getOverlayDiagnostics: () => ({
...layerState._overlayDiagnostics,
singletonTexts: [...layerState._overlayDiagnostics.singletonTexts],
singletonIds: [...layerState._overlayDiagnostics.singletonIds],
clusterTexts: [...layerState._overlayDiagnostics.clusterTexts],
clusterIds: [...layerState._overlayDiagnostics.clusterIds],
clusterMemberships: layerState._overlayDiagnostics.clusterMemberships.map(
(entry) => ({ ...entry }),
),
}),
getTunerStations: parts.tuning.getRadioTunerStations,
beginTuning: parts.tuning.beginRadioTuning,
setTuningStatic: parts.tuning.setRadioTuningStatic,
previewTuningStation: parts.tuning.previewRadioTuningStation,
commitTuningStation: parts.tuning.commitRadioTuningStation,
cancelTuning: parts.tuning.cancelRadioTuning,
endTuning: parts.tuning.endRadioTuning,
setFilter: parts.selection.setRadioFilter,
selectStation: parts.selection.selectRadioStation,
selectRequestedStation: parts.selection.selectRequestedRadioStation,
cycleStation: parts.selection.cycleRadioStation,
togglePlayback: parts.playback.toggleRadioPlayback,
play: parts.playback.playSelectedRadio,
playForVoice: parts.playback.playPreparedRadioForVoice,
pause: parts.playback.pauseRadioPlayback,
stopPlayback: parts.playback.stopRadioPlayback,
setVolume: parts.volume.setRadioVolume,
setVoiceDucked: parts.volume.setRadioVoiceDucking,
};
return { methods };
}
+142
View File
@@ -0,0 +1,142 @@
import { createCatalogModel } from './catalogModel.js';
import { createLabels } from './labels.js';
import { createClustering } from './clustering.js';
import { createVolume } from './volume.js';
import { createNavigation } from './navigation.js';
import { createCategories } from './categories.js';
import { createModel } from './model.js';
import { createQueries } from './queries.js';
import { createInteraction } from './interaction.js';
import { createTuning } from './tuning.js';
import { createTuningNoise } from './tuningNoise.js';
import { createRendering } from './rendering.js';
import { createPresentation } from './presentation.js';
import { createPlayback } from './playback.js';
import { createSelection } from './selection.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { createState } from './state.js';
/** Construct one layer with its own scene state and supplied application services. */
export function createRadioLayer({ services, source }) {
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
parts.catalogModel = createCatalogModel(context);
parts.labels = createLabels(context);
parts.clustering = createClustering(context);
parts.volume = createVolume(context);
parts.navigation = createNavigation(context);
parts.categories = createCategories(context);
parts.model = createModel(context);
parts.queries = createQueries(context);
parts.interaction = createInteraction(context);
parts.tuning = createTuning(context);
parts.tuningNoise = createTuningNoise(context);
parts.rendering = createRendering(context);
parts.presentation = createPresentation(context);
parts.playback = createPlayback(context);
parts.selection = createSelection(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
parts.ingestion = createIngestion(context);
return Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion?.methods,
{
radioGlobeLabel: parts.labels.radioGlobeLabel,
createRadioSelectedOverlayEntry:
parts.labels.createRadioSelectedOverlayEntry,
createRadioClusterOverlayEntry:
parts.labels.createRadioClusterOverlayEntry,
createRadioSingletonOverlayEntry:
parts.labels.createRadioSingletonOverlayEntry,
radioSingletonLabelLimit: parts.clustering.radioSingletonLabelLimit,
selectRadioSingletonCandidates:
parts.clustering.selectRadioSingletonCandidates,
selectRadioClusterCandidates:
parts.clustering.selectRadioClusterCandidates,
reconcileRadioClusterCandidates:
parts.clustering.reconcileRadioClusterCandidates,
radioCameraPositionChanged: parts.navigation.radioCameraPositionChanged,
normalizeRadioTag: parts.categories.normalizeRadioTag,
stationMatchesRadioCategory: parts.categories.stationMatchesRadioCategory,
radioCategoryColor: parts.model.radioCategoryColor,
radioClusterBadgeText: parts.model.radioClusterBadgeText,
radioClusterCategoryId: parts.model.radioClusterCategoryId,
radioSelectionBracketSvg: parts.labels.radioSelectionBracketSvg,
radioStationCategoryId: parts.model.radioStationCategoryId,
buildRadioCategories: parts.categories.buildRadioCategories,
filterRadioStations: parts.categories.filterRadioStations,
isEnglishRadioStation: parts.categories.isEnglishRadioStation,
rankRadioStationsForViewport: parts.queries.rankRadioStationsForViewport,
rankRadioStationsForRequest: parts.queries.rankRadioStationsForRequest,
radioViewIsGlobal: parts.navigation.radioViewIsGlobal,
radioRequestIsCurrent: parts.model.radioRequestIsCurrent,
radioStationIdFromPick: parts.interaction.radioStationIdFromPick,
radioStationCameraPlan: parts.navigation.radioStationCameraPlan,
radioGlobeNeedsRecentering: parts.navigation.radioGlobeNeedsRecentering,
radioGlobeRecenterHeight: parts.navigation.radioGlobeRecenterHeight,
buildRadioTunerBand: parts.tuning.buildRadioTunerBand,
radioTuningStaticShouldPlay:
parts.tuningNoise.radioTuningStaticShouldPlay,
getRadioAcceptedCatalogSnapshot:
parts.presentation.getRadioAcceptedCatalogSnapshot,
radioStationResolutionMatches: parts.model.radioStationResolutionMatches,
getRadioUIState: parts.presentation.getRadioUIState,
subscribeToRadio: parts.presentation.subscribeToRadio,
subscribeToRadioPlaybackControls:
parts.presentation.subscribeToRadioPlaybackControls,
getRadioTunerStations: parts.tuning.getRadioTunerStations,
beginRadioTuning: parts.tuning.beginRadioTuning,
setRadioTuningStatic: parts.tuning.setRadioTuningStatic,
radioCameraNavigationAllowed:
parts.navigation.radioCameraNavigationAllowed,
previewRadioTuningStation: parts.tuning.previewRadioTuningStation,
endRadioTuning: parts.tuning.endRadioTuning,
cancelRadioTuning: parts.tuning.cancelRadioTuning,
commitRadioTuningStation: parts.tuning.commitRadioTuningStation,
playSelectedRadio: parts.playback.playSelectedRadio,
confirmRadioPlayback: parts.playback.confirmRadioPlayback,
playPreparedRadioForVoice: parts.playback.playPreparedRadioForVoice,
stopRadioPlayback: parts.playback.stopRadioPlayback,
toggleRadioPlayback: parts.playback.toggleRadioPlayback,
pauseRadioPlayback: parts.playback.pauseRadioPlayback,
setRadioVolume: parts.volume.setRadioVolume,
setRadioParams: parts.selection.setRadioParams,
getRadioParams: parts.selection.getRadioParams,
setRadioVoiceDucking: parts.volume.setRadioVoiceDucking,
selectRadioStation: parts.selection.selectRadioStation,
cycleRadioStation: parts.selection.cycleRadioStation,
selectRequestedRadioStation: parts.selection.selectRequestedRadioStation,
setRadioFilter: parts.selection.setRadioFilter,
retainRadioClusterIdentitiesForStations:
parts.clustering.retainRadioClusterIdentitiesForStations,
},
);
}
export {
RADIO_OVERLAY_SOURCE_ID,
RADIO_OVERLAY_COHORT_LIMIT,
RADIO_SINGLETON_GLOBAL_LIMIT,
RADIO_SINGLETON_MID_LIMIT,
RADIO_SINGLETON_NEAR_LIMIT,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
RADIO_OVERLAY_SOURCE_OPTIONS,
DEFAULT_RADIO_FILTER,
GLOBAL_RADIO_ALTITUDE_M,
} from './policy.js';
export { createRadioSource } from './source.js';
export {
radioTunerSlot,
radioTunerCommitSlot,
radioTunerPointerPosition,
buildRadioTunerTicks,
} from '../../ui/radioTunerModel.js';
export { normalizeRadioCountryInput } from '../../data/radioCountry.js';
+174
View File
@@ -0,0 +1,174 @@
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
} from './policy.js';
export function createIngestion({
state: layerState,
services,
parts,
source,
}) {
const methods = {
/** Refresh directory metadata through the hardened same-origin broker. */
async update() {
if (!layerState._enabled) return;
const generation = ++layerState._requestGeneration;
const sessionGeneration = layerState._sessionGeneration;
layerState._abortController?.abort();
layerState._abortController = new AbortController();
layerState._loading = true;
layerState._error = null;
parts.presentation.emitState();
try {
const body = await source.getDirectory({
signal: layerState._abortController.signal,
});
if (
!parts.model.radioRequestIsCurrent(
generation,
layerState._requestGeneration,
layerState._enabled,
sessionGeneration,
layerState._sessionGeneration,
)
)
return;
if (!Array.isArray(body?.stations))
throw new Error('Radio directory response was malformed');
const updatedAt =
typeof body.updatedAt === 'string' &&
Number.isFinite(Date.parse(body.updatedAt))
? body.updatedAt
: null;
const updatedAtMs = updatedAt ? Date.parse(updatedAt) : NaN;
if (
!updatedAt ||
updatedAtMs < Date.now() - RADIO_DIRECTORY_STALE_MS ||
updatedAtMs > Date.now() + RADIO_DIRECTORY_FUTURE_SKEW_MS ||
typeof body.stale !== 'boolean' ||
typeof body.degraded !== 'boolean'
)
throw new Error('Radio directory freshness metadata was malformed');
const rows = body.stations;
const acceptedRows = rows.filter(
parts.catalogModel.isValidRadioDirectoryStation,
);
if (!acceptedRows.length)
throw new Error('Radio directory returned no usable stations');
if (acceptedRows.length !== rows.length) {
throw new Error(
'Radio directory response contained malformed stations',
);
}
const acceptedGeneration = body.acceptedGeneration;
if (
acceptedGeneration !== null &&
(!Number.isSafeInteger(acceptedGeneration) || acceptedGeneration < 1)
)
throw new Error('Radio directory generation metadata was malformed');
if (!body.stale && !body.degraded && acceptedGeneration === null) {
throw new Error('Radio directory omitted its accepted generation');
}
const catalogInstance = body.catalogInstance;
if (
!body.stale &&
!body.degraded &&
(typeof catalogInstance !== 'string' || !catalogInstance)
) {
throw new Error('Radio directory omitted its catalog instance');
}
const preservingWarmCatalog =
layerState._stations.length > 0 && (body.stale || body.degraded);
// Generations are only comparable within one producer instance. A new
// instance token (server restart, different proxy process) starts a fresh
// sequence: never a repeat and never a regression.
const sameCatalogInstance =
layerState._acceptedCatalogSnapshot?.instance === catalogInstance;
const currentAcceptedGeneration = sameCatalogInstance
? layerState._acceptedCatalogSnapshot?.generation
: null;
if (
!body.stale &&
!body.degraded &&
Number.isSafeInteger(currentAcceptedGeneration) &&
acceptedGeneration < currentAcceptedGeneration
)
throw new Error('Radio directory generation regressed');
const repeatingAcceptedGeneration =
!body.stale &&
!body.degraded &&
Number.isSafeInteger(currentAcceptedGeneration) &&
acceptedGeneration === currentAcceptedGeneration;
if (!preservingWarmCatalog) {
const immutableRows = acceptedRows.map(
parts.catalogModel.freezeRadioStation,
);
if (repeatingAcceptedGeneration) {
layerState._updatedAt =
layerState._acceptedCatalogSnapshot.updatedAt;
if (
!layerState._tuningActive &&
layerState._cancelledTuningPresentationStation
) {
layerState._cancelledTuningPresentationStation = null;
parts.rendering.updateSelectionEntity();
}
} else if (!body.stale && !body.degraded) {
const acceptedSnapshot =
parts.catalogModel.createAcceptedCatalogSnapshot(
catalogInstance,
acceptedGeneration,
updatedAt,
immutableRows,
);
if (!acceptedSnapshot)
throw new Error(
'Radio directory generation metadata was malformed',
);
layerState._acceptedCatalogSnapshot = acceptedSnapshot;
parts.rendering.reconcileStations(acceptedSnapshot.stations);
} else {
parts.rendering.reconcileStations(immutableRows);
}
if (!repeatingAcceptedGeneration) layerState._updatedAt = updatedAt;
}
layerState._degraded = body.degraded;
layerState._stale = body.stale;
layerState._error = preservingWarmCatalog
? 'Directory refresh degraded; showing the previous station catalog.'
: layerState._degraded
? 'Radio directory coverage is degraded.'
: null;
} catch (error) {
if (
error?.name === 'AbortError' ||
!parts.model.radioRequestIsCurrent(
generation,
layerState._requestGeneration,
layerState._enabled,
sessionGeneration,
layerState._sessionGeneration,
)
)
return;
layerState._error = layerState._stations.length
? 'Directory refresh failed; showing the previous station catalog.'
: 'Radio directory is temporarily unavailable.';
layerState._stale = layerState._stations.length > 0;
layerState._degraded = layerState._stations.length > 0;
} finally {
if (
generation === layerState._requestGeneration &&
sessionGeneration === layerState._sessionGeneration
) {
layerState._loading = false;
layerState._abortController = null;
parts.presentation.emitState();
}
}
},
};
return { methods };
}
+159
View File
@@ -0,0 +1,159 @@
import * as Cesium from 'cesium';
import {
RADIO_PREFIX,
RADIO_PICK_OFFSETS,
RADIO_OVERLAY_SOURCE_ID,
HORIZON_TICK_MS,
} from './policy.js';
export function createInteraction({
state: layerState,
services,
parts,
source,
}) {
const {
resolvePickId,
isOwnedByOtherLayer,
registerPickOwner,
unregisterPickOwner,
} = services.picking;
const { setOverlaySourceVisible } = services.overlays;
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
function radioStationIdFromPick(picked) {
const pending = [picked?.id, picked?.primitive?.id];
const seen = new Set();
while (pending.length) {
const value = pending.shift();
if (typeof value === 'string' || typeof value === 'number') {
const id = String(value);
if (id.startsWith(`${RADIO_PREFIX}selected:`))
return id.slice(`${RADIO_PREFIX}selected:`.length);
if (id.startsWith(RADIO_PREFIX)) return id.slice(RADIO_PREFIX.length);
continue;
}
if (!value || typeof value !== 'object' || seen.has(value)) continue;
seen.add(value);
if (Array.isArray(value)) pending.push(...value);
else {
pending.push(value.id);
if (value.primitive) pending.push(value.primitive.id);
}
}
return null;
}
function pickedRadioStationAt(position) {
const scene = layerState._viewer?.scene;
if (!scene || !position) return null;
const stationFromPick = (picked) => {
const stationId = radioStationIdFromPick(picked);
return stationId && layerState._stationById.has(stationId)
? stationId
: null;
};
const primaryPick = scene.pick(position);
const primaryStationId = stationFromPick(primaryPick);
if (primaryStationId) return primaryStationId;
const primaryId = resolvePickId(primaryPick);
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
if (typeof scene.drillPick === 'function') {
const drilled = scene.drillPick(position, 16) || [];
for (const picked of drilled) {
const stationId = stationFromPick(picked);
if (stationId) return stationId;
}
}
for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) {
const offsetPosition = new Cesium.Cartesian2(
position.x + offsetX,
position.y + offsetY,
);
const picked = scene.pick(offsetPosition);
const pickedId = resolvePickId(picked);
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
const stationId = stationFromPick(picked);
if (stationId) return stationId;
}
return null;
}
function radioPresentationAllowed() {
if (!layerState._managerLifecyclePresentation) return layerState._enabled;
return (
layerState._enabled &&
layerState._managerLifecyclePresentation.lifecycleState === 'enabled' &&
layerState._managerLifecyclePresentation.enabled &&
!layerState._managerLifecyclePresentation.uncertain
);
}
function syncRadioLifecyclePresentation() {
const visible = radioPresentationAllowed();
if (layerState._dataSource) layerState._dataSource.show = visible;
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
if (visible) {
installInteraction();
parts.rendering.updateSelectionEntity();
parts.rendering.updateRenderVisibility();
parts.rendering.scheduleRadioOverlayPublish();
} else {
removeInteraction();
if (layerState._selectedEntity && layerState._viewer)
layerState._viewer.entities.remove(layerState._selectedEntity);
layerState._selectedEntity = null;
}
}
function installInteraction() {
if (!layerState._viewer || layerState._clickHandler) return;
registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX));
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
layerState._viewer.scene.canvas,
);
layerState._clickHandler.setInputAction((click) => {
if (!radioPresentationAllowed()) return;
const stationId = pickedRadioStationAt(click.position);
if (!stationId) return;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
parts.selection.selectRadioStation(stationId, {
autoplay: true,
origin: 'user',
});
if (typeof document !== 'undefined') {
document.dispatchEvent(
new CustomEvent('gev:radio-selected', { detail: { stationId } }),
);
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
// Polling remains bounded during flights, but a stationary camera no longer
// rewrites every station's visibility four times per second while voice and
// audio processing share the main thread.
layerState._horizonTimer = setInterval(
() => parts.rendering.updateRenderVisibility({ force: false }),
HORIZON_TICK_MS,
);
}
function removeInteraction() {
unregisterPickOwner('radio');
layerState._clickHandler?.destroy();
layerState._clickHandler = null;
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
layerState._horizonTimer = null;
}
return {
radioStationIdFromPick,
pickedRadioStationAt,
radioPresentationAllowed,
syncRadioLifecyclePresentation,
installInteraction,
removeInteraction,
};
}
+215
View File
@@ -0,0 +1,215 @@
import {
RADIO_LABEL_SEGMENTER,
RADIO_GLOBE_LABEL_MAX_CHARS,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_CATEGORY_COLORS,
} from './policy.js';
export function createLabels({ state: layerState, services, parts, source }) {
function compactRadioLabelText(value, maxChars) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
const graphemes = RADIO_LABEL_SEGMENTER
? [...RADIO_LABEL_SEGMENTER.segment(text)].map(
(segment) => segment.segment,
)
: Array.from(text);
if (graphemes.length <= maxChars) return text;
return `${graphemes
.slice(0, Math.max(1, maxChars - 1))
.join('')
.trimEnd()}…`;
}
function cleanRadioLabelName(value) {
return String(value || '')
.replace(/^[\s|:·\-–—]+|[\s|:·\-–—]+$/g, '')
.replace(/\s+/g, ' ')
.replace(/\s+\([^()]{1,40}\)$/, '')
.replace(/\s+FM$/i, '')
.trim();
}
/** Return compact frequency-first text for a Radio globe label. */
function radioGlobeLabel(station) {
const fullName = String(station?.name || '')
.replace(/\s+/g, ' ')
.trim();
if (!fullName) return '';
let frequency = null;
let labelName = '';
const explicitMatches = [
...fullName.matchAll(/(\d{2,3}(?:\.\d{1,2})?)\s*FM\b/gi),
];
const explicit = explicitMatches.find((match) => {
const value = Number(match[1]);
return value >= 64 && value <= 108;
});
if (explicit) {
frequency = explicit[1];
const before = cleanRadioLabelName(fullName.slice(0, explicit.index));
const after = cleanRadioLabelName(
fullName.slice(explicit.index + explicit[0].length),
);
labelName = before || after;
} else {
// Infer only a leading decimal in the conventional FM band. Integers such
// as "80's" or "100 GREATEST" and domains such as "1.fm" stay names.
const leading = fullName.match(
/^(\d{2,3}\.\d{1,2})(?:\s+|\s*[-–—]\s*)(.+)$/,
);
const value = Number(leading?.[1]);
if (leading && value >= 87.5 && value <= 108) {
frequency = leading[1];
labelName = cleanRadioLabelName(leading[2].split(/\s+[-–—]\s+/)[0]);
}
}
if (!frequency)
return compactRadioLabelText(fullName, RADIO_GLOBE_LABEL_MAX_CHARS);
const prefix = `${frequency} FM`;
if (!labelName) return prefix;
const nameBudget = RADIO_GLOBE_LABEL_MAX_CHARS - prefix.length - 3;
return `${prefix} — ${compactRadioLabelText(labelName, nameBudget)}`;
}
/** Build the protected selected-station text published through WorldOverlay. */
function createRadioSelectedOverlayEntry(station, position) {
if (!station?.id || !station?.name || !position) return null;
return {
id: `selected:${station.id}`,
position,
variant: 'selected',
selected: true,
protected: true,
paintLane: 'selected',
collisionGroup: 'ambient-label',
priority: Number.MAX_SAFE_INTEGER,
title: radioGlobeLabel(station),
accent: parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
),
interactive: false,
anchorRadiusPx: 20,
minAnchorGapPx: 8,
verticalOnly: true,
placement: 'above',
gapPx: 8,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
};
}
/** Build one bounded ambient cluster badge for the shared overlay host. */
function createRadioClusterOverlayEntry({
id,
position,
text,
accent,
stationCount,
}) {
if (!id || !position || !text) return null;
return {
id: `cluster:${id}`,
position,
variant: 'label',
paintLane: 'ambient-label',
collisionGroup: 'ambient-label',
priority: Math.max(1, Number(stationCount) || 1),
title: text,
accent: accent || RADIO_CATEGORY_COLORS.other,
interactive: false,
anchorRadiusPx: Math.min(
13,
6 + Math.log2(Math.max(1, Number(stationCount) || 1)) * 0.8,
),
minAnchorGapPx: 4,
verticalOnly: true,
placement: 'above',
gapPx: 6,
// Cluster membership/counts update continuously while the camera moves.
// Paint them as hard-opacity incumbents so a replacement count never
// cross-fades with its predecessor or dims merely for leaving the keyhole.
// Collision, viewport rejection, and horizon culling remain host-owned.
stateless: true,
edgeFade: 'none',
horizonCull: true,
terrainOcclusion: false,
// The camera can sit above 24,000 km in the supported full-globe view, and
// horizon clusters are farther from it than the camera's surface altitude.
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
distanceScale: {
near: 100_000,
nearValue: 1.08,
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
farValue: 0.92,
},
};
}
/** Build one ambient station label while Cesium retains its point and picking. */
function createRadioSingletonOverlayEntry({
station,
position,
priority = 1,
}) {
if (!station?.id || !station?.name || !position) return null;
return {
id: `station:${station.id}`,
position,
variant: 'label',
paintLane: 'ambient-label',
collisionGroup: 'ambient-label',
priority: Math.max(1, Number(priority) || 1),
title: radioGlobeLabel(station),
accent: parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
),
interactive: false,
anchorRadiusPx: 9,
minAnchorGapPx: 4,
verticalOnly: true,
placement: 'above',
gapPx: 5,
// Camera/filter changes replace this bounded cohort. Prevent the prior
// cohort from fading over the current visible points.
stateless: true,
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
maxDistance: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
distanceScale: {
near: 100_000,
nearValue: 1,
far: RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
farValue: 0.86,
},
};
}
/** Build the code-native four-corner bracket used by the selected station. */
function radioSelectionBracketSvg(color = RADIO_CATEGORY_COLORS.other) {
const stroke = /^#[0-9a-f]{6}$/i.test(String(color))
? String(color)
: RADIO_CATEGORY_COLORS.other;
return `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40"><path d="M2 13V2H13 M27 2H38V13 M38 27V38H27 M13 38H2V27" fill="none" stroke="${stroke}" stroke-width="2" stroke-linecap="square"/></svg>`;
}
return {
compactRadioLabelText,
cleanRadioLabelName,
radioGlobeLabel,
createRadioSelectedOverlayEntry,
createRadioClusterOverlayEntry,
createRadioSingletonOverlayEntry,
radioSelectionBracketSvg,
};
}
+122
View File
@@ -0,0 +1,122 @@
import * as Cesium from 'cesium';
import {
RADIO_OVERLAY_SOURCE_ID,
DEFAULT_RADIO_VOLUME,
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
DEFAULT_RADIO_FILTER,
} from './policy.js';
export function createLifecycle({
state: layerState,
services,
parts,
source,
}) {
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
const methods = {
/** Initialize the Cesium data source and the single audio element. */
init(viewer) {
layerState._sessionGeneration += 1;
layerState._viewer = viewer;
parts.clustering.resetRadioClusterOverlayIdentities();
parts.playback.installAudio();
if (!layerState._dataSource) {
layerState._dataSource = new Cesium.CustomDataSource('Radio stations');
viewer.dataSources.add(layerState._dataSource);
parts.rendering.installClusterStyling();
}
layerState._dataSource.show = false;
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
},
/** Show stations. Enabling or preset restoration never starts audio. */
enable() {
layerState._enabled = true;
parts.interaction.syncRadioLifecyclePresentation();
parts.presentation.emitState();
},
/** Hide the layer and stop playback without forgetting the selected station. */
disable() {
layerState._sessionGeneration += 1;
layerState._enabled = false;
parts.navigation.invalidateRadioCameraNavigation();
layerState._requestGeneration += 1;
layerState._abortController?.abort();
layerState._abortController = null;
layerState._loading = false;
layerState._selectionGeneration += 1;
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
layerState._selectionTimer = null;
parts.interaction.removeInteraction();
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
layerState._tuningUnavailableStationId = null;
parts.playback.stopRadioPlayback({ origin: 'layer-disable' });
if (layerState._dataSource) layerState._dataSource.show = false;
if (layerState._selectedEntity && layerState._viewer)
layerState._viewer.entities.remove(layerState._selectedEntity);
layerState._selectedEntity = null;
if (layerState._overlayPublishTimer)
clearTimeout(layerState._overlayPublishTimer);
layerState._overlayPublishTimer = null;
parts.clustering.resetRadioClusterOverlayIdentities();
layerState._overlayDiagnostics =
parts.clustering.emptyRadioOverlayDiagnostics();
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
parts.presentation.emitState();
},
/** Release rendering, event, request, and playback resources. */
destroy() {
this.disable();
parts.volume.cancelRadioVolumeTransition();
layerState._voiceDucked = false;
layerState._voiceRestoring = false;
parts.tuning.endRadioTuning();
const closedTuningNoise = layerState._tuningNoiseContext?.close?.();
if (closedTuningNoise?.catch) void closedTuningNoise.catch(() => {});
layerState._tuningNoiseContext = null;
if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME;
layerState._audio = null;
layerState._userVolume = DEFAULT_RADIO_VOLUME;
layerState._removeClusterListener?.();
layerState._removeClusterListener = null;
if (layerState._dataSource && layerState._viewer)
layerState._viewer.dataSources.remove(layerState._dataSource, true);
layerState._dataSource = null;
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
layerState._stations = [];
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._stationById.clear();
layerState._categories = Object.freeze([]);
layerState._filter = DEFAULT_RADIO_FILTER;
layerState._renderById.clear();
parts.clustering.resetRadioClusterOverlayIdentities();
layerState._lastHorizonCameraPosition = null;
layerState._horizonScanCount = 0;
layerState._selectedId = null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
layerState._tuningUnavailableStationId = null;
layerState._cancelledTuningPresentationStation = null;
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
layerState._viewer = null;
parts.presentation.emitState();
layerState._listeners.clear();
layerState._playbackControlListeners.clear();
},
};
return { methods };
}
+131
View File
@@ -0,0 +1,131 @@
import {
RADIO_CATEGORY_COLORS,
RADIO_CLUSTER_LABELS,
RADIO_MARKER_CATEGORY_ORDER,
} from './policy.js';
export function createModel({ state: layerState, services, parts, source }) {
/** Return the shared CSS color for a canonical or detected-genre category. */
function radioCategoryColor(categoryId = 'other') {
const normalized = String(categoryId || 'other');
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
return RADIO_CATEGORY_COLORS[canonical] || RADIO_CATEGORY_COLORS.other;
}
/** Format the concise count/category badge shown above a Radio cluster. */
function radioClusterBadgeText(categoryId = 'other', count = 0) {
const normalized = String(categoryId || 'other');
const canonical = normalized.startsWith('genre:') ? 'music' : normalized;
const label = RADIO_CLUSTER_LABELS[canonical] || RADIO_CLUSTER_LABELS.other;
const stationCount = Math.max(0, Math.floor(Number(count) || 0));
return `${stationCount} ${label}`;
}
/** Choose the category advertised by a cluster in the active station-tag view. */
function radioClusterCategoryId(stations, activeFilter = 'all') {
const filter = String(activeFilter || 'all');
if (filter !== 'all') {
if (
filter.startsWith('genre:') ||
RADIO_MARKER_CATEGORY_ORDER.includes(filter) ||
filter === 'other'
) {
return filter;
}
return 'other';
}
const categoryCounts = new Map();
for (const station of Array.isArray(stations) ? stations : []) {
const categoryId = radioStationCategoryId(station);
categoryCounts.set(categoryId, (categoryCounts.get(categoryId) || 0) + 1);
}
let clusterCategory = 'other';
let clusterCategoryCount = 0;
for (const categoryId of RADIO_MARKER_CATEGORY_ORDER) {
const count = categoryCounts.get(categoryId) || 0;
if (count > clusterCategoryCount) {
clusterCategory = categoryId;
clusterCategoryCount = count;
}
}
if ((categoryCounts.get('other') || 0) > clusterCategoryCount)
return 'other';
return clusterCategory;
}
/** Choose one stable display category for a station that may match several filters. */
function radioStationCategoryId(station) {
return (
RADIO_MARKER_CATEGORY_ORDER.find((categoryId) =>
parts.categories.stationMatchesRadioCategory(station, categoryId),
) || 'other'
);
}
/** Pure stale-response guard shared by the async directory update path. */
function radioRequestIsCurrent(
generation,
currentGeneration,
enabled,
sessionGeneration = null,
currentSessionGeneration = sessionGeneration,
) {
return (
generation === currentGeneration &&
Boolean(enabled) &&
sessionGeneration === currentSessionGeneration
);
}
/** Compare every station field that determines tuner presentation and playback. */
function radioStationResolutionMatches(frozenStation, currentStation) {
if (!frozenStation || !currentStation) return false;
for (const key of [
'id',
'name',
'lat',
'lon',
'streamUrl',
'homepage',
'state',
'country',
'countryCode',
'metadataTrust',
'codec',
'bitrate',
]) {
if (frozenStation[key] !== currentStation[key]) return false;
}
return ['tags', 'languages'].every((key) => {
const frozenValues = Array.isArray(frozenStation[key])
? frozenStation[key]
: [];
const currentValues = Array.isArray(currentStation[key])
? currentStation[key]
: [];
return (
frozenValues.length === currentValues.length &&
frozenValues.every((value, index) => value === currentValues[index])
);
});
}
function tuningResolutionStation(id) {
return id ? layerState._tuningStationById.get(String(id)) || null : null;
}
return {
radioCategoryColor,
radioClusterBadgeText,
radioClusterCategoryId,
radioStationCategoryId,
radioRequestIsCurrent,
radioStationResolutionMatches,
tuningResolutionStation,
};
}
+395
View File
@@ -0,0 +1,395 @@
import * as Cesium from 'cesium';
import {
HORIZON_CAMERA_MOVE_EPSILON_M,
GLOBAL_RADIO_ALTITUDE_M,
RADIO_GLOBE_RECENTER_MAX_HEIGHT_M,
} from './policy.js';
export function createNavigation({
state: layerState,
services,
parts,
source,
}) {
const {
isFullGlobeInsideKeyhole,
GLOBE_ENTER_CLEARANCE_PX,
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} = services.globe;
/** Return whether camera translation materially changes horizon visibility. */
function radioCameraPositionChanged(
previous,
current,
epsilonM = HORIZON_CAMERA_MOVE_EPSILON_M,
) {
if (!previous || !current) return true;
const dx = Number(current.x) - Number(previous.x);
const dy = Number(current.y) - Number(previous.y);
const dz = Number(current.z) - Number(previous.z);
if (![dx, dy, dz].every(Number.isFinite)) return true;
const threshold = Math.max(0, Number(epsilonM) || 0);
return dx * dx + dy * dy + dz * dz > threshold * threshold;
}
/** Classify a globe-scale Radio view without flapping on Cesium height round-off. */
function radioViewIsGlobal(altitudeM) {
return (
Number.isFinite(altitudeM) &&
Math.round(altitudeM) >= GLOBAL_RADIO_ALTITUDE_M
);
}
/** Plan a station-centered camera move that preserves altitude and view angle. */
function radioStationCameraPlan(station, cameraState = {}) {
const targetLat = Number(station?.lat);
const targetLon = Number(station?.lon);
const height = Math.max(1, Number(cameraState.height) || 1);
const heading = Number.isFinite(cameraState.heading)
? cameraState.heading
: 0;
const pitch = Number.isFinite(cameraState.pitch)
? cameraState.pitch
: -Math.PI / 2;
const roll = Number.isFinite(cameraState.roll) ? cameraState.roll : 0;
if (!Number.isFinite(targetLat) || !Number.isFinite(targetLon)) return null;
const downAngle = Math.min(
Math.PI / 2,
Math.max(0.08, Math.abs(Math.min(-0.001, pitch))),
);
const groundOffsetM =
downAngle > Math.PI / 2 - 1e-6
? 0
: Math.min(2_000_000, height / Math.max(0.08, Math.tan(downAngle)));
const angularDistance = groundOffsetM / 6_378_137;
const targetLatRad = (targetLat * Math.PI) / 180;
const cameraBearing = heading + Math.PI;
const cameraLatRad = Math.asin(
Math.sin(targetLatRad) * Math.cos(angularDistance) +
Math.cos(targetLatRad) *
Math.sin(angularDistance) *
Math.cos(cameraBearing),
);
const cameraLonRad =
(targetLon * Math.PI) / 180 +
Math.atan2(
Math.sin(cameraBearing) *
Math.sin(angularDistance) *
Math.cos(targetLatRad),
Math.cos(angularDistance) -
Math.sin(targetLatRad) * Math.sin(cameraLatRad),
);
const cameraLon = (((cameraLonRad * 180) / Math.PI + 540) % 360) - 180;
return {
lat: (cameraLatRad * 180) / Math.PI,
lon: cameraLon,
height,
heading,
pitch,
roll,
};
}
/**
* Decide whether Radio navigation should first restore a centered Earth view.
* Fit-capable clipped discs and closer views whose optical center has reached
* the Earth limb use the staged path; ordinary centered local views stay direct.
*/
function radioGlobeNeedsRecentering(geometry) {
if (!geometry || isFullGlobeInsideKeyhole(geometry, false)) return false;
const centeredGeometry = {
...geometry,
earthCenterX: geometry.keyholeCenterX,
earthCenterY: geometry.keyholeCenterY,
};
if (isFullGlobeInsideKeyhole(centeredGeometry, false)) return true;
const values = [
geometry.earthCenterX,
geometry.earthCenterY,
geometry.earthRadius,
geometry.keyholeCenterX,
geometry.keyholeCenterY,
];
if (!values.every(Number.isFinite) || geometry.earthRadius <= 0)
return false;
const centerOffset = Math.hypot(
geometry.earthCenterX - geometry.keyholeCenterX,
geometry.earthCenterY - geometry.keyholeCenterY,
);
return geometry.earthRadius - centerOffset < GLOBE_ENTER_CLEARANCE_PX;
}
/** Preserve closer zoom while capping an extreme full-globe recovery. */
function radioGlobeRecenterHeight(currentHeight, fullGlobeCapable) {
if (!Number.isFinite(currentHeight) || currentHeight < 0) return null;
return fullGlobeCapable
? Math.min(currentHeight, RADIO_GLOBE_RECENTER_MAX_HEIGHT_M)
: currentHeight;
}
function radioCameraState(camera = layerState._viewer?.camera) {
if (!camera) return null;
return {
height: camera.positionCartographic?.height,
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
};
}
/** Radio may move the globe only while no tracked entity owns the follow camera. */
function radioCameraNavigationAllowed(viewer = layerState._viewer) {
return Boolean(viewer?.camera) && !viewer.trackedEntity;
}
function radioGlobeRecenterPlan(viewer = layerState._viewer) {
const camera = viewer?.camera;
const canvas = viewer?.scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
const cartographic = camera?.positionCartographic;
if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null;
const geometry = projectEarthDiscToViewport(
viewer,
width,
height,
layerState._radioEarthScreenCenter,
layerState._radioEarthToCenter,
);
let fullGlobeCapable = false;
if (geometry) {
if (!radioGlobeNeedsRecentering(geometry)) return null;
fullGlobeCapable = isFullGlobeInsideKeyhole(
{
...geometry,
earthCenterX: geometry.keyholeCenterX,
earthCenterY: geometry.keyholeCenterY,
},
false,
);
} else {
const earthRadius = earthDiscScreenRadius(
Cesium.Cartesian3.magnitude(camera.positionWC),
height,
camera.frustum?.fovy,
);
const facingEarthCenter =
Cesium.Cartesian3.dot(
camera.directionWC,
layerState._radioEarthToCenter,
) > 0;
if (!earthRadius || facingEarthCenter) return null;
const keyhole = getKeyholeGeometry(width, height);
fullGlobeCapable =
earthRadius + GLOBE_ENTER_CLEARANCE_PX <= keyhole.radius;
}
const recenterHeight = radioGlobeRecenterHeight(
cartographic.height,
fullGlobeCapable,
);
if (recenterHeight == null) return null;
return {
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
cartographic.latitude,
recenterHeight,
),
cameraState: {
...radioCameraState(camera),
height: recenterHeight,
heading: 0,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
};
}
function radioCameraNavigationIsCurrent(navigation) {
return Boolean(
navigation &&
navigation.generation === layerState._radioCameraNavigationGeneration &&
layerState._enabled &&
radioCameraNavigationAllowed(layerState._viewer),
);
}
function cancelActiveRadioCameraFlight() {
if (
!layerState._activeRadioCameraFlight ||
!radioCameraNavigationAllowed(layerState._viewer)
)
return;
layerState._activeRadioCameraFlight = null;
layerState._viewer.camera.cancelFlight();
}
function invalidateRadioCameraNavigation() {
layerState._radioCameraNavigationGeneration += 1;
cancelActiveRadioCameraFlight();
layerState._activeRadioCameraFlight = null;
}
function radioCameraNavigationOwnsSelection(navigation, station) {
return Boolean(
navigation &&
station &&
navigation.generation === layerState._radioCameraNavigationGeneration &&
navigation.target?.id === station.id,
);
}
function startRadioCameraFlight(navigation, options, onComplete) {
if (!radioCameraNavigationIsCurrent(navigation)) return false;
const token = ++layerState._radioCameraFlightSequence;
layerState._activeRadioCameraFlight = {
generation: navigation.generation,
token,
};
const finish = (completed) => {
if (layerState._activeRadioCameraFlight?.token === token)
layerState._activeRadioCameraFlight = null;
if (completed && radioCameraNavigationIsCurrent(navigation))
onComplete?.();
};
layerState._viewer.camera.flyTo({
...options,
complete: () => finish(true),
cancel: () => finish(false),
});
return true;
}
function focusRadioNavigationTarget(navigation) {
if (!radioCameraNavigationIsCurrent(navigation) || !navigation.target)
return false;
const plan = radioStationCameraPlan(
navigation.target,
navigation.cameraState,
);
if (!plan) return false;
navigation.phase = 'focusing';
return startRadioCameraFlight(
navigation,
{
destination: Cesium.Cartesian3.fromDegrees(
plan.lon,
plan.lat,
plan.height,
),
orientation: {
heading: plan.heading,
pitch: plan.pitch,
roll: plan.roll,
},
duration: navigation.duration,
},
() => {
navigation.phase = 'settled';
},
);
}
function beginRadioCameraNavigation(cameraState = null) {
const generation = ++layerState._radioCameraNavigationGeneration;
if (
!radioCameraNavigationAllowed(layerState._viewer) ||
!layerState._enabled
)
return null;
cancelActiveRadioCameraFlight();
const recenterPlan = radioGlobeRecenterPlan(layerState._viewer);
return {
generation,
phase: 'idle',
recentered: false,
recenterPlan,
cameraState:
recenterPlan?.cameraState || cameraState || radioCameraState(),
target: null,
duration: 0.35,
};
}
function rotateRadioStationIntoView(
station,
duration = 0.35,
cameraState = null,
navigation = null,
) {
const activeNavigation =
navigation || beginRadioCameraNavigation(cameraState);
if (!station || !radioCameraNavigationIsCurrent(activeNavigation))
return false;
activeNavigation.target = station;
activeNavigation.duration = duration;
if (activeNavigation.phase === 'recentering') return true;
if (activeNavigation.phase === 'focusing') cancelActiveRadioCameraFlight();
if (activeNavigation.recenterPlan && !activeNavigation.recentered) {
activeNavigation.phase = 'recentering';
const recenterDuration = Math.min(0.9, Math.max(0.65, duration));
return startRadioCameraFlight(
activeNavigation,
{
destination: activeNavigation.recenterPlan.destination,
orientation: {
heading: activeNavigation.cameraState.heading,
pitch: activeNavigation.cameraState.pitch,
roll: activeNavigation.cameraState.roll,
},
duration: recenterDuration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
},
() => {
activeNavigation.recentered = true;
activeNavigation.phase = 'idle';
focusRadioNavigationTarget(activeNavigation);
},
);
}
return focusRadioNavigationTarget(activeNavigation);
}
function focusStation(station) {
layerState._radioCameraNavigationGeneration += 1;
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
return false;
cancelActiveRadioCameraFlight();
layerState._viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
station.lon,
station.lat,
85_000,
),
duration: 1.1,
});
return true;
}
return {
radioCameraPositionChanged,
radioViewIsGlobal,
radioStationCameraPlan,
radioGlobeNeedsRecentering,
radioGlobeRecenterHeight,
radioCameraState,
radioCameraNavigationAllowed,
radioGlobeRecenterPlan,
radioCameraNavigationIsCurrent,
cancelActiveRadioCameraFlight,
invalidateRadioCameraNavigation,
radioCameraNavigationOwnsSelection,
startRadioCameraFlight,
focusRadioNavigationTarget,
beginRadioCameraNavigation,
rotateRadioStationIntoView,
focusStation,
};
}
+382
View File
@@ -0,0 +1,382 @@
import { RADIO_VOICE_PLAYBACK_TIMEOUT_MS } from './policy.js';
export function createPlayback({ state: layerState, services, parts, source }) {
function audioEventBelongsToActiveAttempt(audio) {
const attempt = layerState._activePlaybackAttempt;
return (
layerState._audio === audio &&
Boolean(attempt) &&
attempt.generation === layerState._playGeneration &&
attempt.stationId === layerState._audioStationId
);
}
function installAudio({ replace = false } = {}) {
if (layerState._audio && !replace) return;
if (typeof Audio === 'undefined') return;
const previousAudio = layerState._audio;
layerState._audio = null;
if (previousAudio) {
try {
previousAudio.pause();
} catch {
/* already stopped */
}
try {
previousAudio.removeAttribute('src');
} catch {
/* no source */
}
try {
previousAudio.load();
} catch {
/* detached media */
}
}
const audio = new Audio();
layerState._audio = audio;
audio.preload = 'none';
audio.volume = layerState._voiceDucked ? 0 : layerState._userVolume;
audio.addEventListener('playing', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
layerState._audioState = 'playing';
layerState._audioError = null;
if (layerState._tuningAwaitingStationId === layerState._audioStationId)
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
parts.presentation.emitState();
});
audio.addEventListener('pause', () => {
if (layerState._audio !== audio) return;
if (
layerState._audioState === 'stopped' ||
layerState._audioState === 'loading'
)
return;
layerState._audioState = 'paused';
parts.presentation.emitState();
});
audio.addEventListener('waiting', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
layerState._audioState = 'buffering';
parts.presentation.emitState();
});
audio.addEventListener('error', () => {
if (layerState._audio !== audio) return;
if (!audioEventBelongsToActiveAttempt(audio)) return;
if (!['loading', 'buffering', 'playing'].includes(layerState._audioState))
return;
const failedId = layerState._audioStationId;
const attempt = layerState._activePlaybackAttempt;
if (tryRadioFallback(failedId, attempt?.origin, attempt?.id)) return;
layerState._audioState = 'error';
layerState._audioError =
'Broadcaster stream is unavailable or blocked by the browser.';
parts.presentation.emitState();
});
}
function tryRadioFallback(
failedId,
origin = layerState._playFallbackOrigin || 'programmatic',
attemptId = layerState._playFallbackAttemptId,
) {
const fallbackId = layerState._playFallbackId;
const fallbackStation = fallbackId
? layerState._stationById.get(fallbackId)
: null;
const fallbackFocusPolicy = layerState._playFallbackFocus;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
if (
!fallbackStation ||
fallbackId === failedId ||
layerState._selectedId !== failedId
) {
return false;
}
const fallbackFocusResult =
typeof fallbackFocusPolicy === 'function'
? fallbackFocusPolicy(fallbackStation)
: fallbackFocusPolicy;
const fallbackCameraNavigation =
fallbackFocusResult && typeof fallbackFocusResult === 'object'
? fallbackFocusResult
: null;
const fallbackFocus = fallbackCameraNavigation
? false
: Boolean(fallbackFocusResult);
parts.selection.selectRadioStation(fallbackId, {
autoplay: true,
focus: fallbackFocus,
origin: origin || 'programmatic',
attemptId,
cameraNavigation: fallbackCameraNavigation,
});
return true;
}
function recordDirectoryClick(id) {
Promise.resolve()
.then(() => source.recordClick(id))
.catch(() => {});
}
/** Play the selected broadcaster stream after an explicit user action. */
async function playSelectedRadio({
origin = 'programmatic',
attemptId = null,
} = {}) {
const station = parts.queries.selectedStation();
if (!parts.interaction.radioPresentationAllowed() || !station?.streamUrl)
return false;
if (layerState._tuningActive) parts.tuning.endRadioTuning();
// A media event carries no reliable attempt identity. Give every explicit
// play/resume/replacement its own element so queued events from the retired
// attempt remain bound to the discarded element and cannot mutate this one.
installAudio({ replace: true });
if (!layerState._audio) return false;
const generation = ++layerState._playGeneration;
const ownedAttemptId =
attemptId || `radio-play-${++layerState._playAttemptSequence}`;
layerState._activePlaybackAttempt = {
id: ownedAttemptId,
origin,
stationId: station.id,
streamUrl: station.streamUrl,
generation,
};
if (layerState._playFallbackId) {
layerState._playFallbackOrigin = origin;
layerState._playFallbackAttemptId = ownedAttemptId;
}
layerState._audioError = null;
layerState._audioState = 'loading';
if (
layerState._audioStationId !== station.id ||
layerState._audio.src !== station.streamUrl
) {
layerState._audio.pause();
layerState._audio.src = station.streamUrl;
layerState._audioStationId = station.id;
}
parts.presentation.emitState();
try {
const playback = layerState._audio.play();
if (playback?.then) await playback;
if (
generation !== layerState._playGeneration ||
layerState._audioStationId !== station.id ||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
)
return false;
layerState._audioState = 'playing';
if (layerState._tuningAwaitingStationId === station.id)
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
recordDirectoryClick(station.id);
parts.presentation.emitState();
if (origin === 'user')
parts.presentation.emitPlaybackControl('play', origin, ownedAttemptId);
return true;
} catch (error) {
if (
generation !== layerState._playGeneration ||
layerState._activePlaybackAttempt?.id !== ownedAttemptId
)
return false;
if (tryRadioFallback(station.id, origin, ownedAttemptId)) return false;
layerState._audioState = 'error';
layerState._audioError =
error?.name === 'NotAllowedError'
? 'Playback requires a direct click or tap.'
: 'Broadcaster stream could not be started.';
parts.presentation.emitState();
return false;
}
}
/**
* Wait for confirmed Radio playback while voice owns a hard mute.
* A fallback station may replace the first stream during this wait, so the
* state subscription—not the first play() promise—is authoritative.
*/
function confirmRadioPlayback({
startPlayback,
subscribe,
getState,
timeoutMs = RADIO_VOICE_PLAYBACK_TIMEOUT_MS,
} = {}) {
if (
typeof startPlayback !== 'function' ||
typeof subscribe !== 'function' ||
typeof getState !== 'function'
) {
return Promise.resolve(false);
}
return new Promise((resolve) => {
let settled = false;
let started = false;
let unsubscribe = () => {};
let timer = null;
const finish = (ok) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
unsubscribe();
resolve(Boolean(ok));
};
const inspect = (state) => {
if (!started || !state) return;
if (
!state.voiceDucked &&
['loading', 'buffering', 'playing'].includes(state.audioState)
) {
finish(false);
return;
}
if (
state.audioState === 'playing' &&
state.playingStationId &&
state.voiceDucked
) {
finish(true);
} else if (state.audioState === 'error') {
finish(false);
}
};
unsubscribe = subscribe(inspect);
started = true;
timer = setTimeout(
() => finish(false),
Math.max(1, Number(timeoutMs) || RADIO_VOICE_PLAYBACK_TIMEOUT_MS),
);
Promise.resolve()
.then(startPlayback)
.then((playStarted) => {
const state = getState();
inspect(state);
if (
!playStarted &&
!['loading', 'buffering', 'playing'].includes(state?.audioState)
)
finish(false);
})
.catch(() => finish(false));
});
}
/** Start and verify a prepared station without ever making it audible under voice. */
function playPreparedRadioForVoice(options = {}) {
if (!layerState._voiceDucked) return Promise.resolve(false);
return confirmRadioPlayback({
startPlayback: () =>
playSelectedRadio({ origin: 'voice', attemptId: options.attemptId }),
subscribe: parts.presentation.subscribeToRadio,
getState: parts.presentation.getRadioUIState,
timeoutMs: options.timeoutMs,
});
}
/** Stop the shared stream and release its network resource. */
function stopRadioPlayback({
origin = 'programmatic',
attemptId = null,
} = {}) {
if (attemptId && layerState._activePlaybackAttempt?.id !== attemptId)
return false;
const stoppedAttemptId = layerState._activePlaybackAttempt?.id || null;
parts.tuning.endRadioTuning();
layerState._playGeneration += 1;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
layerState._activePlaybackAttempt = null;
if (layerState._audio) {
layerState._audio.pause();
layerState._audio.removeAttribute('src');
layerState._audio.load();
}
layerState._audioStationId = null;
layerState._audioState = 'stopped';
layerState._audioError = null;
parts.presentation.emitState();
if (origin === 'user' || origin === 'voice') {
parts.presentation.emitPlaybackControl('stop', origin, stoppedAttemptId);
}
return true;
}
/** Pause or resume the selected stream. Resuming is still click initiated. */
function toggleRadioPlayback({ origin = 'programmatic' } = {}) {
if (['loading', 'playing', 'buffering'].includes(layerState._audioState)) {
return Promise.resolve(pauseRadioPlayback({ origin }));
}
if (!parts.interaction.radioPresentationAllowed())
return Promise.resolve(false);
if (!layerState._selectedId) {
const ranked = parts.queries.rankedVisibleStations();
if (!ranked.length) return Promise.resolve(false);
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
parts.selection.selectRadioStation(ranked[0].id, {
autoplay: false,
focus: false,
});
}
return playSelectedRadio({ origin });
}
/** Pause Radio without toggling a stopped or already-paused stream back on. */
function pauseRadioPlayback({ origin = 'programmatic' } = {}) {
if (!['loading', 'playing', 'buffering'].includes(layerState._audioState))
return false;
const pausedAttemptId = layerState._activePlaybackAttempt?.id || null;
if (layerState._tuningAwaitingStationId && !layerState._tuningActive)
parts.tuning.endRadioTuning();
layerState._playGeneration += 1;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
layerState._activePlaybackAttempt = null;
layerState._audio?.pause();
layerState._audioState = 'paused';
parts.presentation.emitState();
if (origin === 'user' || origin === 'voice') {
parts.presentation.emitPlaybackControl('pause', origin, pausedAttemptId);
}
return true;
}
return {
audioEventBelongsToActiveAttempt,
installAudio,
tryRadioFallback,
recordDirectoryClick,
playSelectedRadio,
confirmRadioPlayback,
playPreparedRadioForVoice,
stopRadioPlayback,
toggleRadioPlayback,
pauseRadioPlayback,
};
}
+170
View File
@@ -0,0 +1,170 @@
export const RADIO_PREFIX = 'radio:';
export const DIRECTORY_ENDPOINT = '/api/radio/stations';
export const HORIZON_TICK_MS = 250;
export const HORIZON_CAMERA_MOVE_EPSILON_M = 1;
export const MARKER_LIFT_M = 2.5;
export const SELECTED_LIFT_M = 5;
export const RADIO_PICK_TOLERANCE_PX = 8;
export const RADIO_TUNER_DIRECTORY_LIMIT = 750;
export const RADIO_TUNER_STATION_LIMIT = RADIO_TUNER_DIRECTORY_LIMIT;
export const RADIO_TUNER_STATIC_MAX_GAIN = 0.018;
export const RADIO_VOICE_PLAYBACK_TIMEOUT_MS = 12_000;
export const RADIO_DIRECTORY_STALE_MS = 7 * 24 * 60 * 60 * 1000;
export const RADIO_DIRECTORY_FUTURE_SKEW_MS = 5 * 60 * 1000;
export const RADIO_UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const RADIO_OVERLAY_SOURCE_ID = 'radio';
export const RADIO_OVERLAY_COHORT_LIMIT = 64;
export const RADIO_SINGLETON_GLOBAL_LIMIT = 16;
export const RADIO_SINGLETON_MID_LIMIT = 32;
export const RADIO_SINGLETON_NEAR_LIMIT = 48;
export const RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M = 50_000_000;
export const RADIO_GLOBE_RECENTER_MAX_HEIGHT_M = 13_000_000;
export const RADIO_OVERLAY_SOURCE_OPTIONS = Object.freeze({
cohortLimit: RADIO_OVERLAY_COHORT_LIMIT,
collisionCapacity: 96,
moving: false,
});
export const RADIO_PICK_OFFSETS = Object.freeze([
[0, -RADIO_PICK_TOLERANCE_PX],
[RADIO_PICK_TOLERANCE_PX, 0],
[0, RADIO_PICK_TOLERANCE_PX],
[-RADIO_PICK_TOLERANCE_PX, 0],
[6, -6],
[6, 6],
[-6, 6],
[-6, -6],
]);
export const DEFAULT_RADIO_FILTER = 'all';
export const GLOBAL_RADIO_ALTITUDE_M = 2_000_000;
export const MUSIC_GENRES = Object.freeze([
['alternative', 'Alternative'],
['ambient', 'Ambient'],
['blues', 'Blues'],
['classical', 'Classical'],
['country', 'Country'],
['dance', 'Dance'],
['electronic', 'Electronic'],
['folk', 'Folk'],
['funk', 'Funk'],
['hip hop', 'Hip-Hop'],
['house', 'House'],
['indie', 'Indie'],
['jazz', 'Jazz'],
['latin', 'Latin'],
['metal', 'Metal'],
['oldies', 'Oldies'],
['pop', 'Pop'],
['punk', 'Punk'],
['r&b', 'R&B'],
['reggae', 'Reggae'],
['rock', 'Rock'],
['soul', 'Soul'],
['techno', 'Techno'],
['trance', 'Trance'],
['world', 'World'],
]);
export const CATEGORY_MATCHERS = Object.freeze({
news: ['news', 'current affairs', 'journalism'],
talk: ['talk', 'spoken word', 'interview', 'podcast'],
weather: ['weather', 'emergency', 'noaa'],
'public-safety': [
'public safety',
'scanner',
'police',
'fire',
'ems',
'dispatch',
'emergency',
],
'aviation-marine': [
'aviation',
'air traffic',
'atc',
'airport',
'marine',
'maritime',
'coast guard',
],
'traffic-transit': ['traffic', 'transit', 'transport', 'rail', 'metro'],
});
export const RADIO_CATEGORY_COLORS = Object.freeze({
all: '#b9fbff',
news: '#44adff',
talk: '#f2b84b',
weather: '#ff5c78',
'public-safety': '#ff8b4a',
'aviation-marine': '#a87cff',
'traffic-transit': '#ffd166',
music: '#54d17a',
other: '#9aa7b3',
});
export const RADIO_CLUSTER_LABELS = Object.freeze({
news: 'NEWS',
talk: 'TALK',
weather: 'WEATHER',
'public-safety': 'SAFETY',
'aviation-marine': 'AIR / SEA',
'traffic-transit': 'TRANSIT',
music: 'MUSIC',
other: 'OTHER',
});
export const RADIO_MARKER_CATEGORY_ORDER = Object.freeze([
'news',
'public-safety',
'weather',
'aviation-marine',
'traffic-transit',
'talk',
'music',
]);
export const DEFAULT_RADIO_VOLUME = 0.8;
export const EMPTY_ACCEPTED_CATALOG_SNAPSHOT = Object.freeze({
instance: null,
generation: null,
updatedAt: null,
stations: Object.freeze([]),
stationIds: Object.freeze([]),
});
export const VOICE_RESTORE_DELAY_MS = 650;
export const VOICE_RESTORE_DURATION_MS = 1800;
export const RADIO_GLOBE_LABEL_MAX_CHARS = 30;
export const RADIO_LABEL_SEGMENTER =
typeof Intl?.Segmenter === 'function'
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: null;
+105
View File
@@ -0,0 +1,105 @@
export function createPresentation({
state: layerState,
services,
parts,
source,
}) {
/** Return the deeply immutable healthy catalog generation shared with tuner consumers. */
function getRadioAcceptedCatalogSnapshot() {
return layerState._acceptedCatalogSnapshot;
}
/** Return an immutable snapshot consumed by the right-rail UI. */
function getRadioUIState() {
const visible = parts.queries.visibleStations();
const selected = parts.queries.selectedStation();
return Object.freeze({
enabled: layerState._enabled,
loading: layerState._loading,
stale: layerState._stale,
degraded: layerState._degraded,
error: layerState._error,
updatedAt: layerState._updatedAt,
filter: layerState._filter,
categories: layerState._categories,
acceptedCatalogGeneration: layerState._acceptedCatalogSnapshot.generation,
presentationActive: parts.interaction.radioPresentationAllowed(),
stationCount: layerState._stations.length,
filteredCount: visible.length,
selected,
selectedIndex: selected
? visible.findIndex((station) => station.id === selected.id)
: -1,
audioState: layerState._audioState,
audioError: layerState._audioError,
playingStationId: layerState._audioStationId,
volume: layerState._userVolume,
effectiveVolume:
layerState._audio?.volume ??
(layerState._voiceDucked ? 0 : layerState._userVolume),
voiceDucked: layerState._voiceDucked,
voiceRestoring: layerState._voiceRestoring,
tuningActive: layerState._tuningActive,
tuningStatic: layerState._tuningStatic,
tuningAwaitingStationId: layerState._tuningAwaitingStationId,
tuningPreviewStationId: layerState._tuningPreviewId,
tuningRestoredStationId:
layerState._cancelledTuningPresentationStation?.id || null,
tuningCatalogGeneration: layerState._tuningResolutionSnapshot.generation,
tuningUnavailableStationId: layerState._tuningUnavailableStationId,
});
}
function emitState() {
const snapshot = getRadioUIState();
for (const listener of layerState._listeners) {
try {
listener(snapshot);
} catch {
// A broken consumer must not break playback or rendering.
}
}
}
/** Subscribe to radio state; the current state is delivered immediately. */
function subscribeToRadio(listener) {
if (typeof listener !== 'function') return () => {};
layerState._listeners.add(listener);
listener(getRadioUIState());
return () => layerState._listeners.delete(listener);
}
/** Subscribe to explicit playback controls so voice handoffs cannot undo them. */
function subscribeToRadioPlaybackControls(listener) {
if (typeof listener !== 'function') return () => {};
layerState._playbackControlListeners.add(listener);
return () => layerState._playbackControlListeners.delete(listener);
}
function emitPlaybackControl(
action,
origin,
attemptId = layerState._activePlaybackAttempt?.id || null,
) {
const event = { action, origin, attemptId };
for (const listener of layerState._playbackControlListeners) {
try {
listener(event);
} catch {
// Playback controls must remain usable if an observer fails.
}
}
}
return {
getRadioAcceptedCatalogSnapshot,
getRadioUIState,
emitState,
subscribeToRadio,
subscribeToRadioPlaybackControls,
emitPlaybackControl,
};
}
+152
View File
@@ -0,0 +1,152 @@
import * as Cesium from 'cesium';
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
export function createQueries({ state: layerState, services, parts, source }) {
function radioAngularDistance(station, anchor) {
const lat1 = Cesium.Math.toRadians(Number(anchor?.lat));
const lon1 = Cesium.Math.toRadians(Number(anchor?.lon));
const lat2 = Cesium.Math.toRadians(Number(station?.lat));
const lon2 = Cesium.Math.toRadians(Number(station?.lon));
if (![lat1, lon1, lat2, lon2].every(Number.isFinite))
return Number.POSITIVE_INFINITY;
const deltaLat = lat2 - lat1;
const deltaLon = lon2 - lon1;
const haversine =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * Math.asin(Math.min(1, Math.sqrt(Math.max(0, haversine))));
}
/** Rank a copied station list by viewport distance, with an optional English-first tier. */
function rankRadioStationsForViewport(
stations,
anchor,
{ preferEnglish = false } = {},
) {
return (Array.isArray(stations) ? stations : [])
.map((station, index) => ({
station,
index,
distance: radioAngularDistance(station, anchor),
languageTier:
preferEnglish && !parts.categories.isEnglishRadioStation(station)
? 1
: 0,
}))
.sort(
(a, b) =>
a.languageTier - b.languageTier ||
a.distance - b.distance ||
a.index - b.index,
)
.map(({ station }) => station);
}
/** Rank stations for an explicit voice/player request without moving the camera. */
function rankRadioStationsForRequest(
stations,
{ categoryId = 'all', anchor = null, country = '', stationQuery = '' } = {},
) {
const countryFilter = normalizeRadioCountryInput(country);
if (!countryFilter.valid) return [];
const query = parts.categories.normalizeRadioTag(stationQuery);
let matches = parts.categories.filterRadioStations(stations, categoryId);
if (countryFilter.code || countryFilter.name) {
matches = matches.filter((station) => {
const stationCode = String(station?.countryCode || '')
.trim()
.toUpperCase();
const stationCountry = normalizeRadioCountryInput(station?.country);
return (
(countryFilter.code && stationCode === countryFilter.code) ||
(countryFilter.code &&
stationCountry.valid &&
stationCountry.code === countryFilter.code)
);
});
}
if (query) {
matches = matches.filter((station) =>
[
station?.id,
station?.name,
station?.state,
station?.country,
station?.countryCode,
...(Array.isArray(station?.tags) ? station.tags : []),
].some((value) =>
parts.categories.normalizeRadioTag(value).includes(query),
),
);
}
return anchor
? rankRadioStationsForViewport(matches, anchor)
: matches.slice();
}
function selectedStation() {
return layerState._selectedId
? layerState._stationById.get(layerState._selectedId) || null
: null;
}
function selectedPresentationStation() {
if (layerState._tuningActive)
return parts.model.tuningResolutionStation(layerState._tuningPreviewId);
return layerState._cancelledTuningPresentationStation || selectedStation();
}
function visibleStations() {
return parts.categories.filterRadioStations(
layerState._stations,
layerState._filter,
);
}
function viewportRadioAnchor() {
const camera = layerState._viewer?.camera;
const scene = layerState._viewer?.scene;
if (!camera) return null;
const altitudeM = Number(camera.positionCartographic?.height);
let cartographic = null;
const canvas = scene?.canvas;
if (canvas && typeof camera.pickEllipsoid === 'function') {
const center = new Cesium.Cartesian2(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
);
const position = camera.pickEllipsoid(
center,
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
);
if (position) cartographic = Cesium.Cartographic.fromCartesian(position);
}
cartographic ||= camera.positionCartographic || null;
if (!cartographic) return null;
return {
lat: Cesium.Math.toDegrees(cartographic.latitude),
lon: Cesium.Math.toDegrees(cartographic.longitude),
altitudeM,
globalView: parts.navigation.radioViewIsGlobal(altitudeM),
};
}
function rankedVisibleStations() {
const anchor = viewportRadioAnchor();
return rankRadioStationsForViewport(visibleStations(), anchor, {
preferEnglish: Boolean(anchor?.globalView),
});
}
return {
radioAngularDistance,
rankRadioStationsForViewport,
rankRadioStationsForRequest,
selectedStation,
selectedPresentationStation,
visibleStations,
viewportRadioAnchor,
rankedVisibleStations,
};
}
+411
View File
@@ -0,0 +1,411 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import {
MARKER_LIFT_M,
RADIO_PREFIX,
SELECTED_LIFT_M,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
RADIO_OVERLAY_SOURCE_ID,
RADIO_OVERLAY_COHORT_LIMIT,
RADIO_OVERLAY_SOURCE_OPTIONS,
DEFAULT_RADIO_FILTER,
} from './policy.js';
export function createRendering({
state: layerState,
services,
parts,
source,
}) {
const { cachedGroundFloor } = services.ground;
const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } =
services.overlays;
const { governorRequestRender } = services.render;
function markerPosition(station, liftM = MARKER_LIFT_M) {
const floor = cachedGroundFloor(station.lat, station.lon);
return Cesium.Cartesian3.fromDegrees(
station.lon,
station.lat,
(Number.isFinite(floor) ? floor : 0) + liftM,
);
}
function updateSelectionEntity() {
if (layerState._selectedEntity && layerState._viewer)
layerState._viewer.entities.remove(layerState._selectedEntity);
layerState._selectedEntity = null;
const station = parts.queries.selectedPresentationStation();
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._viewer ||
!station
) {
scheduleRadioOverlayPublish();
return;
}
const selectionColor = parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
);
const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`;
layerState._selectedEntity = layerState._viewer.entities.add({
id: `${RADIO_PREFIX}selected:${station.id}`,
position: markerPosition(station, SELECTED_LIFT_M),
point: {
pixelSize: 14,
color: Cesium.Color.fromCssColorString(selectionColor),
outlineColor: Cesium.Color.BLACK,
outlineWidth: 2,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
},
billboard: {
image: bracketImage,
width: 40,
height: 40,
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
verticalOrigin: Cesium.VerticalOrigin.CENTER,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
},
});
scheduleRadioOverlayPublish();
}
function clusterPointCollection() {
return layerState._dataSource?.clustering?._clusterPointCollection || null;
}
function publishRadioOverlayEntries() {
layerState._overlayPublishTimer = null;
if (!parts.interaction.radioPresentationAllowed()) {
parts.clustering.resetRadioClusterOverlayIdentities();
layerState._overlayDiagnostics =
parts.clustering.emptyRadioOverlayDiagnostics();
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
return;
}
const entries = [];
const station = parts.queries.selectedPresentationStation();
if (station) {
const selectedEntry = parts.labels.createRadioSelectedOverlayEntry(
station,
markerPosition(station, SELECTED_LIFT_M),
);
if (selectedEntry) entries.push(selectedEntry);
}
const clusterCandidates = [];
const clusteredStationIds = new Set();
const points = clusterPointCollection();
for (let index = 0; index < (points?.length || 0); index += 1) {
const point = points.get(index);
if (
!point?.show ||
!point.position ||
!Array.isArray(point.id) ||
point.id.length < 3
)
continue;
const stationIds = point.id
.map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length))
.filter((id) => {
const station = layerState._stationById.get(id);
return (
station &&
parts.categories.stationMatchesRadioCategory(
station,
layerState._filter,
)
);
})
.sort();
if (stationIds.length < 3) continue;
for (const stationId of stationIds) clusteredStationIds.add(stationId);
const clusteredStations = stationIds.map((id) =>
layerState._stationById.get(id),
);
const category = parts.model.radioClusterCategoryId(
clusteredStations,
layerState._filter,
);
clusterCandidates.push({
id: `${stationIds[0]}:${stationIds.at(-1)}:${stationIds.length}`,
stationIds,
point,
text: parts.model.radioClusterBadgeText(category, stationIds.length),
accent: parts.model.radioCategoryColor(category),
stationCount: stationIds.length,
});
}
const selectedClusterCandidates =
parts.clustering.selectRadioClusterCandidates(clusterCandidates);
layerState._clusterOverlayIdentities =
parts.clustering.reconcileRadioClusterCandidates(
selectedClusterCandidates,
layerState._clusterOverlayIdentities,
() => `stable:${++layerState._clusterOverlayIdentitySequence}`,
);
for (const candidate of layerState._clusterOverlayIdentities) {
const clusterEntry = parts.labels.createRadioClusterOverlayEntry({
...candidate,
position: () => candidate.point.position,
});
if (clusterEntry) entries.push(clusterEntry);
}
const cameraPosition = layerState._viewer?.camera?.positionWC;
const singletonAllowance = Math.min(
parts.clustering.radioSingletonLabelLimit(
layerState._viewer?.camera?.positionCartographic?.height,
),
Math.max(
0,
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
),
);
const singletonCandidates = parts.clustering.selectRadioSingletonCandidates(
[...layerState._renderById.values()]
.filter(
(record) =>
record.entity?.show &&
record.station?.id !== station?.id &&
!clusteredStationIds.has(record.station?.id),
)
.map((record) => ({
...record,
distanceM: cameraPosition
? Cesium.Cartesian3.distance(cameraPosition, record.position)
: Number.POSITIVE_INFINITY,
})),
singletonAllowance,
);
for (let index = 0; index < singletonCandidates.length; index += 1) {
const candidate = singletonCandidates[index];
const singletonEntry = parts.labels.createRadioSingletonOverlayEntry({
station: candidate.station,
position: candidate.position,
// Cluster priorities begin at three. Keep clusters authoritative while
// retaining nearest-first singleton ordering inside the ambient lane.
priority: 2 - index / Math.max(1, singletonCandidates.length + 1),
});
if (singletonEntry) entries.push(singletonEntry);
}
setOverlayEntries(
RADIO_OVERLAY_SOURCE_ID,
entries,
RADIO_OVERLAY_SOURCE_OPTIONS,
);
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, true);
layerState._overlayDiagnostics = {
entryCount: entries.length,
selectedCount: entries.filter((entry) => entry.selected).length,
singletonTexts: entries
.filter((entry) => entry.id.startsWith('station:'))
.map((entry) => entry.title),
singletonIds: entries
.filter((entry) => entry.id.startsWith('station:'))
.map((entry) => entry.id),
clusterTexts: entries
.filter((entry) => entry.id.startsWith('cluster:'))
.map((entry) => entry.title),
clusterIds: entries
.filter((entry) => entry.id.startsWith('cluster:'))
.map((entry) => entry.id),
clusterMemberships: layerState._clusterOverlayIdentities.map(
(candidate) => ({
membershipId: candidate.membershipId,
entryId: `cluster:${candidate.id}`,
}),
),
};
}
function scheduleRadioOverlayPublish() {
if (layerState._overlayPublishTimer)
clearTimeout(layerState._overlayPublishTimer);
const sessionGeneration = layerState._sessionGeneration;
layerState._overlayPublishTimer = setTimeout(() => {
if (sessionGeneration === layerState._sessionGeneration)
publishRadioOverlayEntries();
}, 0);
}
function updateRenderVisibility({ force = true } = {}) {
if (!layerState._viewer || !layerState._dataSource) return;
const cameraPosition = layerState._viewer.camera?.positionWC;
if (
!force &&
!parts.navigation.radioCameraPositionChanged(
layerState._lastHorizonCameraPosition,
cameraPosition,
)
)
return;
layerState._lastHorizonCameraPosition = cameraPosition
? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
: null;
layerState._horizonScanCount += 1;
const occluder = horizonOccluder(layerState._viewer.camera);
let visibilityChanged = false;
for (const [id, record] of layerState._renderById) {
const matches = parts.categories.stationMatchesRadioCategory(
record.station,
layerState._filter,
);
const visible = matches && occluder.isPointVisible(record.position);
if (record.entity.show !== visible) visibilityChanged = true;
record.entity.show = visible;
}
if (layerState._selectedEntity) {
const position = layerState._selectedEntity.position?.getValue?.(
Cesium.JulianDate.now(),
);
const selectedVisible = !position || occluder.isPointVisible(position);
if (layerState._selectedEntity.show !== selectedVisible)
visibilityChanged = true;
layerState._selectedEntity.show = selectedVisible;
}
scheduleRadioOverlayPublish();
// The horizon timer can commit AFTER the camera settles and the governor
// parks the scene — a changed show flag needs one frame. (perf wave 2 fix)
if (visibilityChanged) governorRequestRender('radio-horizon');
}
function reconcileStations(stations) {
if (!layerState._tuningActive)
layerState._cancelledTuningPresentationStation = null;
layerState._clusterOverlayIdentities =
parts.clustering.retainRadioClusterIdentitiesForStations(
layerState._clusterOverlayIdentities,
stations,
);
layerState._stations = Object.freeze([...stations]);
layerState._stationById = new Map(
stations.map((station) => [station.id, station]),
);
layerState._categories = Object.freeze(
parts.categories
.buildRadioCategories(stations)
.map((category) => Object.freeze(category)),
);
if (
layerState._filter === DEFAULT_RADIO_FILTER &&
!parts.categories.filterRadioStations(stations, DEFAULT_RADIO_FILTER)
.length
) {
layerState._filter = 'all';
}
if (
layerState._selectedId &&
!layerState._stationById.has(layerState._selectedId)
) {
if (layerState._audioStationId === layerState._selectedId)
parts.playback.stopRadioPlayback();
layerState._selectedId = null;
}
if (layerState._dataSource) layerState._dataSource.entities.removeAll();
layerState._renderById.clear();
for (const station of stations) {
const position = markerPosition(station);
const markerColor = parts.model.radioCategoryColor(
parts.model.radioStationCategoryId(station),
);
const entity = layerState._dataSource.entities.add({
id: `${RADIO_PREFIX}${station.id}`,
position,
point: {
pixelSize: 13,
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(
100_000,
1.15,
12_000_000,
1,
),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
},
});
layerState._renderById.set(station.id, { station, entity, position });
}
updateSelectionEntity();
updateRenderVisibility();
}
function installClusterStyling() {
if (!layerState._dataSource || layerState._removeClusterListener) return;
const clustering = layerState._dataSource.clustering;
clustering.enabled = true;
clustering.pixelRange = 42;
clustering.minimumClusterSize = 3;
clustering.clusterPoints = true;
clustering.clusterLabels = false;
clustering.clusterBillboards = false;
layerState._removeClusterListener =
clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => {
const clusteredStations = [];
for (const entity of clusteredEntities) {
const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
const station = layerState._renderById.get(stationId)?.station;
if (station) clusteredStations.push(station);
}
const clusterCategory = parts.model.radioClusterCategoryId(
clusteredStations,
layerState._filter,
);
const clusterColor = Cesium.Color.fromCssColorString(
parts.model.radioCategoryColor(clusterCategory),
);
// Cesium assigns the entity-id array only to the generated cluster label.
// Mirror it to the visible point so clicking either part of the callout
// resolves the first station and remains a direct playback gesture.
cluster.point.id = clusteredEntities;
cluster.billboard.id = clusteredEntities;
cluster.label.show = false;
cluster.label.text = '';
cluster.point.show = true;
cluster.point.pixelSize = Math.min(
26,
12 + Math.log2(clusteredEntities.length) * 1.6,
);
cluster.point.color = clusterColor.withAlpha(0.9);
cluster.point.outlineColor = Cesium.Color.BLACK;
cluster.point.outlineWidth = 2;
cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY;
cluster.point.distanceDisplayCondition =
new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
);
scheduleRadioOverlayPublish();
});
}
return {
markerPosition,
updateSelectionEntity,
clusterPointCollection,
publishRadioOverlayEntries,
scheduleRadioOverlayPublish,
updateRenderVisibility,
reconcileStations,
installClusterStyling,
};
}
+269
View File
@@ -0,0 +1,269 @@
import { normalizeRadioFilter } from '../../data/layerState.js';
import { RADIO_TUNER_DIRECTORY_LIMIT } from './policy.js';
export function createSelection({
state: layerState,
services,
parts,
source,
}) {
const { warmGroundFloor } = services.ground;
/** Durable Radio preferences; this surface never creates or plays audio. */
function setRadioParams(params = {}) {
const nextFilter = Object.hasOwn(params, 'filter')
? normalizeRadioFilter(params.filter)
: null;
if (Object.hasOwn(params, 'filter') && !nextFilter) return false;
const numericVolume = Object.hasOwn(params, 'volume')
? Number(params.volume)
: null;
if (Object.hasOwn(params, 'volume') && !Number.isFinite(numericVolume))
return false;
const nextVolume =
numericVolume === null
? null
: parts.volume.clampRadioVolume(numericVolume);
let changed = false;
let filterChanged = false;
let clearedCancelledPresentation = false;
if (nextFilter !== null) {
filterChanged = nextFilter !== layerState._filter;
if (
filterChanged &&
(layerState._tuningActive || layerState._tuningAwaitingStationId)
)
parts.tuning.endRadioTuning();
clearedCancelledPresentation = Boolean(
layerState._cancelledTuningPresentationStation,
);
layerState._cancelledTuningPresentationStation = null;
layerState._filter = nextFilter;
if (clearedCancelledPresentation) parts.rendering.updateSelectionEntity();
if (filterChanged) parts.clustering.resetRadioClusterOverlayIdentities();
changed ||= filterChanged || clearedCancelledPresentation;
}
if (nextVolume !== null && nextVolume !== layerState._userVolume) {
layerState._userVolume = nextVolume;
if (layerState._audio && !layerState._voiceDucked) {
parts.volume.cancelRadioVolumeTransition();
layerState._voiceRestoring = false;
layerState._audio.volume = nextVolume;
}
parts.tuningNoise.syncTuningNoiseGain();
changed = true;
}
if (changed && parts.interaction.radioPresentationAllowed()) {
parts.rendering.updateRenderVisibility();
if (filterChanged && layerState._dataSource?.clustering?.enabled) {
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
layerState._dataSource.clustering.clusterPoints = clusterPoints;
layerState._viewer?.scene?.requestRender();
}
parts.rendering.scheduleRadioOverlayPublish();
}
parts.presentation.emitState();
return true;
}
function getRadioParams() {
return { filter: layerState._filter, volume: layerState._userVolume };
}
/** Select a station. Playback occurs only when autoplay is explicitly true. */
function selectRadioStation(
id,
{
autoplay = false,
focus = false,
origin = 'programmatic',
attemptId = null,
cameraNavigation = null,
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const station = layerState._stationById.get(String(id));
if (!station) return false;
layerState._tuningUnavailableStationId = null;
if (
!parts.navigation.radioCameraNavigationOwnsSelection(
cameraNavigation,
station,
)
) {
parts.navigation.invalidateRadioCameraNavigation();
}
if (
layerState._tuningActive ||
(layerState._tuningAwaitingStationId &&
layerState._tuningAwaitingStationId !== station.id)
)
parts.tuning.endRadioTuning();
layerState._cancelledTuningPresentationStation = null;
layerState._selectedId = station.id;
const generation = ++layerState._selectionGeneration;
const sessionGeneration = layerState._sessionGeneration;
parts.rendering.updateSelectionEntity();
warmGroundFloor([{ lat: station.lat, lon: station.lon }]);
if (layerState._selectionTimer) clearTimeout(layerState._selectionTimer);
layerState._selectionTimer = setTimeout(() => {
layerState._selectionTimer = null;
if (
sessionGeneration === layerState._sessionGeneration &&
generation === layerState._selectionGeneration &&
layerState._selectedId === station.id
)
parts.rendering.updateSelectionEntity();
}, 1300);
if (focus) parts.navigation.focusStation(station);
parts.presentation.emitState();
if (autoplay) void parts.playback.playSelectedRadio({ origin, attemptId });
return true;
}
/** Select the previous or next station and optionally retain a UI-owned band order. */
function cycleRadioStation(
direction = 1,
{
rotate = false,
stationIds = null,
autoplay = true,
origin = 'programmatic',
} = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const ranked =
Array.isArray(stationIds) && stationIds.length
? stationIds
.slice(0, RADIO_TUNER_DIRECTORY_LIMIT)
.map((id) => layerState._stationById.get(String(id)))
.filter(
(station) =>
station &&
parts.categories.stationMatchesRadioCategory(
station,
layerState._filter,
),
)
: parts.queries.rankedVisibleStations();
if (!ranked.length) return false;
const current = ranked.findIndex(
(station) => station.id === layerState._selectedId,
);
const nextIndex =
current < 0
? 0
: (current + (direction < 0 ? -1 : 1) + ranked.length) % ranked.length;
layerState._playFallbackId =
ranked.length > 1 ? ranked[(nextIndex + 1) % ranked.length].id : null;
const rotationCameraState = rotate
? parts.navigation.radioCameraState()
: null;
const rotationNavigation = rotate
? parts.navigation.beginRadioCameraNavigation(rotationCameraState)
: null;
layerState._playFallbackFocus = rotate
? (fallbackStation) => {
parts.navigation.rotateRadioStationIntoView(
fallbackStation,
0.65,
rotationCameraState,
rotationNavigation,
);
return rotationNavigation;
}
: false;
const station = ranked[nextIndex];
if (rotate)
parts.navigation.rotateRadioStationIntoView(
station,
0.65,
rotationCameraState,
rotationNavigation,
);
return selectRadioStation(station.id, {
autoplay,
focus: false,
origin,
cameraNavigation: rotationNavigation,
});
}
/** Select and optionally play the best station for a location/category request. */
function selectRequestedRadioStation(
criteria = {},
{ autoplay = true, origin = 'programmatic' } = {},
) {
if (!parts.interaction.radioPresentationAllowed()) return null;
const requestedCategory = String(criteria.categoryId || 'all');
const categoryId = layerState._categories.some(
(category) => category.id === requestedCategory,
)
? requestedCategory
: 'all';
setRadioFilter(categoryId);
const ranked = parts.queries.rankRadioStationsForRequest(
layerState._stations,
{
...criteria,
categoryId,
anchor: criteria.anchor || parts.queries.viewportRadioAnchor(),
},
);
if (!ranked.length) return null;
layerState._playFallbackId = ranked[1]?.id || null;
layerState._playFallbackFocus = false;
selectRadioStation(ranked[0].id, { autoplay, focus: false, origin });
return ranked[0];
}
/** Change marker/list category without interrupting an active station. */
function setRadioFilter(categoryId) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const valid = layerState._categories.some(
(category) => category.id === categoryId,
);
const nextFilter = valid ? categoryId : 'all';
const changed = nextFilter !== layerState._filter;
if (
changed &&
(layerState._tuningActive || layerState._tuningAwaitingStationId)
)
parts.tuning.endRadioTuning();
const clearsCancelledPresentation = Boolean(
layerState._cancelledTuningPresentationStation,
);
layerState._cancelledTuningPresentationStation = null;
layerState._filter = nextFilter;
if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity();
if (changed) parts.clustering.resetRadioClusterOverlayIdentities();
parts.rendering.updateRenderVisibility();
if (changed && layerState._dataSource?.clustering?.enabled) {
// Entity visibility changes do not invalidate Cesium's existing cluster
// primitives. Toggle a public clustering input twice to mark the current
// cluster set dirty without changing its effective configuration.
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
layerState._dataSource.clustering.clusterPoints = clusterPoints;
layerState._viewer?.scene?.requestRender();
}
parts.rendering.scheduleRadioOverlayPublish();
parts.presentation.emitState();
return true;
}
return {
setRadioParams,
getRadioParams,
selectRadioStation,
cycleRadioStation,
selectRequestedRadioStation,
setRadioFilter,
};
}
+33
View File
@@ -0,0 +1,33 @@
import { DIRECTORY_ENDPOINT, RADIO_UUID_RE } from './policy.js';
/** Supply directory metadata and click reporting; audio stays with the broadcaster. */
export function createRadioSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
return {
async getDirectory({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(DIRECTORY_ENDPOINT, { signal });
if (!response.ok)
throw new Error(`Radio directory returned ${response.status}`);
const body = await response.json();
signal?.throwIfAborted();
return body;
},
async recordClick(id, { signal } = {}) {
if (typeof id !== 'string' || !RADIO_UUID_RE.test(id))
throw new Error('Invalid radio station id');
signal?.throwIfAborted();
const response = await fetchImpl(
`/api/radio/click/${encodeURIComponent(id)}`,
{
method: 'POST',
signal,
},
);
signal?.throwIfAborted();
if (!response.ok)
throw new Error(`Radio click returned ${response.status}`);
},
};
}
+108
View File
@@ -0,0 +1,108 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRadioSource, createRadioLayer } from './index.js';
const id = '00000000-0000-4000-8000-000000000001';
test('radio source confines directory and click requests to their existing routes', async () => {
const calls = [];
const body = { stations: [], stale: false };
const source = createRadioSource({
fetchImpl: async (path, options) => {
calls.push({ path, options });
return new Response(JSON.stringify(body));
},
});
const controller = new AbortController();
assert.deepEqual(
await source.getDirectory({ signal: controller.signal }),
body,
);
await source.recordClick(id, { signal: controller.signal });
assert.deepEqual(
calls.map((call) => call.path),
['/api/radio/stations', `/api/radio/click/${id}`],
);
assert.equal(calls[1].options.method, 'POST');
assert.ok(calls.every((call) => call.options.signal === controller.signal));
for (const invalid of [
'../stations',
'https://example.com',
'',
null,
`${id}?x=1`,
])
await assert.rejects(
source.recordClick(invalid),
/Invalid radio station id/,
);
assert.equal(calls.length, 2);
});
test('radio source propagates denial and cancels completed body parsing', async () => {
const denied = createRadioSource({
fetchImpl: async () => new Response('', { status: 403 }),
});
await assert.rejects(denied.getDirectory(), /403/);
await assert.rejects(denied.recordClick(id), /403/);
const controller = new AbortController();
const source = createRadioSource({
fetchImpl: async () => ({
ok: true,
json: async () => {
controller.abort();
return { stations: [] };
},
}),
});
await assert.rejects(source.getDirectory({ signal: controller.signal }), {
name: 'AbortError',
});
let called = false;
const idle = createRadioSource({
fetchImpl: async () => {
called = true;
},
});
await assert.rejects(idle.recordClick(id, { signal: controller.signal }), {
name: 'AbortError',
});
assert.equal(called, false);
});
test('radio factories keep settings and subscriptions independent without fetching or audio startup', () => {
let requested = false;
const source = {
getDirectory() {
requested = true;
},
recordClick() {
requested = true;
},
};
const services = {
ground: {},
picking: { unregisterPickOwner() {} },
globe: {},
render: { governorRequestRender() {} },
overlays: {
clearOverlaySource() {},
setOverlaySourceVisible() {},
setOverlayEntries() {},
},
};
const first = createRadioLayer({ source, services });
const second = createRadioLayer({ source, services });
let changes = 0;
const off = second.subscribeToRadio(() => changes++);
const initialChanges = changes;
first.setRadioParams({ volume: 0.3 });
assert.equal(first.getRadioParams().volume, 0.3);
assert.equal(second.getRadioParams().volume, 0.8);
assert.equal(changes, initialChanges);
assert.equal(requested, false);
off();
first.destroy();
assert.equal(second.getRadioParams().volume, 0.8);
second.destroy();
});
+157
View File
@@ -0,0 +1,157 @@
import * as Cesium from 'cesium';
import {
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
DEFAULT_RADIO_FILTER,
DEFAULT_RADIO_VOLUME,
} from './policy.js';
export function createState({ services }) {
const layerState = {};
layerState._radioEarthScreenCenter = new Cesium.Cartesian2();
layerState._radioEarthToCenter = new Cesium.Cartesian3();
layerState._viewer = null;
layerState._dataSource = null;
layerState._enabled = false;
layerState._managerLifecyclePresentation = null;
layerState._loading = false;
layerState._stale = false;
layerState._degraded = false;
layerState._error = null;
layerState._updatedAt = null;
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._stations = [];
layerState._stationById = new Map();
layerState._categories = [];
layerState._renderById = new Map();
layerState._filter = DEFAULT_RADIO_FILTER;
layerState._selectedId = null;
layerState._selectedEntity = null;
layerState._selectionGeneration = 0;
layerState._selectionTimer = null;
layerState._radioCameraNavigationGeneration = 0;
layerState._radioCameraFlightSequence = 0;
layerState._activeRadioCameraFlight = null;
layerState._audio = null;
layerState._audioStationId = null;
layerState._audioState = 'stopped';
layerState._audioError = null;
layerState._userVolume = DEFAULT_RADIO_VOLUME;
layerState._tuningActive = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
layerState._tuningUnavailableStationId = null;
layerState._cancelledTuningPresentationStation = null;
layerState._tuningCameraNavigation = null;
layerState._tuningNoiseContext = null;
layerState._tuningNoiseSource = null;
layerState._tuningNoiseFilter = null;
layerState._tuningNoiseGain = null;
layerState._voiceDucked = false;
layerState._voiceRestoring = false;
layerState._voiceRestoreTimer = null;
layerState._volumeFadeFrame = null;
layerState._volumeTransitionGeneration = 0;
layerState._playGeneration = 0;
layerState._playAttemptSequence = 0;
layerState._activePlaybackAttempt = null;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
layerState._clickHandler = null;
layerState._horizonTimer = null;
layerState._lastHorizonCameraPosition = null;
layerState._horizonScanCount = 0;
layerState._abortController = null;
layerState._requestGeneration = 0;
layerState._sessionGeneration = 0;
layerState._removeClusterListener = null;
layerState._overlayPublishTimer = null;
layerState._clusterOverlayIdentitySequence = 0;
layerState._clusterOverlayIdentities = [];
layerState._overlayDiagnostics = {
entryCount: 0,
selectedCount: 0,
singletonTexts: [],
singletonIds: [],
clusterTexts: [],
clusterIds: [],
clusterMemberships: [],
};
layerState._listeners = new Set();
layerState._playbackControlListeners = new Set();
return layerState;
}
+233
View File
@@ -0,0 +1,233 @@
import {
RADIO_TUNER_STATION_LIMIT,
RADIO_TUNER_DIRECTORY_LIMIT,
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
} from './policy.js';
export function createTuning({ state: layerState, services, parts, source }) {
/** Bound one already ordered filtered directory without injecting outside selections. */
function buildRadioTunerBand(
rankedStations,
selected,
limit = RADIO_TUNER_STATION_LIMIT,
) {
const boundedLimit = Math.min(
RADIO_TUNER_DIRECTORY_LIMIT,
Math.max(1, Math.floor(Number(limit) || RADIO_TUNER_STATION_LIMIT)),
);
return (Array.isArray(rankedStations) ? rankedStations : []).slice(
0,
boundedLimit,
);
}
function captureRadioTuningResolutionSnapshot() {
return layerState._acceptedCatalogSnapshot;
}
/** Return the complete accepted filtered directory in stable catalog order. */
function getRadioTunerStations(limit = RADIO_TUNER_STATION_LIMIT) {
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._acceptedCatalogSnapshot.stations.length
)
return [];
return buildRadioTunerBand(
parts.queries.visibleStations(),
parts.queries.selectedStation(),
limit,
);
}
/** Begin a direct-manipulation tuning gesture and pause the current stream. */
function beginRadioTuning() {
if (
!parts.interaction.radioPresentationAllowed() ||
!parts.queries.visibleStations().length
)
return false;
const snapshot = captureRadioTuningResolutionSnapshot();
if (!Number.isSafeInteger(snapshot.generation) || !snapshot.stations.length)
return false;
layerState._tuningResolutionSnapshot = snapshot;
layerState._tuningStationById = new Map(
layerState._tuningResolutionSnapshot.stations.map((station) => [
station.id,
station,
]),
);
layerState._tuningUnavailableStationId = null;
layerState._tuningActive = true;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._cancelledTuningPresentationStation = null;
layerState._tuningStartStationId = layerState._selectedId;
layerState._tuningPreviewId = layerState._selectedId;
parts.tuningNoise.installTuningNoise();
parts.playback.pauseRadioPlayback({ origin: 'user' });
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return true;
}
/** Toggle low-volume synthesized static for tuner preview and stream handoff. */
function setRadioTuningStatic(active) {
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._tuningActive
)
return false;
const next = Boolean(active);
if (next === layerState._tuningStatic) return true;
layerState._tuningStatic = next;
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return true;
}
/** Preview a tuner station bracket and camera orientation without starting audio. */
function previewRadioTuningStation(id, { rotate = true } = {}) {
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._tuningActive
)
return false;
const station = parts.model.tuningResolutionStation(id);
const nextId = station?.id || null;
const changed = nextId !== layerState._tuningPreviewId;
layerState._tuningPreviewId = nextId;
layerState._tuningStatic = !station;
if (changed) {
parts.rendering.updateSelectionEntity();
if (station && rotate) {
const cameraState = parts.navigation.radioCameraState();
layerState._tuningCameraNavigation =
parts.navigation.beginRadioCameraNavigation(cameraState);
parts.navigation.rotateRadioStationIntoView(
station,
0.35,
cameraState,
layerState._tuningCameraNavigation,
);
} else if (rotate) {
layerState._tuningCameraNavigation = null;
parts.navigation.invalidateRadioCameraNavigation();
}
}
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return Boolean(station);
}
/** Finish a tuning gesture and release its synthesized noise source. */
function endRadioTuning() {
if (
!layerState._tuningActive &&
!layerState._tuningStatic &&
!layerState._tuningAwaitingStationId &&
!layerState._tuningNoiseSource &&
!layerState._cancelledTuningPresentationStation
)
return;
parts.tuningNoise.clearRadioTuningNoise();
}
/** Cancel a tuning gesture and restore its frozen start marker for presentation only. */
function cancelRadioTuning() {
if (!layerState._tuningActive) return false;
const restoredStation = parts.model.tuningResolutionStation(
layerState._tuningStartStationId,
);
parts.tuningNoise.clearRadioTuningNoise({ restoredStation });
return true;
}
/** Commit the exact frozen drag resolution or report that it is unavailable. */
function commitRadioTuningStation(id, { origin = 'programmatic' } = {}) {
const frozenStation = parts.model.tuningResolutionStation(id);
const currentStation = layerState._stationById.get(String(id)) || null;
const generation = layerState._tuningResolutionSnapshot.generation;
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._tuningActive ||
!frozenStation
) {
endRadioTuning();
return Object.freeze({
ok: false,
reason: 'not-tuning',
stationId: String(id || ''),
generation,
});
}
if (
!parts.model.radioStationResolutionMatches(frozenStation, currentStation)
) {
const stationId = frozenStation.id;
// A failed exact release owns the visible outcome. Do not let the previous
// selection—or changed metadata under the same ID—reappear between the
// frozen preview and the unavailable state.
layerState._selectedId = null;
layerState._selectionGeneration += 1;
parts.tuningNoise.clearRadioTuningNoise({ emit: false });
layerState._tuningUnavailableStationId = stationId;
parts.presentation.emitState();
return Object.freeze({
ok: false,
reason: 'station-unavailable',
stationId,
generation,
});
}
layerState._tuningActive = false;
layerState._tuningStatic = true;
layerState._tuningAwaitingStationId = frozenStation.id;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningUnavailableStationId = null;
const cameraNavigation = layerState._tuningCameraNavigation;
layerState._tuningCameraNavigation = null;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
// An exact tuner release owns its frozen target. A fallback armed by an
// earlier non-playing selection must never retarget this gesture if the
// broadcaster fails after release.
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
layerState._playFallbackOrigin = 'programmatic';
layerState._playFallbackAttemptId = null;
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
const selected = parts.selection.selectRadioStation(frozenStation.id, {
autoplay: true,
focus: false,
origin,
cameraNavigation,
});
return Object.freeze({
ok: selected,
reason: selected ? null : 'station-unavailable',
stationId: frozenStation.id,
generation,
});
}
return {
buildRadioTunerBand,
captureRadioTuningResolutionSnapshot,
getRadioTunerStations,
beginRadioTuning,
setRadioTuningStatic,
previewRadioTuningStation,
endRadioTuning,
cancelRadioTuning,
commitRadioTuningStation,
};
}
+145
View File
@@ -0,0 +1,145 @@
import {
RADIO_TUNER_STATIC_MAX_GAIN,
EMPTY_ACCEPTED_CATALOG_SNAPSHOT,
} from './policy.js';
export function createTuningNoise({
state: layerState,
services,
parts,
source,
}) {
/** Decide whether tuner static should be audible for the current handoff state. */
function radioTuningStaticShouldPlay({
tuningActive = false,
tuningStatic = false,
awaitingStationId = null,
voiceDucked = false,
} = {}) {
return Boolean(
tuningStatic && !voiceDucked && (tuningActive || awaitingStationId),
);
}
function stopTuningNoiseSource() {
if (layerState._tuningNoiseSource) {
try {
layerState._tuningNoiseSource.stop();
} catch {
/* already stopped */
}
try {
layerState._tuningNoiseSource.disconnect();
} catch {
/* already disconnected */
}
}
try {
layerState._tuningNoiseFilter?.disconnect();
} catch {
/* already disconnected */
}
try {
layerState._tuningNoiseGain?.disconnect();
} catch {
/* already disconnected */
}
layerState._tuningNoiseSource = null;
layerState._tuningNoiseFilter = null;
layerState._tuningNoiseGain = null;
}
function syncTuningNoiseGain() {
if (!layerState._tuningNoiseContext || !layerState._tuningNoiseGain) return;
const audible = radioTuningStaticShouldPlay({
tuningActive: layerState._tuningActive,
tuningStatic: layerState._tuningStatic,
awaitingStationId: layerState._tuningAwaitingStationId,
voiceDucked: layerState._voiceDucked,
});
const target = audible
? Math.min(RADIO_TUNER_STATIC_MAX_GAIN, layerState._userVolume * 0.03)
: 0;
const now = layerState._tuningNoiseContext.currentTime;
layerState._tuningNoiseGain.gain.cancelScheduledValues(now);
layerState._tuningNoiseGain.gain.setValueAtTime(
layerState._tuningNoiseGain.gain.value,
now,
);
layerState._tuningNoiseGain.gain.linearRampToValueAtTime(
target,
now + 0.025,
);
}
function installTuningNoise() {
const AudioContextClass =
globalThis.AudioContext || globalThis.webkitAudioContext;
if (!AudioContextClass) return false;
if (!layerState._tuningNoiseContext)
layerState._tuningNoiseContext = new AudioContextClass();
const resumed = layerState._tuningNoiseContext.resume?.();
if (resumed?.catch) void resumed.catch(() => {});
if (layerState._tuningNoiseSource) return true;
const frameCount = Math.max(
1,
Math.floor(layerState._tuningNoiseContext.sampleRate * 0.75),
);
const buffer = layerState._tuningNoiseContext.createBuffer(
1,
frameCount,
layerState._tuningNoiseContext.sampleRate,
);
const channel = buffer.getChannelData(0);
for (let index = 0; index < channel.length; index += 1)
channel[index] = Math.random() * 2 - 1;
layerState._tuningNoiseSource =
layerState._tuningNoiseContext.createBufferSource();
layerState._tuningNoiseFilter =
layerState._tuningNoiseContext.createBiquadFilter();
layerState._tuningNoiseGain = layerState._tuningNoiseContext.createGain();
layerState._tuningNoiseSource.buffer = buffer;
layerState._tuningNoiseSource.loop = true;
layerState._tuningNoiseFilter.type = 'bandpass';
layerState._tuningNoiseFilter.frequency.value = 1_650;
layerState._tuningNoiseFilter.Q.value = 0.55;
layerState._tuningNoiseGain.gain.value = 0;
layerState._tuningNoiseSource.connect(layerState._tuningNoiseFilter);
layerState._tuningNoiseFilter.connect(layerState._tuningNoiseGain);
layerState._tuningNoiseGain.connect(
layerState._tuningNoiseContext.destination,
);
layerState._tuningNoiseSource.start();
return true;
}
function clearRadioTuningNoise({ emit = true, restoredStation = null } = {}) {
if (layerState._tuningCameraNavigation)
parts.navigation.invalidateRadioCameraNavigation();
layerState._tuningActive = false;
layerState._tuningStatic = false;
layerState._tuningAwaitingStationId = null;
layerState._tuningPreviewId = null;
layerState._tuningStartStationId = null;
layerState._tuningResolutionSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
layerState._tuningStationById = new Map();
layerState._tuningCameraNavigation = null;
layerState._cancelledTuningPresentationStation = restoredStation;
parts.rendering.updateSelectionEntity();
syncTuningNoiseGain();
stopTuningNoiseSource();
const suspended = layerState._tuningNoiseContext?.suspend?.();
if (suspended?.catch) void suspended.catch(() => {});
if (emit) parts.presentation.emitState();
}
return {
radioTuningStaticShouldPlay,
stopTuningNoiseSource,
syncTuningNoiseGain,
installTuningNoise,
clearRadioTuningNoise,
};
}
+148
View File
@@ -0,0 +1,148 @@
import { VOICE_RESTORE_DELAY_MS, VOICE_RESTORE_DURATION_MS } from './policy.js';
export function createVolume({ state: layerState, services, parts, source }) {
function clampRadioVolume(value) {
return Math.min(1, Math.max(0, Number(value) || 0));
}
function cancelRadioVolumeTransition() {
layerState._volumeTransitionGeneration += 1;
if (layerState._voiceRestoreTimer)
clearTimeout(layerState._voiceRestoreTimer);
layerState._voiceRestoreTimer = null;
if (layerState._volumeFadeFrame) {
if (typeof cancelAnimationFrame === 'function')
cancelAnimationFrame(layerState._volumeFadeFrame);
else clearTimeout(layerState._volumeFadeFrame);
}
layerState._volumeFadeFrame = null;
}
function scheduleVolumeFrame(callback) {
if (typeof requestAnimationFrame === 'function')
return requestAnimationFrame(callback);
return setTimeout(() => callback(Date.now()), 16);
}
function volumeClock() {
return typeof performance !== 'undefined' &&
typeof performance.now === 'function'
? performance.now()
: Date.now();
}
/** Set shared audio volume, clamped to [0, 1]. */
function setRadioVolume(value) {
if (!parts.interaction.radioPresentationAllowed()) return false;
const volume = clampRadioVolume(value);
layerState._userVolume = volume;
parts.playback.installAudio();
if (!layerState._voiceDucked) {
cancelRadioVolumeTransition();
layerState._voiceRestoring = false;
if (layerState._audio) layerState._audio.volume = volume;
}
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return true;
}
/**
* Mute Radio during a live voice turn, then gently restore the user-owned
* volume after voice returns to standby. Repeated state sync is idempotent.
*/
function setRadioVoiceDucking(
ducked,
{
restoreDelayMs = VOICE_RESTORE_DELAY_MS,
restoreDurationMs = VOICE_RESTORE_DURATION_MS,
} = {},
) {
const shouldDuck = Boolean(ducked);
if (
shouldDuck === layerState._voiceDucked &&
(shouldDuck || layerState._voiceRestoring)
)
return;
if (
!shouldDuck &&
!layerState._voiceDucked &&
!layerState._voiceRestoring &&
(!layerState._audio ||
Math.abs(layerState._audio.volume - layerState._userVolume) < 0.001)
)
return;
cancelRadioVolumeTransition();
layerState._voiceDucked = shouldDuck;
layerState._voiceRestoring = false;
if (shouldDuck) {
if (layerState._audio) layerState._audio.volume = 0;
parts.tuningNoise.syncTuningNoiseGain();
parts.presentation.emitState();
return;
}
if (!layerState._audio) {
parts.tuningNoise.syncTuningNoiseGain();
layerState._voiceRestoring = false;
parts.presentation.emitState();
return;
}
const generation = layerState._volumeTransitionGeneration;
parts.tuningNoise.syncTuningNoiseGain();
layerState._voiceRestoring = true;
parts.presentation.emitState();
const beginRestore = () => {
layerState._voiceRestoreTimer = null;
if (
generation !== layerState._volumeTransitionGeneration ||
layerState._voiceDucked ||
!layerState._audio
)
return;
const startedAt = volumeClock();
const initialVolume = layerState._audio.volume;
const duration = Math.max(0, Number(restoreDurationMs) || 0);
const step = (now) => {
if (
generation !== layerState._volumeTransitionGeneration ||
layerState._voiceDucked ||
!layerState._audio
)
return;
const progress =
duration === 0
? 1
: Math.min(1, Math.max(0, (now - startedAt) / duration));
const eased = progress * progress * (3 - 2 * progress);
layerState._audio.volume = clampRadioVolume(
initialVolume + (layerState._userVolume - initialVolume) * eased,
);
if (progress < 1) {
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
return;
}
layerState._volumeFadeFrame = null;
layerState._voiceRestoring = false;
parts.presentation.emitState();
};
layerState._volumeFadeFrame = scheduleVolumeFrame(step);
};
const delay = Math.max(0, Number(restoreDelayMs) || 0);
if (delay > 0)
layerState._voiceRestoreTimer = setTimeout(beginRestore, delay);
else beginRestore();
}
return {
clampRadioVolume,
cancelRadioVolumeTransition,
scheduleVolumeFrame,
volumeClock,
setRadioVolume,
setRadioVoiceDucking,
};
}
@@ -0,0 +1,42 @@
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
// Commercial users must remove it or obtain a TeleGeography license.
// See DATA_SOURCES.md and the bundled dataset's source.json.
const cableUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
const landingPointUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
export function createBundledCableSource({
fetchImpl = (...args) => fetch(...args),
} = {}) {
async function read(url, signal) {
signal?.throwIfAborted();
const response = await fetchImpl(url, { signal, cache: 'force-cache' });
if (!response.ok) {
try {
await response.body?.cancel();
} catch {
/* best effort */
}
throw new Error(`HTTP ${response.status} for ${url}`);
}
const json = await response.json();
signal?.throwIfAborted();
return json;
}
return {
label: 'TeleGeography',
async fetch(signal) {
const [cables, landingPoints] = await Promise.all([
read(cableUrl, signal),
read(landingPointUrl, signal),
]);
return { cables, landingPoints };
},
};
}
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createBundledCableSource } from './bundledSource.js';
test('a failed bundled response releases its body', async () => {
let released = 0;
const source = createBundledCableSource({
fetchImpl: async () => ({
ok: false,
status: 503,
body: {
cancel: async () => {
released++;
},
},
}),
});
await assert.rejects(source.fetch(), /HTTP 503/);
assert.equal(released, 2);
});
test('cancellation during JSON parsing cannot return a late snapshot', async () => {
const controller = new AbortController();
const source = createBundledCableSource({
fetchImpl: async () => ({
ok: true,
async json() {
controller.abort();
return { type: 'FeatureCollection', features: [] };
},
}),
});
await assert.rejects(source.fetch(controller.signal), { name: 'AbortError' });
});
+79
View File
@@ -0,0 +1,79 @@
export function featureReference(feature) {
const geometry = feature?.geometry;
if (!geometry) return null;
const props = feature?.properties || {};
const propertyCoords = coordsFromProperty(props.coordinates);
if (propertyCoords) {
return {
lon: propertyCoords[0],
lat: propertyCoords[1],
};
}
if (geometry.type === 'Point') {
const coords = coordsFromPoint(geometry.coordinates);
if (!coords) return null;
return { lon: coords[0], lat: coords[1] };
}
const coords = [];
collectLonLat(geometry.coordinates, coords);
if (!coords.length) return null;
let lonSum = 0;
let latSum = 0;
for (const [lon, lat] of coords) {
lonSum += lon;
latSum += lat;
}
return {
lon: lonSum / coords.length,
lat: latSum / coords.length,
};
}
export function coordsFromProperty(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
return [lon, lat];
}
export function coordsFromPoint(value) {
if (!Array.isArray(value) || value.length < 2) return null;
const lon = Number(value[0]);
const lat = Number(value[1]);
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null;
return [lon, lat];
}
export function collectLonLat(value, out) {
if (!Array.isArray(value)) return;
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
const lon = Number(value[0]);
const lat = Number(value[1]);
if (Number.isFinite(lon) && Number.isFinite(lat)) {
out.push([lon, lat]);
}
return;
}
for (const child of value) collectLonLat(child, out);
}
export function featureLabel(feature) {
const props = feature?.properties || {};
return String(props.name || props.id || feature?.id || '').trim();
}
export function normalizeFeatures(json, kind) {
const features = Array.isArray(json?.features) ? json.features : [];
return features.map((feature, index) => {
const id = feature?.properties?.id || feature?.id || `${kind}-${index}`;
return {
...feature,
id: String(id),
};
});
}
+53
View File
@@ -0,0 +1,53 @@
import { createState } from './state.js';
import { createLifecycle } from './lifecycle.js';
import { defaultSweepClock } from './policy.js';
import { createRendering } from './rendering.js';
import { createIngestion } from './ingestion.js';
import { createInteraction } from './interaction.js';
export function createSubmarineCableLayer({
source,
overlayHost,
screenSpaceEventHandlerFactory,
mapStackEventTarget = null,
sweepClock = defaultSweepClock,
}) {
if (!source?.fetch || !source.label)
throw new TypeError(
'A cable source with a label and fetch(signal) is required',
);
const state = createState({ overlayHost, sweepClock });
const parts = {};
const context = {
state,
parts,
source,
screenSpaceEventHandlerFactory,
mapStackEventTarget,
};
parts.rendering = createRendering(context);
parts.ingestion = createIngestion(context);
parts.interaction = createInteraction(context);
return createLifecycle(context);
}
export {
selectCableReferenceLabelWinners,
cableReferencePriority,
createCableOverlayEntry,
createCableOverlayPublisher,
createCableReferenceSweepGate,
updateCableReferenceStem,
} from './overlay.js';
export {
cableClassificationTypeForStack,
cableClassificationTypeForScene,
applyTranslucentMarkerBlend,
} from './surface.js';
export {
CABLE_REFERENCE_LABEL_WINNER_CAP,
CABLE_OVERLAY_SOURCE_ID,
CABLE_OVERLAY_COLLISION_CAPACITY,
CABLE_STEM_TIP_EPSILON_M,
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
CABLE_SWEEP_MOTION_EPSILON_M,
CABLE_LABEL_DEPTH_DECISION,
} from './policy.js';
+210
View File
@@ -0,0 +1,210 @@
import {
featureLabel,
featureReference,
normalizeFeatures,
} from './geometry.js';
import * as Cesium from 'cesium';
export function createIngestion({ state, parts, source }) {
async function load(viewer) {
if (state._loading || state._loaded) return;
state._loading = true;
state._error = null;
state._loadingLabel = 'loading...';
// Ownership token (the militaryAwareness activationId pattern): a
// disable/abort followed by a fresh enable starts a NEWER load while this
// one is still settling. The stale load must bail after every await and
// must never touch shared lifecycle state or the viewer it no longer
// owns — clearing `_loading`/`_abort` for the successor is how duplicate
// or post-destroy data sources got added.
const generation = ++state._loadGeneration;
const abort = new AbortController();
state._abort = abort;
const owns = () =>
generation === state._loadGeneration && !abort.signal.aborted;
try {
let cableJson = state._cachedCableJson;
let landingJson = state._cachedLandingJson;
if (!cableJson || !landingJson) {
({ cables: cableJson, landingPoints: landingJson } = await source.fetch(
abort.signal,
));
if (!owns()) return;
if (
!Array.isArray(cableJson?.features) ||
!Array.isArray(landingJson?.features)
) {
throw new TypeError(
'A cable source must return cables and landingPoints GeoJSON collections',
);
}
state._cachedCableJson = cableJson;
state._cachedLandingJson = landingJson;
}
const cableFeatures = normalizeFeatures(cableJson, 'cable');
const landingFeatures = normalizeFeatures(landingJson, 'landing');
const cableDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: cableFeatures },
{
clampToGround: true,
stroke: state.cableColor.withAlpha(0.95),
fill: state.cableColor.withAlpha(0.18),
strokeWidth: 2,
markerColor: state.cableColor,
markerSize: 6,
},
);
if (!owns()) return;
const landingDataSource = await Cesium.GeoJsonDataSource.load(
{ type: 'FeatureCollection', features: landingFeatures },
{
clampToGround: true,
stroke: state.landingColor.withAlpha(0.9),
fill: state.landingColor.withAlpha(0.35),
strokeWidth: 2,
markerColor: state.landingColor,
markerSize: 6,
},
);
if (!owns()) return;
cableDataSource.name = `${source.label} Submarine Cables`;
landingDataSource.name = `${source.label} Landing Points`;
const referenceDataSource = new Cesium.CustomDataSource(
`${source.label} Cable References`,
);
const addedSources = [
cableDataSource,
landingDataSource,
referenceDataSource,
];
// dataSources.add() is itself an await point: Cesium mutates the
// collection on a DEFERRED tick, so a disable/destroy racing this
// window finds nothing to remove and the adds still materialize
// afterwards. Await every add to settlement (allSettled so a partial
// failure cannot leave an unawaited add materializing later), then
// re-check ownership. Pinned teardown mechanism: disable/destroy never
// wait for in-flight adds — the stale generation's own post-await
// check here guarantees cleanup by removing exactly the sources THIS
// generation added.
const addResults = await Promise.allSettled([
viewer.dataSources.add(cableDataSource),
viewer.dataSources.add(landingDataSource),
viewer.dataSources.add(referenceDataSource),
]);
const rejectedAdd = addResults.find(
(result) => result.status === 'rejected',
);
if (!owns() || rejectedAdd) {
// Compensate: every add has settled by now, so removal is effective
// (a remove inside the deferred window would have been a no-op).
// Remove EVERY generation-local source regardless of settle status:
// Cesium's add() pushes into the collection BEFORE raising
// dataSourceAdded, so a REJECTED add may still have landed its
// mutation. remove() of a never-added source is a harmless no-op.
for (const source of addedSources) {
try {
viewer.dataSources.remove(source, true);
} catch {
/* collection gone */
}
}
if (owns() && rejectedAdd) throw rejectedAdd.reason;
return;
}
// Await-free commit: the viewer accepted all three sources and this
// load still owns the lifecycle.
state._cableDataSource = cableDataSource;
state._landingDataSource = landingDataSource;
state._referenceDataSource = referenceDataSource;
const cableEntities = state._cableDataSource.entities.values;
const landingEntities = state._landingDataSource.entities.values;
state._pickByEntity = new WeakMap();
state._referenceRecords = [];
state._surfaceRecords = [];
cableEntities.forEach((entity, index) => {
const feature = cableFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
parts.rendering.styleCableEntity(entity, feature);
parts.interaction.registerPickEntity(entity, {
kind: 'cable',
reference,
label: featureLabel(feature),
});
state._surfaceRecords.push({
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'cable',
color: state.cableColor,
feature,
});
});
landingEntities.forEach((entity, index) => {
const feature = landingFeatures[index];
const reference = featureReference(feature);
if (!reference) return;
parts.rendering.styleLandingEntity(entity, feature);
parts.interaction.registerPickEntity(entity, {
kind: 'landing-point',
reference,
label: featureLabel(feature),
});
state._surfaceRecords.push({
entity,
base: Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0),
});
parts.rendering.addReferenceStem({
reference,
label: featureLabel(feature),
kind: 'landing-point',
color: state.landingColor,
feature,
});
});
state._count = cableFeatures.length + landingFeatures.length;
state._loaded = true;
state._lastUpdate = Date.now();
state._loadingLabel = '';
parts.rendering.updateVisibility();
state._referenceSweepGate.markDirty();
viewer.scene.requestRender?.();
} catch (error) {
// A stale or aborted load reports nothing: its failure belongs to a
// lifecycle the user already left.
if (owns() && error?.name !== 'AbortError') {
state._error = error?.message || 'TeleGeography load failed';
console.warn(
'[Data:telegeography-submarine-cables]',
state._error,
error,
);
}
} finally {
// Only the OWNING load may clear the shared lifecycle. A stale load
// clearing `_loading`/`_abort` would hand a later enable/update a
// duplicate load while the real one is still in flight.
if (generation === state._loadGeneration) {
state._loading = false;
if (state._abort === abort) state._abort = null;
}
}
}
return { load };
}
+75
View File
@@ -0,0 +1,75 @@
import * as Cesium from 'cesium';
export function createInteraction({ state, screenSpaceEventHandlerFactory }) {
function registerPickEntity(entity, info) {
entity.__gevTeleGeography = info;
state._pickByEntity.set(entity, info);
}
function beginInteraction(viewer) {
if (state._clickHandler) return;
state._clickHandler = screenSpaceEventHandlerFactory(viewer.scene.canvas);
state._clickHandler.setInputAction((click) => {
if (!state._enabled) return;
const picked = viewer.scene.pick(click.position);
const record = resolvePickRecord(picked);
if (!record?.reference) return;
flyToReference(viewer, record.reference);
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
}
function resolvePickRecord(picked) {
if (!picked) return null;
const primitive = picked.primitive;
if (primitive) {
const primitiveInfo = state._pickByEntity.get(primitive);
if (primitiveInfo) return primitiveInfo;
if (primitive.__gevTeleGeography) return primitive.__gevTeleGeography;
if (
primitive.id &&
typeof primitive.id === 'object' &&
primitive.id.reference
) {
return primitive.id;
}
}
const entity = picked.id;
if (entity) {
const entityInfo = state._pickByEntity.get(entity);
if (entityInfo) return entityInfo;
if (entity.__gevTeleGeography) return entity.__gevTeleGeography;
if (entity.id && typeof entity.id === 'object' && entity.id.reference) {
return entity.id;
}
}
return null;
}
function flyToReference(viewer, reference) {
if (!viewer || !reference) return;
const destination = Cesium.Cartesian3.fromDegrees(
reference.lon,
reference.lat,
6500,
);
viewer.camera.cancelFlight();
viewer.camera.flyTo({
destination,
orientation: {
heading: viewer.camera.heading || 0,
pitch: Cesium.Math.toRadians(-52),
roll: 0,
},
duration: 1.35,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
});
}
return {
registerPickEntity,
beginInteraction,
resolvePickRecord,
flyToReference,
};
}
+156
View File
@@ -0,0 +1,156 @@
import {
cableClassificationTypeForScene,
cableClassificationTypeForStack,
} from './surface.js';
export function createLifecycle({ state, parts, source, mapStackEventTarget }) {
return {
id: 'telegeography-submarine-cables',
name: 'Submarine Cables',
icon: '≋',
source: source.label,
updateInterval: 0,
statsRefreshInterval: 500,
init(viewer) {
state._viewer = viewer;
state._classificationType = cableClassificationTypeForScene(
viewer?.scene,
);
if (!state._mapStackListener && mapStackEventTarget?.addEventListener) {
state._mapStackListener = (event) => {
parts.rendering.applyCableClassification(
event?.detail?.activeId
? cableClassificationTypeForStack(event.detail.activeId)
: cableClassificationTypeForScene(state._viewer?.scene),
);
};
mapStackEventTarget.addEventListener(
'gev:map-stack-changed',
state._mapStackListener,
);
}
parts.interaction.beginInteraction(viewer);
if (!state._preRenderRemover) {
state._preRenderRemover = viewer.scene.preRender.addEventListener(
() => {
// The camera is handed to the gate so tracked/orbit cameras — which
// never emit moveEnd — reach the motion fallback. No render is
// requested for a fallback sweep: it only fires while the camera is
// already moving, so frames are flowing by construction.
if (
!state._enabled ||
!state._loaded ||
!state._referenceSweepGate.shouldRun(viewer.camera)
)
return;
parts.rendering.updateReferenceVisibility();
},
);
}
if (!state._moveEndRemover) {
state._moveEndRemover = viewer.camera.moveEnd.addEventListener(() => {
if (!state._enabled) return;
state._referenceSweepGate.markDirty();
viewer.scene.requestRender?.();
});
}
},
enable(viewer) {
state._enabled = true;
parts.rendering.updateVisibility();
state._overlayPublisher.show();
// A hide() cleared the host source, so an identical cohort must still
// republish on the next sweep.
parts.rendering.resetPublishSignature();
state._referenceSweepGate.markDirty();
// The gate is dirty-only; ask for the frame its sweep needs in case the
// camera is parked and nothing else is rendering.
(viewer || state._viewer)?.scene?.requestRender?.();
void parts.ingestion.load(viewer || state._viewer);
},
disable() {
state._enabled = false;
state._overlayPublisher.hide();
parts.rendering.resetPublishSignature();
if (state._loading && state._abort) {
state._abort.abort();
state._loading = false;
state._loadingLabel = '';
}
// Free the entities rather than hide them; enable() rebuilds from the
// cached JSON. An in-flight load's own post-await ownership check
// removes whatever that stale generation adds later.
parts.rendering.releaseDataSources(state._viewer);
},
update(viewer) {
return parts.ingestion.load(viewer || state._viewer);
},
destroy(viewer) {
if (state._abort) state._abort.abort();
parts.rendering.releaseDataSources(viewer || state._viewer);
state._cachedCableJson = null;
state._cachedLandingJson = null;
// hide() clears every published host entry and goes invisible while
// keeping the publisher reusable — the legacy layer supported re-init
// after destroy, and hidden publishers already drop late publishes.
// Pinned by the direct-destroy host test: removing this line leaves a
// visible overlay source with orphan labels.
state._overlayPublisher.hide();
if (state._clickHandler) {
state._clickHandler.destroy();
state._clickHandler = null;
}
if (state._preRenderRemover) {
state._preRenderRemover();
state._preRenderRemover = null;
}
if (state._moveEndRemover) {
state._moveEndRemover();
state._moveEndRemover = null;
}
if (state._mapStackListener && mapStackEventTarget?.removeEventListener) {
mapStackEventTarget.removeEventListener(
'gev:map-stack-changed',
state._mapStackListener,
);
state._mapStackListener = null;
}
state._viewer = null;
state._enabled = false;
state._loading = false;
state._loaded = false;
state._count = 0;
state._error = null;
state._lastUpdate = null;
state._loadingLabel = '';
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
state._referenceRecords = [];
state._surfaceRecords = [];
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
state._markerBlendDone = false;
state._markerBlendInvariantWarned = false;
parts.rendering.resetPublishSignature();
state._referenceSweepGate.reset();
},
getStats() {
return {
count: state._count,
lastUpdate: state._lastUpdate,
loading: state._loading,
loadingLabel: state._loadingLabel,
error: state._error,
referenceLabelCount: state._referenceLabelCount,
};
},
};
}
+309
View File
@@ -0,0 +1,309 @@
import * as Cesium from 'cesium';
import {
CABLE_REFERENCE_LABEL_WINNER_CAP,
CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
BASE_CABLE_COLOR,
BASE_LANDING_COLOR,
CABLE_OVERLAY_SOURCE_ID,
CABLE_OVERLAY_COLLISION_CAPACITY,
defaultSweepClock,
CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
CABLE_SWEEP_MOTION_EPSILON_M,
STEM_TARGET_PX,
CABLE_STEM_TIP_EPSILON_SQ,
} from './policy.js';
/**
* Select the nearest visible cable references without exceeding the bounded
* cohort cap the shared host is offered.
* @param {object[]} records Records annotated with `visible` and `distanceM`.
* @param {number} [limit]
* @returns {object[]}
*/
export function selectCableReferenceLabelWinners(
records,
limit = CABLE_REFERENCE_LABEL_WINNER_CAP,
) {
const cap = Math.max(
0,
Math.min(CABLE_REFERENCE_LABEL_WINNER_CAP, Math.floor(Number(limit) || 0)),
);
if (!Array.isArray(records) || cap === 0) return [];
return records
.filter(
(record) =>
record?.visible === true &&
record.label &&
Number.isFinite(record.distanceM) &&
record.distanceM <= CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
)
.sort(
(a, b) =>
a.distanceM - b.distanceM ||
String(a.entity?.id || '').localeCompare(String(b.entity?.id || '')),
)
.slice(0, cap);
}
/**
* Distance-derived arbiter priority. Quantized to 50 km buckets so a slowly
* moving camera does not reshuffle equal-rank labels on every sweep; nearer
* references still win collisions, matching the shipped nearest-first feel.
* @param {number} distanceM
* @returns {number}
*/
export function cableReferencePriority(distanceM) {
const distance = Number.isFinite(distanceM)
? Math.max(0, distanceM)
: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M;
return 1000 - Math.round(distance / 50000);
}
/**
* Build one shared-host ambient label for a cable or landing-point reference.
* The entry stays attached to the record's mutable stem-tip Cartesian and is
* created once per record; only `priority` is refreshed per sweep.
* `interactive: false` follows the infrastructure precedent — the depth-tested
* native point/stem/line remains the click surface, and a non-interactive
* label keeps the host's per-frame accessibility/hit sync allocation-free.
* @param {object} record Reference record ({ id, kind, label, tip }).
* @returns {object}
*/
export function createCableOverlayEntry(record) {
const kind = record?.kind === 'landing-point' ? 'landing-point' : 'cable';
return {
id: String(record?.id || ''),
position: record?.tip,
variant: 'label',
title: String(record?.label || ''),
accent: kind === 'cable' ? BASE_CABLE_COLOR : BASE_LANDING_COLOR,
priority: cableReferencePriority(record?.distanceM),
collisionGroup: 'ambient-label',
paintLane: 'ambient-label',
interactive: false,
minDistance: 0,
maxDistance: CABLE_REFERENCE_LABEL_MAX_DISTANCE_M,
distanceFadeStartRatio: 0.7,
// Former native scaleByDistance curve, unchanged: 1.0× at 250 km → 0.62×
// at 9,000 km.
distanceScale: {
near: 250000,
nearValue: 1,
far: 9000000,
farValue: 0.62,
},
edgeFade: 'keyhole',
horizonCull: true,
terrainOcclusion: false,
gapPx: 14,
verticalOnly: true,
placement: 'above',
};
}
/**
* Bind the cable layer's host visibility and entry lifecycle to the shared
* world overlay (same contract as the infrastructure publisher). The layer
* supports re-init after destroy (legacy contract), so its teardown path is
* hide() — clear the published source and go invisible while staying
* reusable. There is deliberately no permanent-destroy method: a hidden
* publisher already drops late publishes until the next show().
* @param {object} [options]
* @param {string} [options.sourceId]
* @param {object} [options.host] Test seam for the three host lifecycle calls.
* @returns {{show:function():void,publish:function(object[]):void,hide:function():void}}
*/
export function createCableOverlayPublisher({
sourceId = CABLE_OVERLAY_SOURCE_ID,
host,
} = {}) {
let visible = false;
let published = false;
const sourceOptions = {
cohortLimit: CABLE_REFERENCE_LABEL_WINNER_CAP,
collisionCapacity: CABLE_OVERLAY_COLLISION_CAPACITY,
moving: false,
};
return {
show() {
if (visible) return;
visible = true;
host.setVisible(sourceId, true);
},
publish(entries) {
if (!visible) return;
host.setEntries(sourceId, entries, sourceOptions);
published = entries.length > 0;
},
hide() {
if (published) host.clearSource(sourceId);
if (visible) host.setVisible(sourceId, false);
visible = false;
published = false;
},
};
}
/**
* Dirty gate for the reference sweep. TWO dirty conditions, no timer:
*
* 1. EVENT (primary): the sweep runs on the first frame after a camera
* `moveEnd`, layer enable, or load completion. The former 500 ms timer
* path re-sized stems while frames were flowing, and every re-size
* rebuilt the 2,629-instance batched translucent stem primitive
* (measured: 12 rebuilds in 6 s with the replacement primitive unready
* for 46/360 frames — the felt hitch). Stem lengths are therefore
* deliberately stale mid-drag/mid-flight and settle on release;
* reference visibility and label winners go stale the same way, and the
* shared host keeps reprojecting the published positions per frame.
* 2. MOTION FALLBACK: `moveEnd` covers drags, one-shot `setView`, and voice
* `flyTo`, but a TRACKED-entity follow camera or an orbit never emits it,
* so condition 1 alone starves those sessions forever (frozen horizon
* visibility, frozen stems, stale label winners for as long as tracking
* lasts). While frames render, the gate therefore samples the camera at
* most once per `CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS` and re-arms itself
* only when the camera has actually travelled past
* `CABLE_SWEEP_MOTION_EPSILON_M` since the last sweep.
*
* A PARKED camera still costs exactly zero sweeps: the probe compares against
* the last swept position (never the previous frame), so neither a frozen
* camera nor sub-epsilon numeric jitter ever accumulates into a sweep, and
* the per-frame cost of the fallback is one clock read plus one subtraction.
* Position is the only motion channel because it is the only camera input the
* sweep consumes — the horizon occluder, the reference distances, the stem
* sizing, and the winner ranks all derive from `positionWC` alone, so a
* heading-only rotation cannot change a single sweep output and deliberately
* does not spend one.
* @param {object} [options]
* @param {function():number} [options.now] Monotonic clock (test seam).
* @param {number} [options.probeIntervalMs]
* @param {number} [options.motionEpsilonM]
*/
export function createCableReferenceSweepGate({
now = defaultSweepClock,
probeIntervalMs = CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS,
motionEpsilonM = CABLE_SWEEP_MOTION_EPSILON_M,
} = {}) {
const motionEpsilonSq = motionEpsilonM ** 2;
// Preallocated: the probe clones into it, so a moving camera allocates
// nothing per frame.
const lastSweptPosition = new Cesium.Cartesian3();
let hasSweptPosition = false;
let dirty = true;
let lastProbeAt = -Infinity;
/** Arm the next probe window and adopt the camera position being swept. */
function accept(camera, time) {
lastProbeAt = time;
const position = camera?.positionWC;
hasSweptPosition = Boolean(position);
if (position) Cesium.Cartesian3.clone(position, lastSweptPosition);
return true;
}
return {
markDirty() {
dirty = true;
},
/**
* @param {Cesium.Camera} [camera] Live camera; omit to disable the motion
* fallback entirely (pure dirty-only gate).
* @returns {boolean}
*/
shouldRun(camera) {
if (dirty) {
dirty = false;
return accept(camera, now());
}
if (!camera) return false;
// Cheapest possible per-frame path: a clock read and a subtraction. The
// camera getter is only touched once the probe window has actually
// opened, and the window re-arms whether or not the probe sweeps.
const time = now();
if (time - lastProbeAt < probeIntervalMs) return false;
lastProbeAt = time;
const position = camera.positionWC;
if (!position) return false;
if (
hasSweptPosition &&
Cesium.Cartesian3.distanceSquared(position, lastSweptPosition) <=
motionEpsilonSq
) {
return false;
}
return accept(camera, time);
},
reset() {
dirty = true;
lastProbeAt = -Infinity;
hasSweptPosition = false;
},
};
}
/**
* Recompute one staticized stem on the sweep cadence. Replaces the former
* per-frame `CallbackProperty` pair: constant properties are redefined only
* when the camera-scaled tip moved beyond the settle epsilon, and the two
* preallocated position buffers alternate so each real change raises exactly
* one geometry notification (the localGeojson double-buffer pattern).
* @param {object} record Reference record with entity/base/tip/nextTip/buffers.
* @param {Cesium.Cartesian3} cameraPositionWC
* @param {number} canvasHeight CSS-pixel canvas height.
* @param {number} fov Camera frustum field of view (radians).
* @returns {boolean} True when the stem geometry was redefined.
*/
export function updateCableReferenceStem(
record,
cameraPositionWC,
canvasHeight,
fov,
) {
const distance = Cesium.Cartesian3.distance(cameraPositionWC, record.base);
const effectiveDistance = Math.max(distance, 5000);
const height = canvasHeight || 1080;
const fieldOfView = fov || Math.PI / 3;
const metersPerPixelFactor = (2 * Math.tan(fieldOfView / 2)) / height;
const tipHeight = Math.max(
700,
Math.min(85000, effectiveDistance * metersPerPixelFactor * STEM_TARGET_PX),
);
Cesium.Cartesian3.fromDegrees(
record.reference.lon,
record.reference.lat,
tipHeight,
Cesium.Ellipsoid.WGS84,
record.nextTip,
);
if (
Cesium.Cartesian3.distanceSquared(record.tip, record.nextTip) <=
CABLE_STEM_TIP_EPSILON_SQ
) {
return false;
}
Cesium.Cartesian3.clone(record.nextTip, record.tip);
record.stemPositionBufferIndex = 1 - record.stemPositionBufferIndex;
const stemPositions =
record.stemPositionBuffers[record.stemPositionBufferIndex];
stemPositions[0] = record.base;
stemPositions[1] = record.tip;
record.entity.position.setValue(record.tip);
record.entity.polyline.positions.setValue(stemPositions);
return true;
}
export function clampLabel(value, maxLength = 34) {
const text = String(value || '')
.replace(/\s+/g, ' ')
.trim();
if (!text) return '';
if (text.length <= maxLength) return text;
return `${text.slice(0, Math.max(1, maxLength - 3))}...`;
}
+109
View File
@@ -0,0 +1,109 @@
import * as Cesium from 'cesium';
export const BASE_CABLE_COLOR = '#39d5ff';
export const BASE_LANDING_COLOR = '#8fffd2';
export const STEM_TARGET_PX = 66;
export const CABLE_REFERENCE_LABEL_MAX_DISTANCE_M = 9000000;
/** Bounded nearest-visible cohort the layer offers the shared host. */
export const CABLE_REFERENCE_LABEL_WINNER_CAP = 160;
/** Shared world-overlay source id (matches the layer id). */
export const CABLE_OVERLAY_SOURCE_ID = 'telegeography-submarine-cables';
/** Shared ambient-label paint budget, matching the infrastructure sources. */
export const CABLE_OVERLAY_COLLISION_CAPACITY = 96;
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const CABLE_STEM_TIP_EPSILON_M = 0.5;
export const CABLE_STEM_TIP_EPSILON_SQ = CABLE_STEM_TIP_EPSILON_M ** 2;
/**
* Motion-fallback probe window. Cameras that never emit `moveEnd` (tracked
* entities, orbits) would otherwise starve the dirty-only sweep forever, so
* the gate samples camera motion at most this often while frames render.
*/
export const CABLE_SWEEP_MOTION_PROBE_INTERVAL_MS = 2000;
/**
* Camera displacement that counts as meaningful motion for that probe. Stem
* tip height is ~8.5% of camera distance at the shipped 66 px target, so
* 250 m of camera travel moves a tip ~21 m; below that the frozen sweep is
* visually indistinguishable and `moveEnd` settles it exactly.
*/
export const CABLE_SWEEP_MOTION_EPSILON_M = 250;
/**
* Phase-5 depth decision, REVISED 2026-08-18: Option 2. Cable reference text
* now renders in the shared world-overlay host (horizon-cull + keyhole fade,
* no per-label tile depth test), superseding the 2026-08-02 Option-1 native
* `LabelGraphics` exception. The native path evaluated 2 `CallbackProperty`
* channels per reference entity per frame (5,258 across the 2,629-reference
* dataset) and re-batched a 160-label `LabelCollection` on every sweep — the
* layer alone cost ~9.5 ms/frame during camera motion. The depth cue this
* trades away (photoreal tiles occluding label TEXT at low, city-level
* cameras) matches the sibling dams/datacenters sources, which shipped
* host-composited under the same ruling; the anchor points/stems remain
* Cesium-native and depth-tested. See
* the world-overlay consolidation design notes ("Depth-testing
* decision") for both dated decisions.
*/
export const CABLE_LABEL_DEPTH_DECISION = Object.freeze({
option: 2,
decidedAt: '2026-08-18',
supersedes: '2026-08-02',
depthTested: false,
});
/**
* `MAP_STACKS` ids that render imagery on the SHOWN Cesium globe (the ion
* Bing stacks + OSM). Deliberately an explicit allowlist, not "anything that
* is not photoreal": an id this module has never heard of is UNKNOWN, and
* unknown must reach the documented BOTH fallback rather than being asserted
* onto the terrain surface. A stack added to `MAP_STACKS` without being added
* here therefore degrades to the safe pre-optimization behavior (visible on
* every surface) instead of vanishing — and the per-stack unit test walks the
* real `MAP_STACKS` so the omission is caught loudly.
*/
export const CABLE_GLOBE_STACK_IDS = Object.freeze(
new Set(['bing-aerial', 'bing-labels', 'esri-imagery', 'osm']),
);
/** EntityCluster's private marker collections and their required types. */
export const MARKER_COLLECTION_EXPECTATIONS = Object.freeze([
Object.freeze({
key: '_billboardCollection',
type: Cesium.BillboardCollection,
}),
Object.freeze({
key: '_pointCollection',
type: Cesium.PointPrimitiveCollection,
}),
]);
/**
* Monotonic clock for the sweep gate's motion probe. A hoisted declaration:
* the module's `export default createTeleGeographySubmarineCableLayer()`
* runs before this point in source order.
* @returns {number}
*/
export function defaultSweepClock() {
return typeof performance === 'object' &&
typeof performance.now === 'function'
? performance.now()
: Date.now();
}
+274
View File
@@ -0,0 +1,274 @@
import * as Cesium from 'cesium';
import {
probeTranslucentMarkerBlend,
commitTranslucentMarkerBlend,
} from './surface.js';
import {
clampLabel,
createCableOverlayEntry,
updateCableReferenceStem,
selectCableReferenceLabelWinners,
cableReferencePriority,
} from './overlay.js';
export function createRendering({ state }) {
function resetPublishSignature() {
state._lastPublishedIds.length = 0;
state._lastPublishedPriorities.length = 0;
state._lastPublishedCount = -1;
}
/**
* Remove the three data sources from the viewer and forget the built
* entities. Hiding them (`show = false`) is NOT enough: Cesium's
* DataSourceDisplay still walks every visualizer of a hidden source on
* every frame, and the thousands of hidden polylines/billboards keep
* costing CPU and GC pressure long after the user toggled the layer off —
* that is the "everything gets sluggish after I turn layers off" report.
* The fetched JSON stays cached, so the next enable rebuilds without a
* network round trip or a re-parse.
*/
function releaseDataSources(viewer) {
const sources = [
state._cableDataSource,
state._landingDataSource,
state._referenceDataSource,
];
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
state._referenceRecords = [];
state._surfaceRecords = [];
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._publishScratch.length = 0;
// The blend pass targets the collections being dropped; the rebuilt
// sources need their own pass.
state._markerBlendDone = false;
state._loaded = false;
state._referenceSweepGate.reset();
for (const source of sources) {
if (!source) continue;
try {
viewer?.dataSources?.remove(source, true);
} catch {
/* collection gone */
}
}
}
function updateVisibility() {
if (state._cableDataSource) state._cableDataSource.show = state._enabled;
if (state._landingDataSource)
state._landingDataSource.show = state._enabled;
if (state._referenceDataSource)
state._referenceDataSource.show = state._enabled;
}
function styleCableEntity(entity, feature) {
if (!entity?.polyline) return;
const color = feature?.properties?.color
? Cesium.Color.fromCssColorString(String(feature.properties.color))
: state.cableColor;
entity.polyline.material = color.withAlpha(0.92);
entity.polyline.width = 2.5;
entity.polyline.clampToGround = true;
entity.polyline.classificationType = state._classificationType;
entity.show = true;
}
/**
* Apply the single translucent blend pass to the landing-billboard and
* reference-point collections. Landing entities carry only billboards and
* reference entities only points, so completion is one applied collection
* per source; a failed shape probe disables the optimization permanently
* for this session and says so loudly in dev builds.
* Both sources are probed BEFORE either is committed, so the helper's
* "defaults untouched on shape drift" fallback holds across the pair too —
* a reference-source failure can never leave the landing source already
* forced to TRANSLUCENT.
*/
function applyMarkerBlendOnce() {
const landing = probeTranslucentMarkerBlend(state._landingDataSource);
const reference = probeTranslucentMarkerBlend(state._referenceDataSource);
if (landing.invariantFailed || reference.invariantFailed) {
state._markerBlendDone = true;
if (!state._markerBlendInvariantWarned && import.meta.env?.DEV === true) {
state._markerBlendInvariantWarned = true;
console.error(
'[Data:telegeography-submarine-cables] EntityCluster marker-collection ' +
'shape changed — single-pass translucent blend skipped; markers fall ' +
"back to Cesium's default two-pass blend.",
);
}
return;
}
const landingBlend = commitTranslucentMarkerBlend(landing);
const referenceBlend = commitTranslucentMarkerBlend(reference);
if (landingBlend.applied > 0 && referenceBlend.applied > 0)
state._markerBlendDone = true;
}
/**
* Re-classify every cable ground line for the active surface. One batched
* ground-primitive rebuild per stack switch — never per frame.
* @param {Cesium.ClassificationType} next
*/
function applyCableClassification(next) {
if (next === undefined || next === state._classificationType) return;
state._classificationType = next;
if (!state._cableDataSource) return;
const entities = state._cableDataSource.entities.values;
for (let i = 0; i < entities.length; i++) {
const polyline = entities[i].polyline;
if (polyline) polyline.classificationType = next;
}
state._viewer?.scene?.requestRender?.();
}
function styleLandingEntity(entity, feature) {
if (!entity?.point) return;
entity.point.color = state.landingColor.withAlpha(0.92);
entity.point.pixelSize = feature?.properties?.is_tbd ? 6 : 7;
entity.point.outlineColor = state.cableOutline;
entity.point.outlineWidth = 1;
entity.point.disableDepthTestDistance = 0;
entity.show = true;
}
function addReferenceStem({ reference, label, kind, color, feature }) {
if (!state._referenceDataSource || !reference) return;
const base = Cesium.Cartesian3.fromDegrees(reference.lon, reference.lat, 0);
const tip = Cesium.Cartesian3.fromDegrees(
reference.lon,
reference.lat,
2500,
);
const info = {
kind,
reference,
label,
featureId: feature?.id || feature?.properties?.id || null,
};
const pointColor = color.withAlpha(kind === 'cable' ? 0.84 : 0.94);
const stemColor = color.withAlpha(kind === 'cable' ? 0.58 : 0.68);
// Constant properties on the sweep cadence, never per-frame callbacks:
// the sweep redefines them through the alternating buffers below.
const stemPositionBuffers = [
[base, tip],
[base, tip],
];
const entity = state._referenceDataSource.entities.add({
id: `${kind}-reference-${state._referenceRecords.length}-${info.featureId || 'feature'}`,
position: tip,
polyline: {
positions: stemPositionBuffers[0],
width: kind === 'cable' ? 2 : 2.4,
material: new Cesium.ColorMaterialProperty(stemColor),
},
point: {
pixelSize: kind === 'cable' ? 7 : 8,
color: pointColor,
outlineColor: Cesium.Color.BLACK.withAlpha(0.65),
outlineWidth: 1,
disableDepthTestDistance: 0,
},
});
entity.__gevTeleGeography = info;
state._pickByEntity.set(entity, info);
const record = {
id: entity.id,
entity,
base,
tip,
nextTip: Cesium.Cartesian3.clone(tip),
stemPositionBuffers,
stemPositionBufferIndex: 0,
reference,
kind,
label: clampLabel(label),
visible: false,
distanceM: Infinity,
entry: null,
};
record.entry = createCableOverlayEntry(record);
state._referenceRecords.push(record);
}
function updateReferenceVisibility() {
if (!state._enabled || !state._viewer?.camera) return;
const cameraPos = state._viewer.camera.positionWC;
if (!cameraPos) return;
const canvasHeight = state._viewer.scene?.canvas?.clientHeight || 1080;
const fov = state._viewer.camera.frustum?.fov || Math.PI / 3;
const occluder = new Cesium.EllipsoidalOccluder(
Cesium.Ellipsoid.WGS84,
cameraPos,
);
for (const record of state._referenceRecords) {
const visible = occluder.isPointVisible(record.base);
record.visible = visible;
record.distanceM = Cesium.Cartesian3.distance(cameraPos, record.base);
if (record.entity.show !== visible) {
record.entity.show = visible;
}
// Hidden stems keep their last geometry; this same sweep refreshes them
// in the pass where they turn visible again.
if (visible)
updateCableReferenceStem(record, cameraPos, canvasHeight, fov);
}
for (const record of state._surfaceRecords) {
const visible = occluder.isPointVisible(record.base);
if (record.entity.show !== visible) {
record.entity.show = visible;
}
}
const winners = selectCableReferenceLabelWinners(state._referenceRecords);
let changed = winners.length !== state._lastPublishedCount;
for (let i = 0; i < winners.length; i++) {
const record = winners[i];
const priority = cableReferencePriority(record.distanceM);
record.entry.priority = priority;
state._publishScratch[i] = record.entry;
if (
!changed &&
(state._lastPublishedIds[i] !== record.entry.id ||
state._lastPublishedPriorities[i] !== priority)
) {
changed = true;
}
}
state._publishScratch.length = winners.length;
state._referenceLabelCount = winners.length;
// The marker collections are created lazily by the visualizers, so the
// single-pass blend is applied on the sweep cadence until both landed.
if (!state._markerBlendDone) applyMarkerBlendOnce();
if (!changed) return;
state._overlayPublisher.publish(state._publishScratch);
state._lastPublishedCount = winners.length;
for (let i = 0; i < winners.length; i++) {
state._lastPublishedIds[i] = state._publishScratch[i].id;
state._lastPublishedPriorities[i] = state._publishScratch[i].priority;
}
state._lastPublishedIds.length = winners.length;
state._lastPublishedPriorities.length = winners.length;
}
return {
resetPublishSignature,
releaseDataSources,
updateVisibility,
styleCableEntity,
applyMarkerBlendOnce,
applyCableClassification,
styleLandingEntity,
addReferenceStem,
updateReferenceVisibility,
};
}
+107
View File
@@ -0,0 +1,107 @@
import * as Cesium from 'cesium';
import {
createCableReferenceSweepGate,
createCableOverlayPublisher,
} from './overlay.js';
import { BASE_CABLE_COLOR, BASE_LANDING_COLOR } from './policy.js';
export function createState({ sweepClock, overlayHost }) {
const state = {};
state._viewer = null;
state._enabled = false;
state._loading = false;
state._loaded = false;
state._count = 0;
state._error = null;
state._lastUpdate = null;
state._loadingLabel = '';
state._abort = null;
/** Monotonic load-ownership token; every load() start claims a new one. */
state._loadGeneration = 0;
state._cableDataSource = null;
state._landingDataSource = null;
state._referenceDataSource = null;
/**
* Fetched cable/landing JSON kept across disable/enable. A disable RELEASES
* the three data sources (see releaseDataSources) instead of hiding them,
* so a re-enable has to rebuild the entities; the network/parse half of
* that rebuild is the expensive part users would notice, so it is cached.
*/
state._cachedCableJson = null;
state._cachedLandingJson = null;
state._referenceRecords = [];
state._surfaceRecords = [];
state._clickHandler = null;
state._preRenderRemover = null;
state._moveEndRemover = null;
state._mapStackListener = null;
/** Active ground-line classification; refined from live scene state at init. */
state._classificationType = Cesium.ClassificationType.BOTH;
/** True once both marker collections blend translucent (or the shape probe failed). */
state._markerBlendDone = false;
state._markerBlendInvariantWarned = false;
state._pickByEntity = new WeakMap();
state._referenceLabelCount = 0;
state._referenceSweepGate = createCableReferenceSweepGate({
now: sweepClock,
});
state._overlayPublisher = createCableOverlayPublisher({ host: overlayHost });
/** Reused winner→entry scratch so a 2 Hz sweep allocates no arrays. */
state._publishScratch = [];
/**
* Last published cohort signature (ids + quantized priorities). A parked
* camera reproduces the same winners every sweep; skipping the identical
* republish keeps the host from requesting a render per publish, so the
* layer stays render-governor idle exactly like its native predecessor.
* Tip positions need no republish either: the host reprojects the same
* mutable Cartesians on every painted frame.
*/
state._lastPublishedIds = [];
state._lastPublishedPriorities = [];
state._lastPublishedCount = -1;
state.cableColor = Cesium.Color.fromCssColorString(BASE_CABLE_COLOR);
state.cableOutline = Cesium.Color.BLACK.withAlpha(0.45);
state.landingColor = Cesium.Color.fromCssColorString(BASE_LANDING_COLOR);
return state;
}
+126
View File
@@ -0,0 +1,126 @@
import * as Cesium from 'cesium';
import {
CABLE_GLOBE_STACK_IDS,
MARKER_COLLECTION_EXPECTATIONS,
} from './policy.js';
/**
* Ground-line classification for one map stack. The photoreal stack renders
* Google 3D tiles with the Cesium globe HIDDEN, so cable ground lines only
* need the 3D-tile classification pass there; every other known stack (the
* bing stacks and osm) renders imagery on the shown globe, so only the
* terrain pass applies.
* Classifying against just the active surface halves the batched
* GroundPolylinePrimitive's emitted command sets. BOTH is the safe fallback
* for an unknown stack — it renders on every surface, exactly the shipped
* pre-optimization behavior.
* @param {string|null|undefined} activeId MapStackController stack id.
* @returns {Cesium.ClassificationType}
*/
export function cableClassificationTypeForStack(activeId) {
if (activeId === 'photoreal') return Cesium.ClassificationType.CESIUM_3D_TILE;
if (CABLE_GLOBE_STACK_IDS.has(activeId))
return Cesium.ClassificationType.TERRAIN;
return Cesium.ClassificationType.BOTH;
}
/**
* Derive the active surface from live scene state. The boot-time
* `setStack(..., { silent: true })` fires no 'gev:map-stack-changed' event,
* so the initial classification reads the scene the way the height-datum
* listeners do: the photoreal regime is exactly "globe hidden".
* @param {Cesium.Scene|null|undefined} scene
* @returns {Cesium.ClassificationType}
*/
export function cableClassificationTypeForScene(scene) {
if (!scene?.globe) return Cesium.ClassificationType.BOTH;
return scene.globe.show === false
? Cesium.ClassificationType.CESIUM_3D_TILE
: Cesium.ClassificationType.TERRAIN;
}
/**
* Validate one data source's marker collections WITHOUT mutating anything.
* Splitting validation from mutation is what makes the fallback honest: a
* shape failure on the SECOND collection (or the second data source) must
* not leave the first one already forced to TRANSLUCENT.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{ready:object[],pending:number,invariantFailed:boolean}}
*/
export function probeTranslucentMarkerBlend(dataSource) {
const cluster = dataSource?.clustering;
if (!cluster) return { ready: [], pending: 0, invariantFailed: true };
const ready = [];
let pending = 0;
let invariantFailed = false;
for (const { key, type } of MARKER_COLLECTION_EXPECTATIONS) {
if (!(key in cluster)) {
invariantFailed = true;
continue;
}
const collection = cluster[key];
if (collection === undefined || collection === null) {
pending++;
continue;
}
if (!(collection instanceof type) || !('blendOption' in collection)) {
invariantFailed = true;
continue;
}
ready.push(collection);
}
// Any failure discards every collected target: nothing is mutated at all.
if (invariantFailed) return { ready: [], pending: 0, invariantFailed: true };
return { ready, pending, invariantFailed: false };
}
/**
* Commit a clean probe. Only ever called once every probe in the batch has
* passed, so this cannot land a partial application.
* @param {{ready:object[],pending:number}} probe
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function commitTranslucentMarkerBlend(probe) {
for (const collection of probe.ready) {
if (collection.blendOption !== Cesium.BlendOption.TRANSLUCENT) {
collection.blendOption = Cesium.BlendOption.TRANSLUCENT;
}
}
return {
applied: probe.ready.length,
pending: probe.pending,
invariantFailed: false,
};
}
/**
* Force one translucent draw pass for a data source's marker collections.
* The default `OPAQUE_AND_TRANSLUCENT` blend emits two draw commands per
* collection, and every cable marker is translucent (alpha < 1), so the
* opaque pass is pure overhead across 1,917 landing billboards + 2,629
* reference points. Cesium has no public accessor for the entity-backed
* collections, so this reaches through EntityCluster's private fields under
* a SHAPE INVARIANT the caller must honor loudly: the constructor
* pre-assigns `_billboardCollection`/`_pointCollection` (= undefined until a
* visualizer lazily creates them), so a missing PROPERTY means Cesium's
* shape changed — report `invariantFailed` and never touch anything —
* while a present-but-undefined value only means "not created yet"
* (`pending`). Wrong instance types also fail the invariant. The cables
* sources never enable clustering, so created collections persist.
* VALIDATE-ALL-THEN-MUTATE-ALL: every field/type check runs before the first
* assignment, so a failure anywhere leaves BOTH collections on Cesium's
* default blend — the fallback this helper promises.
* @param {Cesium.DataSource|null|undefined} dataSource
* @returns {{applied:number,pending:number,invariantFailed:boolean}}
*/
export function applyTranslucentMarkerBlend(dataSource) {
const probe = probeTranslucentMarkerBlend(dataSource);
if (probe.invariantFailed)
return { applied: 0, pending: 0, invariantFailed: true };
return commitTranslucentMarkerBlend(probe);
}
+312 -149
View File
@@ -34,7 +34,9 @@ function makeElement(tagName = 'div') {
children: [],
classList: {
toggle(name, force) {
const classes = new Set(String(element.className).split(/\s+/).filter(Boolean));
const classes = new Set(
String(element.className).split(/\s+/).filter(Boolean),
);
const next = force === undefined ? !classes.has(name) : !!force;
if (next) classes.add(name);
else classes.delete(name);
@@ -44,15 +46,30 @@ function makeElement(tagName = 'div') {
return String(element.className).split(/\s+/).includes(name);
},
},
appendChild(child) { element.children.push(child); return child; },
setAttribute(name, value) { element.attributes[name] = String(value); },
getAttribute(name) { return element.attributes[name] ?? null; },
addEventListener(type, handler) { (element.listeners[type] ||= []).push(handler); },
click() { for (const handler of element.listeners.click || []) handler(); },
appendChild(child) {
element.children.push(child);
return child;
},
setAttribute(name, value) {
element.attributes[name] = String(value);
},
getAttribute(name) {
return element.attributes[name] ?? null;
},
addEventListener(type, handler) {
(element.listeners[type] ||= []).push(handler);
},
click() {
for (const handler of element.listeners.click || []) handler();
},
};
Object.defineProperty(element, 'innerHTML', {
get() { return ''; },
set() { element.children.length = 0; },
get() {
return '';
},
set() {
element.children.length = 0;
},
});
return element;
}
@@ -60,37 +77,93 @@ function makeElement(tagName = 'div') {
const doc = { createElement: (tagName) => makeElement(tagName) };
/** Text a chip renders, label + optional requirement badge. */
const chipText = (chip) => chip.children.map((child) => child.textContent).join(' ');
const chipText = (chip) =>
chip.children.map((child) => child.textContent).join(' ');
// Shaped exactly like MapStackController.getStacks() output.
const CONTROLLER_STACKS = [
{ id: 'photoreal', label: 'Google 3D', requiresIon: false, available: true, unavailableReason: null },
{ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: true, unavailableReason: null },
{ id: 'bing-labels', label: 'Bing Labels', requiresIon: true, available: true, unavailableReason: null },
{ id: 'esri-imagery', label: 'Esri Satellite', requiresIon: false, available: true, unavailableReason: null },
{ id: 'osm', label: 'OSM', requiresIon: false, available: true, unavailableReason: null },
{
id: 'photoreal',
label: 'Google 3D',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'bing-labels',
label: 'Bing Labels',
requiresIon: true,
available: true,
unavailableReason: null,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
requiresIon: false,
available: true,
unavailableReason: null,
},
{
id: 'osm',
label: 'OSM',
requiresIon: false,
available: true,
unavailableReason: null,
},
];
test('the row renders exactly the five owner-approved sources', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
assert.deepEqual(container.children.map((chip) => chip.dataset.stackId), [
'photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm',
]);
assert.deepEqual(
container.children.map((chip) => chip.dataset.stackId),
['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm'],
);
assert.deepEqual(container.children.map(chipText), [
'Google 3D', 'Bing Aerial', 'Bing Labels', 'Esri Satellite', 'OSM',
'Google 3D',
'Bing Aerial',
'Bing Labels',
'Esri Satellite',
'OSM',
]);
assert.deepEqual(PRESENTED_MAP_STACK_IDS, ['photoreal', 'bing-aerial', 'bing-labels', 'esri-imagery', 'osm']);
assert.ok(container.children.every((chip) => chip.tagName === 'button' && chip.type === 'button'));
assert.ok(container.children.every((chip) => chip.classList.contains(MAP_STACK_CHIP_CLASS)));
assert.deepEqual(PRESENTED_MAP_STACK_IDS, [
'photoreal',
'bing-aerial',
'bing-labels',
'esri-imagery',
'osm',
]);
assert.ok(
container.children.every(
(chip) => chip.tagName === 'button' && chip.type === 'button',
),
);
assert.ok(
container.children.every((chip) =>
chip.classList.contains(MAP_STACK_CHIP_CLASS),
),
);
});
test('internal and future stacks stay outside the approved presentation set', () => {
const container = makeElement();
// A future Hybrid stack may land in the controller, but it must not appear
// until the owner-approved presentation allowlist explicitly includes it.
const withHybrid = [...CONTROLLER_STACKS, { id: 'hybrid', label: 'Hybrid', available: true }];
const withHybrid = [
...CONTROLLER_STACKS,
{ id: 'hybrid', label: 'Hybrid', available: true },
];
renderMapStackChips(container, withHybrid, { activeId: 'photoreal', doc });
assert.equal(container.children.length, 5);
@@ -99,7 +172,10 @@ test('internal and future stacks stay outside the approved presentation set', ()
test('re-rendering replaces the previous chips instead of stacking a second row', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'osm', doc });
assert.equal(container.children.length, PRESENTED_MAP_STACK_IDS.length);
@@ -122,19 +198,32 @@ test('clicking a chip dispatches that stack id — the same selection the dropdo
test('the active chip is the pressed chip, and exactly one is pressed', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'bing-labels', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'bing-labels',
doc,
});
const pressed = container.children.filter((chip) => chip.getAttribute('aria-pressed') === 'true');
assert.deepEqual(pressed.map((chip) => chip.dataset.stackId), ['bing-labels']);
const pressed = container.children.filter(
(chip) => chip.getAttribute('aria-pressed') === 'true',
);
assert.deepEqual(
pressed.map((chip) => chip.dataset.stackId),
['bing-labels'],
);
assert.ok(pressed[0].classList.contains('active'));
assert.ok(container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')));
assert.ok(
container.children
.filter((chip) => chip.dataset.stackId !== 'bing-labels')
.every((chip) => !chip.classList.contains('active')),
);
});
test('the lit chip tracks controller state, not the click', () => {
const container = makeElement();
renderMapStackChips(container, CONTROLLER_STACKS, { activeId: 'photoreal', doc });
renderMapStackChips(container, CONTROLLER_STACKS, {
activeId: 'photoreal',
doc,
});
// A rejected/superseded switch reports the stack that is genuinely active.
syncMapStackChips(container, 'photoreal');
@@ -151,11 +240,16 @@ test('the lit chip tracks controller state, not the click', () => {
test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
const container = makeElement();
const keyless = CONTROLLER_STACKS.map((stack) => (stack.requiresIon ? {
...stack,
available: false,
unavailableReason: 'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
} : stack));
const keyless = CONTROLLER_STACKS.map((stack) =>
stack.requiresIon
? {
...stack,
available: false,
unavailableReason:
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
}
: stack,
);
const selected = [];
renderMapStackChips(container, keyless, {
activeId: 'photoreal',
@@ -164,59 +258,110 @@ test('keyless ion stacks stay focusable, aria-disabled, and say why', () => {
});
const bingAerial = container.children[1];
assert.equal(bingAerial.disabled, false, 'unavailable sources remain keyboard-reachable');
assert.equal(
bingAerial.disabled,
false,
'unavailable sources remain keyboard-reachable',
);
assert.equal(bingAerial.getAttribute('aria-disabled'), 'true');
assert.equal(
bingAerial.getAttribute('aria-label'),
'Bing Aerial unavailable: Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.ok(bingAerial.classList.contains('unavailable'));
assert.equal(bingAerial.title, 'Needs CESIUM_ION_TOKEN — add it in Provider Settings');
assert.equal(
bingAerial.title,
'Needs CESIUM_ION_TOKEN — add it in Provider Settings',
);
assert.equal(chipText(bingAerial), 'Bing Aerial ION');
bingAerial.click();
assert.deepEqual(selected, [], 'an unavailable stack must not reach the switch path');
assert.deepEqual(
selected,
[],
'an unavailable stack must not reach the switch path',
);
assert.equal(container.children[3].getAttribute('aria-disabled'), 'false', 'OSM stays selectable');
assert.equal(
container.children[3].getAttribute('aria-disabled'),
'false',
'OSM stays selectable',
);
});
test('a non-ion stack that fails never claims an ion token is required', () => {
// The startup fallback-to-OSM case: Google 3D tiles failed to load, so
// photoreal is unavailable for a reason that has nothing to do with ion.
const container = makeElement();
const tilesFailed = CONTROLLER_STACKS.map((stack) => (stack.id === 'photoreal' ? {
...stack,
available: false,
unavailableReason: 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
} : stack));
const tilesFailed = CONTROLLER_STACKS.map((stack) =>
stack.id === 'photoreal'
? {
...stack,
available: false,
unavailableReason:
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
}
: stack,
);
renderMapStackChips(container, tilesFailed, { activeId: 'osm', doc });
const google = container.children[0];
assert.equal(google.getAttribute('aria-disabled'), 'true');
assert.equal(google.getAttribute('aria-label'), 'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(google), 'Google 3D', 'no ION badge on a stack that does not need ion');
assert.equal(google.title, 'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings');
assert.equal(chipText(container.children[1]), 'Bing Aerial', 'available ion stacks stay unbadged');
assert.equal(
google.getAttribute('aria-label'),
'Google 3D unavailable: Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(google),
'Google 3D',
'no ION badge on a stack that does not need ion',
);
assert.equal(
google.title,
'Needs GOOGLE_MAPS_API_KEY — add it in Provider Settings',
);
assert.equal(
chipText(container.children[1]),
'Bing Aerial',
'available ion stacks stay unbadged',
);
});
test('models carry the stack\'s own reason and never invent an active chip', () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
}]);
test("models carry the stack's own reason and never invent an active chip", () => {
assert.deepEqual(mapStackChipModels([{ id: 'osm', label: 'OSM' }], null), [
{
id: 'osm',
label: 'OSM',
available: true,
active: false,
requiresIon: false,
requirement: '',
unavailableHint: '',
title: 'OSM',
},
]);
// A stack list without a controller-supplied reason still explains itself.
assert.deepEqual(
[
mapStackChipModel({ id: 'bing-aerial', label: 'Bing Aerial', requiresIon: true, available: false }, null),
mapStackChipModel({ id: 'hybrid', label: 'Hybrid', available: false }, null),
].map(({ requirement, unavailableHint, title }) => ({ requirement, unavailableHint, title })),
mapStackChipModel(
{
id: 'bing-aerial',
label: 'Bing Aerial',
requiresIon: true,
available: false,
},
null,
),
mapStackChipModel(
{ id: 'hybrid', label: 'Hybrid', available: false },
null,
),
].map(({ requirement, unavailableHint, title }) => ({
requirement,
unavailableHint,
title,
})),
[
{
requirement: 'ION',
@@ -234,31 +379,12 @@ test('models carry the stack\'s own reason and never invent an active chip', ()
assert.deepEqual(mapStackChipModels(undefined, 'osm'), []);
});
test('the controller is the single source of the unavailability reason', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.doesNotMatch(
controller,
/bing-road/,
'Bing Road is retired: an old map=bing-road link must take the unknown-id photoreal fallback',
);
assert.match(
controller,
/getStacks\(\) \{[\s\S]*?unavailableReason: available \? null : this\._unavailableReason\(stack\)/,
'getStacks() must expose why a stack is unavailable, not just that it is',
);
assert.match(
controller,
/if \(!this\.isStackAvailable\(stack\.id\)\) \{\s*const message = this\._unavailableReason\(stack\);/,
'the toast and the chip tooltip must read the same reason',
);
assert.match(controller, /_unavailableReason\(stack\) \{[\s\S]*?stack\?\.requiresIon/);
});
test('a missing row or document is inert rather than throwing during boot', () => {
assert.deepEqual(renderMapStackChips(null, CONTROLLER_STACKS, { doc }), []);
assert.deepEqual(renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }), []);
assert.deepEqual(
renderMapStackChips(makeElement(), CONTROLLER_STACKS, { doc: {} }),
[],
);
assert.doesNotThrow(() => syncMapStackChips(null, 'osm'));
});
@@ -268,8 +394,14 @@ test('the active cyan survives hover', () => {
const active = css.indexOf('.map-stack-chip.active {');
const unavailable = css.indexOf('.map-stack-chip.unavailable');
assert.ok(hover > 0 && active > hover, 'active must follow hover so it wins at equal specificity');
assert.ok(unavailable > active, 'unavailable must follow both so a keyless chip never lights up');
assert.ok(
hover > 0 && active > hover,
'active must follow hover so it wins at equal specificity',
);
assert.ok(
unavailable > active,
'unavailable must follow both so a keyless chip never lights up',
);
assert.doesNotMatch(
css.slice(hover, active),
/:not\(/,
@@ -285,18 +417,25 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// chip-state rule sets.
const css = readStylesheet(new URL('../style.css', import.meta.url));
const stripComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '');
const chipRules = [...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g)]
.map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
const chipRules = [
...css.matchAll(/([^{}]*\.map-stack-chip[^{}]*)\{([^{}]*)\}/g),
].map(([, selector, body], order) => ({
selector: stripComments(selector).trim(),
body: stripComments(body),
order,
}));
assert.ok(chipRules.length >= 5, 'expected the chip state rules to be found');
const ringIndex = chipRules.findIndex(({ selector, body }) => selector.includes(':focus-visible')
&& /outline:\s*(?!none)\S/.test(body)
&& /outline-offset:/.test(body));
assert.ok(ringIndex >= 0, 'a :focus-visible rule must draw a real outline ring');
const ringIndex = chipRules.findIndex(
({ selector, body }) =>
selector.includes(':focus-visible') &&
/outline:\s*(?!none)\S/.test(body) &&
/outline-offset:/.test(body),
);
assert.ok(
ringIndex >= 0,
'a :focus-visible rule must draw a real outline ring',
);
// Nothing after it may touch outline again, so the ring cannot be erased by
// .active, :disabled, or anything added later.
@@ -307,7 +446,10 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
`"${rule.selector}" must not touch outline — it would erase the focus ring`,
);
}
for (const selector of ['.map-stack-chip.active', '.map-stack-chip.unavailable']) {
for (const selector of [
'.map-stack-chip.active',
'.map-stack-chip.unavailable',
]) {
assert.ok(
chipRules.some((rule) => rule.selector.includes(selector)),
`expected a ${selector} rule to exist for this check to mean anything`,
@@ -317,83 +459,104 @@ test('the keyboard focus ring survives on the ACTIVE chip', () => {
// `transition: all` animates outline-width off the `outline: none` base, and
// Chrome parks that transition at 0px for chips on a wrapped line — the ring
// never appeared on rows 2 and 3. The base rule must list its properties.
const base = chipRules.find((rule) => rule.selector.endsWith('.map-stack-chip'));
const base = chipRules.find((rule) =>
rule.selector.endsWith('.map-stack-chip'),
);
assert.ok(base, 'expected the base .map-stack-chip rule');
assert.doesNotMatch(
base.body,
/transition:\s*all\b/,
'transition: all animates outline-width and kills the focus ring on wrapped rows',
);
assert.doesNotMatch(base.body, /transition:[^;]*outline/, 'the focus ring must not be animated');
assert.match(base.body, /transition:\s*\n?\s*color/, 'the hover/active treatment still animates');
assert.doesNotMatch(
base.body,
/transition:[^;]*outline/,
'the focus ring must not be animated',
);
assert.match(
base.body,
/transition:\s*\n?\s*color/,
'the hover/active treatment still animates',
);
});
test('the Visual Presets tray owns Map Source and the retired left panel is absent', () => {
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const ui = readFileSync(
new URL('./ui/applicationShell.js', import.meta.url),
'utf8',
);
assert.doesNotMatch(html, /map-stack-select/, 'the SOURCE dropdown is replaced by the chip row');
assert.doesNotMatch(
html,
/map-stack-select/,
'the SOURCE dropdown is replaced by the chip row',
);
assert.match(
html,
/<section class="map-source-section"[\s\S]*?<div id="map-stack-chips" class="map-stack-chip-row" role="group" aria-label="Map source"><\/div>/,
);
assert.doesNotMatch(html, /id="stack-panel"/, 'the duplicate left MAP STACK panel is retired');
assert.match(html, /id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/);
assert.doesNotMatch(
html,
/id="stack-panel"/,
'the duplicate left MAP STACK panel is retired',
);
assert.match(
html,
/id="map-source-label">MAP SOURCE<[\s\S]*?id="map-stack-status"/,
);
assert.match(
html,
/<button id="control-panel-toggle"[\s\S]*?data-dock-toggle-target="control-panel"[\s\S]*?aria-controls="control-panel-popover"/,
'the compact wing must expose a semantic keyboard disclosure',
);
const panels = readFileSync(new URL('./ui/panelDisclosure.js', import.meta.url), 'utf8');
const panels = readFileSync(
new URL('./ui/panelDisclosure.js', import.meta.url),
'utf8',
);
assert.match(panels, /event\.key !== 'Escape'[\s\S]*?disclosure\?\.focus/);
assert.match(ui, /querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/);
const controls = readFileSync(new URL('./ui/mapSourceControls.js', import.meta.url), 'utf8');
assert.match(ui, /return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path');
assert.match(controls, /onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path');
assert.match(controls, /await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller');
assert.match(controls, /render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state');
assert.match(ui, /window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click');
assert.match(ui, /window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover');
assert.match(controls, /unsubscribe\?\.\(\)/,
'component destruction releases its subscription');
});
test('Esri fallbacks report and attribute the imagery source actually rendered', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(
controller,
/effectiveStackId = 'osm';[\s\S]*?fallbackMessage = 'Esri Satellite is unavailable; using OSM'/,
'construction failure must resolve truthfully to OSM',
ui,
/querySelector\(\s*'\.map-stack-chip\.active',?\s*\)\s*\|\|\s*panel\.querySelector\('\.map-stack-chip'\)/,
);
const controls = readFileSync(
new URL('./ui/mapSourceControls.js', import.meta.url),
'utf8',
);
assert.match(
controller,
/this\._activeId = activation\?\.effectiveStackId \|\| stack\.id/,
'the active chip must follow the effective provider, not the requested stack',
ui,
/return this\._mapSourceControls\.select\(stackId, \{ syncShare \}\)/,
'the application action uses the component selection path',
);
assert.match(
controller,
/this\._syncEsriAttribution\(resolution\.effectiveStackId\)/,
'Esri credit must follow the effective provider',
controls,
/onSelect: [\s\S]*?select\(id\)/,
'chip clicks use the same component selection path',
);
});
test('repeated active Esri tile failures fall back to OSM and one transient does not', () => {
const controller = readFileSync(new URL('./mapStackController.js', import.meta.url), 'utf8');
assert.match(controller, /let failures = 0/);
assert.match(controller, /if \(failures < 2 \|\| this\._esriFallbackPending\) return/);
assert.match(controller, /this\.setStack\('osm', \{ silent: true \}\)/);
assert.match(controller, /state\?\.activeId === 'osm'[\s\S]*?this\._emitChange\('error'\)/);
assert.match(
controller,
/gen !== this\._switchGen \|\| this\._activeImageryProvider !== resolution\.provider/,
'a stale provider error must not replace a newer user selection',
controls,
/await controller\.setStack\(stackId\)/,
'selection still delegates source loading to the map controller',
);
assert.match(
controls,
/render\(controller\.getState\(\)\)/,
'the active chip is re-synced from actual controller state',
);
assert.match(
ui,
/window\.addEventListener\('gev:map-stack-changed', onChange\)/,
'provider-driven fallback reaches the component without a user click',
);
assert.match(
ui,
/window\.removeEventListener\('gev:map-stack-changed', onChange\)/,
'the provider-driven subscription has an explicit remover',
);
assert.match(
controls,
/unsubscribe\?\.\(\)/,
'component destruction releases its subscription',
);
});
+25 -487
View File
@@ -1,494 +1,32 @@
import * as Cesium from 'cesium';
import { MapSourceController } from './maps/controller.js';
import { createDefaultMapSources } from './maps/defaultSources.js';
import { governorRequestRender } from './renderGovernor.js';
import { keySetupRequirement } from './keySetupCore.mjs';
export { MAP_STACKS } from './maps/catalog.js';
export { photorealUnavailableReason } from './maps/availability.js';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials) return 'Google 3D tiles unavailable — check the key\'s API restrictions, quota, or network';
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
const DEFAULT_OSM_CREDIT = '© OpenStreetMap contributors';
// Esri World Imagery — the keyless satellite basemap and the default keyless
// landing (a spy-satellite simulator should open on satellite imagery, not a
// street map). The classic ArcGIS Online tile service answers without a key;
// attribution is required and the provider carries the service's own credit
// line. Terms note in DATA_SOURCES.md.
const ESRI_WORLD_IMAGERY_URL =
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer';
const ESRI_IMAGERY_CREDIT =
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community';
// The on-screen notice Esri requires when a third-party library draws its
// service. Rendered via an explicit static credit (see _syncEsriAttribution) —
// the provider's own `credit` option is ignored for tiled ArcGIS servers.
const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
// Keyless global ellipsoidal terrain (Re:Earth Terrain / Mapterhorn, CC BY 4.0,
// EGM2008 geoid via NGA) — quantized-mesh 1.0, `ellipsoid` data-type. Fixes
// regime C (keyless globe stacks previously rendered a flat
// EllipsoidTerrainProvider — see docs/superpowers/specs/2026-07-05-entity-height-datum-design.md
// §1a). Constructed via `.fromUrl()`, never a hand-built `{z}/{x}/{y}.terrain`
// URL (review correction, spec §1a).
const REEARTH_TERRAIN_URL = 'https://terrain.reearth.land/cesium-mesh/ellipsoid';
/**
* Controls the active globe/map stack. Google Photorealistic 3D Tiles remain
* the cinematic default, while Cesium ion world imagery and OSM run as globe
* imagery stacks.
*/
export class MapStackController {
constructor(viewer, {
googleTileset = null,
cesiumToken = '',
initialStack = 'photoreal',
onChange = null,
onError = null,
} = {}) {
this.viewer = viewer;
this.googleTileset = googleTileset;
this.cesiumToken = String(cesiumToken || '').trim();
this._onChange = onChange;
this._onError = onError;
this._activeId = googleTileset ? initialStack : 'esri-imagery';
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._esriFallbackPending = false;
this._imageryProviders = new Map();
this._isSwitching = false;
this._lastError = null;
// Tracks which terrain PROVIDER is actually installed on the scene, not
// just an ion-available boolean: 'world' (Cesium World Terrain, ion
// token), 'keyless' (Re:Earth or its Ellipsoid fallback), or null (never
// set yet — Cesium's own startup default). Using a tri-state here (rather
// than the `enabled` boolean `_setWorldTerrainEnabled` receives) matters
// because both the "never set" and "keyless" states pass `enabled=false`;
// collapsing them to a boolean would make the first real keyless switch
// a no-op against the initial `false` default and leave Cesium's built-in
// provider in place instead of installing Re:Earth terrain.
this._terrainMode = null;
// Cache of the constructed keyless Re:Earth CesiumTerrainProvider, so
// repeat switches into a keyless globe stack don't refetch `layer.json`.
// Lives independently of `_switchGen` — construction is async and racy
// switches are guarded where it's awaited (`_setWorldTerrainEnabled`).
this._reearthTerrainProvider = null;
// Monotonic switch counter. setStack() awaits network-bound provider
// creation; a rapid A→B switch where A (e.g. slow Bing) resolves AFTER B
// (fast OSM) would otherwise revert the user's last choice (M7). Each call
// captures a generation and aborts its own commit once superseded.
this._switchGen = 0;
if (!this.getStack(this._activeId) || !this.isStackAvailable(this._activeId)) {
this._activeId = googleTileset ? 'photoreal' : 'esri-imagery';
}
}
getStacks() {
return MAP_STACKS.map((stack) => {
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
// Why this stack can't be picked, from the ONE place that decides it.
// A stack can be unavailable for reasons other than a missing ion
// token (photoreal is unavailable when the Google tileset failed to
// load), so callers must not infer the reason from `available` alone.
unavailableReason: available ? null : this._unavailableReason(stack),
};
/** Preserve the standalone entry point; applications can compose the source controller directly. */
export class MapStackController extends MapSourceController {
constructor(viewer, options = {}) {
const googleApiKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
const registry = createDefaultMapSources({ ...options, googleApiKey });
super(viewer, {
registry,
initialStack: options.googleTileset
? options.initialStack || 'photoreal'
: registry.defaultId,
...options,
requestRender: governorRequestRender,
});
this.googleTileset = options.googleTileset || null;
this.cesiumToken = String(options.cesiumToken || '').trim();
}
/**
* Human-readable reason a stack can't be activated. Shared by `getStacks()`
* and `setStack()` so the tooltip and the toast never drift apart.
* @param {object} stack - Stack descriptor.
* @returns {string}
*/
_unavailableReason(stack) {
if (stack?.requiresIon) return keySetupRequirement('cesium-ion');
if (stack?.kind === 'photoreal') return photorealUnavailableReason(this._hasPhotorealCredentials());
return `${stack?.label || 'This map stack'} is unavailable`;
}
/** A direct Google key or an ion token is enough to attempt Google 3D. */
_hasPhotorealCredentials() {
const googleKey = typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return Boolean(String(googleKey || '').trim()) || Boolean(String(this.cesiumToken || '').trim());
}
getStack(id) {
return MAP_STACKS.find((stack) => stack.id === id) || null;
}
getActiveId() {
return this._activeId;
}
/**
* Monotonic id of the most recently STARTED switch.
*
* A switch is only superseded by another `setStack()` — nothing else moves
* this number — so a caller that must know whether the globe it is looking
* at is still the one IT asked for can compare this across its own await.
* Unchanged (or advanced by exactly its own call) means no newer switch has
* claimed the globe.
* @returns {number}
*/
getSwitchGeneration() {
return this._switchGen;
}
getActiveStack() {
return this.getStack(this._activeId);
}
isStackAvailable(id) {
const stack = this.getStack(id);
if (!stack) return false;
if (stack.kind === 'photoreal') return !!this.googleTileset;
if (stack.requiresIon) return !!this.cesiumToken;
return true;
}
/** Invalidate pending switches before the viewer is destroyed. */
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._isSwitching = false;
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
this._onChange = null;
this._onError = null;
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack('photoreal');
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
let activation = null;
if (stack.kind === 'photoreal') {
await this._activatePhotoreal(gen);
} else {
activation = await this._activateGlobeStack(stack, gen);
}
// A newer switch started while we were awaiting the provider — that call
// owns the final state now, so don't commit ours or emit a stale 'ready'.
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
// Show/hide of tilesets + imagery swaps need a frame in idle mode;
// subsequent tile loads self-request via Cesium. (perf wave 2)
governorRequestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
if (this.googleTileset) {
await this._activatePhotoreal(gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = 'photoreal';
}
if (!silent) this._emitChange('error');
} finally {
// Only the latest switch clears the switching flag; a superseded call
// must not stomp a newer switch that is still in progress.
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
hasCesiumIonToken: !!this.cesiumToken,
};
}
async _activatePhotoreal(gen) {
this._removeImageryLayer();
this._syncEsriAttribution(null); // Esri is no longer on screen.
if (this.googleTileset) this.googleTileset.show = true;
this.viewer.scene.globe.show = false;
// Terrain is left UNTOUCHED here. The photoreal globe is hidden
// (`globe.show = false`), so the terrain provider is inert — it renders and
// streams nothing. Routing this through `_setWorldTerrainEnabled(false)`
// would make the DEFAULT startup stack await a keyless Re:Earth `layer.json`
// fetch it can't use, delaying photoreal boot on a slow/blocked network and
// (on failure) caching the flat `EllipsoidTerrainProvider` fallback for
// later OSM switches. The Re:Earth fetch is therefore lazy: it happens on
// the first switch to an actual globe stack (`_activateGlobeStack`).
// `_terrainMode` is intentionally not changed — every globe-stack transition
// re-derives the correct provider from it (null/'world'/'keyless'), so
// leaving it as-is keeps the next switch correct without a photoreal fetch.
void gen;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
// A newer switch started while the provider was resolving — don't touch the
// scene's imagery layers, the winning switch already owns them (M7).
if (gen != null && gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = new Cesium.ImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
this._syncEsriAttribution(resolution.effectiveStackId);
this._watchEsriProvider(resolution, gen);
if (this.googleTileset) this.googleTileset.show = false;
this.viewer.scene.globe.show = true;
await this._setWorldTerrainEnabled(!!this.cesiumToken, gen);
return resolution;
}
/**
* Show or hide the required "Powered by Esri" notice with the Esri layer's
* own lifecycle.
*
* This cannot ride on the provider's `credit` option: Cesium IGNORES that
* option for tiled ArcGIS MapServer sources, so passing it there displays
* nothing and the app would be using the service without the attribution
* Esri requires of third-party libraries. It is an ON-SCREEN credit (not the
* lightbox, where per-layer data credits live) because that is what the
* requirement asks for, and it is removed when another stack takes over so
* the globe never claims a source it is not showing.
*/
_syncEsriAttribution(activeStackId) {
const creditDisplay = this.viewer?.scene?.frameState?.creditDisplay;
if (!creditDisplay) return;
const wanted = activeStackId === 'esri-imagery';
if (wanted === !!this._esriCreditShown) return;
if (!this._esriCredit) {
this._esriCredit = new Cesium.Credit(ESRI_ATTRIBUTION_HTML, true);
}
try {
if (wanted) creditDisplay.addStaticCredit(this._esriCredit);
else creditDisplay.removeStaticCredit(this._esriCredit);
this._esriCreditShown = wanted;
} catch {
// A Cesium build without static-credit removal must not break switching.
}
}
async _getImageryProvider(stack) {
if (this._imageryProviders.has(stack.id)) {
return this._imageryProviders.get(stack.id);
}
let provider;
let effectiveStackId = stack.id;
let fallbackMessage = null;
if (stack.kind === 'ion') {
provider = await Cesium.createWorldImageryAsync({ style: stack.style });
} else if (stack.kind === 'esri-imagery') {
try {
provider = await Cesium.ArcGisMapServerImageryProvider.fromUrl(ESRI_WORLD_IMAGERY_URL, {
credit: ESRI_IMAGERY_CREDIT,
enablePickFeatures: false,
});
} catch (error) {
// The keyless DEFAULT landing must never strand a first run on a blank
// globe because Esri is unreachable — fall back to OSM tiles for this
// session. (The fallback is cached under this stack id like any other
// provider, so the session won't re-probe Esri; a restart does.)
console.warn('[MapStack] Esri World Imagery unavailable, falling back to OSM:', error?.message || error);
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
effectiveStackId = 'osm';
fallbackMessage = 'Esri Satellite is unavailable; using OSM';
}
} else if (stack.kind === 'osm') {
provider = new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: DEFAULT_OSM_CREDIT,
});
} else {
throw new Error(`Unsupported map stack: ${stack.id}`);
}
const resolution = { provider, effectiveStackId, fallbackMessage };
this._imageryProviders.set(stack.id, resolution);
if (effectiveStackId === 'osm' && !this._imageryProviders.has('osm')) {
this._imageryProviders.set('osm', { provider, effectiveStackId: 'osm', fallbackMessage: null });
}
return resolution;
}
/**
* Esri provider construction can succeed while its first tile requests fail.
* Two failures for the active provider trigger the same truthful OSM fallback
* as a construction failure; one transient error is left to Cesium's retry.
*/
_watchEsriProvider(resolution, gen) {
if (resolution.effectiveStackId !== 'esri-imagery') return;
const errorEvent = resolution.provider?.errorEvent;
if (!errorEvent?.addEventListener) return;
let failures = 0;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (gen !== this._switchGen || this._activeImageryProvider !== resolution.provider) return;
const retryCount = Number(error?.timesRetried);
failures = Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < 2 || this._esriFallbackPending) return;
this._esriFallbackPending = true;
const message = 'Esri Satellite tile requests failed; using OSM';
this._onError?.(message, this.getStack('esri-imagery'));
void this.setStack('osm', { silent: true }).then((state) => {
if (state?.activeId === 'osm') {
this._lastError = message;
this._emitChange('error');
}
}).finally(() => {
this._esriFallbackPending = false;
});
});
}
_removeImageryLayer() {
if (this._removeImageryErrorListener) {
this._removeImageryErrorListener();
this._removeImageryErrorListener = null;
}
if (!this._imageryLayer) return;
this.viewer.imageryLayers.remove(this._imageryLayer, false);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
/**
* Sets the scene's terrain provider for the current globe stack.
*
* `enabled` selects Cesium World Terrain (ion token present — regime B,
* unchanged). Disabled/keyless (regime C: OSM or any globe stack without an
* ion token) now tries the keyless Re:Earth ellipsoidal terrain instead of
* the flat `EllipsoidTerrainProvider`, falling back to the flat provider
* (today's behavior) if construction fails — no worse than before this fix.
*
* `CesiumTerrainProvider.fromUrl()` is async (fetches `layer.json`), so this
* method is async-safe: `gen` is the caller's switch generation (from
* `setStack`'s `_switchGen`, threaded through `_activatePhotoreal` /
* `_activateGlobeStack`, mirroring the M7 pattern in `_activateGlobeStack`
* for imagery providers). If a newer switch starts while the Re:Earth
* fetch is in flight, this call's result is discarded instead of
* clobbering the newer switch's terrain.
* @param {boolean} enabled
* @param {number} [gen] — switch generation this call belongs to
*/
async _setWorldTerrainEnabled(enabled, gen) {
const targetMode = enabled ? 'world' : 'keyless';
if (targetMode === this._terrainMode) return;
if (enabled) {
this.viewer.scene.setTerrain(Cesium.Terrain.fromWorldTerrain({
requestVertexNormals: true,
}));
} else {
const provider = await this._getKeylessTerrainProvider();
// A newer switch started while the Re:Earth layer.json fetch was in
// flight — that call owns terrain now; don't stomp it (M7 pattern).
if (gen != null && gen !== this._switchGen) return;
this.viewer.terrainProvider = provider;
}
this._terrainMode = targetMode;
}
/**
* Resolves (and caches) the keyless terrain provider for globe stacks
* without an ion token: Re:Earth ellipsoidal quantized-mesh terrain, or
* `EllipsoidTerrainProvider` (flat — current/prior behavior) if the
* Re:Earth endpoint can't be constructed. Never throws.
* @returns {Promise<Cesium.TerrainProvider>}
*/
async _getKeylessTerrainProvider() {
if (this._reearthTerrainProvider) return this._reearthTerrainProvider;
try {
this._reearthTerrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(REEARTH_TERRAIN_URL);
} catch (error) {
console.warn('[mapStackController] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:', error);
this._reearthTerrainProvider = new Cesium.EllipsoidTerrainProvider();
}
return this._reearthTerrainProvider;
}
_emitChange(status) {
this._onChange?.(this.getState(status));
const googleKey =
typeof window !== 'undefined' ? window.__GOOGLE_MAPS_API_KEY__ : '';
return (
Boolean(String(googleKey || '').trim()) ||
Boolean(String(this.cesiumToken || '').trim())
);
}
}
+8 -2
View File
@@ -1,9 +1,15 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapStackController, photorealUnavailableReason } from './mapStackController.js';
import {
MapStackController,
photorealUnavailableReason,
} from './mapStackController.js';
test('missing photoreal credentials explain both supported setup routes', () => {
assert.match(photorealUnavailableReason(false), /Needs GOOGLE_MAPS_API_KEY.*Provider Settings/);
assert.match(
photorealUnavailableReason(false),
/Needs GOOGLE_MAPS_API_KEY.*Provider Settings/,
);
assert.match(photorealUnavailableReason(false), /Cesium ion token/);
});
+4 -51
View File
@@ -1,51 +1,4 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({ googleApiKey = '', cesiumToken = '' } = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
if (ionToken) Cesium.Ion.defaultAccessToken = ionToken;
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
Cesium.GoogleMaps.defaultApiKey = attempt.googleKey;
try {
const tileset = await Cesium.createGooglePhotorealistic3DTileset({
onlyUsingWithGoogleGeocoder: true,
});
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
Cesium.GoogleMaps.defaultApiKey = undefined;
return { tileset: null, route: 'osm', errors };
}
export {
selectMapStartupRoute,
loadPhotorealisticTileset,
} from './maps/google3d.js';
+50 -12
View File
@@ -7,25 +7,42 @@ import {
function fakeCesium(outcomes = []) {
const calls = [];
const next = () => {
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
};
return {
calls,
Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined },
async createGooglePhotorealistic3DTileset(options) {
calls.push({
options,
googleKey: this.GoogleMaps.defaultApiKey,
ionToken: this.Ion.defaultAccessToken,
});
const outcome = outcomes.shift();
if (outcome instanceof Error) throw outcome;
return outcome;
calls.push({ options, googleKey: options.key });
return next();
},
IonResource: {
async fromAssetId(assetId, options) {
return { assetId, ...options };
},
},
Cesium3DTileset: {
async fromUrl(resource, options) {
calls.push({
options,
ionToken: resource.accessToken,
assetId: resource.assetId,
});
return next();
},
},
};
}
test('map startup route reflects the best configured provider', () => {
assert.equal(selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }), 'google-direct');
assert.equal(
selectMapStartupRoute({ googleApiKey: 'google', cesiumToken: 'ion' }),
'google-direct',
);
assert.equal(selectMapStartupRoute({ cesiumToken: 'ion' }), 'google-ion');
assert.equal(selectMapStartupRoute(), 'osm');
});
@@ -53,13 +70,17 @@ test('a direct Google key is preferred', async () => {
test('an ion-only setup loads the hosted Google 3D asset', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, { cesiumToken: 'ion-secret' });
const result = await loadPhotorealisticTileset(Cesium, {
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-ion');
assert.equal(Cesium.calls.length, 1);
assert.equal(Cesium.calls[0].googleKey, undefined);
assert.equal(Cesium.calls[0].ionToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, 'ion-secret');
assert.equal(Cesium.Ion.defaultAccessToken, undefined);
assert.equal(Cesium.calls[0].assetId, 2275207);
assert.equal(Cesium.calls[0].options.enableCollision, true);
});
test('a failed direct request retries through ion before falling back', async () => {
@@ -91,7 +112,10 @@ test('a failed direct-only request does not consume an implicit Cesium token', a
});
test('failed direct and ion requests preserve the keyless OSM fallback', async () => {
const Cesium = fakeCesium([new Error('direct denied'), new Error('ion denied')]);
const Cesium = fakeCesium([
new Error('direct denied'),
new Error('ion denied'),
]);
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
cesiumToken: 'ion-secret',
@@ -102,3 +126,17 @@ test('failed direct and ion requests preserve the keyless OSM fallback', async (
assert.equal(Cesium.calls.length, 2);
assert.equal(Cesium.GoogleMaps.defaultApiKey, undefined);
});
test('independent source configurations never mutate shared SDK credentials', async () => {
const Cesium = fakeCesium([{ id: 'a' }, { id: 'b' }]);
Cesium.Ion.defaultAccessToken = 'untouched-ion';
Cesium.GoogleMaps.defaultApiKey = 'untouched-google';
await Promise.all([
loadPhotorealisticTileset(Cesium, { googleApiKey: 'source-a' }),
loadPhotorealisticTileset(Cesium, { cesiumToken: 'source-b' }),
]);
assert.equal(Cesium.calls[0].googleKey, 'source-a');
assert.equal(Cesium.calls[1].ionToken, 'source-b');
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
});
+15
View File
@@ -0,0 +1,15 @@
import { keySetupRequirement } from '../keySetupCore.mjs';
/**
* Why Google 3D is unavailable, phrased so the tooltip and toast recommend the
* RIGHT fix. With no credentials the fix is a key (or the ion route); with a
* key or ion token configured, the tileset failed for another reason —
* restrictions, quota, an EEA-billed key, or the network — and telling the
* user to add a key they already added is the wrong advice.
* @param {boolean} hasCredentials
* @returns {string}
*/
export function photorealUnavailableReason(hasCredentials) {
if (hasCredentials)
return "Google 3D tiles unavailable — check the key's API restrictions, quota, or network";
return `${keySetupRequirement('google-maps')} — or a Cesium ion token for the ion-hosted route`;
}
+40
View File
@@ -0,0 +1,40 @@
import * as Cesium from 'cesium';
export const MAP_STACKS = [
{
id: 'photoreal',
label: 'Google 3D',
shortLabel: '3D',
kind: 'photoreal',
requiresIon: false,
},
{
id: 'bing-aerial',
label: 'Bing Aerial',
shortLabel: 'Aerial',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL,
requiresIon: true,
},
{
id: 'bing-labels',
label: 'Bing Labels',
shortLabel: 'Labels',
kind: 'ion',
style: Cesium.IonWorldImageryStyle.AERIAL_WITH_LABELS,
requiresIon: true,
},
{
id: 'esri-imagery',
label: 'Esri Satellite',
shortLabel: 'SAT',
kind: 'esri-imagery',
requiresIon: false,
},
{
id: 'osm',
label: 'OSM',
shortLabel: 'OSM',
kind: 'osm',
requiresIon: false,
},
];
+337
View File
@@ -0,0 +1,337 @@
import { indexMapSources } from './registry.js';
import * as Cesium from 'cesium';
import { createMapCredits } from './credits.js';
/** Coordinate source lifetimes and scene changes; the registry owns provider choices. */
export class MapSourceController {
constructor(
viewer,
{
registry,
initialStack,
onChange = null,
onError = null,
requestRender = () => viewer?.scene?.requestRender?.(),
createImageryLayer = (provider) => new Cesium.ImageryLayer(provider),
},
) {
this.viewer = viewer;
this._registry = registry;
this._sources = indexMapSources(registry.sources);
this._activeId = this.isStackAvailable(initialStack)
? initialStack
: registry.defaultId;
this._onChange = onChange;
this._onError = onError;
this._requestRender = requestRender;
this._createImageryLayer = createImageryLayer;
this._credits = createMapCredits(viewer);
this._abort = new AbortController();
this._imageryProviders = new Map();
this._terrainProviders = new Map();
this._tilesets = new Map();
this._ownedTilesets = new Set();
this._disposed = new WeakSet();
this._switchGen = 0;
this._isSwitching = false;
this._lastError = null;
this._imageryLayer = null;
this._activeImageryProvider = null;
this._removeImageryErrorListener = null;
this._terrainMode = null;
this._destroyed = false;
}
getStack(id) {
return this._sources.get(id)?.descriptor || null;
}
getStacks() {
return [...this._sources.values()].map(({ descriptor }) => {
const stack = descriptor;
const available = this.isStackAvailable(stack.id);
return {
...stack,
available,
unavailableReason: available ? null : this._unavailableReason(stack),
};
});
}
isStackAvailable(id) {
const source = this._sources.get(id);
return Boolean(source && source.available !== false);
}
_unavailableReason(stack) {
return (
this._sources.get(stack?.id)?.unavailableReason ||
`${stack?.label || 'This map stack'} is unavailable`
);
}
getActiveId() {
return this._activeId;
}
getActiveStack() {
return this.getStack(this._activeId);
}
getSwitchGeneration() {
return this._switchGen;
}
getState(status = this._isSwitching ? 'switching' : 'ready') {
return {
activeId: this._activeId,
activeStack: this.getActiveStack(),
stacks: this.getStacks(),
status,
lastError: this._lastError,
...this._registry.state,
};
}
_emitChange(status) {
this._onChange?.(this.getState(status));
}
async setStack(id, { silent = false } = {}) {
if (this._destroyed) return this.getState();
const stack = this.getStack(id) || this.getStack(this._registry.unknownId);
if (!stack) return null;
if (!this.isStackAvailable(stack.id)) {
const message = this._unavailableReason(stack);
this._lastError = message;
this._onError?.(message, stack);
return this.getState();
}
const gen = ++this._switchGen;
this._isSwitching = true;
this._lastError = null;
if (!silent) this._emitChange('switching');
try {
const activation = await this._activate(stack, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || stack.id;
if (activation?.fallbackMessage) {
this._lastError = activation.fallbackMessage;
this._onError?.(activation.fallbackMessage, stack);
}
this._requestRender('map-stack');
if (!silent) this._emitChange('ready');
} catch (error) {
if (gen !== this._switchGen) return this.getState();
const message = error?.message || String(error);
this._lastError = message;
this._onError?.(message, stack);
const recovery = this.getStack(this._registry.recoveryId);
if (
recovery &&
recovery.id !== stack.id &&
this.isStackAvailable(recovery.id)
) {
try {
const activation = await this._activate(recovery, gen);
if (gen !== this._switchGen) return this.getState();
this._activeId = activation?.effectiveStackId || recovery.id;
} catch (recoveryError) {
if (gen !== this._switchGen) return this.getState();
this._lastError = recoveryError?.message || String(recoveryError);
this._onError?.(this._lastError, recovery);
}
}
if (!silent) this._emitChange('error');
} finally {
if (gen === this._switchGen) this._isSwitching = false;
}
return this.getState();
}
_activate(stack, gen) {
const source = this._sources.get(stack.id);
return source.imagery
? this._activateGlobeStack(stack, gen)
: this._activateTileset(source, gen);
}
async _activateTileset(source, gen) {
let tileset = source.tileset;
if (!tileset) {
if (!source.createTileset)
throw new Error(`Missing 3D source: ${source.descriptor.id}`);
tileset = await this._cached(this._tilesets, source.descriptor.id, () =>
source.createTileset({ signal: this._abort.signal }),
);
}
if (gen !== this._switchGen) return;
if (!source.tileset && !this._ownedTilesets.has(tileset)) {
tileset.show = false;
this.viewer.scene.primitives.add(tileset);
this._ownedTilesets.add(tileset);
}
this._removeImageryLayer();
this._credits.show(source.credit || null);
this._showTileset(tileset);
this.viewer.scene.globe.show = false;
// Terrain is intentionally untouched while the globe is hidden.
}
_showTileset(active) {
for (const source of this._sources.values())
if (source.tileset) source.tileset.show = source.tileset === active;
for (const tileset of this._ownedTilesets)
tileset.show = tileset === active;
}
async _activateGlobeStack(stack, gen) {
const resolution = await this._getImageryProvider(stack);
if (gen !== this._switchGen) return;
this._removeImageryLayer();
this._imageryLayer = this._createImageryLayer(resolution.provider);
this._activeImageryProvider = resolution.provider;
this.viewer.imageryLayers.add(this._imageryLayer, 0);
const source = this._sources.get(resolution.effectiveStackId);
this._credits.show(source?.credit || null);
this._watchProvider(resolution, gen);
this._showTileset(null);
this.viewer.scene.globe.show = true;
this._activeId = resolution.effectiveStackId;
if (source?.terrain && source.terrain.id !== this._terrainMode) {
const terrain = source.terrain;
const result = await this._cached(
this._terrainProviders,
terrain.id,
() => terrain.create({ signal: this._abort.signal }),
);
if (gen !== this._switchGen) return;
if (result.terrain) this.viewer.scene.setTerrain(result.terrain);
else this.viewer.terrainProvider = result.provider;
this._terrainMode = terrain.id;
}
return resolution;
}
_cached(cache, id, create) {
if (cache.has(id)) return cache.get(id);
const promise = Promise.resolve()
.then(() => {
this._abort.signal.throwIfAborted();
return create();
})
.catch((error) => {
if (cache.get(id) === promise) cache.delete(id);
throw error;
});
cache.set(id, promise);
return promise;
}
_getImageryProvider(stack, visited = new Set()) {
if (visited.has(stack.id))
return Promise.reject(new Error('Map source fallback cycle'));
return this._cached(this._imageryProviders, stack.id, async () => {
const source = this._sources.get(stack.id);
try {
const provider = await source.imagery({ signal: this._abort.signal });
if (this._destroyed) this._dispose(provider);
return { provider, effectiveStackId: stack.id, fallbackMessage: null };
} catch (error) {
const fallback = source.constructionFallback;
if (this._destroyed || !fallback || !this.isStackAvailable(fallback.id))
throw error;
const next = new Set(visited).add(stack.id);
const resolution = await this._getImageryProvider(
this.getStack(fallback.id),
next,
);
return { ...resolution, fallbackMessage: fallback.message };
}
});
}
_watchProvider(resolution, gen) {
const fallback = this._sources.get(
resolution.effectiveStackId,
)?.tileFailureFallback;
const errorEvent = resolution.provider?.errorEvent;
if (!fallback || !errorEvent?.addEventListener) return;
let failures = 0;
let pending = false;
this._removeImageryErrorListener = errorEvent.addEventListener((error) => {
if (
gen !== this._switchGen ||
this._activeImageryProvider !== resolution.provider
)
return;
const retryCount = Number(error?.timesRetried);
failures =
Number.isInteger(retryCount) && retryCount >= 0
? Math.max(failures + 1, retryCount + 1)
: failures + 1;
if (failures < fallback.threshold || pending) return;
pending = true;
this._onError?.(
fallback.message,
this.getStack(resolution.effectiveStackId),
);
const expectedGen = this._switchGen + 1;
void this.setStack(fallback.id, { silent: true })
.then((state) => {
if (
!this._destroyed &&
this._switchGen === expectedGen &&
state?.activeId === fallback.id
) {
this._lastError = fallback.message;
this._emitChange('error');
}
})
.finally(() => {
pending = false;
});
});
}
_removeImageryLayer() {
this._removeImageryErrorListener?.();
this._removeImageryErrorListener = null;
if (this._imageryLayer)
this.viewer.imageryLayers.remove(this._imageryLayer, true);
this._imageryLayer = null;
this._activeImageryProvider = null;
}
_dispose(value) {
if (!value || this._disposed.has(value)) return;
this._disposed.add(value);
if (typeof value.destroy === 'function' && !value.isDestroyed?.())
value.destroy();
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
this._switchGen++;
this._abort.abort();
this._isSwitching = false;
this._removeImageryLayer();
this._credits.destroy();
for (const promise of this._imageryProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const promise of this._terrainProviders.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value.provider),
() => {},
);
for (const tileset of this._ownedTilesets) {
this.viewer.scene.primitives.remove(tileset);
this._dispose(tileset);
}
for (const promise of this._tilesets.values())
void Promise.resolve(promise).then(
(value) => this._dispose(value),
() => {},
);
this._imageryProviders.clear();
this._terrainProviders.clear();
this._tilesets.clear();
this._ownedTilesets.clear();
this._onChange = null;
this._onError = null;
}
}
+383
View File
@@ -0,0 +1,383 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { MapSourceController } from './controller.js';
import { createDefaultMapSources } from './defaultSources.js';
function event() {
const listeners = new Set();
return {
addEventListener(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
raise(value = {}) {
for (const fn of [...listeners]) fn(value);
},
get size() {
return listeners.size;
},
};
}
function fixture(registry, options = {}) {
const imagery = [],
credits = new Set(),
primitives = [],
removed = [],
changes = [];
const viewer = {
scene: {
globe: { show: true },
requestRender() {},
frameState: {
creditDisplay: {
addStaticCredit(value) {
credits.add(value);
},
removeStaticCredit(value) {
credits.delete(value);
},
},
},
primitives: {
add(value) {
primitives.push(value);
},
remove(value) {
primitives.splice(primitives.indexOf(value), 1);
value.destroy?.();
},
},
},
imageryLayers: {
add(value) {
imagery.push(value);
},
remove(value, destroy) {
imagery.splice(imagery.indexOf(value), 1);
removed.push({ value, destroy });
},
},
};
const controller = new MapSourceController(viewer, {
registry,
createImageryLayer: (provider) => ({ provider }),
onChange: (state) => changes.push(state),
...options,
});
return { viewer, controller, imagery, credits, primitives, removed, changes };
}
const settle = async () => {
for (let i = 0; i < 20; i++) await Promise.resolve();
};
const descriptor = (id) => ({ id, label: id, kind: 'imagery' });
function publicFixture() {
const tileset = { show: true };
const registry = createDefaultMapSources({ googleTileset: tileset });
const providers = new Map();
for (const source of registry.sources) {
if (!source.imagery) continue;
const provider = { id: source.descriptor.id, errorEvent: event() };
providers.set(source.descriptor.id, provider);
source.imagery = async () => provider;
source.terrain = {
id: 'keyless',
create: async () => ({ provider: { id: 'terrain' } }),
};
}
return { ...fixture(registry), registry, providers, tileset };
}
test('an additional imagery source needs no controller branch and owns its cached resources', async () => {
let reads = 0,
destroys = 0,
terrainReads = 0;
const provider = {
destroy() {
destroys++;
},
};
const terrain = {
id: 'custom-floor',
create: async () => {
terrainReads++;
return { provider: { id: 'terrain' } };
},
};
const registry = {
defaultId: 'custom',
sources: [
{
descriptor: descriptor('custom'),
imagery: async () => {
reads++;
return provider;
},
terrain,
},
],
};
const env = fixture(registry);
await env.controller.setStack('custom');
await env.controller.setStack('custom');
assert.equal(env.controller.getActiveId(), 'custom');
assert.equal(env.viewer.terrainProvider.id, 'terrain');
assert.equal(reads, 1);
assert.equal(terrainReads, 1);
assert.equal(env.imagery.length, 1);
assert.equal(env.removed[0].destroy, true);
env.controller.destroy();
env.controller.destroy();
await settle();
assert.equal(env.imagery.length, 0);
assert.equal(destroys, 1);
});
test('a destroyed controller aborts creation and disposes a late provider without touching the scene', async () => {
let resolve,
signal,
destroyed = 0;
const env = fixture({
defaultId: 'slow',
sources: [
{
descriptor: descriptor('slow'),
imagery: (request) => {
signal = request.signal;
return new Promise((done) => {
resolve = done;
});
},
},
],
});
const loading = env.controller.setStack('slow');
await settle();
env.controller.destroy();
assert.equal(signal.aborted, true);
resolve({
destroy() {
destroyed++;
},
});
await loading;
await settle();
assert.equal(destroyed, 1);
assert.equal(env.imagery.length, 0);
assert.deepEqual(
env.changes.map((state) => state.status),
['switching'],
);
});
test('slow terrain cannot overwrite a newer source and a 3D view never starts unused terrain', async () => {
const env = publicFixture();
let resolve,
reads = 0;
const osm = env.registry.sources.find(
(source) => source.descriptor.id === 'osm',
);
osm.terrain = {
id: 'slow-floor',
create: () => {
reads++;
return new Promise((done) => {
resolve = done;
});
},
};
await env.controller.setStack('photoreal');
assert.equal(reads, 0);
const loading = env.controller.setStack('osm');
await settle();
await env.controller.setStack('photoreal');
resolve({ provider: { id: 'late-terrain' } });
await loading;
assert.equal(env.viewer.terrainProvider, undefined);
assert.equal(env.viewer.scene.globe.show, false);
assert.equal(env.tileset.show, true);
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('Esri construction fallback reports and attributes the source actually rendered', async () => {
const env = publicFixture();
env.registry.sources.find(
(source) => source.descriptor.id === 'esri-imagery',
).imagery = async () => {
throw new Error('unreachable');
};
await env.controller.setStack('esri-imagery');
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(
env.controller.getState().lastError,
'Esri Satellite is unavailable; using OSM',
);
assert.equal(env.imagery[0].provider, env.providers.get('osm'));
assert.equal(env.credits.size, 0);
env.controller.destroy();
});
test('one Esri tile failure stays put, two fall back, and stale errors cannot replace a selection', async () => {
const env = publicFixture();
await env.controller.setStack('esri-imagery');
assert.equal(env.credits.size, 1);
const errorEvent = env.providers.get('esri-imagery').errorEvent;
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'esri-imagery');
errorEvent.raise();
await settle();
assert.equal(env.controller.getActiveId(), 'osm');
assert.equal(env.credits.size, 0);
assert.equal(
env.controller.getState().lastError,
'Esri Satellite tile requests failed; using OSM',
);
assert.equal(errorEvent.size, 0);
await env.controller.setStack('photoreal');
errorEvent.raise({ timesRetried: 9 });
await settle();
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('tooltips and rejected selection share the registry reason, including retired map IDs', async () => {
const errors = [],
env = publicFixture();
env.controller._onError = (message) => errors.push(message);
const reason = env.controller
.getStacks()
.find((stack) => stack.id === 'bing-aerial').unavailableReason;
await env.controller.setStack('bing-aerial');
assert.deepEqual(errors, [reason]);
assert.match(reason, /CESIUM_ION_TOKEN/);
await env.controller.setStack('bing-road');
assert.equal(env.controller.getActiveId(), 'photoreal');
env.controller.destroy();
});
test('a factory-owned 3D tileset is released after late completion and supplied tilesets stay caller-owned', async () => {
let resolve,
destroyed = 0;
const supplied = {
show: true,
destroy() {
assert.fail('caller-owned tileset');
},
};
const env = fixture({
defaultId: 'three',
sources: [
{
descriptor: { id: 'three' },
createTileset: () =>
new Promise((done) => {
resolve = done;
}),
},
{ descriptor: { id: 'supplied' }, tileset: supplied },
],
});
const loading = env.controller.setStack('three');
await settle();
env.controller.destroy();
resolve({
destroy() {
destroyed++;
},
});
await loading;
assert.equal(destroyed, 1);
assert.equal(env.primitives.length, 0);
});
test('invalid source graphs fail before constructing or caching a provider', () => {
const a = { descriptor: descriptor('a'), constructionFallback: { id: 'b' } };
const b = { descriptor: descriptor('b'), constructionFallback: { id: 'a' } };
assert.throws(() => fixture({ sources: [a, b] }), /fallback cycle/);
assert.throws(() => fixture({ sources: [a, a] }), /unique/);
assert.throws(() => fixture({ sources: [a] }), /Unknown map fallback/);
});
for (const reactivate of [false, true]) {
test(`a superseded 3D load stays outside the scene and is released (reactivate: ${reactivate})`, async () => {
let resolve,
destroyed = 0,
reads = 0;
const tileset = {
show: true,
isDestroyed: () => destroyed > 0,
destroy() {
destroyed++;
},
};
const supplied = { show: true };
const env = fixture({
defaultId: 'slow',
sources: [
{
descriptor: { id: 'slow' },
createTileset: () => {
reads++;
return new Promise((done) => {
resolve = done;
});
},
},
{ descriptor: { id: 'supplied' }, tileset: supplied },
],
});
const loading = env.controller.setStack('slow');
await settle();
await env.controller.setStack('supplied');
resolve(tileset);
await loading;
assert.equal(env.primitives.length, 0);
assert.equal(supplied.show, true);
assert.equal(env.controller.getActiveId(), 'supplied');
if (reactivate) {
await env.controller.setStack('slow');
await env.controller.setStack('slow');
assert.deepEqual(env.primitives, [tileset]);
assert.equal(reads, 1);
assert.equal(tileset.show, true);
assert.equal(supplied.show, false);
}
env.controller.destroy();
await settle();
assert.equal(env.primitives.length, 0);
assert.equal(destroyed, 1);
});
}
test('a failed recovery reports its error and leaves switching settled', async () => {
const errors = [];
const env = fixture(
{
defaultId: 'first',
recoveryId: 'recovery',
sources: [
{
descriptor: descriptor('first'),
imagery: async () => {
throw new Error('first offline');
},
},
{
descriptor: descriptor('recovery'),
imagery: async () => {
throw new Error('recovery offline');
},
},
],
},
{ onError: (message) => errors.push(message) },
);
const result = await env.controller.setStack('first');
assert.equal(result.status, 'ready');
assert.equal(result.lastError, 'recovery offline');
assert.deepEqual(errors, ['first offline', 'recovery offline']);
assert.equal(env.changes.at(-1).status, 'error');
assert.equal(env.imagery.length, 0);
env.controller.destroy();
});
+25
View File
@@ -0,0 +1,25 @@
import * as Cesium from 'cesium';
/** Static credits follow the source actually on screen, including fallback. */
export function createMapCredits(viewer) {
let active = null;
let markup = null;
return {
show(html) {
if (html === markup) return;
const display = viewer?.scene?.frameState?.creditDisplay;
if (!display) return;
try {
if (active) display.removeStaticCredit(active);
active = html ? new Cesium.Credit(html, true) : null;
markup = html;
if (active) display.addStaticCredit(active);
} catch {
/* Switching remains usable on older credit displays. */
}
},
destroy() {
this.show(null);
},
};
}
+74
View File
@@ -0,0 +1,74 @@
import { MAP_STACKS } from './catalog.js';
import { photorealUnavailableReason } from './availability.js';
import { keySetupRequirement } from '../keySetupCore.mjs';
import {
createOsmImagery,
createEsriImagery,
createIonImagery,
ESRI_ATTRIBUTION_HTML,
} from './imagery.js';
import { createWorldTerrain, createKeylessTerrain } from './terrain.js';
/** Select sources and setup guidance without putting provider branches in the controller. */
export function createDefaultMapSources({
googleTileset = null,
cesiumToken = '',
googleApiKey = '',
} = {}) {
const ionToken = String(cesiumToken || '').trim();
const hasIon = Boolean(ionToken);
const hasGoogle = Boolean(String(googleApiKey || '').trim());
const terrain = {
id: hasIon ? 'world' : 'keyless',
create: hasIon
? (request) => createWorldTerrain(ionToken, request)
: createKeylessTerrain,
};
return {
defaultId: googleTileset ? 'photoreal' : 'esri-imagery',
unknownId: 'photoreal',
recoveryId: googleTileset ? 'photoreal' : null,
state: { hasCesiumIonToken: hasIon },
sources: MAP_STACKS.map((descriptor) => {
const common = {
descriptor,
available: !descriptor.requiresIon || hasIon,
unavailableReason: descriptor.requiresIon
? keySetupRequirement('cesium-ion')
: null,
};
if (descriptor.kind === 'photoreal')
return {
...common,
available: Boolean(googleTileset),
unavailableReason: photorealUnavailableReason(hasIon || hasGoogle),
tileset: googleTileset,
};
const imagery =
descriptor.kind === 'ion'
? () => createIonImagery(descriptor.style, ionToken)
: descriptor.id === 'osm'
? createOsmImagery
: createEsriImagery;
return {
...common,
imagery,
terrain,
...(descriptor.id === 'esri-imagery'
? {
credit: ESRI_ATTRIBUTION_HTML,
constructionFallback: {
id: 'osm',
message: 'Esri Satellite is unavailable; using OSM',
},
tileFailureFallback: {
id: 'osm',
threshold: 2,
message: 'Esri Satellite tile requests failed; using OSM',
},
}
: {}),
};
}),
};
}
+81
View File
@@ -0,0 +1,81 @@
const clean = (value) => String(value || '').trim();
/**
* Decide which map provider can deliver the best startup experience.
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {'google-direct'|'google-ion'|'osm'}
*/
export function selectMapStartupRoute({
googleApiKey = '',
cesiumToken = '',
} = {}) {
if (clean(googleApiKey)) return 'google-direct';
if (clean(cesiumToken)) return 'google-ion';
return 'osm';
}
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
for (const attempt of attempts) {
try {
const tileset = attempt.googleKey
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
}
}
return { tileset: null, route: 'osm', errors };
}
/** Pass credentials to the source instead of changing SDK-wide defaults. */
export function createGoogleDirectTileset(Cesium, key) {
key = clean(key);
if (!key) throw new Error('Google 3D requires an explicit browser key');
return Cesium.createGooglePhotorealistic3DTileset({
key,
onlyUsingWithGoogleGeocoder: true,
});
}
export async function createGoogleIonTileset(
Cesium,
accessToken,
{ signal } = {},
) {
accessToken = clean(accessToken);
if (!accessToken)
throw new Error('Google 3D through ion requires an explicit token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(2275207, {
accessToken,
});
signal?.throwIfAborted();
// Match the installed SDK's Google helper rendering/cache defaults.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
});
}
+29
View File
@@ -0,0 +1,29 @@
import * as Cesium from 'cesium';
// Attribution and service rights are documented in DATA_SOURCES.md.
export const ESRI_ATTRIBUTION_HTML =
'<a href="https://www.esri.com" target="_blank" rel="noopener">Powered by Esri</a>';
export function createOsmImagery() {
return new Cesium.OpenStreetMapImageryProvider({
url: 'https://tile.openstreetmap.org/',
credit: '© OpenStreetMap contributors',
});
}
export function createEsriImagery() {
return Cesium.ArcGisMapServerImageryProvider.fromUrl(
'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer',
{
credit:
'Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
enablePickFeatures: false,
},
);
}
export function createIonImagery(style, accessToken) {
accessToken = String(accessToken || '').trim();
if (!accessToken) throw new Error('Ion imagery requires an explicit token');
return Cesium.IonImageryProvider.fromAssetId(style, { accessToken });
}
+21
View File
@@ -0,0 +1,21 @@
/** Catch invalid source graphs before a cached fallback can wait on itself. */
export function indexMapSources(sources) {
const index = new Map();
for (const source of sources) {
const id = source?.descriptor?.id;
if (typeof id !== 'string' || !id || index.has(id))
throw new TypeError('Map source IDs must be nonempty and unique');
index.set(id, source);
}
for (const id of index.keys()) {
const seen = new Set();
let next = id;
while (next) {
if (seen.has(next)) throw new TypeError('Map source fallback cycle');
if (!index.has(next)) throw new TypeError('Unknown map fallback source');
seen.add(next);
next = index.get(next).constructionFallback?.id;
}
}
return index;
}
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createIonImagery } from './imagery.js';
import { createWorldTerrain } from './terrain.js';
test('imagery and terrain pass their own ion token without relying on SDK defaults', async () => {
const originalImagery = Cesium.IonImageryProvider.fromAssetId;
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const calls = [];
const defaultToken = Cesium.Ion.defaultAccessToken;
try {
Cesium.IonImageryProvider.fromAssetId = async (id, options) => {
calls.push({ kind: 'imagery', id, options });
return { id };
};
Cesium.IonResource.fromAssetId = async (id, options) => {
calls.push({ kind: 'resource', id, options });
return { id };
};
Cesium.CesiumTerrainProvider.fromUrl = async (resource, options) => {
calls.push({ kind: 'terrain', resource, options });
return { id: 'terrain' };
};
await createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, 'imagery-token');
const result = await createWorldTerrain('terrain-token');
assert.equal(calls[0].options.accessToken, 'imagery-token');
assert.equal(calls[1].options.accessToken, 'terrain-token');
assert.equal(calls[1].id, 1);
assert.equal(calls[2].options.requestVertexNormals, true);
assert.equal(result.provider.id, 'terrain');
assert.equal(Cesium.Ion.defaultAccessToken, defaultToken);
} finally {
Cesium.IonImageryProvider.fromAssetId = originalImagery;
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('cancellation after ion metadata prevents terrain construction', async () => {
const originalResource = Cesium.IonResource.fromAssetId;
const originalTerrain = Cesium.CesiumTerrainProvider.fromUrl;
const controller = new AbortController();
try {
Cesium.IonResource.fromAssetId = async () => {
controller.abort();
return {};
};
Cesium.CesiumTerrainProvider.fromUrl = () =>
assert.fail('cancelled terrain construction');
await assert.rejects(
createWorldTerrain('test-token', { signal: controller.signal }),
{ name: 'AbortError' },
);
} finally {
Cesium.IonResource.fromAssetId = originalResource;
Cesium.CesiumTerrainProvider.fromUrl = originalTerrain;
}
});
test('credentialed source factories reject an omitted token instead of consuming an SDK default', async () => {
assert.throws(
() => createIonImagery(Cesium.IonWorldImageryStyle.AERIAL, ''),
/explicit token/,
);
await assert.rejects(createWorldTerrain(' '), /explicit ion token/);
});
+35
View File
@@ -0,0 +1,35 @@
import * as Cesium from 'cesium';
/** These factories are lazy: a hidden globe must not trigger terrain loading. */
export async function createWorldTerrain(accessToken, { signal } = {}) {
accessToken = String(accessToken || '').trim();
if (!accessToken)
throw new Error('World terrain requires an explicit ion token');
signal?.throwIfAborted();
const resource = await Cesium.IonResource.fromAssetId(1, { accessToken });
signal?.throwIfAborted();
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(resource, {
requestVertexNormals: true,
requestWaterMask: false,
ellipsoid: Cesium.Ellipsoid.WGS84,
}),
};
}
export async function createKeylessTerrain() {
try {
// Re:Earth / Mapterhorn ellipsoidal quantized mesh, CC BY 4.0.
return {
provider: await Cesium.CesiumTerrainProvider.fromUrl(
'https://terrain.reearth.land/cesium-mesh/ellipsoid',
),
};
} catch (error) {
console.warn(
'[MapStack] Re:Earth terrain unavailable, falling back to flat ellipsoid terrain:',
error,
);
return { provider: new Cesium.EllipsoidTerrainProvider() };
}
}
+7 -5
View File
@@ -11,7 +11,9 @@ import test from 'node:test';
const html = readFileSync(new URL('../index.html', import.meta.url), 'utf8');
const ui = readFileSync(new URL('./ui/applicationShell.js', import.meta.url), 'utf8');
const radio = readFileSync(new URL('./data/radio.js', import.meta.url), 'utf8');
const radio = ['playback', 'interaction'].map(name =>
readFileSync(new URL(`./layers/radio/${name}.js`, import.meta.url), 'utf8')
).join('\n').replace(/layerState\.|parts\.\w+\./g, '');
const rocketLaunches = readLayerSource(new URL('./data/rocketLaunches.js', import.meta.url), 'utf8');
const realtime = readFileSync(new URL('./voice/gevRealtime.js', import.meta.url), 'utf8');
const voice = ['tools', 'instructions'].map(name => readFileSync(new URL(`../server/providers/openai/${name}.js`, import.meta.url), 'utf8')).join('\n');
@@ -342,13 +344,13 @@ test('Radio disclosure is explicit, starts closed while off, and preserves playb
});
test('successful explicit user playback hands the speaker from voice to Radio', () => {
const playStart = radio.indexOf('export async function playSelectedRadio');
const playMethod = radio.slice(playStart, radio.indexOf('\n/**', playStart + 10));
const playStart = radio.indexOf('async function playSelectedRadio');
const playMethod = radio.slice(playStart, radio.indexOf('function confirmRadioPlayback', playStart + 10)).replace(/\s+/g, ' ');
const confirmedPlaying = playMethod.indexOf("_audioState = 'playing'");
const takeoverSignal = playMethod.indexOf("if (origin === 'user') emitPlaybackControl('play', origin, ownedAttemptId)");
assert.ok(confirmedPlaying >= 0 && takeoverSignal > confirmedPlaying);
assert.match(radio, /startPlayback: \(\) => playSelectedRadio\(\{ origin: 'voice', attemptId: options\.attemptId \}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{ autoplay: true, origin: 'user' \}\)/);
assert.match(radio, /startPlayback: \(\) =>\s*playSelectedRadio\(\{\s*origin: 'voice',\s*attemptId: options\.attemptId,?\s*\}\)/);
assert.match(radio, /selectRadioStation\(stationId, \{\s*autoplay: true,\s*origin: 'user',?\s*\}\)/);
assert.match(radioBindings, /togglePlayback\(\{ origin: 'user' \}\)/);
assert.match(radioBindings, /cycleStation\(direction, \{[\s\S]*?origin: 'user'/);
assert.match(radioBindings, /commitTuningStation\(station\.id, \{ origin: 'user' \}\)/);