mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Format live source modules and adopt their formatting checks
This commit is contained in:
1 parent
e968053d2c
commit
80c5a09f15
7 files changed
+533
-125
No files matched your search
@@ -248,5 +248,11 @@
|
|||||||
"src/ui/styles/cockpit.css",
|
"src/ui/styles/cockpit.css",
|
||||||
"src/ui/styles/command-dock-compact.css",
|
"src/ui/styles/command-dock-compact.css",
|
||||||
"src/app/stateChannel.js",
|
"src/app/stateChannel.js",
|
||||||
"src/app/stateChannel.test.mjs"
|
"src/app/stateChannel.test.mjs",
|
||||||
|
"src/sources/live/aircraft.js",
|
||||||
|
"src/sources/live/contract.js",
|
||||||
|
"src/sources/live/contract.test.mjs",
|
||||||
|
"src/sources/live/index.js",
|
||||||
|
"src/sources/live/standalone.js",
|
||||||
|
"src/sources/live/vessels.js"
|
||||||
]
|
]
|
||||||
+124
-34
@@ -1,4 +1,10 @@
|
|||||||
import { admitRecords, cleanText, coordinates, epoch, finite } from './contract.js';
|
import {
|
||||||
|
admitRecords,
|
||||||
|
cleanText,
|
||||||
|
coordinates,
|
||||||
|
epoch,
|
||||||
|
finite,
|
||||||
|
} from './contract.js';
|
||||||
|
|
||||||
/** Normalize the OpenSky state-vector route, including its regional fallback. */
|
/** Normalize the OpenSky state-vector route, including its regional fallback. */
|
||||||
export function normalizeOpenSkyAircraft(row) {
|
export function normalizeOpenSkyAircraft(row) {
|
||||||
@@ -6,71 +12,155 @@ export function normalizeOpenSkyAircraft(row) {
|
|||||||
const id = cleanText(row[0]).toLowerCase();
|
const id = cleanText(row[0]).toLowerCase();
|
||||||
if (!id || !coordinates(row[6], row[5])) return null;
|
if (!id || !coordinates(row[6], row[5])) return null;
|
||||||
return {
|
return {
|
||||||
id, reference: id, latitude: row[6], longitude: row[5],
|
id,
|
||||||
callsign: cleanText(row[1]), originCountry: cleanText(row[2]),
|
reference: id,
|
||||||
positionTimeMs: epoch(row[3], 1000), contactTimeMs: epoch(row[4], 1000),
|
latitude: row[6],
|
||||||
baroAltitudeM: finite(row[7]), ellipsoidAltitudeM: finite(row[13]),
|
longitude: row[5],
|
||||||
onGround: row[8] === true, speedMps: finite(row[9]), courseDeg: finite(row[10]),
|
callsign: cleanText(row[1]),
|
||||||
verticalRateMps: finite(row[11]), category: finite(row[17]),
|
originCountry: cleanText(row[2]),
|
||||||
typeCode: null, registration: null, operator: null,
|
positionTimeMs: epoch(row[3], 1000),
|
||||||
|
contactTimeMs: epoch(row[4], 1000),
|
||||||
|
baroAltitudeM: finite(row[7]),
|
||||||
|
ellipsoidAltitudeM: finite(row[13]),
|
||||||
|
onGround: row[8] === true,
|
||||||
|
speedMps: finite(row[9]),
|
||||||
|
courseDeg: finite(row[10]),
|
||||||
|
verticalRateMps: finite(row[11]),
|
||||||
|
category: finite(row[17]),
|
||||||
|
typeCode: null,
|
||||||
|
registration: null,
|
||||||
|
operator: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Normalize readsb observations. The caller supplies the source snapshot epoch. */
|
/** Normalize readsb observations. The caller supplies the source snapshot epoch. */
|
||||||
export function normalizeReadsbAircraft(row, snapshotTimeMs) {
|
export function normalizeReadsbAircraft(row, snapshotTimeMs) {
|
||||||
const id = cleanText(row?.hex).toLowerCase();
|
const id = cleanText(row?.hex).toLowerCase();
|
||||||
const latitude = finite(row?.lat), longitude = finite(row?.lon);
|
const latitude = finite(row?.lat),
|
||||||
|
longitude = finite(row?.lon);
|
||||||
if (!id || !coordinates(latitude, longitude)) return null;
|
if (!id || !coordinates(latitude, longitude)) return null;
|
||||||
const baroFt = finite(row.alt_baro), geoFt = finite(row.alt_geom);
|
const baroFt = finite(row.alt_baro),
|
||||||
const speedKts = finite(row.gs), rateFtMin = finite(row.baro_rate);
|
geoFt = finite(row.alt_geom);
|
||||||
const seen = finite(row.seen), seenPos = finite(row.seen_pos);
|
const speedKts = finite(row.gs),
|
||||||
|
rateFtMin = finite(row.baro_rate);
|
||||||
|
const seen = finite(row.seen),
|
||||||
|
seenPos = finite(row.seen_pos);
|
||||||
return {
|
return {
|
||||||
id, reference: id, latitude, longitude,
|
id,
|
||||||
callsign: cleanText(row.flight), originCountry: null,
|
reference: id,
|
||||||
positionTimeMs: snapshotTimeMs == null ? null : snapshotTimeMs - (seenPos ?? 0) * 1000,
|
latitude,
|
||||||
contactTimeMs: snapshotTimeMs == null || seen == null ? null : snapshotTimeMs - seen * 1000,
|
longitude,
|
||||||
|
callsign: cleanText(row.flight),
|
||||||
|
originCountry: null,
|
||||||
|
positionTimeMs:
|
||||||
|
snapshotTimeMs == null ? null : snapshotTimeMs - (seenPos ?? 0) * 1000,
|
||||||
|
contactTimeMs:
|
||||||
|
snapshotTimeMs == null || seen == null
|
||||||
|
? null
|
||||||
|
: snapshotTimeMs - seen * 1000,
|
||||||
baroAltitudeM: baroFt == null ? null : baroFt * 0.3048,
|
baroAltitudeM: baroFt == null ? null : baroFt * 0.3048,
|
||||||
ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048,
|
ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048,
|
||||||
onGround: cleanText(row.alt_baro).toLowerCase() === 'ground',
|
onGround: cleanText(row.alt_baro).toLowerCase() === 'ground',
|
||||||
speedMps: speedKts == null ? null : speedKts * 0.514444,
|
speedMps: speedKts == null ? null : speedKts * 0.514444,
|
||||||
courseDeg: finite(row.track), verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508,
|
courseDeg: finite(row.track),
|
||||||
category: row.category ?? null, typeCode: cleanText(row.t),
|
verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508,
|
||||||
registration: cleanText(row.r), operator: cleanText(row.ownOp),
|
category: row.category ?? null,
|
||||||
|
typeCode: cleanText(row.t),
|
||||||
|
registration: cleanText(row.r),
|
||||||
|
operator: cleanText(row.ownOp),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openSkySnapshot(payload, { source = 'OpenSky Network', coverage = 'worldwide upstream snapshot', now = Date.now(), stale = false } = {}) {
|
export function openSkySnapshot(
|
||||||
const admitted = admitRecords(payload?.states, normalizeOpenSkyAircraft, 'OpenSky');
|
payload,
|
||||||
|
{
|
||||||
|
source = 'OpenSky Network',
|
||||||
|
coverage = 'worldwide upstream snapshot',
|
||||||
|
now = Date.now(),
|
||||||
|
stale = false,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const admitted = admitRecords(
|
||||||
|
payload?.states,
|
||||||
|
normalizeOpenSkyAircraft,
|
||||||
|
'OpenSky',
|
||||||
|
);
|
||||||
const observedAtMs = epoch(payload?.time, 1000);
|
const observedAtMs = epoch(payload?.time, 1000);
|
||||||
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
|
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
|
||||||
return { ...admitted, source, coverage, observedAtMs, ageMs,
|
return {
|
||||||
|
...admitted,
|
||||||
|
source,
|
||||||
|
coverage,
|
||||||
|
observedAtMs,
|
||||||
|
ageMs,
|
||||||
stale: stale || (ageMs != null && ageMs > 120000),
|
stale: stale || (ageMs != null && ageMs > 120000),
|
||||||
freshness: observedAtMs == null ? 'unknown' : (stale || ageMs > 120000) ? 'stale' : 'current',
|
freshness:
|
||||||
|
observedAtMs == null
|
||||||
|
? 'unknown'
|
||||||
|
: stale || ageMs > 120000
|
||||||
|
? 'stale'
|
||||||
|
: 'current',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readsbSnapshot(payload, { observedAtMs, source = 'adsb.lol', coverage = 'military upstream snapshot', now = Date.now(), stale = false } = {}) {
|
export function readsbSnapshot(
|
||||||
const admitted = admitRecords(payload?.ac, row => normalizeReadsbAircraft(row, observedAtMs), 'adsb.lol');
|
payload,
|
||||||
|
{
|
||||||
|
observedAtMs,
|
||||||
|
source = 'adsb.lol',
|
||||||
|
coverage = 'military upstream snapshot',
|
||||||
|
now = Date.now(),
|
||||||
|
stale = false,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const admitted = admitRecords(
|
||||||
|
payload?.ac,
|
||||||
|
(row) => normalizeReadsbAircraft(row, observedAtMs),
|
||||||
|
'adsb.lol',
|
||||||
|
);
|
||||||
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
|
const ageMs = observedAtMs == null ? null : Math.max(0, now - observedAtMs);
|
||||||
return { ...admitted, source, coverage, observedAtMs, ageMs, stale,
|
return {
|
||||||
|
...admitted,
|
||||||
|
source,
|
||||||
|
coverage,
|
||||||
|
observedAtMs,
|
||||||
|
ageMs,
|
||||||
|
stale,
|
||||||
freshness: observedAtMs == null ? 'unknown' : stale ? 'stale' : 'current',
|
freshness: observedAtMs == null ? 'unknown' : stale ? 'stale' : 'current',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Track altitude remains barometric; renderers choose a visual ground fallback. */
|
/** Track altitude remains barometric; renderers choose a visual ground fallback. */
|
||||||
export function normalizeAircraftTrack(rows, { baseTimeMs = 0, readsb = false } = {}) {
|
export function normalizeAircraftTrack(
|
||||||
|
rows,
|
||||||
|
{ baseTimeMs = 0, readsb = false } = {},
|
||||||
|
) {
|
||||||
if (!Array.isArray(rows)) return [];
|
if (!Array.isArray(rows)) return [];
|
||||||
return rows.flatMap(row => {
|
return rows.flatMap((row) => {
|
||||||
if (!Array.isArray(row)) return [];
|
if (!Array.isArray(row)) return [];
|
||||||
const latitude = finite(row[1]), longitude = finite(row[2]);
|
const latitude = finite(row[1]),
|
||||||
|
longitude = finite(row[2]);
|
||||||
const offset = finite(row[0]);
|
const offset = finite(row[0]);
|
||||||
const observedAtMs = offset == null ? null : baseTimeMs + offset * 1000;
|
const observedAtMs = offset == null ? null : baseTimeMs + offset * 1000;
|
||||||
if (!coordinates(latitude, longitude) || observedAtMs == null || observedAtMs <= 0) return [];
|
if (
|
||||||
|
!coordinates(latitude, longitude) ||
|
||||||
|
observedAtMs == null ||
|
||||||
|
observedAtMs <= 0
|
||||||
|
)
|
||||||
|
return [];
|
||||||
const altitude = finite(row[3]);
|
const altitude = finite(row[3]);
|
||||||
return [{ latitude, longitude, observedAtMs,
|
return [
|
||||||
baroAltitudeM: altitude == null ? null : altitude * (readsb ? 0.3048 : 1),
|
{
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
observedAtMs,
|
||||||
|
baroAltitudeM:
|
||||||
|
altitude == null ? null : altitude * (readsb ? 0.3048 : 1),
|
||||||
ellipsoidAltitudeM: null,
|
ellipsoidAltitudeM: null,
|
||||||
onGround: readsb ? cleanText(row[3]).toLowerCase() === 'ground' : row[5] === true,
|
onGround: readsb
|
||||||
}];
|
? cleanText(row[3]).toLowerCase() === 'ground'
|
||||||
|
: row[5] === true,
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,11 @@
|
|||||||
* Records contain observation data only, with no scene objects or transport data.
|
* Records contain observation data only, with no scene objects or transport data.
|
||||||
*/
|
*/
|
||||||
export class LiveSourceError extends Error {
|
export class LiveSourceError extends Error {
|
||||||
constructor(code, message, { status = null, retryAfterMs = 20000, source = null } = {}) {
|
constructor(
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
{ status = null, retryAfterMs = 20000, source = null } = {},
|
||||||
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
this.name = 'LiveSourceError';
|
this.name = 'LiveSourceError';
|
||||||
this.code = code;
|
this.code = code;
|
||||||
@@ -36,13 +40,18 @@ export function cleanText(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function coordinates(latitude, longitude) {
|
export function coordinates(latitude, longitude) {
|
||||||
return Number.isFinite(latitude) && Math.abs(latitude) <= 90
|
return (
|
||||||
&& Number.isFinite(longitude) && Math.abs(longitude) <= 180;
|
Number.isFinite(latitude) &&
|
||||||
|
Math.abs(latitude) <= 90 &&
|
||||||
|
Number.isFinite(longitude) &&
|
||||||
|
Math.abs(longitude) <= 180
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
|
/** Admit a snapshot atomically; an all-invalid nonempty feed is unavailable. */
|
||||||
export function admitRecords(rows, normalize, label) {
|
export function admitRecords(rows, normalize, label) {
|
||||||
if (!Array.isArray(rows)) throw new LiveSourceError('malformed', `Malformed ${label} response`);
|
if (!Array.isArray(rows))
|
||||||
|
throw new LiveSourceError('malformed', `Malformed ${label} response`);
|
||||||
const records = [];
|
const records = [];
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -55,11 +64,20 @@ export function admitRecords(rows, normalize, label) {
|
|||||||
if (rows.length && !records.length) {
|
if (rows.length && !records.length) {
|
||||||
throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`);
|
throw new LiveSourceError('malformed', `Malformed ${label} aircraft rows`);
|
||||||
}
|
}
|
||||||
return { records, complete: records.length === rows.length, rejectedCount: rows.length - records.length };
|
return {
|
||||||
|
records,
|
||||||
|
complete: records.length === rows.length,
|
||||||
|
rejectedCount: rows.length - records.length,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancellation is checked after body parsing even when a transport ignores it. */
|
/** Cancellation is checked after body parsing even when a transport ignores it. */
|
||||||
export async function readResponse(fetchImpl, url, { signal, ...init } = {}, source = 'Live source') {
|
export async function readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
url,
|
||||||
|
{ signal, ...init } = {},
|
||||||
|
source = 'Live source',
|
||||||
|
) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
@@ -68,18 +86,35 @@ export async function readResponse(fetchImpl, url, { signal, ...init } = {}, sou
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
if (error?.name === 'AbortError') throw error;
|
if (error?.name === 'AbortError') throw error;
|
||||||
throw new LiveSourceError('unavailable', `${source} network error`, { source });
|
throw new LiveSourceError('unavailable', `${source} network error`, {
|
||||||
|
source,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let payload = null;
|
let payload = null;
|
||||||
try { payload = await response.json(); } catch { /* classified below */ }
|
try {
|
||||||
|
payload = await response.json();
|
||||||
|
} catch {
|
||||||
|
/* classified below */
|
||||||
|
}
|
||||||
signal?.throwIfAborted();
|
signal?.throwIfAborted();
|
||||||
return { response, payload };
|
return { response, payload };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function httpError(response, source) {
|
export function httpError(response, source) {
|
||||||
const status = response.status;
|
const status = response.status;
|
||||||
const code = status === 429 ? 'limited' : status === 401 || status === 403 ? 'denied' : 'unavailable';
|
const code =
|
||||||
return new LiveSourceError(code, status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`, {
|
status === 429
|
||||||
status, source, retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000,
|
? 'limited'
|
||||||
});
|
: status === 401 || status === 403
|
||||||
|
? 'denied'
|
||||||
|
: 'unavailable';
|
||||||
|
return new LiveSourceError(
|
||||||
|
code,
|
||||||
|
status === 429 ? `${source} rate limited` : `${source} HTTP ${status}`,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
retryAfterMs: code === 'limited' || code === 'denied' ? 45000 : 20000,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,13 +1,40 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createOpenSkySource, createAdsbLolSource, createAisStreamSource, normalizeReadsbAircraft, normalizeVesselObservation, openSkySnapshot, readsbSnapshot } from './index.js';
|
import {
|
||||||
|
createOpenSkySource,
|
||||||
|
createAdsbLolSource,
|
||||||
|
createAisStreamSource,
|
||||||
|
normalizeReadsbAircraft,
|
||||||
|
normalizeVesselObservation,
|
||||||
|
openSkySnapshot,
|
||||||
|
readsbSnapshot,
|
||||||
|
} from './index.js';
|
||||||
|
|
||||||
const now = 1800000000000;
|
const now = 1800000000000;
|
||||||
const aircraft = ['AbC123', 'TEST', 'Example', now / 1000 - 15, now / 1000 - 2, -97, 30, 1000, false, 100, 250, -2, null, 1040];
|
const aircraft = [
|
||||||
const response = (payload, headers = {}, status = 200) => Response.json(payload, { headers, status });
|
'AbC123',
|
||||||
|
'TEST',
|
||||||
|
'Example',
|
||||||
|
now / 1000 - 15,
|
||||||
|
now / 1000 - 2,
|
||||||
|
-97,
|
||||||
|
30,
|
||||||
|
1000,
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
250,
|
||||||
|
-2,
|
||||||
|
null,
|
||||||
|
1040,
|
||||||
|
];
|
||||||
|
const response = (payload, headers = {}, status = 200) =>
|
||||||
|
Response.json(payload, { headers, status });
|
||||||
|
|
||||||
test('civil observations retain identity, separate altitude datums and source epochs', () => {
|
test('civil observations retain identity, separate altitude datums and source epochs', () => {
|
||||||
const snapshot = openSkySnapshot({ time: now / 1000 - 130, states: [aircraft] }, { now, source: 'Test source', coverage: 'regional' });
|
const snapshot = openSkySnapshot(
|
||||||
|
{ time: now / 1000 - 130, states: [aircraft] },
|
||||||
|
{ now, source: 'Test source', coverage: 'regional' },
|
||||||
|
);
|
||||||
const row = snapshot.records[0];
|
const row = snapshot.records[0];
|
||||||
assert.equal(row.id, 'abc123');
|
assert.equal(row.id, 'abc123');
|
||||||
assert.equal(row.baroAltitudeM, 1000);
|
assert.equal(row.baroAltitudeM, 1000);
|
||||||
@@ -17,40 +44,77 @@ test('civil observations retain identity, separate altitude datums and source ep
|
|||||||
assert.equal(snapshot.ageMs, 130000);
|
assert.equal(snapshot.ageMs, 130000);
|
||||||
assert.equal(snapshot.freshness, 'stale');
|
assert.equal(snapshot.freshness, 'stale');
|
||||||
assert.equal(snapshot.coverage, 'regional');
|
assert.equal(snapshot.coverage, 'regional');
|
||||||
const missing = openSkySnapshot({ states: [[...aircraft.slice(0, 3), null, null, ...aircraft.slice(5)]] }, { now });
|
const missing = openSkySnapshot(
|
||||||
|
{ states: [[...aircraft.slice(0, 3), null, null, ...aircraft.slice(5)]] },
|
||||||
|
{ now },
|
||||||
|
);
|
||||||
assert.equal(missing.observedAtMs, null);
|
assert.equal(missing.observedAtMs, null);
|
||||||
assert.equal(missing.freshness, 'unknown');
|
assert.equal(missing.freshness, 'unknown');
|
||||||
assert.equal(missing.records[0].positionTimeMs, null);
|
assert.equal(missing.records[0].positionTimeMs, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mixed-invalid and duplicate admissions are explicitly incomplete; all-invalid rejects', () => {
|
test('mixed-invalid and duplicate admissions are explicitly incomplete; all-invalid rejects', () => {
|
||||||
const invalid = [...aircraft]; invalid[5] = 181;
|
const invalid = [...aircraft];
|
||||||
|
invalid[5] = 181;
|
||||||
const partial = openSkySnapshot({ states: [aircraft, invalid, aircraft] });
|
const partial = openSkySnapshot({ states: [aircraft, invalid, aircraft] });
|
||||||
assert.equal(partial.records.length, 1);
|
assert.equal(partial.records.length, 1);
|
||||||
assert.equal(partial.complete, false);
|
assert.equal(partial.complete, false);
|
||||||
assert.equal(partial.rejectedCount, 2);
|
assert.equal(partial.rejectedCount, 2);
|
||||||
assert.throws(() => openSkySnapshot({ states: [invalid] }), { code: 'malformed' });
|
assert.throws(() => openSkySnapshot({ states: [invalid] }), {
|
||||||
|
code: 'malformed',
|
||||||
|
});
|
||||||
assert.throws(() => openSkySnapshot({}), { code: 'malformed' });
|
assert.throws(() => openSkySnapshot({}), { code: 'malformed' });
|
||||||
assert.equal(openSkySnapshot({ states: [] }).complete, true);
|
assert.equal(openSkySnapshot({ states: [] }).complete, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
|
test('readsb units and position/contact ages are normalized once against the source epoch', () => {
|
||||||
const row = normalizeReadsbAircraft({ hex: 'abc123', lat: '30', lon: '-97', alt_baro: 10000, alt_geom: 10100, gs: 120, baro_rate: -600, seen: 2, seen_pos: 12 }, now);
|
const row = normalizeReadsbAircraft(
|
||||||
|
{
|
||||||
|
hex: 'abc123',
|
||||||
|
lat: '30',
|
||||||
|
lon: '-97',
|
||||||
|
alt_baro: 10000,
|
||||||
|
alt_geom: 10100,
|
||||||
|
gs: 120,
|
||||||
|
baro_rate: -600,
|
||||||
|
seen: 2,
|
||||||
|
seen_pos: 12,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
assert.equal(row.baroAltitudeM, 3048);
|
assert.equal(row.baroAltitudeM, 3048);
|
||||||
assert.equal(row.ellipsoidAltitudeM, 3078.48);
|
assert.equal(row.ellipsoidAltitudeM, 3078.48);
|
||||||
assert.equal(row.speedMps, 120 * 0.514444);
|
assert.equal(row.speedMps, 120 * 0.514444);
|
||||||
assert.equal(row.verticalRateMps, -600 * 0.00508);
|
assert.equal(row.verticalRateMps, -600 * 0.00508);
|
||||||
assert.equal(row.positionTimeMs, now - 12000);
|
assert.equal(row.positionTimeMs, now - 12000);
|
||||||
assert.equal(row.contactTimeMs, now - 2000);
|
assert.equal(row.contactTimeMs, now - 2000);
|
||||||
const ground = normalizeReadsbAircraft({ hex: 'abc123', lat: 30, lon: -97, alt_baro: 'ground' }, null);
|
const ground = normalizeReadsbAircraft(
|
||||||
|
{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 'ground' },
|
||||||
|
null,
|
||||||
|
);
|
||||||
assert.equal(ground.onGround, true);
|
assert.equal(ground.onGround, true);
|
||||||
assert.equal(ground.baroAltitudeM, null);
|
assert.equal(ground.baroAltitudeM, null);
|
||||||
assert.equal(ground.positionTimeMs, null);
|
assert.equal(ground.positionTimeMs, null);
|
||||||
assert.equal(readsbSnapshot({ ac: [] }, { observedAtMs: null }).freshness, 'unknown');
|
assert.equal(
|
||||||
|
readsbSnapshot({ ac: [] }, { observedAtMs: null }).freshness,
|
||||||
|
'unknown',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => {
|
test('vessel identity, opaque reference and heading/course remain distinct, unknowns stay null', () => {
|
||||||
const row = normalizeVesselObservation({ mmsi: '123456789', lat: 30, lon: -97, speed: 10, heading: 45, course: 60, last_position_epoch: now / 1000, extra: 'discard' }, 'opaque-reference');
|
const row = normalizeVesselObservation(
|
||||||
|
{
|
||||||
|
mmsi: '123456789',
|
||||||
|
lat: 30,
|
||||||
|
lon: -97,
|
||||||
|
speed: 10,
|
||||||
|
heading: 45,
|
||||||
|
course: 60,
|
||||||
|
last_position_epoch: now / 1000,
|
||||||
|
extra: 'discard',
|
||||||
|
},
|
||||||
|
'opaque-reference',
|
||||||
|
);
|
||||||
assert.equal(row.id, '123456789');
|
assert.equal(row.id, '123456789');
|
||||||
assert.equal(row.reference, 'opaque-reference');
|
assert.equal(row.reference, 'opaque-reference');
|
||||||
assert.equal(row.headingDeg, 45);
|
assert.equal(row.headingDeg, 45);
|
||||||
@@ -59,23 +123,44 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn
|
|||||||
assert.equal(row.altitudeDatum, 'sea-surface');
|
assert.equal(row.altitudeDatum, 'sea-surface');
|
||||||
assert.equal(row.observedAtMs, now);
|
assert.equal(row.observedAtMs, now);
|
||||||
assert.equal('extra' in row, false);
|
assert.equal('extra' in row, false);
|
||||||
assert.equal(normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), null);
|
assert.equal(
|
||||||
|
normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('construction is inert; adapters preserve routes, viewport query, cache epoch and history datum', async () => {
|
test('construction is inert; adapters preserve routes, viewport query, cache epoch and history datum', async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const fetchImpl = async (url, init) => {
|
const fetchImpl = async (url, init) => {
|
||||||
requests.push({ url, init });
|
requests.push({ url, init });
|
||||||
if (url.startsWith('/api/opensky?')) return response({ time: now / 1000, states: [aircraft] }, { 'x-flight-source': 'adsb.lol', 'x-flight-coverage': '250 nm regional' });
|
if (url.startsWith('/api/opensky?'))
|
||||||
if (url.startsWith('/api/opensky-track')) return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
|
return response(
|
||||||
if (url === '/api/adsblol/mil') return response({ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] }, { 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' });
|
{ time: now / 1000, states: [aircraft] },
|
||||||
return response({ timestamp: now / 1000 - 60, trace: [[10, 30, -97, 1000]] });
|
{
|
||||||
|
'x-flight-source': 'adsb.lol',
|
||||||
|
'x-flight-coverage': '250 nm regional',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (url.startsWith('/api/opensky-track'))
|
||||||
|
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
|
||||||
|
if (url === '/api/adsblol/mil')
|
||||||
|
return response(
|
||||||
|
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
|
||||||
|
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
|
||||||
|
);
|
||||||
|
return response({
|
||||||
|
timestamp: now / 1000 - 60,
|
||||||
|
trace: [[10, 30, -97, 1000]],
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const civil = createOpenSkySource({ fetchImpl, now: () => now });
|
const civil = createOpenSkySource({ fetchImpl, now: () => now });
|
||||||
const military = createAdsbLolSource({ fetchImpl, now: () => now });
|
const military = createAdsbLolSource({ fetchImpl, now: () => now });
|
||||||
assert.equal(requests.length, 0);
|
assert.equal(requests.length, 0);
|
||||||
const signal = new AbortController().signal;
|
const signal = new AbortController().signal;
|
||||||
const snapshot = await civil.getSnapshot({ latitude: 30.123456, longitude: -97 }, { signal });
|
const snapshot = await civil.getSnapshot(
|
||||||
|
{ latitude: 30.123456, longitude: -97 },
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
|
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
|
||||||
assert.equal(requests[0].init.signal, signal);
|
assert.equal(requests[0].init.signal, signal);
|
||||||
assert.equal(snapshot.source, 'adsb.lol');
|
assert.equal(snapshot.source, 'adsb.lol');
|
||||||
@@ -95,9 +180,17 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
|||||||
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
|
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
|
||||||
let release;
|
let release;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const source = createOpenSkySource({ fetchImpl: async () => ({ ok: true, json: () => new Promise(resolve => { release = resolve; }) }) });
|
const source = createOpenSkySource({
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
const pending = source.getSnapshot({}, { signal: controller.signal });
|
const pending = source.getSnapshot({}, { signal: controller.signal });
|
||||||
await new Promise(resolve => setImmediate(resolve));
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
controller.abort();
|
controller.abort();
|
||||||
release({ states: [aircraft] });
|
release({ states: [aircraft] });
|
||||||
await assert.rejects(pending, { name: 'AbortError' });
|
await assert.rejects(pending, { name: 'AbortError' });
|
||||||
@@ -106,8 +199,13 @@ test('cancellation after slow body parsing rejects even with a transport that ig
|
|||||||
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
|
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
|
||||||
for (const status of [401, 403, 429, 502]) {
|
for (const status of [401, 403, 429, 502]) {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const source = createOpenSkySource({ fetchImpl: async () => { calls++; return response({ error: 'sensitive upstream detail' }, {}, status); } });
|
const source = createOpenSkySource({
|
||||||
await assert.rejects(source.getSnapshot(), error => {
|
fetchImpl: async () => {
|
||||||
|
calls++;
|
||||||
|
return response({ error: 'sensitive upstream detail' }, {}, status);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(source.getSnapshot(), (error) => {
|
||||||
assert.equal(error.status, status);
|
assert.equal(error.status, status);
|
||||||
assert.equal(error.message.includes('sensitive'), false);
|
assert.equal(error.message.includes('sensitive'), false);
|
||||||
assert.ok(error.retryAfterMs >= 20000);
|
assert.ok(error.retryAfterMs >= 20000);
|
||||||
@@ -118,15 +216,34 @@ test('denials and outages never start another source and do not echo arbitrary r
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
|
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
|
||||||
const source = createAisStreamSource({ origin: () => 'http://example.test', fetchImpl: async url => {
|
const source = createAisStreamSource({
|
||||||
if (url.includes('/track?')) return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
|
origin: () => 'http://example.test',
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
if (url.includes('/track?'))
|
||||||
|
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
|
||||||
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
|
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
|
||||||
return response({ status: 'reconnecting', refreshing: true, rows: [{ mmsi: '123456789', lat: 30, lon: -97, last_position_UTC: new Date(now).toISOString() }], newestPositionAt: new Date(now).toISOString() });
|
return response({
|
||||||
} });
|
status: 'reconnecting',
|
||||||
|
refreshing: true,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
mmsi: '123456789',
|
||||||
|
lat: 30,
|
||||||
|
lon: -97,
|
||||||
|
last_position_UTC: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
newestPositionAt: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
const snapshot = await source.getSnapshot({ maxRows: 500 });
|
const snapshot = await source.getSnapshot({ maxRows: 500 });
|
||||||
assert.equal(snapshot.complete, false);
|
assert.equal(snapshot.complete, false);
|
||||||
assert.equal(snapshot.stale, true);
|
assert.equal(snapshot.stale, true);
|
||||||
assert.equal(snapshot.observedAtMs, now);
|
assert.equal(snapshot.observedAtMs, now);
|
||||||
assert.equal(snapshot.transportStatus, 'reconnecting');
|
assert.equal(snapshot.transportStatus, 'reconnecting');
|
||||||
assert.equal((await source.getTrack('123456789')).records[0].observedAtMs, now);
|
assert.equal(
|
||||||
|
(await source.getTrack('123456789')).records[0].observedAtMs,
|
||||||
|
now,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
export { LiveSourceError } from './contract.js';
|
export { LiveSourceError } from './contract.js';
|
||||||
export { normalizeOpenSkyAircraft, normalizeReadsbAircraft, normalizeAircraftTrack, openSkySnapshot, readsbSnapshot } from './aircraft.js';
|
export {
|
||||||
export { normalizeVesselObservation, normalizeVesselTrack, vesselSnapshot } from './vessels.js';
|
normalizeOpenSkyAircraft,
|
||||||
export { createOpenSkySource, createAdsbLolSource, createAisStreamSource } from './standalone.js';
|
normalizeReadsbAircraft,
|
||||||
|
normalizeAircraftTrack,
|
||||||
|
openSkySnapshot,
|
||||||
|
readsbSnapshot,
|
||||||
|
} from './aircraft.js';
|
||||||
|
export {
|
||||||
|
normalizeVesselObservation,
|
||||||
|
normalizeVesselTrack,
|
||||||
|
vesselSnapshot,
|
||||||
|
} from './vessels.js';
|
||||||
|
export {
|
||||||
|
createOpenSkySource,
|
||||||
|
createAdsbLolSource,
|
||||||
|
createAisStreamSource,
|
||||||
|
} from './standalone.js';
|
||||||
+131
-29
@@ -1,5 +1,15 @@
|
|||||||
import { epoch, finite, httpError, LiveSourceError, readResponse } from './contract.js';
|
import {
|
||||||
import { normalizeAircraftTrack, openSkySnapshot, readsbSnapshot } from './aircraft.js';
|
epoch,
|
||||||
|
finite,
|
||||||
|
httpError,
|
||||||
|
LiveSourceError,
|
||||||
|
readResponse,
|
||||||
|
} from './contract.js';
|
||||||
|
import {
|
||||||
|
normalizeAircraftTrack,
|
||||||
|
openSkySnapshot,
|
||||||
|
readsbSnapshot,
|
||||||
|
} from './aircraft.js';
|
||||||
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
|
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
|
||||||
|
|
||||||
const defaultFetch = (...args) => globalThis.fetch(...args);
|
const defaultFetch = (...args) => globalThis.fetch(...args);
|
||||||
@@ -7,9 +17,16 @@ const header = (response, name) => response.headers?.get?.(name);
|
|||||||
|
|
||||||
function openSkyError(response) {
|
function openSkyError(response) {
|
||||||
const error = httpError(response, 'OpenSky');
|
const error = httpError(response, 'OpenSky');
|
||||||
const mode = String(header(response, 'x-opensky-auth-mode-used') || header(response, 'x-opensky-auth') || '').toLowerCase();
|
const mode = String(
|
||||||
const reason = String(header(response, 'x-opensky-auth-reason') || '').toLowerCase();
|
header(response, 'x-opensky-auth-mode-used') ||
|
||||||
if (response.status === 429 && (!mode || mode === 'anon')) error.message = 'OpenSky rate limited (anonymous)';
|
header(response, 'x-opensky-auth') ||
|
||||||
|
'',
|
||||||
|
).toLowerCase();
|
||||||
|
const reason = String(
|
||||||
|
header(response, 'x-opensky-auth-reason') || '',
|
||||||
|
).toLowerCase();
|
||||||
|
if (response.status === 429 && (!mode || mode === 'anon'))
|
||||||
|
error.message = 'OpenSky rate limited (anonymous)';
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
const reasons = {
|
const reasons = {
|
||||||
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
|
oauth_invalid_or_missing: 'OpenSky OAuth client missing/invalid',
|
||||||
@@ -17,80 +34,165 @@ function openSkyError(response) {
|
|||||||
basic_invalid_credentials: 'OpenSky username/password rejected',
|
basic_invalid_credentials: 'OpenSky username/password rejected',
|
||||||
missing_basic_creds: 'OpenSky auth missing',
|
missing_basic_creds: 'OpenSky auth missing',
|
||||||
missing_oauth_and_basic_creds: 'OpenSky auth missing',
|
missing_oauth_and_basic_creds: 'OpenSky auth missing',
|
||||||
auth_required: 'OpenSky auth required', forced_anonymous: 'OpenSky auth required',
|
auth_required: 'OpenSky auth required',
|
||||||
|
forced_anonymous: 'OpenSky auth required',
|
||||||
};
|
};
|
||||||
error.message = reasons[reason] || (/^(oauth_|basic_)/.test(reason) ? 'OpenSky auth invalid' : mode === 'anon' ? 'OpenSky auth required' : 'OpenSky auth failed');
|
error.message =
|
||||||
|
reasons[reason] ||
|
||||||
|
(/^(oauth_|basic_)/.test(reason)
|
||||||
|
? 'OpenSky auth invalid'
|
||||||
|
: mode === 'anon'
|
||||||
|
? 'OpenSky auth required'
|
||||||
|
: 'OpenSky auth failed');
|
||||||
}
|
}
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Existing same-origin aircraft routes; no request starts during construction. */
|
/** Existing same-origin aircraft routes; no request starts during construction. */
|
||||||
export function createOpenSkySource({ fetchImpl = defaultFetch, now = () => Date.now() } = {}) {
|
export function createOpenSkySource({
|
||||||
|
fetchImpl = defaultFetch,
|
||||||
|
now = () => Date.now(),
|
||||||
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
async getSnapshot(query = {}, { signal } = {}) {
|
async getSnapshot(query = {}, { signal } = {}) {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
|
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
|
||||||
params.set('lat', query.latitude.toFixed(4)); params.set('lon', query.longitude.toFixed(4));
|
params.set('lat', query.latitude.toFixed(4));
|
||||||
|
params.set('lon', query.longitude.toFixed(4));
|
||||||
}
|
}
|
||||||
const { response, payload } = await readResponse(fetchImpl, `/api/opensky${params.size ? '?' + params : ''}`, { signal }, 'OpenSky');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
`/api/opensky${params.size ? '?' + params : ''}`,
|
||||||
|
{ signal },
|
||||||
|
'OpenSky',
|
||||||
|
);
|
||||||
if (!response.ok) throw openSkyError(response);
|
if (!response.ok) throw openSkyError(response);
|
||||||
return { ...openSkySnapshot(payload, {
|
return {
|
||||||
|
...openSkySnapshot(payload, {
|
||||||
source: header(response, 'x-flight-source') || 'OpenSky Network',
|
source: header(response, 'x-flight-source') || 'OpenSky Network',
|
||||||
coverage: header(response, 'x-flight-coverage') || 'worldwide upstream snapshot', now: now(),
|
coverage:
|
||||||
}), status: response.status };
|
header(response, 'x-flight-coverage') ||
|
||||||
|
'worldwide upstream snapshot',
|
||||||
|
now: now(),
|
||||||
|
}),
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async getTrack(reference, { signal } = {}) {
|
async getTrack(reference, { signal } = {}) {
|
||||||
const { response, payload } = await readResponse(fetchImpl, '/api/opensky-track?icao24=' + encodeURIComponent(reference), { signal }, 'OpenSky');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
|
||||||
|
{ signal },
|
||||||
|
'OpenSky',
|
||||||
|
);
|
||||||
if (!response.ok) throw httpError(response, 'OpenSky');
|
if (!response.ok) throw httpError(response, 'OpenSky');
|
||||||
return { records: normalizeAircraftTrack(payload?.path), complete: false };
|
return {
|
||||||
|
records: normalizeAircraftTrack(payload?.path),
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async getEnrichment(query, { signal } = {}) {
|
async getEnrichment(query, { signal } = {}) {
|
||||||
if (!['type', 'route'].includes(query.kind)) throw new LiveSourceError('unsupported', 'Enrichment unavailable');
|
if (!['type', 'route'].includes(query.kind))
|
||||||
const { response, payload } = await readResponse(fetchImpl, `/api/adsbdb/${query.kind}/${encodeURIComponent(query.id)}`, { signal }, 'adsbdb');
|
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
|
||||||
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
`/api/adsbdb/${query.kind}/${encodeURIComponent(query.id)}`,
|
||||||
|
{ signal },
|
||||||
|
'adsbdb',
|
||||||
|
);
|
||||||
if (!response.ok) throw httpError(response, 'adsbdb');
|
if (!response.ok) throw httpError(response, 'adsbdb');
|
||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAdsbLolSource({ fetchImpl = defaultFetch, now = () => Date.now() } = {}) {
|
export function createAdsbLolSource({
|
||||||
|
fetchImpl = defaultFetch,
|
||||||
|
now = () => Date.now(),
|
||||||
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
async getSnapshot(_query = {}, { signal } = {}) {
|
async getSnapshot(_query = {}, { signal } = {}) {
|
||||||
const { response, payload } = await readResponse(fetchImpl, '/api/adsblol/mil', { signal }, 'adsb.lol');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
'/api/adsblol/mil',
|
||||||
|
{ signal },
|
||||||
|
'adsb.lol',
|
||||||
|
);
|
||||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||||
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
|
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
|
||||||
return { ...readsbSnapshot(payload, {
|
return {
|
||||||
observedAtMs: now() - (age != null && age > 0 ? age : 0), now: now(),
|
...readsbSnapshot(payload, {
|
||||||
|
observedAtMs: now() - (age != null && age > 0 ? age : 0),
|
||||||
|
now: now(),
|
||||||
stale: header(response, 'x-ads-b-cache') === 'STALE',
|
stale: header(response, 'x-ads-b-cache') === 'STALE',
|
||||||
}), status: response.status };
|
}),
|
||||||
|
status: response.status,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async getTrack(reference, { signal } = {}) {
|
async getTrack(reference, { signal } = {}) {
|
||||||
const { response, payload } = await readResponse(fetchImpl, '/api/adsblol/trace?hex=' + encodeURIComponent(reference), { signal }, 'adsb.lol');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
|
||||||
|
{ signal },
|
||||||
|
'adsb.lol',
|
||||||
|
);
|
||||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||||
const baseTimeMs = epoch(payload?.timestamp, 1000);
|
const baseTimeMs = epoch(payload?.timestamp, 1000);
|
||||||
return { records: baseTimeMs == null ? [] : normalizeAircraftTrack(payload?.trace, { baseTimeMs, readsb: true }), complete: false };
|
return {
|
||||||
|
records:
|
||||||
|
baseTimeMs == null
|
||||||
|
? []
|
||||||
|
: normalizeAircraftTrack(payload?.trace, {
|
||||||
|
baseTimeMs,
|
||||||
|
readsb: true,
|
||||||
|
}),
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAisStreamSource({ fetchImpl = defaultFetch, apiUrl = '/api/ais-live', origin = () => globalThis.location?.origin || 'http://localhost' } = {}) {
|
export function createAisStreamSource({
|
||||||
|
fetchImpl = defaultFetch,
|
||||||
|
apiUrl = '/api/ais-live',
|
||||||
|
origin = () => globalThis.location?.origin || 'http://localhost',
|
||||||
|
} = {}) {
|
||||||
return {
|
return {
|
||||||
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
|
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
|
||||||
const url = new URL(apiUrl, origin());
|
const url = new URL(apiUrl, origin());
|
||||||
url.searchParams.set('maxRows', String(maxRows));
|
url.searchParams.set('maxRows', String(maxRows));
|
||||||
const { response, payload } = await readResponse(fetchImpl, url.toString(), { signal, cache: 'no-store' }, 'AIS live');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
url.toString(),
|
||||||
|
{ signal, cache: 'no-store' },
|
||||||
|
'AIS live',
|
||||||
|
);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = httpError(response, 'AIS live');
|
const error = httpError(response, 'AIS live');
|
||||||
const reasons = { 'missing-key': 'AISSTREAM_API_KEY not set', 'auth-failed': 'API key rejected — check AISSTREAM_API_KEY', unsupported: 'live feed unsupported', error: 'feed down', closed: 'feed disconnected' };
|
const reasons = {
|
||||||
|
'missing-key': 'AISSTREAM_API_KEY not set',
|
||||||
|
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
|
||||||
|
unsupported: 'live feed unsupported',
|
||||||
|
error: 'feed down',
|
||||||
|
closed: 'feed disconnected',
|
||||||
|
};
|
||||||
error.message = reasons[payload?.status] || error.message;
|
error.message = reasons[payload?.status] || error.message;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return { ...vesselSnapshot(payload), status: response.status };
|
return { ...vesselSnapshot(payload), status: response.status };
|
||||||
},
|
},
|
||||||
async getTrack(reference, { signal } = {}) {
|
async getTrack(reference, { signal } = {}) {
|
||||||
const { response, payload } = await readResponse(fetchImpl, '/api/ais-live/track?mmsi=' + encodeURIComponent(reference), { signal }, 'AIS live');
|
const { response, payload } = await readResponse(
|
||||||
|
fetchImpl,
|
||||||
|
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
|
||||||
|
{ signal },
|
||||||
|
'AIS live',
|
||||||
|
);
|
||||||
if (!response.ok) throw httpError(response, 'AIS live');
|
if (!response.ok) throw httpError(response, 'AIS live');
|
||||||
return { records: normalizeVesselTrack(payload?.samples), complete: false };
|
return {
|
||||||
|
records: normalizeVesselTrack(payload?.samples),
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
+64
-20
@@ -3,49 +3,93 @@ import { coordinates, epoch, finite, LiveSourceError } from './contract.js';
|
|||||||
/** AIS positions are sea-surface observations; heading and course are distinct. */
|
/** AIS positions are sea-surface observations; heading and course are distinct. */
|
||||||
export function normalizeVesselObservation(row, reference = null) {
|
export function normalizeVesselObservation(row, reference = null) {
|
||||||
const id = String(row?.mmsi || row?.input_identifier || '').trim();
|
const id = String(row?.mmsi || row?.input_identifier || '').trim();
|
||||||
const latitude = finite(row?.lat), longitude = finite(row?.lon);
|
const latitude = finite(row?.lat),
|
||||||
|
longitude = finite(row?.lon);
|
||||||
if (!id || !coordinates(latitude, longitude)) return null;
|
if (!id || !coordinates(latitude, longitude)) return null;
|
||||||
const speedKts = finite(row.speed);
|
const speedKts = finite(row.speed);
|
||||||
return {
|
return {
|
||||||
id, reference: reference ?? id, latitude, longitude,
|
id,
|
||||||
|
reference: reference ?? id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
name: String(row.name || row.input_name || id),
|
name: String(row.name || row.input_name || id),
|
||||||
imo: String(row.imo || ''), type: String(row.type_specific || row.type || ''),
|
imo: String(row.imo || ''),
|
||||||
|
type: String(row.type_specific || row.type || ''),
|
||||||
destination: String(row.destination || ''),
|
destination: String(row.destination || ''),
|
||||||
speedMps: speedKts == null ? null : speedKts * 0.514444,
|
speedMps: speedKts == null ? null : speedKts * 0.514444,
|
||||||
courseDeg: finite(row.course), headingDeg: finite(row.heading),
|
courseDeg: finite(row.course),
|
||||||
observedAtMs: epoch(row.last_position_epoch, 1000)
|
headingDeg: finite(row.heading),
|
||||||
?? epoch(Date.parse(row.last_position_UTC)),
|
observedAtMs:
|
||||||
|
epoch(row.last_position_epoch, 1000) ??
|
||||||
|
epoch(Date.parse(row.last_position_UTC)),
|
||||||
altitudeDatum: 'sea-surface',
|
altitudeDatum: 'sea-surface',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function vesselSnapshot(payload, { source = 'AISStream', coverage = 'received AIS positions', referenceFor = row => String(row.mmsi || row.input_identifier || '') } = {}) {
|
export function vesselSnapshot(
|
||||||
if (!Array.isArray(payload?.rows)) throw new LiveSourceError('malformed', 'Malformed vessel response');
|
payload,
|
||||||
|
{
|
||||||
|
source = 'AISStream',
|
||||||
|
coverage = 'received AIS positions',
|
||||||
|
referenceFor = (row) => String(row.mmsi || row.input_identifier || ''),
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(payload?.rows))
|
||||||
|
throw new LiveSourceError('malformed', 'Malformed vessel response');
|
||||||
const rows = payload.rows;
|
const rows = payload.rows;
|
||||||
const records = [], ids = new Set();
|
const records = [],
|
||||||
|
ids = new Set();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const record = normalizeVesselObservation(row, referenceFor(row));
|
const record = normalizeVesselObservation(row, referenceFor(row));
|
||||||
if (record && !ids.has(record.id)) { records.push(record); ids.add(record.id); }
|
if (record && !ids.has(record.id)) {
|
||||||
|
records.push(record);
|
||||||
|
ids.add(record.id);
|
||||||
}
|
}
|
||||||
const observedAtMs = epoch(payload?.newestPositionAt) ?? epoch(Date.parse(payload?.newestPositionAt))
|
}
|
||||||
?? records.reduce((newest, row) => Math.max(newest || 0, row.observedAtMs || 0) || null, null);
|
const observedAtMs =
|
||||||
|
epoch(payload?.newestPositionAt) ??
|
||||||
|
epoch(Date.parse(payload?.newestPositionAt)) ??
|
||||||
|
records.reduce(
|
||||||
|
(newest, row) => Math.max(newest || 0, row.observedAtMs || 0) || null,
|
||||||
|
null,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
records, source, coverage, complete: false, rejectedCount: rows.length - records.length,
|
records,
|
||||||
observedAtMs, freshness: payload?.refreshing ? 'stale' : observedAtMs == null ? 'unknown' : 'current',
|
source,
|
||||||
|
coverage,
|
||||||
|
complete: false,
|
||||||
|
rejectedCount: rows.length - records.length,
|
||||||
|
observedAtMs,
|
||||||
|
freshness: payload?.refreshing
|
||||||
|
? 'stale'
|
||||||
|
: observedAtMs == null
|
||||||
|
? 'unknown'
|
||||||
|
: 'current',
|
||||||
stale: Boolean(payload?.refreshing),
|
stale: Boolean(payload?.refreshing),
|
||||||
// Connection state is distinct from receipt of a usable position.
|
// Connection state is distinct from receipt of a usable position.
|
||||||
transportStatus: typeof payload?.status === 'string' ? payload.status : null,
|
transportStatus:
|
||||||
|
typeof payload?.status === 'string' ? payload.status : null,
|
||||||
lastMessageAt: payload?.lastMessageAt ?? null,
|
lastMessageAt: payload?.lastMessageAt ?? null,
|
||||||
nextAttemptAt: finite(payload?.nextAttemptAt), silentForMs: finite(payload?.silentForMs),
|
nextAttemptAt: finite(payload?.nextAttemptAt),
|
||||||
reconnectAttempt: finite(payload?.reconnectAttempt), rawRowCount: rows.length,
|
silentForMs: finite(payload?.silentForMs),
|
||||||
|
reconnectAttempt: finite(payload?.reconnectAttempt),
|
||||||
|
rawRowCount: rows.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeVesselTrack(samples) {
|
export function normalizeVesselTrack(samples) {
|
||||||
if (!Array.isArray(samples)) return [];
|
if (!Array.isArray(samples)) return [];
|
||||||
return samples.flatMap(sample => {
|
return samples.flatMap((sample) => {
|
||||||
const latitude = finite(sample?.lat), longitude = finite(sample?.lon);
|
const latitude = finite(sample?.lat),
|
||||||
|
longitude = finite(sample?.lon);
|
||||||
if (!coordinates(latitude, longitude)) return [];
|
if (!coordinates(latitude, longitude)) return [];
|
||||||
return [{ latitude, longitude, observedAtMs: epoch(sample?.t, 1000), altitudeDatum: 'sea-surface' }];
|
return [
|
||||||
|
{
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
observedAtMs: epoch(sample?.t, 1000),
|
||||||
|
altitudeDatum: 'sea-surface',
|
||||||
|
},
|
||||||
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user