mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
214 lines
6.6 KiB
JavaScript
214 lines
6.6 KiB
JavaScript
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
|
|
|
|
/** Own source requests and classified feed state through explicit operations. */
|
|
export function createIngestion({
|
|
feed,
|
|
readSource,
|
|
readViewer,
|
|
getRowLimit,
|
|
readCount,
|
|
applyRows,
|
|
classifySnapshot,
|
|
isDefinitiveTransportFailure,
|
|
isGraceEligibleTransport,
|
|
markUnavailable,
|
|
settleFirstConnect,
|
|
now,
|
|
setSourceLabel,
|
|
}) {
|
|
async function loadLivePositions(viewer) {
|
|
if (!viewer || feed.loading) return;
|
|
feed.loading = true;
|
|
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
|
|
const requestController = new AbortController();
|
|
const requestSessionId = feed.sessionId;
|
|
feed.abort = requestController;
|
|
|
|
try {
|
|
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
|
|
// can't wedge the poll indefinitely (parity with the track fetch + flights).
|
|
const signal =
|
|
typeof AbortSignal.any === 'function'
|
|
? AbortSignal.any([
|
|
requestController.signal,
|
|
AbortSignal.timeout(10000),
|
|
])
|
|
: requestController.signal;
|
|
const snapshot = await readSource().getSnapshot(
|
|
{ maxRows: getRowLimit() },
|
|
{ signal },
|
|
);
|
|
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
|
setSourceLabel(snapshot.source);
|
|
// Map observations into the existing display store; source fields stop here.
|
|
applyAisFeedSnapshot(viewer, {
|
|
rows: snapshot.records.map(vesselDisplayRow),
|
|
observedAtMs: snapshot.observedAtMs,
|
|
freshness: snapshot.freshness,
|
|
complete: snapshot.complete,
|
|
rawRowCount: snapshot.rawRowCount,
|
|
reason: snapshot.reason,
|
|
status: snapshot.transportStatus,
|
|
lastMessageAt: snapshot.lastMessageAt,
|
|
nextAttemptAt: snapshot.nextAttemptAt,
|
|
refreshing: snapshot.stale,
|
|
newestPositionAt:
|
|
snapshot.observedAtMs == null
|
|
? null
|
|
: new Date(snapshot.observedAtMs).toISOString(),
|
|
silentForMs: snapshot.silentForMs,
|
|
reconnectAttempt: snapshot.reconnectAttempt,
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
ownsAisRequest(requestController, requestSessionId) &&
|
|
error?.name !== 'AbortError'
|
|
) {
|
|
markUnavailable(error?.message || 'AIS live load failed');
|
|
console.warn('[Data:ais-live-vessels]', feed.error, error);
|
|
}
|
|
} finally {
|
|
if (
|
|
feed.abort === requestController &&
|
|
feed.sessionId === requestSessionId
|
|
) {
|
|
feed.loading = false;
|
|
feed.loadingLabel =
|
|
feed.firstConnectPhase === 'loading' ? AIS_FIRST_CONNECT_LABEL : '';
|
|
feed.abort = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** True while a request still owns this enabled layer lifecycle. */
|
|
|
|
function ownsAisRequest(controller, sessionId) {
|
|
return (
|
|
feed.enabled &&
|
|
feed.sessionId === sessionId &&
|
|
feed.abort === controller &&
|
|
!controller.signal.aborted
|
|
);
|
|
}
|
|
|
|
/** Apply a classified snapshot while preserving warm state on zero accepted rows. */
|
|
|
|
function applyAisFeedSnapshot(viewer, payload) {
|
|
const snapshot = classifySnapshot(payload);
|
|
feed.loaded = true;
|
|
feed.loadingLabel = '';
|
|
feed.transportStatus = snapshot.transportStatus;
|
|
feed.nextAttemptAt = Number(payload?.nextAttemptAt) || null;
|
|
feed.lastMessageAt = snapshot.lastMessageAt;
|
|
feed.rawRowCount = snapshot.rawRowCount;
|
|
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
|
feed.partial = payload?.complete === false;
|
|
|
|
if (snapshot.acceptedRowCount === 0) {
|
|
feed.count = readCount();
|
|
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
|
if (isDefinitiveTransportFailure(snapshot.transportStatus)) {
|
|
markUnavailable(snapshot.error);
|
|
return { reconciled: false, ...snapshot };
|
|
}
|
|
if (
|
|
feed.firstConnectPhase === 'loading' &&
|
|
isGraceEligibleTransport(snapshot.transportStatus)
|
|
) {
|
|
feed.error = null;
|
|
feed.loadingLabel = AIS_FIRST_CONNECT_LABEL;
|
|
return { reconciled: false, ...snapshot };
|
|
}
|
|
if (feed.firstConnectPhase === 'loading') {
|
|
markUnavailable(snapshot.error);
|
|
return { reconciled: false, ...snapshot };
|
|
}
|
|
feed.error = snapshot.error;
|
|
return { reconciled: false, ...snapshot };
|
|
}
|
|
|
|
settleFirstConnect('ready');
|
|
applyRows(viewer, snapshot.acceptedRows, {
|
|
complete: payload?.complete !== false,
|
|
});
|
|
feed.count = readCount();
|
|
feed.stale =
|
|
Boolean(payload?.refreshing) ||
|
|
payload?.freshness === 'stale' ||
|
|
snapshot.transportStatus === 'stale' ||
|
|
payload?.freshness === 'unknown';
|
|
feed.newestPositionAt = payload?.newestPositionAt || null;
|
|
// Not unconditionally null: a degraded feed keeps its reason even though the
|
|
// cached vessels are still drawable, so the chip cannot go quiet on an
|
|
// outage the user is still looking at.
|
|
feed.error = snapshot.error || payload?.reason || null;
|
|
feed.lastUpdate = Object.hasOwn(payload, 'observedAtMs')
|
|
? payload.observedAtMs
|
|
: now();
|
|
return { reconciled: true, ...snapshot };
|
|
}
|
|
|
|
function vesselDisplayRow(record) {
|
|
return {
|
|
mmsi: record.id,
|
|
reference: record.reference,
|
|
lat: record.latitude,
|
|
lon: record.longitude,
|
|
name: record.name,
|
|
imo: record.imo,
|
|
type: record.type,
|
|
destination: record.destination,
|
|
speed: record.speedMps == null ? null : record.speedMps / 0.514444,
|
|
course: record.courseDeg,
|
|
heading: record.headingDeg,
|
|
last_position_epoch:
|
|
record.observedAtMs == null ? null : record.observedAtMs / 1000,
|
|
last_position_UTC:
|
|
record.observedAtMs == null
|
|
? ''
|
|
: new Date(record.observedAtMs).toISOString(),
|
|
};
|
|
}
|
|
const methods = {
|
|
update(viewer) {
|
|
if (!feed.enabled) return Promise.resolve();
|
|
return loadLivePositions(viewer || readViewer());
|
|
},
|
|
};
|
|
|
|
return {
|
|
loadLivePositions,
|
|
ownsAisRequest,
|
|
applyAisFeedSnapshot,
|
|
vesselDisplayRow,
|
|
methods,
|
|
};
|
|
}
|
|
|
|
/** Construct request admission and first-position status for one vessel layer. */
|
|
export function createVesselFeed() {
|
|
return {
|
|
enabled: false,
|
|
loading: false,
|
|
loaded: false,
|
|
stale: false,
|
|
partial: false,
|
|
error: null,
|
|
loadingLabel: '',
|
|
lastUpdate: null,
|
|
count: 0,
|
|
newestPositionAt: null,
|
|
transportStatus: null,
|
|
nextAttemptAt: null,
|
|
lastMessageAt: null,
|
|
rawRowCount: 0,
|
|
acceptedRowCount: 0,
|
|
sessionId: 0,
|
|
firstConnectPhase: 'idle',
|
|
firstConnectStartedAt: null,
|
|
firstConnectDeadline: null,
|
|
firstConnectTimer: null,
|
|
abort: null,
|
|
};
|
|
}
|