mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
541 lines
19 KiB
JavaScript
541 lines
19 KiB
JavaScript
import * as Cesium from 'cesium';
|
||
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({
|
||
vesselState,
|
||
services,
|
||
parts: components,
|
||
layer,
|
||
options,
|
||
}) {
|
||
const { state } = vesselState;
|
||
const { formatKnots } = services.labels;
|
||
|
||
/**
|
||
* Seconds until the server's next reconnect attempt, or 0 when none is
|
||
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
||
* @returns {number}
|
||
*/
|
||
|
||
function aisRetryInSec() {
|
||
// A rejected key is terminal until someone changes it; an hour-long
|
||
// countdown would imply waiting is the fix.
|
||
if (state.feed.transportStatus === 'auth-failed') return 0;
|
||
const at = Number(state.feed.nextAttemptAt);
|
||
if (!Number.isFinite(at) || at <= 0) return 0;
|
||
return Math.max(0, Math.ceil((at - vesselState._aisRuntime.now()) / 1000));
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
* @returns {string}
|
||
*/
|
||
|
||
function describeDegradedAisFeed(status, payload) {
|
||
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';
|
||
}
|
||
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';
|
||
}
|
||
const attempt = Number(payload?.reconnectAttempt);
|
||
const suffix =
|
||
Number.isFinite(attempt) && attempt >= 1 ? ` (attempt ${attempt})` : '';
|
||
return status === 'down'
|
||
? `feed down — retrying slowly${suffix}`
|
||
: `reconnecting to feed…${suffix}`;
|
||
}
|
||
|
||
/**
|
||
* Derive a surfaced error string from an /api/ais-live 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 {number} acceptedRowCount - Number of rows accepted by vessel normalization.
|
||
* @returns {string|null} A short reason for the chip, or null if healthy.
|
||
*/
|
||
|
||
function deriveAisFeedError(payload, acceptedRowCount) {
|
||
const status =
|
||
payload && typeof payload.status === 'string' ? payload.status : null;
|
||
// A feed the server reports as not delivering outranks the row count: the
|
||
// cached vessels on screen are exactly what makes an outage invisible.
|
||
if (status && AIS_DEGRADED_STATUSES.has(status)) {
|
||
return describeDegradedAisFeed(status, payload);
|
||
}
|
||
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…';
|
||
}
|
||
if (!status) return null;
|
||
const detail =
|
||
typeof payload.error === 'string' && payload.error.trim()
|
||
? payload.error.trim()
|
||
: '';
|
||
const reason = AIS_STATUS_REASON[status] || 'feed unavailable';
|
||
return detail && !AIS_STATUS_REASON[status]
|
||
? `${reason} (${detail})`
|
||
: reason;
|
||
}
|
||
|
||
/** True when a raw AIS row can enter the production vessel normalizer. */
|
||
|
||
function hasUsableVesselCoordinates(row) {
|
||
return (
|
||
Number.isFinite(Number(row?.lat)) && Number.isFinite(Number(row?.lon))
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
|
||
* acceptedRowCount: number, error: string|null}}
|
||
*/
|
||
|
||
function classifyAisFeedSnapshot(payload) {
|
||
const rawRows = Array.isArray(payload?.rows) ? payload.rows : [];
|
||
const acceptedRows = rawRows.filter(hasUsableVesselCoordinates);
|
||
const transportStatus =
|
||
typeof payload?.status === 'string' ? payload.status : null;
|
||
const lastMessageAt = payload?.lastMessageAt ?? null;
|
||
const acceptedRowCount = acceptedRows.length;
|
||
return {
|
||
transportStatus,
|
||
lastMessageAt,
|
||
rawRows,
|
||
acceptedRows,
|
||
rawRowCount:
|
||
Number.isInteger(payload?.rawRowCount) &&
|
||
payload.rawRowCount >= rawRows.length
|
||
? payload.rawRowCount
|
||
: rawRows.length,
|
||
acceptedRowCount,
|
||
error:
|
||
deriveAisFeedError(payload, acceptedRowCount) ||
|
||
(acceptedRowCount === 0 ? 'awaiting usable AIS positions…' : null),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
* 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,
|
||
* lat: number|null, lon: number|null, speedKts: number|null,
|
||
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
||
* navStatus: null}}
|
||
*/
|
||
|
||
function mapAnalystRecord(record) {
|
||
const num = (v) => (Number.isFinite(v) ? v : null);
|
||
const text = (v) => {
|
||
const t = String(v ?? '').trim();
|
||
return t || null;
|
||
};
|
||
const mmsi = text(record?.mmsi);
|
||
const name = text(record?.name);
|
||
return {
|
||
id: name || mmsi,
|
||
mmsi,
|
||
name,
|
||
lat: num(record?.lat),
|
||
lon: num(record?.lon),
|
||
speedKts: num(record?.speed),
|
||
courseDeg: num(record?.course),
|
||
shipType: text(record?.type),
|
||
destination: text(record?.destination),
|
||
navStatus: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Ellipsoidal render height (m) for a sea-surface object: the local geoid
|
||
* undulation N plus a small lift. The sea surface ≈ the geoid, which sits
|
||
* −106…+85 m off the WGS84 ellipsoid worldwide (Rotterdam ≈ +45 m — at
|
||
* height 0 the tile sea mesh occludes every chevron; Houston ≈ −27 m).
|
||
* Pure seam, exported for unit tests.
|
||
* @param {number|null|undefined} geoidN - Undulation N (m), or null/undefined while the grid is cold.
|
||
* @param {number} liftM - Lift above the sea surface (m).
|
||
* @returns {number} Ellipsoidal height h = N + lift (N treated as 0 when absent).
|
||
*/
|
||
|
||
function vesselDatumHeightM(geoidN, liftM) {
|
||
return (Number.isFinite(geoidN) ? geoidN : 0) + liftM;
|
||
}
|
||
|
||
/**
|
||
* Reduce one vessel-selection gesture to the layer-owned action it should
|
||
* perform. The interaction handler reserves only vessel-record residuals and
|
||
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
||
* picks before this reducer so their camera action cannot mutate AIS state.
|
||
*
|
||
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
|
||
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
|
||
* @returns {{action: 'none'|'select'|'deselect'}}
|
||
*/
|
||
|
||
function reduceVesselSelection(input = {}) {
|
||
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
||
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
||
const gesture = input.gesture || 'click';
|
||
|
||
if (gesture === 'escape') {
|
||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||
}
|
||
if (gesture !== 'click') {
|
||
return { action: 'none' };
|
||
}
|
||
if (pickedMmsi) {
|
||
if (pickedMmsi === selectedMmsi) {
|
||
return { action: 'none' };
|
||
}
|
||
return { action: 'select' };
|
||
}
|
||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||
}
|
||
|
||
function normalizeSelectionMmsi(value) {
|
||
if (value === null || value === undefined) return null;
|
||
const text = String(value).trim();
|
||
return text || null;
|
||
}
|
||
const methods = {
|
||
mapAnalystRecord,
|
||
deriveAisFeedError,
|
||
classifyAisFeedSnapshot,
|
||
vesselDatumHeightM,
|
||
reduceVesselSelection,
|
||
|
||
id: 'ais-live-vessels',
|
||
|
||
name: 'Live AIS Vessels',
|
||
|
||
icon: '◭',
|
||
|
||
source: 'AISStream',
|
||
|
||
updateInterval: REFRESH_MS,
|
||
|
||
statsRefreshInterval: 1000,
|
||
|
||
/**
|
||
* Find a vessel by exact MMSI or case-insensitive name substring.
|
||
* @param {string|number} query MMSI or partial vessel name.
|
||
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||
*/
|
||
findByQuery(query) {
|
||
if (query === null || query === undefined) return null;
|
||
const records = state.records.all;
|
||
if (!Array.isArray(records) || !records.length) return null;
|
||
const q = String(query).trim();
|
||
if (!q) return null;
|
||
|
||
let record = null;
|
||
if (/^\d+$/.test(q)) {
|
||
record = state.records.byMmsi.get(q) || null;
|
||
}
|
||
if (!record) {
|
||
const lower = q.toLowerCase();
|
||
record =
|
||
records.find((r) =>
|
||
String(r.name || '')
|
||
.toLowerCase()
|
||
.includes(lower),
|
||
) || null;
|
||
}
|
||
if (!record) return null;
|
||
|
||
const position =
|
||
components.rendering.getVisual(record).billboard?.position ||
|
||
components.rendering.getVisual(record).position;
|
||
if (!position) return null;
|
||
return {
|
||
mmsi: record.mmsi,
|
||
name: record.name,
|
||
position,
|
||
latitude: record.lat,
|
||
longitude: record.lon,
|
||
speedKt: record.speed,
|
||
course: record.course,
|
||
type: record.type,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Get vessels within a range of a point, sorted nearest-first.
|
||
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
||
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
|
||
* @param {number} [maxCount=25] Maximum entries to return.
|
||
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||
*/
|
||
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||
const records = state.records.all;
|
||
if (!centerCartesian || !Array.isArray(records) || !records.length)
|
||
return [];
|
||
const range = Number.isFinite(rangeM) && rangeM > 0 ? rangeM : Infinity;
|
||
const cap =
|
||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 25;
|
||
|
||
const entries = [];
|
||
for (const record of records) {
|
||
const visual = components.rendering.getVisual(record);
|
||
if (!Number.isFinite(record.lat) || !Number.isFinite(record.lon))
|
||
continue;
|
||
const position = visual.billboard?.position || visual.position;
|
||
if (!position) continue;
|
||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||
entries.push({
|
||
mmsi: record.mmsi,
|
||
name: record.name,
|
||
position,
|
||
distanceM,
|
||
});
|
||
}
|
||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||
return entries.slice(0, cap);
|
||
},
|
||
|
||
/**
|
||
* Get positions of all currently loaded vessels.
|
||
* @param {number} [maxCount=800] Maximum entries to return.
|
||
* @returns {Array<{ id: string, label: string, position: Cesium.Cartesian3, latitude: number, longitude: number }>}
|
||
*/
|
||
/**
|
||
* Whether this layer still carries a vessel, in O(1).
|
||
*
|
||
* Mirror of `flights.hasContact`: presence consumers must not infer absence
|
||
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
|
||
* A disabled layer keeps its records, so it must decline rather than answer
|
||
* from data the user can no longer see.
|
||
* @param {string} mmsi Vessel identifier.
|
||
* @returns {boolean|null} Presence, or null when the layer is disabled or
|
||
* holds no data and therefore cannot answer.
|
||
*/
|
||
hasContact(mmsi) {
|
||
if (
|
||
!state.feed.enabled ||
|
||
!state.records.byMmsi ||
|
||
state.records.byMmsi.size === 0
|
||
)
|
||
return null;
|
||
if (!mmsi) return false;
|
||
return state.records.byMmsi.has(String(mmsi).trim());
|
||
},
|
||
|
||
getAllPositions(maxCount = 800) {
|
||
const result = [];
|
||
const records = state.records.all;
|
||
if (!Array.isArray(records)) return result;
|
||
const cap =
|
||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 800;
|
||
|
||
for (const record of records) {
|
||
const visual = components.rendering.getVisual(record);
|
||
if (result.length >= cap) break;
|
||
const position = visual.billboard?.position || visual.position;
|
||
if (!position) continue;
|
||
result.push({
|
||
id: record.mmsi,
|
||
label: record.name || record.mmsi,
|
||
position,
|
||
latitude: record.lat,
|
||
longitude: record.lon,
|
||
});
|
||
}
|
||
return result;
|
||
},
|
||
|
||
/**
|
||
* Snapshot the layer's in-memory vessel records as plain JSON-safe
|
||
* objects for the analyst query engine. On-demand only (called at most
|
||
* once per spoken query) — zero per-frame cost, no listeners, no caching.
|
||
* Returns [] while the layer is disabled or empty.
|
||
* @param {number} [maxCount=2000] - Maximum records to return (truncation).
|
||
* @returns {Array<Object>} See mapAnalystRecord for the record shape.
|
||
*/
|
||
getAnalystRecords(maxCount = 2000) {
|
||
if (!state.feed.enabled) return [];
|
||
const records = state.records.all;
|
||
if (!Array.isArray(records) || !records.length) return [];
|
||
const limit = Number.isFinite(maxCount)
|
||
? Math.max(1, Math.floor(maxCount))
|
||
: 2000;
|
||
const result = [];
|
||
for (const record of records) {
|
||
if (result.length >= limit) break;
|
||
result.push(mapAnalystRecord(record));
|
||
}
|
||
return result;
|
||
},
|
||
|
||
/**
|
||
* Select a vessel by MMSI via the same path as a map click
|
||
* (highlight + HUD update).
|
||
* @param {string|number} mmsi Vessel MMSI.
|
||
* @returns {boolean} True if a matching vessel was selected.
|
||
*/
|
||
selectById(mmsi) {
|
||
if (mmsi === null || mmsi === undefined) return false;
|
||
const target = String(mmsi).trim();
|
||
if (!target) return false;
|
||
const record = state.records.byMmsi.get(target);
|
||
if (!record) return false;
|
||
components.selection.selectVessel(record);
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Clear the current vessel selection and reset the HUD readout.
|
||
* @returns {boolean} Always true.
|
||
*/
|
||
clearSelection() {
|
||
components.selection.clearVesselInspection();
|
||
return true;
|
||
},
|
||
|
||
/**
|
||
* Get info about the currently selected vessel.
|
||
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||
*/
|
||
getSelectedInfo() {
|
||
const record = state.selectedRecord;
|
||
if (!record) return null;
|
||
return {
|
||
mmsi: record.mmsi,
|
||
name: record.name,
|
||
latitude: record.lat,
|
||
longitude: record.lon,
|
||
speedKt: record.speed,
|
||
course: record.course,
|
||
type: record.type,
|
||
};
|
||
},
|
||
|
||
/**
|
||
* Return a subset of vessels for the universal detection overlay.
|
||
* Deterministic stride sampling distributes selections evenly across the
|
||
* current record list while honoring the overlay's per-layer budget.
|
||
* @param {Object} [options={}] - Options from the detection system.
|
||
* @param {number} [options.maxCount] - Maximum objects to return (defaults to all).
|
||
* @param {number} [options.seed] - Seed offset for stride sampling.
|
||
* @returns {Array<{position: Cesium.Cartesian3, id: string, type: string, skipLabel: boolean}>}
|
||
*/
|
||
getDetectableObjects(options = {}) {
|
||
if (
|
||
!state.feed.enabled ||
|
||
!state.billboardCollection ||
|
||
!state.billboardCollection.show
|
||
)
|
||
return [];
|
||
const records = state.records.all;
|
||
if (!Array.isArray(records) || !records.length) return [];
|
||
|
||
const maxCount = Number.isFinite(options.maxCount)
|
||
? Math.max(1, Math.floor(options.maxCount))
|
||
: records.length;
|
||
const seed = Number.isFinite(options.seed) ? Math.floor(options.seed) : 0;
|
||
// Deterministic stride: evenly space selections across the record list
|
||
const stride = Math.max(1, Math.ceil(records.length / maxCount));
|
||
const start = seed % stride;
|
||
|
||
const selected = state.selectedRecord;
|
||
const result = [];
|
||
for (let idx = 0; idx < records.length; idx += 1) {
|
||
if ((idx - start) % stride !== 0) continue;
|
||
const record = records[idx];
|
||
const visual = components.rendering.getVisual(record);
|
||
if (visual.billboard && !visual.billboard.show) continue;
|
||
const position = visual.billboard?.position || visual.position;
|
||
if (!position) continue;
|
||
result.push({
|
||
position,
|
||
sourceId: record.mmsi,
|
||
id: record.name || record.mmsi || 'VESSEL',
|
||
type: 'SEA',
|
||
skipLabel: record === selected,
|
||
klass: record.type
|
||
? String(record.type).toUpperCase().slice(0, 14)
|
||
: undefined,
|
||
metric: formatKnots(record.speed), // record.speed is knots
|
||
});
|
||
if (result.length >= maxCount) break;
|
||
}
|
||
return result;
|
||
},
|
||
|
||
...(FOCUS_EVIDENCE_DEV
|
||
? {
|
||
__focusEvidence: Object.freeze({
|
||
setVessels: components.evidence._setFocusEvidenceVessels,
|
||
snapshot: components.evidence._focusEvidenceVesselSnapshot,
|
||
}),
|
||
}
|
||
: {}),
|
||
|
||
getStats() {
|
||
const waitingForFirstPosition =
|
||
state.feed.firstConnectPhase === 'loading';
|
||
return {
|
||
count: state.feed.count,
|
||
lastUpdate: state.feed.lastUpdate,
|
||
loading: state.feed.loading || waitingForFirstPosition,
|
||
loadingLabel: waitingForFirstPosition
|
||
? AIS_FIRST_CONNECT_LABEL
|
||
: state.feed.loadingLabel,
|
||
error: state.feed.error,
|
||
stale: state.feed.stale,
|
||
partial: state.feed.partial,
|
||
status:
|
||
state.feed.firstConnectPhase === 'unavailable'
|
||
? 'unavailable'
|
||
: undefined,
|
||
transportStatus: state.feed.transportStatus,
|
||
lastMessageAt: state.feed.lastMessageAt,
|
||
rawRowCount: state.feed.rawRowCount,
|
||
acceptedRowCount: state.feed.acceptedRowCount,
|
||
// Same chip affordance the flights layer uses: when the server is
|
||
// backing off, say how long until the next attempt instead of leaving
|
||
// the user to guess whether anything is still happening.
|
||
retryInSec: aisRetryInSec(),
|
||
};
|
||
},
|
||
};
|
||
|
||
return {
|
||
aisRetryInSec,
|
||
describeDegradedAisFeed,
|
||
deriveAisFeedError,
|
||
hasUsableVesselCoordinates,
|
||
classifyAisFeedSnapshot,
|
||
mapAnalystRecord,
|
||
vesselDatumHeightM,
|
||
reduceVesselSelection,
|
||
normalizeSelectionMmsi,
|
||
methods,
|
||
};
|
||
}
|