From e8e49a49e09614f5efb4f5df805ec759c9c29fdb Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Sat, 3 Oct 2026 22:44:45 -0700 Subject: [PATCH] feat: ask for vessels by area The vessel source accepts an optional area and sends it as lat, lon and radius_km. The vessel layer asks for the area the camera shows (centered on the middle of the screen, sized by camera height), or every vessel from high enough up, and asks again when the view moves or zooms by a quarter of that radius. vessels_in_area names its area too. Servers that hold every vessel may ignore the area; callers still keep only what is inside it. --- scripts/package-boundaries.json | 7 +++++ src/layers/vessels/cameraArea.js | 33 ++++++++++++++++++++ src/layers/vessels/index.js | 2 ++ src/layers/vessels/ingestion.js | 21 ++++++++++++- src/layers/vessels/ingestion.test.mjs | 43 +++++++++++++++++++++++++++ src/layers/vessels/lifecycle.js | 15 ++++++++++ src/layers/vessels/viewArea.js | 31 +++++++++++++++++++ src/layers/vessels/viewArea.test.mjs | 30 +++++++++++++++++++ src/sources/live/standalone.js | 13 +++++++- src/tools/queries/maritime.js | 30 +++++++++++++++---- src/tools/queries/maritime.test.mjs | 23 ++++++++++++++ 11 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 src/layers/vessels/cameraArea.js create mode 100644 src/layers/vessels/viewArea.js create mode 100644 src/layers/vessels/viewArea.test.mjs diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e91656bb3..22907331f 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -871,6 +871,8 @@ "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", "src/layers/vessels/ingestion.js", + "src/layers/vessels/cameraArea.js", + "src/layers/vessels/viewArea.js", "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", @@ -1724,7 +1726,9 @@ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", + "src/layers/vessels/cameraArea.js", "src/layers/vessels/ingestion.js", + "src/layers/vessels/viewArea.js", "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", @@ -2394,7 +2398,9 @@ "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", "src/layers/vessels/index.js", + "src/layers/vessels/cameraArea.js", "src/layers/vessels/ingestion.js", + "src/layers/vessels/viewArea.js", "src/layers/vessels/lifecycle.js", "src/layers/vessels/policy.js", "src/layers/vessels/queries.js", @@ -2743,6 +2749,7 @@ "exports": ["./layers/vessels/ingestion"], "modules": [ "src/layers/vessels/ingestion.js", + "src/layers/vessels/viewArea.js", "src/layers/vessels/recordPolicy.js" ], "external": [] diff --git a/src/layers/vessels/cameraArea.js b/src/layers/vessels/cameraArea.js new file mode 100644 index 000000000..2325f13e5 --- /dev/null +++ b/src/layers/vessels/cameraArea.js @@ -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 }; +} diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index af52e9e3c..f5ee9b497 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -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, diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 29d0f60eb..fb7597a31 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -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, }; } diff --git a/src/layers/vessels/ingestion.test.mjs b/src/layers/vessels/ingestion.test.mjs index 2563a7f8d..1366ca61d 100644 --- a/src/layers/vessels/ingestion.test.mjs +++ b/src/layers/vessels/ingestion.test.mjs @@ -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 }); +}); diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 3c8c8e245..def0799b8 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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, diff --git a/src/layers/vessels/viewArea.js b/src/layers/vessels/viewArea.js new file mode 100644 index 000000000..8d9373f34 --- /dev/null +++ b/src/layers/vessels/viewArea.js @@ -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; +} diff --git a/src/layers/vessels/viewArea.test.mjs b/src/layers/vessels/viewArea.test.mjs new file mode 100644 index 000000000..dcd54d262 --- /dev/null +++ b/src/layers/vessels/viewArea.test.mjs @@ -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); +}); diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index ecee2a405..270a6d099 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -172,9 +172,20 @@ export function createAisStreamSource({ } = {}) { return { label: 'AISStream', - async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) { + 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(), diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index 045f148dc..5b64f2826 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -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( diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index 21cdad630..5675ebf36 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -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 = createAisStreamSource({ + 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); +});