From ea1e2f9c04ea4f21e4dfc55f44addadcffbff138 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Sat, 17 Oct 2026 06:50:15 +0400 Subject: [PATCH] feat(localAdsb): render on Navara MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- scripts/format-scope.json | 2 + scripts/package-boundaries.json | 3 +- src/app/constructCatalog.js | 4 + src/app/layers/qaSources.js | 13 +- src/app/layers/qaSources.test.mjs | 23 +++ src/app/navaraLayers.test.mjs | 6 +- src/app/navaraMode.js | 3 + src/layers/localAdsb/render/navara.js | 228 +++++++++++++++++++++++++ src/layers/localAdsb/renderer.test.mjs | 142 +++++++++++++++ src/standalone/layerSources.js | 3 + 10 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 src/layers/localAdsb/render/navara.js create mode 100644 src/layers/localAdsb/renderer.test.mjs diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 63443142a..842b4de6e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -886,6 +886,8 @@ "src/sources/adsbRecords.test.mjs", "src/layers/localAdsb/index.test.mjs", "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/localSdrMarkup.test.mjs", "src/ui/localSdrPresentation.test.mjs", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 331d523fd..91cb972af 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2601,7 +2601,8 @@ "src/layers/flights/render/navara.js", "src/layers/military/render/navara.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": [ "hls.js", diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index eef340692..7c972ac1d 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -144,6 +144,10 @@ export function createApplicationCatalog({ () => createApplicationLocalAdsb({ surface, + // QA fixture receivers (?qaFeed=local-adsb:fixture, dev only). + ...(sources.localAdsbFeeds + ? { feeds: sources.localAdsbFeeds } + : {}), enrichment: sources.flights, displayParams: () => flights().getParams(), ...(resolveAsset ? { resolveAsset } : {}), diff --git a/src/app/layers/qaSources.js b/src/app/layers/qaSources.js index c06e3911e..25f5bd6df 100644 --- a/src/app/layers/qaSources.js +++ b/src/app/layers/qaSources.js @@ -8,6 +8,9 @@ // military:synthetic[:count] readsb rows, `count` aircraft (1 000); the // first 25 are also synthetic civil flights // 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 { createFlightSource, @@ -16,14 +19,17 @@ import { } from '../../sources/live/standalone.js'; import { syntheticFlights, + syntheticLocalReceivers, syntheticMilitary, syntheticVessels, } from '../../bench/w1Feeds.js'; +import { createLocalReceiverFeeds } from '../../layers/localAdsb/feeds.js'; const KINDS = Object.freeze({ flights: new Set(['synthetic']), military: new Set(['synthetic']), vessels: new Set(['fixture']), + 'local-adsb': new Set(['fixture']), }); /** 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)) ) 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 }; }); @@ -68,6 +74,11 @@ export function createQaSources(entries, { now = () => Date.now() } = {}) { ? json({ timestamp: now() / 1000, trace: [] }) : json(syntheticMilitary({ nowMs: now(), count: count ?? 1000 })), }); + if (layer === 'local-adsb') + sources.localAdsbFeeds = createLocalReceiverFeeds({ + now, + fetchImpl: async () => json(syntheticLocalReceivers({ nowMs: now() })), + }); if (layer === 'vessels') sources.vessels = createVesselSource({ fetchImpl: async (url) => { diff --git a/src/app/layers/qaSources.test.mjs b/src/app/layers/qaSources.test.mjs index d8e519ddf..ea7f73cb6 100644 --- a/src/app/layers/qaSources.test.mjs +++ b/src/app/layers/qaSources.test.mjs @@ -63,3 +63,26 @@ test('military:synthetic answers with the military source’s shape, some shared const track = await sources.military.getTrack('ae1100'); 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')); +}); diff --git a/src/app/navaraLayers.test.mjs b/src/app/navaraLayers.test.mjs index bf1d93400..49887282b 100644 --- a/src/app/navaraLayers.test.mjs +++ b/src/app/navaraLayers.test.mjs @@ -10,20 +10,20 @@ import { rendererFor, } 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); await Promise.all([ registerNavaraLayerRenderers(), 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.deepEqual( createLayerAvailability('navara') .available() .map(({ id }) => id), - ['flights', 'military'], + ['flights', 'military', 'local-adsb'], ); assert.equal(rendererFor('satellites', { kind: 'navara' }), null); }); diff --git a/src/app/navaraMode.js b/src/app/navaraMode.js index 7401848e4..1e4be2f1d 100644 --- a/src/app/navaraMode.js +++ b/src/app/navaraMode.js @@ -61,6 +61,9 @@ export async function registerNavaraLayerRenderers() { military: async () => (await import('../layers/military/render/navara.js')) .createMilitaryNavaraRenderer, + 'local-adsb': async () => + (await import('../layers/localAdsb/render/navara.js')) + .createLocalAdsbNavaraRenderer, }; const missing = Object.keys(loaders).filter( (id) => !layerAvailableOn(id, 'navara'), diff --git a/src/layers/localAdsb/render/navara.js b/src/layers/localAdsb/render/navara.js new file mode 100644 index 000000000..10e0363c2 --- /dev/null +++ b/src/layers/localAdsb/render/navara.js @@ -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(); + }, + }; +} diff --git a/src/layers/localAdsb/renderer.test.mjs b/src/layers/localAdsb/renderer.test.mjs new file mode 100644 index 000000000..ab469f7ef --- /dev/null +++ b/src/layers/localAdsb/renderer.test.mjs @@ -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(); +}); diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 935351685..70d1a7a50 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -56,6 +56,9 @@ export function createStandaloneLayerSources(overrides = {}) { mapillary: createMapillarySource({ 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)) { if (!Object.hasOwn(defaults, name))