mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
# Conflicts: # docs/CURRENT-STATE.md # src/app/constructCatalog.test.mjs # src/app/sourceComposition.test.mjs # src/data/layerState.test.mjs # src/data/layerStateTokenLedger.test.mjs # src/i18n/locales/ar.json
1098 lines
35 KiB
JavaScript
1098 lines
35 KiB
JavaScript
// Restore labels are marked with msg(); ui/shareRestoration.js translates them.
|
||
// Local, not i18n/marker.js: the token-ledger tests run this file in isolation.
|
||
const msg = (text) => text;
|
||
import { normalizeIdSwitches, patchRequestedIdSwitches } from './idSwitches.js';
|
||
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]$/;
|
||
/**
|
||
* 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.
|
||
*/
|
||
export 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 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;
|
||
},
|
||
});
|
||
}
|
||
|
||
function satelliteFilterOption() {
|
||
const percentage = boundedIntegerOption('infrared', 'i', 100, {
|
||
min: 0,
|
||
max: 100,
|
||
});
|
||
return Object.freeze({
|
||
...percentage,
|
||
normalize: (value) =>
|
||
value === 'filtered'
|
||
? 100
|
||
: value === 'full'
|
||
? 0
|
||
: percentage.normalize(value),
|
||
decode: (value) =>
|
||
value === 'f' ? 100 : value === 'a' ? 0 : percentage.decode(value),
|
||
});
|
||
}
|
||
|
||
const OPTION_GROUPS = Object.freeze({
|
||
traffic: Object.freeze([
|
||
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
|
||
tomtom: 't',
|
||
osm: 'o',
|
||
hybrid: 'h',
|
||
}),
|
||
]),
|
||
'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([
|
||
satelliteFilterOption(),
|
||
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),
|
||
]),
|
||
'street-level': Object.freeze([
|
||
// Preserve the published Mapillary token; custom switches use stable IDs.
|
||
booleanOption('mapillary', 'm', true),
|
||
Object.freeze({
|
||
key: 'providerSwitches',
|
||
patchRequested: patchRequestedIdSwitches,
|
||
token: 'r',
|
||
defaultValue: '',
|
||
normalize: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||
encode: normalizeIdSwitches,
|
||
decode: (value) => normalizeIdSwitches(value, ['mapillary']),
|
||
}),
|
||
enumOption('pano', 'p', 'all', ['all', 'pano', 'flat'], {
|
||
all: 'a',
|
||
pano: 'p',
|
||
flat: 'f',
|
||
}),
|
||
// Relative days, so a link means the same next year. The max matches
|
||
// MAX_SINCE_DAYS in streetLevel/policy.js (pinned by layerState.test).
|
||
boundedIntegerOption('sinceDays', 's', 0, { min: 0, max: 36500 }),
|
||
]),
|
||
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,
|
||
}),
|
||
]),
|
||
});
|
||
|
||
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
|
||
flights: Object.freeze({
|
||
optionOwner: 'flights',
|
||
optionKey: 'selectedFlightsTrackingId',
|
||
expiryWindowMs: 90_000,
|
||
label: msg('flight'),
|
||
}),
|
||
military: Object.freeze({
|
||
optionOwner: 'flights',
|
||
optionKey: 'selectedMilitaryTrackingId',
|
||
expiryWindowMs: 45_000,
|
||
label: msg('military flight'),
|
||
}),
|
||
satellites: Object.freeze({
|
||
optionOwner: 'satellites',
|
||
optionKey: 'selectedSatTrackingId',
|
||
expiryWindowMs: 300_000,
|
||
label: msg('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: 'air-quality',
|
||
token: '4',
|
||
disposition: 'enabled-only',
|
||
}),
|
||
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: 'drone-airspace',
|
||
token: '8',
|
||
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: 'gnss-integrity',
|
||
token: '3',
|
||
disposition: 'enabled-only',
|
||
}),
|
||
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: 's2-ghostmaps',
|
||
token: '5',
|
||
disposition: 'enabled-only',
|
||
}),
|
||
Object.freeze({
|
||
id: 'satellites',
|
||
token: 's',
|
||
disposition: 'enabled+options',
|
||
optionOwner: 'satellites',
|
||
}),
|
||
Object.freeze({ id: 'sea-marks', token: '6', disposition: 'enabled-only' }),
|
||
Object.freeze({ id: 'ski-pistes', token: '7', disposition: 'enabled-only' }),
|
||
Object.freeze({
|
||
id: 'street-level',
|
||
token: '0',
|
||
disposition: 'enabled+options',
|
||
optionOwner: 'street-level',
|
||
}),
|
||
Object.freeze({
|
||
id: 'telegeography-submarine-cables',
|
||
token: 'u',
|
||
disposition: 'enabled-only',
|
||
}),
|
||
Object.freeze({
|
||
id: 'traffic',
|
||
token: 't',
|
||
disposition: 'enabled+options',
|
||
optionOwner: 'traffic',
|
||
}),
|
||
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;
|
||
}
|
||
|
||
export 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),
|
||
),
|
||
]);
|
||
|
||
export 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] };
|
||
}
|