mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
reconcile feat/voice-core with current main
preserve Bilawal's merge history, OpenAI schema-combinator startup fix, and Contacts viewport note keep the validated voice and analyst behavior while adopting the current provider-neutral feed routes and Google token fallback
This commit is contained in:
commit
6858976b01
49 files changed
+832
-324
No files matched your search
+3
-1
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
|
||||
export async function createApplicationScene({
|
||||
requestServices,
|
||||
googleApiKey,
|
||||
googleTokens = null,
|
||||
cesiumToken,
|
||||
credits,
|
||||
MapController = MapStackController,
|
||||
@@ -59,11 +60,12 @@ export async function createApplicationScene({
|
||||
registerDataCredits(viewer, credits);
|
||||
configureCreditKeyboardAccess(document);
|
||||
loaderStatus.textContent =
|
||||
googleApiKey || cesiumToken
|
||||
googleApiKey || googleTokens || cesiumToken
|
||||
? 'Loading Google 3D Tiles...'
|
||||
: 'Loading the keyless globe...';
|
||||
const photoreal = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey,
|
||||
googleTokens,
|
||||
cesiumToken,
|
||||
});
|
||||
const tileset = photoreal.tileset;
|
||||
|
||||
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
courseDeg: 214.0,
|
||||
shipType: 'Cargo',
|
||||
destination: 'OAKLAND',
|
||||
navStatus: null, // /api/ais-live does not surface NavigationalStatus
|
||||
navStatus: null, // /api/vessels does not surface NavigationalStatus
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
|
||||
|
||||
import { createAisStreamSource } from '../sources/live/standalone.js';
|
||||
import { createVesselSource } from '../sources/live/standalone.js';
|
||||
|
||||
const aisLiveVesselsLayer = createApplicationVessels({
|
||||
source: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
source: createVesselSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
|
||||
// Resolved against the document's address, which a panel host may serve
|
||||
// from its own scheme.
|
||||
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
|
||||
|
||||
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
|
||||
});
|
||||
|
||||
test('vessel selection passes the opaque source reference to optional history', async () => {
|
||||
const { createAisStreamSource } = await import('../sources/live/standalone.js');
|
||||
const { createVesselSource } = await import('../sources/live/standalone.js');
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
const requests = [];
|
||||
aisLiveVesselsLayer.setSource({
|
||||
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
_setVesselStateForTest({ enabled: false });
|
||||
aisLiveVesselsLayer.setSource(createAisStreamSource());
|
||||
aisLiveVesselsLayer.setSource(createVesselSource());
|
||||
}
|
||||
});
|
||||
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Status metadata for /api/ais-live. */
|
||||
/** Status metadata for /api/vessels. */
|
||||
function snapshot() {
|
||||
const monoNow = clock.mono();
|
||||
const wallNow = clock.wall();
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationFlights } from '../app/layers/flights.js';
|
||||
|
||||
import { createOpenSkySource } from '../sources/live/standalone.js';
|
||||
import { createFlightSource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const flightsLayer = createApplicationFlights({
|
||||
surface: defaultSurface,
|
||||
source: createOpenSkySource(),
|
||||
source: createFlightSource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
|
||||
|
||||
@@ -203,7 +203,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
let openskyPoll = 0;
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).startsWith('/api/opensky')) {
|
||||
if (!String(url).startsWith('/api/flights')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
|
||||
}
|
||||
const states = openskyPoll++ === 0
|
||||
|
||||
@@ -489,7 +489,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
|
||||
// the dependencies reach it by different routes:
|
||||
//
|
||||
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
|
||||
// as soon as the first /api/ais-live poll answers, so the manager's
|
||||
// as soon as the first /api/vessels poll answers, so the manager's
|
||||
// lifecycle settles to `enabled` — but until the server-side socket delivers
|
||||
// a position, firstConnectPhase stays 'loading' and the module reports busy,
|
||||
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { defaultSurface } from './surfaceServices.js';
|
||||
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
|
||||
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
import { createMilitarySource } from '../sources/live/standalone.js';
|
||||
import * as militaryRegistry from './militaryRegistry.js';
|
||||
|
||||
const militaryFlightsLayer = createApplicationMilitary({
|
||||
surface: defaultSurface,
|
||||
source: createAdsbLolSource(),
|
||||
source: createMilitarySource(),
|
||||
militaryRegistry,
|
||||
});
|
||||
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
|
||||
|
||||
+327
-102
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
|
||||
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
|
||||
'source-unavailable',
|
||||
);
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
|
||||
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
_setMilitaryTrackingRefreshOutcomeForTest({
|
||||
status: 'accepted',
|
||||
ids: ['different'],
|
||||
});
|
||||
const missing =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(missing.status, 'missing');
|
||||
assert.equal(missing.reason, 'target-absent-from-snapshot');
|
||||
});
|
||||
@@ -44,9 +48,9 @@ const FULL_INFO = {
|
||||
operator: 'United States Air Force',
|
||||
};
|
||||
|
||||
test('military stats identify adsb.lol as the primary feed, not a fallback', () => {
|
||||
test('military stats name the feed until the server names its provider, not a fallback', () => {
|
||||
const stats = militaryFlightsLayer.getStats();
|
||||
assert.equal(stats.source, 'adsb.lol');
|
||||
assert.equal(stats.source, 'Military aircraft');
|
||||
assert.equal(stats.fallback, false);
|
||||
});
|
||||
|
||||
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
|
||||
});
|
||||
|
||||
test('military analyst record: no callsign falls back to registration, then icao24', () => {
|
||||
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
|
||||
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
|
||||
'05-8152',
|
||||
);
|
||||
assert.equal(
|
||||
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
|
||||
'ae01ce',
|
||||
);
|
||||
});
|
||||
|
||||
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
|
||||
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
|
||||
assert.equal(r.onGround, false);
|
||||
for (const [key, value] of Object.entries(r)) {
|
||||
assert.notEqual(value, undefined, `${key} must not be undefined`);
|
||||
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
if (typeof value === 'number')
|
||||
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -103,12 +114,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
|
||||
test('military first update forwards caller cancellation into the feed request', async () => {
|
||||
const realFetch = globalThis.fetch;
|
||||
let observedSignal = null;
|
||||
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener('abort', () => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
}, { once: true });
|
||||
});
|
||||
globalThis.fetch = (_url, options = {}) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
observedSignal = options.signal;
|
||||
options.signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
|
||||
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
|
||||
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
|
||||
});
|
||||
try {
|
||||
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
|
||||
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
await militaryFlightsLayer.update({
|
||||
camera: { positionCartographic: null },
|
||||
scene: {},
|
||||
});
|
||||
const resolution =
|
||||
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
|
||||
assert.equal(resolution.status, 'source-unavailable');
|
||||
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
|
||||
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
|
||||
assert.match(
|
||||
militaryFlightsLayer.getStats().error,
|
||||
/Malformed adsb\.lol aircraft rows/,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getAnalystRecords().length,
|
||||
1,
|
||||
'warm aircraft data is preserved',
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
@@ -188,21 +215,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
ac: poll++ === 0 ? [{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
}] : [],
|
||||
ac:
|
||||
poll++ === 0
|
||||
? [
|
||||
{
|
||||
hex: icao24,
|
||||
lon: -96.9,
|
||||
lat: 31.1,
|
||||
alt_baro: 28_000,
|
||||
alt_geom: 28_100,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 0,
|
||||
seen_pos: 0,
|
||||
flight: 'RCH451 ',
|
||||
t: 'C17',
|
||||
r: '05-8152',
|
||||
ownOp: 'United States Air Force',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
_setTrackedMilitaryRefreshStateForTest({
|
||||
icao24: 'ae01ce',
|
||||
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
|
||||
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
|
||||
billboard: {
|
||||
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
|
||||
show: false,
|
||||
},
|
||||
billboardCollection: { show: false, remove() {} },
|
||||
viewer,
|
||||
history,
|
||||
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
|
||||
});
|
||||
let cache = 'MISS';
|
||||
t.mock.method(Date, 'now', () => now);
|
||||
t.mock.method(globalThis, 'fetch', async () => Response.json({
|
||||
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000,
|
||||
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }],
|
||||
}, { headers: {
|
||||
'X-ADS-B-Cache': cache,
|
||||
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }),
|
||||
} }));
|
||||
t.mock.method(globalThis, 'fetch', async () =>
|
||||
Response.json(
|
||||
{
|
||||
ac: [
|
||||
{
|
||||
hex: 'ae01ce',
|
||||
lon: -97,
|
||||
lat: 31,
|
||||
alt_baro: 28000,
|
||||
track: 95,
|
||||
gs: 400,
|
||||
seen: 1,
|
||||
seen_pos: 2,
|
||||
flight: 'RCH451',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Feed-Cache': cache,
|
||||
...(cache === 'MISS'
|
||||
? {}
|
||||
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1);
|
||||
assert.equal(history[0].epochMs, receivedAt - 2000);
|
||||
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
|
||||
for (const [status, age] of [
|
||||
['HIT', 5000],
|
||||
['STALE', 45000],
|
||||
]) {
|
||||
cache = status;
|
||||
now = receivedAt + age;
|
||||
await militaryFlightsLayer.update(viewer);
|
||||
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
|
||||
assert.equal(
|
||||
history.length,
|
||||
1,
|
||||
'replayed positions cannot create a new fix',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
|
||||
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
|
||||
assert.equal(militaryFlightsLayer.getStats().error, null);
|
||||
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
trackedEntityChanged,
|
||||
clock: { currentTime: now },
|
||||
camera: {
|
||||
cancelFlight() { cancelledFlights += 1; },
|
||||
cancelFlight() {
|
||||
cancelledFlights += 1;
|
||||
},
|
||||
position: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
|
||||
direction: Cesium.Cartesian3.UNIT_Y,
|
||||
transform: Cesium.Matrix4.IDENTITY,
|
||||
viewMatrix: Cesium.Matrix4.IDENTITY,
|
||||
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
|
||||
lookAtTransform() { appliedFrames += 1; },
|
||||
lookAtTransform() {
|
||||
appliedFrames += 1;
|
||||
},
|
||||
},
|
||||
scene: {
|
||||
canvas: { clientWidth: 1600, clientHeight: 900 },
|
||||
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.window = new EventTarget();
|
||||
const selectionEvents = [];
|
||||
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
});
|
||||
globalThis.window.addEventListener(
|
||||
'gev:awareness-subject-selected',
|
||||
(event) => {
|
||||
selectionEvents.push(event.detail);
|
||||
},
|
||||
);
|
||||
globalThis.fetch = async () => ({ ok: false });
|
||||
_setTrackedOverlayHostForTest(host);
|
||||
try {
|
||||
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
|
||||
billboardCollection: { show: true, remove() {} },
|
||||
viewer,
|
||||
tracked: false,
|
||||
history: [{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
}],
|
||||
history: [
|
||||
{
|
||||
time: now,
|
||||
epochMs: Date.now(),
|
||||
position,
|
||||
velocity: 231.5,
|
||||
track: 92.1,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
...FULL_INFO,
|
||||
type: 'C17',
|
||||
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
const entity = viewer.trackedEntity;
|
||||
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
|
||||
assert.ok(
|
||||
entity instanceof Cesium.Entity,
|
||||
'trackById must create the real Cesium entity',
|
||||
);
|
||||
assert.equal(entity.label, undefined);
|
||||
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
|
||||
assert.ok(
|
||||
entities.values.every((candidate) => candidate.label === undefined),
|
||||
);
|
||||
assert.deepEqual(entity.gevLabelModel, {
|
||||
title: 'RCH451',
|
||||
details: [
|
||||
'C17 · 05-8152',
|
||||
'United States Air Force · 28000 ft · 450 kt',
|
||||
],
|
||||
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
|
||||
accent: '#ffd166',
|
||||
});
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
const initialAppliedFrames = appliedFrames;
|
||||
const initialCancelledFlights = cancelledFlights;
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights,
|
||||
'ordinary repeated tracking stays camera-idempotent',
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'user',
|
||||
'same-target selection upgrades durable authority',
|
||||
);
|
||||
assert.equal(entity.gevSelectionOrigin, 'user');
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
|
||||
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById('different-flight'),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
selectionEvents.at(-1)?.origin,
|
||||
'voice',
|
||||
'same-target refocus forwards explicit authority',
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
|
||||
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
|
||||
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
|
||||
assert.equal(
|
||||
viewer.trackedEntity,
|
||||
entity,
|
||||
'refocus must retain the exact tracked entity',
|
||||
);
|
||||
assert.equal(
|
||||
appliedFrames,
|
||||
initialAppliedFrames + 1,
|
||||
'repeated refocus keeps one camera-frame owner',
|
||||
);
|
||||
assert.equal(
|
||||
cancelledFlights,
|
||||
initialCancelledFlights + 2,
|
||||
'only explicit refocus requests cancel camera flights',
|
||||
);
|
||||
const publication = publications.at(-1);
|
||||
assert.equal(publication.sourceId, 'tracked');
|
||||
const entry = publication.entries[0];
|
||||
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
|
||||
|
||||
const display = entity.position.getValue(now);
|
||||
assert.ok(display, 'tracked position callback must seed its frame cache');
|
||||
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
|
||||
assert.equal(
|
||||
entry.position(),
|
||||
display,
|
||||
'host must reuse the exact cached display Cartesian',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late001' },
|
||||
{ origin: 'share-restore' },
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'new user selection cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late001',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
icao24,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.stopTracking();
|
||||
militaryFlightsLayer.setParams(
|
||||
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
|
||||
militaryFlightsLayer.stopTracking({ origin: 'user' });
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
null,
|
||||
'explicit clear cancels stale restore',
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late002',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late003' },
|
||||
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late003',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
|
||||
history: [],
|
||||
});
|
||||
_applyPendingMilitaryTrackingRestoreForTest();
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
null,
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late004' },
|
||||
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
|
||||
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late004',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late004',
|
||||
);
|
||||
|
||||
militaryFlightsLayer.setParams(
|
||||
{ selectedMilitaryTrackingId: 'late005' },
|
||||
{ origin: 'local-restore' },
|
||||
);
|
||||
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
_pendingMilitaryTrackingRestoreForTest(),
|
||||
'late005',
|
||||
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
|
||||
);
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'late005',
|
||||
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
|
||||
billboard: {
|
||||
...billboard,
|
||||
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
|
||||
},
|
||||
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
|
||||
history: [],
|
||||
});
|
||||
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
|
||||
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
|
||||
assert.equal(
|
||||
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
|
||||
'late005',
|
||||
);
|
||||
} finally {
|
||||
militaryFlightsLayer.stopTracking();
|
||||
viewer.scene.preUpdate.raiseEvent();
|
||||
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
|
||||
|
||||
// The identity analyst_query hands back for this contact.
|
||||
const analystId = mapAnalystRecord(icao24, {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
}).id;
|
||||
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
|
||||
assert.equal(
|
||||
analystId,
|
||||
'6606',
|
||||
'the analyst names a callsign-less contact by its tail',
|
||||
);
|
||||
|
||||
const found = militaryFlightsLayer.findByQuery(analystId);
|
||||
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
|
||||
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
|
||||
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
|
||||
assert.equal(
|
||||
found?.icao24,
|
||||
icao24,
|
||||
'and the tracker must resolve that same identity',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery('660')?.icao24,
|
||||
icao24,
|
||||
'prefix match too',
|
||||
);
|
||||
assert.equal(
|
||||
militaryFlightsLayer.findByQuery(icao24)?.icao24,
|
||||
icao24,
|
||||
'hex still wins outright',
|
||||
);
|
||||
});
|
||||
|
||||
test('the analyst record carries the hex key the tracker keys on', () => {
|
||||
const record = mapAnalystRecord('ae7f01', {
|
||||
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
|
||||
callsign: null,
|
||||
registration: '6606',
|
||||
rawLat: 40.71,
|
||||
rawLon: -74.01,
|
||||
});
|
||||
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
|
||||
assert.equal(
|
||||
record.icao24,
|
||||
'ae7f01',
|
||||
'a resolvable key must ride along with the display label',
|
||||
);
|
||||
});
|
||||
|
||||
test('a registration never outranks another contact’s exact callsign', () => {
|
||||
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
|
||||
track: 90, klass: 'widebody', onGround: false,
|
||||
type: 'C17',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 25000,
|
||||
speedMps: 180,
|
||||
track: 90,
|
||||
klass: 'widebody',
|
||||
onGround: false,
|
||||
};
|
||||
// Registered FIRST, so insertion order favours it.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0aa1',
|
||||
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'ZULU777',
|
||||
rawLat: 31.0,
|
||||
rawLon: -97.0,
|
||||
},
|
||||
billboard: billboardAt(-97.0, 31.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'ae0bb2',
|
||||
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: 'ZULU777',
|
||||
registration: '05-9999',
|
||||
rawLat: 31.2,
|
||||
rawLon: -97.2,
|
||||
},
|
||||
billboard: billboardAt(-97.2, 31.2),
|
||||
});
|
||||
|
||||
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
|
||||
show: true,
|
||||
});
|
||||
const base = {
|
||||
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
|
||||
track: 45, klass: 'turboprop', onGround: false,
|
||||
type: 'C130',
|
||||
operator: 'USAF',
|
||||
altitudeFt: 21000,
|
||||
speedMps: 150,
|
||||
track: 45,
|
||||
klass: 'turboprop',
|
||||
onGround: false,
|
||||
};
|
||||
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
|
||||
// highest-hex first so insertion order would pick the wrong one.
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef002',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-222',
|
||||
rawLat: 32.0,
|
||||
rawLon: -98.0,
|
||||
},
|
||||
billboard: billboardAt(-98.0, 32.0),
|
||||
});
|
||||
_addMilitaryTrackingCandidateForTest({
|
||||
icao24: 'aef001',
|
||||
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
|
||||
meta: {
|
||||
...base,
|
||||
callsign: null,
|
||||
registration: 'QQ7-111',
|
||||
rawLat: 32.1,
|
||||
rawLon: -98.1,
|
||||
},
|
||||
billboard: billboardAt(-98.1, 32.1),
|
||||
});
|
||||
|
||||
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
|
||||
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
|
||||
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
|
||||
assert.equal(second, first, 'and the same query resolves the same way every time');
|
||||
assert.equal(
|
||||
first,
|
||||
'aef001',
|
||||
'the stable key (lowest hex) wins, not the feed order',
|
||||
);
|
||||
assert.equal(
|
||||
second,
|
||||
first,
|
||||
'and the same query resolves the same way every time',
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
|
||||
import { createAdsbLolSource } from '../sources/live/standalone.js';
|
||||
import { createMilitarySource } from '../sources/live/standalone.js';
|
||||
|
||||
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
|
||||
const registry = createMilitaryRegistry({ source: createMilitarySource() });
|
||||
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
|
||||
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
|
||||
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
|
||||
|
||||
@@ -189,7 +189,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
|
||||
const realFetch = globalThis.fetch;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
globalThis.fetch = async (url) => {
|
||||
if (!String(url).startsWith('/api/opensky')) {
|
||||
if (!String(url).startsWith('/api/flights')) {
|
||||
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
|
||||
}
|
||||
return {
|
||||
@@ -309,7 +309,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
|
||||
meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
|
||||
});
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => (String(url).startsWith('/api/opensky')
|
||||
globalThis.fetch = async (url) => (String(url).startsWith('/api/flights')
|
||||
? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
|
||||
: { ok: true, status: 200, json: async () => ({ ac: [] }) });
|
||||
try {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
|
||||
// setInstallationStatus is only ever called with
|
||||
// loading/zoom-in/ready/stale/empty/unavailable.
|
||||
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
|
||||
// update() both resolve as soon as the first /api/ais-live poll answers,
|
||||
// update() both resolve as soon as the first /api/vessels poll answers,
|
||||
// so the lifecycle settles to `enabled` — but until the server-side socket
|
||||
// delivers a position, firstConnectPhase is 'loading' and getStats()
|
||||
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createQueries({
|
||||
/**
|
||||
* Chip text for a feed the server has reported as not delivering.
|
||||
* @param {string} status - 'stale' | 'reconnecting' | 'down'
|
||||
* @param {Object} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object} payload - Parsed /api/vessels JSON.
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -61,12 +61,12 @@ export function createQueries({
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a surfaced error string from an /api/ais-live payload, or null when the
|
||||
* Derive a surfaced error string from an /api/vessels payload, or null when the
|
||||
* feed has accepted product data. Socket transport, message receipt, and usable
|
||||
* vessel positions are separate health stages: an open socket with no message
|
||||
* or no accepted positions must not read as a fresh successful update.
|
||||
*
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
|
||||
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
|
||||
* @returns {string|null} A short reason for the chip, or null if healthy.
|
||||
*/
|
||||
@@ -106,7 +106,7 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Classify one server snapshot before any destructive reconciliation.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels payload.
|
||||
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
|
||||
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
|
||||
* acceptedRowCount: number, error: string|null}}
|
||||
@@ -140,7 +140,7 @@ export function createQueries({
|
||||
* Map one internal vessel record to a plain JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
|
||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
|
||||
+77
-1
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
|
||||
return {
|
||||
calls,
|
||||
Ion: { defaultAccessToken: undefined },
|
||||
GoogleMaps: { defaultApiKey: undefined },
|
||||
GoogleMaps: {
|
||||
defaultApiKey: undefined,
|
||||
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
|
||||
getDefaultCredit: () => undefined,
|
||||
},
|
||||
Resource: class {
|
||||
constructor(options) {
|
||||
Object.assign(this, options);
|
||||
}
|
||||
},
|
||||
async createGooglePhotorealistic3DTileset(options) {
|
||||
calls.push({ options, googleKey: options.key });
|
||||
return next();
|
||||
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
|
||||
async fromUrl(resource, options) {
|
||||
calls.push({
|
||||
options,
|
||||
resource,
|
||||
ionToken: resource.accessToken,
|
||||
assetId: resource.assetId,
|
||||
});
|
||||
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
|
||||
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
|
||||
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
|
||||
});
|
||||
|
||||
function tokenSource(...issued) {
|
||||
const requests = [];
|
||||
return {
|
||||
requests,
|
||||
async token(options = {}) {
|
||||
requests.push(options);
|
||||
return issued.shift() ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('without a key, server tokens load Google 3D directly', async () => {
|
||||
const tileset = { id: 'token' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleTokens: tokenSource('short-lived'),
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.tileset, tileset);
|
||||
assert.equal(result.route, 'google-token');
|
||||
const { resource, options } = Cesium.calls[0];
|
||||
assert.equal(
|
||||
resource.url,
|
||||
'https://tile.googleapis.com/v1/3dtiles/root.json',
|
||||
);
|
||||
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
|
||||
assert.equal(resource.queryParameters, undefined);
|
||||
assert.equal(options.enableCollision, true);
|
||||
});
|
||||
|
||||
test('a tile refused for an expired token retries once with a renewed one', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'token' }]);
|
||||
const tokens = tokenSource('first', 'second');
|
||||
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
|
||||
const { resource } = Cesium.calls[0];
|
||||
const tile = { headers: { ...resource.headers } };
|
||||
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
|
||||
assert.equal(tile.headers.Authorization, 'Bearer second');
|
||||
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
|
||||
assert.equal(resource.retryAttempts, 1);
|
||||
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
|
||||
});
|
||||
|
||||
test('a key takes precedence over server tokens', async () => {
|
||||
const Cesium = fakeCesium([{ id: 'direct' }]);
|
||||
const tokens = tokenSource('short-lived');
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleApiKey: 'google-secret',
|
||||
googleTokens: tokens,
|
||||
});
|
||||
assert.equal(result.route, 'google-direct');
|
||||
assert.equal(tokens.requests.length, 0);
|
||||
});
|
||||
|
||||
test('a server without tokens falls back to ion', async () => {
|
||||
const tileset = { id: 'ion' };
|
||||
const Cesium = fakeCesium([tileset]);
|
||||
const result = await loadPhotorealisticTileset(Cesium, {
|
||||
googleTokens: tokenSource(),
|
||||
cesiumToken: 'ion-secret',
|
||||
});
|
||||
assert.equal(result.route, 'google-ion');
|
||||
assert.equal(result.errors.length, 1);
|
||||
assert.equal(Cesium.calls.length, 1);
|
||||
});
|
||||
+56
-10
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
|
||||
|
||||
/**
|
||||
* Load Google Photorealistic 3D Tiles through direct Google access when
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. If the
|
||||
* direct request fails and an ion token is available, ion is the recovery path.
|
||||
* configured, otherwise through Cesium ion's hosted Google asset. Direct
|
||||
* access uses a browser key, or else short-lived tokens from the app's
|
||||
* server (see googleTokens.js). If direct access fails and an ion token is
|
||||
* available, ion is the recovery path.
|
||||
*
|
||||
* @param {object} Cesium
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
|
||||
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
|
||||
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
|
||||
*/
|
||||
export async function loadPhotorealisticTileset(
|
||||
Cesium,
|
||||
{ googleApiKey = '', cesiumToken = '' } = {},
|
||||
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
|
||||
) {
|
||||
const googleKey = clean(googleApiKey);
|
||||
const ionToken = clean(cesiumToken);
|
||||
const errors = [];
|
||||
|
||||
const attempts = [];
|
||||
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
|
||||
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
|
||||
if (googleKey)
|
||||
attempts.push({
|
||||
route: 'google-direct',
|
||||
create: () => createGoogleDirectTileset(Cesium, googleKey),
|
||||
});
|
||||
else if (googleTokens)
|
||||
attempts.push({
|
||||
route: 'google-token',
|
||||
create: () => createGoogleTokenTileset(Cesium, googleTokens),
|
||||
});
|
||||
if (ionToken)
|
||||
attempts.push({
|
||||
route: 'google-ion',
|
||||
create: () => createGoogleIonTileset(Cesium, ionToken),
|
||||
});
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const tileset = attempt.googleKey
|
||||
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
|
||||
: await createGoogleIonTileset(Cesium, ionToken);
|
||||
const tileset = await attempt.create();
|
||||
return { tileset, route: attempt.route, errors };
|
||||
} catch (error) {
|
||||
errors.push(error instanceof Error ? error : new Error(String(error)));
|
||||
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 3D with short-lived tokens instead of a key. Every tile request
|
||||
* carries the token as a header; a tile refused for an expired token gets
|
||||
* the renewed one and is tried once more.
|
||||
*/
|
||||
export async function createGoogleTokenTileset(Cesium, tokens) {
|
||||
const token = await tokens.token();
|
||||
if (!token) throw new Error('Google 3D tokens are not offered');
|
||||
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
|
||||
const resource = new Cesium.Resource({
|
||||
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
credits: credit ? [credit] : undefined,
|
||||
retryAttempts: 1,
|
||||
async retryCallback(failed, error) {
|
||||
if (![401, 403].includes(error?.statusCode)) return false;
|
||||
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
|
||||
const renewed = await tokens.token({ replacing: used });
|
||||
if (!renewed || renewed === used) return false;
|
||||
failed.headers.Authorization = `Bearer ${renewed}`;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
// The settings Cesium's Google helper applies to the key route.
|
||||
return Cesium.Cesium3DTileset.fromUrl(resource, {
|
||||
cacheBytes: 1536 * 1024 * 1024,
|
||||
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
|
||||
enableCollision: true,
|
||||
// Tiles keep drawing their own texture while draped weather loads.
|
||||
asynchronouslyLoadImagery: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createGoogleIonTileset(
|
||||
Cesium,
|
||||
accessToken,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Short-lived Google access tokens the app's server may offer in place of a
|
||||
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
|
||||
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
|
||||
* answer means it does not, and the app carries on without them.
|
||||
*/
|
||||
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
|
||||
|
||||
/** Renew a token this long before it expires. */
|
||||
const RENEW_EARLY_MS = 60_000;
|
||||
|
||||
function parseToken(data, now) {
|
||||
const accessToken = data?.accessToken;
|
||||
const expiresAt = Number(data?.expiresAt);
|
||||
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
|
||||
return { accessToken, expiresAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* A source of tokens from the app's server. `token()` resolves the current
|
||||
* token, renewing it shortly before it expires; `token({ replacing })`
|
||||
* renews only if `replacing` is still the current token, so many tile
|
||||
* requests rejected for the same expired token renew it once. Resolves null
|
||||
* when the server does not offer tokens.
|
||||
*/
|
||||
export function createGoogleTokenSource({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
url = GOOGLE_TOKEN_PATH,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
let current = null;
|
||||
let pending = null;
|
||||
|
||||
async function request() {
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return parseToken(await response.json(), now());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async token({ replacing } = {}) {
|
||||
const usable =
|
||||
current &&
|
||||
current.expiresAt - RENEW_EARLY_MS > now() &&
|
||||
(replacing === undefined || current.accessToken !== replacing);
|
||||
if (usable) return current.accessToken;
|
||||
pending ??= request().finally(() => {
|
||||
pending = null;
|
||||
});
|
||||
current = await pending;
|
||||
return current?.accessToken ?? null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
|
||||
|
||||
function server(answers) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
const answer = answers.shift();
|
||||
if (answer instanceof Error) throw answer;
|
||||
return answer;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const offer = (accessToken, expiresAt) =>
|
||||
Response.json({ accessToken, expiresAt });
|
||||
|
||||
test('a token is reused until shortly before it expires', async () => {
|
||||
let now = 1_000_000;
|
||||
const { calls, fetchImpl } = server([
|
||||
offer('first', now + 900_000),
|
||||
offer('second', now + 1_800_000),
|
||||
]);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||
assert.equal(await tokens.token(), 'first');
|
||||
assert.equal(await tokens.token(), 'first');
|
||||
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
|
||||
now += 850_000;
|
||||
assert.equal(await tokens.token(), 'second');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('requests refused for the same token renew it once', async () => {
|
||||
const now = 1_000_000;
|
||||
const { calls, fetchImpl } = server([
|
||||
offer('first', now + 900_000),
|
||||
offer('second', now + 900_000),
|
||||
]);
|
||||
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
|
||||
await tokens.token();
|
||||
const renewed = await Promise.all([
|
||||
tokens.token({ replacing: 'first' }),
|
||||
tokens.token({ replacing: 'first' }),
|
||||
]);
|
||||
assert.deepEqual(renewed, ['second', 'second']);
|
||||
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
test('a server that does not offer tokens yields none', async () => {
|
||||
for (const answer of [
|
||||
new Response('Not found', { status: 404 }),
|
||||
new Response('<!doctype html>', { status: 200 }),
|
||||
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
|
||||
Response.json({ accessToken: 'stale', expiresAt: 1 }),
|
||||
new TypeError('offline'),
|
||||
]) {
|
||||
const { fetchImpl } = server([answer]);
|
||||
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
|
||||
}
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
normalizeReadsbAircraft,
|
||||
normalizeVesselObservation,
|
||||
openSkySnapshot,
|
||||
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
const requests = [];
|
||||
const fetchImpl = async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
if (url.startsWith('/api/opensky?'))
|
||||
if (url.startsWith('/api/flights?'))
|
||||
return response(
|
||||
{ time: now / 1000, states: [aircraft] },
|
||||
{
|
||||
@@ -141,27 +141,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
|
||||
'x-flight-coverage': '250 nm regional',
|
||||
},
|
||||
);
|
||||
if (url.startsWith('/api/opensky-track'))
|
||||
if (url.startsWith('/api/flights/track'))
|
||||
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
|
||||
if (url === '/api/adsblol/mil')
|
||||
if (url === '/api/military')
|
||||
return response(
|
||||
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
|
||||
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
|
||||
{ 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
|
||||
);
|
||||
return response({
|
||||
timestamp: now / 1000 - 60,
|
||||
trace: [[10, 30, -97, 1000]],
|
||||
});
|
||||
};
|
||||
const civil = createOpenSkySource({ fetchImpl, now: () => now });
|
||||
const military = createAdsbLolSource({ fetchImpl, now: () => now });
|
||||
const civil = createFlightSource({ fetchImpl, now: () => now });
|
||||
const military = createMilitarySource({ fetchImpl, now: () => now });
|
||||
assert.equal(requests.length, 0);
|
||||
const signal = new AbortController().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/flights?lat=30.1235&lon=-97.0000');
|
||||
assert.equal(requests[0].init.signal, signal);
|
||||
assert.equal(snapshot.source, 'adsb.lol');
|
||||
assert.equal(snapshot.coverage, '250 nm regional');
|
||||
@@ -180,7 +180,7 @@ 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 () => {
|
||||
let release;
|
||||
const controller = new AbortController();
|
||||
const source = createOpenSkySource({
|
||||
const source = createFlightSource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
json: () =>
|
||||
@@ -199,7 +199,7 @@ 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 () => {
|
||||
for (const status of [401, 403, 429, 502]) {
|
||||
let calls = 0;
|
||||
const source = createOpenSkySource({
|
||||
const source = createFlightSource({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return response({ error: 'sensitive upstream detail' }, {}, status);
|
||||
@@ -216,12 +216,12 @@ 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 () => {
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
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/vessels?maxRows=500');
|
||||
return response({
|
||||
status: 'reconnecting',
|
||||
refreshing: true,
|
||||
@@ -249,7 +249,7 @@ test('AIS reports limited received coverage and keeps connection state separate
|
||||
});
|
||||
|
||||
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
fetchImpl: async () =>
|
||||
response({
|
||||
status: 'live',
|
||||
@@ -273,7 +273,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
|
||||
});
|
||||
|
||||
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
|
||||
const source = createAdsbLolSource({
|
||||
const source = createMilitarySource({
|
||||
fetchImpl: async () =>
|
||||
response({
|
||||
ac: [
|
||||
@@ -293,12 +293,12 @@ test('classification identities include positionless aircraft without admitting
|
||||
});
|
||||
|
||||
test('identity lookup validates its response and honors body-parse cancellation', async () => {
|
||||
const malformed = createAdsbLolSource({
|
||||
const malformed = createMilitarySource({
|
||||
fetchImpl: async () => response({ ac: [{}] }),
|
||||
});
|
||||
await assert.rejects(malformed.getIdentities(), /Malformed/);
|
||||
const abort = new AbortController();
|
||||
const source = createAdsbLolSource({
|
||||
const source = createMilitarySource({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
vesselSnapshot,
|
||||
} from './vessels.js';
|
||||
export {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
} from './standalone.js';
|
||||
@@ -17,7 +17,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
|
||||
const header = (response, name) => response.headers?.get?.(name);
|
||||
|
||||
function openSkyError(response) {
|
||||
const error = httpError(response, 'OpenSky');
|
||||
const error = httpError(response, 'Flights');
|
||||
const mode = String(
|
||||
header(response, 'x-opensky-auth-mode-used') ||
|
||||
header(response, 'x-opensky-auth') ||
|
||||
@@ -49,13 +49,24 @@ function openSkyError(response) {
|
||||
return error;
|
||||
}
|
||||
|
||||
/** Existing same-origin aircraft routes; no request starts during construction. */
|
||||
export function createOpenSkySource({
|
||||
/**
|
||||
* The provider a feed response names in `X-Feed-Source`, so a server that
|
||||
* answers a feed route from another provider is reported as that provider.
|
||||
*/
|
||||
function feedSource(response, fallback) {
|
||||
const named = header(response, 'x-feed-source');
|
||||
return typeof named === 'string' && named.trim()
|
||||
? named.trim().slice(0, 80)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/** The flights feed (/api/flights); no request starts during construction. */
|
||||
export function createFlightSource({
|
||||
fetchImpl = defaultFetch,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'OpenSky Network',
|
||||
label: 'Flights',
|
||||
async getSnapshot(query = {}, { signal } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
|
||||
@@ -64,14 +75,17 @@ export function createOpenSkySource({
|
||||
}
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
`/api/opensky${params.size ? '?' + params : ''}`,
|
||||
`/api/flights${params.size ? '?' + params : ''}`,
|
||||
{ signal },
|
||||
'OpenSky',
|
||||
'Flights',
|
||||
);
|
||||
if (!response.ok) throw openSkyError(response);
|
||||
return {
|
||||
...openSkySnapshot(payload, {
|
||||
source: header(response, 'x-flight-source') || 'OpenSky Network',
|
||||
// A fallback provider names itself in X-Flight-Source.
|
||||
source:
|
||||
header(response, 'x-flight-source') ||
|
||||
feedSource(response, 'Flights'),
|
||||
coverage:
|
||||
header(response, 'x-flight-coverage') ||
|
||||
'worldwide upstream snapshot',
|
||||
@@ -83,11 +97,11 @@ export function createOpenSkySource({
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
|
||||
'/api/flights/track?icao24=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'OpenSky',
|
||||
'Flights',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'OpenSky');
|
||||
if (!response.ok) throw httpError(response, 'Flights');
|
||||
return {
|
||||
records: normalizeAircraftTrack(payload?.path),
|
||||
complete: false,
|
||||
@@ -108,36 +122,37 @@ export function createOpenSkySource({
|
||||
};
|
||||
}
|
||||
|
||||
export function createAdsbLolSource({
|
||||
export function createMilitarySource({
|
||||
fetchImpl = defaultFetch,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'adsb.lol',
|
||||
label: 'Military aircraft',
|
||||
async getIdentities(_query = {}, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/mil',
|
||||
'/api/military',
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
return readsbIdentities(payload);
|
||||
},
|
||||
async getSnapshot(_query = {}, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/mil',
|
||||
'/api/military',
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
const age = finite(header(response, 'x-feed-age-ms'));
|
||||
return {
|
||||
...readsbSnapshot(payload, {
|
||||
source: feedSource(response, 'Military aircraft'),
|
||||
observedAtMs: now() - (age != null && age > 0 ? age : 0),
|
||||
now: now(),
|
||||
stale: header(response, 'x-ads-b-cache') === 'STALE',
|
||||
stale: header(response, 'x-feed-cache') === 'STALE',
|
||||
}),
|
||||
status: response.status,
|
||||
};
|
||||
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
|
||||
'/api/military/track?hex=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'adsb.lol',
|
||||
'Military aircraft',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'adsb.lol');
|
||||
if (!response.ok) throw httpError(response, 'Military aircraft');
|
||||
const baseTimeMs = epoch(payload?.timestamp, 1000);
|
||||
return {
|
||||
records:
|
||||
@@ -165,13 +180,13 @@ export function createAdsbLolSource({
|
||||
};
|
||||
}
|
||||
|
||||
export function createAisStreamSource({
|
||||
export function createVesselSource({
|
||||
fetchImpl = defaultFetch,
|
||||
apiUrl = '/api/ais-live',
|
||||
apiUrl = '/api/vessels',
|
||||
origin = () => globalThis.location?.origin || 'http://localhost',
|
||||
} = {}) {
|
||||
return {
|
||||
label: 'AISStream',
|
||||
label: 'Vessels',
|
||||
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
|
||||
const url = new URL(apiUrl, origin());
|
||||
url.searchParams.set('maxRows', String(maxRows));
|
||||
@@ -190,10 +205,10 @@ export function createAisStreamSource({
|
||||
fetchImpl,
|
||||
url.toString(),
|
||||
{ signal, cache: 'no-store' },
|
||||
'AIS live',
|
||||
'Vessels',
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = httpError(response, 'AIS live');
|
||||
const error = httpError(response, 'Vessels');
|
||||
const reasons = {
|
||||
'missing-key': 'AISSTREAM_API_KEY not set',
|
||||
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
|
||||
@@ -204,16 +219,19 @@ export function createAisStreamSource({
|
||||
error.message = reasons[payload?.status] || error.message;
|
||||
throw error;
|
||||
}
|
||||
return { ...vesselSnapshot(payload), status: response.status };
|
||||
return {
|
||||
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
|
||||
status: response.status,
|
||||
};
|
||||
},
|
||||
async getTrack(reference, { signal } = {}) {
|
||||
const { response, payload } = await readResponse(
|
||||
fetchImpl,
|
||||
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
|
||||
'/api/vessels/track?mmsi=' + encodeURIComponent(reference),
|
||||
{ signal },
|
||||
'AIS live',
|
||||
'Vessels',
|
||||
);
|
||||
if (!response.ok) throw httpError(response, 'AIS live');
|
||||
if (!response.ok) throw httpError(response, 'Vessels');
|
||||
return {
|
||||
records: normalizeVesselTrack(payload?.samples),
|
||||
complete: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
|
||||
import { createStandaloneControls } from './controls.js';
|
||||
import { createStandaloneData } from './data.js';
|
||||
import { createStandaloneTools } from './tools.js';
|
||||
import { createGoogleTokenSource } from '../maps/googleTokens.js';
|
||||
|
||||
// The existing controls and layer catalog contain page-scoped state.
|
||||
let constructed = false;
|
||||
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
|
||||
const scene = await createStandaloneScene({
|
||||
...context,
|
||||
googleApiKey,
|
||||
// Without a key, Google 3D can still load with tokens from the
|
||||
// app's server when it offers them.
|
||||
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
|
||||
cesiumToken,
|
||||
loaderStatus,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
|
||||
import {
|
||||
createOpenSkySource,
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createFlightSource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
} from '../sources/live/standalone.js';
|
||||
import { createCctvSource } from '../layers/cctv/source.js';
|
||||
import { createRadioSource } from '../layers/radio/source.js';
|
||||
@@ -25,10 +25,10 @@ export function createStandaloneLayerSources() {
|
||||
const mapTiles = createOpenFreeMapSource();
|
||||
return {
|
||||
...createReferenceSources(),
|
||||
flights: createOpenSkySource(),
|
||||
military: createAdsbLolSource(),
|
||||
vessels: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
flights: createFlightSource(),
|
||||
military: createMilitarySource(),
|
||||
vessels: createVesselSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
|
||||
// Resolved against the document's address, which a panel host may
|
||||
// serve from its own scheme.
|
||||
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
|
||||
|
||||
@@ -93,11 +93,11 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
|
||||
});
|
||||
const states = install(providers.openSkyProxy());
|
||||
assert.equal(
|
||||
(await states('/api/opensky', '?lat=30&lon=-97')).statusCode,
|
||||
(await states('/api/flights', '?lat=30&lon=-97')).statusCode,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(await states('/api/opensky', '?lat=30&lon=-97')).headers[
|
||||
(await states('/api/flights', '?lat=30&lon=-97')).headers[
|
||||
'x-opensky-cache'
|
||||
],
|
||||
'HIT',
|
||||
@@ -105,14 +105,14 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
|
||||
assert.equal(calls.length, 2);
|
||||
const tracks = install(providers.trackBackfillProxies(), true);
|
||||
assert.equal(
|
||||
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode,
|
||||
(await tracks('/api/flights/track', '?icao24=ABC123')).statusCode,
|
||||
200,
|
||||
);
|
||||
await tracks('/api/opensky-track', '?icao24=abc123');
|
||||
await tracks('/api/flights/track', '?icao24=abc123');
|
||||
assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1);
|
||||
assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
|
||||
assert.equal(
|
||||
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode,
|
||||
(await tracks('/api/military/track', '?hex=invalid')).statusCode,
|
||||
400,
|
||||
);
|
||||
now += 130_000;
|
||||
@@ -131,7 +131,7 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
|
||||
);
|
||||
process.env.OPENSKY_AUTH_MODE = 'anon';
|
||||
const fallback = await install(fresh.openSkyProxy())(
|
||||
'/api/opensky',
|
||||
'/api/flights',
|
||||
'?lat=30&lon=-97',
|
||||
);
|
||||
assert.equal(fallback.statusCode, 200);
|
||||
@@ -174,7 +174,7 @@ test('OpenSky global snapshot attempts are time-limited, retried once, then fall
|
||||
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
|
||||
);
|
||||
const response = await install(fresh.openSkyProxy())(
|
||||
'/api/opensky',
|
||||
'/api/flights',
|
||||
'?lat=30&lon=-97',
|
||||
);
|
||||
return { response, attempts };
|
||||
@@ -203,11 +203,11 @@ test('military aircraft route preserves fresh cache and stale response after ups
|
||||
return Response.json({ ac: [{ hex: 'abc123' }] });
|
||||
});
|
||||
const request = install(providers.adsbLolProxy());
|
||||
const first = await request('/api/adsblol/mil');
|
||||
assert.equal((await request('/api/adsblol/mil')).body, first.body);
|
||||
const first = await request('/api/military');
|
||||
assert.equal((await request('/api/military')).body, first.body);
|
||||
assert.equal(calls, 1);
|
||||
now += 13_000;
|
||||
assert.equal((await request('/api/adsblol/mil')).body, first.body);
|
||||
assert.equal((await request('/api/military')).body, first.body);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
@@ -261,7 +261,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
|
||||
const request = install(plugin, true);
|
||||
let res;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
res = await request('/api/ais-live');
|
||||
res = await request('/api/vessels');
|
||||
if (JSON.parse(res.body).rows.length) break;
|
||||
await delay(10);
|
||||
}
|
||||
@@ -269,10 +269,10 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
|
||||
assert.equal(data.status, 'live');
|
||||
assert.equal(data.rows[0].mmsi, '123456789');
|
||||
assert.equal(data.rows[0].heading, null);
|
||||
const history = await request('/api/ais-live', '/track?mmsi=123456789');
|
||||
const history = await request('/api/vessels', '/track?mmsi=123456789');
|
||||
assert.equal(JSON.parse(history.body).samples.length, 2);
|
||||
assert.equal(
|
||||
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode,
|
||||
(await request('/api/vessels', '/track?mmsi=bad')).statusCode,
|
||||
400,
|
||||
);
|
||||
assert.equal(sockets.length, 1);
|
||||
@@ -280,7 +280,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
|
||||
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
|
||||
assert.equal(sockets[0].readyState, 3);
|
||||
const restarted = install(plugin);
|
||||
await restarted('/api/ais-live');
|
||||
await restarted('/api/vessels');
|
||||
for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
|
||||
assert.equal(sockets.length, 2);
|
||||
});
|
||||
@@ -299,10 +299,10 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
|
||||
});
|
||||
});
|
||||
const request = install(providers.adsbLolProxy());
|
||||
const first = await request('/api/adsblol/mil');
|
||||
const first = await request('/api/military');
|
||||
assert.equal(first.statusCode, 200);
|
||||
now += 13_000;
|
||||
const limited = await request('/api/adsblol/mil');
|
||||
const limited = await request('/api/military');
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(
|
||||
limited.statusCode,
|
||||
@@ -310,16 +310,16 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
|
||||
'a 429 with a cached body is never relayed',
|
||||
);
|
||||
assert.equal(limited.body, first.body);
|
||||
assert.equal(limited.headers['x-ads-b-cache'], 'STALE');
|
||||
assert.equal(limited.headers['x-feed-cache'], 'STALE');
|
||||
assert.equal(limited.headers['x-ads-b-upstream-status'], '429');
|
||||
assert.equal(limited.headers['x-ads-b-cache-age-ms'], '13000');
|
||||
assert.equal(limited.headers['x-feed-age-ms'], '13000');
|
||||
now += 5_000;
|
||||
const cooling = await request('/api/adsblol/mil');
|
||||
const cooling = await request('/api/military');
|
||||
assert.equal(calls, 2, 'no upstream call inside the Retry-After window');
|
||||
assert.equal(cooling.headers['x-ads-b-cache'], 'STALE');
|
||||
assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000');
|
||||
assert.equal(cooling.headers['x-feed-cache'], 'STALE');
|
||||
assert.equal(cooling.headers['x-feed-age-ms'], '18000');
|
||||
now += 16_000;
|
||||
await request('/api/adsblol/mil');
|
||||
await request('/api/military');
|
||||
assert.equal(calls, 3, 'upstream is retried once Retry-After elapses');
|
||||
});
|
||||
|
||||
@@ -331,10 +331,10 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as
|
||||
return new Response('{"error":"rate limited"}', { status: 429 });
|
||||
});
|
||||
const request = install(providers.adsbLolProxy());
|
||||
const limited = await request('/api/adsblol/mil');
|
||||
const limited = await request('/api/military');
|
||||
assert.equal(limited.statusCode, 429);
|
||||
assert.ok(limited.headers['retry-after']);
|
||||
const again = await request('/api/adsblol/mil');
|
||||
const again = await request('/api/military');
|
||||
assert.equal(again.statusCode, 429);
|
||||
assert.equal(
|
||||
calls,
|
||||
@@ -369,21 +369,21 @@ test('military fallback cancels a stalled 5xx body and starts cooldown at receip
|
||||
);
|
||||
});
|
||||
const request = install(providers.adsbLolProxy());
|
||||
await request('/api/adsblol/mil');
|
||||
await request('/api/military');
|
||||
now += 1000;
|
||||
const hit = await request('/api/adsblol/mil');
|
||||
assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000');
|
||||
const hit = await request('/api/military');
|
||||
assert.equal(hit.headers['x-feed-age-ms'], '1000');
|
||||
now += 12000;
|
||||
const fallback = await request('/api/adsblol/mil');
|
||||
const fallback = await request('/api/military');
|
||||
assert.equal(fallback.statusCode, 200);
|
||||
assert.equal(cancelled, true);
|
||||
assert.equal(fallback.headers['x-ads-b-cache-age-ms'], '23000');
|
||||
assert.equal(fallback.headers['x-feed-age-ms'], '23000');
|
||||
assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20');
|
||||
now += 19000;
|
||||
await request('/api/adsblol/mil');
|
||||
await request('/api/military');
|
||||
assert.equal(calls, 2);
|
||||
now += 2000;
|
||||
await request('/api/adsblol/mil');
|
||||
await request('/api/military');
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
@@ -403,7 +403,7 @@ test('military cooldown bounds untrusted Retry-After and defaults server errors'
|
||||
headers: { 'Retry-After': raw },
|
||||
}),
|
||||
);
|
||||
const result = await install(providers.adsbLolProxy())('/api/adsblol/mil');
|
||||
const result = await install(providers.adsbLolProxy())('/api/military');
|
||||
assert.equal(Number(result.headers['retry-after']), seconds);
|
||||
}
|
||||
});
|
||||
@@ -423,7 +423,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
};
|
||||
});
|
||||
|
||||
const res1 = await tracks('/api/opensky-track', '?icao24=def456');
|
||||
const res1 = await tracks('/api/flights/track', '?icao24=def456');
|
||||
assert.equal(res1.statusCode, 502);
|
||||
assert.deepEqual(JSON.parse(res1.body), {
|
||||
error: 'Upstream track response too large',
|
||||
@@ -431,7 +431,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
|
||||
|
||||
// Cached as a 502 (never a 200): a retry inside the window neither
|
||||
// reads as an empty track nor spends OpenSky credits on another download.
|
||||
const res2 = await tracks('/api/opensky-track', '?icao24=def456');
|
||||
const res2 = await tracks('/api/flights/track', '?icao24=def456');
|
||||
assert.equal(res2.statusCode, 502);
|
||||
assert.equal(callCount, 1);
|
||||
});
|
||||
@@ -97,8 +97,8 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
try {
|
||||
for (const [route, status] of [
|
||||
['/api/opensky', 200],
|
||||
['/api/adsblol/mil', 200],
|
||||
['/api/flights', 200],
|
||||
['/api/military', 200],
|
||||
['/api/adsbdb/type/invalid', 400],
|
||||
['/api/firms/status', 200],
|
||||
['/api/fire-perimeters/inciweb/publication/invalid', 400],
|
||||
|
||||
@@ -373,10 +373,10 @@ test('aircraft searches say when a feed that answered is stale', async () => {
|
||||
});
|
||||
|
||||
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
|
||||
const { createOpenSkySource } =
|
||||
const { createFlightSource } =
|
||||
await import('../../sources/live/standalone.js');
|
||||
const now = Date.UTC(2026, 0, 1, 12);
|
||||
const opensky = createOpenSkySource({
|
||||
const opensky = createFlightSource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json({ time: now / 1000 - 3600, states: [] }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
import { createAisStreamSource } from '../../sources/live/standalone.js';
|
||||
import { createVesselSource } from '../../sources/live/standalone.js';
|
||||
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
|
||||
|
||||
const vessel = (id, latitude, longitude, extra = {}) => ({
|
||||
@@ -184,7 +184,7 @@ test('a missing AIS key surfaces as an unavailable feed', async () => {
|
||||
|
||||
test('vessel searches ask for every vessel the server retains', async () => {
|
||||
const requested = [];
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
origin: () => 'http://localhost',
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(new URL(url).searchParams.get('maxRows'));
|
||||
@@ -204,7 +204,7 @@ test('vessel searches ask for every vessel the server retains', async () => {
|
||||
|
||||
test('an area search names its area to the server; a vessel lookup asks for every vessel', async () => {
|
||||
const requested = [];
|
||||
const source = createAisStreamSource({
|
||||
const source = createVesselSource({
|
||||
origin: () => 'http://localhost',
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(new URL(url).searchParams);
|
||||
|
||||
@@ -31,9 +31,9 @@ import { createApplicationRequestServices } from '../services/requests.js';
|
||||
import { createEventPackSource } from '../sources/eventPacks.js';
|
||||
import { createInfrastructureSource } from '../sources/infrastructureData.js';
|
||||
import {
|
||||
createAdsbLolSource,
|
||||
createAisStreamSource,
|
||||
createOpenSkySource,
|
||||
createMilitarySource,
|
||||
createVesselSource,
|
||||
createFlightSource,
|
||||
} from '../sources/live/standalone.js';
|
||||
import {
|
||||
createGeocodePlaceService,
|
||||
@@ -51,9 +51,9 @@ export function createToolServices({ fetchImpl, appUrl, panelKey }) {
|
||||
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
|
||||
fires: createFirmsSource({ fetchImpl }),
|
||||
launches: createLaunchSource({ fetchImpl }),
|
||||
aircraft: createOpenSkySource({ fetchImpl }),
|
||||
military: createAdsbLolSource({ fetchImpl }),
|
||||
vessels: createAisStreamSource({
|
||||
aircraft: createFlightSource({ fetchImpl }),
|
||||
military: createMilitarySource({ fetchImpl }),
|
||||
vessels: createVesselSource({
|
||||
fetchImpl,
|
||||
origin: () => new URL(appUrl).origin,
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user