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:
Sameh Khamis authored and GitHub committed 2026-10-26 15:31:51 -07:00
1 parent fefd5cdcf1
commit da02fd8645
28 files changed
+5605 -97

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
- Share links are now short: the share button makes `/share/<code>` links
that open on any copy of the app, follow the aircraft, military aircraft or
satellite you were following, and fall back to its last-known position with
a notice if it isn't reporting. A mistyped link is refused instead of
opening a different view. Long `#v=2` links keep working (samehkhamis, #1418).
+364
View File
@@ -0,0 +1,364 @@
# Share-link format
A share link restores a view: where the camera is, what it follows, the visual
style, data layers and panel layout. There are two forms.
- **Long form** — `#v=2&lat=30.2672&lon=-97.7431&…`. Parsed by
`src/sharelink.js`. The address bar follows the live view in this form, and
links already in the wild keep working; the long form is never removed.
- **Short form** — `<app>/share/<code>`, where `<app>` is wherever the app is
served (an origin's root, or a base path) and `<code>` is encoded and
decoded by `src/share/viewCodec.js`, which is pure (no DOM, no Cesium) and
needs no server. The share button makes this form. The same code opens on
any copy of the app. `src/share/shareLinkPath.js` reads and builds the
address, and `src/share/legacyShareState.js` converts between the long
form's parsed state and the code's state.
The same view in both forms (a real session's link):
| Form | Characters |
| ----- | ----------------- |
| Long | 302 (the hash) |
| Short | 39 (the `<code>`) |
For example, a camera-only place view is a 24-character code (31 with its
created-at time).
Following an aircraft is 42–50: 42 when the link is made from the app's own
follow camera with the fix taken as the link is made, 44 with a fix four
seconds older, and 50 after orbiting to another heading and pitch. A view in
any other style that keeps the style's own look adds only the style (two
bytes): the 42-character followed aircraft in Noir is 44.
## Opening a short link
At start-up the app reads `<code>` from an address whose path ends in
`share/<code>` (a host can pass the code instead, as `initialShareCode` to
`createApplicationControls`). A code that decodes runs the same restore as a
long link; once the app next writes the address, `/share/<code>` is replaced
by the app's own address and the long form, without a new history entry, so
a reload follows the live view. A code that is rejected restores nothing: the
app starts as usual and says the link can't be opened.
A thing view turns on its entity's layer and follows the entity by id, using
the same restore as a long link's followed flight or satellite (and its
expiry windows); a vessel is selected once the vessel feed has it. Opening is
best effort: until the entity is found, and if it is not found in time, the
camera is where the link was made, looking at the entity's last-known fix,
and the app says that the entity isn't reporting and when the fix was taken.
The share button makes a thing view while the camera follows an aircraft,
military aircraft or satellite. A selected vessel is not followed (the camera
stays where the user puts it), so a link made then is a place view; vessel
thing views open, but the app does not make them yet.
## Payload
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
1. one **version byte**, currently `1`;
2. a sequence of **fields**, each a varint tag `field_number << 3 | wire_type`
followed by its value;
3. one **check byte**: the CRC-8 of everything before it.
| Wire type | Value |
| --------- | ----------------------------------------------------------- |
| 0 varint | unsigned LEB128, at most 8 bytes, at most 2^53 − 1 |
| 1 fixed64 | 8 bytes (not used by v1; skipped) |
| 2 LEN | varint length, then that many bytes (text or a sub-message) |
| 5 fixed32 | 4 bytes (not used by v1; skipped) |
Signed values are zigzag varints (0, −1, 1, −2 → 0, 1, 2, 3). Text is printable
ASCII. Fields are written in ascending order; the decoder accepts any order.
A field whose value equals its default is **omitted**. The decoder fills every
omitted field from the same defaults table the encoder reads (for a
style-dependent field, the decoded style's defaults), so an empty
payload (code `AQc`: the version byte and its check byte) is the all-default
view.
### Check byte
The check byte is CRC-8 with polynomial x⁸ + x² + x + 1 (`0x07`), initial
value 0, no reflection and no final XOR (CRC-8/SMBUS; the check value of the
ASCII digits `123456789` is `0xF4`). A decoder that computes a different value
rejects the code (`checksum`) before reading any field, so a mistyped code
never opens a different view. A degree-8 CRC catches every error burst of up
to 8 bits; one base64url character is 6 bits, so **every single-character
typo is rejected** (a test tries every substitution at every position). Two
swapped neighbours, a dropped or an extra character are caught with a
probability of about 255 in 256 (many of them already fail as base64url).
## Field registry (top level)
| # | State key | Type | Default | Encoding |
| --- | ---------------------------- | ----- | ----------- | ------------------------------------------------------- |
| 1 | `view.kind` | enum | see below | 0 place, 1 thing |
| 2 | `lat` | coord | 0 | zigzag of degrees × 10⁴ |
| 3 | `lon` | coord | 0 | zigzag of degrees × 10⁴ |
| 4 | `alt` | sint | 800 | whole metres |
| 5 | `heading` | angle | 0 | whole degrees, see angles |
| 6 | `pitch` | sint | −35 | whole degrees |
| 7 | `roll` | angle | 0 | whole degrees |
| 8 | `sharedAtMs` | epoch | none | zigzag seconds since 2026-01-01T00:00:00Z |
| 9 | booleans | flags | 0 | bit set when the value differs from default |
| 10 | `layerState.enabledLayerIds` | LEN | none | layer bitset, see layers |
| 11 | `layerState.options` | LEN | none | packed option triples, see layers |
| 12 | `panelState` | LEN | none | packed panel entries, see panels |
| 13 | thing | LEN | none | sub-message, see thing views |
| 14 | `style` | enum | `normal` | normal, retro, surveillance, thermal, anime, noir, snow |
| 15 | `mapStack` | enum | `photoreal` | photoreal, bing-aerial, bing-labels, esri-imagery, osm |
| 16 | `hudVariant` | enum | style | tactical, operator, minimal, cyber |
| 17 | `bloomIntensity` | uint | style | |
| 18 | `bloomVersion` | uint | 2 | |
| 19 | `sharpenIntensity` | uint | style | |
| 20 | `detectionMode` | enum | style | OFF, SPARSE, BALANCED, DENSE |
| 21 | `detectionDensity` | uint | style | one of 0, 25, 50, 75, 100 |
| 22 | `detectionAllocation` | enum | style | ELASTIC, WEIGHTED |
| 23 | `detectionFadePct` | uint | style | 0–40 |
| 24 | `detectionOutsideOpacityPct` | uint | style | 0–100 |
| 25 | `scopeFeatherPct` | uint | 100 | 0–200 |
| 26 | `scopeTerminusPct` | uint | none | 94–100; absent is the adaptive terminus |
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
Enum codes are the list position (0, 1, 2…). A name not in the list is written
as LEN text in the same field, so the app can add a style or map before this
table learns its code. A new style's settings are encoded against its own
defaults, while a decoder that does not know the style uses Normal's; adding a
style also extends the pinned style-defaults table.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
### Style defaults
A default marked **style** is what the view's style applies when it is
selected in a fresh session (`SHARE_VIEW_STYLE_DEFAULTS`): the app's
first-load baseline (`GLOBAL_POST_DEFAULTS` in `src/ui/visualPresets.js`),
with the style's preset on top (`STYLE_PRESET_DEFAULTS`), and the style's
shader parameters at the preset's values, else the shader's own. The encoder
compares against the defaults of the view's style, and the decoder fills
omitted settings from the defaults of the style it decoded (an unknown style
uses Normal's). So a view that keeps its style's look costs its camera and
style alone. (A recipient-side preference the app remembers, such as the
detection allocation, is not a style default: a sender who changed it pays
for it.) In v1:
| Setting | Every style |
| ------------------------- | ------------- |
| bloom, intensity | off, 0 |
| sharpen, intensity | on, 49 |
| HUD visible, variant | on, tactical |
| detection mode, density | DENSE, 75 |
| allocation, fade, outside | ELASTIC, 7, 1 |
| celestial ring | off |
| Style | Shader parameters (field 27 defaults) |
| ------------ | --------------------------------------------------------------- |
| normal | none |
| retro | pixelation 1, distortion 0, instability 0.42 |
| surveillance | gain 0.18, bloom 0.22, scanlineStr 0.96, pixelation 1 |
| thermal | sensitivity 0.85, bloom 0.2, mode 0.33, pixelation 1, palette 0 |
| anime | saturation 1, edgeThick 0.5 |
| noir | contrastAmt 1.2, grainAmt 0.5, vignetteAmt 0.5 |
| snow | density 0.6, wind 0.5 |
These are frozen like every other default: the codec reads them from the
app's tables today, and a test pins them, so if a table moves the old values
are frozen in the codec rather than changing what shared links mean.
### Precision
| Value | Stored as | Matches the long form |
| --------------------------- | ------------------------- | --------------------- |
| Latitude, longitude | 10⁻⁴ degree (about 11 m) | `toFixed(4)` |
| Altitude, range | whole metres | `Math.round` |
| Heading, pitch, roll | whole degrees | `Math.round` |
| Intensities and percentages | integers | `Math.round` |
| Style parameters | hundredths after clamping | `sp` (× 100) |
| Created-at and observed-at | whole seconds | `at` |
Angles: heading and roll are normalized into [0, 360) — the long form can
write `roll=360` for a camera at 359.6°, which the compact form stores as 0 —
and written as the nearer signed turn, so 350° costs one byte (−10).
When a state is encoded, out-of-range input is clamped (latitude, pitch) or
wrapped (longitude). A payload that stores a latitude or longitude beyond
±90/±180 cannot come from the encoder and is rejected.
### Layers (fields 10 and 11)
A layer's number is derived from its permanent layer-state token
(`src/data/layerStateTokenReservations.json`): a one-character token is its
index in `0–9a–z`, a two-character token is 36 + first × 36 + second. The
token ledger never reassigns a token, so layer numbers never change and new
layers need no change here.
Field 10 is a bitset: bit _n_ of byte ⌊n/8⌋ (least significant first) is set
when layer _n_ is on. Present-but-empty means "every layer off"; an absent
field leaves the recipient's layers alone (the long form's `l=` versus no `l`).
A thing view's entity layer is always on, so its bit is not written: in a
thing view, present-but-empty means "only the entity's layer", and an absent
field opens with only the entity's layer on (the app writes the field
whenever it has a layer set).
Field 11 repeats `(layer number, option key, value)` for each option that
differs from its frozen absent meaning (`SHARE_VIEW_LAYER_OPTION_ABSENT`). The
option key is `option token index × 2 + kind`: kind 0 means the value is a
zigzag varint (an option whose long-form spelling is a canonical integer),
kind 1 means a varint length and ASCII text (the long-form spelling). Values
are decoded by the layer-state codec's own option decoders.
A followed aircraft, military aircraft or satellite is a thing view in the
compact form, not a tracking option: the encoder moves it out of the layer
options, and the converter moves it back for the long form.
### Panels (field 12)
Each entry is one varint: `panel number × 16 + collapsed × 4 + pinned`, where
collapsed and pinned are 0 (no value), 1 (false) or 2 (true). Only panels that
differ from the frozen default layout are written, plus a collapsed-0 entry
for each panel the state has no opinion about (an older long-form link that
predates the panel); a panel with no entry takes its default. Values of 3 are
reserved: a decoder ignores that entry, as for collapsed 0.
| # | Panel | Long-form token | Default |
| --- | ---------------------- | --------------- | ------------------- |
| 0 | `control-panel` | `c` | collapsed, unpinned |
| 1 | `location-bar` | `l` | collapsed, unpinned |
| 2 | `data-panel` | `d` | collapsed |
| 3 | `cctv-panel` | `v` | collapsed |
| 4 | `recent-imagery-panel` | `i` | expanded |
| 5 | `street-level-panel` | `t` | collapsed |
| 6 | `radio-panel` | `r` | collapsed |
| 7 | `scene-panel` | `s` | collapsed |
| 8 | `global-context-panel` | `g` | collapsed |
| 9 | `pp-toggles` | `p` | collapsed |
| 10 | `param-slider-panel` | `m` | expanded |
### Style parameters (field 27)
Pairs of varints `(token index, value × 100)` for the parameters of the
view's style that differ from its defaults, using the long form's `sp` tokens
(`SHARE_VIEW_STYLE_PARAMS`). The decoded state always lists every parameter
of the style: the stored ones over the style's defaults. A long link without
`sp` converts to the style's defaults.
## View kinds
A **place** view (field 13 absent) opens at its camera, fields 2–7.
A **thing** view (field 13 present) follows an entity. Field 13 is a
sub-message:
| # | Field | Encoding | Default |
| --- | ----------- | -------------------------------------------------------- | ------- |
| 1 | aircraft | entity id: varint (24-bit ICAO address) or LEN text | |
| 2 | military | entity id: varint (24-bit ICAO address) or LEN text | |
| 3 | vessel | entity id: varint (MMSI) or LEN text | |
| 4 | satellite | entity id: varint (NORAD catalog number) or LEN text | |
| 5 | range | uint metres from the entity to the camera | 2000 |
| 6 | heading | angle, the camera's heading while following | 0 |
| 7 | pitch | sint whole degrees, the camera's pitch while following | −35 |
| 8 | fix lat | coord, the entity's last-known latitude | none |
| 9 | fix lon | coord, its last-known longitude | none |
| 10 | fix alt | sint metres | 0 |
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
The entity kind is the field that carries the id (its code in
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
present (two is malformed). Entity ids are the ones the layers look up: an
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
the entity from `range` metres away, heading clockwise from north, in the
entity's local east-north-up frame. The defaults match the app's own follow
camera, which looks north at about −35°, so only its range is written.
Fields 8–12 are the last-known fix, the entity's position when the link was
made. Fields 8 and 9 are both written whenever there is a fix; their presence
marks it. Its time is the created-at time (field 8 at the top level) minus
field 11, or field 12 when the view has no created-at time.
**The camera of a thing view with a fix is derived, not stored**: fields 2–7
are not written, and the camera is where following the fix from the offset
puts it (`followCameraFor` in `src/share/follow.js`): the camera sits
`−r·cos p·sin h`, `−r·cos p·cos h` and `−r·sin p` metres east, north and up
of the fix (heading h, pitch p, range r), on a sphere of radius 6 371 000 m,
and looks back at the fix (its heading and pitch are those of that direction
at the camera, roll 0; at follow distances they equal h and p). A
decoder ignores fields 2–7 when the fix is present. A thing view without a
fix (one converted from a long link, which records neither offset nor fix)
writes its camera in fields 2–7 like a place view.
A decoder that meets a view kind or an entity kind it does not know (an id in
a thing field it skips) opens the view as a place at its camera — the derived
one when there is a fix — and reports `fallback`.
## Decoding rules
`decodeShareView(code)` takes the bare code and never throws. It returns
`{ ok: true, state, unknownFields?, fallback? }` or `{ ok: false, reason }`.
Rejected (the whole link): input over 2048 characters, bad base64url
(padding, foreign characters, impossible length, stray bits), fewer than two
bytes, a check byte that does not match (`checksum`), an unknown version byte,
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
overlong varint, a LEN longer than what remains (at any level, including
inside the thing sub-message), a reserved wire type, field number 0, a known
field with the wrong wire type or repeated, two entity ids in one thing, a
duplicate panel/option/style entry, non-ASCII text, and a latitude or
longitude (camera or fix) beyond ±90/±180.
Tolerated and reported in `unknownFields`: unknown fields at any level
(skipped by wire type), unknown enum codes and names (the default is used),
unknown flag bits, layers, panel values and style parameters, and layer
options the layer-state codec rejects or that are filed under a layer other
than their owner. Detection mode and allocation names outside their lists
fall back to the default rather than reaching the long form's lenient
migration. The map stack and HUD variant are open: a name outside their lists
that matches `[A-Za-z0-9_-]{1,32}` is kept as written (the app decides what
it means) and is not reported; only a name outside that grammar is. A
created-at time in the future is dropped, as the long form's `at` is.
## Changing the format
These rules keep every link that was ever shared working, in every version of
the app that can open it.
1. **Field numbers are permanent.** Never renumber, never reuse. A field that
is no longer written stays in `SHARE_VIEW_FIELDS` marked `retired: true`.
2. **A field's type and wire type are permanent.** To change one, add a new
field.
3. **Defaults are frozen meanings.** A default is what an omitted field meant
when links were written. If the app's default moves, leave the table alone:
the encoder will start writing the new value explicitly. Style defaults
are read from the app's style tables; when those move, freeze the old
values in the codec (see Style defaults). The same applies
to `SHARE_VIEW_LAYER_OPTION_ABSENT` and the panel defaults.
4. **Codes are append-only.** Enum lists, entity kinds, flag bits, panel
numbers and style parameter tokens only grow.
5. **Add, don't change.** A new setting takes the next free field number
(28 onwards; fields 1–15 have one-byte tags and are all taken). A new
boolean takes the next flag bit instead, which costs nothing when it is at
its default.
6. **New view kinds and entity kinds** get the next code. A new entity kind's
id takes the next free thing field (13 onwards), so older apps skip it and
fall back to the camera. A thing that needs more than an id (for example a
CCTV camera, a radio station or a transit vehicle) adds further thing
fields the same way.
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
(a test fails until it has one).
8. **Bump the version byte only** for a change older decoders must refuse.
Decoders reject any version they do not know. The trailing CRC-8 check
byte belongs to every version.
9. **Derivations are frozen too.** `followCameraFor` and the follow-offset
defaults are part of the format: changing them moves the camera of every
thing link already shared.
Add a test for every change: a round trip, and a payload written with the new
field that the previous decoder still reads.
+9 -1
View File
@@ -1109,5 +1109,13 @@
"src/voice/panel/inactivity.test.mjs", "src/voice/panel/inactivity.test.mjs",
"src/voice/panel/actionObserver.test.mjs", "src/voice/panel/actionObserver.test.mjs",
"src/voice/panel/testDom.mjs", "src/voice/panel/testDom.mjs",
"src/voice/realtimeCostPanel.test.mjs" "src/voice/realtimeCostPanel.test.mjs",
"docs/SHARE-LINK-FORMAT.md",
"src/share/wire.test.mjs",
"src/share/viewCodec.test.mjs",
"src/share/legacyShareState.test.mjs",
"src/testSupport/shareViewFixtures.mjs",
"src/ui/shareLinks.test.mjs",
"src/ui/shareButton.test.mjs",
"src/share/shareLinkPath.test.mjs"
] ]
+10
View File
@@ -770,6 +770,9 @@
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js", "src/ui/actionButton.js",
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/shareFollow.js",
"src/share/follow.js",
"src/data/trackedCamera.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/shellElements.js", "src/ui/shellElements.js",
"src/ui/shellFeedback.js", "src/ui/shellFeedback.js",
@@ -2195,6 +2198,11 @@
"src/services/meshFloorSampler.js", "src/services/meshFloorSampler.js",
"src/services/requests.js", "src/services/requests.js",
"src/services/terrainHeights.js", "src/services/terrainHeights.js",
"src/share/follow.js",
"src/share/legacyShareState.js",
"src/share/shareLinkPath.js",
"src/share/viewCodec.js",
"src/share/wire.js",
"src/sharelink.js", "src/sharelink.js",
"src/sources/live/aircraft.js", "src/sources/live/aircraft.js",
"src/sources/live/contract.js", "src/sources/live/contract.js",
@@ -2287,6 +2295,7 @@
"src/ui/scenePresentation.js", "src/ui/scenePresentation.js",
"src/ui/actionButton.js", "src/ui/actionButton.js",
"src/ui/scenes.js", "src/ui/scenes.js",
"src/ui/shareFollow.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/shellElements.js", "src/ui/shellElements.js",
"src/ui/shellFeedback.js", "src/ui/shellFeedback.js",
@@ -3283,6 +3292,7 @@
"src/data/layerStateTokenReservations.json", "src/data/layerStateTokenReservations.json",
"src/loadingFeedback.js", "src/loadingFeedback.js",
"src/navigationPolicy.js", "src/navigationPolicy.js",
"src/share/follow.js",
"src/ui/shareRestoration.js", "src/ui/shareRestoration.js",
"src/ui/uiLifetime.js", "src/ui/uiLifetime.js",
"src/i18n/index.js", "src/i18n/index.js",
+8 -1
View File
@@ -4,7 +4,11 @@ import { flyToAustin } from '../camera.js';
import { initCockpitCloudEffects } from '../cockpitCloudEffects.js'; import { initCockpitCloudEffects } from '../cockpitCloudEffects.js';
import { t } from '../i18n/index.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({ export function createApplicationControls({
scene: { viewer, mapStackController, operations }, scene: { viewer, mapStackController, operations },
loaderStatus, loaderStatus,
@@ -13,6 +17,7 @@ export function createApplicationControls({
catalog, catalog,
placeSearch, placeSearch,
defer, defer,
initialShareCode = null,
}) { }) {
// Initialize the style manager (post-processing, HUD, locations, share links) // Initialize the style manager (post-processing, HUD, locations, share links)
const styleManager = new Controls(viewer, { const styleManager = new Controls(viewer, {
@@ -27,6 +32,8 @@ export function createApplicationControls({
requestServices: operations.requests, requestServices: operations.requests,
mapStackController, mapStackController,
placeSearch, placeSearch,
// A share code to open at start-up instead of the page's address.
initialShareCode,
}); });
defer(() => styleManager.orbitController.stop()); defer(() => styleManager.orbitController.stop());
defer(() => styleManager.hud.destroy()); defer(() => styleManager.hud.destroy());
+1 -1
View File
@@ -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 * out-of-grammar ID is rejected outright, because half an address is a
* DIFFERENT aircraft, not a shorter name for the same one. * 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 * Ceilings for the untrusted v2 layer fields. The layer ceiling covers the
* complete reserved one-character space plus every two-character base-36 * complete reserved one-character space plus every two-character base-36
+5
View File
@@ -302,6 +302,11 @@
"CLOUD UNKNOWN": "NUBES DESCONOCIDAS", "CLOUD UNKNOWN": "NUBES DESCONOCIDAS",
"Panel layout updated — positions reset to new defaults": "Disposición de paneles actualizada — posiciones restablecidas a los nuevos valores predeterminados", "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 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", "Copy failed": "No se pudo copiar",
"Location not found": "Ubicación no encontrada", "Location not found": "Ubicación no encontrada",
"Search failed": "No se pudo completar la búsqueda", "Search failed": "No se pudo completar la búsqueda",
+5
View File
@@ -294,6 +294,11 @@
"Scene run complete": "Воспроизведение сцены завершено", "Scene run complete": "Воспроизведение сцены завершено",
"CCTV layer unavailable": "Слой CCTV недоступен", "CCTV layer unavailable": "Слой CCTV недоступен",
"Link copied!": "Ссылка скопирована!", "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": "Не удалось скопировать", "Copy failed": "Не удалось скопировать",
"VISUAL PRESETS": "ПРЕСЕТЫ", "VISUAL PRESETS": "ПРЕСЕТЫ",
"LOCATION": "МЕСТО", "LOCATION": "МЕСТО",
+5
View File
@@ -1477,6 +1477,11 @@
"LIVE SIGNALS": "实时信号", "LIVE SIGNALS": "实时信号",
"CCTV layer unavailable": "CCTV 图层不可用", "CCTV layer unavailable": "CCTV 图层不可用",
"Link copied!": "链接已复制!", "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": "复制失败", "Copy failed": "复制失败",
"Return map to straight-down view": "将地图恢复为垂直俯视", "Return map to straight-down view": "将地图恢复为垂直俯视",
"Tilt map to oblique view": "将地图倾斜至斜视角", "Tilt map to oblique view": "将地图倾斜至斜视角",
+16 -9
View File
@@ -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', () => { test('share-follow failures use the universal top-center status instead of the bottom toast', () => {
const handler = shellMethod('_handleShareTrackingRestoreStatus').toString(); const handler = shellMethod('_handleShareTrackingRestoreStatus').toString();
assert.match(handler, /this\.showStatus\(message\)/); const present = shellMethod('_presentAfterStartupCover').toString();
assert.match(handler, /this\.initialRestorePromise\.then\(showAfterStartupCover\)/); assert.match(
assert.match(handler, /this\._lifetime\.frame\(\(\) => \{/); handler,
assert.match(handler, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/,
assert.match(handler, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); );
assert.doesNotMatch(handler, /this\._showToast\(message\)/); assert.match(present, /this\.showStatus\(message\)/);
assert.doesNotMatch(handler, /pushCockpitSignal/); 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, /result\.classification === 'pending'/);
assert.match(handler, /state: 'acquiring'/); assert.match(handler, /state: 'acquiring'/);
assert.match(handler, /persistent: true/); assert.match(handler, /persistent: true/);
assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/); assert.match(handler, /this\._shareTrackingNoticeGeneration \+= 1/);
assert.match(handler, /canPresentDeferredStatusNotice\(/);
assert.match(handler, /if \(this\._shareTrackingAcquiringKey\) return/); 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', () => { test('universal notice masks active loading only for its own fixed dwell', () => {
+3 -2
View File
@@ -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', () => { test('share-panel state excludes responsive collapse and preserves recipient preferences', () => {
const ui = readShellSource(); 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( assert.match(
ui, 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', 'pin and unpin must update the share hash even when collapse state is unchanged',
); );
assert.match(ui, /\{ id: 'param-slider-panel' \}/); 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', () => { test('parameterized Display presets keep one stable scroll owner', () => {
+155
View File
@@ -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,
};
}
+119
View File
@@ -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;
}
+308
View File
@@ -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();
});
+56
View File
@@ -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;
}
+45
View File
@@ -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
+259
View File
@@ -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;
}
}
}
+188
View File
@@ -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}`);
}
}
});
+9 -4
View File
@@ -8,6 +8,12 @@ import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js'; import { ShareLinkManager, decodeShareCreatedAtMs } from './sharelink.js';
import { createDefaultLayerState } from './data/layerState.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(); 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_000_000 }), true);
assert.equal(await manager.copyLink({ nowMs: 2_002_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(sharedState(copied[0]).sharedAtMs, 2_000_000);
assert.equal(new URL(copied[1]).hash.includes('at=2002'), true); assert.equal(sharedState(copied[1]).sharedAtMs, 2_002_000);
assert.equal(window.location.hash, liveHash); assert.equal(window.location.hash, liveHash);
assert.equal(new URLSearchParams(window.location.hash.slice(1)).has('at'), false); 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(); manager.parseInitialHash();
assert.equal(manager._initialRestorePending, true); assert.equal(manager._initialRestorePending, true);
assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true); assert.equal(await manager.copyLink({ nowMs: 3_000_000 }), true);
const params = new URL(copied).hash.slice(1); assert.equal(sharedState(copied).sharedAtMs, 3_000_000);
assert.equal(new URLSearchParams(params).get('at'), '3000');
assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100'); assert.equal(window.location.hash, '#v=2&lat=10&lon=20&l=e&at=100');
}); });
+115 -64
View File
@@ -11,12 +11,30 @@ import {
encodeLayerStateParams, encodeLayerStateParams,
} from './data/layerState.js'; } from './data/layerState.js';
import { STYLE_URL_NAMES } from './view/index.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 * Share Links — URL Hash State Management
* *
* Encodes camera position + style into the URL hash so links can be shared. * The address bar follows the live view in the long hash form:
* 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 * #lat=37.77&lon=-122.42&alt=800&heading=0&pitch=-35&style=nvg&bloom=1&bi=84&bv=2&sharpen=0&si=65&hud=tactical&hv=1&dm=BALANCED&dd=50&da=elastic&kf=16&ko=0&cr=0&map=photoreal
*
* A shared link is the short form, `<app>/share/<code>`, with the code from
* src/share/viewCodec.js (docs/SHARE-LINK-FORMAT.md). Both forms open here.
*/ */
const DEBOUNCE_MS = 500; const DEBOUNCE_MS = 500;
@@ -29,20 +47,9 @@ const SHARE_UI_STATE_PARAM = 'ui';
const SHARE_STYLE_PARAMS_PARAM = 'sp'; const SHARE_STYLE_PARAMS_PARAM = 'sp';
const SHARE_CREATED_AT_PARAM = 'at'; const SHARE_CREATED_AT_PARAM = 'at';
const SHARE_PANEL_STATE_REGISTRY = Object.freeze([ // The panel and visual-preset parameter tables are the share format's own
{ id: 'control-panel', token: 'c', pinnable: true }, // (src/share/viewCodec.js); the long form spells their entries by token.
{ id: 'location-bar', token: 'l', pinnable: true }, const SHARE_PANEL_STATE_REGISTRY = SHARE_VIEW_PANELS;
{ 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 },
]);
const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze( const SHARE_PANEL_STATE_BY_TOKEN = Object.freeze(
new Map(SHARE_PANEL_STATE_REGISTRY.map((entry) => [entry.token, entry])), 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]), Object.entries(STYLE_TO_URL).map(([k, v]) => [v, k]),
); );
const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({ const SHARE_STYLE_PARAM_REGISTRY = SHARE_VIEW_STYLE_PARAMS;
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 },
]),
});
export class ShareLinkManager { export class ShareLinkManager {
constructor( constructor(
viewer, viewer,
{ onRestore, isNavigationCurrent, cancelOwnedNavigation } = {}, {
onRestore,
isNavigationCurrent,
cancelOwnedNavigation,
initialShareCode = null,
} = {},
) { ) {
this.viewer = viewer; 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._onRestore = onRestore; // callback: ({ style, bloom, sharpen }) => void
this._debounceTimer = null; this._debounceTimer = null;
this._currentStyle = 'normal'; 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() { parseInitialHash() {
const hash = window.location.hash.slice(1); const hash = window.location.hash.slice(1);
this._knownHash = hash; 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; if (!state) return null;
this._beginIncomingRestore(state); this._beginIncomingRestore(state);
return 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. */ /** Parse one hash without side effects. Returns null unless it is a share. */
_parseShareHash(hash) { _parseShareHash(hash) {
if (!hash) return null; if (!hash) return null;
@@ -459,6 +470,16 @@ export class ShareLinkManager {
this._layerStateProvider = typeof provider === 'function' ? provider : null; 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. */ /** Install the finalized panel-state source used by URL generation. */
setPanelStateProvider(provider) { setPanelStateProvider(provider) {
this._panelStateProvider = typeof provider === 'function' ? provider : null; this._panelStateProvider = typeof provider === 'function' ? provider : null;
@@ -559,17 +580,41 @@ export class ShareLinkManager {
this._scheduleUpdate(); 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. */ /** Copy a current-state snapshot with a copy-time timestamp. Returns true on success. */
async copyLink({ nowMs = Date.now() } = {}) { 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 { try {
await navigator.clipboard.writeText(copiedUrl.href); const href = this.buildLink({ nowMs });
if (!href) return false;
await navigator.clipboard.writeText(href);
return true; return true;
} catch { } catch {
return false; return false;
@@ -587,8 +632,13 @@ export class ShareLinkManager {
const params = this._buildHashParams(); const params = this._buildHashParams();
if (!params) return; if (!params) return;
// The hash belongs to this page's address, not the document base the // The hash belongs to this page's address, not the document base the
// app may load under. // app may load under. A `/share/<code>` address gives way to the app's
const url = new URL(`#${params.toString()}`, window.location.href); // 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); this._knownHash = url.hash.slice(1);
history.replaceState(null, '', url.href); history.replaceState(null, '', url.href);
} }
@@ -649,8 +699,8 @@ export class ShareLinkManager {
this._styleParamStateProvider?.(this._currentStyle), this._styleParamStateProvider?.(this._currentStyle),
); );
// Copy-time metadata is intentionally absent here. `copyLink()` adds a // Creation-time metadata is intentionally absent here. `buildLink()`
// fresh timestamp to its ephemeral URL without aging the live address. // stamps its own link without aging the live address.
params.delete(SHARE_CREATED_AT_PARAM); params.delete(SHARE_CREATED_AT_PARAM);
return params; return params;
} }
@@ -677,6 +727,7 @@ export class ShareLinkManager {
this._layerStateProvider = null; this._layerStateProvider = null;
this._panelStateProvider = null; this._panelStateProvider = null;
this._styleParamStateProvider = null; this._styleParamStateProvider = null;
this._followSubjectProvider = null;
this._onRestore = null; this._onRestore = null;
} }
} }
+103
View File
@@ -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)];
}
+43 -5
View File
@@ -35,6 +35,10 @@ import * as Cesium from 'cesium';
import { aircraftTrackingTarget } from '../cockpitTracking.js'; import { aircraftTrackingTarget } from '../cockpitTracking.js';
import { ShellFeedback } from './shellFeedback.js'; import { ShellFeedback } from './shellFeedback.js';
import {
applyShareFollowOffset,
readShareFollowSubject,
} from './shareFollow.js';
import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js'; import { runCctvLayerEnableTransition } from '../cctvFocusPolicy.js';
@@ -74,7 +78,13 @@ export class StyleManager extends ShellFacade {
*/ */
constructor( constructor(
viewer, viewer,
{ mapStackController = null, placeSearch, services, requestServices } = {}, {
mapStackController = null,
placeSearch,
services,
requestServices,
initialShareCode = null,
} = {},
) { ) {
super(); super();
const { const {
@@ -165,6 +175,8 @@ export class StyleManager extends ShellFacade {
this._showGlobalStatusNotice(message, options), this._showGlobalStatusNotice(message, options),
feedback: this._feedback, feedback: this._feedback,
updateFeedback: () => this._updateGlobalLoadingFeedback(), updateFeedback: () => this._updateGlobalLoadingFeedback(),
applyFollowOffset: (entity, offset) =>
applyShareFollowOffset(viewer, entity, offset),
}); });
this._visualSettings = new VisualSettings({ this._visualSettings = new VisualSettings({
@@ -430,7 +442,12 @@ export class StyleManager extends ShellFacade {
isNavigationCurrent: (generation) => isNavigationCurrent: (generation) =>
generation === this._navigationGeneration, generation === this._navigationGeneration,
cancelOwnedNavigation: () => this.viewer.camera.cancelFlight(), 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.shareLinkManager.setPanelStateProvider(() =>
this._buildSharePanelState(), this._buildSharePanelState(),
); );
@@ -1652,14 +1669,35 @@ export class StyleManager extends ShellFacade {
// ── Share Button ───────────────────────────── // ── 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} * @returns {void}
*/ */
_initShareButton() { _initShareButton() {
this._lifetime.listen(this._shareBtn, 'click', async () => { this._lifetime.listen(this._shareBtn, 'click', async () => {
const success = await this.shareLinkManager.copyLink(); const share = this.services.shareLink;
if (!this._disposed) let result;
this._showToast(success ? t('Link copied!') : t('Copy failed')); 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'),
);
}); });
} }
+160
View File
@@ -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]);
}
});
+190
View File
@@ -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);
}
+554
View File
@@ -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
View File
@@ -1,9 +1,18 @@
import { LayerStateCoordinator } from '../data/layerStateCoordinator.js'; import { LayerStateCoordinator } from '../data/layerStateCoordinator.js';
import { stampInitialShareGesture } from '../navigationPolicy.js'; import { stampInitialShareGesture } from '../navigationPolicy.js';
import { canPresentDeferredStatusNotice } from '../loadingFeedback.js'; import { canPresentDeferredStatusNotice } from '../loadingFeedback.js';
import { 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'; 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 // 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 // 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 // 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 }); : 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. */ /** Own initial share restoration, durable layer state and restoration notices. */
export class ShareRestoration { export class ShareRestoration {
constructor({ constructor({
@@ -61,6 +100,7 @@ export class ShareRestoration {
showStatus, showStatus,
feedback, feedback,
updateFeedback, updateFeedback,
applyFollowOffset = null,
}) { }) {
Object.assign(this, { Object.assign(this, {
viewer, viewer,
@@ -70,6 +110,9 @@ export class ShareRestoration {
showStatus, showStatus,
feedback, feedback,
updateFeedback, updateFeedback,
// (entity, offset) => disposer: frame a followed entity from a shared
// follow offset (shareFollow.js).
applyFollowOffset,
}); });
this._lifetime = new UiLifetime(); this._lifetime = new UiLifetime();
this._disposed = false; this._disposed = false;
@@ -84,6 +127,10 @@ export class ShareRestoration {
// settles later must neither apply follow-up work nor release URL writes. // settles later must neither apply follow-up work nor release URL writes.
this._shareRestoreGeneration = 0; this._shareRestoreGeneration = 0;
this._navigatedShareRestoreActive = false; this._navigatedShareRestoreActive = false;
this._stopFollowOffset = null;
// Bumped to abandon the wait for a shared vessel.
this._sharedVesselGeneration = 0;
this._sharedVesselPending = false;
} }
attachLinks(shareLinkManager) { attachLinks(shareLinkManager) {
this.shareLinkManager = shareLinkManager; this.shareLinkManager = shareLinkManager;
@@ -121,11 +168,21 @@ export class ShareRestoration {
}, 1500); }, 1500);
} else { } else {
this.syncShareState(); 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 // A recipient can orbit before or during the delayed share flight. That
// gesture keeps ordinary layer state but revokes the passive base camera // gesture keeps ordinary layer state but revokes the passive base camera
// and selected-subject Follow so delayed work cannot seize navigation. // and selected-subject Follow so delayed work cannot seize navigation.
this._initialShareGestureHandler = () => { this._initialShareGestureHandler = () => {
// The recipient taking the camera also ends the wait for a vessel.
if (this._sharedVesselPending) {
this._sharedVesselGeneration += 1;
this._sharedVesselPending = false;
}
if ( if (
this._disposed || this._disposed ||
!this._hasShareState || !this._hasShareState ||
@@ -191,6 +248,10 @@ export class ShareRestoration {
}; };
if (!current()) return; if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken); 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 }); settle({ status: 'settled', share, layers, tracking });
} catch (error) { } catch (error) {
if (!current()) return; 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() { cancelSelection() {
// An explicit navigation also ends the wait for a shared vessel.
this._sharedVesselGeneration += 1;
if ( if (
this._hasShareState && this._hasShareState &&
this._resolveInitialShareRestore && this._resolveInitialShareRestore &&
@@ -347,17 +480,39 @@ export class ShareRestoration {
this.updateFeedback(); this.updateFeedback();
} }
} }
if ( if (result.classification === 'followed') {
result.classification === 'followed' || this._applySharedFollowOffset(result);
result.classification === 'cancelled'
)
return; return;
}
if (result.classification === 'cancelled') return;
// A stale terminal result must never replace a newer target's acquisition. // A stale terminal result must never replace a newer target's acquisition.
if (this._shareTrackingAcquiringKey) return; if (this._shareTrackingAcquiringKey) return;
const noticeGeneration = ownsAcquiringNotice const noticeGeneration = ownsAcquiringNotice
? this._shareTrackingNoticeGeneration ? this._shareTrackingNoticeGeneration
: ++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 = () => { const showAfterStartupCover = () => {
this._lifetime.frame(() => { this._lifetime.frame(() => {
if ( if (
@@ -399,10 +554,6 @@ export class ShareRestoration {
fallbackTimer = this._lifetime.timeout(showOnce, 1000); fallbackTimer = this._lifetime.timeout(showOnce, 1000);
}); });
}; };
if (this._resolveInitialShareRestore) {
void this.initialRestorePromise.then(showAfterStartupCover);
return;
}
showAfterStartupCover(); showAfterStartupCover();
} }
_settleInitialShareRestore(result) { _settleInitialShareRestore(result) {
@@ -419,6 +570,9 @@ export class ShareRestoration {
this._disposed = true; this._disposed = true;
this._shareTrackingNoticeGeneration += 1; this._shareTrackingNoticeGeneration += 1;
this._shareTrackingAcquiringKey = null; this._shareTrackingAcquiringKey = null;
this._sharedVesselGeneration += 1;
this._stopFollowOffset?.();
this._stopFollowOffset = null;
this._layerStateCoordinator?.destroy(); this._layerStateCoordinator?.destroy();
this._layerStateCoordinator = null; this._layerStateCoordinator = null;
this._layerStateRestorePromise = null; this._layerStateRestorePromise = null;