mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'upstream/main' into core-rb2
# Conflicts: # CHANGELOG.md # build/vite.js # scripts/package-boundaries.json # server/standalone/vite.config.js # src/app/scene.js # src/layers/vessels/queries.js # src/standalone/application.js # src/tooling/viteBuild.test.mjs # src/ui/cockpitInstruments.js # src/ui/layerPanel.js # src/ui/panelPositionControls.js
This commit is contained in:
commit
71f7a58169
475 files changed
+67826
-4965
No files matched your search
@@ -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;
|
||||
@@ -104,7 +109,7 @@ export function createIngestion({
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
if (snapshot.acceptedRowCount === 0) {
|
||||
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||
feed.count = readCount();
|
||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -193,10 +193,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');
|
||||
@@ -215,6 +223,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
|
||||
@@ -238,6 +247,12 @@ export function createLifecycle({
|
||||
},
|
||||
};
|
||||
|
||||
function removeViewListener() {
|
||||
const remove = state.feed.removeViewListener;
|
||||
state.feed.removeViewListener = null;
|
||||
if (typeof remove === 'function') remove();
|
||||
}
|
||||
|
||||
return {
|
||||
clearFirstConnectTimer,
|
||||
invalidateAisSession,
|
||||
|
||||
@@ -361,3 +361,47 @@ test('partial vessel snapshots preserve freshness and recover after a complete u
|
||||
layer.destroy();
|
||||
assert.equal(layer.getStats().partial, false);
|
||||
});
|
||||
|
||||
test('authoritative empty coverage clears old contacts without declaring an outage', async () => {
|
||||
let current = snapshot([observation('111')]);
|
||||
const { layer } = setup({
|
||||
async getSnapshot() {
|
||||
return current;
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
current = snapshot([], { observedAtMs: null, lastMessageAt: 500 });
|
||||
await layer.update();
|
||||
assert.ok(!layer.hasContact('111'));
|
||||
assert.equal(layer.getStats().count, 0);
|
||||
assert.equal(layer.getStats().error, null);
|
||||
assert.equal(layer.getStats().stale, false);
|
||||
assert.equal(layer.getStats().lastUpdate, null);
|
||||
});
|
||||
|
||||
test('empty coverage cannot hide stale, incomplete or rejected source data', async () => {
|
||||
for (const patch of [
|
||||
{ freshness: 'unknown' },
|
||||
{ freshness: 'stale' },
|
||||
{ complete: false },
|
||||
{ rawRowCount: 1 },
|
||||
{ transportStatus: 'stale' },
|
||||
{ lastMessageAt: null },
|
||||
]) {
|
||||
let current = snapshot([observation('111')]);
|
||||
const { layer } = setup({
|
||||
async getSnapshot() {
|
||||
return current;
|
||||
},
|
||||
});
|
||||
await layer.update();
|
||||
current = snapshot([], {
|
||||
observedAtMs: null,
|
||||
lastMessageAt: 500,
|
||||
...patch,
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.hasContact('111'), true, JSON.stringify(patch));
|
||||
assert.ok(layer.getStats().error, JSON.stringify(patch));
|
||||
}
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function createQueries({
|
||||
/**
|
||||
* Chip text for a feed the server has reported as not delivering.
|
||||
* @param {string} status - 'stale' | 'reconnecting' | 'down'
|
||||
* @param {Object} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object} payload - Parsed /api/vessels JSON.
|
||||
* @returns {string}
|
||||
*/
|
||||
|
||||
@@ -77,12 +77,12 @@ export function createQueries({
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a surfaced error string from an /api/ais-live payload, or null when the
|
||||
* Derive a surfaced error string from an /api/vessels payload, or null when the
|
||||
* feed has accepted product data. Socket transport, message receipt, and usable
|
||||
* vessel positions are separate health stages: an open socket with no message
|
||||
* or no accepted positions must not read as a fresh successful update.
|
||||
*
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
|
||||
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
|
||||
* @returns {string|null} A short reason for the chip, or null if healthy.
|
||||
*/
|
||||
@@ -122,8 +122,8 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Classify one server snapshot before any destructive reconciliation.
|
||||
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
|
||||
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
|
||||
* @param {Object|null|undefined} payload - Parsed /api/vessels payload.
|
||||
* @returns {{emptyCoverage: boolean, transportStatus: string|null, lastMessageAt: number|string|null,
|
||||
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
|
||||
* acceptedRowCount: number, error: string|null}}
|
||||
*/
|
||||
@@ -135,7 +135,16 @@ export function createQueries({
|
||||
typeof payload?.status === 'string' ? payload.status : null;
|
||||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||||
const acceptedRowCount = acceptedRows.length;
|
||||
const emptyCoverage =
|
||||
rawRows.length === 0 &&
|
||||
payload?.rawRowCount === 0 &&
|
||||
payload?.complete === true &&
|
||||
payload?.freshness === 'current' &&
|
||||
AIS_HEALTHY_STATUSES.has(transportStatus) &&
|
||||
Number.isFinite(Number(lastMessageAt)) &&
|
||||
Number(lastMessageAt) > 0;
|
||||
return {
|
||||
emptyCoverage,
|
||||
transportStatus,
|
||||
lastMessageAt,
|
||||
rawRows,
|
||||
@@ -146,9 +155,12 @@ export function createQueries({
|
||||
? payload.rawRowCount
|
||||
: rawRows.length,
|
||||
acceptedRowCount,
|
||||
error:
|
||||
deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0 ? t('layers.vessel.awaitingPositions') : null),
|
||||
error: emptyCoverage
|
||||
? null
|
||||
: deriveAisFeedError(payload, acceptedRowCount) ||
|
||||
(acceptedRowCount === 0
|
||||
? t('layers.vessel.awaitingPositions')
|
||||
: null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +168,7 @@ export function createQueries({
|
||||
* Map one internal vessel record to a plain JSON-safe analyst record
|
||||
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
|
||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Smallest area asked for around the view, in kilometres. */
|
||||
export const VIEW_AREA_MIN_RADIUS_KM = 10;
|
||||
/** Largest area asked for; a wider view asks for every vessel instead. */
|
||||
export const VIEW_AREA_MAX_RADIUS_KM = 450;
|
||||
|
||||
/**
|
||||
* The radius worth asking for from a camera this high, in kilometres: about
|
||||
* the height, so the area covers what a camera looking down shows. Null when
|
||||
* the view is wide enough that every vessel is wanted.
|
||||
*/
|
||||
export function viewAreaRadiusKm(heightM) {
|
||||
if (!Number.isFinite(heightM)) return null;
|
||||
const radiusKm = heightM / 1000;
|
||||
if (radiusKm > VIEW_AREA_MAX_RADIUS_KM) return null;
|
||||
return Math.max(VIEW_AREA_MIN_RADIUS_KM, radiusKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a view moved far enough from the last area asked for to ask
|
||||
* again: its radius changed by a quarter, or its center moved a quarter of
|
||||
* the radius. Switching between an area and every vessel always counts.
|
||||
*/
|
||||
export function viewAreaMovedEnough(previous, next) {
|
||||
if (!previous || !next) return previous !== next;
|
||||
if (Math.abs(next.radiusKm - previous.radiusKm) >= previous.radiusKm * 0.25)
|
||||
return true;
|
||||
const lat = ((previous.lat + next.lat) / 2) * (Math.PI / 180);
|
||||
const dLat = (next.lat - previous.lat) * 111.32;
|
||||
const dLon = (next.lon - previous.lon) * 111.32 * Math.cos(lat);
|
||||
return Math.hypot(dLat, dLon) >= previous.radiusKm * 0.25;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
VIEW_AREA_MIN_RADIUS_KM,
|
||||
viewAreaMovedEnough,
|
||||
viewAreaRadiusKm,
|
||||
} from './viewArea.js';
|
||||
|
||||
test('the area asked for follows the camera height, and a wide view asks for every vessel', () => {
|
||||
assert.equal(viewAreaRadiusKm(2_000), VIEW_AREA_MIN_RADIUS_KM);
|
||||
assert.equal(viewAreaRadiusKm(80_000), 80);
|
||||
assert.equal(
|
||||
viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000),
|
||||
VIEW_AREA_MAX_RADIUS_KM,
|
||||
);
|
||||
assert.equal(viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000 + 1), null);
|
||||
assert.equal(viewAreaRadiusKm(Number.NaN), null);
|
||||
});
|
||||
|
||||
test('a view asks again only after moving or zooming a quarter of its radius', () => {
|
||||
const here = { lat: 37.8, lon: -122.4, radiusKm: 40 };
|
||||
assert.equal(viewAreaMovedEnough(null, here), true);
|
||||
assert.equal(viewAreaMovedEnough(here, null), true);
|
||||
assert.equal(viewAreaMovedEnough(null, null), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.85 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.9 }), true);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 45 }), false);
|
||||
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 55 }), true);
|
||||
});
|
||||
Reference in new issue
Block a user