mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Fix Navara model display tint, overlay ordering, follow gestures and resource cleanup. Reduce repeated aircraft work and correct fixture enrichment responses. Strengthen dev/preview QA, preserve failure history and document verified W1 results. Validation: 7171 ordinary tests pass, one Windows-only skip; 15 allocation probes; 37 W1 acceptance steps; contracts, shell, four performance gates; 18 identical Cesium baseline PNGs. Known pre-existing CCTV and live-source limits are recorded.
816 lines
27 KiB
JavaScript
816 lines
27 KiB
JavaScript
import * as geo from '../../geo/index.js';
|
||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||
import {
|
||
screenProjectedRotation,
|
||
stabilizeScreenRotation,
|
||
} from '../../data/iconOrientation.js';
|
||
import { rgbaFromCss } from '../shared/color.js';
|
||
import { pickRenderAltitudeM } from '../../data/renderAltitude.js';
|
||
import { routePlausible } from '../../data/routePlausible.js';
|
||
import {
|
||
localAdsbPositionIsFresh,
|
||
mergeLocalAdsbRecords,
|
||
summarizeLocalAdsb,
|
||
} from '../../sources/adsbRecords.js';
|
||
import { GROUND_SCALE } from '../flights/policy.js';
|
||
import {
|
||
localAdsbCardModel,
|
||
localAdsbIsUat,
|
||
localAdsbSourceText,
|
||
localAdsbTitle,
|
||
} from './card.js';
|
||
import { createLocalAdsbEnrichment, localAdsbClass } from './enrichment.js';
|
||
import { createLocalAdsbModels } from './models.js';
|
||
import { localAdsbQaDrawn } from './qaDrawn.js';
|
||
import { LocalAdsbMotion } from './motion.js';
|
||
import {
|
||
ENTITY_PREFIX,
|
||
LAYER_ID,
|
||
LAYER_NAME,
|
||
LAYER_SOURCE,
|
||
LOCAL_ADSB_COLOR,
|
||
LOCAL_ADSB_SYNC_MS,
|
||
LOCAL_ADSB_TICK_MS,
|
||
} from './policy.js';
|
||
import { localAdsbStatus } from './status.js';
|
||
|
||
export {
|
||
LAYER_ID as LOCAL_ADSB_LAYER_ID,
|
||
HEARD_BY_RECEIVER,
|
||
} from './policy.js';
|
||
export {
|
||
localAdsbCardModel,
|
||
localAdsbClassLine,
|
||
localAdsbIsUat,
|
||
localAdsbReceiverLine,
|
||
localAdsbTitle,
|
||
} from './card.js';
|
||
export { localAdsbClass } from './enrichment.js';
|
||
export { localAdsbStatus } from './status.js';
|
||
|
||
const FEET_TO_METERS = 0.3048;
|
||
const RENDER_HOLD_ID = 'local-adsb';
|
||
/** Trail lines pass through their aircraft; a click never stops on one. */
|
||
const TRAIL_PICK_PREFIX = 'gev-trail:';
|
||
/** The model tint (the layer's magenta), engine-neutral. */
|
||
const MODEL_COLOR = Object.freeze(rgbaFromCss(LOCAL_ADSB_COLOR));
|
||
/** The DISPLAY rail's first-run 3D preference, used when no reader is wired. */
|
||
const DEFAULT_DISPLAY = Object.freeze({
|
||
models3d: true,
|
||
models3dMode: 'proximity',
|
||
});
|
||
|
||
/**
|
||
* Local ADS-B layer: draws aircraft heard by the user's own receivers.
|
||
*
|
||
* Two inputs feed it. The receiver is any object with `getState()` returning
|
||
* `{ connected, status, message, mode, webUsbSupported, aircraft, messagesPerSecond }`,
|
||
* `subscribe(listener)` and `setMode(mode)`; `aircraft` holds records from
|
||
* `src/sources/adsbRecords.js`. The browser WebUSB session is one such
|
||
* receiver. The optional `feeds` session (`./feeds.js`) supplies records read
|
||
* by the server from local 1090/978 MHz decoders; it polls only while this
|
||
* layer is enabled. Records are merged by ICAO (`mergeLocalAdsbRecords`).
|
||
* Aircraft heard only on 978 MHz UAT carry a thin ring around the marker.
|
||
*
|
||
* Aircraft get the public Flights treatment in magenta: a class from the
|
||
* emitter category or adsbdb type (`aircraftClass.js`), the class silhouette
|
||
* and scale, the Flights 3D models under the DISPLAY rail's 3D toggle, smooth
|
||
* real-time motion (`./motion.js`, no display delay) and, while selected, a
|
||
* trail of the positions the receiver heard.
|
||
*
|
||
* Markers are never followed by the camera. A marker disappears once its
|
||
* position is older than 60 s; a record without any message for 60 s is gone.
|
||
* @param {object} options
|
||
* @param {object} options.receiver Local receiver session.
|
||
* @param {object} [options.feeds] Decoder-feed session (start, stop,
|
||
* getState, subscribe).
|
||
* @param {object} options.services Render, context, picking, selection
|
||
* (`selectionDispatcherFor`), renderer (`create({ viewer, engine,
|
||
* loadModel, createTrail })`: the engine's Local ADS-B renderer),
|
||
* detection and readout operations supplied by the application;
|
||
* optionally `display` (`getParams()` → `{ models3d, models3dMode }`),
|
||
* `enrichment` (a source with `getEnrichment`), `trails` (`createTrail`),
|
||
* `geoid` (`ensureGeoidReady`, `geoidHeight`) and `groundSnap`
|
||
* (`createGroundSnap`).
|
||
* @param {() => number} [options.now] Clock, injectable for tests.
|
||
* @param {(url: string) => string} [options.resolveAsset] Model URL resolver.
|
||
* @param {(options: object) => Promise<object>} [options.loadModel] Model
|
||
* loader, injectable for tests.
|
||
* @returns {object} Data-layer module.
|
||
*/
|
||
export function createLocalAdsbLayer({
|
||
receiver,
|
||
feeds = null,
|
||
services,
|
||
now = Date.now,
|
||
resolveAsset = (url) => url,
|
||
loadModel,
|
||
}) {
|
||
if (typeof receiver?.getState !== 'function')
|
||
throw new TypeError('Local ADS-B requires a receiver session');
|
||
const {
|
||
governorRequestRender,
|
||
holdContinuousRender = () => {},
|
||
releaseContinuousRender = () => {},
|
||
} = services.render;
|
||
const {
|
||
registerEntityContext,
|
||
selectEntityContext,
|
||
clearSelectedEntityContextForLayer,
|
||
getSelectedEntityContext,
|
||
removeEntityContextsForLayer,
|
||
} = services.context;
|
||
const { registerPickOwner, unregisterPickOwner } = services.picking;
|
||
|
||
/** The engine's Local ADS-B renderer for this lifecycle (render/*.js). */
|
||
let renderer = null;
|
||
let enabled = false;
|
||
let unsubscribe = null;
|
||
let unsubscribeFeeds = null;
|
||
let tickTimer = null;
|
||
let syncTimer = null;
|
||
let selectionRemove = null;
|
||
let removePreRender = null;
|
||
let renderHeld = false;
|
||
let selectedId = null;
|
||
let selectedTrail = null;
|
||
let trailHeadSeq = 0;
|
||
let models = null;
|
||
let geoidReady = false;
|
||
let rejectedFixes = 0;
|
||
let state = receiver.getState();
|
||
const markers = new Map();
|
||
// Per-ICAO receptions (band|source -> last message) across merges.
|
||
const heardBy = new Map();
|
||
const enrichment = createLocalAdsbEnrichment({
|
||
source: services.enrichment || null,
|
||
onChange: () => {
|
||
for (const marker of markers.values()) refreshClass(marker);
|
||
refreshSelectedCard();
|
||
governorRequestRender('local-adsb-enrichment');
|
||
},
|
||
});
|
||
|
||
function displayPreferences() {
|
||
return services.display?.getParams?.() || DEFAULT_DISPLAY;
|
||
}
|
||
|
||
function markSourcesChanged(reason) {
|
||
services.detection?.markSourcesChanged?.(reason);
|
||
}
|
||
|
||
function mergedRecords(at = now(), receiverState = state) {
|
||
return mergeLocalAdsbRecords(
|
||
[receiverState.aircraft || [], feeds?.getState?.()?.records || []],
|
||
at,
|
||
heardBy,
|
||
);
|
||
}
|
||
|
||
function freshRecords(at = now()) {
|
||
return mergedRecords(at).filter((record) =>
|
||
localAdsbPositionIsFresh(record, at),
|
||
);
|
||
}
|
||
|
||
function markerVisible(marker) {
|
||
if (!renderer?.view.camera?.positionWC || !marker.position) return true;
|
||
return renderer.view.horizon().isPointVisible(marker.position);
|
||
}
|
||
|
||
function markerRotation(marker) {
|
||
if (!renderer?.view.scene || !marker.position) return marker.lastRotation;
|
||
const projected = screenProjectedRotation(
|
||
renderer.view.scene,
|
||
marker.position,
|
||
marker.courseDeg ?? 0,
|
||
marker.lastRotation,
|
||
);
|
||
const stable = stabilizeScreenRotation(marker.lastRotation, projected);
|
||
if (stable !== null) marker.lastRotation = stable;
|
||
return marker.lastRotation;
|
||
}
|
||
|
||
function geoidN(lat, lon) {
|
||
if (!geoidReady || typeof services.geoid?.geoidHeight !== 'function')
|
||
return null;
|
||
try {
|
||
const value = services.geoid.geoidHeight(lat, lon);
|
||
return Number.isFinite(value) ? value : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Barometric feet → ellipsoidal render metres, as the Flights layer does
|
||
* for a contact without a geometric altitude: baro + geoid N. */
|
||
function renderHeightM(marker, altitudeFt, lat, lon) {
|
||
if (marker.geoidN === null) marker.geoidN = geoidN(lat, lon);
|
||
const height = pickRenderAltitudeM({
|
||
geoAltM: null,
|
||
baroAltM: Number.isFinite(altitudeFt)
|
||
? altitudeFt * FEET_TO_METERS
|
||
: null,
|
||
onGround: false,
|
||
surfaceM: null,
|
||
geoidN: marker.geoidN,
|
||
});
|
||
return Number.isFinite(height) ? height : (marker.geoidN ?? 0);
|
||
}
|
||
|
||
function fixPosition(marker, fix) {
|
||
return geo.cartesian3.fromDegrees(
|
||
fix.lon,
|
||
fix.lat,
|
||
renderHeightM(marker, fix.altitudeFt, fix.lat, fix.lon),
|
||
);
|
||
}
|
||
|
||
function updateDisplay(marker, at) {
|
||
const display = marker.motion.displayAt(at);
|
||
if (!display) return;
|
||
marker.lat = display.lat;
|
||
marker.lon = display.lon;
|
||
marker.position = geo.cartesian3.fromDegrees(
|
||
display.lon,
|
||
display.lat,
|
||
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
|
||
geo.ellipsoid.WGS84,
|
||
marker.position || geo.cartesian3.create(),
|
||
);
|
||
if (display.courseDeg !== null) marker.courseDeg = display.courseDeg;
|
||
}
|
||
|
||
function billboardScale(marker) {
|
||
return (
|
||
(CLASS_SCALE_2D[marker.klass] || 1) * (marker.onGround ? GROUND_SCALE : 1)
|
||
);
|
||
}
|
||
|
||
/** Class silhouette and per-class scale (×0.8 on the ground), magenta. */
|
||
function refreshClass(marker) {
|
||
const { klass, evidence } = localAdsbClass(
|
||
marker.record,
|
||
enrichment.get(marker.record.icao),
|
||
);
|
||
marker.evidence = evidence;
|
||
marker.klass = klass;
|
||
if (!marker.entity || !renderer) return;
|
||
if (marker.iconKind !== klass) {
|
||
marker.iconKind = klass;
|
||
renderer.markers.setImage(marker.entity, aircraftIcon(klass));
|
||
}
|
||
const scale = billboardScale(marker);
|
||
if (marker.iconScale !== scale) {
|
||
marker.iconScale = scale;
|
||
renderer.markers.setScale(marker.entity, scale);
|
||
}
|
||
}
|
||
|
||
function plausibleRoute(marker, meta) {
|
||
const route = meta?.route;
|
||
if (!route || route.callsign !== marker.record.callsign) return null;
|
||
const record = marker.record;
|
||
const ok = routePlausible({
|
||
latDeg: marker.lat ?? record.lat,
|
||
lonDeg: marker.lon ?? record.lon,
|
||
altitudeM: Number.isFinite(record.altitudeFt)
|
||
? record.altitudeFt * FEET_TO_METERS
|
||
: null,
|
||
verticalRateMps: Number.isFinite(record.verticalRateFpm)
|
||
? (record.verticalRateFpm * FEET_TO_METERS) / 60
|
||
: null,
|
||
origin: route.origin,
|
||
destination: route.destination,
|
||
});
|
||
return ok ? route : null;
|
||
}
|
||
|
||
function cardModel(marker, at) {
|
||
const meta = enrichment.get(marker.record.icao);
|
||
return localAdsbCardModel(marker.record, at, {
|
||
aircraftClass: { klass: marker.klass, evidence: marker.evidence },
|
||
meta,
|
||
route: plausibleRoute(marker, meta),
|
||
});
|
||
}
|
||
|
||
function contextMetadata(id, marker) {
|
||
const record = marker.record;
|
||
const meta = enrichment.get(record.icao);
|
||
return {
|
||
id,
|
||
layerId: LAYER_ID,
|
||
dataSource: renderer?.dataSource ?? null,
|
||
layerName: LAYER_NAME,
|
||
source: localAdsbSourceText(record),
|
||
label: localAdsbTitle(record),
|
||
latitude: marker.lat ?? record.lat,
|
||
longitude: marker.lon ?? record.lon,
|
||
properties: {
|
||
icao: record.icao,
|
||
callsign: record.callsign,
|
||
category: record.category ?? null,
|
||
aircraftClass: marker.evidence ? marker.klass : null,
|
||
typeCode: meta?.typeCode ?? null,
|
||
altitudeFt: record.altitudeFt,
|
||
groundSpeedKt: record.groundSpeedKt,
|
||
trackDeg: record.trackDeg,
|
||
verticalRateFpm: record.verticalRateFpm,
|
||
lastPositionAt: record.lastPositionAt,
|
||
messageCount: record.messageCount,
|
||
receiverSource: record.source,
|
||
bands: record.bands,
|
||
sources: record.sources,
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── Trail of the selected aircraft ──────────────────────────────────────
|
||
|
||
function releaseTrail() {
|
||
if (!selectedTrail) return;
|
||
selectedTrail.trail?.destroy();
|
||
if (selectedTrail.head) renderer?.trails.removeHead(selectedTrail.head);
|
||
selectedTrail = null;
|
||
}
|
||
|
||
function refreshTrail() {
|
||
const marker = selectedId ? markers.get(selectedId) : null;
|
||
if (!marker || !selectedTrail?.trail) return;
|
||
const positions = marker.motion.fixes.map((fix) =>
|
||
fixPosition(marker, fix),
|
||
);
|
||
selectedTrail.lastFix = positions.at(-1) || null;
|
||
selectedTrail.trail.setPositions(positions);
|
||
}
|
||
|
||
function startTrail(marker) {
|
||
releaseTrail();
|
||
const trail = renderer?.trails.create({
|
||
color: LOCAL_ADSB_COLOR,
|
||
width: 2.5,
|
||
});
|
||
if (!trail) return;
|
||
selectedTrail = {
|
||
id: marker.id,
|
||
trail,
|
||
head: null,
|
||
lastFix: null,
|
||
};
|
||
// Live head: last heard fix → the extrapolated marker, every frame.
|
||
selectedTrail.head = renderer.trails.createHead({
|
||
// 'gev-trail' namespace: claimed by the trail pick owner so a click on
|
||
// the head segment never reads as empty space.
|
||
id: `gev-trail:local-adsb-head-${++trailHeadSeq}`,
|
||
positions: () => {
|
||
const current = markers.get(selectedTrail?.id);
|
||
const from = selectedTrail?.lastFix;
|
||
if (!current?.position || !from) return [];
|
||
return [from, geo.cartesian3.clone(current.position)];
|
||
},
|
||
color: LOCAL_ADSB_COLOR,
|
||
width: 2.5,
|
||
});
|
||
refreshTrail();
|
||
}
|
||
|
||
// ── Markers ─────────────────────────────────────────────────────────────
|
||
|
||
function upsertMarker(record, at) {
|
||
const id = `${ENTITY_PREFIX}${record.icao}`;
|
||
let marker = markers.get(id);
|
||
if (!marker) {
|
||
marker = {
|
||
id,
|
||
entity: null,
|
||
record,
|
||
motion: new LocalAdsbMotion(),
|
||
position: null,
|
||
lat: null,
|
||
lon: null,
|
||
courseDeg: Number.isFinite(record.trackDeg) ? record.trackDeg : null,
|
||
lastRotation: 0,
|
||
klass: null,
|
||
evidence: false,
|
||
iconKind: null,
|
||
iconScale: null,
|
||
onGround: Boolean(record.onGround),
|
||
geoidN: null,
|
||
modelOwnsVisual: false,
|
||
};
|
||
markers.set(id, marker);
|
||
}
|
||
marker.record = record;
|
||
marker.onGround = Boolean(record.onGround);
|
||
const rejectedBefore = marker.motion.rejectedFixes;
|
||
const newFix = marker.motion.observe(record, at);
|
||
rejectedFixes += marker.motion.rejectedFixes - rejectedBefore;
|
||
updateDisplay(marker, at);
|
||
if (!marker.entity) {
|
||
// Magenta glyph, 20 px, ×3 near / ×0.5 far, always on top, not drawn
|
||
// past 2,000 km (the renderer's display distance).
|
||
marker.entity = renderer.markers.add({
|
||
id,
|
||
image: aircraftIcon('airliner'),
|
||
position: () => marker.position,
|
||
rotation: () => markerRotation(marker),
|
||
show: () => !marker.modelOwnsVisual && markerVisible(marker),
|
||
});
|
||
marker.entity.gevTrackedId = id;
|
||
marker.entity.gevDisplayPosition = () => marker.position;
|
||
}
|
||
refreshClass(marker);
|
||
const uat = localAdsbIsUat(record);
|
||
if (uat !== renderer.markers.hasRing(marker.entity))
|
||
renderer.markers.setRing(marker.entity, uat, () => markerVisible(marker));
|
||
marker.entity.gevLabelModel = cardModel(marker, at);
|
||
registerEntityContext(marker.entity, contextMetadata(id, marker));
|
||
if (newFix && id === selectedId) refreshTrail();
|
||
return id;
|
||
}
|
||
|
||
function clearSelection({ evicted = false } = {}) {
|
||
if (!selectedId) return;
|
||
selectedId = null;
|
||
releaseTrail();
|
||
clearSelectedEntityContextForLayer(LAYER_ID, { evicted });
|
||
}
|
||
|
||
function refreshSelectedCard() {
|
||
const marker = selectedId ? markers.get(selectedId) : null;
|
||
if (!marker) return;
|
||
marker.entity.gevLabelModel = cardModel(marker, now());
|
||
services.overlays?.refreshReadout?.(marker.entity);
|
||
}
|
||
|
||
function updateRenderHold() {
|
||
const want = enabled && markers.size > 0;
|
||
if (want === renderHeld) return;
|
||
renderHeld = want;
|
||
if (want) holdContinuousRender(RENDER_HOLD_ID);
|
||
else releaseContinuousRender(RENDER_HOLD_ID);
|
||
}
|
||
|
||
function requestEnrichment() {
|
||
const selected = selectedId ? markers.get(selectedId) : null;
|
||
if (selected) enrichment.request(selected.record, { selected: true });
|
||
for (const id of models?.eligible || []) {
|
||
const marker = markers.get(id);
|
||
if (marker) enrichment.request(marker.record);
|
||
}
|
||
}
|
||
|
||
function sync() {
|
||
clearTimeout(syncTimer);
|
||
syncTimer = null;
|
||
if (!renderer) return;
|
||
const at = now();
|
||
const live = new Set();
|
||
let added = false;
|
||
if (enabled) {
|
||
for (const record of freshRecords(at)) {
|
||
const id = `${ENTITY_PREFIX}${record.icao}`;
|
||
if (!markers.has(id)) added = true;
|
||
live.add(upsertMarker(record, at));
|
||
}
|
||
}
|
||
let removed = false;
|
||
for (const [id, marker] of markers) {
|
||
if (live.has(id)) continue;
|
||
if (marker.entity) renderer.markers.remove(marker.entity);
|
||
markers.delete(id);
|
||
removed = true;
|
||
}
|
||
// Expired aircraft take their 3D models (and pending loads) with them.
|
||
if (removed) models?.retain(markers);
|
||
if (selectedId && !live.has(selectedId))
|
||
clearSelection({ evicted: enabled });
|
||
removeEntityContextsForLayer(LAYER_ID, { retainIds: live });
|
||
if (selectedId) {
|
||
const selected = markers.get(selectedId);
|
||
if (getSelectedEntityContext()?.id !== selectedId) {
|
||
selectedId = null;
|
||
releaseTrail();
|
||
} else services.overlays?.refreshReadout?.(selected.entity);
|
||
}
|
||
requestEnrichment();
|
||
updateRenderHold();
|
||
if (live.size || removed) governorRequestRender('local-adsb-update');
|
||
// Detection re-solves labels only when the set of contacts changes, not
|
||
// on every position report.
|
||
if (added || removed) markSourcesChanged('local-adsb-update');
|
||
}
|
||
|
||
/** Per-frame pass: smooth positions and courses, then 3D models. */
|
||
function frame() {
|
||
if (!enabled || !markers.size) return;
|
||
const at = now();
|
||
for (const marker of markers.values()) updateDisplay(marker, at);
|
||
models?.frame(markers, displayPreferences(), at);
|
||
}
|
||
|
||
function scheduleSync(nextState) {
|
||
state = nextState;
|
||
requestSync();
|
||
}
|
||
|
||
function requestSync() {
|
||
if (!enabled || syncTimer) return;
|
||
syncTimer = setTimeout(sync, LOCAL_ADSB_SYNC_MS);
|
||
}
|
||
|
||
function selectMarker(id) {
|
||
const marker = markers.get(id);
|
||
if (!marker) return false;
|
||
const reselect = id === selectedId && selectedTrail?.id === id;
|
||
selectedId = id;
|
||
enrichment.request(marker.record, { selected: true });
|
||
marker.entity.gevLabelModel = cardModel(marker, now());
|
||
selectEntityContext(marker.entity);
|
||
if (!reselect) startTrail(marker);
|
||
governorRequestRender('local-adsb-selection');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Join the engine's selection dispatcher with this layer's own click rule
|
||
* (`lookUnder`): a click on a local aircraft — on top, or under picks no
|
||
* layer owns (a photoreal tile, a ring) and trail lines — always
|
||
* (re)selects it and republishes its card, including the one already
|
||
* selected (its card may have been displaced; a click on a marker never
|
||
* reads as "deselect"); another layer's contact on top, or empty map,
|
||
* releases only this layer's selection, leaving a sibling layer's new
|
||
* selection intact; a click on a trail line alone does nothing. The
|
||
* dispatcher picks with a drill of 8 and yields to a tool that owns the
|
||
* pointer (src/data/inputOwnership.js).
|
||
*/
|
||
function installInteraction() {
|
||
if (selectionRemove || !renderer) return;
|
||
const dispatcher = services.selection?.selectionDispatcherFor?.(
|
||
renderer.engine,
|
||
);
|
||
if (!dispatcher) return;
|
||
selectionRemove = dispatcher.register(LAYER_ID, {
|
||
owns: (id) => markers.has(id),
|
||
lookUnder: (id) => String(id).startsWith(TRAIL_PICK_PREFIX),
|
||
onSelect(id) {
|
||
if (enabled && markers.has(id)) selectMarker(id);
|
||
},
|
||
onEmptyClick() {
|
||
if (!enabled || !selectedId) return;
|
||
clearSelection();
|
||
governorRequestRender('local-adsb-selection');
|
||
},
|
||
});
|
||
}
|
||
|
||
function releaseInteraction() {
|
||
selectionRemove?.();
|
||
selectionRemove = null;
|
||
}
|
||
|
||
function installFrame() {
|
||
if (removePreRender || !renderer) return;
|
||
removePreRender = renderer.view.on('preRender', frame);
|
||
}
|
||
|
||
function releaseFrame() {
|
||
removePreRender?.();
|
||
removePreRender = null;
|
||
}
|
||
|
||
function positionRows(maxCount = 500) {
|
||
if (!enabled) return [];
|
||
const rows = [];
|
||
for (const marker of markers.values()) {
|
||
if (rows.length >= maxCount) break;
|
||
if (!marker.position) continue;
|
||
const carto = geo.cartographic.fromCartesian(marker.position);
|
||
rows.push({
|
||
id: marker.record.icao,
|
||
label: localAdsbTitle(marker.record),
|
||
callsign: marker.record.callsign,
|
||
position: geo.cartesian3.clone(marker.position),
|
||
latitude: marker.lat,
|
||
longitude: marker.lon,
|
||
altitudeM: carto?.height ?? 0,
|
||
});
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
return {
|
||
id: LAYER_ID,
|
||
name: LAYER_NAME,
|
||
icon: '📡',
|
||
source: LAYER_SOURCE,
|
||
updateInterval: 0,
|
||
statsRefreshInterval: LOCAL_ADSB_TICK_MS,
|
||
/** The shared receiver session, also driven by the Radio panel card. */
|
||
receiver,
|
||
/** Decoder-feed session; the Radio card shows its one-line summary. */
|
||
feeds,
|
||
|
||
/**
|
||
* @param {object|null} nextViewer The Cesium viewer (null on an engine
|
||
* without one).
|
||
* @param {{ engine?: object }} [context] LayerLifecycle's context.
|
||
*/
|
||
init(nextViewer, { engine = null } = {}) {
|
||
renderer = services.renderer.create({
|
||
viewer: nextViewer,
|
||
engine,
|
||
loadModel,
|
||
createTrail: services.trails?.createTrail ?? null,
|
||
});
|
||
renderer.attach();
|
||
if (renderer.models) {
|
||
models = createLocalAdsbModels({
|
||
renderer,
|
||
color: MODEL_COLOR,
|
||
resolveAsset,
|
||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||
});
|
||
}
|
||
services.geoid
|
||
?.ensureGeoidReady?.()
|
||
?.then(() => {
|
||
geoidReady = true;
|
||
for (const marker of markers.values()) marker.geoidN = null;
|
||
})
|
||
?.catch(() => {
|
||
/* the baro path stays un-geoid-corrected */
|
||
});
|
||
unsubscribe = receiver.subscribe?.(scheduleSync) || null;
|
||
unsubscribeFeeds = feeds?.subscribe?.(requestSync) || null;
|
||
return true;
|
||
},
|
||
|
||
async enable() {
|
||
enabled = true;
|
||
renderer?.setVisible(true);
|
||
registerPickOwner(LAYER_ID, (pickedValue) => markers.has(pickedValue));
|
||
installInteraction();
|
||
installFrame();
|
||
clearInterval(tickTimer);
|
||
tickTimer = setInterval(sync, LOCAL_ADSB_TICK_MS);
|
||
feeds?.start?.();
|
||
// The layer asks the shared tuner for 1090 MHz. Disabling it leaves the
|
||
// receiver in whatever mode the Radio card shows, so turning the layer
|
||
// off never starts FM audio on its own.
|
||
if (receiver.getState().mode !== 'adsb') await receiver.setMode('adsb');
|
||
state = receiver.getState();
|
||
sync();
|
||
return true;
|
||
},
|
||
|
||
async disable() {
|
||
enabled = false;
|
||
feeds?.stop?.();
|
||
clearInterval(tickTimer);
|
||
tickTimer = null;
|
||
releaseInteraction();
|
||
releaseFrame();
|
||
unregisterPickOwner(LAYER_ID);
|
||
sync();
|
||
models?.clear();
|
||
renderer?.setVisible(false);
|
||
markSourcesChanged('local-adsb-disabled');
|
||
return true;
|
||
},
|
||
|
||
async update() {
|
||
state = receiver.getState();
|
||
sync();
|
||
frame();
|
||
return true;
|
||
},
|
||
|
||
destroy() {
|
||
enabled = false;
|
||
clearInterval(tickTimer);
|
||
clearTimeout(syncTimer);
|
||
tickTimer = null;
|
||
syncTimer = null;
|
||
releaseInteraction();
|
||
releaseFrame();
|
||
unregisterPickOwner(LAYER_ID);
|
||
unsubscribe?.();
|
||
unsubscribe = null;
|
||
unsubscribeFeeds?.();
|
||
unsubscribeFeeds = null;
|
||
feeds?.destroy?.();
|
||
enrichment.destroy();
|
||
clearSelection();
|
||
removeEntityContextsForLayer(LAYER_ID);
|
||
markers.clear();
|
||
updateRenderHold();
|
||
models?.destroy();
|
||
models = null;
|
||
renderer?.detach();
|
||
renderer?.destroy();
|
||
renderer = null;
|
||
void receiver.destroy?.();
|
||
},
|
||
|
||
...(import.meta.env?.DEV
|
||
? {
|
||
qaDrawn() {
|
||
return localAdsbQaDrawn(markers, models?.qaModels?.(), {
|
||
enabled,
|
||
cameraPosition: renderer?.view.camera?.positionWC,
|
||
isVisible: markerVisible,
|
||
});
|
||
},
|
||
}
|
||
: {}),
|
||
|
||
getStats() {
|
||
const current = receiver.getState();
|
||
const at = now();
|
||
const records = mergedRecords(at, current);
|
||
const { heard, positioned } = summarizeLocalAdsb(records, at);
|
||
const lastUpdate =
|
||
records.reduce(
|
||
(latest, record) => Math.max(latest, record.lastMessageAt || 0),
|
||
0,
|
||
) || null;
|
||
return {
|
||
count: positioned,
|
||
lastUpdate,
|
||
source: LAYER_SOURCE,
|
||
// Fixes refused by the speed check: the browser decoder's own count
|
||
// plus the layer's check on every merged record.
|
||
rejectedPositions:
|
||
Math.max(0, Number(current.positionsRejected) || 0) + rejectedFixes,
|
||
...localAdsbStatus({
|
||
receiver: current,
|
||
feedState: feeds?.getState?.() || null,
|
||
heard,
|
||
}),
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Select a heard aircraft's marker and publish its readout card, exactly
|
||
* as a click does. The camera does not follow it.
|
||
* @param {string} icao Lowercase ICAO hex.
|
||
* @returns {boolean} Whether a fresh marker was selected.
|
||
*/
|
||
selectAircraft(icao) {
|
||
return enabled && selectMarker(`${ENTITY_PREFIX}${icao}`);
|
||
},
|
||
|
||
/**
|
||
* Snapshot loaded locally received aircraft as plain JSON-safe records for the analyst
|
||
* query engine. On demand only (once per spoken query); [] while the
|
||
* layer is off.
|
||
* @param {number} [maxCount=Infinity] Most records to return.
|
||
* @returns {Array<Object>} {id, icao24, callsign, lat, lon, altitudeM, speedKts, heading, onGround}.
|
||
*/
|
||
getAnalystRecords(maxCount = Infinity) {
|
||
if (!enabled) return [];
|
||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||
const result = [];
|
||
for (const marker of markers.values()) {
|
||
if (result.length >= maxCount) break;
|
||
const record = marker.record || {};
|
||
const lat = marker.lat ?? record.lat;
|
||
const lon = marker.lon ?? record.lon;
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||
result.push({
|
||
id: record.callsign || record.icao,
|
||
icao24: record.icao || null,
|
||
callsign: record.callsign || null,
|
||
lat,
|
||
lon,
|
||
altitudeM: Number.isFinite(record.altitudeFt)
|
||
? Math.round(record.altitudeFt * 0.3048)
|
||
: null,
|
||
speedKts: num(record.groundSpeedKt),
|
||
heading: num(record.trackDeg),
|
||
onGround: record.onGround === true,
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
getAllPositions(maxCount = 500) {
|
||
return positionRows(maxCount);
|
||
},
|
||
|
||
getDetectableObjects(options = {}) {
|
||
return positionRows(options.maxCount).map((row) => ({
|
||
position: row.position,
|
||
sourceId: row.id,
|
||
// Label only a decoded callsign; never substitute the raw ICAO hex.
|
||
id: row.callsign || '',
|
||
type: 'AIR',
|
||
skipLabel: false,
|
||
}));
|
||
},
|
||
};
|
||
}
|