mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
Merge main into feat/voice-outlines
Adopts main's loadBundledJson(url, {signal}) and builds the Census and
Who's On First pack URLs when they are read, not at import.
This commit is contained in:
commit
41b2475362
191 files changed
+20227
-1730
No files matched your search
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
|
||||
export * from '../../data/contactMatch.js';
|
||||
export * from '../../data/iconOrientation.js';
|
||||
export * from '../../data/layerState.js';
|
||||
export * from '../../data/layerStateCoordinator.js';
|
||||
export * from '../../data/modelVisualAnchor.js';
|
||||
export * from '../../data/motionModel.js';
|
||||
export * from '../../data/renderAltitude.js';
|
||||
|
||||
@@ -1,36 +1,12 @@
|
||||
import {
|
||||
extractStationsArray,
|
||||
normalizeGbfsBool,
|
||||
parseStationInformation,
|
||||
parseStationStatus,
|
||||
toNonNegativeInteger,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
|
||||
export function createModel({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Coerce a GBFS boolean field to a native boolean.
|
||||
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
|
||||
* @param {*} value - Raw field value from GBFS JSON.
|
||||
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
|
||||
function normalizeGbfsBool(value, fallback = true) {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return num !== 0;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (text === 'true' || text === 'yes') return true;
|
||||
if (text === 'false' || text === 'no') return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a value as a non-negative integer, returning fallback on failure.
|
||||
* @param {*} value - Raw numeric value.
|
||||
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
|
||||
* @returns {number|null}
|
||||
*/
|
||||
|
||||
function toNonNegativeInteger(value, fallback = null) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n < 0) return fallback;
|
||||
return Math.round(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a globally unique render key for a station.
|
||||
* @param {string} cityId - City identifier.
|
||||
@@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) {
|
||||
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the stations array from a GBFS JSON payload.
|
||||
* Handles multiple response shapes: { data: { stations: [...] } },
|
||||
* { data: [...] }, and nested objects with a stations sub-key.
|
||||
* @param {Object} payload - Parsed GBFS JSON response.
|
||||
* @returns {Object[]} Array of raw station objects (may be empty).
|
||||
*/
|
||||
|
||||
function extractStationsArray(payload) {
|
||||
const data = payload?.data;
|
||||
if (Array.isArray(data?.stations)) return data.stations;
|
||||
if (Array.isArray(data)) return data;
|
||||
// Some feeds nest stations under an additional key (e.g. locale wrappers)
|
||||
if (data && typeof data === 'object') {
|
||||
for (const value of Object.values(data)) {
|
||||
if (Array.isArray(value?.stations)) return value.stations;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_information payload into a Map of station metadata.
|
||||
* Handles field-name variations across different GBFS providers
|
||||
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
|
||||
* invalid coordinates are silently skipped.
|
||||
* @param {Object} payload - Parsed station_information.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to station info objects.
|
||||
*/
|
||||
|
||||
function parseStationInformation(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const stationMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
// Accept both GBFS 2.x (station_id) and alternate (id) field names
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
const lat = Number(raw?.lat ?? raw?.latitude);
|
||||
const lon = Number(raw?.lon ?? raw?.longitude);
|
||||
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
|
||||
continue;
|
||||
|
||||
stationMap.set(stationId, {
|
||||
stationId,
|
||||
name: String(raw?.name || raw?.short_name || '').trim(),
|
||||
lat,
|
||||
lon,
|
||||
capacity: toNonNegativeInteger(raw?.capacity),
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
});
|
||||
}
|
||||
|
||||
return stationMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GBFS station_status payload into a Map of real-time availability.
|
||||
* @param {Object} payload - Parsed station_status.json response.
|
||||
* @returns {Map<string, Object>} Map of stationId to status objects containing
|
||||
* bikesAvailable, docksAvailable, and operational flags.
|
||||
*/
|
||||
|
||||
function parseStationStatus(payload) {
|
||||
const stations = extractStationsArray(payload);
|
||||
const statusMap = new Map();
|
||||
|
||||
for (const raw of stations) {
|
||||
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
|
||||
if (!stationId) continue;
|
||||
|
||||
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
|
||||
const docks = toNonNegativeInteger(raw?.num_docks_available);
|
||||
statusMap.set(stationId, {
|
||||
stationId,
|
||||
bikesAvailable: bikes,
|
||||
docksAvailable: docks,
|
||||
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
|
||||
isRenting: normalizeGbfsBool(raw?.is_renting, true),
|
||||
isReturning: normalizeGbfsBool(raw?.is_returning, true),
|
||||
lastReported: toNonNegativeInteger(raw?.last_reported),
|
||||
});
|
||||
}
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
return {
|
||||
normalizeGbfsBool,
|
||||
toNonNegativeInteger,
|
||||
|
||||
@@ -51,5 +51,18 @@ export function createCctvSource({
|
||||
},
|
||||
getFrameUrl: frameUrlFor,
|
||||
getMediaUrl: mediaUrlFor,
|
||||
/** Read one current frame through the registered frame endpoint. */
|
||||
async getFrame(camera, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(frameUrlFor(camera), {
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and
|
||||
assert.equal(a.getStats().count, 0);
|
||||
assert.equal(b.getStats().count, 0);
|
||||
});
|
||||
|
||||
test('frames are read through the registered frame endpoint', async () => {
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const requested = [];
|
||||
const source = createCctvSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
requested.push({ url, cache: init.cache });
|
||||
return new Response(png, {
|
||||
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
|
||||
});
|
||||
},
|
||||
});
|
||||
const frame = await source.getFrame(camera);
|
||||
assert.equal(frame.contentType, 'image/png');
|
||||
assert.deepEqual([...frame.bytes], [...png]);
|
||||
assert.equal(
|
||||
requested[0].url.split('?')[0],
|
||||
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
|
||||
);
|
||||
assert.equal(requested[0].cache, 'no-store');
|
||||
const failing = createCctvSource({
|
||||
fetchImpl: async () => new Response(null, { status: 502 }),
|
||||
});
|
||||
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
|
||||
});
|
||||
@@ -4,22 +4,47 @@ import { readFileSync } from 'node:fs';
|
||||
import { createInstallationSource } from './source.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
|
||||
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
|
||||
import {
|
||||
createMilitaryNamesLoader,
|
||||
loadMilitaryNames,
|
||||
} from '../../data/militaryNames.js';
|
||||
import { loadBundledJson } from '../../data/bundledJson.js';
|
||||
|
||||
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
|
||||
const wide = { south: 20, west: -130, north: 60, east: -60 };
|
||||
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
|
||||
function sourceWith(job) { return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
}); }
|
||||
const tile = decodeOpenFreeMapMilitaryTile(
|
||||
readFileSync(
|
||||
new URL(
|
||||
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
12,
|
||||
935,
|
||||
1685,
|
||||
);
|
||||
const flush = async () => {
|
||||
for (let i = 0; i < 30; i++) await Promise.resolve();
|
||||
};
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function sourceWith(job) {
|
||||
return createInstallationSource({
|
||||
loadNames: () => job.promise,
|
||||
fetchImpl: async () => Response.json({ unavailable: true }),
|
||||
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
|
||||
});
|
||||
}
|
||||
|
||||
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
|
||||
const job = deferred(), source = sourceWith(job), abort = new AbortController();
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
abort = new AbortController();
|
||||
const waiting = source.getMappedSites(wide, { signal: abort.signal });
|
||||
const rejection = assert.rejects(waiting, { name: 'AbortError' });
|
||||
const payload = await source.getMappedSites(box);
|
||||
@@ -32,48 +57,81 @@ test('close polygons publish immediately while a wide consumer can cancel its in
|
||||
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
|
||||
});
|
||||
|
||||
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(), source = sourceWith(job), publications = [];
|
||||
let currentBox = box;
|
||||
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
|
||||
const ingestion = createIngestion({ state, source,
|
||||
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
|
||||
parts: {
|
||||
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
|
||||
},
|
||||
for (const stale of [false, true])
|
||||
test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
|
||||
const job = deferred(),
|
||||
source = sourceWith(job),
|
||||
publications = [];
|
||||
let currentBox = box;
|
||||
const state = {
|
||||
enabled: true,
|
||||
viewer: {},
|
||||
records: [],
|
||||
googleSearchRequested: false,
|
||||
};
|
||||
const ingestion = createIngestion({
|
||||
state,
|
||||
source,
|
||||
services: {
|
||||
render: { governorRequestRender() {} },
|
||||
ground: { resolveGroundFloorCellsBounded: async () => {} },
|
||||
},
|
||||
parts: {
|
||||
viewport: {
|
||||
loadArea: () => ({ box: currentBox, coverage: {} }),
|
||||
clearUnavailableRetry() {},
|
||||
scheduleUnavailableRetry() {},
|
||||
},
|
||||
model: { installationWithinViewport: () => true },
|
||||
rendering: {
|
||||
renderRecords: () => publications.push(state.records),
|
||||
warmInstallationFloors() {},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) {
|
||||
currentBox = wide;
|
||||
state.enabled = false;
|
||||
state.abort.abort();
|
||||
}
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
await ingestion.loadInstallations();
|
||||
assert.equal(state.loading, false);
|
||||
assert.ok(publications[0].length);
|
||||
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
|
||||
job.resolve(await loadMilitaryNames());
|
||||
await flush();
|
||||
assert.equal(publications.length, stale ? 1 : 2);
|
||||
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
|
||||
state.abort.abort();
|
||||
});
|
||||
|
||||
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0, signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++; signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
for (const stage of ['response', 'body'])
|
||||
test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
|
||||
let calls = 0,
|
||||
signal;
|
||||
t.mock.method(globalThis, 'fetch', async (_url, options) => {
|
||||
calls++;
|
||||
signal = options.signal;
|
||||
if (calls > 1) return Response.json({ records: [], classes: [] });
|
||||
if (stage === 'response') return new Promise(() => {});
|
||||
return { ok: true, json: () => new Promise(() => {}) };
|
||||
});
|
||||
const load = createMilitaryNamesLoader({
|
||||
timeoutMs: 50,
|
||||
cooldownMs: 100,
|
||||
loadPack: (signal) =>
|
||||
loadBundledJson(new URL('https://example.test/names.json'), { signal }),
|
||||
});
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush();
|
||||
t.mock.timers.tick(51);
|
||||
await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
|
||||
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
|
||||
const first = load();
|
||||
assert.equal(load(), first, 'shared acquisition');
|
||||
const rejected = assert.rejects(first, { name: 'TimeoutError' });
|
||||
await flush(); t.mock.timers.tick(51); await rejected;
|
||||
assert.equal(signal.aborted, true);
|
||||
await assert.rejects(load(), { name: 'TimeoutError' });
|
||||
assert.equal(calls, 1);
|
||||
t.mock.timers.tick(101);
|
||||
assert.deepEqual((await load()).records, []);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
@@ -101,7 +101,7 @@ export function createInstallationSource({
|
||||
} = {}) {
|
||||
let overpassUnavailable = false;
|
||||
const installationIds = new Map();
|
||||
async function getNamedSites(box, signal) {
|
||||
async function getNamedSites(box, signal, thinned = true) {
|
||||
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
|
||||
signal?.throwIfAborted();
|
||||
throw Object.assign(
|
||||
@@ -112,7 +112,13 @@ export function createInstallationSource({
|
||||
);
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
const { records, count } = militaryNamesInView(names, box);
|
||||
// The map keeps one site per display cell; queries can ask for all.
|
||||
const { records, count } = militaryNamesInView(
|
||||
names,
|
||||
box,
|
||||
thinned ? undefined : Infinity,
|
||||
thinned,
|
||||
);
|
||||
return {
|
||||
records,
|
||||
namedInView: count,
|
||||
@@ -124,9 +130,9 @@ export function createInstallationSource({
|
||||
source: 'OpenStreetMap',
|
||||
};
|
||||
}
|
||||
async function getTileSites(box, signal) {
|
||||
async function getTileSites(box, signal, thinned) {
|
||||
const zoom = installationTileZoom(box);
|
||||
if (zoom === null) return getNamedSites(box, signal);
|
||||
if (zoom === null) return getNamedSites(box, signal, thinned);
|
||||
let names = null;
|
||||
const namesJob = waitForSignal(loadNames(), signal)
|
||||
.then((loaded) => {
|
||||
@@ -181,7 +187,7 @@ export function createInstallationSource({
|
||||
mapTiles.clear();
|
||||
installationIds.clear();
|
||||
},
|
||||
async getMappedSites(box, { exact = false, signal } = {}) {
|
||||
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
|
||||
const { south, west, north, east } = box || {};
|
||||
if (
|
||||
![south, west, north, east].every(Number.isFinite) ||
|
||||
@@ -197,8 +203,8 @@ export function createInstallationSource({
|
||||
throw new TypeError('A bounded installation viewport is required');
|
||||
signal?.throwIfAborted();
|
||||
if (east < west || north - south > 10 || east - west > 10)
|
||||
return getNamedSites(box, signal);
|
||||
if (overpassUnavailable) return getTileSites(box, signal);
|
||||
return getNamedSites(box, signal, thinned);
|
||||
if (overpassUnavailable) return getTileSites(box, signal, thinned);
|
||||
const query = new URLSearchParams(
|
||||
Object.entries({ south, west, north, east }).map(([key, value]) => [
|
||||
key,
|
||||
@@ -213,7 +219,7 @@ export function createInstallationSource({
|
||||
signal?.throwIfAborted();
|
||||
if (isUnavailableCapability(body)) {
|
||||
overpassUnavailable = true;
|
||||
return getTileSites(box, signal);
|
||||
return getTileSites(box, signal, thinned);
|
||||
}
|
||||
if (!response.ok)
|
||||
throw Object.assign(
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
export function createLaunchSource({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
/** The launch payload, and whether the proxy served it from a stale cache. */
|
||||
async function getLaunchSnapshot({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/launches', { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||
throw new Error('Malformed launch snapshot');
|
||||
return {
|
||||
payload,
|
||||
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
|
||||
};
|
||||
}
|
||||
return {
|
||||
async getLaunches({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/launches', { signal });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
|
||||
throw new Error('Malformed launch snapshot');
|
||||
return payload;
|
||||
getLaunchSnapshot,
|
||||
async getLaunches(options) {
|
||||
return (await getLaunchSnapshot(options)).payload;
|
||||
},
|
||||
async getActiveTle({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
twoline2satrec,
|
||||
} from 'satellite.js';
|
||||
import { findNextSatellitePass } from '../../data/satellitePass.js';
|
||||
import { parseTleText } from '../../sources/tle.js';
|
||||
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
|
||||
|
||||
export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
@@ -40,24 +41,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
|
||||
/**
|
||||
* Parse TLE text into array of { name, line1, line2 } objects.
|
||||
*/
|
||||
|
||||
function parseTLE(text) {
|
||||
const lines = text
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const result = [];
|
||||
for (let i = 0; i < lines.length - 2; i += 3) {
|
||||
const name = lines[i];
|
||||
const line1 = lines[i + 1];
|
||||
const line2 = lines[i + 2];
|
||||
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
|
||||
result.push({ name, line1, line2 });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const parseTLE = parseTleText;
|
||||
|
||||
/**
|
||||
* Propagate satellite position at a given JS Date.
|
||||
|
||||
@@ -19,7 +19,10 @@ export function createSatelliteSource({
|
||||
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
|
||||
const text = response.ok ? await response.text() : '';
|
||||
signal?.throwIfAborted();
|
||||
return { ok: response.ok, status: response.status, text };
|
||||
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
|
||||
// is down.
|
||||
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
|
||||
return { ok: response.ok, status: response.status, text, stale };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -25,21 +25,31 @@ export function createFlowTileSource({
|
||||
maxTiles: 16,
|
||||
});
|
||||
let partial = false;
|
||||
/**
|
||||
* Flow segments for bounds, with whether any covering tile failed to load
|
||||
* for this request.
|
||||
*/
|
||||
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
|
||||
const result = await tiles.fetchBounds(bounds, {
|
||||
zoom,
|
||||
signal,
|
||||
tiles: bounds.coverage?.coarse,
|
||||
});
|
||||
partial = result.partial;
|
||||
const segments = bounds.coverage
|
||||
? result.tiles.flat()
|
||||
: result.tiles.flat().flatMap((segment) =>
|
||||
clipTileLine(segment.coords, bounds).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
return { segments, partial: result.partial === true };
|
||||
}
|
||||
return {
|
||||
async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
|
||||
const result = await tiles.fetchBounds(bounds, {
|
||||
zoom,
|
||||
signal,
|
||||
tiles: bounds.coverage?.coarse,
|
||||
});
|
||||
partial = result.partial;
|
||||
if (bounds.coverage) return result.tiles.flat();
|
||||
return result.tiles.flat().flatMap((segment) =>
|
||||
clipTileLine(segment.coords, bounds).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
fetchFlowDetail,
|
||||
async fetchFlowForBounds(bounds, options) {
|
||||
return (await fetchFlowDetail(bounds, options)).segments;
|
||||
},
|
||||
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
|
||||
resetFlowTileCache: () => tiles.clear(),
|
||||
|
||||
@@ -5,6 +5,21 @@ export function createTransitSource({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
/** Read the public catalog of enabled feeds. */
|
||||
async getFeeds({ signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl('/api/transit/feeds', {
|
||||
signal,
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok)
|
||||
throw new Error('Transit feeds HTTP ' + response.status);
|
||||
const body = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
if (!Array.isArray(body?.feeds))
|
||||
throw new Error('Malformed transit feed catalog');
|
||||
return body.feeds;
|
||||
},
|
||||
getHistory(feedId, vehicleId, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
return fetchTransitHistory(feedId, vehicleId, signal, fetchImpl);
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createTransitSource } from './source.js';
|
||||
|
||||
const json = (body, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('the feed catalog is read from the public catalog route', async () => {
|
||||
const requested = [];
|
||||
const feeds = [
|
||||
{
|
||||
id: 'mbta',
|
||||
name: 'MBTA',
|
||||
center: { lat: 42.36, lon: -71.06 },
|
||||
loadRadiusKm: 60,
|
||||
},
|
||||
];
|
||||
const source = createTransitSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return json({ feeds });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(await source.getFeeds(), feeds);
|
||||
assert.deepEqual(requested, ['/api/transit/feeds']);
|
||||
});
|
||||
|
||||
test('feed catalog failures are reported', async () => {
|
||||
const failing = createTransitSource({ fetchImpl: async () => json({}, 503) });
|
||||
await assert.rejects(failing.getFeeds(), /Transit feeds HTTP 503/);
|
||||
const malformed = createTransitSource({
|
||||
fetchImpl: async () => json({ feeds: {} }),
|
||||
});
|
||||
await assert.rejects(malformed.getFeeds(), /Malformed transit feed catalog/);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await assert.rejects(
|
||||
createTransitSource({
|
||||
fetchImpl: async () => json({ feeds: [] }),
|
||||
}).getFeeds({ signal: aborted.signal }),
|
||||
(error) => error.name === 'AbortError',
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -132,5 +132,19 @@ export function createWeatherSource({
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
},
|
||||
/** Read one image frame through the bounded image route. */
|
||||
async getImage({ product, time, size, bbox = null, signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const response = await fetchImpl(
|
||||
weatherImageUrl(product, time, size, bbox),
|
||||
{ signal, redirect: 'error' },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(`Weather image HTTP ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
signal?.throwIfAborted();
|
||||
const type = response.headers.get('content-type') || '';
|
||||
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -162,3 +162,40 @@ test('detail-window image URLs carry the bbox and omit the default detail size',
|
||||
/size/,
|
||||
);
|
||||
});
|
||||
|
||||
test('image frames are read through the bounded image route', async () => {
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const requested = [];
|
||||
const source = createWeatherSource({
|
||||
fetchImpl: async (url, init) => {
|
||||
requested.push({ url, redirect: init.redirect });
|
||||
return new Response(png, { headers: { 'Content-Type': 'image/PNG' } });
|
||||
},
|
||||
});
|
||||
const frame = await source.getImage({
|
||||
product: 'radar',
|
||||
time,
|
||||
size: { width: 1024, height: 512 },
|
||||
bbox: { west: -100, south: 30, east: -96, north: 32 },
|
||||
});
|
||||
assert.equal(frame.contentType, 'image/png');
|
||||
assert.deepEqual([...frame.bytes], [...png]);
|
||||
assert.deepEqual(requested, [
|
||||
{
|
||||
url: weatherImageUrl(
|
||||
'radar',
|
||||
time,
|
||||
{ width: 1024, height: 512 },
|
||||
{ west: -100, south: 30, east: -96, north: 32 },
|
||||
),
|
||||
redirect: 'error',
|
||||
},
|
||||
]);
|
||||
const failing = createWeatherSource({
|
||||
fetchImpl: async () => new Response(null, { status: 409 }),
|
||||
});
|
||||
await assert.rejects(
|
||||
failing.getImage({ product: 'radar', time }),
|
||||
/Weather image HTTP 409/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user