Files
gods-eye-view/src/sharelink.js
T
Sameh Khamis da02fd8645 feat(share): short /share/<code> links with a compact versioned view format (#1418)
* feat(share): add compact versioned share-view codec

Add a standalone codec for `#s=<base64url>` share links: a version byte
followed by protobuf-style tag-length-value fields. Settings equal to
their default are omitted and refilled from the same table on decode;
unknown fields are skipped by wire type, so new fields never break
older apps. Views are a place (camera) or a thing (an aircraft,
military aircraft, vessel or satellite to follow, with camera offset
and last-known fix); an unknown kind falls back to the camera.

The decoder bounds its input and rejects malformed payloads with a
reason instead of throwing. A converter maps the long form's parsed
state to and from the codec state; the long form is unchanged.

* fix(share): tighten codec decoding after review

Unknown detection mode and allocation names now fall back to their
defaults instead of reaching the lenient long-form migration. Future
panel values and style-parameter tokens are skipped rather than applied
or rejected, and layer options are accepted only under their owner
layer. Wrap longitudes before scaling, normalize negative zero, reuse
the layer-state tracking ID grammar, and align the format doc with the
decoder.

* feat(share): let a host share the link its own way

ShareLinkManager.buildLink() returns the current share link without
copying it, and copyLink() now copies exactly that. The share button
passes the link to an optional services.shareLink(url), which resolves
to 'shared', 'copied', 'cancelled' or false; 'cancelled' shows no toast
and 'shared' shows the new "Link shared" (es, ru, zh-CN). Without the
service the button copies as before.

* feat(share): open and make short /share/<code> links

The share button now makes `<app>/share/<code>` links with the compact
share-view code, and the app opens them at start-up through the same
restore pipeline as long links. Once the app next writes the address, the
share path is replaced (no new history entry) by the app's own address and
the live long-form hash, so a reload follows the live view. A host can pass
the code as `initialShareCode` instead. A code that fails to decode restores
nothing and shows "This share link can't be opened".

The code gains a trailing CRC-8 check byte, so any single mistyped
character is rejected instead of opening a different view. Thing views no
longer store a camera: it is derived from the entity's last-known fix and
the follow offset on a sphere, the entity kind is implied by the id's
field, and the followed entity's layer bit is implied; an aircraft followed
from the app's own camera is a 42-character code. The `#s=` hash form is
gone.

While the camera follows an aircraft, military aircraft or satellite, the
link is a thing view with the camera's offset and the entity's position.
Opening one turns the layer on and follows the entity through the existing
shared-follow restore, then swings the camera round to the shared offset; a
vessel view selects the vessel once the vessel feed has it. If the entity
is not reporting within its window, the view stays at its last-known fix
and says so. The panel and style-parameter tables now come from the codec,
and a host share hook that returns a plain value or throws still gets a
toast.

* fix(share): encode style settings against the style's own look

Settings a style applies when selected (bloom, sharpen, HUD, detection,
celestial ring and the style's shader parameters) now default, per field,
to what that style applies in a fresh session, read from the tables the
app applies (the first-load baseline, the style presets and the shader
defaults). The encoder compares against the view's style and the decoder
fills omitted settings from the decoded style, so a view that keeps its
style's look costs only its camera and style. The previous defaults did not
match a fresh session (sharpen, HUD and detection were off), so every link
paid for them, and a style's own shader parameters were always written.

A test pins the per-style defaults as frozen v1 meanings. A followed
aircraft in Noir is now 44 characters and the real long-link example 39.

* docs: changelog for #1418
2026-10-26 15:31:51 -07:00

814 lines
31 KiB
JavaScript

import * as Cesium from 'cesium';
import { BLOOM_INTENSITY_DEFAULT, BLOOM_SCALE_VERSION } from './bloom.js';
import {
migrateDetectionState,
normalizeAllocationStrategy,
} from './data/detectionPolicy.js';
import { clampScopeTerminusPct } from './scopeMask.js';
import { clampScopeFeatherPct } from './scopeMaskPolicy.js';
import {
decodeLayerStateParams,
encodeLayerStateParams,
} from './data/layerState.js';
import { STYLE_URL_NAMES } from './view/index.js';
import {
SHARE_VIEW_PANELS,
SHARE_VIEW_STYLE_PARAMS,
decodeShareView,
encodeShareView,
} from './share/viewCodec.js';
import {
fromLegacyShareState,
toLegacyShareState,
} from './share/legacyShareState.js';
import {
appRootUrl,
parseSharePath,
shareLinkUrl,
} from './share/shareLinkPath.js';
/**
* Share Links — URL Hash State Management
*
* The address bar follows the live view in the long hash form:
* #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
*
* A shared link is the short form, `<app>/share/<code>`, with the code from
* src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here.
*/
const DEBOUNCE_MS = 500;
const LEGACY_BLOOM_FALLBACK = 50;
// Style name mapping: internal → URL-friendly, shared with views.
const STYLE_TO_URL = STYLE_URL_NAMES;
const SHARE_UI_STATE_PARAM = 'ui';
const SHARE_STYLE_PARAMS_PARAM = 'sp';
const SHARE_CREATED_AT_PARAM = 'at';
// The panel and visual-preset parameter tables are the share format's own
// (src/share/viewCodec.js); the long form spells their entries by token.
const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS;
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])),
);
const URL_TO_STYLE = Object.fromEntries(
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
);
const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS;
export class ShareLinkManager {
constructor(
viewer,
{
onRestore,
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
} = {},
) {
this.viewer = viewer;
// A host may hand over the share code to open instead of the address.
this._initialShareCode =
typeof initialShareCode === 'string' ? initialShareCode : null;
// Why the share code this page opened with was refused, if it was.
this.rejectedShareCode = null;
this._followSubjectProvider = null;
this._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null;
this._currentStyle = 'normal';
this._bloomEnabled = false;
this._sharpenEnabled = false;
this._bloomIntensity = BLOOM_INTENSITY_DEFAULT;
this._bloomVersion = BLOOM_SCALE_VERSION;
this._sharpenIntensity = 49;
this._hudVariant = 'tactical';
this._hudVisible = false;
this._detectionMode = 'OFF';
this._detectionDensity = 50;
this._detectionAllocation = 'ELASTIC';
this._detectionFadePct = 7;
// Mirrors KEYHOLE_OUTSIDE_OPACITY_DEFAULT in celestialRing.js and the
// slider's markup value (owner final lock 2026-08-24: 5 -> 3 -> 1). This is the
// state the link THIS session generates starts from, so it must match what
// the session actually renders; the `ko` PARSE fallback below is a separate
// question and deliberately stays at 5.
this._detectionOutsideOpacityPct = 1;
this._celestialRingEnabled = false;
this._scopeEnabled = true;
this._scopeFeatherPct = 100;
// null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null;
this._mapStack = 'photoreal';
this._layerStateProvider = null;
this._panelStateProvider = null;
this._styleParamStateProvider = null;
this._initialRestorePending = false;
// Each incoming share (start-up or a later hash navigation) gets a token;
// only the newest one may release URL-write suppression.
this._restoreToken = 0;
// The hash this page last read or wrote. A location change back to it is
// the app's own state, never an incoming share.
this._knownHash = null;
this._unwatchNavigation = null;
this._restoreAuthority = {
visual: 0,
map: 0,
panels: new Map(),
};
this._destroyed = false;
this._restoreGeneration = 0;
this._activeCameraFlight = null;
this._isNavigationCurrent =
typeof isNavigationCurrent === 'function'
? isNavigationCurrent
: () => true;
this._cancelOwnedNavigation =
typeof cancelOwnedNavigation === 'function'
? cancelOwnedNavigation
: null;
// Listen for camera changes
this._removeCameraChanged = this.viewer.camera.changed.addEventListener(
() => {
this._scheduleUpdate();
},
);
}
/**
* Parse the share this page opened with: the host's `initialShareCode`, a
* `/share/<code>` address, or a long-form hash. Returns the parsed state, or
* null for none. A share code that cannot be opened is recorded in
* `rejectedShareCode` and restores nothing.
*/
parseInitialHash() {
const hash = window.location.hash.slice(1);
this._knownHash = hash;
const code =
this._initialShareCode ??
parseSharePath(window.location.pathname)?.code ??
null;
this._initialShareCode = null;
const state =
code === null ? this._parseShareHash(hash) : this._parseShareCode(code);
if (!state) return null;
this._beginIncomingRestore(state);
return state;
}
/**
* The restore state for a share code, in the long form's parsed shape plus
* `shareView` (what the view follows, with its offset and last-known fix),
* or null when the code is refused.
*/
_parseShareCode(code) {
const result = decodeShareView(code);
if (!result.ok) {
this.rejectedShareCode = result.reason;
return null;
}
const state = toLegacyShareState(result.state);
state.shareView = result.state.view;
return state;
}
/** Parse one hash without side effects. Returns null unless it is a share. */
_parseShareHash(hash) {
if (!hash) return null;
const params = new URLSearchParams(hash);
const lat = parseFloat(params.get('lat'));
const lon = parseFloat(params.get('lon'));
// Coordinates drive Cartesian conversion, so reject non-finite URL values
// before marking a share restoration as pending. `parseFloat('Infinity')`
// is not NaN and would otherwise reach Cesium asynchronously at startup.
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
const parseOr = (value, fallback) => {
const num = parseFloat(value);
return Number.isFinite(num) ? num : fallback;
};
const restoredDetection = migrateDetectionState(
params.get('dm') || 'OFF',
parseOr(params.get('dd'), 50),
50,
);
const style = Object.hasOwn(URL_TO_STYLE, params.get('style'))
? URL_TO_STYLE[params.get('style')]
: 'normal';
const decodedLayerState = decodeLayerStateParams(params);
const state = {
lat,
lon,
alt: parseOr(params.get('alt'), 800),
heading: parseOr(params.get('heading'), 0),
pitch: parseOr(params.get('pitch'), -35),
roll: parseOr(params.get('roll'), 0),
style,
styleParams: decodeStyleParamState(params, style),
bloom: params.get('bloom') === '1',
sharpen: params.get('sharpen') === '1',
bloomIntensity: parseOr(params.get('bi'), LEGACY_BLOOM_FALLBACK),
bloomVersion: parseOr(params.get('bv'), 1),
sharpenIntensity: parseOr(params.get('si'), 49),
hudVariant: params.get('hud') || 'tactical',
hudVisible: params.get('hv') === '1',
detectionMode: restoredDetection.enabled
? restoredDetection.profile
: 'OFF',
detectionDensity: restoredDetection.densityPct,
detectionAllocation: normalizeAllocationStrategy(params.get('da')),
detectionFadePct: Math.max(
0,
Math.min(40, Math.round(parseOr(params.get('kf'), 16))),
),
// Deliberately still 5 after the 2026-08-23 default moved to 3. Same rule
// as `scf` below: this is the PARSE fallback for a link that predates
// `ko`, and such a link was authored when 5 was what its author saw. Every
// link since carries `ko` explicitly, because the generator always writes
// the field — so nothing from the 5 % era depends on this number either
// way. The first-run default is a different question, answered in
// celestialRing.js.
detectionOutsideOpacityPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('ko'), 5))),
),
celestialRing: params.has('cr') ? params.get('cr') === '1' : false,
scopeEnabled: params.has('sc') ? params.get('sc') === '1' : true,
// Deliberately still 35 through both later default moves (0 on
// 2026-08-22, 8 on 2026-08-23). This is the PARSE fallback for a link that
// predates `scf` entirely, and such a link was authored when 35 was what
// its author saw — restoring their view is the point of a share link. A
// link from the feather-0 era is unaffected either way: it carries
// `scf=0` explicitly, because the generator always writes the field. The
// first-run default is a different question, answered in scopeMask.js.
// (`_scopeFeatherPct` in the constructor tracks the default: that one
// mirrors live state for the link this session generates, so it must match
// the mask, not the archive.)
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
// the mask — and then got written straight back out on the next update.
scopeTerminusPct: params.has('sce')
? clampScopeTerminusPct(params.get('sce'))
: null,
mapStack: params.get('map') || 'photoreal',
layerState: decodedLayerState,
layerStateInvalid:
params.get('v') === '2' &&
params.has('l') &&
decodedLayerState === null,
panelState: decodePanelStateParams(params),
sharedAtMs: decodeShareCreatedAtMs(params),
};
return state;
}
/** Reserve restore lanes and hold URL writes for one incoming share. */
_beginIncomingRestore(state) {
state.restoreAuthority = {
visual: this._restoreAuthority.visual,
map: this._restoreAuthority.map,
panels: new Map(this._restoreAuthority.panels),
};
state.restoreToken = ++this._restoreToken;
// Hold URL writes until the complete incoming state has been restored. A
// debounced write of the previous view must not land over the new link.
this._initialRestorePending = true;
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
}
/**
* Restore a share link opened in this tab after start-up: a pasted address,
* a followed link, or Back/Forward. The app's own replaceState writes never
* fire these events, and a hash equal to the last one read or written is
* ignored anyway, so only a genuinely different incoming share restores.
*/
watchNavigation(onNavigate) {
this._unwatchNavigation?.();
if (this._destroyed || typeof onNavigate !== 'function') return;
const target = globalThis.window;
if (typeof target?.addEventListener !== 'function') return;
const handler = () => this._handleLocationChange(onNavigate);
target.addEventListener('hashchange', handler);
target.addEventListener('popstate', handler);
this._unwatchNavigation = () => {
target.removeEventListener('hashchange', handler);
target.removeEventListener('popstate', handler);
this._unwatchNavigation = null;
};
}
_handleLocationChange(onNavigate) {
if (this._destroyed) return;
const hash = window.location.hash.slice(1);
// Back/Forward fires both popstate and hashchange for one entry.
if (hash === this._knownHash) return;
this._knownHash = hash;
const state = this._parseShareHash(hash);
if (!state) return;
this._beginIncomingRestore(state);
onNavigate(state);
}
/**
* Apply a parsed state to the viewer + style manager.
*/
async applyState(state, { applyCamera = true, navigationToken = null } = {}) {
if (this._destroyed || !state)
return { succeeded: false, reason: 'unavailable' };
const view = {
destination: Cesium.Cartesian3.fromDegrees(
state.lon,
state.lat,
state.alt,
),
orientation: {
heading: Cesium.Math.toRadians(state.heading),
pitch: Cesium.Math.toRadians(state.pitch),
roll: Cesium.Math.toRadians(state.roll),
},
};
let cameraPromise = Promise.resolve({
status: applyCamera ? 'superseded' : 'skipped',
});
if (applyCamera && this._isNavigationCurrent(navigationToken)) {
const restoreGeneration = ++this._restoreGeneration;
let settleCamera;
cameraPromise = new Promise((resolve) => {
settleCamera = resolve;
});
const releaseOwnedFlight = (status = 'cancelled') => {
if (this._activeCameraFlight?.restoreGeneration === restoreGeneration) {
this._activeCameraFlight = null;
}
settleCamera({ status });
};
this._activeCameraFlight = {
restoreGeneration,
navigationToken,
settle: releaseOwnedFlight,
};
// Re-apply the final pose only while this share restoration still owns
// navigation. A later user or voice command wins over delayed restore.
this.viewer.camera.flyTo({
...view,
duration: 3.0,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete: () => {
if (
this._destroyed ||
restoreGeneration !== this._restoreGeneration ||
!this._isNavigationCurrent(navigationToken)
) {
releaseOwnedFlight('superseded');
return;
}
this.viewer.camera.setView(view);
this.viewer.scene?.requestRender?.();
releaseOwnedFlight('applied');
},
cancel: () => releaseOwnedFlight('cancelled'),
});
}
// Notify the style manager via callback
const reserved = state.restoreAuthority || null;
const visualCurrent =
!reserved || reserved.visual === this._restoreAuthority.visual;
const mapCurrent = !reserved || reserved.map === this._restoreAuthority.map;
let panelState = state.panelState;
if (reserved && panelState?.specs) {
panelState = {
specs: panelState.specs.filter(
(spec) =>
(reserved.panels?.get(spec.id) || 0) ===
(this._restoreAuthority.panels.get(spec.id) || 0),
),
};
if (panelState.specs.length === 0) panelState = null;
}
let restoreStatus = 'skipped';
if (this._onRestore) {
await this._onRestore({
style: visualCurrent ? state.style : undefined,
bloom: visualCurrent ? state.bloom : undefined,
sharpen: visualCurrent ? state.sharpen : undefined,
bloomIntensity: visualCurrent ? state.bloomIntensity : undefined,
bloomVersion: visualCurrent ? state.bloomVersion : undefined,
sharpenIntensity: visualCurrent ? state.sharpenIntensity : undefined,
hudVariant: visualCurrent ? state.hudVariant : undefined,
hudVisible: visualCurrent ? state.hudVisible : undefined,
detectionMode: visualCurrent ? state.detectionMode : undefined,
detectionDensity: visualCurrent ? state.detectionDensity : undefined,
detectionAllocation: visualCurrent
? state.detectionAllocation
: undefined,
detectionFadePct: visualCurrent ? state.detectionFadePct : undefined,
detectionOutsideOpacityPct: visualCurrent
? state.detectionOutsideOpacityPct
: undefined,
celestialRing: visualCurrent ? state.celestialRing : undefined,
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
scopeTerminusPct: visualCurrent ? state.scopeTerminusPct : undefined,
mapStack: mapCurrent ? state.mapStack : undefined,
panelState,
styleParams: visualCurrent ? state.styleParams : undefined,
});
restoreStatus = 'applied';
}
const camera = await cameraPromise;
return {
succeeded: !this._destroyed,
camera: camera.status,
visual: visualCurrent ? restoreStatus : 'superseded',
map: mapCurrent ? restoreStatus : 'superseded',
panels: panelState
? restoreStatus
: state.panelState
? 'superseded'
: 'skipped',
};
}
/**
* Release hash suppression only after every restore owner settles. A token
* from a superseded share restore leaves the newer one holding the URL.
*/
completeInitialRestore(restoreToken = this._restoreToken) {
if (!this._initialRestorePending) return;
if (restoreToken !== this._restoreToken) return;
this._initialRestorePending = false;
this._scheduleUpdate();
}
/** Mark a newer explicit action as owner of one delayed restore lane. */
claimRestoreLane(lane, panelId = null) {
if (!this._initialRestorePending) return;
if (lane === 'panel' && panelId) {
this._restoreAuthority.panels.set(
panelId,
(this._restoreAuthority.panels.get(panelId) || 0) + 1,
);
} else if (lane === 'visual' || lane === 'map') {
this._restoreAuthority[lane] += 1;
}
}
/** Install the finalized durable layer-state source used by URL generation. */
setLayerStateProvider(provider) {
this._layerStateProvider = typeof provider === 'function' ? provider : null;
}
/**
* Install the source of the entity the camera follows, used for share links:
* `({ nowMs }) => ({ kind, id, offset, lastKnown }) | null`, in the share
* format's terms (src/share/follow.js), with the fix as of `nowMs`.
*/
setFollowSubjectProvider(provider) {
this._followSubjectProvider =
typeof provider === 'function' ? provider : null;
}
/** Install the finalized panel-state source used by URL generation. */
setPanelStateProvider(provider) {
this._panelStateProvider = typeof provider === 'function' ? provider : null;
}
/** Install the active visual preset parameter source used by URL generation. */
setStyleParamStateProvider(provider) {
this._styleParamStateProvider =
typeof provider === 'function' ? provider : null;
}
/** Called only when the durable layer preference model changes. */
onLayerStateChange() {
this._scheduleUpdate();
}
/** Called when the panel-state provider changes. */
onPanelStateChange(panelId = null) {
if (panelId) this.claimRestoreLane('panel', panelId);
this._scheduleUpdate();
}
_encodePanelStateParam(params, panelState) {
if (
!panelState ||
!Array.isArray(panelState.specs) ||
panelState.specs.length === 0
) {
params.delete(SHARE_UI_STATE_PARAM);
return;
}
const assignments = [];
for (const spec of SHARE_PANEL_STATE_REGISTRY) {
const state = panelState.specs.find((entry) => entry.id === spec.id);
if (!state || typeof state.collapsed !== 'boolean') continue;
assignments.push(`${spec.token}.c.${state.collapsed ? '1' : '0'}`);
if (spec.pinnable && typeof state.pinned === 'boolean') {
assignments.push(`${spec.token}.p.${state.pinned ? '1' : '0'}`);
}
}
if (assignments.length)
params.set(SHARE_UI_STATE_PARAM, assignments.join('_'));
else params.delete(SHARE_UI_STATE_PARAM);
}
/** Called by StyleManager when style/toggles change */
onStyleChange(styleName) {
this._currentStyle = styleName;
this._scheduleUpdate();
}
onToggleChange(bloom, sharpen, extras = {}) {
this._bloomEnabled = bloom;
this._sharpenEnabled = sharpen;
if (typeof extras.bloomIntensity === 'number')
this._bloomIntensity = extras.bloomIntensity;
if (typeof extras.bloomVersion === 'number')
this._bloomVersion = extras.bloomVersion;
if (typeof extras.sharpenIntensity === 'number')
this._sharpenIntensity = extras.sharpenIntensity;
if (typeof extras.hudVariant === 'string')
this._hudVariant = extras.hudVariant;
if (typeof extras.hudVisible === 'boolean')
this._hudVisible = extras.hudVisible;
if (typeof extras.detectionMode === 'string')
this._detectionMode = extras.detectionMode.toUpperCase();
if (typeof extras.detectionDensity === 'number')
this._detectionDensity = extras.detectionDensity;
if (typeof extras.detectionAllocation === 'string') {
this._detectionAllocation = normalizeAllocationStrategy(
extras.detectionAllocation,
);
}
if (typeof extras.detectionFadePct === 'number') {
this._detectionFadePct = Math.max(
0,
Math.min(40, Math.round(extras.detectionFadePct)),
);
}
if (typeof extras.detectionOutsideOpacityPct === 'number') {
this._detectionOutsideOpacityPct = Math.max(
0,
Math.min(100, Math.round(extras.detectionOutsideOpacityPct)),
);
}
if (typeof extras.celestialRingEnabled === 'boolean')
this._celestialRingEnabled = extras.celestialRingEnabled;
if (typeof extras.scopeEnabled === 'boolean')
this._scopeEnabled = extras.scopeEnabled;
if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
}
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') {
this._scopeTerminusPct = clampScopeTerminusPct(extras.scopeTerminusPct);
}
if (typeof extras.mapStack === 'string') this._mapStack = extras.mapStack;
this._scheduleUpdate();
}
/**
* The share link for the current state, `<app>/share/<code>`, stamped with
* its creation time, or null before the camera has a position. While the
* camera follows an entity, the link follows it too.
*/
buildLink({ nowMs = Date.now() } = {}) {
const params = this._buildHashParams();
if (!params) return null;
// The long form is the one description of the live state; the code is
// that state in the share format.
const state = fromLegacyShareState(this._parseShareHash(params.toString()));
state.sharedAtMs = Math.floor(nowMs / 1000) * 1000;
let subject = null;
try {
subject = this._followSubjectProvider?.({ nowMs }) ?? null;
} catch {
subject = null;
}
if (subject) {
state.view = {
kind: 'thing',
entity: { kind: subject.kind, id: subject.id },
offset: subject.offset ?? null,
lastKnown: subject.lastKnown ?? null,
};
}
return shareLinkUrl(encodeShareView(state), window.location.href);
}
/** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */
async copyLink({ nowMs = Date.now() } = {}) {
try {
const href = this.buildLink({ nowMs });
if (!href) return false;
await navigator.clipboard.writeText(href);
return true;
} catch {
return false;
}
}
_scheduleUpdate() {
if (this._destroyed || this._initialRestorePending) return;
clearTimeout(this._debounceTimer);
this._debounceTimer = setTimeout(() => this._updateHash(), DEBOUNCE_MS);
}
_updateHash() {
if (this._destroyed || this._initialRestorePending) return;
const params = this._buildHashParams();
if (!params) return;
// The hash belongs to this page's address, not the document base the
// app may load under. A `/share/<code>` address gives way to the app's
// own address, so a reload follows the live view; replacing it adds no
// history entry.
const url = parseSharePath(new URL(window.location.href).pathname)
? appRootUrl(window.location.href)
: new URL(window.location.href);
url.hash = params.toString();
this._knownHash = url.hash.slice(1);
history.replaceState(null, '', url.href);
}
/** Build a deterministic snapshot without mutating history. */
_buildHashParams() {
if (this._destroyed) return null;
const camera = this.viewer.camera;
const carto = camera.positionCartographic;
if (!carto) return null;
const params = new URLSearchParams();
params.set('v', '2');
params.set('lat', Cesium.Math.toDegrees(carto.latitude).toFixed(4));
params.set('lon', Cesium.Math.toDegrees(carto.longitude).toFixed(4));
params.set('alt', Math.round(carto.height).toString());
params.set(
'heading',
Math.round(Cesium.Math.toDegrees(camera.heading)).toString(),
);
params.set(
'pitch',
Math.round(Cesium.Math.toDegrees(camera.pitch)).toString(),
);
params.set(
'roll',
Math.round(Cesium.Math.toDegrees(camera.roll)).toString(),
);
params.set('style', STYLE_TO_URL[this._currentStyle] || 'normal');
params.set('bloom', this._bloomEnabled ? '1' : '0');
params.set('sharpen', this._sharpenEnabled ? '1' : '0');
params.set('bi', Math.round(this._bloomIntensity).toString());
params.set('bv', Math.round(this._bloomVersion).toString());
params.set('si', Math.round(this._sharpenIntensity).toString());
params.set('hud', this._hudVariant);
params.set('hv', this._hudVisible ? '1' : '0');
params.set('dm', this._detectionMode);
params.set('dd', Math.round(this._detectionDensity).toString());
params.set('da', this._detectionAllocation.toLowerCase());
params.set('kf', Math.round(this._detectionFadePct).toString());
params.set('ko', Math.round(this._detectionOutsideOpacityPct).toString());
params.set('cr', this._celestialRingEnabled ? '1' : '0');
params.set('sc', this._scopeEnabled ? '1' : '0');
params.set('scf', Math.round(this._scopeFeatherPct).toString());
// Only written when pinned — an absent `sce` IS the adaptive default, so a
// shared link never freezes the ramp for the recipient by accident. The
// same 94..100 clamp applies on the way OUT, so a link can never carry an
// unsupported terminus even if the field was set from somewhere else.
const terminusPct = clampScopeTerminusPct(this._scopeTerminusPct);
if (terminusPct != null) params.set('sce', String(terminusPct));
params.set('map', this._mapStack);
const layerState = this._layerStateProvider?.();
if (layerState) encodeLayerStateParams(params, layerState);
this._encodePanelStateParam(params, this._panelStateProvider?.());
encodeStyleParamState(
params,
this._currentStyle,
this._styleParamStateProvider?.(this._currentStyle),
);
// Creation-time metadata is intentionally absent here. `buildLink()`
// stamps its own link without aging the live address.
params.delete(SHARE_CREATED_AT_PARAM);
return params;
}
/** Cancel owned work and release listeners without disturbing newer navigation. */
destroy() {
if (this._destroyed) return;
const activeFlight = this._activeCameraFlight;
if (
activeFlight &&
this._isNavigationCurrent(activeFlight.navigationToken)
) {
this._cancelOwnedNavigation?.();
}
activeFlight?.settle?.('destroyed');
this._restoreGeneration += 1;
this._activeCameraFlight = null;
this._destroyed = true;
clearTimeout(this._debounceTimer);
this._debounceTimer = null;
this._removeCameraChanged?.();
this._removeCameraChanged = null;
this._unwatchNavigation?.();
this._layerStateProvider = null;
this._panelStateProvider = null;
this._styleParamStateProvider = null;
this._followSubjectProvider = null;
this._onRestore = null;
}
}
/** Decode a strict positive epoch-seconds copy timestamp for age classification. */
export function decodeShareCreatedAtMs(params, { nowMs = Date.now() } = {}) {
const raw = params?.get?.(SHARE_CREATED_AT_PARAM);
if (typeof raw !== 'string' || !/^[1-9]\d*$/.test(raw)) return null;
const seconds = Number(raw);
if (!Number.isSafeInteger(seconds)) return null;
const timestampMs = seconds * 1000;
if (!Number.isSafeInteger(timestampMs) || timestampMs > nowMs) return null;
return timestampMs;
}
/** Encode allowlisted parameters for the active visual preset. */
export function encodeStyleParamState(params, styleName, values) {
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
if (!registry || !values || typeof values !== 'object') {
params.delete(SHARE_STYLE_PARAMS_PARAM);
return;
}
const assignments = [];
for (const spec of registry) {
const numeric = Number(values[spec.key]);
if (!Number.isFinite(numeric)) continue;
const clamped = Math.max(spec.min, Math.min(spec.max, numeric));
assignments.push(`${spec.token}.${Math.round(clamped * 100)}`);
}
if (assignments.length)
params.set(SHARE_STYLE_PARAMS_PARAM, assignments.join('_'));
else params.delete(SHARE_STYLE_PARAMS_PARAM);
}
/** Decode allowlisted parameters for the selected visual preset. */
export function decodeStyleParamState(params, styleName) {
if (params.get('v') !== '2' || !params.has(SHARE_STYLE_PARAMS_PARAM))
return null;
const registry = SHARE_STYLE_PARAM_REGISTRY[styleName];
if (!registry) return null;
const byToken = new Map(registry.map((spec) => [spec.token, spec]));
const decoded = {};
for (const assignment of String(
params.get(SHARE_STYLE_PARAMS_PARAM) || '',
).split('_')) {
const [token, scaledRaw, ...extra] = assignment.split('.');
if (extra.length || !/^-?\d+$/.test(scaledRaw || '')) continue;
const spec = byToken.get(token);
if (!spec) continue;
const numeric = Number(scaledRaw) / 100;
decoded[spec.key] = Math.max(spec.min, Math.min(spec.max, numeric));
}
return Object.keys(decoded).length ? decoded : null;
}
/** Decode the shareable collapsed and pinned state for known panels. */
export function decodePanelStateParams(params) {
if (params.get('v') !== '2' || !params.has(SHARE_UI_STATE_PARAM)) return null;
const raw = String(params.get(SHARE_UI_STATE_PARAM) || '').trim();
if (!raw) return null;
const stateById = new Map();
for (const assignment of raw.split('_')) {
if (!assignment) continue;
const [token, field, value, ...extra] = assignment.split('.');
if (extra.length) continue;
const spec = SHARE_PANEL_STATE_BY_TOKEN.get(token);
if (!spec || (field !== 'c' && field !== 'p')) continue;
if (value !== '0' && value !== '1') continue;
const bool = value === '1';
const current = stateById.get(spec.id) || {
id: spec.id,
collapsed: null,
pinned: null,
};
if (field === 'c') current.collapsed = bool;
else if (field === 'p' && spec.pinnable) current.pinned = bool;
stateById.set(spec.id, current);
}
const specs = Array.from(stateById.values()).filter(
(entry) => typeof entry.collapsed === 'boolean',
);
return specs.length ? { specs } : null;
}