From 9d587c1d3791978426891a5b1bd6181978751dac Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 28 Oct 2026 14:49:18 +0400 Subject: [PATCH] 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. --- package-lock.json | 1 + package.json | 1 + scripts/check-import-directions.mjs | 1 + scripts/fixtures/w2/radio.mjs | 38 +++ scripts/format-scope.json | 9 +- scripts/package-boundaries.json | 15 +- scripts/qa-radio.mjs | 66 ++-- src/app/layers/radio.js | 12 +- src/app/navaraLayers.test.mjs | 2 + src/app/navaraMode.js | 8 +- src/app/navaraScene.test.mjs | 5 +- src/data/inputOwnership.test.mjs | 5 +- src/layers/radio/index.js | 4 +- src/layers/radio/ingestion.js | 3 + src/layers/radio/interaction.js | 50 ++- src/layers/radio/lifecycle.js | 40 +-- src/layers/radio/navigation.js | 62 ++-- src/layers/radio/queries.js | 33 +- src/layers/radio/render/cesium.js | 313 +++++++++++++++++++ src/layers/radio/render/navara.js | 373 +++++++++++++++++++++++ src/layers/radio/render/navara.test.mjs | 205 +++++++++++++ src/layers/radio/rendering.js | 185 ++--------- src/layers/radio/screenClusters.js | 168 ++++++++++ src/layers/radio/screenClusters.test.mjs | 211 +++++++++++++ src/layers/radio/selection.js | 17 +- src/layers/radio/state.js | 8 +- src/overlays/worldOverlay.js | 5 +- 27 files changed, 1490 insertions(+), 350 deletions(-) create mode 100644 scripts/fixtures/w2/radio.mjs create mode 100644 src/layers/radio/render/cesium.js create mode 100644 src/layers/radio/render/navara.js create mode 100644 src/layers/radio/render/navara.test.mjs create mode 100644 src/layers/radio/screenClusters.js create mode 100644 src/layers/radio/screenClusters.test.mjs diff --git a/package-lock.json b/package-lock.json index fa3b782a7..b2b9cbae2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "cesium": "^1.124.0", "egm96-universal": "^1.1.1", "hls.js": "^1.7.3", + "kdbush": "4.0.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", diff --git a/package.json b/package.json index 289c64a97..0735bfbbc 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "cesium": "^1.124.0", "egm96-universal": "^1.1.1", "hls.js": "^1.7.3", + "kdbush": "4.0.2", "mapillary-js": "^4.1.2", "mgrs": "^2.1.0", "pbf": "^5.1.2", diff --git a/scripts/check-import-directions.mjs b/scripts/check-import-directions.mjs index f7ca6a384..e6874f4f0 100644 --- a/scripts/check-import-directions.mjs +++ b/scripts/check-import-directions.mjs @@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/; // engine in their renderers: cesium only in render/cesium*.js, three and // Navara only in render/navara*.js. The list grows with each moved layer. const RENDERED_LAYER_DIRS = [ + 'src/layers/radio/', 'src/layers/bikeshare/', 'src/layers/earthquakes/', 'src/layers/firms/', diff --git a/scripts/fixtures/w2/radio.mjs b/scripts/fixtures/w2/radio.mjs new file mode 100644 index 000000000..41eef6e2e --- /dev/null +++ b/scripts/fixtures/w2/radio.mjs @@ -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, + }; +}); diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 913fcf078..c311b9e78 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -595,6 +595,11 @@ "src/layers/radio/presentation.js", "src/layers/radio/queries.js", "src/layers/radio/rendering.js", + "src/layers/radio/render/cesium.js", + "src/layers/radio/render/navara.js", + "src/layers/radio/render/navara.test.mjs", + "src/layers/radio/screenClusters.js", + "src/layers/radio/screenClusters.test.mjs", "src/layers/radio/selection.js", "src/layers/radio/source.js", "src/layers/radio/state.js", @@ -1221,5 +1226,7 @@ "src/layers/firms/lifecycle.test.mjs", "scripts/qa-w2-firms-renderer-entry.mjs", "scripts/qa-w2-firms-perf.mjs", - "scripts/qa-w2-firms-ground.mjs" + "scripts/qa-w2-firms-ground.mjs", + "scripts/qa-w2-radio.mjs", + "scripts/fixtures/w2/radio.mjs" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e0373eece..f413baf2c 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1603,8 +1603,6 @@ "radio-layer": { "exports": ["./layers/radio"], "modules": [ - "src/data/iconOrientation.js", - "src/data/render/cesiumProjection.js", "src/data/idSwitches.js", "src/data/layerState.js", "src/data/layerStateTokenReservations.json", @@ -1648,11 +1646,9 @@ "src/geo/scaleToGeodeticSurface.js", "src/geo/check.js", "src/geo/debug.js", - "src/geo/developerError.js", - "src/globe/contract.js", - "src/globe/cesium/engineFromViewer.js" + "src/geo/developerError.js" ], - "external": ["cesium"] + "external": [] }, "submarine-cables": { "exports": ["./layers/submarine-cables"], @@ -2709,7 +2705,10 @@ "src/layers/installations/render/navaraGeometry.js", "src/layers/firms/rectangle.js", "src/layers/firms/render/cesium.js", + "src/layers/radio/render/cesium.js", "src/layers/firms/render/navara.js", + "src/layers/radio/render/navara.js", + "src/layers/radio/screenClusters.js", "src/layers/firms/render/navaraGeometry.js", "src/layers/firms/render/navaraSprites.js", "src/layers/shared/render/navaraGroundGeometry.js", @@ -2717,6 +2716,7 @@ "src/layers/shared/render/navaraGroundSurface.js" ], "external": [ + "kdbush", "hls.js", "@mapbox/vector-tile", "cesium", @@ -3314,7 +3314,8 @@ "src/layers/satellites/render/cesium.js", "src/layers/earthquakes/render/cesium.js", "src/layers/firms/rectangle.js", - "src/layers/firms/render/cesium.js" + "src/layers/firms/render/cesium.js", + "src/layers/radio/render/cesium.js" ], "external": [ "hls.js", diff --git a/scripts/qa-radio.mjs b/scripts/qa-radio.mjs index adbbfdf81..a13f5834b 100644 --- a/scripts/qa-radio.mjs +++ b/scripts/qa-radio.mjs @@ -9,12 +9,14 @@ * independence, restoration without autoplay, responsive UI, and a clean * console. Screenshots are written under the gitignored `qa-shots/radio/`. * - * Run: node scripts/qa-radio.mjs --url http://localhost:4173 + * Run: node scripts/qa-radio.mjs --url http://localhost:4193 */ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import puppeteer from 'puppeteer'; +import { stations } from './fixtures/w2/radio.mjs'; +import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = path.resolve(__dirname, '..'); @@ -24,8 +26,10 @@ const option = (name, fallback) => { const index = args.indexOf(name); return index >= 0 && args[index + 1] ? args[index + 1] : fallback; }; -const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173'); +const APP_URL = option('--url', process.env.QA_BASE_URL); +if (!APP_URL) throw new Error('Radio QA requires an explicit --url or QA_BASE_URL'); const APP_ORIGIN = new URL(APP_URL).origin; +const expectedPreviewErrors = []; // Radio owns this harness's keyboard and pointer actions; the welcome dialog // has its own acceptance harness and must not intercept those gestures. const RADIO_URL = new URL(APP_URL); @@ -42,35 +46,6 @@ const chrome = chromeCandidates.find((candidate) => { try { return fs.existsSync(candidate); } catch { return false; } }); -const tags = [ - 'news', 'talk', 'weather,emergency', 'public safety,scanner', - 'aviation,atc', 'marine,maritime', 'traffic,transit', - 'music,jazz', 'music,rock', 'community', -]; -const stations = Array.from({ length: 750 }, (_, index) => { - const row = Math.floor(index / 30); - const column = index % 30; - return { - id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`, - name: index === 360 - ? '100.3 The River - WQRV - Meridianville/Huntsville, AL' - : index === 390 - ? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora' - : `QA Radio ${String(index + 1).padStart(3, '0')}`, - lat: -72 + row * 6, - lon: -174 + column * 12, - streamUrl: `https://audio.example.test/${index}.mp3`, - homepage: `https://station.example.test/${index}`, - tags: tags[index % tags.length].split(','), - languages: index === 360 ? ['Spanish'] : ['English'], - state: `Region ${row + 1}`, - country: 'United States', - countryCode: 'US', - metadataTrust: 'untrusted-community', - codec: index % 2 ? 'AAC' : 'MP3', - bitrate: 128, - }; -}); const results = []; function check(name, ok, detail = '') { @@ -241,6 +216,10 @@ async function main() { const failedResponses = []; const externalCesiumEndpointFailures = []; page.on('console', (message) => { + if (isExpectedPreviewSetupError(message, { preview: args.includes('--preview'), baseUrl: APP_URL })) { + expectedPreviewErrors.push('/api/setup/status: 404'); + return; + } if (message.type() === 'error') { const sourceUrl = message.location()?.url || ''; consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text()); @@ -697,7 +676,7 @@ async function main() { const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index)) .find((item) => item.name === 'Radio stations'); - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const { radioStationIdFromPick } = await import('/src/data/radio.js'); return new Promise((resolve) => { let removePostRender = null; @@ -1283,7 +1262,7 @@ async function main() { ); const clusterBadge = await page.evaluate(async () => { const viewer = window.__godsEyeView.viewer; - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const { radioStationIdFromPick } = await import('/src/data/radio.js'); const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision; @@ -1394,7 +1373,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const ellipsoid = viewer.scene.globe.ellipsoid; - const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js'); const source = radio.getOverlayDiagnostics(); const cameraPosition = viewer.camera.positionWC; @@ -1456,7 +1435,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; const radio = window.__godsEyeView.dataManager.layers.get('radio').module; const ellipsoid = viewer.scene.globe.ellipsoid; - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const paintedClusters = () => new Promise((resolve) => { let removePostRender = null; const timeout = setTimeout(() => { @@ -3699,7 +3678,7 @@ async function main() { radio.setFilter('news'); viewer.scene.requestRender(); await new Promise((resolve) => setTimeout(resolve, 500)); - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; return new Promise((resolve) => { let removePostRender = null; let timeout = null; @@ -3862,7 +3841,7 @@ async function main() { const viewer = window.__godsEyeView.viewer; const selectedEntity = window.__godsEyeView.viewer.entities.values .find((entity) => String(entity.id).startsWith('radio:selected:')); - const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => { const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination); return { @@ -4045,7 +4024,7 @@ async function main() { }); const selectedEntity = viewer.entities.values .find((entity) => String(entity.id) === `radio:selected:${selected.id}`); - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const expectedEntryId = `selected:${selected.id}`; let painted = null; // Radio's horizon pass reveals the selected entity on its own timer @@ -4104,7 +4083,7 @@ async function main() { }); const selectedEntity = viewer.entities.values .find((entity) => String(entity.id) === `radio:selected:${selected.id}`); - const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js'); + const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay; const expectedEntryId = `selected:${selected.id}`; let rect = null; for (let attempt = 0; attempt < 20 && !rect; attempt += 1) { @@ -4141,11 +4120,14 @@ async function main() { selectedNativePick = picks.find((picked) => picked?.id === selectedEntity); selectedPointPick = picks.find((picked) => ( picked?.id === selectedEntity - && String(picked?.primitive?.constructor?.name || '').includes('PointPrimitive') + // Public point properties survive production minification; class names do not. + && Number.isFinite(picked?.primitive?.pixelSize) + && Number.isFinite(picked?.primitive?.outlineWidth) )); selectedBillboardPick = picks.find((picked) => ( picked?.id === selectedEntity - && String(picked?.primitive?.constructor?.name || '').includes('Billboard') + && Number.isFinite(picked?.primitive?.width) + && Number.isFinite(picked?.primitive?.height) )); if (!selectedPointPick) { await new Promise((resolve) => { @@ -4741,6 +4723,8 @@ async function main() { } const failures = results.filter((result) => !result.ok).length; + const reportPath = option('--report'); + if (reportPath) fs.writeFileSync(reportPath, JSON.stringify({ pass: failures === 0, checks: results, expectedPreviewErrors }, null, 2) + '\n'); console.log(`\nRadio QA: ${results.length - failures}/${results.length} passed`); console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`); process.exitCode = failures ? 1 : 0; diff --git a/src/app/layers/radio.js b/src/app/layers/radio.js index 2332a04f6..fbdf793d7 100644 --- a/src/app/layers/radio.js +++ b/src/app/layers/radio.js @@ -1,14 +1,24 @@ +import { createRadioCesiumRenderer } from '../../layers/radio/render/cesium.js'; import { createRadioLayer } from '../../layers/radio/index.js'; +import { rendererFor } from './renderers.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; import * as globe from '../../celestialRing.js'; import * as render from '../../renderGovernor.js'; /** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationRadio({ surface, source }) { +export function createApplicationRadio({ + surface, + source, + rendererFactory = ({ engine, ...context }) => + engine?.kind === 'navara' + ? rendererFor('radio', engine, context) + : createRadioCesiumRenderer(context), +}) { const { groundFloor: ground } = surface; return createRadioLayer({ source, + rendererFactory, services: { ground, picking, overlays, globe, render }, }); } diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index 40d1dfe74..61f05d8d8 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -28,6 +28,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'local-firms', 'military-installations', 'bikeshare', + 'radio', ]) assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.deepEqual( @@ -41,6 +42,7 @@ test('the Navara mode registers migrated renderers once', async () => { 'earthquakes', 'satellites', 'rocket-launches', + 'radio', 'bikeshare', 'ais-live-vessels', 'military-installations', diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index b71cc1274..7a1f1c1dc 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -54,6 +54,9 @@ const NAVARA_CREDIT_KEYS = new Set([ 'launch-library-2', 'usgs', 'firms', + 'gbfs', + 'overture-military-names', + 'radio-browser', ]); /** @@ -62,6 +65,9 @@ const NAVARA_CREDIT_KEYS = new Set([ */ export async function registerNavaraLayerRenderers() { const loaders = { + radio: async () => + (await import('../layers/radio/render/navara.js')) + .createRadioNavaraRenderer, bikeshare: async () => (await import('../layers/bikeshare/render/navara.js')) .createBikeshareNavaraRenderer, @@ -274,7 +280,7 @@ export function createViewerlessTools({ if (window.__godsEyeView === debug) delete window.__godsEyeView; }); // Voice on the engine (phase 3, Task 9): the W1 tools drive the engine; - // the viewer-only ones (director, annotations, cockpit, radio…) refuse. + // the viewer-only ones (director, annotations, cockpit…) refuse. const voiceCommands = initGevVoiceCommands({ ...voice, annotationResolver: operations.annotationResolver, diff --git a/src/app/navaraScene.test.mjs b/src/app/navaraScene.test.mjs index f01af19a4..51941bc53 100644 --- a/src/app/navaraScene.test.mjs +++ b/src/app/navaraScene.test.mjs @@ -182,12 +182,15 @@ test('Navara keeps attribution for every available W1/W2 source and excludes una 'launch-library-2', 'usgs', 'firms', + 'gbfs', + 'overture-military-names', + 'radio-browser', 'photon-geocoder', 'openstreetmap', 'reearth-terrain', ]; const run = await startScene({ - credits: [...keys, 'radio-browser'].map((key) => ({ + credits: [...keys, 'nasa-gibs'].map((key) => ({ key, html: `Credit ${key}`, })), diff --git a/src/data/inputOwnership.test.mjs b/src/data/inputOwnership.test.mjs index 5cc89edf5..097d12738 100644 --- a/src/data/inputOwnership.test.mjs +++ b/src/data/inputOwnership.test.mjs @@ -128,7 +128,10 @@ test('every scene click handler consults ownership before it picks', () => { 'viewer.scene.pick(click.position)', ], ['src/layers/launches/render/cesium.js', 'drillPick(movement.position'], - ['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'], + [ + 'src/layers/radio/interaction.js', + 'const stationId = pickedRadioStationAt(position);', + ], [ 'src/layers/satellites/render/cesium.js', 'viewer.scene.pick(click.position)', diff --git a/src/layers/radio/index.js b/src/layers/radio/index.js index fa84e1054..f38388775 100644 --- a/src/layers/radio/index.js +++ b/src/layers/radio/index.js @@ -19,10 +19,10 @@ import { createIngestion } from './ingestion.js'; import { createState } from './state.js'; /** Construct one layer with its own scene state and supplied application services. */ -export function createRadioLayer({ services, source }) { +export function createRadioLayer({ services, source, rendererFactory }) { const state = createState({ services }); const parts = {}; - const context = { state, services, parts, source }; + const context = { state, services, parts, source, rendererFactory }; parts.catalogModel = createCatalogModel(context); parts.labels = createLabels(context); parts.clustering = createClustering(context); diff --git a/src/layers/radio/ingestion.js b/src/layers/radio/ingestion.js index 8a89b444c..5965b6685 100644 --- a/src/layers/radio/ingestion.js +++ b/src/layers/radio/ingestion.js @@ -1,6 +1,7 @@ import { RADIO_DIRECTORY_STALE_MS, RADIO_DIRECTORY_FUTURE_SKEW_MS, + RADIO_TUNER_DIRECTORY_LIMIT, } from './policy.js'; export function createIngestion({ @@ -36,6 +37,8 @@ export function createIngestion({ return; if (!Array.isArray(body?.stations)) throw new Error('Radio directory response was malformed'); + if (body.stations.length > RADIO_TUNER_DIRECTORY_LIMIT) + throw new Error('Radio directory exceeds its station budget'); const updatedAt = typeof body.updatedAt === 'string' && Number.isFinite(Date.parse(body.updatedAt)) diff --git a/src/layers/radio/interaction.js b/src/layers/radio/interaction.js index 11a22b9dd..1dc3c435b 100644 --- a/src/layers/radio/interaction.js +++ b/src/layers/radio/interaction.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { isPointerFree } from '../../data/inputOwnership.js'; import { RADIO_PREFIX, @@ -13,12 +12,7 @@ export function createInteraction({ parts, source, }) { - const { - resolvePickId, - isOwnedByOtherLayer, - registerPickOwner, - unregisterPickOwner, - } = services.picking; + const { resolvePickId, isOwnedByOtherLayer } = services.picking; const { setOverlaySourceVisible } = services.overlays; /** Resolve a station id from ordinary, selected, or Cesium cluster pick shapes. */ @@ -47,8 +41,8 @@ export function createInteraction({ } function pickedRadioStationAt(position) { - const scene = layerState._viewer?.scene; - if (!scene || !position) return null; + const renderer = parts.renderer; + if (!renderer?.ready || !position) return null; const stationFromPick = (picked) => { const stationId = radioStationIdFromPick(picked); @@ -56,14 +50,14 @@ export function createInteraction({ ? stationId : null; }; - const primaryPick = scene.pick(position); + const primaryPick = renderer.pick(position); const primaryStationId = stationFromPick(primaryPick); if (primaryStationId) return primaryStationId; const primaryId = resolvePickId(primaryPick); if (primaryId && isOwnedByOtherLayer('radio', primaryId)) return null; - if (typeof scene.drillPick === 'function') { - const drilled = scene.drillPick(position, 16) || []; + if (typeof renderer.drillPick === 'function') { + const drilled = renderer.drillPick(position, 16) || []; for (const picked of drilled) { const stationId = stationFromPick(picked); if (stationId) return stationId; @@ -71,11 +65,11 @@ export function createInteraction({ } for (const [offsetX, offsetY] of RADIO_PICK_OFFSETS) { - const offsetPosition = new Cesium.Cartesian2( - position.x + offsetX, - position.y + offsetY, - ); - const picked = scene.pick(offsetPosition); + const offsetPosition = { + x: position.x + offsetX, + y: position.y + offsetY, + }; + const picked = renderer.pick(offsetPosition); const pickedId = resolvePickId(picked); if (pickedId && isOwnedByOtherLayer('radio', pickedId)) continue; const stationId = stationFromPick(picked); @@ -96,7 +90,7 @@ export function createInteraction({ function syncRadioLifecyclePresentation() { const visible = radioPresentationAllowed(); - if (layerState._dataSource) layerState._dataSource.show = visible; + parts.renderer?.setVisible(visible); setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, visible); if (visible) { installInteraction(); @@ -105,23 +99,18 @@ export function createInteraction({ parts.rendering.scheduleRadioOverlayPublish(); } else { removeInteraction(); - if (layerState._selectedEntity && layerState._viewer) - layerState._viewer.entities.remove(layerState._selectedEntity); - layerState._selectedEntity = null; + parts.renderer?.clearSelected(); + layerState._selectedPosition = null; } } function installInteraction() { - if (!layerState._viewer || layerState._clickHandler) return; - registerPickOwner('radio', (id) => id.startsWith(RADIO_PREFIX)); - layerState._clickHandler = new Cesium.ScreenSpaceEventHandler( - layerState._viewer.scene.canvas, - ); - layerState._clickHandler.setInputAction((click) => { + if (!parts.renderer?.ready || layerState._clickHandler) return; + layerState._clickHandler = parts.renderer.installClick((position) => { // A tool owns the pointer (src/data/inputOwnership.js): yield the click. if (!isPointerFree()) return; if (!radioPresentationAllowed()) return; - const stationId = pickedRadioStationAt(click.position); + const stationId = pickedRadioStationAt(position); if (!stationId) return; layerState._playFallbackId = null; layerState._playFallbackFocus = null; @@ -134,7 +123,7 @@ export function createInteraction({ new CustomEvent('gev:radio-selected', { detail: { stationId } }), ); } - }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + }); // Polling remains bounded during flights, but a stationary camera no longer // rewrites every station's visibility four times per second while voice and // audio processing share the main thread. @@ -145,8 +134,7 @@ export function createInteraction({ } function removeInteraction() { - unregisterPickOwner('radio'); - layerState._clickHandler?.destroy(); + layerState._clickHandler?.(); layerState._clickHandler = null; if (layerState._horizonTimer) clearInterval(layerState._horizonTimer); layerState._horizonTimer = null; diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index 414cfa5c0..3c9d7364b 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import { RADIO_OVERLAY_SOURCE_ID, DEFAULT_RADIO_VOLUME, @@ -11,22 +10,28 @@ export function createLifecycle({ services, parts, source, + rendererFactory, }) { const { clearOverlaySource, setOverlaySourceVisible } = services.overlays; const methods = { - /** Initialize the Cesium data source and the single audio element. */ - init(viewer) { + /** Initialize scene resources and the single audio element. */ + init(viewer, options = {}) { + if (parts.renderer?.ready) + throw new Error('Radio layer is already initialized'); + if (typeof rendererFactory !== 'function') + throw new TypeError('A radio renderer factory is required'); + parts.renderer = rendererFactory({ + state: layerState, + services, + parts, + source, + engine: options.engine, + }); + parts.renderer.init(viewer, options); layerState._sessionGeneration += 1; - layerState._viewer = viewer; parts.clustering.resetRadioClusterOverlayIdentities(); parts.playback.installAudio(); - if (!layerState._dataSource) { - layerState._dataSource = new Cesium.CustomDataSource('Radio stations'); - viewer.dataSources.add(layerState._dataSource); - parts.rendering.installClusterStyling(); - } - layerState._dataSource.show = false; clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); setOverlaySourceVisible(RADIO_OVERLAY_SOURCE_ID, false); }, @@ -55,10 +60,9 @@ export function createLifecycle({ layerState._cancelledTuningPresentationStation = null; layerState._tuningUnavailableStationId = null; parts.playback.stopRadioPlayback({ origin: 'layer-disable' }); - if (layerState._dataSource) layerState._dataSource.show = false; - if (layerState._selectedEntity && layerState._viewer) - layerState._viewer.entities.remove(layerState._selectedEntity); - layerState._selectedEntity = null; + parts.renderer?.setVisible(false); + parts.renderer?.clearSelected(); + layerState._selectedPosition = null; if (layerState._overlayPublishTimer) clearTimeout(layerState._overlayPublishTimer); layerState._overlayPublishTimer = null; @@ -83,11 +87,8 @@ export function createLifecycle({ if (layerState._audio) layerState._audio.volume = DEFAULT_RADIO_VOLUME; layerState._audio = null; layerState._userVolume = DEFAULT_RADIO_VOLUME; - layerState._removeClusterListener?.(); - layerState._removeClusterListener = null; - if (layerState._dataSource && layerState._viewer) - layerState._viewer.dataSources.remove(layerState._dataSource, true); - layerState._dataSource = null; + parts.renderer?.destroy(); + parts.renderer = null; clearOverlaySource(RADIO_OVERLAY_SOURCE_ID); layerState._stations = []; layerState._acceptedCatalogSnapshot = EMPTY_ACCEPTED_CATALOG_SNAPSHOT; @@ -111,7 +112,6 @@ export function createLifecycle({ layerState._error = null; layerState._updatedAt = null; layerState._managerLifecyclePresentation = null; - layerState._viewer = null; parts.presentation.emitState(); layerState._listeners.clear(); layerState._playbackControlListeners.clear(); diff --git a/src/layers/radio/navigation.js b/src/layers/radio/navigation.js index 049be7082..6bdcb9685 100644 --- a/src/layers/radio/navigation.js +++ b/src/layers/radio/navigation.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { HORIZON_CAMERA_MOVE_EPSILON_M, @@ -15,7 +14,6 @@ export function createNavigation({ const { isFullGlobeInsideKeyhole, GLOBE_ENTER_CLEARANCE_PX, - projectEarthDiscToViewport, earthDiscScreenRadius, getKeyholeGeometry, } = services.globe; @@ -136,7 +134,8 @@ export function createNavigation({ : currentHeight; } - function radioCameraState(camera = layerState._viewer?.camera) { + function radioCameraState(camera) { + if (camera === undefined) return parts.renderer?.cameraState() ?? null; if (!camera) return null; return { height: camera.positionCartographic?.height, @@ -148,24 +147,16 @@ export function createNavigation({ /** Radio may move the globe only while no tracked entity owns the follow camera. */ - function radioCameraNavigationAllowed(viewer = layerState._viewer) { + function radioCameraNavigationAllowed(viewer) { + if (viewer === undefined) + return parts.renderer?.navigationAllowed() ?? false; return Boolean(viewer?.camera) && !viewer.trackedEntity; } - function radioGlobeRecenterPlan(viewer = layerState._viewer) { - const camera = viewer?.camera; - const canvas = viewer?.scene?.canvas; - const width = canvas?.clientWidth || canvas?.width; - const height = canvas?.clientHeight || canvas?.height; - const cartographic = camera?.positionCartographic; - if (!camera || !cartographic || !(width > 0) || !(height > 0)) return null; - const geometry = projectEarthDiscToViewport( - viewer, - width, - height, - layerState._radioEarthScreenCenter, - layerState._radioEarthToCenter, - ); + function radioGlobeRecenterPlan() { + const view = parts.renderer?.recenterView(); + if (!view) return null; + const { width, height, cartographic, geometry } = view; let fullGlobeCapable = false; if (geometry) { if (!radioGlobeNeedsRecentering(geometry)) return null; @@ -179,13 +170,12 @@ export function createNavigation({ ); } else { const earthRadius = earthDiscScreenRadius( - geo.cartesian3.magnitude(camera.positionWC), + geo.cartesian3.magnitude(view.position), height, - camera.frustum?.fovy, + view.fovy, ); const facingEarthCenter = - geo.cartesian3.dot(camera.directionWC, layerState._radioEarthToCenter) > - 0; + geo.cartesian3.dot(view.direction, view.toCenter) > 0; if (!earthRadius || facingEarthCenter) return null; const keyhole = getKeyholeGeometry(width, height); fullGlobeCapable = @@ -203,7 +193,7 @@ export function createNavigation({ recenterHeight, ), cameraState: { - ...radioCameraState(camera), + ...radioCameraState(), height: recenterHeight, heading: 0, pitch: -geo.math.PI_OVER_TWO, @@ -217,18 +207,15 @@ export function createNavigation({ navigation && navigation.generation === layerState._radioCameraNavigationGeneration && layerState._enabled && - radioCameraNavigationAllowed(layerState._viewer), + radioCameraNavigationAllowed(), ); } function cancelActiveRadioCameraFlight() { - if ( - !layerState._activeRadioCameraFlight || - !radioCameraNavigationAllowed(layerState._viewer) - ) + if (!layerState._activeRadioCameraFlight || !radioCameraNavigationAllowed()) return; layerState._activeRadioCameraFlight = null; - layerState._viewer.camera.cancelFlight(); + parts.renderer.cancelFlight(); } function invalidateRadioCameraNavigation() { @@ -259,7 +246,7 @@ export function createNavigation({ if (completed && radioCameraNavigationIsCurrent(navigation)) onComplete?.(); }; - layerState._viewer.camera.flyTo({ + parts.renderer.flyTo({ ...options, complete: () => finish(true), cancel: () => finish(false), @@ -299,13 +286,9 @@ export function createNavigation({ function beginRadioCameraNavigation(cameraState = null) { const generation = ++layerState._radioCameraNavigationGeneration; - if ( - !radioCameraNavigationAllowed(layerState._viewer) || - !layerState._enabled - ) - return null; + if (!radioCameraNavigationAllowed() || !layerState._enabled) return null; cancelActiveRadioCameraFlight(); - const recenterPlan = radioGlobeRecenterPlan(layerState._viewer); + const recenterPlan = radioGlobeRecenterPlan(); return { generation, phase: 'idle', @@ -345,7 +328,7 @@ export function createNavigation({ roll: activeNavigation.cameraState.roll, }, duration: recenterDuration, - easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT, + easing: 'cubicInOut', }, () => { activeNavigation.recentered = true; @@ -359,10 +342,9 @@ export function createNavigation({ function focusStation(station) { layerState._radioCameraNavigationGeneration += 1; - if (!station || !radioCameraNavigationAllowed(layerState._viewer)) - return false; + if (!station || !radioCameraNavigationAllowed()) return false; cancelActiveRadioCameraFlight(); - layerState._viewer.camera.flyTo({ + parts.renderer.flyTo({ destination: geo.cartesian3.fromDegrees(station.lon, station.lat, 85_000), duration: 1.1, }); diff --git a/src/layers/radio/queries.js b/src/layers/radio/queries.js index e58809476..3477cfccb 100644 --- a/src/layers/radio/queries.js +++ b/src/layers/radio/queries.js @@ -1,4 +1,3 @@ -import * as Cesium from 'cesium'; import * as geo from '../../geo/index.js'; import { normalizeRadioCountryInput } from '../../data/radioCountry.js'; @@ -107,31 +106,13 @@ export function createQueries({ state: layerState, services, parts, source }) { } function viewportRadioAnchor() { - const camera = layerState._viewer?.camera; - const scene = layerState._viewer?.scene; - if (!camera) return null; - const altitudeM = Number(camera.positionCartographic?.height); - let cartographic = null; - const canvas = scene?.canvas; - if (canvas && typeof camera.pickEllipsoid === 'function') { - const center = geo.cartesian2.create( - canvas.clientWidth / 2, - canvas.clientHeight / 2, - ); - const position = camera.pickEllipsoid( - center, - scene.globe?.ellipsoid || Cesium.Ellipsoid.WGS84, - ); - if (position) cartographic = geo.cartographic.fromCartesian(position); - } - cartographic ||= camera.positionCartographic || null; - if (!cartographic) return null; - return { - lat: geo.math.toDegrees(cartographic.latitude), - lon: geo.math.toDegrees(cartographic.longitude), - altitudeM, - globalView: parts.navigation.radioViewIsGlobal(altitudeM), - }; + const anchor = parts.renderer?.viewportAnchor(); + return anchor + ? { + ...anchor, + globalView: parts.navigation.radioViewIsGlobal(anchor.altitudeM), + } + : null; } function rankedVisibleStations() { diff --git a/src/layers/radio/render/cesium.js b/src/layers/radio/render/cesium.js new file mode 100644 index 000000000..d682dd6a8 --- /dev/null +++ b/src/layers/radio/render/cesium.js @@ -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; +} diff --git a/src/layers/radio/render/navara.js b/src/layers/radio/render/navara.js new file mode 100644 index 000000000..42dff2c3e --- /dev/null +++ b/src/layers/radio/render/navara.js @@ -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; +} diff --git a/src/layers/radio/render/navara.test.mjs b/src/layers/radio/render/navara.test.mjs new file mode 100644 index 000000000..ded3d8f1d --- /dev/null +++ b/src/layers/radio/render/navara.test.mjs @@ -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, + ), + ); + } +}); diff --git a/src/layers/radio/rendering.js b/src/layers/radio/rendering.js index 36cbc5050..04194e64a 100644 --- a/src/layers/radio/rendering.js +++ b/src/layers/radio/rendering.js @@ -1,10 +1,7 @@ -import * as Cesium from 'cesium'; -import { horizonOccluder } from '../../data/iconOrientation.js'; +import * as geo from '../../geo/index.js'; import { MARKER_LIFT_M, - RADIO_PREFIX, SELECTED_LIFT_M, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, RADIO_OVERLAY_SOURCE_ID, RADIO_OVERLAY_COHORT_LIMIT, RADIO_OVERLAY_SOURCE_OPTIONS, @@ -17,73 +14,36 @@ export function createRendering({ parts, source, }) { - const { cachedGroundFloor } = services.ground; const { clearOverlaySource, setOverlaySourceVisible, setOverlayEntries } = services.overlays; const { governorRequestRender } = services.render; function markerPosition(station, liftM = MARKER_LIFT_M) { - const floor = cachedGroundFloor(station.lat, station.lon); - return Cesium.Cartesian3.fromDegrees( - station.lon, - station.lat, - (Number.isFinite(floor) ? floor : 0) + liftM, - ); + return parts.renderer.markerPosition(station, liftM); } function updateSelectionEntity() { - if (layerState._selectedEntity && layerState._viewer) - layerState._viewer.entities.remove(layerState._selectedEntity); - layerState._selectedEntity = null; + parts.renderer?.clearSelected(); + layerState._selectedPosition = null; const station = parts.queries.selectedPresentationStation(); if ( !parts.interaction.radioPresentationAllowed() || - !layerState._viewer || + !parts.renderer?.ready || !station ) { scheduleRadioOverlayPublish(); return; } - const selectionColor = parts.model.radioCategoryColor( + const color = parts.model.radioCategoryColor( parts.model.radioStationCategoryId(station), ); - const bracketImage = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(selectionColor))}`; - - layerState._selectedEntity = layerState._viewer.entities.add({ - id: `${RADIO_PREFIX}selected:${station.id}`, - position: markerPosition(station, SELECTED_LIFT_M), - point: { - pixelSize: 14, - color: Cesium.Color.fromCssColorString(selectionColor), - outlineColor: Cesium.Color.BLACK, - outlineWidth: 2, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - billboard: { - image: bracketImage, - width: 40, - height: 40, - horizontalOrigin: Cesium.HorizontalOrigin.CENTER, - verticalOrigin: Cesium.VerticalOrigin.CENTER, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - scaleByDistance: new Cesium.NearFarScalar(100_000, 1, 12_000_000, 0.72), - }, - }); + const bracket = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(parts.labels.radioSelectionBracketSvg(color))}`; + const position = markerPosition(station, SELECTED_LIFT_M); + layerState._selectedPosition = position; + parts.renderer.addSelected(station, position, color, bracket); scheduleRadioOverlayPublish(); } - function clusterPointCollection() { - return layerState._dataSource?.clustering?._clusterPointCollection || null; - } - function publishRadioOverlayEntries() { layerState._overlayPublishTimer = null; if (!parts.interaction.radioPresentationAllowed()) { @@ -107,18 +67,8 @@ export function createRendering({ const clusterCandidates = []; const clusteredStationIds = new Set(); - const points = clusterPointCollection(); - for (let index = 0; index < (points?.length || 0); index += 1) { - const point = points.get(index); - if ( - !point?.show || - !point.position || - !Array.isArray(point.id) || - point.id.length < 3 - ) - continue; - const stationIds = point.id - .map((entity) => String(entity?.id || '').slice(RADIO_PREFIX.length)) + for (const point of parts.renderer?.clusters() || []) { + const stationIds = point.stationIds .filter((id) => { const station = layerState._stationById.get(id); return ( @@ -164,11 +114,9 @@ export function createRendering({ if (clusterEntry) entries.push(clusterEntry); } - const cameraPosition = layerState._viewer?.camera?.positionWC; + const cameraPosition = parts.renderer?.cameraPosition(); const singletonAllowance = Math.min( - parts.clustering.radioSingletonLabelLimit( - layerState._viewer?.camera?.positionCartographic?.height, - ), + parts.clustering.radioSingletonLabelLimit(parts.renderer?.cameraHeight()), Math.max( 0, RADIO_OVERLAY_COHORT_LIMIT - selectedClusterCandidates.length, @@ -178,14 +126,14 @@ export function createRendering({ [...layerState._renderById.values()] .filter( (record) => - record.entity?.show && + record.visible && record.station?.id !== station?.id && !clusteredStationIds.has(record.station?.id), ) .map((record) => ({ ...record, distanceM: cameraPosition - ? Cesium.Cartesian3.distance(cameraPosition, record.position) + ? geo.cartesian3.distance(cameraPosition, record.position) : Number.POSITIVE_INFINITY, })), singletonAllowance, @@ -243,8 +191,8 @@ export function createRendering({ } function updateRenderVisibility({ force = true } = {}) { - if (!layerState._viewer || !layerState._dataSource) return; - const cameraPosition = layerState._viewer.camera?.positionWC; + if (!parts.renderer?.ready) return; + const cameraPosition = parts.renderer.cameraPosition(); if ( !force && !parts.navigation.radioCameraPositionChanged( @@ -257,25 +205,22 @@ export function createRendering({ ? { x: cameraPosition.x, y: cameraPosition.y, z: cameraPosition.z } : null; layerState._horizonScanCount += 1; - const occluder = horizonOccluder(layerState._viewer.camera); let visibilityChanged = false; for (const [id, record] of layerState._renderById) { const matches = parts.categories.stationMatchesRadioCategory( record.station, layerState._filter, ); - const visible = matches && occluder.isPointVisible(record.position); - if (record.entity.show !== visible) visibilityChanged = true; - record.entity.show = visible; + const visible = matches && parts.renderer.horizonVisible(record.position); + if (record.visible !== visible) visibilityChanged = true; + record.visible = visible; + parts.renderer.setStationVisible(record, visible); } - if (layerState._selectedEntity) { - const position = layerState._selectedEntity.position?.getValue?.( - Cesium.JulianDate.now(), - ); - const selectedVisible = !position || occluder.isPointVisible(position); - if (layerState._selectedEntity.show !== selectedVisible) - visibilityChanged = true; - layerState._selectedEntity.show = selectedVisible; + if (layerState._selectedPosition) { + visibilityChanged = + parts.renderer.setSelectedVisible( + parts.renderer.horizonVisible(layerState._selectedPosition), + ) || visibilityChanged; } scheduleRadioOverlayPublish(); // The horizon timer can commit AFTER the camera settles and the governor @@ -316,96 +261,26 @@ export function createRendering({ layerState._selectedId = null; } - if (layerState._dataSource) layerState._dataSource.entities.removeAll(); + parts.renderer?.clearStations(); layerState._renderById.clear(); for (const station of stations) { const position = markerPosition(station); const markerColor = parts.model.radioCategoryColor( parts.model.radioStationCategoryId(station), ); - const entity = layerState._dataSource.entities.add({ - id: `${RADIO_PREFIX}${station.id}`, - position, - point: { - pixelSize: 13, - color: Cesium.Color.fromCssColorString(markerColor).withAlpha(0.86), - outlineColor: Cesium.Color.fromCssColorString('#071b25'), - outlineWidth: 1, - disableDepthTestDistance: Number.POSITIVE_INFINITY, - scaleByDistance: new Cesium.NearFarScalar( - 100_000, - 1.15, - 12_000_000, - 1, - ), - distanceDisplayCondition: new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ), - }, - }); - layerState._renderById.set(station.id, { station, entity, position }); + const record = parts.renderer.addStation(station, position, markerColor); + layerState._renderById.set(station.id, record); } updateSelectionEntity(); updateRenderVisibility(); } - function installClusterStyling() { - if (!layerState._dataSource || layerState._removeClusterListener) return; - const clustering = layerState._dataSource.clustering; - clustering.enabled = true; - clustering.pixelRange = 42; - clustering.minimumClusterSize = 3; - clustering.clusterPoints = true; - clustering.clusterLabels = false; - clustering.clusterBillboards = false; - layerState._removeClusterListener = - clustering.clusterEvent.addEventListener((clusteredEntities, cluster) => { - const clusteredStations = []; - for (const entity of clusteredEntities) { - const stationId = String(entity.id || '').slice(RADIO_PREFIX.length); - const station = layerState._renderById.get(stationId)?.station; - if (station) clusteredStations.push(station); - } - const clusterCategory = parts.model.radioClusterCategoryId( - clusteredStations, - layerState._filter, - ); - const clusterColor = Cesium.Color.fromCssColorString( - parts.model.radioCategoryColor(clusterCategory), - ); - // Cesium assigns the entity-id array only to the generated cluster label. - // Mirror it to the visible point so clicking either part of the callout - // resolves the first station and remains a direct playback gesture. - cluster.point.id = clusteredEntities; - cluster.billboard.id = clusteredEntities; - cluster.label.show = false; - cluster.label.text = ''; - cluster.point.show = true; - cluster.point.pixelSize = Math.min( - 26, - 12 + Math.log2(clusteredEntities.length) * 1.6, - ); - cluster.point.color = clusterColor.withAlpha(0.9); - cluster.point.outlineColor = Cesium.Color.BLACK; - cluster.point.outlineWidth = 2; - cluster.point.disableDepthTestDistance = Number.POSITIVE_INFINITY; - cluster.point.distanceDisplayCondition = - new Cesium.DistanceDisplayCondition( - 0, - RADIO_GLOBE_INTERACTION_MAX_DISTANCE_M, - ); - scheduleRadioOverlayPublish(); - }); - } return { markerPosition, updateSelectionEntity, - clusterPointCollection, publishRadioOverlayEntries, scheduleRadioOverlayPublish, updateRenderVisibility, reconcileStations, - installClusterStyling, }; } diff --git a/src/layers/radio/screenClusters.js b/src/layers/radio/screenClusters.js new file mode 100644 index 000000000..b409947df --- /dev/null +++ b/src/layers/radio/screenClusters.js @@ -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 }; +} diff --git a/src/layers/radio/screenClusters.test.mjs b/src/layers/radio/screenClusters.test.mjs new file mode 100644 index 000000000..896b79c27 --- /dev/null +++ b/src/layers/radio/screenClusters.test.mjs @@ -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'] }); +}); diff --git a/src/layers/radio/selection.js b/src/layers/radio/selection.js index bffc7de04..88d665c83 100644 --- a/src/layers/radio/selection.js +++ b/src/layers/radio/selection.js @@ -57,12 +57,7 @@ export function createSelection({ } if (changed && parts.interaction.radioPresentationAllowed()) { parts.rendering.updateRenderVisibility(); - if (filterChanged && layerState._dataSource?.clustering?.enabled) { - const clusterPoints = layerState._dataSource.clustering.clusterPoints; - layerState._dataSource.clustering.clusterPoints = !clusterPoints; - layerState._dataSource.clustering.clusterPoints = clusterPoints; - layerState._viewer?.scene?.requestRender(); - } + if (filterChanged) parts.renderer?.invalidateClusters(); parts.rendering.scheduleRadioOverlayPublish(); } parts.presentation.emitState(); @@ -245,15 +240,7 @@ export function createSelection({ if (clearsCancelledPresentation) parts.rendering.updateSelectionEntity(); if (changed) parts.clustering.resetRadioClusterOverlayIdentities(); parts.rendering.updateRenderVisibility(); - if (changed && layerState._dataSource?.clustering?.enabled) { - // Entity visibility changes do not invalidate Cesium's existing cluster - // primitives. Toggle a public clustering input twice to mark the current - // cluster set dirty without changing its effective configuration. - const clusterPoints = layerState._dataSource.clustering.clusterPoints; - layerState._dataSource.clustering.clusterPoints = !clusterPoints; - layerState._dataSource.clustering.clusterPoints = clusterPoints; - layerState._viewer?.scene?.requestRender(); - } + if (changed) parts.renderer?.invalidateClusters(); parts.rendering.scheduleRadioOverlayPublish(); parts.presentation.emitState(); return true; diff --git a/src/layers/radio/state.js b/src/layers/radio/state.js index ac915635a..86a4f2b24 100644 --- a/src/layers/radio/state.js +++ b/src/layers/radio/state.js @@ -12,10 +12,6 @@ export function createState({ services }) { layerState._radioEarthToCenter = geo.cartesian3.create(); - layerState._viewer = null; - - layerState._dataSource = null; - layerState._enabled = false; layerState._managerLifecyclePresentation = null; @@ -44,7 +40,7 @@ export function createState({ services }) { layerState._selectedId = null; - layerState._selectedEntity = null; + layerState._selectedPosition = null; layerState._selectionGeneration = 0; @@ -132,8 +128,6 @@ export function createState({ services }) { layerState._sessionGeneration = 0; - layerState._removeClusterListener = null; - layerState._overlayPublishTimer = null; layerState._clusterOverlayIdentitySequence = 0; diff --git a/src/overlays/worldOverlay.js b/src/overlays/worldOverlay.js index 65de0e450..8c0a0db67 100644 --- a/src/overlays/worldOverlay.js +++ b/src/overlays/worldOverlay.js @@ -2666,7 +2666,10 @@ function drawWorldOverlay() { function createDevFacade() { if (typeof window === 'undefined' || import.meta.env?.DEV !== true) return; - window.__gevWorldOverlay = { getDiagnostics: getWorldOverlayDiagnostics }; + window.__gevWorldOverlay = { + getDiagnostics: getWorldOverlayDiagnostics, + getPaintRect: getOverlayPaintRect, + }; } /**