mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
65e30546dd
commit
ea1e2f9c04
10 files changed
+422
-5
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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))
|
||||||
|
|||||||
Reference in new issue
Block a user