Merge main into feat/voice-core

Keeps the voice-core prompt rules and adds main's world-query and
vessels-vs-visuals rules; the tool catalog wraps the deixis-aware runner.

Also fixes two things the branch had before the merge:
- Live voice could not start: OpenAI rejects track_entity's top-level
  anyOf. Model-facing tools now omit top-level combinators; the runner
  and benchmark still require a query or a referent.
- The Contacts-centred analyst answer dropped the viewport coverage note.
This commit is contained in:
Bilawal Sidhu committed 2026-10-03 23:30:02 -07:00
commit e7cb9b02b5
191 files changed
+20005 -1450

No files matched your search

+1
View File
@@ -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';
+8 -119
View File
@@ -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,
+13
View File
@@ -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 };
},
};
}
+25
View File
@@ -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/);
});
+110 -52
View File
@@ -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);
});
+14 -8
View File
@@ -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(
+17 -9
View File
@@ -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();
+2 -18
View File
@@ -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.
+4 -1
View File
@@ -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 };
},
};
}
+15 -10
View File
@@ -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 };
},
+24 -14
View File
@@ -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(),
+15
View File
@@ -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);
+46
View File
@@ -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',
);
});
+33
View File
@@ -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 };
}
+2
View File
@@ -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,
+20 -1
View File
@@ -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,
};
}
+43
View File
@@ -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 });
});
+15
View File
@@ -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,
+31
View File
@@ -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;
}
+30
View File
@@ -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);
});
+14
View File
@@ -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 };
},
};
}
+37
View File
@@ -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/,
);
});