feat(localAdsb): render on Navara

Task 12b. src/layers/localAdsb/render/navara.js implements the Local
ADS-B renderer interface of 12a on Navara:
- markers: the shared aircraft renderer's glyph sets in magenta #ff4fd8
  (class silhouette and scale, ×3 near / ×0.5 far, always on top), drawn
  under every sprite layer as Cesium draws the entities; each frame writes
  what Cesium's CallbackProperties gave (position, screen rotation,
  visibility) and the 2,000 km display distance;
- the 978 MHz UAT ring: its own sprite set, a white ring atlas tinted
  #ffb3ef, Cesium's 24 px point plus its 1.5 px outline (27 px), the same
  distance scale and distance, pickable as its aircraft;
- models (the Flights GLBs, magenta MIX tint), trail and head: the shared
  aircraft renderer's; the camera never follows a local aircraft.
Registered by registerNavaraLayerRenderers (import()); the Navara catalog
now offers flights, military and local ADS-B.

- ?qaFeed=local-adsb:fixture: the local receivers' answer in the server's
  format (src/bench/w1Feeds syntheticLocalReceivers: 40 aircraft near JFK,
  10 on 978 MHz UAT) through the layer's own decoder-feed session
  (sources.localAdsbFeeds, null by default: the layer makes its own).
- qa-w1-layers --layer=local-adsb: a click selects a marker and shows its
  card with the receiver line, the camera does not follow, Escape leaves
  the selection (it never answered Escape), empty map releases it.

qa-w1-layers local-adsb: Cesium 12/12, Navara 13/13, --compare parity
(global pose: the 2.27 px camera offset only). qa-flights-engines
--layer=local-adsb: 14/14 (fills exact; UAT rings side by side in
docs/migration/phase3/localAdsb).
This commit is contained in:
German Khokhlov committed 2026-10-17 06:50:15 +04:00
1 parent 65e30546dd
commit ea1e2f9c04
10 files changed
+422 -5

No files matched your search

+2
View File
@@ -886,6 +886,8 @@
"src/sources/adsbRecords.test.mjs", "src/sources/adsbRecords.test.mjs",
"src/layers/localAdsb/index.test.mjs", "src/layers/localAdsb/index.test.mjs",
"src/layers/localAdsb/render/cesium.js", "src/layers/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/navara.js",
"src/layers/localAdsb/renderer.test.mjs",
"src/ui/localSdrControls.test.mjs", "src/ui/localSdrControls.test.mjs",
"src/ui/localSdrMarkup.test.mjs", "src/ui/localSdrMarkup.test.mjs",
"src/ui/localSdrPresentation.test.mjs", "src/ui/localSdrPresentation.test.mjs",
+2 -1
View File
@@ -2601,7 +2601,8 @@
"src/layers/flights/render/navara.js", "src/layers/flights/render/navara.js",
"src/layers/military/render/navara.js", "src/layers/military/render/navara.js",
"src/layers/aircraft/qaDrawn.js", "src/layers/aircraft/qaDrawn.js",
"src/layers/localAdsb/render/cesium.js" "src/layers/localAdsb/render/cesium.js",
"src/layers/localAdsb/render/navara.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
+4
View File
@@ -144,6 +144,10 @@ export function createApplicationCatalog({
() => () =>
createApplicationLocalAdsb({ createApplicationLocalAdsb({
surface, surface,
// QA fixture receivers (?qaFeed=local-adsb:fixture, dev only).
...(sources.localAdsbFeeds
? { feeds: sources.localAdsbFeeds }
: {}),
enrichment: sources.flights, enrichment: sources.flights,
displayParams: () => flights().getParams(), displayParams: () => flights().getParams(),
...(resolveAsset ? { resolveAsset } : {}), ...(resolveAsset ? { resolveAsset } : {}),
+12 -1
View File
@@ -8,6 +8,9 @@
// military:synthetic[:count] readsb rows, `count` aircraft (1 000); the // military:synthetic[:count] readsb rows, `count` aircraft (1 000); the
// first 25 are also synthetic civil flights // first 25 are also synthetic civil flights
// vessels:fixture 12 000 AIS rows, or those within an area // vessels:fixture 12 000 AIS rows, or those within an area
// local-adsb:fixture the local receivers' answer (server format,
// /api/local-receivers/aircraft): 40 aircraft
// near JFK, 1090 MHz and 978 MHz UAT
import { import {
createFlightSource, createFlightSource,
@@ -16,14 +19,17 @@ import {
} from '../../sources/live/standalone.js'; } from '../../sources/live/standalone.js';
import { import {
syntheticFlights, syntheticFlights,
syntheticLocalReceivers,
syntheticMilitary, syntheticMilitary,
syntheticVessels, syntheticVessels,
} from '../../bench/w1Feeds.js'; } from '../../bench/w1Feeds.js';
import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js';
const KINDS = Object.freeze({ const KINDS = Object.freeze({
flights: new Set(['synthetic']), flights: new Set(['synthetic']),
military: new Set(['synthetic']), military: new Set(['synthetic']),
vessels: new Set(['fixture']), vessels: new Set(['fixture']),
'local-adsb': new Set(['fixture']),
}); });
/** The qaFeed entries of a location search, or a TypeError. */ /** The qaFeed entries of a location search, or a TypeError. */
@@ -38,7 +44,7 @@ export function parseQaFeed(search = '') {
(count !== null && !(Number.isInteger(count) && count > 0)) (count !== null && !(Number.isInteger(count) && count > 0))
) )
throw new TypeError( throw new TypeError(
`qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture)`, `qaFeed: unknown entry "${item}" (flights:synthetic[:count], military:synthetic[:count], vessels:fixture, local-adsb:fixture)`,
); );
return { layer, kind, count }; return { layer, kind, count };
}); });
@@ -68,6 +74,11 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) {
? json({ timestamp: now() / 1000, trace: [] }) ? json({ timestamp: now() / 1000, trace: [] })
: json(syntheticMilitary({ nowMs: now(), count: count ?? 1000 })), : json(syntheticMilitary({ nowMs: now(), count: count ?? 1000 })),
}); });
if (layer === 'local-adsb')
sources.localAdsbFeeds = createLocalReceiverFeeds({
now,
fetchImpl: async () => json(syntheticLocalReceivers({ nowMs: now() })),
});
if (layer === 'vessels') if (layer === 'vessels')
sources.vessels = createVesselSource({ sources.vessels = createVesselSource({
fetchImpl: async (url) => { fetchImpl: async (url) => {
+23
View File
@@ -63,3 +63,26 @@ test('military:synthetic answers with the military source’s shape, some shared
const track = await sources.military.getTrack('ae1100'); const track = await sources.military.getTrack('ae1100');
assert.deepEqual(track.records, []); assert.deepEqual(track.records, []);
}); });
test('local-adsb:fixture answers as the local receivers do (1090 MHz and UAT)', async () => {
const now = Date.UTC(2026, 9, 16, 12);
assert.deepEqual(parseQaFeed('?qaFeed=local-adsb:fixture'), [
{ layer: 'local-adsb', kind: 'fixture', count: null },
]);
const { localAdsbFeeds } = createQaSources(
parseQaFeed('?qaFeed=local-adsb:fixture'),
{ now: () => now },
);
const loaded = new Promise((resolve) =>
localAdsbFeeds.subscribe((state) => {
if (state.records?.length) resolve();
}),
);
localAdsbFeeds.start();
await loaded;
const state = localAdsbFeeds.getState();
localAdsbFeeds.stop();
assert.equal(state.records.length, 40);
assert.ok(state.records.some((record) => record.band === '978'));
assert.ok(state.records.some((record) => record.band === '1090'));
});
+3 -3
View File
@@ -10,20 +10,20 @@ import {
rendererFor, rendererFor,
} from './layers/renderers.js'; } from './layers/renderers.js';
test('the Navara mode registers the flights and military renderers, once', async () => { test('the Navara mode registers the aircraft layers’ renderers, once', async () => {
assert.equal(layerAvailableOn('military', 'navara'), false); assert.equal(layerAvailableOn('military', 'navara'), false);
await Promise.all([ await Promise.all([
registerNavaraLayerRenderers(), registerNavaraLayerRenderers(),
registerNavaraLayerRenderers(), registerNavaraLayerRenderers(),
]); ]);
await registerNavaraLayerRenderers(); await registerNavaraLayerRenderers();
for (const id of ['flights', 'military']) for (const id of ['flights', 'military', 'local-adsb'])
assert.equal(layerAvailableOn(id, 'navara'), true, id); assert.equal(layerAvailableOn(id, 'navara'), true, id);
assert.deepEqual( assert.deepEqual(
createLayerAvailability('navara') createLayerAvailability('navara')
.available() .available()
.map(({ id }) => id), .map(({ id }) => id),
['flights', 'military'], ['flights', 'military', 'local-adsb'],
); );
assert.equal(rendererFor('satellites', { kind: 'navara' }), null); assert.equal(rendererFor('satellites', { kind: 'navara' }), null);
}); });
+3
View File
@@ -61,6 +61,9 @@ export async function registerNavaraLayerRenderers() {
military: async () => military: async () =>
(await import('../layers/military/render/navara.js')) (await import('../layers/military/render/navara.js'))
.createMilitaryNavaraRenderer, .createMilitaryNavaraRenderer,
'local-adsb': async () =>
(await import('../layers/localAdsb/render/navara.js'))
.createLocalAdsbNavaraRenderer,
}; };
const missing = Object.keys(loaders).filter( const missing = Object.keys(loaders).filter(
(id) => !layerAvailableOn(id, 'navara'), (id) => !layerAvailableOn(id, 'navara'),
+228
View File
@@ -0,0 +1,228 @@
// The Local ADS-B layer's renderer on the Navara engine (phase 3 Task 12b),
// with render/cesium.js's interface: the shared aircraft renderer
// (src/layers/aircraft/render/navara.js) draws the magenta markers (its
// fleet glyphs), the Flights models and the trail; the markers' per-frame
// position, rotation and visibility (Cesium's CallbackProperties and the
// 2,000 km display distance) are written each frame; the 978 MHz UAT ring
// is its own sprite set (Cesium's 24 px transparent point with a 1.5 px
// outline). Registered for 'navara' by the Navara mode after its import()
// (src/app/navaraMode.js). The camera never follows a local aircraft.
import * as geo from '../../../geo/index.js';
import {
createAircraftNavaraRenderer,
createMoverBank,
} from '../../aircraft/render/navara.js';
import { MODEL_HEADING_OFFSET_DEG } from '../../flights/policy.js';
import { nearFarScale, rgbaFromCss } from '../../shared/color.js';
import {
LAYER_ID,
LOCAL_ADSB_COLOR,
LOCAL_ADSB_UAT_RING_COLOR,
} from '../policy.js';
/** Markers (and the UAT ring) are not drawn past this camera distance (m). */
export const LOCAL_ADSB_DISPLAY_DISTANCE_M = 2_000_000;
const MAGENTA = Object.freeze(rgbaFromCss(LOCAL_ADSB_COLOR));
const RING = rgbaFromCss(LOCAL_ADSB_UAT_RING_COLOR).rgba();
const SCALE_BY_DISTANCE = Object.freeze(
nearFarScale(1000, 3.0, 8_000_000, 0.5),
);
// Cesium's point: 24 px plus a 1.5 px outline on each side, the outline
// drawn on the outer 1.5 px.
const RING_PIXEL_SIZE = 24;
const RING_OUTLINE_PX = 1.5;
const RING_TOTAL_PX = RING_PIXEL_SIZE + 2 * RING_OUTLINE_PX;
const MARKER_ORDER = -1;
/** The ring's atlas: a white ring on a 64 px canvas (tinted per instance). */
export function drawUatRingAtlas(size = 64) {
const canvas = globalThis.document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
const scale = size / RING_TOTAL_PX;
const outer = (RING_TOTAL_PX / 2) * scale;
const band = RING_OUTLINE_PX * scale;
context.strokeStyle = '#ffffff';
context.lineWidth = band;
context.beginPath();
context.arc(size / 2, size / 2, outer - band / 2, 0, Math.PI * 2);
context.stroke();
return { canvas, cells: { ring: [0, 0, 1, 1] } };
}
/**
* Registry factory (src/app/layers/renderers.js): `(engine, options)`.
* @param {import('../../../globe/contract.js').GlobeEngine} engine
* @param {{ drawAtlas?: Function, drawRing?: Function }} [options] Test seams.
*/
export function createLocalAdsbNavaraRenderer(
engine,
{ drawAtlas, drawRing = drawUatRingAtlas } = {},
) {
const aircraft = createAircraftNavaraRenderer({
engine,
layerId: LAYER_ID,
// Under every sprite collection, as Cesium draws the layer's entities
// (data/spriteOrder raises the collections over the data sources).
order: MARKER_ORDER,
headingOffsetDeg: MODEL_HEADING_OFFSET_DEG,
...(drawAtlas ? { drawAtlas } : {}),
});
const view = aircraft.view;
const handles = new Set();
let visible = false;
let ringBank = null;
let removeFrame = null;
const scratch = geo.cartesian3.create();
const ringWrite = (handle, set, index) => {
set.set(index, {
position: handle.sprite.position,
color: RING,
cell: 'ring',
show: handle.ringShown,
pickId: handle.id,
});
};
const ringBankFor = () => {
if (ringBank) return ringBank;
const atlas = drawRing();
ringBank = createMoverBank(engine, {
options: (capacity, generation) => ({
id: `${LAYER_ID}-rings-${generation}`,
kind: 'sprite',
capacity,
order: MARKER_ORDER + 0.25,
depthTest: 'always',
pickable: true,
sprite: {
atlas,
sizePx: RING_TOTAL_PX,
scaleByDistance: [
SCALE_BY_DISTANCE.near,
SCALE_BY_DISTANCE.nearValue,
SCALE_BY_DISTANCE.far,
SCALE_BY_DISTANCE.farValue,
],
},
}),
write: ringWrite,
onCommit: (bank) => queueMicrotask(() => bank.commit()),
});
ringBank.setVisible(visible);
return ringBank;
};
// Each frame: Cesium's per-frame callbacks and its display distance.
const frame = () => {
const camera = view.camera.positionWC;
const limitSq = LOCAL_ADSB_DISPLAY_DISTANCE_M ** 2;
for (const handle of handles) {
const position = handle.spec.position();
if (!position) continue;
geo.cartesian3.clone(position, scratch);
const near = geo.cartesian3.distanceSquared(camera, scratch) <= limitSq;
const sprite = handle.sprite;
if (!geo.cartesian3.equals(sprite.position, scratch))
sprite.position = scratch;
const rotation = handle.spec.rotation();
if (rotation !== sprite.rotation) sprite.rotation = rotation;
const show = near && handle.spec.show() === true;
if (show !== sprite.show) sprite.show = show;
if (handle.ring) {
handle.ringShown = near && handle.ring() === true;
ringBank?.update(handle, {
position: sprite.position,
show: handle.ringShown,
});
}
}
ringBank?.commit();
};
const markers = {
add(spec) {
const sprite = aircraft.fleet.add({
position: spec.position() ?? geo.cartesian3.ZERO,
image: spec.image,
width: 20,
height: 20,
scale: 1,
color: MAGENTA,
rotation: 0,
scaleByDistance: SCALE_BY_DISTANCE,
disableDepthTestDistance: Number.POSITIVE_INFINITY,
id: spec.id,
show: false,
});
const handle = {
id: spec.id,
spec,
sprite,
ring: null,
ringShown: false,
gevBank: null,
gevIndex: -1,
};
handles.add(handle);
return handle;
},
remove(handle) {
if (!handle) return;
handles.delete(handle);
aircraft.fleet.remove(handle.sprite);
handle.gevBank?.remove(handle);
},
setImage(handle, image) {
if (handle) handle.sprite.image = image;
},
setScale(handle, scale) {
if (handle) handle.sprite.scale = scale;
},
hasRing: (handle) => Boolean(handle?.ring),
setRing(handle, on, ringVisible) {
if (on) {
handle.ring = ringVisible;
ringBankFor().add(handle);
} else {
handle.ring = null;
handle.gevBank?.remove(handle);
}
},
};
return {
kind: 'navara',
engine,
view,
markers,
models: aircraft.models,
trails: aircraft.trails,
modelColor: MAGENTA,
dataSource: null,
whenReady: () => aircraft.whenReady(),
attach() {
removeFrame ??= view.on('preRender', frame);
},
setVisible(next) {
visible = next;
aircraft.fleet.setVisible(next);
ringBank?.setVisible(next);
if (next) view.requestRender();
},
detach() {
removeFrame?.();
removeFrame = null;
for (const handle of [...handles]) markers.remove(handle);
ringBank?.destroy();
ringBank = null;
aircraft.fleet.detach();
},
destroy() {
aircraft.destroy();
},
};
}
+142
View File
@@ -0,0 +1,142 @@
// The Local ADS-B logic on its Navara renderer (phase 3 Task 12b) over a
// fake engine: magenta markers in the shared aircraft renderer's glyph sets,
// the UAT ring as its own set, a click through the engine's selection
// dispatcher selects (the camera never follows), empty map releases, and the
// layer leaves no mover set behind.
import assert from 'node:assert/strict';
import test from 'node:test';
import { createLocalAdsbLayer } from './index.js';
import { createLocalAdsbNavaraRenderer } from './render/navara.js';
import { aircraftAtlasLayout } from '../aircraft/render/navara.js';
import * as selection from '../../data/selectionDispatcher.js';
import { createFakeEngine } from '../../globe/testing/fakeEngine.js';
import * as geo from '../../geo/index.js';
const receiver = {
getState: () => ({
connected: true,
mode: 'adsb',
status: 'streaming',
aircraft: [],
}),
subscribe: () => () => {},
setMode: async () => true,
};
function feedsOf(records) {
return {
getState: () => ({ configured: true, feeds: [], records }),
subscribe: () => () => {},
start() {},
stop() {},
};
}
const record = (icao, lat, band = '1090') => ({
icao,
callsign: `L${icao}`,
lat,
lon: 0,
altitudeFt: 10_000,
groundSpeedKt: 180,
trackDeg: 90,
verticalRateFpm: 0,
lastPositionAt: 100_000,
lastMessageAt: 100_000,
messageCount: 10,
band,
source: 'feed',
});
test('local aircraft draw, ring and select on the Navara renderer; the camera never follows', async () => {
const engine = createFakeEngine();
engine.camera.setView({
destination: geo.cartesian3.fromDegrees(0, 0, 60_000),
});
const layout = aircraftAtlasLayout();
let renderer = null;
const selected = [];
const cleared = [];
const layer = createLocalAdsbLayer({
receiver,
feeds: feedsOf([record('abc123', 0), record('def456', 0.05, '978')]),
now: () => 100_500,
services: {
render: { governorRequestRender() {} },
context: {
registerEntityContext() {},
selectEntityContext: (entity) => selected.push(entity.gevTrackedId),
clearSelectedEntityContextForLayer: (layerId) => cleared.push(layerId),
getSelectedEntityContext: () =>
selected.length > cleared.length ? { id: selected.at(-1) } : null,
removeEntityContextsForLayer() {},
},
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
selection,
renderer: {
create: ({ engine: given }) =>
(renderer = createLocalAdsbNavaraRenderer(given, {
drawAtlas: async () => ({
canvas: { width: layout.width, height: layout.height },
cells: layout.cells,
}),
drawRing: () => ({
canvas: { width: 64, height: 64 },
cells: { ring: [0, 0, 1, 1] },
}),
})),
},
},
});
layer.init(null, { engine });
await layer.enable();
try {
await renderer.whenReady();
await layer.update();
engine.fake.frame();
await Promise.resolve();
const sets = () => engine.movers.list();
const sprites = sets().filter(({ id }) =>
id.startsWith('local-adsb-sprites'),
);
assert.equal(sprites.length, 1);
assert.equal(sprites[0].count, 2, 'one magenta glyph per aircraft');
const { store } = engine.fake.moverSet(sprites[0].id);
assert.deepEqual([...store.pickIds.slice(0, 2)].sort(), [
'local-adsb:abc123',
'local-adsb:def456',
]);
// Magenta #ff4fd8.
assert.deepEqual(
[...store.colors.slice(0, 3)].map((value) => Math.round(value * 255)),
[255, 79, 216],
);
const rings = sets().filter(({ id }) => id.startsWith('local-adsb-rings'));
assert.equal(rings.length, 1);
assert.equal(rings[0].count, 1, 'a ring for the UAT-only aircraft');
assert.equal(layer.getAllPositions().length, 2);
// A click selects through the dispatcher; the camera does not follow.
engine.fake.script('pick', () => ({ id: 'local-adsb:def456' }));
engine.fake.script('drillPick', () => [{ id: 'local-adsb:def456' }]);
const click = () => {
engine.fake.input('down', { position: { x: 10, y: 10 } });
engine.fake.input('up', { position: { x: 10, y: 10 } });
engine.fake.input('click', { position: { x: 10, y: 10 } });
};
click();
assert.deepEqual(selected, ['local-adsb:def456']);
assert.equal(engine.camera.following(), null);
// Empty map releases it.
engine.fake.script('pick', () => undefined);
engine.fake.script('drillPick', () => []);
click();
assert.deepEqual(cleared, ['local-adsb']);
} finally {
await layer.disable();
layer.destroy();
}
assert.equal(engine.movers.list().length, 0, 'no mover set left behind');
engine.destroy();
});
+3
View File
@@ -56,6 +56,9 @@ export function createStandaloneLayerSources(overrides = {}) {
mapillary: createMapillarySource({ mapillary: createMapillarySource({
token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '', token: import.meta.env?.MAPILLARY_CLIENT_TOKEN || '',
}), }),
// The local receivers' decoder-feed session: none here (the Local ADS-B
// layer makes its own); QA fixtures replace it (?qaFeed=local-adsb:fixture).
localAdsbFeeds: null,
}; };
for (const name of Object.keys(overrides)) { for (const name of Object.keys(overrides)) {
if (!Object.hasOwn(defaults, name)) if (!Object.hasOwn(defaults, name))