feat(radio): render stations and clusters on Navara

Preserve native clustering, selection and direct-action playback through renderer ports. Record both-engine dev/preview, legacy, contract, shell and build evidence with remaining W2 limits.
This commit is contained in:
German Khokhlov committed 2026-10-28 14:49:18 +04:00
1 parent d82a6f1550
commit 9d587c1d37
27 files changed
+1490 -350

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"hls.js": "^1.7.3",
"kdbush": "4.0.2",
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
+1
View File
@@ -64,6 +64,7 @@
"cesium": "^1.124.0",
"egm96-universal": "^1.1.1",
"hls.js": "^1.7.3",
"kdbush": "4.0.2",
"mapillary-js": "^4.1.2",
"mgrs": "^2.1.0",
"pbf": "^5.1.2",
+1
View File
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// engine in their renderers: cesium only in render/cesium*.js, three and
// Navara only in render/navara*.js. The list grows with each moved layer.
const RENDERED_LAYER_DIRS = [
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
'src/layers/firms/',
+38
View File
@@ -0,0 +1,38 @@
// Shared 750-station directory for legacy and both-engine radio QA.
const tags = [
'news',
'talk',
'weather,emergency',
'public safety,scanner',
'aviation,atc',
'marine,maritime',
'traffic,transit',
'music,jazz',
'music,rock',
'community',
];
export const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name:
index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
+8 -1
View File
@@ -595,6 +595,11 @@
"src/layers/radio/presentation.js",
"src/layers/radio/queries.js",
"src/layers/radio/rendering.js",
"src/layers/radio/render/cesium.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/render/navara.test.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/layers/radio/selection.js",
"src/layers/radio/source.js",
"src/layers/radio/state.js",
@@ -1221,5 +1226,7 @@
"src/layers/firms/lifecycle.test.mjs",
"scripts/qa-w2-firms-renderer-entry.mjs",
"scripts/qa-w2-firms-perf.mjs",
"scripts/qa-w2-firms-ground.mjs"
"scripts/qa-w2-firms-ground.mjs",
"scripts/qa-w2-radio.mjs",
"scripts/fixtures/w2/radio.mjs"
]
+8 -7
View File
@@ -1603,8 +1603,6 @@
"radio-layer": {
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
@@ -1648,11 +1646,9 @@
"src/geo/scaleToGeodeticSurface.js",
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
"src/geo/developerError.js"
],
"external": ["cesium"]
"external": []
},
"submarine-cables": {
"exports": ["./layers/submarine-cables"],
@@ -2709,7 +2705,10 @@
"src/layers/installations/render/navaraGeometry.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js",
"src/layers/radio/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/screenClusters.js",
"src/layers/firms/render/navaraGeometry.js",
"src/layers/firms/render/navaraSprites.js",
"src/layers/shared/render/navaraGroundGeometry.js",
@@ -2717,6 +2716,7 @@
"src/layers/shared/render/navaraGroundSurface.js"
],
"external": [
"kdbush",
"hls.js",
"@mapbox/vector-tile",
"cesium",
@@ -3314,7 +3314,8 @@
"src/layers/satellites/render/cesium.js",
"src/layers/earthquakes/render/cesium.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js"
"src/layers/firms/render/cesium.js",
"src/layers/radio/render/cesium.js"
],
"external": [
"hls.js",
+25 -41
View File
@@ -9,12 +9,14 @@
* independence, restoration without autoplay, responsive UI, and a clean
* console. Screenshots are written under the gitignored `qa-shots/radio/`.
*
* Run: node scripts/qa-radio.mjs --url http://localhost:4173
* Run: node scripts/qa-radio.mjs --url http://localhost:4193
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { stations } from './fixtures/w2/radio.mjs';
import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
@@ -24,8 +26,10 @@ const option = (name, fallback) => {
const index = args.indexOf(name);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
const APP_URL = option('--url', process.env.QA_BASE_URL);
if (!APP_URL) throw new Error('Radio QA requires an explicit --url or QA_BASE_URL');
const APP_ORIGIN = new URL(APP_URL).origin;
const expectedPreviewErrors = [];
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
// has its own acceptance harness and must not intercept those gestures.
const RADIO_URL = new URL(APP_URL);
@@ -42,35 +46,6 @@ const chrome = chromeCandidates.find((candidate) => {
try { return fs.existsSync(candidate); } catch { return false; }
});
const tags = [
'news', 'talk', 'weather,emergency', 'public safety,scanner',
'aviation,atc', 'marine,maritime', 'traffic,transit',
'music,jazz', 'music,rock', 'community',
];
const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name: index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
const results = [];
function check(name, ok, detail = '') {
@@ -241,6 +216,10 @@ async function main() {
const failedResponses = [];
const externalCesiumEndpointFailures = [];
page.on('console', (message) => {
if (isExpectedPreviewSetupError(message, { preview: args.includes('--preview'), baseUrl: APP_URL })) {
expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
const sourceUrl = message.location()?.url || '';
consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text());
@@ -697,7 +676,7 @@ async function main() {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations');
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { radioStationIdFromPick } = await import('/src/data/radio.js');
return new Promise((resolve) => {
let removePostRender = null;
@@ -1283,7 +1262,7 @@ async function main() {
);
const clusterBadge = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const { radioStationIdFromPick } = await import('/src/data/radio.js');
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
@@ -1394,7 +1373,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const source = radio.getOverlayDiagnostics();
const cameraPosition = viewer.camera.positionWC;
@@ -1456,7 +1435,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const paintedClusters = () => new Promise((resolve) => {
let removePostRender = null;
const timeout = setTimeout(() => {
@@ -3699,7 +3678,7 @@ async function main() {
radio.setFilter('news');
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 500));
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
return new Promise((resolve) => {
let removePostRender = null;
let timeout = null;
@@ -3862,7 +3841,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const selectedEntity = window.__godsEyeView.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'));
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => {
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
return {
@@ -4045,7 +4024,7 @@ async function main() {
});
const selectedEntity = viewer.entities.values
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const expectedEntryId = `selected:${selected.id}`;
let painted = null;
// Radio's horizon pass reveals the selected entity on its own timer
@@ -4104,7 +4083,7 @@ async function main() {
});
const selectedEntity = viewer.entities.values
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const expectedEntryId = `selected:${selected.id}`;
let rect = null;
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
@@ -4141,11 +4120,14 @@ async function main() {
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
selectedPointPick = picks.find((picked) => (
picked?.id === selectedEntity
&& String(picked?.primitive?.constructor?.name || '').includes('PointPrimitive')
// Public point properties survive production minification; class names do not.
&& Number.isFinite(picked?.primitive?.pixelSize)
&& Number.isFinite(picked?.primitive?.outlineWidth)
));
selectedBillboardPick = picks.find((picked) => (
picked?.id === selectedEntity
&& String(picked?.primitive?.constructor?.name || '').includes('Billboard')
&& Number.isFinite(picked?.primitive?.width)
&& Number.isFinite(picked?.primitive?.height)
));
if (!selectedPointPick) {
await new Promise((resolve) => {
@@ -4741,6 +4723,8 @@ async function main() {
}
const failures = results.filter((result) => !result.ok).length;
const reportPath = option('--report');
if (reportPath) fs.writeFileSync(reportPath, JSON.stringify({ pass: failures === 0, checks: results, expectedPreviewErrors }, null, 2) + '\n');
console.log(`\nRadio QA: ${results.length - failures}/${results.length} passed`);
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
process.exitCode = failures ? 1 : 0;
+11 -1
View File
@@ -1,14 +1,24 @@
import { createRadioCesiumRenderer } from '../../layers/radio/render/cesium.js';
import { createRadioLayer } from '../../layers/radio/index.js';
import { rendererFor } from './renderers.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as globe from '../../celestialRing.js';
import * as render from '../../renderGovernor.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationRadio({ surface, source }) {
export function createApplicationRadio({
surface,
source,
rendererFactory = ({ engine, ...context }) =>
engine?.kind === 'navara'
? rendererFor('radio', engine, context)
: createRadioCesiumRenderer(context),
}) {
const { groundFloor: ground } = surface;
return createRadioLayer({
source,
rendererFactory,
services: { ground, picking, overlays, globe, render },
});
}
+2
View File
@@ -28,6 +28,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'local-firms',
'military-installations',
'bikeshare',
'radio',
])
assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual(
@@ -41,6 +42,7 @@ test('the Navara mode registers migrated renderers once', async () => {
'earthquakes',
'satellites',
'rocket-launches',
'radio',
'bikeshare',
'ais-live-vessels',
'military-installations',
+7 -1
View File
@@ -54,6 +54,9 @@ const NAVARA_CREDIT_KEYS = new Set([
'launch-library-2',
'usgs',
'firms',
'gbfs',
'overture-military-names',
'radio-browser',
]);
/**
@@ -62,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([
*/
export async function registerNavaraLayerRenderers() {
const loaders = {
radio: async () =>
(await import('../layers/radio/render/navara.js'))
.createRadioNavaraRenderer,
bikeshare: async () =>
(await import('../layers/bikeshare/render/navara.js'))
.createBikeshareNavaraRenderer,
@@ -274,7 +280,7 @@ export function createViewerlessTools({
if (window.__godsEyeView === debug) delete window.__godsEyeView;
});
// Voice on the engine (phase 3, Task 9): the W1 tools drive the engine;
// the viewer-only ones (director, annotations, cockpit, radio…) refuse.
// the viewer-only ones (director, annotations, cockpit…) refuse.
const voiceCommands = initGevVoiceCommands({
...voice,
annotationResolver: operations.annotationResolver,
+4 -1
View File
@@ -182,12 +182,15 @@ test('Navara keeps attribution for every available W1/W2 source and excludes una
'launch-library-2',
'usgs',
'firms',
'gbfs',
'overture-military-names',
'radio-browser',
'photon-geocoder',
'openstreetmap',
'reearth-terrain',
];
const run = await startScene({
credits: [...keys, 'radio-browser'].map((key) => ({
credits: [...keys, 'nasa-gibs'].map((key) => ({
key,
html: `Credit ${key}`,
})),
+4 -1
View File
@@ -128,7 +128,10 @@ test('every scene click handler consults ownership before it picks', () => {
'viewer.scene.pick(click.position)',
],
['src/layers/launches/render/cesium.js', 'drillPick(movement.position'],
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
[
'src/layers/radio/interaction.js',
'const stationId = pickedRadioStationAt(position);',
],
[
'src/layers/satellites/render/cesium.js',
'viewer.scene.pick(click.position)',
+2 -2
View File
@@ -19,10 +19,10 @@ 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 }) {
export function createRadioLayer({ services, source, rendererFactory }) {
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source };
const context = { state, services, parts, source, rendererFactory };
parts.catalogModel = createCatalogModel(context);
parts.labels = createLabels(context);
parts.clustering = createClustering(context);
+3
View File
@@ -1,6 +1,7 @@
import {
RADIO_DIRECTORY_STALE_MS,
RADIO_DIRECTORY_FUTURE_SKEW_MS,
RADIO_TUNER_DIRECTORY_LIMIT,
} from './policy.js';
export function createIngestion({
@@ -36,6 +37,8 @@ export function createIngestion({
return;
if (!Array.isArray(body?.stations))
throw new Error('Radio directory response was malformed');
if (body.stations.length > RADIO_TUNER_DIRECTORY_LIMIT)
throw new Error('Radio directory exceeds its station budget');
const updatedAt =
typeof body.updatedAt === 'string' &&
Number.isFinite(Date.parse(body.updatedAt))
+19 -31
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import { isPointerFree } from '../../data/inputOwnership.js';
import {
RADIO_PREFIX,
@@ -13,12 +12,7 @@ export function createInteraction({
parts,
source,
}) {
const {
resolvePickId,
isOwnedByOtherLayer,
registerPickOwner,
unregisterPickOwner,
} = services.picking;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { setOverlaySourceVisible } = services.overlays;
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
@@ -47,8 +41,8 @@ export function createInteraction({
}
function pickedRadioStationAt(position) {
const scene = layerState._viewer?.scene;
if (!scene || !position) return null;
const renderer = parts.renderer;
if (!renderer?.ready || !position) return null;
const stationFromPick = (picked) => {
const stationId = radioStationIdFromPick(picked);
@@ -56,14 +50,14 @@ export function createInteraction({
? stationId
: null;
};
const primaryPick = scene.pick(position);
const primaryPick = renderer.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) || [];
if (typeof renderer.drillPick === 'function') {
const drilled = renderer.drillPick(position, 16) || [];
for (const picked of drilled) {
const stationId = stationFromPick(picked);
if (stationId) return stationId;
@@ -71,11 +65,11 @@ export function createInteraction({
}
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 offsetPosition = {
x: position.x + offsetX,
y: position.y + offsetY,
};
const picked = renderer.pick(offsetPosition);
const pickedId = resolvePickId(picked);
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
const stationId = stationFromPick(picked);
@@ -96,7 +90,7 @@ export function createInteraction({
function syncRadioLifecyclePresentation() {
const visible = radioPresentationAllowed();
if (layerState._dataSource) layerState._dataSource.show = visible;
parts.renderer?.setVisible(visible);
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
if (visible) {
installInteraction();
@@ -105,23 +99,18 @@ export function createInteraction({
parts.rendering.scheduleRadioOverlayPublish();
} else {
removeInteraction();
if (layerState._selectedEntity && layerState._viewer)
layerState._viewer.entities.remove(layerState._selectedEntity);
layerState._selectedEntity = null;
parts.renderer?.clearSelected();
layerState._selectedPosition = 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 (!parts.renderer?.ready || layerState._clickHandler) return;
layerState._clickHandler = parts.renderer.installClick((position) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!radioPresentationAllowed()) return;
const stationId = pickedRadioStationAt(click.position);
const stationId = pickedRadioStationAt(position);
if (!stationId) return;
layerState._playFallbackId = null;
layerState._playFallbackFocus = null;
@@ -134,7 +123,7 @@ export function createInteraction({
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.
@@ -145,8 +134,7 @@ export function createInteraction({
}
function removeInteraction() {
unregisterPickOwner('radio');
layerState._clickHandler?.destroy();
layerState._clickHandler?.();
layerState._clickHandler = null;
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
layerState._horizonTimer = null;
+20 -20
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import {
RADIO_OVERLAY_SOURCE_ID,
DEFAULT_RADIO_VOLUME,
@@ -11,22 +10,28 @@ export function createLifecycle({
services,
parts,
source,
rendererFactory,
}) {
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
const methods = {
/** Initialize the Cesium data source and the single audio element. */
init(viewer) {
/** Initialize scene resources and the single audio element. */
init(viewer, options = {}) {
if (parts.renderer?.ready)
throw new Error('Radio layer is already initialized');
if (typeof rendererFactory !== 'function')
throw new TypeError('A radio renderer factory is required');
parts.renderer = rendererFactory({
state: layerState,
services,
parts,
source,
engine: options.engine,
});
parts.renderer.init(viewer, options);
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);
},
@@ -55,10 +60,9 @@ export function createLifecycle({
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;
parts.renderer?.setVisible(false);
parts.renderer?.clearSelected();
layerState._selectedPosition = null;
if (layerState._overlayPublishTimer)
clearTimeout(layerState._overlayPublishTimer);
layerState._overlayPublishTimer = null;
@@ -83,11 +87,8 @@ export function createLifecycle({
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;
parts.renderer?.destroy();
parts.renderer = null;
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
layerState._stations = [];
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
@@ -111,7 +112,6 @@ export function createLifecycle({
layerState._error = null;
layerState._updatedAt = null;
layerState._managerLifecyclePresentation = null;
layerState._viewer = null;
parts.presentation.emitState();
layerState._listeners.clear();
layerState._playbackControlListeners.clear();
+22 -40
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import {
HORIZON_CAMERA_MOVE_EPSILON_M,
@@ -15,7 +14,6 @@ export function createNavigation({
const {
isFullGlobeInsideKeyhole,
GLOBE_ENTER_CLEARANCE_PX,
projectEarthDiscToViewport,
earthDiscScreenRadius,
getKeyholeGeometry,
} = services.globe;
@@ -136,7 +134,8 @@ export function createNavigation({
: currentHeight;
}
function radioCameraState(camera = layerState._viewer?.camera) {
function radioCameraState(camera) {
if (camera === undefined) return parts.renderer?.cameraState() ?? null;
if (!camera) return null;
return {
height: camera.positionCartographic?.height,
@@ -148,24 +147,16 @@ export function createNavigation({
/** Radio may move the globe only while no tracked entity owns the follow camera. */
function radioCameraNavigationAllowed(viewer = layerState._viewer) {
function radioCameraNavigationAllowed(viewer) {
if (viewer === undefined)
return parts.renderer?.navigationAllowed() ?? false;
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,
);
function radioGlobeRecenterPlan() {
const view = parts.renderer?.recenterView();
if (!view) return null;
const { width, height, cartographic, geometry } = view;
let fullGlobeCapable = false;
if (geometry) {
if (!radioGlobeNeedsRecentering(geometry)) return null;
@@ -179,13 +170,12 @@ export function createNavigation({
);
} else {
const earthRadius = earthDiscScreenRadius(
geo.cartesian3.magnitude(camera.positionWC),
geo.cartesian3.magnitude(view.position),
height,
camera.frustum?.fovy,
view.fovy,
);
const facingEarthCenter =
geo.cartesian3.dot(camera.directionWC, layerState._radioEarthToCenter) >
0;
geo.cartesian3.dot(view.direction, view.toCenter) > 0;
if (!earthRadius || facingEarthCenter) return null;
const keyhole = getKeyholeGeometry(width, height);
fullGlobeCapable =
@@ -203,7 +193,7 @@ export function createNavigation({
recenterHeight,
),
cameraState: {
...radioCameraState(camera),
...radioCameraState(),
height: recenterHeight,
heading: 0,
pitch: -geo.math.PI_OVER_TWO,
@@ -217,18 +207,15 @@ export function createNavigation({
navigation &&
navigation.generation === layerState._radioCameraNavigationGeneration &&
layerState._enabled &&
radioCameraNavigationAllowed(layerState._viewer),
radioCameraNavigationAllowed(),
);
}
function cancelActiveRadioCameraFlight() {
if (
!layerState._activeRadioCameraFlight ||
!radioCameraNavigationAllowed(layerState._viewer)
)
if (!layerState._activeRadioCameraFlight || !radioCameraNavigationAllowed())
return;
layerState._activeRadioCameraFlight = null;
layerState._viewer.camera.cancelFlight();
parts.renderer.cancelFlight();
}
function invalidateRadioCameraNavigation() {
@@ -259,7 +246,7 @@ export function createNavigation({
if (completed && radioCameraNavigationIsCurrent(navigation))
onComplete?.();
};
layerState._viewer.camera.flyTo({
parts.renderer.flyTo({
...options,
complete: () => finish(true),
cancel: () => finish(false),
@@ -299,13 +286,9 @@ export function createNavigation({
function beginRadioCameraNavigation(cameraState = null) {
const generation = ++layerState._radioCameraNavigationGeneration;
if (
!radioCameraNavigationAllowed(layerState._viewer) ||
!layerState._enabled
)
return null;
if (!radioCameraNavigationAllowed() || !layerState._enabled) return null;
cancelActiveRadioCameraFlight();
const recenterPlan = radioGlobeRecenterPlan(layerState._viewer);
const recenterPlan = radioGlobeRecenterPlan();
return {
generation,
phase: 'idle',
@@ -345,7 +328,7 @@ export function createNavigation({
roll: activeNavigation.cameraState.roll,
},
duration: recenterDuration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
easing: 'cubicInOut',
},
() => {
activeNavigation.recentered = true;
@@ -359,10 +342,9 @@ export function createNavigation({
function focusStation(station) {
layerState._radioCameraNavigationGeneration += 1;
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
return false;
if (!station || !radioCameraNavigationAllowed()) return false;
cancelActiveRadioCameraFlight();
layerState._viewer.camera.flyTo({
parts.renderer.flyTo({
destination: geo.cartesian3.fromDegrees(station.lon, station.lat, 85_000),
duration: 1.1,
});
+7 -26
View File
@@ -1,4 +1,3 @@
import * as Cesium from 'cesium';
import * as geo from '../../geo/index.js';
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
@@ -107,31 +106,13 @@ export function createQueries({ state: layerState, services, parts, source }) {
}
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 = geo.cartesian2.create(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
);
const position = camera.pickEllipsoid(
center,
scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
);
if (position) cartographic = geo.cartographic.fromCartesian(position);
}
cartographic ||= camera.positionCartographic || null;
if (!cartographic) return null;
return {
lat: geo.math.toDegrees(cartographic.latitude),
lon: geo.math.toDegrees(cartographic.longitude),
altitudeM,
globalView: parts.navigation.radioViewIsGlobal(altitudeM),
};
const anchor = parts.renderer?.viewportAnchor();
return anchor
? {
...anchor,
globalView: parts.navigation.radioViewIsGlobal(anchor.altitudeM),
}
: null;
}
function rankedVisibleStations() {
+313
View File
@@ -0,0 +1,313 @@
import * as Cesium from 'cesium';
import * as geo from '../../../geo/index.js';
import { horizonOccluder } from '../../../data/iconOrientation.js';
import {
RADIO_PREFIX,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
} from '../policy.js';
/** Native scene resources stay owned by the viewer passed to init. */
export function createRadioCesiumRenderer({
state: layerState,
services,
parts,
}) {
let viewer = null;
let dataSource = null;
let selectedEntity = null;
let removeClusterListener = null;
let removeClick = null;
function installClusterStyling() {
if (!dataSource || removeClusterListener) return;
const clustering = dataSource.clustering;
clustering.enabled = true;
clustering.pixelRange = 42;
clustering.minimumClusterSize = 3;
clustering.clusterPoints = true;
clustering.clusterLabels = false;
clustering.clusterBillboards = false;
removeClusterListener = clustering.clusterEvent.addEventListener(
(clusteredEntities, cluster) => {
const clusteredStations = [];
for (const entity of clusteredEntities) {
const stationId = String(entity.id || '').slice(RADIO_PREFIX.length);
const station = 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,
);
parts.rendering.scheduleRadioOverlayPublish();
},
);
}
const renderer = {
get ready() {
return Boolean(viewer && dataSource);
},
init(owner) {
if (viewer) throw new Error('Radio renderer is already initialized');
viewer = owner;
dataSource = new Cesium.CustomDataSource('Radio stations');
viewer.dataSources.add(dataSource);
installClusterStyling();
dataSource.show = false;
},
setVisible(visible) {
if (dataSource) dataSource.show = visible;
},
markerPosition(station, lift) {
const floor = services.ground.cachedGroundFloor(station.lat, station.lon);
return Cesium.Cartesian3.fromDegrees(
station.lon,
station.lat,
(Number.isFinite(floor) ? floor : 0) + lift,
);
},
clearStations() {
dataSource?.entities.removeAll();
},
addStation(station, position, markerColor) {
const entity = dataSource.entities.add({
id: `${RADIO_PREFIX}${station.id}`,
position,
point: {
pixelSize: 13,
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
outlineWidth: 1,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
scaleByDistance: new Cesium.NearFarScalar(
100_000,
1.15,
12_000_000,
1,
),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
0,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
),
},
});
return { station, position, entity, visible: true };
},
setStationVisible(record, visible) {
record.entity.show = visible;
},
clearSelected() {
if (selectedEntity && viewer) viewer.entities.remove(selectedEntity);
selectedEntity = null;
},
addSelected(station, position, selectionColor, bracketImage) {
selectedEntity = viewer.entities.add({
id: `${RADIO_PREFIX}selected:${station.id}`,
position: position,
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,
),
},
});
},
setSelectedVisible(visible) {
if (!selectedEntity) return false;
const changed = selectedEntity.show !== visible;
selectedEntity.show = visible;
return changed;
},
clusters() {
const points = dataSource?.clustering?._clusterPointCollection;
const clusters = [];
for (let index = 0; index < (points?.length || 0); index++) {
const point = points.get(index);
if (!point?.show || !point.position || !Array.isArray(point.id))
continue;
clusters.push({
position: point.position,
stationIds: point.id.map((entity) =>
String(entity?.id || '').slice(RADIO_PREFIX.length),
),
});
}
return clusters;
},
invalidateClusters() {
if (!dataSource?.clustering.enabled) return;
// Entity show changes alone do not invalidate native cluster primitives.
const enabled = dataSource.clustering.clusterPoints;
dataSource.clustering.clusterPoints = !enabled;
dataSource.clustering.clusterPoints = enabled;
viewer.scene?.requestRender?.();
},
cameraPosition() {
return viewer?.camera?.positionWC;
},
cameraHeight() {
return viewer?.camera?.positionCartographic?.height;
},
horizonVisible(position) {
return horizonOccluder(viewer.camera).isPointVisible(position);
},
pick(position) {
return viewer?.scene.pick(position);
},
drillPick(position, limit) {
return viewer?.scene.drillPick?.(position, limit) || [];
},
installClick(callback) {
services.picking.registerPickOwner('radio', (id) =>
id.startsWith(RADIO_PREFIX),
);
const handler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
handler.setInputAction(
(event) => callback(event.position),
Cesium.ScreenSpaceEventType.LEFT_CLICK,
);
let removed = false;
removeClick = () => {
if (!removed) {
handler.destroy();
services.picking.unregisterPickOwner('radio');
}
removed = true;
};
return removeClick;
},
cameraState() {
const camera = viewer?.camera;
return camera
? {
height: camera.positionCartographic?.height,
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
}
: null;
},
navigationAllowed() {
return Boolean(viewer?.camera) && !viewer.trackedEntity;
},
recenterView() {
const camera = viewer?.camera;
const canvas = viewer?.scene?.canvas;
const width = canvas?.clientWidth || canvas?.width;
const height = canvas?.clientHeight || canvas?.height;
if (!camera?.positionCartographic || !(width > 0) || !(height > 0))
return null;
const geometry = services.globe.projectEarthDiscToViewport(
viewer,
width,
height,
layerState._radioEarthScreenCenter,
layerState._radioEarthToCenter,
);
return {
width,
height,
cartographic: camera.positionCartographic,
position: camera.positionWC,
direction: camera.directionWC,
fovy: camera.frustum?.fovy,
geometry,
toCenter: layerState._radioEarthToCenter,
};
},
flyTo({ easing, ...options }) {
viewer.camera.flyTo({
...options,
...(easing === 'cubicInOut'
? { easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT }
: {}),
});
},
cancelFlight() {
viewer?.camera.cancelFlight();
},
viewportAnchor() {
const camera = viewer?.camera;
if (!camera) return null;
const canvas = viewer.scene?.canvas;
const position =
canvas &&
camera.pickEllipsoid?.(
geo.cartesian2.create(
canvas.clientWidth / 2,
canvas.clientHeight / 2,
),
viewer.scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84,
);
const cartographic = position
? geo.cartographic.fromCartesian(position)
: camera.positionCartographic;
return cartographic
? {
lat: geo.math.toDegrees(cartographic.latitude),
lon: geo.math.toDegrees(cartographic.longitude),
altitudeM: Number(camera.positionCartographic?.height),
}
: null;
},
destroy() {
removeClick?.();
removeClick = null;
renderer.clearSelected();
removeClusterListener?.();
removeClusterListener = null;
if (dataSource && viewer) viewer.dataSources.remove(dataSource, true);
dataSource = null;
viewer = null;
},
};
return renderer;
}
+373
View File
@@ -0,0 +1,373 @@
import * as geo from '../../../geo/index.js';
import { selectionDispatcherFor } from '../../../data/selectionDispatcher.js';
import { rgbaFromCss } from '../../shared/color.js';
import {
clusterRadioPoints,
createRadioPointOrder,
} from '../screenClusters.js';
import {
RADIO_PREFIX,
RADIO_TUNER_DIRECTORY_LIMIT,
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
} from '../policy.js';
/** Point batches and a single reusable selection bracket; no media ownership. */
export function createRadioNavaraRenderer(engine, { state, services, parts }) {
let initialized = false;
let visible = false;
let sets = [];
let normal, grouped, selected, bracket;
let clusters = [];
let singletonIds = new Set();
let previousHeight;
let selectedRecord = null;
let dirty = true;
const pointOrder = createRadioPointOrder();
let lastCamera = null;
let removeFrame = null,
removeCamera = null,
removeClick = null;
const request = () => engine.render.request('radio-render');
const rgba = (css, alpha = 1) => rgbaFromCss(css).withAlpha(alpha).rgba();
const inRange = (position) =>
geo.cartesian3.distance(engine.camera.livePosition, position) <=
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M;
const pointSet = (id, capacity, order, point) =>
engine.movers.createSet({
id,
capacity,
order,
point,
kind: 'point',
pickable: true,
depthTest: 'always',
sharpen: true,
});
const syncSelection = () => {
const record = selectedRecord;
const count = record ? 1 : 0;
selected.setCount(count);
bracket.setCount(count);
if (record) {
const fields = {
position: record.position,
color: record.color,
show: visible && record.visible && inRange(record.position),
pickId: `${RADIO_PREFIX}selected:${record.station.id}`,
};
selected.set(0, fields);
bracket.set(0, { ...fields, cell: 'bracket' });
}
selected.commit();
bracket.commit();
};
const syncPoints = () => {
normal.setCount(state._renderById.size);
let index = 0;
for (const record of state._renderById.values())
normal.set(index++, {
position: record.position,
color: record.color,
show:
visible &&
record.visible &&
singletonIds.has(record.station.id) &&
inRange(record.position),
pickId: `${RADIO_PREFIX}${record.station.id}`,
});
grouped.setCount(clusters.length);
clusters.forEach((cluster, i) => {
const stations = cluster.stationIds.map((id) =>
state._stationById.get(id),
);
const category = parts.model.radioClusterCategoryId(
stations,
state._filter,
);
grouped.set(i, {
position: cluster.position,
color: rgba(parts.model.radioCategoryColor(category), 0.9),
scale: Math.min(26, 12 + Math.log2(stations.length) * 1.6) / 12,
show: visible && inRange(cluster.position),
// The native cluster click resolves its first actual station too.
pickId: `${RADIO_PREFIX}${cluster.stationIds[0]}`,
});
});
normal.commit();
grouped.commit();
};
const rebuild = () => {
if (!initialized || !visible) return;
const height = engine.camera.height();
const result = clusterRadioPoints({
records: pointOrder.update(state._renderById.values()),
project: (p) => engine.projector.worldToWindow(p),
horizonVisible: (p) => engine.projector.horizonVisible(p),
height,
previousHeight,
previousClusters: clusters,
});
clusters = result.clusters;
singletonIds = new Set(result.singletons);
previousHeight = height;
dirty = false;
syncPoints();
syncSelection();
parts.rendering.scheduleRadioOverlayPublish();
};
const frame = () => {
if (!visible) return;
if (dirty) rebuild();
const camera = engine.camera.livePosition;
if (
!lastCamera ||
camera.x !== lastCamera.x ||
camera.y !== lastCamera.y ||
camera.z !== lastCamera.z
) {
lastCamera = { ...camera };
syncPoints();
syncSelection();
}
};
const renderer = {
get ready() {
return initialized;
},
init() {
if (initialized) throw new Error('Radio renderer is already initialized');
normal = pointSet('radio-stations', RADIO_TUNER_DIRECTORY_LIMIT, 40, {
sizePx: 13,
outlinePx: 1,
outlineColor: rgba('#071b25'),
scaleByDistance: [100_000, 1.15, 12_000_000, 1],
});
grouped = pointSet('radio-clusters', RADIO_TUNER_DIRECTORY_LIMIT, 41, {
sizePx: 12,
outlinePx: 2,
outlineColor: [0, 0, 0, 1],
});
selected = pointSet('radio-selected', 1, 42, {
sizePx: 14,
outlinePx: 2,
outlineColor: [0, 0, 0, 1],
});
const image = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg('#ffffff'))}`;
bracket = engine.movers.createSet({
id: 'radio-bracket',
kind: 'sprite',
capacity: 1,
order: 43,
pickable: true,
depthTest: 'always',
sharpen: true,
sprite: {
sizePx: 40,
scaleByDistance: [100_000, 1, 12_000_000, 0.72],
atlas: { url: image, cells: { bracket: [0, 0, 1, 1] } },
},
});
sets = [normal, grouped, selected, bracket];
for (const set of sets) {
set.setCount(0);
set.commit();
}
initialized = true;
removeFrame = engine.frames.preRender.addEventListener(frame);
removeCamera = engine.camera.changed.addEventListener((amount) => {
if (Number.isFinite(amount) && amount < 0.05) return;
dirty = true;
if (visible) request();
});
},
setVisible(value) {
visible = value;
if (!initialized) return;
if (value) {
dirty = true;
request();
} else {
pointOrder.update(state._renderById.values(), false);
for (const set of sets) {
set.setCount(0);
set.commit();
}
}
},
markerPosition(station, lift) {
const floor = services.ground.cachedGroundFloor(station.lat, station.lon);
return geo.cartesian3.fromDegrees(
station.lon,
station.lat,
(Number.isFinite(floor) ? floor : 0) + lift,
);
},
clearStations() {
pointOrder.clear(state._renderById.values());
// Native EntityCluster retains its previous zoom clusters across a
// directory refresh; candidates are re-evaluated against the new rows.
singletonIds.clear();
dirty = true;
if (initialized) {
normal.setCount(0);
normal.commit();
}
},
addStation(station, position, color) {
if (state._renderById.size >= RADIO_TUNER_DIRECTORY_LIMIT)
throw new RangeError('Radio directory exceeds its point budget');
dirty = true;
return { station, position, color: rgba(color, 0.86), visible: true };
},
setStationVisible(record, value) {
if (record.renderVisible !== value) {
record.renderVisible = value;
dirty = true;
if (visible) request();
}
},
clearSelected() {
selectedRecord = null;
if (initialized) syncSelection();
},
addSelected(station, position, color) {
selectedRecord = { station, position, color: rgba(color), visible: true };
syncSelection();
},
setSelectedVisible(value) {
if (!selectedRecord || selectedRecord.visible === value) return false;
selectedRecord.visible = value;
syncSelection();
return true;
},
clusters() {
return clusters;
},
invalidateClusters() {
dirty = true;
if (visible) request();
},
cameraPosition() {
return engine.camera.livePosition;
},
cameraHeight() {
return engine.camera.height();
},
horizonVisible(position) {
return engine.projector.horizonVisible(position);
},
pick(position) {
return engine.picker.pick(position);
},
drillPick(position, limit) {
return engine.picker.drillPick(position, limit);
},
installClick(callback) {
removeClick = selectionDispatcherFor(engine).register('radio', {
owns: (id) => id.startsWith(RADIO_PREFIX),
onClick: ({ position }) => callback(position),
});
return removeClick;
},
cameraState() {
const pose = engine.camera.pose();
return {
height: pose.cartographic.height,
heading: pose.heading,
pitch: pose.pitch,
roll: pose.roll,
};
},
navigationAllowed() {
return !engine.camera.following();
},
recenterView() {
const pose = engine.camera.pose();
const { width, height } = engine.host.size();
if (!(width > 0) || !(height > 0)) return null;
const toCenter = geo.cartesian3.negate(
pose.position,
state._radioEarthToCenter,
);
const radius = services.globe.earthDiscScreenRadius(
geo.cartesian3.magnitude(pose.position),
height,
pose.fovy,
);
const center =
geo.cartesian3.dot(pose.direction, toCenter) > 0
? engine.projector.worldToWindow(
geo.cartesian3.ZERO,
state._radioEarthScreenCenter,
)
: null;
const keyhole = services.globe.getKeyholeGeometry(width, height);
const geometry =
center && radius
? {
earthCenterX: center.x,
earthCenterY: center.y,
earthRadius: radius,
keyholeCenterX: keyhole.centerX,
keyholeCenterY: keyhole.centerY,
keyholeRadius: keyhole.radius,
}
: null;
return {
width,
height,
cartographic: pose.cartographic,
position: pose.position,
direction: pose.direction,
fovy: pose.fovy,
toCenter,
geometry,
};
},
flyTo(options) {
engine.camera.flyTo(options);
},
cancelFlight() {
engine.camera.cancelFlight();
},
viewportAnchor() {
const { width, height } = engine.host.size();
const position = engine.camera.pickEllipsoid({
x: width / 2,
y: height / 2,
});
const cartographic = position
? geo.cartographic.fromCartesian(position)
: engine.camera.pose().cartographic;
return cartographic
? {
lat: geo.math.toDegrees(cartographic.latitude),
lon: geo.math.toDegrees(cartographic.longitude),
altitudeM: engine.camera.height(),
}
: null;
},
destroy() {
removeClick?.();
removeClick = null;
removeFrame?.();
removeFrame = null;
removeCamera?.();
removeCamera = null;
for (const set of sets) set.destroy();
sets = [];
initialized = false;
visible = false;
selectedRecord = null;
clusters = [];
singletonIds.clear();
previousHeight = undefined;
pointOrder.reset();
lastCamera = null;
dirty = true;
},
};
return renderer;
}
+205
View File
@@ -0,0 +1,205 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createFakeEngine } from '../../../globe/testing/fakeEngine.js';
import { createRadioLayer } from '../index.js';
import { createRadioNavaraRenderer } from './navara.js';
const rows = Array.from({ length: 4 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, '0')}`,
name: `Station ${i}`,
lat: 0,
lon: i === 3 ? 10 : i * 0.001,
streamUrl: `https://audio.example.test/${i}.mp3`,
homepage: null,
tags: ['news'],
languages: ['English'],
state: '',
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: 'MP3',
bitrate: 128,
}));
const body = (stations = rows, generation = 1) => ({
stations,
updatedAt: new Date().toISOString(),
stale: false,
degraded: false,
acceptedGeneration: generation,
catalogInstance: 'fixture',
});
const noop = () => {};
function fixture(t) {
const previous = globalThis.Audio;
const plays = [];
globalThis.Audio = class {
volume = 0.8;
addEventListener() {}
pause() {}
removeAttribute() {}
load() {}
play() {
plays.push(this.src);
return Promise.resolve();
}
};
t.after(() => {
globalThis.Audio = previous;
});
const engine = createFakeEngine({ kind: 'navara' });
engine.fake.setPose({ longitude: 0, latitude: 0, height: 1_000_000 });
let response = body();
let context;
const entries = new Map();
const layer = createRadioLayer({
source: { getDirectory: async () => response, recordClick: async () => {} },
services: {
ground: { cachedGroundFloor: () => 20, warmGroundFloor: noop },
globe: {
earthDiscScreenRadius: () => null,
getKeyholeGeometry: () => ({ radius: 500 }),
},
picking: {
registerPickOwner: noop,
unregisterPickOwner: noop,
resolvePickId: (pick) => pick?.id,
isOwnedByOtherLayer: () => false,
},
render: { governorRequestRender: noop },
overlays: {
clearOverlaySource: (id) => entries.delete(id),
setOverlaySourceVisible: noop,
setOverlayEntries: (id, value) => entries.set(id, value),
},
},
rendererFactory(value) {
context = value;
return createRadioNavaraRenderer(engine, value);
},
});
layer.init(null, { engine });
t.after(() => layer.destroy());
return {
engine,
layer,
plays,
entries,
context,
respond(value) {
response = value;
},
async settle() {
engine.fake.frame();
await new Promise((resolve) => setTimeout(resolve, 1));
},
};
}
test('Navara radio preserves station clusters, direct gesture audio and selected style', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
assert.equal(f.plays.length, 0, 'enable must not autoplay');
const normal = f.engine.fake.moverSet('radio-stations');
const cluster = f.engine.fake.moverSet('radio-clusters');
assert.equal(normal.store.count, 4);
assert.equal(cluster.store.count, 1);
assert.equal(normal.options.point.sizePx, 13);
assert.equal(normal.options.point.outlinePx, 1);
assert.equal(cluster.options.point.outlinePx, 2);
assert.equal(normal.options.sharpen, true);
assert.deepEqual(
Array.from(normal.store.shows.slice(0, normal.store.count)),
[0, 0, 0, 1],
);
const id = cluster.store.pickIds[0];
assert.ok(id.startsWith('radio:'));
f.engine.fake.script('pick', { id });
f.engine.fake.input('click', { position: { x: 100, y: 100 } });
assert.equal(
f.plays.length,
1,
'play is called inside the direct input callback',
);
assert.equal(f.layer.getUIState().selected.id, id.slice(6));
const selected = f.engine.fake.moverSet('radio-selected');
const bracket = f.engine.fake.moverSet('radio-bracket');
assert.equal(selected.store.count, 1);
assert.equal(selected.options.point.sizePx, 14);
assert.equal(selected.options.point.scaleByDistance, null);
assert.equal(bracket.options.sprite.sizePx, 40);
assert.equal(bracket.store.pickIds[0], `radio:selected:${id.slice(6)}`);
f.layer.setFilter('talk');
await f.settle();
assert.equal(f.layer.getUIState().selected.id, id.slice(6));
assert.equal(cluster.store.count, 0);
assert.equal(selected.store.count, 1);
});
test('Navara radio budget rejection preserves the accepted catalog and repeated lifecycle frees every batch', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
const accepted = f.layer.getAcceptedCatalogSnapshot();
f.respond(body(Array(751).fill(rows[0]), 2));
await f.layer.update();
assert.equal(f.layer.getAcceptedCatalogSnapshot(), accepted);
assert.equal(f.layer.getStats().count, 4);
assert.match(f.layer.getStats().error, /previous station catalog/);
for (let i = 0; i < 10; i++) {
f.layer.disable();
await f.settle();
for (const name of [
'radio-stations',
'radio-clusters',
'radio-selected',
'radio-bracket',
])
assert.equal(f.engine.fake.moverSet(name).store.count, 0);
assert.equal(f.context.state._clickHandler, null);
assert.equal(f.context.state._horizonTimer, null);
f.layer.enable();
f.layer.enable();
await f.settle();
assert.equal(f.engine.frames.preRender.numberOfListeners, 1);
assert.equal(f.engine.camera.changed.numberOfListeners, 1);
}
f.layer.destroy();
f.layer.destroy();
assert.deepEqual(f.engine.movers.list(), []);
assert.equal(f.engine.frames.preRender.numberOfListeners, 0);
assert.equal(f.engine.camera.changed.numberOfListeners, 0);
assert.equal(f.plays.length, 0);
});
test('Navara radio navigation yields to follow ownership and hides points outside the native distance range', async (t) => {
const f = fixture(t);
f.layer.enable();
await f.layer.update();
await f.settle();
const follow = f.engine.camera.follow({
position: () => ({ x: 6378140, y: 0, z: 0 }),
viewFrom: { x: 100, y: 0, z: 0 },
});
const before = f.engine.fake.callsOf('camera.flyTo').length;
f.layer.selectStation(rows[0].id, { focus: true, autoplay: false });
assert.equal(f.engine.fake.callsOf('camera.flyTo').length, before);
follow.stop();
f.engine.fake.setPose({ longitude: 0, latitude: 0, height: 60_000_000 });
await f.settle();
for (const name of [
'radio-stations',
'radio-clusters',
'radio-selected',
'radio-bracket',
]) {
const { store } = f.engine.fake.moverSet(name);
assert.ok(
Array.from(store.shows.slice(0, store.count)).every(
(value) => value === 0,
),
);
}
});
+30 -155
View File
@@ -1,10 +1,7 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import * as geo from '../../geo/index.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,
@@ -17,73 +14,36 @@ export function createRendering({
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,
);
return parts.renderer.markerPosition(station, liftM);
}
function updateSelectionEntity() {
if (layerState._selectedEntity && layerState._viewer)
layerState._viewer.entities.remove(layerState._selectedEntity);
layerState._selectedEntity = null;
parts.renderer?.clearSelected();
layerState._selectedPosition = null;
const station = parts.queries.selectedPresentationStation();
if (
!parts.interaction.radioPresentationAllowed() ||
!layerState._viewer ||
!parts.renderer?.ready ||
!station
) {
scheduleRadioOverlayPublish();
return;
}
const selectionColor = parts.model.radioCategoryColor(
const color = 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),
},
});
const bracket = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(color))}`;
const position = markerPosition(station, SELECTED_LIFT_M);
layerState._selectedPosition = position;
parts.renderer.addSelected(station, position, color, bracket);
scheduleRadioOverlayPublish();
}
function clusterPointCollection() {
return layerState._dataSource?.clustering?._clusterPointCollection || null;
}
function publishRadioOverlayEntries() {
layerState._overlayPublishTimer = null;
if (!parts.interaction.radioPresentationAllowed()) {
@@ -107,18 +67,8 @@ export function createRendering({
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))
for (const point of parts.renderer?.clusters() || []) {
const stationIds = point.stationIds
.filter((id) => {
const station = layerState._stationById.get(id);
return (
@@ -164,11 +114,9 @@ export function createRendering({
if (clusterEntry) entries.push(clusterEntry);
}
const cameraPosition = layerState._viewer?.camera?.positionWC;
const cameraPosition = parts.renderer?.cameraPosition();
const singletonAllowance = Math.min(
parts.clustering.radioSingletonLabelLimit(
layerState._viewer?.camera?.positionCartographic?.height,
),
parts.clustering.radioSingletonLabelLimit(parts.renderer?.cameraHeight()),
Math.max(
0,
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
@@ -178,14 +126,14 @@ export function createRendering({
[...layerState._renderById.values()]
.filter(
(record) =>
record.entity?.show &&
record.visible &&
record.station?.id !== station?.id &&
!clusteredStationIds.has(record.station?.id),
)
.map((record) => ({
...record,
distanceM: cameraPosition
? Cesium.Cartesian3.distance(cameraPosition, record.position)
? geo.cartesian3.distance(cameraPosition, record.position)
: Number.POSITIVE_INFINITY,
})),
singletonAllowance,
@@ -243,8 +191,8 @@ export function createRendering({
}
function updateRenderVisibility({ force = true } = {}) {
if (!layerState._viewer || !layerState._dataSource) return;
const cameraPosition = layerState._viewer.camera?.positionWC;
if (!parts.renderer?.ready) return;
const cameraPosition = parts.renderer.cameraPosition();
if (
!force &&
!parts.navigation.radioCameraPositionChanged(
@@ -257,25 +205,22 @@ export function createRendering({
? { 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;
const visible = matches && parts.renderer.horizonVisible(record.position);
if (record.visible !== visible) visibilityChanged = true;
record.visible = visible;
parts.renderer.setStationVisible(record, 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;
if (layerState._selectedPosition) {
visibilityChanged =
parts.renderer.setSelectedVisible(
parts.renderer.horizonVisible(layerState._selectedPosition),
) || visibilityChanged;
}
scheduleRadioOverlayPublish();
// The horizon timer can commit AFTER the camera settles and the governor
@@ -316,96 +261,26 @@ export function createRendering({
layerState._selectedId = null;
}
if (layerState._dataSource) layerState._dataSource.entities.removeAll();
parts.renderer?.clearStations();
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 });
const record = parts.renderer.addStation(station, position, markerColor);
layerState._renderById.set(station.id, record);
}
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,
};
}
+168
View File
@@ -0,0 +1,168 @@
import KDBush from 'kdbush';
const HALF_BOX = 13 / 2 + 42;
const MINIMUM = 3;
/** Preserve the native point pool's FIFO reuse after visibility changes. */
export function createRadioPointOrder() {
const slots = [];
const indices = new Map();
const free = [];
const remove = (record) => {
const id = record.station.id;
const index = indices.get(id);
if (index === undefined) return;
slots[index] = null;
indices.delete(id);
free.push(index);
};
return {
update(records, visible = true) {
for (const record of records) {
if (!visible || !record.visible) {
remove(record);
continue;
}
const id = record.station.id;
let index = indices.get(id);
if (index === undefined) {
index = free.length ? free.shift() : slots.length;
indices.set(id, index);
}
slots[index] = record;
}
return slots.filter(Boolean);
},
clear(records) {
// PointVisualizer releases a removed entity collection in reverse order.
for (const record of [...records].reverse()) remove(record);
},
reset() {
slots.length = 0;
indices.clear();
free.length = 0;
},
};
}
/**
* Radio's point-only EntityCluster semantics, independent of a scene SDK.
* The native oracle test covers seed order, inclusive range edges and the
* retained-cluster zoom-in rule. Bounding boxes use the unscaled 13px point,
* as Cesium does; outlines and distance scaling do not expand this box.
*/
export function clusterRadioPoints({
records,
project,
horizonVisible,
height,
previousHeight,
previousClusters = [],
}) {
const points = [];
for (const record of records) {
if (!record.visible || !horizonVisible(record.position)) continue;
const screen = project(record.position);
if (screen) points.push({ record, screen, used: false });
}
const clusters = [];
const singletons = [];
if (!points.length) return { clusters, singletons };
// Match the native index's traversal order as well as its range membership:
// the first member of a retained cluster is the direct-click station.
const index = new KDBush(points.length, 64, Float64Array);
for (const { screen } of points) index.add(screen.x, screen.y);
index.finish();
const membersIn = (left, top, right, bottom) =>
index
.range(left, top, right, bottom)
.map((i) => points[i])
.filter((point) => !point.used);
const consume = (members) => {
for (const point of members) point.used = true;
return members.map(({ record }) => record.station.id);
};
if (height < previousHeight) {
const factor = 1 - height / previousHeight;
for (const previous of previousClusters) {
if (!horizonVisible(previous.position)) continue;
const center = project(previous.position);
if (!center) continue;
const width = previous.width * factor;
const boxWidth = Math.max(width, previous.minimumWidth);
const boxHeight = Math.max(
previous.height * factor,
previous.minimumHeight,
);
// Native retained clusters use asymmetric right/bottom bounds.
const members = membersIn(
center.x - boxWidth / 2,
center.y - boxHeight / 2,
center.x + boxWidth,
center.y + boxHeight,
);
if (members.length < MINIMUM) continue;
clusters.push({
...previous,
width,
height: previous.height * factor,
stationIds: consume(members),
});
}
}
for (const seed of points) {
if (seed.used) continue;
seed.used = true;
const { x, y } = seed.screen;
const seedLeft = x - 13 / 2 - 42;
const seedTop = y - 13 / 2 - 42;
const members = [
seed,
...membersIn(
seedLeft,
seedTop,
seedLeft + HALF_BOX * 2,
seedTop + HALF_BOX * 2,
),
];
if (members.length < MINIMUM) {
singletons.push(seed.record.station.id);
continue;
}
const sum = { ...seed.record.position };
let left = seedLeft,
top = seedTop;
let width = HALF_BOX * 2,
boxHeight = HALF_BOX * 2;
for (const point of members.slice(1)) {
const { position } = point.record;
sum.x += position.x;
sum.y += position.y;
sum.z += position.z;
const nextLeft = point.screen.x - 13 / 2 - 42;
const nextTop = point.screen.y - 13 / 2 - 42;
const right = Math.max(left + width, nextLeft + HALF_BOX * 2);
const bottom = Math.max(top + boxHeight, nextTop + HALF_BOX * 2);
left = Math.min(left, nextLeft);
top = Math.min(top, nextTop);
width = right - left;
boxHeight = bottom - top;
}
const inverseCount = 1 / members.length;
clusters.push({
position: {
x: sum.x * inverseCount,
y: sum.y * inverseCount,
z: sum.z * inverseCount,
},
stationIds: consume(members),
width,
height: boxHeight,
minimumWidth: HALF_BOX * 2,
minimumHeight: HALF_BOX * 2,
});
}
return { clusters, singletons };
}
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { clusterRadioPoints, createRadioPointOrder } from './screenClusters.js';
test('radio cluster seed order follows native FIFO point reuse across filters and reloads', () => {
const native = new Cesium.EntityCluster();
const items = [];
native._pointCollection = {
get length() {
return items.length;
},
get: (i) => items[i],
add() {
const item = {};
items.push(item);
return item;
},
};
const order = createRadioPointOrder();
const records = Array.from({ length: 12 }, (_, i) => ({
station: { id: String(i) },
visible: true,
}));
const update = (visible = true) => {
for (const record of records) {
const entity = { id: record.station.id };
if (!visible || !record.visible) native.removePoint(entity);
else Object.assign(native.getPoint(entity), { id: entity, show: true });
}
assert.deepEqual(
order.update(records, visible).map((record) => record.station.id),
items.filter((item) => item.show).map((item) => item.id.id),
);
};
update();
for (const remainder of [0, 1, 2, 0]) {
for (let i = 0; i < records.length; i++)
records[i].visible = i % 3 === remainder;
update();
}
update(false);
update();
for (const record of [...records].reverse())
native.removePoint({ id: record.station.id });
order.clear(records);
for (const record of records) record.visible = true;
update();
});
// Execute the installed SDK's actual declutter callback. Only projection and
// drawing collections are supplied by the fixture; clustering is not mocked.
function nativeOracle(records, project) {
const native = new Cesium.EntityCluster({
enabled: true,
pixelRange: 42,
minimumClusterSize: 3,
clusterLabels: false,
clusterBillboards: false,
});
const scene = {
mode: Cesium.SceneMode.SCENE3D,
ellipsoid: Cesium.Ellipsoid.WGS84,
camera: {
changed: new Cesium.Event(),
positionWC: new Cesium.Cartesian3(20_000_000, 0, 0),
positionCartographic: { height: 10_000_000 },
},
};
native._initialize(scene);
const drawing = () => ({
items: [],
get length() {
return this.items.length;
},
add() {
const item = {};
this.items.push(item);
return item;
},
removeAll() {
this.items = [];
},
destroy() {},
});
const items = records.map((record) => ({
position: record.position,
id: { id: record.station.id, _point: {} },
show: record.visible,
pixelSize: 13,
_pointPrimitiveCollection: {},
computeScreenSpacePosition: () => project(record.position),
}));
native._pointCollection = { length: items.length, get: (i) => items[i] };
let output = [];
native.clusterEvent.addEventListener((ids, cluster) =>
output.push({
stationIds: ids.map(({ id }) => id),
position: { ...cluster.point.position },
}),
);
return {
run(height) {
output = [];
for (let i = 0; i < items.length; i++) items[i].show = records[i].visible;
native._clusterLabelCollection = drawing();
native._clusterBillboardCollection = drawing();
native._clusterPointCollection = drawing();
scene.camera.positionCartographic.height = height;
native._cluster();
return {
clusters: output,
singletons: items
.filter((item) => item.show && item.clusterShow)
.map((item) => item.id.id),
retained: native._previousClusters,
};
},
};
}
test('radio screen clustering matches native membership, anchor and retained zoom clusters', (t) => {
const original = Cesium.Billboard._computeScreenSpacePosition;
let zoom = 1;
const project = (p) =>
p.x < 0 ? undefined : { x: p.y * zoom, y: p.z * zoom };
Cesium.Billboard._computeScreenSpacePosition = (_matrix, position) =>
project(position);
t.after(() => {
Cesium.Billboard._computeScreenSpacePosition = original;
});
const datasets = [
[
[0, 0],
[48.5, 0],
[0, 48.5],
[48.50001, 0],
[200, 200],
[205, 205],
],
Array.from({ length: 750 }, (_, i) => [
Math.sin(i * 9.17) * 800,
Math.cos(i * 4.13) * 500,
]),
Array.from({ length: 100 }, (_, i) => [i * 45, 0]),
];
for (const dataset of datasets) {
const records = dataset.map(([y, z], i) => ({
station: { id: String(i) },
visible: true,
position: { x: 6_378_140, y, z },
}));
const oracle = nativeOracle(records, project);
let previousClusters = [],
previousHeight;
for (const [height, scale, filter] of [
[10e6, 1, false],
[9e6, 1.1, false],
[8e6, 1.2, true],
[3e6, 3, true],
[12e6, 0.8, false],
[12e6, 0.8, true],
]) {
zoom = scale;
records.forEach((record, i) => {
record.visible = !filter || i % 3 !== 0;
});
const expected = oracle.run(height);
const actual = clusterRadioPoints({
records,
project,
horizonVisible: () => true,
height,
previousHeight,
previousClusters,
});
assert.deepEqual(
actual.clusters.map(({ stationIds, position }) => ({
stationIds,
position,
})),
expected.clusters,
);
assert.deepEqual(actual.singletons, expected.singletons);
assert.deepEqual(
actual.clusters.map(({ stationIds, ...rest }) => rest),
expected.retained.map(({ position, ...rest }) => ({
position: { ...position },
...rest,
})),
);
previousClusters = actual.clusters;
previousHeight = height;
}
}
});
test('radio clustering drops hidden and unprojectable points and keeps a two-point group separate', () => {
const records = Array.from({ length: 5 }, (_, i) => ({
station: { id: String(i) },
visible: i !== 2,
position: { x: i, y: 0, z: 0 },
}));
const result = clusterRadioPoints({
records,
project: (p) => (p.x === 3 ? undefined : { x: p.x, y: 0 }),
horizonVisible: (p) => p.x !== 4,
height: 1,
});
assert.deepEqual(result, { clusters: [], singletons: ['0', '1'] });
});
+2 -15
View File
@@ -57,12 +57,7 @@ export function createSelection({
}
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();
}
if (filterChanged) parts.renderer?.invalidateClusters();
parts.rendering.scheduleRadioOverlayPublish();
}
parts.presentation.emitState();
@@ -245,15 +240,7 @@ export function createSelection({
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();
}
if (changed) parts.renderer?.invalidateClusters();
parts.rendering.scheduleRadioOverlayPublish();
parts.presentation.emitState();
return true;
+1 -7
View File
@@ -12,10 +12,6 @@ export function createState({ services }) {
layerState._radioEarthToCenter = geo.cartesian3.create();
layerState._viewer = null;
layerState._dataSource = null;
layerState._enabled = false;
layerState._managerLifecyclePresentation = null;
@@ -44,7 +40,7 @@ export function createState({ services }) {
layerState._selectedId = null;
layerState._selectedEntity = null;
layerState._selectedPosition = null;
layerState._selectionGeneration = 0;
@@ -132,8 +128,6 @@ export function createState({ services }) {
layerState._sessionGeneration = 0;
layerState._removeClusterListener = null;
layerState._overlayPublishTimer = null;
layerState._clusterOverlayIdentitySequence = 0;
+4 -1
View File
@@ -2666,7 +2666,10 @@ function drawWorldOverlay() {
function createDevFacade() {
if (typeof window === 'undefined' || import.meta.env?.DEV !== true) return;
window.__gevWorldOverlay = { getDiagnostics: getWorldOverlayDiagnostics };
window.__gevWorldOverlay = {
getDiagnostics: getWorldOverlayDiagnostics,
getPaintRect: getOverlayPaintRect,
};
}
/**