From 3ab0bbf7c142ee7b839d434caaa7c010595af04e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 00:21:45 +0400 Subject: [PATCH] Hide a selected radio station beyond the horizon, also after a switch Re-adding the selected station's marker reset it to visible, and the horizon check skips a still camera, so a station selected on the far side of the Earth showed through the globe until the camera moved. The marker now starts with the station's current horizon visibility, and the layer recomputes it once the view is restored after an engine switch. --- src/layers/radio/lifecycle.js | 5 + src/layers/radio/lifecycle.test.mjs | 139 ++++++++++++++++++++++++++++ src/layers/radio/render/display.js | 7 +- 3 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 src/layers/radio/lifecycle.test.mjs diff --git a/src/layers/radio/lifecycle.js b/src/layers/radio/lifecycle.js index ac7b1dffe..cb3603f71 100644 --- a/src/layers/radio/lifecycle.js +++ b/src/layers/radio/lifecycle.js @@ -50,6 +50,11 @@ export function createLifecycle({ } }, + resumeViewport() { + if (parts.interaction.radioPresentationAllowed()) + parts.rendering.updateRenderVisibility(); + }, + detachRenderer() { parts.interaction.removeInteraction(); clearTimeout(layerState._overlayPublishTimer); diff --git a/src/layers/radio/lifecycle.test.mjs b/src/layers/radio/lifecycle.test.mjs new file mode 100644 index 000000000..bb2a12b6d --- /dev/null +++ b/src/layers/radio/lifecycle.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { LayerLifecycle } from '../../data/lifecycle.js'; +import * as geo from '../../geo/index.js'; +import { createFakeEngine } from '../../globe/testing/fakeEngine.js'; +import { createRadioLayer } from './index.js'; +import { createRadioDisplay } from './render/display.js'; + +const station = { + id: '00000000-0000-4000-8000-000000000001', + name: 'Station', + lat: 0, + lon: 0, + streamUrl: 'https://audio.example.test/station.mp3', + homepage: null, + tags: ['news'], + languages: ['English'], + state: '', + country: 'United States', + countryCode: 'US', + metadataTrust: 'untrusted-community', + codec: 'MP3', + bitrate: 128, +}; +const noop = () => {}; +const at = (engine, longitude) => + engine.camera.setView({ + destination: geo.cartesian3.fromDegrees(longitude, 0, 1_000_000), + }); + +function assertSelection(engine, shown) { + for (const id of ['radio-selected', 'radio-bracket']) { + const { store } = engine.fake.moverSet(id); + assert.equal(store.count, 1, id); + assert.equal(store.pickIds[0], `radio:selected:${station.id}`, id); + assert.equal(Boolean(store.shows[0]), shown, id); + } +} + +async function fixture(t, kind, longitude = 0) { + t.mock.timers.enable({ apis: ['setTimeout', 'setInterval'] }); + const engine = createFakeEngine({ kind }); + at(engine, longitude); + const layer = createRadioLayer({ + source: { + getDirectory: async () => ({ + stations: [station], + updatedAt: new Date().toISOString(), + stale: false, + degraded: false, + acceptedGeneration: 1, + catalogInstance: 'fixture', + }), + recordClick: async () => {}, + }, + services: { + ground: { cachedGroundFloor: () => 0, warmGroundFloor: noop }, + globe: {}, + picking: { + resolvePickId: (pick) => pick?.id, + isOwnedByOtherLayer: () => false, + }, + render: { + governorRequestRender: (reason, engine) => + engine.render.request(reason), + }, + overlays: { clearSource: noop, setVisible: noop, setEntries: noop }, + }, + rendererFactory: (context) => createRadioDisplay(context.engine, context), + }); + const catalog = { + layers: [layer], + metadata: [{ id: 'radio', disposition: 'enabled-only', token: 'r' }], + }; + const manager = new LayerLifecycle(null); + t.after(async () => { + await manager.destroyAll(); + engine.destroy(); + }); + await manager.bindCatalog(engine, catalog); + assert.equal(await manager.setEnabled('radio', true), true); + assert.equal(layer.selectStation(station.id), true); + engine.fake.frame(); + return { engine, layer, manager, catalog }; +} + +for (const [from, to] of [ + ['cesium', 'navara'], + ['navara', 'cesium'], +]) { + test(`selected radio markers resume at the restored camera (${from} -> ${to})`, async (t) => { + const { engine, layer, manager, catalog } = await fixture(t, from); + assertSelection(engine, true); + const incoming = createFakeEngine({ kind: to }); + t.after(() => incoming.destroy()); + at(incoming, 90); + await manager.detachScene(); + let restoreView; + const viewRestored = new Promise((resolve) => { + restoreView = resolve; + }); + await manager.bindCatalog(incoming, catalog, { viewRestored }); + incoming.fake.frame(); + assertSelection(incoming, false); + at(incoming, 0); + restoreView(); + await manager.resumeScene(); + incoming.fake.frame(); + assertSelection(incoming, true); + assert.equal(layer.getUIState().selected.id, station.id); + assert.equal(layer.getUIState().audioState, 'stopped'); + t.mock.timers.tick(1500); + incoming.fake.frame(); + assertSelection(incoming, true); + await manager.setEnabled('radio', false); + layer.resumeViewport(); + for (const id of ['radio-selected', 'radio-bracket']) + assert.equal(incoming.fake.moverSet(id).store.count, 0, id); + }); + + test(`a stationary far-side radio selection stays hidden across rebuilds and engines (${from} -> ${to})`, async (t) => { + const { engine, layer, manager, catalog } = await fixture(t, from, 90); + // A preference update checks the horizon before the delayed marker rebuild. + layer.setParams({ volume: 0.36 }); + assertSelection(engine, false); + t.mock.timers.tick(1500); + engine.fake.frame(); + assertSelection(engine, false); + const incoming = createFakeEngine({ kind: to }); + t.after(() => incoming.destroy()); + at(incoming, 90); + await manager.detachScene(); + await manager.bindCatalog(incoming, catalog); + await manager.resumeScene(); + incoming.fake.frame(); + assertSelection(incoming, false); + assert.equal(layer.getUIState().selected.id, station.id); + }); +} diff --git a/src/layers/radio/render/display.js b/src/layers/radio/render/display.js index e16bfb519..26edfc5f3 100644 --- a/src/layers/radio/render/display.js +++ b/src/layers/radio/render/display.js @@ -274,7 +274,12 @@ export function createRadioDisplay(engine, { state, services, parts }) { if (initialized) syncSelection(); }, addSelected(station, position, color) { - selectedRecord = { station, position, color: rgba(color), visible: true }; + selectedRecord = { + station, + position, + color: rgba(color), + visible: engine.projector.horizonVisible(position), + }; syncSelection(); }, setSelectedVisible(value) {