mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' of https://github.com/bilawalsidhu/gods-eye-view into pr/mapillary-layer
# Conflicts: # CHANGELOG.md # README.md
This commit is contained in:
commit
91d8bba4a3
75 files changed
+1599
-381
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();
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
loadVegvesenSourcesFromOpenData,
|
||||
vegvesenCameraToSource,
|
||||
} from '../../server/providers/cctv/sources.js';
|
||||
import {
|
||||
DEFAULT_VEGVESEN_CCTV_URL,
|
||||
VEGVESEN_IMAGE_ORIGIN,
|
||||
} from '../../server/providers/cctv/constants.js';
|
||||
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
|
||||
|
||||
/** One `datex_3_1:CctvSimple` feature, shaped like the live OGC payload. */
|
||||
const feature = (props = {}, coordinates = [5.459189, 61.832706]) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates },
|
||||
properties: {
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
cameraId: '3000063_1',
|
||||
roadNumber: 'F614',
|
||||
'status.stillImageAvailability': 'videoOrImagesAvailable',
|
||||
orientationDescription: 'Svelgen',
|
||||
description: 'Langesi',
|
||||
...props,
|
||||
},
|
||||
});
|
||||
|
||||
test('a Vegvesen feature maps to a source on the pinned frame host', () => {
|
||||
const source = vegvesenCameraToSource(feature());
|
||||
assert.equal(source.id, 'no-vegvesen-3000063_1');
|
||||
assert.equal(source.name, 'F614 Langesi → Svelgen');
|
||||
assert.equal(source.city, 'Norway');
|
||||
assert.equal(source.cityId, 'norway');
|
||||
assert.equal(source.provider, 'Statens vegvesen');
|
||||
assert.equal(source.lat, 61.832706);
|
||||
assert.equal(source.lon, 5.459189);
|
||||
assert.equal(source.headingConfidence, 'low');
|
||||
assert.equal(source.sourceKind, 'vegvesen-datex');
|
||||
assert.equal(source.code, 'LANGESI');
|
||||
assert.ok(source.url.startsWith(VEGVESEN_IMAGE_ORIGIN));
|
||||
assert.equal(source.snapshotUrl, source.url);
|
||||
});
|
||||
|
||||
test('cameras that publish HLS get live video with the still as fallback', (t) => {
|
||||
const video = {
|
||||
videoServiceLevel: 1,
|
||||
videoEncodingStandard: 'hls',
|
||||
videoUrl: 'https://kamera.vegvesen.no/public/3000063_1/manifest.m3u8',
|
||||
};
|
||||
const source = vegvesenCameraToSource(feature(video));
|
||||
assert.equal(source.feedType, 'hls');
|
||||
assert.equal(source.url, video.videoUrl);
|
||||
assert.equal(
|
||||
source.snapshotUrl,
|
||||
'https://kamera.atlas.vegvesen.no/api/images/3000063_1',
|
||||
);
|
||||
// A manifest anywhere but the camera's own path stays a still.
|
||||
const offPath = vegvesenCameraToSource(
|
||||
feature({ ...video, videoUrl: 'https://evil.test/x/manifest.m3u8' }),
|
||||
);
|
||||
assert.equal(offPath.feedType, 'image');
|
||||
assert.equal(offPath.url, offPath.snapshotUrl);
|
||||
// The kill switch keeps every camera on stills.
|
||||
const saved = process.env.CCTV_VEGVESEN_VIDEO;
|
||||
t.after(() => {
|
||||
if (saved === undefined) delete process.env.CCTV_VEGVESEN_VIDEO;
|
||||
else process.env.CCTV_VEGVESEN_VIDEO = saved;
|
||||
});
|
||||
process.env.CCTV_VEGVESEN_VIDEO = '0';
|
||||
assert.equal(vegvesenCameraToSource(feature(video)).feedType, 'image');
|
||||
});
|
||||
|
||||
test('faulty cameras, off-host frames and bad geometry are dropped', () => {
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
'status.stillImageAvailability':
|
||||
'videoOrImagesUnavailableDueToCameraFault',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({ stillImageUrl: 'https://evil.test/api/images/3000063_1' }),
|
||||
),
|
||||
null,
|
||||
);
|
||||
// The frame URL must be exactly the camera's own image path.
|
||||
assert.equal(
|
||||
vegvesenCameraToSource(
|
||||
feature({
|
||||
stillImageUrl: 'https://kamera.atlas.vegvesen.no/api/images/999_1',
|
||||
}),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(vegvesenCameraToSource(feature({ cameraId: '../x' })), null);
|
||||
// Copenhagen: a plausible coordinate, but outside the Norway box.
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [12.57, 55.68])), null);
|
||||
assert.equal(vegvesenCameraToSource(feature({}, [5.4])), null);
|
||||
assert.equal(vegvesenCameraToSource(null), null);
|
||||
});
|
||||
|
||||
test('the loader dedupes, and failures degrade to an empty pack', async (t) => {
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
const requested = [];
|
||||
const fetchMock = t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
requested.push(String(url));
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
feature(),
|
||||
feature(),
|
||||
feature(
|
||||
{
|
||||
cameraId: '0629001_1',
|
||||
stillImageUrl:
|
||||
'https://kamera.atlas.vegvesen.no/api/images/0629001_1',
|
||||
description: 'Oslo S',
|
||||
},
|
||||
[10.752, 59.911],
|
||||
),
|
||||
],
|
||||
});
|
||||
});
|
||||
const cameras = await loadVegvesenSourcesFromOpenData();
|
||||
assert.deepEqual(requested, [DEFAULT_VEGVESEN_CCTV_URL]);
|
||||
// Nearest-to-anchor first: Oslo leads.
|
||||
assert.deepEqual(
|
||||
cameras.map((c) => c.id),
|
||||
['no-vegvesen-0629001_1', 'no-vegvesen-3000063_1'],
|
||||
);
|
||||
|
||||
fetchMock.mock.mockImplementation(
|
||||
async () => new Response('nope', { status: 503 }),
|
||||
);
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
fetchMock.mock.mockImplementation(async () => {
|
||||
throw new Error('offline');
|
||||
});
|
||||
assert.deepEqual(await loadVegvesenSourcesFromOpenData(), []);
|
||||
});
|
||||
|
||||
const runCatalog = async (t) => {
|
||||
const requested = [];
|
||||
t.mock.method(console, 'log', () => {});
|
||||
t.mock.method(console, 'warn', () => {});
|
||||
t.mock.method(globalThis, 'fetch', async (url) => {
|
||||
const href = String(url);
|
||||
requested.push(href);
|
||||
if (href === DEFAULT_VEGVESEN_CCTV_URL) {
|
||||
return Response.json({
|
||||
type: 'FeatureCollection',
|
||||
features: [feature()],
|
||||
});
|
||||
}
|
||||
return Response.json([]);
|
||||
});
|
||||
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
|
||||
return { requested, sources };
|
||||
};
|
||||
|
||||
const withEnv = async (patch, fn) => {
|
||||
const saved = { ...process.env };
|
||||
try {
|
||||
delete process.env.CCTV_SOURCES_FILE;
|
||||
delete process.env.CCTV_SOURCES_JSON;
|
||||
delete process.env.CCTV_VEGVESEN_ENABLED;
|
||||
Object.assign(process.env, patch);
|
||||
await fn();
|
||||
} finally {
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in saved)) delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, saved);
|
||||
}
|
||||
};
|
||||
|
||||
test('the Vegvesen lane is wired into the catalog', async (t) => {
|
||||
await withEnv({}, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.ok(requested.includes(DEFAULT_VEGVESEN_CCTV_URL));
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway').map((s) => s.id),
|
||||
['no-vegvesen-3000063_1'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('CCTV_VEGVESEN_ENABLED=0 keeps the lane from being loaded', async (t) => {
|
||||
await withEnv({ CCTV_VEGVESEN_ENABLED: '0' }, async () => {
|
||||
const { requested, sources } = await runCatalog(t);
|
||||
assert.equal(requested.includes(DEFAULT_VEGVESEN_CCTV_URL), false);
|
||||
assert.deepEqual(
|
||||
sources.filter((s) => s.cityId === 'norway'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -240,6 +240,13 @@ export const DATA_CREDITS = [
|
||||
'Traffic cameras (Calgary): contains information licensed under the ' +
|
||||
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
|
||||
},
|
||||
{
|
||||
key: 'vegvesen-cctv',
|
||||
html:
|
||||
'Road cameras (Norway): contains data under the ' +
|
||||
'<a href="https://data.norge.no/nlod/en/2.0" target="_blank" rel="noopener">Norwegian licence for Open Government data (NLOD)</a> ' +
|
||||
'distributed by Statens vegvesen',
|
||||
},
|
||||
{
|
||||
key: 'gbfs',
|
||||
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
|
||||
|
||||
+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
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
|
||||
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
* @param {object} services Caller-owned context, overlay and render operations.
|
||||
* @returns {object[]} Datacenters then dams, with stable standalone identities.
|
||||
*/
|
||||
export function createInfrastructureLayers(services) {
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
const datacenters = createLocalGeoJsonLayer(
|
||||
{
|
||||
id: 'local-datacenters',
|
||||
|
||||
@@ -476,7 +476,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
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
|
||||
// Commercial users must remove it or obtain a TeleGeography license.
|
||||
// See DATA_SOURCES.md and the bundled dataset's source.json.
|
||||
const cableUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
const landingPointUrl = new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
// Resolved when read, so importing this module never needs a module URL.
|
||||
function cableUrl() {
|
||||
return new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
}
|
||||
function landingPointUrl() {
|
||||
return new URL(
|
||||
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
|
||||
import.meta.url,
|
||||
).href;
|
||||
}
|
||||
|
||||
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
|
||||
export function createBundledCableSource({
|
||||
@@ -33,8 +38,8 @@ export function createBundledCableSource({
|
||||
label: 'TeleGeography',
|
||||
async fetch(signal) {
|
||||
const [cables, landingPoints] = await Promise.all([
|
||||
read(cableUrl, signal),
|
||||
read(landingPointUrl, signal),
|
||||
read(cableUrl(), signal),
|
||||
read(landingPointUrl(), signal),
|
||||
]);
|
||||
return { cables, landingPoints };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { viewAreaRadiusKm } from './viewArea.js';
|
||||
|
||||
/**
|
||||
* The area a viewer is looking at, as `{ lat, lon, radiusKm }`: centered on
|
||||
* the point in the middle of the screen (or below the camera when the middle
|
||||
* shows sky), sized by the camera's height. Null for a view that wants every
|
||||
* vessel.
|
||||
*/
|
||||
export function vesselViewArea(viewer) {
|
||||
const camera = viewer?.camera;
|
||||
const scene = viewer?.scene;
|
||||
const height = camera?.positionCartographic?.height;
|
||||
const radiusKm = viewAreaRadiusKm(height);
|
||||
if (radiusKm == null) return null;
|
||||
let center = camera.positionCartographic;
|
||||
const canvas = scene?.canvas;
|
||||
if (canvas?.clientWidth && canvas?.clientHeight) {
|
||||
try {
|
||||
const hit = camera.pickEllipsoid(
|
||||
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
|
||||
scene.globe?.ellipsoid,
|
||||
);
|
||||
if (hit) center = Cesium.Cartographic.fromCartesian(hit);
|
||||
} catch {
|
||||
/* the camera position stands in */
|
||||
}
|
||||
}
|
||||
const lat = Cesium.Math.toDegrees(center.latitude);
|
||||
const lon = Cesium.Math.toDegrees(center.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
return { lat, lon, radiusKm };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createVesselState } from './state.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { vesselViewArea } from './cameraArea.js';
|
||||
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { createSelection } from './selection.js';
|
||||
@@ -36,6 +37,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
feed: vesselState.state.feed,
|
||||
readSource: () => vesselState._source,
|
||||
readViewer: () => vesselState.state.viewer,
|
||||
readArea: vesselViewArea,
|
||||
getRowLimit: parts.rendering.renderRowLimit,
|
||||
readCount: () => vesselState.state.records.all.length,
|
||||
applyRows: parts.snapshots.reconcileVessels,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
|
||||
import { viewAreaMovedEnough } from './viewArea.js';
|
||||
|
||||
/** Own source requests and classified feed state through explicit operations. */
|
||||
export function createIngestion({
|
||||
feed,
|
||||
readSource,
|
||||
readViewer,
|
||||
readArea = () => null,
|
||||
getRowLimit,
|
||||
readCount,
|
||||
applyRows,
|
||||
@@ -34,8 +36,11 @@ export function createIngestion({
|
||||
AbortSignal.timeout(10000),
|
||||
])
|
||||
: requestController.signal;
|
||||
// Ask for the vessels in view; a wide view asks for every vessel.
|
||||
const area = readArea(viewer);
|
||||
feed.lastArea = area;
|
||||
const snapshot = await readSource().getSnapshot(
|
||||
{ maxRows: getRowLimit() },
|
||||
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
|
||||
{ signal },
|
||||
);
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||
@@ -169,6 +174,15 @@ export function createIngestion({
|
||||
: new Date(record.observedAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
/** Ask again when the view moved away from the last area asked for. */
|
||||
function refreshIfMoved(viewer) {
|
||||
if (!feed.enabled || feed.loading) return Promise.resolve();
|
||||
const active = viewer || readViewer();
|
||||
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
|
||||
return Promise.resolve();
|
||||
return loadLivePositions(active);
|
||||
}
|
||||
|
||||
const methods = {
|
||||
update(viewer) {
|
||||
if (!feed.enabled) return Promise.resolve();
|
||||
@@ -178,6 +192,7 @@ export function createIngestion({
|
||||
|
||||
return {
|
||||
loadLivePositions,
|
||||
refreshIfMoved,
|
||||
ownsAisRequest,
|
||||
applyAisFeedSnapshot,
|
||||
vesselDisplayRow,
|
||||
@@ -209,5 +224,9 @@ export function createVesselFeed() {
|
||||
firstConnectDeadline: null,
|
||||
firstConnectTimer: null,
|
||||
abort: null,
|
||||
/** The area last asked for, null for every vessel. */
|
||||
lastArea: null,
|
||||
/** Stops listening for camera moves. */
|
||||
removeViewListener: null,
|
||||
};
|
||||
}
|
||||
@@ -104,3 +104,46 @@ test('vessel ingestion converts source units once and retains warm records on a
|
||||
assert.equal(probe.feed.loading, false);
|
||||
assert.equal(probe.feed.abort, null);
|
||||
});
|
||||
|
||||
test('vessels are asked for by the area in view, and again only when the view moves away', async () => {
|
||||
const asked = [];
|
||||
let area = { lat: 37.8, lon: -122.4, radiusKm: 40 };
|
||||
const feed = createVesselFeed();
|
||||
feed.enabled = true;
|
||||
feed.sessionId = 1;
|
||||
const ingestion = createIngestion({
|
||||
feed,
|
||||
readSource: () => ({
|
||||
getSnapshot: async (query) => {
|
||||
asked.push(query);
|
||||
return { records: [], source: 'Feed' };
|
||||
},
|
||||
}),
|
||||
readViewer: () => ({}),
|
||||
readArea: () => area,
|
||||
getRowLimit: () => 500,
|
||||
readCount: () => 0,
|
||||
now: () => 0,
|
||||
setSourceLabel: () => {},
|
||||
applyRows: () => {},
|
||||
classifySnapshot: () => ({
|
||||
acceptedRows: [],
|
||||
acceptedRowCount: 0,
|
||||
rawRowCount: 0,
|
||||
}),
|
||||
isDefinitiveTransportFailure: () => false,
|
||||
isGraceEligibleTransport: () => false,
|
||||
markUnavailable: () => {},
|
||||
settleFirstConnect: () => {},
|
||||
});
|
||||
await ingestion.methods.update();
|
||||
assert.deepEqual(asked, [{ maxRows: 500, area }]);
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.equal(asked.length, 1);
|
||||
area = { ...area, lat: 38.2 };
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.deepEqual(asked.at(-1), { maxRows: 500, area });
|
||||
area = null;
|
||||
await ingestion.refreshIfMoved();
|
||||
assert.deepEqual(asked.at(-1), { maxRows: 500 });
|
||||
});
|
||||
@@ -196,10 +196,18 @@ export function createLifecycle({
|
||||
state.records.byMmsi.has(pickedId),
|
||||
);
|
||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||
// Vessels are asked for by view, so ask again when the view moves away.
|
||||
removeViewListener();
|
||||
const moveEnd = activeViewer?.camera?.moveEnd;
|
||||
if (typeof moveEnd?.addEventListener === 'function')
|
||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
||||
components.ingestion.refreshIfMoved(activeViewer);
|
||||
});
|
||||
return components.ingestion.loadLivePositions(activeViewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
removeViewListener();
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels');
|
||||
@@ -218,6 +226,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
@@ -241,6 +250,12 @@ export function createLifecycle({
|
||||
},
|
||||
};
|
||||
|
||||
function removeViewListener() {
|
||||
const remove = state.feed.removeViewListener;
|
||||
state.feed.removeViewListener = null;
|
||||
if (typeof remove === 'function') remove();
|
||||
}
|
||||
|
||||
return {
|
||||
clearFirstConnectTimer,
|
||||
invalidateAisSession,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Smallest area asked for around the view, in kilometres. */
|
||||
export const VIEW_AREA_MIN_RADIUS_KM = 10;
|
||||
/** Largest area asked for; a wider view asks for every vessel instead. */
|
||||
export const VIEW_AREA_MAX_RADIUS_KM = 450;
|
||||
|
||||
/**
|
||||
* The radius worth asking for from a camera this high, in kilometres: about
|
||||
* the height, so the area covers what a camera looking down shows. Null when
|
||||
* the view is wide enough that every vessel is wanted.
|
||||
*/
|
||||
export function viewAreaRadiusKm(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
const radiusKm = heightM / 1000;
|
||||
if (radiusKm > VIEW_AREA_MAX_RADIUS_KM) return null;
|
||||
return Math.max(VIEW_AREA_MIN_RADIUS_KM, radiusKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a view moved far enough from the last area asked for to ask
|
||||
* again: its radius changed by a quarter, or its center moved a quarter of
|
||||
* the radius. Switching between an area and every vessel always counts.
|
||||
*/
|
||||
export function viewAreaMovedEnough(previous, next) {
|
||||
if (!previous || !next) return previous !== next;
|
||||
if (Math.abs(next.radiusKm - previous.radiusKm) >= previous.radiusKm * 0.25)
|
||||
return true;
|
||||
const lat = ((previous.lat + next.lat) / 2) * (Math.PI / 180);
|
||||
const dLat = (next.lat - previous.lat) * 111.32;
|
||||
const dLon = (next.lon - previous.lon) * 111.32 * Math.cos(lat);
|
||||
return Math.hypot(dLat, dLon) >= previous.radiusKm * 0.25;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
VIEW_AREA_MIN_RADIUS_KM,
|
||||
viewAreaMovedEnough,
|
||||
viewAreaRadiusKm,
|
||||
} from './viewArea.js';
|
||||
|
||||
test('the area asked for follows the camera height, and a wide view asks for every vessel', () => {
|
||||
assert.equal(viewAreaRadiusKm(2_000), VIEW_AREA_MIN_RADIUS_KM);
|
||||
assert.equal(viewAreaRadiusKm(80_000), 80);
|
||||
assert.equal(
|
||||
viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000),
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
);
|
||||
assert.equal(viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000 + 1), null);
|
||||
assert.equal(viewAreaRadiusKm(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('a view asks again only after moving or zooming a quarter of its radius', () => {
|
||||
const here = { lat: 37.8, lon: -122.4, radiusKm: 40 };
|
||||
assert.equal(viewAreaMovedEnough(null, here), true);
|
||||
assert.equal(viewAreaMovedEnough(here, null), true);
|
||||
assert.equal(viewAreaMovedEnough(null, null), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.85 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.9 }), true);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 45 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 55 }), true);
|
||||
});
|
||||
+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);
|
||||
}
|
||||
});
|
||||
@@ -3,16 +3,19 @@
|
||||
* data files, line-delimited GeoJSON parsing and analyst records.
|
||||
*/
|
||||
|
||||
// Resolved by Vite in builds and relative to this module in other consumers.
|
||||
// Resolved by Vite in builds and relative to this module in other consumers,
|
||||
// when read, so importing this module never needs a module URL.
|
||||
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
|
||||
'local-datacenters': new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href,
|
||||
'local-dams': new URL(
|
||||
'../data/local_data/dams/dams.geojsonl',
|
||||
import.meta.url,
|
||||
).href,
|
||||
get 'local-datacenters'() {
|
||||
return new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href;
|
||||
},
|
||||
get 'local-dams'() {
|
||||
return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
|
||||
.href;
|
||||
},
|
||||
});
|
||||
|
||||
/** Parse GeoJSON Lines: one Feature per non-empty line. */
|
||||
|
||||
@@ -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,24 +180,35 @@ 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',
|
||||
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
|
||||
label: 'Vessels',
|
||||
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
|
||||
const url = new URL(apiUrl, origin());
|
||||
url.searchParams.set('maxRows', String(maxRows));
|
||||
// An area asks for the vessels around a point; servers that hold every
|
||||
// vessel may ignore it, since callers still keep only what they need.
|
||||
if (
|
||||
Number.isFinite(area?.lat) &&
|
||||
Number.isFinite(area?.lon) &&
|
||||
Number.isFinite(area?.radiusKm)
|
||||
) {
|
||||
url.searchParams.set('lat', area.lat.toFixed(5));
|
||||
url.searchParams.set('lon', area.lon.toFixed(5));
|
||||
url.searchParams.set('radius_km', area.radiusKm.toFixed(1));
|
||||
}
|
||||
const { response, payload } = await readResponse(
|
||||
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',
|
||||
@@ -193,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';
|
||||
@@ -26,10 +26,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],
|
||||
|
||||
@@ -122,3 +122,14 @@ test('a view that only follows an aircraft is framed where the aircraft is', asy
|
||||
/not reported now/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('the package exports the panel resource and its runtime together', async () => {
|
||||
const panel = await import('gods-eye-view/tools/panel');
|
||||
const resource = panel.createGlobePanelResource({
|
||||
runtime: panel.panelRuntime,
|
||||
panelKey: 'key',
|
||||
});
|
||||
assert.equal(resource.uri, panel.GLOBE_PANEL_URI);
|
||||
assert.equal(panel.PANEL_BASE, '/panel/');
|
||||
assert.match(resource.text, /panelRuntime|function/);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* The MCP Apps globe panel for MCP servers that compose Core's tools: the
|
||||
* panel resource and the script its page runs. A server serves the panel
|
||||
* build at PANEL_BASE and passes the same key to the resource and to the
|
||||
* tool services (see src/tools/services.js).
|
||||
*/
|
||||
export {
|
||||
GLOBE_PANEL_URI,
|
||||
PANEL_BASE,
|
||||
createGlobePanelResource,
|
||||
} from './globePanel.js';
|
||||
export { panelRuntime } from '../app/globePanelRuntime.js';
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
areaRadiusKm,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
@@ -48,12 +49,29 @@ function vesselRow(record, from) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every vessel the source retains. The source's default is the newest few
|
||||
* thousand, which can leave out a vessel the server still holds; asking for
|
||||
* more than any server keeps lets the server return all of them.
|
||||
* Every vessel the source retains, or with `area`, every vessel around it.
|
||||
* The source's default is the newest few thousand, which can leave out a
|
||||
* vessel the server still holds; asking for more than any server keeps lets
|
||||
* the server return all of them. The area lets a server answer for that
|
||||
* place alone; callers still keep only the vessels inside it.
|
||||
*/
|
||||
function readVessels(services, signal) {
|
||||
return services.vessels.getSnapshot({ maxRows: ALL_VESSELS }, { signal });
|
||||
function readVessels(services, signal, area) {
|
||||
const center = area ? areaCenter(area) : null;
|
||||
return services.vessels.getSnapshot(
|
||||
{
|
||||
maxRows: ALL_VESSELS,
|
||||
...(center
|
||||
? {
|
||||
area: {
|
||||
lat: center.lat,
|
||||
lon: center.lon,
|
||||
radiusKm: areaRadiusKm(area),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
function snapshotInfo(snapshot) {
|
||||
@@ -90,7 +108,7 @@ export const vesselsInArea = defineTool({
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const snapshot = await readVessels(services, signal);
|
||||
const snapshot = await readVessels(services, signal, area);
|
||||
const wanted = args.type?.toLowerCase();
|
||||
const rows = snapshot.records
|
||||
.filter(
|
||||
|
||||
@@ -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'));
|
||||
@@ -201,3 +201,26 @@ test('vessel searches ask for every vessel the server retains', async () => {
|
||||
for (const maxRows of requested)
|
||||
assert.ok(Number(maxRows) >= AISSTREAM_CACHE_MAX, maxRows);
|
||||
});
|
||||
|
||||
test('an area search names its area to the server; a vessel lookup asks for every vessel', async () => {
|
||||
const requested = [];
|
||||
const source = createVesselSource({
|
||||
origin: () => 'http://localhost',
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(new URL(url).searchParams);
|
||||
return Response.json({ rows: [], source: 'AISStream', status: 'live' });
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: source },
|
||||
});
|
||||
await catalog.call('vessels_in_area', { area: bay });
|
||||
await catalog.call('find_vessel', { mmsi: '366999712' });
|
||||
const [area, lookup] = requested;
|
||||
assert.equal(area.get('lat'), '37.78000');
|
||||
assert.equal(area.get('lon'), '-122.40000');
|
||||
assert.equal(area.get('radius_km'), '20.0');
|
||||
assert.equal(lookup.has('lat'), false);
|
||||
assert.equal(lookup.has('radius_km'), false);
|
||||
});
|
||||
@@ -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