Files
gods-eye-view/src/sources/adsbRecords.js
T
Sameh Khamis 73e789489b chore: drop exports nothing imports
Knip, configured with the application, package exports, scripts, QA
harnesses, workers, tools and tests as entry points, reports exports
that no module imports. Each was checked by hand against string-based
and namespace imports.

- Symbols that are still used inside their own module lose only the
  `export` keyword (176 constants and helpers).
- Unused re-exports are removed from barrel modules (flight, military,
  vessel, CCTV, directions, transit history, local ADS-B and street-level
  policy modules).
- Declarations that nothing uses at all are deleted, among them
  AIS_WATCHDOG_STATUSES, applyFilter, isRadioCountryCode, compactText,
  DENSITY_STOPS, the unused CCTV endpoint constants, and the SDR band
  constants.
- Default exports that nothing imports are removed. The ones in
  src/data/earthquakes.js, src/data/telegeographySubmarineCables.js,
  src/data/wind.js and src/data/bhoteKoshiLocator.js each constructed a
  layer instance at import time that nothing used; those instances are
  no longer created.

Every export of a package.json entry stays, as does TOMTOM_CREDIT,
which the traffic layer reads from the credits namespace it is given.
2026-10-22 19:00:40 -07:00

401 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Source-agnostic records for locally received ADS-B aircraft.
*
* Every local receiver path produces the same plain record, so the Local ADS-B
* renderer does not care whether the frames were demodulated in this browser
* from a WebUSB RTL-SDR or decoded by a dump1090/readsb/dump978 process whose
* `aircraft.json` the server reads as a decoder feed:
*
* {
* icao: string, // lowercase 24-bit hex address
* callsign: string|null,
* lat: number|null, // degrees, null until a position decodes
* lon: number|null,
* altitudeFt: number|null, // barometric altitude
* groundSpeedKt: number|null,
* trackDeg: number|null, // true track over ground, [0, 360)
* verticalRateFpm: number|null,
* category: string|null, // ADS-B emitter category, dump1090 style
* // ('A1' light, 'A3' large, 'A7' rotorcraft…)
* onGround: boolean, // the receiver reported the aircraft on the
* // surface (dump1090 `alt_baro: "ground"`)
* lastPositionAt: number|null, // epoch ms of the newest decoded position
* lastMessageAt: number, // epoch ms of the newest CRC-valid message
* messageCount: number,
* rssiDbfs: number|null,
* band: '1090'|'978', // 1090 MHz Mode S/ES or 978 MHz UAT
* source: 'webusb'|'feed', // browser SDR or decoder feed
* }
*
* This module is portable: no Cesium, DOM, network or storage access.
*/
/** A marker is dropped once its newest position is this old. */
export const LOCAL_ADSB_POSITION_STALE_MS = 60_000;
/** An aircraft is forgotten once no message has arrived for this long. */
export const LOCAL_ADSB_MESSAGE_STALE_MS = 60_000;
const ICAO_PATTERN = /^[0-9a-f]{6}$/;
const CATEGORY_PATTERN = /^[A-D][0-7]$/;
/** A previous accepted fix older than this is no reference for a new one. */
export const LOCAL_ADSB_REFERENCE_MAX_AGE_MS = 10 * 60_000;
/** Speed limit (kt) for an aircraft that has not reported a ground speed. */
const LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT = 1_000;
/** Tighter limit (kt) for light aircraft and rotorcraft without a speed. */
const LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT = 350;
/** Reported ground speed is multiplied by this, plus the margin below. */
const LOCAL_ADSB_SPEED_FACTOR = 1.5;
const LOCAL_ADSB_SPEED_MARGIN_KT = 50;
/** Distance always allowed between two fixes (CPR and reception error). */
export const LOCAL_ADSB_POSITION_MARGIN_M = 500;
/**
* After this many consecutive rejected fixes the newest one becomes the new
* reference: the previously accepted fix was the outlier, not the stream.
*/
export const LOCAL_ADSB_REANCHOR_AFTER = 3;
const SLOW_CATEGORIES = new Set(['A1', 'A7', 'B1', 'B4']);
const KT_TO_MPS = 1852 / 3600;
const EARTH_RADIUS_M = 6_371_008.8;
function finiteOrNull(value) {
const number = typeof value === 'string' ? Number.NaN : Number(value);
return value !== null && value !== undefined && Number.isFinite(number)
? number
: null;
}
function normalizeIcao(value) {
const icao = String(value ?? '')
.trim()
.toLowerCase();
return ICAO_PATTERN.test(icao) ? icao : null;
}
function normalizeCallsign(value) {
const callsign = String(value ?? '')
.replace(/[^0-9A-Za-z]/g, ' ')
.trim()
.toUpperCase();
return callsign || null;
}
function normalizeTrack(value) {
const track = finiteOrNull(value);
return track === null ? null : ((track % 360) + 360) % 360;
}
/**
* Normalize an ADS-B emitter category to the dump1090 string ('A7').
* @param {unknown} value
* @returns {string|null}
*/
export function normalizeAdsbCategory(value) {
const category = String(value ?? '')
.trim()
.toUpperCase();
return CATEGORY_PATTERN.test(category) ? category : null;
}
function surfaceDistanceM(lat1, lon1, lat2, lon2) {
const toRad = Math.PI / 180;
const dLat = (lat2 - lat1) * toRad;
const dLon = (lon2 - lon1) * toRad;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.min(1, Math.sqrt(a)));
}
/**
* The fastest an aircraft is assumed to move, in knots: 1.5 × its reported
* ground speed + 50 kt, or 1,000 kt (350 kt for light aircraft, gliders and
* rotorcraft) when no speed is known.
* @param {{groundSpeedKt?:number|null, category?:string|null}} [options]
* @returns {number}
*/
export function localAdsbSpeedLimitKt({
groundSpeedKt = null,
category = null,
} = {}) {
if (Number.isFinite(groundSpeedKt))
return (
Math.max(0, groundSpeedKt) * LOCAL_ADSB_SPEED_FACTOR +
LOCAL_ADSB_SPEED_MARGIN_KT
);
return SLOW_CATEGORIES.has(normalizeAdsbCategory(category))
? LOCAL_ADSB_SLOW_CATEGORY_LIMIT_KT
: LOCAL_ADSB_UNKNOWN_SPEED_LIMIT_KT;
}
/**
* dump1090-style position sanity check: whether `next` is reachable from the
* previous accepted fix at a plausible speed. The allowance is a fixed
* reception margin plus the distance covered in the elapsed time + 1 s at
* 1.5 × the reported ground speed + 50 kt, or at 1,000 kt (350 kt for light
* aircraft, gliders and rotorcraft) when no speed is known. Without a
* reference younger than 10 minutes every fix is plausible.
* @param {{lat:number, lon:number, at:number}|null} previous Last accepted fix.
* @param {{lat:number, lon:number, at:number}} next Candidate fix.
* @param {{groundSpeedKt?:number|null, category?:string|null}} [options]
* @returns {boolean}
*/
export function localAdsbFixIsPlausible(
previous,
next,
{ groundSpeedKt = null, category = null } = {},
) {
if (
!previous ||
!Number.isFinite(previous.lat) ||
!Number.isFinite(previous.lon) ||
!Number.isFinite(previous.at) ||
!Number.isFinite(next?.at)
)
return true;
const elapsedMs = next.at - previous.at;
if (elapsedMs < 0 || elapsedMs > LOCAL_ADSB_REFERENCE_MAX_AGE_MS) return true;
const limitKt = localAdsbSpeedLimitKt({ groundSpeedKt, category });
const allowedM =
LOCAL_ADSB_POSITION_MARGIN_M +
((elapsedMs + 1_000) / 1_000) * limitKt * KT_TO_MPS;
return (
surfaceDistanceM(previous.lat, previous.lon, next.lat, next.lon) <= allowedM
);
}
function normalizePosition(lat, lon) {
const latitude = finiteOrNull(lat);
const longitude = finiteOrNull(lon);
if (
latitude === null ||
longitude === null ||
Math.abs(latitude) > 90 ||
Math.abs(longitude) > 180
)
return { lat: null, lon: null };
return { lat: latitude, lon: longitude };
}
/**
* Map one track from the browser Mode S decoder into a local ADS-B record.
* @param {object} track Decoder track (icao, callsign, latitude, longitude,
* altitudeFt, speedKt, headingDeg, verticalRateFpm, messages, lastSeen,
* lastPositionAt, onGround from surface position messages).
* @returns {object|null} Normalized record, or null without a valid address.
*/
export function recordFromDecoderTrack(track) {
const icao = normalizeIcao(track?.icao);
if (!icao) return null;
const { lat, lon } = normalizePosition(track.latitude, track.longitude);
return {
icao,
callsign: normalizeCallsign(track.callsign),
category: normalizeAdsbCategory(track.category),
onGround: track.onGround === true,
lat,
lon,
altitudeFt: finiteOrNull(track.altitudeFt),
groundSpeedKt: finiteOrNull(track.speedKt),
trackDeg: normalizeTrack(track.headingDeg),
verticalRateFpm: finiteOrNull(track.verticalRateFpm),
lastPositionAt: lat === null ? null : finiteOrNull(track.lastPositionAt),
lastMessageAt: finiteOrNull(track.lastSeen) ?? 0,
messageCount: Math.max(0, Math.trunc(finiteOrNull(track.messages) ?? 0)),
rssiDbfs: null,
band: '1090',
source: 'webusb',
};
}
/**
* Map a dump1090/readsb `aircraft.json` document into local ADS-B records.
*
* Ages (`seen`, `seen_pos`) are relative to the document's own `now`, so they
* are anchored to the caller's receipt clock rather than trusting the
* receiver host's wall clock. Entries without a valid 24-bit address (for
* example non-ICAO TIS-B `~` addresses) are skipped.
* skyaware978 writes the same document shape for 978 MHz UAT, so one adapter
* serves both bands; the caller names the band.
* @param {object} json Parsed aircraft.json document.
* @param {number} nowMs Local epoch ms at which the document was received.
* @param {object} [options]
* @param {'1090'|'978'} [options.band='1090'] Band the feed decodes.
* @returns {object[]} Normalized records.
*/
export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) {
const recordBand = band === '978' ? '978' : '1090';
const receivedAt = finiteOrNull(nowMs);
if (receivedAt === null || !Array.isArray(json?.aircraft)) return [];
const records = [];
for (const entry of json.aircraft) {
const icao = normalizeIcao(entry?.hex);
if (!icao) continue;
const seenS = Math.max(0, finiteOrNull(entry.seen) ?? 0);
const seenPosS = finiteOrNull(entry.seen_pos);
const position = normalizePosition(entry.lat, entry.lon);
const hasPosition = position.lat !== null && seenPosS !== null;
const altitude =
entry.alt_baro === 'ground' ? 0 : finiteOrNull(entry.alt_baro);
records.push({
icao,
callsign: normalizeCallsign(entry.flight),
category: normalizeAdsbCategory(entry.category),
onGround: entry.alt_baro === 'ground',
lat: hasPosition ? position.lat : null,
lon: hasPosition ? position.lon : null,
altitudeFt: altitude,
groundSpeedKt: finiteOrNull(entry.gs),
trackDeg: normalizeTrack(entry.track),
verticalRateFpm: finiteOrNull(entry.baro_rate),
lastPositionAt: hasPosition
? receivedAt - Math.max(0, seenPosS) * 1000
: null,
lastMessageAt: receivedAt - seenS * 1000,
messageCount: Math.max(0, Math.trunc(finiteOrNull(entry.messages) ?? 0)),
rssiDbfs: finiteOrNull(entry.rssi),
band: recordBand,
source: 'feed',
});
}
return records;
}
/**
* Whether a record has been heard recently enough to keep.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms.
* @returns {boolean}
*/
export function localAdsbRecordIsLive(record, nowMs) {
return (
Number.isFinite(record?.lastMessageAt) &&
nowMs - record.lastMessageAt < LOCAL_ADSB_MESSAGE_STALE_MS
);
}
/**
* Whether a record carries a position fresh enough to draw a marker.
* @param {object} record Local ADS-B record.
* @param {number} nowMs Current epoch ms.
* @returns {boolean}
*/
export function localAdsbPositionIsFresh(record, nowMs) {
return (
localAdsbRecordIsLive(record, nowMs) &&
Number.isFinite(record.lat) &&
Number.isFinite(record.lon) &&
Number.isFinite(record.lastPositionAt) &&
nowMs - record.lastPositionAt < LOCAL_ADSB_POSITION_STALE_MS
);
}
/**
* Receiver counts shown beside the gain control.
* @param {object[]} records Local ADS-B records.
* @param {number} nowMs Current epoch ms.
* @returns {{heard:number, positioned:number}}
*/
export function summarizeLocalAdsb(records, nowMs) {
let heard = 0;
let positioned = 0;
for (const record of Array.isArray(records) ? records : []) {
if (!localAdsbRecordIsLive(record, nowMs)) continue;
heard += 1;
if (localAdsbPositionIsFresh(record, nowMs)) positioned += 1;
}
return { heard, positioned };
}
const BAND_ORDER = Object.freeze(['1090', '978']);
const SOURCE_ORDER = Object.freeze(['webusb', 'feed']);
function ordered(values, order) {
return order.filter((value) => values.has(value));
}
function positionTime(record) {
return Number.isFinite(record?.lastPositionAt)
? record.lastPositionAt
: Number.NEGATIVE_INFINITY;
}
function messageTime(record) {
return Number.isFinite(record?.lastMessageAt)
? record.lastMessageAt
: Number.NEGATIVE_INFINITY;
}
/**
* Whether `candidate` should replace `current` for the same ICAO: the most
* recent position wins, and on a tie the most recent message.
* @param {object} candidate
* @param {object} current
* @returns {boolean}
*/
export function localAdsbRecordIsNewer(candidate, current) {
const byPosition = positionTime(candidate) - positionTime(current);
if (byPosition !== 0 && !Number.isNaN(byPosition)) return byPosition > 0;
return messageTime(candidate) > messageTime(current);
}
/**
* Merge records from every local input (browser SDR, decoder feeds) into one
* record per ICAO.
*
* Keeps the record with the most recent position (tie: most recent message)
* and annotates it with every band and source that heard the aircraft within
* the last 60 s. A category missing from the winning record is taken from
* another input that decoded it. `memory` (a Map owned by the caller) carries those
* receptions across calls, so an input that drops an aircraft does not erase
* that it was heard there moments ago.
* @param {object[][]} inputs Record lists; each record has `band`/`source`.
* @param {number} nowMs Current epoch ms.
* @param {Map<string, Map<string, number>>} [memory] Per-ICAO reception log
* keyed `band|source`, mutated in place and pruned to 60 s.
* @returns {object[]} Merged records with `bands` and `sources` arrays.
*/
export function mergeLocalAdsbRecords(inputs, nowMs, memory = new Map()) {
const best = new Map();
const categories = new Map();
for (const list of Array.isArray(inputs) ? inputs : []) {
for (const record of Array.isArray(list) ? list : []) {
if (!record?.icao || !localAdsbRecordIsLive(record, nowMs)) continue;
if (record.category && !categories.has(record.icao))
categories.set(record.icao, record.category);
const current = best.get(record.icao);
if (!current || localAdsbRecordIsNewer(record, current))
best.set(record.icao, record);
let heard = memory.get(record.icao);
if (!heard) {
heard = new Map();
memory.set(record.icao, heard);
}
const key = `${record.band || '1090'}|${record.source || 'webusb'}`;
heard.set(key, Math.max(heard.get(key) ?? 0, record.lastMessageAt));
}
}
for (const [icao, heard] of memory) {
for (const [key, at] of heard)
if (nowMs - at >= LOCAL_ADSB_MESSAGE_STALE_MS) heard.delete(key);
if (!heard.size) memory.delete(icao);
}
const merged = [];
for (const [icao, record] of best) {
const bands = new Set();
const sources = new Set();
for (const key of memory.get(icao)?.keys() || []) {
const [band, source] = key.split('|');
bands.add(band);
sources.add(source);
}
merged.push({
...record,
category: record.category || categories.get(icao) || null,
bands: ordered(bands, BAND_ORDER),
sources: ordered(sources, SOURCE_ORDER),
});
}
return merged;
}