mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +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",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
|
"kdbush": "4.0.2",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"pbf": "^5.1.2",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"cesium": "^1.124.0",
|
"cesium": "^1.124.0",
|
||||||
"egm96-universal": "^1.1.1",
|
"egm96-universal": "^1.1.1",
|
||||||
"hls.js": "^1.7.3",
|
"hls.js": "^1.7.3",
|
||||||
|
"kdbush": "4.0.2",
|
||||||
"mapillary-js": "^4.1.2",
|
"mapillary-js": "^4.1.2",
|
||||||
"mgrs": "^2.1.0",
|
"mgrs": "^2.1.0",
|
||||||
"pbf": "^5.1.2",
|
"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
|
// 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.
|
// Navara only in render/navara*.js. The list grows with each moved layer.
|
||||||
const RENDERED_LAYER_DIRS = [
|
const RENDERED_LAYER_DIRS = [
|
||||||
|
'src/layers/radio/',
|
||||||
'src/layers/bikeshare/',
|
'src/layers/bikeshare/',
|
||||||
'src/layers/earthquakes/',
|
'src/layers/earthquakes/',
|
||||||
'src/layers/firms/',
|
'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/presentation.js",
|
||||||
"src/layers/radio/queries.js",
|
"src/layers/radio/queries.js",
|
||||||
"src/layers/radio/rendering.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/selection.js",
|
||||||
"src/layers/radio/source.js",
|
"src/layers/radio/source.js",
|
||||||
"src/layers/radio/state.js",
|
"src/layers/radio/state.js",
|
||||||
@@ -1221,5 +1226,7 @@
|
|||||||
"src/layers/firms/lifecycle.test.mjs",
|
"src/layers/firms/lifecycle.test.mjs",
|
||||||
"scripts/qa-w2-firms-renderer-entry.mjs",
|
"scripts/qa-w2-firms-renderer-entry.mjs",
|
||||||
"scripts/qa-w2-firms-perf.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": {
|
"radio-layer": {
|
||||||
"exports": ["./layers/radio"],
|
"exports": ["./layers/radio"],
|
||||||
"modules": [
|
"modules": [
|
||||||
"src/data/iconOrientation.js",
|
|
||||||
"src/data/render/cesiumProjection.js",
|
|
||||||
"src/data/idSwitches.js",
|
"src/data/idSwitches.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
@@ -1648,11 +1646,9 @@
|
|||||||
"src/geo/scaleToGeodeticSurface.js",
|
"src/geo/scaleToGeodeticSurface.js",
|
||||||
"src/geo/check.js",
|
"src/geo/check.js",
|
||||||
"src/geo/debug.js",
|
"src/geo/debug.js",
|
||||||
"src/geo/developerError.js",
|
"src/geo/developerError.js"
|
||||||
"src/globe/contract.js",
|
|
||||||
"src/globe/cesium/engineFromViewer.js"
|
|
||||||
],
|
],
|
||||||
"external": ["cesium"]
|
"external": []
|
||||||
},
|
},
|
||||||
"submarine-cables": {
|
"submarine-cables": {
|
||||||
"exports": ["./layers/submarine-cables"],
|
"exports": ["./layers/submarine-cables"],
|
||||||
@@ -2709,7 +2705,10 @@
|
|||||||
"src/layers/installations/render/navaraGeometry.js",
|
"src/layers/installations/render/navaraGeometry.js",
|
||||||
"src/layers/firms/rectangle.js",
|
"src/layers/firms/rectangle.js",
|
||||||
"src/layers/firms/render/cesium.js",
|
"src/layers/firms/render/cesium.js",
|
||||||
|
"src/layers/radio/render/cesium.js",
|
||||||
"src/layers/firms/render/navara.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/navaraGeometry.js",
|
||||||
"src/layers/firms/render/navaraSprites.js",
|
"src/layers/firms/render/navaraSprites.js",
|
||||||
"src/layers/shared/render/navaraGroundGeometry.js",
|
"src/layers/shared/render/navaraGroundGeometry.js",
|
||||||
@@ -2717,6 +2716,7 @@
|
|||||||
"src/layers/shared/render/navaraGroundSurface.js"
|
"src/layers/shared/render/navaraGroundSurface.js"
|
||||||
],
|
],
|
||||||
"external": [
|
"external": [
|
||||||
|
"kdbush",
|
||||||
"hls.js",
|
"hls.js",
|
||||||
"@mapbox/vector-tile",
|
"@mapbox/vector-tile",
|
||||||
"cesium",
|
"cesium",
|
||||||
@@ -3314,7 +3314,8 @@
|
|||||||
"src/layers/satellites/render/cesium.js",
|
"src/layers/satellites/render/cesium.js",
|
||||||
"src/layers/earthquakes/render/cesium.js",
|
"src/layers/earthquakes/render/cesium.js",
|
||||||
"src/layers/firms/rectangle.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": [
|
"external": [
|
||||||
"hls.js",
|
"hls.js",
|
||||||
|
|||||||
+25
-41
@@ -9,12 +9,14 @@
|
|||||||
* independence, restoration without autoplay, responsive UI, and a clean
|
* independence, restoration without autoplay, responsive UI, and a clean
|
||||||
* console. Screenshots are written under the gitignored `qa-shots/radio/`.
|
* 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 fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import puppeteer from 'puppeteer';
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
@@ -24,8 +26,10 @@ const option = (name, fallback) => {
|
|||||||
const index = args.indexOf(name);
|
const index = args.indexOf(name);
|
||||||
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
|
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 APP_ORIGIN = new URL(APP_URL).origin;
|
||||||
|
const expectedPreviewErrors = [];
|
||||||
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
|
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
|
||||||
// has its own acceptance harness and must not intercept those gestures.
|
// has its own acceptance harness and must not intercept those gestures.
|
||||||
const RADIO_URL = new URL(APP_URL);
|
const RADIO_URL = new URL(APP_URL);
|
||||||
@@ -42,35 +46,6 @@ const chrome = chromeCandidates.find((candidate) => {
|
|||||||
try { return fs.existsSync(candidate); } catch { return false; }
|
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 = [];
|
const results = [];
|
||||||
function check(name, ok, detail = '') {
|
function check(name, ok, detail = '') {
|
||||||
@@ -241,6 +216,10 @@ async function main() {
|
|||||||
const failedResponses = [];
|
const failedResponses = [];
|
||||||
const externalCesiumEndpointFailures = [];
|
const externalCesiumEndpointFailures = [];
|
||||||
page.on('console', (message) => {
|
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') {
|
if (message.type() === 'error') {
|
||||||
const sourceUrl = message.location()?.url || '';
|
const sourceUrl = message.location()?.url || '';
|
||||||
consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text());
|
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 radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
|
||||||
.find((item) => item.name === 'Radio stations');
|
.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');
|
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let removePostRender = null;
|
let removePostRender = null;
|
||||||
@@ -1283,7 +1262,7 @@ async function main() {
|
|||||||
);
|
);
|
||||||
const clusterBadge = await page.evaluate(async () => {
|
const clusterBadge = await page.evaluate(async () => {
|
||||||
const viewer = window.__godsEyeView.viewer;
|
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 { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||||
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
const { radioStationIdFromPick } = await import('/src/data/radio.js');
|
||||||
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
|
||||||
@@ -1394,7 +1373,7 @@ async function main() {
|
|||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
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 { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
|
||||||
const source = radio.getOverlayDiagnostics();
|
const source = radio.getOverlayDiagnostics();
|
||||||
const cameraPosition = viewer.camera.positionWC;
|
const cameraPosition = viewer.camera.positionWC;
|
||||||
@@ -1456,7 +1435,7 @@ async function main() {
|
|||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
|
||||||
const ellipsoid = viewer.scene.globe.ellipsoid;
|
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) => {
|
const paintedClusters = () => new Promise((resolve) => {
|
||||||
let removePostRender = null;
|
let removePostRender = null;
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
@@ -3699,7 +3678,7 @@ async function main() {
|
|||||||
radio.setFilter('news');
|
radio.setFilter('news');
|
||||||
viewer.scene.requestRender();
|
viewer.scene.requestRender();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
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) => {
|
return new Promise((resolve) => {
|
||||||
let removePostRender = null;
|
let removePostRender = null;
|
||||||
let timeout = null;
|
let timeout = null;
|
||||||
@@ -3862,7 +3841,7 @@ async function main() {
|
|||||||
const viewer = window.__godsEyeView.viewer;
|
const viewer = window.__godsEyeView.viewer;
|
||||||
const selectedEntity = window.__godsEyeView.viewer.entities.values
|
const selectedEntity = window.__godsEyeView.viewer.entities.values
|
||||||
.find((entity) => String(entity.id).startsWith('radio:selected:'));
|
.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 flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => {
|
||||||
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
|
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
|
||||||
return {
|
return {
|
||||||
@@ -4045,7 +4024,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
const selectedEntity = viewer.entities.values
|
const selectedEntity = viewer.entities.values
|
||||||
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
.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}`;
|
const expectedEntryId = `selected:${selected.id}`;
|
||||||
let painted = null;
|
let painted = null;
|
||||||
// Radio's horizon pass reveals the selected entity on its own timer
|
// Radio's horizon pass reveals the selected entity on its own timer
|
||||||
@@ -4104,7 +4083,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
const selectedEntity = viewer.entities.values
|
const selectedEntity = viewer.entities.values
|
||||||
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
|
.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}`;
|
const expectedEntryId = `selected:${selected.id}`;
|
||||||
let rect = null;
|
let rect = null;
|
||||||
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
|
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
|
||||||
@@ -4141,11 +4120,14 @@ async function main() {
|
|||||||
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
|
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
|
||||||
selectedPointPick = picks.find((picked) => (
|
selectedPointPick = picks.find((picked) => (
|
||||||
picked?.id === selectedEntity
|
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) => (
|
selectedBillboardPick = picks.find((picked) => (
|
||||||
picked?.id === selectedEntity
|
picked?.id === selectedEntity
|
||||||
&& String(picked?.primitive?.constructor?.name || '').includes('Billboard')
|
&& Number.isFinite(picked?.primitive?.width)
|
||||||
|
&& Number.isFinite(picked?.primitive?.height)
|
||||||
));
|
));
|
||||||
if (!selectedPointPick) {
|
if (!selectedPointPick) {
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@@ -4741,6 +4723,8 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const failures = results.filter((result) => !result.ok).length;
|
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(`\nRadio QA: ${results.length - failures}/${results.length} passed`);
|
||||||
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
|
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
|
||||||
process.exitCode = failures ? 1 : 0;
|
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 { createRadioLayer } from '../../layers/radio/index.js';
|
||||||
|
import { rendererFor } from './renderers.js';
|
||||||
import * as picking from '../../data/pickRegistry.js';
|
import * as picking from '../../data/pickRegistry.js';
|
||||||
import * as overlays from '../../overlays/worldOverlay.js';
|
import * as overlays from '../../overlays/worldOverlay.js';
|
||||||
import * as globe from '../../celestialRing.js';
|
import * as globe from '../../celestialRing.js';
|
||||||
import * as render from '../../renderGovernor.js';
|
import * as render from '../../renderGovernor.js';
|
||||||
|
|
||||||
/** Construct one layer using the application scene owners and a supplied source. */
|
/** 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;
|
const { groundFloor: ground } = surface;
|
||||||
return createRadioLayer({
|
return createRadioLayer({
|
||||||
source,
|
source,
|
||||||
|
rendererFactory,
|
||||||
services: { ground, picking, overlays, globe, render },
|
services: { ground, picking, overlays, globe, render },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'local-firms',
|
'local-firms',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
|
'radio',
|
||||||
])
|
])
|
||||||
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
assert.equal(layerAvailableOn(id, 'navara'), true, id);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -41,6 +42,7 @@ test('the Navara mode registers migrated renderers once', async () => {
|
|||||||
'earthquakes',
|
'earthquakes',
|
||||||
'satellites',
|
'satellites',
|
||||||
'rocket-launches',
|
'rocket-launches',
|
||||||
|
'radio',
|
||||||
'bikeshare',
|
'bikeshare',
|
||||||
'ais-live-vessels',
|
'ais-live-vessels',
|
||||||
'military-installations',
|
'military-installations',
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
'launch-library-2',
|
'launch-library-2',
|
||||||
'usgs',
|
'usgs',
|
||||||
'firms',
|
'firms',
|
||||||
|
'gbfs',
|
||||||
|
'overture-military-names',
|
||||||
|
'radio-browser',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -62,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([
|
|||||||
*/
|
*/
|
||||||
export async function registerNavaraLayerRenderers() {
|
export async function registerNavaraLayerRenderers() {
|
||||||
const loaders = {
|
const loaders = {
|
||||||
|
radio: async () =>
|
||||||
|
(await import('../layers/radio/render/navara.js'))
|
||||||
|
.createRadioNavaraRenderer,
|
||||||
bikeshare: async () =>
|
bikeshare: async () =>
|
||||||
(await import('../layers/bikeshare/render/navara.js'))
|
(await import('../layers/bikeshare/render/navara.js'))
|
||||||
.createBikeshareNavaraRenderer,
|
.createBikeshareNavaraRenderer,
|
||||||
@@ -274,7 +280,7 @@ export function createViewerlessTools({
|
|||||||
if (window.__godsEyeView === debug) delete window.__godsEyeView;
|
if (window.__godsEyeView === debug) delete window.__godsEyeView;
|
||||||
});
|
});
|
||||||
// Voice on the engine (phase 3, Task 9): the W1 tools drive the engine;
|
// 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({
|
const voiceCommands = initGevVoiceCommands({
|
||||||
...voice,
|
...voice,
|
||||||
annotationResolver: operations.annotationResolver,
|
annotationResolver: operations.annotationResolver,
|
||||||
|
|||||||
@@ -182,12 +182,15 @@ test('Navara keeps attribution for every available W1/W2 source and excludes una
|
|||||||
'launch-library-2',
|
'launch-library-2',
|
||||||
'usgs',
|
'usgs',
|
||||||
'firms',
|
'firms',
|
||||||
|
'gbfs',
|
||||||
|
'overture-military-names',
|
||||||
|
'radio-browser',
|
||||||
'photon-geocoder',
|
'photon-geocoder',
|
||||||
'openstreetmap',
|
'openstreetmap',
|
||||||
'reearth-terrain',
|
'reearth-terrain',
|
||||||
];
|
];
|
||||||
const run = await startScene({
|
const run = await startScene({
|
||||||
credits: [...keys, 'radio-browser'].map((key) => ({
|
credits: [...keys, 'nasa-gibs'].map((key) => ({
|
||||||
key,
|
key,
|
||||||
html: `Credit ${key}`,
|
html: `Credit ${key}`,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -128,7 +128,10 @@ test('every scene click handler consults ownership before it picks', () => {
|
|||||||
'viewer.scene.pick(click.position)',
|
'viewer.scene.pick(click.position)',
|
||||||
],
|
],
|
||||||
['src/layers/launches/render/cesium.js', 'drillPick(movement.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',
|
'src/layers/satellites/render/cesium.js',
|
||||||
'viewer.scene.pick(click.position)',
|
'viewer.scene.pick(click.position)',
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import { createIngestion } from './ingestion.js';
|
|||||||
import { createState } from './state.js';
|
import { createState } from './state.js';
|
||||||
|
|
||||||
/** Construct one layer with its own scene state and supplied application services. */
|
/** 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 state = createState({ services });
|
||||||
const parts = {};
|
const parts = {};
|
||||||
const context = { state, services, parts, source };
|
const context = { state, services, parts, source, rendererFactory };
|
||||||
parts.catalogModel = createCatalogModel(context);
|
parts.catalogModel = createCatalogModel(context);
|
||||||
parts.labels = createLabels(context);
|
parts.labels = createLabels(context);
|
||||||
parts.clustering = createClustering(context);
|
parts.clustering = createClustering(context);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
RADIO_DIRECTORY_STALE_MS,
|
RADIO_DIRECTORY_STALE_MS,
|
||||||
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
RADIO_DIRECTORY_FUTURE_SKEW_MS,
|
||||||
|
RADIO_TUNER_DIRECTORY_LIMIT,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
export function createIngestion({
|
export function createIngestion({
|
||||||
@@ -36,6 +37,8 @@ export function createIngestion({
|
|||||||
return;
|
return;
|
||||||
if (!Array.isArray(body?.stations))
|
if (!Array.isArray(body?.stations))
|
||||||
throw new Error('Radio directory response was malformed');
|
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 =
|
const updatedAt =
|
||||||
typeof body.updatedAt === 'string' &&
|
typeof body.updatedAt === 'string' &&
|
||||||
Number.isFinite(Date.parse(body.updatedAt))
|
Number.isFinite(Date.parse(body.updatedAt))
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import { isPointerFree } from '../../data/inputOwnership.js';
|
import { isPointerFree } from '../../data/inputOwnership.js';
|
||||||
import {
|
import {
|
||||||
RADIO_PREFIX,
|
RADIO_PREFIX,
|
||||||
@@ -13,12 +12,7 @@ export function createInteraction({
|
|||||||
parts,
|
parts,
|
||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
|
||||||
resolvePickId,
|
|
||||||
isOwnedByOtherLayer,
|
|
||||||
registerPickOwner,
|
|
||||||
unregisterPickOwner,
|
|
||||||
} = services.picking;
|
|
||||||
const { setOverlaySourceVisible } = services.overlays;
|
const { setOverlaySourceVisible } = services.overlays;
|
||||||
|
|
||||||
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
|
/** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */
|
||||||
@@ -47,8 +41,8 @@ export function createInteraction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pickedRadioStationAt(position) {
|
function pickedRadioStationAt(position) {
|
||||||
const scene = layerState._viewer?.scene;
|
const renderer = parts.renderer;
|
||||||
if (!scene || !position) return null;
|
if (!renderer?.ready || !position) return null;
|
||||||
|
|
||||||
const stationFromPick = (picked) => {
|
const stationFromPick = (picked) => {
|
||||||
const stationId = radioStationIdFromPick(picked);
|
const stationId = radioStationIdFromPick(picked);
|
||||||
@@ -56,14 +50,14 @@ export function createInteraction({
|
|||||||
? stationId
|
? stationId
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
const primaryPick = scene.pick(position);
|
const primaryPick = renderer.pick(position);
|
||||||
const primaryStationId = stationFromPick(primaryPick);
|
const primaryStationId = stationFromPick(primaryPick);
|
||||||
if (primaryStationId) return primaryStationId;
|
if (primaryStationId) return primaryStationId;
|
||||||
const primaryId = resolvePickId(primaryPick);
|
const primaryId = resolvePickId(primaryPick);
|
||||||
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
|
if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null;
|
||||||
|
|
||||||
if (typeof scene.drillPick === 'function') {
|
if (typeof renderer.drillPick === 'function') {
|
||||||
const drilled = scene.drillPick(position, 16) || [];
|
const drilled = renderer.drillPick(position, 16) || [];
|
||||||
for (const picked of drilled) {
|
for (const picked of drilled) {
|
||||||
const stationId = stationFromPick(picked);
|
const stationId = stationFromPick(picked);
|
||||||
if (stationId) return stationId;
|
if (stationId) return stationId;
|
||||||
@@ -71,11 +65,11 @@ export function createInteraction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) {
|
for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) {
|
||||||
const offsetPosition = new Cesium.Cartesian2(
|
const offsetPosition = {
|
||||||
position.x + offsetX,
|
x: position.x + offsetX,
|
||||||
position.y + offsetY,
|
y: position.y + offsetY,
|
||||||
);
|
};
|
||||||
const picked = scene.pick(offsetPosition);
|
const picked = renderer.pick(offsetPosition);
|
||||||
const pickedId = resolvePickId(picked);
|
const pickedId = resolvePickId(picked);
|
||||||
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
|
if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue;
|
||||||
const stationId = stationFromPick(picked);
|
const stationId = stationFromPick(picked);
|
||||||
@@ -96,7 +90,7 @@ export function createInteraction({
|
|||||||
|
|
||||||
function syncRadioLifecyclePresentation() {
|
function syncRadioLifecyclePresentation() {
|
||||||
const visible = radioPresentationAllowed();
|
const visible = radioPresentationAllowed();
|
||||||
if (layerState._dataSource) layerState._dataSource.show = visible;
|
parts.renderer?.setVisible(visible);
|
||||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
|
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible);
|
||||||
if (visible) {
|
if (visible) {
|
||||||
installInteraction();
|
installInteraction();
|
||||||
@@ -105,23 +99,18 @@ export function createInteraction({
|
|||||||
parts.rendering.scheduleRadioOverlayPublish();
|
parts.rendering.scheduleRadioOverlayPublish();
|
||||||
} else {
|
} else {
|
||||||
removeInteraction();
|
removeInteraction();
|
||||||
if (layerState._selectedEntity && layerState._viewer)
|
parts.renderer?.clearSelected();
|
||||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
layerState._selectedPosition = null;
|
||||||
layerState._selectedEntity = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function installInteraction() {
|
function installInteraction() {
|
||||||
if (!layerState._viewer || layerState._clickHandler) return;
|
if (!parts.renderer?.ready || layerState._clickHandler) return;
|
||||||
registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX));
|
layerState._clickHandler = parts.renderer.installClick((position) => {
|
||||||
layerState._clickHandler = new Cesium.ScreenSpaceEventHandler(
|
|
||||||
layerState._viewer.scene.canvas,
|
|
||||||
);
|
|
||||||
layerState._clickHandler.setInputAction((click) => {
|
|
||||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||||
if (!isPointerFree()) return;
|
if (!isPointerFree()) return;
|
||||||
if (!radioPresentationAllowed()) return;
|
if (!radioPresentationAllowed()) return;
|
||||||
const stationId = pickedRadioStationAt(click.position);
|
const stationId = pickedRadioStationAt(position);
|
||||||
if (!stationId) return;
|
if (!stationId) return;
|
||||||
layerState._playFallbackId = null;
|
layerState._playFallbackId = null;
|
||||||
layerState._playFallbackFocus = null;
|
layerState._playFallbackFocus = null;
|
||||||
@@ -134,7 +123,7 @@ export function createInteraction({
|
|||||||
new CustomEvent('gev:radio-selected', { detail: { stationId } }),
|
new CustomEvent('gev:radio-selected', { detail: { stationId } }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
});
|
||||||
// Polling remains bounded during flights, but a stationary camera no longer
|
// Polling remains bounded during flights, but a stationary camera no longer
|
||||||
// rewrites every station's visibility four times per second while voice and
|
// rewrites every station's visibility four times per second while voice and
|
||||||
// audio processing share the main thread.
|
// audio processing share the main thread.
|
||||||
@@ -145,8 +134,7 @@ export function createInteraction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeInteraction() {
|
function removeInteraction() {
|
||||||
unregisterPickOwner('radio');
|
layerState._clickHandler?.();
|
||||||
layerState._clickHandler?.destroy();
|
|
||||||
layerState._clickHandler = null;
|
layerState._clickHandler = null;
|
||||||
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
|
if (layerState._horizonTimer) clearInterval(layerState._horizonTimer);
|
||||||
layerState._horizonTimer = null;
|
layerState._horizonTimer = null;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import {
|
import {
|
||||||
RADIO_OVERLAY_SOURCE_ID,
|
RADIO_OVERLAY_SOURCE_ID,
|
||||||
DEFAULT_RADIO_VOLUME,
|
DEFAULT_RADIO_VOLUME,
|
||||||
@@ -11,22 +10,28 @@ export function createLifecycle({
|
|||||||
services,
|
services,
|
||||||
parts,
|
parts,
|
||||||
source,
|
source,
|
||||||
|
rendererFactory,
|
||||||
}) {
|
}) {
|
||||||
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
|
const { clearOverlaySource, setOverlaySourceVisible } = services.overlays;
|
||||||
|
|
||||||
const methods = {
|
const methods = {
|
||||||
/** Initialize the Cesium data source and the single audio element. */
|
/** Initialize scene resources and the single audio element. */
|
||||||
init(viewer) {
|
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._sessionGeneration += 1;
|
||||||
layerState._viewer = viewer;
|
|
||||||
parts.clustering.resetRadioClusterOverlayIdentities();
|
parts.clustering.resetRadioClusterOverlayIdentities();
|
||||||
parts.playback.installAudio();
|
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);
|
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||||
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false);
|
||||||
},
|
},
|
||||||
@@ -55,10 +60,9 @@ export function createLifecycle({
|
|||||||
layerState._cancelledTuningPresentationStation = null;
|
layerState._cancelledTuningPresentationStation = null;
|
||||||
layerState._tuningUnavailableStationId = null;
|
layerState._tuningUnavailableStationId = null;
|
||||||
parts.playback.stopRadioPlayback({ origin: 'layer-disable' });
|
parts.playback.stopRadioPlayback({ origin: 'layer-disable' });
|
||||||
if (layerState._dataSource) layerState._dataSource.show = false;
|
parts.renderer?.setVisible(false);
|
||||||
if (layerState._selectedEntity && layerState._viewer)
|
parts.renderer?.clearSelected();
|
||||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
layerState._selectedPosition = null;
|
||||||
layerState._selectedEntity = null;
|
|
||||||
if (layerState._overlayPublishTimer)
|
if (layerState._overlayPublishTimer)
|
||||||
clearTimeout(layerState._overlayPublishTimer);
|
clearTimeout(layerState._overlayPublishTimer);
|
||||||
layerState._overlayPublishTimer = null;
|
layerState._overlayPublishTimer = null;
|
||||||
@@ -83,11 +87,8 @@ export function createLifecycle({
|
|||||||
if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME;
|
if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME;
|
||||||
layerState._audio = null;
|
layerState._audio = null;
|
||||||
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
layerState._userVolume = DEFAULT_RADIO_VOLUME;
|
||||||
layerState._removeClusterListener?.();
|
parts.renderer?.destroy();
|
||||||
layerState._removeClusterListener = null;
|
parts.renderer = null;
|
||||||
if (layerState._dataSource && layerState._viewer)
|
|
||||||
layerState._viewer.dataSources.remove(layerState._dataSource, true);
|
|
||||||
layerState._dataSource = null;
|
|
||||||
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
clearOverlaySource(RADIO_OVERLAY_SOURCE_ID);
|
||||||
layerState._stations = [];
|
layerState._stations = [];
|
||||||
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT;
|
||||||
@@ -111,7 +112,6 @@ export function createLifecycle({
|
|||||||
layerState._error = null;
|
layerState._error = null;
|
||||||
layerState._updatedAt = null;
|
layerState._updatedAt = null;
|
||||||
layerState._managerLifecyclePresentation = null;
|
layerState._managerLifecyclePresentation = null;
|
||||||
layerState._viewer = null;
|
|
||||||
parts.presentation.emitState();
|
parts.presentation.emitState();
|
||||||
layerState._listeners.clear();
|
layerState._listeners.clear();
|
||||||
layerState._playbackControlListeners.clear();
|
layerState._playbackControlListeners.clear();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import {
|
import {
|
||||||
HORIZON_CAMERA_MOVE_EPSILON_M,
|
HORIZON_CAMERA_MOVE_EPSILON_M,
|
||||||
@@ -15,7 +14,6 @@ export function createNavigation({
|
|||||||
const {
|
const {
|
||||||
isFullGlobeInsideKeyhole,
|
isFullGlobeInsideKeyhole,
|
||||||
GLOBE_ENTER_CLEARANCE_PX,
|
GLOBE_ENTER_CLEARANCE_PX,
|
||||||
projectEarthDiscToViewport,
|
|
||||||
earthDiscScreenRadius,
|
earthDiscScreenRadius,
|
||||||
getKeyholeGeometry,
|
getKeyholeGeometry,
|
||||||
} = services.globe;
|
} = services.globe;
|
||||||
@@ -136,7 +134,8 @@ export function createNavigation({
|
|||||||
: currentHeight;
|
: currentHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function radioCameraState(camera = layerState._viewer?.camera) {
|
function radioCameraState(camera) {
|
||||||
|
if (camera === undefined) return parts.renderer?.cameraState() ?? null;
|
||||||
if (!camera) return null;
|
if (!camera) return null;
|
||||||
return {
|
return {
|
||||||
height: camera.positionCartographic?.height,
|
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. */
|
/** 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;
|
return Boolean(viewer?.camera) && !viewer.trackedEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
function radioGlobeRecenterPlan(viewer = layerState._viewer) {
|
function radioGlobeRecenterPlan() {
|
||||||
const camera = viewer?.camera;
|
const view = parts.renderer?.recenterView();
|
||||||
const canvas = viewer?.scene?.canvas;
|
if (!view) return null;
|
||||||
const width = canvas?.clientWidth || canvas?.width;
|
const { width, height, cartographic, geometry } = view;
|
||||||
const height = canvas?.clientHeight || canvas?.height;
|
|
||||||
const cartographic = camera?.positionCartographic;
|
|
||||||
if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null;
|
|
||||||
const geometry = projectEarthDiscToViewport(
|
|
||||||
viewer,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
layerState._radioEarthScreenCenter,
|
|
||||||
layerState._radioEarthToCenter,
|
|
||||||
);
|
|
||||||
let fullGlobeCapable = false;
|
let fullGlobeCapable = false;
|
||||||
if (geometry) {
|
if (geometry) {
|
||||||
if (!radioGlobeNeedsRecentering(geometry)) return null;
|
if (!radioGlobeNeedsRecentering(geometry)) return null;
|
||||||
@@ -179,13 +170,12 @@ export function createNavigation({
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const earthRadius = earthDiscScreenRadius(
|
const earthRadius = earthDiscScreenRadius(
|
||||||
geo.cartesian3.magnitude(camera.positionWC),
|
geo.cartesian3.magnitude(view.position),
|
||||||
height,
|
height,
|
||||||
camera.frustum?.fovy,
|
view.fovy,
|
||||||
);
|
);
|
||||||
const facingEarthCenter =
|
const facingEarthCenter =
|
||||||
geo.cartesian3.dot(camera.directionWC, layerState._radioEarthToCenter) >
|
geo.cartesian3.dot(view.direction, view.toCenter) > 0;
|
||||||
0;
|
|
||||||
if (!earthRadius || facingEarthCenter) return null;
|
if (!earthRadius || facingEarthCenter) return null;
|
||||||
const keyhole = getKeyholeGeometry(width, height);
|
const keyhole = getKeyholeGeometry(width, height);
|
||||||
fullGlobeCapable =
|
fullGlobeCapable =
|
||||||
@@ -203,7 +193,7 @@ export function createNavigation({
|
|||||||
recenterHeight,
|
recenterHeight,
|
||||||
),
|
),
|
||||||
cameraState: {
|
cameraState: {
|
||||||
...radioCameraState(camera),
|
...radioCameraState(),
|
||||||
height: recenterHeight,
|
height: recenterHeight,
|
||||||
heading: 0,
|
heading: 0,
|
||||||
pitch: -geo.math.PI_OVER_TWO,
|
pitch: -geo.math.PI_OVER_TWO,
|
||||||
@@ -217,18 +207,15 @@ export function createNavigation({
|
|||||||
navigation &&
|
navigation &&
|
||||||
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
navigation.generation === layerState._radioCameraNavigationGeneration &&
|
||||||
layerState._enabled &&
|
layerState._enabled &&
|
||||||
radioCameraNavigationAllowed(layerState._viewer),
|
radioCameraNavigationAllowed(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelActiveRadioCameraFlight() {
|
function cancelActiveRadioCameraFlight() {
|
||||||
if (
|
if (!layerState._activeRadioCameraFlight || !radioCameraNavigationAllowed())
|
||||||
!layerState._activeRadioCameraFlight ||
|
|
||||||
!radioCameraNavigationAllowed(layerState._viewer)
|
|
||||||
)
|
|
||||||
return;
|
return;
|
||||||
layerState._activeRadioCameraFlight = null;
|
layerState._activeRadioCameraFlight = null;
|
||||||
layerState._viewer.camera.cancelFlight();
|
parts.renderer.cancelFlight();
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateRadioCameraNavigation() {
|
function invalidateRadioCameraNavigation() {
|
||||||
@@ -259,7 +246,7 @@ export function createNavigation({
|
|||||||
if (completed && radioCameraNavigationIsCurrent(navigation))
|
if (completed && radioCameraNavigationIsCurrent(navigation))
|
||||||
onComplete?.();
|
onComplete?.();
|
||||||
};
|
};
|
||||||
layerState._viewer.camera.flyTo({
|
parts.renderer.flyTo({
|
||||||
...options,
|
...options,
|
||||||
complete: () => finish(true),
|
complete: () => finish(true),
|
||||||
cancel: () => finish(false),
|
cancel: () => finish(false),
|
||||||
@@ -299,13 +286,9 @@ export function createNavigation({
|
|||||||
|
|
||||||
function beginRadioCameraNavigation(cameraState = null) {
|
function beginRadioCameraNavigation(cameraState = null) {
|
||||||
const generation = ++layerState._radioCameraNavigationGeneration;
|
const generation = ++layerState._radioCameraNavigationGeneration;
|
||||||
if (
|
if (!radioCameraNavigationAllowed() || !layerState._enabled) return null;
|
||||||
!radioCameraNavigationAllowed(layerState._viewer) ||
|
|
||||||
!layerState._enabled
|
|
||||||
)
|
|
||||||
return null;
|
|
||||||
cancelActiveRadioCameraFlight();
|
cancelActiveRadioCameraFlight();
|
||||||
const recenterPlan = radioGlobeRecenterPlan(layerState._viewer);
|
const recenterPlan = radioGlobeRecenterPlan();
|
||||||
return {
|
return {
|
||||||
generation,
|
generation,
|
||||||
phase: 'idle',
|
phase: 'idle',
|
||||||
@@ -345,7 +328,7 @@ export function createNavigation({
|
|||||||
roll: activeNavigation.cameraState.roll,
|
roll: activeNavigation.cameraState.roll,
|
||||||
},
|
},
|
||||||
duration: recenterDuration,
|
duration: recenterDuration,
|
||||||
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
|
easing: 'cubicInOut',
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
activeNavigation.recentered = true;
|
activeNavigation.recentered = true;
|
||||||
@@ -359,10 +342,9 @@ export function createNavigation({
|
|||||||
|
|
||||||
function focusStation(station) {
|
function focusStation(station) {
|
||||||
layerState._radioCameraNavigationGeneration += 1;
|
layerState._radioCameraNavigationGeneration += 1;
|
||||||
if (!station || !radioCameraNavigationAllowed(layerState._viewer))
|
if (!station || !radioCameraNavigationAllowed()) return false;
|
||||||
return false;
|
|
||||||
cancelActiveRadioCameraFlight();
|
cancelActiveRadioCameraFlight();
|
||||||
layerState._viewer.camera.flyTo({
|
parts.renderer.flyTo({
|
||||||
destination: geo.cartesian3.fromDegrees(station.lon, station.lat, 85_000),
|
destination: geo.cartesian3.fromDegrees(station.lon, station.lat, 85_000),
|
||||||
duration: 1.1,
|
duration: 1.1,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import * as Cesium from 'cesium';
|
|
||||||
import * as geo from '../../geo/index.js';
|
import * as geo from '../../geo/index.js';
|
||||||
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
import { normalizeRadioCountryInput } from '../../data/radioCountry.js';
|
||||||
|
|
||||||
@@ -107,31 +106,13 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function viewportRadioAnchor() {
|
function viewportRadioAnchor() {
|
||||||
const camera = layerState._viewer?.camera;
|
const anchor = parts.renderer?.viewportAnchor();
|
||||||
const scene = layerState._viewer?.scene;
|
return anchor
|
||||||
if (!camera) return null;
|
? {
|
||||||
const altitudeM = Number(camera.positionCartographic?.height);
|
...anchor,
|
||||||
let cartographic = null;
|
globalView: parts.navigation.radioViewIsGlobal(anchor.altitudeM),
|
||||||
const canvas = scene?.canvas;
|
}
|
||||||
if (canvas && typeof camera.pickEllipsoid === 'function') {
|
: null;
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rankedVisibleStations() {
|
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 * as geo from '../../geo/index.js';
|
||||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
|
||||||
import {
|
import {
|
||||||
MARKER_LIFT_M,
|
MARKER_LIFT_M,
|
||||||
RADIO_PREFIX,
|
|
||||||
SELECTED_LIFT_M,
|
SELECTED_LIFT_M,
|
||||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
|
||||||
RADIO_OVERLAY_SOURCE_ID,
|
RADIO_OVERLAY_SOURCE_ID,
|
||||||
RADIO_OVERLAY_COHORT_LIMIT,
|
RADIO_OVERLAY_COHORT_LIMIT,
|
||||||
RADIO_OVERLAY_SOURCE_OPTIONS,
|
RADIO_OVERLAY_SOURCE_OPTIONS,
|
||||||
@@ -17,73 +14,36 @@ export function createRendering({
|
|||||||
parts,
|
parts,
|
||||||
source,
|
source,
|
||||||
}) {
|
}) {
|
||||||
const { cachedGroundFloor } = services.ground;
|
|
||||||
const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } =
|
const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } =
|
||||||
services.overlays;
|
services.overlays;
|
||||||
const { governorRequestRender } = services.render;
|
const { governorRequestRender } = services.render;
|
||||||
|
|
||||||
function markerPosition(station, liftM = MARKER_LIFT_M) {
|
function markerPosition(station, liftM = MARKER_LIFT_M) {
|
||||||
const floor = cachedGroundFloor(station.lat, station.lon);
|
return parts.renderer.markerPosition(station, liftM);
|
||||||
return Cesium.Cartesian3.fromDegrees(
|
|
||||||
station.lon,
|
|
||||||
station.lat,
|
|
||||||
(Number.isFinite(floor) ? floor : 0) + liftM,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSelectionEntity() {
|
function updateSelectionEntity() {
|
||||||
if (layerState._selectedEntity && layerState._viewer)
|
parts.renderer?.clearSelected();
|
||||||
layerState._viewer.entities.remove(layerState._selectedEntity);
|
layerState._selectedPosition = null;
|
||||||
layerState._selectedEntity = null;
|
|
||||||
const station = parts.queries.selectedPresentationStation();
|
const station = parts.queries.selectedPresentationStation();
|
||||||
if (
|
if (
|
||||||
!parts.interaction.radioPresentationAllowed() ||
|
!parts.interaction.radioPresentationAllowed() ||
|
||||||
!layerState._viewer ||
|
!parts.renderer?.ready ||
|
||||||
!station
|
!station
|
||||||
) {
|
) {
|
||||||
scheduleRadioOverlayPublish();
|
scheduleRadioOverlayPublish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectionColor = parts.model.radioCategoryColor(
|
const color = parts.model.radioCategoryColor(
|
||||||
parts.model.radioStationCategoryId(station),
|
parts.model.radioStationCategoryId(station),
|
||||||
);
|
);
|
||||||
const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`;
|
const bracket = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(color))}`;
|
||||||
|
const position = markerPosition(station, SELECTED_LIFT_M);
|
||||||
layerState._selectedEntity = layerState._viewer.entities.add({
|
layerState._selectedPosition = position;
|
||||||
id: `${RADIO_PREFIX}selected:${station.id}`,
|
parts.renderer.addSelected(station, position, color, bracket);
|
||||||
position: markerPosition(station, SELECTED_LIFT_M),
|
|
||||||
point: {
|
|
||||||
pixelSize: 14,
|
|
||||||
color: Cesium.Color.fromCssColorString(selectionColor),
|
|
||||||
outlineColor: Cesium.Color.BLACK,
|
|
||||||
outlineWidth: 2,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
|
||||||
0,
|
|
||||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
billboard: {
|
|
||||||
image: bracketImage,
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
horizontalOrigin: Cesium.HorizontalOrigin.CENTER,
|
|
||||||
verticalOrigin: Cesium.VerticalOrigin.CENTER,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
|
||||||
0,
|
|
||||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
|
||||||
),
|
|
||||||
scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
scheduleRadioOverlayPublish();
|
scheduleRadioOverlayPublish();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clusterPointCollection() {
|
|
||||||
return layerState._dataSource?.clustering?._clusterPointCollection || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function publishRadioOverlayEntries() {
|
function publishRadioOverlayEntries() {
|
||||||
layerState._overlayPublishTimer = null;
|
layerState._overlayPublishTimer = null;
|
||||||
if (!parts.interaction.radioPresentationAllowed()) {
|
if (!parts.interaction.radioPresentationAllowed()) {
|
||||||
@@ -107,18 +67,8 @@ export function createRendering({
|
|||||||
|
|
||||||
const clusterCandidates = [];
|
const clusterCandidates = [];
|
||||||
const clusteredStationIds = new Set();
|
const clusteredStationIds = new Set();
|
||||||
const points = clusterPointCollection();
|
for (const point of parts.renderer?.clusters() || []) {
|
||||||
for (let index = 0; index < (points?.length || 0); index += 1) {
|
const stationIds = point.stationIds
|
||||||
const point = points.get(index);
|
|
||||||
if (
|
|
||||||
!point?.show ||
|
|
||||||
!point.position ||
|
|
||||||
!Array.isArray(point.id) ||
|
|
||||||
point.id.length < 3
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
const stationIds = point.id
|
|
||||||
.map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length))
|
|
||||||
.filter((id) => {
|
.filter((id) => {
|
||||||
const station = layerState._stationById.get(id);
|
const station = layerState._stationById.get(id);
|
||||||
return (
|
return (
|
||||||
@@ -164,11 +114,9 @@ export function createRendering({
|
|||||||
if (clusterEntry) entries.push(clusterEntry);
|
if (clusterEntry) entries.push(clusterEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cameraPosition = layerState._viewer?.camera?.positionWC;
|
const cameraPosition = parts.renderer?.cameraPosition();
|
||||||
const singletonAllowance = Math.min(
|
const singletonAllowance = Math.min(
|
||||||
parts.clustering.radioSingletonLabelLimit(
|
parts.clustering.radioSingletonLabelLimit(parts.renderer?.cameraHeight()),
|
||||||
layerState._viewer?.camera?.positionCartographic?.height,
|
|
||||||
),
|
|
||||||
Math.max(
|
Math.max(
|
||||||
0,
|
0,
|
||||||
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
|
RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length,
|
||||||
@@ -178,14 +126,14 @@ export function createRendering({
|
|||||||
[...layerState._renderById.values()]
|
[...layerState._renderById.values()]
|
||||||
.filter(
|
.filter(
|
||||||
(record) =>
|
(record) =>
|
||||||
record.entity?.show &&
|
record.visible &&
|
||||||
record.station?.id !== station?.id &&
|
record.station?.id !== station?.id &&
|
||||||
!clusteredStationIds.has(record.station?.id),
|
!clusteredStationIds.has(record.station?.id),
|
||||||
)
|
)
|
||||||
.map((record) => ({
|
.map((record) => ({
|
||||||
...record,
|
...record,
|
||||||
distanceM: cameraPosition
|
distanceM: cameraPosition
|
||||||
? Cesium.Cartesian3.distance(cameraPosition, record.position)
|
? geo.cartesian3.distance(cameraPosition, record.position)
|
||||||
: Number.POSITIVE_INFINITY,
|
: Number.POSITIVE_INFINITY,
|
||||||
})),
|
})),
|
||||||
singletonAllowance,
|
singletonAllowance,
|
||||||
@@ -243,8 +191,8 @@ export function createRendering({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateRenderVisibility({ force = true } = {}) {
|
function updateRenderVisibility({ force = true } = {}) {
|
||||||
if (!layerState._viewer || !layerState._dataSource) return;
|
if (!parts.renderer?.ready) return;
|
||||||
const cameraPosition = layerState._viewer.camera?.positionWC;
|
const cameraPosition = parts.renderer.cameraPosition();
|
||||||
if (
|
if (
|
||||||
!force &&
|
!force &&
|
||||||
!parts.navigation.radioCameraPositionChanged(
|
!parts.navigation.radioCameraPositionChanged(
|
||||||
@@ -257,25 +205,22 @@ export function createRendering({
|
|||||||
? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
|
? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z }
|
||||||
: null;
|
: null;
|
||||||
layerState._horizonScanCount += 1;
|
layerState._horizonScanCount += 1;
|
||||||
const occluder = horizonOccluder(layerState._viewer.camera);
|
|
||||||
let visibilityChanged = false;
|
let visibilityChanged = false;
|
||||||
for (const [id, record] of layerState._renderById) {
|
for (const [id, record] of layerState._renderById) {
|
||||||
const matches = parts.categories.stationMatchesRadioCategory(
|
const matches = parts.categories.stationMatchesRadioCategory(
|
||||||
record.station,
|
record.station,
|
||||||
layerState._filter,
|
layerState._filter,
|
||||||
);
|
);
|
||||||
const visible = matches && occluder.isPointVisible(record.position);
|
const visible = matches && parts.renderer.horizonVisible(record.position);
|
||||||
if (record.entity.show !== visible) visibilityChanged = true;
|
if (record.visible !== visible) visibilityChanged = true;
|
||||||
record.entity.show = visible;
|
record.visible = visible;
|
||||||
|
parts.renderer.setStationVisible(record, visible);
|
||||||
}
|
}
|
||||||
if (layerState._selectedEntity) {
|
if (layerState._selectedPosition) {
|
||||||
const position = layerState._selectedEntity.position?.getValue?.(
|
visibilityChanged =
|
||||||
Cesium.JulianDate.now(),
|
parts.renderer.setSelectedVisible(
|
||||||
);
|
parts.renderer.horizonVisible(layerState._selectedPosition),
|
||||||
const selectedVisible = !position || occluder.isPointVisible(position);
|
) || visibilityChanged;
|
||||||
if (layerState._selectedEntity.show !== selectedVisible)
|
|
||||||
visibilityChanged = true;
|
|
||||||
layerState._selectedEntity.show = selectedVisible;
|
|
||||||
}
|
}
|
||||||
scheduleRadioOverlayPublish();
|
scheduleRadioOverlayPublish();
|
||||||
// The horizon timer can commit AFTER the camera settles and the governor
|
// The horizon timer can commit AFTER the camera settles and the governor
|
||||||
@@ -316,96 +261,26 @@ export function createRendering({
|
|||||||
layerState._selectedId = null;
|
layerState._selectedId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerState._dataSource) layerState._dataSource.entities.removeAll();
|
parts.renderer?.clearStations();
|
||||||
layerState._renderById.clear();
|
layerState._renderById.clear();
|
||||||
for (const station of stations) {
|
for (const station of stations) {
|
||||||
const position = markerPosition(station);
|
const position = markerPosition(station);
|
||||||
const markerColor = parts.model.radioCategoryColor(
|
const markerColor = parts.model.radioCategoryColor(
|
||||||
parts.model.radioStationCategoryId(station),
|
parts.model.radioStationCategoryId(station),
|
||||||
);
|
);
|
||||||
const entity = layerState._dataSource.entities.add({
|
const record = parts.renderer.addStation(station, position, markerColor);
|
||||||
id: `${RADIO_PREFIX}${station.id}`,
|
layerState._renderById.set(station.id, record);
|
||||||
position,
|
|
||||||
point: {
|
|
||||||
pixelSize: 13,
|
|
||||||
color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86),
|
|
||||||
outlineColor: Cesium.Color.fromCssColorString('#071b25'),
|
|
||||||
outlineWidth: 1,
|
|
||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
|
||||||
scaleByDistance: new Cesium.NearFarScalar(
|
|
||||||
100_000,
|
|
||||||
1.15,
|
|
||||||
12_000_000,
|
|
||||||
1,
|
|
||||||
),
|
|
||||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
|
||||||
0,
|
|
||||||
RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
layerState._renderById.set(station.id, { station, entity, position });
|
|
||||||
}
|
}
|
||||||
updateSelectionEntity();
|
updateSelectionEntity();
|
||||||
updateRenderVisibility();
|
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 {
|
return {
|
||||||
markerPosition,
|
markerPosition,
|
||||||
updateSelectionEntity,
|
updateSelectionEntity,
|
||||||
clusterPointCollection,
|
|
||||||
publishRadioOverlayEntries,
|
publishRadioOverlayEntries,
|
||||||
scheduleRadioOverlayPublish,
|
scheduleRadioOverlayPublish,
|
||||||
updateRenderVisibility,
|
updateRenderVisibility,
|
||||||
reconcileStations,
|
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()) {
|
if (changed && parts.interaction.radioPresentationAllowed()) {
|
||||||
parts.rendering.updateRenderVisibility();
|
parts.rendering.updateRenderVisibility();
|
||||||
if (filterChanged && layerState._dataSource?.clustering?.enabled) {
|
if (filterChanged) parts.renderer?.invalidateClusters();
|
||||||
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
|
||||||
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
|
||||||
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
|
||||||
layerState._viewer?.scene?.requestRender();
|
|
||||||
}
|
|
||||||
parts.rendering.scheduleRadioOverlayPublish();
|
parts.rendering.scheduleRadioOverlayPublish();
|
||||||
}
|
}
|
||||||
parts.presentation.emitState();
|
parts.presentation.emitState();
|
||||||
@@ -245,15 +240,7 @@ export function createSelection({
|
|||||||
if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity();
|
if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity();
|
||||||
if (changed) parts.clustering.resetRadioClusterOverlayIdentities();
|
if (changed) parts.clustering.resetRadioClusterOverlayIdentities();
|
||||||
parts.rendering.updateRenderVisibility();
|
parts.rendering.updateRenderVisibility();
|
||||||
if (changed && layerState._dataSource?.clustering?.enabled) {
|
if (changed) parts.renderer?.invalidateClusters();
|
||||||
// Entity visibility changes do not invalidate Cesium's existing cluster
|
|
||||||
// primitives. Toggle a public clustering input twice to mark the current
|
|
||||||
// cluster set dirty without changing its effective configuration.
|
|
||||||
const clusterPoints = layerState._dataSource.clustering.clusterPoints;
|
|
||||||
layerState._dataSource.clustering.clusterPoints = !clusterPoints;
|
|
||||||
layerState._dataSource.clustering.clusterPoints = clusterPoints;
|
|
||||||
layerState._viewer?.scene?.requestRender();
|
|
||||||
}
|
|
||||||
parts.rendering.scheduleRadioOverlayPublish();
|
parts.rendering.scheduleRadioOverlayPublish();
|
||||||
parts.presentation.emitState();
|
parts.presentation.emitState();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -12,10 +12,6 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._radioEarthToCenter = geo.cartesian3.create();
|
layerState._radioEarthToCenter = geo.cartesian3.create();
|
||||||
|
|
||||||
layerState._viewer = null;
|
|
||||||
|
|
||||||
layerState._dataSource = null;
|
|
||||||
|
|
||||||
layerState._enabled = false;
|
layerState._enabled = false;
|
||||||
|
|
||||||
layerState._managerLifecyclePresentation = null;
|
layerState._managerLifecyclePresentation = null;
|
||||||
@@ -44,7 +40,7 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._selectedId = null;
|
layerState._selectedId = null;
|
||||||
|
|
||||||
layerState._selectedEntity = null;
|
layerState._selectedPosition = null;
|
||||||
|
|
||||||
layerState._selectionGeneration = 0;
|
layerState._selectionGeneration = 0;
|
||||||
|
|
||||||
@@ -132,8 +128,6 @@ export function createState({ services }) {
|
|||||||
|
|
||||||
layerState._sessionGeneration = 0;
|
layerState._sessionGeneration = 0;
|
||||||
|
|
||||||
layerState._removeClusterListener = null;
|
|
||||||
|
|
||||||
layerState._overlayPublishTimer = null;
|
layerState._overlayPublishTimer = null;
|
||||||
|
|
||||||
layerState._clusterOverlayIdentitySequence = 0;
|
layerState._clusterOverlayIdentitySequence = 0;
|
||||||
|
|||||||
@@ -2666,7 +2666,10 @@ function drawWorldOverlay() {
|
|||||||
|
|
||||||
function createDevFacade() {
|
function createDevFacade() {
|
||||||
if (typeof window === 'undefined' || import.meta.env?.DEV !== true) return;
|
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