import reservationRows from './layerStateTokenReservations.json' with { type: 'json' }; const VALID_DISPOSITIONS = new Set([ 'enabled-only', 'enabled+options', 'enabled+mirrored-options', ]); export const LAYER_STATE_VERSION = 2; export const LAYER_STATE_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/; 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 * ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding * it at the codec keeps an arbitrarily long string out of durable state, the * generated URL, and local storage. Identity is never TRUNCATED to fit — an * out-of-grammar ID is rejected outright, because half an address is a * DIFFERENT aircraft, not a shorter name for the same one. */ const TRACKING_ID_GRAMMAR = /^[0-9a-z~_-]{1,16}$/; /** * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the * complete reserved one-character space plus every two-character base-36 * allocation, while the option ceiling covers a dozen short assignments. * Reject the WHOLE payload, matching the unknown-token rule — never salvage a * prefix. */ const MAX_ENABLED_LAYERS_CHARS = 4_096; const MAX_LAYER_OPTIONS_CHARS = 512; export const LAYER_STATE_STORAGE_KEY = 'gev:layer-state:v2'; export const LAYER_RESTORE_ORIGINS = Object.freeze({ share: 'share-restore', local: 'local-restore', }); const RADIO_FILTER_CODES = Object.freeze({ all: 'a', news: 'n', talk: 't', weather: 'w', 'public-safety': 'p', 'aviation-marine': 'v', 'traffic-transit': 'x', music: 'm', other: 'o', }); const RADIO_CODE_FILTERS = Object.freeze( Object.fromEntries( Object.entries(RADIO_FILTER_CODES).map(([key, value]) => [value, key]), ), ); function normalizeBoolean(value) { return typeof value === 'boolean' ? value : null; } function normalizeEnum(values, value) { return values.includes(value) ? value : null; } export function normalizeRadioFilter(value) { const normalized = String(value || '') .trim() .toLowerCase(); if (Object.hasOwn(RADIO_FILTER_CODES, normalized)) return normalized; if (/^genre:[a-z0-9][a-z0-9 &-]{0,31}$/.test(normalized)) return normalized; return null; } function encodeRadioFilter(value) { return RADIO_FILTER_CODES[value] || `g-${value.slice('genre:'.length)}`; } function decodeRadioFilter(value) { if (Object.hasOwn(RADIO_CODE_FILTERS, value)) return RADIO_CODE_FILTERS[value]; if (/^g-[a-z0-9][a-z0-9 &-]{0,31}$/.test(value)) return `genre:${value.slice(2)}`; return null; } function normalizeVolume(value) { const numeric = Number(value); if (!Number.isFinite(numeric)) return null; return Math.round(Math.max(0, Math.min(1, numeric)) * 100) / 100; } function booleanOption( key, token, defaultValue, { absentValue = defaultValue } = {}, ) { return Object.freeze({ key, token, defaultValue, absentValue, normalize: normalizeBoolean, encode: (value) => (value ? '1' : '0'), decode: (value) => (value === '1' ? true : value === '0' ? false : null), }); } /** * What an ABSENT token means for this option inside the CURRENT schema version. * * Usually that is simply the default: the encoder omits default-valued fields to * keep the URL short and the decoder fills the default back in. But a DEFAULT CAN * MOVE while the schema version does not, and every link already in the wild was * authored under the old one. `absentValue` is that frozen historical meaning — * what an omitted token meant when links like it were being written — so a * default flip cannot silently rewrite what an existing link SAYS. A share link * is authored state; the only honest reading of `v=2&l=f` is the one its author * saw. * * The consequence is not cosmetic: once `absentValue` and `defaultValue` differ, * the NEW default has to be emitted EXPLICITLY, or one omission would mean two * different things inside a single schema version. Both sides of the codec read * this function so they cannot disagree about which it is. * * (This is the same rule `scf` follows in sharelink.js by hand. `models3d` is the * first option in THIS codec to need it — flipped to default-ON on 2026-08-22.) */ function absentTokenValue(spec) { return Object.hasOwn(spec, 'absentValue') ? spec.absentValue : spec.defaultValue; } function trackingIdOption(key, token, defaultValue = null) { const bounded = (candidate) => { if (candidate === null || candidate === undefined) return null; const raw = typeof candidate === 'number' && Number.isFinite(candidate) ? String(candidate) : typeof candidate === 'string' ? candidate : null; if (raw === null) return null; const normalized = raw.trim().toLowerCase(); return TRACKING_ID_GRAMMAR.test(normalized) ? normalized : null; }; return Object.freeze({ key, token, defaultValue, normalize: bounded, encode: (value) => String(value), decode: bounded, }); } function stringOption(key, token, defaultValue) { return Object.freeze({ key, token, defaultValue, normalize: (value) => { if (typeof value === 'number' && Number.isFinite(value)) return String(value).trim().toLowerCase(); if (typeof value !== 'string') return null; const normalized = value.trim().toLowerCase(); return normalized ? normalized : null; }, encode: (value) => String(value), decode: (value) => typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null, }); } function enumOption( key, token, defaultValue, values, codes, { absentValue = defaultValue } = {}, ) { const reverse = Object.fromEntries( Object.entries(codes).map(([name, code]) => [code, name]), ); return Object.freeze({ key, token, defaultValue, absentValue, normalize: (value) => normalizeEnum(values, value), encode: (value) => codes[value], decode: (value) => reverse[value] || null, }); } function integerOption(key, token, defaultValue) { return Object.freeze({ key, token, defaultValue, normalize: (value) => { if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value; const candidate = typeof value === 'string' ? value.trim() : ''; const parsed = Number(candidate); if (!candidate || !Number.isInteger(parsed) || parsed <= 0) return null; return parsed; }, encode: (value) => String(Math.trunc(value)), decode: (value) => { const candidate = Number(value); if (!Number.isInteger(candidate) || candidate <= 0) return null; return candidate; }, }); } /** * A share-link-only option: the whole-state codec carries it, but the stored * local blob always holds its default. */ function shareOnlyOption(spec) { return Object.freeze({ ...spec, shareOnly: true }); } /** * A signed integer in `[min, max]`, rejected (never clamped) outside it: a * clamped box edge would be a different box, not a shorter spelling. */ function boundedIntegerOption(key, token, defaultValue, { min, max }) { const parse = (value) => { const number = typeof value === 'number' ? value : /^-?\d{1,9}$/.test(String(value).trim()) ? Number(String(value).trim()) : NaN; return Number.isInteger(number) && number >= min && number <= max ? number : null; }; return Object.freeze({ key, token, defaultValue, normalize: parse, encode: (value) => String(value), decode: parse, }); } /** * Recent Imagery day: runtime key `S30:2026-09-18`, URL form `S20260918`. * The calendar is checked both ways, and "today" is never consulted, so a * link decodes the same whenever it is opened. */ const IMAGERY_LETTERS = Object.freeze({ S30: 'S', L30: 'L', VIIRS: 'V' }); const IMAGERY_PRODUCTS = Object.freeze({ S: 'S30', L: 'L30', V: 'VIIRS' }); function imageryKey(product, year, month, day) { const time = Date.UTC(Number(year), Number(month) - 1, Number(day)); const iso = Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : ''; return product && iso === `${year}-${month}-${day}` ? `${product}:${iso}` : null; } function imageryPinOption(key, token) { return Object.freeze({ key, token, defaultValue: null, normalize: (value) => { const match = /^(S30|L30|VIIRS):(\d{4})-(\d{2})-(\d{2})$/.exec( typeof value === 'string' ? value.trim() : '', ); return match ? imageryKey(match[1], match[2], match[3], match[4]) : null; }, encode: (value) => { const [product, day] = value.split(':'); return `${IMAGERY_LETTERS[product]}${day.replaceAll('-', '')}`; }, decode: (value) => { const match = /^([SLV])(\d{4})(\d{2})(\d{2})$/.exec( typeof value === 'string' ? value : '', ); return match ? imageryKey(IMAGERY_PRODUCTS[match[1]], match[2], match[3], match[4]) : null; }, }); } const OPTION_GROUPS = Object.freeze({ 'weather-lightning': Object.freeze([ enumOption('opacity', 'o', 'strong', ['light', 'strong'], { light: 'l', strong: 's', }), ]), 'weather-radar': Object.freeze([ enumOption('opacity', 'o', 'strong', ['light', 'strong'], { light: 'l', strong: 's', }), ]), 'weather-satellite': Object.freeze([ enumOption('infrared', 'i', 'filtered', ['filtered', 'full'], { filtered: 'f', full: 'a', }), enumOption('opacity', 'o', 'strong', ['light', 'strong'], { light: 'l', strong: 's', }), enumOption( 'product', 'p', 'clouds-regional', ['clouds', 'clouds-regional'], { clouds: 'g', 'clouds-regional': 'r' }, ), ]), wind: Object.freeze([ enumOption('model', 'm', 'gfs', ['gfs', 'ifs'], { gfs: 'g', ifs: 'i' }), enumOption( 'overlay', 'o', 'none', ['none', 'speed', 'temperature', 'pressure'], { none: 'n', speed: 's', temperature: 't', pressure: 'p' }, { absentValue: 'speed' }, ), enumOption('units', 'u', 'km/h', ['km/h', 'm/s', 'mph'], { 'km/h': 'k', 'm/s': 'm', mph: 'i', }), booleanOption('paused', 'p', false), ]), flights: Object.freeze([ // Owner directive 2026-08-22: the fleet's 3D models are DEFAULT-ON in // PROXIMITY mode. Proximity is itself the altitude/count gate — models only // materialize once the camera is close enough and only for the nearest // contacts in view — so "on" costs nothing at globe scale, and an operator // who wants every in-view plane still opts into `all` deliberately. // This default must stay in lockstep with `_models3dEnabled` in BOTH flight // layers, `this._models3dEnabled` in ui.js, and the `active` / `visible` // classes in index.html: the fresh-boot path skips restoration entirely (see // `start()` below), so nothing ever pushes this value into the layers — those // four initializers ARE the agreement, and they are pinned together in // layerState.test.mjs. // // `absentValue: false` is what keeps the flip out of links already in the // wild. Schema v2 shipped with OFF as the omitted default, so `v=2&l=f` // MEANS off — and it has to keep meaning that. Moving the default without // this would have silently turned 3D on for every existing v2 link, and // `v=2&l=f&lo=f.m.a` (an OFF link that remembered mode All) would have come // back as ON+All. The price is that ON is now written explicitly (`f.e.1`) // instead of ridden in on the omission; see `absentTokenValue`. booleanOption('models3d', 'e', true, { absentValue: false }), enumOption('models3dMode', 'm', 'proximity', ['proximity', 'all'], { proximity: 'p', all: 'a', }), trackingIdOption('selectedFlightsTrackingId', 't', null), trackingIdOption('selectedMilitaryTrackingId', 'u', null), ]), satellites: Object.freeze([ enumOption('catalog', 'c', 'core', ['core', 'dense'], { core: 'c', dense: 'd', }), integerOption('selectedSatTrackingId', 't', null), ]), cctv: Object.freeze([ enumOption('coverageMode', 'c', 'on', ['off', 'on', 'viewshed'], { off: '0', on: '1', viewshed: 'v', }), booleanOption('showProjection', 'p', true), booleanOption('autoHop', 'a', false), ]), 'recent-imagery': Object.freeze([ // Box edges in degrees × 100000; latitudes stop at the Web-Mercator limit. boundedIntegerOption('west', 'w', null, { min: -18000000, max: 18000000 }), boundedIntegerOption('south', 's', null, { min: -8505110, max: 8505110 }), boundedIntegerOption('east', 'e', null, { min: -18000000, max: 18000000 }), boundedIntegerOption('north', 'n', null, { min: -8505110, max: 8505110 }), imageryPinOption('a', 'a'), imageryPinOption('b', 'b'), // 0 one image, 1 image against the basemap, 2 two images A / B. boundedIntegerOption('mode', 'm', 0, { min: 0, max: 2 }), // The swipe position is one comparison's framing, not a preference: a // stored split resurfaced in the next session's first comparison. shareOnlyOption( boundedIntegerOption('split', 'p', 50, { min: 0, max: 100 }), ), booleanOption('viirs', 'v', false), ]), radio: Object.freeze([ Object.freeze({ key: 'filter', token: 'f', defaultValue: 'all', normalize: normalizeRadioFilter, encode: encodeRadioFilter, decode: decodeRadioFilter, }), Object.freeze({ key: 'volume', token: 'v', defaultValue: 0.8, normalize: normalizeVolume, encode: (value) => String(Math.round(value * 100)), decode: (value) => /^\d{1,3}$/.test(value) ? normalizeVolume(Number(value) / 100) : null, }), ]), }); const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({ flights: 'selectedFlightsTrackingId', military: 'selectedMilitaryTrackingId', satellites: 'selectedSatTrackingId', }); export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({ flights: Object.freeze({ optionOwner: 'flights', optionKey: 'selectedFlightsTrackingId', expiryWindowMs: 90_000, label: 'flight', }), military: Object.freeze({ optionOwner: 'flights', optionKey: 'selectedMilitaryTrackingId', expiryWindowMs: 45_000, label: 'military flight', }), satellites: Object.freeze({ optionOwner: 'satellites', optionKey: 'selectedSatTrackingId', expiryWindowMs: 300_000, label: 'satellite', }), }); /** * The original single-character assignments are a closed compatibility set. * Keep their exact mapping pinned by the independent snapshot in the tests. */ export const LEGACY_LAYER_STATE_TOKENS = Object.freeze({ 'ais-live-vessels': 'a', 'alpr-cameras': 'p', 'bhote-koshi-2026': 'h', 'bhote-koshi-locator': 'z', bikeshare: 'b', cctv: 'c', directions: 'n', earthquakes: 'e', 'fire-perimeters': '2', flights: 'f', 'local-dams': 'q', 'local-datacenters': 'd', 'local-firms': 'w', military: 'm', 'military-awareness': 'g', 'military-installations': 'i', radio: 'r', 'recent-imagery': '1', 'rocket-launches': 'x', satellites: 's', 'telegeography-submarine-cables': 'u', traffic: 't', transit: 'j', 'weather-cyclones': 'y', 'weather-lightning': 'l', 'weather-radar': 'v', 'weather-satellite': 'o', wind: 'k', }); /** * Permanent token ownership. Existing share links are public authored state, * so an allocation stays here even if its layer is later removed. The JSON * ledger is also read directly from the published Git base by the checker. */ export function parseLayerStateTokenReservations(rows) { if (!Array.isArray(rows)) { throw new Error('Layer-state token ledger must be an array'); } const reservations = Object.create(null); const reservedIdsByToken = new Map(); for (const row of rows) { if ( !Array.isArray(row) || row.length !== 2 || typeof row[0] !== 'string' || typeof row[1] !== 'string' ) { throw new Error('Invalid layer-state token ledger row'); } const [id, token] = row; if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) { throw new Error(`Invalid layer-state token reservation: ${id}`); } if (Object.hasOwn(reservations, id)) { throw new Error(`Duplicate layer-state token reservation id: ${id}`); } if (reservedIdsByToken.has(token)) { throw new Error(`Duplicate layer-state token reservation: ${token}`); } if ( (Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) && token !== LEGACY_LAYER_STATE_TOKENS[id]) || (token.length === 1 && !NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) && LEGACY_LAYER_STATE_TOKENS[id] !== token) ) { throw new Error(`Legacy layer-state token is immutable: ${id}`); } reservations[id] = token; reservedIdsByToken.set(token, id); } for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { if (reservations[id] !== token) { throw new Error(`Missing or changed legacy layer-state token: ${id}`); } } return Object.freeze(reservations); } export const LAYER_STATE_TOKEN_RESERVATIONS = parseLayerStateTokenReservations(reservationRows); /** * Canonical serialization registry. Its order, not runtime registration order, * owns stable URL ordering. */ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'ais-live-vessels', token: 'a', disposition: 'enabled-only', }), Object.freeze({ id: 'alpr-cameras', token: 'p', disposition: 'enabled-only', }), Object.freeze({ id: 'bhote-koshi-2026', token: 'h', disposition: 'enabled-only', }), Object.freeze({ id: 'bhote-koshi-locator', token: 'z', disposition: 'enabled-only', }), Object.freeze({ id: 'bikeshare', token: 'b', disposition: 'enabled-only' }), Object.freeze({ id: 'cctv', token: 'c', disposition: 'enabled+options', optionOwner: 'cctv', }), Object.freeze({ id: 'directions', token: 'n', disposition: 'enabled-only' }), Object.freeze({ id: 'earthquakes', token: 'e', disposition: 'enabled-only' }), Object.freeze({ id: 'fire-perimeters', token: '2', disposition: 'enabled-only', }), Object.freeze({ id: 'flights', token: 'f', disposition: 'enabled+options', optionOwner: 'flights', }), Object.freeze({ id: 'local-dams', token: 'q', disposition: 'enabled-only' }), Object.freeze({ id: 'local-datacenters', token: 'd', disposition: 'enabled-only', }), Object.freeze({ id: 'local-firms', token: 'w', disposition: 'enabled-only' }), Object.freeze({ id: 'military', token: 'm', disposition: 'enabled+mirrored-options', optionOwner: 'flights', }), Object.freeze({ id: 'military-awareness', token: 'g', disposition: 'enabled-only', }), Object.freeze({ id: 'military-installations', token: 'i', disposition: 'enabled-only', }), Object.freeze({ id: 'radio', token: 'r', disposition: 'enabled+options', optionOwner: 'radio', }), Object.freeze({ id: 'recent-imagery', token: '1', disposition: 'enabled+options', optionOwner: 'recent-imagery', }), Object.freeze({ id: 'rocket-launches', token: 'x', disposition: 'enabled-only', }), Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites', }), Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only', }), Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }), Object.freeze({ id: 'transit', token: 'j', disposition: 'enabled-only' }), Object.freeze({ id: 'weather-cyclones', token: 'y', disposition: 'enabled-only', }), Object.freeze({ id: 'weather-lightning', token: 'l', disposition: 'enabled+options', optionOwner: 'weather-lightning', }), Object.freeze({ id: 'weather-radar', token: 'v', disposition: 'enabled+options', optionOwner: 'weather-radar', }), Object.freeze({ id: 'weather-satellite', token: 'o', disposition: 'enabled+options', optionOwner: 'weather-satellite', }), Object.freeze({ id: 'wind', token: 'k', disposition: 'enabled+options', optionOwner: 'wind', }), ]); export const REGISTERED_LAYER_IDS = Object.freeze( LAYER_STATE_REGISTRY.map((entry) => entry.id), ); /** Consume unreserved digits before the two-character base-36 namespace. */ export function nextLayerStateToken( reservations = LAYER_STATE_TOKEN_RESERVATIONS, ) { const occupied = new Set(Object.values(reservations || {})); for (const digit of '0123456789') { if (!occupied.has(digit)) return digit; } for (const first of LAYER_STATE_TOKEN_ALPHABET) { for (const second of LAYER_STATE_TOKEN_ALPHABET) { const candidate = `${first}${second}`; if (!occupied.has(candidate)) return candidate; } } throw new Error('Layer-state token namespace exhausted'); } function allocationRank(token) { if (NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token)) return Number(token); if (typeof token !== 'string' || token.length !== 2) return Infinity; const first = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[0]); const second = LAYER_STATE_TOKEN_ALPHABET.indexOf(token[1]); return first < 0 || second < 0 ? Infinity : 10 + first * 36 + second; } /** Check a proposed ledger against the published merge-time base. */ export function validateLayerStateAllocations( baseReservations, reservations = LAYER_STATE_TOKEN_RESERVATIONS, ) { if (!baseReservations || typeof baseReservations !== 'object') { throw new Error('Base layer-state token reservations are required'); } for (const [id, token] of Object.entries(baseReservations)) { if (reservations[id] !== token) { throw new Error(`Published layer-state token changed or removed: ${id}`); } } const newlyReserved = Object.entries(reservations) .filter(([id]) => !Object.hasOwn(baseReservations, id)) .sort((left, right) => allocationRank(left[1]) - allocationRank(right[1])); const occupied = { ...baseReservations }; for (const [id, token] of newlyReserved) { const expected = nextLayerStateToken(occupied); if (token !== expected) { throw new Error( `Layer-state token for ${id} must be the next free token ${expected}`, ); } occupied[id] = token; } return true; } const REGISTRY_BY_ID = new Map( LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]), ); const REGISTRY_BY_TOKEN = new Map( LAYER_STATE_REGISTRY.map((entry) => [entry.token, entry]), ); const OPTION_OWNER_IDS = Object.freeze([ ...new Set( LAYER_STATE_REGISTRY.map((entry) => entry.optionOwner).filter(Boolean), ), ]); function optionSpecs(ownerId) { return OPTION_GROUPS[ownerId] || []; } function defaultsForOwner(ownerId) { return Object.fromEntries( optionSpecs(ownerId).map((spec) => [spec.key, spec.defaultValue]), ); } function normalizeOwnerOptions(ownerId, candidate = {}) { const input = candidate && typeof candidate === 'object' ? candidate : {}; const normalized = {}; for (const spec of optionSpecs(ownerId)) { const value = Object.hasOwn(input, spec.key) ? spec.normalize(input[spec.key]) : null; normalized[spec.key] = value === null ? spec.defaultValue : value; } return normalized; } /** Return whether an event origin represents durable direct intent. */ export function isExplicitLayerStateOrigin(origin) { return origin === 'user' || origin === 'voice' || origin === 'tool'; } /** Validate the static registry itself before it is used to seal a manager. */ export function validateLayerStateRegistry( registry = LAYER_STATE_REGISTRY, reservations = LAYER_STATE_TOKEN_RESERVATIONS, ) { if (!Array.isArray(registry) || registry.length === 0) { throw new Error('Layer-state registry must be a non-empty array'); } if ( !reservations || typeof reservations !== 'object' || Array.isArray(reservations) ) { throw new Error('Layer-state token reservations must be an object'); } const reservedIdsByToken = new Map(); for (const [id, token] of Object.entries(reservations)) { if (!/^[a-z0-9-]+$/.test(id) || !LAYER_STATE_TOKEN_PATTERN.test(token)) { throw new Error(`Invalid layer-state token reservation: ${id}`); } if ( (Object.hasOwn(LEGACY_LAYER_STATE_TOKENS, id) && token !== LEGACY_LAYER_STATE_TOKENS[id]) || (token.length === 1 && !NEW_SINGLE_CHARACTER_TOKEN_PATTERN.test(token) && LEGACY_LAYER_STATE_TOKENS[id] !== token) ) { throw new Error(`Legacy layer-state token is immutable: ${id}`); } if (reservedIdsByToken.has(token)) { throw new Error(`Duplicate layer-state token reservation: ${token}`); } reservedIdsByToken.set(token, id); } const ids = new Set(); const tokens = new Set(); for (const entry of registry) { if (!entry || typeof entry.id !== 'string' || !entry.id) throw new Error('Layer-state entry missing id'); if (!/^[a-z0-9-]+$/.test(entry.id)) throw new Error(`Invalid layer-state id: ${entry.id}`); if (ids.has(entry.id)) throw new Error(`Duplicate layer-state id: ${entry.id}`); ids.add(entry.id); if (!LAYER_STATE_TOKEN_PATTERN.test(entry.token || '')) throw new Error(`Invalid layer-state token: ${entry.id}`); if (reservations[entry.id] !== entry.token) { throw new Error(`Unreserved layer-state token: ${entry.id}`); } if (reservedIdsByToken.get(entry.token) !== entry.id) { throw new Error(`Layer-state token reservation mismatch: ${entry.token}`); } if (tokens.has(entry.token)) throw new Error(`Duplicate layer-state token: ${entry.token}`); tokens.add(entry.token); if (!VALID_DISPOSITIONS.has(entry.disposition)) { throw new Error(`Invalid layer-state disposition: ${entry.id}`); } if (entry.disposition !== 'enabled-only') { if (!entry.optionOwner || optionSpecs(entry.optionOwner).length === 0) { throw new Error(`Layer-state option owner missing: ${entry.id}`); } } else if (entry.optionOwner) { throw new Error(`Enabled-only layer cannot own options: ${entry.id}`); } } return true; } validateLayerStateRegistry(); /** Produce the complete durable default state. */ export function createDefaultLayerState() { return { version: LAYER_STATE_VERSION, enabledLayerIds: [], options: Object.fromEntries( OPTION_OWNER_IDS.map((ownerId) => [ownerId, defaultsForOwner(ownerId)]), ), }; } /** Sanitize and canonicalize an externally supplied layer-state object. */ export function normalizeLayerState(candidate) { const input = candidate && typeof candidate === 'object' ? candidate : {}; const requestedEnabled = new Set( Array.isArray(input.enabledLayerIds) ? input.enabledLayerIds.map(String) : [], ); const enabledLayerIds = REGISTERED_LAYER_IDS.filter((id) => requestedEnabled.has(id), ); const enabled = new Set(enabledLayerIds); const options = Object.fromEntries( OPTION_OWNER_IDS.map((ownerId) => [ ownerId, normalizeOwnerOptions(ownerId, input.options?.[ownerId]), ]), ); // A selected entity cannot outlive an explicitly disabled owner layer. // Keeping these IDs would resurrect tracking when that layer is enabled // later, even though OFF was newer explicit intent. if (!enabled.has('flights')) options.flights.selectedFlightsTrackingId = null; if (!enabled.has('military')) options.flights.selectedMilitaryTrackingId = null; if (!enabled.has('satellites')) options.satellites.selectedSatTrackingId = null; // The codec has no cross-family recency field, so multiple tracking IDs are // ambiguous rather than an ordered handoff. Fail closed instead of letting // asynchronous feed arrival decide which tracker and camera owner wins. const trackingSelectionCount = [ options.flights.selectedFlightsTrackingId, options.flights.selectedMilitaryTrackingId, options.satellites.selectedSatTrackingId, ].filter((value) => value !== null).length; if (trackingSelectionCount > 1) { options.flights.selectedFlightsTrackingId = null; options.flights.selectedMilitaryTrackingId = null; options.satellites.selectedSatTrackingId = null; } return { version: LAYER_STATE_VERSION, enabledLayerIds, options, }; } export function cloneLayerState(state) { const normalized = normalizeLayerState(state); return { ...normalized, enabledLayerIds: [...normalized.enabledLayerIds], options: Object.fromEntries( Object.entries(normalized.options).map(([id, options]) => [ id, { ...options }, ]), ), }; } /** Append the compact v2 layer fields to an existing URLSearchParams object. */ export function encodeLayerStateParams(params, state) { const normalized = normalizeLayerState(state); const enabled = new Set(normalized.enabledLayerIds); params.set( 'l', LAYER_STATE_REGISTRY.filter((entry) => enabled.has(entry.id)) .map((entry) => entry.token) .join('.'), ); const encodedOptions = []; for (const ownerId of OPTION_OWNER_IDS) { const ownerEntry = REGISTRY_BY_ID.get(ownerId); const ownerOptions = normalized.options[ownerId]; for (const spec of optionSpecs(ownerId)) { // Omit against what an ABSENT token means to a DECODER, not against the // current default — those are the same thing for every option whose // default never moved, and deliberately different for one whose did. if (ownerOptions[spec.key] === absentTokenValue(spec)) continue; encodedOptions.push( `${ownerEntry.token}.${spec.token}.${spec.encode(ownerOptions[spec.key])}`, ); } } if (encodedOptions.length) params.set('lo', encodedOptions.join('_')); else params.delete('lo'); return params; } /** Decode v2 fields. Null means that the layer payload is absent. */ export function decodeLayerStateParams(params) { const layerFields = params.getAll('l'); if ( params.get('v') !== String(LAYER_STATE_VERSION) || layerFields.length !== 1 ) return null; const rawLayers = layerFields[0]; const rawOptionsField = String(params.get('lo') || ''); // Fail closed on an oversized payload rather than decoding a truncated one. if (rawLayers.length > MAX_ENABLED_LAYERS_CHARS) return null; if (rawOptionsField.length > MAX_LAYER_OPTIONS_CHARS) return null; const layerTokens = rawLayers ? rawLayers.split('.') : []; // `l=` is the one valid explicit-empty representation. Reject repeated // fields, empty members, duplicate members, or unknown members; silently // removing one would turn a malformed share into a different state. if ( layerTokens.some((token) => !token || !REGISTRY_BY_TOKEN.has(token)) || new Set(layerTokens).size !== layerTokens.length ) return null; const enabledLayerIds = layerTokens.map( (token) => REGISTRY_BY_TOKEN.get(token).id, ); const rawOptions = {}; for (const assignment of rawOptionsField.split('_')) { if (!assignment) continue; const [layerToken, optionToken, encodedValue, ...extra] = assignment.split('.'); if (extra.length) continue; const entry = REGISTRY_BY_TOKEN.get(layerToken); const ownerId = entry?.optionOwner || null; if (!ownerId) continue; const spec = optionSpecs(ownerId).find( (candidate) => candidate.token === optionToken, ); if (!spec) continue; const decoded = spec.decode(encodedValue); if (decoded === null) continue; if (!rawOptions[ownerId]) rawOptions[ownerId] = {}; rawOptions[ownerId][spec.key] = decoded; } // Fill every token the link did NOT carry with its absent-meaning before // normalization, which would otherwise substitute the CURRENT default. For all // but one option those are identical and this is a no-op; for `models3d` it is // the whole point — an omitted `e` is a v2 author saying OFF, not a v2 author // saying "whatever the default happens to be today". See `absentTokenValue`. for (const ownerId of OPTION_OWNER_IDS) { for (const spec of optionSpecs(ownerId)) { if (rawOptions[ownerId] && Object.hasOwn(rawOptions[ownerId], spec.key)) continue; if (!rawOptions[ownerId]) rawOptions[ownerId] = {}; rawOptions[ownerId][spec.key] = absentTokenValue(spec); } } return normalizeLayerState({ enabledLayerIds, options: rawOptions }); } /** Options with share-link-only values reset to their defaults. */ function withoutShareOnlyOptions(options) { const out = {}; for (const ownerId of OPTION_OWNER_IDS) { out[ownerId] = { ...(options?.[ownerId] || {}) }; for (const spec of optionSpecs(ownerId)) { if (spec.shareOnly) out[ownerId][spec.key] = spec.defaultValue; } } return out; } /** * Stable local-storage representation (full IDs for debuggability), with * share-link-only options at their defaults. */ export function serializeStoredLayerState(state) { const normalized = normalizeLayerState(state); return JSON.stringify({ v: LAYER_STATE_VERSION, l: normalized.enabledLayerIds, o: withoutShareOnlyOptions(normalized.options), }); } export function parseStoredLayerState(raw) { if (typeof raw !== 'string' || !raw) return null; try { const parsed = JSON.parse(raw); if (parsed?.v !== LAYER_STATE_VERSION || !Array.isArray(parsed.l)) return null; return normalizeLayerState({ enabledLayerIds: parsed.l, options: withoutShareOnlyOptions(parsed.o), }); } catch { return null; } } /** Return sanitized options to apply to one registered module. */ export function layerOptionsForRestore(state, layerId) { const entry = REGISTRY_BY_ID.get(layerId); if (!entry?.optionOwner) return null; 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; } }