mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
refactor: move the layer state coordinator into its own module
layerState.js held both the share-link layer codec and the coordinator that restores layer choices into the running app and persists them to browser storage. Move the coordinator, unchanged, to layerStateCoordinator.js so the codec stays a pure data module that code outside the browser can use to write share links. The registry stays in layerState.js, where the token tooling reads it, and the aircraft barrel re-exports both.
This commit is contained in:
1 parent
a8b5860e9f
commit
9de2705eaf
6 files changed
+751
-725
No files matched your search
@@ -513,6 +513,7 @@
|
|||||||
"src/data/detectionPolicy.js",
|
"src/data/detectionPolicy.js",
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/militaryAwarenessEngine.js",
|
"src/data/militaryAwarenessEngine.js",
|
||||||
"src/data/scenePick.js",
|
"src/data/scenePick.js",
|
||||||
@@ -834,6 +835,7 @@
|
|||||||
"src/data/contactMatch.js",
|
"src/data/contactMatch.js",
|
||||||
"src/data/iconOrientation.js",
|
"src/data/iconOrientation.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/modelVisualAnchor.js",
|
"src/data/modelVisualAnchor.js",
|
||||||
"src/data/motionModel.js",
|
"src/data/motionModel.js",
|
||||||
@@ -1435,6 +1437,7 @@
|
|||||||
"src/data/issPass.js",
|
"src/data/issPass.js",
|
||||||
"src/data/labelArbiter.js",
|
"src/data/labelArbiter.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/data/lifecycle.js",
|
"src/data/lifecycle.js",
|
||||||
"src/data/localGeojson.js",
|
"src/data/localGeojson.js",
|
||||||
@@ -2611,6 +2614,7 @@
|
|||||||
"modules": [
|
"modules": [
|
||||||
"src/data/installationFeedback.js",
|
"src/data/installationFeedback.js",
|
||||||
"src/data/layerState.js",
|
"src/data/layerState.js",
|
||||||
|
"src/data/layerStateCoordinator.js",
|
||||||
"src/data/layerStateTokenReservations.json",
|
"src/data/layerStateTokenReservations.json",
|
||||||
"src/loadingFeedback.js",
|
"src/loadingFeedback.js",
|
||||||
"src/navigationPolicy.js",
|
"src/navigationPolicy.js",
|
||||||
|
|||||||
+2
-723
@@ -11,8 +11,6 @@ export const LAYER_STATE_TOKEN_ALPHABET =
|
|||||||
'0123456789abcdefghijklmnopqrstuvwxyz';
|
'0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
|
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
|
||||||
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||||
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
|
||||||
const PENDING_TRACKING_POLL_MS = 1_000;
|
|
||||||
/**
|
/**
|
||||||
* A tracking ID is a transponder address, not free text: 6 hex digits for an
|
* A tracking ID is a transponder address, not free text: 6 hex digits for an
|
||||||
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
|
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
|
||||||
@@ -433,12 +431,6 @@ const OPTION_GROUPS = Object.freeze({
|
|||||||
]),
|
]),
|
||||||
});
|
});
|
||||||
|
|
||||||
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
|
|
||||||
flights: 'selectedFlightsTrackingId',
|
|
||||||
military: 'selectedMilitaryTrackingId',
|
|
||||||
satellites: 'selectedSatTrackingId',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||||||
flights: Object.freeze({
|
flights: Object.freeze({
|
||||||
optionOwner: 'flights',
|
optionOwner: 'flights',
|
||||||
@@ -740,7 +732,7 @@ export function validateLayerStateAllocations(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const REGISTRY_BY_ID = new Map(
|
export const REGISTRY_BY_ID = new Map(
|
||||||
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
|
||||||
);
|
);
|
||||||
const REGISTRY_BY_TOKEN = new Map(
|
const REGISTRY_BY_TOKEN = new Map(
|
||||||
@@ -752,7 +744,7 @@ const OPTION_OWNER_IDS = Object.freeze([
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function optionSpecs(ownerId) {
|
export function optionSpecs(ownerId) {
|
||||||
return OPTION_GROUPS[ownerId] || [];
|
return OPTION_GROUPS[ownerId] || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,716 +1047,3 @@ export function layerOptionsForRestore(state, layerId) {
|
|||||||
if (!entry?.optionOwner) return null;
|
if (!entry?.optionOwner) return null;
|
||||||
return { ...normalizeLayerState(state).options[entry.optionOwner] };
|
return { ...normalizeLayerState(state).options[entry.optionOwner] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeStorage() {
|
|
||||||
try {
|
|
||||||
return globalThis.localStorage || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentLayerOutcome(dataManager, layerId) {
|
|
||||||
const state = dataManager.getLayerLifecycleState?.(layerId);
|
|
||||||
return {
|
|
||||||
settledEnabled: Boolean(state?.enabled),
|
|
||||||
lifecycleState: state?.lifecycleState || 'missing',
|
|
||||||
lifecycleUncertain: Boolean(state?.uncertain),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns durable user layer preferences independently from transient runtime
|
|
||||||
* choreography, and coordinates passive post-registration restoration.
|
|
||||||
*/
|
|
||||||
export class LayerStateCoordinator {
|
|
||||||
constructor(
|
|
||||||
dataManager,
|
|
||||||
shareLinkManager,
|
|
||||||
{
|
|
||||||
storage = safeStorage(),
|
|
||||||
restoreGate = null,
|
|
||||||
onDurableStateChange = null,
|
|
||||||
onTrackingRestoreStatus = null,
|
|
||||||
now = () => Date.now(),
|
|
||||||
// Injectable so the pending-window behavior is deterministically testable
|
|
||||||
// without sleeping out a 90 s expiry.
|
|
||||||
setTimer = (fn, ms) => setTimeout(fn, ms),
|
|
||||||
clearTimer = (handle) => clearTimeout(handle),
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
if (!dataManager?.registrationsFinalized) {
|
|
||||||
throw new Error(
|
|
||||||
'Layer state requires finalized data-layer registrations',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.dataManager = dataManager;
|
|
||||||
this.shareLinkManager = shareLinkManager || null;
|
|
||||||
this.storage = storage;
|
|
||||||
this.restoreGate = restoreGate;
|
|
||||||
this.onDurableStateChange = onDurableStateChange;
|
|
||||||
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
|
|
||||||
this.now = now;
|
|
||||||
this.setTimer = setTimer;
|
|
||||||
this.clearTimer = clearTimer;
|
|
||||||
this._durableState = createDefaultLayerState();
|
|
||||||
this._source = 'defaults';
|
|
||||||
this._destroyed = false;
|
|
||||||
this._restoreControllers = new Map();
|
|
||||||
this._shareCreatedAtMs = null;
|
|
||||||
this._trackingRestoreController = null;
|
|
||||||
this._trackingRestoreGeneration = 0;
|
|
||||||
this._pendingTrackingTimer = null;
|
|
||||||
this._pendingTrackingContext = null;
|
|
||||||
this._unsubscribe = this.dataManager.subscribe((change) =>
|
|
||||||
this._handleManagerChange(change),
|
|
||||||
);
|
|
||||||
this._unsubscribeVisibilityRequests =
|
|
||||||
this.dataManager.subscribeVisibilityRequests((change) =>
|
|
||||||
this._handleVisibilityRequest(change),
|
|
||||||
);
|
|
||||||
this.restorePromise = Promise.resolve([]);
|
|
||||||
this.lastRestoreResults = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
start({
|
|
||||||
shareLayerState = null,
|
|
||||||
allowLocalState = true,
|
|
||||||
shareCreatedAtMs = null,
|
|
||||||
} = {}) {
|
|
||||||
if (this._destroyed)
|
|
||||||
throw new Error('Layer-state coordinator is destroyed');
|
|
||||||
let selected = shareLayerState
|
|
||||||
? normalizeLayerState(shareLayerState)
|
|
||||||
: null;
|
|
||||||
if (selected) {
|
|
||||||
this._source = 'share';
|
|
||||||
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
|
||||||
? shareCreatedAtMs
|
|
||||||
: null;
|
|
||||||
} else if (allowLocalState) {
|
|
||||||
let stored = null;
|
|
||||||
try {
|
|
||||||
stored = parseStoredLayerState(
|
|
||||||
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* best effort */
|
|
||||||
}
|
|
||||||
if (stored) {
|
|
||||||
selected = stored;
|
|
||||||
this._source = 'local';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// A valid historical camera/style share with no v2 layer payload keeps
|
|
||||||
// the exact legacy default-layer behavior. It must not inherit an
|
|
||||||
// unrelated recipient's saved local layer preferences.
|
|
||||||
this._source = 'legacy-share';
|
|
||||||
}
|
|
||||||
this._durableState = selected || createDefaultLayerState();
|
|
||||||
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
|
||||||
this.getDurableState(),
|
|
||||||
);
|
|
||||||
this.shareLinkManager?.onLayerStateChange?.();
|
|
||||||
this._notifyDurableState();
|
|
||||||
if (!selected) return this.restorePromise;
|
|
||||||
this.restorePromise = this._restoreSelectedState(
|
|
||||||
this._source === 'share'
|
|
||||||
? LAYER_RESTORE_ORIGINS.share
|
|
||||||
: LAYER_RESTORE_ORIGINS.local,
|
|
||||||
);
|
|
||||||
return this.restorePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
get source() {
|
|
||||||
return this._source;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDurableState() {
|
|
||||||
return cloneLayerState(this._durableState);
|
|
||||||
}
|
|
||||||
|
|
||||||
_notifyDurableState() {
|
|
||||||
try {
|
|
||||||
this.onDurableStateChange?.(this.getDurableState());
|
|
||||||
} catch {
|
|
||||||
/* UI sync is best effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleVisibilityRequest(change) {
|
|
||||||
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
|
||||||
this._restoreControllers
|
|
||||||
.get(change.layerId)
|
|
||||||
?.abort('superseded-by-explicit-visibility');
|
|
||||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
|
||||||
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
|
||||||
for (const controller of this._restoreControllers.values())
|
|
||||||
controller.abort(reason);
|
|
||||||
this._revokePendingTrackingWatch(reason);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a pending shared Follow. Physical navigation may also clear only
|
|
||||||
* the exact passive selection, without writing recipient preferences.
|
|
||||||
*/
|
|
||||||
cancelPendingShareTracking(
|
|
||||||
reason = 'superseded-by-explicit-navigation',
|
|
||||||
{ clearSelection = false } = {},
|
|
||||||
) {
|
|
||||||
this._revokePendingTrackingWatch(reason);
|
|
||||||
if (!clearSelection) return false;
|
|
||||||
const selected = this._selectedShareTrackingTarget();
|
|
||||||
return selected ? this._passivelyClearTrackingSelection(selected) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
_handleManagerChange(change) {
|
|
||||||
if (!change || this._destroyed) return;
|
|
||||||
// Parameter and visibility ownership are independent. A newer explicit
|
|
||||||
// option request may replace passive share options, but it must not abort
|
|
||||||
// the same layer's visibility lifecycle.
|
|
||||||
if (change.type === 'params-requested') {
|
|
||||||
if (
|
|
||||||
isExplicitLayerStateOrigin(change.origin) &&
|
|
||||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
|
||||||
) {
|
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A layer that goes away takes its latch with it, at ANY origin — a
|
|
||||||
// programmatic disable or teardown never reaches the explicit-intent path
|
|
||||||
// below, so revoke here before that early return.
|
|
||||||
if (
|
|
||||||
change.type === 'visibility' &&
|
|
||||||
change.enabled === false &&
|
|
||||||
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
|
||||||
) {
|
|
||||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
|
||||||
}
|
|
||||||
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
|
||||||
if (change.type === 'visibility') {
|
|
||||||
this._restoreControllers
|
|
||||||
.get(change.layerId)
|
|
||||||
?.abort('superseded-by-explicit-visibility');
|
|
||||||
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
|
||||||
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
|
||||||
}
|
|
||||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
|
||||||
if (change.enabled) enabled.add(change.layerId);
|
|
||||||
else enabled.delete(change.layerId);
|
|
||||||
this._commitExplicit({
|
|
||||||
...this._durableState,
|
|
||||||
enabledLayerIds: [...enabled],
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (change.type !== 'params') return;
|
|
||||||
const entry = REGISTRY_BY_ID.get(change.layerId);
|
|
||||||
if (!entry?.optionOwner) return;
|
|
||||||
const ownerId = entry.optionOwner;
|
|
||||||
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
|
||||||
const requestedParams = change.requestedParams || {};
|
|
||||||
const trackingOptionKey =
|
|
||||||
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
|
||||||
let changed = false;
|
|
||||||
for (const spec of optionSpecs(ownerId)) {
|
|
||||||
// Only persist keys present in this explicit request. getLayerParams()
|
|
||||||
// can return a wider live snapshot containing transient or passively
|
|
||||||
// changed values that this user action did not own. Tracking is the one
|
|
||||||
// exception: an unrelated explicit option cancels a pending restoration
|
|
||||||
// in that family, so its wider live value (active ID or null) must replace
|
|
||||||
// the formerly durable pending ID instead of allowing reload resurrection.
|
|
||||||
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
|
||||||
const implicitTrackingSync =
|
|
||||||
!explicitlyRequested && spec.key === trackingOptionKey;
|
|
||||||
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
|
||||||
const value = spec.normalize(change.params[spec.key]);
|
|
||||||
if (value === null) {
|
|
||||||
if (implicitTrackingSync) {
|
|
||||||
nextOwnerOptions[spec.key] = null;
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
nextOwnerOptions[spec.key] = value;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
if (!changed) return;
|
|
||||||
this._commitExplicit({
|
|
||||||
...this._durableState,
|
|
||||||
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_commitExplicit(candidate) {
|
|
||||||
this._durableState = normalizeLayerState(candidate);
|
|
||||||
const serialized = serializeStoredLayerState(this._durableState);
|
|
||||||
try {
|
|
||||||
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
|
||||||
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* storage can be unavailable or quota-limited */
|
|
||||||
}
|
|
||||||
this.shareLinkManager?.onLayerStateChange?.();
|
|
||||||
this._notifyDurableState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async _waitForRestoreGate() {
|
|
||||||
if (!this.restoreGate) return;
|
|
||||||
await (typeof this.restoreGate === 'function'
|
|
||||||
? this.restoreGate()
|
|
||||||
: this.restoreGate);
|
|
||||||
}
|
|
||||||
|
|
||||||
async _restoreSelectedState(origin) {
|
|
||||||
for (const entry of LAYER_STATE_REGISTRY) {
|
|
||||||
this._restoreControllers.set(entry.id, new AbortController());
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await this._waitForRestoreGate();
|
|
||||||
const enabled = new Set(this._durableState.enabledLayerIds);
|
|
||||||
const settled = await Promise.allSettled(
|
|
||||||
LAYER_STATE_REGISTRY.map(async (entry) => {
|
|
||||||
const controller = this._restoreControllers.get(entry.id);
|
|
||||||
const targetEnabled = enabled.has(entry.id);
|
|
||||||
const options = layerOptionsForRestore(this._durableState, entry.id);
|
|
||||||
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
|
||||||
for (const trackingKey of Object.values(
|
|
||||||
TRACKING_OPTION_KEY_BY_LAYER,
|
|
||||||
)) {
|
|
||||||
delete options[trackingKey];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this._destroyed || controller?.signal.aborted) {
|
|
||||||
return {
|
|
||||||
layerId: entry.id,
|
|
||||||
targetEnabled,
|
|
||||||
origin,
|
|
||||||
phase: 'reserved',
|
|
||||||
...currentLayerOutcome(this.dataManager, entry.id),
|
|
||||||
appliedOptions: {},
|
|
||||||
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
|
||||||
errorClass: 'cancelled',
|
|
||||||
persistenceWrite: false,
|
|
||||||
succeeded: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Reserve passive option state before any asynchronous lifecycle work.
|
|
||||||
// A later explicit params intent then wins on its own lane without
|
|
||||||
// cancelling or being overwritten by the visibility restore.
|
|
||||||
const paramsSucceeded =
|
|
||||||
!options ||
|
|
||||||
Object.keys(options).length === 0 ||
|
|
||||||
this.dataManager.setLayerParams(entry.id, options, { origin });
|
|
||||||
return this.dataManager
|
|
||||||
.restoreLayerState(
|
|
||||||
entry.id,
|
|
||||||
{
|
|
||||||
enabled: targetEnabled,
|
|
||||||
params: null,
|
|
||||||
},
|
|
||||||
{ origin, signal: controller.signal },
|
|
||||||
)
|
|
||||||
.then((result) => ({
|
|
||||||
...result,
|
|
||||||
appliedOptions: paramsSucceeded && options ? options : {},
|
|
||||||
errorClass: paramsSucceeded
|
|
||||||
? result.errorClass
|
|
||||||
: 'ParamsRejected',
|
|
||||||
succeeded: paramsSucceeded && result.succeeded,
|
|
||||||
}));
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
this.lastRestoreResults = settled.map((result, index) => {
|
|
||||||
if (result.status === 'fulfilled') return result.value;
|
|
||||||
const entry = LAYER_STATE_REGISTRY[index];
|
|
||||||
return {
|
|
||||||
layerId: entry.id,
|
|
||||||
targetEnabled: enabled.has(entry.id),
|
|
||||||
origin,
|
|
||||||
phase: 'coordinator',
|
|
||||||
...currentLayerOutcome(this.dataManager, entry.id),
|
|
||||||
appliedOptions: {},
|
|
||||||
cancellationReason: null,
|
|
||||||
errorClass: result.reason?.name || 'Error',
|
|
||||||
error: String(result.reason?.message || result.reason),
|
|
||||||
persistenceWrite: false,
|
|
||||||
succeeded: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return this.lastRestoreResults.map((result) => ({ ...result }));
|
|
||||||
} finally {
|
|
||||||
this._restoreControllers.clear();
|
|
||||||
this._notifyDurableState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_selectedShareTrackingTarget() {
|
|
||||||
if (this._source !== 'share') return null;
|
|
||||||
for (const [layerId, policy] of Object.entries(
|
|
||||||
SHARE_TRACKING_RESTORE_POLICIES,
|
|
||||||
)) {
|
|
||||||
const targetId =
|
|
||||||
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
|
||||||
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
|
||||||
return { layerId, targetId, ...policy };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
_passivelyClearTrackingSelection(selected) {
|
|
||||||
const current =
|
|
||||||
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
|
||||||
if (String(current) !== String(selected.targetId)) return false;
|
|
||||||
const ownerOptions = {
|
|
||||||
...this._durableState.options[selected.optionOwner],
|
|
||||||
[selected.optionKey]: null,
|
|
||||||
};
|
|
||||||
this._durableState = normalizeLayerState({
|
|
||||||
...this._durableState,
|
|
||||||
options: {
|
|
||||||
...this._durableState.options,
|
|
||||||
[selected.optionOwner]: ownerOptions,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.dataManager.setLayerParams?.(
|
|
||||||
selected.layerId,
|
|
||||||
{ [selected.optionKey]: null },
|
|
||||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
|
||||||
);
|
|
||||||
this.shareLinkManager?.onLayerStateChange?.();
|
|
||||||
this._notifyDurableState();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `atMs` is the moment the subject was first found ABSENT, not the moment the
|
|
||||||
* verdict is delivered. Waiting out the pending window must not by itself
|
|
||||||
* push a fresh link into the "expired" wording — that word describes the
|
|
||||||
* SHARE's age, not how long this client watched for the subject.
|
|
||||||
*/
|
|
||||||
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
|
|
||||||
const copiedAt = this._shareCreatedAtMs;
|
|
||||||
if (!Number.isFinite(copiedAt)) return 'unavailable';
|
|
||||||
const ageMs = atMs - copiedAt;
|
|
||||||
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the owning layer currently follows the shared subject. */
|
|
||||||
_trackingTargetLatched(selected) {
|
|
||||||
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
|
||||||
const active = params?.[selected.optionKey];
|
|
||||||
return (
|
|
||||||
active !== null &&
|
|
||||||
active !== undefined &&
|
|
||||||
String(active) === String(selected.targetId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stop watching a pending shared subject without deciding its fate. */
|
|
||||||
_cancelPendingTrackingWatch() {
|
|
||||||
if (this._pendingTrackingTimer !== null)
|
|
||||||
this.clearTimer(this._pendingTrackingTimer);
|
|
||||||
this._pendingTrackingTimer = null;
|
|
||||||
const pending = this._pendingTrackingContext;
|
|
||||||
if (pending?.signal && pending.abortHandler) {
|
|
||||||
pending.signal.removeEventListener('abort', pending.abortHandler);
|
|
||||||
pending.abortHandler = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
|
||||||
_publishTrackingRestoreStatus(status) {
|
|
||||||
try {
|
|
||||||
this.onTrackingRestoreStatus?.(status);
|
|
||||||
} catch {
|
|
||||||
/* status UI is best effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke a pending shared Follow wholesale.
|
|
||||||
*
|
|
||||||
* The watch and the LAYER's own deferred-restore latch are two halves of one
|
|
||||||
* mechanism, so they must die together. Aborting only the restore controller
|
|
||||||
* left the timer alive: the controller has already settled by the time the
|
|
||||||
* watch exists, so the abort was a no-op and the orphaned timer went on to
|
|
||||||
* announce "Shared … unavailable" for a subject whose latch had been
|
|
||||||
* cancelled — a notice about work no longer being attempted.
|
|
||||||
*/
|
|
||||||
_revokePendingTrackingWatch(reason) {
|
|
||||||
this._trackingRestoreController?.abort(reason);
|
|
||||||
this._trackingRestoreGeneration += 1;
|
|
||||||
this._cancelPendingTrackingWatch();
|
|
||||||
const pending = this._pendingTrackingContext;
|
|
||||||
this._pendingTrackingContext = null;
|
|
||||||
if (pending) {
|
|
||||||
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
|
|
||||||
origin: LAYER_RESTORE_ORIGINS.share,
|
|
||||||
reason: String(reason || 'cancelled'),
|
|
||||||
});
|
|
||||||
this._publishTrackingRestoreStatus({
|
|
||||||
...pending.probe,
|
|
||||||
...pending.selected,
|
|
||||||
status: 'cancelled',
|
|
||||||
classification: 'cancelled',
|
|
||||||
reason: String(reason || 'cancelled'),
|
|
||||||
cleared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
|
|
||||||
*
|
|
||||||
* A recipient's first authoritative refresh routinely lands without a given
|
|
||||||
* contact — the feed is polled, coverage is partial, and rendering trails the
|
|
||||||
* snapshot by a poll. Reload-from-local already survives this: its restore
|
|
||||||
* arms the layer's own deferred-restore latch, which re-attempts on every
|
|
||||||
* later poll. The shared path used to decide on that single refresh, clear
|
|
||||||
* the subject from durable state AND from the URL, then post a failure notice
|
|
||||||
* seconds into startup — so the same link healed on reload but never on the
|
|
||||||
* share.
|
|
||||||
*
|
|
||||||
* Arm the SAME latch, then watch it in the background: the caller is never
|
|
||||||
* blocked (startup must not wait out a 90 s window before it may write the
|
|
||||||
* URL again), and the terminal verdict is deferred until the source-specific
|
|
||||||
* window has genuinely expired. The existing wordings are unchanged.
|
|
||||||
*/
|
|
||||||
async _beginPendingTrackingRestore(selected, probe, signal = null) {
|
|
||||||
const generation = this._trackingRestoreGeneration;
|
|
||||||
const absentAtMs = this.now();
|
|
||||||
// Arm the layer's deferred-restore latch under the passive share origin, so
|
|
||||||
// it re-attempts each poll and never rewrites recipient preferences.
|
|
||||||
let armed = false;
|
|
||||||
let armError = null;
|
|
||||||
try {
|
|
||||||
armed =
|
|
||||||
(await this.dataManager.setLayerParams?.(
|
|
||||||
selected.layerId,
|
|
||||||
{ [selected.optionKey]: selected.targetId },
|
|
||||||
{ origin: LAYER_RESTORE_ORIGINS.share },
|
|
||||||
)) === true;
|
|
||||||
} catch (error) {
|
|
||||||
armError = error;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
this._destroyed ||
|
|
||||||
generation !== this._trackingRestoreGeneration ||
|
|
||||||
signal?.aborted
|
|
||||||
) {
|
|
||||||
if (armed) {
|
|
||||||
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
|
||||||
origin: LAYER_RESTORE_ORIGINS.share,
|
|
||||||
reason: String(signal?.reason || 'superseded'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...probe,
|
|
||||||
...selected,
|
|
||||||
status: 'cancelled',
|
|
||||||
classification: 'cancelled',
|
|
||||||
reason: String(signal?.reason || 'superseded'),
|
|
||||||
cleared: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!armed) {
|
|
||||||
const terminal = {
|
|
||||||
...probe,
|
|
||||||
...selected,
|
|
||||||
status: 'source-unavailable',
|
|
||||||
classification: 'source-unavailable',
|
|
||||||
reason: String(
|
|
||||||
armError?.message || armError || 'tracking restore latch rejected',
|
|
||||||
),
|
|
||||||
cleared: this._passivelyClearTrackingSelection(selected),
|
|
||||||
};
|
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
|
||||||
return terminal;
|
|
||||||
}
|
|
||||||
const deadline = this.now() + selected.expiryWindowMs;
|
|
||||||
const settle = (terminal) => {
|
|
||||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
|
||||||
this._cancelPendingTrackingWatch();
|
|
||||||
this._pendingTrackingContext = null;
|
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
|
||||||
};
|
|
||||||
const poll = () => {
|
|
||||||
this._pendingTrackingTimer = null;
|
|
||||||
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
|
||||||
return;
|
|
||||||
// The layer that owns the latch may have gone away since the last tick
|
|
||||||
// (disable, teardown, replacement). There is nothing left attempting this
|
|
||||||
// restore, so abandon it silently rather than announcing a verdict.
|
|
||||||
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
|
|
||||||
this._revokePendingTrackingWatch('owner-layer-disabled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._trackingTargetLatched(selected)) {
|
|
||||||
settle({
|
|
||||||
...probe,
|
|
||||||
...selected,
|
|
||||||
status: 'found',
|
|
||||||
classification: 'followed',
|
|
||||||
cleared: false,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.now() >= deadline) {
|
|
||||||
// The window really has elapsed — only now does the verdict apply.
|
|
||||||
const classification =
|
|
||||||
probe.status === 'missing'
|
|
||||||
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
|
||||||
: 'source-unavailable';
|
|
||||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
|
||||||
settle({ ...probe, ...selected, classification, cleared });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._pendingTrackingTimer = this.setTimer(
|
|
||||||
poll,
|
|
||||||
PENDING_TRACKING_POLL_MS,
|
|
||||||
);
|
|
||||||
this._pendingTrackingTimer?.unref?.();
|
|
||||||
};
|
|
||||||
this._cancelPendingTrackingWatch();
|
|
||||||
const pending = {
|
|
||||||
...probe,
|
|
||||||
...selected,
|
|
||||||
status: 'pending',
|
|
||||||
classification: 'pending',
|
|
||||||
cleared: false,
|
|
||||||
};
|
|
||||||
const pendingContext = {
|
|
||||||
generation,
|
|
||||||
selected,
|
|
||||||
probe,
|
|
||||||
signal,
|
|
||||||
abortHandler: null,
|
|
||||||
};
|
|
||||||
if (signal) {
|
|
||||||
pendingContext.abortHandler = () => {
|
|
||||||
if (this._pendingTrackingContext?.generation !== generation) return;
|
|
||||||
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
|
||||||
};
|
|
||||||
signal.addEventListener('abort', pendingContext.abortHandler, {
|
|
||||||
once: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
this._pendingTrackingContext = pendingContext;
|
|
||||||
this._publishTrackingRestoreStatus(pending);
|
|
||||||
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
|
||||||
this._pendingTrackingTimer?.unref?.();
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh and restore the one shareable tracked target after destination
|
|
||||||
* camera and ordinary layer restoration have settled.
|
|
||||||
*/
|
|
||||||
async restoreShareTrackingSelection({ signal = null } = {}) {
|
|
||||||
const selected = this._selectedShareTrackingTarget();
|
|
||||||
if (!selected || this._destroyed)
|
|
||||||
return { status: 'skipped', reason: 'no-shared-target' };
|
|
||||||
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
|
||||||
const controller = new AbortController();
|
|
||||||
const combinedSignal = signal
|
|
||||||
? AbortSignal.any([signal, controller.signal])
|
|
||||||
: controller.signal;
|
|
||||||
this._trackingRestoreController = controller;
|
|
||||||
const generation = ++this._trackingRestoreGeneration;
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await this.dataManager.resolveLayerTrackingTarget(
|
|
||||||
selected.layerId,
|
|
||||||
selected.targetId,
|
|
||||||
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
result = combinedSignal.aborted
|
|
||||||
? {
|
|
||||||
status: 'cancelled',
|
|
||||||
reason: String(combinedSignal.reason || 'aborted'),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
status: 'source-unavailable',
|
|
||||||
reason: String(error?.message || error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
generation !== this._trackingRestoreGeneration ||
|
|
||||||
this._destroyed ||
|
|
||||||
combinedSignal.aborted
|
|
||||||
) {
|
|
||||||
if (this._trackingRestoreController === controller)
|
|
||||||
this._trackingRestoreController = null;
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
status: 'cancelled',
|
|
||||||
reason: String(combinedSignal.reason || 'superseded'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (this._trackingRestoreController === controller)
|
|
||||||
this._trackingRestoreController = null;
|
|
||||||
|
|
||||||
if (result.status === 'found') {
|
|
||||||
const terminal = {
|
|
||||||
...result,
|
|
||||||
...selected,
|
|
||||||
classification: 'followed',
|
|
||||||
cleared: false,
|
|
||||||
};
|
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
|
||||||
return terminal;
|
|
||||||
}
|
|
||||||
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
|
||||||
return result;
|
|
||||||
|
|
||||||
// A subject that is simply not here YET is not a subject that is gone. Hold
|
|
||||||
// it on the layer's own deferred-restore latch for its source-specific
|
|
||||||
// window before any verdict is reached or shown. `unsupported` layers have
|
|
||||||
// no latch to arm, so they still decide immediately.
|
|
||||||
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
|
||||||
return this._beginPendingTrackingRestore(
|
|
||||||
selected,
|
|
||||||
result,
|
|
||||||
combinedSignal,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const classification =
|
|
||||||
result.status === 'missing'
|
|
||||||
? this._classifyMissingTrackingTarget(selected)
|
|
||||||
: 'source-unavailable';
|
|
||||||
const cleared = this._passivelyClearTrackingSelection(selected);
|
|
||||||
const terminal = { ...result, ...selected, classification, cleared };
|
|
||||||
this._publishTrackingRestoreStatus(terminal);
|
|
||||||
return terminal;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if (this._destroyed) return;
|
|
||||||
this._destroyed = true;
|
|
||||||
for (const controller of this._restoreControllers.values())
|
|
||||||
controller.abort('coordinator-destroyed');
|
|
||||||
this._restoreControllers.clear();
|
|
||||||
this._revokePendingTrackingWatch('coordinator-destroyed');
|
|
||||||
this._trackingRestoreController = null;
|
|
||||||
this._unsubscribe?.();
|
|
||||||
this._unsubscribe = null;
|
|
||||||
this._unsubscribeVisibilityRequests?.();
|
|
||||||
this._unsubscribeVisibilityRequests = null;
|
|
||||||
this.shareLinkManager?.setLayerStateProvider?.(null);
|
|
||||||
this.onDurableStateChange = null;
|
|
||||||
this.onTrackingRestoreStatus = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
LAYER_STATE_STORAGE_KEY,
|
LAYER_STATE_STORAGE_KEY,
|
||||||
LAYER_STATE_TOKEN_ALPHABET,
|
LAYER_STATE_TOKEN_ALPHABET,
|
||||||
LAYER_STATE_TOKEN_RESERVATIONS,
|
LAYER_STATE_TOKEN_RESERVATIONS,
|
||||||
LayerStateCoordinator,
|
|
||||||
REGISTERED_LAYER_IDS,
|
REGISTERED_LAYER_IDS,
|
||||||
SHARE_TRACKING_RESTORE_POLICIES,
|
SHARE_TRACKING_RESTORE_POLICIES,
|
||||||
createDefaultLayerState,
|
createDefaultLayerState,
|
||||||
@@ -24,6 +23,7 @@ import {
|
|||||||
validateLayerStateAllocations,
|
validateLayerStateAllocations,
|
||||||
validateLayerStateRegistry,
|
validateLayerStateRegistry,
|
||||||
} from './layerState.js';
|
} from './layerState.js';
|
||||||
|
import { LayerStateCoordinator } from './layerStateCoordinator.js';
|
||||||
import radioLayer from './radio.js';
|
import radioLayer from './radio.js';
|
||||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,742 @@
|
|||||||
|
/**
|
||||||
|
* Applies layer state to the running application: restores shared and stored
|
||||||
|
* layer choices and persists changes to browser storage.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
LAYER_RESTORE_ORIGINS,
|
||||||
|
LAYER_STATE_REGISTRY,
|
||||||
|
LAYER_STATE_STORAGE_KEY,
|
||||||
|
REGISTRY_BY_ID,
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES,
|
||||||
|
cloneLayerState,
|
||||||
|
createDefaultLayerState,
|
||||||
|
isExplicitLayerStateOrigin,
|
||||||
|
layerOptionsForRestore,
|
||||||
|
normalizeLayerState,
|
||||||
|
optionSpecs,
|
||||||
|
parseStoredLayerState,
|
||||||
|
serializeStoredLayerState,
|
||||||
|
} from './layerState.js';
|
||||||
|
|
||||||
|
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
|
||||||
|
const PENDING_TRACKING_POLL_MS = 1_000;
|
||||||
|
|
||||||
|
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
|
||||||
|
flights: 'selectedFlightsTrackingId',
|
||||||
|
military: 'selectedMilitaryTrackingId',
|
||||||
|
satellites: 'selectedSatTrackingId',
|
||||||
|
});
|
||||||
|
|
||||||
|
function safeStorage() {
|
||||||
|
try {
|
||||||
|
return globalThis.localStorage || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentLayerOutcome(dataManager, layerId) {
|
||||||
|
const state = dataManager.getLayerLifecycleState?.(layerId);
|
||||||
|
return {
|
||||||
|
settledEnabled: Boolean(state?.enabled),
|
||||||
|
lifecycleState: state?.lifecycleState || 'missing',
|
||||||
|
lifecycleUncertain: Boolean(state?.uncertain),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns durable user layer preferences independently from transient runtime
|
||||||
|
* choreography, and coordinates passive post-registration restoration.
|
||||||
|
*/
|
||||||
|
export class LayerStateCoordinator {
|
||||||
|
constructor(
|
||||||
|
dataManager,
|
||||||
|
shareLinkManager,
|
||||||
|
{
|
||||||
|
storage = safeStorage(),
|
||||||
|
restoreGate = null,
|
||||||
|
onDurableStateChange = null,
|
||||||
|
onTrackingRestoreStatus = null,
|
||||||
|
now = () => Date.now(),
|
||||||
|
// Injectable so the pending-window behavior is deterministically testable
|
||||||
|
// without sleeping out a 90 s expiry.
|
||||||
|
setTimer = (fn, ms) => setTimeout(fn, ms),
|
||||||
|
clearTimer = (handle) => clearTimeout(handle),
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
if (!dataManager?.registrationsFinalized) {
|
||||||
|
throw new Error(
|
||||||
|
'Layer state requires finalized data-layer registrations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.dataManager = dataManager;
|
||||||
|
this.shareLinkManager = shareLinkManager || null;
|
||||||
|
this.storage = storage;
|
||||||
|
this.restoreGate = restoreGate;
|
||||||
|
this.onDurableStateChange = onDurableStateChange;
|
||||||
|
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
|
||||||
|
this.now = now;
|
||||||
|
this.setTimer = setTimer;
|
||||||
|
this.clearTimer = clearTimer;
|
||||||
|
this._durableState = createDefaultLayerState();
|
||||||
|
this._source = 'defaults';
|
||||||
|
this._destroyed = false;
|
||||||
|
this._restoreControllers = new Map();
|
||||||
|
this._shareCreatedAtMs = null;
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
this._trackingRestoreGeneration = 0;
|
||||||
|
this._pendingTrackingTimer = null;
|
||||||
|
this._pendingTrackingContext = null;
|
||||||
|
this._unsubscribe = this.dataManager.subscribe((change) =>
|
||||||
|
this._handleManagerChange(change),
|
||||||
|
);
|
||||||
|
this._unsubscribeVisibilityRequests =
|
||||||
|
this.dataManager.subscribeVisibilityRequests((change) =>
|
||||||
|
this._handleVisibilityRequest(change),
|
||||||
|
);
|
||||||
|
this.restorePromise = Promise.resolve([]);
|
||||||
|
this.lastRestoreResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
start({
|
||||||
|
shareLayerState = null,
|
||||||
|
allowLocalState = true,
|
||||||
|
shareCreatedAtMs = null,
|
||||||
|
} = {}) {
|
||||||
|
if (this._destroyed)
|
||||||
|
throw new Error('Layer-state coordinator is destroyed');
|
||||||
|
let selected = shareLayerState
|
||||||
|
? normalizeLayerState(shareLayerState)
|
||||||
|
: null;
|
||||||
|
if (selected) {
|
||||||
|
this._source = 'share';
|
||||||
|
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
|
||||||
|
? shareCreatedAtMs
|
||||||
|
: null;
|
||||||
|
} else if (allowLocalState) {
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
stored = parseStoredLayerState(
|
||||||
|
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
if (stored) {
|
||||||
|
selected = stored;
|
||||||
|
this._source = 'local';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// A valid historical camera/style share with no v2 layer payload keeps
|
||||||
|
// the exact legacy default-layer behavior. It must not inherit an
|
||||||
|
// unrelated recipient's saved local layer preferences.
|
||||||
|
this._source = 'legacy-share';
|
||||||
|
}
|
||||||
|
this._durableState = selected || createDefaultLayerState();
|
||||||
|
this.shareLinkManager?.setLayerStateProvider?.(() =>
|
||||||
|
this.getDurableState(),
|
||||||
|
);
|
||||||
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
|
this._notifyDurableState();
|
||||||
|
if (!selected) return this.restorePromise;
|
||||||
|
this.restorePromise = this._restoreSelectedState(
|
||||||
|
this._source === 'share'
|
||||||
|
? LAYER_RESTORE_ORIGINS.share
|
||||||
|
: LAYER_RESTORE_ORIGINS.local,
|
||||||
|
);
|
||||||
|
return this.restorePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
get source() {
|
||||||
|
return this._source;
|
||||||
|
}
|
||||||
|
|
||||||
|
getDurableState() {
|
||||||
|
return cloneLayerState(this._durableState);
|
||||||
|
}
|
||||||
|
|
||||||
|
_notifyDurableState() {
|
||||||
|
try {
|
||||||
|
this.onDurableStateChange?.(this.getDurableState());
|
||||||
|
} catch {
|
||||||
|
/* UI sync is best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleVisibilityRequest(change) {
|
||||||
|
if (!isExplicitLayerStateOrigin(change?.origin)) return;
|
||||||
|
this._restoreControllers
|
||||||
|
.get(change.layerId)
|
||||||
|
?.abort('superseded-by-explicit-visibility');
|
||||||
|
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke every passive restore before explicit navigation can be reclaimed. */
|
||||||
|
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
|
||||||
|
for (const controller of this._restoreControllers.values())
|
||||||
|
controller.abort(reason);
|
||||||
|
this._revokePendingTrackingWatch(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a pending shared Follow. Physical navigation may also clear only
|
||||||
|
* the exact passive selection, without writing recipient preferences.
|
||||||
|
*/
|
||||||
|
cancelPendingShareTracking(
|
||||||
|
reason = 'superseded-by-explicit-navigation',
|
||||||
|
{ clearSelection = false } = {},
|
||||||
|
) {
|
||||||
|
this._revokePendingTrackingWatch(reason);
|
||||||
|
if (!clearSelection) return false;
|
||||||
|
const selected = this._selectedShareTrackingTarget();
|
||||||
|
return selected ? this._passivelyClearTrackingSelection(selected) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleManagerChange(change) {
|
||||||
|
if (!change || this._destroyed) return;
|
||||||
|
// Parameter and visibility ownership are independent. A newer explicit
|
||||||
|
// option request may replace passive share options, but it must not abort
|
||||||
|
// the same layer's visibility lifecycle.
|
||||||
|
if (change.type === 'params-requested') {
|
||||||
|
if (
|
||||||
|
isExplicitLayerStateOrigin(change.origin) &&
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||||
|
) {
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-explicit-params');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A layer that goes away takes its latch with it, at ANY origin — a
|
||||||
|
// programmatic disable or teardown never reaches the explicit-intent path
|
||||||
|
// below, so revoke here before that early return.
|
||||||
|
if (
|
||||||
|
change.type === 'visibility' &&
|
||||||
|
change.enabled === false &&
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
|
||||||
|
) {
|
||||||
|
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||||
|
}
|
||||||
|
if (!isExplicitLayerStateOrigin(change.origin)) return;
|
||||||
|
if (change.type === 'visibility') {
|
||||||
|
this._restoreControllers
|
||||||
|
.get(change.layerId)
|
||||||
|
?.abort('superseded-by-explicit-visibility');
|
||||||
|
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
|
||||||
|
}
|
||||||
|
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||||
|
if (change.enabled) enabled.add(change.layerId);
|
||||||
|
else enabled.delete(change.layerId);
|
||||||
|
this._commitExplicit({
|
||||||
|
...this._durableState,
|
||||||
|
enabledLayerIds: [...enabled],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (change.type !== 'params') return;
|
||||||
|
const entry = REGISTRY_BY_ID.get(change.layerId);
|
||||||
|
if (!entry?.optionOwner) return;
|
||||||
|
const ownerId = entry.optionOwner;
|
||||||
|
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
|
||||||
|
const requestedParams = change.requestedParams || {};
|
||||||
|
const trackingOptionKey =
|
||||||
|
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
|
||||||
|
let changed = false;
|
||||||
|
for (const spec of optionSpecs(ownerId)) {
|
||||||
|
// Only persist keys present in this explicit request. getLayerParams()
|
||||||
|
// can return a wider live snapshot containing transient or passively
|
||||||
|
// changed values that this user action did not own. Tracking is the one
|
||||||
|
// exception: an unrelated explicit option cancels a pending restoration
|
||||||
|
// in that family, so its wider live value (active ID or null) must replace
|
||||||
|
// the formerly durable pending ID instead of allowing reload resurrection.
|
||||||
|
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
|
||||||
|
const implicitTrackingSync =
|
||||||
|
!explicitlyRequested && spec.key === trackingOptionKey;
|
||||||
|
if (!explicitlyRequested && !implicitTrackingSync) continue;
|
||||||
|
const value = spec.normalize(change.params[spec.key]);
|
||||||
|
if (value === null) {
|
||||||
|
if (implicitTrackingSync) {
|
||||||
|
nextOwnerOptions[spec.key] = null;
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (spec.defaultValue !== null || change.params[spec.key] !== null)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextOwnerOptions[spec.key] = value;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
this._commitExplicit({
|
||||||
|
...this._durableState,
|
||||||
|
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_commitExplicit(candidate) {
|
||||||
|
this._durableState = normalizeLayerState(candidate);
|
||||||
|
const serialized = serializeStoredLayerState(this._durableState);
|
||||||
|
try {
|
||||||
|
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
|
||||||
|
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* storage can be unavailable or quota-limited */
|
||||||
|
}
|
||||||
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
|
this._notifyDurableState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _waitForRestoreGate() {
|
||||||
|
if (!this.restoreGate) return;
|
||||||
|
await (typeof this.restoreGate === 'function'
|
||||||
|
? this.restoreGate()
|
||||||
|
: this.restoreGate);
|
||||||
|
}
|
||||||
|
|
||||||
|
async _restoreSelectedState(origin) {
|
||||||
|
for (const entry of LAYER_STATE_REGISTRY) {
|
||||||
|
this._restoreControllers.set(entry.id, new AbortController());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this._waitForRestoreGate();
|
||||||
|
const enabled = new Set(this._durableState.enabledLayerIds);
|
||||||
|
const settled = await Promise.allSettled(
|
||||||
|
LAYER_STATE_REGISTRY.map(async (entry) => {
|
||||||
|
const controller = this._restoreControllers.get(entry.id);
|
||||||
|
const targetEnabled = enabled.has(entry.id);
|
||||||
|
const options = layerOptionsForRestore(this._durableState, entry.id);
|
||||||
|
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
|
||||||
|
for (const trackingKey of Object.values(
|
||||||
|
TRACKING_OPTION_KEY_BY_LAYER,
|
||||||
|
)) {
|
||||||
|
delete options[trackingKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this._destroyed || controller?.signal.aborted) {
|
||||||
|
return {
|
||||||
|
layerId: entry.id,
|
||||||
|
targetEnabled,
|
||||||
|
origin,
|
||||||
|
phase: 'reserved',
|
||||||
|
...currentLayerOutcome(this.dataManager, entry.id),
|
||||||
|
appliedOptions: {},
|
||||||
|
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
|
||||||
|
errorClass: 'cancelled',
|
||||||
|
persistenceWrite: false,
|
||||||
|
succeeded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Reserve passive option state before any asynchronous lifecycle work.
|
||||||
|
// A later explicit params intent then wins on its own lane without
|
||||||
|
// cancelling or being overwritten by the visibility restore.
|
||||||
|
const paramsSucceeded =
|
||||||
|
!options ||
|
||||||
|
Object.keys(options).length === 0 ||
|
||||||
|
this.dataManager.setLayerParams(entry.id, options, { origin });
|
||||||
|
return this.dataManager
|
||||||
|
.restoreLayerState(
|
||||||
|
entry.id,
|
||||||
|
{
|
||||||
|
enabled: targetEnabled,
|
||||||
|
params: null,
|
||||||
|
},
|
||||||
|
{ origin, signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((result) => ({
|
||||||
|
...result,
|
||||||
|
appliedOptions: paramsSucceeded && options ? options : {},
|
||||||
|
errorClass: paramsSucceeded
|
||||||
|
? result.errorClass
|
||||||
|
: 'ParamsRejected',
|
||||||
|
succeeded: paramsSucceeded && result.succeeded,
|
||||||
|
}));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.lastRestoreResults = settled.map((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') return result.value;
|
||||||
|
const entry = LAYER_STATE_REGISTRY[index];
|
||||||
|
return {
|
||||||
|
layerId: entry.id,
|
||||||
|
targetEnabled: enabled.has(entry.id),
|
||||||
|
origin,
|
||||||
|
phase: 'coordinator',
|
||||||
|
...currentLayerOutcome(this.dataManager, entry.id),
|
||||||
|
appliedOptions: {},
|
||||||
|
cancellationReason: null,
|
||||||
|
errorClass: result.reason?.name || 'Error',
|
||||||
|
error: String(result.reason?.message || result.reason),
|
||||||
|
persistenceWrite: false,
|
||||||
|
succeeded: false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return this.lastRestoreResults.map((result) => ({ ...result }));
|
||||||
|
} finally {
|
||||||
|
this._restoreControllers.clear();
|
||||||
|
this._notifyDurableState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectedShareTrackingTarget() {
|
||||||
|
if (this._source !== 'share') return null;
|
||||||
|
for (const [layerId, policy] of Object.entries(
|
||||||
|
SHARE_TRACKING_RESTORE_POLICIES,
|
||||||
|
)) {
|
||||||
|
const targetId =
|
||||||
|
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
|
||||||
|
if (targetId !== null && targetId !== undefined && targetId !== '') {
|
||||||
|
return { layerId, targetId, ...policy };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_passivelyClearTrackingSelection(selected) {
|
||||||
|
const current =
|
||||||
|
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
|
||||||
|
if (String(current) !== String(selected.targetId)) return false;
|
||||||
|
const ownerOptions = {
|
||||||
|
...this._durableState.options[selected.optionOwner],
|
||||||
|
[selected.optionKey]: null,
|
||||||
|
};
|
||||||
|
this._durableState = normalizeLayerState({
|
||||||
|
...this._durableState,
|
||||||
|
options: {
|
||||||
|
...this._durableState.options,
|
||||||
|
[selected.optionOwner]: ownerOptions,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.dataManager.setLayerParams?.(
|
||||||
|
selected.layerId,
|
||||||
|
{ [selected.optionKey]: null },
|
||||||
|
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||||
|
);
|
||||||
|
this.shareLinkManager?.onLayerStateChange?.();
|
||||||
|
this._notifyDurableState();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `atMs` is the moment the subject was first found ABSENT, not the moment the
|
||||||
|
* verdict is delivered. Waiting out the pending window must not by itself
|
||||||
|
* push a fresh link into the "expired" wording — that word describes the
|
||||||
|
* SHARE's age, not how long this client watched for the subject.
|
||||||
|
*/
|
||||||
|
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
|
||||||
|
const copiedAt = this._shareCreatedAtMs;
|
||||||
|
if (!Number.isFinite(copiedAt)) return 'unavailable';
|
||||||
|
const ageMs = atMs - copiedAt;
|
||||||
|
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the owning layer currently follows the shared subject. */
|
||||||
|
_trackingTargetLatched(selected) {
|
||||||
|
const params = this.dataManager.getLayerParams?.(selected.layerId);
|
||||||
|
const active = params?.[selected.optionKey];
|
||||||
|
return (
|
||||||
|
active !== null &&
|
||||||
|
active !== undefined &&
|
||||||
|
String(active) === String(selected.targetId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop watching a pending shared subject without deciding its fate. */
|
||||||
|
_cancelPendingTrackingWatch() {
|
||||||
|
if (this._pendingTrackingTimer !== null)
|
||||||
|
this.clearTimer(this._pendingTrackingTimer);
|
||||||
|
this._pendingTrackingTimer = null;
|
||||||
|
const pending = this._pendingTrackingContext;
|
||||||
|
if (pending?.signal && pending.abortHandler) {
|
||||||
|
pending.signal.removeEventListener('abort', pending.abortHandler);
|
||||||
|
pending.abortHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
|
||||||
|
_publishTrackingRestoreStatus(status) {
|
||||||
|
try {
|
||||||
|
this.onTrackingRestoreStatus?.(status);
|
||||||
|
} catch {
|
||||||
|
/* status UI is best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a pending shared Follow wholesale.
|
||||||
|
*
|
||||||
|
* The watch and the LAYER's own deferred-restore latch are two halves of one
|
||||||
|
* mechanism, so they must die together. Aborting only the restore controller
|
||||||
|
* left the timer alive: the controller has already settled by the time the
|
||||||
|
* watch exists, so the abort was a no-op and the orphaned timer went on to
|
||||||
|
* announce "Shared … unavailable" for a subject whose latch had been
|
||||||
|
* cancelled — a notice about work no longer being attempted.
|
||||||
|
*/
|
||||||
|
_revokePendingTrackingWatch(reason) {
|
||||||
|
this._trackingRestoreController?.abort(reason);
|
||||||
|
this._trackingRestoreGeneration += 1;
|
||||||
|
this._cancelPendingTrackingWatch();
|
||||||
|
const pending = this._pendingTrackingContext;
|
||||||
|
this._pendingTrackingContext = null;
|
||||||
|
if (pending) {
|
||||||
|
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
|
||||||
|
origin: LAYER_RESTORE_ORIGINS.share,
|
||||||
|
reason: String(reason || 'cancelled'),
|
||||||
|
});
|
||||||
|
this._publishTrackingRestoreStatus({
|
||||||
|
...pending.probe,
|
||||||
|
...pending.selected,
|
||||||
|
status: 'cancelled',
|
||||||
|
classification: 'cancelled',
|
||||||
|
reason: String(reason || 'cancelled'),
|
||||||
|
cleared: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
|
||||||
|
*
|
||||||
|
* A recipient's first authoritative refresh routinely lands without a given
|
||||||
|
* contact — the feed is polled, coverage is partial, and rendering trails the
|
||||||
|
* snapshot by a poll. Reload-from-local already survives this: its restore
|
||||||
|
* arms the layer's own deferred-restore latch, which re-attempts on every
|
||||||
|
* later poll. The shared path used to decide on that single refresh, clear
|
||||||
|
* the subject from durable state AND from the URL, then post a failure notice
|
||||||
|
* seconds into startup — so the same link healed on reload but never on the
|
||||||
|
* share.
|
||||||
|
*
|
||||||
|
* Arm the SAME latch, then watch it in the background: the caller is never
|
||||||
|
* blocked (startup must not wait out a 90 s window before it may write the
|
||||||
|
* URL again), and the terminal verdict is deferred until the source-specific
|
||||||
|
* window has genuinely expired. The existing wordings are unchanged.
|
||||||
|
*/
|
||||||
|
async _beginPendingTrackingRestore(selected, probe, signal = null) {
|
||||||
|
const generation = this._trackingRestoreGeneration;
|
||||||
|
const absentAtMs = this.now();
|
||||||
|
// Arm the layer's deferred-restore latch under the passive share origin, so
|
||||||
|
// it re-attempts each poll and never rewrites recipient preferences.
|
||||||
|
let armed = false;
|
||||||
|
let armError = null;
|
||||||
|
try {
|
||||||
|
armed =
|
||||||
|
(await this.dataManager.setLayerParams?.(
|
||||||
|
selected.layerId,
|
||||||
|
{ [selected.optionKey]: selected.targetId },
|
||||||
|
{ origin: LAYER_RESTORE_ORIGINS.share },
|
||||||
|
)) === true;
|
||||||
|
} catch (error) {
|
||||||
|
armError = error;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this._destroyed ||
|
||||||
|
generation !== this._trackingRestoreGeneration ||
|
||||||
|
signal?.aborted
|
||||||
|
) {
|
||||||
|
if (armed) {
|
||||||
|
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
|
||||||
|
origin: LAYER_RESTORE_ORIGINS.share,
|
||||||
|
reason: String(signal?.reason || 'superseded'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'cancelled',
|
||||||
|
classification: 'cancelled',
|
||||||
|
reason: String(signal?.reason || 'superseded'),
|
||||||
|
cleared: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!armed) {
|
||||||
|
const terminal = {
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'source-unavailable',
|
||||||
|
classification: 'source-unavailable',
|
||||||
|
reason: String(
|
||||||
|
armError?.message || armError || 'tracking restore latch rejected',
|
||||||
|
),
|
||||||
|
cleared: this._passivelyClearTrackingSelection(selected),
|
||||||
|
};
|
||||||
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
const deadline = this.now() + selected.expiryWindowMs;
|
||||||
|
const settle = (terminal) => {
|
||||||
|
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||||
|
this._cancelPendingTrackingWatch();
|
||||||
|
this._pendingTrackingContext = null;
|
||||||
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
|
};
|
||||||
|
const poll = () => {
|
||||||
|
this._pendingTrackingTimer = null;
|
||||||
|
if (this._destroyed || generation !== this._trackingRestoreGeneration)
|
||||||
|
return;
|
||||||
|
// The layer that owns the latch may have gone away since the last tick
|
||||||
|
// (disable, teardown, replacement). There is nothing left attempting this
|
||||||
|
// restore, so abandon it silently rather than announcing a verdict.
|
||||||
|
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
|
||||||
|
this._revokePendingTrackingWatch('owner-layer-disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._trackingTargetLatched(selected)) {
|
||||||
|
settle({
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'found',
|
||||||
|
classification: 'followed',
|
||||||
|
cleared: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.now() >= deadline) {
|
||||||
|
// The window really has elapsed — only now does the verdict apply.
|
||||||
|
const classification =
|
||||||
|
probe.status === 'missing'
|
||||||
|
? this._classifyMissingTrackingTarget(selected, absentAtMs)
|
||||||
|
: 'source-unavailable';
|
||||||
|
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||||
|
settle({ ...probe, ...selected, classification, cleared });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._pendingTrackingTimer = this.setTimer(
|
||||||
|
poll,
|
||||||
|
PENDING_TRACKING_POLL_MS,
|
||||||
|
);
|
||||||
|
this._pendingTrackingTimer?.unref?.();
|
||||||
|
};
|
||||||
|
this._cancelPendingTrackingWatch();
|
||||||
|
const pending = {
|
||||||
|
...probe,
|
||||||
|
...selected,
|
||||||
|
status: 'pending',
|
||||||
|
classification: 'pending',
|
||||||
|
cleared: false,
|
||||||
|
};
|
||||||
|
const pendingContext = {
|
||||||
|
generation,
|
||||||
|
selected,
|
||||||
|
probe,
|
||||||
|
signal,
|
||||||
|
abortHandler: null,
|
||||||
|
};
|
||||||
|
if (signal) {
|
||||||
|
pendingContext.abortHandler = () => {
|
||||||
|
if (this._pendingTrackingContext?.generation !== generation) return;
|
||||||
|
this._revokePendingTrackingWatch(signal.reason || 'aborted');
|
||||||
|
};
|
||||||
|
signal.addEventListener('abort', pendingContext.abortHandler, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this._pendingTrackingContext = pendingContext;
|
||||||
|
this._publishTrackingRestoreStatus(pending);
|
||||||
|
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
|
||||||
|
this._pendingTrackingTimer?.unref?.();
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh and restore the one shareable tracked target after destination
|
||||||
|
* camera and ordinary layer restoration have settled.
|
||||||
|
*/
|
||||||
|
async restoreShareTrackingSelection({ signal = null } = {}) {
|
||||||
|
const selected = this._selectedShareTrackingTarget();
|
||||||
|
if (!selected || this._destroyed)
|
||||||
|
return { status: 'skipped', reason: 'no-shared-target' };
|
||||||
|
this._revokePendingTrackingWatch('superseded-by-newer-restore');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const combinedSignal = signal
|
||||||
|
? AbortSignal.any([signal, controller.signal])
|
||||||
|
: controller.signal;
|
||||||
|
this._trackingRestoreController = controller;
|
||||||
|
const generation = ++this._trackingRestoreGeneration;
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = await this.dataManager.resolveLayerTrackingTarget(
|
||||||
|
selected.layerId,
|
||||||
|
selected.targetId,
|
||||||
|
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
result = combinedSignal.aborted
|
||||||
|
? {
|
||||||
|
status: 'cancelled',
|
||||||
|
reason: String(combinedSignal.reason || 'aborted'),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'source-unavailable',
|
||||||
|
reason: String(error?.message || error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
generation !== this._trackingRestoreGeneration ||
|
||||||
|
this._destroyed ||
|
||||||
|
combinedSignal.aborted
|
||||||
|
) {
|
||||||
|
if (this._trackingRestoreController === controller)
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
status: 'cancelled',
|
||||||
|
reason: String(combinedSignal.reason || 'superseded'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (this._trackingRestoreController === controller)
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
|
||||||
|
if (result.status === 'found') {
|
||||||
|
const terminal = {
|
||||||
|
...result,
|
||||||
|
...selected,
|
||||||
|
classification: 'followed',
|
||||||
|
cleared: false,
|
||||||
|
};
|
||||||
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
// A subject that is simply not here YET is not a subject that is gone. Hold
|
||||||
|
// it on the layer's own deferred-restore latch for its source-specific
|
||||||
|
// window before any verdict is reached or shown. `unsupported` layers have
|
||||||
|
// no latch to arm, so they still decide immediately.
|
||||||
|
if (result.status === 'missing' || result.status === 'source-unavailable') {
|
||||||
|
return this._beginPendingTrackingRestore(
|
||||||
|
selected,
|
||||||
|
result,
|
||||||
|
combinedSignal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classification =
|
||||||
|
result.status === 'missing'
|
||||||
|
? this._classifyMissingTrackingTarget(selected)
|
||||||
|
: 'source-unavailable';
|
||||||
|
const cleared = this._passivelyClearTrackingSelection(selected);
|
||||||
|
const terminal = { ...result, ...selected, classification, cleared };
|
||||||
|
this._publishTrackingRestoreStatus(terminal);
|
||||||
|
return terminal;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this._destroyed) return;
|
||||||
|
this._destroyed = true;
|
||||||
|
for (const controller of this._restoreControllers.values())
|
||||||
|
controller.abort('coordinator-destroyed');
|
||||||
|
this._restoreControllers.clear();
|
||||||
|
this._revokePendingTrackingWatch('coordinator-destroyed');
|
||||||
|
this._trackingRestoreController = null;
|
||||||
|
this._unsubscribe?.();
|
||||||
|
this._unsubscribe = null;
|
||||||
|
this._unsubscribeVisibilityRequests?.();
|
||||||
|
this._unsubscribeVisibilityRequests = null;
|
||||||
|
this.shareLinkManager?.setLayerStateProvider?.(null);
|
||||||
|
this.onDurableStateChange = null;
|
||||||
|
this.onTrackingRestoreStatus = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
|
|||||||
export * from '../../data/contactMatch.js';
|
export * from '../../data/contactMatch.js';
|
||||||
export * from '../../data/iconOrientation.js';
|
export * from '../../data/iconOrientation.js';
|
||||||
export * from '../../data/layerState.js';
|
export * from '../../data/layerState.js';
|
||||||
|
export * from '../../data/layerStateCoordinator.js';
|
||||||
export * from '../../data/modelVisualAnchor.js';
|
export * from '../../data/modelVisualAnchor.js';
|
||||||
export * from '../../data/motionModel.js';
|
export * from '../../data/motionModel.js';
|
||||||
export * from '../../data/renderAltitude.js';
|
export * from '../../data/renderAltitude.js';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LayerStateCoordinator } from '../data/layerState.js';
|
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||||
import { UiLifetime } from './uiLifetime.js';
|
import { UiLifetime } from './uiLifetime.js';
|
||||||
|
|||||||
Reference in new issue
Block a user