feat(radio): render stations and clusters on Navara

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

No files matched your search

+1
View File
@@ -34,6 +34,7 @@ const navaraPackage = /^(?:(?:three|postprocessing)(?:\/|$)|@navaramap\/)/;
// engine in their renderers: cesium only in render/cesium*.js, three and
// Navara only in render/navara*.js. The list grows with each moved layer.
const RENDERED_LAYER_DIRS = [
'src/layers/radio/',
'src/layers/bikeshare/',
'src/layers/earthquakes/',
'src/layers/firms/',
+38
View File
@@ -0,0 +1,38 @@
// Shared 750-station directory for legacy and both-engine radio QA.
const tags = [
'news',
'talk',
'weather,emergency',
'public safety,scanner',
'aviation,atc',
'marine,maritime',
'traffic,transit',
'music,jazz',
'music,rock',
'community',
];
export const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name:
index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
+8 -1
View File
@@ -595,6 +595,11 @@
"src/layers/radio/presentation.js",
"src/layers/radio/queries.js",
"src/layers/radio/rendering.js",
"src/layers/radio/render/cesium.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/render/navara.test.mjs",
"src/layers/radio/screenClusters.js",
"src/layers/radio/screenClusters.test.mjs",
"src/layers/radio/selection.js",
"src/layers/radio/source.js",
"src/layers/radio/state.js",
@@ -1221,5 +1226,7 @@
"src/layers/firms/lifecycle.test.mjs",
"scripts/qa-w2-firms-renderer-entry.mjs",
"scripts/qa-w2-firms-perf.mjs",
"scripts/qa-w2-firms-ground.mjs"
"scripts/qa-w2-firms-ground.mjs",
"scripts/qa-w2-radio.mjs",
"scripts/fixtures/w2/radio.mjs"
]
+8 -7
View File
@@ -1603,8 +1603,6 @@
"radio-layer": {
"exports": ["./layers/radio"],
"modules": [
"src/data/iconOrientation.js",
"src/data/render/cesiumProjection.js",
"src/data/idSwitches.js",
"src/data/layerState.js",
"src/data/layerStateTokenReservations.json",
@@ -1648,11 +1646,9 @@
"src/geo/scaleToGeodeticSurface.js",
"src/geo/check.js",
"src/geo/debug.js",
"src/geo/developerError.js",
"src/globe/contract.js",
"src/globe/cesium/engineFromViewer.js"
"src/geo/developerError.js"
],
"external": ["cesium"]
"external": []
},
"submarine-cables": {
"exports": ["./layers/submarine-cables"],
@@ -2709,7 +2705,10 @@
"src/layers/installations/render/navaraGeometry.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js",
"src/layers/radio/render/cesium.js",
"src/layers/firms/render/navara.js",
"src/layers/radio/render/navara.js",
"src/layers/radio/screenClusters.js",
"src/layers/firms/render/navaraGeometry.js",
"src/layers/firms/render/navaraSprites.js",
"src/layers/shared/render/navaraGroundGeometry.js",
@@ -2717,6 +2716,7 @@
"src/layers/shared/render/navaraGroundSurface.js"
],
"external": [
"kdbush",
"hls.js",
"@mapbox/vector-tile",
"cesium",
@@ -3314,7 +3314,8 @@
"src/layers/satellites/render/cesium.js",
"src/layers/earthquakes/render/cesium.js",
"src/layers/firms/rectangle.js",
"src/layers/firms/render/cesium.js"
"src/layers/firms/render/cesium.js",
"src/layers/radio/render/cesium.js"
],
"external": [
"hls.js",
+25 -41
View File
@@ -9,12 +9,14 @@
* independence, restoration without autoplay, responsive UI, and a clean
* console. Screenshots are written under the gitignored `qa-shots/radio/`.
*
* Run: node scripts/qa-radio.mjs --url http://localhost:4173
* Run: node scripts/qa-radio.mjs --url http://localhost:4193
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import puppeteer from 'puppeteer';
import { stations } from './fixtures/w2/radio.mjs';
import { isExpectedPreviewSetupError } from './qa-browserEvidence.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..');
@@ -24,8 +26,10 @@ const option = (name, fallback) => {
const index = args.indexOf(name);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
const APP_URL = option('--url', process.env.QA_BASE_URL || 'http://localhost:4173');
const APP_URL = option('--url', process.env.QA_BASE_URL);
if (!APP_URL) throw new Error('Radio QA requires an explicit --url or QA_BASE_URL');
const APP_ORIGIN = new URL(APP_URL).origin;
const expectedPreviewErrors = [];
// Radio owns this harness's keyboard and pointer actions; the welcome dialog
// has its own acceptance harness and must not intercept those gestures.
const RADIO_URL = new URL(APP_URL);
@@ -42,35 +46,6 @@ const chrome = chromeCandidates.find((candidate) => {
try { return fs.existsSync(candidate); } catch { return false; }
});
const tags = [
'news', 'talk', 'weather,emergency', 'public safety,scanner',
'aviation,atc', 'marine,maritime', 'traffic,transit',
'music,jazz', 'music,rock', 'community',
];
const stations = Array.from({ length: 750 }, (_, index) => {
const row = Math.floor(index / 30);
const column = index % 30;
return {
id: `00000000-0000-4000-8000-${index.toString(16).padStart(12, '0')}`,
name: index === 360
? '100.3 The River - WQRV - Meridianville/Huntsville, AL'
: index === 390
? 'La Zeta (Hermosillo) - 93.9 FM - XHHY - Uniradio - Hermosillo, Sonora'
: `QA Radio ${String(index + 1).padStart(3, '0')}`,
lat: -72 + row * 6,
lon: -174 + column * 12,
streamUrl: `https://audio.example.test/${index}.mp3`,
homepage: `https://station.example.test/${index}`,
tags: tags[index % tags.length].split(','),
languages: index === 360 ? ['Spanish'] : ['English'],
state: `Region ${row + 1}`,
country: 'United States',
countryCode: 'US',
metadataTrust: 'untrusted-community',
codec: index % 2 ? 'AAC' : 'MP3',
bitrate: 128,
};
});
const results = [];
function check(name, ok, detail = '') {
@@ -241,6 +216,10 @@ async function main() {
const failedResponses = [];
const externalCesiumEndpointFailures = [];
page.on('console', (message) => {
if (isExpectedPreviewSetupError(message, { preview: args.includes('--preview'), baseUrl: APP_URL })) {
expectedPreviewErrors.push('/api/setup/status: 404');
return;
}
if (message.type() === 'error') {
const sourceUrl = message.location()?.url || '';
consoleErrors.push(sourceUrl ? `${message.text()} [${sourceUrl}]` : message.text());
@@ -697,7 +676,7 @@ async function main() {
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const dataSource = Array.from({ length: viewer.dataSources.length }, (_, index) => viewer.dataSources.get(index))
.find((item) => item.name === 'Radio stations');
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { radioStationIdFromPick } = await import('/src/data/radio.js');
return new Promise((resolve) => {
let removePostRender = null;
@@ -1283,7 +1262,7 @@ async function main() {
);
const clusterBadge = await page.evaluate(async () => {
const viewer = window.__godsEyeView.viewer;
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const { radioStationIdFromPick } = await import('/src/data/radio.js');
const baselineSolveRevision = getWorldOverlayDiagnostics().solveRevision;
@@ -1394,7 +1373,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const { distanceFade } = await import('/src/overlays/worldOverlayDraw.js');
const source = radio.getOverlayDiagnostics();
const cameraPosition = viewer.camera.positionWC;
@@ -1456,7 +1435,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const radio = window.__godsEyeView.dataManager.layers.get('radio').module;
const ellipsoid = viewer.scene.globe.ellipsoid;
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const paintedClusters = () => new Promise((resolve) => {
let removePostRender = null;
const timeout = setTimeout(() => {
@@ -3699,7 +3678,7 @@ async function main() {
radio.setFilter('news');
viewer.scene.requestRender();
await new Promise((resolve) => setTimeout(resolve, 500));
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
return new Promise((resolve) => {
let removePostRender = null;
let timeout = null;
@@ -3862,7 +3841,7 @@ async function main() {
const viewer = window.__godsEyeView.viewer;
const selectedEntity = window.__godsEyeView.viewer.entities.values
.find((entity) => String(entity.id).startsWith('radio:selected:'));
const { getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const flightTargets = window.__qaRadioFlyToCalls.map(({ destination }) => {
const position = viewer.scene.globe.ellipsoid.cartesianToCartographic(destination);
return {
@@ -4045,7 +4024,7 @@ async function main() {
});
const selectedEntity = viewer.entities.values
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const expectedEntryId = `selected:${selected.id}`;
let painted = null;
// Radio's horizon pass reveals the selected entity on its own timer
@@ -4104,7 +4083,7 @@ async function main() {
});
const selectedEntity = viewer.entities.values
.find((entity) => String(entity.id) === `radio:selected:${selected.id}`);
const { getOverlayPaintRect, getWorldOverlayDiagnostics } = await import('/src/overlays/worldOverlay.js');
const { getPaintRect: getOverlayPaintRect, getDiagnostics: getWorldOverlayDiagnostics } = window.__gevWorldOverlay;
const expectedEntryId = `selected:${selected.id}`;
let rect = null;
for (let attempt = 0; attempt < 20 && !rect; attempt += 1) {
@@ -4141,11 +4120,14 @@ async function main() {
selectedNativePick = picks.find((picked) => picked?.id === selectedEntity);
selectedPointPick = picks.find((picked) => (
picked?.id === selectedEntity
&& String(picked?.primitive?.constructor?.name || '').includes('PointPrimitive')
// Public point properties survive production minification; class names do not.
&& Number.isFinite(picked?.primitive?.pixelSize)
&& Number.isFinite(picked?.primitive?.outlineWidth)
));
selectedBillboardPick = picks.find((picked) => (
picked?.id === selectedEntity
&& String(picked?.primitive?.constructor?.name || '').includes('Billboard')
&& Number.isFinite(picked?.primitive?.width)
&& Number.isFinite(picked?.primitive?.height)
));
if (!selectedPointPick) {
await new Promise((resolve) => {
@@ -4741,6 +4723,8 @@ async function main() {
}
const failures = results.filter((result) => !result.ok).length;
const reportPath = option('--report');
if (reportPath) fs.writeFileSync(reportPath, JSON.stringify({ pass: failures === 0, checks: results, expectedPreviewErrors }, null, 2) + '\n');
console.log(`\nRadio QA: ${results.length - failures}/${results.length} passed`);
console.log(`RESULT: ${results.length - failures} passed, ${failures} failed, 0 skipped`);
process.exitCode = failures ? 1 : 0;