import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js'; import { msg, t } from '../../i18n/index.js'; import { AIS_FIRST_CONNECT_GRACE_MS, AIS_HEALTHY_STATUSES } from './policy.js'; export function createLifecycle({ vesselState, services, parts: components, layer, options, }) { const { state } = vesselState; const { restoreSpriteOrder, restoreSpriteOrderOnEnable } = services.sprites; const { releaseContinuousRender } = services.render; const { ensureGeoidReady } = services.geoid; const { registerPickOwner, unregisterPickOwner } = services.picking; function clearFirstConnectTimer() { if (state.feed.firstConnectTimer === null) return; vesselState._aisRuntime.clearTimeout(state.feed.firstConnectTimer); state.feed.firstConnectTimer = null; } function invalidateAisSession() { clearFirstConnectTimer(); state.feed.sessionId = ++vesselState._aisSessionSequence; state.feed.firstConnectPhase = 'idle'; state.feed.firstConnectStartedAt = null; state.feed.firstConnectDeadline = null; } function beginAisSession() { clearFirstConnectTimer(); const sessionId = ++vesselState._aisSessionSequence; const startedAt = vesselState._aisRuntime.now(); state.feed.sessionId = sessionId; state.feed.firstConnectPhase = 'loading'; state.feed.firstConnectStartedAt = startedAt; state.feed.firstConnectDeadline = startedAt + AIS_FIRST_CONNECT_GRACE_MS; state.feed.error = null; state.feed.loadingLabel = t('awaiting first AIS position…'); scheduleFirstConnectExpiry(sessionId, AIS_FIRST_CONNECT_GRACE_MS); } function scheduleFirstConnectExpiry(sessionId, delayMs) { state.feed.firstConnectTimer = vesselState._aisRuntime.setTimeout(() => { if ( !state.feed.enabled || state.feed.sessionId !== sessionId || state.feed.firstConnectPhase !== 'loading' ) return; const remainingMs = state.feed.firstConnectDeadline - vesselState._aisRuntime.now(); if (remainingMs > 0) { scheduleFirstConnectExpiry(sessionId, remainingMs); return; } state.feed.firstConnectTimer = null; state.feed.firstConnectPhase = 'unavailable'; state.feed.loadingLabel = ''; state.feed.error = state.feed.lastMessageAt ? t('awaiting usable AIS positions…') : t('awaiting first AIS message…'); state.feed.stale = state.feed.count > 0; }, delayMs); } function settleFirstConnectPhase(phase) { clearFirstConnectTimer(); state.feed.firstConnectPhase = phase; state.feed.loadingLabel = ''; } function isGraceEligibleTransport(status) { return AIS_HEALTHY_STATUSES.has(status) || status === 'connecting'; } function isDefinitiveTransportFailure(status) { return Boolean(status) && !isGraceEligibleTransport(status); } function markAisUnavailable(reason) { settleFirstConnectPhase('unavailable'); state.feed.error = reason || msg('AIS live load failed'); state.feed.stale = state.feed.count > 0; } function resetState() { components.rendering.resetRecordVisuals(); state.feed.abort?.abort(); state.trailAbort?.abort(); state.trailAbort = null; clearFirstConnectTimer(); state.viewer = null; state.feed.enabled = false; state.feed.loading = false; state.feed.loaded = false; state.feed.stale = false; state.feed.partial = false; state.feed.error = null; state.feed.loadingLabel = ''; state.feed.lastUpdate = null; state.feed.count = 0; state.feed.newestPositionAt = null; state.feed.transportStatus = null; state.feed.nextAttemptAt = null; state.feed.lastMessageAt = null; state.feed.rawRowCount = 0; state.feed.acceptedRowCount = 0; state.feed.sessionId = ++vesselState._aisSessionSequence; state.feed.firstConnectPhase = 'idle'; state.feed.firstConnectStartedAt = null; state.feed.firstConnectDeadline = null; state.feed.firstConnectTimer = null; state.feed.abort = null; state.renderer?.destroy(); state.renderer = null; state.records.all = []; state.records.byMmsi = new Map(); state.records.unkeyed = []; state.clickHandler = null; state.selectionRemove = null; state.keyTarget = null; state.keydownHandler = null; state.trackedEntityRemover = null; state.interactionHandlerFactory = null; state.interactionKeyTarget = null; state.preRenderRemover = null; components.rendering.cancelTrailingVisibilityPass(); state.lastVisibilityUpdate = 0; state.lastFocusUpdate = 0; state.activeFocusCount = 0; state.activeLabelCount = 0; state.selectedRecord = null; state.trail = null; state.trailPositions = []; state.trailMmsi = null; state.trailBackfillToken = 0; } const methods = { /** Configure the source before initialization; an active layer keeps its owner. */ setSource(source) { // Initialized: a viewer, or an engine's renderer without one. if (state.viewer || state.renderer?.engine || state.feed.enabled) throw new Error('Configure the source before layer initialization'); if (typeof source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); vesselState._source = source; this.source = source.label || this.source; }, /** * Creates the engine's vessel renderer (render/*.js), its sprite * collection, the click rule and the per-frame pass. * @param {object|null} viewer The Cesium viewer (null on an engine * without one). * @param {{ engine?: object }} [context] LayerLifecycle's context. */ init(viewer, { engine = null } = {}) { if (typeof vesselState._source?.getSnapshot !== 'function') throw new TypeError('A snapshot source is required'); if (viewer || engine) this.attachRenderer(viewer, { engine }); }, attachRenderer(viewer, { engine = null } = {}) { state.viewer = viewer; state.renderer ??= createRenderer(viewer, engine); const scene = viewer || state.renderer?.view || null; components.rendering.ensureCollections(scene); components.selection.installInteraction(scene); components.rendering.installRuntime(scene); restoreSpriteOrder(viewer); components.rendering.setVisible(state.feed.enabled); if (!state.feed.enabled) return; bindVisibleScene(viewer, scene); const horizon = components.rendering.makeOccluder(); for (const record of state.records.all) components.rendering.addRecordPrimitives(record, horizon); components.tracking.attachSelectedVesselTrail(); components.rendering.updateVisibility(true); if (state.selectedRecord) components.cards.updateSelectedVesselHud(state.selectedRecord); }, detachRenderer() { removeViewListener(); components.selection.removeVesselInteraction(); state.preRenderRemover?.(); state.preRenderRemover = null; components.rendering.cancelTrailingVisibilityPass(); releaseContinuousRender('ais-vessels'); unregisterPickOwner('ais-live-vessels'); components.tracking.detachSelectedVesselTrail(); for (const record of state.records.all) components.rendering.removeRecordPrimitives(record); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); components.cards.resetSelectedVesselHud(); detachFleet(); state.renderer?.destroy(); state.renderer = null; state.viewer = null; state.activeFocusCount = 0; state.activeLabelCount = 0; }, enable(viewer) { const wasEnabled = state.feed.enabled; state.feed.enabled = true; if (!wasEnabled) beginAisSession(); const activeViewer = viewer || state.viewer; // The scene the layer draws in: the viewer, or the renderer's view on // an engine without one. const scene = activeViewer || state.renderer?.view || null; components.rendering.ensureCollections(scene); components.selection.installInteraction(scene); components.rendering.setVisible(true); // Height-datum fix: warm the geoid grid once per layer-enable, never // blocking a poll. The first refresh may land pre-resolve (N = 0), and // the next is up to 60 s out — so re-floor in place on resolve. A load // failure leaves N = 0 forever, which is safe: sprites are depth-test- // free, so vessels stay visible either way. if (!vesselState._geoidReady) { const sessionId = state.feed.sessionId; ensureGeoidReady() .then(() => { if (!state.feed.enabled || state.feed.sessionId !== sessionId) return; vesselState._geoidReady = true; components.tracking.refloorVesselRecords(); }) .catch(() => { /* grid failed to load — anchors stay at ellipsoid 0 */ }); } bindVisibleScene(activeViewer, scene); return components.ingestion.loadLivePositions(scene); }, disable() { removeViewListener(); state.feed.enabled = false; invalidateAisSession(); releaseContinuousRender('ais-vessels'); components.rendering.cancelTrailingVisibilityPass(); unregisterPickOwner('ais-live-vessels'); components.rendering.setVisible(false); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); components.selection.clearVesselInspection(); components.tracking.destroySelectedVesselTrail(); components.selection.removeVesselInteraction(); if (state.feed.abort) { state.feed.abort.abort(); state.feed.abort = null; } state.feed.loading = false; state.feed.loadingLabel = ''; }, destroy() { removeViewListener(); invalidateAisSession(); releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix) components.rendering.cancelTrailingVisibilityPass(); if (state.feed.abort) state.feed.abort.abort(); unregisterPickOwner('ais-live-vessels'); components.selection.clearVesselInspection(); components.tracking.destroySelectedVesselTrail(); detachFleet(); vesselState._vesselOverlayHost.clearSource(VESSEL_OVERLAY_SOURCE_ID); vesselState._vesselOverlayHost.setVisible( VESSEL_OVERLAY_SOURCE_ID, false, ); components.selection.removeVesselInteraction(); if (state.preRenderRemover) { state.preRenderRemover(); } resetState(); }, }; /** The engine's vessel renderer (the Cesium one without an engine). */ function createRenderer(viewer, engine) { return ( services.renderer?.create({ viewer, engine, createTrail: services.trails?.createTrail ?? null, }) ?? null ); } function bindVisibleScene(activeViewer, scene) { // Pick-ownership (H2): vessel picks carry the record OBJECT as their id; // the registry resolver reduces it to the record's mmsi (a string key). registerPickOwner('ais-live-vessels', (pickedId) => state.records.byMmsi.has(pickedId), ); restoreSpriteOrderOnEnable('ais', activeViewer); // Vessels are asked for by view, so ask again when the view moves away. removeViewListener(); const onMoveEnd = () => { components.ingestion.refreshIfMoved(scene); }; if (state.renderer) state.feed.removeViewListener = state.renderer.view.on( 'moveEnd', onMoveEnd, ); else { const moveEnd = activeViewer?.camera?.moveEnd; if (typeof moveEnd?.addEventListener === 'function') state.feed.removeViewListener = moveEnd.addEventListener(onMoveEnd); } } function detachFleet() { const fleet = state.renderer?.fleet; if (fleet?.collection) services.sprites.unregisterSpriteCollection?.('ais', fleet.collection); fleet?.detach(); } function removeViewListener() { const remove = state.feed.removeViewListener; state.feed.removeViewListener = null; if (typeof remove === 'function') remove(); } return { clearFirstConnectTimer, invalidateAisSession, beginAisSession, scheduleFirstConnectExpiry, settleFirstConnectPhase, isGraceEligibleTransport, isDefinitiveTransportFailure, markAisUnavailable, resetState, createRenderer, methods, }; }