mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
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:
1 parent
d82a6f1550
commit
9d587c1d37
27 files changed
+1490
-350
No files matched your search
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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
@@ -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
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
})),
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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'] });
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user