function cloneLayerParams(value) { if (Array.isArray(value)) return value.map(cloneLayerParams); if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, nested]) => [ key, cloneLayerParams(nested), ]), ); } return value; } const SUPERSEDED_VISIBILITY_INTENT = Symbol('superseded-visibility-intent'); const VALID_LAYER_SERIALIZATION_DISPOSITIONS = new Set([ 'enabled-only', 'enabled+options', 'enabled+mirrored-options', ]); function isAbortError(error) { return error?.name === 'AbortError'; } function lifecycleRejectedError(layerId, phase) { const error = new Error( `[Data] ${layerId} ${phase} rejected the lifecycle transition`, ); error.name = 'LifecycleRejectedError'; return error; } function paramsRejectedError(layerId) { const error = new Error(`[Data] ${layerId} rejected layer parameters`); error.name = 'LayerParamsRejectedError'; return error; } function isExplicitLayerIntentOrigin(origin) { return origin === 'user' || origin === 'voice' || origin === 'tool'; } function cancelPendingLayerRestore(entry, origin, reason) { if (!isExplicitLayerIntentOrigin(origin)) return; try { ( entry.module?.cancelPendingRestore || entry.module?.cancelPendingTrackingRestore )?.({ origin, reason }); } catch (error) { console.warn( `[Data] ${entry.module?.id || 'layer'} pending restore cancellation error:`, error, ); } } function refreshFailureFromStats(stats, label) { const specific = stats?.error || stats?.lastError; if (specific) return specific instanceof Error ? specific : new Error(String(specific)); if (stats?.unavailable === true || stats?.available === false) { return new Error(`${label} refresh unavailable`); } return null; } /** * LayerLifecycle — Manages registration, toggling, and update loops * for real-time data overlays on the CesiumJS globe. */ export class LayerLifecycle { constructor(viewer, { allowQaRegistration = false } = {}) { this.viewer = viewer; this._activityListeners = new Set(); this.layers = new Map(); // id → { module, enabled, initialized, intervalId, lifecycleState, lifecycleUncertain } this._listeners = new Set(); this._visibilityRequestListeners = new Set(); this._beforeDestroyListeners = new Set(); this._visibilityGuards = new Set(); this._registrationsFinalized = false; this._registrationDispositions = null; this._allowQaRegistration = allowQaRegistration === true; this._qaLayerIds = new Set(); } register(layerModule) { if (this._registrationsFinalized) { throw new Error('Data-layer registrations are finalized'); } this._registerLayer(layerModule); } /** Register a synthetic layer after sealing in an explicitly dev-enabled manager. */ registerForQa(layerModule) { if (!this._allowQaRegistration || !this._registrationsFinalized) { throw new Error('QA layer registration is not authorized'); } this._registerLayer(layerModule); this._qaLayerIds.add(layerModule.id); return layerModule.id; } /** Destroy a layer previously registered through the dev QA seam. */ async unregisterForQa(layerId) { if (!this._allowQaRegistration || !this._qaLayerIds.has(layerId)) return false; const destroyed = await this.destroyLayer(layerId); if (destroyed) this._qaLayerIds.delete(layerId); return destroyed; } _registerLayer(layerModule) { if (!layerModule || typeof layerModule.id !== 'string' || !layerModule.id) { throw new Error('Data layer must provide a stable id'); } if (this.layers.has(layerModule.id)) { throw new Error(`Duplicate data-layer id: ${layerModule.id}`); } this.layers.set(layerModule.id, { module: layerModule, enabled: false, initialized: false, intervalId: null, // Periodic data refreshes are manager-owned work, independent from the // authoritative enable/disable lifecycle above. Every registered layer // receives the same normalized presentation contract even when its own // getStats() omits loading fields. refreshing: false, refreshEpoch: 0, managerRefreshError: null, // `enabled` is authoritative settled visibility. Awaited lifecycle work // is reported separately so callers never mistake activation for ON or // teardown for OFF before the transaction settles. lifecycleState: 'disabled', // A lifecycle rejection can leave the module's real state unknowable. // Keep the conservative public state, but do not let setEnabled() treat // that state as settled until the requested lifecycle is reconciled. lifecycleUncertain: false, // Absolute setEnabled() calls own a monotonic intent lane separate from // relative toggle() calls. A newer request can abort lifecycle work that // is already inside this entry's serialized queue, while the epoch keeps // older queued requests from starting after they have been superseded. visibilityIntentEpoch: 0, visibilityIntentEnabled: false, visibilityIntentOrigin: 'programmatic', activeVisibilityIntent: null, latestQueuedAbsoluteIntent: null, pendingVisibilityAdoptionEpoch: 0, // Exact absolute-intent completions are retained in a small bounded map. // Context transactions use these records to adopt a named successor // without guessing from mutable lifecycle state during listener re-entry. visibilityIntentRecords: new Map(), visibilityIntentFailures: new Map(), paramsIntentEpoch: 0, paramsIntentOrigin: 'programmatic', // Teardown owns the layer from its synchronous entry boundary. New // visibility work is refused while destroy drains and cleans up earlier // intents, so no request can publish settled state into a dying entry. destroying: false, // Clear All reserves its complete target set synchronously before any // per-layer teardown begins. A later absolute request supersedes this // reservation by advancing visibilityIntentEpoch. clearVisibilityReservation: null, // Promise chain that serializes toggle() calls for THIS entry. Without it // a second toggle during the awaited init()/first-update() of the first // interleaves: the disable branch runs while enable is mid-flight, the // interval is armed after the user already turned the layer off, and a // subsequent enable arms a SECOND interval → 2× poll → OpenSky 429 (M1). toggleChain: Promise.resolve(), }); } /** Seal registration and prove each production layer has one share disposition. */ finalizeRegistrations(serializationRegistry) { if (this._registrationsFinalized) throw new Error('Data-layer registrations are already finalized'); if (!Array.isArray(serializationRegistry)) throw new Error('Layer serialization registry must be an array'); const dispositions = new Map(); for (const entry of serializationRegistry) { if (!entry?.id || !entry?.disposition) throw new Error('Layer serialization disposition is incomplete'); if (dispositions.has(entry.id)) throw new Error( `Duplicate layer serialization disposition: ${entry.id}`, ); if (!VALID_LAYER_SERIALIZATION_DISPOSITIONS.has(entry.disposition)) { throw new Error(`Invalid layer serialization disposition: ${entry.id}`); } dispositions.set(entry.id, entry.disposition); } const registeredIds = [...this.layers.keys()]; const missing = registeredIds.filter((id) => !dispositions.has(id)); const extra = [...dispositions.keys()].filter((id) => !this.layers.has(id)); if (missing.length || extra.length) { throw new Error( `Layer serialization registry mismatch (missing: ${missing.join(', ') || 'none'}; extra: ${extra.join(', ') || 'none'})`, ); } this._registrationDispositions = dispositions; this._registrationsFinalized = true; return true; } get registrationsFinalized() { return this._registrationsFinalized; } _moduleStats(entry) { if (!entry?.initialized || typeof entry.module?.getStats !== 'function') { return { count: 0, lastUpdate: null }; } try { const stats = entry.module.getStats(); return stats && typeof stats === 'object' ? stats : { count: 0, lastUpdate: null }; } catch (error) { console.warn(`[Data] ${entry.module.id} getStats error:`, error); return { count: 0, lastUpdate: null, error }; } } _normalizedStats(entry) { const moduleStats = this._moduleStats(entry); const lifecycleLoading = entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling'; return { count: 0, lastUpdate: null, ...moduleStats, loading: lifecycleLoading || moduleStats.loading === true, refreshing: entry.refreshing || moduleStats.refreshing === true, managerRefreshError: entry.managerRefreshError, }; } _invalidateRefresh(layerId, entry, reason = 'invalidated') { const wasRefreshing = entry.refreshing; const refreshEpoch = entry.refreshEpoch; entry.refreshEpoch += 1; entry.refreshing = false; if (wasRefreshing) { this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'refresh-cancelled', layerId, enabled: entry.enabled, refreshEpoch, reason, }); } } async _runPeriodicUpdate(layerId, entry, { signal = null } = {}) { if ( !entry.enabled || entry.lifecycleState !== 'enabled' || entry.destroying || entry.refreshing || signal?.aborted ) return false; const refreshEpoch = ++entry.refreshEpoch; entry.refreshing = true; this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'refresh-transition', layerId, enabled: true, refreshEpoch, }); let result; let failure = null; try { result = await entry.module.update(this.viewer, { signal }); // Even a rejected/partial update may have changed the data exposed by // the layer. Publish before classifying its result so observers see it. this._publishActivity({ type: 'data-updated', layerId }); if (result === false) failure = lifecycleRejectedError(layerId, 'refresh'); if (!failure) failure = refreshFailureFromStats( this._moduleStats(entry), entry.module.name || layerId, ); } catch (error) { failure = error; } if (signal?.aborted) { if ( this.layers.get(layerId) === entry && !entry.destroying && entry.enabled && entry.refreshEpoch === refreshEpoch ) { entry.refreshing = false; entry.managerRefreshError = null; this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'refresh-cancelled', layerId, enabled: true, refreshEpoch, }); } return false; } if ( this.layers.get(layerId) !== entry || entry.destroying || !entry.enabled || entry.refreshEpoch !== refreshEpoch ) { return false; } entry.refreshing = false; entry.managerRefreshError = failure ? String(failure.message || failure) : null; this._publishActivity({ type: 'status' }); if (failure) { console.warn(`[Data] ${layerId} refresh error:`, failure); this._notifyListeners({ type: 'refresh-failed', layerId, enabled: true, refreshEpoch, phase: 'refresh', error: failure, }); return false; } this._notifyListeners({ type: 'refresh', layerId, enabled: true, refreshEpoch, }); return result !== false; } /** * Request one fresh update for an already-enabled layer. If the periodic * loop currently owns a refresh, wait for it to settle and then run a new * update so viewport-dependent callers do not reuse work started for the * prior camera location. * @param {string} layerId Registered layer id. * @param {object} [options] Refresh authority. * @param {AbortSignal|null} [options.signal] Caller cancellation authority. * @returns {Promise} True only when the requested fresh update settles successfully. */ async refreshLayer(layerId, { signal = null } = {}) { const entry = this.layers.get(layerId); if ( !entry || !entry.enabled || entry.lifecycleState !== 'enabled' || entry.destroying || signal?.aborted ) return false; if (entry.refreshing) { const settled = await new Promise((resolve) => { let done = false; const finish = (value) => { if (done) return; done = true; unsubscribe(); signal?.removeEventListener?.('abort', onAbort); resolve(value); }; const onAbort = () => finish(false); const unsubscribe = this.subscribe((change) => { if ( change?.layerId === layerId && ['refresh', 'refresh-failed', 'refresh-cancelled'].includes( change.type, ) ) { finish(true); } }); signal?.addEventListener?.('abort', onAbort, { once: true }); queueMicrotask(() => { if (!entry.refreshing) finish(true); }); }); if ( !settled || signal?.aborted || this.layers.get(layerId) !== entry || !entry.enabled || entry.lifecycleState !== 'enabled' || entry.destroying ) return false; } return this._runPeriodicUpdate(layerId, entry, { signal }); } /** * Refresh one enabled tracked layer at the destination, then let that layer * decide whether the requested ID was present in an authoritative snapshot. * Lifecycle success alone is deliberately insufficient for this decision. */ async resolveLayerTrackingTarget( layerId, targetId, { signal = null, origin = 'share-restore' } = {}, ) { const entry = this.layers.get(layerId); const base = { layerId, targetId, origin, refreshSucceeded: false, }; if (!entry || !entry.enabled || entry.destroying) { return { ...base, status: 'unavailable', reason: 'layer-unavailable' }; } if (typeof entry.module?.resolveTrackingRestoreTarget !== 'function') { return { ...base, status: 'unsupported', reason: 'tracking-restore-unsupported', }; } if (signal?.aborted) { return { ...base, status: 'cancelled', reason: String(signal.reason || 'aborted'), }; } const refreshSucceeded = await this.refreshLayer(layerId, { signal }); if (signal?.aborted) { return { ...base, refreshSucceeded, status: 'cancelled', reason: String(signal.reason || 'aborted'), }; } if ( this.layers.get(layerId) !== entry || entry.destroying || !entry.enabled ) { return { ...base, refreshSucceeded, status: 'destroyed', reason: 'layer-destroyed', }; } try { const resolution = await entry.module.resolveTrackingRestoreTarget( targetId, { signal, origin, refreshSucceeded, }, ); if (signal?.aborted) { return { ...base, refreshSucceeded, status: 'cancelled', reason: String(signal.reason || 'aborted'), }; } const status = [ 'found', 'missing', 'source-unavailable', 'cancelled', 'superseded', 'destroyed', ].includes(resolution?.status) ? resolution.status : 'source-unavailable'; return { ...base, refreshSucceeded, ...resolution, status }; } catch (error) { if (signal?.aborted || isAbortError(error)) { return { ...base, refreshSucceeded, status: 'cancelled', reason: String(signal?.reason || error?.message || 'aborted'), errorClass: 'AbortError', }; } return { ...base, refreshSucceeded, status: 'source-unavailable', reason: String(error?.message || error), errorClass: error?.name || 'Error', }; } } _armUpdateLoop(layerId, entry) { const configuredRefreshInterval = Number(entry.module.refreshInterval); const updateInterval = Number(entry.module.updateInterval); const refreshInterval = configuredRefreshInterval > 0 ? configuredRefreshInterval : updateInterval > 0 ? updateInterval : 0; if (refreshInterval > 0) { entry.intervalId = setInterval(() => { void this._runPeriodicUpdate(layerId, entry); }, refreshInterval); } else if (updateInterval === 0) { entry.intervalId = setInterval(() => { if (!entry.enabled) return; this._publishActivity({ type: 'status' }); }, entry.module.statsRefreshInterval || 1000); } } toggle(layerId, { origin = 'programmatic', notificationToken = null } = {}) { const entry = this.layers.get(layerId); if (!entry || entry.destroying) return Promise.resolve(false); // A relative user action still needs the same revocable authority as an // absolute request. Invert the effective (latest-intent) state now, then // let the absolute-intent lane serialize and cancel obsolete lifecycle // work. This also makes two rapid toggles deterministically mean ON, OFF. return this._setEnabledWithIntent( layerId, !this.isEffectivelyEnabled(layerId), { origin, notificationToken, notifyWillChangeBeforeEffective: true, }, ).promise; } _enqueueToggle(entry, operation) { const next = entry.toggleChain.catch(() => {}).then(operation); entry.toggleChain = next; return next; } _setLifecycleTransition(entry, requestedChange, lifecycleState) { entry.lifecycleState = lifecycleState; this._syncModuleLifecyclePresentation(entry); this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-transition', lifecycleState, settledEnabled: entry.enabled, }); } _settleLifecycle(entry) { entry.lifecycleState = entry.enabled ? 'enabled' : 'disabled'; this._syncModuleLifecyclePresentation(entry); } _syncModuleLifecyclePresentation(entry) { if (typeof entry.module.setLifecyclePresentation !== 'function') return; try { entry.module.setLifecyclePresentation({ lifecycleState: entry.lifecycleState, enabled: entry.enabled, uncertain: entry.lifecycleUncertain, }); } catch (error) { console.warn( `[Data] ${entry.module.id} lifecycle presentation error:`, error, ); } } _visibilityCancellationMetadata( entry, intentEpoch, signal, phase, resourceAbort = false, ) { const hasSuccessor = Number.isInteger(intentEpoch) && entry.visibilityIntentEpoch > intentEpoch; const metadata = { ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), phase, cancellationReason: resourceAbort ? 'resource-abort' : hasSuccessor || signal?.reason === SUPERSEDED_VISIBILITY_INTENT ? 'superseded' : 'caller-abort', ...(hasSuccessor ? { successorIntentEpoch: entry.visibilityIntentEpoch, successorEnabled: entry.visibilityIntentEnabled, successorOrigin: entry.visibilityIntentOrigin, } : {}), }; const record = entry.visibilityIntentRecords.get(intentEpoch); if (record) Object.assign(record, metadata); return metadata; } _setVisibilityIntentPhase(entry, intentEpoch, phase) { if (!Number.isInteger(intentEpoch)) return; const record = entry.visibilityIntentRecords.get(intentEpoch); if (record) record.phase = phase; } async _doToggle( entry, layerId, origin, { signal = null, targetEnabled = !entry.enabled, notificationToken = null, intentEpoch = null, suppressWillChangeNotification = false, beforeEnableParams = null, } = {}, ) { const desiredState = Boolean(targetEnabled); const recordVisibilityFailure = (phase, error) => { if (!Number.isInteger(intentEpoch)) return; entry.visibilityIntentFailures.set(intentEpoch, { phase, error: error || lifecycleRejectedError(layerId, phase), }); }; const isSuperseded = () => intentEpoch !== null && intentEpoch !== entry.visibilityIntentEpoch; const settleLifecycle = () => { if (!isSuperseded()) { entry.pendingVisibilityAdoptionEpoch = 0; this._settleLifecycle(entry); // setLifecyclePresentation() is synchronous and may notify a // subscriber that immediately issues a newer absolute request. Re-read // the epoch after that callback boundary before treating settlement as // owned by this transaction. if (!isSuperseded()) return true; } // Cleanup belongs to the obsolete transaction, so retain the actual // conservative state but keep presentation transitional/hidden. The // latest queued absolute request will reconcile or adopt it and alone // publish settled visibility under its own origin. entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; this._syncModuleLifecyclePresentation(entry); return false; }; const requestedChange = { type: 'visibility-will-change', layerId, enabled: desiredState, origin, ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), ...(notificationToken ? { notificationToken } : {}), }; if (signal?.aborted) return false; if (!suppressWillChangeNotification) this._notifyListeners(requestedChange); const blockReason = await this._visibilityBlockReason(requestedChange); if (signal?.aborted) { this._notifyListeners({ ...requestedChange, type: 'visibility-cancelled', ...this._visibilityCancellationMetadata( entry, intentEpoch, signal, 'guard', ), }); return false; } if (blockReason) { this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-blocked', reason: blockReason, }); return false; } this._setLifecycleTransition( entry, requestedChange, desiredState ? 'enabling' : 'disabling', ); if (!desiredState) { // Disable this._invalidateRefresh(layerId, entry, 'layer-disabled'); const finishCancelledDisable = async ( phase = 'disable', resourceAbort = false, ) => { // The module may already have completed its disable work, so compensate // inside this serialized manager transaction. Restore ON only after a // successful enable; otherwise remain truthfully OFF and let the next // setEnabled(true) perform real lifecycle work. let compensated = false; let compensationError = null; try { compensated = (await entry.module.enable(this.viewer)) !== false; if (!compensated) { compensationError = lifecycleRejectedError( layerId, 'cancel-disable-compensation', ); } } catch (error) { compensationError = error; console.warn( `[Data] ${layerId} cancelled-disable cleanup error:`, error, ); } let cleanupConfirmed = false; if (!compensated) { try { cleanupConfirmed = (await entry.module.disable(this.viewer)) !== false; } catch (error) { console.warn( `[Data] ${layerId} cancelled-disable final cleanup error:`, error, ); } } // A failed enable may have partially activated the module. Only record // OFF when a subsequent disable positively confirms cleanup; otherwise // retain ON as the conservative authoritative state. entry.enabled = compensated || !cleanupConfirmed; entry.lifecycleUncertain = !compensated && !cleanupConfirmed; settleLifecycle(); if (!entry.enabled && entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } this._publishActivity({ type: 'status' }); if (!compensated) { recordVisibilityFailure( 'cancel-disable-compensation', compensationError || lifecycleRejectedError(layerId, 'cancel-disable-compensation'), ); } this._notifyListeners({ ...requestedChange, type: compensated ? 'visibility-cancelled' : 'visibility-failed', ...(compensated ? this._visibilityCancellationMetadata( entry, intentEpoch, signal, phase, resourceAbort, ) : {}), ...(compensated ? {} : { phase: 'cancel-disable-compensation', error: compensationError, }), }); return false; }; try { const disabled = await entry.module.disable(this.viewer, { signal }); if (disabled === false) throw lifecycleRejectedError(layerId, 'disable'); } catch (e) { if (signal?.aborted || isAbortError(e)) { return finishCancelledDisable( 'disable', isAbortError(e) && !signal?.aborted, ); } // Fail closed: the module may still be polling or rendering, so keep // the manager's authoritative state enabled and preserve its interval. entry.enabled = true; entry.lifecycleUncertain = true; settleLifecycle(); console.warn(`[Data] ${layerId} disable error:`, e); recordVisibilityFailure('disable', e); this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-failed', phase: 'disable', error: e, }); return false; } if (signal?.aborted) return finishCancelledDisable('disable'); if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } entry.enabled = false; entry.lifecycleUncertain = false; if (!settleLifecycle() || signal?.aborted) return finishCancelledDisable('settle'); } else { // Enable let abortCleanup = null; const cancelEnable = () => { if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } // Disable immediately so modules with their own AbortController (Radio) // cancel pending update work at the same turn boundary. A second // disable after the current lifecycle await settles closes the race // where an asynchronous enable finishes after this callback. try { abortCleanup = Promise.resolve( entry.module.disable(this.viewer), ).catch((error) => { console.warn( `[Data] ${layerId} cancelled-enable cleanup error:`, error, ); return false; }); } catch (error) { console.warn( `[Data] ${layerId} cancelled-enable cleanup error:`, error, ); abortCleanup = Promise.resolve(false); } }; const finishCancelledEnable = async (phase, resourceAbort = false) => { // A resource-local AbortError settles this transaction without // aborting the caller's signal. Release that signal's listener now so // a later abort cannot revoke a successful retry. signal?.removeEventListener('abort', cancelEnable); if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } await abortCleanup; let cleanupConfirmed = false; try { cleanupConfirmed = (await entry.module.disable(this.viewer)) !== false; } catch (error) { console.warn( `[Data] ${layerId} cancelled-enable final cleanup error:`, error, ); } entry.enabled = !cleanupConfirmed; entry.lifecycleUncertain = !cleanupConfirmed; settleLifecycle(); this._publishActivity({ type: 'status' }); if (!cleanupConfirmed) { recordVisibilityFailure( 'cancel-enable-cleanup', lifecycleRejectedError(layerId, 'cancel-enable-cleanup'), ); } this._notifyListeners({ ...requestedChange, type: cleanupConfirmed ? 'visibility-cancelled' : 'visibility-failed', ...(cleanupConfirmed ? this._visibilityCancellationMetadata( entry, intentEpoch, signal, phase, resourceAbort, ) : {}), ...(cleanupConfirmed ? {} : { phase: 'cancel-enable-cleanup' }), }); return false; }; const finishFailedEnable = async (phase, error) => { if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } let cleanupConfirmed = false; try { cleanupConfirmed = (await entry.module.disable(this.viewer)) !== false; } catch (cleanupError) { console.warn( `[Data] ${layerId} failed-enable cleanup error:`, cleanupError, ); } entry.enabled = !cleanupConfirmed; entry.lifecycleUncertain = !cleanupConfirmed; settleLifecycle(); console.warn(`[Data] ${layerId} ${phase} error:`, error); recordVisibilityFailure(phase, error); this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-failed', phase, error, }); signal?.removeEventListener('abort', cancelEnable); return false; }; signal?.addEventListener('abort', cancelEnable, { once: true }); if (!entry.initialized) { this._setVisibilityIntentPhase(entry, intentEpoch, 'init'); try { const initialized = await entry.module.init(this.viewer, { signal }); if (initialized === false) throw lifecycleRejectedError(layerId, 'init'); entry.initialized = true; } catch (e) { if (signal?.aborted || isAbortError(e)) { return finishCancelledEnable( 'init', isAbortError(e) && !signal?.aborted, ); } return finishFailedEnable('init', e); } } if (signal?.aborted) return finishCancelledEnable('init'); if (beforeEnableParams) { this._setVisibilityIntentPhase(entry, intentEpoch, 'params'); const paramsResult = this._applyLayerParamsIntent( layerId, beforeEnableParams.params, { origin: beforeEnableParams.origin, paramsIntentEpoch: beforeEnableParams.paramsIntentEpoch, }, ); beforeEnableParams.result = paramsResult; if (!paramsResult.succeeded) { if (signal?.aborted || paramsResult.cancellationReason) { return finishCancelledEnable('params'); } return finishFailedEnable( 'params', paramsResult.error || paramsRejectedError(layerId), ); } } if (signal?.aborted) return finishCancelledEnable('params'); entry.lifecycleUncertain = false; this._setVisibilityIntentPhase(entry, intentEpoch, 'enable'); try { const enabled = await entry.module.enable(this.viewer, { signal }); if (enabled === false) throw lifecycleRejectedError(layerId, 'enable'); } catch (e) { if (signal?.aborted || isAbortError(e)) { return finishCancelledEnable( 'enable', isAbortError(e) && !signal?.aborted, ); } return finishFailedEnable('enable', e); } if (signal?.aborted) return finishCancelledEnable('enable'); // First update immediately this._setVisibilityIntentPhase(entry, intentEpoch, 'update'); try { const updated = await entry.module.update(this.viewer, { signal }); if (updated === false) throw lifecycleRejectedError(layerId, 'update'); } catch (e) { if (signal?.aborted || isAbortError(e)) { return finishCancelledEnable( 'update', isAbortError(e) && !signal?.aborted, ); } return finishFailedEnable('update', e); } if (signal?.aborted) return finishCancelledEnable('update'); entry.managerRefreshError = null; entry.enabled = true; entry.lifecycleUncertain = false; this._setVisibilityIntentPhase(entry, intentEpoch, 'settle'); if (!settleLifecycle() || signal?.aborted) return finishCancelledEnable('settle'); // Always clear any stale interval before assigning a new one, so we never // orphan a running timer and end up double-polling. if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } // Manager-owned periodic refresh work has one normalized loading/error // contract. Camera-driven layers may keep updateInterval=0 and opt into // a slower data fetch with refreshInterval. this._armUpdateLoop(layerId, entry); signal?.removeEventListener('abort', cancelEnable); } this._publishActivity({ type: 'status' }); this._publishActivity({ type: 'visibility-settled', layerId }); this._notifyListeners({ type: 'visibility', layerId, enabled: entry.enabled, origin, ...(Number.isInteger(intentEpoch) ? { intentEpoch } : {}), ...(notificationToken ? { notificationToken } : {}), }); return true; } /** * Ensure a layer is in the requested enabled/disabled state. * Deterministic helper for scripted scene playback. */ setEnabled( layerId, shouldEnable, { origin = 'programmatic', signal = null, notificationToken = null } = {}, ) { return this._setEnabledWithIntent(layerId, shouldEnable, { origin, signal, notificationToken, }).promise; } /** * Internal absolute-visibility request with an exact intent handle. * The ordinary setEnabled() promise remains the public control contract. */ _setEnabledWithIntent( layerId, shouldEnable, { origin = 'programmatic', signal = null, notificationToken = null, notifyWillChangeBeforeEffective = false, beforeEnableParams = null, } = {}, ) { const entry = this.layers.get(layerId); if (!entry) return { intentEpoch: null, promise: Promise.resolve() }; const desiredState = Boolean(shouldEnable); if (entry.destroying) { return { intentEpoch: null, promise: Promise.resolve(desiredState === false), }; } cancelPendingLayerRestore(entry, origin, 'explicit-visibility'); const intentEpoch = ++entry.visibilityIntentEpoch; entry.visibilityIntentEnabled = desiredState; entry.visibilityIntentOrigin = origin; let resolveIntentRecord; const settled = new Promise((resolve) => { resolveIntentRecord = resolve; }); const intentRecord = { intentEpoch, enabled: desiredState, origin, phase: 'queued', settled, resolve: resolveIntentRecord, }; entry.visibilityIntentRecords.set(intentEpoch, intentRecord); for (const [recordEpoch, record] of entry.visibilityIntentRecords) { if (entry.visibilityIntentRecords.size <= 16) break; if (recordEpoch !== intentEpoch && record.completed) entry.visibilityIntentRecords.delete(recordEpoch); } // Relative toggle historically exposes its will-change edge while the // settled/effective snapshot is still the pre-click state. Preserve that // Context capture boundary, but reserve the epoch first so a re-entrant // listener can still supersede this request authoritatively. if (notifyWillChangeBeforeEffective) { this._notifyListeners({ type: 'visibility-will-change', layerId, enabled: desiredState, origin, intentEpoch, ...(notificationToken ? { notificationToken } : {}), }); } // Effective visibility must follow the NEWEST absolute intent from the // synchronous moment it is requested — the superseded transaction's // cleanup updates lifecycleState later, and Context capture can run in // between. Cleared by this request's own queue turn when it finishes. if (entry.visibilityIntentEpoch === intentEpoch) { entry.latestQueuedAbsoluteIntent = { intentEpoch, enabled: desiredState }; } this._notifyVisibilityRequest({ type: 'visibility-requested', layerId, enabled: desiredState, origin, intentEpoch, ...(notificationToken ? { notificationToken } : {}), }); // Advance absolute intent before aborting so the obsolete transaction's // cleanup can defer settlement to this exact latest epoch. Supersede even // a same-target request: its newer origin may carry explicit user intent // that must own the eventual persistence-bearing visibility event. entry.activeVisibilityIntent?.controller.abort( SUPERSEDED_VISIBILITY_INTENT, ); // The idempotency check belongs inside the same per-layer queue as toggle. // Checking before enqueueing lets two simultaneous setEnabled(true) calls // both observe OFF and accidentally perform enable-then-disable (M6). const releaseQueuedIntent = () => { if (entry.latestQueuedAbsoluteIntent?.intentEpoch === intentEpoch) { entry.latestQueuedAbsoluteIntent = null; } }; const runIntentTurn = async () => { const requestedChange = { type: 'visibility-will-change', layerId, enabled: desiredState, origin, intentEpoch, ...(notificationToken ? { notificationToken } : {}), }; if (intentEpoch !== entry.visibilityIntentEpoch) { this._notifyListeners({ ...requestedChange, type: 'visibility-cancelled', ...this._visibilityCancellationMetadata( entry, intentEpoch, null, 'queued', ), }); return false; } if (signal?.aborted) { if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { entry.pendingVisibilityAdoptionEpoch = 0; this._settleLifecycle(entry); this._publishActivity({ type: 'status' }); } // Every accepted absolute request has one authoritative terminal // outcome. Even when it is aborted before its queue turn and no // adoption is pending, publish cancellation and populate the exact // intent record used by waiters. this._notifyListeners({ ...requestedChange, type: 'visibility-cancelled', ...this._visibilityCancellationMetadata( entry, intentEpoch, signal, 'queued', ), }); return false; } if ( entry.pendingVisibilityAdoptionEpoch === intentEpoch && entry.enabled === desiredState && !entry.lifecycleUncertain ) { // Adoption publishes a successful settled visibility, so it must pass // the same guards a fresh transition would — a guard installed after // the superseded transaction started (e.g. an exclusive Context mode) // must be able to veto the adopted state, not just future requests. const adoptionBlockReason = await this._visibilityBlockReason(requestedChange); if (intentEpoch !== entry.visibilityIntentEpoch) { // The guard is an async boundary. A newer intent can arrive while it // is pending, so this adoption needs the same exact terminal envelope // as every other superseded phase. Context follows these successor // fields instead of guessing from mutable manager state. if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; } entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; this._syncModuleLifecyclePresentation(entry); this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-cancelled', ...this._visibilityCancellationMetadata( entry, intentEpoch, null, 'adoption', ), }); return false; } if (signal?.aborted) { if (entry.pendingVisibilityAdoptionEpoch === intentEpoch) { entry.pendingVisibilityAdoptionEpoch = 0; this._settleLifecycle(entry); this._publishActivity({ type: 'status' }); this._notifyListeners({ ...requestedChange, type: 'visibility-cancelled', ...this._visibilityCancellationMetadata( entry, intentEpoch, signal, 'adoption', ), }); } return false; } if (adoptionBlockReason) { entry.pendingVisibilityAdoptionEpoch = 0; this._publishActivity({ type: 'status' }); if (entry.enabled === desiredState) { // The guard forbids the very state the superseded transaction's // cleanup left behind. Reconcile through the ordinary lifecycle to // the guard-respecting opposite — but the CALLER's request stays // unfulfilled either way. Ordering matters: the compensation // registers as the active intent AND takes over the queued-intent // record BEFORE the blocked event is announced, so a listener that // re-enters setEnabled() during the callback has a live intent to // abort and observes the reconciliation target as effective // visibility, not the refused request. const compensationController = new AbortController(); const compensationIntent = { intentEpoch, enabled: !desiredState, controller: compensationController, }; entry.activeVisibilityIntent = compensationIntent; entry.latestQueuedAbsoluteIntent = { intentEpoch, enabled: !desiredState, }; this._notifyListeners({ ...requestedChange, type: 'visibility-blocked', reason: adoptionBlockReason, }); // The callback may have superseded this turn. The newest intent // owns reconciliation now — defer exactly like an obsolete // transaction instead of installing an already-doomed compensation. if ( intentEpoch !== entry.visibilityIntentEpoch || compensationController.signal.aborted ) { if (entry.activeVisibilityIntent === compensationIntent) { entry.activeVisibilityIntent = null; } entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; this._syncModuleLifecyclePresentation(entry); return false; } try { await this._doToggle(entry, layerId, origin, { signal: compensationController.signal, targetEnabled: !desiredState, notificationToken, intentEpoch, }); } finally { if (entry.activeVisibilityIntent === compensationIntent) { entry.activeVisibilityIntent = null; } } return false; } this._notifyListeners({ ...requestedChange, type: 'visibility-blocked', reason: adoptionBlockReason, }); this._settleLifecycle(entry); return false; } entry.pendingVisibilityAdoptionEpoch = 0; this._settleLifecycle(entry); if (intentEpoch !== entry.visibilityIntentEpoch) { entry.lifecycleState = entry.visibilityIntentEnabled ? 'enabling' : 'disabling'; entry.pendingVisibilityAdoptionEpoch = entry.visibilityIntentEpoch; this._syncModuleLifecyclePresentation(entry); return false; } this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'visibility', layerId, enabled: desiredState, origin, intentEpoch, ...(notificationToken ? { notificationToken } : {}), }); return true; } if (entry.enabled === desiredState && !entry.lifecycleUncertain) { // Idempotent exit — but an aborted predecessor (e.g. a compensation // cancelled before its first transition) may have left a stale // transitional presentation. State and intent agree here, so settle // the presentation rather than orphaning ENABLING/DISABLING forever. // Publish the accepted absolute intent even when lifecycle work is a // no-op: a newer explicit origin can own Context/persistence behavior // without redundantly re-enabling the module. if ( entry.lifecycleState === 'enabling' || entry.lifecycleState === 'disabling' ) { this._settleLifecycle(entry); } this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'visibility', layerId, enabled: desiredState, origin, intentEpoch, ...(notificationToken ? { notificationToken } : {}), }); return true; } entry.pendingVisibilityAdoptionEpoch = 0; const controller = new AbortController(); const forwardCallerAbort = () => controller.abort(signal?.reason); signal?.addEventListener('abort', forwardCallerAbort, { once: true }); const activeIntent = { intentEpoch, enabled: desiredState, controller }; entry.activeVisibilityIntent = activeIntent; try { return await this._doToggle(entry, layerId, origin, { signal: controller.signal, targetEnabled: desiredState, notificationToken, intentEpoch, suppressWillChangeNotification: notifyWillChangeBeforeEffective, beforeEnableParams, }); } finally { signal?.removeEventListener('abort', forwardCallerAbort); if (entry.activeVisibilityIntent === activeIntent) { entry.activeVisibilityIntent = null; } } }; const promise = this._enqueueToggle(entry, async () => { try { return await runIntentTurn(); } finally { // This turn no longer owns queued-intent effective visibility — // either it settled, or a newer epoch superseded it (that epoch's // own record already replaced this one). releaseQueuedIntent(); } }); promise.then( (result) => { const failure = entry.visibilityIntentFailures.get(intentEpoch) || null; entry.visibilityIntentFailures.delete(intentEpoch); intentRecord.completed = true; intentRecord.result = result; intentRecord.error = failure?.error || null; intentRecord.settledEnabled = entry.enabled; intentRecord.uncertain = entry.lifecycleUncertain; intentRecord.resolve({ intentEpoch, enabled: desiredState, origin, phase: failure?.phase || intentRecord.phase, result, ...(failure?.error ? { error: failure.error } : {}), settledEnabled: entry.enabled, uncertain: entry.lifecycleUncertain, succeeded: result !== false && entry.enabled === desiredState && !entry.lifecycleUncertain, cancellationReason: intentRecord.cancellationReason || null, successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, successorEnabled: intentRecord.successorEnabled ?? null, successorOrigin: intentRecord.successorOrigin ?? null, }); }, (error) => { entry.visibilityIntentFailures.delete(intentEpoch); intentRecord.completed = true; intentRecord.error = error; intentRecord.resolve({ intentEpoch, enabled: desiredState, origin, phase: intentRecord.phase, result: false, error, settledEnabled: entry.enabled, uncertain: entry.lifecycleUncertain, succeeded: false, cancellationReason: intentRecord.cancellationReason || null, successorIntentEpoch: intentRecord.successorIntentEpoch ?? null, successorEnabled: intentRecord.successorEnabled ?? null, successorOrigin: intentRecord.successorOrigin ?? null, }); }, ); return { intentEpoch, promise }; } /** Wait for one exact absolute visibility intent to complete. */ async _waitForVisibilityIntent(layerId, intentEpoch) { const record = this.layers .get(layerId) ?.visibilityIntentRecords?.get(intentEpoch); return record ? record.settled : null; } /** * Follow one restore request through any explicit superseding intent chain. * The newest named successor must reach a terminal state before restore can * judge the layer; an obsolete caller boolean is never sufficient. */ async _waitForAuthoritativeVisibilityIntent(layerId, intentEpoch) { const entry = this.layers.get(layerId); if (!entry || !Number.isInteger(intentEpoch)) return null; let epoch = intentEpoch; let outcome = null; while (Number.isInteger(epoch)) { outcome = await this._waitForVisibilityIntent(layerId, epoch); if (!outcome) return null; const newerEpoch = entry.visibilityIntentEpoch > epoch ? entry.visibilityIntentEpoch : outcome.successorIntentEpoch; if (!Number.isInteger(newerEpoch) || newerEpoch <= epoch) break; if (outcome.cancellationReason !== 'superseded') return null; epoch = newerEpoch; } return outcome; } /** * The layer's effective visibility target: settled state, unless awaited * lifecycle work (or a superseded transaction awaiting adoption) is moving * it — an ENABLING layer is effectively ON and a DISABLING layer is * effectively OFF, regardless of which side has settled. */ _effectiveEnabled(entry) { // Newest-intent-wins: an absolute request owns effective visibility from // the synchronous moment it is made, even while a superseded transaction // has not yet updated lifecycleState. if ( entry.clearVisibilityReservation && entry.visibilityIntentEpoch === entry.clearVisibilityReservation.intentEpoch ) return false; if (entry.latestQueuedAbsoluteIntent) return entry.latestQueuedAbsoluteIntent.enabled; if (entry.lifecycleState === 'enabling') return true; if (entry.lifecycleState === 'disabling') return false; return entry.enabled; } /** * Whether a layer is effectively enabled, counting in-flight transitions as * their target state. Context capture/isolation must use THIS (not the * settled `isEnabled()`) so a layer mid-activation is isolated and * snapshotted as ON, and a layer honoring a user's in-flight OFF is not * snapshotted (and later restored) as ON. * @param {string} layerId Registered layer identifier. * @returns {boolean} */ isEffectivelyEnabled(layerId) { const entry = this.layers.get(layerId); return entry ? this._effectiveEnabled(entry) : false; } /** * Snapshot the exact set of registered layers the user currently intends * enabled, counting in-flight transitions as their target state. * @returns {Set} A detached set safe for later restoration. */ getEnabledLayerIds() { return new Set( [...this.layers] .filter(([, entry]) => this._effectiveEnabled(entry)) .map(([layerId]) => layerId), ); } /** * Turn off every layer whose latest authoritative intent is currently ON. * Reverse registration order lets dependents settle their teardown before * an earlier-registered dependency receives its final OFF intent. Each layer * keeps its normal latest-intent manager authority, so a newer direct request * can supersede this batch without being overwritten by a retry loop. * * @param {object} [options] Clear transition options. * @param {string} [options.origin='user'] Visibility-event origin. * @param {symbol|null} [options.notificationToken] Shared notification owner. * @returns {Promise<{targetIds:string[],items:object[],clearedIds:string[],notClearedIds:string[]}>} */ async clearSelectedLayers({ origin = 'user', notificationToken = null, } = {}) { const clearBatchId = Symbol('clear-selected-layers'); const targets = [...this.layers] .filter(([, entry]) => this._effectiveEnabled(entry)) .map(([layerId, entry]) => ({ layerId, intentEpoch: entry.visibilityIntentEpoch, })) .reverse(); // Reserve the whole batch before the first awaited OFF. This makes Clear // All's global OFF authority observable synchronously while preserving // reverse-order lifecycle teardown. Any later absolute intent advances // the epoch and therefore owns that layer instead. for (const { layerId, intentEpoch } of targets) { const entry = this.layers.get(layerId); if (entry) entry.clearVisibilityReservation = { clearBatchId, intentEpoch }; } const targetIds = targets.map(({ layerId }) => layerId); const attempts = new Map(); try { for (const { layerId, intentEpoch } of targets) { const entry = this.layers.get(layerId); const superseded = entry && entry.visibilityIntentEpoch !== intentEpoch; if (superseded) { attempts.set(layerId, { superseded: true }); continue; } try { const clearIntentEpoch = entry.visibilityIntentEpoch + 1; const result = await this.setEnabled(layerId, false, { origin, ...(notificationToken ? { notificationToken } : {}), }); attempts.set(layerId, { result, superseded: entry.visibilityIntentEpoch !== clearIntentEpoch, }); } catch (error) { attempts.set(layerId, { error }); } } } finally { for (const { layerId } of targets) { const entry = this.layers.get(layerId); if (entry?.clearVisibilityReservation?.clearBatchId === clearBatchId) { entry.clearVisibilityReservation = null; } } } const items = targetIds.map((id) => { const state = this.getLayerLifecycleState(id) || { enabled: false, lifecycleState: 'missing', uncertain: true, }; const attempt = attempts.get(id) || {}; const cleared = !state.enabled && state.lifecycleState === 'disabled' && !state.uncertain; return { id, requested: false, cleared, result: attempt.result, error: attempt.error || null, superseded: attempt.superseded === true, ...state, }; }); return { targetIds, items, clearedIds: items.filter((item) => item.cleared).map((item) => item.id), notClearedIds: items .filter((item) => !item.cleared) .map((item) => item.id), }; } /** * Restore the exact enabled set captured before a focused mode took over. * Each transition uses the normal serialized lifecycle and visibility event * path so subscribers observe the same state changes as direct operations. * * @param {Iterable} enabledLayerIds Exact target enabled layer ids. * @param {object} [options] Restore notification options. * @param {string} [options.origin='programmatic'] Visibility event origin. * @param {symbol|null} [options.notificationToken] Opaque caller token used * to correlate one transition's user-facing failure notification. * @param {Iterable} [options.excludeLayerIds] Layers owned by an * in-flight transition that must not enqueue behind themselves. * @param {AbortSignal|null} [options.signal] Caller cancellation authority. * @returns {Promise} Resolves after every registered layer settles. */ async restoreEnabledLayerIds( enabledLayerIds, { origin = 'programmatic', excludeLayerIds = [], notificationToken = null, signal = null, } = {}, ) { const target = new Set(enabledLayerIds || []); const excluded = new Set(excludeLayerIds || []); const layerIds = [...this.layers.keys()].filter((layerId) => { if (excluded.has(layerId)) return false; const entry = this.layers.get(layerId); // Pre-destroy restoration cannot enqueue onto the layer whose teardown // already owns OFF. That terminal teardown satisfies an OFF snapshot; // an impossible ON target remains visible to the failure checks below. return !(entry?.destroying && !target.has(layerId)); }); const transitionOptions = { origin, ...(notificationToken ? { notificationToken } : {}), ...(signal ? { signal } : {}), }; const handles = layerIds.map((layerId) => this._setEnabledWithIntent( layerId, target.has(layerId), transitionOptions, ), ); const results = await Promise.allSettled( handles.map((handle, index) => this._waitForAuthoritativeVisibilityIntent( layerIds[index], handle.intentEpoch, ), ), ); const failedLayerIds = results.flatMap((result, index) => { if (result.status === 'rejected') return [layerIds[index]]; const outcome = result.value; const layerId = layerIds[index]; const state = this.getLayerLifecycleState(layerId); const desiredState = target.has(layerId); const failed = !outcome || outcome.error || !state || state.enabled !== desiredState || state.lifecycleState !== (desiredState ? 'enabled' : 'disabled') || state.uncertain || this.layers.get(layerId)?.latestQueuedAbsoluteIntent; return failed ? [layerId] : []; }); if (failedLayerIds.length === 0) return; const failedIndex = layerIds.indexOf(failedLayerIds[0]); const failed = results[failedIndex]; const error = failed.status === 'rejected' ? failed.reason : new Error( `Failed to restore layer "${layerIds[failedIndex]}" visibility`, ); error.failedLayerIds = [ ...new Set([ ...(Array.isArray(error.failedLayerIds) ? error.failedLayerIds : []), ...failedLayerIds, ]), ]; throw error; } /** * Wait until lifecycle work already queued for one layer has settled. * Callers use this outside manager listeners before scheduling reconciliation. * @param {string} layerId Layer identifier. * @returns {Promise} Resolves after the captured queue settles. */ async waitForLayerSettled(layerId) { const entry = this.layers.get(layerId); if (!entry) return; await entry.toggleChain.catch(() => {}); } _reserveLayerParamsIntent(layerId, params, origin = 'programmatic') { const entry = this.layers.get(layerId); if ( !entry || entry.destroying || typeof entry.module?.setParams !== 'function' ) return null; cancelPendingLayerRestore(entry, origin, 'explicit-params'); const paramsIntentEpoch = ++entry.paramsIntentEpoch; entry.paramsIntentOrigin = origin; this._notifyListeners({ type: 'params-requested', layerId, params: cloneLayerParams(params || {}), origin, paramsIntentEpoch, }); return paramsIntentEpoch; } _applyLayerParamsIntent( layerId, params, { origin = 'programmatic', paramsIntentEpoch = null } = {}, ) { const entry = this.layers.get(layerId); if ( !entry || !entry.module || typeof entry.module.setParams !== 'function' ) { return { succeeded: false, error: paramsRejectedError(layerId), params: null, }; } if ( !Number.isInteger(paramsIntentEpoch) || entry.paramsIntentEpoch !== paramsIntentEpoch ) { const result = { succeeded: false, cancellationReason: 'superseded', params: null, }; this._notifyListeners({ type: 'params-cancelled', layerId, origin, paramsIntentEpoch, cancellationReason: result.cancellationReason, successorParamsIntentEpoch: entry.paramsIntentEpoch, successorOrigin: entry.paramsIntentOrigin, }); return result; } try { const accepted = entry.module.setParams(params || {}, { origin, paramsIntentEpoch, }); if (accepted === false) throw paramsRejectedError(layerId); if (entry.paramsIntentEpoch !== paramsIntentEpoch) { const result = { succeeded: false, cancellationReason: 'superseded', params: null, }; this._notifyListeners({ type: 'params-cancelled', layerId, origin, paramsIntentEpoch, cancellationReason: result.cancellationReason, successorParamsIntentEpoch: entry.paramsIntentEpoch, successorOrigin: entry.paramsIntentOrigin, }); return result; } const appliedParams = this.getLayerParams(layerId) || cloneLayerParams(params || {}); this._publishActivity({ type: 'status' }); this._publishActivity({ type: 'params-settled', layerId }); this._notifyListeners({ type: 'params', layerId, params: appliedParams, requestedParams: cloneLayerParams(params || {}), origin, paramsIntentEpoch, }); return { succeeded: true, params: appliedParams, paramsIntentEpoch }; } catch (error) { console.warn(`[Data] ${layerId} setParams error:`, error); this._notifyListeners({ type: 'params-failed', layerId, params: cloneLayerParams(params || {}), origin, paramsIntentEpoch, error, }); return { succeeded: false, error, params: null, paramsIntentEpoch }; } } /** Apply runtime parameters through an origin-bearing intent lane. */ setLayerParams(layerId, params, { origin = 'programmatic' } = {}) { const paramsIntentEpoch = this._reserveLayerParamsIntent( layerId, params, origin, ); if (!Number.isInteger(paramsIntentEpoch)) return false; return this._applyLayerParamsIntent(layerId, params, { origin, paramsIntentEpoch, }).succeeded; } /** Cancel a module-owned pending restore without creating a parameter intent. */ cancelPendingLayerRestore( layerId, { origin = 'programmatic', reason = 'cancelled' } = {}, ) { const entry = this.layers.get(layerId); const cancel = entry?.module?.cancelPendingRestore || entry?.module?.cancelPendingTrackingRestore; if (typeof cancel !== 'function') return false; try { cancel.call(entry.module, { origin, reason }); return true; } catch (error) { console.warn( `[Data] ${layerId} pending restore cancellation error:`, error, ); return false; } } /** Publish parameters already applied by a layer's direct interaction. */ adoptLayerParams(layerId, params, { origin = 'programmatic' } = {}) { const requestedParams = cloneLayerParams(params || {}); const paramsIntentEpoch = this._reserveLayerParamsIntent( layerId, requestedParams, origin, ); if (!Number.isInteger(paramsIntentEpoch)) return false; const appliedParams = this.getLayerParams(layerId); const matches = appliedParams && Object.entries(requestedParams).every(([key, value]) => Object.is(appliedParams[key], value), ); if (!matches) { const error = paramsRejectedError(layerId); this._notifyListeners({ type: 'params-failed', layerId, params: requestedParams, origin, paramsIntentEpoch, error, }); return false; } this._publishActivity({ type: 'status' }); this._publishActivity({ type: 'params-settled', layerId }); this._notifyListeners({ type: 'params', layerId, params: appliedParams, requestedParams, origin, paramsIntentEpoch, }); return true; } /** * Publish an explicit owner adoption of an already-settled layer visibility. * This is used when a direct selection promotes a Context-owned dependency * into durable user state without redundantly re-running its lifecycle. */ adoptLayerVisibility( layerId, enabled, { origin = 'programmatic', adoptedFromSelection = false } = {}, ) { const entry = this.layers.get(layerId); const desiredState = Boolean(enabled); if (!entry || entry.enabled !== desiredState || entry.lifecycleUncertain) return false; cancelPendingLayerRestore( entry, origin, 'superseded-by-explicit-visibility-adoption', ); this._publishActivity({ type: 'status' }); this._notifyListeners({ type: 'visibility', layerId, enabled: desiredState, origin, intentEpoch: entry.visibilityIntentEpoch, adopted: true, adoptedFromSelection: Boolean(adoptedFromSelection), }); return true; } /** * Restore one finalized-registry layer independently. Parameters apply after * init and before enable, with a terminal envelope that never writes local * persistence. */ async restoreLayerState( layerId, { enabled = false, params = null } = {}, { origin = 'programmatic', signal = null } = {}, ) { if (!this._registrationsFinalized) throw new Error('Layer restore requires finalized registrations'); const entry = this.layers.get(layerId); const targetEnabled = Boolean(enabled); if (!entry) { return { layerId, targetEnabled, origin, phase: 'missing', settledEnabled: false, lifecycleState: 'missing', lifecycleUncertain: false, appliedOptions: {}, errorClass: 'UnknownLayer', persistenceWrite: false, succeeded: false, }; } const requestedParams = params && typeof params === 'object' && Object.keys(params).length ? cloneLayerParams(params) : null; let paramsEnvelope = null; let beforeEnableParams = null; if (requestedParams) { const paramsIntentEpoch = this._reserveLayerParamsIntent( layerId, requestedParams, origin, ); if (Number.isInteger(paramsIntentEpoch)) { if (targetEnabled && !entry.enabled) { beforeEnableParams = { params: requestedParams, origin, paramsIntentEpoch, result: null, }; } else { paramsEnvelope = this._applyLayerParamsIntent( layerId, requestedParams, { origin, paramsIntentEpoch, }, ); } } else { paramsEnvelope = { succeeded: false, error: paramsRejectedError(layerId), }; } } const handle = this._setEnabledWithIntent(layerId, targetEnabled, { origin, signal, beforeEnableParams, }); const requestedVisibility = await this._waitForVisibilityIntent( layerId, handle.intentEpoch, ); const authoritativeVisibility = requestedVisibility?.cancellationReason === 'superseded' ? await this._waitForAuthoritativeVisibilityIntent( layerId, handle.intentEpoch, ) : requestedVisibility; paramsEnvelope = paramsEnvelope || beforeEnableParams?.result; const state = this.getLayerLifecycleState(layerId); const paramsSucceeded = !requestedParams || paramsEnvelope?.succeeded === true; const visibilitySucceeded = authoritativeVisibility?.succeeded === true && state?.enabled === targetEnabled && state?.uncertain === false; const error = paramsEnvelope?.error || authoritativeVisibility?.error || requestedVisibility?.error || null; return { layerId, targetEnabled, origin, phase: requestedVisibility?.phase || (signal?.aborted ? 'reserved' : 'unknown'), completionPhase: authoritativeVisibility?.phase || null, settledEnabled: Boolean(state?.enabled), lifecycleState: state?.lifecycleState || 'missing', lifecycleUncertain: Boolean(state?.uncertain), appliedOptions: paramsSucceeded && requestedParams ? requestedParams : {}, intentEpoch: handle.intentEpoch, authoritativeIntentEpoch: authoritativeVisibility?.intentEpoch ?? handle.intentEpoch, authoritativeEnabled: authoritativeVisibility?.enabled ?? null, authoritativeOrigin: authoritativeVisibility?.origin ?? null, paramsIntentEpoch: paramsEnvelope?.paramsIntentEpoch ?? beforeEnableParams?.paramsIntentEpoch ?? null, cancellationReason: requestedVisibility?.cancellationReason || paramsEnvelope?.cancellationReason || null, successorIntentEpoch: requestedVisibility?.successorIntentEpoch ?? null, successorEnabled: requestedVisibility?.successorEnabled ?? null, successorOrigin: requestedVisibility?.successorOrigin ?? null, errorClass: error?.name || (signal?.aborted ? 'AbortError' : null), ...(error ? { error: String(error.message || error) } : {}), persistenceWrite: false, succeeded: paramsSucceeded && visibilitySucceeded, }; } /** * Read runtime parameters from a layer if it exposes `getParams()`. */ getLayerParams(layerId) { const entry = this.layers.get(layerId); if (!entry || !entry.module || typeof entry.module.getParams !== 'function') return null; try { const params = entry.module.getParams(); if (!params || typeof params !== 'object') return null; return cloneLayerParams(params); } catch (error) { console.warn(`[Data] ${layerId} getParams error:`, error); return null; } } /** * Destroy a single layer — calls its destroy() method if it has one, * then removes it from the manager. This is the proper cleanup path * that was previously missing. */ async destroyLayer(layerId) { const entry = this.layers.get(layerId); if (!entry || entry.destroying) return false; entry.destroying = true; this._invalidateRefresh(layerId, entry, 'layer-destroyed'); // Teardown becomes authoritative before the first await. Advancing the // intent epoch and aborting active work prevents a pending enable/update // from publishing a settled ON while the layer is being destroyed. The // synthetic successor metadata also lets Context restore rather than adopt. const teardownIntentEpoch = ++entry.visibilityIntentEpoch; entry.visibilityIntentEnabled = false; entry.visibilityIntentOrigin = 'teardown'; entry.latestQueuedAbsoluteIntent = { intentEpoch: teardownIntentEpoch, enabled: false, }; entry.clearVisibilityReservation = null; entry.activeVisibilityIntent?.controller.abort( SUPERSEDED_VISIBILITY_INTENT, ); entry.paramsIntentEpoch += 1; entry.paramsIntentOrigin = 'teardown'; await entry.toggleChain.catch(() => {}); // Let focused UI modes restore their exact pre-session state before any // dependency is irreversibly removed. The intent revocation above makes // this callback safe to run before the old queue drains. for (const callback of this._beforeDestroyListeners) { try { await callback({ type: 'before-destroy', layerId }); } catch (error) { console.warn('[Data] before-destroy listener error:', error); } } await entry.toggleChain.catch(() => {}); if (this.layers.get(layerId) !== entry) return false; entry.latestQueuedAbsoluteIntent = null; if (entry.enabled) { try { const disabled = await entry.module.disable(this.viewer); if (disabled === false) throw lifecycleRejectedError(layerId, 'destroy-disable'); } catch (e) { console.warn(`[Data] ${layerId} disable error:`, e); entry.destroying = false; entry.visibilityIntentEnabled = entry.enabled; this._settleLifecycle(entry); this._publishActivity({ type: 'status' }); return false; } if (entry.intervalId) { clearInterval(entry.intervalId); entry.intervalId = null; } entry.enabled = false; } if (typeof entry.module.destroy === 'function') { try { const destroyed = await entry.module.destroy(this.viewer); if (destroyed === false) throw lifecycleRejectedError(layerId, 'destroy'); } catch (e) { console.warn(`[Data] ${layerId} destroy error:`, e); entry.destroying = false; entry.visibilityIntentEnabled = entry.enabled; this._settleLifecycle(entry); this._publishActivity({ type: 'status' }); return false; } } this.layers.delete(layerId); this._qaLayerIds.delete(layerId); return true; } /** * Destroy all layers and clear the manager. * Should be called when the viewer is being torn down. */ async destroyAll() { this._publishActivity({ type: 'destroy-all' }); for (const layerId of [...this.layers.keys()]) { await this.destroyLayer(layerId); } } isEnabled(layerId) { const entry = this.layers.get(layerId); return entry ? entry.enabled : false; } /** Return authoritative settled visibility and the current lifecycle phase. */ getLayerLifecycleState(layerId) { const entry = this.layers.get(layerId); if (!entry) return null; return Object.freeze({ enabled: entry.enabled, lifecycleState: entry.lifecycleState, uncertain: entry.lifecycleUncertain, }); } getAll() { const result = []; for (const [id, entry] of this.layers) { result.push({ id, name: entry.module.name, icon: entry.module.icon, source: entry.module.source, showInTogglePanel: entry.module.showInTogglePanel !== false, enabled: entry.enabled, lifecycleState: entry.lifecycleState, lifecycleUncertain: entry.lifecycleUncertain, stats: this._normalizedStats(entry), }); } return result; } subscribe(callback) { if (typeof callback !== 'function') return () => {}; this._listeners.add(callback); return () => this._listeners.delete(callback); } /** * Subscribe to explicit setEnabled intent before it joins the lifecycle queue. * This lets a newer direct-user OFF request cancel older work before that work * can publish an intermediate settled visibility state. * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => void} callback * @returns {() => void} Unsubscribe function. */ subscribeVisibilityRequests(callback) { if (typeof callback !== 'function') return () => {}; this._visibilityRequestListeners.add(callback); return () => this._visibilityRequestListeners.delete(callback); } /** * Register a visibility guard evaluated before layer lifecycle work. Guards * may await bounded mode preparation and return a string or `{reason}` to * refuse a transition. * @param {(change:{type:string,layerId:string,enabled:boolean,origin:string}) => (string|object|null|void)} callback Guard. * @returns {() => void} Unsubscribe function. */ addVisibilityGuard(callback) { if (typeof callback !== 'function') return () => {}; this._visibilityGuards.add(callback); return () => this._visibilityGuards.delete(callback); } /** * Subscribe to the awaited pre-destroy lifecycle boundary. * @param {(change:{type:string,layerId:string}) => (void|Promise)} callback * @returns {() => void} Unsubscribe function. */ subscribeBeforeDestroy(callback) { if (typeof callback !== 'function') return () => {}; this._beforeDestroyListeners.add(callback); return () => this._beforeDestroyListeners.delete(callback); } _notifyListeners(change) { for (const callback of this._listeners) { try { callback(change); } catch (error) { console.warn('[Data] listener error:', error); } } } _notifyVisibilityRequest(change) { for (const callback of this._visibilityRequestListeners) { try { callback(change); } catch (error) { console.warn('[Data] visibility request listener error:', error); } } } async _visibilityBlockReason(change) { for (const callback of this._visibilityGuards) { try { const result = await callback(change); if (typeof result === 'string' && result.trim()) return result.trim(); if ( result && typeof result.reason === 'string' && result.reason.trim() ) { return result.reason.trim(); } } catch (error) { console.warn('[Data] visibility guard error:', error); } } return null; } /** Observe lifecycle activity without owning presentation or rendering. */ subscribeActivity(callback) { if (typeof callback !== 'function') return () => {}; this._activityListeners.add(callback); return () => this._activityListeners.delete(callback); } _publishActivity(change) { for (const callback of this._activityListeners) { try { callback(change); } catch (error) { console.warn('[Data] activity listener error:', error); } } } }