mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
feat(sdr): local receivers — browser RTL-SDR, Local ADS-B layer and decoder feeds (#732)
Adds a Local ADS-B layer fed by your own receivers: a browser WebUSB RTL-SDR (with FM listening) and local 1090/978 MHz decoder feeds (LOCAL_RECEIVER_FEEDS, local and private addresses only). Local aircraft draw in magenta with class, silhouettes, 3D models, trails and a position sanity filter; the click card names the band and input that heard them. Builds on Sameh Khamis's SDR prototype and the initial GEV port by Mazeyar Moeini Feizabadi (#134); the decoder-feed design follows the notes on #57. Closes #57. Co-authored-by: Sameh Khamis <[email protected]> Co-authored-by: Mazeyar Moeini Feizabadi <[email protected]>
This commit is contained in:
78 files changed
+12267
-108
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import { AIRCRAFT_CLASS_LABELS } from '../../data/aircraftClass.js';
|
||||
import {
|
||||
BAND_LABELS,
|
||||
HEARD_BY_RECEIVER,
|
||||
LOCAL_ADSB_COLOR,
|
||||
SOURCE_LABELS,
|
||||
} from './policy.js';
|
||||
|
||||
const DASH = '—';
|
||||
|
||||
function rounded(value, digits = 0) {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return Number(value.toFixed(digits));
|
||||
}
|
||||
|
||||
function grouped(value) {
|
||||
return value === null ? DASH : value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/**
|
||||
* Title of a locally heard aircraft: callsign when decoded, else ICAO hex.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbTitle(record) {
|
||||
return record?.callsign || String(record?.icao || '').toUpperCase();
|
||||
}
|
||||
|
||||
function recordBands(record) {
|
||||
if (Array.isArray(record?.bands) && record.bands.length) return record.bands;
|
||||
return record?.band ? [record.band] : [];
|
||||
}
|
||||
|
||||
function recordSources(record) {
|
||||
if (Array.isArray(record?.sources) && record.sources.length)
|
||||
return record.sources;
|
||||
return record?.source ? [record.source] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an aircraft was heard only on 978 MHz UAT (its marker gets the
|
||||
* UAT ring). One also heard on 1090 MHz draws as a 1090 aircraft.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function localAdsbIsUat(record) {
|
||||
const bands = recordBands(record);
|
||||
return bands.includes('978') && !bands.includes('1090');
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiver line of the click card, e.g. "Heard by your receiver · 1090 MHz ·
|
||||
* browser SDR" or "… · 1090 MHz + 978 MHz UAT · browser SDR + decoder feed".
|
||||
* @param {object} record Local ADS-B record (merged: `bands`, `sources`).
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbReceiverLine(record) {
|
||||
const bands = recordBands(record)
|
||||
.map((band) => BAND_LABELS[band])
|
||||
.filter(Boolean);
|
||||
const sources = recordSources(record)
|
||||
.map((source) => SOURCE_LABELS[source])
|
||||
.filter(Boolean);
|
||||
return [HEARD_BY_RECEIVER, bands.join(' + '), sources.join(' + ')]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a heard aircraft came from, for the entity context's source field.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbSourceText(record) {
|
||||
const sources = recordSources(record);
|
||||
if (sources.includes('webusb') && sources.includes('feed'))
|
||||
return 'Your RTL-SDR receiver and decoder feed';
|
||||
if (sources.includes('feed')) return 'Your decoder feed';
|
||||
return 'Your RTL-SDR receiver';
|
||||
}
|
||||
|
||||
/**
|
||||
* Class line of the click card, e.g. "Helicopter · A7". Empty when neither an
|
||||
* ICAO type nor an emitter category backs the class.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {{klass?:string, evidence?:boolean}} [aircraftClass]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localAdsbClassLine(record, aircraftClass = {}) {
|
||||
if (!aircraftClass.evidence) return '';
|
||||
const label = AIRCRAFT_CLASS_LABELS[aircraftClass.klass];
|
||||
return [label, record?.category].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Click-card presentation for one locally heard aircraft.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {number} nowMs Current epoch ms, for the position age.
|
||||
* @param {object} [extras]
|
||||
* @param {{klass:string, evidence:boolean}} [extras.aircraftClass] Class and
|
||||
* whether a type or category backs it.
|
||||
* @param {object|null} [extras.meta] adsbdb metadata (typeCode, typeName,
|
||||
* airline).
|
||||
* @param {object|null} [extras.route] A route already judged plausible.
|
||||
* @returns {{title:string, details:string[], accent:string}}
|
||||
*/
|
||||
export function localAdsbCardModel(
|
||||
record,
|
||||
nowMs,
|
||||
{ aircraftClass = null, meta = null, route = null } = {},
|
||||
) {
|
||||
const icao = String(record?.icao || '').toUpperCase();
|
||||
const altitude = rounded(record?.altitudeFt);
|
||||
const speed = rounded(record?.groundSpeedKt);
|
||||
const track = rounded(record?.trackDeg);
|
||||
const vertical = rounded(record?.verticalRateFpm);
|
||||
const ageS = Number.isFinite(record?.lastPositionAt)
|
||||
? Math.max(0, Math.round((nowMs - record.lastPositionAt) / 1000))
|
||||
: null;
|
||||
const messages = Math.max(0, Math.trunc(Number(record?.messageCount) || 0));
|
||||
// Same identity and route lines the public flight card shows.
|
||||
const identity = [meta?.airline, meta?.typeName || meta?.typeCode]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
const routeLine =
|
||||
route?.origin?.code && route?.destination?.code
|
||||
? `${route.origin.code} → ${route.destination.code}`
|
||||
: '';
|
||||
return {
|
||||
title: localAdsbTitle(record),
|
||||
details: [
|
||||
`ICAO ${icao} · ${record?.callsign || 'NO CALLSIGN'}`,
|
||||
localAdsbClassLine(record, aircraftClass || {}),
|
||||
identity,
|
||||
routeLine,
|
||||
`ALT ${grouped(altitude)} FT · GS ${speed === null ? DASH : speed} KT · TRK ${track === null ? DASH : `${track}°`}`,
|
||||
`V/S ${vertical === null ? DASH : `${vertical > 0 ? '+' : ''}${grouped(vertical)} FPM`}`,
|
||||
`POSITION ${ageS === null ? DASH : `${ageS} S AGO`} · ${messages} ${messages === 1 ? 'MSG' : 'MSGS'}`,
|
||||
localAdsbReceiverLine(record),
|
||||
].filter(Boolean),
|
||||
accent: LOCAL_ADSB_COLOR,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { classifyAircraft } from '../../data/aircraftClass.js';
|
||||
import { stickyText } from '../../data/aircraftMeta.js';
|
||||
import {
|
||||
ENRICH_DISPATCH_GAP_MS,
|
||||
ENRICH_MAX_INFLIGHT,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
const ICAO = /^[0-9a-f]{6}$/i;
|
||||
/** Airline-style callsigns only (LLL + digit); GA tails do not resolve. */
|
||||
const AIRLINE_CALLSIGN = /^[A-Z]{3}\d/;
|
||||
/** Bound on the per-session metadata map. */
|
||||
const META_LIMIT = 2_000;
|
||||
|
||||
/**
|
||||
* Class of a locally heard aircraft: the adsbdb ICAO type when known, else
|
||||
* the emitter category it broadcasts. `evidence` is false when neither is
|
||||
* known, so the card never presents the classifier's default as fact.
|
||||
* @param {{category?:string|null}} record
|
||||
* @param {{typeCode?:string|null}} [meta]
|
||||
* @returns {{klass:string, evidence:boolean}}
|
||||
*/
|
||||
export function localAdsbClass(record, meta = null) {
|
||||
const typeCode = meta?.typeCode || null;
|
||||
const category = record?.category || null;
|
||||
return {
|
||||
klass: classifyAircraft({ typeCode, category }),
|
||||
evidence: Boolean(typeCode) || /^(?:A[1-7]|B1)$/.test(category || ''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* adsbdb enrichment for Local ADS-B through the same cached proxy and the same
|
||||
* rules as the public Flights layer: each key is asked for at most once per
|
||||
* session, at most four requests are in flight and dispatches are dripped
|
||||
* 200 ms apart. Only the selected aircraft (type + route) and aircraft that
|
||||
* are about to render as 3D models (type) are ever looked up — never one
|
||||
* request per contact per tick.
|
||||
* @param {object} options
|
||||
* @param {{getEnrichment?: Function}|null} options.source Flights source.
|
||||
* @param {() => void} options.onChange Called when metadata arrives.
|
||||
* @param {() => number} [options.now]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLocalAdsbEnrichment({
|
||||
source,
|
||||
onChange,
|
||||
now = Date.now,
|
||||
}) {
|
||||
const meta = new Map();
|
||||
const seen = new Set();
|
||||
const queue = [];
|
||||
let active = 0;
|
||||
let lastDispatchAt = -Infinity;
|
||||
let dripTimer = null;
|
||||
let lifetime = new AbortController();
|
||||
|
||||
function entry(icao) {
|
||||
let value = meta.get(icao);
|
||||
if (!value) {
|
||||
value = {};
|
||||
meta.set(icao, value);
|
||||
if (meta.size > META_LIMIT) meta.delete(meta.keys().next().value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function enqueue(key, query, apply, priority) {
|
||||
if (typeof source?.getEnrichment !== 'function' || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const job = { query, apply };
|
||||
if (priority) queue.unshift(job);
|
||||
else queue.push(job);
|
||||
drain();
|
||||
}
|
||||
|
||||
function drain() {
|
||||
const signal = lifetime.signal;
|
||||
while (!signal.aborted && active < ENRICH_MAX_INFLIGHT && queue.length) {
|
||||
const wait = ENRICH_DISPATCH_GAP_MS - (now() - lastDispatchAt);
|
||||
if (wait > 0) {
|
||||
if (!dripTimer)
|
||||
dripTimer = setTimeout(() => {
|
||||
dripTimer = null;
|
||||
drain();
|
||||
}, wait);
|
||||
return;
|
||||
}
|
||||
lastDispatchAt = now();
|
||||
const job = queue.shift();
|
||||
active += 1;
|
||||
Promise.resolve()
|
||||
.then(() => source.getEnrichment(job.query, { signal }))
|
||||
.then((data) => {
|
||||
if (!signal.aborted && data?.found) {
|
||||
job.apply(data);
|
||||
onChange?.();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* enrichment never surfaces errors */
|
||||
})
|
||||
.finally(() => {
|
||||
if (signal.aborted) return;
|
||||
active -= 1;
|
||||
drain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/** @param {string} icao @returns {object|null} */
|
||||
get(icao) {
|
||||
return meta.get(String(icao || '').toLowerCase()) || null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask for the aircraft's type (and, when selected, its route).
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {{selected?: boolean}} [options]
|
||||
*/
|
||||
request(record, { selected = false } = {}) {
|
||||
const icao = String(record?.icao || '').toLowerCase();
|
||||
if (!ICAO.test(icao)) return;
|
||||
enqueue(
|
||||
`t:${icao}`,
|
||||
{ kind: 'type', id: icao },
|
||||
(data) => {
|
||||
const value = entry(icao);
|
||||
value.typeCode = stickyText(data.typeCode, value.typeCode) || null;
|
||||
value.typeName = stickyText(data.typeName, value.typeName) || null;
|
||||
value.registration =
|
||||
stickyText(data.registration, value.registration) || null;
|
||||
},
|
||||
selected,
|
||||
);
|
||||
const callsign = String(record.callsign || '')
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!selected || !AIRLINE_CALLSIGN.test(callsign)) return;
|
||||
enqueue(
|
||||
`r:${callsign}`,
|
||||
{ kind: 'route', id: callsign },
|
||||
(data) => {
|
||||
const value = entry(icao);
|
||||
value.airline = stickyText(data.airline, value.airline) || null;
|
||||
if (data.origin && data.destination)
|
||||
value.route = {
|
||||
callsign,
|
||||
origin: data.origin,
|
||||
destination: data.destination,
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
lifetime.abort();
|
||||
lifetime = new AbortController();
|
||||
clearTimeout(dripTimer);
|
||||
dripTimer = null;
|
||||
queue.length = 0;
|
||||
active = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Names for the server's decoder feeds, shared by the Local ADS-B row status
|
||||
* and the Radio card. Pure; no imports, so any surface may use it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Display name of one feed: its band, or its ordinal label ("978 MHz UAT #2")
|
||||
* when another configured feed shares the band.
|
||||
* @param {object} feed Feed status from the route.
|
||||
* @param {object[]} [feeds] Every configured feed.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function localReceiverFeedName(feed, feeds = []) {
|
||||
const band = feed?.band;
|
||||
if (!band) return feed?.label || 'feed';
|
||||
const sharing = feeds.filter((other) => other?.band === band);
|
||||
if (sharing.length < 2) return band;
|
||||
if (typeof feed.label === 'string' && feed.label.includes('#'))
|
||||
return feed.label;
|
||||
return `${feed.label || band} #${sharing.indexOf(feed) + 1}`;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import {
|
||||
localAdsbRecordIsLive,
|
||||
localAdsbRecordIsNewer,
|
||||
} from '../../sources/adsbRecords.js';
|
||||
|
||||
/** Same-origin route served by `server/providers/local-receivers.js`. */
|
||||
export const LOCAL_RECEIVERS_URL = '/api/local-receivers/aircraft';
|
||||
export const LOCAL_RECEIVER_POLL_MS = 1_000;
|
||||
/**
|
||||
* Browser-side deadline for one route request, body included. The server
|
||||
* bounds each upstream read at 2 s; this bounds the browser-to-server leg.
|
||||
*/
|
||||
export const LOCAL_RECEIVER_REQUEST_TIMEOUT_MS = 5_000;
|
||||
|
||||
function abortError() {
|
||||
const error = new Error('Local receiver request aborted');
|
||||
error.name = 'AbortError';
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Reject with an AbortError as soon as `signal` aborts. */
|
||||
function untilAborted(promise, signal) {
|
||||
if (signal.aborted) return Promise.reject(abortError());
|
||||
let onAbort = null;
|
||||
const aborted = new Promise((_, reject) => {
|
||||
onAbort = () => reject(abortError());
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
return Promise.race([promise, aborted]).finally(() =>
|
||||
signal.removeEventListener('abort', onAbort),
|
||||
);
|
||||
}
|
||||
|
||||
function shiftTime(value, offset) {
|
||||
return Number.isFinite(value) ? value + offset : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser poller for the server's local decoder feeds.
|
||||
*
|
||||
* Polls only between `start()` and `stop()` (the Local ADS-B layer calls them
|
||||
* on enable/disable), and only while the route reports `configured`: an
|
||||
* unconfigured server, or a build without the route, stops the loop after one
|
||||
* request. `probe()` makes that one request without polling, so the Radio card
|
||||
* can show which feeds exist before the layer is turned on.
|
||||
*
|
||||
* Records are rebased from the server clock to this browser's clock and kept
|
||||
* by ICAO and band for up to 60 s after their last message, so a feed that
|
||||
* drops an aircraft or goes briefly unreachable does not erase it at once.
|
||||
* @param {object} [options]
|
||||
* @param {typeof fetch} [options.fetchImpl]
|
||||
* @param {string} [options.url]
|
||||
* @param {number} [options.intervalMs]
|
||||
* @param {number} [options.requestTimeoutMs] Deadline per request.
|
||||
* @param {() => number} [options.now]
|
||||
* @returns {object} Feed session: start, stop, probe, getState, subscribe.
|
||||
*/
|
||||
export function createLocalReceiverFeeds({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
url = LOCAL_RECEIVERS_URL,
|
||||
intervalMs = LOCAL_RECEIVER_POLL_MS,
|
||||
requestTimeoutMs = LOCAL_RECEIVER_REQUEST_TIMEOUT_MS,
|
||||
now = Date.now,
|
||||
} = {}) {
|
||||
const listeners = new Set();
|
||||
const retained = new Map();
|
||||
/** Abort controllers of requests still in flight. */
|
||||
const inFlight = new Set();
|
||||
let state = {
|
||||
configured: null,
|
||||
polling: false,
|
||||
feeds: [],
|
||||
records: [],
|
||||
error: null,
|
||||
updatedAt: null,
|
||||
};
|
||||
let generation = 0;
|
||||
let timer = null;
|
||||
let probing = null;
|
||||
|
||||
function emit(patch) {
|
||||
state = { ...state, ...patch };
|
||||
for (const listener of listeners) listener(state);
|
||||
}
|
||||
|
||||
function retain(records, at) {
|
||||
for (const record of records) {
|
||||
if (!record?.icao) continue;
|
||||
// Two feeds on one band both report an aircraft: keep the fresher
|
||||
// record, never simply the one listed (or polled) last.
|
||||
const key = `${record.icao}|${record.band}`;
|
||||
const current = retained.get(key);
|
||||
if (!current || !localAdsbRecordIsNewer(current, record))
|
||||
retained.set(key, record);
|
||||
}
|
||||
for (const [key, record] of retained) {
|
||||
if (!localAdsbRecordIsLive(record, at)) retained.delete(key);
|
||||
}
|
||||
return [...retained.values()];
|
||||
}
|
||||
|
||||
/** One route request with a deadline through the body, abortable by stop. */
|
||||
async function request() {
|
||||
const controller = new AbortController();
|
||||
inFlight.add(controller);
|
||||
const timer = setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
const response = await untilAborted(
|
||||
fetchImpl(url, {
|
||||
cache: 'no-store',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal,
|
||||
}),
|
||||
controller.signal,
|
||||
);
|
||||
if (!response?.ok) throw new Error('local receiver route unavailable');
|
||||
return await untilAborted(response.json(), controller.signal);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
inFlight.delete(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function abortInFlight() {
|
||||
for (const controller of inFlight) controller.abort();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
function apply(body) {
|
||||
if (body?.configured !== true) {
|
||||
retained.clear();
|
||||
emit({
|
||||
configured: false,
|
||||
polling: false,
|
||||
feeds: [],
|
||||
records: [],
|
||||
error: null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const at = now();
|
||||
const offset = Number.isFinite(body.generatedAt)
|
||||
? at - body.generatedAt
|
||||
: 0;
|
||||
const records = (Array.isArray(body.records) ? body.records : []).map(
|
||||
(record) => ({
|
||||
...record,
|
||||
lastPositionAt: shiftTime(record.lastPositionAt, offset),
|
||||
lastMessageAt: shiftTime(record.lastMessageAt, offset),
|
||||
}),
|
||||
);
|
||||
emit({
|
||||
configured: true,
|
||||
feeds: Array.isArray(body.feeds) ? body.feeds : [],
|
||||
records: retain(records, at),
|
||||
error: null,
|
||||
updatedAt: at,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function fail() {
|
||||
// A route that never answered means there is nothing to poll; a route
|
||||
// that answered before is reported unreachable and polled again.
|
||||
if (state.configured !== true) {
|
||||
emit({ configured: false, polling: false, error: 'unavailable' });
|
||||
return false;
|
||||
}
|
||||
emit({
|
||||
feeds: state.feeds.map((feed) =>
|
||||
feed.status === 'invalid'
|
||||
? feed
|
||||
: { ...feed, status: 'unreachable', aircraft: 0 },
|
||||
),
|
||||
records: retain([], now()),
|
||||
error: 'unavailable',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function tick(current) {
|
||||
timer = null;
|
||||
let keepPolling;
|
||||
try {
|
||||
const body = await request();
|
||||
if (current !== generation) return;
|
||||
keepPolling = apply(body);
|
||||
} catch {
|
||||
if (current !== generation) return;
|
||||
keepPolling = fail();
|
||||
}
|
||||
if (!keepPolling) {
|
||||
generation += 1;
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(() => void tick(current), intervalMs);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Start polling (no-op while already polling). */
|
||||
start() {
|
||||
if (state.polling) return;
|
||||
generation += 1;
|
||||
emit({ polling: true });
|
||||
void tick(generation);
|
||||
},
|
||||
|
||||
/** Stop polling; the last feed statuses stay readable. */
|
||||
stop() {
|
||||
generation += 1;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
abortInFlight();
|
||||
if (state.polling) emit({ polling: false });
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask the route once whether feeds are configured, without polling.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
probe() {
|
||||
if (state.polling || state.configured !== null) return Promise.resolve();
|
||||
if (!probing) {
|
||||
const current = generation;
|
||||
probing = request()
|
||||
.then((body) => {
|
||||
if (current === generation && !state.polling) apply(body);
|
||||
})
|
||||
.catch(() => {
|
||||
if (current === generation && !state.polling)
|
||||
emit({ configured: false, error: 'unavailable' });
|
||||
})
|
||||
.finally(() => {
|
||||
probing = null;
|
||||
});
|
||||
}
|
||||
return probing;
|
||||
},
|
||||
|
||||
getState: () => state,
|
||||
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.stop();
|
||||
listeners.clear();
|
||||
retained.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { LOCAL_RECEIVERS_URL, createLocalReceiverFeeds } from './feeds.js';
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
function route(bodies) {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(url);
|
||||
const next = bodies.length > 1 ? bodies.shift() : bodies[0];
|
||||
if (next instanceof Error) throw next;
|
||||
if (next === 404) return { ok: false, status: 404 };
|
||||
return { ok: true, json: async () => structuredClone(next) };
|
||||
};
|
||||
return { calls, fetchImpl };
|
||||
}
|
||||
|
||||
function payload(records, generatedAt = 1_000_000) {
|
||||
return {
|
||||
configured: true,
|
||||
generatedAt,
|
||||
feeds: [
|
||||
{ band: '1090', label: '1090 MHz', status: 'live', aircraft: 1 },
|
||||
{ band: '978', label: '978 MHz UAT', status: 'live', aircraft: 1 },
|
||||
],
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function aircraft(overrides = {}) {
|
||||
return {
|
||||
icao: 'ae5d8a',
|
||||
lat: 30.27,
|
||||
lon: -97.79,
|
||||
lastPositionAt: 999_000,
|
||||
lastMessageAt: 999_500,
|
||||
band: '1090',
|
||||
source: 'feed',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('an unconfigured server is asked once and never polled', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([
|
||||
{ configured: false, feeds: [], records: [] },
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl });
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.deepEqual(calls, [LOCAL_RECEIVERS_URL]);
|
||||
assert.equal(feeds.getState().configured, false);
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('a build without the route stops after one failed request', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([404]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl });
|
||||
feeds.start();
|
||||
await flush();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(feeds.getState().configured, false);
|
||||
});
|
||||
|
||||
test('configured feeds poll every second between start and stop, on the browser clock', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([payload([aircraft()])]);
|
||||
const clock = 1_000_250;
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
|
||||
const seen = [];
|
||||
feeds.subscribe((state) => seen.push(state.polling));
|
||||
feeds.start();
|
||||
await flush();
|
||||
const state = feeds.getState();
|
||||
assert.equal(state.configured, true);
|
||||
assert.equal(state.polling, true);
|
||||
assert.equal(state.feeds.length, 2);
|
||||
// Server generated at 1_000_000; received at 1_000_250: rebased +250 ms.
|
||||
assert.equal(state.records[0].lastPositionAt, 999_250);
|
||||
assert.equal(state.records[0].lastMessageAt, 999_750);
|
||||
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
feeds.stop();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3, 'no polling once stopped');
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
assert.equal(seen.at(-1), false);
|
||||
});
|
||||
|
||||
test('an aircraft a feed drops is kept until 60 s after its last message', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const bodies = [
|
||||
payload([aircraft(), aircraft({ band: '978' })], 1_000_000),
|
||||
payload([aircraft()], 1_010_000),
|
||||
payload([], 1_060_000),
|
||||
];
|
||||
let clock = 1_000_000;
|
||||
const { fetchImpl } = route(bodies);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => clock });
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(feeds.getState().records.length, 2);
|
||||
clock = 1_010_000;
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
feeds.getState().records.map((record) => record.band),
|
||||
['1090', '978'],
|
||||
'the 978 reception is remembered',
|
||||
);
|
||||
clock = 1_060_000;
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(feeds.getState().records, [], 'forgotten after 60 s');
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
test('a route that answered before is reported unreachable and polled again', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([
|
||||
payload([aircraft()]),
|
||||
new TypeError('network down'),
|
||||
payload([aircraft()]),
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.deepEqual(
|
||||
feeds.getState().feeds.map((feed) => feed.status),
|
||||
['unreachable', 'unreachable'],
|
||||
);
|
||||
assert.equal(feeds.getState().records.length, 1, 'recent records remain');
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 3);
|
||||
assert.equal(feeds.getState().feeds[0].status, 'live');
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
test('probe asks once without polling so the Radio card can list feeds', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { calls, fetchImpl } = route([payload([aircraft()])]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
await Promise.all([feeds.probe(), feeds.probe()]);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(feeds.getState().configured, true);
|
||||
assert.equal(feeds.getState().polling, false);
|
||||
await feeds.probe();
|
||||
t.mock.timers.tick(5_000);
|
||||
await flush();
|
||||
assert.equal(calls.length, 1);
|
||||
});
|
||||
|
||||
test('two feeds on one band keep the fresher record, whichever is listed last', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const fresh = aircraft({ lastPositionAt: 999_000, lastMessageAt: 999_500 });
|
||||
// A second 1090 feed that heard the aircraft earlier and has no position.
|
||||
const older = aircraft({
|
||||
lat: null,
|
||||
lon: null,
|
||||
lastPositionAt: null,
|
||||
lastMessageAt: 990_000,
|
||||
});
|
||||
const { fetchImpl } = route([
|
||||
payload([fresh, older], 1_000_000),
|
||||
payload([older], 1_000_000),
|
||||
]);
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
let [kept] = feeds.getState().records;
|
||||
assert.equal(feeds.getState().records.length, 1);
|
||||
assert.equal(kept.lastPositionAt, 999_000, 'same response');
|
||||
t.mock.timers.tick(1_000);
|
||||
await flush();
|
||||
[kept] = feeds.getState().records;
|
||||
assert.equal(kept.lastPositionAt, 999_000, 'next poll');
|
||||
assert.equal(kept.lat, 30.27);
|
||||
feeds.stop();
|
||||
});
|
||||
|
||||
/** A route whose requests never answer until their signal aborts. */
|
||||
function stalledRoute({ stallBody = false } = {}) {
|
||||
const signals = [];
|
||||
const hang = (signal) =>
|
||||
new Promise((_, reject) => {
|
||||
signal?.addEventListener('abort', () =>
|
||||
reject(new DOMException('aborted', 'AbortError')),
|
||||
);
|
||||
});
|
||||
const fetchImpl = async (url, options = {}) => {
|
||||
signals.push(options.signal);
|
||||
if (!stallBody) return hang(options.signal);
|
||||
return { ok: true, json: () => hang(options.signal) };
|
||||
};
|
||||
return { signals, fetchImpl };
|
||||
}
|
||||
|
||||
test('a stalled feed request is abandoned at its deadline and polling continues', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
for (const stallBody of [false, true]) {
|
||||
const { signals, fetchImpl } = stalledRoute({ stallBody });
|
||||
const feeds = createLocalReceiverFeeds({
|
||||
fetchImpl,
|
||||
now: () => 1_000_000,
|
||||
requestTimeoutMs: 3_000,
|
||||
});
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(signals.length, 1);
|
||||
assert.ok(signals[0], 'every request carries an abort signal');
|
||||
t.mock.timers.tick(3_000);
|
||||
await flush();
|
||||
assert.equal(signals[0].aborted, true, `aborted (stallBody ${stallBody})`);
|
||||
assert.equal(feeds.getState().configured, false, 'never answered');
|
||||
feeds.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('stop() aborts the request in flight so start() is never blocked by it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const { signals, fetchImpl } = stalledRoute();
|
||||
const feeds = createLocalReceiverFeeds({ fetchImpl, now: () => 1_000_000 });
|
||||
feeds.start();
|
||||
await flush();
|
||||
feeds.stop();
|
||||
assert.equal(signals[0].aborted, true);
|
||||
feeds.start();
|
||||
await flush();
|
||||
feeds.stop();
|
||||
feeds.start();
|
||||
await flush();
|
||||
assert.equal(signals.length, 3);
|
||||
assert.deepEqual(
|
||||
signals.map((signal) => signal.aborted),
|
||||
[true, true, false],
|
||||
'no disable/enable cycle leaves an old request outstanding',
|
||||
);
|
||||
feeds.destroy();
|
||||
assert.equal(signals[2].aborted, true);
|
||||
});
|
||||
@@ -0,0 +1,846 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { CLASS_SCALE_2D } from '../../data/aircraftClass.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import {
|
||||
horizonOccluder,
|
||||
screenProjectedRotation,
|
||||
stabilizeScreenRotation,
|
||||
} from '../../data/iconOrientation.js';
|
||||
import { isPointerFree } from '../../data/inputOwnership.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 { LocalAdsbMotion } from './motion.js';
|
||||
import {
|
||||
ENTITY_PREFIX,
|
||||
LAYER_ID,
|
||||
LAYER_NAME,
|
||||
LAYER_SOURCE,
|
||||
LOCAL_ADSB_COLOR,
|
||||
LOCAL_ADSB_SYNC_MS,
|
||||
LOCAL_ADSB_TICK_MS,
|
||||
LOCAL_ADSB_UAT_RING_COLOR,
|
||||
} 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';
|
||||
/** Picks looked through under a click for a local aircraft. */
|
||||
const CLICK_DRILL_LIMIT = 8;
|
||||
/** Trail lines pass through their aircraft; a click never stops on one. */
|
||||
const TRAIL_PICK_PREFIX = 'gev-trail:';
|
||||
/** 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, 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.
|
||||
* @param {(canvas: HTMLCanvasElement) => object} [options.createInputHandler]
|
||||
* Click-handler factory, injectable for tests.
|
||||
* @returns {object} Data-layer module.
|
||||
*/
|
||||
export function createLocalAdsbLayer({
|
||||
receiver,
|
||||
feeds = null,
|
||||
services,
|
||||
now = Date.now,
|
||||
resolveAsset = (url) => url,
|
||||
loadModel,
|
||||
createInputHandler = (canvas) => new Cesium.ScreenSpaceEventHandler(canvas),
|
||||
}) {
|
||||
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;
|
||||
const color = Cesium.Color.fromCssColorString(LOCAL_ADSB_COLOR);
|
||||
const uatRingColor = Cesium.Color.fromCssColorString(
|
||||
LOCAL_ADSB_UAT_RING_COLOR,
|
||||
);
|
||||
|
||||
let viewer = null;
|
||||
let dataSource = null;
|
||||
let enabled = false;
|
||||
let unsubscribe = null;
|
||||
let unsubscribeFeeds = null;
|
||||
let tickTimer = null;
|
||||
let syncTimer = null;
|
||||
let clickHandler = 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 (!viewer?.camera?.positionWC || !marker.position) return true;
|
||||
return horizonOccluder(viewer.camera).isPointVisible(marker.position);
|
||||
}
|
||||
|
||||
function markerRotation(marker) {
|
||||
if (!viewer?.scene || !marker.position) return marker.lastRotation;
|
||||
const projected = screenProjectedRotation(
|
||||
viewer.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 Cesium.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 = Cesium.Cartesian3.fromDegrees(
|
||||
display.lon,
|
||||
display.lat,
|
||||
renderHeightM(marker, display.altitudeFt, display.lat, display.lon),
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
marker.position || new Cesium.Cartesian3(),
|
||||
);
|
||||
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;
|
||||
const billboard = marker.entity?.billboard;
|
||||
if (!billboard) return;
|
||||
if (marker.iconKind !== klass) {
|
||||
marker.iconKind = klass;
|
||||
billboard.image = aircraftIcon(klass);
|
||||
}
|
||||
const scale = billboardScale(marker);
|
||||
if (marker.iconScale !== scale) {
|
||||
marker.iconScale = scale;
|
||||
billboard.scale = 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,
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function uatRing(marker) {
|
||||
return new Cesium.PointGraphics({
|
||||
pixelSize: 24,
|
||||
color: Cesium.Color.TRANSPARENT,
|
||||
outlineColor: uatRingColor,
|
||||
outlineWidth: 1.5,
|
||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
|
||||
show: new Cesium.CallbackProperty(() => markerVisible(marker), false),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
2_000_000,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Trail of the selected aircraft ──────────────────────────────────────
|
||||
|
||||
function releaseTrail() {
|
||||
if (!selectedTrail) return;
|
||||
selectedTrail.trail?.destroy();
|
||||
if (selectedTrail.head && viewer && !viewer.isDestroyed?.()) {
|
||||
try {
|
||||
viewer.entities.remove(selectedTrail.head);
|
||||
} catch {
|
||||
/* torn down */
|
||||
}
|
||||
}
|
||||
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 createTrail = services.trails?.createTrail;
|
||||
if (typeof createTrail !== 'function' || !viewer?.entities) return;
|
||||
selectedTrail = {
|
||||
id: marker.id,
|
||||
trail: createTrail(viewer, { color: LOCAL_ADSB_COLOR, width: 2.5 }),
|
||||
head: null,
|
||||
lastFix: null,
|
||||
};
|
||||
// Live head: last heard fix → the extrapolated marker, every frame.
|
||||
selectedTrail.head = viewer.entities.add({
|
||||
// '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}`,
|
||||
polyline: {
|
||||
positions: new Cesium.CallbackProperty(() => {
|
||||
const current = markers.get(selectedTrail?.id);
|
||||
const from = selectedTrail?.lastFix;
|
||||
if (!current?.position || !from) return [];
|
||||
return [from, Cesium.Cartesian3.clone(current.position)];
|
||||
}, false),
|
||||
width: 2.5,
|
||||
material: color.withAlpha(0.9),
|
||||
depthFailMaterial: color.withAlpha(0.45),
|
||||
arcType: Cesium.ArcType.GEODESIC,
|
||||
},
|
||||
});
|
||||
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) {
|
||||
marker.entity = dataSource.entities.add({
|
||||
id,
|
||||
position: new Cesium.CallbackProperty(() => marker.position, false),
|
||||
billboard: {
|
||||
image: aircraftIcon('airliner'),
|
||||
width: 20,
|
||||
height: 20,
|
||||
scale: 1,
|
||||
color,
|
||||
sizeInMeters: false,
|
||||
scaleByDistance: new Cesium.NearFarScalar(1000, 3.0, 8_000_000, 0.5),
|
||||
alignedAxis: Cesium.Cartesian3.ZERO,
|
||||
rotation: new Cesium.CallbackProperty(
|
||||
() => markerRotation(marker),
|
||||
false,
|
||||
),
|
||||
show: new Cesium.CallbackProperty(
|
||||
() => !marker.modelOwnsVisual && markerVisible(marker),
|
||||
false,
|
||||
),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(
|
||||
0,
|
||||
2_000_000,
|
||||
),
|
||||
},
|
||||
});
|
||||
marker.entity.gevTrackedId = id;
|
||||
marker.entity.gevDisplayPosition = () => marker.position;
|
||||
}
|
||||
refreshClass(marker);
|
||||
const uat = localAdsbIsUat(record);
|
||||
if (uat !== Boolean(marker.entity.point))
|
||||
marker.entity.point = uat ? uatRing(marker) : undefined;
|
||||
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 (!dataSource) 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) dataSource.entities.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;
|
||||
}
|
||||
|
||||
function pickedId(picked) {
|
||||
const resolved = services.picking.resolvePickId?.(picked);
|
||||
if (resolved !== undefined) return resolved;
|
||||
if (typeof picked?.id === 'string') return picked.id;
|
||||
if (typeof picked?.id?.id === 'string') return picked.id.id;
|
||||
return typeof picked?.primitive?.id === 'string'
|
||||
? picked.primitive.id
|
||||
: null;
|
||||
}
|
||||
|
||||
function ownedElsewhere(id) {
|
||||
return Boolean(id) && services.picking.isOwnedByOtherLayer?.(LAYER_ID, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a click at `position` means for this layer: a local aircraft id,
|
||||
* `'other'` when a sibling layer's contact is under the pointer, `'trail'`
|
||||
* for a trail line, or null for empty map.
|
||||
*
|
||||
* The frontmost pick decides, except that something no layer owns (a
|
||||
* photoreal tile, a ring) or a trail line drawn over or through a local
|
||||
* aircraft does not hide it: the click looks underneath for the aircraft.
|
||||
*/
|
||||
function clickTarget(position) {
|
||||
const scene = viewer.scene;
|
||||
const top = pickedId(scene.pick(position));
|
||||
if (top && markers.has(top)) return top;
|
||||
const trail = Boolean(top) && String(top).startsWith(TRAIL_PICK_PREFIX);
|
||||
if (ownedElsewhere(top) && !trail) return 'other';
|
||||
let hits = [];
|
||||
try {
|
||||
hits = scene.drillPick?.(position, CLICK_DRILL_LIMIT) || [];
|
||||
} catch {
|
||||
hits = [];
|
||||
}
|
||||
for (const hit of hits) {
|
||||
const id = pickedId(hit);
|
||||
if (id && markers.has(id)) return id;
|
||||
if (id && String(id).startsWith(TRAIL_PICK_PREFIX)) continue;
|
||||
// A sibling layer's contact above ours takes the click.
|
||||
if (ownedElsewhere(id)) return 'other';
|
||||
}
|
||||
return trail ? 'trail' : null;
|
||||
}
|
||||
|
||||
function installInteraction() {
|
||||
if (clickHandler || !viewer?.scene?.canvas) return;
|
||||
clickHandler = createInputHandler(viewer.scene.canvas);
|
||||
clickHandler.setInputAction((click) => {
|
||||
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
|
||||
if (!isPointerFree() || !enabled) return;
|
||||
const target = clickTarget(click.position);
|
||||
// Clicking a local aircraft always (re)selects it and republishes its
|
||||
// card, including the one already selected: its card may have been
|
||||
// displaced, and a click on a marker must never read as "deselect".
|
||||
if (target && markers.has(target)) {
|
||||
selectMarker(target);
|
||||
return;
|
||||
}
|
||||
// A click on a trail line is not empty map.
|
||||
if (target === 'trail' || !selectedId) return;
|
||||
// Another contact or empty map releases only this layer's selection,
|
||||
// leaving a sibling layer's new selection intact.
|
||||
clearSelection();
|
||||
governorRequestRender('local-adsb-selection');
|
||||
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
|
||||
}
|
||||
|
||||
function releaseInteraction() {
|
||||
clickHandler?.destroy();
|
||||
clickHandler = null;
|
||||
}
|
||||
|
||||
function installFrame() {
|
||||
if (removePreRender || !viewer?.scene?.preRender?.addEventListener) return;
|
||||
removePreRender = viewer.scene.preRender.addEventListener(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 = Cesium.Cartographic.fromCartesian(marker.position);
|
||||
rows.push({
|
||||
id: marker.record.icao,
|
||||
label: localAdsbTitle(marker.record),
|
||||
callsign: marker.record.callsign,
|
||||
position: Cesium.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,
|
||||
|
||||
init(nextViewer) {
|
||||
viewer = nextViewer;
|
||||
dataSource = new Cesium.CustomDataSource(LAYER_ID);
|
||||
dataSource.show = false;
|
||||
viewer.dataSources.add(dataSource);
|
||||
if (viewer.scene?.primitives) {
|
||||
models = createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap: services.groundSnap?.createGroundSnap?.() || null,
|
||||
...(loadModel ? { loadModel } : {}),
|
||||
});
|
||||
}
|
||||
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;
|
||||
if (dataSource) dataSource.show = 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();
|
||||
if (dataSource) dataSource.show = 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;
|
||||
if (viewer && dataSource) viewer.dataSources.remove(dataSource, true);
|
||||
dataSource = null;
|
||||
viewer = null;
|
||||
void receiver.destroy?.();
|
||||
},
|
||||
|
||||
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}`);
|
||||
},
|
||||
|
||||
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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { horizonOccluder } from '../../data/iconOrientation.js';
|
||||
import { selectModelEligible } from '../../data/modelEligibility.js';
|
||||
import { civilAircraftModelSpec } from '../flights/modelSpec.js';
|
||||
import {
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
MODEL_ALL_ADD_M,
|
||||
MODEL_ALL_KEEP_M,
|
||||
MODEL_ALT_CEIL_M,
|
||||
MODEL_HEADING_OFFSET_DEG,
|
||||
MODEL_MAX,
|
||||
MODEL_MAX_ALL,
|
||||
MODEL_MIN_PX,
|
||||
MODEL_PROX_ADD_M,
|
||||
MODEL_PROX_KEEP_M,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
const specKey = (spec) => `${spec.url}@${spec.scale}`;
|
||||
|
||||
/**
|
||||
* 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle.
|
||||
*
|
||||
* Mirrors the public Flights fleet: models only below the altitude ceiling,
|
||||
* the same proximity/all caps and add/keep radii, the same visible-first
|
||||
* eligibility (`selectModelEligible`), the same per-class asset and scale
|
||||
* (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here
|
||||
* magenta, as Flights tints military amber). Each model owns its matrix. A
|
||||
* billboard stays the visual until its model is loaded AND placed; a grounded
|
||||
* aircraft's model rides the one-shot ground snap plus the class belly
|
||||
* offset, and without a resolved ground it stays a billboard.
|
||||
* @param {object} options
|
||||
* @param {object} options.viewer Cesium viewer.
|
||||
* @param {Cesium.Color} options.color Model tint.
|
||||
* @param {(url: string) => string} options.resolveAsset
|
||||
* @param {object|null} [options.groundSnap] `createGroundSnap()` instance.
|
||||
* @param {(options: object) => Promise<object>} [options.loadModel]
|
||||
* @returns {object}
|
||||
*/
|
||||
export function createLocalAdsbModels({
|
||||
viewer,
|
||||
color,
|
||||
resolveAsset,
|
||||
groundSnap = null,
|
||||
loadModel = (options) => Cesium.Model.fromGltfAsync(options),
|
||||
}) {
|
||||
const collection = new Cesium.PrimitiveCollection();
|
||||
viewer.scene.primitives.add(collection);
|
||||
/** @type {Map<string, object>} id -> admitted model */
|
||||
const models = new Map();
|
||||
/** @type {Map<string, number>} id -> generation of an in-flight load */
|
||||
const pending = new Map();
|
||||
let generation = 0;
|
||||
let destroyed = false;
|
||||
let lastEligibilityAt = 0;
|
||||
let eligible = new Set();
|
||||
const scratchHpr = new Cesium.HeadingPitchRoll();
|
||||
const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1);
|
||||
const scratchCarto = new Cesium.Cartographic();
|
||||
const scratchGround = new Cesium.Cartesian3();
|
||||
|
||||
function release(id) {
|
||||
pending.delete(id);
|
||||
const model = models.get(id);
|
||||
if (!model) return;
|
||||
models.delete(id);
|
||||
if (!collection.isDestroyed()) collection.remove(model);
|
||||
groundSnap?.forget?.(id);
|
||||
}
|
||||
|
||||
function releaseAll() {
|
||||
for (const id of [...models.keys()]) release(id);
|
||||
pending.clear();
|
||||
generation += 1;
|
||||
}
|
||||
|
||||
async function ensure(id, marker, cap) {
|
||||
const spec = civilAircraftModelSpec(marker.klass);
|
||||
const key = specKey(spec);
|
||||
const current = models.get(id);
|
||||
if (current && current._gevSpecKey === key) return;
|
||||
if (current) release(id);
|
||||
if (pending.has(id) || models.size + pending.size >= cap) return;
|
||||
const loadGeneration = generation;
|
||||
pending.set(id, loadGeneration);
|
||||
let model;
|
||||
try {
|
||||
model = await loadModel({
|
||||
url: resolveAsset(spec.url),
|
||||
asynchronous: false,
|
||||
minimumPixelSize: MODEL_MIN_PX,
|
||||
scale: spec.scale,
|
||||
color,
|
||||
colorBlendMode: Cesium.ColorBlendMode.MIX,
|
||||
colorBlendAmount: spec.blendAmount,
|
||||
id,
|
||||
});
|
||||
} catch {
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
return;
|
||||
}
|
||||
const stale =
|
||||
destroyed ||
|
||||
pending.get(id) !== loadGeneration ||
|
||||
generation !== loadGeneration ||
|
||||
!eligible.has(id) ||
|
||||
models.has(id) ||
|
||||
specKey(civilAircraftModelSpec(marker.klass)) !== key;
|
||||
if (pending.get(id) === loadGeneration) pending.delete(id);
|
||||
if (stale) {
|
||||
model.destroy?.();
|
||||
return;
|
||||
}
|
||||
model.id = id;
|
||||
model._gevSpecKey = key;
|
||||
model._gevBellyM = spec.bellyM;
|
||||
// Admitted, not yet the visual: placement turns it on.
|
||||
model.show = false;
|
||||
collection.add(model);
|
||||
models.set(id, model);
|
||||
}
|
||||
|
||||
function regime(preferences) {
|
||||
if (!preferences?.models3d) return false;
|
||||
const height = viewer.camera?.positionCartographic?.height ?? Infinity;
|
||||
return height < MODEL_ALT_CEIL_M;
|
||||
}
|
||||
|
||||
function refreshEligibility(markers, preferences) {
|
||||
const all = preferences.models3dMode === 'all';
|
||||
const cap = all ? MODEL_MAX_ALL : MODEL_MAX;
|
||||
const addM = all ? MODEL_ALL_ADD_M : MODEL_PROX_ADD_M;
|
||||
const keepM = all ? MODEL_ALL_KEEP_M : MODEL_PROX_KEEP_M;
|
||||
const camera = viewer.camera;
|
||||
const cameraPosition = camera.positionWC;
|
||||
const cull = camera.frustum.computeCullingVolume(
|
||||
cameraPosition,
|
||||
camera.directionWC,
|
||||
camera.upWC,
|
||||
);
|
||||
const candidates = [];
|
||||
for (const [id, marker] of markers) {
|
||||
if (!marker.position) continue;
|
||||
const distanceSq = Cesium.Cartesian3.distanceSquared(
|
||||
cameraPosition,
|
||||
marker.position,
|
||||
);
|
||||
if (distanceSq > keepM * keepM) continue;
|
||||
Cesium.Cartesian3.clone(marker.position, scratchSphere.center);
|
||||
candidates.push([
|
||||
id,
|
||||
distanceSq,
|
||||
cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE,
|
||||
]);
|
||||
}
|
||||
candidates.sort((a, b) => a[1] - b[1]);
|
||||
eligible = selectModelEligible(candidates, {
|
||||
cap,
|
||||
addDistSq: addM * addM,
|
||||
isModeled: (id) => models.has(id),
|
||||
});
|
||||
for (const id of [...models.keys(), ...pending.keys()])
|
||||
if (!eligible.has(id)) release(id);
|
||||
for (const id of eligible) void ensure(id, markers.get(id), cap);
|
||||
}
|
||||
|
||||
function placement(id, marker) {
|
||||
if (!marker.onGround) return marker.position;
|
||||
const model = models.get(id);
|
||||
// The vertical sample must hit the tile skin, not this aircraft's own
|
||||
// billboard or model.
|
||||
const height = groundSnap?.heightFor?.(viewer, id, marker.position, () =>
|
||||
[marker.entity, model].filter(Boolean),
|
||||
);
|
||||
if (!Number.isFinite(height)) return null;
|
||||
const carto = Cesium.Cartographic.fromCartesian(
|
||||
marker.position,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchCarto,
|
||||
);
|
||||
return Cesium.Cartesian3.fromRadians(
|
||||
carto.longitude,
|
||||
carto.latitude,
|
||||
height + (model?._gevBellyM || 0),
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
scratchGround,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
/** Ids whose model admission is current; the layer enriches these. */
|
||||
get eligible() {
|
||||
return eligible;
|
||||
},
|
||||
|
||||
/**
|
||||
* Per-frame pass: re-derive eligibility at the fleet cadence, then place
|
||||
* every admitted model and hand each marker's visual to its model only
|
||||
* once the model is ready and placed.
|
||||
* @param {Map<string, object>} markers Layer markers (position, courseDeg,
|
||||
* klass, onGround); `modelOwnsVisual` is written back.
|
||||
* @param {object} preferences `{ models3d, models3dMode }`.
|
||||
* @param {number} nowMs
|
||||
*/
|
||||
frame(markers, preferences, nowMs) {
|
||||
if (destroyed) return;
|
||||
if (!regime(preferences)) {
|
||||
if (models.size || pending.size) releaseAll();
|
||||
eligible = new Set();
|
||||
for (const marker of markers.values()) marker.modelOwnsVisual = false;
|
||||
return;
|
||||
}
|
||||
if (nowMs - lastEligibilityAt >= FLEET_DR_INTERVAL_MS) {
|
||||
lastEligibilityAt = nowMs;
|
||||
refreshEligibility(markers, preferences);
|
||||
}
|
||||
const occluder = horizonOccluder(viewer.camera);
|
||||
for (const [id, marker] of markers) {
|
||||
const model = models.get(id);
|
||||
marker.modelOwnsVisual = false;
|
||||
if (!model || !marker.position) continue;
|
||||
const position = placement(id, marker);
|
||||
if (!position || !occluder.isPointVisible(marker.position)) {
|
||||
model.show = false;
|
||||
continue;
|
||||
}
|
||||
scratchHpr.heading = Cesium.Math.toRadians(
|
||||
(marker.courseDeg || 0) + MODEL_HEADING_OFFSET_DEG,
|
||||
);
|
||||
scratchHpr.pitch = 0;
|
||||
scratchHpr.roll = 0;
|
||||
Cesium.Transforms.headingPitchRollToFixedFrame(
|
||||
position,
|
||||
scratchHpr,
|
||||
Cesium.Ellipsoid.WGS84,
|
||||
undefined,
|
||||
model.modelMatrix,
|
||||
);
|
||||
if (!model.ready) {
|
||||
model.show = false;
|
||||
continue;
|
||||
}
|
||||
model.show = true;
|
||||
marker.modelOwnsVisual = true;
|
||||
}
|
||||
for (const id of [...models.keys()]) if (!markers.has(id)) release(id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the model, and any load still in flight, of every aircraft that
|
||||
* is no longer a marker. The layer calls this whenever markers expire:
|
||||
* the per-frame pass does not run once the last marker is gone.
|
||||
* @param {Map<string, object>|Set<string>} live Current marker ids.
|
||||
*/
|
||||
retain(live) {
|
||||
if (destroyed) return;
|
||||
let dropped = false;
|
||||
for (const id of [...models.keys(), ...pending.keys()]) {
|
||||
if (live.has(id)) continue;
|
||||
release(id);
|
||||
eligible.delete(id);
|
||||
dropped = true;
|
||||
}
|
||||
if (dropped) eligible = new Set(eligible);
|
||||
},
|
||||
|
||||
/** Drop every model (3D off, layer disabled). */
|
||||
clear: releaseAll,
|
||||
|
||||
/** Number of admitted models (tests and diagnostics). */
|
||||
get size() {
|
||||
return models.size;
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
releaseAll();
|
||||
groundSnap?.clear?.();
|
||||
if (!viewer.isDestroyed?.() && !collection.isDestroyed())
|
||||
viewer.scene.primitives.remove(collection);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import {
|
||||
COURSE_HOLD_SPEED_MPS,
|
||||
TURN_MIN_SPEED_MPS,
|
||||
courseBetweenCartesians,
|
||||
courseSlewCapDps,
|
||||
estimateTurnRateDps,
|
||||
lerpAngleDeg,
|
||||
limitCourseStep,
|
||||
norm360,
|
||||
projectGroundArcLatLon,
|
||||
speedRamp,
|
||||
staleCoastLimitSeconds,
|
||||
} from '../../data/motionModel.js';
|
||||
import {
|
||||
LOCAL_ADSB_REANCHOR_AFTER,
|
||||
localAdsbFixIsPlausible,
|
||||
} from '../../sources/adsbRecords.js';
|
||||
import {
|
||||
COURSE_MAX_DPS,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
DR_CORRECTION_MS,
|
||||
} from '../flights/policy.js';
|
||||
|
||||
/** Heard positions kept per aircraft for its trail: 10 minutes, 600 fixes. */
|
||||
export const LOCAL_ADSB_HISTORY_MS = 10 * 60_000;
|
||||
export const LOCAL_ADSB_HISTORY_POINTS = 600;
|
||||
/** A display correction larger than this snaps instead of sliding. */
|
||||
export const LOCAL_ADSB_SNAP_M = 3_000;
|
||||
/** Chord course uses the newest fix at least this much older than the anchor. */
|
||||
const CHORD_BASELINE_MS = 2_000;
|
||||
/** Turn rate is estimated over this window of heard fixes. */
|
||||
const TURN_WINDOW_MS = 30_000;
|
||||
/** Coast at least this long past a fix; longer while messages keep coming. */
|
||||
const COAST_MINIMUM_SEC = 10;
|
||||
const COAST_CONTACT_GRACE_SEC = 10;
|
||||
const COAST_MAXIMUM_SEC = 60;
|
||||
const KT_TO_MPS = 1852 / 3600;
|
||||
const EARTH_RADIUS_M = 6_371_008.8;
|
||||
const DEG = Math.PI / 180;
|
||||
const _chordFrom = new Cesium.Cartesian3();
|
||||
const _chordTo = new Cesium.Cartesian3();
|
||||
|
||||
function finite(value) {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a record's telemetry (altitude, speed, track, climb) was last heard:
|
||||
* its newest message, which velocity-only and altitude-only messages advance
|
||||
* without a new position.
|
||||
*/
|
||||
function telemetryTime(record) {
|
||||
const messageAt = finite(record?.lastMessageAt);
|
||||
const positionAt = finite(record?.lastPositionAt);
|
||||
if (messageAt === null) return positionAt;
|
||||
return positionAt === null ? messageAt : Math.max(messageAt, positionAt);
|
||||
}
|
||||
|
||||
function chordCourse(from, to) {
|
||||
Cesium.Cartesian3.fromDegrees(from.lon, from.lat, 0, undefined, _chordFrom);
|
||||
Cesium.Cartesian3.fromDegrees(to.lon, to.lat, 0, undefined, _chordTo);
|
||||
return courseBetweenCartesians(_chordFrom, _chordTo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Real-time display motion for one locally heard aircraft.
|
||||
*
|
||||
* Unlike the public flight layers there is no deliberate one-poll display
|
||||
* delay: local fixes arrive about once a second, so the marker is
|
||||
* extrapolated FORWARD from the newest fix with the shared motion model
|
||||
* (constant-rate-turn arc, path-derived course blended over the reported
|
||||
* track by speed, rate-limited slew) and a new fix is absorbed by a
|
||||
* correction offset that decays over the same window the tracked public
|
||||
* flight uses, so the marker never snaps. Every fix also passes the same
|
||||
* speed check the decoder applies, which covers decoder-feed records too.
|
||||
*
|
||||
* Positions are geodetic (degrees, barometric feet); callers add the render
|
||||
* datum. The heard history doubles as the selected aircraft's trail.
|
||||
*/
|
||||
export class LocalAdsbMotion {
|
||||
constructor() {
|
||||
/** @type {Array<{at:number, lat:number, lon:number, altitudeFt:number|null,
|
||||
* trackDeg:number|null, speedMps:number|null}>} */
|
||||
this.fixes = [];
|
||||
this.anchor = null;
|
||||
this.correction = null;
|
||||
this.courseDeg = null;
|
||||
this.lastCourseAt = null;
|
||||
this.lastContactAt = null;
|
||||
this.rejectStreak = 0;
|
||||
this.rejectedFixes = 0;
|
||||
// The newest position evaluated, accepted or refused. Re-reading it (the
|
||||
// layer re-syncs the same record several times a second) is not a new
|
||||
// observation and must not count toward a re-anchor.
|
||||
this.lastCandidate = null;
|
||||
// When the anchor's telemetry was heard. Telemetry has its own freshness:
|
||||
// position freshness only decides trail points and re-anchoring.
|
||||
this.telemetryAt = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a record's newest position. Returns true when it became the new
|
||||
* anchor; a repeated, out-of-order or implausible fix returns false.
|
||||
* @param {object} record Local ADS-B record.
|
||||
* @param {number} nowMs Current epoch ms (display clock).
|
||||
* @returns {boolean}
|
||||
*/
|
||||
observe(record, nowMs) {
|
||||
if (Number.isFinite(record?.lastMessageAt))
|
||||
this.lastContactAt = Math.max(
|
||||
this.lastContactAt ?? -Infinity,
|
||||
record.lastMessageAt,
|
||||
);
|
||||
const at = finite(record?.lastPositionAt);
|
||||
const lat = finite(record?.lat);
|
||||
const lon = finite(record?.lon);
|
||||
if (at === null || lat === null || lon === null) return false;
|
||||
const newest = this.fixes.at(-1);
|
||||
const seen = this.lastCandidate;
|
||||
// No new position, but velocity and altitude messages arrive between
|
||||
// position messages: newer telemetry still reaches the anchor.
|
||||
if (
|
||||
(newest && at <= newest.at) ||
|
||||
(seen && at <= seen.at) ||
|
||||
// A decoder feed re-reads the same fix every poll and its rebased time
|
||||
// jitters with `seen_pos` rounding: the same position is not a new fix.
|
||||
(seen && !seen.accepted && lat === seen.lat && lon === seen.lon)
|
||||
) {
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
if (newest && lat === newest.lat && lon === newest.lon) {
|
||||
// Same coordinates, but altitude and velocity may still be newer (a
|
||||
// hovering helicopter climbing, an aircraft coming to a stop): update
|
||||
// the motion telemetry without adding a trail point.
|
||||
this.lastCandidate = { at, lat, lon, accepted: true };
|
||||
this.rejectStreak = 0;
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
const speedMps = Number.isFinite(record.groundSpeedKt)
|
||||
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
|
||||
: (newest?.speedMps ?? null);
|
||||
const fix = {
|
||||
at,
|
||||
lat,
|
||||
lon,
|
||||
altitudeFt: finite(record.altitudeFt),
|
||||
trackDeg: Number.isFinite(record.trackDeg)
|
||||
? norm360(record.trackDeg)
|
||||
: null,
|
||||
speedMps,
|
||||
};
|
||||
if (
|
||||
newest &&
|
||||
!localAdsbFixIsPlausible(newest, fix, {
|
||||
// The last known speed stands in while a record carries none.
|
||||
groundSpeedKt: Number.isFinite(speedMps) ? speedMps / KT_TO_MPS : null,
|
||||
category: record.category,
|
||||
})
|
||||
) {
|
||||
this.rejectedFixes += 1;
|
||||
this.rejectStreak += 1;
|
||||
this.lastCandidate = { at, lat, lon, accepted: false };
|
||||
if (this.rejectStreak < LOCAL_ADSB_REANCHOR_AFTER) {
|
||||
// The position is refused, not the record's telemetry.
|
||||
this._refreshTelemetry(record, nowMs);
|
||||
return false;
|
||||
}
|
||||
// Three refusals in a row: the stream moved on and the old anchor was
|
||||
// the outlier. Restart the history from the new fix.
|
||||
this.fixes = [];
|
||||
this.correction = null;
|
||||
}
|
||||
this.rejectStreak = 0;
|
||||
this.lastCandidate = { at, lat, lon, accepted: true };
|
||||
|
||||
const prior = this.anchor;
|
||||
const before = prior ? this.displayAt(nowMs, { slew: false }) : null;
|
||||
this.fixes.push(fix);
|
||||
this._trimHistory(at);
|
||||
// Position and telemetry are accepted independently: merged receivers
|
||||
// can deliver a newer position in a record whose last message is OLDER
|
||||
// than the telemetry the anchor already holds. The trail point keeps the
|
||||
// record's values as heard; the anchor keeps the newer telemetry.
|
||||
const recordTelemetryAt = telemetryTime(record);
|
||||
const keepTelemetry =
|
||||
prior &&
|
||||
this.telemetryAt !== null &&
|
||||
(recordTelemetryAt === null || recordTelemetryAt < this.telemetryAt);
|
||||
if (keepTelemetry) {
|
||||
this.anchor = {
|
||||
...this._anchorFrom(
|
||||
{
|
||||
...fix,
|
||||
altitudeFt: prior.altitudeFt,
|
||||
speedMps: prior.speedMps,
|
||||
trackDeg: prior.trackDeg,
|
||||
},
|
||||
{ verticalRateFpm: prior.verticalRateFpm },
|
||||
),
|
||||
altitudeAt: Number.isFinite(prior.altitudeAt)
|
||||
? prior.altitudeAt
|
||||
: prior.at,
|
||||
};
|
||||
} else {
|
||||
this.anchor = this._anchorFrom(fix, record);
|
||||
this.telemetryAt = recordTelemetryAt;
|
||||
}
|
||||
this._absorb(before, nowMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a record's altitude, speed, track and vertical rate to the current
|
||||
* anchor when they were heard after the anchor's telemetry, whether or not
|
||||
* its position is new.
|
||||
*/
|
||||
_refreshTelemetry(record, nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const newest = this.fixes.at(-1);
|
||||
if (!anchor || !newest) return;
|
||||
const at = telemetryTime(record);
|
||||
if (at === null || (this.telemetryAt !== null && at <= this.telemetryAt))
|
||||
return;
|
||||
this.telemetryAt = at;
|
||||
const altitudeFt = finite(record.altitudeFt) ?? anchor.altitudeFt;
|
||||
const speedMps = Number.isFinite(record.groundSpeedKt)
|
||||
? Math.max(0, record.groundSpeedKt) * KT_TO_MPS
|
||||
: anchor.speedMps;
|
||||
const trackDeg = Number.isFinite(record.trackDeg)
|
||||
? norm360(record.trackDeg)
|
||||
: anchor.trackDeg;
|
||||
const verticalRateFpm = finite(record.verticalRateFpm);
|
||||
if (
|
||||
altitudeFt === anchor.altitudeFt &&
|
||||
speedMps === anchor.speedMps &&
|
||||
trackDeg === anchor.trackDeg &&
|
||||
verticalRateFpm === anchor.verticalRateFpm
|
||||
)
|
||||
return;
|
||||
const before = this.displayAt(nowMs, { slew: false });
|
||||
// The trail point keeps its heard position; the anchor takes the newer
|
||||
// telemetry, with altitude extrapolated from when it was reported.
|
||||
this.anchor = {
|
||||
...this._anchorFrom(
|
||||
{ ...newest, altitudeFt, speedMps, trackDeg },
|
||||
{ verticalRateFpm },
|
||||
),
|
||||
altitudeAt: at,
|
||||
};
|
||||
this._absorb(before, nowMs);
|
||||
}
|
||||
|
||||
/** Slide from where the marker was drawn to the new anchor, or snap. */
|
||||
_absorb(before, nowMs) {
|
||||
if (before) {
|
||||
const after = this._raw(nowMs);
|
||||
const cosLat = Math.cos(after.lat * DEG);
|
||||
const north = (before.lat - after.lat) * DEG * EARTH_RADIUS_M;
|
||||
const east = (before.lon - after.lon) * DEG * EARTH_RADIUS_M * cosLat;
|
||||
const up =
|
||||
Number.isFinite(before.altitudeFt) && Number.isFinite(after.altitudeFt)
|
||||
? before.altitudeFt - after.altitudeFt
|
||||
: 0;
|
||||
this.correction =
|
||||
Math.hypot(north, east) > LOCAL_ADSB_SNAP_M
|
||||
? null
|
||||
: { east, north, upFt: up, startAt: nowMs };
|
||||
}
|
||||
}
|
||||
|
||||
_trimHistory(newestAt) {
|
||||
let drop = 0;
|
||||
while (
|
||||
drop < this.fixes.length - 1 &&
|
||||
(newestAt - this.fixes[drop].at > LOCAL_ADSB_HISTORY_MS ||
|
||||
this.fixes.length - drop > LOCAL_ADSB_HISTORY_POINTS)
|
||||
)
|
||||
drop += 1;
|
||||
if (drop) this.fixes.splice(0, drop);
|
||||
}
|
||||
|
||||
_anchorFrom(fix, record) {
|
||||
let chord = null;
|
||||
for (let index = this.fixes.length - 2; index >= 0; index -= 1) {
|
||||
if (fix.at - this.fixes[index].at < CHORD_BASELINE_MS) continue;
|
||||
chord = chordCourse(this.fixes[index], fix);
|
||||
break;
|
||||
}
|
||||
let courseDeg = fix.trackDeg;
|
||||
if (chord !== null)
|
||||
courseDeg =
|
||||
courseDeg === null
|
||||
? chord
|
||||
: lerpAngleDeg(courseDeg, chord, speedRamp(fix.speedMps));
|
||||
const samples = [];
|
||||
let lastSampleAt = -Infinity;
|
||||
for (const sample of this.fixes) {
|
||||
if (fix.at - sample.at > TURN_WINDOW_MS) continue;
|
||||
if (sample.at - lastSampleAt < CHORD_BASELINE_MS) continue;
|
||||
lastSampleAt = sample.at;
|
||||
samples.push({
|
||||
tSec: sample.at / 1000,
|
||||
trackDeg: sample.trackDeg,
|
||||
speedMps: sample.speedMps,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...fix,
|
||||
courseDeg,
|
||||
turnRateDps: estimateTurnRateDps(
|
||||
samples,
|
||||
undefined,
|
||||
undefined,
|
||||
TURN_MIN_SPEED_MPS,
|
||||
),
|
||||
verticalRateFpm: finite(record.verticalRateFpm),
|
||||
};
|
||||
}
|
||||
|
||||
_coastSeconds(nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const limit = staleCoastLimitSeconds({
|
||||
fixEpochMs: anchor.at,
|
||||
lastContactEpochMs: this.lastContactAt,
|
||||
minimumSec: COAST_MINIMUM_SEC,
|
||||
contactGraceSec: COAST_CONTACT_GRACE_SEC,
|
||||
maximumSec: COAST_MAXIMUM_SEC,
|
||||
});
|
||||
return Math.min(limit, Math.max(0, (nowMs - anchor.at) / 1000));
|
||||
}
|
||||
|
||||
_raw(nowMs) {
|
||||
const anchor = this.anchor;
|
||||
const dtSec = this._coastSeconds(nowMs);
|
||||
const moving =
|
||||
Number.isFinite(anchor.speedMps) &&
|
||||
anchor.speedMps >= COURSE_HOLD_SPEED_MPS &&
|
||||
Number.isFinite(anchor.courseDeg);
|
||||
const projected = moving
|
||||
? projectGroundArcLatLon(
|
||||
anchor.lat,
|
||||
anchor.lon,
|
||||
anchor.courseDeg,
|
||||
anchor.speedMps,
|
||||
anchor.turnRateDps,
|
||||
dtSec,
|
||||
)
|
||||
: { lat: anchor.lat, lon: anchor.lon };
|
||||
const altitudeDtSec = Number.isFinite(anchor.altitudeAt)
|
||||
? Math.min(dtSec, Math.max(0, (nowMs - anchor.altitudeAt) / 1000))
|
||||
: dtSec;
|
||||
const altitudeFt =
|
||||
Number.isFinite(anchor.altitudeFt) &&
|
||||
Number.isFinite(anchor.verticalRateFpm)
|
||||
? anchor.altitudeFt + (anchor.verticalRateFpm * altitudeDtSec) / 60
|
||||
: anchor.altitudeFt;
|
||||
return {
|
||||
lat: projected.lat,
|
||||
lon: projected.lon,
|
||||
altitudeFt,
|
||||
courseDeg: moving
|
||||
? norm360(anchor.courseDeg + anchor.turnRateDps * dtSec)
|
||||
: null,
|
||||
speedMps: anchor.speedMps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to draw the aircraft now.
|
||||
* @param {number} nowMs Current epoch ms.
|
||||
* @param {{slew?: boolean}} [options] `slew: false` reads without advancing
|
||||
* the rate-limited course.
|
||||
* @returns {{lat:number, lon:number, altitudeFt:number|null,
|
||||
* courseDeg:number|null}|null}
|
||||
*/
|
||||
displayAt(nowMs, { slew = true } = {}) {
|
||||
if (!this.anchor) return null;
|
||||
const raw = this._raw(nowMs);
|
||||
const correction = this.correction;
|
||||
if (correction) {
|
||||
const weight = 1 - (nowMs - correction.startAt) / DR_CORRECTION_MS;
|
||||
if (weight <= 0) this.correction = null;
|
||||
else {
|
||||
const w = Math.min(1, weight);
|
||||
raw.lat += (correction.north * w) / EARTH_RADIUS_M / DEG;
|
||||
raw.lon +=
|
||||
(correction.east * w) /
|
||||
(EARTH_RADIUS_M * Math.max(1e-6, Math.cos(raw.lat * DEG))) /
|
||||
DEG;
|
||||
if (Number.isFinite(raw.altitudeFt))
|
||||
raw.altitudeFt += correction.upFt * w;
|
||||
}
|
||||
}
|
||||
let courseDeg = this.courseDeg;
|
||||
// Hover and taxi noise: below the hold speed the course is not updated.
|
||||
const target =
|
||||
raw.courseDeg ?? (courseDeg === null ? this.anchor.trackDeg : null);
|
||||
if (slew && target !== null) {
|
||||
const dtSec =
|
||||
this.lastCourseAt === null
|
||||
? 0
|
||||
: Math.min(
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
Math.max(0, (nowMs - this.lastCourseAt) / 1000),
|
||||
);
|
||||
courseDeg = limitCourseStep(
|
||||
courseDeg,
|
||||
target,
|
||||
courseSlewCapDps(raw.speedMps, COURSE_MAX_DPS),
|
||||
dtSec,
|
||||
);
|
||||
this.courseDeg = courseDeg;
|
||||
this.lastCourseAt = nowMs;
|
||||
} else if (courseDeg === null && target !== null) {
|
||||
courseDeg = target;
|
||||
}
|
||||
return {
|
||||
lat: raw.lat,
|
||||
lon: raw.lon,
|
||||
altitudeFt: raw.altitudeFt,
|
||||
courseDeg,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
LOCAL_ADSB_HISTORY_MS,
|
||||
LOCAL_ADSB_HISTORY_POINTS,
|
||||
LocalAdsbMotion,
|
||||
} from './motion.js';
|
||||
|
||||
function fix(at, lat, overrides = {}) {
|
||||
return {
|
||||
icao: 'abc123',
|
||||
lat,
|
||||
lon: 0,
|
||||
altitudeFt: 5_000,
|
||||
groundSpeedKt: 120,
|
||||
trackDeg: 0,
|
||||
verticalRateFpm: 0,
|
||||
lastPositionAt: at,
|
||||
lastMessageAt: at,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// 120 kt north: degrees of latitude per second.
|
||||
const STEP = (120 * 1852) / 3600 / 111_195;
|
||||
|
||||
test('the heard history is bounded to 600 fixes and 10 minutes', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
for (let second = 0; second < 700; second += 1)
|
||||
motion.observe(fix(second * 1_000, second * STEP), second * 1_000);
|
||||
assert.equal(motion.fixes.length, LOCAL_ADSB_HISTORY_POINTS);
|
||||
assert.equal(motion.fixes[0].at, 100_000);
|
||||
|
||||
const sparse = new LocalAdsbMotion();
|
||||
for (let minute = 0; minute < 15; minute += 1) {
|
||||
const at = minute * 60_000;
|
||||
sparse.observe(fix(at, minute * 60 * STEP), at);
|
||||
}
|
||||
assert.ok(
|
||||
sparse.fixes.at(-1).at - sparse.fixes[0].at <= LOCAL_ADSB_HISTORY_MS,
|
||||
);
|
||||
assert.equal(sparse.fixes.length, 11);
|
||||
});
|
||||
|
||||
test('repeated and out-of-order fixes do not move the anchor', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
assert.equal(motion.observe(fix(10_000, 0), 10_000), true);
|
||||
assert.equal(motion.observe(fix(10_000, 0), 10_500), false);
|
||||
assert.equal(motion.observe(fix(9_000, 0), 10_500), false);
|
||||
// A feed re-reading the same fix with a jittered, later time.
|
||||
assert.equal(motion.observe(fix(10_080, 0), 11_000), false);
|
||||
assert.equal(motion.fixes.length, 1);
|
||||
});
|
||||
|
||||
test('coasting stops 10 s after the last message', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
const coasted = motion.displayAt(30_000).lat;
|
||||
assert.ok(Math.abs(coasted - 10 * STEP) < 1e-5, `${coasted}`);
|
||||
});
|
||||
|
||||
test('three refused fixes in a row restart the track from the new stream', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
for (let index = 1; index <= 2; index += 1)
|
||||
assert.equal(
|
||||
motion.observe(fix(index * 1_000, 1 + index * STEP), index * 1_000),
|
||||
false,
|
||||
);
|
||||
assert.equal(motion.rejectedFixes, 2);
|
||||
assert.equal(motion.observe(fix(3_000, 1 + 3 * STEP), 3_000), true);
|
||||
assert.equal(motion.rejectedFixes, 3);
|
||||
assert.equal(
|
||||
motion.fixes.length,
|
||||
1,
|
||||
'the history restarts at the new stream',
|
||||
);
|
||||
assert.ok(Math.abs(motion.displayAt(3_000).lat - (1 + 3 * STEP)) < 1e-9);
|
||||
});
|
||||
|
||||
test('the last known speed bounds a fix whose record carries none', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
// 1.2 nm in 10 s (432 kt): under the 1,000 kt unknown-speed cap but far
|
||||
// beyond the 120 kt the aircraft last reported.
|
||||
assert.equal(
|
||||
motion.observe(fix(10_000, 1.2 / 60, { groundSpeedKt: null }), 10_000),
|
||||
false,
|
||||
);
|
||||
assert.equal(motion.rejectedFixes, 1);
|
||||
});
|
||||
|
||||
test('re-observing one refused fix never re-anchors on it', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0), 0);
|
||||
const outlier = fix(1_000, 1);
|
||||
// The layer re-syncs the same record several times a second.
|
||||
for (let sync = 0; sync < 6; sync += 1)
|
||||
assert.equal(motion.observe(outlier, 1_000 + sync * 250), false);
|
||||
assert.equal(motion.rejectedFixes, 1, 'one fix is refused once');
|
||||
assert.equal(motion.rejectStreak, 1);
|
||||
assert.ok(motion.displayAt(2_000).lat < 0.01, 'still on the old track');
|
||||
// A jittered re-read of the same refused position is the same fix too.
|
||||
assert.equal(motion.observe(fix(1_080, 1), 2_500), false);
|
||||
assert.equal(motion.rejectStreak, 1);
|
||||
});
|
||||
|
||||
test('an unchanged position still delivers newer altitude and velocity', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 120 }), 0);
|
||||
// A hovering helicopter: same coordinates, climbing, now stationary.
|
||||
motion.observe(
|
||||
fix(5_000, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
verticalRateFpm: 0,
|
||||
trackDeg: 90,
|
||||
}),
|
||||
5_000,
|
||||
);
|
||||
assert.equal(motion.fixes.length, 1, 'no duplicate trail point');
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.anchor.trackDeg, 90);
|
||||
// Past the correction window the display settles on the new telemetry.
|
||||
const display = motion.displayAt(20_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
|
||||
});
|
||||
|
||||
test('a delayed position never rolls back newer telemetry', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
const step100 = (100 * 1852) / 3600 / 111_195;
|
||||
assert.equal(
|
||||
motion.observe(
|
||||
fix(10_000, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }),
|
||||
10_000,
|
||||
),
|
||||
true,
|
||||
);
|
||||
// Velocity and altitude heard at 20 s, no new position.
|
||||
motion.observe(
|
||||
fix(10_000, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
lastMessageAt: 20_000,
|
||||
}),
|
||||
20_000,
|
||||
);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.telemetryAt, 20_000);
|
||||
// Another receiver's record: a newer position (15 s) whose telemetry is
|
||||
// older than what the anchor already holds.
|
||||
const delayed = fix(15_000, 5 * step100, {
|
||||
altitudeFt: 1_000,
|
||||
groundSpeedKt: 100,
|
||||
});
|
||||
assert.equal(motion.observe(delayed, 21_000), true, 'the position is new');
|
||||
assert.equal(motion.fixes.length, 2);
|
||||
assert.equal(motion.anchor.lat, 5 * step100, 'the anchor moves');
|
||||
assert.equal(motion.anchor.speedMps, 0, 'telemetry stays 0 kt');
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500, 'telemetry stays 1,500 ft');
|
||||
assert.equal(motion.telemetryAt, 20_000, 'telemetryAt never moves back');
|
||||
assert.equal(motion.fixes.at(-1).altitudeFt, 1_000, 'trail point as heard');
|
||||
const display = motion.displayAt(40_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(display.lat - 5 * step100) < 1e-9,
|
||||
'a stopped aircraft does not coast',
|
||||
);
|
||||
});
|
||||
|
||||
test('a velocity/altitude-only update with the same position time still refreshes telemetry', () => {
|
||||
const motion = new LocalAdsbMotion();
|
||||
motion.observe(fix(0, 0, { altitudeFt: 1_000, groundSpeedKt: 100 }), 0);
|
||||
// No new position message: only velocity and altitude are newer.
|
||||
const newer = fix(0, 0, {
|
||||
altitudeFt: 1_500,
|
||||
groundSpeedKt: 0,
|
||||
trackDeg: 90,
|
||||
lastMessageAt: 5_000,
|
||||
});
|
||||
assert.equal(motion.observe(newer, 5_000), false, 'no new trail point');
|
||||
assert.equal(motion.fixes.length, 1);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500);
|
||||
assert.equal(motion.anchor.trackDeg, 90);
|
||||
// Re-reading that record is not newer telemetry, and an older message
|
||||
// never rolls it back.
|
||||
motion.observe(newer, 5_500);
|
||||
motion.observe(
|
||||
fix(0, 0, { altitudeFt: 900, groundSpeedKt: 300, lastMessageAt: 4_000 }),
|
||||
6_000,
|
||||
);
|
||||
assert.equal(motion.anchor.altitudeFt, 1_500);
|
||||
assert.equal(motion.anchor.speedMps, 0);
|
||||
const display = motion.displayAt(20_000);
|
||||
assert.ok(
|
||||
Math.abs(display.altitudeFt - 1_500) < 1e-6,
|
||||
`${display.altitudeFt}`,
|
||||
);
|
||||
assert.ok(Math.abs(display.lat) < 1e-9, 'a stopped aircraft does not coast');
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
export const LAYER_ID = 'local-adsb';
|
||||
export const LAYER_NAME = 'Local ADS-B';
|
||||
export const LAYER_SOURCE = 'RTL-SDR · WebUSB';
|
||||
/** Distinct from the public Flights palette so both can be drawn together. */
|
||||
export const LOCAL_ADSB_COLOR = '#ff4fd8';
|
||||
/** Ring drawn around 978 MHz UAT aircraft: same magenta family, lighter. */
|
||||
export const LOCAL_ADSB_UAT_RING_COLOR = '#ffb3ef';
|
||||
export const ENTITY_PREFIX = `${LAYER_ID}:`;
|
||||
/** Re-evaluate marker freshness and the card's position age this often. */
|
||||
export const LOCAL_ADSB_TICK_MS = 1_000;
|
||||
/** Coalesce receiver bursts into at most one scene sync per interval. */
|
||||
export const LOCAL_ADSB_SYNC_MS = 200;
|
||||
export const HEARD_BY_RECEIVER = 'Heard by your receiver';
|
||||
export const BAND_LABELS = Object.freeze({
|
||||
1090: '1090 MHz',
|
||||
978: '978 MHz UAT',
|
||||
});
|
||||
export const SOURCE_LABELS = Object.freeze({
|
||||
webusb: 'browser SDR',
|
||||
feed: 'decoder feed',
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { localReceiverFeedName } from './feedNames.js';
|
||||
import { LAYER_SOURCE } from './policy.js';
|
||||
|
||||
/**
|
||||
* Layers-panel status for Local ADS-B, from its two inputs: the browser
|
||||
* WebUSB receiver and the server's decoder feeds. Pure; no DOM or Cesium.
|
||||
*/
|
||||
|
||||
export const FEED_SOURCE = 'Decoder feeds';
|
||||
export const COMBINED_SOURCE = 'WebUSB + decoder feeds';
|
||||
|
||||
function plural(count, one, many) {
|
||||
return count === 1 ? one : many;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the feeds that are not live, grouped by status, e.g.
|
||||
* "feed 978 unreachable" or "feeds 1090, 978 stale".
|
||||
* @param {object[]} feeds Feed statuses from the route.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function describeFeedProblems(feeds) {
|
||||
const byStatus = new Map();
|
||||
for (const feed of feeds) {
|
||||
if (feed?.status === 'live') continue;
|
||||
const status = feed?.status || 'unreachable';
|
||||
if (!byStatus.has(status)) byStatus.set(status, new Set());
|
||||
byStatus.get(status).add(localReceiverFeedName(feed, feeds));
|
||||
}
|
||||
return [...byStatus]
|
||||
.map(([status, names]) => {
|
||||
const list = [...names];
|
||||
return `${plural(list.length, 'feed', 'feeds')} ${list.join(', ')} ${status}`;
|
||||
})
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
/** Whether the server reported at least one configured feed. */
|
||||
export function feedsConfigured(feedState) {
|
||||
return Boolean(
|
||||
feedState?.configured === true &&
|
||||
Array.isArray(feedState.feeds) &&
|
||||
feedState.feeds.length,
|
||||
);
|
||||
}
|
||||
|
||||
function webUsbStatus(receiver, heard, feedState) {
|
||||
if (receiver.status === 'error')
|
||||
return { status: 'error', error: receiver.message };
|
||||
if (receiver.status === 'connecting' || receiver.status === 'tuning')
|
||||
return { loading: true, loadingLabel: 'opening receiver' };
|
||||
if (
|
||||
feedState?.polling &&
|
||||
feedState.configured === null &&
|
||||
!receiver.connected
|
||||
)
|
||||
return { loading: true, loadingLabel: 'checking decoder feeds' };
|
||||
if (!receiver.webUsbSupported)
|
||||
return {
|
||||
status: 'idle',
|
||||
statusMessage: 'WebUSB needs desktop Chrome or Edge',
|
||||
};
|
||||
if (!receiver.connected)
|
||||
return { status: 'idle', statusMessage: 'connect a receiver in Radio' };
|
||||
if (receiver.mode !== 'adsb')
|
||||
return { status: 'idle', statusMessage: 'receiver is in FM mode' };
|
||||
const rate = Number.isFinite(receiver.messagesPerSecond)
|
||||
? `${receiver.messagesPerSecond} msg/s`
|
||||
: 'listening';
|
||||
return { status: 'streaming', loadingLabel: `${heard} heard · ${rate}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Status fields of `getStats()` for the Local ADS-B row.
|
||||
*
|
||||
* Without configured feeds this is exactly the WebUSB status. With feeds:
|
||||
* "2 feeds live · 14 heard" when every feed is live. While any input is
|
||||
* producing (the browser receiver streaming, or a feed live) the row stays
|
||||
* nominal and names the other feeds as a trailing note, e.g. "3 heard · USB
|
||||
* 5.8 msg/s · feed 1090 stale": a decoder stopped so the browser can take the
|
||||
* dongle is not a fault. With nothing producing, every readable feed stale is
|
||||
* STALE and anything else is an error.
|
||||
* @param {object} options
|
||||
* @param {object} options.receiver WebUSB receiver snapshot.
|
||||
* @param {object|null} options.feedState Feed poller snapshot.
|
||||
* @param {number} options.heard Merged aircraft heard in the last 60 s.
|
||||
* @returns {object} status / statusMessage / loading / loadingLabel / error /
|
||||
* stale / source.
|
||||
*/
|
||||
export function localAdsbStatus({ receiver, feedState, heard }) {
|
||||
if (!feedsConfigured(feedState))
|
||||
return {
|
||||
source: LAYER_SOURCE,
|
||||
...webUsbStatus(receiver, heard, feedState),
|
||||
};
|
||||
const usbActive = Boolean(receiver.connected && receiver.mode === 'adsb');
|
||||
const source = usbActive ? COMBINED_SOURCE : FEED_SOURCE;
|
||||
const live = feedState.feeds.filter((feed) => feed.status === 'live');
|
||||
const problems = [describeFeedProblems(feedState.feeds)].filter(Boolean);
|
||||
if (usbActive && receiver.status === 'error') problems.push('USB error');
|
||||
const rate =
|
||||
usbActive &&
|
||||
receiver.status !== 'error' &&
|
||||
Number.isFinite(receiver.messagesPerSecond)
|
||||
? ` · USB ${receiver.messagesPerSecond} msg/s`
|
||||
: '';
|
||||
const heardText = `${heard} heard`;
|
||||
if (!problems.length) {
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
loadingLabel: `${live.length} ${plural(live.length, 'feed', 'feeds')} live · ${heardText}${rate}`,
|
||||
};
|
||||
}
|
||||
const problemText = problems.join(' · ');
|
||||
const usbProducing = usbActive && receiver.status !== 'error';
|
||||
if (!live.length && !usbProducing) {
|
||||
// Decoders that stopped but still serve their last aircraft.json are
|
||||
// stale, not down: only an unreachable or unusable feed is an error.
|
||||
const readable = feedState.feeds.filter(
|
||||
(feed) => feed.status !== 'invalid',
|
||||
);
|
||||
if (readable.length && readable.every((feed) => feed.status === 'stale'))
|
||||
return {
|
||||
source,
|
||||
status: 'stale',
|
||||
stale: true,
|
||||
statusMessage: `${problemText} · ${heardText}`,
|
||||
};
|
||||
return {
|
||||
source,
|
||||
status: 'error',
|
||||
error: heard > 0 ? `${problemText} · ${heardText}` : problemText,
|
||||
};
|
||||
}
|
||||
return {
|
||||
source,
|
||||
status: 'streaming',
|
||||
loadingLabel: `${heardText}${rate} · ${problemText}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user