mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
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.
254 lines
7.3 KiB
JavaScript
254 lines
7.3 KiB
JavaScript
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';
|
|
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.
|
|
*/
|
|
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();
|
|
},
|
|
};
|
|
}
|