Merge remote-tracking branch 'upstream/main' into pr871-fix

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Said Turksever committed 2026-10-28 15:15:50 +00:00
commit 87133e8dbf
1421 files changed
+215216 -12342

No files matched your search

+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 };
}
+39 -16
View File
@@ -1,7 +1,9 @@
import {
accentForVesselType,
displayVesselType,
normalizeVesselType,
} from '../../data/vesselLabels.js';
import { formatNumber, t } from '../../i18n/index.js';
export function createCards({
vesselState,
@@ -10,8 +12,6 @@ export function createCards({
layer,
options,
}) {
const { state } = vesselState;
function updateSelectedVesselHud(record) {
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
@@ -20,9 +20,13 @@ export function createCards({
const stale = (record.missedRefreshes || 0) > 0;
el.classList.add('active');
el.textContent = [
`AIS: ${trimHudValue(record.name, 32)}`,
`${trimHudValue(record.type || 'VESSEL', 24)} SPD: ${formatSpeed(record.speed)} HDG: ${formatHeading(record.heading ?? record.course)}`,
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
t('AIS: {name}', { name: trimHudValue(record.name, 32) }),
t('{type} SPD: {speed} HDG: {heading}', {
type: trimHudValue(record.type || t('VESSEL'), 24),
speed: formatSpeed(record.speed),
heading: formatHeading(record.heading ?? record.course),
}),
`MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
].join('\n');
}
@@ -30,7 +34,7 @@ export function createCards({
const el = document.getElementById('hud-ais-vessel');
if (!el) return;
el.classList.remove('active');
el.textContent = 'AIS: --';
el.textContent = t('AIS: --');
}
function trimHudValue(value, maxLength) {
@@ -85,7 +89,7 @@ export function createCards({
const direction = record.heading ?? record.course;
const details = [
[
vesselTypeShort(record) || 'VESSEL',
vesselTypeShort(record) || t('VESSEL'),
formatSpeed(record.speed),
Number.isFinite(direction) ? `${Math.round(direction)}°` : '--°',
].join(' · '),
@@ -94,7 +98,7 @@ export function createCards({
if (destination) details.push(`→ ${trimHudValue(destination, 24)}`);
const stale = (record.missedRefreshes || 0) > 0;
details.push(
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ' · STALE' : ''}`,
`MMSI ${record.mmsi || '--'} · ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`,
);
return {
id: vesselOverlayEntryId(record),
@@ -122,10 +126,18 @@ export function createCards({
return `vessel:unkeyed:${name}:${lat}:${lon}`;
}
/** Uppercased, card-width-bounded AIS type (empty string when unknown). */
/**
* Uppercased AIS type for the card (empty string when unknown). Free feed
* text is bounded to the card width; a translated type token is a short
* catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO").
*/
function vesselTypeShort(record) {
return normalizeVesselType(record.type).toUpperCase().slice(0, 14);
const feedType = normalizeVesselType(record.type);
const shown = displayVesselType(record.type);
return shown === feedType
? feedType.toUpperCase().slice(0, 14)
: shown.toUpperCase();
}
/**
@@ -151,22 +163,33 @@ export function createCards({
function displayVesselName(record) {
const name = String(record.name || '').trim();
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
return record.mmsi ? `MMSI ${record.mmsi}` : 'VESSEL';
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
}
function formatSpeed(speed) {
return speed === null ? '--KT' : `${speed.toFixed(1)}KT`;
return t('{speed}KT', {
speed:
speed === null
? '--'
: formatNumber(speed, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
useGrouping: false,
}),
});
}
function formatHeading(heading) {
return Number.isFinite(heading) ? `${Math.round(heading)}DEG` : '--DEG';
return t('{heading}DEG', {
heading: Number.isFinite(heading) ? Math.round(heading) : '--',
});
}
function formatPositionTime(record) {
if (!record.lastPositionUtc) return 'POS: LIVE';
if (!record.lastPositionUtc) return t('POS: LIVE');
const date = new Date(record.lastPositionUtc);
if (Number.isNaN(date.getTime())) return 'POS: LIVE';
return `POS: ${date.toISOString().slice(11, 19)}Z`;
if (Number.isNaN(date.getTime())) return t('POS: LIVE');
return t('POS: {time}Z', { time: date.toISOString().slice(11, 19) });
}
return {
updateSelectedVesselHud,
+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,
+26 -3
View File
@@ -1,10 +1,16 @@
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
import { viewAreaMovedEnough } from './viewArea.js';
// A portable export (package.json): no i18n here. The text stays English data
// and the layer panel shows it translated.
const msg = (text) => text;
/** Own source requests and classified feed state through explicit operations. */
export function createIngestion({
feed,
readSource,
readViewer,
readArea = () => null,
getRowLimit,
readCount,
applyRows,
@@ -34,8 +40,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;
@@ -64,7 +73,7 @@ export function createIngestion({
ownsAisRequest(requestController, requestSessionId) &&
error?.name !== 'AbortError'
) {
markUnavailable(error?.message || 'AIS live load failed');
markUnavailable(error?.message || msg('AIS live load failed'));
console.warn('[Data:ais-live-vessels]', feed.error, error);
}
} finally {
@@ -104,7 +113,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 +178,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 +196,7 @@ export function createIngestion({
return {
loadLivePositions,
refreshIfMoved,
ownsAisRequest,
applyAisFeedSnapshot,
vesselDisplayRow,
@@ -209,5 +228,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 });
});
+21 -9
View File
@@ -1,9 +1,6 @@
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
import {
AIS_FIRST_CONNECT_GRACE_MS,
AIS_FIRST_CONNECT_LABEL,
AIS_HEALTHY_STATUSES,
} from './policy.js';
import { msg, t } from '../../i18n/index.js';
import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js';
export function createLifecycle({
vesselState,
@@ -41,7 +38,7 @@ export function createLifecycle({
state.feed.firstConnectStartedAt = startedAt;
state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS;
state.feed.error = null;
state.feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
state.feed.loadingLabel = t('awaiting first AIS position…');
scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS);
}
@@ -63,8 +60,8 @@ export function createLifecycle({
state.feed.firstConnectPhase = 'unavailable';
state.feed.loadingLabel = '';
state.feed.error = state.feed.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
state.feed.stale = state.feed.count > 0;
}, delayMs);
}
@@ -85,7 +82,7 @@ export function createLifecycle({
function markAisUnavailable(reason) {
settleFirstConnectPhase('unavailable');
state.feed.error = reason || 'AIS live load failed';
state.feed.error = reason || msg('AIS live load failed');
state.feed.stale = state.feed.count > 0;
}
@@ -196,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');
@@ -219,6 +224,7 @@ export function createLifecycle({
},
destroy(viewer) {
removeViewListener();
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
@@ -243,6 +249,12 @@ export function createLifecycle({
},
};
function removeViewListener() {
const remove = state.feed.removeViewListener;
state.feed.removeViewListener = null;
if (typeof remove === 'function') remove();
}
return {
clearFirstConnectTimer,
invalidateAisSession,
+44
View File
@@ -365,3 +365,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));
}
});
+7 -11
View File
@@ -1,9 +1,5 @@
export {
PARTIAL_RETENTION_MS,
SELECTED_PIN_REFRESHES,
AIS_FIRST_CONNECT_LABEL,
} from './recordPolicy.js';
import { VESSEL_LABEL_GRID_PX } from '../../data/vesselLabels.js';
import { msg } from '../../i18n/index.js';
export const FOCUS_EVIDENCE_DEV = import.meta.env?.DEV === true;
@@ -71,12 +67,12 @@ export const DEFAULT_AIS_RUNTIME = Object.freeze({
*/
export const AIS_STATUS_REASON = {
'missing-key': 'AISSTREAM_API_KEY not set',
unsupported: 'live feed unsupported',
connecting: 'connecting to feed…',
closed: 'feed disconnected',
error: 'feed down',
idle: 'feed idle',
'missing-key': msg('AISSTREAM_API_KEY not set'),
unsupported: msg('live feed unsupported'),
connecting: msg('connecting to feed…'),
closed: msg('feed disconnected'),
error: msg('feed down'),
idle: msg('feed idle'),
};
/**
+49 -22
View File
@@ -1,11 +1,25 @@
import * as Cesium from 'cesium';
import { t, msg } from '../../i18n/index.js';
// ingestion.js is a portable export (package.json) and writes English labels
// into state; translate the known ones here, pass anything else through.
const FEED_STATE_LABEL_KEYS = Object.freeze({
'refreshing...': msg('refreshing...'),
'loading...': msg('loading...'),
'awaiting first AIS position…': msg('awaiting first AIS position…'),
});
function localizeFeedLabel(raw) {
if (typeof raw !== 'string' || !raw.trim()) return raw;
const key = FEED_STATE_LABEL_KEYS[raw.trim()];
return key ? t(key) : raw;
}
import {
AIS_DEGRADED_STATUSES,
AIS_HEALTHY_STATUSES,
AIS_STATUS_REASON,
REFRESH_MS,
FOCUS_EVIDENCE_DEV,
AIS_FIRST_CONNECT_LABEL,
} from './policy.js';
export function createQueries({
@@ -36,7 +50,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}
*/
@@ -44,29 +58,31 @@ export function createQueries({
if (status === 'auth-failed') {
// Actionable, not a countdown: retrying cannot fix a rejected credential,
// so the chip asks the operator to do the one thing that can.
return 'API key rejected — check AISSTREAM_API_KEY';
return t('API key rejected — check AISSTREAM_API_KEY');
}
if (status === 'stale') {
const silentSec = Math.round(Number(payload?.silentForMs) / 1000);
return Number.isFinite(silentSec) && silentSec > 0
? `feed silent ${silentSec}s — no AIS data`
: 'feed silent — no AIS data';
? t('feed silent {seconds}s — no AIS data', { seconds: silentSec })
: t('feed silent — no AIS data');
}
const attempt = Number(payload?.reconnectAttempt);
const suffix =
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
if (Number.isFinite(attempt) && attempt >= 1)
return status === 'down'
? t('feed down — retrying slowly (attempt {attempt})', { attempt })
: t('reconnecting to feed… (attempt {attempt})', { attempt });
return status === 'down'
? `feed down — retrying slowly${suffix}`
: `reconnecting to feed…${suffix}`;
? t('feed down — retrying slowly')
: t('reconnecting to feed…');
}
/**
* 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.
*/
@@ -82,15 +98,16 @@ export function createQueries({
if (acceptedRowCount > 0) return null; // accepted rows may be stale while reconnecting, but remain usable
if (AIS_HEALTHY_STATUSES.has(status)) {
return payload?.lastMessageAt
? 'awaiting usable AIS positions…'
: 'awaiting first AIS message…';
? t('awaiting usable AIS positions…')
: t('awaiting first AIS message…');
}
if (!status) return null;
const detail =
typeof payload.error === 'string' && payload.error.trim()
? payload.error.trim()
: '';
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
// The reasons are English in policy.js (msg) and shown in the UI language.
const reason = t(AIS_STATUS_REASON[status] || msg('feed unavailable'));
return detail && !AIS_STATUS_REASON[status]
? `${reason} (${detail})`
: reason;
@@ -106,8 +123,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}}
*/
@@ -119,7 +136,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,
@@ -130,9 +156,10 @@ export function createQueries({
? payload.rawRowCount
: rawRows.length,
acceptedRowCount,
error:
deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
error: emptyCoverage
? null
: deriveAisFeedError(payload, acceptedRowCount) ||
(acceptedRowCount === 0 ? t('awaiting usable AIS positions…') : null),
};
}
@@ -140,7 +167,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,
@@ -504,8 +531,8 @@ export function createQueries({
lastUpdate: state.feed.lastUpdate,
loading: state.feed.loading || waitingForFirstPosition,
loadingLabel: waitingForFirstPosition
? AIS_FIRST_CONNECT_LABEL
: state.feed.loadingLabel,
? t('awaiting first AIS position…')
: localizeFeedLabel(state.feed.loadingLabel),
error: state.feed.error,
stale: state.feed.stale,
partial: state.feed.partial,
+5 -1
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { t } from '../../i18n/index.js';
import {
vesselTypeCss,
vesselOverlayCohortLimit,
@@ -503,7 +504,10 @@ export function createRendering({
: '';
return {
...card,
accessibilityLabel: `Focus vessel ${card.title}, MMSI ${mmsi}`,
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
name: card.title,
mmsi,
}),
activate: () => {
const record = state.records.byMmsi.get(mmsi);
if (!record) return false;
+4 -2
View File
@@ -10,7 +10,7 @@ export function createSelection({
options,
}) {
const { state } = vesselState;
const { resolvePickId, isOwnedByOtherLayer } = services.picking;
const { resolvePickId, isOwnedByOtherLayer, pickContact } = services.picking;
const { requestWorldFocus } = services.worldFocus;
const {
selectEntityContext,
@@ -37,7 +37,9 @@ export function createSelection({
// A tool owns the pointer (src/data/inputOwnership.js): yield the click.
if (!isPointerFree()) return;
if (!state.feed.enabled) return;
const picked = viewer.scene.pick(click.position);
const picked = pickContact
? pickContact(viewer.scene, click.position)
: viewer.scene.pick(click.position);
const pickedId = resolvePickId(picked);
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
const rawId = picked?.id ?? picked?.primitive?.id;
+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);
});