mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 18:34:02 +00:00
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
This commit is contained in:
1 parent
fefd5cdcf1
commit
da02fd8645
28 files changed
+5605
-97
No files matched your search
Vendored
+8
-1
@@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js';
|
||||
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
|
||||
import { t } from '../i18n/index.js';
|
||||
|
||||
/** Construct the existing controls and camera presentation. */
|
||||
/**
|
||||
* Construct the existing controls and camera presentation. `initialShareCode`
|
||||
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
|
||||
* whose address does not carry one.
|
||||
*/
|
||||
export function createApplicationControls({
|
||||
scene: { viewer, mapStackController, operations },
|
||||
loaderStatus,
|
||||
@@ -13,6 +17,7 @@ export function createApplicationControls({
|
||||
catalog,
|
||||
placeSearch,
|
||||
defer,
|
||||
initialShareCode = null,
|
||||
}) {
|
||||
// Initialize the style manager (post-processing, HUD, locations, share links)
|
||||
const styleManager = new Controls(viewer, {
|
||||
@@ -27,6 +32,8 @@ export function createApplicationControls({
|
||||
requestServices: operations.requests,
|
||||
mapStackController,
|
||||
placeSearch,
|
||||
// A share code to open at start-up instead of the page's address.
|
||||
initialShareCode,
|
||||
});
|
||||
defer(() => styleManager.orbitController.stop());
|
||||
defer(() => styleManager.hud.destroy());
|
||||
|
||||
@@ -23,7 +23,7 @@ const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
|
||||
* 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}$/;
|
||||
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
|
||||
|
||||
@@ -302,6 +302,11 @@
|
||||
"CLOUD UNKNOWN": "NUBES DESCONOCIDAS",
|
||||
"Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados",
|
||||
"Link copied!": "¡Enlace copiado!",
|
||||
"Link shared": "Enlace compartido",
|
||||
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||
"Copy failed": "No se pudo copiar",
|
||||
"Location not found": "Ubicación no encontrada",
|
||||
"Search failed": "No se pudo completar la búsqueda",
|
||||
|
||||
@@ -294,6 +294,11 @@
|
||||
"Scene run complete": "Воспроизведение сцены завершено",
|
||||
"CCTV layer unavailable": "Слой CCTV недоступен",
|
||||
"Link copied!": "Ссылка скопирована!",
|
||||
"Link shared": "Ссылка отправлена",
|
||||
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
|
||||
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||
"Copy failed": "Не удалось скопировать",
|
||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||
"LOCATION": "МЕСТО",
|
||||
|
||||
@@ -1477,6 +1477,11 @@
|
||||
"LIVE SIGNALS": "实时信号",
|
||||
"CCTV layer unavailable": "CCTV 图层不可用",
|
||||
"Link copied!": "链接已复制!",
|
||||
"Link shared": "链接已分享",
|
||||
"This share link can't be opened": "无法打开此分享链接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
|
||||
"Shared vessel is unavailable": "分享的船只不可用",
|
||||
"Copy failed": "复制失败",
|
||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||
"Tilt map to oblique view": "将地图倾斜至斜视角",
|
||||
|
||||
@@ -94,20 +94,27 @@ test('deferred terminal notices lose ownership to newer acquisition epochs and d
|
||||
|
||||
test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
|
||||
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
|
||||
assert.match(handler, /this\.showStatus\(message\)/);
|
||||
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/);
|
||||
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/);
|
||||
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
||||
assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
|
||||
assert.doesNotMatch(handler, /this\._showToast\(message\)/);
|
||||
assert.doesNotMatch(handler, /pushCockpitSignal/);
|
||||
const present = shellMethod('_presentAfterStartupCover').toString();
|
||||
assert.match(
|
||||
handler,
|
||||
/this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/,
|
||||
);
|
||||
assert.match(present, /this\.showStatus\(message\)/);
|
||||
assert.match(present, /this\._lifetime\.frame\(\(\) => \{/);
|
||||
assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/);
|
||||
assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/);
|
||||
assert.match(present, /canPresentDeferredStatusNotice\(/);
|
||||
for (const source of [handler, present]) {
|
||||
assert.doesNotMatch(source, /this\._showToast\(message\)/);
|
||||
assert.doesNotMatch(source, /pushCockpitSignal/);
|
||||
}
|
||||
assert.match(handler, /result\.classification === 'pending'/);
|
||||
assert.match(handler, /state: 'acquiring'/);
|
||||
assert.match(handler, /persistent: true/);
|
||||
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
|
||||
assert.match(handler, /canPresentDeferredStatusNotice\(/);
|
||||
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/);
|
||||
assert.match(handler, /result\.classification === 'followed'\s*\|\|\s*result\.classification === 'cancelled'/);
|
||||
assert.match(handler, /result\.classification === 'followed'/);
|
||||
assert.match(handler, /result\.classification === 'cancelled'\) return/);
|
||||
});
|
||||
|
||||
test('universal notice masks active loading only for its own fixed dwell', () => {
|
||||
|
||||
@@ -193,7 +193,8 @@ test('desktop panel lanes use per-panel allocations and presentation-only auto-c
|
||||
|
||||
test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
|
||||
const ui = readShellSource();
|
||||
const sharelink = readFileSync(new URL('./sharelink.js', import.meta.url), 'utf8');
|
||||
// The long form spells the share format's panel table (one source of truth).
|
||||
const panels = readFileSync(new URL('./share/viewCodec.js', import.meta.url), 'utf8');
|
||||
|
||||
assert.match(
|
||||
ui,
|
||||
@@ -211,7 +212,7 @@ test('share-panel state excludes responsive collapse and preserves recipient pre
|
||||
'pin and unpin must update the share hash even when collapse state is unchanged',
|
||||
);
|
||||
assert.match(ui, /\{ id: 'param-slider-panel' \}/);
|
||||
assert.match(sharelink, /\{ id: 'param-slider-panel', token: 'm', pinnable: false \}/);
|
||||
assert.match(panels, /id: 'param-slider-panel',\s*token: 'm',\s*pinnable: false,/);
|
||||
});
|
||||
|
||||
test('parameterized Display presets keep one stable scroll owner', () => {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* What a share link's thing view follows, and where its camera is: the
|
||||
* entity kinds and the follow-offset geometry of the share format
|
||||
* (docs/SHARE-LINK-FORMAT.md). Like the rest of the format, every code,
|
||||
* default and formula here is permanent.
|
||||
*
|
||||
* Pure and dependency-free, so the app's follow camera and the codec
|
||||
* (viewCodec.js) share one definition.
|
||||
*/
|
||||
|
||||
const freezeAll = (rows) =>
|
||||
Object.freeze(rows.map((row) => Object.freeze(row)));
|
||||
|
||||
/**
|
||||
* Things a view can follow. `code` is permanent and is also the number of the
|
||||
* thing-message field that carries this kind's id, so the kind costs nothing
|
||||
* on the wire. `idForm` picks the compact numeric spelling (a 24-bit ICAO
|
||||
* address or a decimal number); any other identifier travels as text.
|
||||
* `tracking` names the layer-state slot that holds the same selection in the
|
||||
* long form.
|
||||
*/
|
||||
export const SHARE_VIEW_ENTITY_KINDS = freezeAll([
|
||||
{
|
||||
code: 1,
|
||||
kind: 'aircraft',
|
||||
layerId: 'flights',
|
||||
idForm: 'hex24',
|
||||
tracking: 'flights',
|
||||
},
|
||||
{
|
||||
code: 2,
|
||||
kind: 'military_aircraft',
|
||||
layerId: 'military',
|
||||
idForm: 'hex24',
|
||||
tracking: 'military',
|
||||
},
|
||||
{
|
||||
code: 3,
|
||||
kind: 'vessel',
|
||||
layerId: 'ais-live-vessels',
|
||||
idForm: 'decimal',
|
||||
tracking: null,
|
||||
},
|
||||
{
|
||||
code: 4,
|
||||
kind: 'satellite',
|
||||
layerId: 'satellites',
|
||||
idForm: 'decimal',
|
||||
tracking: 'satellites',
|
||||
},
|
||||
]);
|
||||
/**
|
||||
* The frozen meaning of an omitted follow-offset field. An offset whose
|
||||
* fields all equal these is written as nothing, so it decodes as these.
|
||||
*/
|
||||
export const SHARE_VIEW_FOLLOW_OFFSET_DEFAULT = Object.freeze({
|
||||
range: 2000,
|
||||
heading: 0,
|
||||
pitch: -35,
|
||||
});
|
||||
|
||||
const EARTH_RADIUS_M = 6_371_000;
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/**
|
||||
* The camera's offset from a followed entity in the entity's local east,
|
||||
* north and up metres: the camera looks along (heading, pitch) at the entity
|
||||
* from `range` metres away. Heading is clockwise from north; a negative pitch
|
||||
* looks down, so the camera sits above the entity.
|
||||
*/
|
||||
export function followOffsetToEnu({ range, heading, pitch }) {
|
||||
const horizontal = range * Math.cos(pitch * RAD);
|
||||
return {
|
||||
east: -horizontal * Math.sin(heading * RAD),
|
||||
north: -horizontal * Math.cos(heading * RAD),
|
||||
up: -range * Math.sin(pitch * RAD),
|
||||
};
|
||||
}
|
||||
|
||||
/** The inverse of {@link followOffsetToEnu}, in whole metres and degrees. */
|
||||
export function followOffsetFromEnu({ east, north, up }) {
|
||||
const range = Math.hypot(east, north, up);
|
||||
if (!(range > 0)) return null;
|
||||
return {
|
||||
range: Math.round(range),
|
||||
heading: (Math.round(Math.atan2(-east, -north) / RAD) + 360) % 360,
|
||||
pitch: Math.round(Math.asin(Math.max(-1, Math.min(1, -up / range))) / RAD),
|
||||
};
|
||||
}
|
||||
|
||||
/** Earth-centred position of (lat, lon, alt) on the format's sphere. */
|
||||
function toEcef(lat, lon, alt) {
|
||||
const r = EARTH_RADIUS_M + alt;
|
||||
const cosLat = Math.cos(lat * RAD);
|
||||
return [
|
||||
r * cosLat * Math.cos(lon * RAD),
|
||||
r * cosLat * Math.sin(lon * RAD),
|
||||
r * Math.sin(lat * RAD),
|
||||
];
|
||||
}
|
||||
|
||||
/** The local east, north and up unit vectors at (lat, lon). */
|
||||
function enuBasis(lat, lon) {
|
||||
const sinLat = Math.sin(lat * RAD);
|
||||
const cosLat = Math.cos(lat * RAD);
|
||||
const sinLon = Math.sin(lon * RAD);
|
||||
const cosLon = Math.cos(lon * RAD);
|
||||
return {
|
||||
east: [-sinLon, cosLon, 0],
|
||||
north: [-sinLat * cosLon, -sinLat * sinLon, cosLat],
|
||||
up: [cosLat * cosLon, cosLat * sinLon, sinLat],
|
||||
};
|
||||
}
|
||||
|
||||
const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
|
||||
/**
|
||||
* Where the camera sits while following an entity at `fix` from `offset`,
|
||||
* and how it looks back at the entity, on a sphere of radius 6 371 000 m. A
|
||||
* thing view with a fix stores no camera of its own: this is its camera, and
|
||||
* the place a decoder falls back to. Pure, and computed only from values at
|
||||
* the format's precision, so encoder and decoder agree.
|
||||
*/
|
||||
export function followCameraFor(fix, offset) {
|
||||
const local = followOffsetToEnu(offset);
|
||||
const at = toEcef(fix.lat, fix.lon, fix.alt);
|
||||
const basis = enuBasis(fix.lat, fix.lon);
|
||||
const camera = [0, 1, 2].map(
|
||||
(i) =>
|
||||
at[i] +
|
||||
local.east * basis.east[i] +
|
||||
local.north * basis.north[i] +
|
||||
local.up * basis.up[i],
|
||||
);
|
||||
const horizontal = Math.hypot(camera[0], camera[1]);
|
||||
const lat = Math.atan2(camera[2], horizontal) / RAD;
|
||||
const lon = Math.atan2(camera[1], camera[0]) / RAD;
|
||||
const alt = Math.hypot(horizontal, camera[2]) - EARTH_RADIUS_M;
|
||||
// The view direction, camera to entity, in the camera's own local frame.
|
||||
const view = [0, 1, 2].map((i) => at[i] - camera[i]);
|
||||
const own = enuBasis(lat, lon);
|
||||
const length = Math.hypot(...view);
|
||||
if (!(length > 0))
|
||||
return { ...fix, heading: offset.heading, pitch: offset.pitch, roll: 0 };
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
alt,
|
||||
heading:
|
||||
(Math.atan2(dot(view, own.east), dot(view, own.north)) / RAD + 360) % 360,
|
||||
pitch:
|
||||
Math.asin(Math.max(-1, Math.min(1, dot(view, own.up) / length))) / RAD,
|
||||
roll: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Conversion between the long share-link form's parsed state (what
|
||||
* `ShareLinkManager.parseInitialHash()` returns for `#v=2&lat=…`) and the
|
||||
* compact share-view state of viewCodec.js.
|
||||
*
|
||||
* The two use the same field names, so the conversion is small: the compact
|
||||
* form adds `view`, and the long form keeps a followed entity as a tracking
|
||||
* selection inside the layer options instead.
|
||||
*/
|
||||
|
||||
import {
|
||||
SHARE_TRACKING_RESTORE_POLICIES,
|
||||
createDefaultLayerState,
|
||||
normalizeLayerState,
|
||||
} from '../data/layerState.js';
|
||||
import {
|
||||
SHARE_VIEW_ENTITY_KINDS,
|
||||
normalizeShareViewState,
|
||||
} from './viewCodec.js';
|
||||
|
||||
/** Fields of the long form's parsed state, in its own order. */
|
||||
export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
|
||||
'lat',
|
||||
'lon',
|
||||
'alt',
|
||||
'heading',
|
||||
'pitch',
|
||||
'roll',
|
||||
'style',
|
||||
'styleParams',
|
||||
'bloom',
|
||||
'sharpen',
|
||||
'bloomIntensity',
|
||||
'bloomVersion',
|
||||
'sharpenIntensity',
|
||||
'hudVariant',
|
||||
'hudVisible',
|
||||
'detectionMode',
|
||||
'detectionDensity',
|
||||
'detectionAllocation',
|
||||
'detectionFadePct',
|
||||
'detectionOutsideOpacityPct',
|
||||
'celestialRing',
|
||||
'scopeEnabled',
|
||||
'scopeFeatherPct',
|
||||
'scopeTerminusPct',
|
||||
'mapStack',
|
||||
'layerState',
|
||||
'layerStateInvalid',
|
||||
'panelState',
|
||||
'sharedAtMs',
|
||||
]);
|
||||
|
||||
/**
|
||||
* A compact share-view state from a parsed long-form state. A tracking
|
||||
* selection in the layer options becomes a thing view with no offset or
|
||||
* last-known fix, since the long form records neither. Restore bookkeeping
|
||||
* (`restoreAuthority`, `layerStateInvalid`) is not part of a view.
|
||||
*/
|
||||
export function fromLegacyShareState(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const picked = {};
|
||||
for (const key of LEGACY_SHARE_STATE_KEYS) {
|
||||
if (key !== 'layerStateInvalid' && Object.hasOwn(parsed, key))
|
||||
picked[key] = parsed[key];
|
||||
}
|
||||
return normalizeShareViewState(picked);
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed long-form state for a compact share-view state, ready for
|
||||
* `ShareLinkManager.applyState`. A thing view's layer is on (in a fresh layer
|
||||
* set when the view has none), and a followed aircraft, military aircraft or
|
||||
* satellite goes back into its layer's tracking selection. The long form
|
||||
* cannot express the rest of a thing view (a vessel, the follow offset, the
|
||||
* last-known fix); callers that need them read `state.view` directly.
|
||||
*/
|
||||
export function toLegacyShareState(input) {
|
||||
const state = normalizeShareViewState(input);
|
||||
const entry =
|
||||
state.view.kind === 'thing'
|
||||
? SHARE_VIEW_ENTITY_KINDS.find((e) => e.kind === state.view.entity.kind)
|
||||
: null;
|
||||
let layerState =
|
||||
entry && !state.layerState
|
||||
? normalizeLayerState({
|
||||
...createDefaultLayerState(),
|
||||
enabledLayerIds: [entry.layerId],
|
||||
})
|
||||
: state.layerState;
|
||||
const policy = entry?.tracking
|
||||
? SHARE_TRACKING_RESTORE_POLICIES[entry.tracking]
|
||||
: null;
|
||||
if (
|
||||
layerState &&
|
||||
policy &&
|
||||
layerState.enabledLayerIds.includes(entry.layerId)
|
||||
) {
|
||||
const id =
|
||||
entry.kind === 'satellite'
|
||||
? Number(state.view.entity.id)
|
||||
: state.view.entity.id;
|
||||
layerState = normalizeLayerState({
|
||||
...layerState,
|
||||
options: {
|
||||
...layerState.options,
|
||||
[policy.optionOwner]: {
|
||||
...layerState.options[policy.optionOwner],
|
||||
[policy.optionKey]: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const legacy = {};
|
||||
for (const key of LEGACY_SHARE_STATE_KEYS) legacy[key] = state[key];
|
||||
legacy.layerState = layerState;
|
||||
legacy.layerStateInvalid = false;
|
||||
return legacy;
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { createDefaultLayerState } from '../data/layerState.js';
|
||||
import { HUD_LAYOUTS } from '../hudLayoutPolicy.js';
|
||||
import { VIEW_MAPS, VIEW_STYLES } from '../view/index.js';
|
||||
import {
|
||||
HAL_EXAMPLE_HASH,
|
||||
pick,
|
||||
randomLayerState,
|
||||
rng,
|
||||
} from '../testSupport/shareViewFixtures.mjs';
|
||||
import {
|
||||
SHARE_VIEW_PANELS,
|
||||
SHARE_VIEW_STYLE_PARAMS,
|
||||
decodeShareView,
|
||||
encodeShareView,
|
||||
} from './viewCodec.js';
|
||||
import { SHARE_VIEW_STYLE_DEFAULTS } from './viewCodec.js';
|
||||
import {
|
||||
LEGACY_SHARE_STATE_KEYS,
|
||||
fromLegacyShareState,
|
||||
toLegacyShareState,
|
||||
} from './legacyShareState.js';
|
||||
|
||||
const DEG = Math.PI / 180;
|
||||
|
||||
function stubViewer(camera = {}) {
|
||||
return {
|
||||
camera: {
|
||||
changed: { addEventListener: () => () => {} },
|
||||
positionCartographic: camera.position ?? {
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
height: 0,
|
||||
},
|
||||
heading: camera.heading ?? 0,
|
||||
pitch: camera.pitch ?? 0,
|
||||
roll: camera.roll ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The long form's own parser, run on `hash`. */
|
||||
function parseLegacy(hash) {
|
||||
const body = hash.startsWith('#') ? hash : `#${hash}`;
|
||||
globalThis.window = {
|
||||
location: { hash: body, href: `http://localhost/${body}` },
|
||||
};
|
||||
const manager = new ShareLinkManager(stubViewer());
|
||||
const parsed = manager.parseInitialHash();
|
||||
manager.destroy();
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parsed state as a view: restore bookkeeping removed, and headings and
|
||||
* rolls wrapped into [0, 360) — the codec's one normalization of values the
|
||||
* long-form generator writes (Cesium can round 359.6° up to `roll=360`).
|
||||
*/
|
||||
function comparable(parsed) {
|
||||
const out = {};
|
||||
for (const key of LEGACY_SHARE_STATE_KEYS) out[key] = parsed[key];
|
||||
// A long link without `sp` leaves the style's parameters as they are; the
|
||||
// compact form says what they are, the style's own unless changed.
|
||||
const ownParams = SHARE_VIEW_STYLE_DEFAULTS[out.style].styleParams;
|
||||
out.styleParams = ownParams ? { ...ownParams, ...out.styleParams } : null;
|
||||
for (const key of ['heading', 'roll'])
|
||||
out[key] = ((out[key] % 360) + 360) % 360;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Legacy hash → parsed state → codec → parsed state, asserting equality. */
|
||||
function assertLegacyRoundTrip(hash) {
|
||||
const parsed = parseLegacy(hash);
|
||||
assert.ok(parsed, `long form parses: ${hash}`);
|
||||
const state = fromLegacyShareState(parsed);
|
||||
const encoded = encodeShareView(state);
|
||||
const decoded = decodeShareView(encoded, { nowMs: Date.now() });
|
||||
assert.equal(decoded.ok, true, decoded.reason);
|
||||
assert.deepEqual(decoded.state, state, 'codec state survives');
|
||||
assert.deepEqual(
|
||||
toLegacyShareState(decoded.state),
|
||||
comparable(parsed),
|
||||
'parsed long-form state survives',
|
||||
);
|
||||
return { parsed, state, encoded };
|
||||
}
|
||||
|
||||
test('the HAL example survives the round trip and shrinks to 39 characters', () => {
|
||||
const { parsed, state, encoded } = assertLegacyRoundTrip(HAL_EXAMPLE_HASH);
|
||||
assert.equal(HAL_EXAMPLE_HASH.length, 302);
|
||||
assert.equal(encoded.length, 39);
|
||||
assert.equal(encoded, 'ARCg-SQYrah3ILAJKB4wO1IEAIQAEGIBNMgBC1o');
|
||||
assert.equal(parsed.roll, 360);
|
||||
assert.equal(state.roll, 0);
|
||||
assert.deepEqual(state.layerState.enabledLayerIds, [
|
||||
'ais-live-vessels',
|
||||
'flights',
|
||||
'satellites',
|
||||
]);
|
||||
assert.equal(state.layerState.options.flights.models3d, true);
|
||||
assert.equal(state.layerState.options.wind.overlay, 'none');
|
||||
assert.deepEqual(
|
||||
state.panelState.specs.find((spec) => spec.id === 'cctv-panel'),
|
||||
{ id: 'cctv-panel', collapsed: false, pinned: null },
|
||||
);
|
||||
});
|
||||
|
||||
test('links from earlier long-form versions keep their historical meaning', () => {
|
||||
for (const hash of [
|
||||
'lat=10&lon=20',
|
||||
'lat=37.7749&lon=-122.4194&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal',
|
||||
'v=2&lat=1&lon=2&l=f&lo=f.m.a',
|
||||
'v=2&lat=1&lon=2&l=',
|
||||
'v=2&lat=1&lon=2&l=k.r&lo=r.f.g-jazz_r.v.35',
|
||||
'v=2&lat=1&lon=2&ui=v.c.0',
|
||||
'v=2&lat=1&lon=2&style=crt&sp=p.500_d.25_i.100',
|
||||
'v=2&lat=1&lon=2&sce=0&scf=999&dm=GOD&dd=10&at=1767225600',
|
||||
]) {
|
||||
assertLegacyRoundTrip(hash);
|
||||
}
|
||||
// A pre-v2 link's omitted settings are written explicitly, so the compact
|
||||
// form keeps the fallbacks the long form gives them (ko=5, kf=16, scf=35).
|
||||
const old = fromLegacyShareState(parseLegacy('lat=10&lon=20'));
|
||||
assert.deepEqual(
|
||||
[old.detectionOutsideOpacityPct, old.detectionFadePct, old.scopeFeatherPct],
|
||||
[5, 16, 35],
|
||||
);
|
||||
assert.deepEqual(
|
||||
[old.bloomIntensity, old.bloomVersion],
|
||||
[50, 1],
|
||||
'legacy bloom scale',
|
||||
);
|
||||
// `l=f` without `e` is a v2 link with 3D models OFF; the codec writes that.
|
||||
const off = fromLegacyShareState(parseLegacy('v=2&lat=1&lon=2&l=f'));
|
||||
assert.equal(off.layerState.options.flights.models3d, false);
|
||||
});
|
||||
|
||||
test('a tracking selection becomes a thing view and back', () => {
|
||||
const flights = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=f&lo=f.t.a1b2c3');
|
||||
assert.deepEqual(flights.state.view.entity, {
|
||||
kind: 'aircraft',
|
||||
id: 'a1b2c3',
|
||||
});
|
||||
assert.equal(
|
||||
flights.state.layerState.options.flights.selectedFlightsTrackingId,
|
||||
null,
|
||||
);
|
||||
const military = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=m&lo=f.u.~ae1234');
|
||||
assert.deepEqual(military.state.view.entity, {
|
||||
kind: 'military_aircraft',
|
||||
id: '~ae1234',
|
||||
});
|
||||
const satellite = assertLegacyRoundTrip('v=2&lat=1&lon=2&l=s&lo=s.t.25544');
|
||||
assert.deepEqual(satellite.state.view.entity, {
|
||||
kind: 'satellite',
|
||||
id: '25544',
|
||||
});
|
||||
assert.equal(
|
||||
toLegacyShareState(satellite.state).layerState.options.satellites
|
||||
.selectedSatTrackingId,
|
||||
25544,
|
||||
);
|
||||
});
|
||||
|
||||
test('thing views the long form cannot express convert without one', () => {
|
||||
const layerState = createDefaultLayerState();
|
||||
layerState.enabledLayerIds = ['ais-live-vessels'];
|
||||
const vessel = toLegacyShareState({
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
layerState,
|
||||
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
|
||||
});
|
||||
assert.deepEqual(vessel.layerState, layerState);
|
||||
});
|
||||
|
||||
test('a thing view turns its layer on, with or without a layer set', () => {
|
||||
for (const layerState of [createDefaultLayerState(), null]) {
|
||||
const legacy = toLegacyShareState({
|
||||
layerState,
|
||||
view: { kind: 'thing', entity: { kind: 'aircraft', id: 'a1b2c3' } },
|
||||
});
|
||||
assert.deepEqual(legacy.layerState.enabledLayerIds, ['flights']);
|
||||
assert.equal(
|
||||
legacy.layerState.options.flights.selectedFlightsTrackingId,
|
||||
'a1b2c3',
|
||||
);
|
||||
assert.equal(legacy.layerStateInvalid, false);
|
||||
}
|
||||
const vessel = toLegacyShareState({
|
||||
view: { kind: 'thing', entity: { kind: 'vessel', id: '366999712' } },
|
||||
});
|
||||
assert.deepEqual(vessel.layerState.enabledLayerIds, ['ais-live-vessels']);
|
||||
// A place view without a layer set leaves the recipient's layers alone.
|
||||
assert.equal(toLegacyShareState({ lat: 1, lon: 2 }).layerState, null);
|
||||
});
|
||||
|
||||
/** A long-form link written by the real generator from random live state. */
|
||||
function generatedLegacyHash(random) {
|
||||
const int = (min, max) => min + Math.floor(random() * (max - min + 1));
|
||||
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||
const manager = new ShareLinkManager(
|
||||
stubViewer({
|
||||
position: {
|
||||
latitude: (int(-899999, 899999) / 1e4) * DEG,
|
||||
longitude: (int(-1799999, 1799999) / 1e4) * DEG,
|
||||
height: pick(random, [int(-50, 3000), int(0, 30_000_000)]),
|
||||
},
|
||||
heading: int(0, 359) * DEG + (random() < 0.1 ? 0.99 * DEG : 0),
|
||||
pitch: int(-90, 0) * DEG,
|
||||
roll: pick(random, [0, 359.7 * DEG, int(0, 359) * DEG]),
|
||||
}),
|
||||
);
|
||||
const style = pick(random, VIEW_STYLES);
|
||||
manager.onStyleChange(style);
|
||||
const density = pick(random, [0, 25, 50, 75, 100]);
|
||||
manager.onToggleChange(random() < 0.5, random() < 0.5, {
|
||||
bloomIntensity: int(0, 100),
|
||||
bloomVersion: pick(random, [1, 2]),
|
||||
sharpenIntensity: int(0, 100),
|
||||
hudVariant: pick(random, HUD_LAYOUTS),
|
||||
hudVisible: random() < 0.5,
|
||||
detectionMode: pick(random, ['OFF', 'SPARSE', 'BALANCED', 'DENSE']),
|
||||
detectionDensity: density,
|
||||
detectionAllocation: pick(random, ['ELASTIC', 'WEIGHTED']),
|
||||
detectionFadePct: int(0, 40),
|
||||
detectionOutsideOpacityPct: int(0, 100),
|
||||
celestialRingEnabled: random() < 0.5,
|
||||
scopeEnabled: random() < 0.5,
|
||||
scopeFeatherPct: int(0, 200),
|
||||
scopeTerminusPct: random() < 0.3 ? int(94, 100) : null,
|
||||
mapStack: pick(random, VIEW_MAPS),
|
||||
});
|
||||
if (random() < 0.8) {
|
||||
const layerState = randomLayerState(random);
|
||||
manager.setLayerStateProvider(() => layerState);
|
||||
}
|
||||
if (random() < 0.8) {
|
||||
const panelState = {
|
||||
specs: SHARE_VIEW_PANELS.filter(() => random() < 0.9).map((panel) => ({
|
||||
id: panel.id,
|
||||
collapsed: random() < 0.7,
|
||||
...(panel.pinnable ? { pinned: random() < 0.3 } : {}),
|
||||
})),
|
||||
};
|
||||
manager.setPanelStateProvider(() => panelState);
|
||||
}
|
||||
const specs = SHARE_VIEW_STYLE_PARAMS[style];
|
||||
if (specs && random() < 0.7) {
|
||||
const values = Object.fromEntries(
|
||||
specs.map((spec) => [
|
||||
spec.key,
|
||||
spec.min + (spec.max - spec.min) * random(),
|
||||
]),
|
||||
);
|
||||
manager.setStyleParamStateProvider(() => values);
|
||||
}
|
||||
const params = manager._buildHashParams();
|
||||
if (random() < 0.7)
|
||||
params.set('at', String(int(1_600_000_000, Math.floor(Date.now() / 1000))));
|
||||
manager.destroy();
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
test('links written by the long-form generator survive the compact form', () => {
|
||||
const random = rng(0xc0dec);
|
||||
let totalLegacy = 0;
|
||||
let totalCompact = 0;
|
||||
for (let i = 0; i < 400; i += 1) {
|
||||
const hash = generatedLegacyHash(random);
|
||||
const { encoded } = assertLegacyRoundTrip(hash);
|
||||
totalLegacy += hash.length;
|
||||
totalCompact += encoded.length;
|
||||
}
|
||||
// Even random states, far from defaults, are under half the length.
|
||||
assert.ok(
|
||||
totalCompact * 2 < totalLegacy,
|
||||
`${totalCompact} vs ${totalLegacy}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('the converter ignores non-objects and restore bookkeeping', () => {
|
||||
// A malformed v2 layer list is reported by the long-form parser; the view
|
||||
// itself simply has no layer set.
|
||||
const invalid = parseLegacy('v=2&lat=1&lon=2&l=f.unknown');
|
||||
assert.equal(invalid.layerStateInvalid, true);
|
||||
assert.equal(fromLegacyShareState(invalid).layerState, null);
|
||||
assert.equal(fromLegacyShareState(null), null);
|
||||
const parsed = parseLegacy(HAL_EXAMPLE_HASH);
|
||||
assert.ok(parsed.restoreAuthority);
|
||||
const state = fromLegacyShareState(parsed);
|
||||
assert.equal(Object.hasOwn(state, 'restoreAuthority'), false);
|
||||
assert.equal(Object.hasOwn(state, 'layerStateInvalid'), false);
|
||||
});
|
||||
|
||||
test('the converter knows every key of the long form’s parsed state', () => {
|
||||
// A key the converter does not list would silently drop out of share codes.
|
||||
globalThis.window = { location: { hash: '', href: 'http://localhost/' } };
|
||||
const manager = new ShareLinkManager(stubViewer({}));
|
||||
const parsed = manager._parseShareHash('v=2&lat=1&lon=2');
|
||||
assert.deepEqual(
|
||||
Object.keys(parsed).sort(),
|
||||
[...LEGACY_SHARE_STATE_KEYS].sort(),
|
||||
);
|
||||
manager.destroy();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Share-link addresses: a share code travels in the path, `<app>/share/<code>`,
|
||||
* where `<app>` is wherever the app is served (the origin's root, or a base
|
||||
* path). The same code opens on any copy of the app.
|
||||
*
|
||||
* Pure: no DOM. Callers pass the addresses they have.
|
||||
*/
|
||||
|
||||
/** The path segment that introduces a share code. */
|
||||
export const SHARE_PATH_SEGMENT = 'share';
|
||||
|
||||
// Any last segment counts as a code here, so a mangled one is refused by the
|
||||
// decoder (and said so) rather than opening the app at a wrong address.
|
||||
const SHARE_PATH = new RegExp(`^(.*/)${SHARE_PATH_SEGMENT}/([^/]+)/?$`);
|
||||
|
||||
/**
|
||||
* The share code and the app's base path for a share-link pathname, or null
|
||||
* when the pathname is not a share link. `/share/AQc` gives
|
||||
* `{ code: 'AQc', basePath: '/' }`; `/gev/share/AQc` gives basePath `/gev/`.
|
||||
*/
|
||||
export function parseSharePath(pathname) {
|
||||
if (typeof pathname !== 'string') return null;
|
||||
const match = SHARE_PATH.exec(pathname);
|
||||
if (!match) return null;
|
||||
let code = match[2];
|
||||
try {
|
||||
code = decodeURIComponent(code);
|
||||
} catch {
|
||||
// Left as written; the decoder refuses it.
|
||||
}
|
||||
return { code, basePath: match[1] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's root address for `href`: the base path of a share link, or the
|
||||
* directory of any other page, with no hash. The query is kept.
|
||||
*/
|
||||
export function appRootUrl(href) {
|
||||
const url = new URL(href);
|
||||
url.hash = '';
|
||||
const share = parseSharePath(url.pathname);
|
||||
url.pathname = share ? share.basePath : new URL('./', url).pathname;
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* The share link for `code` on the app at `href`. The sender's `?lang` stays
|
||||
* behind: the recipient keeps their own language.
|
||||
*/
|
||||
export function shareLinkUrl(code, href) {
|
||||
const root = appRootUrl(href);
|
||||
const url = new URL(`${SHARE_PATH_SEGMENT}/${code}`, root);
|
||||
url.search = root.search;
|
||||
url.searchParams.delete('lang');
|
||||
return url.href;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { appRootUrl, parseSharePath, shareLinkUrl } from './shareLinkPath.js';
|
||||
|
||||
test('a share path names its code and the app base path', () => {
|
||||
assert.deepEqual(parseSharePath('/share/AQc'), {
|
||||
code: 'AQc',
|
||||
basePath: '/',
|
||||
});
|
||||
assert.deepEqual(parseSharePath('/gev/share/AQc/'), {
|
||||
code: 'AQc',
|
||||
basePath: '/gev/',
|
||||
});
|
||||
// A mangled code is still a share link, so the app refuses it out loud.
|
||||
assert.equal(parseSharePath('/share/AQ.c').code, 'AQ.c');
|
||||
for (const path of ['/', '/share', '/share/', '/share/a/b', '/sharex/AQc'])
|
||||
assert.equal(parseSharePath(path), null, path);
|
||||
assert.equal(parseSharePath(undefined), null);
|
||||
});
|
||||
|
||||
test('the app root drops a share path and the hash, and keeps the query', () => {
|
||||
assert.equal(
|
||||
appRootUrl('https://gev.example/share/AQc?qa=1#v=2').href,
|
||||
'https://gev.example/?qa=1',
|
||||
);
|
||||
assert.equal(
|
||||
appRootUrl('http://localhost:4173/gev/share/AQc').href,
|
||||
'http://localhost:4173/gev/',
|
||||
);
|
||||
assert.equal(
|
||||
appRootUrl('https://gev.example/index.html#v=2&lat=1').href,
|
||||
'https://gev.example/',
|
||||
);
|
||||
});
|
||||
|
||||
test('a share link sits under the app root without the sender ?lang', () => {
|
||||
assert.equal(
|
||||
shareLinkUrl('AQc', 'https://gev.example/?lang=ru#v=2&lat=1'),
|
||||
'https://gev.example/share/AQc',
|
||||
);
|
||||
assert.equal(
|
||||
shareLinkUrl('AQc', 'https://gev.example/gev/share/old?qa=1&lang=es'),
|
||||
'https://gev.example/gev/share/AQc?qa=1',
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Byte-level primitives for the compact share-view format: base64url text,
|
||||
* protobuf-style varints, and a tag-length-value reader and writer.
|
||||
*
|
||||
* Pure: no DOM, no Cesium, no platform buffers. Every integrity failure is a
|
||||
* {@link ShareWireError} with a stable `reason`, so the view codec can turn it
|
||||
* into a rejected link instead of an exception at app start.
|
||||
*/
|
||||
|
||||
/** Wire types. Field numbers and their wire types are permanent. */
|
||||
export const WIRE_VARINT = 0;
|
||||
export const WIRE_FIXED64 = 1;
|
||||
export const WIRE_LEN = 2;
|
||||
export const WIRE_FIXED32 = 5;
|
||||
|
||||
/** Field numbers stay inside protobuf's range so tags fit in five bytes. */
|
||||
export const MAX_FIELD_NUMBER = 2 ** 29 - 1;
|
||||
const MAX_VARINT_BYTES = 8; // 56 bits covers every safe integer.
|
||||
|
||||
/** A malformed or out-of-bounds payload. `reason` is a stable code. */
|
||||
export class ShareWireError extends Error {
|
||||
constructor(reason, detail = '') {
|
||||
super(detail ? `${reason}: ${detail}` : reason);
|
||||
this.name = 'ShareWireError';
|
||||
this.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE64URL =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
const BASE64URL_INDEX = new Map(
|
||||
[...BASE64URL].map((char, index) => [char, index]),
|
||||
);
|
||||
|
||||
/** Unpadded base64url text for `bytes`. */
|
||||
export function encodeBase64Url(bytes) {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i];
|
||||
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
|
||||
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
|
||||
const triple = (a << 16) | (b << 8) | c;
|
||||
out += BASE64URL[(triple >> 18) & 63] + BASE64URL[(triple >> 12) & 63];
|
||||
if (i + 1 < bytes.length) out += BASE64URL[(triple >> 6) & 63];
|
||||
if (i + 2 < bytes.length) out += BASE64URL[triple & 63];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes for unpadded base64url text. Rejects padding, foreign characters, an
|
||||
* impossible length and non-zero trailing bits, so every payload has exactly
|
||||
* one spelling.
|
||||
*/
|
||||
export function decodeBase64Url(text) {
|
||||
if (typeof text !== 'string' || text.length === 0)
|
||||
throw new ShareWireError('empty');
|
||||
if (text.length % 4 === 1) throw new ShareWireError('bad-base64');
|
||||
const bytes = new Uint8Array(Math.floor((text.length * 3) / 4));
|
||||
let buffer = 0;
|
||||
let bits = 0;
|
||||
let out = 0;
|
||||
for (const char of text) {
|
||||
const value = BASE64URL_INDEX.get(char);
|
||||
if (value === undefined) throw new ShareWireError('bad-base64');
|
||||
buffer = ((buffer << 6) | value) & 0xffffff;
|
||||
bits += 6;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
bytes[out++] = (buffer >> bits) & 0xff;
|
||||
}
|
||||
}
|
||||
if (bits > 0 && (buffer & ((1 << bits) - 1)) !== 0)
|
||||
throw new ShareWireError('bad-base64', 'non-zero trailing bits');
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* CRC-8 (polynomial x⁸ + x² + x + 1, 0x07; initial value 0; no reflection;
|
||||
* no final XOR) of `bytes`. A degree-8 CRC detects every error burst of up to
|
||||
* 8 bits, so any one mistyped base64url character (6 bits) is caught.
|
||||
*/
|
||||
export function crc8(bytes) {
|
||||
let crc = 0;
|
||||
for (const byte of bytes) {
|
||||
crc ^= byte;
|
||||
for (let bit = 0; bit < 8; bit += 1)
|
||||
crc = crc & 0x80 ? ((crc << 1) ^ 0x07) & 0xff : (crc << 1) & 0xff;
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/** Map a signed integer onto the unsigned varint space (0,-1,1,-2 → 0,1,2,3). */
|
||||
export function zigzag(value) {
|
||||
return value >= 0 ? value * 2 : -value * 2 - 1;
|
||||
}
|
||||
|
||||
/** Inverse of {@link zigzag}. */
|
||||
export function unzigzag(value) {
|
||||
return value % 2 === 0 ? value / 2 : -(value + 1) / 2;
|
||||
}
|
||||
|
||||
/** Largest magnitude a zigzag field can carry without losing precision. */
|
||||
export const MAX_ZIGZAG_MAGNITUDE = Math.floor(Number.MAX_SAFE_INTEGER / 2);
|
||||
|
||||
/** Append-only byte sink with protobuf-style field helpers. */
|
||||
export class WireWriter {
|
||||
constructor() {
|
||||
this.bytes = [];
|
||||
}
|
||||
|
||||
varint(value) {
|
||||
if (!Number.isSafeInteger(value) || value < 0)
|
||||
throw new RangeError(`varint out of range: ${value}`);
|
||||
let rest = value;
|
||||
while (rest >= 0x80) {
|
||||
this.bytes.push((rest % 0x80) | 0x80);
|
||||
rest = Math.floor(rest / 0x80);
|
||||
}
|
||||
this.bytes.push(rest);
|
||||
return this;
|
||||
}
|
||||
|
||||
tag(field, wireType) {
|
||||
if (!Number.isInteger(field) || field < 1 || field > MAX_FIELD_NUMBER)
|
||||
throw new RangeError(`field number out of range: ${field}`);
|
||||
return this.varint(field * 8 + wireType);
|
||||
}
|
||||
|
||||
uintField(field, value) {
|
||||
return this.tag(field, WIRE_VARINT).varint(value);
|
||||
}
|
||||
|
||||
sintField(field, value) {
|
||||
if (!Number.isSafeInteger(value) || Math.abs(value) > MAX_ZIGZAG_MAGNITUDE)
|
||||
throw new RangeError(`signed value out of range: ${value}`);
|
||||
return this.tag(field, WIRE_VARINT).varint(zigzag(value));
|
||||
}
|
||||
|
||||
bytesField(field, bytes) {
|
||||
this.tag(field, WIRE_LEN).varint(bytes.length);
|
||||
for (const byte of bytes) this.bytes.push(byte);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** ASCII-only text; every share-view string is drawn from ASCII grammars. */
|
||||
textField(field, text) {
|
||||
return this.bytesField(field, asciiBytes(text));
|
||||
}
|
||||
|
||||
finish() {
|
||||
return Uint8Array.from(this.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/** Printable-ASCII bytes for `text`; anything else is a programming error. */
|
||||
export function asciiBytes(text) {
|
||||
const bytes = [];
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code < 0x20 || code > 0x7e)
|
||||
throw new RangeError('share-view text must be printable ASCII');
|
||||
bytes.push(code);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Printable-ASCII text from bytes; anything else is a malformed payload. */
|
||||
export function asciiText(bytes) {
|
||||
let out = '';
|
||||
for (const byte of bytes) {
|
||||
if (byte < 0x20 || byte > 0x7e) throw new ShareWireError('bad-text');
|
||||
out += String.fromCharCode(byte);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Bounds-checked cursor over one message (or one packed blob). */
|
||||
export class WireReader {
|
||||
constructor(bytes, start = 0, end = bytes.length) {
|
||||
this.bytes = bytes;
|
||||
this.pos = start;
|
||||
this.end = end;
|
||||
}
|
||||
|
||||
get done() {
|
||||
return this.pos >= this.end;
|
||||
}
|
||||
|
||||
/** One canonical varint no larger than Number.MAX_SAFE_INTEGER. */
|
||||
varint() {
|
||||
let value = 0;
|
||||
let scale = 1;
|
||||
for (let count = 0; count < MAX_VARINT_BYTES; count += 1) {
|
||||
if (this.pos >= this.end) throw new ShareWireError('truncated');
|
||||
const byte = this.bytes[this.pos++];
|
||||
value += (byte & 0x7f) * scale;
|
||||
if ((byte & 0x80) === 0) {
|
||||
// A zero final byte after a continuation is an overlong spelling.
|
||||
if (count > 0 && byte === 0)
|
||||
throw new ShareWireError('non-canonical-varint');
|
||||
if (!Number.isSafeInteger(value))
|
||||
throw new ShareWireError('varint-overflow');
|
||||
return value;
|
||||
}
|
||||
scale *= 0x80;
|
||||
}
|
||||
throw new ShareWireError('varint-overflow');
|
||||
}
|
||||
|
||||
svarint() {
|
||||
return unzigzag(this.varint());
|
||||
}
|
||||
|
||||
/** The next field's number and wire type, rejecting reserved wire types. */
|
||||
tag() {
|
||||
const key = this.varint();
|
||||
const field = Math.floor(key / 8);
|
||||
const wireType = key % 8;
|
||||
if (field < 1 || field > MAX_FIELD_NUMBER)
|
||||
throw new ShareWireError('bad-field-number');
|
||||
if (
|
||||
wireType !== WIRE_VARINT &&
|
||||
wireType !== WIRE_FIXED64 &&
|
||||
wireType !== WIRE_LEN &&
|
||||
wireType !== WIRE_FIXED32
|
||||
)
|
||||
throw new ShareWireError('bad-wire-type', String(wireType));
|
||||
return { field, wireType };
|
||||
}
|
||||
|
||||
/** A length-delimited body as a sub-reader over the same bytes. */
|
||||
lengthDelimited() {
|
||||
const length = this.varint();
|
||||
if (length > this.end - this.pos)
|
||||
throw new ShareWireError('truncated', 'length exceeds payload');
|
||||
const reader = new WireReader(this.bytes, this.pos, this.pos + length);
|
||||
this.pos += length;
|
||||
return reader;
|
||||
}
|
||||
|
||||
/** Remaining bytes of this reader as a fresh array. */
|
||||
rest() {
|
||||
const out = this.bytes.slice(this.pos, this.end);
|
||||
this.pos = this.end;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Skip one value of `wireType`, so newer fields cost older decoders nothing. */
|
||||
skip(wireType) {
|
||||
if (wireType === WIRE_VARINT) this.varint();
|
||||
else if (wireType === WIRE_LEN) this.lengthDelimited();
|
||||
else {
|
||||
const size = wireType === WIRE_FIXED64 ? 8 : 4;
|
||||
if (size > this.end - this.pos) throw new ShareWireError('truncated');
|
||||
this.pos += size;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
ShareWireError,
|
||||
WIRE_FIXED32,
|
||||
WIRE_FIXED64,
|
||||
WIRE_LEN,
|
||||
WIRE_VARINT,
|
||||
WireReader,
|
||||
WireWriter,
|
||||
crc8,
|
||||
decodeBase64Url,
|
||||
encodeBase64Url,
|
||||
unzigzag,
|
||||
zigzag,
|
||||
} from './wire.js';
|
||||
|
||||
const reasonOf = (fn) => {
|
||||
try {
|
||||
fn();
|
||||
} catch (error) {
|
||||
assert.ok(error instanceof ShareWireError, String(error));
|
||||
return error.reason;
|
||||
}
|
||||
assert.fail('expected a ShareWireError');
|
||||
};
|
||||
|
||||
test('base64url round-trips every length and byte value without padding', () => {
|
||||
for (let length = 0; length < 70; length += 1) {
|
||||
const bytes = Uint8Array.from(
|
||||
{ length },
|
||||
(_, i) => (i * 97 + length) & 0xff,
|
||||
);
|
||||
const text = encodeBase64Url(bytes);
|
||||
assert.match(text, /^[A-Za-z0-9_-]*$/);
|
||||
if (length) assert.deepEqual(decodeBase64Url(text), bytes);
|
||||
}
|
||||
const all = Uint8Array.from({ length: 256 }, (_, i) => i);
|
||||
assert.deepEqual(decodeBase64Url(encodeBase64Url(all)), all);
|
||||
assert.equal(encodeBase64Url(Uint8Array.of(0xfb, 0xff)), '-_8');
|
||||
});
|
||||
|
||||
test('base64url rejects padding, foreign characters, bad lengths and stray bits', () => {
|
||||
for (const text of ['', 'AQ==', 'A+Q', 'A/Q', 'A Q', 'AQ%', 'A', 'AQAAA']) {
|
||||
assert.ok(
|
||||
['empty', 'bad-base64'].includes(reasonOf(() => decodeBase64Url(text))),
|
||||
text,
|
||||
);
|
||||
}
|
||||
// "AR" carries one byte (0x01) plus four trailing bits that must be zero.
|
||||
assert.equal(
|
||||
reasonOf(() => decodeBase64Url('AR')),
|
||||
'bad-base64',
|
||||
);
|
||||
assert.deepEqual(decodeBase64Url('AQ'), Uint8Array.of(1));
|
||||
});
|
||||
|
||||
test('zigzag maps small magnitudes of either sign to small codes', () => {
|
||||
assert.deepEqual([0, -1, 1, -2, 2].map(zigzag), [0, 1, 2, 3, 4]);
|
||||
for (const value of [0, 1, -1, 63, -64, 1e9, -1e9, 2 ** 51, -(2 ** 51)]) {
|
||||
assert.equal(unzigzag(zigzag(value)), value);
|
||||
}
|
||||
});
|
||||
|
||||
test('varints round-trip up to the largest safe integer', () => {
|
||||
const values = [
|
||||
0,
|
||||
1,
|
||||
127,
|
||||
128,
|
||||
300,
|
||||
2 ** 21,
|
||||
2 ** 31,
|
||||
2 ** 32 + 5,
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
];
|
||||
const writer = new WireWriter();
|
||||
for (const value of values) writer.varint(value);
|
||||
const reader = new WireReader(writer.finish());
|
||||
for (const value of values) assert.equal(reader.varint(), value);
|
||||
assert.ok(reader.done);
|
||||
assert.deepEqual(
|
||||
new WireWriter().varint(300).finish(),
|
||||
Uint8Array.of(0xac, 0x02),
|
||||
);
|
||||
});
|
||||
|
||||
test('the writer refuses values the format cannot carry', () => {
|
||||
assert.throws(() => new WireWriter().varint(-1), RangeError);
|
||||
assert.throws(() => new WireWriter().varint(1.5), RangeError);
|
||||
assert.throws(() => new WireWriter().varint(2 ** 53), RangeError);
|
||||
assert.throws(() => new WireWriter().tag(0, WIRE_VARINT), RangeError);
|
||||
assert.throws(() => new WireWriter().textField(1, 'é'), RangeError);
|
||||
});
|
||||
|
||||
test('the reader rejects truncated, overlong and overflowing varints', () => {
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(Uint8Array.of(0x80)).varint()),
|
||||
'truncated',
|
||||
);
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(Uint8Array.of(0x81, 0x00)).varint()),
|
||||
'non-canonical-varint',
|
||||
);
|
||||
const tooLong = new Uint8Array(9).fill(0xff);
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(tooLong).varint()),
|
||||
'varint-overflow',
|
||||
);
|
||||
const beyondSafe = Uint8Array.of(
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0xff,
|
||||
0x7f,
|
||||
);
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(beyondSafe).varint()),
|
||||
'varint-overflow',
|
||||
);
|
||||
});
|
||||
|
||||
test('the reader bounds lengths and rejects group and reserved wire types', () => {
|
||||
const absurd = new WireWriter().tag(3, WIRE_LEN).varint(1_000_000).finish();
|
||||
const reader = new WireReader(absurd);
|
||||
reader.tag();
|
||||
assert.equal(
|
||||
reasonOf(() => reader.lengthDelimited()),
|
||||
'truncated',
|
||||
);
|
||||
for (const wireType of [3, 4, 6, 7]) {
|
||||
const bytes = new WireWriter().varint(8 + wireType).finish();
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(bytes).tag()),
|
||||
'bad-wire-type',
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
reasonOf(() => new WireReader(Uint8Array.of(0x00)).tag()),
|
||||
'bad-field-number',
|
||||
);
|
||||
});
|
||||
|
||||
test('skip steps over each wire type and stops at a truncated value', () => {
|
||||
const writer = new WireWriter()
|
||||
.uintField(1, 300)
|
||||
.bytesField(2, [1, 2, 3])
|
||||
.tag(3, WIRE_FIXED32);
|
||||
writer.bytes.push(1, 2, 3, 4);
|
||||
writer.tag(4, WIRE_FIXED64);
|
||||
writer.bytes.push(1, 2, 3, 4, 5, 6, 7, 8);
|
||||
writer.uintField(5, 9);
|
||||
const reader = new WireReader(writer.finish());
|
||||
for (let field = 1; field <= 4; field += 1)
|
||||
reader.skip(reader.tag().wireType);
|
||||
assert.deepEqual(reader.tag(), { field: 5, wireType: WIRE_VARINT });
|
||||
assert.equal(reader.varint(), 9);
|
||||
const short = new WireWriter().tag(1, WIRE_FIXED64).finish();
|
||||
const shortReader = new WireReader(Uint8Array.of(...short, 1, 2));
|
||||
assert.equal(
|
||||
reasonOf(() => shortReader.skip(shortReader.tag().wireType)),
|
||||
'truncated',
|
||||
);
|
||||
});
|
||||
|
||||
test('crc8 is CRC-8/SMBUS and catches every burst of up to 8 bits', () => {
|
||||
// The catalogued check value for the ASCII digits 1–9.
|
||||
assert.equal(crc8(new TextEncoder().encode('123456789')), 0xf4);
|
||||
assert.equal(crc8([]), 0);
|
||||
const bytes = [1, 0x10, 0x82, 0x40, 0x7f, 0xff, 0x00, 0x33];
|
||||
const sum = crc8(bytes);
|
||||
for (let bit = 0; bit + 8 <= bytes.length * 8; bit += 1) {
|
||||
for (let pattern = 1; pattern < 256; pattern += 1) {
|
||||
// Flip a burst starting at `bit` (its first bit always set).
|
||||
if (!(pattern & 1)) continue;
|
||||
const flipped = [...bytes];
|
||||
for (let k = 0; k < 8; k += 1) {
|
||||
if (!(pattern & (1 << k))) continue;
|
||||
const at = bit + k;
|
||||
flipped[at >> 3] ^= 0x80 >> (at & 7);
|
||||
}
|
||||
assert.notEqual(crc8(flipped), sum, `burst ${pattern} at bit ${bit}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -8,6 +8,12 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
|
||||
import { createDefaultLayerState } from './data/layerState.js';
|
||||
import { parseSharePath } from './share/shareLinkPath.js';
|
||||
import { decodeShareView } from './share/viewCodec.js';
|
||||
|
||||
/** The decoded view of a copied `/share/<code>` link. */
|
||||
const sharedState = (href) =>
|
||||
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
|
||||
|
||||
const uiSource = readShellSource();
|
||||
|
||||
@@ -310,8 +316,8 @@ test('copy adds a fresh ephemeral timestamp without aging the live URL', async (
|
||||
|
||||
assert.equal(await manager.copyLink({ nowMs: 2_000_000 }), true);
|
||||
assert.equal(await manager.copyLink({ nowMs: 2_002_000 }), true);
|
||||
assert.equal(new URL(copied[0]).hash.includes('at=2000'), true);
|
||||
assert.equal(new URL(copied[1]).hash.includes('at=2002'), true);
|
||||
assert.equal(sharedState(copied[0]).sharedAtMs, 2_000_000);
|
||||
assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000);
|
||||
assert.equal(window.location.hash, liveHash);
|
||||
assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false);
|
||||
});
|
||||
@@ -323,8 +329,7 @@ test('copy snapshots current state while incoming hash writes are still suppress
|
||||
manager.parseInitialHash();
|
||||
assert.equal(manager._initialRestorePending, true);
|
||||
assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true);
|
||||
const params = new URL(copied).hash.slice(1);
|
||||
assert.equal(new URLSearchParams(params).get('at'), '3000');
|
||||
assert.equal(sharedState(copied).sharedAtMs, 3_000_000);
|
||||
assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100');
|
||||
});
|
||||
|
||||
|
||||
+115
-64
@@ -11,12 +11,30 @@ import {
|
||||
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
|
||||
*
|
||||
* Encodes camera position + style into the URL hash so links can be shared.
|
||||
* Format: #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
|
||||
* 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;
|
||||
@@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui';
|
||||
const SHARE_STYLE_PARAMS_PARAM = 'sp';
|
||||
const SHARE_CREATED_AT_PARAM = 'at';
|
||||
|
||||
const SHARE_PANEL_STATE_REGISTRY = Object.freeze([
|
||||
{ id: 'control-panel', token: 'c', pinnable: true },
|
||||
{ id: 'location-bar', token: 'l', pinnable: true },
|
||||
{ id: 'data-panel', token: 'd', pinnable: false },
|
||||
{ id: 'cctv-panel', token: 'v', pinnable: false },
|
||||
{ id: 'recent-imagery-panel', token: 'i', pinnable: false },
|
||||
{ id: 'street-level-panel', token: 't', pinnable: false },
|
||||
{ id: 'radio-panel', token: 'r', pinnable: false },
|
||||
{ id: 'scene-panel', token: 's', pinnable: false },
|
||||
{ id: 'global-context-panel', token: 'g', pinnable: false },
|
||||
{ id: 'pp-toggles', token: 'p', pinnable: false },
|
||||
{ id: 'param-slider-panel', token: 'm', pinnable: false },
|
||||
]);
|
||||
|
||||
// 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])),
|
||||
);
|
||||
@@ -51,46 +58,25 @@ const URL_TO_STYLE = Object.fromEntries(
|
||||
Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
|
||||
);
|
||||
|
||||
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
|
||||
retro: Object.freeze([
|
||||
{ key: 'pixelation', token: 'p', min: 1, max: 10 },
|
||||
{ key: 'distortion', token: 'd', min: 0, max: 1 },
|
||||
{ key: 'instability', token: 'i', min: 0, max: 1 },
|
||||
]),
|
||||
surveillance: Object.freeze([
|
||||
{ key: 'gain', token: 'g', min: 0, max: 1 },
|
||||
{ key: 'bloom', token: 'b', min: 0, max: 1 },
|
||||
{ key: 'scanlineStr', token: 's', min: 0, max: 1 },
|
||||
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
|
||||
]),
|
||||
thermal: Object.freeze([
|
||||
{ key: 'sensitivity', token: 's', min: 0, max: 1 },
|
||||
{ key: 'bloom', token: 'b', min: 0, max: 1 },
|
||||
{ key: 'mode', token: 'm', min: 0, max: 1 },
|
||||
{ key: 'pixelation', token: 'p', min: 1, max: 6 },
|
||||
{ key: 'palette', token: 'a', min: 0, max: 1 },
|
||||
]),
|
||||
anime: Object.freeze([
|
||||
{ key: 'saturation', token: 's', min: 0, max: 2 },
|
||||
{ key: 'edgeThick', token: 'e', min: 0, max: 1 },
|
||||
]),
|
||||
noir: Object.freeze([
|
||||
{ key: 'contrastAmt', token: 'c', min: 0, max: 2 },
|
||||
{ key: 'grainAmt', token: 'g', min: 0, max: 1 },
|
||||
{ key: 'vignetteAmt', token: 'v', min: 0, max: 1 },
|
||||
]),
|
||||
snow: Object.freeze([
|
||||
{ key: 'density', token: 'd', min: 0, max: 1 },
|
||||
{ key: 'wind', token: 'w', min: 0, max: 1 },
|
||||
]),
|
||||
});
|
||||
const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS;
|
||||
|
||||
export class ShareLinkManager {
|
||||
constructor(
|
||||
viewer,
|
||||
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {},
|
||||
{
|
||||
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';
|
||||
@@ -155,17 +141,42 @@ export class ShareLinkManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse URL hash on page load. Returns parsed state or null.
|
||||
* 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 state = this._parseShareHash(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;
|
||||
@@ -459,6 +470,16 @@ export class ShareLinkManager {
|
||||
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;
|
||||
@@ -559,17 +580,41 @@ export class ShareLinkManager {
|
||||
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() } = {}) {
|
||||
const params = this._buildHashParams();
|
||||
if (!params) return false;
|
||||
params.set(SHARE_CREATED_AT_PARAM, String(Math.floor(nowMs / 1000)));
|
||||
const copiedUrl = new URL(window.location.href);
|
||||
// ?lang is the sender's language override; the recipient keeps their own.
|
||||
copiedUrl.searchParams.delete('lang');
|
||||
copiedUrl.hash = params.toString();
|
||||
try {
|
||||
await navigator.clipboard.writeText(copiedUrl.href);
|
||||
const href = this.buildLink({ nowMs });
|
||||
if (!href) return false;
|
||||
await navigator.clipboard.writeText(href);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -587,8 +632,13 @@ export class ShareLinkManager {
|
||||
const params = this._buildHashParams();
|
||||
if (!params) return;
|
||||
// The hash belongs to this page's address, not the document base the
|
||||
// app may load under.
|
||||
const url = new URL(`#${params.toString()}`, window.location.href);
|
||||
// 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);
|
||||
}
|
||||
@@ -649,8 +699,8 @@ export class ShareLinkManager {
|
||||
this._styleParamStateProvider?.(this._currentStyle),
|
||||
);
|
||||
|
||||
// Copy-time metadata is intentionally absent here. `copyLink()` adds a
|
||||
// fresh timestamp to its ephemeral URL without aging the live address.
|
||||
// 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;
|
||||
}
|
||||
@@ -677,6 +727,7 @@ export class ShareLinkManager {
|
||||
this._layerStateProvider = null;
|
||||
this._panelStateProvider = null;
|
||||
this._styleParamStateProvider = null;
|
||||
this._followSubjectProvider = null;
|
||||
this._onRestore = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Shared fixtures for the share-view codec tests: a seeded generator and
|
||||
* real long-form links.
|
||||
*/
|
||||
import {
|
||||
REGISTERED_LAYER_IDS,
|
||||
createDefaultLayerState,
|
||||
optionSpecs,
|
||||
} from '../data/layerState.js';
|
||||
|
||||
/** A long-form link copied from a real session. */
|
||||
export const HAL_EXAMPLE_HASH =
|
||||
'v=2&lat=30.2672&lon=-97.7431&alt=600&heading=15&pitch=-30&roll=360&style=normal&bloom=0&sharpen=1&bi=0&bv=2&si=49&hud=tactical&hv=1&dm=DENSE&dd=75&da=elastic&kf=7&ko=1&cr=0&sc=1&scf=11&map=photoreal&l=a.f.s&lo=f.e.1_k.o.n&ui=c.c.1_c.p.0_l.c.1_l.p.0_d.c.1_v.c.0_i.c.0_t.c.1_r.c.1_s.c.1_g.c.1_p.c.1_m.c.0';
|
||||
|
||||
/** Deterministic generator (mulberry32) for property-style tests. */
|
||||
export function rng(seed) {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const OPTION_CANDIDATES = [
|
||||
true,
|
||||
false,
|
||||
'light',
|
||||
'strong',
|
||||
'gfs',
|
||||
'ifs',
|
||||
'none',
|
||||
'speed',
|
||||
'temperature',
|
||||
'pressure',
|
||||
'km/h',
|
||||
'm/s',
|
||||
'mph',
|
||||
'proximity',
|
||||
'all',
|
||||
'core',
|
||||
'dense',
|
||||
'off',
|
||||
'on',
|
||||
'viewshed',
|
||||
'tomtom',
|
||||
'osm',
|
||||
'hybrid',
|
||||
'clouds',
|
||||
'clouds-regional',
|
||||
'pano',
|
||||
'flat',
|
||||
'filtered',
|
||||
'full',
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
37,
|
||||
50,
|
||||
100,
|
||||
365,
|
||||
-12345678,
|
||||
12345678,
|
||||
8505110,
|
||||
'S30:2026-09-18',
|
||||
'L30:2024-02-29',
|
||||
'VIIRS:2025-01-02',
|
||||
'genre:jazz & blues',
|
||||
'news',
|
||||
'public-safety',
|
||||
0.35,
|
||||
0.8,
|
||||
'abc-1*def-0',
|
||||
'osm-1',
|
||||
25544,
|
||||
'a1b2c3',
|
||||
'~abc123',
|
||||
];
|
||||
|
||||
/** A random layer state using only values each option accepts. */
|
||||
export function randomLayerState(random) {
|
||||
const state = createDefaultLayerState();
|
||||
state.enabledLayerIds = REGISTERED_LAYER_IDS.filter(() => random() < 0.3);
|
||||
for (const ownerId of Object.keys(state.options)) {
|
||||
for (const spec of optionSpecs(ownerId)) {
|
||||
if (random() < 0.5) continue;
|
||||
const accepted = OPTION_CANDIDATES.filter(
|
||||
(value) => spec.normalize(value) !== null,
|
||||
);
|
||||
if (accepted.length)
|
||||
state.options[ownerId][spec.key] =
|
||||
accepted[Math.floor(random() * accepted.length)];
|
||||
}
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** One element of `values`, chosen by `random`. */
|
||||
export function pick(random, values) {
|
||||
return values[Math.floor(random() * values.length)];
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
|
||||
import { aircraftTrackingTarget } from '../cockpitTracking.js';
|
||||
|
||||
import { ShellFeedback } from './shellFeedback.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
|
||||
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
|
||||
|
||||
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
constructor(
|
||||
viewer,
|
||||
{ mapStackController = null, placeSearch, services, requestServices } = {},
|
||||
{
|
||||
mapStackController = null,
|
||||
placeSearch,
|
||||
services,
|
||||
requestServices,
|
||||
initialShareCode = null,
|
||||
} = {},
|
||||
) {
|
||||
super();
|
||||
const {
|
||||
@@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade {
|
||||
this._showGlobalStatusNotice(message, options),
|
||||
feedback: this._feedback,
|
||||
updateFeedback: () => this._updateGlobalLoadingFeedback(),
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
|
||||
this._visualSettings = new VisualSettings({
|
||||
@@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade {
|
||||
isNavigationCurrent: (generation) =>
|
||||
generation === this._navigationGeneration,
|
||||
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(),
|
||||
// A host may open a share code without a `/share/<code>` address.
|
||||
initialShareCode,
|
||||
});
|
||||
this.shareLinkManager.setFollowSubjectProvider(({ nowMs }) =>
|
||||
readShareFollowSubject({ viewer: this.viewer, nowMs }),
|
||||
);
|
||||
this.shareLinkManager.setPanelStateProvider(() =>
|
||||
this._buildSharePanelState(),
|
||||
);
|
||||
@@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade {
|
||||
// ── Share Button ─────────────────────────────
|
||||
|
||||
/**
|
||||
* Wires the share button click to copy the current share link to the clipboard.
|
||||
* Wires the share button. By default it copies the current share link to the
|
||||
* clipboard; a host may pass `services.shareLink(url)` to share it another way
|
||||
* (a system share sheet, for example). It resolves to 'shared', 'copied',
|
||||
* 'cancelled' (no toast) or false (failed).
|
||||
* @returns {void}
|
||||
*/
|
||||
_initShareButton() {
|
||||
this._lifetime.listen(this._shareBtn, 'click', async () => {
|
||||
const success = await this.shareLinkManager.copyLink();
|
||||
if (!this._disposed)
|
||||
this._showToast(success ? t('Link copied!') : t('Copy failed'));
|
||||
const share = this.services.shareLink;
|
||||
let result;
|
||||
if (typeof share === 'function') {
|
||||
// A host's hook may return a plain value or throw; both are answers.
|
||||
result = await Promise.resolve()
|
||||
.then(() => {
|
||||
const href = this.shareLinkManager.buildLink();
|
||||
return href ? share(href) : false;
|
||||
})
|
||||
.catch(() => false);
|
||||
} else {
|
||||
result = (await this.shareLinkManager.copyLink()) ? 'copied' : false;
|
||||
}
|
||||
if (this._disposed || result === 'cancelled') return;
|
||||
this._showToast(
|
||||
result === 'shared'
|
||||
? t('Link shared')
|
||||
: result === 'copied'
|
||||
? t('Link copied!')
|
||||
: t('Copy failed'),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// The share button copies by default; a host's `services.shareLink` shares the
|
||||
// link its own way and reports how it went.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { StyleManager } from './applicationShell.js';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { parseSharePath } from '../share/shareLinkPath.js';
|
||||
import { decodeShareView } from '../share/viewCodec.js';
|
||||
|
||||
/** The decoded view of a `/share/<code>` link. */
|
||||
const sharedState = (href) =>
|
||||
decodeShareView(parseSharePath(new URL(href).pathname).code).state;
|
||||
const SHARE_LINK = /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/;
|
||||
|
||||
function makeManager(href, { positioned = true } = {}) {
|
||||
globalThis.window = { location: { href, hash: new URL(href).hash } };
|
||||
globalThis.history = { replaceState() {} };
|
||||
const viewer = {
|
||||
camera: {
|
||||
changed: { addEventListener() {} },
|
||||
positionCartographic: positioned
|
||||
? { latitude: 0, longitude: 0, height: 1000 }
|
||||
: undefined,
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
},
|
||||
};
|
||||
return new ShareLinkManager(viewer);
|
||||
}
|
||||
|
||||
function installClipboard(writeText) {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { clipboard: { writeText } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Wire the real share button handler onto a minimal shell; return a click. */
|
||||
function shell({ manager, services = {} }) {
|
||||
const toasts = [];
|
||||
let click;
|
||||
const owner = {
|
||||
services,
|
||||
shareLinkManager: manager,
|
||||
_shareBtn: {},
|
||||
_disposed: false,
|
||||
_lifetime: { listen: (_el, _type, handler) => (click = handler) },
|
||||
_showToast: (message) => toasts.push(message),
|
||||
};
|
||||
StyleManager.prototype._initShareButton.call(owner);
|
||||
return { toasts, click: () => click() };
|
||||
}
|
||||
|
||||
test('buildLink is a /share link stamped with its creation time, without ?lang', () => {
|
||||
const manager = makeManager('https://gev.example/?lang=ru#v=2&lat=10&lon=20');
|
||||
const href = manager.buildLink({ nowMs: 5_000_000 });
|
||||
assert.match(href, SHARE_LINK);
|
||||
const link = new URL(href);
|
||||
assert.equal(link.searchParams.get('lang'), null);
|
||||
assert.equal(link.hash, '');
|
||||
assert.equal(sharedState(href).sharedAtMs, 5_000_000);
|
||||
// The camera is the live one, not the address bar's.
|
||||
assert.equal(sharedState(href).lat, 0);
|
||||
assert.equal(
|
||||
makeManager('https://gev.example/', { positioned: false }).buildLink(),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('copyLink copies exactly what buildLink builds', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const manager = makeManager('https://gev.example/#v=2');
|
||||
assert.equal(await manager.copyLink({ nowMs: 7_000_000 }), true);
|
||||
assert.equal(copied, manager.buildLink({ nowMs: 7_000_000 }));
|
||||
});
|
||||
|
||||
test('without a host hook the button copies the link', async () => {
|
||||
let copied = null;
|
||||
installClipboard(async (url) => {
|
||||
copied = url;
|
||||
});
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
});
|
||||
await click();
|
||||
assert.match(copied, SHARE_LINK);
|
||||
assert.deepEqual(toasts, ['Link copied!']);
|
||||
|
||||
installClipboard(async () => {
|
||||
throw new Error('denied');
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, ['Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook receives the link and its result picks the toast', async () => {
|
||||
installClipboard(async () => assert.fail('the host shares instead'));
|
||||
const shared = [];
|
||||
const results = ['shared', 'copied', 'cancelled', false];
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: {
|
||||
shareLink: async (url) => {
|
||||
shared.push(url);
|
||||
return results.shift();
|
||||
},
|
||||
},
|
||||
});
|
||||
for (let i = 0; i < 4; i++) await click();
|
||||
assert.equal(shared.length, 4);
|
||||
assert.match(shared[0], SHARE_LINK);
|
||||
// 'cancelled' (the user closed the sheet) shows nothing.
|
||||
assert.deepEqual(toasts, ['Link shared', 'Link copied!', 'Copy failed']);
|
||||
});
|
||||
|
||||
test('a failing host hook, or no camera position, reports a failure', async () => {
|
||||
const failing = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink: async () => Promise.reject(new Error('no sheet')) },
|
||||
});
|
||||
await failing.click();
|
||||
assert.deepEqual(failing.toasts, ['Copy failed']);
|
||||
|
||||
let called = false;
|
||||
const unpositioned = shell({
|
||||
manager: makeManager('https://gev.example/', { positioned: false }),
|
||||
services: {
|
||||
shareLink: async () => {
|
||||
called = true;
|
||||
return 'shared';
|
||||
},
|
||||
},
|
||||
});
|
||||
await unpositioned.click();
|
||||
assert.equal(called, false);
|
||||
assert.deepEqual(unpositioned.toasts, ['Copy failed']);
|
||||
});
|
||||
|
||||
test('a host hook that answers without a promise, or throws, still gets a toast', async () => {
|
||||
for (const [shareLink, toast] of [
|
||||
[() => 'shared', 'Link shared'],
|
||||
[
|
||||
() => {
|
||||
throw new Error('no sheet');
|
||||
},
|
||||
'Copy failed',
|
||||
],
|
||||
]) {
|
||||
const { toasts, click } = shell({
|
||||
manager: makeManager('https://gev.example/#v=2'),
|
||||
services: { shareLink },
|
||||
});
|
||||
await click();
|
||||
assert.deepEqual(toasts, [toast]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { trackedDisplayPositionForCamera } from '../data/trackedCamera.js';
|
||||
import {
|
||||
SHARE_VIEW_ENTITY_KINDS,
|
||||
followOffsetFromEnu,
|
||||
followOffsetToEnu,
|
||||
} from '../share/follow.js';
|
||||
|
||||
/**
|
||||
* The bridge between the camera following an entity and a share link's thing
|
||||
* view: what the camera follows now, in the share format's terms, and the
|
||||
* stored follow offset applied to an entity the camera has started to follow.
|
||||
*/
|
||||
|
||||
const KIND_BY_LAYER = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.layerId, entry.kind]),
|
||||
);
|
||||
const MAX_FRAMES = 120;
|
||||
|
||||
/** `position`'s local east/north/up offset from `origin`, in metres. */
|
||||
function enuOffset(origin, position) {
|
||||
const frame = Cesium.Transforms.eastNorthUpToFixedFrame(origin);
|
||||
const local = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(frame, new Cesium.Matrix4()),
|
||||
position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return { east: local.x, north: local.y, up: local.z };
|
||||
}
|
||||
|
||||
/**
|
||||
* The entity the camera follows as `{ kind, id, offset, lastKnown }`, with
|
||||
* the camera's offset from it and its displayed position as of `nowMs`; null
|
||||
* when the camera follows nothing a share link can name. A selected vessel is
|
||||
* not followed (the camera stays where the user puts it), so a link made then
|
||||
* keeps its own camera.
|
||||
*/
|
||||
export function readShareFollowSubject({ viewer, nowMs = Date.now() }) {
|
||||
const cameraPosition = viewer?.camera?.positionWC;
|
||||
if (!cameraPosition) return null;
|
||||
const tracked = viewer.trackedEntity;
|
||||
const trackedId = String(tracked?.gevTrackedId || '');
|
||||
const separator = trackedId.indexOf(':');
|
||||
if (separator <= 0) return null;
|
||||
const kind = KIND_BY_LAYER.get(trackedId.slice(0, separator));
|
||||
const id = trackedId.slice(separator + 1);
|
||||
const target = kind
|
||||
? trackedDisplayPositionForCamera(
|
||||
tracked,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!kind || !id || !target) return null;
|
||||
const offset = followOffsetFromEnu(enuOffset(target, cameraPosition));
|
||||
const fix = Cesium.Cartographic.fromCartesian(target);
|
||||
if (!offset || !fix) return null;
|
||||
return {
|
||||
kind,
|
||||
id,
|
||||
offset,
|
||||
lastKnown: {
|
||||
lat: Cesium.Math.toDegrees(fix.latitude),
|
||||
lon: Cesium.Math.toDegrees(fix.longitude),
|
||||
alt: fix.height,
|
||||
observedAtMs: nowMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Largest turn, in radians, the camera makes around the entity per frame. */
|
||||
const MAX_TURN_PER_FRAME = Math.PI / 3;
|
||||
|
||||
/**
|
||||
* Put the camera at a shared follow offset from `entity` once following it
|
||||
* has framed the camera. Cesium keeps the offset afterwards, as it keeps one
|
||||
* the user orbits to. The camera swings round the entity at most 60° a frame:
|
||||
* the follow's own guard treats a jump to the far side as the camera crossing
|
||||
* through the entity and puts it back. Returns a disposer.
|
||||
*/
|
||||
export function applyShareFollowOffset(viewer, entity, offset) {
|
||||
if (!viewer?.scene?.preUpdate || !entity || !(offset?.range > 0))
|
||||
return () => {};
|
||||
const local = followOffsetToEnu(offset);
|
||||
let frames = 0;
|
||||
let remove = null;
|
||||
const stop = () => {
|
||||
remove?.();
|
||||
remove = null;
|
||||
};
|
||||
remove = viewer.scene.preUpdate.addEventListener(() => {
|
||||
if (viewer.isDestroyed?.() || viewer.trackedEntity !== entity) {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
frames += 1;
|
||||
const camera = viewer.camera;
|
||||
// Let the follow frame the camera first (its first frame), then move it.
|
||||
const framed =
|
||||
frames > 1 &&
|
||||
!Cesium.Matrix4.equals(camera.transform, Cesium.Matrix4.IDENTITY);
|
||||
const target = framed
|
||||
? trackedDisplayPositionForCamera(
|
||||
entity,
|
||||
viewer.clock.currentTime,
|
||||
new Cesium.Cartesian3(),
|
||||
)
|
||||
: null;
|
||||
if (!target) {
|
||||
if (frames >= MAX_FRAMES) stop();
|
||||
return;
|
||||
}
|
||||
// The offset is east/north/up at the entity; the follow's own frame may
|
||||
// differ, so place the camera in world space and re-express it there.
|
||||
const world = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const goal = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Matrix4.inverseTransformation(
|
||||
camera.transform,
|
||||
new Cesium.Matrix4(),
|
||||
),
|
||||
world,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const from = Cesium.Cartesian3.clone(
|
||||
camera.position,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const angle =
|
||||
Cesium.Cartesian3.magnitude(from) > 0
|
||||
? Cesium.Cartesian3.angleBetween(from, goal)
|
||||
: 0;
|
||||
const done = !(angle > MAX_TURN_PER_FRAME);
|
||||
const next = done ? goal : turnToward(from, goal, MAX_TURN_PER_FRAME);
|
||||
camera.lookAtTransform(camera.transform, next);
|
||||
viewer.scene.requestRender?.();
|
||||
if (done) stop();
|
||||
});
|
||||
return stop;
|
||||
}
|
||||
|
||||
/**
|
||||
* `from` turned `step` radians toward `to`, with its length moved the same
|
||||
* fraction of the way: one frame of the swing round the entity.
|
||||
*/
|
||||
function turnToward(from, to, step) {
|
||||
const angle = Cesium.Cartesian3.angleBetween(from, to);
|
||||
const unitFrom = Cesium.Cartesian3.normalize(from, new Cesium.Cartesian3());
|
||||
// The direction perpendicular to `from` in the plane toward `to` (or any
|
||||
// perpendicular when they are opposite).
|
||||
let toward = Cesium.Cartesian3.subtract(
|
||||
Cesium.Cartesian3.normalize(to, new Cesium.Cartesian3()),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(angle),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
if (Cesium.Cartesian3.magnitude(toward) < 1e-9)
|
||||
toward = Cesium.Cartesian3.cross(
|
||||
unitFrom,
|
||||
Math.abs(unitFrom.z) < 0.9
|
||||
? Cesium.Cartesian3.UNIT_Z
|
||||
: Cesium.Cartesian3.UNIT_X,
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
Cesium.Cartesian3.normalize(toward, toward);
|
||||
const length =
|
||||
Cesium.Cartesian3.magnitude(from) +
|
||||
(Cesium.Cartesian3.magnitude(to) - Cesium.Cartesian3.magnitude(from)) *
|
||||
(step / angle);
|
||||
const direction = Cesium.Cartesian3.add(
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
unitFrom,
|
||||
Math.cos(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
Cesium.Cartesian3.multiplyByScalar(
|
||||
toward,
|
||||
Math.sin(step),
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
return Cesium.Cartesian3.multiplyByScalar(direction, length, direction);
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
// Short share links: `/share/<code>` opens through the start-up restore
|
||||
// pipeline, the address then follows the live view, and the share button
|
||||
// makes `/share/<code>` links (following an entity when the camera does).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { ShareLinkManager } from '../sharelink.js';
|
||||
import { ShareRestoration, sharedFixNotice } from './shareRestoration.js';
|
||||
import {
|
||||
applyShareFollowOffset,
|
||||
readShareFollowSubject,
|
||||
} from './shareFollow.js';
|
||||
import { createDefaultLayerState } from '../data/layerState.js';
|
||||
import { parseSharePath, shareLinkUrl } from '../share/shareLinkPath.js';
|
||||
import {
|
||||
decodeShareView,
|
||||
encodeShareView,
|
||||
followCameraFor,
|
||||
followOffsetToEnu,
|
||||
} from '../share/viewCodec.js';
|
||||
|
||||
const NOW_MS = Date.now();
|
||||
const FIX = { lat: 37.6213, lon: -122.379, alt: 3200 };
|
||||
const OFFSET = { range: 5800, heading: 0, pitch: -35 };
|
||||
|
||||
const PLACE_CODE = encodeShareView({
|
||||
lat: 48.8584,
|
||||
lon: 2.2945,
|
||||
alt: 1200,
|
||||
heading: 120,
|
||||
pitch: -25,
|
||||
style: 'noir',
|
||||
panelState: { specs: [{ id: 'data-panel', collapsed: false }] },
|
||||
sharedAtMs: NOW_MS - 60_000,
|
||||
});
|
||||
|
||||
function aircraftCode() {
|
||||
const layerState = createDefaultLayerState();
|
||||
layerState.enabledLayerIds = ['flights'];
|
||||
return encodeShareView({
|
||||
view: {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS - 120_000 },
|
||||
},
|
||||
layerState,
|
||||
sharedAtMs: NOW_MS - 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A page at `href`. `history.replaceState` rewrites the address in place and
|
||||
* is counted; `pushState` (a new history entry) is counted too.
|
||||
*/
|
||||
function installPage(t, href) {
|
||||
const prior = { window: globalThis.window, history: globalThis.history };
|
||||
const target = new EventTarget();
|
||||
const location = new URL(href);
|
||||
target.location = location;
|
||||
globalThis.window = target;
|
||||
const writes = { replaced: 0, pushed: 0 };
|
||||
globalThis.history = {
|
||||
replaceState(_state, _title, next) {
|
||||
writes.replaced += 1;
|
||||
location.href = new URL(next, location.href).href;
|
||||
},
|
||||
pushState() {
|
||||
writes.pushed += 1;
|
||||
},
|
||||
};
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return { location, writes, target };
|
||||
}
|
||||
|
||||
function installManualTimers(t) {
|
||||
const prior = {
|
||||
setTimeout: globalThis.setTimeout,
|
||||
clearTimeout: globalThis.clearTimeout,
|
||||
};
|
||||
const timers = new Map();
|
||||
let timerId = 0;
|
||||
globalThis.setTimeout = (fn) => {
|
||||
timers.set(++timerId, fn);
|
||||
return timerId;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => timers.delete(id);
|
||||
t.after(() => Object.assign(globalThis, prior));
|
||||
return {
|
||||
timers,
|
||||
runAll() {
|
||||
for (const [id, fn] of [...timers]) {
|
||||
timers.delete(id);
|
||||
fn();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** A viewer whose camera flights land at once. */
|
||||
function fakeViewer() {
|
||||
const flights = [];
|
||||
return {
|
||||
flights,
|
||||
canvas: new EventTarget(),
|
||||
camera: {
|
||||
changed: { addEventListener: () => () => {} },
|
||||
positionCartographic: { latitude: 0, longitude: 0, height: 1000 },
|
||||
heading: 0,
|
||||
pitch: -Math.PI / 2,
|
||||
roll: 0,
|
||||
flyTo(options) {
|
||||
flights.push(options);
|
||||
options.complete?.();
|
||||
},
|
||||
setView() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/** A ShareRestoration over a real ShareLinkManager, as the shell wires them. */
|
||||
function openPage(t, href, { managerOptions = {}, tracking = null } = {}) {
|
||||
const page = installPage(t, href);
|
||||
const clock = installManualTimers(t);
|
||||
const viewer = fakeViewer();
|
||||
const restores = [];
|
||||
const manager = new ShareLinkManager(viewer, {
|
||||
onRestore: async (state) => restores.push(state),
|
||||
isNavigationCurrent: () => true,
|
||||
...managerOptions,
|
||||
});
|
||||
const notices = [];
|
||||
let generation = 0;
|
||||
const owner = new ShareRestoration({
|
||||
viewer,
|
||||
navigation: {
|
||||
_beginDeferredNavigation: () => ++generation,
|
||||
_reassertNavigationHandoff: () => true,
|
||||
_stampNavigation() {},
|
||||
},
|
||||
syncShareState() {},
|
||||
syncModels3d() {},
|
||||
showStatus() {},
|
||||
feedback: {},
|
||||
updateFeedback() {},
|
||||
applyFollowOffset: (entity, offset) =>
|
||||
applyShareFollowOffset(viewer, entity, offset),
|
||||
});
|
||||
// Notices are checked by message; their start-up-cover timing is not.
|
||||
owner._presentAfterStartupCover = (message) => notices.push(message);
|
||||
owner.attachLinks(manager);
|
||||
owner._layerStateCoordinator = {
|
||||
applyShareLayerState: () => Promise.resolve([]),
|
||||
getDurableState: () => null,
|
||||
cancelPendingShareTracking: () => false,
|
||||
restoreShareTrackingSelection: async () => tracking?.(owner) ?? null,
|
||||
destroy() {},
|
||||
};
|
||||
owner.start();
|
||||
t.after(() => owner.destroy());
|
||||
return { ...page, ...clock, viewer, manager, owner, restores, notices };
|
||||
}
|
||||
|
||||
/** Run the delayed start-up restore and let it settle. */
|
||||
async function restore(page) {
|
||||
page.runAll();
|
||||
const result = await page.owner.initialRestorePromise;
|
||||
await flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
test('a /share/<code> address opens its place view through the start-up restore', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
assert.equal(page.timers.size, 1, 'the restore waits for its delayed flight');
|
||||
assert.equal(page.manager._initialRestorePending, true);
|
||||
const result = await restore(page);
|
||||
assert.equal(result.status, 'settled');
|
||||
assert.equal(result.share.camera, 'applied');
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
assert.deepEqual(page.restores[0].panelState.specs, [
|
||||
{ id: 'data-panel', collapsed: false, pinned: null },
|
||||
]);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - 48.8584) < 1e-6,
|
||||
);
|
||||
assert.equal(page.manager._initialRestorePending, false);
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('after a restore the address becomes the app root and the long hash, with no new history entry', async (t) => {
|
||||
for (const [href, root] of [
|
||||
[`http://localhost/share/${PLACE_CODE}`, 'http://localhost/'],
|
||||
[
|
||||
`http://localhost:4173/gev/share/${PLACE_CODE}`,
|
||||
'http://localhost:4173/gev/',
|
||||
],
|
||||
]) {
|
||||
const page = openPage(t, href);
|
||||
await restore(page);
|
||||
page.manager._updateHash();
|
||||
assert.equal(`${page.location.origin}${page.location.pathname}`, root);
|
||||
// The live view's long form, as written for any other page.
|
||||
const params = new URLSearchParams(page.location.hash.slice(1));
|
||||
assert.equal(params.get('v'), '2');
|
||||
assert.ok(params.has('lat') && params.has('style'));
|
||||
assert.equal(page.writes.pushed, 0);
|
||||
assert.equal(page.writes.replaced, 1);
|
||||
page.manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test('a host can pass the share code instead of the address', async (t) => {
|
||||
const page = openPage(t, 'http://localhost/', {
|
||||
managerOptions: { initialShareCode: PLACE_CODE },
|
||||
});
|
||||
await restore(page);
|
||||
assert.equal(page.restores[0].style, 'noir');
|
||||
});
|
||||
|
||||
test('a code that fails its check restores nothing and says so', async (t) => {
|
||||
// One character changed: the check byte no longer matches.
|
||||
const last = PLACE_CODE.at(-2) === 'A' ? 'B' : 'A';
|
||||
const typo = `${PLACE_CODE.slice(0, -2)}${last}${PLACE_CODE.at(-1)}`;
|
||||
const page = openPage(t, `http://localhost/share/${typo}`);
|
||||
assert.equal(page.manager.rejectedShareCode, 'checksum');
|
||||
assert.notEqual(page.owner._hasShareState, true);
|
||||
assert.equal(page.timers.size, 0, 'nothing to restore');
|
||||
assert.equal(
|
||||
(await page.owner.initialRestorePromise).status,
|
||||
'not-requested',
|
||||
);
|
||||
assert.deepEqual(page.notices, ["This share link can't be opened"]);
|
||||
// The default start's first address write leaves the bad link behind.
|
||||
page.manager._updateHash();
|
||||
assert.equal(page.location.pathname, '/');
|
||||
});
|
||||
|
||||
test('a thing view turns its layer on, follows by id and frames the shared offset', async (t) => {
|
||||
let entity = null;
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// What the coordinator does when the aircraft is in the feed.
|
||||
owner.viewer.trackedEntity = entity;
|
||||
owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
classification: 'followed',
|
||||
});
|
||||
return { classification: 'followed' };
|
||||
},
|
||||
});
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
Object.assign(page.viewer, {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
isDestroyed: () => false,
|
||||
});
|
||||
Object.assign(page.viewer.camera, {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
// The app's own follow framing: south of the aircraft and above it.
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(offset);
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
});
|
||||
entity = {
|
||||
gevTrackedId: 'flights:a1b2c3',
|
||||
gevDisplayPosition: () => target,
|
||||
};
|
||||
|
||||
const parsed = page.manager._parseShareCode(aircraftCode());
|
||||
assert.deepEqual(parsed.layerState.enabledLayerIds, ['flights']);
|
||||
assert.equal(
|
||||
parsed.layerState.options.flights.selectedFlightsTrackingId,
|
||||
'a1b2c3',
|
||||
);
|
||||
await restore(page);
|
||||
// The opening flight goes where following the fix puts the camera.
|
||||
const camera = followCameraFor(FIX, OFFSET);
|
||||
const destination = Cesium.Cartographic.fromCartesian(
|
||||
page.viewer.flights[0].destination,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.latitude) - camera.lat) < 1e-3,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(Cesium.Math.toDegrees(destination.longitude) - camera.lon) < 1e-3,
|
||||
);
|
||||
// Once followed, the camera takes the shared offset after the follow's
|
||||
// own first frame.
|
||||
assert.equal(listeners.length, 1);
|
||||
listeners[0]();
|
||||
assert.equal(looks.length, 0);
|
||||
listeners[0]?.();
|
||||
assert.equal(looks.length, 1);
|
||||
const expected = followOffsetToEnu(OFFSET);
|
||||
assert.ok(Math.abs(looks[0].x - expected.east) < 1);
|
||||
assert.ok(Math.abs(looks[0].y - expected.north) < 1);
|
||||
assert.ok(Math.abs(looks[0].z - expected.up) < 1);
|
||||
assert.equal(listeners.length, 0, 'the offset is applied once');
|
||||
assert.deepEqual(page.notices, []);
|
||||
});
|
||||
|
||||
test('a thing that is not reporting leaves the view at its last-known fix, with a notice', async (t) => {
|
||||
const page = openPage(t, `http://localhost/share/${aircraftCode()}`, {
|
||||
tracking: (owner) => {
|
||||
// The coordinator's verdict once the follow window has passed.
|
||||
const result = {
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
};
|
||||
owner._handleShareTrackingRestoreStatus(result);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
await restore(page);
|
||||
await flush();
|
||||
assert.equal(page.notices.length, 1);
|
||||
assert.match(
|
||||
page.notices[0],
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .+/,
|
||||
);
|
||||
// A long link names no fix and keeps the existing notice.
|
||||
page.owner._initialShareState = { shareView: null };
|
||||
page.owner._handleShareTrackingRestoreStatus({
|
||||
layerId: 'flights',
|
||||
targetId: 'a1b2c3',
|
||||
label: 'flight',
|
||||
classification: 'expired',
|
||||
});
|
||||
assert.equal(page.notices[1], 'Shared flight follow expired');
|
||||
});
|
||||
|
||||
test('the not-reporting notice names the id and the time of the fix', () => {
|
||||
const at = new Date(2026, 9, 26, 14, 5).getTime();
|
||||
assert.match(
|
||||
sharedFixNotice({ kind: 'aircraft', id: 'a1b2c3' }, at, at + 60_000),
|
||||
/^A1B2C3 isn't reporting now — showing where it was at .*05/,
|
||||
);
|
||||
assert.equal(
|
||||
sharedFixNotice({ kind: 'vessel', id: '366999712' }, null),
|
||||
"366999712 isn't reporting now — showing where it was last seen",
|
||||
);
|
||||
});
|
||||
|
||||
test('a shared vessel is selected once the vessel feed has it', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['Date'], now: NOW_MS });
|
||||
const page = openPage(t, 'http://localhost/');
|
||||
const selected = [];
|
||||
let present = false;
|
||||
page.owner._dataManager = {
|
||||
layers: new Map([
|
||||
[
|
||||
'ais-live-vessels',
|
||||
{
|
||||
module: {
|
||||
hasContact: (id) => present && id === '366999712',
|
||||
selectById: (id) => selected.push(id) > 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
]),
|
||||
};
|
||||
const view = {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'vessel', id: '366999712' },
|
||||
lastKnown: { lat: 1, lon: 2, alt: 0, observedAtMs: NOW_MS - 30_000 },
|
||||
};
|
||||
const found = page.owner._restoreSharedVessel(view, () => true);
|
||||
await flush();
|
||||
present = true;
|
||||
page.runAll();
|
||||
assert.equal(await found, true);
|
||||
assert.deepEqual(selected, ['366999712']);
|
||||
|
||||
present = false;
|
||||
const missing = page.owner._restoreSharedVessel(view, () => true);
|
||||
for (let i = 0; i < 61; i += 1) {
|
||||
await flush();
|
||||
t.mock.timers.tick(2_000);
|
||||
page.runAll();
|
||||
}
|
||||
assert.equal(await missing, false);
|
||||
assert.match(page.notices.at(-1), /^366999712 isn't reporting now/);
|
||||
});
|
||||
|
||||
test('the share button links to /share/<code>, following the followed entity', (t) => {
|
||||
installPage(t, 'https://gev.example/?lang=ru#v=2&lat=1&lon=2');
|
||||
const manager = new ShareLinkManager(fakeViewer());
|
||||
// A fresh session's look, as the app reports it at start-up.
|
||||
manager.onToggleChange(false, true, {
|
||||
hudVisible: true,
|
||||
detectionMode: 'DENSE',
|
||||
detectionDensity: 75,
|
||||
});
|
||||
// No entity: a place view at the live camera.
|
||||
const place = manager.buildLink({ nowMs: NOW_MS });
|
||||
assert.match(place, /^https:\/\/gev\.example\/share\/[A-Za-z0-9_-]+$/);
|
||||
assert.equal(
|
||||
decodeShareView(parseSharePath(new URL(place).pathname).code).state.view
|
||||
.kind,
|
||||
'place',
|
||||
);
|
||||
|
||||
manager.setFollowSubjectProvider(() => ({
|
||||
kind: 'aircraft',
|
||||
id: 'a1b2c3',
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: NOW_MS },
|
||||
}));
|
||||
const href = manager.buildLink({ nowMs: NOW_MS });
|
||||
const code = parseSharePath(new URL(href).pathname).code;
|
||||
const { state } = decodeShareView(code, { nowMs: NOW_MS });
|
||||
assert.deepEqual(state.view, {
|
||||
kind: 'thing',
|
||||
entity: { kind: 'aircraft', id: 'a1b2c3' },
|
||||
offset: OFFSET,
|
||||
lastKnown: { ...FIX, observedAtMs: Math.floor(NOW_MS / 1000) * 1000 },
|
||||
});
|
||||
// The followed aircraft's layer is implied; the camera is derived.
|
||||
assert.ok(code.length <= 45, `${code.length} characters`);
|
||||
assert.equal(href, shareLinkUrl(code, 'https://gev.example/'));
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test('the followed entity is read in the share format, and its offset round-trips', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const offset = { range: 2400, heading: 30, pitch: -20 };
|
||||
const local = followOffsetToEnu(offset);
|
||||
const cameraPosition = Cesium.Matrix4.multiplyByPoint(
|
||||
Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
new Cesium.Cartesian3(local.east, local.north, local.up),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
const viewer = {
|
||||
camera: { positionWC: cameraPosition },
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
trackedEntity: {
|
||||
gevTrackedId: 'military:ae1234',
|
||||
gevDisplayPosition: () => target,
|
||||
},
|
||||
};
|
||||
const subject = readShareFollowSubject({ viewer, nowMs: NOW_MS });
|
||||
assert.equal(subject.kind, 'military_aircraft');
|
||||
assert.equal(subject.id, 'ae1234');
|
||||
assert.deepEqual(subject.offset, offset);
|
||||
assert.ok(Math.abs(subject.lastKnown.lat - FIX.lat) < 1e-9);
|
||||
assert.ok(Math.abs(subject.lastKnown.alt - FIX.alt) < 1e-3);
|
||||
// A followed entity the share format has no kind for is not a thing view.
|
||||
viewer.trackedEntity.gevTrackedId = 'local-adsb:abc';
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
// Nor is a camera that follows nothing (a selected vessel is not followed).
|
||||
viewer.trackedEntity = undefined;
|
||||
assert.equal(readShareFollowSubject({ viewer }), null);
|
||||
});
|
||||
|
||||
test('a shared offset on the far side swings round, never jumping through the entity', () => {
|
||||
const target = Cesium.Cartesian3.fromDegrees(FIX.lon, FIX.lat, FIX.alt);
|
||||
const listeners = [];
|
||||
const looks = [];
|
||||
const entity = { gevDisplayPosition: () => target };
|
||||
const viewer = {
|
||||
clock: { currentTime: Cesium.JulianDate.now() },
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: entity,
|
||||
camera: {
|
||||
transform: Cesium.Transforms.eastNorthUpToFixedFrame(target),
|
||||
position: new Cesium.Cartesian3(0, -4800, 3300),
|
||||
lookAtTransform(transform, offset) {
|
||||
looks.push(Cesium.Cartesian3.clone(offset));
|
||||
this.position = Cesium.Cartesian3.clone(offset);
|
||||
},
|
||||
},
|
||||
};
|
||||
// Looking south at the nose: the opposite side of the app's framing.
|
||||
const offset = { range: 5800, heading: 180, pitch: -35 };
|
||||
applyShareFollowOffset(viewer, entity, offset);
|
||||
let previous = viewer.camera.position;
|
||||
for (let frame = 0; frame < 10 && listeners.length; frame += 1) {
|
||||
listeners[0]();
|
||||
const now = viewer.camera.position;
|
||||
// The follow's guard treats a turn of 90° or more as a crossing.
|
||||
assert.ok(Cesium.Cartesian3.angleBetween(previous, now) < Math.PI / 2);
|
||||
previous = now;
|
||||
}
|
||||
assert.equal(listeners.length, 0, 'it arrives');
|
||||
assert.ok(looks.length >= 2, `${looks.length} steps`);
|
||||
const expected = followOffsetToEnu(offset);
|
||||
const last = looks.at(-1);
|
||||
assert.ok(Math.abs(last.x - expected.east) < 1);
|
||||
assert.ok(Math.abs(last.y - expected.north) < 1);
|
||||
assert.ok(Math.abs(last.z - expected.up) < 1);
|
||||
});
|
||||
|
||||
test('applying a follow offset gives up when the follow ends', () => {
|
||||
const listeners = [];
|
||||
const viewer = {
|
||||
scene: {
|
||||
preUpdate: {
|
||||
addEventListener(fn) {
|
||||
listeners.push(fn);
|
||||
return () => listeners.splice(listeners.indexOf(fn), 1);
|
||||
},
|
||||
},
|
||||
},
|
||||
trackedEntity: null,
|
||||
};
|
||||
applyShareFollowOffset(viewer, {}, OFFSET);
|
||||
listeners[0]();
|
||||
assert.equal(listeners.length, 0);
|
||||
});
|
||||
|
||||
test('long links and hash navigation are unaffected by a share code start', async (t) => {
|
||||
// A long link still opens from the hash.
|
||||
const long = openPage(t, 'http://localhost/#v=2&lat=10&lon=20&style=nvg');
|
||||
await restore(long);
|
||||
assert.equal(long.restores[0].style, 'surveillance');
|
||||
assert.equal(long.restores[0].shareView, undefined);
|
||||
long.manager._updateHash();
|
||||
assert.equal(long.location.pathname, '/');
|
||||
|
||||
// After a /share start, navigating to a long link restores it (#1416).
|
||||
const page = openPage(t, `http://localhost/share/${PLACE_CODE}`);
|
||||
await restore(page);
|
||||
page.location.hash = 'v=2&lat=1.0000&lon=2.0000&style=normal';
|
||||
page.target.dispatchEvent(new Event('hashchange'));
|
||||
await flush();
|
||||
assert.equal(page.restores.length, 2);
|
||||
assert.equal(page.restores[1].style, 'normal');
|
||||
});
|
||||
+164
-10
@@ -1,9 +1,18 @@
|
||||
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
|
||||
import { stampInitialShareGesture } from '../navigationPolicy.js';
|
||||
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
|
||||
import { t, msg } from '../i18n/index.js';
|
||||
import { t, msg, formatDate } from '../i18n/index.js';
|
||||
import { SHARE_VIEW_ENTITY_KINDS } from '../share/follow.js';
|
||||
import { UiLifetime } from './uiLifetime.js';
|
||||
|
||||
/** How long a shared vessel may take to appear in the vessel feed. */
|
||||
export const SHARED_VESSEL_WAIT_MS = 120_000;
|
||||
const SHARED_VESSEL_POLL_MS = 2_000;
|
||||
const DAY_MS = 86_400_000;
|
||||
const ENTITY_BY_KIND = new Map(
|
||||
SHARE_VIEW_ENTITY_KINDS.map((entry) => [entry.kind, entry]),
|
||||
);
|
||||
|
||||
// One ready-made sentence per restore policy (SHARE_TRACKING_RESTORE_POLICIES
|
||||
// label) and outcome: a label dropped into a template cannot take the case or
|
||||
// article the sentence needs in every language. The generic template stays
|
||||
@@ -51,6 +60,36 @@ export function shareTrackingRestoreNotice(result) {
|
||||
: t('Shared {subject} is unavailable', { subject });
|
||||
}
|
||||
|
||||
/**
|
||||
* The notice for a shared entity that is not reporting: the view shows where
|
||||
* it was when the link was made.
|
||||
* @param {{kind: string, id: string}} entity
|
||||
* @param {number|null} observedAtMs
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sharedFixNotice(entity, observedAtMs, nowMs = Date.now()) {
|
||||
// ICAO addresses read as they are written on the aircraft: upper case.
|
||||
const id =
|
||||
ENTITY_BY_KIND.get(entity.kind)?.idForm === 'hex24'
|
||||
? String(entity.id).toUpperCase()
|
||||
: String(entity.id);
|
||||
if (!Number.isFinite(observedAtMs))
|
||||
return t("{id} isn't reporting now — showing where it was last seen", {
|
||||
id,
|
||||
});
|
||||
const time = formatDate(
|
||||
observedAtMs,
|
||||
nowMs - observedAtMs < DAY_MS
|
||||
? { hour: '2-digit', minute: '2-digit' }
|
||||
: { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' },
|
||||
);
|
||||
return t("{id} isn't reporting now — showing where it was at {time}", {
|
||||
id,
|
||||
time,
|
||||
});
|
||||
}
|
||||
|
||||
/** Own initial share restoration, durable layer state and restoration notices. */
|
||||
export class ShareRestoration {
|
||||
constructor({
|
||||
@@ -61,6 +100,7 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
applyFollowOffset = null,
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
viewer,
|
||||
@@ -70,6 +110,9 @@ export class ShareRestoration {
|
||||
showStatus,
|
||||
feedback,
|
||||
updateFeedback,
|
||||
// (entity, offset) => disposer: frame a followed entity from a shared
|
||||
// follow offset (shareFollow.js).
|
||||
applyFollowOffset,
|
||||
});
|
||||
this._lifetime = new UiLifetime();
|
||||
this._disposed = false;
|
||||
@@ -84,6 +127,10 @@ export class ShareRestoration {
|
||||
// settles later must neither apply follow-up work nor release URL writes.
|
||||
this._shareRestoreGeneration = 0;
|
||||
this._navigatedShareRestoreActive = false;
|
||||
this._stopFollowOffset = null;
|
||||
// Bumped to abandon the wait for a shared vessel.
|
||||
this._sharedVesselGeneration = 0;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
attachLinks(shareLinkManager) {
|
||||
this.shareLinkManager = shareLinkManager;
|
||||
@@ -121,11 +168,21 @@ export class ShareRestoration {
|
||||
}, 1500);
|
||||
} else {
|
||||
this.syncShareState();
|
||||
if (this.shareLinkManager?.rejectedShareCode)
|
||||
this._presentAfterStartupCover(
|
||||
t("This share link can't be opened"),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
}
|
||||
// A recipient can orbit before or during the delayed share flight. That
|
||||
// gesture keeps ordinary layer state but revokes the passive base camera
|
||||
// and selected-subject Follow so delayed work cannot seize navigation.
|
||||
this._initialShareGestureHandler = () => {
|
||||
// The recipient taking the camera also ends the wait for a vessel.
|
||||
if (this._sharedVesselPending) {
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (
|
||||
this._disposed ||
|
||||
!this._hasShareState ||
|
||||
@@ -191,6 +248,10 @@ export class ShareRestoration {
|
||||
};
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
// Selecting a vessel leaves the camera alone, so it does not depend on
|
||||
// the shared flight having landed.
|
||||
const vessel = this._sharedVessel(state);
|
||||
if (vessel) void this._restoreSharedVessel(vessel, current);
|
||||
settle({ status: 'settled', share, layers, tracking });
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
@@ -301,7 +362,79 @@ export class ShareRestoration {
|
||||
});
|
||||
}
|
||||
}
|
||||
/** The shared vessel a share's thing view selects, if it names one. */
|
||||
_sharedVessel(state) {
|
||||
const view = state?.shareView;
|
||||
return view?.kind === 'thing' && view.entity.kind === 'vessel'
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a shared vessel once the vessel feed has it. Vessels are selected,
|
||||
* not followed: the camera is already at the shared view. If the vessel does
|
||||
* not appear in time, the view stays where it was when the link was made.
|
||||
*/
|
||||
async _restoreSharedVessel(view, current) {
|
||||
const generation = ++this._sharedVesselGeneration;
|
||||
const live = () => current() && generation === this._sharedVesselGeneration;
|
||||
const deadline = Date.now() + SHARED_VESSEL_WAIT_MS;
|
||||
const { id } = view.entity;
|
||||
const { layerId } = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
this._sharedVesselPending = true;
|
||||
try {
|
||||
while (live()) {
|
||||
const vessels = this._dataManager?.layers?.get?.(layerId)?.module;
|
||||
if (vessels?.hasContact?.(id) && vessels.selectById(id)) return true;
|
||||
if (Date.now() >= deadline) break;
|
||||
await new Promise((resolve) =>
|
||||
this._lifetime.timeout(resolve, SHARED_VESSEL_POLL_MS),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (generation === this._sharedVesselGeneration)
|
||||
this._sharedVesselPending = false;
|
||||
}
|
||||
if (!live()) return false;
|
||||
this._presentAfterStartupCover(
|
||||
view.lastKnown
|
||||
? sharedFixNotice(view.entity, view.lastKnown.observedAtMs)
|
||||
: t('Shared vessel is unavailable'),
|
||||
++this._shareTrackingNoticeGeneration,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The current share's thing view when `result` is about its entity. */
|
||||
_sharedThingFor(result) {
|
||||
const view = this._initialShareState?.shareView;
|
||||
if (view?.kind !== 'thing') return null;
|
||||
const entry = ENTITY_BY_KIND.get(view.entity.kind);
|
||||
return entry?.tracking === result?.layerId &&
|
||||
String(view.entity.id) === String(result?.targetId)
|
||||
? view
|
||||
: null;
|
||||
}
|
||||
|
||||
/** Frame a shared entity the camera now follows from the shared offset. */
|
||||
_applySharedFollowOffset(result) {
|
||||
const view = this._sharedThingFor(result);
|
||||
const entity = this.viewer?.trackedEntity;
|
||||
// Only a link made while following carries a real offset (with the fix);
|
||||
// a long link's follow keeps the app's own framing.
|
||||
if (
|
||||
!view?.lastKnown ||
|
||||
String(entity?.gevTrackedId || '') !==
|
||||
`${result.layerId}:${result.targetId}`
|
||||
)
|
||||
return;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = this.applyFollowOffset?.(entity, view.offset);
|
||||
}
|
||||
|
||||
cancelSelection() {
|
||||
// An explicit navigation also ends the wait for a shared vessel.
|
||||
this._sharedVesselGeneration += 1;
|
||||
if (
|
||||
this._hasShareState &&
|
||||
this._resolveInitialShareRestore &&
|
||||
@@ -347,17 +480,39 @@ export class ShareRestoration {
|
||||
this.updateFeedback();
|
||||
}
|
||||
}
|
||||
if (
|
||||
result.classification === 'followed' ||
|
||||
result.classification === 'cancelled'
|
||||
)
|
||||
if (result.classification === 'followed') {
|
||||
this._applySharedFollowOffset(result);
|
||||
return;
|
||||
}
|
||||
if (result.classification === 'cancelled') return;
|
||||
// A stale terminal result must never replace a newer target's acquisition.
|
||||
if (this._shareTrackingAcquiringKey) return;
|
||||
const noticeGeneration = ownsAcquiringNotice
|
||||
? this._shareTrackingNoticeGeneration
|
||||
: ++this._shareTrackingNoticeGeneration;
|
||||
const message = shareTrackingRestoreNotice(result);
|
||||
// A share link that knows where the entity was says so; the view is
|
||||
// already there.
|
||||
const fix = this._sharedThingFor(result)?.lastKnown;
|
||||
const message = fix
|
||||
? sharedFixNotice(
|
||||
this._initialShareState.shareView.entity,
|
||||
fix.observedAtMs,
|
||||
)
|
||||
: shareTrackingRestoreNotice(result);
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(() =>
|
||||
this._presentAfterStartupCover(message, noticeGeneration),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._presentAfterStartupCover(message, noticeGeneration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a status notice once the start-up cover is gone, unless a newer
|
||||
* notice (generation) has taken its place.
|
||||
*/
|
||||
_presentAfterStartupCover(message, noticeGeneration) {
|
||||
const showAfterStartupCover = () => {
|
||||
this._lifetime.frame(() => {
|
||||
if (
|
||||
@@ -399,10 +554,6 @@ export class ShareRestoration {
|
||||
fallbackTimer = this._lifetime.timeout(showOnce, 1000);
|
||||
});
|
||||
};
|
||||
if (this._resolveInitialShareRestore) {
|
||||
void this.initialRestorePromise.then(showAfterStartupCover);
|
||||
return;
|
||||
}
|
||||
showAfterStartupCover();
|
||||
}
|
||||
_settleInitialShareRestore(result) {
|
||||
@@ -419,6 +570,9 @@ export class ShareRestoration {
|
||||
this._disposed = true;
|
||||
this._shareTrackingNoticeGeneration += 1;
|
||||
this._shareTrackingAcquiringKey = null;
|
||||
this._sharedVesselGeneration += 1;
|
||||
this._stopFollowOffset?.();
|
||||
this._stopFollowOffset = null;
|
||||
this._layerStateCoordinator?.destroy();
|
||||
this._layerStateCoordinator = null;
|
||||
this._layerStateRestorePromise = null;
|
||||
|
||||
Reference in new issue
Block a user